313 lines
11 KiB
TypeScript
313 lines
11 KiB
TypeScript
"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, listReviewsByOrder } 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 { ArrowLeft, CheckCircle, Clock, Star } from "lucide-react"
|
||
import Link from "next/link"
|
||
import { use, useEffect, 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 [reviews, setReviews] = useState<Awaited<ReturnType<typeof listReviewsByOrder>>>([])
|
||
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(() => {
|
||
let cancelled = false
|
||
|
||
;(async () => {
|
||
try {
|
||
const reviews = await Promise.resolve(listReviewsByOrder(id))
|
||
if (cancelled) return
|
||
setReviews(reviews)
|
||
} catch {
|
||
if (cancelled) return
|
||
setReviews([])
|
||
}
|
||
})()
|
||
|
||
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>
|
||
)
|
||
}
|