feat: search, player detail, shop detail, order flow, chat, review, and dispute pages
This commit is contained in:
@@ -1,8 +1,211 @@
|
||||
export default function PlayerDetailPage({ params: _params }: { params: Promise<{ id: string }> }) {
|
||||
import { CheckCircle, Clock, MapPin, MessageSquare, ShoppingBag, Star } from "lucide-react"
|
||||
import Link from "next/link"
|
||||
import { notFound } from "next/navigation"
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import { mockPlayers, mockReviews, mockServices } from "@/lib/mock-data"
|
||||
|
||||
export default async function PlayerDetailPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params
|
||||
const player = mockPlayers.find((p) => p.id === id)
|
||||
|
||||
if (!player) {
|
||||
notFound()
|
||||
}
|
||||
|
||||
const playerReviews = mockReviews.filter((r) => r.toUserId === player.id)
|
||||
const playerServices =
|
||||
player.services && player.services.length > 0
|
||||
? player.services
|
||||
: mockServices.filter((s) => s.playerId === player.id)
|
||||
|
||||
return (
|
||||
<div className="container mx-auto py-8 px-4">
|
||||
<h1 className="text-2xl font-bold">打手详情</h1>
|
||||
<p className="mt-2 text-muted-foreground">评分、服务列表、评价、所属店铺</p>
|
||||
<div className="container mx-auto py-8 px-4 max-w-5xl">
|
||||
<div className="flex flex-col md:flex-row gap-8 mb-8">
|
||||
<div className="flex-shrink-0">
|
||||
<Avatar className="w-32 h-32 border-4 border-background shadow-lg">
|
||||
<AvatarImage src={player.user.avatar} alt={player.user.nickname} />
|
||||
<AvatarFallback>{player.user.nickname[0]}</AvatarFallback>
|
||||
</Avatar>
|
||||
</div>
|
||||
|
||||
<div className="flex-grow space-y-4">
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold flex items-center gap-3">
|
||||
{player.user.nickname}
|
||||
<Badge
|
||||
variant={player.status === "available" ? "default" : "secondary"}
|
||||
className="text-sm"
|
||||
>
|
||||
{player.status === "available" ? "可接单" : "忙碌中"}
|
||||
</Badge>
|
||||
</h1>
|
||||
<div className="flex items-center gap-2 mt-2 text-muted-foreground">
|
||||
<div className="flex items-center text-yellow-500">
|
||||
<Star className="w-4 h-4 fill-current" />
|
||||
<span className="ml-1 font-medium text-foreground">{player.rating}</span>
|
||||
</div>
|
||||
<Separator orientation="vertical" className="h-4" />
|
||||
<span>接单 {player.totalOrders}</span>
|
||||
<Separator orientation="vertical" className="h-4" />
|
||||
<span>完成率 {(player.completionRate * 100).toFixed(0)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{player.shopId && (
|
||||
<Link href={`/shop/${player.shopId}`}>
|
||||
<Button variant="outline" className="gap-2">
|
||||
<ShoppingBag className="w-4 h-4" />
|
||||
所属店铺: {player.shopName}
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="bg-muted/50 p-4 rounded-lg">
|
||||
<p className="text-sm leading-relaxed">
|
||||
{player.user.bio || "这个打手很懒,什么都没写~"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{player.tags.map((tag) => (
|
||||
<Badge key={tag} variant="secondary" className="px-3 py-1">
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="services" className="space-y-6">
|
||||
<TabsList className="w-full justify-start border-b rounded-none h-auto p-0 bg-transparent">
|
||||
<TabsTrigger
|
||||
value="services"
|
||||
className="rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent px-6 py-3 text-base"
|
||||
>
|
||||
服务列表 ({playerServices.length})
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="reviews"
|
||||
className="rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent px-6 py-3 text-base"
|
||||
>
|
||||
评价 ({playerReviews.length})
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="services" className="mt-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{playerServices.map((service) => (
|
||||
<Card
|
||||
key={service.id}
|
||||
className="flex flex-col h-full hover:shadow-md transition-shadow"
|
||||
>
|
||||
<CardHeader>
|
||||
<div className="flex justify-between items-start">
|
||||
<Badge variant="outline">{service.gameName}</Badge>
|
||||
<div className="text-lg font-bold text-primary">
|
||||
¥{service.price}{" "}
|
||||
<span className="text-sm text-muted-foreground font-normal">
|
||||
/ {service.unit}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<CardTitle className="mt-2 text-xl">{service.title}</CardTitle>
|
||||
<CardDescription className="line-clamp-2 mt-1">
|
||||
{service.description}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex-grow space-y-3 text-sm">
|
||||
{service.rankRange && (
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<MapPin className="w-4 h-4" />
|
||||
<span>段位: {service.rankRange}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-start gap-2 text-muted-foreground">
|
||||
<Clock className="w-4 h-4 mt-0.5" />
|
||||
<div className="flex flex-col">
|
||||
{service.availability.map((time) => (
|
||||
<span key={time}>{time}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<Button className="w-full">立即下单</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
))}
|
||||
{playerServices.length === 0 && (
|
||||
<div className="col-span-full text-center py-12 text-muted-foreground">
|
||||
<p>暂无服务</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="reviews" className="mt-6">
|
||||
<div className="space-y-6">
|
||||
{playerReviews.length > 0 ? (
|
||||
playerReviews.map((review) => (
|
||||
<Card key={review.id}>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-start gap-4">
|
||||
<Avatar>
|
||||
<AvatarImage src={review.fromUserAvatar} alt={review.fromUserName} />
|
||||
<AvatarFallback>{review.fromUserName[0]}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex-grow space-y-2">
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<div className="font-medium">{review.fromUserName}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{new Date(review.createdAt).toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center text-yellow-500">
|
||||
{[1, 2, 3, 4, 5].map((star) => (
|
||||
<Star
|
||||
key={star}
|
||||
className={`w-4 h-4 ${star <= review.rating ? "fill-current" : "text-muted stroke-muted-foreground"}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-foreground/90">{review.content}</p>
|
||||
{review.sealed && (
|
||||
<div className="flex items-center gap-1 text-xs text-green-600 bg-green-50 w-fit px-2 py-1 rounded">
|
||||
<CheckCircle className="w-3 h-3" />
|
||||
<span>平台认证评价</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))
|
||||
) : (
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
<MessageSquare className="w-12 h-12 mx-auto mb-4 opacity-20" />
|
||||
<p>暂无评价</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
+571
-4
@@ -1,8 +1,575 @@
|
||||
export default function SearchPage() {
|
||||
"use client"
|
||||
|
||||
import {
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
Filter,
|
||||
Gamepad2,
|
||||
Search,
|
||||
SlidersHorizontal,
|
||||
Star,
|
||||
Store,
|
||||
User,
|
||||
XCircle,
|
||||
} from "lucide-react"
|
||||
import Link from "next/link"
|
||||
import { useRouter, useSearchParams } from "next/navigation"
|
||||
import { Suspense, useEffect, useMemo, useState } from "react"
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent, CardFooter, CardHeader } from "@/components/ui/card"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import {
|
||||
Sheet,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
} from "@/components/ui/sheet"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { mockGames, mockPlayers } from "@/lib/mock-data"
|
||||
import type { Player } from "@/lib/types"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function StatusBadge({ status }: { status: Player["status"] }) {
|
||||
switch (status) {
|
||||
case "available":
|
||||
return (
|
||||
<Badge variant="outline" className="bg-green-50 text-green-700 border-green-200 gap-1">
|
||||
<CheckCircle2 className="w-3 h-3" />
|
||||
可接单
|
||||
</Badge>
|
||||
)
|
||||
case "busy":
|
||||
return (
|
||||
<Badge variant="outline" className="bg-yellow-50 text-yellow-700 border-yellow-200 gap-1">
|
||||
<Clock className="w-3 h-3" />
|
||||
忙碌中
|
||||
</Badge>
|
||||
)
|
||||
case "offline":
|
||||
return (
|
||||
<Badge variant="outline" className="bg-gray-50 text-gray-500 border-gray-200 gap-1">
|
||||
<XCircle className="w-3 h-3" />
|
||||
离线
|
||||
</Badge>
|
||||
)
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function PlayerCard({ player }: { player: Player }) {
|
||||
const minPrice = useMemo(() => {
|
||||
if (!player.services || player.services.length === 0) return 0
|
||||
return Math.min(...player.services.map((s) => s.price))
|
||||
}, [player.services])
|
||||
|
||||
const unit = useMemo(() => {
|
||||
if (!player.services || player.services.length === 0) return "局"
|
||||
const cheapestService = player.services.reduce((prev, curr) =>
|
||||
prev.price < curr.price ? prev : curr,
|
||||
)
|
||||
return cheapestService.unit
|
||||
}, [player.services])
|
||||
|
||||
return (
|
||||
<div className="container mx-auto py-8 px-4">
|
||||
<h1 className="text-2xl font-bold">搜索</h1>
|
||||
<p className="mt-2 text-muted-foreground">搜索打手、店铺和服务</p>
|
||||
<Link href={`/player/${player.id}`} className="block h-full">
|
||||
<Card className="h-full hover:shadow-md transition-shadow duration-200 overflow-hidden flex flex-col">
|
||||
<CardHeader className="p-4 pb-2 flex flex-row items-start justify-between space-y-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<Avatar className="h-12 w-12 border-2 border-white shadow-sm">
|
||||
<AvatarImage src={player.user.avatar} alt={player.user.nickname} />
|
||||
<AvatarFallback>{player.user.nickname.slice(0, 2)}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div>
|
||||
<h3 className="font-semibold text-base leading-none mb-1">{player.user.nickname}</h3>
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
{player.shopName ? (
|
||||
<span className="flex items-center gap-0.5 text-blue-600 bg-blue-50 px-1.5 py-0.5 rounded">
|
||||
<Store className="w-3 h-3" />
|
||||
{player.shopName}
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-0.5 text-gray-600 bg-gray-100 px-1.5 py-0.5 rounded">
|
||||
<User className="w-3 h-3" />
|
||||
个人
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<StatusBadge status={player.status} />
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="p-4 pt-2 flex-grow">
|
||||
<div className="flex items-center gap-4 mb-3 text-sm">
|
||||
<div className="flex items-center gap-1 text-yellow-500 font-medium">
|
||||
<Star className="w-4 h-4 fill-current" />
|
||||
{player.rating.toFixed(1)}
|
||||
</div>
|
||||
<div className="text-muted-foreground">接单 {player.totalOrders}</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-1.5 mb-3">
|
||||
{player.games.slice(0, 3).map((game) => (
|
||||
<Badge key={game} variant="secondary" className="text-xs font-normal">
|
||||
{game}
|
||||
</Badge>
|
||||
))}
|
||||
{player.games.length > 3 && (
|
||||
<Badge variant="secondary" className="text-xs font-normal">
|
||||
+{player.games.length - 3}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{player.tags.slice(0, 2).map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="text-[10px] px-1.5 py-0.5 bg-muted rounded text-muted-foreground"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
<Separator />
|
||||
|
||||
<CardFooter className="p-3 bg-muted/20 flex items-center justify-between">
|
||||
<div className="flex items-baseline gap-1">
|
||||
<span className="text-xs text-muted-foreground">起</span>
|
||||
<span className="text-lg font-bold text-primary">¥{minPrice}</span>
|
||||
<span className="text-xs text-muted-foreground">/{unit}</span>
|
||||
</div>
|
||||
<Button size="sm" variant="ghost" className="h-8 px-3 text-xs">
|
||||
查看详情
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
interface FilterProps {
|
||||
selectedGames: string[]
|
||||
onGameChange: (game: string, checked: boolean) => void
|
||||
priceRange: { min: string; max: string }
|
||||
onPriceChange: (type: "min" | "max", value: string) => void
|
||||
onlyOnline: boolean
|
||||
onOnlineChange: (checked: boolean) => void
|
||||
minRating: string
|
||||
onRatingChange: (value: string) => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
function FilterSection({
|
||||
selectedGames,
|
||||
onGameChange,
|
||||
priceRange,
|
||||
onPriceChange,
|
||||
onlyOnline,
|
||||
onOnlineChange,
|
||||
minRating,
|
||||
onRatingChange,
|
||||
className,
|
||||
}: FilterProps) {
|
||||
return (
|
||||
<div className={cn("space-y-6", className)}>
|
||||
<div className="space-y-3">
|
||||
<h3 className="font-medium text-sm flex items-center gap-2">
|
||||
<Gamepad2 className="w-4 h-4" />
|
||||
游戏类型
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{mockGames.map((game) => (
|
||||
<div key={game.id} className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id={`game-${game.id}`}
|
||||
checked={selectedGames.includes(game.name)}
|
||||
onCheckedChange={(checked) => onGameChange(game.name, checked as boolean)}
|
||||
/>
|
||||
<Label
|
||||
htmlFor={`game-${game.id}`}
|
||||
className="text-sm font-normal cursor-pointer flex items-center gap-2"
|
||||
>
|
||||
<span>{game.icon}</span>
|
||||
{game.name}
|
||||
</Label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-3">
|
||||
<h3 className="font-medium text-sm">价格区间 (元)</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="最低"
|
||||
className="h-8 text-sm"
|
||||
value={priceRange.min}
|
||||
onChange={(e) => onPriceChange("min", e.target.value)}
|
||||
min={0}
|
||||
/>
|
||||
<span className="text-muted-foreground">-</span>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="最高"
|
||||
className="h-8 text-sm"
|
||||
value={priceRange.max}
|
||||
onChange={(e) => onPriceChange("max", e.target.value)}
|
||||
min={0}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="online-mode" className="font-medium text-sm cursor-pointer">
|
||||
只看在线
|
||||
</Label>
|
||||
<Switch id="online-mode" checked={onlyOnline} onCheckedChange={onOnlineChange} />
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-3">
|
||||
<h3 className="font-medium text-sm flex items-center gap-2">
|
||||
<Star className="w-4 h-4" />
|
||||
最低评分
|
||||
</h3>
|
||||
<Select value={minRating} onValueChange={onRatingChange}>
|
||||
<SelectTrigger className="h-9">
|
||||
<SelectValue placeholder="选择评分" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="0">不限</SelectItem>
|
||||
<SelectItem value="4.0">4.0分以上</SelectItem>
|
||||
<SelectItem value="4.5">4.5分以上</SelectItem>
|
||||
<SelectItem value="4.8">4.8分以上</SelectItem>
|
||||
<SelectItem value="5.0">5.0分满分</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SearchPageContent() {
|
||||
const searchParams = useSearchParams()
|
||||
const router = useRouter()
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState(searchParams.get("q") || "")
|
||||
const [selectedGames, setSelectedGames] = useState<string[]>([])
|
||||
const [priceRange, setPriceRange] = useState<{ min: string; max: string }>({ min: "", max: "" })
|
||||
const [onlyOnline, setOnlyOnline] = useState(false)
|
||||
const [minRating, setMinRating] = useState("0")
|
||||
const [sortBy, setSortBy] = useState("composite")
|
||||
const [visibleCount, setVisibleCount] = useState(12)
|
||||
|
||||
useEffect(() => {
|
||||
const q = searchParams.get("q")
|
||||
if (q !== null && q !== searchQuery) {
|
||||
setSearchQuery(q)
|
||||
}
|
||||
}, [searchParams, searchQuery])
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
const params = new URLSearchParams(searchParams.toString())
|
||||
if (searchQuery) {
|
||||
params.set("q", searchQuery)
|
||||
} else {
|
||||
params.delete("q")
|
||||
}
|
||||
router.replace(`/search?${params.toString()}`, { scroll: false })
|
||||
}, 500)
|
||||
return () => clearTimeout(timer)
|
||||
}, [searchQuery, router, searchParams])
|
||||
|
||||
const handleGameChange = (game: string, checked: boolean) => {
|
||||
setSelectedGames((prev) => (checked ? [...prev, game] : prev.filter((g) => g !== game)))
|
||||
}
|
||||
|
||||
const handlePriceChange = (type: "min" | "max", value: string) => {
|
||||
setPriceRange((prev) => ({ ...prev, [type]: value }))
|
||||
}
|
||||
|
||||
const filteredPlayers = useMemo(() => {
|
||||
return mockPlayers.filter((player) => {
|
||||
if (searchQuery) {
|
||||
const query = searchQuery.toLowerCase()
|
||||
const matchName = player.user.nickname.toLowerCase().includes(query)
|
||||
const matchTags = player.tags.some((tag) => tag.toLowerCase().includes(query))
|
||||
const matchGames = player.games.some((game) => game.toLowerCase().includes(query))
|
||||
if (!matchName && !matchTags && !matchGames) return false
|
||||
}
|
||||
|
||||
if (selectedGames.length > 0) {
|
||||
const hasGame = player.games.some((game) => selectedGames.includes(game))
|
||||
if (!hasGame) return false
|
||||
}
|
||||
|
||||
const minP = priceRange.min ? Number(priceRange.min) : 0
|
||||
const maxP = priceRange.max ? Number(priceRange.max) : Infinity
|
||||
const playerMinPrice = Math.min(...(player.services?.map((s) => s.price) || [0]))
|
||||
|
||||
if (playerMinPrice < minP) return false
|
||||
if (priceRange.max && playerMinPrice > maxP) return false
|
||||
|
||||
if (onlyOnline && player.status !== "available") return false
|
||||
|
||||
if (player.rating < Number(minRating)) return false
|
||||
|
||||
return true
|
||||
})
|
||||
}, [searchQuery, selectedGames, priceRange, onlyOnline, minRating])
|
||||
|
||||
const sortedPlayers = useMemo(() => {
|
||||
return [...filteredPlayers].sort((a, b) => {
|
||||
switch (sortBy) {
|
||||
case "rating":
|
||||
return b.rating - a.rating
|
||||
case "price_asc": {
|
||||
const priceA = Math.min(...(a.services?.map((s) => s.price) || [0]))
|
||||
const priceB = Math.min(...(b.services?.map((s) => s.price) || [0]))
|
||||
return priceA - priceB
|
||||
}
|
||||
case "price_desc": {
|
||||
const priceA = Math.min(...(a.services?.map((s) => s.price) || [0]))
|
||||
const priceB = Math.min(...(b.services?.map((s) => s.price) || [0]))
|
||||
return priceB - priceA
|
||||
}
|
||||
case "orders":
|
||||
return b.totalOrders - a.totalOrders
|
||||
case "composite": {
|
||||
const scoreA = a.rating * Math.log10(a.totalOrders + 1)
|
||||
const scoreB = b.rating * Math.log10(b.totalOrders + 1)
|
||||
return scoreB - scoreA
|
||||
}
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
})
|
||||
}, [filteredPlayers, sortBy])
|
||||
|
||||
const displayedPlayers = sortedPlayers.slice(0, visibleCount)
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-6 min-h-screen">
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 mb-8">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">寻找陪玩</h1>
|
||||
<p className="text-muted-foreground mt-1">找到最适合你的游戏伙伴,一起畅玩游戏世界</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 w-full md:w-auto">
|
||||
<div className="relative flex-1 md:w-80">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
type="search"
|
||||
placeholder="搜索陪玩、游戏、标签..."
|
||||
className="pl-9"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Sheet>
|
||||
<SheetTrigger asChild>
|
||||
<Button variant="outline" size="icon" className="md:hidden shrink-0">
|
||||
<Filter className="h-4 w-4" />
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent side="left" className="w-[300px] sm:w-[400px] overflow-y-auto">
|
||||
<SheetHeader>
|
||||
<SheetTitle>筛选条件</SheetTitle>
|
||||
<SheetDescription>调整筛选条件以找到更匹配的陪玩</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div className="py-6">
|
||||
<FilterSection
|
||||
selectedGames={selectedGames}
|
||||
onGameChange={handleGameChange}
|
||||
priceRange={priceRange}
|
||||
onPriceChange={handlePriceChange}
|
||||
onlyOnline={onlyOnline}
|
||||
onOnlineChange={setOnlyOnline}
|
||||
minRating={minRating}
|
||||
onRatingChange={setMinRating}
|
||||
/>
|
||||
</div>
|
||||
<SheetFooter>
|
||||
<SheetClose asChild>
|
||||
<Button className="w-full">查看 {filteredPlayers.length} 个结果</Button>
|
||||
</SheetClose>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col md:flex-row gap-8">
|
||||
<aside className="hidden md:block w-64 shrink-0 space-y-6">
|
||||
<Card className="sticky top-24">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="font-semibold">筛选</h3>
|
||||
{(selectedGames.length > 0 ||
|
||||
priceRange.min ||
|
||||
priceRange.max ||
|
||||
onlyOnline ||
|
||||
minRating !== "0") && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-auto p-0 text-xs text-muted-foreground hover:text-primary"
|
||||
onClick={() => {
|
||||
setSelectedGames([])
|
||||
setPriceRange({ min: "", max: "" })
|
||||
setOnlyOnline(false)
|
||||
setMinRating("0")
|
||||
}}
|
||||
>
|
||||
重置
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<FilterSection
|
||||
selectedGames={selectedGames}
|
||||
onGameChange={handleGameChange}
|
||||
priceRange={priceRange}
|
||||
onPriceChange={handlePriceChange}
|
||||
onlyOnline={onlyOnline}
|
||||
onOnlineChange={setOnlyOnline}
|
||||
minRating={minRating}
|
||||
onRatingChange={setMinRating}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</aside>
|
||||
|
||||
<main className="flex-1">
|
||||
<div className="flex items-center justify-between mb-6 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 sticky top-0 z-10 py-2 md:static md:bg-transparent md:p-0">
|
||||
<div className="flex items-center gap-2 overflow-x-auto pb-2 md:pb-0 no-scrollbar">
|
||||
<Button
|
||||
variant={sortBy === "composite" ? "secondary" : "ghost"}
|
||||
size="sm"
|
||||
onClick={() => setSortBy("composite")}
|
||||
className="whitespace-nowrap"
|
||||
>
|
||||
综合排序
|
||||
</Button>
|
||||
<Button
|
||||
variant={sortBy === "rating" ? "secondary" : "ghost"}
|
||||
size="sm"
|
||||
onClick={() => setSortBy("rating")}
|
||||
className="whitespace-nowrap"
|
||||
>
|
||||
评分最高
|
||||
</Button>
|
||||
<Button
|
||||
variant={sortBy === "orders" ? "secondary" : "ghost"}
|
||||
size="sm"
|
||||
onClick={() => setSortBy("orders")}
|
||||
className="whitespace-nowrap"
|
||||
>
|
||||
接单最多
|
||||
</Button>
|
||||
<Button
|
||||
variant={sortBy.startsWith("price") ? "secondary" : "ghost"}
|
||||
size="sm"
|
||||
onClick={() => setSortBy(sortBy === "price_asc" ? "price_desc" : "price_asc")}
|
||||
className="whitespace-nowrap gap-1"
|
||||
>
|
||||
价格
|
||||
<SlidersHorizontal className="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground hidden sm:block">
|
||||
共找到 {filteredPlayers.length} 位陪玩
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{displayedPlayers.length > 0 ? (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||
{displayedPlayers.map((player) => (
|
||||
<PlayerCard key={player.id} player={player} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-20 bg-muted/30 rounded-lg border border-dashed">
|
||||
<div className="mx-auto w-12 h-12 rounded-full bg-muted flex items-center justify-center mb-4">
|
||||
<Search className="w-6 h-6 text-muted-foreground" />
|
||||
</div>
|
||||
<h3 className="text-lg font-medium">未找到相关陪玩</h3>
|
||||
<p className="text-muted-foreground mt-1 max-w-sm mx-auto">
|
||||
尝试调整筛选条件或更换搜索关键词
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="mt-4"
|
||||
onClick={() => {
|
||||
setSearchQuery("")
|
||||
setSelectedGames([])
|
||||
setPriceRange({ min: "", max: "" })
|
||||
setOnlyOnline(false)
|
||||
setMinRating("0")
|
||||
}}
|
||||
>
|
||||
清除所有筛选
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{filteredPlayers.length > visibleCount && (
|
||||
<div className="mt-8 text-center">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="lg"
|
||||
onClick={() => setVisibleCount((prev) => prev + 12)}
|
||||
>
|
||||
加载更多
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function SearchPage() {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="container mx-auto px-4 py-6 flex items-center justify-center min-h-[400px]">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<SearchPageContent />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,8 +1,273 @@
|
||||
export default function ShopDetailPage({ params: _params }: { params: Promise<{ id: string }> }) {
|
||||
import { Gamepad2, Megaphone, ShoppingBag, Star, Users } from "lucide-react"
|
||||
import Image from "next/image"
|
||||
import Link from "next/link"
|
||||
import { notFound } from "next/navigation"
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import { mockPlayers, mockReviews, mockServices, mockShops } from "@/lib/mock-data"
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ id: string }>
|
||||
}
|
||||
|
||||
export default async function ShopPage({ params }: PageProps) {
|
||||
const { id } = await params
|
||||
const shop = mockShops.find((s) => s.id === id)
|
||||
|
||||
if (!shop) {
|
||||
notFound()
|
||||
}
|
||||
|
||||
const shopPlayers = mockPlayers.filter((p) => p.shopId === shop.id)
|
||||
const playerIds = shopPlayers.map((p) => p.id)
|
||||
const shopServices = mockServices.filter((s) => playerIds.includes(s.playerId))
|
||||
const shopReviews = mockReviews.filter((r) => playerIds.includes(r.toUserId))
|
||||
|
||||
const sortedSections = [...shop.templateConfig.sections]
|
||||
.filter((s) => s.enabled)
|
||||
.sort((a, b) => a.order - b.order)
|
||||
|
||||
return (
|
||||
<div className="container mx-auto py-8 px-4">
|
||||
<h1 className="text-2xl font-bold">店铺详情</h1>
|
||||
<p className="mt-2 text-muted-foreground">店铺主页、服务列表、打手列表</p>
|
||||
<div className="container mx-auto py-6 space-y-8">
|
||||
{sortedSections.map((section) => {
|
||||
switch (section.type) {
|
||||
case "banner":
|
||||
return (
|
||||
<div
|
||||
key="banner"
|
||||
className="relative h-48 md:h-64 rounded-xl overflow-hidden bg-muted"
|
||||
>
|
||||
{shop.banner ? (
|
||||
<Image src={shop.banner} alt={shop.name} fill className="object-cover" />
|
||||
) : (
|
||||
<div className="w-full h-full bg-gradient-to-r from-primary/20 to-primary/10" />
|
||||
)}
|
||||
<div className="absolute inset-0 bg-black/40 flex items-center justify-center">
|
||||
<h1 className="text-3xl md:text-5xl font-bold text-white text-center px-4">
|
||||
{shop.name}
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
case "intro":
|
||||
return (
|
||||
<Card key="intro">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex flex-col md:flex-row gap-6 items-start">
|
||||
<div className="flex-1 space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-2xl font-bold">{shop.name}</h2>
|
||||
<Badge variant="secondary">
|
||||
{shop.commissionType === "percentage"
|
||||
? `抽成 ${shop.commissionValue}%`
|
||||
: `固定抽成 ${shop.commissionValue}`}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-muted-foreground">{shop.description}</p>
|
||||
<div className="flex flex-wrap gap-4 text-sm">
|
||||
<div className="flex items-center gap-1">
|
||||
<Star className="w-4 h-4 text-yellow-500 fill-yellow-500" />
|
||||
<span className="font-medium">{shop.rating}</span>
|
||||
<span className="text-muted-foreground">评分</span>
|
||||
</div>
|
||||
<Separator orientation="vertical" className="h-4" />
|
||||
<div className="flex items-center gap-1">
|
||||
<ShoppingBag className="w-4 h-4" />
|
||||
<span className="font-medium">{shop.totalOrders}</span>
|
||||
<span className="text-muted-foreground">总订单</span>
|
||||
</div>
|
||||
<Separator orientation="vertical" className="h-4" />
|
||||
<div className="flex items-center gap-1">
|
||||
<Users className="w-4 h-4" />
|
||||
<span className="font-medium">{shop.playerCount}</span>
|
||||
<span className="text-muted-foreground">陪玩师</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<Avatar className="w-16 h-16">
|
||||
<AvatarImage src={shop.owner.avatar} />
|
||||
<AvatarFallback>{shop.owner.nickname[0]}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div>
|
||||
<p className="font-medium">店长:{shop.owner.nickname}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{shop.owner.bio || "暂无介绍"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
|
||||
case "announcements":
|
||||
if (shop.announcements.length === 0) return null
|
||||
return (
|
||||
<Card key="announcements">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Megaphone className="w-5 h-5 text-primary" />
|
||||
店铺公告
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ul className="space-y-2">
|
||||
{shop.announcements.map((announcement) => (
|
||||
<li key={announcement} className="flex items-start gap-2 text-sm">
|
||||
<span className="mt-1.5 w-1.5 h-1.5 rounded-full bg-primary shrink-0" />
|
||||
<span>{announcement}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
|
||||
case "services":
|
||||
if (shopServices.length === 0) return null
|
||||
return (
|
||||
<div key="services" className="space-y-4">
|
||||
<h3 className="text-xl font-bold flex items-center gap-2">
|
||||
<Gamepad2 className="w-5 h-5" />
|
||||
热门服务
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{shopServices.map((service) => (
|
||||
<Card key={service.id}>
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex justify-between items-start">
|
||||
<Badge variant="outline">{service.gameName}</Badge>
|
||||
<div className="text-right">
|
||||
<span className="text-lg font-bold text-primary">¥{service.price}</span>
|
||||
<span className="text-xs text-muted-foreground">/{service.unit}</span>
|
||||
</div>
|
||||
</div>
|
||||
<CardTitle className="text-base mt-2">{service.title}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground line-clamp-2 mb-2">
|
||||
{service.description}
|
||||
</p>
|
||||
{service.rankRange && (
|
||||
<div className="text-xs bg-muted px-2 py-1 rounded inline-block">
|
||||
段位:{service.rankRange}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
case "players":
|
||||
if (shopPlayers.length === 0) return null
|
||||
return (
|
||||
<div key="players" className="space-y-4">
|
||||
<h3 className="text-xl font-bold flex items-center gap-2">
|
||||
<Users className="w-5 h-5" />
|
||||
明星陪玩
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{shopPlayers.map((player) => (
|
||||
<Link key={player.id} href={`/player/${player.id}`}>
|
||||
<Card className="h-full hover:shadow-md transition-shadow">
|
||||
<CardContent className="pt-6 text-center space-y-3">
|
||||
<div className="relative inline-block">
|
||||
<Avatar className="w-20 h-20 mx-auto">
|
||||
<AvatarImage src={player.user.avatar} />
|
||||
<AvatarFallback>{player.user.nickname[0]}</AvatarFallback>
|
||||
</Avatar>
|
||||
<span
|
||||
className={`absolute bottom-0 right-0 w-4 h-4 border-2 border-background rounded-full ${
|
||||
player.status === "available"
|
||||
? "bg-green-500"
|
||||
: player.status === "busy"
|
||||
? "bg-yellow-500"
|
||||
: "bg-gray-500"
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-bold">{player.user.nickname}</h4>
|
||||
<div className="flex items-center justify-center gap-1 text-sm text-muted-foreground mt-1">
|
||||
<Star className="w-3 h-3 fill-yellow-500 text-yellow-500" />
|
||||
<span>{player.rating}</span>
|
||||
<span>•</span>
|
||||
<span>{player.totalOrders}单</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap justify-center gap-1">
|
||||
{player.games.slice(0, 3).map((game) => (
|
||||
<Badge key={game} variant="secondary" className="text-xs">
|
||||
{game}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
case "reviews":
|
||||
if (shopReviews.length === 0) return null
|
||||
return (
|
||||
<div key="reviews" className="space-y-4">
|
||||
<h3 className="text-xl font-bold flex items-center gap-2">
|
||||
<Star className="w-5 h-5" />
|
||||
最新评价
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
{shopReviews.map((review) => (
|
||||
<Card key={review.id}>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex gap-4">
|
||||
<Avatar>
|
||||
<AvatarImage src={review.fromUserAvatar} />
|
||||
<AvatarFallback>{review.fromUserName[0]}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex-1 space-y-1">
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<p className="font-medium text-sm">{review.fromUserName}</p>
|
||||
<div className="flex items-center gap-0.5">
|
||||
{[1, 2, 3, 4, 5].map((star) => (
|
||||
<Star
|
||||
key={`star-${star}`}
|
||||
className={`w-3 h-3 ${
|
||||
star <= review.rating
|
||||
? "fill-yellow-500 text-yellow-500"
|
||||
: "text-muted-foreground"
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{new Date(review.createdAt).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">{review.content}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
default:
|
||||
return null
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,8 +1,126 @@
|
||||
export default function ChatDetailPage({ params: _params }: { params: Promise<{ id: string }> }) {
|
||||
"use client"
|
||||
|
||||
import { ArrowLeft, Lock, Send } from "lucide-react"
|
||||
import Link from "next/link"
|
||||
import { use, useState } from "react"
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||
import { mockChatMessages, mockChatSessions } from "@/lib/mock-data"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export default function ChatDetailPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = use(params)
|
||||
const session = mockChatSessions.find((s) => s.id === id)
|
||||
const messages = mockChatMessages.filter((m) => m.sessionId === id)
|
||||
const [input, setInput] = useState("")
|
||||
|
||||
if (!session) {
|
||||
return (
|
||||
<div className="container mx-auto py-8 px-4 text-center text-muted-foreground">
|
||||
会话不存在
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const other = session.participants[1]
|
||||
const currentUserId = session.participants[0].id
|
||||
|
||||
return (
|
||||
<div className="container mx-auto py-8 px-4">
|
||||
<h1 className="text-2xl font-bold">聊天</h1>
|
||||
<p className="mt-2 text-muted-foreground">与打手沟通</p>
|
||||
<div className="flex flex-col h-[calc(100vh-3.5rem)]">
|
||||
<div className="border-b px-4 py-3 flex items-center gap-3">
|
||||
<Link href="/chat" className="text-muted-foreground hover:text-foreground">
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
</Link>
|
||||
<Avatar className="h-8 w-8">
|
||||
<AvatarImage src={other.avatar} />
|
||||
<AvatarFallback>{other.name[0]}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div>
|
||||
<span className="text-sm font-medium">{other.name}</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<Badge variant="outline" className="text-[10px] px-1.5 py-0">
|
||||
{session.type === "order" ? "订单会话" : "咨询会话"}
|
||||
</Badge>
|
||||
{session.readonly && (
|
||||
<span className="text-[10px] text-muted-foreground flex items-center gap-0.5">
|
||||
<Lock className="h-3 w-3" />
|
||||
只读
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="flex-1 p-4">
|
||||
<div className="space-y-4 max-w-2xl mx-auto">
|
||||
{messages.map((msg) => {
|
||||
if (msg.type === "system") {
|
||||
return (
|
||||
<div key={msg.id} className="text-center">
|
||||
<span className="text-xs text-muted-foreground bg-muted px-2 py-1 rounded">
|
||||
{msg.content}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const isMine = msg.senderId === currentUserId
|
||||
return (
|
||||
<div key={msg.id} className={cn("flex gap-2", isMine && "flex-row-reverse")}>
|
||||
<Avatar className="h-8 w-8 shrink-0">
|
||||
<AvatarImage src={msg.senderAvatar} />
|
||||
<AvatarFallback>{msg.senderName[0]}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className={cn("max-w-[70%]", isMine && "text-right")}>
|
||||
<p
|
||||
className={cn(
|
||||
"inline-block rounded-lg px-3 py-2 text-sm",
|
||||
isMine ? "bg-primary text-primary-foreground" : "bg-muted",
|
||||
)}
|
||||
>
|
||||
{msg.content}
|
||||
</p>
|
||||
<p className="text-[10px] text-muted-foreground mt-1">
|
||||
{new Date(msg.createdAt).toLocaleTimeString("zh-CN", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
{!session.readonly ? (
|
||||
<div className="border-t p-4">
|
||||
<form
|
||||
className="flex gap-2 max-w-2xl mx-auto"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
setInput("")
|
||||
}}
|
||||
>
|
||||
<Input
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
placeholder="输入消息..."
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button type="submit" size="icon" disabled={!input.trim()}>
|
||||
<Send className="h-4 w-4" />
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
) : (
|
||||
<div className="border-t p-4 text-center text-sm text-muted-foreground">
|
||||
<Lock className="h-4 w-4 inline mr-1" />
|
||||
订单已关闭,会话为只读状态
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,8 +1,61 @@
|
||||
import { Lock, MessageSquare } from "lucide-react"
|
||||
import Link from "next/link"
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
import { mockChatSessions } from "@/lib/mock-data"
|
||||
|
||||
export default function ChatListPage() {
|
||||
return (
|
||||
<div className="container mx-auto py-8 px-4">
|
||||
<h1 className="text-2xl font-bold">消息</h1>
|
||||
<p className="mt-2 text-muted-foreground">咨询会话和订单会话</p>
|
||||
<div className="container mx-auto py-8 px-4 max-w-2xl">
|
||||
<h1 className="text-2xl font-bold mb-6">消息</h1>
|
||||
|
||||
<div className="space-y-2">
|
||||
{mockChatSessions.map((session) => {
|
||||
const other = session.participants[1]
|
||||
return (
|
||||
<Link key={session.id} href={`/chat/${session.id}`}>
|
||||
<Card className="hover:bg-accent/30 transition-colors">
|
||||
<CardContent className="flex items-center gap-3 py-3">
|
||||
<Avatar className="h-10 w-10">
|
||||
<AvatarImage src={other.avatar} />
|
||||
<AvatarFallback>{other.name[0]}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium">{other.name}</span>
|
||||
<Badge variant="outline" className="text-[10px] px-1.5 py-0">
|
||||
{session.type === "order" ? "订单" : "咨询"}
|
||||
</Badge>
|
||||
{session.readonly && <Lock className="h-3 w-3 text-muted-foreground" />}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground truncate">{session.lastMessage}</p>
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-1 shrink-0">
|
||||
{session.lastMessageAt && (
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{new Date(session.lastMessageAt).toLocaleDateString("zh-CN")}
|
||||
</span>
|
||||
)}
|
||||
{session.unreadCount > 0 && (
|
||||
<Badge className="h-4 min-w-4 px-1 flex items-center justify-center text-[10px]">
|
||||
{session.unreadCount}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
|
||||
{mockChatSessions.length === 0 && (
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
<MessageSquare className="h-12 w-12 mx-auto mb-2 opacity-50" />
|
||||
暂无消息
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,8 +1,176 @@
|
||||
export default function DisputePage({ params: _params }: { params: Promise<{ id: string }> }) {
|
||||
"use client"
|
||||
|
||||
import { AlertTriangle, ArrowLeft, Clock, FileText } from "lucide-react"
|
||||
import Link from "next/link"
|
||||
import { use, useState } from "react"
|
||||
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 { mockDisputes, mockOrders } from "@/lib/mock-data"
|
||||
|
||||
const disputeStatusLabels: Record<string, string> = {
|
||||
open: "已提交",
|
||||
reviewing: "审核中",
|
||||
resolved: "已解决",
|
||||
appealed: "申诉中",
|
||||
}
|
||||
|
||||
export default function DisputePage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = use(params)
|
||||
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)
|
||||
|
||||
if (!order) {
|
||||
return (
|
||||
<div className="container mx-auto py-8 px-4 text-center text-muted-foreground">
|
||||
订单不存在
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (existingDispute) {
|
||||
return (
|
||||
<div className="container mx-auto py-8 px-4 max-w-lg">
|
||||
<Link
|
||||
href={`/order/${id}`}
|
||||
className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground mb-4"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
返回订单
|
||||
</Link>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle>争议详情</CardTitle>
|
||||
<Badge variant="outline">{disputeStatusLabels[existingDispute.status]}</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<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>
|
||||
<Label className="text-muted-foreground">争议原因</Label>
|
||||
<p className="mt-1 text-sm">{existingDispute.reason}</p>
|
||||
</div>
|
||||
{existingDispute.evidence.length > 0 && (
|
||||
<div>
|
||||
<Label className="text-muted-foreground">证据截图</Label>
|
||||
<div className="mt-1 flex gap-2">
|
||||
{existingDispute.evidence.map((url) => (
|
||||
<div
|
||||
key={url}
|
||||
className="h-20 w-20 rounded border bg-muted flex items-center justify-center text-xs text-muted-foreground"
|
||||
>
|
||||
截图
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{existingDispute.result && (
|
||||
<>
|
||||
<Separator />
|
||||
<div>
|
||||
<Label className="text-muted-foreground">仲裁结果</Label>
|
||||
<p className="mt-1 text-sm font-medium">
|
||||
{existingDispute.result === "full_refund"
|
||||
? "全额退款"
|
||||
: existingDispute.result === "full_payment"
|
||||
? "全额支付给打手"
|
||||
: "部分退款"}
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (submitted) {
|
||||
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>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto py-8 px-4">
|
||||
<h1 className="text-2xl font-bold">争议仲裁</h1>
|
||||
<p className="mt-2 text-muted-foreground">提交争议说明和证据</p>
|
||||
<div className="container mx-auto py-8 px-4 max-w-lg">
|
||||
<Link
|
||||
href={`/order/${id}`}
|
||||
className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground mb-4"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
返回订单
|
||||
</Link>
|
||||
|
||||
<Card>
|
||||
<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={(e) => setReason(e.target.value)}
|
||||
rows={4}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md bg-muted/50 p-3 text-xs text-muted-foreground space-y-1">
|
||||
<p>· 提交争议后,订单资金将继续托管</p>
|
||||
<p>· 聊天记录将作为证据保留</p>
|
||||
<p>· 平台将在 3 个工作日内审核</p>
|
||||
<p>· 对仲裁结果不满可申诉一次</p>
|
||||
</div>
|
||||
|
||||
<Button className="w-full" disabled={!reason.trim()} onClick={() => setSubmitted(true)}>
|
||||
提交争议
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,8 +1,240 @@
|
||||
export default function OrderDetailPage({ params: _params }: { params: Promise<{ id: string }> }) {
|
||||
import {
|
||||
AlertTriangle,
|
||||
ArrowLeft,
|
||||
CheckCircle,
|
||||
Clock,
|
||||
MessageSquare,
|
||||
RefreshCw,
|
||||
Star,
|
||||
} from "lucide-react"
|
||||
import Link from "next/link"
|
||||
import { notFound } from "next/navigation"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import { mockOrders, mockReviews } from "@/lib/mock-data"
|
||||
import type { OrderStatus } from "@/lib/types"
|
||||
|
||||
const statusLabels: Record<OrderStatus, string> = {
|
||||
pending_payment: "待支付",
|
||||
pending_accept: "待接单",
|
||||
in_progress: "进行中",
|
||||
pending_close: "待结单",
|
||||
pending_review: "待评价",
|
||||
disputed: "争议中",
|
||||
completed: "已完成",
|
||||
cancelled: "已取消",
|
||||
}
|
||||
|
||||
const statusSteps: OrderStatus[] = [
|
||||
"pending_payment",
|
||||
"pending_accept",
|
||||
"in_progress",
|
||||
"pending_close",
|
||||
"pending_review",
|
||||
"completed",
|
||||
]
|
||||
|
||||
export default async function OrderDetailPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params
|
||||
const order = mockOrders.find((o) => o.id === id)
|
||||
if (!order) notFound()
|
||||
|
||||
const reviews = mockReviews.filter((r) => r.orderId === id)
|
||||
const currentStepIndex = statusSteps.indexOf(order.status)
|
||||
|
||||
return (
|
||||
<div className="container mx-auto py-8 px-4">
|
||||
<h1 className="text-2xl font-bold">订单详情</h1>
|
||||
<p className="mt-2 text-muted-foreground">订单状态、聊天、评价、争议</p>
|
||||
<div className="container mx-auto py-8 px-4 max-w-3xl">
|
||||
<Link
|
||||
href="/orders"
|
||||
className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground mb-4"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
返回订单列表
|
||||
</Link>
|
||||
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1 className="text-2xl font-bold">订单详情</h1>
|
||||
<Badge variant="outline">{statusLabels[order.status]}</Badge>
|
||||
</div>
|
||||
|
||||
{order.status !== "disputed" && order.status !== "cancelled" && (
|
||||
<Card className="mb-6">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center justify-between">
|
||||
{statusSteps.map((step, i) => {
|
||||
const isActive = i <= currentStepIndex
|
||||
const isCurrent = i === currentStepIndex
|
||||
return (
|
||||
<div key={step} className="flex flex-col items-center gap-1 flex-1">
|
||||
<div
|
||||
className={`h-8 w-8 rounded-full flex items-center justify-center text-xs font-medium ${
|
||||
isCurrent
|
||||
? "bg-primary text-primary-foreground"
|
||||
: isActive
|
||||
? "bg-primary/20 text-primary"
|
||||
: "bg-muted text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
{isActive ? <CheckCircle className="h-4 w-4" /> : i + 1}
|
||||
</div>
|
||||
<span className="text-[10px] text-muted-foreground text-center">
|
||||
{statusLabels[step]}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card className="mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">服务信息</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">服务</span>
|
||||
<span>{order.service.title}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">游戏</span>
|
||||
<span>{order.service.gameName}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">单价</span>
|
||||
<span>
|
||||
¥{order.service.price}/{order.service.unit}
|
||||
</span>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">打手</span>
|
||||
<Link href={`/player/${order.playerId}`} className="text-primary hover:underline">
|
||||
{order.playerName}
|
||||
</Link>
|
||||
</div>
|
||||
{order.shopName && (
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">店铺</span>
|
||||
<Link href={`/shop/${order.shopId}`} className="text-primary hover:underline">
|
||||
{order.shopName}
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
<Separator />
|
||||
<div className="flex justify-between text-sm font-medium">
|
||||
<span>总价</span>
|
||||
<span className="text-lg">¥{order.totalPrice}</span>
|
||||
</div>
|
||||
{order.note && (
|
||||
<div className="text-sm">
|
||||
<span className="text-muted-foreground">备注: </span>
|
||||
{order.note}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">时间线</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span className="text-muted-foreground">下单时间:</span>
|
||||
{new Date(order.createdAt).toLocaleString("zh-CN")}
|
||||
</div>
|
||||
{order.acceptedAt && (
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle className="h-3.5 w-3.5 text-green-500" />
|
||||
<span className="text-muted-foreground">接单时间:</span>
|
||||
{new Date(order.acceptedAt).toLocaleString("zh-CN")}
|
||||
</div>
|
||||
)}
|
||||
{order.closedAt && (
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle className="h-3.5 w-3.5 text-green-500" />
|
||||
<span className="text-muted-foreground">结单时间:</span>
|
||||
{new Date(order.closedAt).toLocaleString("zh-CN")}
|
||||
</div>
|
||||
)}
|
||||
{order.completedAt && (
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle className="h-3.5 w-3.5 text-green-500" />
|
||||
<span className="text-muted-foreground">完成时间:</span>
|
||||
{new Date(order.completedAt).toLocaleString("zh-CN")}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{reviews.length > 0 && (
|
||||
<Card className="mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">评价</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{reviews.map((review) => (
|
||||
<div key={review.id} className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium">{review.fromUserName}</span>
|
||||
<div className="flex">
|
||||
{[1, 2, 3, 4, 5].map((star) => (
|
||||
<Star
|
||||
key={`star-${star}`}
|
||||
className={`h-3.5 w-3.5 ${star <= review.rating ? "fill-yellow-400 text-yellow-400" : "text-muted"}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{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")}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{(order.status === "in_progress" || order.status === "pending_close") && (
|
||||
<Button asChild>
|
||||
<Link href={`/chat/chat1`}>
|
||||
<MessageSquare className="mr-1 h-4 w-4" />
|
||||
聊天
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
{order.status === "pending_review" && (
|
||||
<Button asChild>
|
||||
<Link href={`/review/${order.id}`}>
|
||||
<Star className="mr-1 h-4 w-4" />
|
||||
评价
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
{["in_progress", "pending_close"].includes(order.status) && (
|
||||
<Button variant="destructive" asChild>
|
||||
<Link href={`/dispute/${order.id}`}>
|
||||
<AlertTriangle className="mr-1 h-4 w-4" />
|
||||
发起争议
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
{order.status === "completed" && (
|
||||
<Button variant="outline">
|
||||
<RefreshCw className="mr-1 h-4 w-4" />
|
||||
再来一单
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
+125
-2
@@ -1,8 +1,131 @@
|
||||
"use client"
|
||||
|
||||
import { Clock, MessageSquare, RefreshCw } from "lucide-react"
|
||||
import Link from "next/link"
|
||||
import { useState } from "react"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import { mockOrders } from "@/lib/mock-data"
|
||||
import type { OrderStatus } from "@/lib/types"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { useAuthStore } from "@/store/auth"
|
||||
|
||||
const statusLabels: Record<OrderStatus, string> = {
|
||||
pending_payment: "待支付",
|
||||
pending_accept: "待接单",
|
||||
in_progress: "进行中",
|
||||
pending_close: "待结单",
|
||||
pending_review: "待评价",
|
||||
disputed: "争议中",
|
||||
completed: "已完成",
|
||||
cancelled: "已取消",
|
||||
}
|
||||
|
||||
const statusColors: Record<OrderStatus, string> = {
|
||||
pending_payment: "bg-yellow-100 text-yellow-800",
|
||||
pending_accept: "bg-blue-100 text-blue-800",
|
||||
in_progress: "bg-green-100 text-green-800",
|
||||
pending_close: "bg-orange-100 text-orange-800",
|
||||
pending_review: "bg-purple-100 text-purple-800",
|
||||
disputed: "bg-red-100 text-red-800",
|
||||
completed: "bg-gray-100 text-gray-800",
|
||||
cancelled: "bg-gray-100 text-gray-500",
|
||||
}
|
||||
|
||||
type TabFilter = "all" | "active" | "completed" | "disputed"
|
||||
|
||||
export default function OrderListPage() {
|
||||
const [tab, setTab] = useState<TabFilter>("all")
|
||||
const { currentRole } = useAuthStore()
|
||||
|
||||
const filtered = mockOrders.filter((order) => {
|
||||
if (tab === "active")
|
||||
return ["pending_accept", "in_progress", "pending_close", "pending_review"].includes(
|
||||
order.status,
|
||||
)
|
||||
if (tab === "completed") return order.status === "completed" || order.status === "cancelled"
|
||||
if (tab === "disputed") return order.status === "disputed"
|
||||
return true
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="container mx-auto py-8 px-4">
|
||||
<h1 className="text-2xl font-bold">我的订单</h1>
|
||||
<p className="mt-2 text-muted-foreground">查看和管理订单</p>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1 className="text-2xl font-bold">我的订单</h1>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{currentRole === "consumer"
|
||||
? "消费者视角"
|
||||
: currentRole === "player"
|
||||
? "打手视角"
|
||||
: "店主视角"}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<Tabs value={tab} onValueChange={(v) => setTab(v as TabFilter)}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="all">全部</TabsTrigger>
|
||||
<TabsTrigger value="active">进行中</TabsTrigger>
|
||||
<TabsTrigger value="completed">已完成</TabsTrigger>
|
||||
<TabsTrigger value="disputed">争议</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value={tab} className="mt-4 space-y-3">
|
||||
{filtered.length === 0 ? (
|
||||
<div className="text-center py-12 text-muted-foreground">暂无订单</div>
|
||||
) : (
|
||||
filtered.map((order) => (
|
||||
<Card key={order.id}>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-base">{order.service.title}</CardTitle>
|
||||
<Badge className={cn("text-xs", statusColors[order.status])}>
|
||||
{statusLabels[order.status]}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{currentRole === "consumer"
|
||||
? `打手: ${order.playerName}`
|
||||
: `消费者: ${order.consumerName}`}
|
||||
{order.shopName && ` · ${order.shopName}`}
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4 text-sm text-muted-foreground">
|
||||
<span className="font-medium text-foreground">¥{order.totalPrice}</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="h-3.5 w-3.5" />
|
||||
{new Date(order.createdAt).toLocaleDateString("zh-CN")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{order.status === "in_progress" && (
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<Link href={`/chat/chat1`}>
|
||||
<MessageSquare className="mr-1 h-3.5 w-3.5" />
|
||||
聊天
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
{order.status === "completed" && (
|
||||
<Button variant="outline" size="sm">
|
||||
<RefreshCw className="mr-1 h-3.5 w-3.5" />
|
||||
再来一单
|
||||
</Button>
|
||||
)}
|
||||
<Button size="sm" asChild>
|
||||
<Link href={`/order/${order.id}`}>查看详情</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,8 +1,110 @@
|
||||
export default function ReviewPage({ params: _params }: { params: Promise<{ id: string }> }) {
|
||||
"use client"
|
||||
|
||||
import { ArrowLeft, Lock, Star } from "lucide-react"
|
||||
import Link from "next/link"
|
||||
import { use, useState } from "react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { mockOrders } from "@/lib/mock-data"
|
||||
|
||||
export default function ReviewPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = use(params)
|
||||
const order = mockOrders.find((o) => o.id === id)
|
||||
const [rating, setRating] = useState(0)
|
||||
const [hoverRating, setHoverRating] = useState(0)
|
||||
const [content, setContent] = useState("")
|
||||
const [submitted, setSubmitted] = useState(false)
|
||||
|
||||
if (!order) {
|
||||
return (
|
||||
<div className="container mx-auto py-8 px-4 text-center text-muted-foreground">
|
||||
订单不存在
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (submitted) {
|
||||
return (
|
||||
<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>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto py-8 px-4">
|
||||
<h1 className="text-2xl font-bold">评价</h1>
|
||||
<p className="mt-2 text-muted-foreground">对本次服务进行评价</p>
|
||||
<div className="container mx-auto py-8 px-4 max-w-lg">
|
||||
<Link
|
||||
href={`/order/${id}`}
|
||||
className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground mb-4"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
返回订单
|
||||
</Link>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>评价服务</CardTitle>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{order.service.title} · {order.playerName}
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<Label>评分</Label>
|
||||
<div className="flex gap-1">
|
||||
{[1, 2, 3, 4, 5].map((star) => (
|
||||
<button
|
||||
key={star}
|
||||
type="button"
|
||||
onClick={() => setRating(star)}
|
||||
onMouseEnter={() => setHoverRating(star)}
|
||||
onMouseLeave={() => setHoverRating(0)}
|
||||
className="p-0.5"
|
||||
>
|
||||
<Star
|
||||
className={`h-8 w-8 transition-colors ${
|
||||
star <= (hoverRating || rating)
|
||||
? "fill-yellow-400 text-yellow-400"
|
||||
: "text-muted"
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="review-content">评价内容(可选)</Label>
|
||||
<Textarea
|
||||
id="review-content"
|
||||
placeholder="分享你的体验..."
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
rows={4}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md bg-muted/50 p-3 text-xs text-muted-foreground flex items-start gap-2">
|
||||
<Lock className="h-4 w-4 shrink-0 mt-0.5" />
|
||||
<span>
|
||||
评价采用密封机制:你的评价将在双方都提交后同时揭晓,确保评价的真实性和公正性。
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Button className="w-full" disabled={rating === 0} onClick={() => setSubmitted(true)}>
|
||||
提交评价
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user