fix: restore search mixed results and order entry links
This commit is contained in:
+181
-25
@@ -42,8 +42,8 @@ import {
|
|||||||
} from "@/components/ui/sheet"
|
} from "@/components/ui/sheet"
|
||||||
import { Switch } from "@/components/ui/switch"
|
import { Switch } from "@/components/ui/switch"
|
||||||
import { GameIcon } from "@/lib/game-icons"
|
import { GameIcon } from "@/lib/game-icons"
|
||||||
import { mockGames, mockPlayers } from "@/lib/mock"
|
import { mockGames, mockPlayers, mockServices, mockShops } from "@/lib/mock"
|
||||||
import type { Player } from "@/lib/types"
|
import type { Player, Shop } from "@/lib/types"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
function StatusBadge({ status }: { status: Player["status"] }) {
|
function StatusBadge({ status }: { status: Player["status"] }) {
|
||||||
@@ -165,6 +165,95 @@ function PlayerCard({ player }: { player: Player }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
interface FilterProps {
|
||||||
selectedGames: string[]
|
selectedGames: string[]
|
||||||
onGameChange: (game: string, checked: boolean) => void
|
onGameChange: (game: string, checked: boolean) => void
|
||||||
@@ -316,6 +405,29 @@ function SearchPageContent() {
|
|||||||
setPriceRange((prev) => ({ ...prev, [type]: value }))
|
setPriceRange((prev) => ({ ...prev, [type]: value }))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const shopResultItems = useMemo<ShopResultItem[]>(() => {
|
||||||
|
return mockShops.map((shop) => {
|
||||||
|
const shopPlayers = mockPlayers.filter((player) => player.shopId === shop.id)
|
||||||
|
const playerIds = new Set(shopPlayers.map((player) => player.id))
|
||||||
|
const services = mockServices.filter((service) => playerIds.has(service.playerId))
|
||||||
|
const minPrice =
|
||||||
|
services.length > 0 ? Math.min(...services.map((service) => service.price)) : 0
|
||||||
|
const unit =
|
||||||
|
services.length > 0
|
||||||
|
? services.reduce((prev, curr) => (prev.price < curr.price ? prev : curr)).unit
|
||||||
|
: "局"
|
||||||
|
const games = [...new Set(services.map((service) => service.gameName))]
|
||||||
|
const hasAvailable = shopPlayers.some((player) => player.status === "available")
|
||||||
|
return {
|
||||||
|
shop,
|
||||||
|
minPrice,
|
||||||
|
unit,
|
||||||
|
games,
|
||||||
|
hasAvailable,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
const filteredPlayers = useMemo(() => {
|
const filteredPlayers = useMemo(() => {
|
||||||
return mockPlayers.filter((player) => {
|
return mockPlayers.filter((player) => {
|
||||||
if (searchQuery) {
|
if (searchQuery) {
|
||||||
@@ -346,35 +458,73 @@ function SearchPageContent() {
|
|||||||
})
|
})
|
||||||
}, [searchQuery, selectedGames, priceRange, onlyOnline, minRating])
|
}, [searchQuery, selectedGames, priceRange, onlyOnline, minRating])
|
||||||
|
|
||||||
const sortedPlayers = useMemo(() => {
|
const filteredShops = useMemo(() => {
|
||||||
return [...filteredPlayers].sort((a, b) => {
|
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) {
|
switch (sortBy) {
|
||||||
case "rating":
|
case "rating":
|
||||||
return b.rating - a.rating
|
return b.rating - a.rating
|
||||||
case "price_asc": {
|
case "price_asc":
|
||||||
const priceA = Math.min(...a.services.map((s) => s.price))
|
return a.minPrice - b.minPrice
|
||||||
const priceB = Math.min(...b.services.map((s) => s.price))
|
case "price_desc":
|
||||||
return priceA - priceB
|
return b.minPrice - a.minPrice
|
||||||
}
|
|
||||||
case "price_desc": {
|
|
||||||
const priceA = Math.min(...a.services.map((s) => s.price))
|
|
||||||
const priceB = Math.min(...b.services.map((s) => s.price))
|
|
||||||
return priceB - priceA
|
|
||||||
}
|
|
||||||
case "orders":
|
case "orders":
|
||||||
return b.totalOrders - a.totalOrders
|
return b.orders - a.orders
|
||||||
case "composite": {
|
case "composite": {
|
||||||
const scoreA = a.rating * Math.log10(a.totalOrders + 1)
|
const scoreA = a.rating * Math.log10(a.orders + 1)
|
||||||
const scoreB = b.rating * Math.log10(b.totalOrders + 1)
|
const scoreB = b.rating * Math.log10(b.orders + 1)
|
||||||
return scoreB - scoreA
|
return scoreB - scoreA
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}, [filteredPlayers, sortBy])
|
}, [filteredPlayers, filteredShops, sortBy])
|
||||||
|
|
||||||
const displayedPlayers = sortedPlayers.slice(0, visibleCount)
|
const displayedResults = sortedResults.slice(0, visibleCount)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto px-4 py-6 min-h-screen">
|
<div className="container mx-auto px-4 py-6 min-h-screen">
|
||||||
@@ -421,7 +571,7 @@ function SearchPageContent() {
|
|||||||
</div>
|
</div>
|
||||||
<SheetFooter>
|
<SheetFooter>
|
||||||
<SheetClose asChild>
|
<SheetClose asChild>
|
||||||
<Button className="w-full">查看 {filteredPlayers.length} 个结果</Button>
|
<Button className="w-full">查看 {sortedResults.length} 个结果</Button>
|
||||||
</SheetClose>
|
</SheetClose>
|
||||||
</SheetFooter>
|
</SheetFooter>
|
||||||
</SheetContent>
|
</SheetContent>
|
||||||
@@ -509,14 +659,20 @@ function SearchPageContent() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-muted-foreground hidden sm:block">
|
<div className="text-sm text-muted-foreground hidden sm:block">
|
||||||
共找到 {filteredPlayers.length} 位陪玩
|
共找到 {sortedResults.length} 位陪玩
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{displayedPlayers.length > 0 ? (
|
{displayedResults.length > 0 ? (
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||||
{displayedPlayers.map((player) => (
|
{displayedResults.map((result) => (
|
||||||
<PlayerCard key={player.id} player={player} />
|
<div key={`${result.type}-${result.id}`}>
|
||||||
|
{result.type === "player" ? (
|
||||||
|
<PlayerCard player={result.item} />
|
||||||
|
) : (
|
||||||
|
<ShopCard item={result.item} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -544,7 +700,7 @@ function SearchPageContent() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{filteredPlayers.length > visibleCount && (
|
{sortedResults.length > visibleCount && (
|
||||||
<div className="mt-8 text-center">
|
<div className="mt-8 text-center">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { notFound } from "next/navigation"
|
|||||||
import { FavoriteButton } from "@/components/favorite-button"
|
import { FavoriteButton } from "@/components/favorite-button"
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
import { Separator } from "@/components/ui/separator"
|
import { Separator } from "@/components/ui/separator"
|
||||||
import { mockFavorites, mockPlayers, mockReviews, mockServices, mockShops } from "@/lib/mock"
|
import { mockFavorites, mockPlayers, mockReviews, mockServices, mockShops } from "@/lib/mock"
|
||||||
@@ -155,6 +156,9 @@ export default async function ShopPage({ params }: PageProps) {
|
|||||||
段位:{service.rankRange}
|
段位:{service.rankRange}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
<Button className="w-full mt-3" size="sm" asChild>
|
||||||
|
<Link href={`/order/new?serviceId=${service.id}`}>立即下单</Link>
|
||||||
|
</Button>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -146,7 +146,7 @@ export default function OrderListPage() {
|
|||||||
})()}
|
})()}
|
||||||
{order.status === "completed" && (
|
{order.status === "completed" && (
|
||||||
<Button variant="outline" size="sm" asChild>
|
<Button variant="outline" size="sm" asChild>
|
||||||
<Link href={`/order/new?playerId=${order.playerId}`}>
|
<Link href={`/order/new?serviceId=${order.service.id}`}>
|
||||||
<RefreshCw className="mr-1 h-3.5 w-3.5" />
|
<RefreshCw className="mr-1 h-3.5 w-3.5" />
|
||||||
再来一单
|
再来一单
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
Reference in New Issue
Block a user