feat: add interactive dispute evidence upload and sealed review mechanism

This commit is contained in:
zetaloop
2026-02-20 22:47:33 +08:00
parent 86ece33c00
commit a5780c8393
4 changed files with 121 additions and 24 deletions
+6 -2
View File
@@ -9,7 +9,7 @@ import { Card, CardContent, CardHeader } from "@/components/ui/card"
import { Separator } from "@/components/ui/separator"
import { Textarea } from "@/components/ui/textarea"
import { roleLabels } from "@/lib/constants"
import { mockComments, mockOrders, mockPosts } from "@/lib/mock-data"
import { mockComments, mockOrders, mockPlayers, mockPosts } from "@/lib/mock-data"
export default async function PostDetailPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params
@@ -20,6 +20,9 @@ export default async function PostDetailPage({ params }: { params: Promise<{ id:
const linkedOrder = post.linkedOrderId
? mockOrders.find((o) => o.id === post.linkedOrderId)
: null
const linkedPlayer = linkedOrder
? mockPlayers.find((player) => player.id === linkedOrder.playerId)
: null
return (
<div className="container mx-auto py-8 px-4 max-w-2xl">
@@ -74,7 +77,8 @@ export default async function PostDetailPage({ params }: { params: Promise<{ id:
<span className="font-medium"></span>
</div>
<p className="text-muted-foreground text-xs">
{linkedOrder.service.title} · {linkedOrder.playerName} · ¥{linkedOrder.totalPrice}
{linkedOrder.service.gameName} · {linkedOrder.service.title} · {" "}
{linkedPlayer?.rating ?? "--"}
</p>
</div>
</Link>
+104 -14
View File
@@ -1,9 +1,10 @@
"use client"
import { AlertTriangle, ArrowLeft, Clock, FileText } from "lucide-react"
import { AlertTriangle, ArrowLeft, Clock, FileText, Upload, X } from "lucide-react"
import Image from "next/image"
import Link from "next/link"
import { use, useState } from "react"
import { useRouter, useSearchParams } from "next/navigation"
import { type ChangeEvent, 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"
@@ -21,10 +22,59 @@ const disputeStatusLabels: Record<string, string> = {
export default function DisputePage({ params }: { params: Promise<{ id: string }> }) {
const { id } = use(params)
const router = useRouter()
const searchParams = useSearchParams()
const order = mockOrders.find((o) => o.id === id)
const existingDispute = mockDisputes.find((d) => d.orderId === id)
const [reason, setReason] = useState("")
const [submitted, setSubmitted] = useState(false)
const [files, setFiles] = useState<string[]>([])
const fileInputRef = useRef<HTMLInputElement>(null)
const filesRef = useRef<string[]>([])
useEffect(() => {
filesRef.current = files
}, [files])
useEffect(
() => () => {
filesRef.current.forEach((url) => {
URL.revokeObjectURL(url)
})
},
[],
)
const handleFileSelect = (event: ChangeEvent<HTMLInputElement>) => {
const selectedFiles = event.target.files
if (!selectedFiles?.length) return
setFiles((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 handleRemoveFile = (index: number) => {
setFiles((prev) => {
const removed = prev[index]
if (removed) URL.revokeObjectURL(removed)
return prev.filter((_, currentIndex) => currentIndex !== index)
})
}
const handleSubmit = () => {
setSubmitted(true)
router.replace(`/dispute/${id}?submitted=1`)
}
const showSubmitted = submitted || searchParams.get("submitted") === "1"
if (!order) {
return (
@@ -106,17 +156,15 @@ export default function DisputePage({ params }: { params: Promise<{ id: string }
)
}
if (submitted) {
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">
3 使
</p>
<Button asChild>
<Link href={`/order/${id}`}></Link>
</Button>
<h2 className="text-xl font-bold"></h2>
<p className="text-sm text-muted-foreground"> 3 </p>
<Link href={`/order/${id}`} className="text-sm text-primary hover:underline">
</Link>
</div>
)
}
@@ -155,9 +203,51 @@ export default function DisputePage({ params }: { params: Promise<{ id: string }
<div className="space-y-2">
<Label></Label>
<div className="border-2 border-dashed rounded-md p-6 text-center text-sm text-muted-foreground">
5
</div>
<input
ref={fileInputRef}
type="file"
accept="image/*"
multiple
className="hidden"
onChange={handleFileSelect}
/>
<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={() => handleRemoveFile(index)}
>
<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">
@@ -167,7 +257,7 @@ export default function DisputePage({ params }: { params: Promise<{ id: string }
<p>· </p>
</div>
<Button className="w-full" disabled={!reason.trim()} onClick={() => setSubmitted(true)}>
<Button className="w-full" disabled={!reason.trim()} onClick={handleSubmit}>
</Button>
</CardContent>
+6 -2
View File
@@ -174,8 +174,12 @@ export default async function OrderDetailPage({ params }: { params: Promise<{ id
))}
</div>
</div>
{review.content && (
<p className="text-sm text-muted-foreground">{review.content}</p>
{review.sealed ? (
<p className="text-sm text-muted-foreground"></p>
) : (
review.content && (
<p className="text-sm text-muted-foreground">{review.content}</p>
)
)}
<p className="text-xs text-muted-foreground">
{new Date(review.createdAt).toLocaleDateString("zh-CN")}
+5 -6
View File
@@ -30,12 +30,11 @@ export default function ReviewPage({ params }: { params: Promise<{ id: string }>
<div className="container mx-auto py-8 px-4 max-w-lg text-center space-y-4">
<Lock className="h-12 w-12 mx-auto text-muted-foreground" />
<h2 className="text-xl font-bold"></h2>
<p className="text-sm text-muted-foreground">
</p>
<Button asChild>
<Link href={`/order/${id}`}></Link>
</Button>
<p className="text-sm text-muted-foreground"></p>
<p className="text-sm text-muted-foreground"></p>
<Link href={`/order/${id}`} className="text-sm text-primary hover:underline">
</Link>
</div>
)
}