feat: 重构分类管理功能
- 添加独立分类管理弹窗 - 支持添加、编辑、删除、排序分类 - 10种预设颜色供选择 - 23种预设图标供网站选择
This commit is contained in:
+201
-138
@@ -1,6 +1,19 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { categoryApi, websiteApi } from './api'
|
||||
|
||||
// 预设图标
|
||||
const PRESET_ICONS = [
|
||||
'book', 'briefcase', 'code', 'dollar-sign', 'edit', 'folder', 'globe', 'heart',
|
||||
'home', 'layers', 'link', 'map-pin', 'message-circle', 'monitor', 'music',
|
||||
'package', 'settings', 'shopping-cart', 'star', 'tool', 'truck', 'video', 'zap'
|
||||
]
|
||||
|
||||
// 预设颜色
|
||||
const PRESET_COLORS = [
|
||||
'#667eea', '#764ba2', '#f56565', '#ed8936', '#ecc94b',
|
||||
'#48bb78', '#38b2ac', '#4299e1', '#9f7aea', '#ed64a6'
|
||||
]
|
||||
|
||||
// SVG 图标
|
||||
const Icon = ({ name, size = 16 }) => {
|
||||
const icons = {
|
||||
@@ -9,11 +22,30 @@ const Icon = ({ name, size = 16 }) => {
|
||||
plus: <svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>,
|
||||
folder: <svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>,
|
||||
star: <svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>,
|
||||
globe: <svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>,
|
||||
globe: <svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/></svg>,
|
||||
edit: <svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/></svg>,
|
||||
trash: <svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>,
|
||||
x: <svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>,
|
||||
settings: <svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0 1.82.33l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>,
|
||||
grip: <svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="9" cy="6" r="1"/><circle cx="15" cy="6" r="1"/><circle cx="9" cy="12" r="1"/><circle cx="15" cy="12" r="1"/><circle cx="9" cy="18" r="1"/><circle cx="15" cy="18" r="1"/></svg>,
|
||||
'book': <svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"/><path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"/></svg>,
|
||||
'briefcase': <svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><rect x="2" y="7" width="20" height="14" rx="2"/><path d="M16 21V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16"/></svg>,
|
||||
'code': <svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>,
|
||||
'dollar-sign': <svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="12" y1="1" x2="12" y2="23"/><path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/></svg>,
|
||||
'heart': <svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/></svg>,
|
||||
'home': <svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg>,
|
||||
'layers': <svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><polygon points="12 2 2 7 12 12 22 7 12 2"/><polyline points="2 17 12 22 22 17"/><polyline points="2 12 12 17 22 12"/></svg>,
|
||||
'link': <svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>,
|
||||
'map-pin': <svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/></svg>,
|
||||
'message-circle': <svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"/></svg>,
|
||||
'monitor': <svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>,
|
||||
'music': <svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>,
|
||||
'package': <svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="16.5" y1="9.4" x2="7.5" y2="4.21"/><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/><polyline points="3.27 6.96 12 12.01 20.73 6.96"/><line x1="12" y1="22.08" x2="12" y2="12"/></svg>,
|
||||
'settings': <svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0 1.82.33l.06.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09a1.65 1.65 0 0 0 1-1.51 1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09a1.65 1.65 0 0 0 1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09a1.65 1.65 0 0 0 1-1.51 1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09a1.65 1.65 0 0 0 1.51-1H3a2 2 0 0 1 2-2 2 2 0 0 1-2 2h.09a1.65 1.65 0 0 0 1 1.51 1H3a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1H3a2 2 0 0 1-2 2 2 2 0 0 1 2 2h.09a1.65 1.65 0 0 0 1.51 1H3a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1H3a2 2 0 0 1-2 2 2 2 0 0 1 2 2h.09a1.65 1.65 0 0 0 1.51 1H3a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1H3a2 2 0 0 1-2 2"/></svg>,
|
||||
'shopping-cart': <svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="9" cy="21" r="1"/><circle cx="20" cy="21" r="1"/><path d="M1 1h4l2.68 13.39a2 2 0 0 0 2 1.61h9.72a2 2 0 0 0 2-1.61L23 6H6"/></svg>,
|
||||
'tool': <svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg>,
|
||||
'truck': <svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><rect x="1" y="3" width="15" height="13"/><polygon points="16 8 20 8 20 16 16 16 16 8"/><circle cx="5.5" cy="18.5" r="2.5"/><circle cx="18.5" cy="18.5" r="2.5"/></svg>,
|
||||
'video': <svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><polygon points="23 7 16 12 23 5 23 19 16 19 16 12 23 7"/><rect x="1" y="5" width="15" height="14" rx="2"/></svg>,
|
||||
'zap': <svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg>,
|
||||
}
|
||||
return icons[name] || icons.globe
|
||||
}
|
||||
@@ -24,12 +56,11 @@ function App() {
|
||||
const [selectedCategory, setSelectedCategory] = useState(null)
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [modal, setModal] = useState({ show: false, type: 'website', data: null })
|
||||
const [categoryModal, setCategoryModal] = useState(false)
|
||||
const [categoryForm, setCategoryForm] = useState({ name: '', icon: 'folder', color: '#667eea', sort_order: 0 })
|
||||
const [editingCategory, setEditingCategory] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [showCategoryManager, setShowCategoryManager] = useState(false)
|
||||
const [form, setForm] = useState({
|
||||
name: '', url: '', icon: '', description: '', category_id: '', is_favorite: false,
|
||||
color: '#667eea', icon_name: 'folder', sort_order: 0
|
||||
})
|
||||
const [websiteForm, setWebsiteForm] = useState({ name: '', url: '', icon: '', description: '', category_id: '', is_favorite: false })
|
||||
|
||||
useEffect(() => { loadData() }, [])
|
||||
|
||||
@@ -53,61 +84,84 @@ function App() {
|
||||
return matchCat && matchSearch
|
||||
})
|
||||
|
||||
// 收藏和普通分开
|
||||
const favorites = filteredWebsites.filter(s => s.is_favorite)
|
||||
const normal = filteredWebsites.filter(s => !s.is_favorite)
|
||||
|
||||
// 获取分类对应的网站数量
|
||||
const getCategoryCount = (catId) => websites.filter(w => w.category_id === catId).length
|
||||
|
||||
// 打开弹窗
|
||||
const openModal = (type, data = null) => {
|
||||
if (type === 'website') {
|
||||
setForm(data ? { ...data, category_id: data.category_id || '', is_favorite: !!data.is_favorite } : { name: '', url: '', icon: '', description: '', category_id: '', is_favorite: false, color: '#667eea', icon_name: 'folder', sort_order: 0 })
|
||||
} else {
|
||||
setForm(data ? {
|
||||
name: data.name,
|
||||
icon_name: data.icon || 'folder',
|
||||
color: data.color || '#667eea',
|
||||
sort_order: data.sort_order || 0
|
||||
} : { name: '', icon_name: 'folder', color: '#667eea', sort_order: 0 })
|
||||
}
|
||||
setModal({ show: true, type, data })
|
||||
// 打开网站弹窗
|
||||
const openWebsiteModal = (data = null) => {
|
||||
setWebsiteForm(data ? {
|
||||
name: data.name,
|
||||
url: data.url,
|
||||
icon: data.icon || '',
|
||||
description: data.description || '',
|
||||
category_id: data.category_id || '',
|
||||
is_favorite: !!data.is_favorite
|
||||
} : { name: '', url: '', icon: '', description: '', category_id: selectedCategory || '', is_favorite: false })
|
||||
setModal({ show: true, type: 'website', data })
|
||||
}
|
||||
|
||||
const closeModal = () => setModal({ ...modal, show: false, data: null })
|
||||
|
||||
// 提交表单
|
||||
const handleSubmit = async () => {
|
||||
// 打开分类管理弹窗
|
||||
const openCategoryModal = () => setCategoryModal(true)
|
||||
const closeCategoryModal = () => { setCategoryModal(false); setEditingCategory(null); setCategoryForm({ name: '', icon: 'folder', color: '#667eea', sort_order: 0 }) }
|
||||
|
||||
const editCategory = (cat) => {
|
||||
setEditingCategory(cat)
|
||||
setCategoryForm({ name: cat.name, icon: cat.icon || 'folder', color: cat.color || '#667eea', sort_order: cat.sort_order || 0 })
|
||||
}
|
||||
|
||||
const saveCategory = async () => {
|
||||
try {
|
||||
if (modal.type === 'website') {
|
||||
const payload = { ...form, category_id: form.category_id || null }
|
||||
modal.data ? await websiteApi.update(modal.data.id, payload) : await websiteApi.create(payload)
|
||||
} else {
|
||||
const payload = {
|
||||
name: form.name,
|
||||
icon: form.icon_name,
|
||||
color: form.color,
|
||||
sort_order: parseInt(form.sort_order) || 0
|
||||
}
|
||||
modal.data ? await categoryApi.update(modal.data.id, payload) : await categoryApi.create(payload)
|
||||
const payload = {
|
||||
name: categoryForm.name,
|
||||
icon: categoryForm.icon,
|
||||
color: categoryForm.color,
|
||||
sort_order: parseInt(categoryForm.sort_order) || 0
|
||||
}
|
||||
closeModal()
|
||||
if (editingCategory) {
|
||||
await categoryApi.update(editingCategory.id, payload)
|
||||
} else {
|
||||
await categoryApi.create(payload)
|
||||
}
|
||||
setCategoryForm({ name: '', icon: 'folder', color: '#667eea', sort_order: 0 })
|
||||
setEditingCategory(null)
|
||||
loadData()
|
||||
} catch (e) { console.error(e) }
|
||||
}
|
||||
|
||||
// 删除
|
||||
const handleDelete = async (type, id) => {
|
||||
if (!confirm('确定删除?')) return
|
||||
const deleteCategory = async (id) => {
|
||||
if (!confirm('确定删除该分类?该分类下的网站将变为未分类状态。')) return
|
||||
try {
|
||||
type === 'website' ? await websiteApi.delete(id) : await categoryApi.delete(id)
|
||||
if (type === 'category' && selectedCategory === id) setSelectedCategory(null)
|
||||
await categoryApi.delete(id)
|
||||
if (selectedCategory === id) setSelectedCategory(null)
|
||||
loadData()
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
// 保存网站
|
||||
const handleWebsiteSubmit = async () => {
|
||||
try {
|
||||
const payload = { ...websiteForm, category_id: websiteForm.category_id || null }
|
||||
if (modal.data) {
|
||||
await websiteApi.update(modal.data.id, payload)
|
||||
} else {
|
||||
await websiteApi.create(payload)
|
||||
}
|
||||
closeModal()
|
||||
loadData()
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
const handleDeleteWebsite = async (id) => {
|
||||
if (!confirm('确定删除?')) return
|
||||
try {
|
||||
await websiteApi.delete(id)
|
||||
loadData()
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
// 切换收藏
|
||||
const toggleFavorite = async (site) => {
|
||||
try {
|
||||
await websiteApi.update(site.id, { is_favorite: !site.is_favorite })
|
||||
@@ -115,7 +169,6 @@ function App() {
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
// 点击网站
|
||||
const handleClick = async (site) => {
|
||||
try { await websiteApi.click(site.id) } catch (e) {}
|
||||
window.open(site.url, '_blank')
|
||||
@@ -150,7 +203,7 @@ function App() {
|
||||
<span className="category-name">{cat.name}</span>
|
||||
<span className="category-count">{getCategoryCount(cat.id)}</span>
|
||||
</div>
|
||||
<button className="category-edit-btn" onClick={(e) => { e.stopPropagation(); openModal('category', cat) }} title="编辑">
|
||||
<button className="category-edit-btn" onClick={(e) => { e.stopPropagation(); editCategory(cat) }} title="编辑">
|
||||
<Icon name="edit" size={12}/>
|
||||
</button>
|
||||
</div>
|
||||
@@ -158,8 +211,10 @@ function App() {
|
||||
</div>
|
||||
|
||||
<div className="sidebar-footer">
|
||||
|
||||
<button className="add-btn" onClick={() => openModal('website')}>
|
||||
<button className="add-btn" onClick={openCategoryModal}>
|
||||
<Icon name="folder" size={14}/><span>添加分类</span>
|
||||
</button>
|
||||
<button className="add-btn" onClick={() => openWebsiteModal()}>
|
||||
<Icon name="plus" size={14}/><span>添加网站</span>
|
||||
</button>
|
||||
</div>
|
||||
@@ -167,18 +222,11 @@ function App() {
|
||||
|
||||
{/* 主内容 */}
|
||||
<main className="main-content">
|
||||
{/* 搜索 */}
|
||||
<div className="search-bar">
|
||||
<Icon name="search" size={18} className="search-icon"/>
|
||||
<input
|
||||
className="search-input"
|
||||
placeholder="搜索网站名称或描述..."
|
||||
value={searchQuery}
|
||||
onChange={e => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
<input className="search-input" placeholder="搜索网站名称或描述..." value={searchQuery} onChange={e => setSearchQuery(e.target.value)}/>
|
||||
</div>
|
||||
|
||||
{/* 收藏 */}
|
||||
{favorites.length > 0 && (
|
||||
<div style={{ marginBottom: 36 }}>
|
||||
<div className="section-header">
|
||||
@@ -186,13 +234,12 @@ function App() {
|
||||
</div>
|
||||
<div className="website-grid">
|
||||
{favorites.map(site => (
|
||||
<SiteCard key={site.id} site={site} onClick={() => handleClick(site)} onToggleFav={() => toggleFavorite(site)} onEdit={() => openModal('website', site)} onDelete={() => handleDelete('website', site.id)}/>
|
||||
<SiteCard key={site.id} site={site} onClick={() => handleClick(site)} onToggleFav={() => toggleFavorite(site)} onEdit={() => openWebsiteModal(site)} onDelete={() => handleDeleteWebsite(site.id)}/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 普通网站 */}
|
||||
{normal.length > 0 && (
|
||||
<div style={{ marginBottom: 36 }}>
|
||||
<div className="section-header">
|
||||
@@ -203,92 +250,122 @@ function App() {
|
||||
</div>
|
||||
<div className="website-grid">
|
||||
{normal.map(site => (
|
||||
<SiteCard key={site.id} site={site} onClick={() => handleClick(site)} onToggleFav={() => toggleFavorite(site)} onEdit={() => openModal('website', site)} onDelete={() => handleDelete('website', site.id)}/>
|
||||
<SiteCard key={site.id} site={site} onClick={() => handleClick(site)} onToggleFav={() => toggleFavorite(site)} onEdit={() => openWebsiteModal(site)} onDelete={() => handleDeleteWebsite(site.id)}/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 空状态 */}
|
||||
{filteredWebsites.length === 0 && (
|
||||
<div className="empty-state">
|
||||
<div className="empty-icon"><Icon name="globe" size={44}/></div>
|
||||
<div className="empty-title">{searchQuery ? '没有找到匹配的网站' : '暂无网站'}</div>
|
||||
<div className="empty-desc">{searchQuery ? '尝试其他关键词' : '点击左侧「添加网站」开始收藏你的第一个链接'}</div>
|
||||
{!searchQuery && <button className="add-btn" onClick={() => openModal('website')}><Icon name="plus" size={14}/><span>添加网站</span></button>}
|
||||
<div className="empty-desc">{searchQuery ? '尝试其他关键词' : '点击左侧「添加网站」开始收藏'}</div>
|
||||
{!searchQuery && <button className="add-btn" onClick={() => openWebsiteModal()}><Icon name="plus" size={14}/><span>添加网站</span></button>}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
|
||||
{/* 弹窗 */}
|
||||
{/* 网站弹窗 */}
|
||||
{modal.show && (
|
||||
<div className="modal-overlay" onClick={closeModal}>
|
||||
<div className="modal" onClick={e => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<div className="modal-title">{modal.data ? '编辑' : '添加'}{modal.type === 'website' ? '网站' : '分类'}</div>
|
||||
<div className="modal-title">{modal.data ? '编辑网站' : '添加网站'}</div>
|
||||
<button className="modal-close" onClick={closeModal}><Icon name="x" size={16}/></button>
|
||||
</div>
|
||||
|
||||
{modal.type === 'website' ? (
|
||||
<>
|
||||
<div className="form-group">
|
||||
<label className="form-label">名称 *</label>
|
||||
<input className="form-input" value={form.name} onChange={e => setForm({...form, name: e.target.value})} placeholder="网站名称"/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">链接 *</label>
|
||||
<input className="form-input" value={form.url} onChange={e => setForm({...form, url: e.target.value})} placeholder="https://..."/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">描述</label>
|
||||
<input className="form-input" value={form.description || ''} onChange={e => setForm({...form, description: e.target.value})} placeholder="简短描述"/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">分类</label>
|
||||
<select className="form-select" value={form.category_id} onChange={e => setForm({...form, category_id: e.target.value})}>
|
||||
<option value="">未分类</option>
|
||||
{categories.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-checkbox">
|
||||
<input type="checkbox" checked={form.is_favorite} onChange={e => setForm({...form, is_favorite: e.target.checked})}/>
|
||||
收藏
|
||||
</label>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="form-group">
|
||||
<label className="form-label">名称 *</label>
|
||||
<input className="form-input" value={form.name} onChange={e => setForm({...form, name: e.target.value})} placeholder="分类名称"/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">排序(数字越小越靠前)</label>
|
||||
<input type="number" className="form-input" value={form.sort_order} onChange={e => setForm({...form, sort_order: e.target.value})} placeholder="0"/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">颜色</label>
|
||||
<input type="color" className="color-picker" value={form.color} onChange={e => setForm({...form, color: e.target.value})}/>
|
||||
</div>
|
||||
{modal.data && (
|
||||
<div className="form-group">
|
||||
<label className="form-checkbox">
|
||||
<input type="checkbox" onChange={() => {
|
||||
if (confirm('确定删除该分类?该分类下的网站将变为未分类状态。')) {
|
||||
handleDelete('category', modal.data.id)
|
||||
closeModal()
|
||||
}
|
||||
}}/> 删除分类
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<div className="form-group">
|
||||
<label className="form-label">名称 *</label>
|
||||
<input className="form-input" value={websiteForm.name} onChange={e => setWebsiteForm({...websiteForm, name: e.target.value})} placeholder="网站名称"/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">链接 *</label>
|
||||
<input className="form-input" value={websiteForm.url} onChange={e => setWebsiteForm({...websiteForm, url: e.target.value})} placeholder="https://..."/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">图标(可选)</label>
|
||||
<div className="icon-picker">
|
||||
<button type="button" className={`icon-option ${!websiteForm.icon ? 'selected' : ''}`} onClick={() => setWebsiteForm({...websiteForm, icon: ''})}>
|
||||
<Icon name="globe" size={16}/>
|
||||
</button>
|
||||
{PRESET_ICONS.map(iconName => (
|
||||
<button key={iconName} type="button" className={`icon-option ${websiteForm.icon === iconName ? 'selected' : ''}`} onClick={() => setWebsiteForm({...websiteForm, icon: iconName})}>
|
||||
<Icon name={iconName} size={16}/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">描述</label>
|
||||
<input className="form-input" value={websiteForm.description || ''} onChange={e => setWebsiteForm({...websiteForm, description: e.target.value})} placeholder="简短描述"/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">分类</label>
|
||||
<select className="form-select" value={websiteForm.category_id} onChange={e => setWebsiteForm({...websiteForm, category_id: e.target.value})}>
|
||||
<option value="">未分类</option>
|
||||
{categories.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-checkbox">
|
||||
<input type="checkbox" checked={websiteForm.is_favorite} onChange={e => setWebsiteForm({...websiteForm, is_favorite: e.target.checked})}/>
|
||||
收藏
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="form-actions">
|
||||
<button className="btn btn-cancel" onClick={closeModal}>取消</button>
|
||||
<button className="btn btn-submit" onClick={handleSubmit} disabled={!form.name}>{modal.data ? '保存' : '添加'}</button>
|
||||
<button className="btn btn-submit" onClick={handleWebsiteSubmit}>{modal.data ? '保存' : '添加'}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 分类管理弹窗 */}
|
||||
{categoryModal && (
|
||||
<div className="modal-overlay" onClick={closeCategoryModal}>
|
||||
<div className="modal" style={{maxWidth: '500px'}} onClick={e => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<div className="modal-title">分类管理</div>
|
||||
<button className="modal-close" onClick={closeCategoryModal}><Icon name="x" size={16}/></button>
|
||||
</div>
|
||||
|
||||
{/* 添加/编辑表单 */}
|
||||
<div className="category-form">
|
||||
<div className="form-row">
|
||||
<input className="form-input" style={{flex: 1}} value={categoryForm.name} onChange={e => setCategoryForm({...categoryForm, name: e.target.value})} placeholder="分类名称"/>
|
||||
<input type="number" className="form-input" style={{width: '80px'}} value={categoryForm.sort_order} onChange={e => setCategoryForm({...categoryForm, sort_order: e.target.value})} placeholder="排序"/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">颜色</label>
|
||||
<div className="color-picker-row">
|
||||
{PRESET_COLORS.map(c => (
|
||||
<button key={c} type="button" className={`color-option ${categoryForm.color === c ? 'selected' : ''}`} style={{background: c}} onClick={() => setCategoryForm({...categoryForm, color: c})}/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<button className="btn btn-submit" style={{width: '100%', marginTop: '12px'}} onClick={saveCategory} disabled={!categoryForm.name}>
|
||||
{editingCategory ? '保存修改' : '添加分类'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 分类列表 */}
|
||||
<div className="category-list">
|
||||
{categories.map(cat => (
|
||||
<div key={cat.id} className="category-list-item">
|
||||
<div className="category-list-info">
|
||||
<span className="category-list-color" style={{background: cat.color || '#667eea'}}/>
|
||||
<Icon name={cat.icon || 'folder'} size={14}/>
|
||||
<span className="category-list-name">{cat.name}</span>
|
||||
<span className="category-list-count">({getCategoryCount(cat.id)})</span>
|
||||
</div>
|
||||
<div className="category-list-actions">
|
||||
<button onClick={() => editCategory(cat)}><Icon name="edit" size={14}/></button>
|
||||
<button onClick={() => deleteCategory(cat.id)}><Icon name="trash" size={14}/></button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -297,37 +374,23 @@ function App() {
|
||||
)
|
||||
}
|
||||
|
||||
// 网站卡片
|
||||
function SiteCard({ site, onClick, onToggleFav, onEdit, onDelete }) {
|
||||
const handleDelete = (e) => {
|
||||
e.stopPropagation()
|
||||
onDelete()
|
||||
}
|
||||
const handleEdit = (e) => {
|
||||
e.stopPropagation()
|
||||
onEdit()
|
||||
}
|
||||
const handleFav = (e) => {
|
||||
e.stopPropagation()
|
||||
onToggleFav()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="site-card" onClick={onClick}>
|
||||
{site.category && <span className="card-category">{site.category.name}</span>}
|
||||
<div className="card-icon">
|
||||
{site.icon && site.icon.startsWith('http') ? <img src={site.icon} alt=""/> : <Icon name="globe" size={26}/>}
|
||||
{site.icon && site.icon.startsWith('http') ? <img src={site.icon} alt=""/> : <Icon name={site.icon || 'globe'} size={26}/>}
|
||||
</div>
|
||||
<div className="card-name">{site.name}</div>
|
||||
{site.description && <div className="card-desc">{site.description}</div>}
|
||||
<div className="card-bottom-actions">
|
||||
<button className={`card-favorite ${site.is_favorite ? 'active' : ''}`} onClick={handleFav} title="收藏">
|
||||
<button className={`card-favorite ${site.is_favorite ? 'active' : ''}`} onClick={(e) => { e.stopPropagation(); onToggleFav() }}>
|
||||
<Icon name="star" size={15}/>
|
||||
</button>
|
||||
<button className="action-btn" onClick={handleEdit} title="编辑">
|
||||
<button className="action-btn" onClick={(e) => { e.stopPropagation(); onEdit() }}>
|
||||
<Icon name="edit" size={13}/>
|
||||
</button>
|
||||
<button className="action-btn delete" onClick={handleDelete} title="删除">
|
||||
<button className="action-btn delete" onClick={(e) => { e.stopPropagation(); onDelete() }}>
|
||||
<Icon name="trash" size={13}/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user