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
+210 -32
View File
@@ -4,14 +4,23 @@ import { AlertTriangle, ArrowLeft, Clock, FileText, Upload, X } from "lucide-rea
import Image from "next/image" import Image from "next/image"
import Link from "next/link" import Link from "next/link"
import { useRouter, useSearchParams } from "next/navigation" 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 { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Label } from "@/components/ui/label" import { Label } from "@/components/ui/label"
import { Separator } from "@/components/ui/separator" import { Separator } from "@/components/ui/separator"
import { Textarea } from "@/components/ui/textarea" 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" import { useOrderStore } from "@/store/orders"
const disputeStatusLabels: Record<string, string> = { const disputeStatusLabels: Record<string, string> = {
@@ -26,34 +35,55 @@ export default function DisputePage({ params }: { params: Promise<{ id: string }
const router = useRouter() const router = useRouter()
const searchParams = useSearchParams() const searchParams = useSearchParams()
const order = useOrderStore((state) => state.orders.find((item) => item.id === id)) const order = useOrderStore((state) => state.orders.find((item) => item.id === id))
const updateOrderStatus = useOrderStore((state) => state.updateOrderStatus) const userId = useAuthStore((state) => state.user?.id)
const existingDispute = mockDisputes.find((d) => d.orderId === 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 [reason, setReason] = useState("")
const [submitted, setSubmitted] = useState(false)
const [files, setFiles] = useState<string[]>([]) const [files, setFiles] = useState<string[]>([])
const [responseReason, setResponseReason] = useState("")
const [responseFiles, setResponseFiles] = useState<string[]>([])
const [appealReason, setAppealReason] = useState("")
const fileInputRef = useRef<HTMLInputElement>(null) const fileInputRef = useRef<HTMLInputElement>(null)
const responseFileInputRef = useRef<HTMLInputElement>(null)
const filesRef = useRef<string[]>([]) const filesRef = useRef<string[]>([])
const responseFilesRef = useRef<string[]>([])
useEffect(() => { useEffect(() => {
filesRef.current = files filesRef.current = files
}, [files]) }, [files])
useEffect(() => {
responseFilesRef.current = responseFiles
}, [responseFiles])
useEffect( useEffect(
() => () => { () => () => {
filesRef.current.forEach((url) => { filesRef.current.forEach((url) => {
URL.revokeObjectURL(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 const selectedFiles = event.target.files
if (!selectedFiles?.length) return if (!selectedFiles?.length) return
setFiles((prev) => { setter((prev) => {
const remaining = 5 - prev.length const remaining = 5 - prev.length
if (remaining <= 0) return prev if (remaining <= 0) return prev
const nextUrls = Array.from(selectedFiles) const nextUrls = Array.from(selectedFiles)
.slice(0, remaining) .slice(0, remaining)
.map((file) => URL.createObjectURL(file)) .map((file) => URL.createObjectURL(file))
@@ -63,21 +93,31 @@ export default function DisputePage({ params }: { params: Promise<{ id: string }
event.target.value = "" event.target.value = ""
} }
const handleRemoveFile = (index: number) => { const removeFile = (
setFiles((prev) => { index: number,
const removed = prev[index] filesState: string[],
if (removed) URL.revokeObjectURL(removed) setter: Dispatch<SetStateAction<string[]>>,
return prev.filter((_, currentIndex) => currentIndex !== index) ) => {
}) const removed = filesState[index]
if (removed) {
URL.revokeObjectURL(removed)
}
setter((prev) => prev.filter((_, currentIndex) => currentIndex !== index))
} }
const handleSubmit = () => { const handleSubmit = () => {
updateOrderStatus(id, "disputed") if (!userId || !userName || !reason.trim()) return
setSubmitted(true) submitDispute({
orderId: id,
initiatorId: userId,
initiatorName: userName,
reason,
evidence: files,
})
router.replace(`/dispute/${id}?submitted=1`) router.replace(`/dispute/${id}?submitted=1`)
} }
const showSubmitted = submitted || searchParams.get("submitted") === "1" const showSubmitted = searchParams.get("submitted") === "1" && !existingDispute
if (!order) { if (!order) {
return ( return (
@@ -88,8 +128,15 @@ export default function DisputePage({ params }: { params: Promise<{ id: string }
} }
if (existingDispute) { 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 ( 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 <Link
href={`/order/${id}`} href={`/order/${id}`}
className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground mb-4" 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> <Badge variant="outline">{disputeStatusLabels[existingDispute.status]}</Badge>
</div> </div>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-5">
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center gap-2 text-sm"> <div className="flex items-center gap-2 text-sm">
<FileText className="h-4 w-4 text-muted-foreground" /> <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")} {new Date(existingDispute.createdAt).toLocaleString("zh-CN")}
</div> </div>
</div> </div>
<Separator /> <Separator />
<div>
<Label className="text-muted-foreground"></Label> <div className="space-y-2">
<p className="mt-1 text-sm">{existingDispute.reason}</p> <Label className="text-muted-foreground"></Label>
</div> <p className="text-sm">{existingDispute.reason}</p>
{existingDispute.evidence.length > 0 && ( {existingDispute.evidence.length > 0 && (
<div> <div className="flex gap-2 flex-wrap">
<Label className="text-muted-foreground"></Label>
<div className="mt-1 flex gap-2">
{existingDispute.evidence.map((url) => ( {existingDispute.evidence.map((url) => (
<div <div
key={url} key={url}
className="relative h-20 w-20 rounded border overflow-hidden bg-muted" 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>
)}
</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> </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 && ( {existingDispute.result && (
<> <>
<Separator /> <Separator />
<div> <div className="space-y-1">
<Label className="text-muted-foreground"></Label> <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_refund"
? "全额退款" ? "全额退款"
: existingDispute.result === "full_payment" : existingDispute.result === "full_payment"
@@ -153,6 +292,45 @@ export default function DisputePage({ params }: { params: Promise<{ id: string }
</div> </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> </CardContent>
</Card> </Card>
</div> </div>
@@ -199,7 +377,7 @@ export default function DisputePage({ params }: { params: Promise<{ id: string }
id="dispute-reason" id="dispute-reason"
placeholder="请详细描述你遇到的问题..." placeholder="请详细描述你遇到的问题..."
value={reason} value={reason}
onChange={(e) => setReason(e.target.value)} onChange={(event) => setReason(event.target.value)}
rows={4} rows={4}
/> />
</div> </div>
@@ -212,7 +390,7 @@ export default function DisputePage({ params }: { params: Promise<{ id: string }
accept="image/*" accept="image/*"
multiple multiple
className="hidden" className="hidden"
onChange={handleFileSelect} onChange={(event) => handleFileSelect(event, setFiles)}
/> />
<button <button
type="button" type="button"
@@ -242,7 +420,7 @@ export default function DisputePage({ params }: { params: Promise<{ id: string }
<button <button
type="button" type="button"
className="absolute right-0 top-0 rounded-bl bg-background/90 p-0.5" 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" /> <X className="h-3 w-3" />
</button> </button>
@@ -260,7 +438,7 @@ export default function DisputePage({ params }: { params: Promise<{ id: string }
<p>· </p> <p>· </p>
</div> </div>
<Button className="w-full" disabled={!reason.trim()} onClick={handleSubmit}> <Button className="w-full" disabled={!reason.trim() || !userId} onClick={handleSubmit}>
</Button> </Button>
</CardContent> </CardContent>
+222
View File
@@ -0,0 +1,222 @@
import { create } from "zustand"
import { generateId } from "@/lib/id"
import { mockDisputes } from "@/lib/mock"
import type { Dispute } from "@/lib/types"
import { useOrderStore } from "@/store/orders"
type DisputeTimelineType = "created" | "response" | "reviewing" | "resolved" | "appealed"
interface DisputeTimelineItem {
id: string
type: DisputeTimelineType
content: string
createdAt: string
}
export interface DisputeRecord extends Dispute {
respondentReason?: string
respondentEvidence: string[]
appealReason?: string
appealedAt?: string
timeline: DisputeTimelineItem[]
}
interface SubmitDisputeInput {
orderId: string
initiatorId: string
initiatorName: string
reason: string
evidence: string[]
}
interface DisputeState {
disputes: DisputeRecord[]
getDisputeByOrderId: (orderId: string) => DisputeRecord | undefined
submitDispute: (input: SubmitDisputeInput) => DisputeRecord
submitResponse: (disputeId: string, reason: string, evidence: string[]) => void
submitAppeal: (disputeId: string, reason: string) => void
}
const progressTimers = new Map<string, ReturnType<typeof setTimeout>[]>()
function clearProgressTimers(disputeId: string) {
const timers = progressTimers.get(disputeId)
if (!timers) return
timers.forEach((timer) => {
clearTimeout(timer)
})
progressTimers.delete(disputeId)
}
function asRecord(dispute: Dispute): DisputeRecord {
const timeline: DisputeTimelineItem[] = [
{
id: generateId("timeline"),
type: "created",
content: `${dispute.initiatorName} 提交争议`,
createdAt: dispute.createdAt,
},
]
if (dispute.status === "reviewing") {
timeline.push({
id: generateId("timeline"),
type: "reviewing",
content: "平台已受理并进入审核",
createdAt: dispute.createdAt,
})
}
if (dispute.status === "resolved") {
timeline.push({
id: generateId("timeline"),
type: "resolved",
content: "平台已给出仲裁结果",
createdAt: dispute.createdAt,
})
}
return {
...dispute,
respondentEvidence: [],
timeline,
}
}
export const useDisputeStore = create<DisputeState>((set, get) => {
const scheduleProgress = (disputeId: string) => {
clearProgressTimers(disputeId)
const toReviewing = setTimeout(() => {
set((state) => ({
disputes: state.disputes.map((dispute) => {
if (dispute.id !== disputeId) return dispute
if (dispute.status !== "open" && dispute.status !== "appealed") return dispute
return {
...dispute,
status: "reviewing",
timeline: [
...dispute.timeline,
{
id: generateId("timeline"),
type: "reviewing",
content: "平台已受理并进入审核",
createdAt: new Date().toISOString(),
},
],
}
}),
}))
}, 5000)
const toResolved = setTimeout(() => {
set((state) => ({
disputes: state.disputes.map((dispute) => {
if (dispute.id !== disputeId) return dispute
if (dispute.status !== "reviewing" && dispute.status !== "appealed") return dispute
return {
...dispute,
status: "resolved",
result: dispute.result ?? "partial_refund",
timeline: [
...dispute.timeline,
{
id: generateId("timeline"),
type: "resolved",
content: "平台已给出仲裁结果",
createdAt: new Date().toISOString(),
},
],
}
}),
}))
clearProgressTimers(disputeId)
}, 10000)
progressTimers.set(disputeId, [toReviewing, toResolved])
}
return {
disputes: mockDisputes.map(asRecord),
getDisputeByOrderId: (orderId) => get().disputes.find((dispute) => dispute.orderId === orderId),
submitDispute: (input) => {
const createdAt = new Date().toISOString()
const dispute: DisputeRecord = {
id: generateId("disp"),
orderId: input.orderId,
initiatorId: input.initiatorId,
initiatorName: input.initiatorName,
reason: input.reason.trim(),
evidence: input.evidence,
status: "open",
createdAt,
respondentEvidence: [],
timeline: [
{
id: generateId("timeline"),
type: "created",
content: `${input.initiatorName} 提交争议`,
createdAt,
},
],
}
set((state) => ({ disputes: [dispute, ...state.disputes] }))
useOrderStore.getState().updateOrderStatus(input.orderId, "disputed")
scheduleProgress(dispute.id)
return dispute
},
submitResponse: (disputeId, reason, evidence) => {
if (!reason.trim()) return
set((state) => ({
disputes: state.disputes.map((dispute) => {
if (dispute.id !== disputeId || dispute.respondentReason) return dispute
return {
...dispute,
respondentReason: reason.trim(),
respondentEvidence: evidence,
timeline: [
...dispute.timeline,
{
id: generateId("timeline"),
type: "response",
content: "对方已提交回应材料",
createdAt: new Date().toISOString(),
},
],
}
}),
}))
},
submitAppeal: (disputeId, reason) => {
if (!reason.trim()) return
set((state) => ({
disputes: state.disputes.map((dispute) => {
if (dispute.id !== disputeId || dispute.appealedAt || dispute.status !== "resolved") {
return dispute
}
return {
...dispute,
status: "appealed",
appealReason: reason.trim(),
appealedAt: new Date().toISOString(),
timeline: [
...dispute.timeline,
{
id: generateId("timeline"),
type: "appealed",
content: "已提交申诉,平台将复核",
createdAt: new Date().toISOString(),
},
],
}
}),
}))
scheduleProgress(disputeId)
},
}
})