fix: 修复分类管理和搜索功能

- 添加分类管理功能(添加、编辑、删除、排序)
- 修复搜索功能,支持名称和描述搜索
- 侧边栏添加管理分类按钮
- 分类支持sort_order排序
- 修复空状态显示
This commit is contained in:
小龙 2026-04-06 16:46:24 +00:00
parent 27c725a8d4
commit 84d9098049

View File

@ -13,6 +13,7 @@ const Icon = ({ name, size = 16 }) => {
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>,
}
return icons[name] || icons.globe
}
@ -24,7 +25,11 @@ function App() {
const [searchQuery, setSearchQuery] = useState('')
const [modal, setModal] = useState({ show: false, type: 'website', data: null })
const [loading, setLoading] = useState(true)
const [form, setForm] = useState({ name: '', url: '', icon: '', description: '', category_id: '', is_favorite: false, color: '#667eea', icon_name: 'folder' })
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
})
useEffect(() => { loadData() }, [])
@ -32,8 +37,8 @@ function App() {
setLoading(true)
try {
const [c, w] = await Promise.all([categoryApi.getAll(), websiteApi.getAll()])
setCategories(c.data)
setWebsites(w.data)
setCategories(c.data || [])
setWebsites(w.data || [])
} catch (e) { console.error(e) }
setLoading(false)
}
@ -41,7 +46,10 @@ function App() {
//
const filteredWebsites = websites.filter(site => {
const matchCat = selectedCategory === null || site.category_id === selectedCategory
const matchSearch = !searchQuery || site.name.includes(searchQuery) || (site.description && site.description.includes(searchQuery))
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
})
@ -55,9 +63,14 @@ function App() {
//
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 })
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' } : { name: '', icon_name: 'folder', color: '#667eea' })
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 })
}
@ -71,7 +84,12 @@ function App() {
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 }
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)
}
closeModal()
@ -135,7 +153,12 @@ function App() {
</div>
<div className="sidebar-footer">
<button className="add-btn" onClick={() => openModal('website')}><Icon name="plus" size={14}/><span>添加网站</span></button>
<button className="add-btn" onClick={() => openModal('category')} style={{ marginBottom: '8px' }}>
<Icon name="folder" size={14}/><span>管理分类</span>
</button>
<button className="add-btn" onClick={() => openModal('website')}>
<Icon name="plus" size={14}/><span>添加网站</span>
</button>
</div>
</aside>
@ -146,7 +169,7 @@ function App() {
<Icon name="search" size={18} className="search-icon"/>
<input
className="search-input"
placeholder="搜索网站..."
placeholder="搜索网站名称或描述..."
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
/>
@ -187,9 +210,9 @@ function App() {
{filteredWebsites.length === 0 && (
<div className="empty-state">
<div className="empty-icon"><Icon name="globe" size={44}/></div>
<div className="empty-title">暂无网站</div>
<div className="empty-desc">点击左侧添加网站开始收藏你的第一个链接</div>
<button className="add-btn" onClick={() => openModal('website')}><Icon name="plus" size={14}/><span>添加网站</span></button>
<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>
)}
</main>
@ -215,7 +238,7 @@ function App() {
</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="简短描述"/>
<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>
@ -237,16 +260,32 @@ function App() {
<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-actions">
<button className="btn btn-cancel" onClick={closeModal}>取消</button>
<button className="btn btn-submit" onClick={handleSubmit}>{modal.data ? '保存' : '添加'}</button>
<button className="btn btn-submit" onClick={handleSubmit} disabled={!form.name}>{modal.data ? '保存' : '添加'}</button>
</div>
</div>
</div>
@ -259,26 +298,19 @@ function App() {
function SiteCard({ site, onClick, onToggleFav, onEdit, onDelete }) {
const handleDelete = (e) => {
e.stopPropagation()
e.preventDefault()
onDelete()
}
const handleEdit = (e) => {
e.stopPropagation()
onEdit()
}
const handleFav = (e) => {
e.stopPropagation()
onToggleFav()
}
const handleCardClick = () => {
onClick()
}
return (
<div className="site-card" onClick={handleCardClick}>
<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}/>}