feat: 导航收藏网站 v1.0

- 左侧边栏分类导航
- 紫蓝渐变背景
- 精致卡片设计
- 网站增删改查、收藏功能
- 搜索过滤
- 后端: Flask + MySQL
- 前端: React + Vite
This commit is contained in:
小龙 2026-04-06 14:42:37 +00:00
parent a1951c3aeb
commit 4d36e80695
16 changed files with 1051 additions and 653 deletions

View File

@ -140,15 +140,15 @@ def get_websites():
# 转换日期时间并添加分类信息
for site in websites:
if site.get('created_at'):
if site.get('created_at') and hasattr(site['created_at'], 'isoformat'):
site['created_at'] = site['created_at'].isoformat()
if site.get('updated_at'):
if site.get('updated_at') and hasattr(site['updated_at'], 'isoformat'):
site['updated_at'] = site['updated_at'].isoformat()
cat_id = site.get('category_id')
if cat_id and cat_id in categories:
cat = categories[cat_id]
if cat.get('created_at'):
if cat.get('created_at') and hasattr(cat['created_at'], 'isoformat'):
cat['created_at'] = cat['created_at'].isoformat()
site['category'] = cat
else:

View File

@ -5,9 +5,15 @@
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>导航收藏夹</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
/* 系统字体栈 */
@font-face {
font-family: 'System UI';
font-style: normal;
font-weight: 400;
src: local('SF Pro Text'), local('Segoe UI'), local('Roboto'), local('Helvetica Neue'), local('Arial');
}
</style>
</head>
<body>
<div id="root"></div>

View File

