555 lines
19 KiB
TypeScript
555 lines
19 KiB
TypeScript
"use client"
|
||
|
||
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 { getOrderById } from "@/lib/api"
|
||
import {
|
||
getDisputeByOrderId,
|
||
submitDispute,
|
||
submitDisputeAppeal,
|
||
submitDisputeResponse,
|
||
} from "@/lib/api/disputes"
|
||
import { DISPUTE_TO_RESOLVED_MS } from "@/lib/config/demo-timers"
|
||
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: "申诉中",
|
||
}
|
||
|
||
function deriveMinimalTimeline<TCreatedAt>(dispute: {
|
||
id: string
|
||
status: string
|
||
createdAt: TCreatedAt
|
||
timeline?: { id: string; content: string; createdAt: TCreatedAt }[]
|
||
}) {
|
||
const existing = dispute.timeline
|
||
if (existing?.length) return existing
|
||
|
||
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 [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(() => {
|
||
let cancelled = false
|
||
setLoading(true)
|
||
setOrder(null)
|
||
setExistingDispute(null)
|
||
|
||
Promise.all([Promise.resolve(getOrderById(id)), Promise.resolve(getDisputeByOrderId(id))])
|
||
.then(([nextOrder, nextDispute]) => {
|
||
if (cancelled) return
|
||
setOrder(nextOrder ?? null)
|
||
setExistingDispute(nextDispute ?? null)
|
||
})
|
||
.finally(() => {
|
||
if (cancelled) return
|
||
setLoading(false)
|
||
})
|
||
|
||
return () => {
|
||
cancelled = true
|
||
}
|
||
}, [id])
|
||
|
||
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 || !reason.trim()) return
|
||
|
||
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 py-8 px-4 text-center text-muted-foreground">加载中...</div>
|
||
)
|
||
}
|
||
|
||
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
|
||
|
||
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="hover:shadow-card-hover">
|
||
<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
|
||
Promise.resolve(
|
||
submitDisputeResponse({
|
||
disputeId: existingDispute.id,
|
||
reason: responseReason,
|
||
evidence: responseFiles,
|
||
}),
|
||
).then((decision) => {
|
||
if (!decision.ok) {
|
||
notifyInfo(decision.error.msg)
|
||
}
|
||
})
|
||
}}
|
||
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
|
||
Promise.resolve(
|
||
submitDisputeAppeal({
|
||
disputeId: existingDispute.id,
|
||
reason: appealReason,
|
||
}),
|
||
).then((decision) => {
|
||
if (!decision.ok) {
|
||
notifyInfo(decision.error.msg)
|
||
}
|
||
})
|
||
}}
|
||
disabled={!appealReason.trim()}
|
||
>
|
||
提交申诉
|
||
</Button>
|
||
</div>
|
||
</>
|
||
)}
|
||
|
||
<Separator />
|
||
|
||
<div className="space-y-2">
|
||
<Label className="text-muted-foreground">处理时间线</Label>
|
||
<div className="space-y-2">
|
||
{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 max-w-lg 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="hover:shadow-card-hover">
|
||
<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>
|
||
)
|
||
}
|