519fb92c34
Turn on react-hooks/set-state-in-effect and react-hooks/incompatible-library, then remove effect-driven local state sync patterns across affected pages. Keep behavior stable by deriving values from source state, remounting tab state by role key, and replacing useForm watch with useWatch.
737 lines
25 KiB
TypeScript
737 lines
25 KiB
TypeScript
"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 { listGames, listPlayers, listServices, listShops } from "@/lib/api"
|
|
import { GameIcon } from "@/lib/game-icons"
|
|
import type { Game, Player, Shop } 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 =
|
|
!player.services || player.services.length === 0
|
|
? 0
|
|
: Math.min(...player.services.map((s) => s.price))
|
|
|
|
const unit =
|
|
!player.services || player.services.length === 0
|
|
? "局"
|
|
: player.services.reduce((prev, curr) => (prev.price < curr.price ? prev : curr)).unit
|
|
|
|
return (
|
|
<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 ShopResultItem {
|
|
shop: Shop
|
|
minPrice: number
|
|
unit: string
|
|
games: string[]
|
|
hasAvailable: boolean
|
|
}
|
|
|
|
function ShopCard({ item }: { item: ShopResultItem }) {
|
|
return (
|
|
<Link href={`/shop/${item.shop.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={item.shop.owner.avatar} alt={item.shop.name} />
|
|
<AvatarFallback>{item.shop.name.slice(0, 2)}</AvatarFallback>
|
|
</Avatar>
|
|
<div>
|
|
<h3 className="font-semibold text-base leading-none mb-1">{item.shop.name}</h3>
|
|
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
|
<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" />
|
|
店铺
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<StatusBadge status={item.hasAvailable ? "available" : "busy"} />
|
|
</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" />
|
|
{item.shop.rating.toFixed(1)}
|
|
</div>
|
|
<div className="text-muted-foreground">接单 {item.shop.totalOrders}</div>
|
|
</div>
|
|
|
|
<div className="flex flex-wrap gap-1.5 mb-3">
|
|
{item.games.slice(0, 3).map((game) => (
|
|
<Badge key={game} variant="secondary" className="text-xs font-normal">
|
|
{game}
|
|
</Badge>
|
|
))}
|
|
{item.games.length > 3 && (
|
|
<Badge variant="secondary" className="text-xs font-normal">
|
|
+{item.games.length - 3}
|
|
</Badge>
|
|
)}
|
|
</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">¥{item.minPrice}</span>
|
|
<span className="text-xs text-muted-foreground">/{item.unit}</span>
|
|
</div>
|
|
<Button size="sm" variant="ghost" className="h-8 px-3 text-xs">
|
|
查看详情
|
|
</Button>
|
|
</CardFooter>
|
|
</Card>
|
|
</Link>
|
|
)
|
|
}
|
|
|
|
type SearchResult =
|
|
| {
|
|
type: "player"
|
|
id: string
|
|
rating: number
|
|
orders: number
|
|
minPrice: number
|
|
item: Player
|
|
}
|
|
| {
|
|
type: "shop"
|
|
id: string
|
|
rating: number
|
|
orders: number
|
|
minPrice: number
|
|
item: ShopResultItem
|
|
}
|
|
|
|
interface FilterProps {
|
|
games: Game[]
|
|
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({
|
|
games,
|
|
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">
|
|
{games.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"
|
|
>
|
|
<GameIcon name={game.icon} className="h-4 w-4" />
|
|
{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 games = listGames()
|
|
const players = listPlayers()
|
|
const services = listServices()
|
|
const shops = listShops()
|
|
|
|
const [searchQuery, setSearchQuery] = useState(searchParams.get("q") || "")
|
|
const [selectedGames, setSelectedGames] = useState<string[]>(() => {
|
|
const game = searchParams.get("game")
|
|
return game ? [game] : []
|
|
})
|
|
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 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 shopResultItems = useMemo<ShopResultItem[]>(() => {
|
|
return shops.map((shop) => {
|
|
const shopPlayers = players.filter((player) => player.shopId === shop.id)
|
|
const playerIds = new Set(shopPlayers.map((player) => player.id))
|
|
const shopServices = services.filter((service) => playerIds.has(service.playerId))
|
|
const minPrice =
|
|
shopServices.length > 0 ? Math.min(...shopServices.map((service) => service.price)) : 0
|
|
const unit =
|
|
shopServices.length > 0
|
|
? shopServices.reduce((prev, curr) => (prev.price < curr.price ? prev : curr)).unit
|
|
: "局"
|
|
const shopGames = [...new Set(shopServices.map((service) => service.gameName))]
|
|
const hasAvailable = shopPlayers.some((player) => player.status === "available")
|
|
return {
|
|
shop,
|
|
minPrice,
|
|
unit,
|
|
games: shopGames,
|
|
hasAvailable,
|
|
}
|
|
})
|
|
}, [players, services, shops])
|
|
|
|
const filteredPlayers = useMemo(() => {
|
|
return players.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))
|
|
|
|
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
|
|
})
|
|
}, [minRating, onlyOnline, players, priceRange, searchQuery, selectedGames])
|
|
|
|
const filteredShops = useMemo(() => {
|
|
return shopResultItems.filter((item) => {
|
|
if (searchQuery) {
|
|
const query = searchQuery.toLowerCase()
|
|
const matchName = item.shop.name.toLowerCase().includes(query)
|
|
const matchDescription = item.shop.description.toLowerCase().includes(query)
|
|
const matchGames = item.games.some((game) => game.toLowerCase().includes(query))
|
|
if (!matchName && !matchDescription && !matchGames) return false
|
|
}
|
|
|
|
if (selectedGames.length > 0) {
|
|
const hasGame = item.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
|
|
if (item.minPrice < minP) return false
|
|
if (priceRange.max && item.minPrice > maxP) return false
|
|
|
|
if (onlyOnline && !item.hasAvailable) return false
|
|
if (item.shop.rating < Number(minRating)) return false
|
|
|
|
return true
|
|
})
|
|
}, [shopResultItems, searchQuery, selectedGames, priceRange, onlyOnline, minRating])
|
|
|
|
const sortedResults = useMemo<SearchResult[]>(() => {
|
|
const playerResults: SearchResult[] = filteredPlayers.map((player) => ({
|
|
type: "player",
|
|
id: player.id,
|
|
rating: player.rating,
|
|
orders: player.totalOrders,
|
|
minPrice: Math.min(...player.services.map((service) => service.price)),
|
|
item: player,
|
|
}))
|
|
const shopResults: SearchResult[] = filteredShops.map((item) => ({
|
|
type: "shop",
|
|
id: item.shop.id,
|
|
rating: item.shop.rating,
|
|
orders: item.shop.totalOrders,
|
|
minPrice: item.minPrice,
|
|
item,
|
|
}))
|
|
|
|
return [...playerResults, ...shopResults].sort((a, b) => {
|
|
switch (sortBy) {
|
|
case "rating":
|
|
return b.rating - a.rating
|
|
case "price_asc":
|
|
return a.minPrice - b.minPrice
|
|
case "price_desc":
|
|
return b.minPrice - a.minPrice
|
|
case "orders":
|
|
return b.orders - a.orders
|
|
case "composite": {
|
|
const scoreA = a.rating * Math.log10(a.orders + 1)
|
|
const scoreB = b.rating * Math.log10(b.orders + 1)
|
|
return scoreB - scoreA
|
|
}
|
|
default:
|
|
return 0
|
|
}
|
|
})
|
|
}, [filteredPlayers, filteredShops, sortBy])
|
|
|
|
const displayedResults = sortedResults.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
|
|
games={games}
|
|
selectedGames={selectedGames}
|
|
onGameChange={handleGameChange}
|
|
priceRange={priceRange}
|
|
onPriceChange={handlePriceChange}
|
|
onlyOnline={onlyOnline}
|
|
onOnlineChange={setOnlyOnline}
|
|
minRating={minRating}
|
|
onRatingChange={setMinRating}
|
|
/>
|
|
</div>
|
|
<SheetFooter>
|
|
<SheetClose asChild>
|
|
<Button className="w-full">查看 {sortedResults.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
|
|
games={games}
|
|
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">
|
|
共找到 {sortedResults.length} 位陪玩
|
|
</div>
|
|
</div>
|
|
|
|
{displayedResults.length > 0 ? (
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
|
{displayedResults.map((result) => (
|
|
<div key={`${result.type}-${result.id}`}>
|
|
{result.type === "player" ? (
|
|
<PlayerCard player={result.item} />
|
|
) : (
|
|
<ShopCard item={result.item} />
|
|
)}
|
|
</div>
|
|
))}
|
|
</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>
|
|
)}
|
|
|
|
{sortedResults.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>
|
|
)
|
|
}
|