feat: init

This commit is contained in:
Simon
2025-09-29 16:33:15 +08:00
parent e8041e0582
commit 847620b5e8
98 changed files with 20166 additions and 0 deletions

View File

@@ -0,0 +1,448 @@
import { useState, useEffect } from 'react'
import { Link } from 'wouter'
interface Product {
id: number
name: string
category: string
price: number
stock: number
rating: number
image: string
description: string
tags: string[]
}
const generateProducts = (count: number): Product[] => {
const categories = ['手机', '电脑', '平板', '耳机', '手表', '相机']
const brands = ['Apple', 'Samsung', 'Huawei', 'Xiaomi', 'Sony', 'Dell']
const adjectives = ['Pro', 'Max', 'Ultra', 'Plus', 'Air', 'Mini']
return Array.from({ length: count }, (_, i) => ({
id: i + 1,
name: `${brands[i % brands.length]} ${categories[i % categories.length]} ${adjectives[i % adjectives.length]}`,
category: categories[i % categories.length],
price: Math.floor(Math.random() * 10000) + 500,
stock: Math.floor(Math.random() * 100),
rating: Math.round((Math.random() * 2 + 3) * 10) / 10,
image: `https://picsum.photos/200/200?random=${i}`,
description: `这是一款优秀的${categories[i % categories.length]}产品,具有出色的性能和设计。`,
tags: ['热销', '新品', '推荐'].slice(0, Math.floor(Math.random() * 3) + 1)
}))
}
export default function ListTestPage() {
const [products, setProducts] = useState<Product[]>([])
const [filteredProducts, setFilteredProducts] = useState<Product[]>([])
const [loading, setLoading] = useState(true)
const [searchTerm, setSearchTerm] = useState('')
const [selectedCategory, setSelectedCategory] = useState('全部')
const [sortBy, setSortBy] = useState('name')
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('asc')
const [currentPage, setCurrentPage] = useState(1)
const [itemsPerPage, setItemsPerPage] = useState(12)
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid')
const categories = ['全部', '手机', '电脑', '平板', '耳机', '手表', '相机']
// 模拟数据加载
useEffect(() => {
const loadData = async () => {
setLoading(true)
// 模拟网络延迟
await new Promise(resolve => setTimeout(resolve, 1500))
const data = generateProducts(150)
setProducts(data)
setFilteredProducts(data)
setLoading(false)
}
loadData()
}, [])
// 搜索和过滤
useEffect(() => {
let filtered = products
// 按类别过滤
if (selectedCategory !== '全部') {
filtered = filtered.filter(product => product.category === selectedCategory)
}
// 按搜索词过滤
if (searchTerm) {
filtered = filtered.filter(product =>
product.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
product.description.toLowerCase().includes(searchTerm.toLowerCase())
)
}
// 排序
filtered.sort((a, b) => {
let aValue: any = a[sortBy as keyof Product]
let bValue: any = b[sortBy as keyof Product]
if (typeof aValue === 'string') {
aValue = aValue.toLowerCase()
bValue = bValue.toLowerCase()
}
if (sortOrder === 'asc') {
return aValue > bValue ? 1 : -1
} else {
return aValue < bValue ? 1 : -1
}
})
setFilteredProducts(filtered)
setCurrentPage(1) // 重置到第一页
}, [products, searchTerm, selectedCategory, sortBy, sortOrder])
// 分页计算
const totalPages = Math.ceil(filteredProducts.length / itemsPerPage)
const startIndex = (currentPage - 1) * itemsPerPage
const endIndex = startIndex + itemsPerPage
const currentProducts = filteredProducts.slice(startIndex, endIndex)
const handlePageChange = (page: number) => {
setCurrentPage(page)
// 滚动到顶部
window.scrollTo({ top: 0, behavior: 'smooth' })
}
const LoadingSkeleton = () => (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
{Array.from({ length: 12 }).map((_, i) => (
<div key={i} className="bg-white dark:bg-gray-800 rounded-lg shadow p-4 animate-pulse">
<div className="bg-gray-300 dark:bg-gray-600 h-48 rounded-lg mb-4"></div>
<div className="space-y-2">
<div className="bg-gray-300 dark:bg-gray-600 h-4 rounded w-3/4"></div>
<div className="bg-gray-300 dark:bg-gray-600 h-4 rounded w-1/2"></div>
<div className="bg-gray-300 dark:bg-gray-600 h-4 rounded w-1/4"></div>
</div>
</div>
))}
</div>
)
const ProductCard = ({ product }: { product: Product }) => (
<div className="bg-white dark:bg-gray-800 rounded-lg shadow hover:shadow-lg transition-shadow p-4">
<div className="relative mb-4">
<img
src={product.image}
alt={product.name}
className="w-full h-48 object-cover rounded-lg"
loading="lazy"
/>
<div className="absolute top-2 right-2 flex flex-wrap gap-1">
{product.tags.map((tag, index) => (
<span
key={index}
className="bg-red-500 text-white text-xs px-2 py-1 rounded-full"
>
{tag}
</span>
))}
</div>
</div>
<h3 className="font-semibold text-gray-900 dark:text-white mb-2 line-clamp-2">
{product.name}
</h3>
<p className="text-gray-600 dark:text-gray-300 text-sm mb-3 line-clamp-2">
{product.description}
</p>
<div className="flex items-center justify-between mb-3">
<span className="text-2xl font-bold text-blue-600 dark:text-blue-400">
¥{product.price.toLocaleString()}
</span>
<div className="flex items-center">
<span className="text-yellow-400"></span>
<span className="text-sm text-gray-600 dark:text-gray-300 ml-1">
{product.rating}
</span>
</div>
</div>
<div className="flex items-center justify-between mb-4">
<span className="text-sm text-gray-500 dark:text-gray-400">
: {product.stock}
</span>
<span className="text-sm text-gray-500 dark:text-gray-400">
{product.category}
</span>
</div>
<button className="w-full bg-blue-600 hover:bg-blue-700 text-white py-2 px-4 rounded-md transition-colors">
</button>
</div>
)
const ProductListItem = ({ product }: { product: Product }) => (
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-4 flex items-center space-x-4">
<img
src={product.image}
alt={product.name}
className="w-20 h-20 object-cover rounded-lg flex-shrink-0"
loading="lazy"
/>
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-gray-900 dark:text-white mb-1">
{product.name}
</h3>
<p className="text-gray-600 dark:text-gray-300 text-sm mb-2 line-clamp-1">
{product.description}
</p>
<div className="flex items-center space-x-4 text-sm text-gray-500 dark:text-gray-400">
<span>{product.category}</span>
<span>: {product.stock}</span>
<div className="flex items-center">
<span className="text-yellow-400"></span>
<span className="ml-1">{product.rating}</span>
</div>
</div>
</div>
<div className="flex items-center space-x-4">
<span className="text-xl font-bold text-blue-600 dark:text-blue-400">
¥{product.price.toLocaleString()}
</span>
<button className="bg-blue-600 hover:bg-blue-700 text-white py-2 px-4 rounded-md transition-colors">
</button>
</div>
</div>
)
const Pagination = () => {
const getPageNumbers = () => {
const pages = []
const maxVisible = 5
let start = Math.max(1, currentPage - Math.floor(maxVisible / 2))
const end = Math.min(totalPages, start + maxVisible - 1)
if (end - start + 1 < maxVisible) {
start = Math.max(1, end - maxVisible + 1)
}
for (let i = start; i <= end; i++) {
pages.push(i)
}
return pages
}
return (
<div className="flex items-center justify-between mt-8">
<div className="text-sm text-gray-700 dark:text-gray-300">
{startIndex + 1}-{Math.min(endIndex, filteredProducts.length)}
{filteredProducts.length}
</div>
<div className="flex items-center space-x-2">
<button
onClick={() => handlePageChange(currentPage - 1)}
disabled={currentPage === 1}
className="px-3 py-2 text-sm font-medium text-gray-500 bg-white border border-gray-300 rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed dark:bg-gray-800 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700"
>
</button>
{getPageNumbers().map(page => (
<button
key={page}
onClick={() => handlePageChange(page)}
className={`px-3 py-2 text-sm font-medium rounded-md ${
page === currentPage
? 'bg-blue-600 text-white'
: 'text-gray-500 bg-white border border-gray-300 hover:bg-gray-50 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700'
}`}
>
{page}
</button>
))}
<button
onClick={() => handlePageChange(currentPage + 1)}
disabled={currentPage === totalPages}
className="px-3 py-2 text-sm font-medium text-gray-500 bg-white border border-gray-300 rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed dark:bg-gray-800 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700"
>
</button>
</div>
</div>
)
}
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-8">
<div className="max-w-7xl mx-auto px-4">
<div className="mb-8">
<h1 className="text-3xl font-bold text-gray-900 dark:text-white mb-2">
</h1>
<p className="text-gray-600 dark:text-gray-300">
</p>
</div>
{/* 搜索和过滤栏 */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6 mb-8">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-4">
{/* 搜索框 */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
</label>
<input
type="text"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder="输入产品名称或描述..."
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white"
/>
</div>
{/* 类别过滤 */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
</label>
<select
value={selectedCategory}
onChange={(e) => setSelectedCategory(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white"
>
{categories.map(category => (
<option key={category} value={category}>{category}</option>
))}
</select>
</div>
{/* 排序方式 */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
</label>
<select
value={sortBy}
onChange={(e) => setSortBy(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white"
>
<option value="name"></option>
<option value="price"></option>
<option value="rating"></option>
<option value="stock"></option>
</select>
</div>
{/* 排序顺序 */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
</label>
<select
value={sortOrder}
onChange={(e) => setSortOrder(e.target.value as 'asc' | 'desc')}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white"
>
<option value="asc"></option>
<option value="desc"></option>
</select>
</div>
</div>
{/* 视图控制 */}
<div className="flex items-center justify-between">
<div className="flex items-center space-x-4">
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
:
</span>
<select
value={itemsPerPage}
onChange={(e) => setItemsPerPage(Number(e.target.value))}
className="px-3 py-1 border border-gray-300 dark:border-gray-600 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white"
>
<option value={12}>12</option>
<option value={24}>24</option>
<option value={48}>48</option>
</select>
</div>
<div className="flex items-center space-x-2">
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
:
</span>
<button
onClick={() => setViewMode('grid')}
className={`p-2 rounded-md ${
viewMode === 'grid'
? 'bg-blue-600 text-white'
: 'bg-gray-200 dark:bg-gray-600 text-gray-700 dark:text-gray-300'
}`}
>
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path d="M5 3a2 2 0 00-2 2v2a2 2 0 002 2h2a2 2 0 002-2V5a2 2 0 00-2-2H5zM5 11a2 2 0 00-2 2v2a2 2 0 002 2h2a2 2 0 002-2v-2a2 2 0 00-2-2H5zM11 5a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V5zM11 13a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z" />
</svg>
</button>
<button
onClick={() => setViewMode('list')}
className={`p-2 rounded-md ${
viewMode === 'list'
? 'bg-blue-600 text-white'
: 'bg-gray-200 dark:bg-gray-600 text-gray-700 dark:text-gray-300'
}`}
>
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path d="M3 4a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zM3 8a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zM3 12a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zM3 16a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1z" />
</svg>
</button>
</div>
</div>
</div>
{/* 产品列表 */}
{loading ? (
<LoadingSkeleton />
) : filteredProducts.length === 0 ? (
<div className="text-center py-12">
<div className="text-6xl mb-4">🔍</div>
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-2">
</h3>
<p className="text-gray-600 dark:text-gray-300">
</p>
</div>
) : (
<>
{viewMode === 'grid' ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
{currentProducts.map(product => (
<ProductCard key={product.id} product={product} />
))}
</div>
) : (
<div className="space-y-4">
{currentProducts.map(product => (
<ProductListItem key={product.id} product={product} />
))}
</div>
)}
<Pagination />
</>
)}
{/* 返回顶部按钮 */}
<button
onClick={() => window.scrollTo({ top: 0, behavior: 'smooth' })}
className="fixed bottom-8 right-8 bg-blue-600 hover:bg-blue-700 text-white p-3 rounded-full shadow-lg transition-colors"
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 10l7-7m0 0l7 7m-7-7v18" />
</svg>
</button>
{/* 返回链接 */}
<div className="mt-12 pt-8 border-t border-gray-200 dark:border-gray-700">
<Link href="/" className="text-blue-600 hover:text-blue-500 dark:text-blue-400">
</Link>
</div>
</div>
</div>
)
}