605 lines
21 KiB
TypeScript
605 lines
21 KiB
TypeScript
"use client"
|
||
|
||
import { Button } from "@/components/ui/button"
|
||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||
import { EmptyState } from "@/components/ui/empty-state"
|
||
import { Label } from "@/components/ui/label"
|
||
import { Separator } from "@/components/ui/separator"
|
||
import { StatusBadge, type StatusBadgeProps } from "@/components/ui/status-badge"
|
||
import { Textarea } from "@/components/ui/textarea"
|
||
import { getOrderById, getPlayerById, uploadFile } from "@/lib/api"
|
||
import {
|
||
getDisputeByOrderId,
|
||
submitDispute,
|
||
submitDisputeAppeal,
|
||
submitDisputeResponse,
|
||
} from "@/lib/api/disputes"
|
||
import { notifyInfo } from "@/lib/toast"
|
||
import { useAuthStore } from "@/store/auth"
|
||
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"
|
||
|
||
const disputeStatusLabels: Record<string, string> = {
|
||
open: "已提交",
|
||
reviewing: "审核中",
|
||
resolved: "已解决",
|
||
appealed: "申诉中",
|
||
}
|
||
|
||
const disputeStatusVariants: Record<string, StatusBadgeProps["status"]> = {
|
||
open: "warning",
|
||
reviewing: "info",
|
||
resolved: "success",
|
||
appealed: "info",
|
||
}
|
||
|
||
function deriveMinimalTimeline(dispute: { id: string; status: string; createdAt: string }) {
|
||
const steps = [
|
||
{ status: "open", content: "争议已提交" },
|
||
{ status: "reviewing", content: "平台审核中" },
|
||
{ status: "resolved", content: "争议已解决" },
|
||
{ status: "appealed", content: "已发起申诉" },
|
||
]
|
||
|
||
const currentIndex = steps.findIndex((step) => step.status === dispute.status)
|
||
const lastIndex = currentIndex >= 0 ? currentIndex : 0
|
||
return steps.slice(0, lastIndex + 1).map((step) => ({
|
||
id: `${dispute.id}-${step.status}`,
|
||
content: step.content,
|
||
createdAt: dispute.createdAt,
|
||
}))
|
||
}
|
||
|
||
export default function DisputePage({ params }: { params: Promise<{ id: string }> }) {
|
||
const { id } = use(params)
|
||
const router = useRouter()
|
||
const searchParams = useSearchParams()
|
||
const userId = useAuthStore((state) => state.user?.id)
|
||
|
||
const [loading, setLoading] = useState(true)
|
||
const [order, setOrder] = useState<Awaited<ReturnType<typeof getOrderById>> | null>(null)
|
||
const [existingDispute, setExistingDispute] = useState<Awaited<
|
||
ReturnType<typeof getDisputeByOrderId>
|
||
> | null>(null)
|
||
const [playerUserId, setPlayerUserId] = useState<string | null>(null)
|
||
|
||
const [reason, setReason] = useState("")
|
||
const [files, setFiles] = useState<string[]>([])
|
||
const [responseReason, setResponseReason] = useState("")
|
||
const [responseFiles, setResponseFiles] = useState<string[]>([])
|
||
const [appealReason, setAppealReason] = useState("")
|
||
const [uploading, setUploading] = useState(false)
|
||
|
||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||
const responseFileInputRef = useRef<HTMLInputElement>(null)
|
||
const filesRef = useRef<string[]>([])
|
||
const responseFilesRef = useRef<string[]>([])
|
||
|
||
useEffect(() => {
|
||
let cancelled = false
|
||
|
||
const reset = () => {
|
||
setLoading(true)
|
||
setOrder(null)
|
||
setExistingDispute(null)
|
||
setPlayerUserId(null)
|
||
}
|
||
|
||
const load = async () => {
|
||
reset()
|
||
|
||
const [nextOrder, nextDispute] = await Promise.all([
|
||
Promise.resolve(getOrderById(id)),
|
||
Promise.resolve(getDisputeByOrderId(id)),
|
||
])
|
||
|
||
if (cancelled) return
|
||
setOrder(nextOrder ?? null)
|
||
setExistingDispute(nextDispute ?? null)
|
||
|
||
if (nextOrder) {
|
||
const player = await getPlayerById(String(nextOrder.playerId))
|
||
if (cancelled) return
|
||
setPlayerUserId(player?.user.id ?? null)
|
||
}
|
||
|
||
setLoading(false)
|
||
}
|
||
|
||
void load()
|
||
|
||
return () => {
|
||
cancelled = true
|
||
}
|
||
}, [id])
|
||
|
||
useEffect(() => {
|
||
filesRef.current = files
|
||
}, [files])
|
||
|
||
useEffect(() => {
|
||
responseFilesRef.current = responseFiles
|
||
}, [responseFiles])
|
||
|
||
useEffect(
|
||
() => () => {
|
||
filesRef.current = []
|
||
responseFilesRef.current = []
|
||
},
|
||
[],
|
||
)
|
||
|
||
const handleFileSelect = async (
|
||
event: ChangeEvent<HTMLInputElement>,
|
||
setter: Dispatch<SetStateAction<string[]>>,
|
||
currentFiles: string[],
|
||
) => {
|
||
const selectedFiles = event.target.files
|
||
if (!selectedFiles?.length) return
|
||
|
||
setUploading(true)
|
||
try {
|
||
const remaining = 5 - currentFiles.length
|
||
if (remaining <= 0) return
|
||
const nextUrls = await Promise.all(
|
||
Array.from(selectedFiles)
|
||
.slice(0, remaining)
|
||
.map((file) => uploadFile(file, "dispute")),
|
||
)
|
||
setter((prev) => [...prev, ...nextUrls])
|
||
} catch {
|
||
notifyInfo("证据上传失败")
|
||
} finally {
|
||
setUploading(false)
|
||
event.target.value = ""
|
||
}
|
||
}
|
||
|
||
const removeFile = (
|
||
index: number,
|
||
filesState: string[],
|
||
setter: Dispatch<SetStateAction<string[]>>,
|
||
) => {
|
||
setter((prev) => prev.filter((_, currentIndex) => currentIndex !== index))
|
||
}
|
||
|
||
const reloadDispute = async () => {
|
||
const nextDispute = await getDisputeByOrderId(id)
|
||
setExistingDispute(nextDispute ?? null)
|
||
}
|
||
|
||
const handleSubmit = () => {
|
||
if (!userId || !reason.trim()) return
|
||
|
||
void Promise.resolve(
|
||
submitDispute({
|
||
orderId: id,
|
||
reason,
|
||
evidence: files,
|
||
}),
|
||
).then((result) => {
|
||
if (!result.decision.ok) {
|
||
notifyInfo(result.decision.error.msg)
|
||
return
|
||
}
|
||
router.replace(`/dispute/${id}?submitted=1`)
|
||
})
|
||
}
|
||
|
||
const showSubmitted = !loading && searchParams.get("submitted") === "1" && !existingDispute
|
||
|
||
if (loading) {
|
||
return (
|
||
<div className="container mx-auto max-w-2xl px-4 py-8">
|
||
<EmptyState title="争议加载中" icon={Clock} />
|
||
</div>
|
||
)
|
||
}
|
||
|
||
if (!order) {
|
||
return (
|
||
<div className="container mx-auto max-w-2xl px-4 py-8">
|
||
<EmptyState title="订单不存在" description="无法找到对应订单。" icon={FileText} />
|
||
</div>
|
||
)
|
||
}
|
||
|
||
const isParticipant = Boolean(
|
||
userId && (String(order.consumerId) === userId || playerUserId === userId),
|
||
)
|
||
if (!isParticipant) {
|
||
return (
|
||
<div className="container mx-auto max-w-2xl px-4 py-8">
|
||
<EmptyState
|
||
title="无法访问争议页面"
|
||
description="仅该订单参与方可访问争议页面。"
|
||
icon={AlertTriangle}
|
||
/>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
if (existingDispute) {
|
||
const canRespond =
|
||
isParticipant &&
|
||
!existingDispute.respondentReason &&
|
||
(existingDispute.status === "open" || existingDispute.status === "reviewing")
|
||
const canAppeal =
|
||
isParticipant && existingDispute.status === "resolved" && !existingDispute.appealedAt
|
||
|
||
const timeline = deriveMinimalTimeline(existingDispute)
|
||
|
||
return (
|
||
<div className="container mx-auto max-w-2xl px-4 py-8 space-y-4">
|
||
<Link
|
||
href={`/order/${id}`}
|
||
className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground"
|
||
>
|
||
<ArrowLeft className="h-4 w-4" />
|
||
返回订单
|
||
</Link>
|
||
|
||
<Card className="border-border/80 shadow-sm">
|
||
<CardHeader>
|
||
<div className="flex items-center justify-between">
|
||
<CardTitle>争议详情</CardTitle>
|
||
<StatusBadge status={disputeStatusVariants[existingDispute.status] ?? "neutral"}>
|
||
{disputeStatusLabels[existingDispute.status]}
|
||
</StatusBadge>
|
||
</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>
|
||
发起方
|
||
</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 overflow-hidden rounded-md border border-border/60 bg-muted/30"
|
||
>
|
||
<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 overflow-hidden rounded-md border border-border/60 bg-muted/30"
|
||
>
|
||
<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) => {
|
||
void handleFileSelect(event, setResponseFiles, responseFiles)
|
||
}}
|
||
/>
|
||
<Button
|
||
variant="outline"
|
||
className="border-border/60"
|
||
onClick={() => responseFileInputRef.current?.click()}
|
||
disabled={uploading}
|
||
>
|
||
<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 overflow-hidden rounded-md border border-border/60 bg-muted/30"
|
||
>
|
||
<Image
|
||
src={url}
|
||
alt={`回应证据 ${index + 1}`}
|
||
fill
|
||
unoptimized
|
||
className="object-cover"
|
||
/>
|
||
<button
|
||
type="button"
|
||
aria-label={`移除回应证据 ${index + 1}`}
|
||
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
|
||
void Promise.resolve(
|
||
submitDisputeResponse({
|
||
disputeId: existingDispute.id,
|
||
reason: responseReason,
|
||
evidence: responseFiles,
|
||
}),
|
||
).then((decision) => {
|
||
if (!decision.ok) {
|
||
notifyInfo(decision.error.msg)
|
||
return
|
||
}
|
||
setResponseReason("")
|
||
setResponseFiles([])
|
||
return reloadDispute()
|
||
})
|
||
}}
|
||
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"
|
||
className="border-border/60"
|
||
onClick={() => {
|
||
if (!userId) return
|
||
void Promise.resolve(
|
||
submitDisputeAppeal({
|
||
disputeId: existingDispute.id,
|
||
reason: appealReason,
|
||
}),
|
||
).then((decision) => {
|
||
if (!decision.ok) {
|
||
notifyInfo(decision.error.msg)
|
||
return
|
||
}
|
||
setAppealReason("")
|
||
return reloadDispute()
|
||
})
|
||
}}
|
||
disabled={!appealReason.trim()}
|
||
>
|
||
提交申诉
|
||
</Button>
|
||
</div>
|
||
</>
|
||
)}
|
||
|
||
<Separator />
|
||
|
||
<div className="space-y-2">
|
||
<Label className="text-muted-foreground">处理时间线</Label>
|
||
<div className="rounded-lg border border-border/60">
|
||
{timeline.map((item) => (
|
||
<div
|
||
key={item.id}
|
||
className="flex items-start justify-between gap-3 border-b border-border/60 px-3 py-2 text-sm last:border-b-0"
|
||
>
|
||
<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 max-w-2xl px-4 py-8">
|
||
<EmptyState
|
||
title="争议已提交,请等待平台处理"
|
||
description="争议已提交,请等待平台处理"
|
||
icon={AlertTriangle}
|
||
action={
|
||
<Button variant="outline" className="border-border/60" asChild>
|
||
<Link href={`/order/${id}`}>返回订单详情</Link>
|
||
</Button>
|
||
}
|
||
/>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<div className="container mx-auto max-w-2xl px-4 py-8 space-y-4">
|
||
<Link
|
||
href={`/order/${id}`}
|
||
className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground"
|
||
>
|
||
<ArrowLeft className="h-4 w-4" />
|
||
返回订单
|
||
</Link>
|
||
|
||
<Card className="border-border/80 shadow-sm">
|
||
<CardHeader>
|
||
<CardTitle className="flex items-center gap-2">
|
||
<AlertTriangle className="h-5 w-5 text-warning" />
|
||
发起争议
|
||
</CardTitle>
|
||
<p className="text-sm text-muted-foreground">{order.service.title}</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) => {
|
||
void handleFileSelect(event, setFiles, files)
|
||
}}
|
||
/>
|
||
<button
|
||
type="button"
|
||
className="w-full rounded-md border-2 border-dashed border-border/60 bg-muted/20 p-6 text-center text-sm text-muted-foreground transition-colors hover:border-primary/60 hover:bg-muted/30 hover:text-foreground disabled:opacity-50"
|
||
onClick={() => fileInputRef.current?.click()}
|
||
disabled={files.length >= 5 || uploading}
|
||
>
|
||
<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 overflow-hidden rounded-md border border-border/60 bg-muted/30"
|
||
>
|
||
<Image
|
||
src={fileUrl}
|
||
alt={`证据截图 ${index + 1}`}
|
||
fill
|
||
unoptimized
|
||
className="object-cover"
|
||
/>
|
||
<button
|
||
type="button"
|
||
aria-label={`移除证据截图 ${index + 1}`}
|
||
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">
|
||
{uploading ? "证据上传中..." : "最多5张"}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="space-y-1 rounded-lg border border-border/60 bg-muted/20 p-3 text-xs text-muted-foreground">
|
||
<p>· 提交争议后,订单资金将继续托管</p>
|
||
<p>· 聊天记录将作为证据保留</p>
|
||
<p>· 平台审核完成后将给出仲裁结果</p>
|
||
<p>· 对仲裁结果不满可申诉一次</p>
|
||
</div>
|
||
|
||
<Button className="w-full" disabled={!reason.trim() || !userId} onClick={handleSubmit}>
|
||
提交争议
|
||
</Button>
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
)
|
||
}
|