feat: search, player detail, shop detail, order flow, chat, review, and dispute pages

This commit is contained in:
zetaloop
2026-02-20 15:10:31 +08:00
parent e2b47681a3
commit 6ae5e533c1
10 changed files with 1865 additions and 34 deletions
+207 -4
View File
@@ -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 ( return (
<div className="container mx-auto py-8 px-4"> <div className="container mx-auto py-8 px-4 max-w-5xl">
<h1 className="text-2xl font-bold"></h1> <div className="flex flex-col md:flex-row gap-8 mb-8">
<p className="mt-2 text-muted-foreground"></p> <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> </div>
) )
} }
+571 -4
View File
@@ -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 ( return (
<div className="container mx-auto py-8 px-4"> <Badge variant="outline" className="bg-green-50 text-green-700 border-green-200 gap-1">
<h1 className="text-2xl font-bold"></h1> <CheckCircle2 className="w-3 h-3" />
<p className="mt-2 text-muted-foreground"></p>
</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 (
<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> </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>
)
}
+269 -4
View File
@@ -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 ( return (
<div className="container mx-auto py-8 px-4"> <div className="container mx-auto py-6 space-y-8">
<h1 className="text-2xl font-bold"></h1> {sortedSections.map((section) => {
<p className="mt-2 text-muted-foreground"></p> 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> </div>
) )
} }
+122 -4
View File
@@ -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 ( return (
<div className="container mx-auto py-8 px-4"> <div className="container mx-auto py-8 px-4 text-center text-muted-foreground">
<h1 className="text-2xl font-bold"></h1>
<p className="mt-2 text-muted-foreground"></p> </div>
)
}
const other = session.participants[1]
const currentUserId = session.participants[0].id
return (
<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> </div>
) )
} }
+56 -3
View File
@@ -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() { export default function ChatListPage() {
return ( return (
<div className="container mx-auto py-8 px-4"> <div className="container mx-auto py-8 px-4 max-w-2xl">
<h1 className="text-2xl font-bold"></h1> <h1 className="text-2xl font-bold mb-6"></h1>
<p className="mt-2 text-muted-foreground"></p>
<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> </div>
) )
} }
+172 -4
View File
@@ -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 ( return (
<div className="container mx-auto py-8 px-4"> <div className="container mx-auto py-8 px-4 text-center text-muted-foreground">
<h1 className="text-2xl font-bold"></h1>
<p className="mt-2 text-muted-foreground"></p> </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 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> </div>
) )
} }
+235 -3
View File
@@ -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 ( return (
<div className="container mx-auto py-8 px-4"> <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> <h1 className="text-2xl font-bold"></h1>
<p className="mt-2 text-muted-foreground"></p> <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> </div>
) )
} }
+124 -1
View File
@@ -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() { 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 ( return (
<div className="container mx-auto py-8 px-4"> <div className="container mx-auto py-8 px-4">
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold"></h1> <h1 className="text-2xl font-bold"></h1>
<p className="mt-2 text-muted-foreground"></p> <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> </div>
) )
} }
+106 -4
View File
@@ -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 ( return (
<div className="container mx-auto py-8 px-4"> <div className="container mx-auto py-8 px-4 text-center text-muted-foreground">
<h1 className="text-2xl font-bold"></h1>
<p className="mt-2 text-muted-foreground"></p> </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 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> </div>
) )
} }
+1 -1
View File
@@ -1,6 +1,6 @@
/// <reference types="next" /> /// <reference types="next" />
/// <reference types="next/image-types/global" /> /// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts" import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited // NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. // see https://nextjs.org/docs/app/api-reference/config/typescript for more information.