feat(disputes): migrate disputes and reviews to backend API
This commit is contained in:
@@ -6,12 +6,16 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { submitDispute, submitDisputeAppeal, submitDisputeResponse } from "@/lib/api/disputes"
|
||||
import { getOrderById } from "@/lib/api"
|
||||
import {
|
||||
getDisputeByOrderId,
|
||||
submitDispute,
|
||||
submitDisputeAppeal,
|
||||
submitDisputeResponse,
|
||||
} from "@/lib/api/disputes"
|
||||
import { DISPUTE_TO_RESOLVED_MS } from "@/lib/config/demo-timers"
|
||||
import { notifyInfo } from "@/lib/toast"
|
||||
import { useAuthStore } from "@/store/auth"
|
||||
import { useDisputeStore } from "@/store/disputes"
|
||||
import { useOrderStore } from "@/store/orders"
|
||||
import { AlertTriangle, ArrowLeft, Clock, FileText, Upload, X } from "lucide-react"
|
||||
import Image from "next/image"
|
||||
import Link from "next/link"
|
||||
@@ -33,13 +37,42 @@ const disputeStatusLabels: Record<string, string> = {
|
||||
appealed: "申诉中",
|
||||
}
|
||||
|
||||
function deriveMinimalTimeline<TCreatedAt>(dispute: {
|
||||
id: string
|
||||
status: string
|
||||
createdAt: TCreatedAt
|
||||
timeline?: { id: string; content: string; createdAt: TCreatedAt }[]
|
||||
}) {
|
||||
const existing = dispute.timeline
|
||||
if (existing?.length) return existing
|
||||
|
||||
const steps = [
|
||||
{ status: "open", content: "争议已提交" },
|
||||
{ status: "reviewing", content: "平台审核中" },
|
||||
{ status: "resolved", content: "争议已解决" },
|
||||
{ status: "appealed", content: "已发起申诉" },
|
||||
]
|
||||
|
||||
const currentIndex = steps.findIndex((step) => step.status === dispute.status)
|
||||
const lastIndex = currentIndex >= 0 ? currentIndex : 0
|
||||
return steps.slice(0, lastIndex + 1).map((step) => ({
|
||||
id: `${dispute.id}-${step.status}`,
|
||||
content: step.content,
|
||||
createdAt: dispute.createdAt,
|
||||
}))
|
||||
}
|
||||
|
||||
export default function DisputePage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = use(params)
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const order = useOrderStore((state) => state.orders.find((item) => item.id === id))
|
||||
const userId = useAuthStore((state) => state.user?.id)
|
||||
const existingDispute = useDisputeStore((state) => state.getDisputeByOrderId(id))
|
||||
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [order, setOrder] = useState<Awaited<ReturnType<typeof getOrderById>> | null>(null)
|
||||
const [existingDispute, setExistingDispute] = useState<Awaited<
|
||||
ReturnType<typeof getDisputeByOrderId>
|
||||
> | null>(null)
|
||||
|
||||
const [reason, setReason] = useState("")
|
||||
const [files, setFiles] = useState<string[]>([])
|
||||
@@ -52,6 +85,28 @@ export default function DisputePage({ params }: { params: Promise<{ id: string }
|
||||
const filesRef = useRef<string[]>([])
|
||||
const responseFilesRef = useRef<string[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
setLoading(true)
|
||||
setOrder(null)
|
||||
setExistingDispute(null)
|
||||
|
||||
Promise.all([Promise.resolve(getOrderById(id)), Promise.resolve(getDisputeByOrderId(id))])
|
||||
.then(([nextOrder, nextDispute]) => {
|
||||
if (cancelled) return
|
||||
setOrder(nextOrder ?? null)
|
||||
setExistingDispute(nextDispute ?? null)
|
||||
})
|
||||
.finally(() => {
|
||||
if (cancelled) return
|
||||
setLoading(false)
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [id])
|
||||
|
||||
useEffect(() => {
|
||||
filesRef.current = files
|
||||
}, [files])
|
||||
@@ -106,19 +161,29 @@ export default function DisputePage({ params }: { params: Promise<{ id: string }
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!userId || !reason.trim()) return
|
||||
const result = submitDispute({
|
||||
orderId: id,
|
||||
reason,
|
||||
evidence: files,
|
||||
|
||||
Promise.resolve(
|
||||
submitDispute({
|
||||
orderId: id,
|
||||
reason,
|
||||
evidence: files,
|
||||
}),
|
||||
).then((result) => {
|
||||
if (!result.decision.ok) {
|
||||
notifyInfo(result.decision.error.msg)
|
||||
return
|
||||
}
|
||||
router.replace(`/dispute/${id}?submitted=1`)
|
||||
})
|
||||
if (!result.decision.ok) {
|
||||
notifyInfo(result.decision.error.msg)
|
||||
return
|
||||
}
|
||||
router.replace(`/dispute/${id}?submitted=1`)
|
||||
}
|
||||
|
||||
const showSubmitted = searchParams.get("submitted") === "1" && !existingDispute
|
||||
const showSubmitted = !loading && searchParams.get("submitted") === "1" && !existingDispute
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="container mx-auto py-8 px-4 text-center text-muted-foreground">加载中...</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!order) {
|
||||
return (
|
||||
@@ -149,6 +214,8 @@ export default function DisputePage({ params }: { params: Promise<{ id: string }
|
||||
const canAppeal =
|
||||
isParticipant && existingDispute.status === "resolved" && !existingDispute.appealedAt
|
||||
|
||||
const timeline = deriveMinimalTimeline(existingDispute)
|
||||
|
||||
return (
|
||||
<div className="container mx-auto max-w-2xl px-4 py-8 space-y-4">
|
||||
<Link
|
||||
@@ -282,14 +349,17 @@ export default function DisputePage({ params }: { params: Promise<{ id: string }
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (!userId) return
|
||||
const decision = submitDisputeResponse({
|
||||
disputeId: existingDispute.id,
|
||||
reason: responseReason,
|
||||
evidence: responseFiles,
|
||||
Promise.resolve(
|
||||
submitDisputeResponse({
|
||||
disputeId: existingDispute.id,
|
||||
reason: responseReason,
|
||||
evidence: responseFiles,
|
||||
}),
|
||||
).then((decision) => {
|
||||
if (!decision.ok) {
|
||||
notifyInfo(decision.error.msg)
|
||||
}
|
||||
})
|
||||
if (!decision.ok) {
|
||||
notifyInfo(decision.error.msg)
|
||||
}
|
||||
}}
|
||||
disabled={!responseReason.trim()}
|
||||
>
|
||||
@@ -331,13 +401,16 @@ export default function DisputePage({ params }: { params: Promise<{ id: string }
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
if (!userId) return
|
||||
const decision = submitDisputeAppeal({
|
||||
disputeId: existingDispute.id,
|
||||
reason: appealReason,
|
||||
Promise.resolve(
|
||||
submitDisputeAppeal({
|
||||
disputeId: existingDispute.id,
|
||||
reason: appealReason,
|
||||
}),
|
||||
).then((decision) => {
|
||||
if (!decision.ok) {
|
||||
notifyInfo(decision.error.msg)
|
||||
}
|
||||
})
|
||||
if (!decision.ok) {
|
||||
notifyInfo(decision.error.msg)
|
||||
}
|
||||
}}
|
||||
disabled={!appealReason.trim()}
|
||||
>
|
||||
@@ -352,7 +425,7 @@ export default function DisputePage({ params }: { params: Promise<{ id: string }
|
||||
<div className="space-y-2">
|
||||
<Label className="text-muted-foreground">处理时间线</Label>
|
||||
<div className="space-y-2">
|
||||
{existingDispute.timeline.map((item) => (
|
||||
{timeline.map((item) => (
|
||||
<div key={item.id} className="text-sm flex items-start justify-between gap-3">
|
||||
<span>{item.content}</span>
|
||||
<span className="text-xs text-muted-foreground shrink-0">
|
||||
|
||||
Reference in New Issue
Block a user