@ -425,6 +425,20 @@
"linux"
]
},
"node_modules/@rollup/rollup-linux-x64-musl": {
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.1.tgz",
"integrity": "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@types/babel__core": {
"version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",

View File

@ -0,0 +1,3 @@
# `@rollup/rollup-linux-x64-musl`
This is the **x86_64-unknown-linux-musl** binary for `rollup`

View File

@ -0,0 +1,25 @@
{
"name": "@rollup/rollup-linux-x64-musl",
"version": "4.60.1",
"os": [
"linux"
],
"cpu": [
"x64"
],
"files": [
"rollup.linux-x64-musl.node"
],
"description": "Native bindings for Rollup",
"author": "Lukas Taegert-Atkinson",
"homepage": "https://rollupjs.org/",
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://github.com/rollup/rollup.git"
},
"libc": [
"musl"
],
"main": "./rollup.linux-x64-musl.node"
}

Binary file not shown.

View File

@ -9,10 +9,10 @@
"preview": "vite preview"
},
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0",
"axios": "^1.6.5",
"lucide-react": "^0.312.0",
"axios": "^1.6.5"
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"devDependencies": {
"@types/react": "^18.2.48",

View File

@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<defs>
<linearGradient id="grad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#8b5cf6"/>
<stop offset="100%" style="stop-color:#c4b5fd"/>
</linearGradient>
</defs>
<rect width="100" height="100" rx="20" fill="url(#grad)"/>
<path d="M30 25 L70 25 L70 45 L55 45 L55 75 L45 75 L45 45 L30 45 Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 432 B

View File

@ -0,0 +1 @@
Couldn't find the requested file /fonts/Inter-Bold.woff2 in inter-font.

View File

@ -0,0 +1 @@
Couldn't find the requested file /fonts/Inter-Medium.woff2 in inter-font.

View File

@ -0,0 +1 @@
Couldn't find the requested file /fonts/Inter-Regular.woff2 in inter-font.

View File

@ -0,0 +1 @@
Couldn't find the requested file /fonts/Inter-SemiBold.woff2 in inter-font.

View File

@ -1,21 +1,20 @@
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,
// 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"/><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>,
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>,
}
return icons[name] || icons.globe
}
function App() {
@ -23,101 +22,71 @@ function App() {
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 [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 [websiteForm, setWebsiteForm] = useState({
name: '',
url: '',
icon: '',
description: '',
category_id: '',
is_favorite: false,
})
const [categoryForm, setCategoryForm] = useState({
name: '',
icon: 'folder',
color: '#6366f1',
})
//
useEffect(() => {
loadData()
}, [])
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)
}
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 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 matchCat = selectedCategory === null || site.category_id === selectedCategory
const matchSearch = !searchQuery || site.name.includes(searchQuery) || (site.description && site.description.includes(searchQuery))
return matchCat && matchSearch
})
//
const favoriteWebsites = filteredWebsites.filter(s => s.is_favorite)
const normalWebsites = filteredWebsites.filter(s => !s.is_favorite)
//
const favorites = filteredWebsites.filter(s => s.is_favorite)
const normal = 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 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 })
} else {
setForm(data ? { name: data.name, icon_name: data.icon || 'folder', color: data.color || '#667eea' } : { name: '', icon_name: 'folder', color: '#667eea' })
}
setModal({ show: true, type, data })
}
//
const handleUpdateWebsite = async () => {
const closeModal = () => setModal({ ...modal, show: false, data: null })
//
const handleSubmit = async () => {
try {
await websiteApi.update(editingWebsite.id, {
...websiteForm,
category_id: websiteForm.category_id || null,
})
setEditingWebsite(null)
setShowAddModal(false)
resetWebsiteForm()
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 }
modal.data ? await categoryApi.update(modal.data.id, payload) : await categoryApi.create(payload)
}
closeModal()
loadData()
} catch (error) {
console.error('更新网站失败:', error)
}
} catch (e) { console.error(e) }
}
//
const handleDeleteWebsite = async (id) => {
if (!confirm('确定删除这个网站吗')) return
//
const handleDelete = async (type, id) => {
if (!confirm('确定删除?')) return
try {
await websiteApi.delete(id)
type === 'website' ? await websiteApi.delete(id) : await categoryApi.delete(id)
if (type === 'category' && selectedCategory === id) setSelectedCategory(null)
loadData()
} catch (error) {
console.error('删除网站失败:', error)
}
} catch (e) {}
}
//
@ -125,512 +94,207 @@ function App() {
try {
await websiteApi.update(site.id, { is_favorite: !site.is_favorite })
loadData()
} catch (error) {
console.error('切换收藏失败:', error)
}
} catch (e) {}
}
//
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 handleClick = async (site) => {
try { await websiteApi.click(site.id) } catch (e) {}
window.open(site.url, '_blank')
}
//
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>
)
}
if (loading) return <div className="loading"><div className="spinner"/></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 className="app">
{/* 左侧边栏 */}
<aside className="sidebar">
<div className="sidebar-header">
<div className="logo">
<div className="logo-icon"><Icon name="bookmark"/></div>
<span className="logo-text">导航收藏夹</span>
</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 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 ${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>
))}
</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 className="sidebar-footer">
<button className="add-btn" onClick={() => openModal('website')}><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={() => openModal('website', site)} onDelete={() => handleDelete('website', site.id)}/>
))}
</div>
</section>
</div>
)}
{/* 普通网站 */}
{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}
/>
{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={() => openModal('website', site)} onDelete={() => handleDelete('website', site.id)}/>
))}
</div>
</section>
</div>
)}
{/* 空状态 */}
{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 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>
)}
</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"
/>
{/* 弹窗 */}
{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>
<button className="modal-close" onClick={closeModal}><Icon name="x" size={16}/></button>
</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="例如:社交媒体"
/>
{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="color" className="color-picker" value={form.color} onChange={e => setForm({...form, color: e.target.value})}/>
</div>
</>
)}
<div className="form-actions">
<button className="btn btn-cancel" onClick={closeModal}>取消</button>
<button className="btn btn-submit" onClick={handleSubmit}>{modal.data ? '保存' : '添加'}</button>
</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>
</div>
)}
</div>
)
}
//
function WebsiteCard({ site, onClick, onToggleFavorite, onEdit, onDelete, getIcon }) {
//
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="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 className="site-card" onClick={handleCardClick}>
<div className="card-icon">
{site.icon && site.icon.startsWith('http') ? <img src={site.icon} alt=""/> : <Icon name="globe" size={26}/>}
</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 className="card-name">{site.name}</div>
{site.description && <div className="card-desc">{site.description}</div>}
{site.category && <span className="card-category">{site.category.name}</span>}
<div className="card-bottom-actions">
<button className={`card-favorite ${site.is_favorite ? 'active' : ''}`} onClick={handleFav} title="收藏">
<Icon name="star" size={15}/>
</button>
<button className="action-btn" onClick={handleEdit} title="编辑">
<Icon name="edit" size={13}/>
</button>
<button className="action-btn delete" onClick={handleDelete} title="删除">
<Icon name="trash" size={13}/>
</button>
</div>
</div>
)

