Files
juwan-frontend/app/(order)/dispute/[id]/page.tsx
T
2026-03-01 22:53:48 +08:00

563 lines
19 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 { 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
const reset = () => {
setLoading(true)
setOrder(null)
setExistingDispute(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)
setLoading(false)
}
void load()
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
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 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
void 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
void 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>
)
}