feat(order): add sealed review reveal, timeout rules, and dispatch behavior

This commit is contained in:
zetaloop
2026-02-22 08:16:35 +08:00
parent 33b7e4d0b9
commit a7d56240ff
5 changed files with 288 additions and 19 deletions
+34 -3
View File
@@ -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 (
<div className="container mx-auto py-8 px-4 text-center text-muted-foreground">
@@ -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 (
<div className="container mx-auto py-8 px-4 max-w-3xl">
@@ -106,6 +131,12 @@ export default function OrderDetailPage({ params }: { params: Promise<{ id: stri
</CardContent>
</Card>
{timeoutHint && (
<Card className="mb-6">
<CardContent className="py-3 text-sm text-muted-foreground">{timeoutHint}</CardContent>
</Card>
)}
<Card className="mb-6">
<CardHeader>
<CardTitle className="text-base"></CardTitle>
+29 -6
View File
@@ -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 (
<div className="container mx-auto py-8 px-4 max-w-lg text-center space-y-4">
<Lock className="h-12 w-12 mx-auto text-muted-foreground" />
@@ -40,6 +46,18 @@ export default function ReviewPage({ params }: { params: Promise<{ id: string }>
)
}
if (hasSubmitted && isRevealed) {
return (
<div className="container mx-auto py-8 px-4 max-w-lg text-center space-y-4">
<h2 className="text-xl font-bold"></h2>
<p className="text-sm text-muted-foreground"></p>
<Link href={`/order/${id}`} className="text-sm text-primary hover:underline">
</Link>
</div>
)
}
return (
<div className="container mx-auto py-8 px-4 max-w-lg">
<Link
@@ -100,10 +118,15 @@ export default function ReviewPage({ params }: { params: Promise<{ id: string }>
<Button
className="w-full"
disabled={rating === 0}
disabled={rating === 0 || !userId}
onClick={() => {
updateOrderStatus(id, "completed")
setSubmitted(true)
if (!userId) return
submitReview({
orderId: id,
fromUserId: userId,
rating,
content,
})
}}
>
+45 -10
View File
@@ -1,6 +1,14 @@
"use client"
import { AlertTriangle, CheckCircle2, MessageSquare, RefreshCw, Star, XCircle } from "lucide-react"
import {
AlertTriangle,
CheckCircle2,
Clock,
MessageSquare,
RefreshCw,
Star,
XCircle,
} from "lucide-react"
import Link from "next/link"
import { useEffect, useState } from "react"
import { Button } from "@/components/ui/button"
@@ -8,6 +16,7 @@ import { notifySuccess } from "@/lib/toast"
import type { OrderStatus } from "@/lib/types"
import { useChatStore } from "@/store/chat"
import { useOrderStore } from "@/store/orders"
import { useShopStore } from "@/store/shops"
interface OrderActionsProps {
orderId: string
@@ -29,6 +38,11 @@ export default function OrderActions({
const order = useOrderStore((state) => state.orders.find((item) => item.id === orderId))
const updateOrderStatus = useOrderStore((state) => state.updateOrderStatus)
const ensureOrderSession = useChatStore((state) => state.ensureOrderSession)
const dispatchMode = useShopStore((state) => {
if (!order?.shopId) return "manual"
const shop = state.shops.find((item) => item.id === order.shopId)
return shop?.dispatchMode ?? "manual"
})
const [resolvedChatSessionId, setResolvedChatSessionId] = useState(chatSessionId)
const status = order?.status ?? initialStatus
@@ -44,6 +58,20 @@ export default function OrderActions({
setResolvedChatSessionId(session.id)
}, [chatSessionId, order, ensureOrderSession])
useEffect(() => {
if (!order) return
if (order.status !== "pending_accept") return
if (!order.shopId) return
if (dispatchMode !== "auto") return
const timer = setTimeout(() => {
updateOrderStatus(orderId, "in_progress")
showFeedback("系统已自动派单")
}, 3000)
return () => clearTimeout(timer)
}, [dispatchMode, order, orderId, updateOrderStatus])
return (
<div className="flex gap-2 flex-wrap">
{status === "pending_payment" && (
@@ -81,15 +109,22 @@ export default function OrderActions({
<XCircle className="mr-1 h-4 w-4" />
</Button>
<Button
onClick={() => {
updateOrderStatus(orderId, "in_progress")
showFeedback("已接单")
}}
>
<CheckCircle2 className="mr-1 h-4 w-4" />
</Button>
{order?.shopId && dispatchMode === "auto" ? (
<Button disabled>
<Clock className="mr-1 h-4 w-4" />
</Button>
) : (
<Button
onClick={() => {
updateOrderStatus(orderId, "in_progress")
showFeedback("已接单")
}}
>
<CheckCircle2 className="mr-1 h-4 w-4" />
</Button>
)}
</>
)}
+65
View File
@@ -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<string, ReturnType<typeof setTimeout>>()
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<OrderState>((set) => ({
orders: mockOrders,
createOrder: (input) => {
@@ -44,6 +83,8 @@ export const useOrderStore = create<OrderState>((set) => ({
orders: [order, ...state.orders],
}))
scheduleOrderTimeout(order.id, order.status)
return order
},
updateOrderStatus: (orderId, status) =>
@@ -60,6 +101,12 @@ export const useOrderStore = create<OrderState>((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<OrderState>((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<OrderState>((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)
}
})
})
+115
View File
@@ -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<string, ReturnType<typeof setTimeout>>()
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<ReviewState>((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)
},
}))