feat: 导航收藏网站 v1.0
- 左侧边栏分类导航 - 紫蓝渐变背景 - 精致卡片设计 - 网站增删改查、收藏功能 - 搜索过滤 - 后端: Flask + MySQL - 前端: React + Vite
This commit is contained in:
parent
a1951c3aeb
commit
4d36e80695
@ -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:
|
||||
|
||||
@ -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>
|
||||
|
||||
14
frontend/node_modules/.package-lock.json
generated
vendored
14
frontend/node_modules/.package-lock.json
generated
vendored
@ -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",
|
||||
|
||||
3
frontend/node_modules/@rollup/rollup-linux-x64-musl/README.md
generated
vendored
Normal file
3
frontend/node_modules/@rollup/rollup-linux-x64-musl/README.md
generated
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
# `@rollup/rollup-linux-x64-musl`
|
||||
|
||||
This is the **x86_64-unknown-linux-musl** binary for `rollup`
|
||||
25
frontend/node_modules/@rollup/rollup-linux-x64-musl/package.json
generated
vendored
Normal file
25
frontend/node_modules/@rollup/rollup-linux-x64-musl/package.json
generated
vendored
Normal 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"
|
||||
}
|
||||
BIN
frontend/node_modules/@rollup/rollup-linux-x64-musl/rollup.linux-x64-musl.node
generated
vendored
Normal file
BIN
frontend/node_modules/@rollup/rollup-linux-x64-musl/rollup.linux-x64-musl.node
generated
vendored
Normal file
Binary file not shown.
@ -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",
|
||||
|
||||
10
frontend/public/favicon.svg
Normal file
10
frontend/public/favicon.svg
Normal 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 |
1
frontend/public/fonts/Inter-Bold.woff2
Normal file
1
frontend/public/fonts/Inter-Bold.woff2
Normal file
@ -0,0 +1 @@
|
||||
Couldn't find the requested file /fonts/Inter-Bold.woff2 in inter-font.
|
||||
1
frontend/public/fonts/Inter-Medium.woff2
Normal file
1
frontend/public/fonts/Inter-Medium.woff2
Normal file
@ -0,0 +1 @@
|
||||
Couldn't find the requested file /fonts/Inter-Medium.woff2 in inter-font.
|
||||
1
frontend/public/fonts/Inter-Regular.woff2
Normal file
1
frontend/public/fonts/Inter-Regular.woff2
Normal file
@ -0,0 +1 @@
|
||||
Couldn't find the requested file /fonts/Inter-Regular.woff2 in inter-font.
|
||||
1
frontend/public/fonts/Inter-SemiBold.woff2
Normal file
1
frontend/public/fonts/Inter-SemiBold.woff2
Normal file
@ -0,0 +1 @@
|
||||
Couldn't find the requested file /fonts/Inter-SemiBold.woff2 in inter-font.
|
||||
@ -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>
|
||||
)
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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',
|
||||
|
||||
Loading…
Reference in New Issue
Block a user