View File

@ -1,95 +1,761 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
--bg-primary: #0d0f12;
--bg-secondary: #15181f;
--bg-card: #1a1d24;
--bg-hover: #1e293b;
--text-primary: #f1f5f9;
--text-secondary: #94a3b8;
--text-muted: #64748b;
--border-color: #2a2f3a;
--accent: #6366f1;
--accent-hover: #818cf8;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html {
font-family: 'Inter', system-ui, -apple-system, sans-serif;
}
/* ===== 精致美观导航网站 v3 ===== */
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body, #root { height: 100%; }
body {
background: var(--bg-primary);
color: var(--text-primary);
font-family: 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', -apple-system, sans-serif;
font-size: 14px;
line-height: 1.6;
color: #333;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
-webkit-font-smoothing: antialiased;
}
/* ===== 整体布局 ===== */
.app {
display: flex;
min-height: 100vh;
}
/* Glass effect */
.glass {
background: rgba(26, 29, 36, 0.8);
backdrop-filter: blur(12px);
border: 1px solid var(--border-color);
/* ===== 左侧边栏 ===== */
.sidebar {
width: 240px;
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(20px);
border-right: 1px solid rgba(255, 255, 255, 0.2);
display: flex;
flex-direction: column;
flex-shrink: 0;
}
/* Card hover effect */
.card-hover {
.sidebar-header {
padding: 20px;
border-bottom: 1px solid rgba(0, 0, 0, 0.05);
}
.logo {
display: flex;
align-items: center;
gap: 10px;
}
.logo-icon {
width: 36px;
height: 36px;
background: linear-gradient(135deg, #667eea, #764ba2);
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 16px;
}
.logo-text {
font-size: 16px;
font-weight: 600;
background: linear-gradient(135deg, #667eea, #764ba2);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
/* 分类列表 */
.categories-section {
flex: 1;
overflow-y: auto;
padding: 16px 12px;
}
.categories-title {
font-size: 11px;
font-weight: 600;
color: #999;
text-transform: uppercase;
letter-spacing: 0.5px;
padding: 0 8px;
margin-bottom: 8px;
}
.category-item {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s ease;
margin-bottom: 4px;
color: #666;
font-size: 13px;
}
.category-item:hover {
background: rgba(102, 126, 234, 0.08);
color: #667eea;
}
.category-item.active {
background: linear-gradient(135deg, #667eea, #764ba2);
color: white;
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3);
}
.category-icon {
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 6px;
background: rgba(0, 0, 0, 0.04);
font-size: 14px;
}
.category-item.active .category-icon {
background: rgba(255, 255, 255, 0.2);
}
.category-name {
flex: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.category-count {
font-size: 11px;
padding: 2px 6px;
background: rgba(0, 0, 0, 0.06);
border-radius: 10px;
}
.category-item.active .category-count {
background: rgba(255, 255, 255, 0.2);
}
/* 侧边栏底部 */
.sidebar-footer {
padding: 16px;
border-top: 1px solid rgba(0, 0, 0, 0.05);
}
.add-btn {
width: 100%;
height: 40px;
background: linear-gradient(135deg, #667eea, #764ba2);
color: white;
border: none;
border-radius: 10px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
transition: all 0.2s ease;
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3);
}
.add-btn:hover {
transform: translateY(-1px);
box-shadow: 0 6px 16px rgba(102, 126, 234, 0.4);
}
/* ===== 主内容区 ===== */
.main-content {
flex: 1;
padding: 32px;
overflow-y: auto;
}
/* 搜索栏 */
.search-bar {
max-width: 600px;
margin-bottom: 32px;
position: relative;
}
.search-input {
width: 100%;
height: 48px;
padding: 0 20px 0 48px;
background: rgba(255, 255, 255, 0.95);
border: 2px solid transparent;
border-radius: 24px;
font-size: 14px;
color: #333;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
transition: all 0.2s ease;
}
.card-hover:hover {
transform: translateY(-4px);
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.4);
border-color: var(--accent);
.search-input:focus {
outline: none;
border-color: rgba(102, 126, 234, 0.5);
box-shadow: 0 4px 25px rgba(102, 126, 234, 0.2);
}
/* Scrollbar */
.search-icon {
position: absolute;
left: 18px;
top: 50%;
transform: translateY(-50%);
color: #999;
}
/* 区块标题 */
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20px;
}
.section-title {
display: flex;
align-items: center;
gap: 8px;
font-size: 16px;
font-weight: 600;
color: white;
text-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
}
.section-count {
font-size: 13px;
font-weight: 400;
opacity: 0.8;
}
/* 网站网格 */
.website-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 20px;
}
/* 网站卡片 */
.site-card {
position: relative;
background: white;
border-radius: 16px;
padding: 24px 20px;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
overflow: hidden;
}
.site-card::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 4px;
background: linear-gradient(90deg, #667eea, #764ba2);
opacity: 0;
transition: opacity 0.3s ease;
}
.site-card:hover {
transform: translateY(-6px);
box-shadow: 0 16px 50px rgba(102, 126, 234, 0.25);
}
.site-card:hover::before {
opacity: 1;
}
.site-card:hover .card-bottom-actions {
opacity: 1;
}
.card-bottom-actions {
position: absolute;
bottom: 12px;
left: 12px;
right: 12px;
display: flex;
gap: 6px;
justify-content: flex-end;
align-items: center;
}
.site-card:hover .external-icon {
opacity: 0;
}
/* 卡片图标 */
.card-icon {
width: 52px;
height: 52px;
background: linear-gradient(135deg, #f5f7fa, #e4e8ec);
border-radius: 14px;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 16px;
transition: all 0.3s ease;
}
.site-card:hover .card-icon {
transform: scale(1.05);
background: linear-gradient(135deg, #667eea15, #764ba215);
}
.card-icon img {
width: 28px;
height: 28px;
object-fit: contain;
}
.card-icon svg {
width: 26px;
height: 26px;
color: #667eea;
}
.card-name {
font-size: 14px;
font-weight: 600;
color: #1a1a1a;
margin-bottom: 4px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.card-desc {
font-size: 12px;
color: #888;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.card-category {
display: inline-block;
margin-top: 12px;
padding: 4px 10px;
background: linear-gradient(135deg, #667eea10, #764ba210);
border-radius: 6px;
font-size: 11px;
font-weight: 500;
color: #667eea;
}
/* 收藏星星 */
.card-favorite {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border: none;
background: #f5f5f5;
color: #ccc;
cursor: pointer;
border-radius: 8px;
padding: 0;
transition: all 0.2s ease;
}
.card-favorite:hover,
.card-favorite.active {
color: #fbbf24;
background: #fef3c7;
}
/* 操作按钮 */
.card-actions {
position: absolute;
bottom: 12px;
right: 12px;
display: flex;
gap: 4px;
padding: 4px;
background: rgba(255, 255, 255, 0.95);
border-radius: 10px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
opacity: 0;
transition: all 0.2s ease;
}
.action-btn {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border: none;
background: #f5f5f5;
color: #888;
cursor: pointer;
border-radius: 8px;
transition: all 0.2s ease;
}
.action-btn:hover {
border-color: #667eea;
color: #667eea;
transform: scale(1.05);
}
.action-btn.delete:hover {
border-color: #ef4444;
color: #ef4444;
background: #fef2f2;
}
/* 外部链接图标 */
.external-icon {
position: absolute;
bottom: 14px;
right: 14px;
color: #ccc;
opacity: 0;
transition: all 0.2s ease;
}
/* 空状态 */
.empty-state {
text-align: center;
padding: 80px 40px;
background: rgba(255, 255, 255, 0.95);
border-radius: 24px;
box-shadow: 0 8px 40px rgba(0, 0, 0, 0.1);
}
.empty-icon {
width: 100px;
height: 100px;
margin: 0 auto 24px;
background: linear-gradient(135deg, #667eea15, #764ba215);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
.empty-icon svg {
width: 44px;
height: 44px;
color: #667eea;
}
.empty-title {
font-size: 20px;
font-weight: 600;
color: #1a1a1a;
margin-bottom: 8px;
}
.empty-desc {
font-size: 14px;
color: #888;
margin-bottom: 28px;
}
/* ===== 弹窗 ===== */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 24px;
}
.modal {
background: white;
border-radius: 20px;
padding: 28px;
width: 100%;
max-width: 420px;
box-shadow: 0 25px 80px rgba(0, 0, 0, 0.25);
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 24px;
}
.modal-title {
font-size: 18px;
font-weight: 600;
color: #1a1a1a;
}
.modal-close {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border: none;
background: #f5f5f5;
color: #888;
cursor: pointer;
border-radius: 8px;
transition: all 0.2s ease;
}
.modal-close:hover {
background: #eee;
color: #333;
}
/* ===== 表单 ===== */
.form-group {
margin-bottom: 18px;
}
.form-label {
display: block;
font-size: 13px;
font-weight: 500;
color: #555;
margin-bottom: 8px;
}
.form-input {
width: 100%;
height: 44px;
padding: 0 14px;
border: 2px solid #f0f0f0;
border-radius: 10px;
font-size: 14px;
color: #333;
transition: all 0.2s ease;
}
.form-input:focus {
outline: none;
border-color: #667eea;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
}
.form-select {
width: 100%;
height: 44px;
padding: 0 14px;
border: 2px solid #f0f0f0;
border-radius: 10px;
font-size: 14px;
color: #333;
background: white;
cursor: pointer;
transition: all 0.2s ease;
}
.form-select:focus {
outline: none;
border-color: #667eea;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
}
.form-checkbox {
display: flex;
align-items: center;
gap: 10px;
font-size: 14px;
color: #555;
cursor: pointer;
}
.form-checkbox input {
width: 18px;
height: 18px;
accent-color: #667eea;
}
.color-picker {
width: 100%;
height: 44px;
padding: 4px;
border: 2px solid #f0f0f0;
border-radius: 10px;
cursor: pointer;
background: white;
}
.icon-picker {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.icon-option {
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
border: 2px solid #f0f0f0;
border-radius: 10px;
background: white;
color: #666;
cursor: pointer;
transition: all 0.2s ease;
}
.icon-option:hover {
border-color: #667eea;
color: #667eea;
}
.icon-option.selected {
border-color: #667eea;
background: linear-gradient(135deg, #667eea10, #764ba210);
color: #667eea;
}
.form-actions {
display: flex;
gap: 12px;
margin-top: 28px;
}
.form-actions .btn {
flex: 1;
height: 46px;
border-radius: 12px;
font-size: 15px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
}
.btn-cancel {
background: #f5f5f5;
border: none;
color: #666;
}
.btn-cancel:hover {
background: #eee;
}
.btn-submit {
background: linear-gradient(135deg, #667eea, #764ba2);
border: none;
color: white;
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3);
}
.btn-submit:hover {
transform: translateY(-1px);
box-shadow: 0 6px 16px rgba(102, 126, 234, 0.4);
}
/* ===== 加载 ===== */
.loading {
display: flex;
align-items: center;
justify-content: center;
min-height: 300px;
}
.spinner {
width: 32px;
height: 32px;
border: 3px solid rgba(255, 255, 255, 0.3);
border-top-color: white;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* ===== 响应式 ===== */
@media (max-width: 768px) {
.app {
flex-direction: column;
}
.sidebar {
width: 100%;
max-height: 40vh;
border-right: none;
border-bottom: 1px solid rgba(255, 255, 255, 0.2);
}
.categories-section {
display: flex;
flex-wrap: wrap;
gap: 8px;
padding: 12px;
}
.categories-title {
display: none;
}
.category-item {
margin: 0;
padding: 8px 12px;
background: rgba(0, 0, 0, 0.04);
border-radius: 8px;
}
.category-count {
display: none;
}
.sidebar-footer {
padding: 12px;
}
.add-btn span {
display: none;
}
.main-content {
padding: 20px;
}
.search-bar {
margin-bottom: 20px;
}
.search-input {
height: 42px;
}
.website-grid {
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 14px;
}
.site-card {
padding: 18px 16px;
}
}
/* ===== 滚动条 ===== */
::-webkit-scrollbar {
width: 8px;
height: 8px;
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: var(--bg-secondary);
background: transparent;
}
::-webkit-scrollbar-thumb {
background: var(--bg-hover);
border-radius: 4px;
background: rgba(0, 0, 0, 0.15);
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: #334155;
}
/* Input focus */
input:focus, textarea:focus, select:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.2);
}
/* Button press effect */
.btn-press:active {
transform: scale(0.97);
}
/* Modal backdrop */
.modal-backdrop {
background: rgba(0, 0, 0, 0.7);
backdrop-filter: blur(4px);
}
/* Gradient text */
.gradient-text {
background: linear-gradient(135deg, #6366f1 0%, #a855f7 50%, #ec4899 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
background: rgba(0, 0, 0, 0.25);
}

View File

@ -8,23 +8,28 @@ export default {
extend: {
colors: {
primary: {
50: '#f0f9ff',
100: '#e0f2fe',
200: '#bae6fd',
300: '#7dd3fc',
400: '#38bdf8',
500: '#0ea5e9',
600: '#0284c7',
700: '#0369a1',
800: '#075985',
900: '#0c4a6e',
50: '#faf5ff',
100: '#f3e8ff',
200: '#e9d5ff',
300: '#d8b4fe',
400: '#c084fc',
500: '#a855f7',
600: '#9333ea',
700: '#7e22ce',
800: '#6b21a8',
900: '#581c87',
},
dark: {
100: '#1e293b',
200: '#1a1d24',
300: '#15181f',
400: '#111318',
500: '#0d0f12',
surface: {
50: '#ffffff',
100: '#fafafa',
200: '#f4f4f5',
300: '#e4e4e7',
400: '#a1a1aa',
500: '#71717a',
600: '#52525b',
700: '#3f3f46',
800: '#27272a',
900: '#18181b',
}
},
fontFamily: {

View File

@ -6,6 +6,7 @@ export default defineConfig({
server: {
host: '0.0.0.0',
port: 5173,
allowedHosts: ['nav.pags.cn', 'localhost', '127.0.0.1'],
proxy: {
'/api': {
target: 'http://localhost:5003',