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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user