Files
juwan-frontend/app/(order)/order/[id]/page.tsx
T
2026-02-28 18:13:42 +08:00

300 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client"
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 { useReviewStore } from "@/store/reviews"
import { ArrowLeft, CheckCircle, Clock, Star } from "lucide-react"
import Link from "next/link"
import { use, useEffect, useMemo, useState } from "react"
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 sessions = useChatStore((state) => state.sessions)
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 [order, setOrder] = useState<Awaited<ReturnType<typeof getOrderById>> | 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
const timer = setInterval(() => {
setNowTs(Date.now())
}, 1000)
return () => clearInterval(timer)
}, [order])
if (loading) {
return (
<div className="container mx-auto py-8 px-4 text-center text-muted-foreground">...</div>
)
}
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 timeoutMs =
order.status === "pending_accept" ? ORDER_ACCEPT_TIMEOUT_MS : ORDER_CLOSE_TIMEOUT_MS
const remainSeconds = Math.max(0, Math.ceil((timeoutMs - (nowTs - base)) / 1000))
return order.status === "pending_accept"
? `${Math.floor(ORDER_ACCEPT_TIMEOUT_MS / 1000)} 秒内无人接单,订单将自动取消(剩余 ${remainSeconds} 秒)`
: `${Math.floor(ORDER_CLOSE_TIMEOUT_MS / 1000)} 秒内未确认,订单将自动进入待评价(剩余 ${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 shadow-sm"
: 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}
order={order}
onOrderChange={(next) => setOrder(next)}
initialStatus={order.status}
chatSessionId={chatSession?.id}
serviceId={order.service.id}
/>
</div>
)
}