519fb92c34
Turn on react-hooks/set-state-in-effect and react-hooks/incompatible-library, then remove effect-driven local state sync patterns across affected pages. Keep behavior stable by deriving values from source state, remounting tab state by role key, and replacing useForm watch with useWatch.
271 lines
10 KiB
TypeScript
271 lines
10 KiB
TypeScript
"use client"
|
||
|
||
import { ArrowLeft, CheckCircle, Clock, Star } from "lucide-react"
|
||
import Link from "next/link"
|
||
import { use, useEffect, useMemo, 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 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",
|
||
"pending_accept",
|
||
"in_progress",
|
||
"pending_close",
|
||
"pending_review",
|
||
"completed",
|
||
]
|
||
|
||
const disputedStatusSteps: OrderStatus[] = [
|
||
"pending_payment",
|
||
"pending_accept",
|
||
"in_progress",
|
||
"pending_close",
|
||
"disputed",
|
||
]
|
||
|
||
const cancelledStatusSteps: OrderStatus[] = ["pending_payment", "pending_accept", "cancelled"]
|
||
|
||
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 ensureOrderSession = useChatStore((state) => state.ensureOrderSession)
|
||
const allReviews = useReviewStore((state) => state.reviews)
|
||
// Filtering is deferred to useMemo after reading the raw store array.
|
||
// Zustand v5 compares selector outputs by reference stability.
|
||
// 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 [nowTs, setNowTs] = useState(0)
|
||
|
||
useEffect(() => {
|
||
if (!order) return
|
||
if (order.status === "pending_payment" || order.status === "cancelled") return
|
||
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">
|
||
订单不存在
|
||
</div>
|
||
)
|
||
}
|
||
|
||
const chatSession = sessions.find((session) => session.type === "order" && session.orderId === id)
|
||
const statusSteps =
|
||
order.status === "disputed"
|
||
? disputedStatusSteps
|
||
: order.status === "cancelled"
|
||
? 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">
|
||
<Link
|
||
href="/orders"
|
||
className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground mb-4"
|
||
>
|
||
<ArrowLeft className="h-4 w-4" />
|
||
返回订单列表
|
||
</Link>
|
||
|
||
<div className="flex items-center justify-between mb-6">
|
||
<h1 className="text-2xl font-bold">订单详情</h1>
|
||
<Badge variant="outline">{statusLabels[order.status]}</Badge>
|
||
</div>
|
||
|
||
<Card className="mb-6">
|
||
<CardContent className="pt-6">
|
||
<div className="flex items-center justify-between">
|
||
{statusSteps.map((step, i) => {
|
||
const isActive = i <= currentStepIndex
|
||
const isCurrent = i === currentStepIndex
|
||
return (
|
||
<div key={step} className="flex flex-col items-center gap-1 flex-1">
|
||
<div
|
||
className={`h-8 w-8 rounded-full flex items-center justify-center text-xs font-medium ${
|
||
isCurrent
|
||
? "bg-primary text-primary-foreground"
|
||
: isActive
|
||
? "bg-primary/20 text-primary"
|
||
: "bg-muted text-muted-foreground"
|
||
}`}
|
||
>
|
||
{isActive ? <CheckCircle className="h-4 w-4" /> : i + 1}
|
||
</div>
|
||
<span className="text-[10px] text-muted-foreground text-center">
|
||
{statusLabels[step]}
|
||
</span>
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
</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>
|
||
</CardHeader>
|
||
<CardContent className="space-y-3">
|
||
<div className="flex justify-between text-sm">
|
||
<span className="text-muted-foreground">服务</span>
|
||
<span>{order.service.title}</span>
|
||
</div>
|
||
<div className="flex justify-between text-sm">
|
||
<span className="text-muted-foreground">游戏</span>
|
||
<span>{order.service.gameName}</span>
|
||
</div>
|
||
<div className="flex justify-between text-sm">
|
||
<span className="text-muted-foreground">单价</span>
|
||
<span>
|
||
¥{order.service.price}/{order.service.unit}
|
||
</span>
|
||
</div>
|
||
<Separator />
|
||
<div className="flex justify-between text-sm">
|
||
<span className="text-muted-foreground">打手</span>
|
||
<Link href={`/player/${order.playerId}`} className="text-primary hover:underline">
|
||
{order.playerName}
|
||
</Link>
|
||
</div>
|
||
{order.shopName && (
|
||
<div className="flex justify-between text-sm">
|
||
<span className="text-muted-foreground">店铺</span>
|
||
<Link href={`/shop/${order.shopId}`} className="text-primary hover:underline">
|
||
{order.shopName}
|
||
</Link>
|
||
</div>
|
||
)}
|
||
<Separator />
|
||
<div className="flex justify-between text-sm font-medium">
|
||
<span>总价</span>
|
||
<span className="text-lg">¥{order.totalPrice}</span>
|
||
</div>
|
||
{order.note && (
|
||
<div className="text-sm">
|
||
<span className="text-muted-foreground">备注: </span>
|
||
{order.note}
|
||
</div>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<Card className="mb-6">
|
||
<CardHeader>
|
||
<CardTitle className="text-base">时间线</CardTitle>
|
||
</CardHeader>
|
||
<CardContent className="space-y-2 text-sm">
|
||
<div className="flex items-center gap-2">
|
||
<Clock className="h-3.5 w-3.5 text-muted-foreground" />
|
||
<span className="text-muted-foreground">下单时间:</span>
|
||
{new Date(order.createdAt).toLocaleString("zh-CN")}
|
||
</div>
|
||
{order.acceptedAt && (
|
||
<div className="flex items-center gap-2">
|
||
<CheckCircle className="h-3.5 w-3.5 text-green-500" />
|
||
<span className="text-muted-foreground">接单时间:</span>
|
||
{new Date(order.acceptedAt).toLocaleString("zh-CN")}
|
||
</div>
|
||
)}
|
||
{order.closedAt && (
|
||
<div className="flex items-center gap-2">
|
||
<CheckCircle className="h-3.5 w-3.5 text-green-500" />
|
||
<span className="text-muted-foreground">结单时间:</span>
|
||
{new Date(order.closedAt).toLocaleString("zh-CN")}
|
||
</div>
|
||
)}
|
||
{order.completedAt && (
|
||
<div className="flex items-center gap-2">
|
||
<CheckCircle className="h-3.5 w-3.5 text-green-500" />
|
||
<span className="text-muted-foreground">完成时间:</span>
|
||
{new Date(order.completedAt).toLocaleString("zh-CN")}
|
||
</div>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{reviews.length > 0 && (
|
||
<Card className="mb-6">
|
||
<CardHeader>
|
||
<CardTitle className="text-base">评价</CardTitle>
|
||
</CardHeader>
|
||
<CardContent className="space-y-4">
|
||
{reviews.map((review) => (
|
||
<div key={review.id} className="space-y-1">
|
||
<div className="flex items-center gap-2">
|
||
<span className="text-sm font-medium">{review.fromUserName}</span>
|
||
<div className="flex">
|
||
{[1, 2, 3, 4, 5].map((star) => (
|
||
<Star
|
||
key={`star-${star}`}
|
||
className={`h-3.5 w-3.5 ${star <= review.rating ? "fill-yellow-400 text-yellow-400" : "text-muted"}`}
|
||
/>
|
||
))}
|
||
</div>
|
||
</div>
|
||
{review.sealed ? (
|
||
<p className="text-sm text-muted-foreground">评价已提交,待揭晓</p>
|
||
) : (
|
||
review.content && (
|
||
<p className="text-sm text-muted-foreground">{review.content}</p>
|
||
)
|
||
)}
|
||
<p className="text-xs text-muted-foreground">
|
||
{new Date(review.createdAt).toLocaleDateString("zh-CN")}
|
||
</p>
|
||
</div>
|
||
))}
|
||
</CardContent>
|
||
</Card>
|
||
)}
|
||
|
||
<OrderActions
|
||
orderId={order.id}
|
||
initialStatus={order.status}
|
||
chatSessionId={chatSession?.id}
|
||
serviceId={order.service.id}
|
||
/>
|
||
</div>
|
||
)
|
||
}
|