402 lines
24 KiB
React
402 lines
24 KiB
React
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 = {
|
||
bookmark: <svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"/></svg>,
|
||
search: <svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>,
|
||
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"/></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>,
|
||
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
|
||
}
|
||
|
||
function App() {
|
||
const [categories, setCategories] = useState([])
|
||
const [websites, setWebsites] = useState([])
|
||
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 [websiteForm, setWebsiteForm] = useState({ name: '', url: '', icon: '', description: '', category_id: '', is_favorite: false })
|
||
|
||
useEffect(() => { loadData() }, [])
|
||
|
||
const loadData = async () => {
|
||
setLoading(true)
|
||
try {
|
||
const [c, w] = await Promise.all([categoryApi.getAll(), websiteApi.getAll()])
|
||
setCategories(c.data || [])
|
||
setWebsites(w.data || [])
|
||
} catch (e) { console.error(e) }
|
||
setLoading(false)
|
||
}
|
||
|
||
// 过滤网站
|
||
const filteredWebsites = websites.filter(site => {
|
||
const matchCat = selectedCategory === null || site.category_id === selectedCategory
|
||
const query = searchQuery.toLowerCase().trim()
|
||
const matchSearch = !query ||
|
||
(site.name && site.name.toLowerCase().includes(query)) ||
|
||
(site.description && site.description.toLowerCase().includes(query))
|
||
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 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 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 {
|
||
const payload = {
|
||
name: categoryForm.name,
|
||
icon: categoryForm.icon,
|
||
color: categoryForm.color,
|
||
sort_order: parseInt(categoryForm.sort_order) || 0
|
||
}
|
||
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 deleteCategory = async (id) => {
|
||
if (!confirm('确定删除该分类?该分类下的网站将变为未分类状态。')) return
|
||
try {
|
||
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 })
|
||
loadData()
|
||
} catch (e) {}
|
||
}
|
||
|
||
const handleClick = async (site) => {
|
||
try { await websiteApi.click(site.id) } catch (e) {}
|
||
window.open(site.url, '_blank')
|
||
}
|
||
|
||
if (loading) return <div className="loading"><div className="spinner"/></div>
|
||
|
||
return (
|
||
<div className="app">
|
||
{/* 左侧边栏 */}
|
||
<aside className="sidebar">
|
||
<div className="sidebar-header">
|
||
<div className="logo">
|
||
<div className="logo-icon"><Icon name="bookmark"/></div>
|
||
<span className="logo-text">EasyNavi</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="categories-section">
|
||
<div className="categories-title">分类</div>
|
||
|
||
<div className={`category-item ${selectedCategory === null ? 'active' : ''}`} onClick={() => setSelectedCategory(null)}>
|
||
<div className="category-icon"><Icon name="globe" size={14}/></div>
|
||
<span className="category-name">全部网站</span>
|
||
<span className="category-count">{websites.length}</span>
|
||
</div>
|
||
|
||
{categories.map(cat => (
|
||
<div key={cat.id} className="category-item-wrapper">
|
||
<div className={`category-item ${selectedCategory === cat.id ? 'active' : ''}`} onClick={() => setSelectedCategory(cat.id)}>
|
||
<div className="category-icon"><Icon name={cat.icon || 'folder'} size={14}/></div>
|
||
<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(); editCategory(cat) }} title="编辑">
|
||
<Icon name="edit" size={12}/>
|
||
</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
<div className="sidebar-footer">
|
||
<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>
|
||
</aside>
|
||
|
||
{/* 主内容 */}
|
||
<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)}/>
|
||
</div>
|
||
|
||
{favorites.length > 0 && (
|
||
<div style={{ marginBottom: 36 }}>
|
||
<div className="section-header">
|
||
<div className="section-title"><Icon name="star" size={18}/> 我的收藏 <span className="section-count">({favorites.length})</span></div>
|
||
</div>
|
||
<div className="website-grid">
|
||
{favorites.map(site => (
|
||
<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">
|
||
<div className="section-title">
|
||
{selectedCategory ? categories.find(c => c.id === selectedCategory)?.name : '全部网站'}
|
||
<span className="section-count">({normal.length})</span>
|
||
</div>
|
||
</div>
|
||
<div className="website-grid">
|
||
{normal.map(site => (
|
||
<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={() => 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 ? '编辑网站' : '添加网站'}</div>
|
||
<button className="modal-close" onClick={closeModal}><Icon name="x" size={16}/></button>
|
||
</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={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>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function SiteCard({ site, onClick, onToggleFav, onEdit, onDelete }) {
|
||
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={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={(e) => { e.stopPropagation(); onToggleFav() }}>
|
||
<Icon name="star" size={15}/>
|
||
</button>
|
||
<button className="action-btn" onClick={(e) => { e.stopPropagation(); onEdit() }}>
|
||
<Icon name="edit" size={13}/>
|
||
</button>
|
||
<button className="action-btn delete" onClick={(e) => { e.stopPropagation(); onDelete() }}>
|
||
<Icon name="trash" size={13}/>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
export default App
|