├── postcss.config.js ├── .env.example ├── next.config.js ├── vercel.json ├── src ├── types │ └── index.ts ├── pages │ ├── _app.tsx │ ├── _document.tsx │ ├── api │ │ └── links.ts │ └── index.tsx ├── components │ ├── Loading.tsx │ ├── ThemeSwitch.tsx │ └── IconBackground.tsx └── styles │ └── globals.css ├── .vscode └── settings.json ├── .gitignore ├── tailwind.config.js ├── tsconfig.json ├── package.json ├── LICENSE └── README.md /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | FEISHU_APP_ID=your_app_id_here 2 | FEISHU_APP_SECRET=your_app_secret_here 3 | FEISHU_TABLE_ID=your_table_id_here -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | reactStrictMode: true, 4 | swcMinify: true, 5 | } 6 | 7 | module.exports = nextConfig -------------------------------------------------------------------------------- /vercel.json: -------------------------------------------------------------------------------- 1 | { 2 | "framework": "nextjs", 3 | "buildCommand": "next build", 4 | "devCommand": "next dev", 5 | "installCommand": "npm install", 6 | "outputDirectory": ".next" 7 | } -------------------------------------------------------------------------------- /src/types/index.ts: -------------------------------------------------------------------------------- 1 | export interface Link { 2 | title: string 3 | url: string 4 | description: string 5 | category: string[] 6 | icon?: string 7 | recommend?: string 8 | order: number 9 | tags: string[] 10 | viewOrders: Record 11 | } 12 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "css.validate": false, 3 | "less.validate": false, 4 | "scss.validate": false, 5 | "editor.quickSuggestions": { 6 | "strings": true 7 | }, 8 | "tailwindCSS.includeLanguages": { 9 | "plaintext": "html" 10 | }, 11 | "editor.codeActionsOnSave": { 12 | "source.fixAll.eslint": "always" 13 | } 14 | } -------------------------------------------------------------------------------- /src/pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import '@/styles/globals.css' 2 | import type { AppProps } from 'next/app' 3 | import { ThemeProvider } from 'next-themes' 4 | 5 | export default function App({ Component, pageProps }: AppProps) { 6 | return ( 7 | 14 | 15 | 16 | ) 17 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | 27 | # local env files 28 | .env*.local 29 | 30 | # vercel 31 | .vercel 32 | 33 | # typescript 34 | *.tsbuildinfo 35 | next-env.d.ts 36 | 37 | # uploads 38 | /public/uploads/* 39 | !/public/uploads/.gitkeep 40 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | darkMode: 'class', 4 | content: [ 5 | './src/**/*.{js,ts,jsx,tsx}', 6 | ], 7 | theme: { 8 | extend: {}, 9 | }, 10 | plugins: [], 11 | corePlugins: { 12 | scrollbar: false, 13 | }, 14 | variants: { 15 | scrollbar: ['rounded'] 16 | }, 17 | extend: { 18 | utilities: { 19 | '.scrollbar-none': { 20 | '-ms-overflow-style': 'none', 21 | 'scrollbar-width': 'none', 22 | '&::-webkit-scrollbar': { 23 | display: 'none' 24 | } 25 | } 26 | } 27 | } 28 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "noEmit": true, 10 | "esModuleInterop": true, 11 | "module": "esnext", 12 | "moduleResolution": "node", 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "jsx": "preserve", 16 | "incremental": true, 17 | "baseUrl": ".", 18 | "paths": { 19 | "@/*": ["./src/*"] 20 | } 21 | }, 22 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"], 23 | "exclude": ["node_modules"] 24 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nav-site", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint" 10 | }, 11 | "dependencies": { 12 | "axios": "1.6.7", 13 | "next": "13.5.6", 14 | "next-themes": "^0.4.4", 15 | "react": "18.3.1", 16 | "react-dom": "18.3.1", 17 | "react-spinners": "0.13.8" 18 | }, 19 | "devDependencies": { 20 | "@types/node": "18.19.17", 21 | "@types/react": "18.2.55", 22 | "@types/react-dom": "18.2.19", 23 | "autoprefixer": "10.4.17", 24 | "postcss": "8.4.35", 25 | "tailwindcss": "3.3.3", 26 | "typescript": "4.9.5" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/components/Loading.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react' 2 | import { HashLoader } from 'react-spinners' 3 | 4 | export default function Loading() { 5 | const [showLoader, setShowLoader] = useState(false) 6 | 7 | // 添加一个小延迟,避免闪烁 8 | useEffect(() => { 9 | const timer = setTimeout(() => { 10 | setShowLoader(true) 11 | }, 200) // 200ms 延迟,避免加载太快时的闪烁 12 | 13 | return () => clearTimeout(timer) 14 | }, []) 15 | 16 | if (!showLoader) return null 17 | 18 | return ( 19 |
20 | 25 | 26 | 加载中... 27 | 28 |
29 | ) 30 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Eagle-CN 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/components/ThemeSwitch.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { useTheme } from 'next-themes' 4 | import { useEffect, useState } from 'react' 5 | 6 | export function ThemeSwitch() { 7 | const [mounted, setMounted] = useState(false) 8 | const { resolvedTheme, setTheme } = useTheme() 9 | 10 | useEffect(() => { 11 | setMounted(true) 12 | }, []) 13 | 14 | if (!mounted) return null 15 | 16 | const toggleTheme = () => { 17 | setTheme(resolvedTheme === 'dark' ? 'light' : 'dark') 18 | } 19 | 20 | return ( 21 | 46 | ) 47 | } -------------------------------------------------------------------------------- /src/pages/_document.tsx: -------------------------------------------------------------------------------- 1 | import { Html, Head, Main, NextScript } from 'next/document' 2 | 3 | export default function Document() { 4 | return ( 5 | 6 | 7 | {/* 基本元信息 */} 8 | 9 | 10 | 11 | 12 | {/* Open Graph 标签 */} 13 | 14 | 15 | 16 | 17 | {/* 网站图标 */} 18 | 19 | 20 | 21 | {/* PWA 相关 */} 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | {/* 字体预加载 */} 30 | 37 | 38 | {/* 网站标题 */} 39 | iTools - 简洁优雅的导航网站 40 | 41 | 42 |
43 | 44 | 45 | 46 | ) 47 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 飞书导航站点 2 | 3 | 基于飞书多维表格的导航网站,使用 Next.js 构建。通过飞书多维表格管理导航链接,自动同步到网站显示。 4 | 5 | ## 功能特点 6 | 7 | - 🔄 实时同步飞书多维表格数据 8 | - 📱 响应式布局,支持移动端 9 | - 🏷️ 支持分类展示 10 | - ⭐ 支持推荐标记 11 | - 🔢 支持自定义排序 12 | - 🖼️ 支持图标显示 13 | 14 | ## 技术栈 15 | 16 | - Next.js 13 17 | - TypeScript 18 | - Tailwind CSS 19 | - 飞书开放 API 20 | - Vercel 部署 21 | 22 | ## 快速开始 23 | 24 | ### 1. 飞书配置 25 | 26 | 1. 创建飞书多维表格,包含以下字段: 27 | - Title (文本) 28 | - URL (链接) 29 | - Description (文本) 30 | - Category (单选) 31 | - Icon (文本,可选) 32 | - Recommend (文本,可选) 33 | - Order (数字,可选) 34 | 35 | 2. 在[飞书开发者平台](https://open.feishu.cn/app)创建应用: 36 | - 创建企业自建应用 37 | - 获取 App ID 和 App Secret 38 | - 开启多维表格权限:`bitable:app`,`bitable:table` 39 | 40 | ### 2. 本地开发 41 | 42 | 1. 克隆项目: 43 | 44 | ```bash 45 | git clone https://github.com/yourusername/feishu-navigation.git 46 | cd feishu-navigation 47 | ``` 48 | 49 | 2. 安装依赖: 50 | 51 | ```bash 52 | npm install 53 | ``` 54 | 55 | 3. 配置环境变量,创建 `.env.local` 文件: 56 | 57 | ```bash 58 | FEISHU_APP_ID=your_app_id 59 | FEISHU_APP_SECRET=your_app_secret 60 | FEISHU_TABLE_ID=your_table_id 61 | ``` 62 | 63 | 4. 启动开发服务器: 64 | 65 | ```bash 66 | npm run dev 67 | ``` 68 | 69 | 70 | ### 3. Vercel 部署 71 | 72 | 1. Fork 本项目到你的 GitHub 73 | 74 | 2. 在 Vercel 中导入项目: 75 | - 登录 [Vercel](https://vercel.com) 76 | - 点击 "New Project" 77 | - 选择你的 GitHub 仓库 78 | - 配置环境变量: 79 | * `FEISHU_APP_ID` 80 | * `FEISHU_APP_SECRET` 81 | * `FEISHU_TABLE_ID` 82 | - 点击 "Deploy" 83 | 84 | ## 项目结构 85 | 86 | ```bash 87 | nav-site/ 88 | ├── src/ 89 | │ ├── pages/ 90 | │ │ ├── api/ 91 | │ │ │ └── links.ts # 飞书 API 处理 92 | │ │ ├── app.tsx 93 | │ │ └── index.tsx # 主页面 94 | │ ├── components/ 95 | │ │ └── Loading.tsx # 加载组件 96 | │ ├── styles/ 97 | │ │ └── globals.css # 全局样式 98 | │ └── types/ 99 | │ └── index.ts # 类型定义 100 | ├── public/ 101 | ├── .env.local # 本地环境变量 102 | ├── vercel.json # Vercel 配置 103 | └── package.json 104 | ``` 105 | 106 | 107 | ## 环境变量说明 108 | 109 | | 变量名 | 说明 | 示例 | 110 | |--------|------|------| 111 | | FEISHU_APP_ID | 飞书应用 ID | cli_xxxx | 112 | | FEISHU_APP_SECRET | 飞书应用密钥 | xxxx | 113 | | FEISHU_TABLE_ID | 飞书多维表格 ID | tblxxxx | 114 | 115 | ## 开发说明 116 | 117 | 1. 修改样式: 118 | - 编辑 `src/pages/index.tsx` 中的 Tailwind 类名 119 | - 或在 `src/styles/globals.css` 添加自定义样式 120 | 121 | 2. 修改布局: 122 | - 编辑 `src/pages/index.tsx` 中的 JSX 结构 123 | 124 | 3. 添加新功能: 125 | - 在 `src/pages/api/` 添加新的 API 路由 126 | - 在 `src/components/` 添加新组件 127 | 128 | ## License 129 | 130 | MIT 131 | 132 | 133 | -------------------------------------------------------------------------------- /src/styles/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | /* 隐藏全局滚动条 */ 6 | ::-webkit-scrollbar { 7 | display: none; 8 | } 9 | 10 | * { 11 | -ms-overflow-style: none; 12 | scrollbar-width: none; 13 | } 14 | 15 | /* 自定义工具类 */ 16 | .scrollbar-none { 17 | -ms-overflow-style: none; 18 | scrollbar-width: none; 19 | } 20 | 21 | .scrollbar-none::-webkit-scrollbar { 22 | display: none; 23 | } 24 | 25 | /* 主题变量 */ 26 | :root { 27 | --background: #ffffff; 28 | --background-secondary: #ffffff; 29 | --icon-background: #f9fafb; 30 | --text-primary: #1a1a1a; 31 | --text-secondary: #666666; 32 | --text-description: #595959; 33 | --border-color: #e5e7eb; 34 | --hover-bg: #f3f4f6; 35 | --card-hover-bg: #454545; 36 | } 37 | 38 | .dark { 39 | --background: #121212; 40 | --background-secondary: #1a1a1a; 41 | --icon-background: #454545; 42 | --text-primary: #ffffff; 43 | --text-secondary: #8b8b8b; 44 | --text-description: #8b8b8b; 45 | --border-color: #2e2e2e; 46 | --hover-bg: #2a2a2a; 47 | --card-hover-bg: #454545; 48 | } 49 | 50 | /* 主题类 */ 51 | .theme-bg { 52 | background-color: var(--background); 53 | } 54 | 55 | .theme-bg-secondary { 56 | background-color: var(--background-secondary); 57 | } 58 | 59 | .theme-text-primary { 60 | color: var(--text-primary); 61 | } 62 | 63 | .theme-text-secondary { 64 | color: var(--text-secondary); 65 | } 66 | 67 | .theme-text-description { 68 | color: var(--text-description); 69 | } 70 | 71 | .theme-border { 72 | border: 1px solid var(--border-color); 73 | } 74 | 75 | /* 如果只想应用边框颜色 */ 76 | .theme-border-color { 77 | border-color: var(--border-color); 78 | } 79 | 80 | .theme-hover-bg { 81 | @apply transition-colors duration-200; 82 | } 83 | 84 | .theme-hover-bg:hover { 85 | background-color: var(--hover-bg); 86 | } 87 | 88 | /* 卡片悬浮效果 */ 89 | .dark .theme-bg-secondary.group:hover { 90 | background-color: var(--card-hover-bg); 91 | transform: translateY(-2px); 92 | transition: all 0.3s ease; 93 | } 94 | 95 | /* 搜索框样式 */ 96 | .dark .search-input { 97 | background-color: rgba(255, 255, 255, 0.05); 98 | color: var(--text-primary); 99 | } 100 | 101 | .dark .search-input::placeholder { 102 | color: var(--text-secondary); 103 | } 104 | 105 | /* 导航栏样式 */ 106 | .dark .nav-item { 107 | @apply text-gray-400 hover:text-white transition-colors; 108 | } 109 | 110 | .dark .nav-item.active { 111 | @apply text-white bg-gray-800; 112 | } 113 | 114 | /* 添加图标背景主题类 */ 115 | .theme-icon-bg { 116 | background-color: var(--icon-background); 117 | } 118 | 119 | /* 图标浮动动画 */ 120 | @keyframes float { 121 | 0%, 100% { 122 | transform: translateY(0); 123 | } 124 | 50% { 125 | transform: translateY(-10px); 126 | } 127 | } 128 | 129 | .animate-float-slow { 130 | animation: float 6s ease-in-out infinite; 131 | } 132 | 133 | .animate-float-normal { 134 | animation: float 5s ease-in-out infinite; 135 | } 136 | 137 | .animate-float-fast { 138 | animation: float 4s ease-in-out infinite; 139 | } -------------------------------------------------------------------------------- /src/pages/api/links.ts: -------------------------------------------------------------------------------- 1 | import { NextApiRequest, NextApiResponse } from 'next' 2 | import axios from 'axios' 3 | import { Link } from '@/types' 4 | 5 | // 飞书API配置 6 | const FEISHU_APP_ID = process.env.FEISHU_APP_ID 7 | const FEISHU_APP_SECRET = process.env.FEISHU_APP_SECRET 8 | const TABLE_ID = process.env.FEISHU_TABLE_ID 9 | 10 | // 获取访问令牌 11 | async function getAccessToken() { 12 | try { 13 | const res = await axios.post('https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal', { 14 | app_id: FEISHU_APP_ID, 15 | app_secret: FEISHU_APP_SECRET 16 | }) 17 | return res.data.tenant_access_token 18 | } catch (error) { 19 | console.error('Failed to get token:', error) 20 | throw error 21 | } 22 | } 23 | 24 | // 获取视图列表 25 | const getViews = async (token: string, appId: string, tableId: string) => { 26 | const response = await axios.get( 27 | `https://open.feishu.cn/open-apis/bitable/v1/apps/${appId}/tables/${tableId}/views`, 28 | { 29 | headers: { 30 | 'Authorization': `Bearer ${token}`, 31 | 'Content-Type': 'application/json' 32 | } 33 | } 34 | ) 35 | console.log('Views response:', JSON.stringify(response.data, null, 2)) 36 | return response.data.data.items[0].view_id 37 | } 38 | 39 | interface TableRecord { 40 | id: string; 41 | fields: { 42 | Category?: string | string[]; 43 | [key: string]: any; 44 | }; 45 | } 46 | 47 | // API路由处理函数 48 | export default async function handler(req: NextApiRequest, res: NextApiResponse) { 49 | try { 50 | const token = await getAccessToken() 51 | 52 | // 1. 获取表格列表 53 | const tablesResponse = await axios.get( 54 | `https://open.feishu.cn/open-apis/bitable/v1/apps/${TABLE_ID}/tables`, 55 | { 56 | headers: { 57 | 'Authorization': `Bearer ${token}`, 58 | 'Content-Type': 'application/json' 59 | } 60 | } 61 | ) 62 | 63 | const firstTableId = tablesResponse.data.data.items[0].table_id 64 | 65 | // 2. 获取所有记录 66 | const recordsResponse = await axios.get( 67 | `https://open.feishu.cn/open-apis/bitable/v1/apps/${TABLE_ID}/tables/${firstTableId}/records`, 68 | { 69 | headers: { 70 | 'Authorization': `Bearer ${token}`, 71 | 'Content-Type': 'application/json' 72 | } 73 | } 74 | ) 75 | 76 | // 3. 获取所有视图 77 | const viewsResponse = await axios.get( 78 | `https://open.feishu.cn/open-apis/bitable/v1/apps/${TABLE_ID}/tables/${firstTableId}/views`, 79 | { 80 | headers: { 81 | 'Authorization': `Bearer ${token}`, 82 | 'Content-Type': 'application/json' 83 | } 84 | } 85 | ) 86 | 87 | const views = viewsResponse.data.data.items 88 | const categoryOrder = views.map((view: { view_name: string }) => view.view_name) 89 | 90 | // 4. 处理记录数据 91 | const records = recordsResponse.data.data.items || [] 92 | 93 | // 修改处理 Category 的逻辑 94 | const processedRecords = records.map((record: TableRecord) => ({ 95 | ...record, 96 | fields: { 97 | ...record.fields, 98 | Category: Array.isArray(record.fields.Category) 99 | ? record.fields.Category 100 | : [record.fields.Category] 101 | } 102 | })); 103 | 104 | // 打印原始记录示例 105 | console.log('Sample raw record:', JSON.stringify(processedRecords[0], null, 2)) 106 | 107 | const links = processedRecords 108 | .filter((record: any) => 109 | record.fields.Title && 110 | record.fields.URL && 111 | record.fields.Description 112 | ) 113 | .map((record: any) => ({ 114 | title: record.fields.Title || '', 115 | url: record.fields.URL?.link || record.fields.URL?.text || '', 116 | description: record.fields.Description || '', 117 | category: record.fields.Category || [], 118 | icon: record.fields.Icon || '', 119 | recommend: record.fields.Recommend || '', 120 | order: record.fields.Order ? parseInt(record.fields.Order, 10) : Number.MAX_SAFE_INTEGER, 121 | tags: record.fields.Tags || [], 122 | viewOrders: record.fields.Category?.reduce((acc: Record, cat: string) => { 123 | acc[cat] = record.fields.Order ? parseInt(record.fields.Order, 10) : Number.MAX_SAFE_INTEGER 124 | return acc 125 | }, {}) || {} 126 | })) 127 | .sort((a: Link, b: Link) => a.order - b.order) 128 | 129 | // 打印处理后的示例数据 130 | console.log('Sample processed link:', JSON.stringify(links[0], null, 2)) 131 | console.log('Sample viewOrders:', JSON.stringify(links[0]?.viewOrders, null, 2)) 132 | 133 | // 返回处理后的数据 134 | res.status(200).json({ 135 | links, 136 | categoryOrder, 137 | // 添加调试信息 138 | debug: { 139 | sampleRawRecord: processedRecords[0], 140 | sampleProcessedLink: links[0], 141 | recordsCount: processedRecords.length, 142 | linksCount: links.length 143 | } 144 | }) 145 | 146 | } catch (error: any) { 147 | console.error('API Error:', error) 148 | res.status(500).json({ 149 | error: 'Failed to fetch data', 150 | message: error.message, 151 | details: error.response?.data 152 | }) 153 | } 154 | } -------------------------------------------------------------------------------- /src/components/IconBackground.tsx: -------------------------------------------------------------------------------- 1 | export function IconBackground() { 2 | return ( 3 |
4 |
5 | {/* 左上 */} 6 |
7 |
8 | 9 | 10 | 11 | 12 |
13 |
14 | 15 | {/* 左下 */} 16 |
17 |
18 | 19 | 20 | 21 | 22 | 23 | 24 |
25 |
26 | 27 | {/* 右上 */} 28 |
29 |
30 | 31 | 32 | 33 | 34 | 35 | 36 |
37 |
38 | 39 | {/* 右中 */} 40 |
41 |
42 | 43 | 44 | 45 | 46 |
47 |
48 | 49 | {/* 右下 */} 50 |
51 |
52 | 53 | 54 | 55 | 56 |
57 |
58 |
59 |
60 | ) 61 | } -------------------------------------------------------------------------------- /src/pages/index.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react' 2 | import axios from 'axios' 3 | import { Link } from '@/types' 4 | import Loading from '@/components/Loading' 5 | import { ThemeSwitch } from '@/components/ThemeSwitch' 6 | import { IconBackground } from '@/components/IconBackground' 7 | 8 | // 添加渐变色数组 9 | const gradientColors = [ 10 | 'from-pink-400 to-purple-400', 11 | 'from-blue-400 to-cyan-400', 12 | 'from-green-400 to-emerald-400', 13 | 'from-yellow-400 to-orange-400', 14 | 'from-purple-400 to-indigo-400', 15 | 'from-red-400 to-pink-400', 16 | ] 17 | 18 | export default function Home() { 19 | const [links, setLinks] = useState([]) 20 | const [categoryOrder, setCategoryOrder] = useState([]) 21 | const [error, setError] = useState('') 22 | const [loading, setLoading] = useState(true) 23 | const [loadStartTime, setLoadStartTime] = useState(0) 24 | const [searchTerm, setSearchTerm] = useState('') 25 | const [activeCategory, setActiveCategory] = useState('') 26 | const [activeTag, setActiveTag] = useState('') 27 | 28 | useEffect(() => { 29 | const fetchLinks = async () => { 30 | setLoadStartTime(Date.now()) 31 | try { 32 | const res = await axios.get('/api/links') 33 | console.log('API Response Debug:', res.data.debug) 34 | setLinks(res.data.links) 35 | setCategoryOrder(res.data.categoryOrder) 36 | } catch (err) { 37 | setError('Failed to fetch links') 38 | console.error(err) 39 | } finally { 40 | // 确保加载动画至少显示 500ms,避免太快的闪烁 41 | const loadTime = Date.now() - loadStartTime 42 | if (loadTime < 500) { 43 | setTimeout(() => setLoading(false), 500 - loadTime) 44 | } else { 45 | setLoading(false) 46 | } 47 | } 48 | } 49 | 50 | fetchLinks() 51 | }, []) 52 | 53 | // 添加获取随机渐变的函数 54 | const getRandomGradient = (text: string) => { 55 | const index = text.charCodeAt(0) % gradientColors.length 56 | return gradientColors[index] 57 | } 58 | 59 | // 获取所有标签 60 | const getAllTags = (category: string) => { 61 | return Array.from(new Set( 62 | links 63 | .filter(link => !category || link.category.includes(category)) 64 | .flatMap(link => link.tags) 65 | )).filter(Boolean) 66 | } 67 | 68 | if (loading) return 69 | if (error) return
Error: {error}
70 | 71 | // 修改过滤逻辑 72 | const filteredLinks = links 73 | .filter(link => { 74 | const matchesSearch = 75 | link.title.toLowerCase().includes(searchTerm.toLowerCase()) || 76 | link.description.toLowerCase().includes(searchTerm.toLowerCase()) 77 | const matchesCategory = !activeCategory || link.category.includes(activeCategory) 78 | const matchesTag = !activeTag || link.tags.includes(activeTag) 79 | return matchesSearch && matchesCategory && matchesTag 80 | }) 81 | .sort((a, b) => a.order - b.order) // 先对过滤后的链接进行排序 82 | 83 | // 修改分组逻辑 84 | const groupedLinks = filteredLinks.reduce((groups, link) => { 85 | // 如果是"全部"分类,按照categoryOrder分组 86 | if (!activeCategory) { 87 | link.category.forEach(category => { 88 | if (!groups[category]) { 89 | groups[category] = [] 90 | } 91 | groups[category].push(link) 92 | }) 93 | } else { 94 | // 如果是特定分类,使用现有的排序逻辑 95 | const cat = activeCategory 96 | if (!groups[cat]) { 97 | groups[cat] = [] 98 | } 99 | if (link.category.includes(cat)) { 100 | groups[cat].push(link) 101 | } 102 | } 103 | return groups 104 | }, {} as Record) 105 | 106 | // 只在特定分类下进行Order排序 107 | if (activeCategory) { 108 | Object.keys(groupedLinks).forEach(category => { 109 | groupedLinks[category].sort((a, b) => { 110 | if (a.order === b.order) { 111 | return a.title.localeCompare(b.title) 112 | } 113 | return a.order - b.order 114 | }) 115 | }) 116 | } 117 | 118 | // 修改 orderedCategories 的定义,确保只包含有内容的分类 119 | const orderedCategories = activeCategory 120 | ? [activeCategory] 121 | : categoryOrder.filter(cat => groupedLinks[cat] && groupedLinks[cat].length > 0) 122 | 123 | return ( 124 |
125 |
126 | {/* 左侧边栏 */} 127 |
128 |
129 | 141 | {categoryOrder.map(category => ( 142 | 155 | ))} 156 |
157 |
158 | 159 | {/* 右侧内容区 */} 160 |
161 | {/* 搜索区域 */} 162 |
166 | {/* 添加图标背景 */} 167 | 168 | 169 | {/* 主题切换按钮 */} 170 |
171 |
172 |
{/* 添加内边距增加点击区域 */} 173 | 174 |
175 |
{/* 添加内边距增加点击区域 */} 176 | 183 | 188 | 189 | 190 | 191 |
192 |
193 |
194 | 195 |

196 | 一个小小的导航网站 197 |

198 | 199 |
200 |
201 | setSearchTerm(e.target.value)} 211 | /> 212 | 218 | 224 | 225 |
226 |
227 |
228 | 229 | {/* 分类标题和标签 */} 230 | {activeCategory && ( 231 |
232 |

233 | {activeCategory} 234 |

235 | 236 |
237 | 247 | {getAllTags(activeCategory).map(tag => ( 248 | 259 | ))} 260 |
261 |
262 | )} 263 | 264 | {/* 内容区域 */} 265 | 359 |
360 |
361 |
362 | ) 363 | } --------------------------------------------------------------------------------