Files

605 lines
21 KiB
TypeScript
Raw Permalink 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 { 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>
)
}