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 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>
|
)}
|
||||||
|
</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 && (
|
{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>
|
||||||
|
|||||||
@@ -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)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user