Files
juwan-frontend/app/(order)/dispute/[id]/page.tsx
T

485 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client"
import { AlertTriangle, ArrowLeft, Clock, FileText, Upload, X } from "lucide-react"
import Image from "next/image"
import Link from "next/link"
import { useRouter, useSearchParams } from "next/navigation"
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 { DISPUTE_TO_RESOLVED_MS } from "@/lib/config/demo-timers"
import { notifyInfo } from "@/lib/toast"
import { Label } from "@/components/ui/label"
import { Separator } from "@/components/ui/separator"
import { Textarea } from "@/components/ui/textarea"
import { useAuthStore } from "@/store/auth"
import { useDisputeStore } from "@/store/disputes"
import { useOrderStore } from "@/store/orders"
const disputeStatusLabels: Record<string, string> = {
open: "已提交",
reviewing: "审核中",
resolved: "已解决",
appealed: "申诉中",
}
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 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 [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>,
setter: Dispatch<SetStateAction<string[]>>,
) => {
const selectedFiles = event.target.files
if (!selectedFiles?.length) return
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))
return [...prev, ...nextUrls]
})
event.target.value = ""
}
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 = () => {
if (!userId || !userName || !reason.trim()) return
const result = submitDispute({
orderId: id,
initiatorId: userId,
initiatorName: userName,
reason,
evidence: files,
})
if (!result.decision.ok) {
notifyInfo(result.decision.message ?? "提交争议失败")
return
}
router.replace(`/dispute/${id}?submitted=1`)
}
const showSubmitted = searchParams.get("submitted") === "1" && !existingDispute
if (!order) {
return (
<div className="container mx-auto py-8 px-4 text-center text-muted-foreground">
</div>
)
}
const isParticipant = Boolean(
userId && (order.consumerId === userId || order.playerId === userId),
)
if (!isParticipant) {
return (
<div className="container mx-auto py-8 px-4 max-w-lg text-center text-muted-foreground">
访
</div>
)
}
if (existingDispute) {
const isInitiator = userId === existingDispute.initiatorId
const canRespond =
isParticipant &&
!isInitiator &&
!existingDispute.respondentReason &&
(existingDispute.status === "open" || existingDispute.status === "reviewing")
const canAppeal =
isParticipant && existingDispute.status === "resolved" && !existingDispute.appealedAt
return (
<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"
>
<ArrowLeft className="h-4 w-4" />
</Link>
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle></CardTitle>
<Badge variant="outline">{disputeStatusLabels[existingDispute.status]}</Badge>
</div>
</CardHeader>
<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" />
<span className="text-muted-foreground">:</span>
{existingDispute.initiatorName}
</div>
<div className="flex items-center gap-2 text-sm">
<Clock className="h-4 w-4 text-muted-foreground" />
<span className="text-muted-foreground">:</span>
{new Date(existingDispute.createdAt).toLocaleString("zh-CN")}
</div>
</div>
<Separator />
<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 unoptimized className="object-cover" />
</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={() => {
if (!userId) return
const decision = submitResponse(
existingDispute.id,
userId,
responseReason,
responseFiles,
)
if (!decision.ok) {
notifyInfo(decision.message ?? "提交回应失败")
}
}}
disabled={!responseReason.trim()}
>
</Button>
</div>
</>
)}
{existingDispute.result && (
<>
<Separator />
<div className="space-y-1">
<Label className="text-muted-foreground"></Label>
<p className="text-sm font-medium">
{existingDispute.result === "full_refund"
? "全额退款"
: existingDispute.result === "full_payment"
? "全额支付给打手"
: "部分退款"}
</p>
</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={() => {
if (!userId) return
const decision = submitAppeal(existingDispute.id, userId, appealReason)
if (!decision.ok) {
notifyInfo(decision.message ?? "提交申诉失败")
}
}}
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>
)
}
if (showSubmitted) {
return (
<div className="container mx-auto py-8 px-4 max-w-lg text-center space-y-4">
<AlertTriangle className="h-12 w-12 mx-auto text-yellow-500" />
<h2 className="text-xl font-bold"></h2>
<p className="text-sm text-muted-foreground">
{Math.floor(DISPUTE_TO_RESOLVED_MS / 1000)}
</p>
<Link href={`/order/${id}`} className="text-sm text-primary hover:underline">
</Link>
</div>
)
}
return (
<div className="container mx-auto py-8 px-4 max-w-lg">
<Link
href={`/order/${id}`}
className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground mb-4"
>
<ArrowLeft className="h-4 w-4" />
</Link>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<AlertTriangle className="h-5 w-5 text-yellow-500" />
</CardTitle>
<p className="text-sm text-muted-foreground">
{order.service.title} · {order.playerName}
</p>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="dispute-reason"></Label>
<Textarea
id="dispute-reason"
placeholder="请详细描述你遇到的问题..."
value={reason}
onChange={(event) => setReason(event.target.value)}
rows={4}
/>
</div>
<div className="space-y-2">
<Label></Label>
<input
ref={fileInputRef}
type="file"
accept="image/*"
multiple
className="hidden"
onChange={(event) => handleFileSelect(event, setFiles)}
/>
<button
type="button"
className="w-full border-2 border-dashed rounded-md p-6 text-center text-sm text-muted-foreground hover:border-primary/50 hover:text-foreground transition-colors disabled:opacity-50"
onClick={() => fileInputRef.current?.click()}
disabled={files.length >= 5}
>
<div className="flex flex-col items-center gap-2">
<Upload className="h-5 w-5" />
<span>5</span>
</div>
</button>
{files.length > 0 && (
<div className="grid grid-cols-5 gap-2">
{files.map((fileUrl, index) => (
<div
key={fileUrl}
className="relative h-16 w-16 rounded border overflow-hidden bg-muted"
>
<Image
src={fileUrl}
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, files, setFiles)}
>
<X className="h-3 w-3" />
</button>
</div>
))}
</div>
)}
<div className="text-xs text-muted-foreground">5</div>
</div>
<div className="rounded-md bg-muted/50 p-3 text-xs text-muted-foreground space-y-1">
<p>· </p>
<p>· </p>
<p>· {Math.floor(DISPUTE_TO_RESOLVED_MS / 1000)} </p>
<p>· </p>
</div>
<Button className="w-full" disabled={!reason.trim() || !userId} onClick={handleSubmit}>
</Button>
</CardContent>
</Card>
</div>
)
}