From 9739c94bdcf5c81245ab2e2eee725b52f9326f3a Mon Sep 17 00:00:00 2001 From: zetaloop Date: Sat, 28 Feb 2026 18:13:42 +0800 Subject: [PATCH] feat(orders): migrate orders to backend API --- app/(dashboard)/dashboard/page.tsx | 22 +++- app/(main)/community/page.tsx | 8 +- app/(main)/post/[id]/page.tsx | 14 +-- app/(order)/order/[id]/page.tsx | 37 +++++- app/(order)/order/new/page.tsx | 41 +++---- app/(order)/orders/page.tsx | 25 +++- components/order-actions.tsx | 45 ++++--- lib/api/orders.ts | 187 +++++++++++++++++++---------- tests/orders-api.test.ts | 14 ++- 9 files changed, 263 insertions(+), 130 deletions(-) diff --git a/app/(dashboard)/dashboard/page.tsx b/app/(dashboard)/dashboard/page.tsx index 87691dc..17c5667 100644 --- a/app/(dashboard)/dashboard/page.tsx +++ b/app/(dashboard)/dashboard/page.tsx @@ -19,7 +19,8 @@ export default function DashboardPage() { const [player, setPlayer] = useState(null) const [shop, setShop] = useState(null) const [services, setServices] = useState([]) - const recentOrders = listOrders().slice(0, 3) + const [orders, setOrders] = useState>>([]) + const recentOrders = orders.slice(0, 3) useEffect(() => { let cancelled = false @@ -43,6 +44,25 @@ export default function DashboardPage() { } }, []) + useEffect(() => { + let cancelled = false + + ;(async () => { + try { + const orders = await Promise.resolve(listOrders()) + if (cancelled) return + setOrders(orders) + } catch { + if (cancelled) return + setOrders([]) + } + })() + + return () => { + cancelled = true + } + }, []) + const totalOrders = isOwner ? (shop?.totalOrders ?? 0) : (player?.totalOrders ?? 0) const rating = isOwner ? (shop?.rating ?? 0) : (player?.rating ?? 0) const playerCount = shop?.playerCount ?? 0 diff --git a/app/(main)/community/page.tsx b/app/(main)/community/page.tsx index 6fb8646..bffb631 100644 --- a/app/(main)/community/page.tsx +++ b/app/(main)/community/page.tsx @@ -14,9 +14,9 @@ import { useEffect, useState } from "react" export default function CommunityPage() { const [games, setGames] = useState([]) const [players, setPlayers] = useState([]) + const [orders, setOrders] = useState>>([]) const [posts, setPosts] = useState([]) const [postsLoading, setPostsLoading] = useState(true) - const orders = listOrders() const [sortMode, setSortMode] = useState<"latest" | "hot">("latest") const [selectedGame, setSelectedGame] = useState(null) @@ -26,11 +26,12 @@ export default function CommunityPage() { setPostsLoading(true) - Promise.all([listGames(), listPlayers(), listPosts()]) - .then(([gamesItems, playersItems, postsItems]) => { + Promise.all([listGames(), listPlayers(), Promise.resolve(listOrders()), listPosts()]) + .then(([gamesItems, playersItems, ordersItems, postsItems]) => { if (cancelled) return setGames(gamesItems) setPlayers(playersItems) + setOrders(ordersItems) setPosts(postsItems) setPostsLoading(false) }) @@ -38,6 +39,7 @@ export default function CommunityPage() { if (cancelled) return setGames([]) setPlayers([]) + setOrders([]) setPosts([]) setPostsLoading(false) }) diff --git a/app/(main)/post/[id]/page.tsx b/app/(main)/post/[id]/page.tsx index 511cb83..7e85708 100644 --- a/app/(main)/post/[id]/page.tsx +++ b/app/(main)/post/[id]/page.tsx @@ -5,7 +5,7 @@ import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar" import { Badge } from "@/components/ui/badge" import { Card, CardContent, CardHeader } from "@/components/ui/card" import { Separator } from "@/components/ui/separator" -import { getOrderById, getPlayerById, getPostById } from "@/lib/api" +import { getPostById } from "@/lib/api" import { roleLabels } from "@/lib/constants" import { ArrowLeft, Pin, Star } from "lucide-react" import Image from "next/image" @@ -17,9 +17,6 @@ export default async function PostDetailPage({ params }: { params: Promise<{ id: const post = await getPostById(id) if (!post) notFound() - const linkedOrder = post.linkedOrderId ? getOrderById(post.linkedOrderId) : null - const linkedPlayer = linkedOrder ? await getPlayerById(linkedOrder.playerId) : null - return (
)} - {linkedOrder && ( - + {post.linkedOrderId && ( +
关联订单
-

- {linkedOrder.service.gameName} · {linkedOrder.service.title} · 评分{" "} - {linkedPlayer?.rating ?? "--"} -

+

点击查看订单详情

)} diff --git a/app/(order)/order/[id]/page.tsx b/app/(order)/order/[id]/page.tsx index 154a1a8..b89a3da 100644 --- a/app/(order)/order/[id]/page.tsx +++ b/app/(order)/order/[id]/page.tsx @@ -4,11 +4,11 @@ import OrderActions from "@/components/order-actions" import { Badge } from "@/components/ui/badge" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" import { Separator } from "@/components/ui/separator" +import { getOrderById } from "@/lib/api" import { ORDER_ACCEPT_TIMEOUT_MS, ORDER_CLOSE_TIMEOUT_MS } from "@/lib/config/demo-timers" import { statusLabels } from "@/lib/constants" import type { OrderStatus } from "@/lib/types" import { useChatStore } from "@/store/chat" -import { useOrderStore } from "@/store/orders" import { useReviewStore } from "@/store/reviews" import { ArrowLeft, CheckCircle, Clock, Star } from "lucide-react" import Link from "next/link" @@ -35,7 +35,6 @@ const cancelledStatusSteps: OrderStatus[] = ["pending_payment", "pending_accept" export default function OrderDetailPage({ params }: { params: Promise<{ id: string }> }) { const { id } = use(params) - const order = useOrderStore((state) => state.orders.find((item) => item.id === id)) const sessions = useChatStore((state) => state.sessions) const allReviews = useReviewStore((state) => state.reviews) // Filtering is deferred to useMemo after reading the raw store array. @@ -43,8 +42,34 @@ export default function OrderDetailPage({ params }: { params: Promise<{ id: stri // Returning a fresh filtered array from the selector can re-trigger updates // and loop under useSyncExternalStore (pmndrs/zustand#1936, #3155). const reviews = useMemo(() => allReviews.filter((item) => item.orderId === id), [allReviews, id]) + const [order, setOrder] = useState> | undefined>( + undefined, + ) + const [loading, setLoading] = useState(true) const [nowTs, setNowTs] = useState(0) + useEffect(() => { + let cancelled = false + + setLoading(true) + ;(async () => { + try { + const order = await Promise.resolve(getOrderById(id)) + if (cancelled) return + setOrder(order) + setLoading(false) + } catch { + if (cancelled) return + setOrder(undefined) + setLoading(false) + } + })() + + return () => { + cancelled = true + } + }, [id]) + useEffect(() => { if (!order) return if (order.status !== "pending_accept" && order.status !== "pending_close") return @@ -56,6 +81,12 @@ export default function OrderDetailPage({ params }: { params: Promise<{ id: stri return () => clearInterval(timer) }, [order]) + if (loading) { + return ( +
加载中...
+ ) + } + if (!order) { return (
@@ -257,6 +288,8 @@ export default function OrderDetailPage({ params }: { params: Promise<{ id: stri setOrder(next)} initialStatus={order.status} chatSessionId={chatSession?.id} serviceId={order.service.id} diff --git a/app/(order)/order/new/page.tsx b/app/(order)/order/new/page.tsx index 4716bf8..9bba553 100644 --- a/app/(order)/order/new/page.tsx +++ b/app/(order)/order/new/page.tsx @@ -7,13 +7,11 @@ import { Input } from "@/components/ui/input" import { Label } from "@/components/ui/label" import { Separator } from "@/components/ui/separator" import { Textarea } from "@/components/ui/textarea" -import type { Actor } from "@/lib/actor" import { getPlayerById, getServiceById } from "@/lib/api" +import { createPaidOrder } from "@/lib/api/orders" import { notifySuccess } from "@/lib/toast" import type { Player, PlayerService } from "@/lib/types" import { useRequireAuth } from "@/lib/use-require-auth" -import { useAuthStore } from "@/store/auth" -import { useOrderStore } from "@/store/orders" import { useWalletStore } from "@/store/wallet" import { ArrowLeft, CheckCircle, CreditCard, ShieldCheck } from "lucide-react" import Link from "next/link" @@ -24,7 +22,6 @@ export default function NewOrderPage() { const router = useRouter() const searchParams = useSearchParams() const { requireAuth } = useRequireAuth() - const createPaidOrder = useOrderStore((state) => state.createPaidOrder) const balance = useWalletStore((state) => state.balance) const serviceId = searchParams.get("serviceId") @@ -234,34 +231,26 @@ export default function NewOrderPage() { disabled={balance < totalPrice} onClick={() => requireAuth(() => { - const authUser = useAuthStore.getState().user - if (!authUser) return - - const actor: Actor = { - userId: authUser.id, - role: "consumer", - } - - const result = createPaidOrder( - { + Promise.resolve( + createPaidOrder({ playerId: player.id, serviceId: service.id, shopId: player.shopId, quantity, note, - }, - actor, - ) - if (!result.decision.ok || !result.order) { - return - } - const nextOrder = result.order + }), + ).then((result) => { + if (!result.decision.ok || !result.order) { + return + } + const nextOrder = result.order - setSubmitted(true) - notifySuccess("下单成功") - setTimeout(() => { - router.push(`/order/${nextOrder.id}`) - }, 800) + setSubmitted(true) + notifySuccess("下单成功") + setTimeout(() => { + router.push(`/order/${nextOrder.id}`) + }, 800) + }) }) } > diff --git a/app/(order)/orders/page.tsx b/app/(order)/orders/page.tsx index 85e9f78..d29e914 100644 --- a/app/(order)/orders/page.tsx +++ b/app/(order)/orders/page.tsx @@ -4,6 +4,7 @@ import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" +import { listOrders } from "@/lib/api" import { statusLabels } from "@/lib/constants" import { isActiveOrder, @@ -16,11 +17,10 @@ import type { OrderStatus, UserRole } from "@/lib/types" import { cn } from "@/lib/utils" import { useAuthStore } from "@/store/auth" import { useChatStore } from "@/store/chat" -import { useOrderStore } from "@/store/orders" import { useShopStore } from "@/store/shops" import { Clock, MessageSquare, RefreshCw } from "lucide-react" import Link from "next/link" -import { useState } from "react" +import { useEffect, useState } from "react" const statusColors: Record = { pending_payment: "bg-yellow-100 text-yellow-800", @@ -82,9 +82,28 @@ function OrderListContent({ ownerShopId?: string }) { const [tab, setTab] = useState("all") - const orders = useOrderStore((state) => state.orders) + const [orders, setOrders] = useState>>([]) const sessions = useChatStore((state) => state.sessions) + useEffect(() => { + let cancelled = false + + ;(async () => { + try { + const items = await Promise.resolve(listOrders()) + if (cancelled) return + setOrders(items) + } catch { + if (cancelled) return + setOrders([]) + } + })() + + return () => { + cancelled = true + } + }, []) + const tabs = currentRole === "consumer" ? consumerTabs : currentRole === "player" ? playerTabs : ownerTabs diff --git a/components/order-actions.tsx b/components/order-actions.tsx index febd655..e1a7d82 100644 --- a/components/order-actions.tsx +++ b/components/order-actions.tsx @@ -10,7 +10,7 @@ import { } from "@/lib/api/orders" import type { ApiDecision } from "@/lib/errors" import { notifyInfo, notifySuccess } from "@/lib/toast" -import type { OrderStatus } from "@/lib/types" +import type { Order, OrderStatus } from "@/lib/types" import { useAuthStore } from "@/store/auth" import { useChatStore } from "@/store/chat" import { useOrderStore } from "@/store/orders" @@ -29,6 +29,8 @@ import { useCallback } from "react" interface OrderActionsProps { orderId: string + order?: Order + onOrderChange?: (order: Order) => void initialStatus: OrderStatus chatSessionId?: string serviceId: string @@ -40,12 +42,15 @@ function showFeedback(message: string) { export default function OrderActions({ orderId, + order: orderProp, + onOrderChange, initialStatus, chatSessionId, serviceId, }: OrderActionsProps) { const currentUserId = useAuthStore((state) => state.user?.id) - const order = useOrderStore((state) => state.orders.find((item) => item.id === orderId)) + const storeOrder = useOrderStore((state) => state.orders.find((item) => item.id === orderId)) + const order = orderProp ?? storeOrder const sessions = useChatStore((state) => state.sessions) const dispatchMode = useShopStore((state) => { if (!order?.shopId) return "manual" @@ -60,7 +65,9 @@ export default function OrderActions({ const isConsumer = order?.consumerId === currentUserId const isPlayer = order?.playerId === currentUserId - const handleDecision = useCallback((okMessage: string, result: { decision: ApiDecision }) => { + type ActionResult = { decision: ApiDecision; order?: Order } + + const handleDecision = useCallback((okMessage: string, result: ActionResult) => { if (result.decision.ok) { showFeedback(okMessage) return @@ -69,6 +76,20 @@ export default function OrderActions({ notifyInfo(result.decision.error.msg) }, []) + const runAction = useCallback( + (okMessage: string, actionCall: ActionResult | Promise) => { + Promise.resolve(actionCall) + .then((result) => { + handleDecision(okMessage, result) + if (result.order) onOrderChange?.(result.order) + }) + .catch(() => { + notifyInfo("操作失败") + }) + }, + [handleDecision, onOrderChange], + ) + return (
{status === "pending_payment" && isConsumer && ( @@ -80,8 +101,7 @@ export default function OrderActions({ notifyInfo("请先登录") return } - const result = cancelPreAccept(orderId) - handleDecision("订单已取消", result) + runAction("订单已取消", cancelPreAccept(orderId)) }} > @@ -93,8 +113,7 @@ export default function OrderActions({ notifyInfo("请先登录") return } - const result = payOrder(orderId) - handleDecision("订单支付成功", result) + runAction("订单支付成功", payOrder(orderId)) }} > @@ -113,8 +132,7 @@ export default function OrderActions({ notifyInfo("请先登录") return } - const result = cancelPreAccept(orderId) - handleDecision("订单已取消", result) + runAction("订单已取消", cancelPreAccept(orderId)) }} > @@ -134,8 +152,7 @@ export default function OrderActions({ notifyInfo("请先登录") return } - const result = acceptOrder(orderId) - handleDecision("已接单", result) + runAction("已接单", acceptOrder(orderId)) }} > @@ -163,8 +180,7 @@ export default function OrderActions({ return } - const result = requestClose(orderId) - handleDecision("已发起结单", result) + runAction("已发起结单", requestClose(orderId)) }} > 发起结单 @@ -187,8 +203,7 @@ export default function OrderActions({ notifyInfo("请先登录") return } - const result = confirmClose(orderId) - handleDecision("已确认结单", result) + runAction("已确认结单", confirmClose(orderId)) }} > 确认结单 diff --git a/lib/api/orders.ts b/lib/api/orders.ts index 12850fc..1378d3c 100644 --- a/lib/api/orders.ts +++ b/lib/api/orders.ts @@ -1,21 +1,87 @@ -import type { Actor } from "@/lib/actor" import { allow, deny } from "@/lib/decision" -import { resolveOwnerShop } from "@/lib/domain/resolve-current-shop" -import type { ApiDecision } from "@/lib/errors" -import { useAuthStore } from "@/store/auth" -import { useOrderStore } from "@/store/orders" -import { useShopStore } from "@/store/shops" +import { isApiError, toApiError, type ApiDecision } from "@/lib/errors" +import type { Order, OrderStatus } from "@/lib/types" -export function listOrders() { - return useOrderStore.getState().orders +import { httpJson } from "./http" + +type Paginated = { + items: T[] + meta: { + total: number + offset: number + limit: number + } } -export function getOrderById(orderId: string) { - return useOrderStore.getState().orders.find((order) => order.id === orderId) +export type ListOrdersOptions = { + role?: "consumer" | "player" | "owner" + status?: OrderStatus + offset?: number + limit?: number } -export function listOrdersByConsumer(consumerId: string) { - return useOrderStore.getState().orders.filter((order) => order.consumerId === consumerId) +type ActionResult = { decision: ApiDecision; order?: Order } + +function withOffsetLimit(path: string, options?: ListOrdersOptions): string { + const offset = options?.offset ?? 0 + const limit = options?.limit ?? 1000 + + const searchParams = new URLSearchParams({ + offset: String(offset), + limit: String(limit), + }) + + if (options?.role) searchParams.set("role", options.role) + if (options?.status) searchParams.set("status", options.status) + + return `${path}?${searchParams.toString()}` +} + +function unwrapOrder(value: unknown): Order | undefined { + if (typeof value !== "object" || value === null) return undefined + if ("order" in value) { + const envelope = value as { order?: Order } + return envelope.order + } + return value as Order +} + +function denyFromError(error: unknown): ApiDecision { + if (error instanceof Error && error.message === "UNAUTHORIZED") { + return deny(401, "请先登录") + } + + const apiError = toApiError(error) + return deny(apiError.code, apiError.msg) +} + +export async function listOrders(options?: ListOrdersOptions): Promise { + const res = await httpJson>(withOffsetLimit("/api/v1/orders", options), { + cache: "no-store", + }) + return res.items +} + +export async function getOrderById(orderId: string): Promise { + try { + const res = await httpJson(`/api/v1/orders/${encodeURIComponent(orderId)}`, { + cache: "no-store", + }) + return unwrapOrder(res) + } catch (error) { + if (error instanceof Error && error.message === "UNAUTHORIZED") { + throw error + } + if (isApiError(error) && error.code === 404) { + return undefined + } + throw error + } +} + +export async function listOrdersByConsumer(consumerId: string): Promise { + const items = await listOrders({ role: "consumer" }) + return items.filter((order) => order.consumerId === consumerId) } interface CreatePaidOrderInput { @@ -26,69 +92,62 @@ interface CreatePaidOrderInput { note?: string } -function resolveActorContext(): { actor?: Actor; decision: ApiDecision } { - const auth = useAuthStore.getState() - if (!auth.user?.id) { - return { decision: deny(401, "请先登录") } - } - - const shopId = - auth.currentRole === "owner" - ? resolveOwnerShop(auth.user.id, useShopStore.getState().shops)?.id - : undefined - - return { - actor: { - userId: auth.user.id, - role: auth.currentRole, - shopId, - }, - decision: allow(), +export async function createPaidOrder(input: CreatePaidOrderInput): Promise { + try { + const res = await httpJson("/api/v1/orders/paid", { + method: "POST", + cache: "no-store", + json: input, + }) + const order = unwrapOrder(res) + if (!order) { + return { decision: deny(500, "订单创建失败") } + } + return { decision: allow(), order } + } catch (error) { + return { decision: denyFromError(error) } } } -export function createPaidOrder(input: CreatePaidOrderInput) { - const { actor, decision } = resolveActorContext() - if (!actor) return { decision } - return useOrderStore.getState().createPaidOrder(input, actor) +async function postOrderAction(orderId: string, action: string): Promise { + try { + const res = await httpJson(`/api/v1/orders/${encodeURIComponent(orderId)}/${action}`, { + method: "POST", + cache: "no-store", + }) + + const order = unwrapOrder(res) + if (order) { + return { decision: allow(), order } + } + + const refetched = await getOrderById(orderId).catch(() => undefined) + if (refetched) { + return { decision: allow(), order: refetched } + } + + return { decision: allow() } + } catch (error) { + return { decision: denyFromError(error) } + } } -export function payOrder(orderId: string) { - const { actor, decision } = resolveActorContext() - if (!actor) return { decision } - return useOrderStore.getState().payOrder(orderId, actor) +export async function payOrder(orderId: string): Promise { + return postOrderAction(orderId, "pay") } -export function acceptOrder(orderId: string) { - const { actor, decision } = resolveActorContext() - if (!actor) return { decision } - return useOrderStore.getState().acceptOrder(orderId, actor) +export async function acceptOrder(orderId: string): Promise { + return postOrderAction(orderId, "accept") } -export function acceptOrderAsActor(orderId: string, actor: Actor) { - return useOrderStore.getState().acceptOrder(orderId, actor) +export async function requestClose(orderId: string): Promise { + return postOrderAction(orderId, "request-close") } -export function requestClose(orderId: string) { - const { actor, decision } = resolveActorContext() - if (!actor) return { decision } - return useOrderStore.getState().requestClose(orderId, actor) +export async function confirmClose(orderId: string): Promise { + return postOrderAction(orderId, "confirm-close") } -export function confirmClose(orderId: string) { - const { actor, decision } = resolveActorContext() - if (!actor) return { decision } - return useOrderStore.getState().confirmClose(orderId, actor) -} - -export function cancelPreAccept(orderId: string) { - const { actor, decision } = resolveActorContext() - if (!actor) return { decision } - return useOrderStore.getState().cancelPreAccept(orderId, actor) -} - -export function markDisputed(orderId: string) { - const { actor, decision } = resolveActorContext() - if (!actor) return { decision } - return useOrderStore.getState().markDisputed(orderId, actor) +export async function cancelPreAccept(orderId: string): Promise { + return postOrderAction(orderId, "cancel") } diff --git a/tests/orders-api.test.ts b/tests/orders-api.test.ts index b74652b..414f8df 100644 --- a/tests/orders-api.test.ts +++ b/tests/orders-api.test.ts @@ -3,14 +3,16 @@ import { useAuthStore } from "@/store/auth" import { describe, expect, it } from "vitest" describe("lib/api/orders", () => { - it("returns { decision: { ok:false, error:{code,msg} } } when unauthenticated", () => { + it("returns { decision: { ok:false, error:{code,msg} } } when unauthenticated", async () => { useAuthStore.getState().logout() - const res = createPaidOrder({ - playerId: "1005", - serviceId: "5001", - quantity: 1, - }) + const res = await Promise.resolve( + createPaidOrder({ + playerId: "1005", + serviceId: "5001", + quantity: 1, + }), + ) expect(res).toEqual({ decision: {