feat: 导航收藏网站 v1.0

功能:
- 网站收藏管理(添加/编辑/删除/收藏)
- 分类管理(支持自定义图标和颜色)
- 搜索功能
- 点击统计
- 现代暗色主题 UI

技术栈:
- 后端: Python Flask + MySQL
- 前端: React + Vite + Tailwind CSS
- 数据库: MySQL (192.168.8.160)
- 端口: 后端 5003, 前端 5173
This commit is contained in:
小龙
2026-04-06 10:40:43 +00:00
commit a1951c3aeb
6825 changed files with 939030 additions and 0 deletions
+639
View File
@@ -0,0 +1,639 @@
import React, { useState, useEffect } from 'react'
import {
Search, Plus, FolderOpen, Star, ExternalLink,
Trash2, Edit3, X, Check, Globe, Bookmark,
MessageCircle, Code, Briefcase, Gamepad2, BookOpen, Folder
} from 'lucide-react'
import { categoryApi, websiteApi } from './api'
// 图标映射
const iconMap = {
'message-circle': MessageCircle,
'code': Code,
'briefcase': Briefcase,
'gamepad-2': Gamepad2,
'book-open': BookOpen,
'folder': Folder,
'globe': Globe,
'star': Star,
}
function App() {
const [categories, setCategories] = useState([])
const [websites, setWebsites] = useState([])
const [selectedCategory, setSelectedCategory] = useState(null)
const [searchQuery, setSearchQuery] = useState('')
const [showAddModal, setShowAddModal] = useState(false)
const [showCategoryModal, setShowCategoryModal] = useState(false)
const [editingWebsite, setEditingWebsite] = useState(null)
const [editingCategory, setEditingCategory] = useState(null)
const [loading, setLoading] = useState(true)
// 表单状态
const [websiteForm, setWebsiteForm] = useState({
name: '',
url: '',
icon: '',
description: '',
category_id: '',
is_favorite: false,
})
const [categoryForm, setCategoryForm] = useState({
name: '',
icon: 'folder',
color: '#6366f1',
})
// 加载数据
useEffect(() => {
loadData()
}, [])
const loadData = async () => {
setLoading(true)
try {
const [catRes, webRes] = await Promise.all([
categoryApi.getAll(),
websiteApi.getAll(),
])
setCategories(catRes.data)
setWebsites(webRes.data)
} catch (error) {
console.error('加载数据失败:', error)
}
setLoading(false)
}
// 过滤网站
const filteredWebsites = websites.filter(site => {
const matchCategory = selectedCategory === null || site.category_id === selectedCategory
const matchSearch = searchQuery === '' ||
site.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
site.description.toLowerCase().includes(searchQuery.toLowerCase())
return matchCategory && matchSearch
})
// 收藏网站单独显示
const favoriteWebsites = filteredWebsites.filter(s => s.is_favorite)
const normalWebsites = filteredWebsites.filter(s => !s.is_favorite)
// 添加网站
const handleAddWebsite = async () => {
try {
await websiteApi.create({
...websiteForm,
category_id: websiteForm.category_id || null,
})
setShowAddModal(false)
resetWebsiteForm()
loadData()
} catch (error) {
console.error('添加网站失败:', error)
}
}
// 更新网站
const handleUpdateWebsite = async () => {
try {
await websiteApi.update(editingWebsite.id, {
...websiteForm,
category_id: websiteForm.category_id || null,
})
setEditingWebsite(null)
setShowAddModal(false)
resetWebsiteForm()
loadData()
} catch (error) {
console.error('更新网站失败:', error)
}
}
// 删除网站
const handleDeleteWebsite = async (id) => {
if (!confirm('确定要删除这个网站吗?')) return
try {
await websiteApi.delete(id)
loadData()
} catch (error) {
console.error('删除网站失败:', error)
}
}
// 切换收藏
const toggleFavorite = async (site) => {
try {
await websiteApi.update(site.id, { is_favorite: !site.is_favorite })
loadData()
} catch (error) {
console.error('切换收藏失败:', error)
}
}
// 点击网站
const handleClickWebsite = async (site) => {
try {
await websiteApi.click(site.id)
window.open(site.url, '_blank', 'noopener,noreferrer')
} catch (error) {
console.error('点击失败:', error)
window.open(site.url, '_blank', 'noopener,noreferrer')
}
}
// 重置表单
const resetWebsiteForm = () => {
setWebsiteForm({
name: '',
url: '',
icon: '',
description: '',
category_id: '',
is_favorite: false,
})
}
// 添加分类
const handleAddCategory = async () => {
try {
await categoryApi.create(categoryForm)
setShowCategoryModal(false)
setEditingCategory(null)
setCategoryForm({ name: '', icon: 'folder', color: '#6366f1' })
loadData()
} catch (error) {
console.error('添加分类失败:', error)
}
}
// 更新分类
const handleUpdateCategory = async () => {
try {
await categoryApi.update(editingCategory.id, categoryForm)
setEditingCategory(null)
setShowCategoryModal(false)
setCategoryForm({ name: '', icon: 'folder', color: '#6366f1' })
loadData()
} catch (error) {
console.error('更新分类失败:', error)
}
}
// 删除分类
const handleDeleteCategory = async (id) => {
if (!confirm('确定要删除这个分类吗?分类下的网站将变为未分类状态。')) return
try {
await categoryApi.delete(id)
if (selectedCategory === id) setSelectedCategory(null)
loadData()
} catch (error) {
console.error('删除分类失败:', error)
}
}
// 编辑网站
const openEditWebsite = (site) => {
setEditingWebsite(site)
setWebsiteForm({
name: site.name,
url: site.url,
icon: site.icon || '',
description: site.description || '',
category_id: site.category_id || '',
is_favorite: site.is_favorite,
})
setShowAddModal(true)
}
// 编辑分类
const openEditCategory = (cat) => {
setEditingCategory(cat)
setCategoryForm({
name: cat.name,
icon: cat.icon,
color: cat.color,
})
setShowCategoryModal(true)
}
// 获取网站图标
const getWebsiteIcon = (site) => {
if (site.icon && site.icon.startsWith('http')) {
return <img src={site.icon} alt="" className="w-8 h-8 rounded-lg object-cover" />
}
return <Globe className="w-8 h-8 text-indigo-400" />
}
// 渲染图标选择器
const renderIconPicker = () => {
const icons = ['folder', 'globe', 'star', 'message-circle', 'code', 'briefcase', 'gamepad-2', 'book-open']
return (
<div className="flex gap-2 flex-wrap">
{icons.map(icon => {
const IconComponent = iconMap[icon]
return (
<button
key={icon}
type="button"
onClick={() => setCategoryForm({...categoryForm, icon})}
className={`p-3 rounded-lg border-2 transition-all ${
categoryForm.icon === icon
? 'border-indigo-500 bg-indigo-500/20'
: 'border-[#2a2f3a] hover:border-[#3a3f4a]'
}`}
>
<IconComponent className="w-5 h-5" />
</button>
)
})}
</div>
)
}
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="animate-spin rounded-full h-12 w-12 border-4 border-indigo-500 border-t-transparent"></div>
</div>
)
}
return (
<div className="min-h-screen bg-[#0d0f12]">
{/* 头部 */}
<header className="sticky top-0 z-40 glass border-b border-[#2a2f3a]">
<div className="max-w-7xl mx-auto px-4 py-4">
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-indigo-500 via-purple-500 to-pink-500 flex items-center justify-center">
<Bookmark className="w-5 h-5 text-white" />
</div>
<h1 className="text-xl font-bold gradient-text hidden sm:block">导航收藏夹</h1>
</div>
{/* 搜索 */}
<div className="flex-1 max-w-md relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-[#64748b]" />
<input
type="text"
placeholder="搜索网站..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-10 pr-4 py-2.5 bg-[#1a1d24] border border-[#2a2f3a] rounded-xl text-[#f1f5f9] placeholder-[#64748b] transition-all"
/>
</div>
{/* 操作按钮 */}
<div className="flex items-center gap-2">
<button
onClick={() => { setEditingCategory(null); setShowCategoryModal(true); setCategoryForm({ name: '', icon: 'folder', color: '#6366f1' }) }}
className="p-2.5 bg-[#1a1d24] border border-[#2a2f3a] rounded-xl hover:border-[#3a3f4a] transition-all btn-press"
title="添加分类"
>
<FolderOpen className="w-5 h-5 text-[#94a3b8]" />
</button>
<button
onClick={() => { setEditingWebsite(null); setShowAddModal(true); resetWebsiteForm() }}
className="flex items-center gap-2 px-4 py-2.5 bg-gradient-to-r from-indigo-500 to-purple-500 rounded-xl font-medium hover:opacity-90 transition-all btn-press"
>
<Plus className="w-5 h-5" />
<span className="hidden sm:inline">添加网站</span>
</button>
</div>
</div>
</div>
</header>
<main className="max-w-7xl mx-auto px-4 py-6">
{/* 分类标签 */}
<div className="flex items-center gap-2 mb-8 overflow-x-auto pb-2">
<button
onClick={() => setSelectedCategory(null)}
className={`px-4 py-2 rounded-xl font-medium whitespace-nowrap transition-all ${
selectedCategory === null
? 'bg-gradient-to-r from-indigo-500 to-purple-500 text-white'
: 'bg-[#1a1d24] text-[#94a3b8] hover:text-[#f1f5f9] border border-[#2a2f3a]'
}`}
>
全部
</button>
{categories.map(cat => {
const IconComponent = iconMap[cat.icon] || Folder
return (
<div
key={cat.id}
className="flex items-center gap-1 group"
>
<button
onClick={() => setSelectedCategory(cat.id)}
className={`flex items-center gap-2 px-4 py-2 rounded-xl font-medium whitespace-nowrap transition-all ${
selectedCategory === cat.id
? 'text-white'
: 'bg-[#1a1d24] text-[#94a3b8] hover:text-[#f1f5f9] border border-[#2a2f3a]'
}`}
style={selectedCategory === cat.id ? { background: `linear-gradient(135deg, ${cat.color}99, ${cat.color}66)` } : {}}
>
<IconComponent className="w-4 h-4" style={{ color: cat.color }} />
{cat.name}
</button>
<div className="hidden group-hover:flex items-center gap-1">
<button
onClick={() => openEditCategory(cat)}
className="p-1 hover:bg-[#2a2f3a] rounded"
>
<Edit3 className="w-3 h-3 text-[#64748b]" />
</button>
<button
onClick={() => handleDeleteCategory(cat.id)}
className="p-1 hover:bg-red-500/20 rounded"
>
<Trash2 className="w-3 h-3 text-red-400" />
</button>
</div>
</div>
)
})}
</div>
{/* 收藏网站 */}
{favoriteWebsites.length > 0 && (
<section className="mb-8">
<h2 className="flex items-center gap-2 text-lg font-semibold text-[#f1f5f9] mb-4">
<Star className="w-5 h-5 text-yellow-500 fill-yellow-500" />
我的收藏
</h2>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4">
{favoriteWebsites.map(site => (
<WebsiteCard
key={site.id}
site={site}
onClick={() => handleClickWebsite(site)}
onToggleFavorite={() => toggleFavorite(site)}
onEdit={() => openEditWebsite(site)}
onDelete={() => handleDeleteWebsite(site.id)}
getIcon={getWebsiteIcon}
/>
))}
</div>
</section>
)}
{/* 普通网站 */}
{normalWebsites.length > 0 && (
<section>
<h2 className="text-lg font-semibold text-[#f1f5f9] mb-4">
{selectedCategory
? categories.find(c => c.id === selectedCategory)?.name
: '全部网站'}
<span className="ml-2 text-[#64748b] font-normal">({normalWebsites.length})</span>
</h2>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4">
{normalWebsites.map(site => (
<WebsiteCard
key={site.id}
site={site}
onClick={() => handleClickWebsite(site)}
onToggleFavorite={() => toggleFavorite(site)}
onEdit={() => openEditWebsite(site)}
onDelete={() => handleDeleteWebsite(site.id)}
getIcon={getWebsiteIcon}
/>
))}
</div>
</section>
)}
{/* 空状态 */}
{filteredWebsites.length === 0 && (
<div className="text-center py-20">
<div className="w-20 h-20 mx-auto mb-4 rounded-2xl bg-[#1a1d24] flex items-center justify-center">
<Globe className="w-10 h-10 text-[#64748b]" />
</div>
<h3 className="text-xl font-semibold text-[#f1f5f9] mb-2">暂无网站</h3>
<p className="text-[#64748b] mb-6">点击上方按钮添加你的第一个收藏网站</p>
<button
onClick={() => { setEditingWebsite(null); setShowAddModal(true); resetWebsiteForm() }}
className="inline-flex items-center gap-2 px-6 py-3 bg-gradient-to-r from-indigo-500 to-purple-500 rounded-xl font-medium hover:opacity-90 transition-all"
>
<Plus className="w-5 h-5" />
添加网站
</button>
</div>
)}
</main>
{/* 添加/编辑网站弹窗 */}
{showAddModal && (
<Modal onClose={() => { setShowAddModal(false); setEditingWebsite(null); resetWebsiteForm() }}>
<h2 className="text-xl font-bold text-[#f1f5f9] mb-6">
{editingWebsite ? '编辑网站' : '添加网站'}
</h2>
<form onSubmit={(e) => { e.preventDefault(); editingWebsite ? handleUpdateWebsite() : handleAddWebsite() }} className="space-y-4">
<div>
<label className="block text-sm font-medium text-[#94a3b8] mb-2">网站名称 *</label>
<input
type="text"
required
value={websiteForm.name}
onChange={(e) => setWebsiteForm({...websiteForm, name: e.target.value})}
className="w-full px-4 py-2.5 bg-[#15181f] border border-[#2a2f3a] rounded-xl text-[#f1f5f9]"
placeholder="例如:GitHub"
/>
</div>
<div>
<label className="block text-sm font-medium text-[#94a3b8] mb-2">网站链接 *</label>
<input
type="url"
required
value={websiteForm.url}
onChange={(e) => setWebsiteForm({...websiteForm, url: e.target.value})}
className="w-full px-4 py-2.5 bg-[#15181f] border border-[#2a2f3a] rounded-xl text-[#f1f5f9]"
placeholder="https://github.com"
/>
</div>
<div>
<label className="block text-sm font-medium text-[#94a3b8] mb-2">图标链接可选</label>
<input
type="url"
value={websiteForm.icon}
onChange={(e) => setWebsiteForm({...websiteForm, icon: e.target.value})}
className="w-full px-4 py-2.5 bg-[#15181f] border border-[#2a2f3a] rounded-xl text-[#f1f5f9]"
placeholder="https://example.com/favicon.ico"
/>
</div>
<div>
<label className="block text-sm font-medium text-[#94a3b8] mb-2">描述</label>
<input
type="text"
value={websiteForm.description}
onChange={(e) => setWebsiteForm({...websiteForm, description: e.target.value})}
className="w-full px-4 py-2.5 bg-[#15181f] border border-[#2a2f3a] rounded-xl text-[#f1f5f9]"
placeholder="简短描述(可选)"
/>
</div>
<div>
<label className="block text-sm font-medium text-[#94a3b8] mb-2">分类</label>
<select
value={websiteForm.category_id}
onChange={(e) => setWebsiteForm({...websiteForm, category_id: e.target.value})}
className="w-full px-4 py-2.5 bg-[#15181f] border border-[#2a2f3a] rounded-xl text-[#f1f5f9]"
>
<option value="">未分类</option>
{categories.map(cat => (
<option key={cat.id} value={cat.id}>{cat.name}</option>
))}
</select>
</div>
<div className="flex items-center gap-3">
<input
type="checkbox"
id="favorite"
checked={websiteForm.is_favorite}
onChange={(e) => setWebsiteForm({...websiteForm, is_favorite: e.target.checked})}
className="w-5 h-5 rounded border-[#2a2f3a] bg-[#15181f] text-indigo-500 focus:ring-indigo-500"
/>
<label htmlFor="favorite" className="text-[#94a3b8]">添加到收藏</label>
</div>
<div className="flex gap-3 pt-4">
<button
type="button"
onClick={() => { setShowAddModal(false); setEditingWebsite(null); resetWebsiteForm() }}
className="flex-1 px-4 py-2.5 bg-[#1a1d24] border border-[#2a2f3a] rounded-xl text-[#94a3b8] hover:text-[#f1f5f9] transition-all"
>
取消
</button>
<button
type="submit"
className="flex-1 px-4 py-2.5 bg-gradient-to-r from-indigo-500 to-purple-500 rounded-xl font-medium hover:opacity-90 transition-all"
>
{editingWebsite ? '保存' : '添加'}
</button>
</div>
</form>
</Modal>
)}
{/* 添加/编辑分类弹窗 */}
{showCategoryModal && (
<Modal onClose={() => { setShowCategoryModal(false); setEditingCategory(null) }}>
<h2 className="text-xl font-bold text-[#f1f5f9] mb-6">
{editingCategory ? '编辑分类' : '添加分类'}
</h2>
<form onSubmit={(e) => { e.preventDefault(); editingCategory ? handleUpdateCategory() : handleAddCategory() }} className="space-y-4">
<div>
<label className="block text-sm font-medium text-[#94a3b8] mb-2">分类名称 *</label>
<input
type="text"
required
value={categoryForm.name}
onChange={(e) => setCategoryForm({...categoryForm, name: e.target.value})}
className="w-full px-4 py-2.5 bg-[#15181f] border border-[#2a2f3a] rounded-xl text-[#f1f5f9]"
placeholder="例如:社交媒体"
/>
</div>
<div>
<label className="block text-sm font-medium text-[#94a3b8] mb-2">图标</label>
{renderIconPicker()}
</div>
<div>
<label className="block text-sm font-medium text-[#94a3b8] mb-2">颜色</label>
<input
type="color"
value={categoryForm.color}
onChange={(e) => setCategoryForm({...categoryForm, color: e.target.value})}
className="w-full h-12 bg-[#15181f] border border-[#2a2f3a] rounded-xl cursor-pointer"
/>
</div>
<div className="flex gap-3 pt-4">
<button
type="button"
onClick={() => { setShowCategoryModal(false); setEditingCategory(null) }}
className="flex-1 px-4 py-2.5 bg-[#1a1d24] border border-[#2a2f3a] rounded-xl text-[#94a3b8] hover:text-[#f1f5f9] transition-all"
>
取消
</button>
<button
type="submit"
className="flex-1 px-4 py-2.5 bg-gradient-to-r from-indigo-500 to-purple-500 rounded-xl font-medium hover:opacity-90 transition-all"
>
{editingCategory ? '保存' : '添加'}
</button>
</div>
</form>
</Modal>
)}
</div>
)
}
// 网站卡片组件
function WebsiteCard({ site, onClick, onToggleFavorite, onEdit, onDelete, getIcon }) {
return (
<div className="group relative bg-[#1a1d24] border border-[#2a2f3a] rounded-2xl p-4 card-hover cursor-pointer" onClick={onClick}>
{/* 操作按钮 */}
<div className="absolute top-2 right-2 flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity z-10">
<button
onClick={(e) => { e.stopPropagation(); onToggleFavorite() }}
className={`p-1.5 rounded-lg transition-colors ${site.is_favorite ? 'bg-yellow-500/20 text-yellow-500' : 'bg-[#2a2f3a] text-[#64748b] hover:text-yellow-500'}`}
>
<Star className={`w-3.5 h-3.5 ${site.is_favorite ? 'fill-yellow-500' : ''}`} />
</button>
<button
onClick={(e) => { e.stopPropagation(); onEdit() }}
className="p-1.5 bg-[#2a2f3a] rounded-lg text-[#64748b] hover:text-indigo-400 transition-colors"
>
<Edit3 className="w-3.5 h-3.5" />
</button>
<button
onClick={(e) => { e.stopPropagation(); onDelete() }}
className="p-1.5 bg-[#2a2f3a] rounded-lg text-[#64748b] hover:text-red-400 transition-colors"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
{/* 图标 */}
<div className="mb-3">
{getIcon(site)}
</div>
{/* 信息 */}
<h3 className="font-semibold text-[#f1f5f9] truncate mb-1">{site.name}</h3>
{site.description && (
<p className="text-sm text-[#64748b] truncate">{site.description}</p>
)}
{site.category && (
<span
className="inline-block mt-2 px-2 py-0.5 rounded-md text-xs font-medium"
style={{
backgroundColor: `${site.category.color}20`,
color: site.category.color
}}
>
{site.category.name}
</span>
)}
{/* 外部链接图标 */}
<div className="absolute bottom-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity">
<ExternalLink className="w-4 h-4 text-[#64748b]" />
</div>
</div>
)
}
// 弹窗组件
function Modal({ children, onClose }) {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 modal-backdrop animate-fade-in" onClick={onClose}>
<div
className="w-full max-w-md bg-[#1a1d24] border border-[#2a2f3a] rounded-2xl p-6 animate-scale-in"
onClick={(e) => e.stopPropagation()}
>
{children}
</div>
</div>
)
}
export default App