├── src ├── assets │ ├── font │ │ ├── iconfont.ttf │ │ ├── iconfont.woff │ │ ├── iconfont.woff2 │ │ └── iconfont.css │ ├── img │ │ ├── loading │ │ │ └── 3.gif │ │ ├── logo │ │ │ └── favicon.ico │ │ ├── wallpaper │ │ │ ├── 2.jpeg │ │ │ └── khadmv.webp │ │ └── error │ │ │ └── image-error.png │ └── css │ │ ├── tailwind.css │ │ ├── content.scss │ │ ├── normalize.css │ │ └── base.css ├── utils │ └── index.ts ├── vite-env.d.ts ├── App.vue ├── config │ ├── index.ts │ └── data.json ├── store │ ├── index.ts │ └── admin.ts ├── main.ts ├── router │ └── index.ts ├── components │ ├── index │ │ ├── Background.vue │ │ ├── Footer.vue │ │ ├── LeftDrawer.vue │ │ ├── Clock.vue │ │ ├── Sidebar.vue │ │ ├── Anchor.vue │ │ ├── Head.vue │ │ ├── Search.vue │ │ └── Site.vue │ └── admin │ │ └── LoginDialog.vue └── views │ ├── AdminCallback.vue │ ├── Index │ └── index.vue │ └── AdminDashboard.vue ├── postcss.config.js ├── vercel.json ├── tsconfig.node.json ├── .gitignore ├── api ├── tsconfig.json ├── auth │ ├── callback.ts │ └── github.ts ├── github │ ├── get-file.ts │ └── update-file.ts └── favicon.ts ├── index.html ├── package.json ├── tsconfig.app.json ├── tailwind.utils.js ├── tsconfig.json ├── env.example ├── vite-plugin-favicon-api.ts ├── README.md ├── vite.config.ts └── tailwind.config.js /src/assets/font/iconfont.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xia-66/nav/HEAD/src/assets/font/iconfont.ttf -------------------------------------------------------------------------------- /src/assets/img/loading/3.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xia-66/nav/HEAD/src/assets/img/loading/3.gif -------------------------------------------------------------------------------- /src/utils/index.ts: -------------------------------------------------------------------------------- 1 | // 打开页面 2 | export function openUrl(url: string) { 3 | window.open(url) 4 | } 5 | -------------------------------------------------------------------------------- /src/assets/font/iconfont.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xia-66/nav/HEAD/src/assets/font/iconfont.woff -------------------------------------------------------------------------------- /src/assets/font/iconfont.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xia-66/nav/HEAD/src/assets/font/iconfont.woff2 -------------------------------------------------------------------------------- /src/assets/img/logo/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xia-66/nav/HEAD/src/assets/img/logo/favicon.ico -------------------------------------------------------------------------------- /src/assets/img/wallpaper/2.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xia-66/nav/HEAD/src/assets/img/wallpaper/2.jpeg -------------------------------------------------------------------------------- /src/assets/img/error/image-error.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xia-66/nav/HEAD/src/assets/img/error/image-error.png -------------------------------------------------------------------------------- /src/assets/img/wallpaper/khadmv.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xia-66/nav/HEAD/src/assets/img/wallpaper/khadmv.webp -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /src/assets/css/tailwind.css: -------------------------------------------------------------------------------- 1 | /* 取消浏览器样式 */ 2 | /* @tailwind base; */ 3 | @tailwind base; 4 | @tailwind components; 5 | @tailwind utilities; 6 | -------------------------------------------------------------------------------- /src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | declare module '*.vue' { 3 | import { DefineComponent } from "vue" 4 | const component: DefineComponent<{}, {}, any> 5 | export default component 6 | } 7 | -------------------------------------------------------------------------------- /vercel.json: -------------------------------------------------------------------------------- 1 | { 2 | "buildCommand": "npm run build", 3 | "outputDirectory": "dist", 4 | "framework": "vite", 5 | "rewrites": [ 6 | { 7 | "source": "/api/(.*)", 8 | "destination": "/api/$1" 9 | }, 10 | { 11 | "source": "/(.*)", 12 | "destination": "/index.html" 13 | } 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /src/App.vue: -------------------------------------------------------------------------------- 1 | 4 | 9 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", 5 | "skipLibCheck": true, 6 | "module": "ESNext", 7 | "moduleResolution": "bundler", 8 | "allowSyntheticDefaultImports": true, 9 | "strict": true, 10 | "noEmit": true 11 | }, 12 | "include": ["vite.config.ts", "vite-plugin-*.ts"] 13 | } 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | .env 26 | .env.local 27 | .env.development 28 | .env.production -------------------------------------------------------------------------------- /api/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2022", 4 | "module": "ESNext", 5 | "moduleResolution": "bundler", 6 | "lib": ["ES2022"], 7 | "esModuleInterop": true, 8 | "skipLibCheck": true, 9 | "strict": true, 10 | "resolveJsonModule": true, 11 | "isolatedModules": true, 12 | "noEmit": true 13 | }, 14 | "include": ["*.ts"], 15 | "exclude": ["node_modules"] 16 | } 17 | 18 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 黑羽导航 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/config/index.ts: -------------------------------------------------------------------------------- 1 | import * as pkg from '../../package.json' 2 | 3 | // ==================== 网站基本信息 ==================== 4 | // 网站名称 5 | export const SITE_NAME = '黑羽导航' 6 | 7 | // 备案号 8 | export const ICP_NUMBER = '' 9 | 10 | // ==================== 版本信息 ==================== 11 | // 项目版本号 12 | export const RELEASE = `v${pkg.version}` 13 | 14 | // ==================== API 配置 ==================== 15 | // 获取网站Favicon接口 16 | // 开发环境通过 Vite 插件模拟,生产环境使用 Vercel Serverless Function 17 | export const Favicon = '/api/favicon?url=' 18 | -------------------------------------------------------------------------------- /src/store/index.ts: -------------------------------------------------------------------------------- 1 | import { defineStore } from 'pinia' 2 | 3 | export const useMainStore = defineStore('mainStore', { 4 | state: () => { 5 | return { 6 | isShowDrawer: false, 7 | site: [], 8 | menu: [ 9 | { 10 | index: 1, 11 | name: '首页', 12 | iconClass: 'iconfont icon-md-home' 13 | }, 14 | { 15 | index: 2, 16 | name: '更新日志', 17 | iconClass: 'iconfont icon-iconset0278' 18 | }, 19 | { 20 | index: 3, 21 | name: '关于我们', 22 | iconClass: 'iconfont icon-iconset0156' 23 | } 24 | ] 25 | } 26 | }, 27 | getters: {}, 28 | actions: {}, 29 | 30 | persist: true 31 | }) 32 | -------------------------------------------------------------------------------- /api/auth/callback.ts: -------------------------------------------------------------------------------- 1 | import { VercelRequest, VercelResponse } from '@vercel/node'; 2 | 3 | /** 4 | * GitHub OAuth 回调处理 5 | * 将 GitHub 的回调重定向到前端的 hash 路由 6 | */ 7 | export default async function handler(req: VercelRequest, res: VercelResponse) { 8 | const { code, error, error_description } = req.query; 9 | 10 | // 如果有错误,重定向到首页并提示错误 11 | if (error) { 12 | const errorMsg = error_description || error || '授权失败'; 13 | return res.redirect(`/#/index?error=${encodeURIComponent(errorMsg as string)}`); 14 | } 15 | 16 | // 如果没有 code,返回错误 17 | if (!code) { 18 | return res.redirect('/#/index?error=缺少授权码'); 19 | } 20 | 21 | // 重定向到前端的 callback 页面(hash 路由) 22 | return res.redirect(`/#/admin/callback?code=${code}`); 23 | } 24 | 25 | -------------------------------------------------------------------------------- /src/assets/css/content.scss: -------------------------------------------------------------------------------- 1 | body { 2 | font-size: 14px; 3 | color: var(--gray-600); 4 | } 5 | 6 | a { 7 | text-decoration: none; 8 | } 9 | 10 | li { 11 | list-style-type: none; 12 | } 13 | 14 | // 鼠标小手 15 | .pointer { 16 | cursor: pointer; 17 | } 18 | 19 | // 文字溢出处理 20 | .text { 21 | white-space: nowrap; 22 | overflow: hidden; 23 | text-overflow: ellipsis; 24 | word-break: break-all; 25 | } 26 | 27 | // 右键菜单选择颜色 28 | .hs-right-menu-shadow { 29 | box-shadow: 0 0px 0px 2px var(--red-400), 0px 0px 0px 4px var(--blue-400) !important; 30 | } 31 | 32 | // 继承主题样式 33 | .inherit-theme { 34 | .inherit-text { 35 | color: inherit !important; 36 | } 37 | .inherit-bg { 38 | background-color: inherit !important; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { createApp } from 'vue' 2 | import App from './App.vue' 3 | import ElementPlus from 'element-plus' 4 | import 'element-plus/dist/index.css' 5 | import '@/assets/css/tailwind.css' 6 | import '@/assets/css/normalize.css' 7 | import '@/assets/font/iconfont.css' 8 | import '@/assets/css/base.css' 9 | import '@/assets/css/content.scss' 10 | import router from "./router"; 11 | 12 | import { createPinia } from "pinia"; 13 | import piniaPluginPersistedstate from 'pinia-plugin-persistedstate' 14 | 15 | const pinia = createPinia(); 16 | pinia.use(piniaPluginPersistedstate) 17 | createApp(App).use(router).use(ElementPlus).use(pinia).mount('#app') 18 | // router.beforeEach((to, from, next) => { 19 | // if (to.meta.title) { 20 | // document.title = to.meta.title; 21 | // } 22 | // next(); 23 | // }); -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "heiyu-nav", 3 | "private": true, 4 | "version": "2.2.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite --mode dev --open", 8 | "build": "vite build --mode prod", 9 | "preview": "vite preview" 10 | }, 11 | "dependencies": { 12 | "element-plus": "^2.7.6", 13 | "pinia": "^2.1.7", 14 | "pinia-plugin-persistedstate": "^3.2.1", 15 | "vue": "^3.4.29", 16 | "vue-router": "^4.4.0" 17 | }, 18 | "devDependencies": { 19 | "@types/node": "^20.14.9", 20 | "@vercel/node": "^5.5.3", 21 | "@vitejs/plugin-vue": "^5.0.5", 22 | "autoprefixer": "^10.4.19", 23 | "postcss": "^8.4.39", 24 | "sass": "^1.86.3", 25 | "tailwindcss": "^3.4.4", 26 | "typescript": "^5.2.2", 27 | "vite": "^5.3.1", 28 | "vue-tsc": "^2.0.21" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", 5 | "target": "ES2020", 6 | "useDefineForClassFields": true, 7 | "module": "ESNext", 8 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 9 | "skipLibCheck": true, 10 | 11 | /* Bundler mode */ 12 | "moduleResolution": "bundler", 13 | "allowImportingTsExtensions": true, 14 | "resolveJsonModule": true, 15 | "isolatedModules": true, 16 | "moduleDetection": "force", 17 | "noEmit": true, 18 | "jsx": "preserve", 19 | 20 | /* Linting */ 21 | "strict": true, 22 | "noUnusedLocals": true, 23 | "noUnusedParameters": true, 24 | "noFallthroughCasesInSwitch": true 25 | }, 26 | "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"] 27 | } 28 | -------------------------------------------------------------------------------- /tailwind.utils.js: -------------------------------------------------------------------------------- 1 | 2 | 3 | /** 4 | * 注入精细化变量: 5 | * top-px-98 6 | * -top-px-98 7 | * p-px-50 8 | * -p-px-50 9 | * m-px-50 10 | * -m-px-50 11 | * w-px-50 12 | * -w-px-50 13 | * h-px-50 14 | * -h-px-50 15 | * @returns Object 16 | */ 17 | 18 | /** 19 | * // 构建px的尺寸 20 | * @param {Number} range 生成范围 21 | * @param {String} tag 正数 | 负数 22 | * @returns 23 | */ 24 | function createPX(range = 400, tag) { 25 | let fullPxs = {}; 26 | new Array(range).fill(undefined).forEach((item, index) => { 27 | if (index % 2 === 0 || index % 5 === 0) { 28 | if (tag === 'negative') { 29 | // 负数 30 | fullPxs[`-px-${index}`] = `-${index}px`; 31 | } else { 32 | // 正数 33 | fullPxs[`px-${index}`] = `${index}px`; 34 | } 35 | } 36 | }); 37 | return fullPxs; 38 | } 39 | 40 | module.exports = { createPX }; 41 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "module": "ESNext", 6 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 7 | "skipLibCheck": true, 8 | 9 | /* 解决找不到vue模块的问题 */ 10 | "moduleResolution": "node", 11 | "allowImportingTsExtensions": true, 12 | "resolveJsonModule": true, 13 | "allowSyntheticDefaultImports": true, 14 | "isolatedModules": true, 15 | "noEmit": true, 16 | "jsx": "preserve", 17 | 18 | "strict": true, 19 | "noUnusedLocals": true, 20 | "noUnusedParameters": true, 21 | "noFallthroughCasesInSwitch": true, 22 | 23 | /* 使用@映射至src目录 */ 24 | "paths": { 25 | "@/*": ["./src/*"] 26 | } 27 | }, 28 | "include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue", "src/utils/index.ts"], 29 | "references": [{ "path": "./tsconfig.node.json" }] 30 | } 31 | -------------------------------------------------------------------------------- /env.example: -------------------------------------------------------------------------------- 1 | # GitHub OAuth 配置 2 | # 在 https://github.com/settings/developers 创建 OAuth App 获取 3 | GITHUB_CLIENT_ID=your_github_client_id_here 4 | GITHUB_CLIENT_SECRET=your_github_client_secret_here 5 | 6 | # GitHub 仓库配置 7 | # 格式:owner/repo(例如:xia-66/nav) 8 | GITHUB_REPO_OWNER=xia-66 9 | GITHUB_REPO_NAME=nav 10 | 11 | # 允许访问后台的 GitHub 用户名(多个用户用逗号分隔) 12 | GITHUB_ALLOWED_USERS=xia-66 13 | 14 | # 说明: 15 | # 1. VITE_GITHUB_CLIENT_ID - GitHub OAuth App 的 Client ID(前端使用) 16 | # 2. GITHUB_CLIENT_SECRET - GitHub OAuth App 的 Client Secret(后端使用,不要泄露) 17 | # 3. GITHUB_REPO_OWNER - 仓库所有者(你的 GitHub 用户名) 18 | # 4. GITHUB_REPO_NAME - 仓库名称 19 | # 5. GITHUB_ALLOWED_USERS - 允许登录后台的用户名 20 | # 21 | # OAuth App 配置: 22 | # - Application name: 任意名称(例如:黑羽导航管理后台) 23 | # - Homepage URL: https://yourdomain.com 24 | # - Authorization callback URL: https://yourdomain.com/api/auth/callback 25 | # 26 | # 需要的权限(Scope): 27 | # - repo(用于读写仓库文件) 28 | 29 | -------------------------------------------------------------------------------- /src/router/index.ts: -------------------------------------------------------------------------------- 1 | import { createRouter, createWebHashHistory, RouteRecordRaw } from 'vue-router' 2 | import { SITE_NAME } from '@/config' 3 | 4 | const routes: Array = [ 5 | { 6 | path: '/', 7 | redirect: '/index' 8 | }, 9 | { 10 | path: '/index', 11 | name: 'Index', 12 | component: () => import('@/views/Index/index.vue'), 13 | meta: { title: SITE_NAME } 14 | }, 15 | { 16 | path: '/admin/callback', 17 | name: 'AdminCallback', 18 | component: () => import('@/views/AdminCallback.vue'), 19 | meta: { title: '授权回调 - ' + SITE_NAME } 20 | }, 21 | { 22 | path: '/admin/dashboard', 23 | name: 'AdminDashboard', 24 | component: () => import('@/views/AdminDashboard.vue'), 25 | meta: { title: '后台管理 - ' + SITE_NAME } 26 | } 27 | ] 28 | 29 | const router = createRouter({ 30 | history: createWebHashHistory(), 31 | routes 32 | }) 33 | 34 | export default router 35 | -------------------------------------------------------------------------------- /src/components/index/Background.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 51 | -------------------------------------------------------------------------------- /src/views/AdminCallback.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 40 | 41 | 46 | 47 | -------------------------------------------------------------------------------- /src/components/index/Footer.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 26 | 27 | 48 | -------------------------------------------------------------------------------- /api/github/get-file.ts: -------------------------------------------------------------------------------- 1 | import { VercelRequest, VercelResponse } from '@vercel/node'; 2 | 3 | interface GitHubFileResponse { 4 | content: string; 5 | sha: string; 6 | } 7 | 8 | const REPO_OWNER = process.env.GITHUB_REPO_OWNER || 'xia-66'; 9 | const REPO_NAME = process.env.GITHUB_REPO_NAME || 'nav'; 10 | const FILE_PATH = 'src/config/data.json'; 11 | 12 | export default async function handler(req: VercelRequest, res: VercelResponse) { 13 | const { token } = req.query; 14 | 15 | if (!token || typeof token !== 'string') { 16 | return res.status(401).json({ error: '未授权' }); 17 | } 18 | 19 | try { 20 | // 获取文件内容和 SHA 21 | const response = await fetch( 22 | `https://api.github.com/repos/${REPO_OWNER}/${REPO_NAME}/contents/${FILE_PATH}`, 23 | { 24 | headers: { 25 | 'Authorization': `token ${token}`, 26 | 'Accept': 'application/vnd.github.v3+json', 27 | }, 28 | } 29 | ); 30 | 31 | if (!response.ok) { 32 | throw new Error('获取文件失败'); 33 | } 34 | 35 | const data = await response.json() as GitHubFileResponse; 36 | 37 | // 解码 Base64 内容 38 | const content = Buffer.from(data.content, 'base64').toString('utf-8'); 39 | 40 | return res.status(200).json({ 41 | content: JSON.parse(content), 42 | sha: data.sha, 43 | }); 44 | } catch (error) { 45 | console.error('Get File Error:', error); 46 | return res.status(500).json({ error: '获取文件失败' }); 47 | } 48 | } 49 | 50 | -------------------------------------------------------------------------------- /src/components/index/LeftDrawer.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 38 | -------------------------------------------------------------------------------- /src/components/index/Clock.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 34 | 35 | -------------------------------------------------------------------------------- /src/views/Index/index.vue: -------------------------------------------------------------------------------- 1 | 27 | 28 | 46 | 75 | -------------------------------------------------------------------------------- /src/components/index/Sidebar.vue: -------------------------------------------------------------------------------- 1 | 19 | 60 | 61 | 85 | -------------------------------------------------------------------------------- /src/components/index/Anchor.vue: -------------------------------------------------------------------------------- 1 | 14 | 45 | 46 | 83 | -------------------------------------------------------------------------------- /api/auth/github.ts: -------------------------------------------------------------------------------- 1 | import { VercelRequest, VercelResponse } from '@vercel/node'; 2 | 3 | interface GitHubTokenResponse { 4 | access_token?: string; 5 | error?: string; 6 | error_description?: string; 7 | } 8 | 9 | interface GitHubUser { 10 | login: string; 11 | name: string; 12 | avatar_url: string; 13 | } 14 | 15 | const ALLOWED_USERS = (process.env.GITHUB_ALLOWED_USERS || 'xia-66').split(',').map(u => u.trim()); 16 | 17 | export default async function handler(req: VercelRequest, res: VercelResponse) { 18 | const { code } = req.query; 19 | 20 | if (!code) { 21 | return res.status(400).json({ error: '缺少授权码' }); 22 | } 23 | 24 | const clientId = process.env.GITHUB_CLIENT_ID; 25 | const clientSecret = process.env.GITHUB_CLIENT_SECRET; 26 | 27 | if (!clientId || !clientSecret) { 28 | return res.status(500).json({ error: '服务器配置错误' }); 29 | } 30 | 31 | try { 32 | // 1. 使用 code 换取 access_token 33 | const tokenResponse = await fetch('https://github.com/login/oauth/access_token', { 34 | method: 'POST', 35 | headers: { 36 | 'Content-Type': 'application/json', 37 | 'Accept': 'application/json', 38 | }, 39 | body: JSON.stringify({ 40 | client_id: clientId, 41 | client_secret: clientSecret, 42 | code: code, 43 | }), 44 | }); 45 | 46 | const tokenData = await tokenResponse.json() as GitHubTokenResponse; 47 | 48 | if (tokenData.error) { 49 | return res.status(400).json({ error: tokenData.error_description || '授权失败' }); 50 | } 51 | 52 | const accessToken = tokenData.access_token; 53 | 54 | // 2. 获取用户信息 55 | const userResponse = await fetch('https://api.github.com/user', { 56 | headers: { 57 | 'Authorization': `token ${accessToken}`, 58 | 'Accept': 'application/vnd.github.v3+json', 59 | }, 60 | }); 61 | 62 | const userData = await userResponse.json() as GitHubUser; 63 | 64 | // 3. 验证用户是否在允许列表中 65 | if (!ALLOWED_USERS.includes(userData.login)) { 66 | return res.status(403).json({ error: `无权限访问,仅允许以下用户: ${ALLOWED_USERS.join(', ')}` }); 67 | } 68 | 69 | // 4. 返回 token 和用户信息 70 | return res.status(200).json({ 71 | token: accessToken, 72 | user: { 73 | login: userData.login, 74 | name: userData.name, 75 | avatar_url: userData.avatar_url, 76 | }, 77 | }); 78 | } catch (error) { 79 | console.error('GitHub OAuth Error:', error); 80 | return res.status(500).json({ error: '服务器内部错误' }); 81 | } 82 | } 83 | 84 | -------------------------------------------------------------------------------- /api/github/update-file.ts: -------------------------------------------------------------------------------- 1 | import { VercelRequest, VercelResponse } from '@vercel/node'; 2 | 3 | interface GitHubUser { 4 | login: string; 5 | } 6 | 7 | interface GitHubErrorResponse { 8 | message?: string; 9 | } 10 | 11 | interface GitHubUpdateResponse { 12 | commit: { 13 | sha: string; 14 | url: string; 15 | }; 16 | } 17 | 18 | const REPO_OWNER = process.env.GITHUB_REPO_OWNER || 'xia-66'; 19 | const REPO_NAME = process.env.GITHUB_REPO_NAME || 'nav'; 20 | const FILE_PATH = 'src/config/data.json'; 21 | const ALLOWED_USERS = (process.env.GITHUB_ALLOWED_USERS || 'xia-66').split(',').map(u => u.trim()); 22 | 23 | export default async function handler(req: VercelRequest, res: VercelResponse) { 24 | if (req.method !== 'POST') { 25 | return res.status(405).json({ error: '方法不允许' }); 26 | } 27 | 28 | const { token, content, sha, message } = req.body; 29 | 30 | if (!token || !content || !sha) { 31 | return res.status(400).json({ error: '缺少必要参数' }); 32 | } 33 | 34 | try { 35 | // 验证用户身份 36 | const userResponse = await fetch('https://api.github.com/user', { 37 | headers: { 38 | 'Authorization': `token ${token}`, 39 | 'Accept': 'application/vnd.github.v3+json', 40 | }, 41 | }); 42 | 43 | const userData = await userResponse.json() as GitHubUser; 44 | 45 | if (!ALLOWED_USERS.includes(userData.login)) { 46 | return res.status(403).json({ error: `无权限修改,仅允许以下用户: ${ALLOWED_USERS.join(', ')}` }); 47 | } 48 | 49 | // 更新文件 50 | const updateResponse = await fetch( 51 | `https://api.github.com/repos/${REPO_OWNER}/${REPO_NAME}/contents/${FILE_PATH}`, 52 | { 53 | method: 'PUT', 54 | headers: { 55 | 'Authorization': `token ${token}`, 56 | 'Accept': 'application/vnd.github.v3+json', 57 | 'Content-Type': 'application/json', 58 | }, 59 | body: JSON.stringify({ 60 | message: message || '更新导航数据', 61 | content: Buffer.from(JSON.stringify(content, null, 2)).toString('base64'), 62 | sha: sha, 63 | }), 64 | } 65 | ); 66 | 67 | if (!updateResponse.ok) { 68 | const errorData = await updateResponse.json() as GitHubErrorResponse; 69 | throw new Error(errorData.message || '更新文件失败'); 70 | } 71 | 72 | const result = await updateResponse.json() as GitHubUpdateResponse; 73 | 74 | return res.status(200).json({ 75 | success: true, 76 | commit: result.commit, 77 | }); 78 | } catch (error: any) { 79 | console.error('Update File Error:', error); 80 | return res.status(500).json({ error: error.message || '更新文件失败' }); 81 | } 82 | } 83 | 84 | -------------------------------------------------------------------------------- /src/assets/css/normalize.css: -------------------------------------------------------------------------------- 1 | article, 2 | aside, 3 | details, 4 | figcaption, 5 | figure, 6 | footer, 7 | header, 8 | hgroup, 9 | main, 10 | nav, 11 | section, 12 | summary { 13 | display: block; 14 | } 15 | 16 | audio, 17 | canvas, 18 | video { 19 | display: inline-block; 20 | } 21 | 22 | audio:not([controls]) { 23 | display: none; 24 | height: 0; 25 | } 26 | 27 | [hidden], 28 | template { 29 | display: none; 30 | } 31 | 32 | html { 33 | font-family: sans-serif; /* 1 */ 34 | -ms-text-size-adjust: 100%; /* 2 */ 35 | -webkit-text-size-adjust: 100%; /* 2 */ 36 | } 37 | 38 | body { 39 | margin: 0; 40 | } 41 | 42 | a { 43 | background: transparent; 44 | } 45 | 46 | a:focus { 47 | outline: none; 48 | } 49 | 50 | a:active, 51 | a:hover { 52 | outline: 0; 53 | } 54 | 55 | h1 { 56 | font-size: 2em; 57 | margin: 0.67em 0; 58 | } 59 | 60 | abbr[title] { 61 | border-bottom: 1px dotted; 62 | } 63 | 64 | b, 65 | strong { 66 | font-weight: bold; 67 | } 68 | 69 | dfn { 70 | font-style: italic; 71 | } 72 | 73 | hr { 74 | -moz-box-sizing: content-box; 75 | box-sizing: content-box; 76 | height: 0; 77 | } 78 | 79 | mark { 80 | background: #ff0; 81 | color: #000; 82 | } 83 | 84 | code, 85 | kbd, 86 | pre, 87 | samp { 88 | font-family: monospace, serif; 89 | font-size: 1em; 90 | } 91 | 92 | pre { 93 | white-space: pre-wrap; 94 | } 95 | 96 | q { 97 | quotes: "\201C""\201D""\2018""\2019"; 98 | } 99 | 100 | small { 101 | font-size: 80%; 102 | } 103 | 104 | sub, 105 | sup { 106 | font-size: 75%; 107 | line-height: 0; 108 | position: relative; 109 | vertical-align: baseline; 110 | } 111 | 112 | sup { 113 | top: -0.5em; 114 | } 115 | 116 | sub { 117 | bottom: -0.25em; 118 | } 119 | 120 | img { 121 | border: 0; 122 | } 123 | 124 | svg:not(:root) { 125 | overflow: hidden; 126 | } 127 | 128 | figure { 129 | margin: 0; 130 | } 131 | 132 | fieldset { 133 | border: 1px solid #c0c0c0; 134 | margin: 0 2px; 135 | padding: 0.35em 0.625em 0.75em; 136 | } 137 | 138 | legend { 139 | border: 0; /* 1 */ 140 | padding: 0; /* 2 */ 141 | } 142 | 143 | button, 144 | input, 145 | select, 146 | textarea { 147 | font-family: inherit; /* 1 */ 148 | font-size: 100%; /* 2 */ 149 | margin: 0; /* 3 */ 150 | } 151 | 152 | button, 153 | input { 154 | line-height: normal; 155 | } 156 | 157 | button, 158 | select { 159 | text-transform: none; 160 | } 161 | 162 | button, 163 | html input[type="button"], /* 1 */ 164 | input[type="reset"], 165 | input[type="submit"] { 166 | -webkit-appearance: button; /* 2 */ 167 | cursor: pointer; /* 3 */ 168 | } 169 | 170 | button[disabled], 171 | html input[disabled] { 172 | cursor: default; 173 | } 174 | 175 | input[type="checkbox"], 176 | input[type="radio"] { 177 | box-sizing: border-box; /* 1 */ 178 | padding: 0; /* 2 */ 179 | } 180 | 181 | input[type="search"] { 182 | -webkit-appearance: textfield; /* 1 */ 183 | -moz-box-sizing: content-box; 184 | -webkit-box-sizing: content-box; /* 2 */ 185 | box-sizing: content-box; 186 | } 187 | 188 | input[type="search"]::-webkit-search-cancel-button, 189 | input[type="search"]::-webkit-search-decoration { 190 | -webkit-appearance: none; 191 | } 192 | 193 | button::-moz-focus-inner, 194 | input::-moz-focus-inner { 195 | border: 0; 196 | padding: 0; 197 | } 198 | 199 | textarea { 200 | overflow: auto; /* 1 */ 201 | vertical-align: top; /* 2 */ 202 | } 203 | 204 | table { 205 | border-collapse: collapse; 206 | border-spacing: 0; 207 | } 208 | -------------------------------------------------------------------------------- /src/store/admin.ts: -------------------------------------------------------------------------------- 1 | import { defineStore } from 'pinia'; 2 | import { ref } from 'vue'; 3 | 4 | interface User { 5 | login: string; 6 | name: string; 7 | avatar_url: string; 8 | } 9 | 10 | export const useAdminStore = defineStore('admin', () => { 11 | const token = ref(localStorage.getItem('github_token')); 12 | const user = ref( 13 | localStorage.getItem('github_user') 14 | ? JSON.parse(localStorage.getItem('github_user')!) 15 | : null 16 | ); 17 | const isAuthenticated = ref(!!token.value); 18 | 19 | // 设置认证信息 20 | const setAuth = (newToken: string, newUser: User) => { 21 | token.value = newToken; 22 | user.value = newUser; 23 | isAuthenticated.value = true; 24 | localStorage.setItem('github_token', newToken); 25 | localStorage.setItem('github_user', JSON.stringify(newUser)); 26 | }; 27 | 28 | // 清除认证信息 29 | const clearAuth = () => { 30 | token.value = null; 31 | user.value = null; 32 | isAuthenticated.value = false; 33 | localStorage.removeItem('github_token'); 34 | localStorage.removeItem('github_user'); 35 | }; 36 | 37 | // GitHub OAuth 登录 38 | const loginWithGitHub = () => { 39 | const clientId = import.meta.env.VITE_GITHUB_CLIENT_ID; 40 | // 注意:这里使用 /api/auth/callback 作为回调地址(没有 #) 41 | // 服务端会重定向到 /#/admin/callback 42 | const redirectUri = `${window.location.origin}/api/auth/callback`; 43 | const scope = 'repo'; 44 | 45 | window.location.href = `https://github.com/login/oauth/authorize?client_id=${clientId}&redirect_uri=${redirectUri}&scope=${scope}`; 46 | }; 47 | 48 | // 处理 OAuth 回调 49 | const handleCallback = async (code: string) => { 50 | try { 51 | const response = await fetch(`/api/auth/github?code=${code}`); 52 | const data = await response.json(); 53 | 54 | if (!response.ok) { 55 | throw new Error(data.error || '登录失败'); 56 | } 57 | 58 | setAuth(data.token, data.user); 59 | return { success: true }; 60 | } catch (error: any) { 61 | return { success: false, error: error.message }; 62 | } 63 | }; 64 | 65 | // 获取文件内容 66 | const getFileContent = async () => { 67 | if (!token.value) { 68 | throw new Error('未授权'); 69 | } 70 | 71 | try { 72 | const response = await fetch(`/api/github/get-file?token=${token.value}`); 73 | const data = await response.json(); 74 | 75 | if (!response.ok) { 76 | throw new Error(data.error || '获取文件失败'); 77 | } 78 | 79 | return data; 80 | } catch (error: any) { 81 | throw new Error(error.message || '获取文件失败'); 82 | } 83 | }; 84 | 85 | // 更新文件内容 86 | const updateFileContent = async (content: any, sha: string, message?: string) => { 87 | if (!token.value) { 88 | throw new Error('未授权'); 89 | } 90 | 91 | try { 92 | const response = await fetch('/api/github/update-file', { 93 | method: 'POST', 94 | headers: { 95 | 'Content-Type': 'application/json', 96 | }, 97 | body: JSON.stringify({ 98 | token: token.value, 99 | content, 100 | sha, 101 | message, 102 | }), 103 | }); 104 | 105 | const data = await response.json(); 106 | 107 | if (!response.ok) { 108 | throw new Error(data.error || '更新文件失败'); 109 | } 110 | 111 | return data; 112 | } catch (error: any) { 113 | throw new Error(error.message || '更新文件失败'); 114 | } 115 | }; 116 | 117 | return { 118 | token, 119 | user, 120 | isAuthenticated, 121 | setAuth, 122 | clearAuth, 123 | loginWithGitHub, 124 | handleCallback, 125 | getFileContent, 126 | updateFileContent, 127 | }; 128 | }); 129 | 130 | -------------------------------------------------------------------------------- /vite-plugin-favicon-api.ts: -------------------------------------------------------------------------------- 1 | import type { Plugin } from 'vite' 2 | 3 | /** 4 | * Vite 插件 - 开发环境 Favicon API 模拟 5 | * 在开发环境中提供 /api/favicon 路由 6 | */ 7 | export default function faviconApiPlugin(): Plugin { 8 | return { 9 | name: 'vite-plugin-favicon-api', 10 | configureServer(server) { 11 | server.middlewares.use(async (req, res, next) => { 12 | // 只处理 /api/favicon 请求 13 | if (!req.url?.startsWith('/api/favicon')) { 14 | return next() 15 | } 16 | 17 | // 解析查询参数 18 | const url = new URL(req.url, `http://${req.headers.host}`) 19 | const targetUrl = url.searchParams.get('url') 20 | 21 | if (!targetUrl) { 22 | res.statusCode = 400 23 | res.setHeader('Content-Type', 'application/json') 24 | res.end(JSON.stringify({ error: '缺少 URL 参数' })) 25 | return 26 | } 27 | 28 | try { 29 | // 解析目标 URL 30 | let parsedUrl: URL 31 | try { 32 | parsedUrl = new URL(targetUrl.startsWith('http') ? targetUrl : `https://${targetUrl}`) 33 | } catch { 34 | res.statusCode = 400 35 | res.setHeader('Content-Type', 'application/json') 36 | res.end(JSON.stringify({ error: 'URL 格式错误' })) 37 | return 38 | } 39 | 40 | const hostname = parsedUrl.hostname 41 | 42 | // 尝试获取 favicon 43 | const services = [ 44 | `https://www.google.com/s2/favicons?domain=${hostname}&sz=128`, 45 | `https://icons.duckduckgo.com/ip3/${hostname}.ico`, 46 | `https://favicon.im/${hostname}?larger=true`, 47 | `${parsedUrl.origin}/favicon.ico`, 48 | ] 49 | 50 | for (const serviceUrl of services) { 51 | try { 52 | const controller = new AbortController() 53 | const timeoutId = setTimeout(() => controller.abort(), 5000) 54 | 55 | const response = await fetch(serviceUrl, { 56 | headers: { 57 | 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', 58 | }, 59 | signal: controller.signal, 60 | }) 61 | 62 | clearTimeout(timeoutId) 63 | 64 | if (response.ok) { 65 | const buffer = await response.arrayBuffer() 66 | const contentType = response.headers.get('content-type') || 'image/x-icon' 67 | 68 | res.statusCode = 200 69 | res.setHeader('Content-Type', contentType) 70 | res.setHeader('Cache-Control', 'public, max-age=86400') 71 | res.end(Buffer.from(buffer)) 72 | return 73 | } 74 | } catch (error) { 75 | // 继续尝试下一个服务 76 | continue 77 | } 78 | } 79 | 80 | // 所有服务都失败,返回默认图标 81 | const defaultSvg = ` 82 | 83 | ? 84 | ` 85 | 86 | res.statusCode = 200 87 | res.setHeader('Content-Type', 'image/svg+xml') 88 | res.setHeader('Cache-Control', 'public, max-age=3600') 89 | res.end(defaultSvg) 90 | } catch (error) { 91 | console.error('Favicon API 错误:', error) 92 | 93 | const errorSvg = ` 94 | 95 | ! 96 | ` 97 | 98 | res.statusCode = 200 99 | res.setHeader('Content-Type', 'image/svg+xml') 100 | res.end(errorSvg) 101 | } 102 | }) 103 | }, 104 | } 105 | } 106 | 107 | -------------------------------------------------------------------------------- /src/components/index/Head.vue: -------------------------------------------------------------------------------- 1 | 22 | 72 | -------------------------------------------------------------------------------- /api/favicon.ts: -------------------------------------------------------------------------------- 1 | import type { VercelRequest, VercelResponse } from '@vercel/node' 2 | 3 | /** 4 | * Vercel Serverless Function - 获取网站 Favicon 5 | * 使用方法: /api/favicon?url=https://example.com 6 | */ 7 | export default async function handler(req: VercelRequest, res: VercelResponse) { 8 | // 允许跨域 9 | res.setHeader('Access-Control-Allow-Origin', '*') 10 | res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS') 11 | res.setHeader('Access-Control-Allow-Headers', 'Content-Type') 12 | 13 | // 处理 OPTIONS 请求 14 | if (req.method === 'OPTIONS') { 15 | return res.status(200).end() 16 | } 17 | 18 | // 只允许 GET 请求 19 | if (req.method !== 'GET') { 20 | return res.status(405).json({ error: '方法不允许' }) 21 | } 22 | 23 | const { url } = req.query 24 | 25 | // 验证 URL 参数 26 | if (!url || typeof url !== 'string') { 27 | return res.status(400).json({ error: '缺少 URL 参数' }) 28 | } 29 | 30 | try { 31 | // 解析 URL,处理各种格式 32 | let targetUrl: URL 33 | try { 34 | // 如果 URL 没有协议,添加 https:// 35 | const urlToProcess = url.startsWith('http') ? url : `https://${url}` 36 | targetUrl = new URL(urlToProcess) 37 | } catch (urlError) { 38 | return res.status(400).json({ error: 'URL 格式错误' }) 39 | } 40 | 41 | const hostname = targetUrl.hostname 42 | 43 | // 首先尝试通过 Google 的服务获取(最可靠) 44 | const googleFaviconUrl = `https://www.google.com/s2/favicons?domain=${hostname}&sz=128` 45 | 46 | try { 47 | const controller = new AbortController() 48 | const timeoutId = setTimeout(() => controller.abort(), 5000) 49 | 50 | const response = await fetch(googleFaviconUrl, { 51 | headers: { 52 | 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36' 53 | }, 54 | signal: controller.signal 55 | }) 56 | 57 | clearTimeout(timeoutId) 58 | 59 | if (response.ok) { 60 | const buffer = await response.arrayBuffer() 61 | const contentType = response.headers.get('content-type') || 'image/x-icon' 62 | 63 | // 设置缓存(24小时) 64 | res.setHeader('Cache-Control', 'public, max-age=86400, s-maxage=86400, stale-while-revalidate=604800') 65 | res.setHeader('Content-Type', contentType) 66 | 67 | return res.status(200).send(Buffer.from(buffer)) 68 | } 69 | } catch (googleError) { 70 | console.error('Google favicon 服务失败:', googleError instanceof Error ? googleError.message : String(googleError)) 71 | } 72 | 73 | // 如果 Google 服务失败,尝试其他服务 74 | const fallbackServices = [ 75 | `https://icons.duckduckgo.com/ip3/${hostname}.ico`, 76 | `https://favicon.im/${hostname}?larger=true`, 77 | ] 78 | 79 | for (const serviceUrl of fallbackServices) { 80 | try { 81 | const controller = new AbortController() 82 | const timeoutId = setTimeout(() => controller.abort(), 3000) 83 | 84 | const response = await fetch(serviceUrl, { 85 | headers: { 86 | 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36' 87 | }, 88 | signal: controller.signal 89 | }) 90 | 91 | clearTimeout(timeoutId) 92 | 93 | if (response.ok) { 94 | const buffer = await response.arrayBuffer() 95 | const contentType = response.headers.get('content-type') || 'image/x-icon' 96 | 97 | res.setHeader('Cache-Control', 'public, max-age=86400, s-maxage=86400, stale-while-revalidate=604800') 98 | res.setHeader('Content-Type', contentType) 99 | 100 | return res.status(200).send(Buffer.from(buffer)) 101 | } 102 | } catch (error) { 103 | console.error(`服务 ${serviceUrl} 失败:`, error instanceof Error ? error.message : String(error)) 104 | continue 105 | } 106 | } 107 | 108 | // 尝试直接从网站获取 109 | const directUrls = [ 110 | `${targetUrl.origin}/favicon.ico`, 111 | `${targetUrl.origin}/favicon.png`, 112 | ] 113 | 114 | for (const directUrl of directUrls) { 115 | try { 116 | const controller = new AbortController() 117 | const timeoutId = setTimeout(() => controller.abort(), 2000) 118 | 119 | const response = await fetch(directUrl, { 120 | headers: { 121 | 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36' 122 | }, 123 | signal: controller.signal 124 | }) 125 | 126 | clearTimeout(timeoutId) 127 | 128 | if (response.ok) { 129 | const buffer = await response.arrayBuffer() 130 | const contentType = response.headers.get('content-type') || 'image/x-icon' 131 | 132 | res.setHeader('Cache-Control', 'public, max-age=86400, s-maxage=86400, stale-while-revalidate=604800') 133 | res.setHeader('Content-Type', contentType) 134 | 135 | return res.status(200).send(Buffer.from(buffer)) 136 | } 137 | } catch (error) { 138 | continue 139 | } 140 | } 141 | 142 | // 所有尝试都失败,返回一个简单的 SVG 占位图标 143 | const defaultSvg = ` 144 | 145 | ? 146 | ` 147 | 148 | res.setHeader('Content-Type', 'image/svg+xml') 149 | res.setHeader('Cache-Control', 'public, max-age=3600') 150 | return res.status(200).send(defaultSvg) 151 | 152 | } catch (error) { 153 | console.error('获取 favicon 错误:', error) 154 | 155 | // 返回错误占位图标 156 | const errorSvg = ` 157 | 158 | ! 159 | ` 160 | 161 | res.setHeader('Content-Type', 'image/svg+xml') 162 | return res.status(200).send(errorSvg) 163 | } 164 | } 165 | 166 | -------------------------------------------------------------------------------- /src/assets/css/base.css: -------------------------------------------------------------------------------- 1 | * { 2 | padding: 0; 3 | margin: 0; 4 | } 5 | 6 | html { 7 | /* 缅怀黑白 */ 8 | /* filter: grayscale(100%); */ 9 | } 10 | 11 | body { 12 | /* element色卡 */ 13 | --white: #ffffff; 14 | --black: #000000; 15 | --blue: #6e8cd7; 16 | --pink: #e83e8c; 17 | --red: #dc3545; 18 | --orange: #fd7e14; 19 | --yellow: #ffc107; 20 | --teal: #33b86c; 21 | --gray: #eef0f4; 22 | 23 | --primary: #007bff; 24 | --secondary: #6c757d; 25 | --success: #28a745; 26 | --info: #17a2b8; 27 | --warning: #ffc107; 28 | --danger: #dc3545; 29 | 30 | /* --ui-theme: rgba(255, 0, 0, 0.8); */ 31 | --ui-theme: #409eff; 32 | } 33 | 34 | /* 明亮模式 */ 35 | :root[theme-mode='light'] { 36 | --gray-o1: rgba(255, 255, 255, 0.1); 37 | --gray-o2: rgba(255, 255, 255, 0.2); 38 | --gray-o3: rgba(255, 255, 255, 0.3); 39 | --gray-o4: rgba(255, 255, 255, 0.4); 40 | --gray-o5: rgba(255, 255, 255, 0.5); 41 | --gray-o6: rgba(255, 255, 255, 0.6); 42 | --gray-o7: rgba(255, 255, 255, 0.7); 43 | --gray-o8: rgba(255, 255, 255, 0.8); 44 | --gray-o9: rgba(255, 255, 255, 0.9); 45 | 46 | --gray-0: #ffffff; 47 | --gray-50: #f9fafb; 48 | --gray-100: #f3f4f6; 49 | --gray-200: #e5e7eb; 50 | --gray-300: #d1d5db; 51 | --gray-400: #9ca3af; 52 | --gray-500: #6b7280; 53 | --gray-600: #4b5563; 54 | --gray-700: #374151; 55 | --gray-800: #1f2937; 56 | --gray-900: #111827; 57 | --gray-1000: #000000; 58 | 59 | --red-50: #fef2f2; 60 | --red-100: #fee2e2; 61 | --red-200: #fecaca; 62 | --red-300: #fca5a5; 63 | --red-400: #f87171; 64 | --red-500: #ef4444; 65 | --red-600: #dc2626; 66 | --red-700: #b91c1c; 67 | --red-800: #991b1b; 68 | --red-900: #7f1d1d; 69 | 70 | --green-50: #ecfdf5; 71 | --green-100: #d1fae5; 72 | --green-200: #a7f3d0; 73 | --green-300: #6ee7b7; 74 | --green-400: #34d399; 75 | --green-500: #10b981; 76 | --green-600: #059669; 77 | --green-700: #047857; 78 | --green-800: #065f46; 79 | --green-900: #064e3b; 80 | 81 | --yellow-50: #fffbeb; 82 | --yellow-100: #fef3c7; 83 | --yellow-200: #fde68a; 84 | --yellow-300: #fcd34d; 85 | --yellow-400: #fbbf24; 86 | --yellow-500: #f59e0b; 87 | --yellow-600: #d97706; 88 | --yellow-700: #b45309; 89 | --yellow-800: #92400e; 90 | --yellow-900: #78350f; 91 | 92 | --blue-50: #eff6ff; 93 | --blue-100: #dbeafe; 94 | --blue-200: #bfdbfe; 95 | --blue-300: #93c5fd; 96 | --blue-400: #60a5fa; 97 | --blue-500: #3b82f6; 98 | --blue-600: #2563eb; 99 | --blue-700: #1d4ed8; 100 | --blue-800: #1e40af; 101 | --blue-900: #1e3a8a; 102 | 103 | --indigo-50: #eef2ff; 104 | --indigo-100: #e0e7ff; 105 | --indigo-200: #c7d2fe; 106 | --indigo-300: #a5b4fc; 107 | --indigo-400: #818cf8; 108 | --indigo-500: #6366f1; 109 | --indigo-600: #4f46e5; 110 | --indigo-700: #4338ca; 111 | --indigo-800: #3730a3; 112 | --indigo-900: #312e81; 113 | 114 | --purple-50: #f5f3ff; 115 | --purple-100: #ede9fe; 116 | --purple-200: #ddd6fe; 117 | --purple-300: #c4b5fd; 118 | --purple-400: #a78bfa; 119 | --purple-500: #8b5cf6; 120 | --purple-600: #7c3aed; 121 | --purple-700: #6d28d9; 122 | --purple-800: #5b21b6; 123 | --purple-900: #4c1d95; 124 | 125 | --pink-50: #fdf2f8; 126 | --pink-100: #fce7f3; 127 | --pink-200: #fbcfe8; 128 | --pink-300: #f9a8d4; 129 | --pink-400: #f472b6; 130 | --pink-500: #ec4899; 131 | --pink-600: #db2777; 132 | --pink-700: #be185d; 133 | --pink-800: #9d174d; 134 | --pink-900: #831843; 135 | } 136 | 137 | /* 暗夜模式 */ 138 | :root[theme-mode='dark'] { 139 | --gray-o9: rgba(255, 255, 255, 0.1); 140 | --gray-o8: rgba(255, 255, 255, 0.2); 141 | --gray-o7: rgba(255, 255, 255, 0.3); 142 | --gray-o6: rgba(255, 255, 255, 0.4); 143 | --gray-o5: rgba(255, 255, 255, 0.5); 144 | --gray-o4: rgba(255, 255, 255, 0.6); 145 | --gray-o3: rgba(255, 255, 255, 0.7); 146 | --gray-o2: rgba(255, 255, 255, 0.8); 147 | --gray-o1: rgba(255, 255, 255, 0.9); 148 | 149 | --gray-1000: #ffffff; 150 | --gray-900: #f9fafb; 151 | --gray-800: #f3f4f6; 152 | --gray-700: #e5e7eb; 153 | --gray-600: #d1d5db; 154 | --gray-500: #9ca3af; 155 | --gray-400: #6b7280; 156 | --gray-300: #4b5563; 157 | --gray-200: #374151; 158 | --gray-100: #1f2937; 159 | --gray-50: #111827; 160 | --gray-0: #000000; 161 | 162 | --red-900: #fef2f2; 163 | --red-800: #fee2e2; 164 | --red-700: #fecaca; 165 | --red-600: #fca5a5; 166 | --red-500: #f87171; 167 | --red-400: #ef4444; 168 | --red-300: #dc2626; 169 | --red-200: #b91c1c; 170 | --red-100: #991b1b; 171 | --red-50: #7f1d1d; 172 | 173 | --green-900: #ecfdf5; 174 | --green-800: #d1fae5; 175 | --green-700: #a7f3d0; 176 | --green-600: #6ee7b7; 177 | --green-500: #34d399; 178 | --green-400: #10b981; 179 | --green-300: #059669; 180 | --green-200: #047857; 181 | --green-100: #065f46; 182 | --green-50: #064e3b; 183 | 184 | --yellow-900: #fffbeb; 185 | --yellow-800: #fef3c7; 186 | --yellow-700: #fde68a; 187 | --yellow-600: #fcd34d; 188 | --yellow-500: #fbbf24; 189 | --yellow-400: #f59e0b; 190 | --yellow-300: #d97706; 191 | --yellow-200: #b45309; 192 | --yellow-100: #92400e; 193 | --yellow-50: #78350f; 194 | 195 | --blue-900: #eff6ff; 196 | --blue-800: #dbeafe; 197 | --blue-700: #bfdbfe; 198 | --blue-600: #93c5fd; 199 | --blue-500: #60a5fa; 200 | --blue-400: #3b82f6; 201 | --blue-300: #2563eb; 202 | --blue-200: #1d4ed8; 203 | --blue-100: #1e40af; 204 | --blue-50: #1e3a8a; 205 | 206 | --indigo-900: #eef2ff; 207 | --indigo-800: #e0e7ff; 208 | --indigo-700: #c7d2fe; 209 | --indigo-600: #a5b4fc; 210 | --indigo-500: #818cf8; 211 | --indigo-400: #6366f1; 212 | --indigo-300: #4f46e5; 213 | --indigo-200: #4338ca; 214 | --indigo-100: #3730a3; 215 | --indigo-50: #312e81; 216 | 217 | --purple-900: #f5f3ff; 218 | --purple-800: #ede9fe; 219 | --purple-700: #ddd6fe; 220 | --purple-600: #c4b5fd; 221 | --purple-500: #a78bfa; 222 | --purple-400: #8b5cf6; 223 | --purple-300: #7c3aed; 224 | --purple-200: #6d28d9; 225 | --purple-100: #5b21b6; 226 | --purple-50: #4c1d95; 227 | 228 | --pink-900: #fdf2f8; 229 | --pink-800: #fce7f3; 230 | --pink-700: #fbcfe8; 231 | --pink-600: #f9a8d4; 232 | --pink-500: #f472b6; 233 | --pink-400: #ec4899; 234 | --pink-300: #db2777; 235 | --pink-200: #be185d; 236 | --pink-100: #9d174d; 237 | --pink-50: #831843; 238 | } 239 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 黑羽导航 v2.2.0 2 | 3 | > 一个简洁、美观、易用的个人导航网站 4 | 5 | [![Version](https://img.shields.io/badge/version-2.2.0-blue.svg)](https://github.com/xia-66/nav) 6 | [![License](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE) 7 | [![Vue](https://img.shields.io/badge/vue-3.4.29-brightgreen.svg)](https://vuejs.org/) 8 | [![Vite](https://img.shields.io/badge/vite-5.3.1-646cff.svg)](https://vitejs.dev/) 9 | 10 | ## 🚀 一键部署到 Vercel 11 | 12 | 点击下方按钮即可一键部署到 Vercel,几分钟内拥有你的专属导航网站: 13 | 14 | [![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https://github.com/xia-66/nav) 15 | 16 | ### 部署步骤 17 | 18 | 1. **Fork 本仓库** - 点击右上角 Fork 按钮,将项目复制到你的 GitHub 账号 19 | 2. **修改配置** - 根据你的需求修改以下配置文件: 20 | - `src/config/data.json` - 网站数据(分类和网址) 21 | - `src/config/index.ts` - 网站基本信息(标题、描述、备案号等) 22 | 3. **一键部署** - 点击上方 Deploy 按钮,选择你 Fork 的仓库 23 | 4. **完成** - 等待几分钟,即可获得你的专属导航网站 24 | 25 | ## ✨ 特性 26 | 27 | - 🎨 **美观界面** - 现代化设计,支持响应式布局 28 | - 🚀 **快速搜索** - 本地搜索,即时响应 29 | - 📱 **移动优先** - 完美支持移动端和桌面端 30 | - 🎯 **分类管理** - 9 大分类,79 个精选网站 31 | - 🔍 **智能搜索** - 支持按名称和描述搜索 32 | - 🔐 **后台管理** - GitHub OAuth 登录,在线编辑数据 33 | - ⚡ **一键部署** - 支持 Vercel 一键部署 34 | - 💾 **GitHub 同步** - 数据自动同步到 GitHub 仓库 35 | - 🌐 **全球 CDN** - 支持 Vercel 等平台部署 36 | 37 | ## 📦 技术栈 38 | 39 | - **前端框架**: Vue 3 + TypeScript 40 | - **构建工具**: Vite 5 41 | - **UI 框架**: Element Plus 42 | - **状态管理**: Pinia 43 | - **路由管理**: Vue Router 44 | - **CSS 框架**: TailwindCSS + Sass 45 | - **图标字体**: 自定义 iconfont 46 | 47 | ## 🚀 快速开始 48 | 49 | ### 环境要求 50 | 51 | - Node.js >= 16 52 | - npm >= 8 53 | 54 | ### 安装依赖 55 | 56 | ```bash 57 | npm install 58 | ``` 59 | 60 | ### 开发模式 61 | 62 | ```bash 63 | npm run dev 64 | ``` 65 | 66 | 访问 http://localhost:8080 67 | 68 | ### 构建生产版本 69 | 70 | ```bash 71 | npm run build 72 | ``` 73 | 74 | ### 预览构建结果 75 | 76 | ```bash 77 | npm run preview 78 | ``` 79 | 80 | ## 📂 项目结构 81 | 82 | ``` 83 | heiyu-nav/ 84 | ├── src/ 85 | │ ├── assets/ # 静态资源 86 | │ │ ├── css/ # 样式文件 87 | │ │ ├── font/ # 字体图标 88 | │ │ └── img/ # 图片资源 89 | │ ├── components/ # 组件 90 | │ │ ├── index/ # 前台组件 91 | │ │ └── admin/ # 后台组件 92 | │ ├── views/ # 视图页面 93 | │ │ ├── Index/ # 前台页面 94 | │ │ ├── AdminCallback.vue # OAuth 回调页面 95 | │ │ └── AdminDashboard.vue # 后台管理页面 96 | │ ├── router/ # 路由配置 97 | │ ├── store/ # 状态管理 98 | │ │ ├── index.ts # 主 store 99 | │ │ └── admin.ts # 后台 store 100 | │ ├── utils/ # 工具函数 101 | │ ├── config/ # ⭐ 配置文件(需要修改) 102 | │ │ ├── data.json # ⭐ 网站数据 103 | │ │ └── index.ts # ⭐ 网站信息 104 | │ ├── App.vue # 根组件 105 | │ └── main.ts # 入口文件 106 | ├── api/ # Serverless API 107 | │ ├── auth/ # OAuth 认证 108 | │ │ ├── github.ts # GitHub OAuth 处理 109 | │ │ └── callback.ts # OAuth 回调处理 110 | │ ├── github/ # GitHub API 111 | │ │ ├── get-file.ts # 获取文件 112 | │ │ └── update-file.ts # 更新文件 113 | │ └── favicon.ts # 网站图标获取 114 | ├── public/ # 公共资源 115 | ├── index.html # HTML 模板 116 | ├── vite.config.ts # Vite 配置 117 | ├── vercel.json # Vercel 配置 118 | ├── tsconfig.json # TypeScript 配置 119 | ├── tailwind.config.js # TailwindCSS 配置 120 | └── package.json # 项目配置 121 | ``` 122 | 123 | 124 | ## 📝 配置说明 125 | 126 | ### 必改配置项 127 | 128 | #### 1. 网站数据 (`src/config/data.json`) 129 | 130 | 包含所有网站分类和链接: 131 | 132 | ```json 133 | { 134 | "categories": [ 135 | { 136 | "id": 1, 137 | "name": "分类名称" 138 | } 139 | ], 140 | "items": [ 141 | { 142 | "id": 1, 143 | "name": "网站名称", 144 | "url": "https://example.com", 145 | "description": "网站描述", 146 | "categoryId": 1 147 | } 148 | ] 149 | } 150 | ``` 151 | 152 | #### 2. 网站信息 (`src/config/index.ts`) 153 | 154 | 配置项说明: 155 | 156 | ```typescript 157 | // 网站名称 158 | export const SITE_NAME = '黑羽导航' 159 | 160 | // 备案号(如果没有可以留空或删除) 161 | export const ICP_NUMBER = '豫ICP备2023030596号' 162 | 163 | // 网站图标API(无需修改) 164 | export const Favicon = '/api/favicon?url=' 165 | ``` 166 | 167 | ### 可选配置 168 | 169 | - **自定义域名**:在 Vercel Dashboard 中配置 170 | - **环境变量**:在 Vercel 项目设置中添加 171 | - **构建配置**:`vercel.json` 已预配置,一般无需修改 172 | 173 | ## 🔐 后台管理系统 174 | 175 | 本项目内置了后台管理系统,支持通过 Web 界面管理导航数据。 176 | 177 | ### 快速配置 178 | 179 | 1. **创建 GitHub OAuth App** - 参考 [ADMIN_CONFIG.md](./ADMIN_CONFIG.md) 180 | 2. **配置环境变量**: 181 | 182 | 在 Vercel Dashboard → Settings → Environment Variables 添加: 183 | 184 | ```bash 185 | # 必需的环境变量 186 | GITHUB_CLIENT_ID=你的_client_id # 后端 API 使用 187 | GITHUB_CLIENT_SECRET=你的_client_secret # 后端 API 使用 188 | VITE_GITHUB_CLIENT_ID=你的_client_id # 前端使用(与上面相同) 189 | 190 | # 可选的环境变量(有默认值) 191 | GITHUB_REPO_OWNER=你的_github_用户名 # 默认: xia-66 192 | GITHUB_REPO_NAME=nav # 默认: nav 193 | GITHUB_ALLOWED_USERS=你的_github_用户名 # 默认: xia-66 194 | ``` 195 | 196 | 3. **访问后台** - `https://你的域名.com/#/admin/login` 197 | 198 | 详细配置请参考:[后台管理配置指南](./ADMIN_CONFIG.md) 199 | 200 | ## 📊 网站分类 201 | 202 | | 分类 | 数量 | 说明 | 203 | | -------- | ---- | --------------------------- | 204 | | 常用推荐 | 12 | AI 工具、视频网站、云服务等 | 205 | | 实用工具 | 17 | 截图、文件传输、激活工具等 | 206 | | 日漫专区 | 4 | 动漫资源和在线观看 | 207 | | 在线翻译 | 2 | 多语种翻译工具 | 208 | | 站长工具 | 11 | 测速、检测、域名工具等 | 209 | | 清理工具 | 2 | 磁盘清理和卸载工具 | 210 | | 我的收藏 | 15 | VPS、代理、OCR 等工具 | 211 | | 国外服务 | 11 | AI 助手、GitHub、YouTube 等 | 212 | | 开发工具 | 5 | API、图标、模板等 | 213 | 214 | ## 🎨 功能特性 215 | 216 | ### 主要功能 217 | 218 | **前台功能** 219 | - ✅ 网站展示 - 按分类展示所有导航网站 220 | - ✅ 实时搜索 - 支持搜索网站名称和描述 221 | - ✅ 锚点导航 - 快速跳转到指定分类 222 | - ✅ 懒加载 - 网站图标按需加载 223 | - ✅ 响应式 - 完美支持 PC 和移动端 224 | - ✅ 侧边栏 - 移动端友好的抽屉菜单 225 | - ✅ 时钟显示 - 实时显示当前时间 226 | - ✅ 背景效果 - 精美的背景图片 227 | 228 | **后台管理** 229 | - ✅ GitHub OAuth - 安全的第三方登录 230 | - ✅ 在线编辑 - 可视化编辑导航数据 231 | - ✅ 实时预览 - 修改后即时查看效果 232 | - ✅ 自动同步 - 一键提交到 GitHub 仓库 233 | - ✅ 权限控制 - 仅允许授权用户访问 234 | 235 | ### 交互体验 236 | 237 | - 流畅的动画效果 238 | - 直观的操作界面 239 | - 快速的搜索响应 240 | - 优雅的加载状态 241 | 242 | ## 📖 版本历史 243 | 244 | ### v2.2.0 (2024-11-27) 245 | 246 | - 🎨 优化后台登录对话框组件,简化样式 247 | - ✅ 修复 Vercel 部署环境变量配置问题 248 | - ✅ 替换用户头像为 `el-avatar` 组件 249 | - ✅ 移除登录加载页面,改为静默处理 250 | - ✅ 修复图标字体问题(icon-lock → icon-md-lock) 251 | - ✅ 移除按钮悬浮样式 252 | - ✅ 优化弹窗关闭逻辑 253 | - 📝 完善环境变量配置文档 254 | 255 | ### v2.1.0 (2024-10-31) 256 | 257 | - 🚀 优化部署流程,支持 Vercel 一键部署 258 | - ✅ 简化配置文件管理 259 | - ✅ 更新文档说明 260 | - ✅ 优化项目结构 261 | 262 | ### v2.0.0 (2024-10-25) 263 | 264 | - 🎉 重大更新:纯前端化架构 265 | - ✅ 删除所有后台代码和依赖 266 | - ✅ 使用本地 JSON 数据 267 | - ✅ 优化所有网站描述 268 | - ✅ 删除无用代码和文件 269 | - ✅ 精简项目结构 270 | 271 | ## 🤝 贡献 272 | 273 | 欢迎贡献!你可以: 274 | 275 | - 提交 Issue 报告问题 276 | - 发起 Pull Request 改进代码 277 | - 分享你的网站资源 278 | - 完善文档说明 279 | 280 | ## 📄 开源协议 281 | 282 | 本项目采用 MIT 协议开源。 283 | 284 | ## 🙏 鸣谢 285 | 286 | - [Vue.js](https://vuejs.org/) - 渐进式 JavaScript 框架 287 | - [Vite](https://vitejs.dev/) - 下一代前端构建工具 288 | - [Element Plus](https://element-plus.org/) - Vue 3 组件库 289 | - [TailwindCSS](https://tailwindcss.com/) - CSS 框架 290 | - [Vercel](https://vercel.com/) - 部署平台 291 | 292 | ## 📮 联系方式 293 | 294 | - 项目主页: [GitHub](https://github.com/xia-66/nav) 295 | - 问题反馈: [Issues](https://github.com/xia-66/nav/issues) 296 | 297 | --- 298 | 299 | ⭐ 如果这个项目对你有帮助,请给个 Star 支持一下! 300 | 301 | **黑羽导航 - 你的个人网站导航助手** 🎉 302 | -------------------------------------------------------------------------------- /src/components/index/Search.vue: -------------------------------------------------------------------------------- 1 | 34 | 35 | 154 | 155 | 334 | -------------------------------------------------------------------------------- /src/components/index/Site.vue: -------------------------------------------------------------------------------- 1 | 35 | 36 | 137 | 138 | 286 | -------------------------------------------------------------------------------- /src/components/admin/LoginDialog.vue: -------------------------------------------------------------------------------- 1 | 50 | 51 | 82 | 83 | 352 | 353 | -------------------------------------------------------------------------------- /src/assets/font/iconfont.css: -------------------------------------------------------------------------------- 1 | @font-face { 2 | font-family: "iconfont"; /* Project id 2940247 */ 3 | src: url('iconfont.woff2?t=1711903975415') format('woff2'), 4 | url('iconfont.woff?t=1711903975415') format('woff'), 5 | url('iconfont.ttf?t=1711903975415') format('truetype'); 6 | } 7 | 8 | .iconfont { 9 | font-family: "iconfont" !important; 10 | font-size: 20px; 11 | font-style: normal; 12 | -webkit-font-smoothing: antialiased; 13 | -moz-osx-font-smoothing: grayscale; 14 | } 15 | 16 | .icon-SEVENZ:before { 17 | content: "\e613"; 18 | } 19 | 20 | .icon-BAT:before { 21 | content: "\e615"; 22 | } 23 | 24 | .icon-CONF:before { 25 | content: "\e61a"; 26 | } 27 | 28 | .icon-EOT:before { 29 | content: "\e61b"; 30 | } 31 | 32 | .icon-DOCX:before { 33 | content: "\e61e"; 34 | } 35 | 36 | .icon-DOC:before { 37 | content: "\e61f"; 38 | } 39 | 40 | .icon-ICO:before { 41 | content: "\e623"; 42 | } 43 | 44 | .icon-JAR:before { 45 | content: "\e624"; 46 | } 47 | 48 | .icon-JAVA:before { 49 | content: "\e625"; 50 | } 51 | 52 | .icon-JPEG:before { 53 | content: "\e626"; 54 | } 55 | 56 | .icon-JPG:before { 57 | content: "\e627"; 58 | } 59 | 60 | .icon-JS:before { 61 | content: "\e628"; 62 | } 63 | 64 | .icon-MD:before { 65 | content: "\e629"; 66 | } 67 | 68 | .icon-MP3:before { 69 | content: "\e62a"; 70 | } 71 | 72 | .icon-MP4:before { 73 | content: "\e62b"; 74 | } 75 | 76 | .icon-PNG:before { 77 | content: "\e62e"; 78 | } 79 | 80 | .icon-PPT:before { 81 | content: "\e630"; 82 | } 83 | 84 | .icon-PSD:before { 85 | content: "\e631"; 86 | } 87 | 88 | .icon-RAR:before { 89 | content: "\e633"; 90 | } 91 | 92 | .icon-SH:before { 93 | content: "\e634"; 94 | } 95 | 96 | .icon-SVG:before { 97 | content: "\e635"; 98 | } 99 | 100 | .icon-TEXT:before { 101 | content: "\e636"; 102 | } 103 | 104 | .icon-XLSX:before { 105 | content: "\e638"; 106 | } 107 | 108 | .icon-XML:before { 109 | content: "\e639"; 110 | } 111 | 112 | .icon-ZIP:before { 113 | content: "\e63a"; 114 | } 115 | 116 | .icon-BIN:before { 117 | content: "\e63b"; 118 | } 119 | 120 | .icon-github:before { 121 | content: "\e66f"; 122 | } 123 | 124 | .icon-rcd-image-error-f:before { 125 | content: "\e618"; 126 | } 127 | 128 | .icon-xitongpeizhi:before { 129 | content: "\e686"; 130 | } 131 | 132 | .icon-chengxvpeizhi:before { 133 | content: "\e619"; 134 | } 135 | 136 | .icon-json:before { 137 | content: "\e7bd"; 138 | } 139 | 140 | .icon-daima:before { 141 | content: "\e66a"; 142 | } 143 | 144 | .icon-fenlei:before { 145 | content: "\e643"; 146 | } 147 | 148 | .icon-chubufenlei:before { 149 | content: "\e663"; 150 | } 151 | 152 | .icon-rizhifuwu:before { 153 | content: "\e669"; 154 | } 155 | 156 | .icon-wenzi:before { 157 | content: "\e883"; 158 | } 159 | 160 | .icon-bianji:before { 161 | content: "\e621"; 162 | } 163 | 164 | .icon-bianjibiaoge:before { 165 | content: "\eabc"; 166 | } 167 | 168 | .icon-xinzeng:before { 169 | content: "\e632"; 170 | } 171 | 172 | .icon-CSS:before { 173 | content: "\e614"; 174 | } 175 | 176 | .icon-HTML:before { 177 | content: "\e617"; 178 | } 179 | 180 | .icon-qitawenjian:before { 181 | content: "\e683"; 182 | } 183 | 184 | .icon-PDF:before { 185 | content: "\e61d"; 186 | } 187 | 188 | .icon-tupian:before { 189 | content: "\e63d"; 190 | } 191 | 192 | .icon-badge-fill:before { 193 | content: "\e9fe"; 194 | } 195 | 196 | .icon-badge-line:before { 197 | content: "\ea0c"; 198 | } 199 | 200 | .icon-a-boxminus-line:before { 201 | content: "\ea22"; 202 | } 203 | 204 | .icon-bug-fill:before { 205 | content: "\ea69"; 206 | } 207 | 208 | .icon-a-documentationupload-line:before { 209 | content: "\eb69"; 210 | } 211 | 212 | .icon-a-downloadto-fill:before { 213 | content: "\eb70"; 214 | } 215 | 216 | .icon-exit-line:before { 217 | content: "\eb8d"; 218 | } 219 | 220 | .icon-girl-line:before { 221 | content: "\ec38"; 222 | } 223 | 224 | .icon-interactive-fill:before { 225 | content: "\eca7"; 226 | } 227 | 228 | .icon-man-line:before { 229 | content: "\ed11"; 230 | } 231 | 232 | .icon-a-lefttoo-line:before { 233 | content: "\ed17"; 234 | } 235 | 236 | .icon-pen-fill:before { 237 | content: "\eda5"; 238 | } 239 | 240 | .icon-remind-line:before { 241 | content: "\ee11"; 242 | } 243 | 244 | .icon-a-rightlinefill-line:before { 245 | content: "\ee19"; 246 | } 247 | 248 | .icon-robot-line:before { 249 | content: "\ee35"; 250 | } 251 | 252 | .icon-rocket-fill:before { 253 | content: "\ee3a"; 254 | } 255 | 256 | .icon-a-smartrobot-fill:before { 257 | content: "\ef09"; 258 | } 259 | 260 | .icon-star-fill:before { 261 | content: "\ef0b"; 262 | } 263 | 264 | .icon-star-line:before { 265 | content: "\ef14"; 266 | } 267 | 268 | .icon-think-fill:before { 269 | content: "\ef91"; 270 | } 271 | 272 | .icon-trophy-fill:before { 273 | content: "\efa6"; 274 | } 275 | 276 | .icon-a-unfoldcross-line:before { 277 | content: "\efb5"; 278 | } 279 | 280 | .icon-bianji-yonghu:before { 281 | content: "\efc6"; 282 | } 283 | 284 | .icon-tianjia-yonghu:before { 285 | content: "\efc7"; 286 | } 287 | 288 | .icon-a-leftsmallline-line:before { 289 | content: "\f010"; 290 | } 291 | 292 | .icon-a-rightsmallline-line:before { 293 | content: "\f011"; 294 | } 295 | 296 | .icon-a-upsmall-line:before { 297 | content: "\f012"; 298 | } 299 | 300 | .icon-a-undersmall-line:before { 301 | content: "\f013"; 302 | } 303 | 304 | .icon-tag:before { 305 | content: "\e61c"; 306 | } 307 | 308 | .icon-tianjia:before { 309 | content: "\e65b"; 310 | } 311 | 312 | .icon-paixu:before { 313 | content: "\e7e9"; 314 | } 315 | 316 | .icon-tuichu:before { 317 | content: "\e62c"; 318 | } 319 | 320 | .icon-md-menu:before { 321 | content: "\e9b8"; 322 | } 323 | 324 | .icon-md-reorder:before { 325 | content: "\e9e8"; 326 | } 327 | 328 | .icon-md-rocket:before { 329 | content: "\e9f8"; 330 | } 331 | 332 | .icon-md-search:before { 333 | content: "\e9fd"; 334 | } 335 | 336 | .icon-md-notifications:before { 337 | content: "\e9c1"; 338 | } 339 | 340 | .icon-md-notifications-outline:before { 341 | content: "\e9c6"; 342 | } 343 | 344 | .icon-md-photos:before { 345 | content: "\e9d0"; 346 | } 347 | 348 | .icon-md-pin:before { 349 | content: "\e9d4"; 350 | } 351 | 352 | .icon-md-qr-scanner:before { 353 | content: "\e9e4"; 354 | } 355 | 356 | .icon-md-planet:before { 357 | content: "\e9e6"; 358 | } 359 | 360 | .icon-md-school:before { 361 | content: "\e9fb"; 362 | } 363 | 364 | .icon-md-settings:before { 365 | content: "\ea01"; 366 | } 367 | 368 | .icon-md-stats:before { 369 | content: "\ea0a"; 370 | } 371 | 372 | .icon-md-sync:before { 373 | content: "\ea11"; 374 | } 375 | 376 | .icon-md-trash:before { 377 | content: "\ea1e"; 378 | } 379 | 380 | .icon-weixin:before { 381 | content: "\e694"; 382 | } 383 | 384 | .icon-md-arrow-dropdown:before { 385 | content: "\e907"; 386 | } 387 | 388 | .icon-md-arrow-dropleft:before { 389 | content: "\e90b"; 390 | } 391 | 392 | .icon-md-arrow-dropup:before { 393 | content: "\e90d"; 394 | } 395 | 396 | .icon-md-arrow-dropright:before { 397 | content: "\e90f"; 398 | } 399 | 400 | .icon-md-arrow-round-back:before { 401 | content: "\e911"; 402 | } 403 | 404 | .icon-md-arrow-round-down:before { 405 | content: "\e912"; 406 | } 407 | 408 | .icon-md-arrow-round-up:before { 409 | content: "\e913"; 410 | } 411 | 412 | .icon-md-arrow-round-forward:before { 413 | content: "\e914"; 414 | } 415 | 416 | .icon-md-attach:before { 417 | content: "\e916"; 418 | } 419 | 420 | .icon-md-barcode:before { 421 | content: "\e918"; 422 | } 423 | 424 | .icon-md-at:before { 425 | content: "\e91c"; 426 | } 427 | 428 | .icon-md-checkmark:before { 429 | content: "\e942"; 430 | } 431 | 432 | .icon-md-checkmark-circle:before { 433 | content: "\e943"; 434 | } 435 | 436 | .icon-md-clipboard:before { 437 | content: "\e945"; 438 | } 439 | 440 | .icon-md-close:before { 441 | content: "\e946"; 442 | } 443 | 444 | .icon-md-close-circle:before { 445 | content: "\e948"; 446 | } 447 | 448 | .icon-md-close-circle-outline:before { 449 | content: "\e94a"; 450 | } 451 | 452 | .icon-md-contact:before { 453 | content: "\e959"; 454 | } 455 | 456 | .icon-md-cut:before { 457 | content: "\e964"; 458 | } 459 | 460 | .icon-md-cube:before { 461 | content: "\e965"; 462 | } 463 | 464 | .icon-md-eye:before { 465 | content: "\e96c"; 466 | } 467 | 468 | .icon-md-eye-off:before { 469 | content: "\e96e"; 470 | } 471 | 472 | .icon-md-happy:before { 473 | content: "\e989"; 474 | } 475 | 476 | .icon-md-heart:before { 477 | content: "\e98e"; 478 | } 479 | 480 | .icon-md-heart-empty:before { 481 | content: "\e991"; 482 | } 483 | 484 | .icon-md-help-circle:before { 485 | content: "\e994"; 486 | } 487 | 488 | .icon-md-help-circle-outline:before { 489 | content: "\e996"; 490 | } 491 | 492 | .icon-md-home:before { 493 | content: "\e998"; 494 | } 495 | 496 | .icon-md-image:before { 497 | content: "\e99a"; 498 | } 499 | 500 | .icon-md-information-circle:before { 501 | content: "\e99e"; 502 | } 503 | 504 | .icon-md-laptop:before { 505 | content: "\e9a3"; 506 | } 507 | 508 | .icon-md-link:before { 509 | content: "\e9a5"; 510 | } 511 | 512 | .icon-md-lock:before { 513 | content: "\e9a7"; 514 | } 515 | 516 | .icon-md-moon:before { 517 | content: "\e9ba"; 518 | } 519 | 520 | .icon-iconset0133:before { 521 | content: "\e620"; 522 | } 523 | 524 | .icon-iconset0148:before { 525 | content: "\e62f"; 526 | } 527 | 528 | .icon-iconset0156:before { 529 | content: "\e637"; 530 | } 531 | 532 | .icon-iconset0278:before { 533 | content: "\e6b1"; 534 | } 535 | 536 | .icon-iconset0352:before { 537 | content: "\e6fb"; 538 | } 539 | 540 | .icon-iconset0448:before { 541 | content: "\e747"; 542 | } 543 | 544 | .icon-iconset0455:before { 545 | content: "\e74e"; 546 | } 547 | 548 | .icon-chongwugou:before { 549 | content: "\e77d"; 550 | } 551 | 552 | .icon-chongwugouliang:before { 553 | content: "\e77e"; 554 | } 555 | 556 | .icon-tuwenxiangqing:before { 557 | content: "\e600"; 558 | } 559 | 560 | .icon-xuexi:before { 561 | content: "\e7f1"; 562 | } 563 | 564 | .icon-translate:before { 565 | content: "\eaf0"; 566 | } 567 | 568 | .icon-chrome:before { 569 | content: "\e77f"; 570 | } 571 | 572 | .icon-baidu:before { 573 | content: "\e780"; 574 | } 575 | 576 | .icon-yingyong:before { 577 | content: "\e781"; 578 | } 579 | 580 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig, loadEnv } from 'vite' 2 | import vue from '@vitejs/plugin-vue' 3 | import { resolve } from 'path' 4 | import faviconApiPlugin from './vite-plugin-favicon-api' 5 | import pkg from './package.json' 6 | 7 | // https://vitejs.dev/config/ 8 | export default defineConfig(({ mode }) => { 9 | // 加载环境变量 10 | const env = loadEnv(mode, process.cwd(), '') 11 | 12 | return { 13 | plugins: [ 14 | vue(), 15 | faviconApiPlugin(), 16 | // 自定义插件处理所有 API 路由(模拟 Vercel Serverless) 17 | { 18 | name: 'api-handler', 19 | configureServer(server) { 20 | server.middlewares.use(async (req, res, next) => { 21 | // 处理 /api/auth/callback 路由 22 | if (req.url?.startsWith('/api/auth/callback')) { 23 | const url = new URL(req.url, `http://${req.headers.host}`); 24 | const code = url.searchParams.get('code'); 25 | const error = url.searchParams.get('error'); 26 | const errorDescription = url.searchParams.get('error_description'); 27 | 28 | if (error) { 29 | const errorMsg = errorDescription || error || '授权失败'; 30 | res.writeHead(302, { Location: `/#/index?error=${encodeURIComponent(errorMsg)}` }); 31 | res.end(); 32 | return; 33 | } 34 | 35 | if (!code) { 36 | res.writeHead(302, { Location: '/#/index?error=缺少授权码' }); 37 | res.end(); 38 | return; 39 | } 40 | 41 | // 重定向到前端的 callback 页面(hash 路由) 42 | res.writeHead(302, { Location: `/#/admin/callback?code=${code}` }); 43 | res.end(); 44 | return; 45 | } 46 | 47 | // 处理 /api/auth/github 路由 48 | if (req.url?.startsWith('/api/auth/github')) { 49 | const url = new URL(req.url, `http://${req.headers.host}`); 50 | const code = url.searchParams.get('code'); 51 | 52 | res.setHeader('Content-Type', 'application/json'); 53 | 54 | if (!code) { 55 | res.statusCode = 400; 56 | res.end(JSON.stringify({ error: '缺少授权码' })); 57 | return; 58 | } 59 | 60 | const clientId = env.VITE_GITHUB_CLIENT_ID; 61 | const clientSecret = env.GITHUB_CLIENT_SECRET; 62 | 63 | if (!clientId || !clientSecret) { 64 | res.statusCode = 500; 65 | res.end(JSON.stringify({ error: '服务器配置错误:请检查 .env 文件中的 GITHUB_CLIENT_ID 和 GITHUB_CLIENT_SECRET' })); 66 | return; 67 | } 68 | 69 | try { 70 | // 1. 使用 code 换取 access_token 71 | const tokenResponse = await fetch('https://github.com/login/oauth/access_token', { 72 | method: 'POST', 73 | headers: { 74 | 'Content-Type': 'application/json', 75 | 'Accept': 'application/json', 76 | }, 77 | body: JSON.stringify({ 78 | client_id: clientId, 79 | client_secret: clientSecret, 80 | code: code, 81 | }), 82 | }); 83 | 84 | const tokenData = await tokenResponse.json(); 85 | 86 | if (tokenData.error) { 87 | res.statusCode = 400; 88 | res.end(JSON.stringify({ error: tokenData.error_description || '授权失败' })); 89 | return; 90 | } 91 | 92 | const accessToken = tokenData.access_token; 93 | 94 | // 2. 获取用户信息 95 | const userResponse = await fetch('https://api.github.com/user', { 96 | headers: { 97 | 'Authorization': `token ${accessToken}`, 98 | 'Accept': 'application/vnd.github.v3+json', 99 | }, 100 | }); 101 | 102 | const userData = await userResponse.json(); 103 | 104 | // 3. 验证用户是否为 xia-66 105 | if (userData.login !== 'xia-66') { 106 | res.statusCode = 403; 107 | res.end(JSON.stringify({ error: '无权限访问,仅允许 xia-66 用户' })); 108 | return; 109 | } 110 | 111 | // 4. 返回 token 和用户信息 112 | res.statusCode = 200; 113 | res.end(JSON.stringify({ 114 | token: accessToken, 115 | user: { 116 | login: userData.login, 117 | name: userData.name, 118 | avatar_url: userData.avatar_url, 119 | }, 120 | })); 121 | return; 122 | } catch (error) { 123 | console.error('GitHub OAuth Error:', error); 124 | res.statusCode = 500; 125 | res.end(JSON.stringify({ error: '服务器内部错误' })); 126 | return; 127 | } 128 | } 129 | 130 | // 处理 /api/github/get-file 路由 131 | if (req.url?.startsWith('/api/github/get-file')) { 132 | const url = new URL(req.url, `http://${req.headers.host}`); 133 | const token = url.searchParams.get('token'); 134 | 135 | res.setHeader('Content-Type', 'application/json'); 136 | 137 | if (!token) { 138 | res.statusCode = 401; 139 | res.end(JSON.stringify({ error: '未授权' })); 140 | return; 141 | } 142 | 143 | const repoOwner = env.GITHUB_REPO_OWNER || 'xia-66'; 144 | const repoName = env.GITHUB_REPO_NAME || 'nav'; 145 | 146 | try { 147 | const response = await fetch( 148 | `https://api.github.com/repos/${repoOwner}/${repoName}/contents/src/config/data.json`, 149 | { 150 | headers: { 151 | 'Authorization': `token ${token}`, 152 | 'Accept': 'application/vnd.github.v3+json', 153 | }, 154 | } 155 | ); 156 | 157 | if (!response.ok) { 158 | res.statusCode = response.status; 159 | res.end(JSON.stringify({ error: '获取文件失败' })); 160 | return; 161 | } 162 | 163 | const data = await response.json(); 164 | const content = Buffer.from(data.content, 'base64').toString('utf-8'); 165 | 166 | res.statusCode = 200; 167 | res.end(JSON.stringify({ 168 | content: JSON.parse(content), 169 | sha: data.sha, 170 | })); 171 | return; 172 | } catch (error) { 173 | console.error('Get File Error:', error); 174 | res.statusCode = 500; 175 | res.end(JSON.stringify({ error: '获取文件失败' })); 176 | return; 177 | } 178 | } 179 | 180 | // 处理 /api/github/update-file 路由 181 | if (req.url?.startsWith('/api/github/update-file') && req.method === 'POST') { 182 | let body = ''; 183 | req.on('data', chunk => { 184 | body += chunk.toString(); 185 | }); 186 | 187 | req.on('end', async () => { 188 | try { 189 | const { token, content, sha, message } = JSON.parse(body); 190 | 191 | res.setHeader('Content-Type', 'application/json'); 192 | 193 | if (!token || !content || !sha) { 194 | res.statusCode = 400; 195 | res.end(JSON.stringify({ error: '缺少必要参数' })); 196 | return; 197 | } 198 | 199 | const allowedUsers = (env.GITHUB_ALLOWED_USERS || 'xia-66').split(',').map(u => u.trim()); 200 | 201 | // 验证用户身份 202 | const userResponse = await fetch('https://api.github.com/user', { 203 | headers: { 204 | 'Authorization': `token ${token}`, 205 | 'Accept': 'application/vnd.github.v3+json', 206 | }, 207 | }); 208 | 209 | const userData = await userResponse.json(); 210 | 211 | if (!allowedUsers.includes(userData.login)) { 212 | res.statusCode = 403; 213 | res.end(JSON.stringify({ error: `无权限修改,仅允许以下用户: ${allowedUsers.join(', ')}` })); 214 | return; 215 | } 216 | 217 | const repoOwner = env.GITHUB_REPO_OWNER || 'xia-66'; 218 | const repoName = env.GITHUB_REPO_NAME || 'nav'; 219 | 220 | // 更新文件 221 | const updateResponse = await fetch( 222 | `https://api.github.com/repos/${repoOwner}/${repoName}/contents/src/config/data.json`, 223 | { 224 | method: 'PUT', 225 | headers: { 226 | 'Authorization': `token ${token}`, 227 | 'Accept': 'application/vnd.github.v3+json', 228 | 'Content-Type': 'application/json', 229 | }, 230 | body: JSON.stringify({ 231 | message: message || '更新导航数据', 232 | content: Buffer.from(JSON.stringify(content, null, 2)).toString('base64'), 233 | sha: sha, 234 | }), 235 | } 236 | ); 237 | 238 | if (!updateResponse.ok) { 239 | const errorData = await updateResponse.json(); 240 | res.statusCode = updateResponse.status; 241 | res.end(JSON.stringify({ error: errorData.message || '更新文件失败' })); 242 | return; 243 | } 244 | 245 | const result = await updateResponse.json(); 246 | 247 | res.statusCode = 200; 248 | res.end(JSON.stringify({ 249 | success: true, 250 | commit: result.commit, 251 | })); 252 | return; 253 | } catch (error) { 254 | console.error('Update File Error:', error); 255 | res.statusCode = 500; 256 | res.end(JSON.stringify({ error: '更新文件失败' })); 257 | return; 258 | } 259 | }); 260 | return; 261 | } 262 | 263 | next(); 264 | }); 265 | } 266 | } 267 | ], 268 | define: { 269 | __APP_VERSION__: JSON.stringify(pkg.version), 270 | }, 271 | // 配置根路径 272 | resolve: { 273 | // ↓路径别名,主要是这部分 274 | alias: { 275 | '@': resolve(__dirname, './src') 276 | } 277 | }, 278 | css: { 279 | preprocessorOptions: { 280 | // 使用新的API方式 281 | scss: { 282 | api: 'modern-compiler' 283 | } 284 | // 如果需要全局引入变量或mixin 285 | // additionalData: `@import "@/styles/variables.scss";` 286 | } 287 | }, 288 | server: { 289 | // 配置host,局域网可访问 290 | host: '0.0.0.0', 291 | port: 8080 292 | } 293 | }}) 294 | -------------------------------------------------------------------------------- /src/config/data.json: -------------------------------------------------------------------------------- 1 | { 2 | "categories": [ 3 | { 4 | "id": 1, 5 | "name": "常用推荐" 6 | }, 7 | { 8 | "id": 2, 9 | "name": "实用工具" 10 | }, 11 | { 12 | "id": 3, 13 | "name": "日漫专区" 14 | }, 15 | { 16 | "id": 5, 17 | "name": "站长工具" 18 | }, 19 | { 20 | "id": 8, 21 | "name": "清理工具" 22 | }, 23 | { 24 | "id": 10, 25 | "name": "我的收藏" 26 | }, 27 | { 28 | "id": 11, 29 | "name": "国外服务" 30 | }, 31 | { 32 | "id": 12, 33 | "name": "开发工具" 34 | } 35 | ], 36 | "items": [ 37 | { 38 | "id": 1, 39 | "name": "Kimi", 40 | "url": "https://kimi.moonshot.cn/", 41 | "description": "月之暗面AI助手", 42 | "categoryId": 1 43 | }, 44 | { 45 | "id": 2, 46 | "name": "Gmail", 47 | "url": "https://www.google.com.hk/intl/zh-HK_hk/gmail/about/", 48 | "description": "谷歌免费邮箱服务", 49 | "categoryId": 11 50 | }, 51 | { 52 | "id": 4, 53 | "name": "游戏下载", 54 | "url": "https://www.gamer520.com/", 55 | "description": "游戏资源下载站", 56 | "categoryId": 1 57 | }, 58 | { 59 | "id": 8, 60 | "name": "ChatGPT", 61 | "url": "https://chat.openai.com/", 62 | "description": "OpenAI聊天机器人", 63 | "categoryId": 11 64 | }, 65 | { 66 | "id": 9, 67 | "name": "QQ邮箱", 68 | "url": "https://mail.qq.com/", 69 | "description": "腾讯免费邮箱服务", 70 | "categoryId": 1 71 | }, 72 | { 73 | "id": 10, 74 | "name": "农大Gitlab", 75 | "url": "https://git.henau.edu.cn/", 76 | "description": "河南农业大学代码托管平台", 77 | "categoryId": 1 78 | }, 79 | { 80 | "id": 12, 81 | "name": "哔哩哔哩", 82 | "url": "https://www.bilibili.com/", 83 | "description": "年轻人的视频社区", 84 | "categoryId": 1 85 | }, 86 | { 87 | "id": 14, 88 | "name": "致美化", 89 | "url": "https://zhutix.com/", 90 | "description": "Windows美化主题资源", 91 | "categoryId": 2 92 | }, 93 | { 94 | "id": 17, 95 | "name": "性格测试", 96 | "url": "https://www.16personalities.com/ch", 97 | "description": "MBTI性格测试", 98 | "categoryId": 2 99 | }, 100 | { 101 | "id": 18, 102 | "name": "一键激活", 103 | "url": "https://kms.cx/", 104 | "description": "Windows系统激活工具", 105 | "categoryId": 2 106 | }, 107 | { 108 | "id": 19, 109 | "name": "PPT超级市场", 110 | "url": "https://www.pptsupermarket.com/", 111 | "description": "PPT模板免费下载", 112 | "categoryId": 2 113 | }, 114 | { 115 | "id": 22, 116 | "name": "uTools", 117 | "url": "https://u.tools/", 118 | "description": "新一代效率工具平台", 119 | "categoryId": 2 120 | }, 121 | { 122 | "id": 23, 123 | "name": "Real-ESRGAN-GUI", 124 | "url": "https://github.com/tsukumijima/Real-ESRGAN-GUI", 125 | "description": "AI图片超分辨率工具", 126 | "categoryId": 10 127 | }, 128 | { 129 | "id": 25, 130 | "name": "GitHub Desktop", 131 | "url": "https://desktop.github.com/", 132 | "description": "GitHub官方桌面客户端", 133 | "categoryId": 10 134 | }, 135 | { 136 | "id": 26, 137 | "name": "文件转换器", 138 | "url": "https://convertio.co/zh/", 139 | "description": "在线文件格式转换工具", 140 | "categoryId": 2 141 | }, 142 | { 143 | "id": 27, 144 | "name": "草料二维码", 145 | "url": "https://cli.im/", 146 | "description": "二维码生成与解码", 147 | "categoryId": 2 148 | }, 149 | { 150 | "id": 28, 151 | "name": "Adobe全家桶", 152 | "url": "https://www.superso.top/Adobe/", 153 | "description": "Adobe软件下载", 154 | "categoryId": 2 155 | }, 156 | { 157 | "id": 29, 158 | "name": "LocalSend", 159 | "url": "https://localsend.org/#/", 160 | "description": "跨平台局域网文件传输", 161 | "categoryId": 2 162 | }, 163 | { 164 | "id": 30, 165 | "name": "Greasy Fork", 166 | "url": "https://greasyfork.org/zh-CN", 167 | "description": "用户脚本分享平台", 168 | "categoryId": 2 169 | }, 170 | { 171 | "id": 32, 172 | "name": "Anime Garden", 173 | "url": "https://garden.onekuma.cn/", 174 | "description": "动漫花园资源站", 175 | "categoryId": 3 176 | }, 177 | { 178 | "id": 35, 179 | "name": "风车动漫", 180 | "url": "https://www.dm302.com/", 181 | "description": "免费动漫在线观看", 182 | "categoryId": 3 183 | }, 184 | { 185 | "id": 36, 186 | "name": "Omofun", 187 | "url": "https://omofun.link/", 188 | "description": "动漫追番网站", 189 | "categoryId": 3 190 | }, 191 | { 192 | "id": 37, 193 | "name": "沪江小D", 194 | "url": "https://dict.hjenglish.com/", 195 | "description": "多语种在线词典", 196 | "categoryId": 2 197 | }, 198 | { 199 | "id": 38, 200 | "name": "有道翻译", 201 | "url": "https://fanyi.youdao.com/index.html#/", 202 | "description": "网易有道在线翻译", 203 | "categoryId": 2 204 | }, 205 | { 206 | "id": 40, 207 | "name": "获取Favicon图标", 208 | "url": "https://favicon.vwood.xyz/", 209 | "description": "网站图标提取工具", 210 | "categoryId": 12 211 | }, 212 | { 213 | "id": 42, 214 | "name": "Web API", 215 | "url": "https://api.vvhan.com/", 216 | "description": "免费API接口服务", 217 | "categoryId": 12 218 | }, 219 | { 220 | "id": 44, 221 | "name": "帧率测试", 222 | "url": "https://www.testufo.com/", 223 | "description": "显示器刷新率测试", 224 | "categoryId": 5 225 | }, 226 | { 227 | "id": 45, 228 | "name": "CDN加速", 229 | "url": "https://www.bootcdn.cn/", 230 | "description": "前端开源库CDN加速", 231 | "categoryId": 5 232 | }, 233 | { 234 | "id": 47, 235 | "name": "测速网站", 236 | "url": "https://test.ustc.edu.cn/", 237 | "description": "中科大网络测速", 238 | "categoryId": 5 239 | }, 240 | { 241 | "id": 48, 242 | "name": "IPv6测试", 243 | "url": "https://www.test-ipv6.com/index.html.zh_CN", 244 | "description": "IPv6连通性测试", 245 | "categoryId": 5 246 | }, 247 | { 248 | "id": 49, 249 | "name": "宝塔面板", 250 | "url": "https://www.bt.cn/new/index.html?btwaf=35186210", 251 | "description": "Linux服务器管理面板", 252 | "categoryId": 5 253 | }, 254 | { 255 | "id": 50, 256 | "name": "IT Tools", 257 | "url": "https://it-tools.tech/", 258 | "description": "程序员工具集合", 259 | "categoryId": 12 260 | }, 261 | { 262 | "id": 51, 263 | "name": "检测IP端口", 264 | "url": "https://coding.tools/cn/port-checker", 265 | "description": "在线端口检测工具", 266 | "categoryId": 5 267 | }, 268 | { 269 | "id": 52, 270 | "name": "IPChecking", 271 | "url": "https://ipcheck.ing/", 272 | "description": "IP地址信息查询", 273 | "categoryId": 5 274 | }, 275 | { 276 | "id": 54, 277 | "name": "Vercel", 278 | "url": "https://vercel.com/", 279 | "description": "前端应用部署平台", 280 | "categoryId": 11 281 | }, 282 | { 283 | "id": 63, 284 | "name": "SpaceSniffer(磁盘空间分析工具)", 285 | "url": "https://www.spacesniffer.com.cn", 286 | "description": "磁盘空间可视化分析", 287 | "categoryId": 8 288 | }, 289 | { 290 | "id": 64, 291 | "name": "Geek", 292 | "url": "https://geekuninstaller.com/", 293 | "description": "极客卸载工具", 294 | "categoryId": 8 295 | }, 296 | { 297 | "id": 88, 298 | "name": "V2EX", 299 | "url": "https://global.v2ex.co/", 300 | "description": "创意工作者社区", 301 | "categoryId": 1 302 | }, 303 | { 304 | "id": 89, 305 | "name": "LINUX DO", 306 | "url": "https://linux.do/", 307 | "description": "Linux技术社区", 308 | "categoryId": 1 309 | }, 310 | { 311 | "id": 92, 312 | "name": "sparkle", 313 | "url": "https://github.com/xishang0128/sparkle", 314 | "description": "Clash代理客户端", 315 | "categoryId": 10 316 | }, 317 | { 318 | "id": 93, 319 | "name": "异次元发卡", 320 | "url": "https://github.com/lizhipay/acg-faka", 321 | "description": "开源自动发卡系统", 322 | "categoryId": 10 323 | }, 324 | { 325 | "id": 98, 326 | "name": "GitHub", 327 | "url": "https://github.com/", 328 | "description": "全球最大开源社区", 329 | "categoryId": 11 330 | }, 331 | { 332 | "id": 101, 333 | "name": "腾讯元宝", 334 | "url": "https://yuanbao.tencent.com/chat", 335 | "description": "腾讯AI智能助手", 336 | "categoryId": 1 337 | }, 338 | { 339 | "id": 104, 340 | "name": "VUE后台模板", 341 | "url": "http://vue.easydo.work/", 342 | "description": "Vue后台管理系统模板", 343 | "categoryId": 12 344 | }, 345 | { 346 | "id": 107, 347 | "name": "小码短链接", 348 | "url": "https://xiaomark.com/", 349 | "description": "短网址生成工具", 350 | "categoryId": 2 351 | }, 352 | { 353 | "id": 109, 354 | "name": "FOFA", 355 | "url": "https://fofa.info/", 356 | "description": "网络资产搜索引擎", 357 | "categoryId": 12 358 | }, 359 | { 360 | "id": 110, 361 | "name": "Ping", 362 | "url": "https://www.itdog.cn/ping/", 363 | "description": "全国多地Ping测试", 364 | "categoryId": 5 365 | }, 366 | { 367 | "id": 113, 368 | "name": "IP纯净度", 369 | "url": "https://ping0.cc/", 370 | "description": "IP信誉检测工具", 371 | "categoryId": 5 372 | }, 373 | { 374 | "id": 119, 375 | "name": "网络面板", 376 | "url": "https://traffic.heiyu.fun", 377 | "description": "流量消耗测试工具", 378 | "categoryId": 5 379 | }, 380 | { 381 | "id": 121, 382 | "name": "宝可梦", 383 | "url": "https://web2.52pokemon.cc/dashboard", 384 | "description": "代理服务订阅", 385 | "categoryId": 10 386 | }, 387 | { 388 | "id": 122, 389 | "name": "临时邮箱", 390 | "url": "https://mail.heiyu.fun", 391 | "description": "临时邮箱服务", 392 | "categoryId": 2 393 | }, 394 | { 395 | "id": 123, 396 | "name": "宝塔纯净版", 397 | "url": "https://www.hostcli.com/", 398 | "description": "宝塔面板纯净版", 399 | "categoryId": 10 400 | }, 401 | { 402 | "id": 132, 403 | "name": "icon", 404 | "url": "https://www.flaticon.com/", 405 | "description": "免费图标资源库", 406 | "categoryId": 12 407 | }, 408 | { 409 | "id": 134, 410 | "name": "随机身份信息", 411 | "url": "https://id.dcode.top/", 412 | "description": "随机身份信息生成", 413 | "categoryId": 12 414 | }, 415 | { 416 | "id": 136, 417 | "name": "Claude", 418 | "url": "https://claude.ai/login", 419 | "description": "Anthropic AI助手", 420 | "categoryId": 11 421 | }, 422 | { 423 | "id": 137, 424 | "name": "Grok", 425 | "url": "https://grok.com/", 426 | "description": "xAI聊天机器人", 427 | "categoryId": 11 428 | }, 429 | { 430 | "id": 138, 431 | "name": "Gemini", 432 | "url": "https://deepmind.google/technologies/gemini/", 433 | "description": "Google AI助手", 434 | "categoryId": 11 435 | }, 436 | { 437 | "id": 141, 438 | "name": "FIclash", 439 | "url": "https://github.com/chen08209/FlClash", 440 | "description": "Flutter Clash客户端", 441 | "categoryId": 10 442 | }, 443 | { 444 | "id": 143, 445 | "name": "Cloudflare", 446 | "url": "https://www.cloudflare-cn.com/enterprise/", 447 | "description": "CDN与网络安全服务", 448 | "categoryId": 11 449 | }, 450 | { 451 | "id": 144, 452 | "name": "YouTube", 453 | "url": "https://www.youtube.com/", 454 | "description": "全球视频分享平台", 455 | "categoryId": 11 456 | }, 457 | { 458 | "id": 146, 459 | "name": "CSDN解锁", 460 | "url": "https://csdn.zeroai.chat/", 461 | "description": "CSDN内容免费查看", 462 | "categoryId": 2 463 | }, 464 | { 465 | "id": 148, 466 | "name": "lanerc", 467 | "url": "https://lanerc.app/", 468 | "description": "动漫追番应用", 469 | "categoryId": 3 470 | }, 471 | { 472 | "id": 149, 473 | "name": "spaceship", 474 | "url": "https://www.spaceship.com/", 475 | "description": "域名注册服务商", 476 | "categoryId": 11 477 | }, 478 | { 479 | "id": 150, 480 | "name": "whois", 481 | "url": "https://whois.heiyu.fun/", 482 | "description": "域名信息查询", 483 | "categoryId": 5 484 | }, 485 | { 486 | "id": 152, 487 | "name": "NodeSeek", 488 | "url": "https://www.nodeseek.com/", 489 | "description": "VPS交流社区", 490 | "categoryId": 1 491 | }, 492 | { 493 | "id": 156, 494 | "name": "文叔叔", 495 | "url": "https://www.wenshushu.cn/", 496 | "description": "大文件传输工具", 497 | "categoryId": 2 498 | }, 499 | { 500 | "id": 159, 501 | "name": "v2rayNG", 502 | "url": "https://github.com/2dust/v2rayNG/releases", 503 | "description": "Android代理客户端", 504 | "categoryId": 10 505 | }, 506 | { 507 | "id": 160, 508 | "name": "华为云", 509 | "url": "https://www.huaweicloud.com/intl/zh-cn/", 510 | "description": "华为云服务平台", 511 | "categoryId": 1 512 | }, 513 | { 514 | "id": 161, 515 | "name": "Everything", 516 | "url": "https://www.voidtools.com/zh-cn/support/everything/", 517 | "description": "文件快速搜索工具", 518 | "categoryId": 2 519 | }, 520 | { 521 | "id": 162, 522 | "name": "PixPin", 523 | "url": "https://pixpin.cn/", 524 | "description": "功能强大的截图工具", 525 | "categoryId": 2 526 | }, 527 | { 528 | "id": 163, 529 | "name": "Cherry Studio", 530 | "url": "https://www.cherry-ai.com/", 531 | "description": "多AI模型桌面客户端", 532 | "categoryId": 10 533 | }, 534 | { 535 | "id": 164, 536 | "name": "Umi-OCR", 537 | "url": "https://github.com/hiroi-sora/Umi-OCR", 538 | "description": "离线OCR文字识别", 539 | "categoryId": 10 540 | } 541 | ] 542 | } -------------------------------------------------------------------------------- /src/views/AdminDashboard.vue: -------------------------------------------------------------------------------- 1 | 239 | 240 | 547 | 548 | 998 | 999 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | const colors = require('tailwindcss/colors') 2 | // 导入tailwindcss辅助方法 3 | const { createPX } = require('./tailwind.utils.js') 4 | 5 | module.exports = { 6 | content: ['./src/**/*.html', './src/**/*.vue', './src/**/*.js'], 7 | presets: [], 8 | // 主题触发方式 9 | darkMode: 'class', // 'media' or 'class' 10 | theme: { 11 | // 屏幕断点 12 | screens: { 13 | xl: { min: '1280px' }, 14 | lg: { max: '1280px' }, 15 | md: { max: '1024px' }, 16 | sm: { max: '640px' } 17 | }, 18 | // 内置颜色 19 | colors: { 20 | // 自定义 21 | 'gray-0': '#ffffff', 22 | 'gray-1000': '#000000', 23 | 'gray-o5': 'rgba(255, 255, 255, 0.5)', 24 | 'gray-o3': 'rgba(255, 255, 255, 0.3)', 25 | 'gray-o7': 'rgba(255, 255, 255, 0.7)', 26 | // 系统自带 27 | transparent: 'transparent', 28 | current: 'currentColor', 29 | black: colors.black, 30 | white: colors.white, 31 | gray: colors.gray, 32 | red: colors.red, 33 | yellow: colors.amber, 34 | green: colors.emerald, 35 | blue: colors.blue, 36 | indigo: colors.indigo, 37 | purple: colors.violet, 38 | pink: colors.pink 39 | }, 40 | // 间隔 41 | spacing: { 42 | px: '1px', 43 | 0: '0px', 44 | 0.5: '0.125rem', 45 | 1: '0.25rem', 46 | 1.5: '0.375rem', 47 | 2: '0.5rem', 48 | 2.5: '0.625rem', 49 | 3: '0.75rem', 50 | 3.5: '0.875rem', 51 | 4: '1rem', 52 | 5: '1.25rem', 53 | 6: '1.5rem', 54 | 7: '1.75rem', 55 | 8: '2rem', 56 | 9: '2.25rem', 57 | 10: '2.5rem', 58 | 11: '2.75rem', 59 | 12: '3rem', 60 | 14: '3.5rem', 61 | 16: '4rem', 62 | 20: '5rem', 63 | 24: '6rem', 64 | 28: '7rem', 65 | 32: '8rem', 66 | 36: '9rem', 67 | 40: '10rem', 68 | 44: '11rem', 69 | 48: '12rem', 70 | 52: '13rem', 71 | 56: '14rem', 72 | 60: '15rem', 73 | 64: '16rem', 74 | 72: '18rem', 75 | 80: '20rem', 76 | 96: '24rem', 77 | ...createPX(800, 'positive'), 78 | ...createPX(200, 'negative') 79 | }, 80 | // 默认动画 81 | animation: { 82 | none: 'none', 83 | spin: 'spin 1s linear infinite', 84 | ping: 'ping 1s cubic-bezier(0, 0, 0.2, 1) infinite', 85 | pulse: 'pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite', 86 | bounce: 'bounce 1s infinite' 87 | }, 88 | backdropBlur: theme => theme('blur'), 89 | backdropBrightness: theme => theme('brightness'), 90 | backdropContrast: theme => theme('contrast'), 91 | backdropGrayscale: theme => theme('grayscale'), 92 | backdropHueRotate: theme => theme('hueRotate'), 93 | backdropInvert: theme => theme('invert'), 94 | backdropOpacity: theme => theme('opacity'), 95 | backdropSaturate: theme => theme('saturate'), 96 | backdropSepia: theme => theme('sepia'), 97 | backgroundColor: theme => theme('colors'), 98 | // 默认渐变 99 | backgroundImage: { 100 | none: 'none', 101 | 'gradient-to-t': 'linear-gradient(to top, var(--tw-gradient-stops))', 102 | 'gradient-to-tr': 'linear-gradient(to top right, var(--tw-gradient-stops))', 103 | 'gradient-to-r': 'linear-gradient(to right, var(--tw-gradient-stops))', 104 | 'gradient-to-br': 'linear-gradient(to bottom right, var(--tw-gradient-stops))', 105 | 'gradient-to-b': 'linear-gradient(to bottom, var(--tw-gradient-stops))', 106 | 'gradient-to-bl': 'linear-gradient(to bottom left, var(--tw-gradient-stops))', 107 | 'gradient-to-l': 'linear-gradient(to left, var(--tw-gradient-stops))', 108 | 'gradient-to-tl': 'linear-gradient(to top left, var(--tw-gradient-stops))' 109 | }, 110 | // 背景透明度 111 | backgroundOpacity: theme => theme('opacity'), 112 | // 背景定位 113 | backgroundPosition: { 114 | bottom: 'bottom', 115 | center: 'center', 116 | left: 'left', 117 | 'left-bottom': 'left bottom', 118 | 'left-top': 'left top', 119 | right: 'right', 120 | 'right-bottom': 'right bottom', 121 | 'right-top': 'right top', 122 | top: 'top' 123 | }, 124 | // 背景填充方式 125 | backgroundSize: { 126 | auto: 'auto', 127 | cover: 'cover', 128 | contain: 'contain' 129 | }, 130 | // 背景模糊度 131 | blur: { 132 | 0: '0', 133 | none: '0', 134 | sm: '4px', 135 | DEFAULT: '8px', 136 | md: '12px', 137 | lg: '16px', 138 | xl: '24px', 139 | '2xl': '40px', 140 | '3xl': '64px' 141 | }, 142 | // 亮度过滤器 143 | brightness: { 144 | 0: '0', 145 | 50: '.5', 146 | 75: '.75', 147 | 90: '.9', 148 | 95: '.95', 149 | 100: '1', 150 | 105: '1.05', 151 | 110: '1.1', 152 | 125: '1.25', 153 | 150: '1.5', 154 | 200: '2' 155 | }, 156 | // 边框颜色 157 | borderColor: theme => ({ 158 | ...theme('colors'), 159 | DEFAULT: theme('colors.gray.200', 'currentColor') 160 | }), 161 | // 边框透明度 162 | borderOpacity: theme => theme('opacity'), 163 | // 边框圆角 164 | borderRadius: { 165 | none: '0px', 166 | sm: '0.125rem', 167 | DEFAULT: '0.25rem', 168 | md: '0.375rem', 169 | lg: '0.5rem', 170 | xl: '0.75rem', 171 | '2xl': '1rem', 172 | '3xl': '1.5rem', 173 | full: '9999px' 174 | }, 175 | // 边框宽度 176 | borderWidth: { 177 | DEFAULT: '1px', 178 | 0: '0px', 179 | 2: '2px', 180 | 4: '4px', 181 | 8: '8px' 182 | }, 183 | // 盒子阴影 184 | boxShadow: { 185 | sm: '0 1px 2px 0 rgba(0, 0, 0, 0.05)', 186 | DEFAULT: '0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06)', 187 | md: '0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)', 188 | lg: '0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05)', 189 | xl: '0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04)', 190 | '2xl': '0 25px 50px -12px rgba(0, 0, 0, 0.25)', 191 | inner: 'inset 0 2px 4px 0 rgba(0, 0, 0, 0.06)', 192 | none: 'none' 193 | }, 194 | // 光标的颜色 195 | caretColor: theme => theme('colors'), 196 | // 对比度过滤器 197 | contrast: { 198 | 0: '0', 199 | 50: '.5', 200 | 75: '.75', 201 | 100: '1', 202 | 125: '1.25', 203 | 150: '1.5', 204 | 200: '2' 205 | }, 206 | container: {}, 207 | content: { 208 | none: 'none' 209 | }, 210 | // 鼠标图标 211 | cursor: { 212 | auto: 'auto', 213 | default: 'default', 214 | pointer: 'pointer', 215 | wait: 'wait', 216 | text: 'text', 217 | move: 'move', 218 | help: 'help', 219 | 'not-allowed': 'not-allowed' 220 | }, 221 | divideColor: theme => theme('borderColor'), 222 | divideOpacity: theme => theme('borderOpacity'), 223 | divideWidth: theme => theme('borderWidth'), 224 | dropShadow: { 225 | sm: '0 1px 1px rgba(0,0,0,0.05)', 226 | DEFAULT: ['0 1px 2px rgba(0, 0, 0, 0.1)', '0 1px 1px rgba(0, 0, 0, 0.06)'], 227 | md: ['0 4px 3px rgba(0, 0, 0, 0.07)', '0 2px 2px rgba(0, 0, 0, 0.06)'], 228 | lg: ['0 10px 8px rgba(0, 0, 0, 0.04)', '0 4px 3px rgba(0, 0, 0, 0.1)'], 229 | xl: ['0 20px 13px rgba(0, 0, 0, 0.03)', '0 8px 5px rgba(0, 0, 0, 0.08)'], 230 | '2xl': '0 25px 25px rgba(0, 0, 0, 0.15)', 231 | none: '0 0 #0000' 232 | }, 233 | fill: { current: 'currentColor' }, 234 | grayscale: { 235 | 0: '0', 236 | DEFAULT: '100%' 237 | }, 238 | hueRotate: { 239 | '-180': '-180deg', 240 | '-90': '-90deg', 241 | '-60': '-60deg', 242 | '-30': '-30deg', 243 | '-15': '-15deg', 244 | 0: '0deg', 245 | 15: '15deg', 246 | 30: '30deg', 247 | 60: '60deg', 248 | 90: '90deg', 249 | 180: '180deg' 250 | }, 251 | invert: { 252 | 0: '0', 253 | DEFAULT: '100%' 254 | }, 255 | flex: { 256 | 1: '1 1 0%', 257 | auto: '1 1 auto', 258 | initial: '0 1 auto', 259 | none: 'none' 260 | }, 261 | flexGrow: { 262 | 0: '0', 263 | DEFAULT: '1' 264 | }, 265 | flexShrink: { 266 | 0: '0', 267 | DEFAULT: '1' 268 | }, 269 | // 字体 270 | fontFamily: { 271 | sans: ['ui-sans-serif', 'system-ui', '-apple-system', 'BlinkMacSystemFont', '"Segoe UI"', 'Roboto', '"Helvetica Neue"', 'Arial', '"Noto Sans"', 'sans-serif', '"Apple Color Emoji"', '"Segoe UI Emoji"', '"Segoe UI Symbol"', '"Noto Color Emoji"'], 272 | serif: ['ui-serif', 'Georgia', 'Cambria', '"Times New Roman"', 'Times', 'serif'], 273 | mono: ['ui-monospace', 'SFMono-Regular', 'Menlo', 'Monaco', 'Consolas', '"Liberation Mono"', '"Courier New"', 'monospace'] 274 | }, 275 | // 文字大小 276 | fontSize: { 277 | xs: ['0.75rem', { lineHeight: '1rem' }], 278 | sm: ['0.875rem', { lineHeight: '1.25rem' }], 279 | base: ['1rem', { lineHeight: '1.5rem' }], 280 | lg: ['1.125rem', { lineHeight: '1.75rem' }], 281 | xl: ['1.25rem', { lineHeight: '1.75rem' }], 282 | '2xl': ['1.5rem', { lineHeight: '2rem' }], 283 | '3xl': ['1.875rem', { lineHeight: '2.25rem' }], 284 | '4xl': ['2.25rem', { lineHeight: '2.5rem' }], 285 | '5xl': ['3rem', { lineHeight: '1' }], 286 | '6xl': ['3.75rem', { lineHeight: '1' }], 287 | '7xl': ['4.5rem', { lineHeight: '1' }], 288 | '8xl': ['6rem', { lineHeight: '1' }], 289 | '9xl': ['8rem', { lineHeight: '1' }], 290 | // 文字大小配合px值 291 | '10px': '10px', 292 | '12px': '12px', 293 | '14px': '14px', 294 | '16px': '16px', 295 | '18px': '18px', 296 | '20px': '20px', 297 | '22px': '22px', 298 | '24px': '24px', 299 | '26px': '26px', 300 | '28px': '28px', 301 | '30px': '30px', 302 | '32px': '32px' 303 | }, 304 | // 字体粗细 305 | fontWeight: { 306 | thin: '100', 307 | extralight: '200', 308 | light: '300', 309 | normal: '400', 310 | medium: '500', 311 | semibold: '600', 312 | bold: '700', 313 | extrabold: '800', 314 | black: '900' 315 | }, 316 | gap: theme => theme('spacing'), 317 | gradientColorStops: theme => theme('colors'), 318 | gridAutoColumns: { 319 | auto: 'auto', 320 | min: 'min-content', 321 | max: 'max-content', 322 | fr: 'minmax(0, 1fr)' 323 | }, 324 | gridAutoRows: { 325 | auto: 'auto', 326 | min: 'min-content', 327 | max: 'max-content', 328 | fr: 'minmax(0, 1fr)' 329 | }, 330 | gridColumn: { 331 | auto: 'auto', 332 | 'span-1': 'span 1 / span 1', 333 | 'span-2': 'span 2 / span 2', 334 | 'span-3': 'span 3 / span 3', 335 | 'span-4': 'span 4 / span 4', 336 | 'span-5': 'span 5 / span 5', 337 | 'span-6': 'span 6 / span 6', 338 | 'span-7': 'span 7 / span 7', 339 | 'span-8': 'span 8 / span 8', 340 | 'span-9': 'span 9 / span 9', 341 | 'span-10': 'span 10 / span 10', 342 | 'span-11': 'span 11 / span 11', 343 | 'span-12': 'span 12 / span 12', 344 | 'span-full': '1 / -1' 345 | }, 346 | gridColumnEnd: { 347 | auto: 'auto', 348 | 1: '1', 349 | 2: '2', 350 | 3: '3', 351 | 4: '4', 352 | 5: '5', 353 | 6: '6', 354 | 7: '7', 355 | 8: '8', 356 | 9: '9', 357 | 10: '10', 358 | 11: '11', 359 | 12: '12', 360 | 13: '13' 361 | }, 362 | gridColumnStart: { 363 | auto: 'auto', 364 | 1: '1', 365 | 2: '2', 366 | 3: '3', 367 | 4: '4', 368 | 5: '5', 369 | 6: '6', 370 | 7: '7', 371 | 8: '8', 372 | 9: '9', 373 | 10: '10', 374 | 11: '11', 375 | 12: '12', 376 | 13: '13' 377 | }, 378 | gridRow: { 379 | auto: 'auto', 380 | 'span-1': 'span 1 / span 1', 381 | 'span-2': 'span 2 / span 2', 382 | 'span-3': 'span 3 / span 3', 383 | 'span-4': 'span 4 / span 4', 384 | 'span-5': 'span 5 / span 5', 385 | 'span-6': 'span 6 / span 6', 386 | 'span-full': '1 / -1' 387 | }, 388 | gridRowStart: { 389 | auto: 'auto', 390 | 1: '1', 391 | 2: '2', 392 | 3: '3', 393 | 4: '4', 394 | 5: '5', 395 | 6: '6', 396 | 7: '7' 397 | }, 398 | gridRowEnd: { 399 | auto: 'auto', 400 | 1: '1', 401 | 2: '2', 402 | 3: '3', 403 | 4: '4', 404 | 5: '5', 405 | 6: '6', 406 | 7: '7' 407 | }, 408 | gridTemplateColumns: { 409 | none: 'none', 410 | 1: 'repeat(1, minmax(0, 1fr))', 411 | 2: 'repeat(2, minmax(0, 1fr))', 412 | 3: 'repeat(3, minmax(0, 1fr))', 413 | 4: 'repeat(4, minmax(0, 1fr))', 414 | 5: 'repeat(5, minmax(0, 1fr))', 415 | 6: 'repeat(6, minmax(0, 1fr))', 416 | 7: 'repeat(7, minmax(0, 1fr))', 417 | 8: 'repeat(8, minmax(0, 1fr))', 418 | 9: 'repeat(9, minmax(0, 1fr))', 419 | 10: 'repeat(10, minmax(0, 1fr))', 420 | 11: 'repeat(11, minmax(0, 1fr))', 421 | 12: 'repeat(12, minmax(0, 1fr))' 422 | }, 423 | gridTemplateRows: { 424 | none: 'none', 425 | 1: 'repeat(1, minmax(0, 1fr))', 426 | 2: 'repeat(2, minmax(0, 1fr))', 427 | 3: 'repeat(3, minmax(0, 1fr))', 428 | 4: 'repeat(4, minmax(0, 1fr))', 429 | 5: 'repeat(5, minmax(0, 1fr))', 430 | 6: 'repeat(6, minmax(0, 1fr))' 431 | }, 432 | // 宽度定义 433 | width: theme => ({ 434 | auto: 'auto', 435 | ...theme('spacing'), 436 | '1/2': '50%', 437 | '1/3': '33.333333%', 438 | '2/3': '66.666667%', 439 | '1/4': '25%', 440 | '2/4': '50%', 441 | '3/4': '75%', 442 | '1/5': '20%', 443 | '2/5': '40%', 444 | '3/5': '60%', 445 | '4/5': '80%', 446 | '1/6': '16.666667%', 447 | '2/6': '33.333333%', 448 | '3/6': '50%', 449 | '4/6': '66.666667%', 450 | '5/6': '83.333333%', 451 | '1/12': '8.333333%', 452 | '2/12': '16.666667%', 453 | '3/12': '25%', 454 | '4/12': '33.333333%', 455 | '5/12': '41.666667%', 456 | '6/12': '50%', 457 | '7/12': '58.333333%', 458 | '8/12': '66.666667%', 459 | '9/12': '75%', 460 | '10/12': '83.333333%', 461 | '11/12': '91.666667%', 462 | full: '100%', 463 | screen: '100vw', 464 | min: 'min-content', 465 | max: 'max-content' 466 | }), 467 | // 高度配置 468 | height: theme => ({ 469 | auto: 'auto', 470 | ...theme('spacing'), 471 | '1/2': '50%', 472 | '1/3': '33.333333%', 473 | '2/3': '66.666667%', 474 | '1/4': '25%', 475 | '2/4': '50%', 476 | '3/4': '75%', 477 | '1/5': '20%', 478 | '2/5': '40%', 479 | '3/5': '60%', 480 | '4/5': '80%', 481 | '1/6': '16.666667%', 482 | '2/6': '33.333333%', 483 | '3/6': '50%', 484 | '4/6': '66.666667%', 485 | '5/6': '83.333333%', 486 | full: '100%', 487 | screen: '100vh' 488 | }), 489 | inset: (theme, { negative }) => ({ 490 | auto: 'auto', 491 | full: '100%', 492 | '1/2': '50%', 493 | '1/3': '33.333333%', 494 | '2/3': '66.666667%', 495 | '1/4': '25%', 496 | '2/4': '50%', 497 | '3/4': '75%', 498 | '-1/2': '-50%', 499 | '-1/3': '-33.333333%', 500 | '-2/3': '-66.666667%', 501 | '-1/4': '-25%', 502 | '-2/4': '-50%', 503 | '-3/4': '-75%', 504 | '-full': '-100%', 505 | ...theme('spacing'), 506 | ...negative(theme('spacing')) 507 | }), 508 | keyframes: { 509 | spin: { 510 | to: { 511 | transform: 'rotate(360deg)' 512 | } 513 | }, 514 | ping: { 515 | '75%, 100%': { 516 | transform: 'scale(2)', 517 | opacity: '0' 518 | } 519 | }, 520 | pulse: { 521 | '50%': { 522 | opacity: '.5' 523 | } 524 | }, 525 | bounce: { 526 | '0%, 100%': { 527 | transform: 'translateY(-25%)', 528 | animationTimingFunction: 'cubic-bezier(0.8,0,1,1)' 529 | }, 530 | '50%': { 531 | transform: 'none', 532 | animationTimingFunction: 'cubic-bezier(0,0,0.2,1)' 533 | } 534 | } 535 | }, 536 | letterSpacing: { 537 | tighter: '-0.05em', 538 | tight: '-0.025em', 539 | normal: '0em', 540 | wide: '0.025em', 541 | wider: '0.05em', 542 | widest: '0.1em' 543 | }, 544 | lineHeight: { 545 | none: '1', 546 | tight: '1.25', 547 | snug: '1.375', 548 | normal: '1.5', 549 | relaxed: '1.625', 550 | loose: '2', 551 | 3: '.75rem', 552 | 4: '1rem', 553 | 5: '1.25rem', 554 | 6: '1.5rem', 555 | 7: '1.75rem', 556 | 8: '2rem', 557 | 9: '2.25rem', 558 | 10: '2.5rem' 559 | }, 560 | listStyleType: { 561 | none: 'none', 562 | disc: 'disc', 563 | decimal: 'decimal' 564 | }, 565 | margin: (theme, { negative }) => ({ 566 | auto: 'auto', 567 | ...theme('spacing'), 568 | ...negative(theme('spacing')) 569 | }), 570 | maxHeight: theme => ({ 571 | ...theme('spacing'), 572 | full: '100%', 573 | screen: '100vh' 574 | }), 575 | maxWidth: (theme, { breakpoints }) => ({ 576 | none: 'none', 577 | 0: '0rem', 578 | xs: '20rem', 579 | sm: '24rem', 580 | md: '28rem', 581 | lg: '32rem', 582 | xl: '36rem', 583 | '2xl': '42rem', 584 | '3xl': '48rem', 585 | '4xl': '56rem', 586 | '5xl': '64rem', 587 | '6xl': '72rem', 588 | '7xl': '80rem', 589 | full: '100%', 590 | min: 'min-content', 591 | max: 'max-content', 592 | prose: '65ch', 593 | ...breakpoints(theme('screens')) 594 | }), 595 | minHeight: { 596 | 0: '0px', 597 | full: '100%', 598 | screen: '100vh' 599 | }, 600 | minWidth: { 601 | 0: '0px', 602 | full: '100%', 603 | min: 'min-content', 604 | max: 'max-content' 605 | }, 606 | objectPosition: { 607 | bottom: 'bottom', 608 | center: 'center', 609 | left: 'left', 610 | 'left-bottom': 'left bottom', 611 | 'left-top': 'left top', 612 | right: 'right', 613 | 'right-bottom': 'right bottom', 614 | 'right-top': 'right top', 615 | top: 'top' 616 | }, 617 | opacity: { 618 | 0: '0', 619 | 5: '0.05', 620 | 10: '0.1', 621 | 20: '0.2', 622 | 25: '0.25', 623 | 30: '0.3', 624 | 40: '0.4', 625 | 50: '0.5', 626 | 60: '0.6', 627 | 70: '0.7', 628 | 75: '0.75', 629 | 80: '0.8', 630 | 90: '0.9', 631 | 95: '0.95', 632 | 100: '1' 633 | }, 634 | order: { 635 | first: '-9999', 636 | last: '9999', 637 | none: '0', 638 | 1: '1', 639 | 2: '2', 640 | 3: '3', 641 | 4: '4', 642 | 5: '5', 643 | 6: '6', 644 | 7: '7', 645 | 8: '8', 646 | 9: '9', 647 | 10: '10', 648 | 11: '11', 649 | 12: '12' 650 | }, 651 | outline: { 652 | none: ['2px solid transparent', '2px'], 653 | white: ['2px dotted white', '2px'], 654 | black: ['2px dotted black', '2px'] 655 | }, 656 | padding: theme => theme('spacing'), 657 | placeholderColor: theme => theme('colors'), 658 | placeholderOpacity: theme => theme('opacity'), 659 | ringColor: theme => ({ 660 | DEFAULT: theme('colors.blue.500', '#3b82f6'), 661 | ...theme('colors') 662 | }), 663 | ringOffsetColor: theme => theme('colors'), 664 | ringOffsetWidth: { 665 | 0: '0px', 666 | 1: '1px', 667 | 2: '2px', 668 | 4: '4px', 669 | 8: '8px' 670 | }, 671 | ringOpacity: theme => ({ 672 | DEFAULT: '0.5', 673 | ...theme('opacity') 674 | }), 675 | ringWidth: { 676 | DEFAULT: '3px', 677 | 0: '0px', 678 | 1: '1px', 679 | 2: '2px', 680 | 4: '4px', 681 | 8: '8px' 682 | }, 683 | rotate: { 684 | '-180': '-180deg', 685 | '-90': '-90deg', 686 | '-45': '-45deg', 687 | '-12': '-12deg', 688 | '-6': '-6deg', 689 | '-3': '-3deg', 690 | '-2': '-2deg', 691 | '-1': '-1deg', 692 | 0: '0deg', 693 | 1: '1deg', 694 | 2: '2deg', 695 | 3: '3deg', 696 | 6: '6deg', 697 | 12: '12deg', 698 | 45: '45deg', 699 | 90: '90deg', 700 | 180: '180deg' 701 | }, 702 | saturate: { 703 | 0: '0', 704 | 50: '.5', 705 | 100: '1', 706 | 150: '1.5', 707 | 200: '2' 708 | }, 709 | scale: { 710 | 0: '0', 711 | 50: '.5', 712 | 75: '.75', 713 | 90: '.9', 714 | 95: '.95', 715 | 100: '1', 716 | 105: '1.05', 717 | 110: '1.1', 718 | 125: '1.25', 719 | 150: '1.5' 720 | }, 721 | sepia: { 722 | 0: '0', 723 | DEFAULT: '100%' 724 | }, 725 | skew: { 726 | '-12': '-12deg', 727 | '-6': '-6deg', 728 | '-3': '-3deg', 729 | '-2': '-2deg', 730 | '-1': '-1deg', 731 | 0: '0deg', 732 | 1: '1deg', 733 | 2: '2deg', 734 | 3: '3deg', 735 | 6: '6deg', 736 | 12: '12deg' 737 | }, 738 | space: (theme, { negative }) => ({ 739 | ...theme('spacing'), 740 | ...negative(theme('spacing')) 741 | }), 742 | stroke: { 743 | current: 'currentColor' 744 | }, 745 | strokeWidth: { 746 | 0: '0', 747 | 1: '1', 748 | 2: '2' 749 | }, 750 | textColor: theme => theme('colors'), 751 | textOpacity: theme => theme('opacity'), 752 | transformOrigin: { 753 | center: 'center', 754 | top: 'top', 755 | 'top-right': 'top right', 756 | right: 'right', 757 | 'bottom-right': 'bottom right', 758 | bottom: 'bottom', 759 | 'bottom-left': 'bottom left', 760 | left: 'left', 761 | 'top-left': 'top left' 762 | }, 763 | transitionDelay: { 764 | 75: '75ms', 765 | 100: '100ms', 766 | 150: '150ms', 767 | 200: '200ms', 768 | 300: '300ms', 769 | 500: '500ms', 770 | 700: '700ms', 771 | 1000: '1000ms' 772 | }, 773 | transitionDuration: { 774 | DEFAULT: '150ms', 775 | 75: '75ms', 776 | 100: '100ms', 777 | 150: '150ms', 778 | 200: '200ms', 779 | 300: '300ms', 780 | 500: '500ms', 781 | 700: '700ms', 782 | 1000: '1000ms' 783 | }, 784 | transitionProperty: { 785 | none: 'none', 786 | all: 'all', 787 | DEFAULT: 'background-color, border-color, color, fill, stroke, opacity, box-shadow, transform, filter, backdrop-filter', 788 | colors: 'background-color, border-color, color, fill, stroke', 789 | opacity: 'opacity', 790 | shadow: 'box-shadow', 791 | transform: 'transform' 792 | }, 793 | transitionTimingFunction: { 794 | DEFAULT: 'cubic-bezier(0.4, 0, 0.2, 1)', 795 | linear: 'linear', 796 | in: 'cubic-bezier(0.4, 0, 1, 1)', 797 | out: 'cubic-bezier(0, 0, 0.2, 1)', 798 | 'in-out': 'cubic-bezier(0.4, 0, 0.2, 1)' 799 | }, 800 | translate: (theme, { negative }) => ({ 801 | ...theme('spacing'), 802 | ...negative(theme('spacing')), 803 | '1/2': '50%', 804 | '1/3': '33.333333%', 805 | '2/3': '66.666667%', 806 | '1/4': '25%', 807 | '2/4': '50%', 808 | '3/4': '75%', 809 | full: '100%', 810 | '-1/2': '-50%', 811 | '-1/3': '-33.333333%', 812 | '-2/3': '-66.666667%', 813 | '-1/4': '-25%', 814 | '-2/4': '-50%', 815 | '-3/4': '-75%', 816 | '-full': '-100%' 817 | }), 818 | // 层级 819 | zIndex: { 820 | auto: 'auto', 821 | 0: '0', 822 | 10: '10', 823 | 20: '20', 824 | 30: '30', 825 | 40: '40', 826 | 50: '50' 827 | } 828 | }, 829 | 830 | // 变体顺序 831 | variantOrder: ['first', 'last', 'odd', 'even', 'visited', 'checked', 'empty', 'read-only', 'group-hover', 'group-focus', 'focus-within', 'hover', 'focus', 'focus-visible', 'active', 'disabled'], 832 | // 变体触发条件 833 | variants: { 834 | accessibility: ['responsive', 'focus-within', 'focus'], 835 | alignContent: ['responsive'], 836 | alignItems: ['responsive'], 837 | alignSelf: ['responsive'], 838 | animation: ['responsive'], 839 | appearance: ['responsive'], 840 | backdropBlur: ['responsive'], 841 | backdropBrightness: ['responsive'], 842 | backdropContrast: ['responsive'], 843 | backdropFilter: ['responsive'], 844 | backdropGrayscale: ['responsive'], 845 | backdropHueRotate: ['responsive'], 846 | backdropInvert: ['responsive'], 847 | backdropOpacity: ['responsive'], 848 | backdropSaturate: ['responsive'], 849 | backdropSepia: ['responsive'], 850 | backgroundAttachment: ['responsive'], 851 | backgroundBlendMode: ['responsive'], 852 | backgroundClip: ['responsive'], 853 | backgroundColor: ['responsive', 'dark', 'group-hover', 'focus-within', 'hover', 'focus'], 854 | backgroundImage: ['responsive'], 855 | backgroundOpacity: ['responsive', 'dark', 'group-hover', 'focus-within', 'hover', 'focus'], 856 | backgroundPosition: ['responsive'], 857 | backgroundRepeat: ['responsive'], 858 | backgroundSize: ['responsive'], 859 | backgroundOrigin: ['responsive'], 860 | blur: ['responsive'], 861 | borderCollapse: ['responsive'], 862 | borderColor: ['responsive', 'dark', 'group-hover', 'focus-within', 'hover', 'focus'], 863 | borderOpacity: ['responsive', 'dark', 'group-hover', 'focus-within', 'hover', 'focus'], 864 | borderRadius: ['responsive'], 865 | borderStyle: ['responsive'], 866 | borderWidth: ['responsive'], 867 | boxDecorationBreak: ['responsive'], 868 | boxShadow: ['responsive', 'group-hover', 'focus-within', 'hover', 'focus'], 869 | boxSizing: ['responsive'], 870 | brightness: ['responsive'], 871 | clear: ['responsive'], 872 | container: ['responsive'], 873 | contrast: ['responsive'], 874 | cursor: ['responsive'], 875 | display: ['responsive'], 876 | divideColor: ['responsive', 'dark'], 877 | divideOpacity: ['responsive', 'dark'], 878 | divideStyle: ['responsive'], 879 | divideWidth: ['responsive'], 880 | dropShadow: ['responsive'], 881 | fill: ['responsive'], 882 | filter: ['responsive'], 883 | flex: ['responsive'], 884 | flexDirection: ['responsive'], 885 | flexGrow: ['responsive'], 886 | flexShrink: ['responsive'], 887 | flexWrap: ['responsive'], 888 | float: ['responsive'], 889 | fontFamily: ['responsive'], 890 | fontSize: ['responsive'], 891 | fontSmoothing: ['responsive'], 892 | fontStyle: ['responsive'], 893 | fontVariantNumeric: ['responsive'], 894 | fontWeight: ['responsive'], 895 | gap: ['responsive'], 896 | gradientColorStops: ['responsive', 'dark', 'hover', 'focus'], 897 | grayscale: ['responsive'], 898 | gridAutoColumns: ['responsive'], 899 | gridAutoFlow: ['responsive'], 900 | gridAutoRows: ['responsive'], 901 | gridColumn: ['responsive'], 902 | gridColumnEnd: ['responsive'], 903 | gridColumnStart: ['responsive'], 904 | gridRow: ['responsive'], 905 | gridRowEnd: ['responsive'], 906 | gridRowStart: ['responsive'], 907 | gridTemplateColumns: ['responsive'], 908 | gridTemplateRows: ['responsive'], 909 | height: ['responsive'], 910 | hueRotate: ['responsive'], 911 | inset: ['responsive'], 912 | invert: ['responsive'], 913 | isolation: ['responsive'], 914 | justifyContent: ['responsive'], 915 | justifyItems: ['responsive'], 916 | justifySelf: ['responsive'], 917 | letterSpacing: ['responsive'], 918 | lineHeight: ['responsive'], 919 | listStylePosition: ['responsive'], 920 | listStyleType: ['responsive'], 921 | margin: ['responsive', 'first', 'last', 'hover'], 922 | maxHeight: ['responsive'], 923 | maxWidth: ['responsive'], 924 | minHeight: ['responsive'], 925 | minWidth: ['responsive'], 926 | mixBlendMode: ['responsive'], 927 | objectFit: ['responsive'], 928 | objectPosition: ['responsive'], 929 | opacity: ['responsive', 'group-hover', 'focus-within', 'hover', 'focus'], 930 | order: ['responsive'], 931 | outline: ['responsive', 'focus-within', 'focus'], 932 | overflow: ['responsive'], 933 | overscrollBehavior: ['responsive'], 934 | padding: ['responsive'], 935 | placeContent: ['responsive'], 936 | placeItems: ['responsive'], 937 | placeSelf: ['responsive'], 938 | placeholderColor: ['responsive', 'dark', 'focus'], 939 | placeholderOpacity: ['responsive', 'dark', 'focus'], 940 | pointerEvents: ['responsive'], 941 | position: ['responsive'], 942 | resize: ['responsive'], 943 | ringColor: ['responsive', 'dark', 'focus-within', 'focus'], 944 | ringOffsetColor: ['responsive', 'dark', 'focus-within', 'focus'], 945 | ringOffsetWidth: ['responsive', 'focus-within', 'focus'], 946 | ringOpacity: ['responsive', 'dark', 'focus-within', 'focus'], 947 | ringWidth: ['responsive', 'focus-within', 'focus'], 948 | rotate: ['responsive', 'hover', 'focus'], 949 | saturate: ['responsive'], 950 | scale: ['responsive', 'hover', 'focus'], 951 | sepia: ['responsive'], 952 | skew: ['responsive', 'hover', 'focus'], 953 | space: ['responsive'], 954 | stroke: ['responsive'], 955 | strokeWidth: ['responsive'], 956 | tableLayout: ['responsive'], 957 | textAlign: ['responsive'], 958 | textColor: ['responsive', 'dark', 'group-hover', 'focus-within', 'hover', 'focus'], 959 | textDecoration: ['responsive', 'group-hover', 'focus-within', 'hover', 'focus'], 960 | textOpacity: ['responsive', 'dark', 'group-hover', 'focus-within', 'hover', 'focus'], 961 | textOverflow: ['responsive'], 962 | textTransform: ['responsive'], 963 | transform: ['responsive'], 964 | transformOrigin: ['responsive'], 965 | transitionDelay: ['responsive'], 966 | transitionDuration: ['responsive'], 967 | transitionProperty: ['responsive'], 968 | transitionTimingFunction: ['responsive'], 969 | translate: ['responsive', 'hover', 'focus'], 970 | userSelect: ['responsive'], 971 | verticalAlign: ['responsive'], 972 | visibility: ['responsive'], 973 | whitespace: ['responsive'], 974 | width: ['responsive'], 975 | wordBreak: ['responsive'], 976 | zIndex: ['responsive', 'focus-within', 'focus'] 977 | }, 978 | plugins: [] 979 | } 980 | --------------------------------------------------------------------------------