From a7d56240ff591b1abbbded1078efad5a4a71190f Mon Sep 17 00:00:00 2001 From: zetaloop Date: Sun, 22 Feb 2026 08:16:35 +0800 Subject: [PATCH] feat(order): add sealed review reveal, timeout rules, and dispatch behavior --- app/(order)/order/[id]/page.tsx | 37 +++++++++- app/(order)/review/[id]/page.tsx | 35 ++++++++-- components/order-actions.tsx | 55 ++++++++++++--- store/orders.ts | 65 +++++++++++++++++ store/reviews.ts | 115 +++++++++++++++++++++++++++++++ 5 files changed, 288 insertions(+), 19 deletions(-) create mode 100644 store/reviews.ts diff --git a/app/(order)/order/[id]/page.tsx b/app/(order)/order/[id]/page.tsx index c400364..b4845bd 100644 --- a/app/(order)/order/[id]/page.tsx +++ b/app/(order)/order/[id]/page.tsx @@ -2,16 +2,16 @@ import { ArrowLeft, CheckCircle, Clock, Star } from "lucide-react" import Link from "next/link" -import { use, useEffect } from "react" +import { use, useEffect, useState } from "react" 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 { statusLabels } from "@/lib/constants" -import { mockReviews } from "@/lib/mock" import type { OrderStatus } from "@/lib/types" import { useChatStore } from "@/store/chat" import { useOrderStore } from "@/store/orders" +import { useReviewStore } from "@/store/reviews" const normalStatusSteps: OrderStatus[] = [ "pending_payment", @@ -37,6 +37,8 @@ export default function OrderDetailPage({ params }: { params: Promise<{ id: stri const order = useOrderStore((state) => state.orders.find((item) => item.id === id)) const sessions = useChatStore((state) => state.sessions) const ensureOrderSession = useChatStore((state) => state.ensureOrderSession) + const reviews = useReviewStore((state) => state.reviews.filter((item) => item.orderId === id)) + const [nowTs, setNowTs] = useState(Date.now()) useEffect(() => { if (!order) return @@ -44,6 +46,17 @@ export default function OrderDetailPage({ params }: { params: Promise<{ id: stri ensureOrderSession(order) }, [order, ensureOrderSession]) + useEffect(() => { + if (!order) return + if (order.status !== "pending_accept" && order.status !== "pending_close") return + + const timer = setInterval(() => { + setNowTs(Date.now()) + }, 1000) + + return () => clearInterval(timer) + }, [order]) + if (!order) { return (
@@ -52,7 +65,6 @@ export default function OrderDetailPage({ params }: { params: Promise<{ id: stri ) } - const reviews = mockReviews.filter((r) => r.orderId === id) const chatSession = sessions.find((session) => session.type === "order" && session.orderId === id) const statusSteps = order.status === "disputed" @@ -61,6 +73,19 @@ export default function OrderDetailPage({ params }: { params: Promise<{ id: stri ? cancelledStatusSteps : normalStatusSteps const currentStepIndex = statusSteps.indexOf(order.status) + const timeoutHint = (() => { + if (order.status !== "pending_accept" && order.status !== "pending_close") return null + + const base = + order.status === "pending_accept" + ? new Date(order.createdAt).getTime() + : new Date(order.closedAt ?? order.createdAt).getTime() + const remainSeconds = Math.max(0, 30 - Math.floor((nowTs - base) / 1000)) + + return order.status === "pending_accept" + ? `若 30 秒内无人接单,订单将自动取消(剩余 ${remainSeconds} 秒)` + : `若 30 秒内未确认,订单将自动进入待评价(剩余 ${remainSeconds} 秒)` + })() return (
@@ -106,6 +131,12 @@ export default function OrderDetailPage({ params }: { params: Promise<{ id: stri + {timeoutHint && ( + + {timeoutHint} + + )} + 服务信息 diff --git a/app/(order)/review/[id]/page.tsx b/app/(order)/review/[id]/page.tsx index e444f02..f7552ed 100644 --- a/app/(order)/review/[id]/page.tsx +++ b/app/(order)/review/[id]/page.tsx @@ -7,16 +7,19 @@ import { Button } from "@/components/ui/button" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" import { Label } from "@/components/ui/label" import { Textarea } from "@/components/ui/textarea" +import { useAuthStore } from "@/store/auth" import { useOrderStore } from "@/store/orders" +import { useReviewStore } from "@/store/reviews" export default function ReviewPage({ params }: { params: Promise<{ id: string }> }) { const { id } = use(params) const order = useOrderStore((state) => state.orders.find((item) => item.id === id)) - const updateOrderStatus = useOrderStore((state) => state.updateOrderStatus) + const userId = useAuthStore((state) => state.user?.id) + const submitReview = useReviewStore((state) => state.submitReview) + const reviews = useReviewStore((state) => state.reviews.filter((item) => item.orderId === id)) const [rating, setRating] = useState(0) const [hoverRating, setHoverRating] = useState(0) const [content, setContent] = useState("") - const [submitted, setSubmitted] = useState(false) if (!order) { return ( @@ -26,7 +29,10 @@ export default function ReviewPage({ params }: { params: Promise<{ id: string }> ) } - if (submitted) { + const hasSubmitted = Boolean(userId && reviews.some((review) => review.fromUserId === userId)) + const isRevealed = reviews.length >= 2 && reviews.every((review) => !review.sealed) + + if (hasSubmitted && !isRevealed) { return (
@@ -40,6 +46,18 @@ export default function ReviewPage({ params }: { params: Promise<{ id: string }> ) } + if (hasSubmitted && isRevealed) { + return ( +
+

评价已揭晓

+

双方评价已同步公开,可在订单详情查看。

+ + 返回订单详情 + +
+ ) + } + return (
- + {order?.shopId && dispatchMode === "auto" ? ( + + ) : ( + + )} )} diff --git a/store/orders.ts b/store/orders.ts index 0de22f8..b166b81 100644 --- a/store/orders.ts +++ b/store/orders.ts @@ -2,6 +2,7 @@ import { create } from "zustand" import { generateId } from "@/lib/id" import { mockOrders } from "@/lib/mock" import type { Order, OrderStatus, PlayerService } from "@/lib/types" +import { useWalletStore } from "@/store/wallet" interface CreateOrderInput { consumerId: string @@ -22,6 +23,44 @@ interface OrderState { updateOrderStatus: (orderId: string, status: OrderStatus) => void } +const orderTimeouts = new Map>() + +function clearOrderTimeout(orderId: string) { + const timer = orderTimeouts.get(orderId) + if (!timer) return + clearTimeout(timer) + orderTimeouts.delete(orderId) +} + +function scheduleOrderTimeout(orderId: string, status: OrderStatus) { + clearOrderTimeout(orderId) + + if (status !== "pending_accept" && status !== "pending_close") { + return + } + + const timer = setTimeout(() => { + const state = useOrderStore.getState() + const order = state.orders.find((item) => item.id === orderId) + if (!order || order.status !== status) { + orderTimeouts.delete(orderId) + return + } + + if (status === "pending_accept") { + state.updateOrderStatus(orderId, "cancelled") + } + + if (status === "pending_close") { + state.updateOrderStatus(orderId, "pending_review") + } + + orderTimeouts.delete(orderId) + }, 30000) + + orderTimeouts.set(orderId, timer) +} + export const useOrderStore = create((set) => ({ orders: mockOrders, createOrder: (input) => { @@ -44,6 +83,8 @@ export const useOrderStore = create((set) => ({ orders: [order, ...state.orders], })) + scheduleOrderTimeout(order.id, order.status) + return order }, updateOrderStatus: (orderId, status) => @@ -60,6 +101,12 @@ export const useOrderStore = create((set) => ({ status, acceptedAt: order.acceptedAt ?? now, } + case "pending_close": + return { + ...order, + status, + closedAt: order.closedAt ?? now, + } case "pending_review": return { ...order, @@ -67,6 +114,9 @@ export const useOrderStore = create((set) => ({ closedAt: order.closedAt ?? now, } case "completed": + if (order.status !== "completed") { + useWalletStore.getState().addIncome(order.id, order.totalPrice) + } return { ...order, status, @@ -82,3 +132,18 @@ export const useOrderStore = create((set) => ({ }), })), })) + +useOrderStore.subscribe((state, prevState) => { + state.orders.forEach((order) => { + const prevOrder = prevState.orders.find((item) => item.id === order.id) + if (!prevOrder || prevOrder.status !== order.status) { + scheduleOrderTimeout(order.id, order.status) + } + }) + + prevState.orders.forEach((order) => { + if (!state.orders.some((item) => item.id === order.id)) { + clearOrderTimeout(order.id) + } + }) +}) diff --git a/store/reviews.ts b/store/reviews.ts new file mode 100644 index 0000000..c189df1 --- /dev/null +++ b/store/reviews.ts @@ -0,0 +1,115 @@ +import { create } from "zustand" +import { generateId } from "@/lib/id" +import { mockReviews, mockUsers } from "@/lib/mock" +import type { Review } from "@/lib/types" +import { useOrderStore } from "@/store/orders" + +interface SubmitReviewInput { + orderId: string + fromUserId: string + rating: number + content?: string +} + +interface ReviewState { + reviews: Review[] + submitReview: (input: SubmitReviewInput) => void + getReviewsByOrder: (orderId: string) => Review[] + hasUserReviewed: (orderId: string, userId: string) => boolean +} + +const autoReplyTimers = new Map>() + +function resolveUser(userId: string) { + return mockUsers.find((user) => user.id === userId) +} + +function resolveOrderUser(orderId: string, userId: string) { + const order = useOrderStore.getState().orders.find((item) => item.id === orderId) + if (!order) return null + + if (order.consumerId === userId) { + return { + fromUserName: order.consumerName, + toUserId: order.playerId, + toUserName: order.playerName, + } + } + + if (order.playerId === userId) { + return { + fromUserName: order.playerName, + toUserId: order.consumerId, + toUserName: order.consumerName, + } + } + + return null +} + +export const useReviewStore = create((set, get) => ({ + reviews: mockReviews, + getReviewsByOrder: (orderId) => get().reviews.filter((review) => review.orderId === orderId), + hasUserReviewed: (orderId, userId) => + get().reviews.some((review) => review.orderId === orderId && review.fromUserId === userId), + submitReview: (input) => { + const relation = resolveOrderUser(input.orderId, input.fromUserId) + if (!relation) return + + const exists = get().hasUserReviewed(input.orderId, input.fromUserId) + if (exists) return + + const fromUser = resolveUser(input.fromUserId) + const createdAt = new Date().toISOString() + + const review: Review = { + id: generateId("rev"), + orderId: input.orderId, + fromUserId: input.fromUserId, + fromUserName: relation.fromUserName, + fromUserAvatar: fromUser?.avatar ?? "", + toUserId: relation.toUserId, + rating: input.rating, + content: input.content?.trim() || undefined, + sealed: true, + createdAt, + } + + set((state) => ({ reviews: [...state.reviews, review] })) + + const orderReviews = get().getReviewsByOrder(input.orderId) + if (orderReviews.length >= 2) { + const timer = autoReplyTimers.get(input.orderId) + if (timer) { + clearTimeout(timer) + autoReplyTimers.delete(input.orderId) + } + + set((state) => ({ + reviews: state.reviews.map((item) => + item.orderId === input.orderId ? { ...item, sealed: false } : item, + ), + })) + useOrderStore.getState().updateOrderStatus(input.orderId, "completed") + return + } + + if (autoReplyTimers.has(input.orderId)) return + + const timer = setTimeout(() => { + autoReplyTimers.delete(input.orderId) + if (get().hasUserReviewed(input.orderId, relation.toUserId)) { + return + } + + get().submitReview({ + orderId: input.orderId, + fromUserId: relation.toUserId, + rating: 5, + content: `收到 ${relation.toUserName} 的评价,感谢本次对局。`, + }) + }, 3000) + + autoReplyTimers.set(input.orderId, timer) + }, +}))