feat(dispute): support bilateral evidence, timeline, and one-time appeal

This commit is contained in:
zetaloop
2026-02-22 08:16:51 +08:00
parent a7d56240ff
commit 5542015abe
2 changed files with 434 additions and 34 deletions
+212 -34
View File
@@ -4,14 +4,23 @@ import { AlertTriangle, ArrowLeft, Clock, FileText, Upload, X } from "lucide-rea
import Image from "next/image"
import Link from "next/link"
import { useRouter, useSearchParams } from "next/navigation"
import { type ChangeEvent, use, useEffect, useRef, useState } from "react"
import {
type ChangeEvent,
type Dispatch,
type SetStateAction,
use,
useEffect,
useRef,
useState,
} from "react"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
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 { mockDisputes } from "@/lib/mock"
import { useAuthStore } from "@/store/auth"
import { useDisputeStore } from "@/store/disputes"
import { useOrderStore } from "@/store/orders"
const disputeStatusLabels: Record<string, string> = {
@@ -26,34 +35,55 @@ export default function DisputePage({ params }: { params: Promise<{ id: string }
const router = useRouter()
const searchParams = useSearchParams()
const order = useOrderStore((state) => state.orders.find((item) => item.id === id))
const updateOrderStatus = useOrderStore((state) => state.updateOrderStatus)
const existingDispute = mockDisputes.find((d) => d.orderId === id)
const userId = useAuthStore((state) => state.user?.id)
const userName = useAuthStore((state) => state.user?.nickname)
const existingDispute = useDisputeStore((state) => state.getDisputeByOrderId(id))
const submitDispute = useDisputeStore((state) => state.submitDispute)
const submitResponse = useDisputeStore((state) => state.submitResponse)
const submitAppeal = useDisputeStore((state) => state.submitAppeal)
const [reason, setReason] = useState("")
const [submitted, setSubmitted] = useState(false)
const [files, setFiles] = useState<string[]>([])
const [responseReason, setResponseReason] = useState("")
const [responseFiles, setResponseFiles] = useState<string[]>([])
const [appealReason, setAppealReason] = useState("")
const fileInputRef = useRef<HTMLInputElement>(null)
const responseFileInputRef = useRef<HTMLInputElement>(null)
const filesRef = useRef<string[]>([])
const responseFilesRef = useRef<string[]>([])
useEffect(() => {
filesRef.current = files
}, [files])
useEffect(() => {
responseFilesRef.current = responseFiles
}, [responseFiles])
useEffect(
() => () => {
filesRef.current.forEach((url) => {
URL.revokeObjectURL(url)
})
responseFilesRef.current.forEach((url) => {
URL.revokeObjectURL(url)
})
},
[],
)
const handleFileSelect = (event: ChangeEvent<HTMLInputElement>) => {
const handleFileSelect = (
event: ChangeEvent<HTMLInputElement>,
setter: Dispatch<SetStateAction<string[]>>,
) => {
const selectedFiles = event.target.files
if (!selectedFiles?.length) return
setFiles((prev) => {
setter((prev) => {
const remaining = 5 - prev.length
if (remaining <= 0) return prev
const nextUrls = Array.from(selectedFiles)
.slice(0, remaining)
.map((file) => URL.createObjectURL(file))
@@ -63,21 +93,31 @@ export default function DisputePage({ params }: { params: Promise<{ id: string }
event.target.value = ""
}
const handleRemoveFile = (index: number) => {
setFiles((prev) => {
const removed = prev[index]
if (removed) URL.revokeObjectURL(removed)
return prev.filter((_, currentIndex) => currentIndex !== index)
})
const removeFile = (
index: number,
filesState: string[],
setter: Dispatch<SetStateAction<string[]>>,
) => {
const removed = filesState[index]
if (removed) {
URL.revokeObjectURL(removed)
}
setter((prev) => prev.filter((_, currentIndex) => currentIndex !== index))
}
const handleSubmit = () => {
updateOrderStatus(id, "disputed")
setSubmitted(true)
if (!userId || !userName || !reason.trim()) return
submitDispute({
orderId: id,
initiatorId: userId,
initiatorName: userName,
reason,
evidence: files,
})
router.replace(`/dispute/${id}?submitted=1`)
}
const showSubmitted = submitted || searchParams.get("submitted") === "1"
const showSubmitted = searchParams.get("submitted") === "1" && !existingDispute
if (!order) {
return (
@@ -88,8 +128,15 @@ export default function DisputePage({ params }: { params: Promise<{ id: string }
}
if (existingDispute) {
const isInitiator = userId === existingDispute.initiatorId
const canRespond =
!isInitiator &&
!existingDispute.respondentReason &&
(existingDispute.status === "open" || existingDispute.status === "reviewing")
const canAppeal = existingDispute.status === "resolved" && !existingDispute.appealedAt
return (
<div className="container mx-auto py-8 px-4 max-w-lg">
<div className="container mx-auto py-8 px-4 max-w-2xl">
<Link
href={`/order/${id}`}
className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground mb-4"
@@ -105,7 +152,7 @@ export default function DisputePage({ params }: { params: Promise<{ id: string }
<Badge variant="outline">{disputeStatusLabels[existingDispute.status]}</Badge>
</div>
</CardHeader>
<CardContent className="space-y-4">
<CardContent className="space-y-5">
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm">
<FileText className="h-4 w-4 text-muted-foreground" />
@@ -118,32 +165,124 @@ export default function DisputePage({ params }: { params: Promise<{ id: string }
{new Date(existingDispute.createdAt).toLocaleString("zh-CN")}
</div>
</div>
<Separator />
<div>
<Label className="text-muted-foreground"></Label>
<p className="mt-1 text-sm">{existingDispute.reason}</p>
</div>
{existingDispute.evidence.length > 0 && (
<div>
<Label className="text-muted-foreground"></Label>
<div className="mt-1 flex gap-2">
<div className="space-y-2">
<Label className="text-muted-foreground"></Label>
<p className="text-sm">{existingDispute.reason}</p>
{existingDispute.evidence.length > 0 && (
<div className="flex gap-2 flex-wrap">
{existingDispute.evidence.map((url) => (
<div
key={url}
className="relative h-20 w-20 rounded border overflow-hidden bg-muted"
>
<Image src={url} alt="证据截图" fill className="object-cover" />
<Image src={url} alt="发起方证据" fill unoptimized className="object-cover" />
</div>
))}
</div>
</div>
)}
</div>
<Separator />
<div className="space-y-2">
<Label className="text-muted-foreground"></Label>
{existingDispute.respondentReason ? (
<>
<p className="text-sm">{existingDispute.respondentReason}</p>
{existingDispute.respondentEvidence.length > 0 && (
<div className="flex gap-2 flex-wrap">
{existingDispute.respondentEvidence.map((url) => (
<div
key={url}
className="relative h-20 w-20 rounded border overflow-hidden bg-muted"
>
<Image
src={url}
alt="对方证据"
fill
unoptimized
className="object-cover"
/>
</div>
))}
</div>
)}
</>
) : (
<p className="text-sm text-muted-foreground"></p>
)}
</div>
{canRespond && (
<>
<Separator />
<div className="space-y-3">
<Label htmlFor="response-reason"></Label>
<Textarea
id="response-reason"
value={responseReason}
onChange={(event) => setResponseReason(event.target.value)}
placeholder="请补充说明你的情况..."
rows={3}
/>
<input
ref={responseFileInputRef}
type="file"
accept="image/*"
multiple
className="hidden"
onChange={(event) => handleFileSelect(event, setResponseFiles)}
/>
<Button variant="outline" onClick={() => responseFileInputRef.current?.click()}>
<Upload className="mr-1 h-4 w-4" />
</Button>
{responseFiles.length > 0 && (
<div className="grid grid-cols-5 gap-2">
{responseFiles.map((url, index) => (
<div
key={url}
className="relative h-16 w-16 rounded border overflow-hidden bg-muted"
>
<Image
src={url}
alt={`回应证据 ${index + 1}`}
fill
unoptimized
className="object-cover"
/>
<button
type="button"
className="absolute right-0 top-0 rounded-bl bg-background/90 p-0.5"
onClick={() => removeFile(index, responseFiles, setResponseFiles)}
>
<X className="h-3 w-3" />
</button>
</div>
))}
</div>
)}
<Button
onClick={() =>
submitResponse(existingDispute.id, responseReason, responseFiles)
}
disabled={!responseReason.trim()}
>
</Button>
</div>
</>
)}
{existingDispute.result && (
<>
<Separator />
<div>
<div className="space-y-1">
<Label className="text-muted-foreground"></Label>
<p className="mt-1 text-sm font-medium">
<p className="text-sm font-medium">
{existingDispute.result === "full_refund"
? "全额退款"
: existingDispute.result === "full_payment"
@@ -153,6 +292,45 @@ export default function DisputePage({ params }: { params: Promise<{ id: string }
</div>
</>
)}
{canAppeal && (
<>
<Separator />
<div className="space-y-2">
<Label htmlFor="appeal-reason"></Label>
<Textarea
id="appeal-reason"
value={appealReason}
onChange={(event) => setAppealReason(event.target.value)}
placeholder="请说明申诉理由..."
rows={3}
/>
<Button
variant="outline"
onClick={() => submitAppeal(existingDispute.id, appealReason)}
disabled={!appealReason.trim()}
>
</Button>
</div>
</>
)}
<Separator />
<div className="space-y-2">
<Label className="text-muted-foreground">线</Label>
<div className="space-y-2">
{existingDispute.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">
{new Date(item.createdAt).toLocaleString("zh-CN")}
</span>
</div>
))}
</div>
</div>
</CardContent>
</Card>
</div>
@@ -199,7 +377,7 @@ export default function DisputePage({ params }: { params: Promise<{ id: string }
id="dispute-reason"
placeholder="请详细描述你遇到的问题..."
value={reason}
onChange={(e) => setReason(e.target.value)}
onChange={(event) => setReason(event.target.value)}
rows={4}
/>
</div>
@@ -212,7 +390,7 @@ export default function DisputePage({ params }: { params: Promise<{ id: string }
accept="image/*"
multiple
className="hidden"
onChange={handleFileSelect}
onChange={(event) => handleFileSelect(event, setFiles)}
/>
<button
type="button"
@@ -242,7 +420,7 @@ export default function DisputePage({ params }: { params: Promise<{ id: string }
<button
type="button"
className="absolute right-0 top-0 rounded-bl bg-background/90 p-0.5"
onClick={() => handleRemoveFile(index)}
onClick={() => removeFile(index, files, setFiles)}
>
<X className="h-3 w-3" />
</button>
@@ -260,7 +438,7 @@ export default function DisputePage({ params }: { params: Promise<{ id: string }
<p>· </p>
</div>
<Button className="w-full" disabled={!reason.trim()} onClick={handleSubmit}>
<Button className="w-full" disabled={!reason.trim() || !userId} onClick={handleSubmit}>
</Button>
</CardContent>