feat(dispute): support bilateral evidence, timeline, and one-time appeal
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user