This commit is contained in:
2026-03-11 20:41:03 +08:00
commit c5af0cc946
21 changed files with 5831 additions and 0 deletions

19
.gitignore vendored Normal file
View File

@@ -0,0 +1,19 @@
## Dependencies
**/node_modules/
## Build output
**/dist/
## Cloudflare / Wrangler
**/.wrangler/
**/.cache/
## Local env / secrets
**/.dev.vars
**/.env
**/.env.*
## OS / editor
.DS_Store
Thumbs.db
.vscode/

1504
cf-nav-backend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,12 @@
{
"name": "cf-nav-backend",
"version": "1.0.0",
"private": true,
"scripts": {
"deploy": "wrangler deploy",
"dev": "wrangler dev"
},
"devDependencies": {
"wrangler": "^4.68.1"
}
}

309
cf-nav-backend/worker.js Normal file
View File

@@ -0,0 +1,309 @@
// Cloudflare Worker - 仅 API 后端(前后端分离)
// 部署到 cf-nav-backend前端静态资源由 Cloudflare Pages 托管
const corsHeaders = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
};
function verifyAuth(request, env) {
const auth = request.headers.get('Authorization');
const token = (env.ADMIN_PASSWORD || env.ADMIN_TOKEN || '').trim();
if (!token) return false;
const prefix = 'Bearer ';
if (!auth || !auth.startsWith(prefix)) return false;
const provided = auth.slice(prefix.length).trim();
return provided === token;
}
export default {
async fetch(request, env) {
if (request.method === 'OPTIONS') {
return new Response(null, { headers: corsHeaders });
}
const url = new URL(request.url);
const path = url.pathname;
try {
if (path === '/api/sites') {
return handleSites(request, env, corsHeaders);
}
if (path === '/api/auth/check') {
return handleAuthCheck(request, env, corsHeaders);
}
if (path.match(/^\/api\/sites\/[^/]+\/click$/)) {
const id = path.split('/')[3];
return handleSiteClick(request, env, id, corsHeaders);
}
if (path.startsWith('/api/sites/')) {
const id = path.split('/')[3];
return handleSite(request, env, id, corsHeaders);
}
if (path === '/api/categories') {
return handleCategories(request, env, corsHeaders);
}
if (path.startsWith('/api/categories/')) {
const name = decodeURIComponent(path.split('/')[3] || '');
return handleCategory(request, env, name, corsHeaders);
}
if (path === '/api/favicon') {
return handleFavicon(request, env, corsHeaders);
}
return new Response('Not Found', { status: 404 });
} catch (error) {
return new Response('Internal Server Error: ' + error.message, {
status: 500,
headers: corsHeaders,
});
}
},
};
/** 校验管理员 token用于前端进入后台时确认链接有效 */
async function handleAuthCheck(request, env, corsHeaders) {
if (request.method !== 'GET') {
return new Response('Method not allowed', { status: 405, headers: corsHeaders });
}
const token = (env.ADMIN_PASSWORD || env.ADMIN_TOKEN || '').trim();
if (!token) {
return new Response(JSON.stringify({ ok: false }), {
status: 401,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
const auth = request.headers.get('Authorization');
const prefix = 'Bearer ';
if (!auth || !auth.startsWith(prefix)) {
return new Response(JSON.stringify({ ok: false }), {
status: 401,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
const provided = auth.slice(prefix.length).trim();
if (provided !== token) {
return new Response(JSON.stringify({ ok: false }), {
status: 401,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
return new Response(JSON.stringify({ ok: true }), {
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
async function handleFavicon(request, env, corsHeaders) {
const url = new URL(request.url);
const domain = url.searchParams.get('domain');
if (!domain) {
return new Response('Missing domain parameter', { status: 400, headers: corsHeaders });
}
const faviconApi = env.FAVICON_API || env.FAVICON;
if (faviconApi && faviconApi !== '') {
const targetUrl = domain.startsWith('http') ? domain : `https://${domain}`;
try {
const res = await fetch(faviconApi + encodeURIComponent(targetUrl), {
headers: { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36' },
cf: { cacheTtl: 86400, cacheEverything: true },
});
if (res.ok) {
return new Response(res.body, {
status: res.status,
headers: {
...corsHeaders,
'Content-Type': res.headers.get('Content-Type') || 'image/x-icon',
'Cache-Control': 'public, max-age=86400',
},
});
}
} catch (_) {
/* 外置 API 失败时回退到内置源 */
}
}
const faviconSources = [
`https://www.google.com/s2/favicons?domain=${domain}&sz=64`,
`https://icons.duckduckgo.com/ip3/${domain}.ico`,
`https://favicon.api.shumengya.top/${domain}`,
];
for (const source of faviconSources) {
try {
const response = await fetch(source, {
headers: { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36' },
cf: { cacheTtl: 86400, cacheEverything: true },
});
if (response.ok) {
return new Response(response.body, {
status: response.status,
headers: {
...corsHeaders,
'Content-Type': response.headers.get('Content-Type') || 'image/x-icon',
'Cache-Control': 'public, max-age=86400',
},
});
}
} catch (_) {
continue;
}
}
return new Response('Favicon not found', { status: 404, headers: corsHeaders });
}
async function handleSites(request, env, corsHeaders) {
if (request.method === 'GET') {
const sitesData = (await env.NAV_KV.get('sites', { type: 'json' })) || [];
const normalized = sitesData.map((s) => ({ ...s, clicks: typeof s.clicks === 'number' ? s.clicks : 0 }));
return new Response(JSON.stringify(normalized), {
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
if (request.method === 'POST') {
if (!verifyAuth(request, env)) {
return new Response('Unauthorized', { status: 401, headers: corsHeaders });
}
const newSite = await request.json();
const sites = (await env.NAV_KV.get('sites', { type: 'json' })) || [];
const categories = (await env.NAV_KV.get('categories', { type: 'json' })) || [];
newSite.id = Date.now().toString();
newSite.clicks = 0;
sites.push(newSite);
if (newSite.category && !categories.includes(newSite.category)) {
categories.push(newSite.category);
await env.NAV_KV.put('categories', JSON.stringify(categories));
}
await env.NAV_KV.put('sites', JSON.stringify(sites));
return new Response(JSON.stringify(newSite), {
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
return new Response('Method not allowed', { status: 405, headers: corsHeaders });
}
async function handleSite(request, env, id, corsHeaders) {
const sites = (await env.NAV_KV.get('sites', { type: 'json' })) || [];
const categories = (await env.NAV_KV.get('categories', { type: 'json' })) || [];
if (request.method === 'GET') {
const site = sites.find((s) => s.id === id);
if (!site) return new Response('Not Found', { status: 404, headers: corsHeaders });
const normalized = { ...site, clicks: typeof site.clicks === 'number' ? site.clicks : 0 };
return new Response(JSON.stringify(normalized), {
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
if (request.method === 'PUT') {
if (!verifyAuth(request, env)) {
return new Response('Unauthorized', { status: 401, headers: corsHeaders });
}
const updatedSite = await request.json();
const index = sites.findIndex((s) => s.id === id);
if (index === -1) return new Response('Not Found', { status: 404, headers: corsHeaders });
const existingClicks = typeof sites[index].clicks === 'number' ? sites[index].clicks : 0;
sites[index] = { ...sites[index], ...updatedSite, id, clicks: existingClicks };
if (updatedSite.category && !categories.includes(updatedSite.category)) {
categories.push(updatedSite.category);
await env.NAV_KV.put('categories', JSON.stringify(categories));
}
await env.NAV_KV.put('sites', JSON.stringify(sites));
return new Response(JSON.stringify(sites[index]), {
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
if (request.method === 'DELETE') {
if (!verifyAuth(request, env)) {
return new Response('Unauthorized', { status: 401, headers: corsHeaders });
}
const index = sites.findIndex((s) => s.id === id);
if (index === -1) return new Response('Not Found', { status: 404, headers: corsHeaders });
sites.splice(index, 1);
await env.NAV_KV.put('sites', JSON.stringify(sites));
return new Response(JSON.stringify({ success: true }), {
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
return new Response('Method not allowed', { status: 405, headers: corsHeaders });
}
async function handleSiteClick(request, env, id, corsHeaders) {
if (request.method !== 'POST') {
return new Response('Method not allowed', { status: 405, headers: corsHeaders });
}
const sites = (await env.NAV_KV.get('sites', { type: 'json' })) || [];
const index = sites.findIndex((s) => s.id === id);
if (index === -1) {
return new Response('Not Found', { status: 404, headers: corsHeaders });
}
const site = sites[index];
const prev = typeof site.clicks === 'number' ? site.clicks : 0;
sites[index] = { ...site, clicks: prev + 1 };
await env.NAV_KV.put('sites', JSON.stringify(sites));
return new Response(JSON.stringify({ success: true, clicks: prev + 1 }), {
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
async function handleCategories(request, env, corsHeaders) {
if (request.method === 'GET') {
let categories = (await env.NAV_KV.get('categories', { type: 'json' })) || [];
if (!categories.length) {
const sites = (await env.NAV_KV.get('sites', { type: 'json' })) || [];
categories = [...new Set(sites.map((s) => s.category).filter(Boolean))];
if (categories.length) await env.NAV_KV.put('categories', JSON.stringify(categories));
}
return new Response(JSON.stringify(categories), {
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
if (request.method === 'POST') {
if (!verifyAuth(request, env)) {
return new Response('Unauthorized', { status: 401, headers: corsHeaders });
}
const { name } = await request.json();
if (!name || !name.trim()) {
return new Response('Bad Request', { status: 400, headers: corsHeaders });
}
const categories = (await env.NAV_KV.get('categories', { type: 'json' })) || [];
if (!categories.includes(name.trim())) {
categories.push(name.trim());
await env.NAV_KV.put('categories', JSON.stringify(categories));
}
return new Response(JSON.stringify({ success: true }), {
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
return new Response('Method not allowed', { status: 405, headers: corsHeaders });
}
async function handleCategory(request, env, name, corsHeaders) {
if (!name) return new Response('Bad Request', { status: 400, headers: corsHeaders });
if (!verifyAuth(request, env)) {
return new Response('Unauthorized', { status: 401, headers: corsHeaders });
}
const categories = (await env.NAV_KV.get('categories', { type: 'json' })) || [];
if (request.method === 'DELETE') {
const filtered = categories.filter((c) => c !== name);
await env.NAV_KV.put('categories', JSON.stringify(filtered));
return new Response(JSON.stringify({ success: true }), {
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
if (request.method === 'PUT') {
const body = await request.json();
const newName = (body?.name || '').trim();
if (!newName) return new Response('Bad Request', { status: 400, headers: corsHeaders });
const updated = categories.map((c) => (c === name ? newName : c));
const unique = Array.from(new Set(updated));
await env.NAV_KV.put('categories', JSON.stringify(unique));
const sites = (await env.NAV_KV.get('sites', { type: 'json' })) || [];
const updatedSites = sites.map((site) =>
site.category === name ? { ...site, category: newName } : site
);
await env.NAV_KV.put('sites', JSON.stringify(updatedSites));
return new Response(JSON.stringify({ success: true }), {
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
return new Response('Method not allowed', { status: 405, headers: corsHeaders });
}

View File

@@ -0,0 +1,15 @@
name = "cf-nav-backend"
main = "worker.js"
compatibility_date = "2026-01-01"
# KV 命名空间(与原先 cf-nav 使用同一套数据可复用同一 id
[[kv_namespaces]]
binding = "NAV_KV"
id = "a89f429e1a684d2084eae8619755ee11"
# 管理令牌/密码:与前端 config.js 的 ADMIN_TOKEN 一致;请求头带 Authorization: Bearer <本值> 才能写数据(也可用 wrangler secret put ADMIN_PASSWORD 设置)
[vars]
ADMIN_PASSWORD = "shumengya5201314"
FAVICON_API = "https://cf-favicon.pages.dev/api/favicon?url="

82
cf-nav-frontend/README.md Normal file
View File

@@ -0,0 +1,82 @@
# 萌芽导航 - 前后端分离版
- **前端**:静态 PWA部署到 **Cloudflare Pages**
- **后端**API Worker部署到 **Cloudflare Workers**(目录 `cf-nav-backend`
## 一、后端Cloudflare Worker
1. 进入后端目录并安装依赖、部署:
```bash
cd cf-nav-backend
npm install
npm run deploy
```
2. 修改 `cf-nav-backend/wrangler.toml`
- 如需新 KV`wrangler kv:namespace create "NAV_KV"`,将返回的 `id` 填入 `[[kv_namespaces]]``id`
- 修改 `[vars]` 中的 `ADMIN_PASSWORD`,或使用 `wrangler secret put ADMIN_PASSWORD` 设置密钥
3. 部署成功后记下 Worker 地址,例如:
`https://cf-nav-backend.你的子域.workers.dev`
## 二、前端Cloudflare Pages
1. **配置 API 地址**
编辑项目根目录下的 `config.js`,将 `window.API_BASE` 改为你的后端 Worker 地址,例如:
```javascript
window.API_BASE = 'https://cf-nav-backend.你的子域.workers.dev';
```
2. **部署到 Pages**
- **方式 A - Git 推送**
- 在 Cloudflare Dashboard → Pages → 创建项目 → 连接 Git
- 构建:**无**(或留空)
- 输出目录:`/`(根目录)
- 部署后前端即可通过你的 `*.pages.dev` 或自定义域名访问
- **方式 B - 直接上传**
- Pages → 创建项目 → 直接上传
- 将当前仓库根目录下所有前端文件(含 `config.js``index.html``app.js``admin.html``admin.js``styles.css``sw.js``manifest.webmanifest``logo.png``favicon.ico``offline.html``_redirects`)打包上传
3. **本地预览**
```bash
npm install
npm run dev
```
浏览器打开 `http://localhost:3000`。本地未配置同源 API 时,需在 `config.js` 中填写后端 Worker 地址才能正常请求数据。
## 三、目录结构说明
```
mengya-nav/
├── index.html # 首页
├── admin.html # 后台
├── app.js # 首页逻辑
├── admin.js # 后台逻辑
├── config.js # API 地址(部署前必改)
├── styles.css
├── sw.js # PWA Service Worker
├── manifest.webmanifest
├── offline.html
├── logo.png / favicon.ico
├── _redirects # 后台请直接访问 /admin.html勿用 /admin 避免重定向)
├── package.json # 前端脚本
└── cf-nav-backend/ # 后端 Worker
├── worker.js # 仅 API无静态资源
├── wrangler.toml
└── package.json
```
## 四、CORS 与安全
- 后端 Worker 已设置 `Access-Control-Allow-Origin: *`Pages 域名可正常请求。
- 生产环境建议在 Worker 中把 `Access-Control-Allow-Origin` 改为你的 Pages 域名,并务必修改/保管好 `ADMIN_PASSWORD`
## 五、数据迁移
若之前已用旧版单 Worker 部署并使用了 KV只需在 `cf-nav-backend/wrangler.toml` 中沿用原来的 KV namespace `id`,即可继续使用同一份数据,无需迁移。

View File

@@ -0,0 +1 @@
# 后台请直接访问 /admin.html?token=你的令牌(不要配置 /admin 或 /admin.html 的重定向,避免循环)

417
cf-nav-frontend/admin.html Normal file
View File

@@ -0,0 +1,417 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="theme-color" content="#10b981">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="default">
<meta name="apple-mobile-web-app-title" content="萌芽导航管理">
<title>萌芽导航-后台管理</title>
<link rel="manifest" href="/manifest.webmanifest">
<link rel="icon" href="/favicon.ico" sizes="any">
<link rel="icon" href="/logo.png" type="image/png">
<link rel="apple-touch-icon" href="/logo.png">
<link rel="stylesheet" href="styles.css">
<script src="config.js"></script>
<script src="apply-config.js"></script>
<style>
.login-container {
max-width: 480px;
margin: 80px auto;
padding: 32px;
background: white;
border-radius: 12px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
text-align: center;
}
.no-permission h2 {
color: #64748b;
margin-bottom: 12px;
}
.no-permission p {
color: #94a3b8;
font-size: 0.9rem;
}
.admin-container {
display: none;
}
.admin-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
padding: 15px 20px;
background: white;
border-radius: 10px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.08);
}
.logout-btn {
background: #dc2626;
color: white;
border: none;
padding: 8px 16px;
border-radius: 50px;
cursor: pointer;
font-weight: 600;
font-size: 0.9rem;
}
.logout-btn:hover {
background: #b91c1c;
}
.exit-btn {
background: #64748b;
color: white;
border: none;
padding: 8px 16px;
border-radius: 50px;
cursor: pointer;
font-weight: 600;
font-size: 0.9rem;
}
.exit-btn:hover {
background: #475569;
}
.sites-table {
background: white;
border-radius: 10px;
padding: 20px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.08);
overflow-x: auto;
}
table {
width: 100%;
border-collapse: collapse;
}
th, td {
padding: 12px;
text-align: left;
border-bottom: 1px solid #f1f5f9;
}
th {
background: #f8fafc;
font-weight: 600;
color: #1e293b;
}
.action-btns {
display: flex;
gap: 8px;
}
.btn-edit, .btn-delete {
padding: 6px 12px;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 0.85rem;
font-weight: 500;
}
.btn-edit {
background: #3b82f6;
color: white;
}
.btn-edit:hover {
background: #2563eb;
}
.btn-delete {
background: #ef4444;
color: white;
}
.btn-delete:hover {
background: #dc2626;
}
.modal-overlay {
display: none;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 9999;
align-items: center;
justify-content: center;
}
.modal-overlay.active {
display: flex;
}
.error-message {
color: #dc2626;
margin-top: 10px;
font-size: 0.9rem;
}
.success-message {
color: #059669;
margin-top: 10px;
font-size: 0.9rem;
}
.tag-display {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.tag-display .tag {
background: #e0e7ff;
color: #4338ca;
padding: 2px 8px;
border-radius: 10px;
font-size: 0.75rem;
}
.categories-panel {
background: white;
border-radius: 10px;
padding: 16px 20px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.08);
margin-bottom: 16px;
}
.categories-panel .panel-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
}
.category-add {
display: flex;
align-items: center;
gap: 8px;
}
.category-add input {
padding: 8px 10px;
border: 1px solid #e2e8f0;
border-radius: 8px;
font-size: 0.9rem;
min-width: 160px;
}
.category-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.category-item {
display: inline-flex;
align-items: center;
gap: 6px;
background: #f8fafc;
border: 1px solid #e2e8f0;
padding: 6px 10px;
border-radius: 20px;
font-size: 0.85rem;
}
.category-item .category-actions button {
border: none;
background: transparent;
color: #475569;
cursor: pointer;
font-size: 0.85rem;
padding: 0 2px;
}
.category-item .category-actions button:hover {
color: #1d4ed8;
}
.sites-table-header {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 12px;
margin-bottom: 15px;
}
.sites-table-header h2 {
margin: 0;
}
.sites-filter {
display: flex;
align-items: center;
gap: 8px;
}
.sites-filter label {
font-size: 0.9rem;
color: #64748b;
}
#site-category-filter {
padding: 6px 12px;
border: 1px solid #e2e8f0;
border-radius: 8px;
font-size: 0.9rem;
min-width: 160px;
background: white;
cursor: pointer;
}
#site-category-filter:focus {
outline: none;
border-color: #3b82f6;
}
</style>
</head>
<body>
<div class="container">
<!-- 无 token 或 token 错误时显示 -->
<div class="login-container" id="no-permission">
<h2>⚠️ 无法进入后台</h2>
<p>请使用带 token 的链接访问,例如:</p>
<p style="margin-top: 8px; word-break: break-all; color: #1e293b; font-size: 0.85rem;"><strong>/admin.html?token=管理员密钥</strong></p>
<p style="margin-top: 16px;">链接中的 token 需为后端 Worker 配置的管理员密钥ADMIN_PASSWORD</p>
</div>
<!-- 管理界面(仅 token 正确时显示) -->
<div class="admin-container" id="admin-container">
<div class="admin-header">
<h1 id="admin-title">萌芽导航-管理后台</h1>
<button class="exit-btn" id="logout-btn">退出</button>
</div>
<div class="categories-panel">
<div class="panel-header">
<h2>📁 分类管理</h2>
<div class="category-add">
<input type="text" id="new-category-name" placeholder="新增分类">
<button class="btn-edit" id="add-category-btn">添加分类</button>
</div>
</div>
<div class="category-list" id="category-list">
<!-- 分类标签 -->
</div>
</div>
<div style="margin-bottom: 20px;">
<button class="add-site-btn" id="add-new-site">✚ 添加新网站</button>
</div>
<div class="sites-table">
<div class="sites-table-header">
<h2>网站列表</h2>
<div class="sites-filter">
<label for="site-category-filter">按分类筛选:</label>
<select id="site-category-filter" title="选择分类可快速筛选网站">
<option value="">全部</option>
</select>
</div>
</div>
<table id="sites-table">
<thead>
<tr>
<th>名称</th>
<th>URL</th>
<th>分类</th>
<th>描述</th>
<th>标签</th>
<th>操作</th>
</tr>
</thead>
<tbody id="sites-tbody">
<!-- 动态加载 -->
</tbody>
</table>
</div>
</div>
<!-- 编辑/添加网站模态框 -->
<div class="modal-overlay" id="edit-modal">
<div class="modal-content">
<div class="modal-header">
<h2 id="modal-title">添加网站</h2>
<button class="close-modal" id="close-edit-modal">&times;</button>
</div>
<div class="modal-body">
<form id="edit-site-form">
<input type="hidden" id="edit-site-id">
<div class="form-group">
<label for="edit-site-name">网站名称 *</label>
<input type="text" id="edit-site-name" required>
</div>
<div class="form-group">
<label for="edit-site-url">网站地址 *</label>
<input type="url" id="edit-site-url" required>
</div>
<div class="form-group">
<label for="edit-site-description">网站描述</label>
<textarea id="edit-site-description"></textarea>
</div>
<div class="form-row">
<div class="form-group">
<label for="edit-site-category">网站分类 *</label>
<select id="edit-site-category" required>
<option value="">选择分类</option>
<option value="常用">常用</option>
<option value="工作">工作</option>
<option value="学习">学习</option>
<option value="娱乐">娱乐</option>
<option value="社交">社交</option>
<option value="工具">工具</option>
<option value="新闻">新闻</option>
<option value="购物">购物</option>
<option value="其他">其他</option>
</select>
</div>
<div class="form-group">
<label for="edit-site-tags">网站标签</label>
<input type="text" id="edit-site-tags" placeholder="用逗号分隔">
</div>
</div>
<button type="submit" class="submit-btn">保存</button>
</form>
</div>
</div>
</div>
<div class="toast" id="toast">
<span class="toast-icon"></span>
<span class="toast-message">操作成功</span>
</div>
</div>
<script src="admin.js"></script>
<script>
// 注册 Service Worker
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js')
.then(registration => {
console.log('✅ Service Worker 注册成功:', registration.scope);
})
.catch(error => {
console.error('❌ Service Worker 注册失败:', error);
});
});
}
</script>
</body>
</html>

420
cf-nav-frontend/admin.js Normal file
View File

@@ -0,0 +1,420 @@
// 后台管理 JavaScriptAPI 地址从 config.js 读取token 仅从 URL ?token= 传入并由后端校验)
const API_BASE = typeof window !== 'undefined' && window.API_BASE !== undefined ? window.API_BASE : '';
// 从 URL 读取 token不在前端校验直接交给后端无 token 则不显示后台
const urlParams = new URLSearchParams(typeof location !== 'undefined' ? location.search : '');
const authToken = urlParams.get('token') || null;
let categories = [];
let allSites = [];
// 显示提示消息
function showToast(message, type = 'success') {
const toast = document.getElementById('toast');
toast.className = `toast ${type} show`;
document.querySelector('.toast-message').textContent = message;
setTimeout(() => {
toast.classList.remove('show');
}, 3000);
}
// 显示无权限提示(无 token 或 token 错误)
function showNoPermission() {
const noPerm = document.getElementById('no-permission');
const adminEl = document.getElementById('admin-container');
if (noPerm) noPerm.style.display = 'block';
if (adminEl) adminEl.style.display = 'none';
}
// 退出:跳转到当前页不带 query下次进入需重新带 token
document.getElementById('logout-btn').addEventListener('click', () => {
if (typeof location !== 'undefined') location.href = location.pathname;
});
// 显示管理面板(仅 token 正确时)
function showAdminPanel() {
const noPerm = document.getElementById('no-permission');
const adminEl = document.getElementById('admin-container');
if (noPerm) noPerm.style.display = 'none';
if (adminEl) adminEl.style.display = 'block';
loadSites();
loadCategories();
}
// 进入页时先向后端校验 token通过才显示后台
async function initAdmin() {
if (!authToken) {
showNoPermission();
return;
}
try {
const response = await fetch(`${API_BASE}/api/auth/check`, {
headers: { 'Authorization': `Bearer ${authToken}` }
});
if (response.status !== 200) {
showNoPermission();
return;
}
const data = await response.json().catch(() => ({}));
if (!data || !data.ok) {
showNoPermission();
return;
}
showAdminPanel();
} catch (_) {
showNoPermission();
}
}
// 加载分类列表
async function loadCategories() {
try {
const response = await fetch(`${API_BASE}/api/categories`, {
headers: authToken ? { 'Authorization': `Bearer ${authToken}` } : {}
});
if (response.status === 401) {
showNoPermission();
return;
}
categories = response.ok ? await response.json() : [];
renderCategoryList();
updateCategorySelect();
updateSiteCategoryFilterOptions();
if (allSites.length) renderSites(allSites);
} catch (error) {
categories = [];
renderCategoryList();
}
}
// 渲染分类标签
function renderCategoryList() {
const container = document.getElementById('category-list');
container.innerHTML = '';
if (!categories.length) {
const empty = document.createElement('div');
empty.style.color = '#64748b';
empty.textContent = '暂无分类';
container.appendChild(empty);
return;
}
categories.forEach(name => {
const item = document.createElement('div');
item.className = 'category-item';
item.innerHTML = `
<span>${name}</span>
<span class="category-actions">
<button onclick="editCategory('${name.replace(/'/g, "\\'")}')">编辑</button>
<button onclick="deleteCategory('${name.replace(/'/g, "\\'")}')">删除</button>
</span>
`;
container.appendChild(item);
});
}
// 更新分类下拉
function updateCategorySelect(selectedValue = '') {
const select = document.getElementById('edit-site-category');
if (!select) {
return;
}
const options = ['默认'];
const merged = Array.from(new Set([...options, ...categories].filter(Boolean)));
select.innerHTML = '<option value="">选择分类</option>';
merged.forEach(name => {
const option = document.createElement('option');
option.value = name;
option.textContent = name;
if (name === selectedValue) {
option.selected = true;
}
select.appendChild(option);
});
}
// 更新「网站列表」上方的分类筛选下拉
function updateSiteCategoryFilterOptions() {
const select = document.getElementById('site-category-filter');
if (!select) return;
const current = select.value;
const fromSites = (allSites || []).map(s => s.category || '默认').filter(Boolean);
const combined = Array.from(new Set(['默认', ...categories, ...fromSites]));
select.innerHTML = '<option value="">全部</option>';
combined.forEach(name => {
const option = document.createElement('option');
option.value = name;
option.textContent = name;
select.appendChild(option);
});
select.value = current || '';
}
// 根据当前筛选条件渲染网站表格
function renderSites(sites) {
const tbody = document.getElementById('sites-tbody');
const filterEl = document.getElementById('site-category-filter');
const categoryFilter = filterEl ? filterEl.value : '';
const list = categoryFilter
? sites.filter(site => (site.category || '默认') === categoryFilter)
: sites;
tbody.innerHTML = '';
list.forEach(site => {
const tr = document.createElement('tr');
const tagsHtml = site.tags && site.tags.length > 0
? `<div class="tag-display">${site.tags.map(tag => `<span class="tag">${tag}</span>`).join('')}</div>`
: '-';
tr.innerHTML = `
<td><strong>${site.name}</strong></td>
<td><a href="${site.url}" target="_blank" style="color: #3b82f6;">${site.url}</a></td>
<td>${site.category}</td>
<td>${site.description || '-'}</td>
<td>${tagsHtml}</td>
<td>
<div class="action-btns">
<button class="btn-edit" onclick="editSite('${site.id}')">编辑</button>
<button class="btn-delete" onclick="deleteSite('${site.id}')">删除</button>
</div>
</td>
`;
tbody.appendChild(tr);
});
}
// 加载网站列表
async function loadSites() {
try {
const response = await fetch(`${API_BASE}/api/sites`, {
headers: authToken ? { 'Authorization': `Bearer ${authToken}` } : {}
});
if (response.status === 401) {
showNoPermission();
return;
}
const sites = await response.json();
allSites = sites || [];
updateSiteCategoryFilterOptions();
renderSites(allSites);
} catch (error) {
showToast('加载网站列表失败', 'error');
}
}
// 网站列表按分类筛选
const siteCategoryFilterEl = document.getElementById('site-category-filter');
if (siteCategoryFilterEl) {
siteCategoryFilterEl.addEventListener('change', () => {
renderSites(allSites);
});
}
// 添加新网站
document.getElementById('add-new-site').addEventListener('click', () => {
if (!authToken) return;
document.getElementById('modal-title').textContent = '添加新网站';
document.getElementById('edit-site-id').value = '';
document.getElementById('edit-site-form').reset();
updateCategorySelect();
document.getElementById('edit-modal').classList.add('active');
});
// 编辑网站
async function editSite(id) {
try {
const response = await fetch(`${API_BASE}/api/sites/${id}`);
const site = await response.json();
document.getElementById('modal-title').textContent = '编辑网站';
document.getElementById('edit-site-id').value = site.id;
document.getElementById('edit-site-name').value = site.name;
document.getElementById('edit-site-url').value = site.url;
document.getElementById('edit-site-description').value = site.description || '';
updateCategorySelect(site.category);
document.getElementById('edit-site-tags').value = site.tags ? site.tags.join(', ') : '';
document.getElementById('edit-modal').classList.add('active');
} catch (error) {
showToast('加载网站信息失败', 'error');
}
}
// 删除网站
async function deleteSite(id) {
if (!confirm('确定要删除这个网站吗?')) {
return;
}
try {
const response = await fetch(`${API_BASE}/api/sites/${id}`, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${authToken}`
}
});
if (response.ok) {
showToast('网站删除成功');
loadSites();
} else {
showToast('删除失败', 'error');
}
} catch (error) {
showToast('删除请求失败', 'error');
}
}
// 保存网站(添加或更新)
document.getElementById('edit-site-form').addEventListener('submit', async (e) => {
e.preventDefault();
const id = document.getElementById('edit-site-id').value;
const site = {
name: document.getElementById('edit-site-name').value.trim(),
url: document.getElementById('edit-site-url').value.trim(),
description: document.getElementById('edit-site-description').value.trim(),
category: document.getElementById('edit-site-category').value,
tags: document.getElementById('edit-site-tags').value
.split(',')
.map(tag => tag.trim())
.filter(tag => tag)
};
// 验证URL
if (!site.url.startsWith('http://') && !site.url.startsWith('https://')) {
site.url = 'https://' + site.url;
}
try {
const url = id ? `${API_BASE}/api/sites/${id}` : `${API_BASE}/api/sites`;
const method = id ? 'PUT' : 'POST';
const response = await fetch(url, {
method,
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${authToken}`
},
body: JSON.stringify(site)
});
if (response.ok) {
showToast(id ? '网站更新成功' : '网站添加成功');
document.getElementById('edit-modal').classList.remove('active');
loadSites();
} else {
showToast('保存失败', 'error');
}
} catch (error) {
showToast('保存请求失败', 'error');
}
});
// 关闭模态框
document.getElementById('close-edit-modal').addEventListener('click', () => {
document.getElementById('edit-modal').classList.remove('active');
});
document.getElementById('edit-modal').addEventListener('click', (e) => {
if (e.target.id === 'edit-modal') {
document.getElementById('edit-modal').classList.remove('active');
}
});
// 添加分类
document.getElementById('add-category-btn').addEventListener('click', async () => {
const input = document.getElementById('new-category-name');
const name = input.value.trim();
if (!name) {
return;
}
try {
const response = await fetch(`${API_BASE}/api/categories`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${authToken}`
},
body: JSON.stringify({ name })
});
if (response.ok) {
input.value = '';
await loadCategories();
showToast('分类添加成功');
} else {
showToast('分类添加失败', 'error');
}
} catch (error) {
showToast('分类添加失败', 'error');
}
});
// 编辑分类
async function editCategory(oldName) {
const newName = prompt('请输入新的分类名称', oldName);
if (!newName || newName.trim() === oldName) {
return;
}
try {
const response = await fetch(`${API_BASE}/api/categories/${encodeURIComponent(oldName)}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${authToken}`
},
body: JSON.stringify({ name: newName.trim() })
});
if (response.ok) {
await loadCategories();
await loadSites();
showToast('分类更新成功');
} else {
showToast('分类更新失败', 'error');
}
} catch (error) {
showToast('分类更新失败', 'error');
}
}
// 删除分类
async function deleteCategory(name) {
if (!confirm(`确定删除分类「${name}」吗?`)) {
return;
}
try {
const response = await fetch(`${API_BASE}/api/categories/${encodeURIComponent(name)}`, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${authToken}`
}
});
if (response.ok) {
await loadCategories();
showToast('分类删除成功');
} else {
showToast('分类删除失败', 'error');
}
} catch (error) {
showToast('分类删除失败', 'error');
}
}
// 初始化
initAdmin();

498
cf-nav-frontend/app.js Normal file
View File

@@ -0,0 +1,498 @@
// API 配置(从 config.js 读取,部署 Pages 时请设置后端 Worker 地址)
const API_BASE = typeof window !== 'undefined' && window.API_BASE !== undefined ? window.API_BASE : '';
// 初始数据(示例)
let sites = [
{
id: '1',
name: '百度',
url: 'https://www.baidu.com',
description: '全球最大的中文搜索引擎',
category: '常用',
tags: ['搜索', '中文']
},
{
id: '2',
name: '知乎',
url: 'https://www.zhihu.com',
description: '中文互联网高质量的问答社区',
category: '社交',
tags: ['问答', '知识']
},
{
id: '3',
name: 'GitHub',
url: 'https://github.com',
description: '全球最大的代码托管平台',
category: '工作',
tags: ['代码', '开发']
},
{
id: '4',
name: 'Bilibili',
url: 'https://www.bilibili.com',
description: '中国年轻世代高度聚集的文化社区',
category: '娱乐',
tags: ['视频', '弹幕']
},
{
id: '5',
name: '淘宝',
url: 'https://www.taobao.com',
description: '亚洲最大的购物网站',
category: '购物',
tags: ['电商', '购物']
}
];
let deferredInstallPrompt = null;
let installBtn = null;
let hasRefreshing = false;
function isStandaloneMode() {
return window.matchMedia('(display-mode: standalone)').matches || window.navigator.standalone === true;
}
function createInstallButton() {
if (installBtn) {
return installBtn;
}
installBtn = document.createElement('button');
installBtn.className = 'install-btn';
installBtn.type = 'button';
installBtn.textContent = '📲 安装应用';
installBtn.style.cssText = `
position: fixed;
bottom: 20px;
right: 20px;
background: linear-gradient(135deg, #10b981, #059669);
color: #fff;
border: none;
padding: 12px 24px;
border-radius: 50px;
cursor: pointer;
font-weight: 600;
font-size: 0.95rem;
box-shadow: 0 4px 15px rgba(16, 185, 129, 0.3);
display: none;
z-index: 1000;
transition: all 0.2s ease;
`;
installBtn.addEventListener('mouseenter', () => {
installBtn.style.transform = 'translateY(-2px)';
installBtn.style.boxShadow = '0 6px 20px rgba(16, 185, 129, 0.4)';
});
installBtn.addEventListener('mouseleave', () => {
installBtn.style.transform = 'translateY(0)';
installBtn.style.boxShadow = '0 4px 15px rgba(16, 185, 129, 0.3)';
});
installBtn.addEventListener('click', async () => {
if (!deferredInstallPrompt) {
return;
}
deferredInstallPrompt.prompt();
const choice = await deferredInstallPrompt.userChoice;
if (choice.outcome === 'accepted') {
showToast('已触发安装流程');
}
deferredInstallPrompt = null;
installBtn.style.display = 'none';
});
document.body.appendChild(installBtn);
return installBtn;
}
function initInstallPrompt() {
const button = createInstallButton();
if (isStandaloneMode()) {
button.style.display = 'none';
return;
}
window.addEventListener('beforeinstallprompt', (event) => {
event.preventDefault();
deferredInstallPrompt = event;
button.style.display = 'block';
});
window.addEventListener('appinstalled', () => {
deferredInstallPrompt = null;
button.style.display = 'none';
showToast('应用安装成功');
});
}
async function registerServiceWorker() {
if (!('serviceWorker' in navigator)) {
return;
}
try {
const registration = await navigator.serviceWorker.register('/sw.js', { scope: '/' });
if (registration.waiting) {
promptRefresh(registration.waiting);
}
registration.addEventListener('updatefound', () => {
const newWorker = registration.installing;
if (!newWorker) {
return;
}
newWorker.addEventListener('statechange', () => {
if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
promptRefresh(newWorker);
}
});
});
navigator.serviceWorker.addEventListener('controllerchange', () => {
if (hasRefreshing) {
return;
}
hasRefreshing = true;
window.location.reload();
});
} catch (error) {
console.error('Service Worker 注册失败:', error);
}
}
function promptRefresh(worker) {
const shouldRefresh = window.confirm('发现新版本,是否立即刷新?');
if (shouldRefresh) {
worker.postMessage('SKIP_WAITING');
}
}
// 加载网站数据
async function loadSites() {
try {
const response = await fetch(`${API_BASE}/api/sites`);
if (response.ok) {
sites = await response.json();
updateStats();
renderSites();
}
} catch (error) {
console.error('加载网站数据失败:', error);
// 使用默认数据
updateStats();
renderSites();
}
}
// 更新统计信息
function updateStats() {
const totalSites = sites.length;
const categories = [...new Set(sites.map(site => site.category))];
const allTags = sites.flatMap(site => site.tags || []);
const uniqueTags = [...new Set(allTags)];
document.getElementById('total-sites').textContent = totalSites;
document.getElementById('total-categories').textContent = categories.length;
document.getElementById('total-tags').textContent = uniqueTags.length;
// 更新分类过滤器
updateCategoryFilters();
}
// 更新分类过滤器
function updateCategoryFilters() {
const categoryFilters = document.getElementById('category-filters');
const categories = ['all', ...new Set(sites.map(site => site.category))];
categoryFilters.innerHTML = '';
categories.forEach(category => {
const filterBtn = document.createElement('div');
filterBtn.className = 'category-filter';
filterBtn.textContent = category === 'all' ? '全部' : category;
filterBtn.dataset.category = category;
if (category === 'all') {
filterBtn.classList.add('active');
}
filterBtn.addEventListener('click', () => {
document.querySelectorAll('.category-filter').forEach(btn => {
btn.classList.remove('active');
});
filterBtn.classList.add('active');
filterSites();
closeCategorySidebar();
});
categoryFilters.appendChild(filterBtn);
});
}
// 渲染网站
function renderSites(filteredSites = null) {
const container = document.getElementById('categories-container');
const searchInput = document.getElementById('search-input').value.toLowerCase();
const activeCategory = document.querySelector('.category-filter.active').dataset.category;
// 如果没有传入过滤后的网站,则使用全部网站
const sitesToRender = filteredSites || sites;
// 如果有搜索关键词,进一步过滤
let finalSites = sitesToRender;
if (searchInput) {
finalSites = sitesToRender.filter(site =>
site.name.toLowerCase().includes(searchInput) ||
(site.description && site.description.toLowerCase().includes(searchInput)) ||
(site.tags && site.tags.some(tag => tag.toLowerCase().includes(searchInput)))
);
}
// 如果有分类过滤
if (activeCategory !== 'all') {
finalSites = finalSites.filter(site => site.category === activeCategory);
}
// 按分类分组
const sitesByCategory = {};
finalSites.forEach(site => {
if (!sitesByCategory[site.category]) {
sitesByCategory[site.category] = [];
}
sitesByCategory[site.category].push(site);
});
// 如果没有网站匹配
if (Object.keys(sitesByCategory).length === 0) {
container.innerHTML = `
<div class="empty-state">
<div style="font-size: 4rem; margin-bottom: 15px;">🔍</div>
<h2>没有找到匹配的网站</h2>
<p>尝试调整搜索关键词或分类筛选条件</p>
</div>
`;
return;
}
// 渲染分类区块
container.innerHTML = '';
Object.keys(sitesByCategory).sort().forEach(category => {
const categorySection = document.createElement('div');
categorySection.className = 'category-section';
categorySection.innerHTML = `
<h2 class="category-title">${category}</h2>
<div class="sites-grid" id="category-${category.replace(/\s+/g, '-')}">
${sitesByCategory[category].map(site => createSiteCard(site)).join('')}
</div>
`;
container.appendChild(categorySection);
});
// 添加点击事件
document.querySelectorAll('.site-card').forEach(card => {
card.addEventListener('click', (e) => {
if (!e.target.closest('.site-icon') && !e.target.closest('img')) {
window.open(card.dataset.url, '_blank');
}
});
});
}
// 通过 Worker 代理获取 favicon
function getFaviconUrl(domain) {
return `${API_BASE}/api/favicon?domain=${domain}`;
}
// 生成favicon HTML使用 Worker 代理
function generateFaviconHtml(domain, firstLetter) {
const faviconUrl = getFaviconUrl(domain);
// Worker 会自动尝试多个源,失败时显示占位符
const onerrorCode = `this.parentElement.innerHTML='<div class=\\'favicon-placeholder\\'>${firstLetter}</div>';`;
return {
src: faviconUrl,
onerror: onerrorCode
};
}
// 创建网站卡片HTML
function createSiteCard(site) {
// 从URL提取域名用于获取favicon
const domain = new URL(site.url).hostname.replace('www.', '');
// 生成网站名称首字母作为备用图标
const firstLetter = site.name.charAt(0).toUpperCase();
// 获取favicon配置
const faviconConfig = generateFaviconHtml(domain, firstLetter);
// 处理标签
const tagsHtml = site.tags && site.tags.length > 0
? site.tags.map(tag => `<span class="tag">${tag}</span>`).join('')
: '';
// 处理标签文本(用于 title
const tagsText = site.tags && site.tags.length > 0
? site.tags.join('、')
: '';
const clickCount = typeof site.clicks === 'number' ? site.clicks : 0;
return `
<a href="${site.url}" target="_blank" class="site-card" data-url="${site.url}" data-site-id="${site.id}">
<span class="site-card-clicks" title="访问次数">${clickCount}</span>
<div class="site-icon">
<img
src="${faviconConfig.src}"
onerror="${faviconConfig.onerror}"
alt="${site.name}图标"
>
</div>
<div class="site-card-body">
<div class="site-name" title="${site.name}">${site.name}</div>
<div class="site-description" title="${site.description || ''}">${site.description || ''}</div>
</div>
<div class="site-tags" title="${tagsText}">${tagsHtml}</div>
</a>
`;
}
// 显示提示消息
function showToast(message, type = 'success') {
const toast = document.getElementById('toast');
toast.className = `toast ${type} show`;
document.querySelector('.toast-message').textContent = message;
setTimeout(() => {
toast.classList.remove('show');
}, 3000);
}
// 过滤网站(搜索和分类)
function filterSites() {
renderSites();
}
// 显示提示消息
function showToast(message, type = 'success') {
const toast = document.getElementById('toast');
toast.className = `toast ${type} show`;
document.querySelector('.toast-message').textContent = message;
setTimeout(() => {
toast.classList.remove('show');
}, 3000);
}
// 过滤网站(搜索和分类)
function filterSites() {
renderSites();
}
// 网页搜索处理
function performWebSearch(query, engine) {
const encodedQuery = encodeURIComponent(query);
let searchUrl = '';
switch (engine) {
case 'google':
searchUrl = `https://www.google.com/search?q=${encodedQuery}`;
break;
case 'baidu':
searchUrl = `https://www.baidu.com/s?wd=${encodedQuery}`;
break;
case 'bing':
searchUrl = `https://www.bing.com/search?q=${encodedQuery}`;
break;
case 'duckduckgo':
searchUrl = `https://duckduckgo.com/?q=${encodedQuery}`;
break;
case 'yandex':
searchUrl = `https://yandex.com/search/?text=${encodedQuery}`;
break;
default:
searchUrl = `https://www.google.com/search?q=${encodedQuery}`;
}
window.open(searchUrl, '_blank');
}
function openCategorySidebar() {
document.body.classList.add('category-sidebar-open');
const backdrop = document.getElementById('category-sidebar-backdrop');
if (backdrop) backdrop.setAttribute('aria-hidden', 'false');
}
function closeCategorySidebar() {
document.body.classList.remove('category-sidebar-open');
const backdrop = document.getElementById('category-sidebar-backdrop');
if (backdrop) backdrop.setAttribute('aria-hidden', 'true');
}
function initCategorySidebar() {
const toggle = document.getElementById('category-sidebar-toggle');
const closeBtn = document.getElementById('category-sidebar-close');
const backdrop = document.getElementById('category-sidebar-backdrop');
if (toggle) toggle.addEventListener('click', openCategorySidebar);
if (closeBtn) closeBtn.addEventListener('click', closeCategorySidebar);
if (backdrop) backdrop.addEventListener('click', closeCategorySidebar);
}
// 初始化
document.addEventListener('DOMContentLoaded', async () => {
initInstallPrompt();
initCategorySidebar();
// 点击卡片时上报访问次数(不阻止跳转)
const container = document.getElementById('categories-container');
if (container) {
container.addEventListener('click', (e) => {
const card = e.target.closest('.site-card');
if (!card) return;
const id = card.dataset.siteId;
if (id) {
const base = typeof window !== 'undefined' && window.API_BASE !== undefined ? window.API_BASE : '';
fetch(base + '/api/sites/' + id + '/click', { method: 'POST', keepalive: true }).catch(() => {});
}
});
}
// 注册 PWA Service Worker
await registerServiceWorker();
// 加载网站数据
await loadSites();
// 搜索输入
document.getElementById('search-input').addEventListener('input', filterSites);
// 网页搜索表单提交
const webSearchForm = document.getElementById('web-search-form');
if (webSearchForm) {
webSearchForm.addEventListener('submit', (e) => {
e.preventDefault();
const query = document.getElementById('web-search-input').value.trim();
const engine = document.getElementById('search-engine').value;
if (query) {
performWebSearch(query, engine);
document.getElementById('web-search-input').value = '';
}
});
}
});

View File

@@ -0,0 +1,89 @@
/**
* 根据 config.js 应用站点名称到页面标题、meta、标题元素并动态生成 PWA manifest
* 需在 config.js 之后加载
*/
(function () {
var name = window.SITE_NAME || '萌芽导航';
var shortName = window.SITE_SHORT_NAME || '萌芽';
var desc = window.SITE_DESCRIPTION || (name + ' - 轻量好用的网址导航');
document.title = name;
var metaDesc = document.querySelector('meta[name="description"]');
if (metaDesc) metaDesc.setAttribute('content', desc);
var metaApp = document.querySelector('meta[name="apple-mobile-web-app-title"]');
if (metaApp) metaApp.setAttribute('content', name);
var siteTitle = document.getElementById('site-title');
if (siteTitle) siteTitle.textContent = '\u2728 ' + name;
var adminTitle = document.getElementById('admin-title');
if (adminTitle) adminTitle.textContent = name + '-管理后台';
var offlineTitle = document.getElementById('offline-title');
if (offlineTitle) {
document.title = name + ' - 离线';
offlineTitle.textContent = name + ' - 离线';
}
var offlineLogo = document.getElementById('offline-logo');
if (offlineLogo) offlineLogo.textContent = shortName.charAt(0);
var glassOpacity = window.SITE_GLASS_OPACITY;
if (typeof glassOpacity === 'number' && glassOpacity >= 0 && glassOpacity <= 1) {
document.documentElement.style.setProperty('--site-glass-opacity', String(glassOpacity));
}
var isMobile = window.matchMedia('(max-width: 767px)').matches;
var bgImages = isMobile
? window.SITE_MOBILE_BACKGROUND_IMAGES
: window.SITE_DESKTOP_BACKGROUND_IMAGES;
if (!Array.isArray(bgImages) || bgImages.length === 0) {
bgImages = isMobile ? window.SITE_DESKTOP_BACKGROUND_IMAGES : window.SITE_MOBILE_BACKGROUND_IMAGES;
}
if (Array.isArray(bgImages) && bgImages.length > 0) {
var imgUrl = bgImages[Math.floor(Math.random() * bgImages.length)];
var applyBg = function () {
if (!document.body) return;
var bgEl = document.createElement('div');
bgEl.className = 'site-bg';
bgEl.setAttribute('aria-hidden', 'true');
bgEl.style.backgroundImage = 'url(' + imgUrl + ')';
document.body.insertBefore(bgEl, document.body.firstChild);
};
if (document.body) {
applyBg();
} else {
document.addEventListener('DOMContentLoaded', applyBg);
}
}
var manifest = {
name: name,
short_name: shortName,
description: desc,
lang: 'zh-CN',
dir: 'ltr',
id: '/',
start_url: '/?source=pwa',
scope: '/',
display: 'standalone',
display_override: ['standalone', 'minimal-ui', 'browser'],
orientation: 'portrait-primary',
theme_color: '#10b981',
background_color: '#f8fafc',
categories: ['productivity', 'utilities'],
prefer_related_applications: false,
icons: [
{ src: '/logo.png', type: 'image/png', sizes: '2048x2048', purpose: 'any' },
{ src: '/logo.png', type: 'image/png', sizes: '2048x2048', purpose: 'maskable' },
{ src: '/favicon.ico', type: 'image/x-icon', sizes: '16x16 24x24 32x32 48x48 64x64', purpose: 'any' }
]
};
var blob = new Blob([JSON.stringify(manifest)], { type: 'application/json' });
var url = URL.createObjectURL(blob);
var link = document.querySelector('link[rel="manifest"]');
if (link) link.href = url;
else {
link = document.createElement('link');
link.rel = 'manifest';
link.href = url;
document.head.appendChild(link);
}
})();

44
cf-nav-frontend/config.js Normal file
View File

@@ -0,0 +1,44 @@
/**
* 前端配置(前后端分离)
*
* API 地址:
* - 部署到 Cloudflare Pages 时改为你的 Worker 地址,如:
* API_BASE = 'https://cf-nav-backend.你的子域.workers.dev';
* - 本地同源或未配置时留空 ''
*
* 站点名称可改为「XX导航」等别人使用时改成自己的品牌
* - SITE_NAME: 完整名称用于标题、PWA 名称等
* - SITE_SHORT_NAME: 短名称,用于 PWA 桌面图标下方、离线页等
* - SITE_DESCRIPTION: 站点描述,用于 meta description 和 PWA
*/
window.API_BASE = 'https://cf-nav.api.smyhub.com';
// 站点名称配置(可改成你自己的导航站名)
window.SITE_NAME = '萌芽导航';
window.SITE_SHORT_NAME = '萌芽';
window.SITE_DESCRIPTION = '萌芽导航 - 轻量好用的网址导航';
// 网站背景图(全屏随机一张,带约 30% 高斯模糊)。设为 [] 或不配置则使用默认渐变背景
// 手机端用 SITE_MOBILE_BACKGROUND_IMAGES电脑端用 SITE_DESKTOP_BACKGROUND_IMAGES按视口宽度 768px 区分)
window.SITE_MOBILE_BACKGROUND_IMAGES = [
'https://image.smyhub.com/file/手机壁纸/女生/1772108123232_VJ86r.jpg',
'https://image.smyhub.com/file/手机壁纸/女生/1772108022800_f945774e0a45f7a4afdc3da2b112025f.png',
'https://image.smyhub.com/file/手机壁纸/女生/1772108024006_3f9030ba77e355869115bc90fe019d53.png',
"https://image.smyhub.com/file/手机壁纸/女生/1772108030393_159bbf61f88b38475ee9144a2e8e4956.png",
"https://image.smyhub.com/file/手机壁纸/女生/1772108021977_8020902a0c8788538eee1cd06e784c6a.png",
"https://image.smyhub.com/file/手机壁纸/女生/1772108021881_44374ab6c1daa54e0204bca48ac382f2.png",
];
window.SITE_DESKTOP_BACKGROUND_IMAGES = [
'https://image.smyhub.com/file/电脑壁纸/女生/cuSpSkq4.webp',
'https://image.smyhub.com/file/电脑壁纸/女生/5CrdoShv.webp',
'https://image.smyhub.com/file/电脑壁纸/女生/xTsVkCli.webp',
"https://image.smyhub.com/file/电脑壁纸/女生/ItOJOHST.webp",
"https://image.smyhub.com/file/电脑壁纸/女生/cUDkKiOf.webp",
"https://image.smyhub.com/file/电脑壁纸/女生/c2HxMuGK.webp",
"https://image.smyhub.com/file/电脑壁纸/女生/L0nQHehz.webp",
"https://image.smyhub.com/file/电脑壁纸/女生/hj64Cqxn.webp",
];
// 内容区块搜索框、分类块、网站卡片、顶栏的半透明背景透明度0=全透明 1=不透明,可自行修改
window.SITE_GLASS_OPACITY = 0.25;

BIN
cf-nav-frontend/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

View File

@@ -0,0 +1,85 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="萌芽导航 - 轻量好用的网址导航">
<meta name="theme-color" content="#10b981">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="default">
<meta name="apple-mobile-web-app-title" content="萌芽导航">
<title>萌芽导航</title>
<link rel="manifest" href="/manifest.webmanifest">
<link rel="icon" href="/favicon.ico" sizes="any">
<link rel="icon" href="/logo.png" type="image/png">
<link rel="apple-touch-icon" href="/logo.png">
<link rel="stylesheet" href="styles.css">
<script src="config.js"></script>
<script src="apply-config.js"></script>
</head>
<body>
<div class="container">
<header class="main-header">
<h1 id="site-title">萌芽导航</h1>
</header>
<div class="web-search-box">
<form id="web-search-form">
<div class="search-wrapper">
<span class="search-icon">🔍</span>
<input type="text" id="web-search-input" placeholder="搜索网页...">
<select id="search-engine" class="search-select">
<option value="google">Google</option>
<option value="baidu">百度</option>
<option value="bing">Bing</option>
<option value="duckduckgo">DuckDuckGo</option>
<option value="yandex">Yandex</option>
</select>
</div>
</form>
</div>
<div class="top-bar">
<div class="stats-group">
<div class="stats-item">📚 网站总数: <span id="total-sites">0</span></div>
<div class="stats-item">📁 分类数量: <span id="total-categories">0</span></div>
<div class="stats-item">🏷️ 标签数量: <span id="total-tags">0</span></div>
</div>
<div class="search-container compact">
<span class="search-icon">🔍</span>
<input type="text" id="search-input" placeholder="搜索网站、描述或标签...">
</div>
</div>
<div class="categories-container" id="categories-container">
<!-- 网站分类和网站卡片将通过JavaScript动态生成 -->
<div class="empty-state">
<div style="font-size: 4rem; margin-bottom: 15px;">📭</div>
<h2>暂无网站</h2>
<p>请在后台管理中添加网站</p>
</div>
</div>
</div>
<div class="toast" id="toast">
<span class="toast-icon"></span>
<span class="toast-message">操作成功</span>
</div>
<!-- 分类侧边栏:固定不随页面滚动,点击左侧「分类」按钮展开 -->
<button type="button" id="category-sidebar-toggle" class="category-toggle-btn category-toggle-fixed" aria-label="打开分类">📁 分类</button>
<div id="category-sidebar-backdrop" class="category-sidebar-backdrop" aria-hidden="true"></div>
<aside id="category-sidebar" class="category-sidebar" aria-label="分类筛选">
<div class="category-sidebar-header">
<h2>分类</h2>
<button type="button" class="category-sidebar-close" id="category-sidebar-close" aria-label="关闭">&times;</button>
</div>
<div class="category-sidebar-list" id="category-filters">
<!-- 由 JS 动态填充 -->
</div>
</aside>
<script src="app.js"></script>
</body>
</html>

BIN
cf-nav-frontend/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 MiB

View File

@@ -0,0 +1,37 @@
{
"name": "萌芽导航",
"short_name": "萌芽导航",
"description": "萌芽导航 - 轻量好用的网址导航",
"lang": "zh-CN",
"dir": "ltr",
"id": "/",
"start_url": "/?source=pwa",
"scope": "/",
"display": "standalone",
"display_override": ["standalone", "minimal-ui", "browser"],
"orientation": "portrait-primary",
"theme_color": "#10b981",
"background_color": "#f8fafc",
"categories": ["productivity", "utilities"],
"prefer_related_applications": false,
"icons": [
{
"src": "/logo.png",
"type": "image/png",
"sizes": "2048x2048",
"purpose": "any"
},
{
"src": "/logo.png",
"type": "image/png",
"sizes": "2048x2048",
"purpose": "maskable"
},
{
"src": "/favicon.ico",
"type": "image/x-icon",
"sizes": "16x16 24x24 32x32 48x48 64x64",
"purpose": "any"
}
]
}

View File

@@ -0,0 +1,113 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="theme-color" content="#10b981">
<title>萌芽导航 - 离线</title>
<script src="/config.js"></script>
<script src="/apply-config.js"></script>
<style>
:root {
color-scheme: light;
--bg: #f5f7fa;
--card: #ffffff;
--text: #1e293b;
--muted: #64748b;
--brand: #10b981;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
display: grid;
place-items: center;
padding: 24px;
background: linear-gradient(135deg, var(--bg) 0%, #e4edf5 100%);
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
color: var(--text);
}
.panel {
width: min(520px, 100%);
background: var(--card);
border-radius: 16px;
box-shadow: 0 10px 30px rgba(2, 6, 23, 0.12);
padding: 28px 24px;
text-align: center;
}
.logo {
width: 72px;
height: 72px;
border-radius: 18px;
margin: 0 auto 14px;
background: linear-gradient(135deg, #34d399, #10b981);
display: grid;
place-items: center;
color: #fff;
font-size: 34px;
font-weight: 700;
}
h1 {
margin: 0 0 8px;
font-size: 1.4rem;
}
p {
margin: 0;
color: var(--muted);
line-height: 1.6;
font-size: 0.95rem;
}
.actions {
margin-top: 18px;
display: flex;
justify-content: center;
gap: 10px;
flex-wrap: wrap;
}
button {
border: none;
border-radius: 999px;
padding: 10px 16px;
font-size: 0.9rem;
cursor: pointer;
transition: all 0.2s ease;
}
.retry {
background: var(--brand);
color: white;
box-shadow: 0 6px 16px rgba(16, 185, 129, 0.25);
}
.retry:hover {
transform: translateY(-1px);
}
.back {
background: #e2e8f0;
color: #0f172a;
}
</style>
</head>
<body>
<main class="panel">
<div class="logo" id="offline-logo"></div>
<h1 id="offline-title">当前离线,已切换离线页面</h1>
<p>网络恢复后,刷新页面即可继续访问最新数据。已缓存的页面资源可以继续使用。</p>
<div class="actions">
<button class="retry" onclick="window.location.reload()">重新尝试</button>
<button class="back" onclick="window.history.back()">返回上页</button>
</div>
</main>
</body>
</html>

1060
cf-nav-frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,14 @@
{
"name": "mengya-nav-frontend",
"version": "1.0.0",
"description": "萌芽导航 - 前端 PWACloudflare Pages",
"private": true,
"scripts": {
"build": "echo Static site - no build required",
"dev": "npx serve . -l 3000",
"preview": "npx serve . -l 3000"
},
"devDependencies": {
"serve": "^14.0.0"
}
}

930
cf-nav-frontend/styles.css Normal file
View File

@@ -0,0 +1,930 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
:root {
--primary-color: #4361ee;
--secondary-color: #3f37c9;
--success-color: #4cc9f0;
--dark-color: #1e293b;
--light-color: #f8fafc;
--gray-color: #64748b;
--border-radius: 10px;
--box-shadow: 0 4px 18px rgba(0, 0, 0, 0.08);
--transition: all 0.25s ease;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background: linear-gradient(135deg, #f5f7fa 0%, #e4edf5 100%);
color: var(--dark-color);
line-height: 1.5;
padding: 12px;
min-height: 100vh;
}
/* 全屏背景图层:仅当 config 中 SITE_BACKGROUND_IMAGES 非空时由 JS 插入,约 30% 高斯模糊 */
.site-bg {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: -1;
background-size: cover;
background-position: center;
background-repeat: no-repeat;
filter: blur(10px);
opacity: 0.85;
}
.container {
max-width: 1500px;
margin: 0 auto;
}
header {
text-align: center;
margin-bottom: 16px;
padding: 10px 0;
}
.main-header {
display: flex;
align-items: center;
justify-content: center;
padding: 10px 0;
margin-bottom: 16px;
}
.main-header h1 {
margin: 0;
flex: 1;
text-align: center;
}
/* 左上角固定「分类」按钮:不随页面滚动 */
.category-toggle-fixed {
position: fixed;
left: 16px;
top: 16px;
z-index: 998;
}
body.category-sidebar-open .category-toggle-fixed {
z-index: 997;
}
.category-toggle-btn {
padding: 8px 14px;
border-radius: 8px;
border: 1px solid rgba(67, 97, 238, 0.35);
background: white;
color: var(--primary-color);
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
transition: var(--transition);
box-shadow: 0 2px 8px rgba(67, 97, 238, 0.1);
}
.category-toggle-btn:hover {
background: var(--primary-color);
color: white;
border-color: var(--primary-color);
box-shadow: 0 4px 12px rgba(67, 97, 238, 0.25);
}
/* 分类侧边栏:固定不随页面滚动 */
.category-sidebar-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.35);
z-index: 999;
opacity: 0;
visibility: hidden;
transition: opacity 0.25s ease, visibility 0.25s ease;
}
body.category-sidebar-open .category-sidebar-backdrop {
opacity: 1;
visibility: visible;
}
.category-sidebar {
position: fixed;
top: 0;
left: 0;
width: 280px;
max-width: 85vw;
height: 100vh;
background: white;
box-shadow: 4px 0 24px rgba(0, 0, 0, 0.12);
z-index: 1000;
display: flex;
flex-direction: column;
transform: translateX(-100%);
transition: transform 0.3s ease;
overflow: hidden;
}
body.category-sidebar-open .category-sidebar {
transform: translateX(0);
}
body.category-sidebar-open {
overflow: hidden;
}
.category-sidebar-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 18px;
border-bottom: 1px solid #f1f5f9;
flex-shrink: 0;
}
.category-sidebar-header h2 {
font-size: 1.15rem;
font-weight: 700;
color: var(--dark-color);
margin: 0;
}
.category-sidebar-close {
width: 36px;
height: 36px;
border: none;
background: #f1f5f9;
border-radius: 50%;
font-size: 1.5rem;
line-height: 1;
cursor: pointer;
color: var(--gray-color);
display: flex;
align-items: center;
justify-content: center;
transition: var(--transition);
}
.category-sidebar-close:hover {
background: #e2e8f0;
color: var(--dark-color);
}
.category-sidebar-list {
flex: 1;
overflow-y: auto;
padding: 12px 14px;
display: flex;
flex-direction: column;
gap: 6px;
}
.category-sidebar-list .category-filter {
display: block;
width: 100%;
padding: 12px 14px;
text-align: left;
border-radius: 10px;
font-size: 0.9rem;
}
.category-sidebar-list .category-filter:hover:not(.active) {
background: #f8fafc;
}
/* 隐藏整个页面的滚动条 */
html {
/* 隐藏IE/Edge滚动条 */
-ms-overflow-style: none;
/* 隐藏Firefox滚动条 */
scrollbar-width: none;
}
/* 隐藏Chrome/Safari/Opera滚动条 */
html::-webkit-scrollbar {
display: none;
}
/* 确保滚动功能正常 */
body {
/* 允许滚动 */
overflow-y: scroll;
/* 防止内容抖动,保持滚动条空间 */
overflow-x: hidden;
/* 兼容WebKit内核浏览器 */
-webkit-overflow-scrolling: touch;
}
h1 {
font-size: 2.1rem;
margin-bottom: 6px;
background: linear-gradient(45deg, var(--primary-color), var(--success-color));
-webkit-background-clip: text;
background-clip: text;
color: transparent;
font-weight: 800;
letter-spacing: -0.5px;
}
.subtitle {
display: none;
}
.web-search-box {
background: rgba(255, 255, 255, var(--site-glass-opacity, 0.78));
border-radius: 14px;
padding: 20px 22px;
margin-bottom: 20px;
box-shadow: 0 8px 32px rgba(67, 97, 238, 0.18), 0 4px 12px rgba(0, 0, 0, 0.06);
border: 2px solid rgba(67, 97, 238, 0.2);
}
#web-search-form {
width: 100%;
}
.search-wrapper {
display: flex;
align-items: center;
gap: 12px;
position: relative;
}
#web-search-input {
flex: 1;
min-width: 0;
padding: 16px 18px 16px 46px;
border: 2px solid rgba(67, 97, 238, 0.3);
border-radius: 50px;
font-size: 1.05rem;
transition: var(--transition);
background: linear-gradient(135deg, #f8faff 0%, #f0f4ff 100%);
box-shadow: 0 4px 12px rgba(67, 97, 238, 0.12), inset 0 1px 0 rgba(255,255,255,0.8);
}
#web-search-input::placeholder {
color: var(--gray-color);
}
#web-search-input:hover {
border-color: var(--primary-color);
background: #fff;
box-shadow: 0 4px 16px rgba(67, 97, 238, 0.15);
}
#web-search-input:focus {
outline: none;
border-color: var(--primary-color);
box-shadow: 0 0 0 5px rgba(67, 97, 238, 0.25);
background: #fff;
}
.search-wrapper .search-icon {
position: absolute;
left: 18px;
font-size: 1.35rem;
color: var(--primary-color);
opacity: 0.95;
}
.search-select {
padding: 8px 10px;
border: 1px solid #ddd;
border-radius: 8px;
background: white;
cursor: pointer;
font-size: 0.82rem;
transition: var(--transition);
font-weight: 500;
flex-shrink: 0;
max-width: 100px;
}
.search-select:hover {
border-color: var(--primary-color);
}
.search-select:focus {
outline: none;
border-color: var(--primary-color);
box-shadow: 0 0 0 3px rgba(67, 97, 238, 0.15);
}
@media (max-width: 768px) {
.web-search-box {
padding: 16px 14px;
margin-bottom: 16px;
}
/* 移动端:搜索框与搜索引擎下拉并排同一行(并排 = side-by-side */
.search-wrapper {
flex-direction: row;
align-items: center;
gap: 8px;
}
#web-search-input {
flex: 1;
min-width: 0;
width: auto;
padding: 14px 12px 14px 42px;
font-size: 1rem;
}
.search-wrapper .search-icon {
left: 14px;
font-size: 1.2rem;
}
.search-select {
width: auto;
min-width: 72px;
max-width: 36%;
padding: 8px 8px;
font-size: 0.78rem;
}
}
.add-site-btn {
background: linear-gradient(45deg, var(--primary-color), var(--secondary-color));
color: white;
border: none;
padding: 9px 18px;
font-size: 0.95rem;
border-radius: 50px;
cursor: pointer;
margin: 0;
display: inline-flex;
align-items: center;
justify-content: center;
box-shadow: 0 4px 12px rgba(67, 97, 238, 0.28);
transition: var(--transition);
font-weight: 600;
letter-spacing: 0.2px;
white-space: nowrap;
}
.add-site-btn:hover {
transform: translateY(-2px);
box-shadow: 0 6px 18px rgba(67, 97, 238, 0.4);
}
.add-site-btn:active {
transform: translateY(0);
}
.modal {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.6);
z-index: 1000;
justify-content: center;
align-items: center;
}
.modal-content {
background-color: white;
border-radius: var(--border-radius);
width: 92%;
max-width: 560px;
box-shadow: var(--box-shadow);
animation: modalOpen 0.35s ease-out;
overflow: hidden;
}
@keyframes modalOpen {
from { opacity: 0; transform: translateY(-16px); }
to { opacity: 1; transform: translateY(0); }
}
.modal-header {
padding: 16px 20px;
background: linear-gradient(45deg, var(--primary-color), var(--secondary-color));
color: white;
display: flex;
justify-content: space-between;
align-items: center;
}
.modal-header h2 {
font-size: 1.25rem;
font-weight: 600;
}
.close-modal {
background: none;
border: none;
color: white;
font-size: 1.6rem;
cursor: pointer;
width: 32px;
height: 32px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
transition: var(--transition);
}
.close-modal:hover {
background-color: rgba(255, 255, 255, 0.2);
}
.modal-body {
padding: 18px 20px 20px;
}
.form-group {
margin-bottom: 14px;
}
.form-group label {
display: block;
margin-bottom: 6px;
font-weight: 500;
color: var(--dark-color);
}
.form-group input,
.form-group textarea,
.form-group select {
width: 100%;
padding: 10px 12px;
border: 1px solid #ddd;
border-radius: 8px;
font-size: 0.95rem;
transition: var(--transition);
}
.form-group input:focus,
.form-group textarea:focus,
.form-group select:focus {
outline: none;
border-color: var(--primary-color);
box-shadow: 0 0 0 3px rgba(67, 97, 238, 0.18);
}
.form-group textarea {
min-height: 70px;
resize: vertical;
}
.form-row {
display: flex;
gap: 12px;
}
.form-row .form-group {
flex: 1;
}
.submit-btn {
background: linear-gradient(45deg, var(--primary-color), var(--secondary-color));
color: white;
border: none;
padding: 11px 18px;
font-size: 0.98rem;
border-radius: 8px;
cursor: pointer;
width: 100%;
font-weight: 600;
letter-spacing: 0.3px;
transition: var(--transition);
margin-top: 6px;
}
.submit-btn:hover {
transform: translateY(-1px);
box-shadow: 0 4px 10px rgba(67, 97, 238, 0.25);
}
.categories-container {
display: grid;
gap: 16px;
}
.category-section {
background: rgba(255, 255, 255, var(--site-glass-opacity, 0.78));
border-radius: var(--border-radius);
box-shadow: var(--box-shadow);
padding: 16px;
transition: var(--transition);
}
.category-section:hover {
transform: translateY(-2px);
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.1);
}
.category-title {
font-size: 1.2rem;
margin-bottom: 12px;
padding-bottom: 8px;
border-bottom: 2px solid #f1f5f9;
display: flex;
align-items: center;
color: var(--dark-color);
font-weight: 700;
}
.category-title::before {
content: "";
display: inline-block;
width: 6px;
height: 18px;
background: var(--primary-color);
border-radius: 4px;
margin-right: 10px;
}
.sites-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(110px, 1fr));
gap: 10px;
}
.site-card {
background: rgba(255, 255, 255, var(--site-glass-opacity, 0.78));
border-radius: 12px;
padding: 14px 10px;
text-align: center;
transition: var(--transition);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
border: 1px solid transparent;
cursor: pointer;
display: flex;
flex-direction: column;
align-items: center;
min-height: 140px;
text-decoration: none;
color: var(--dark-color);
position: relative;
}
/* 卡片右上角点击次数,可与内容重叠 */
.site-card-clicks {
position: absolute;
top: 2px;
right: 4px;
font-size: 9px;
line-height: 1;
color: #94a3b8;
z-index: 2;
pointer-events: none;
}
/* 图标固定在最上方 */
.site-icon {
width: 64px;
height: 64px;
border-radius: 12px;
background-color: #f1f5f9;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 8px;
overflow: hidden;
flex-shrink: 0;
}
.site-icon img {
width: 48px;
height: 48px;
object-fit: contain;
}
/* 中间区域:网站名称+描述,弹性占据剩余空间 */
.site-card-body {
flex: 1;
min-height: 0;
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
overflow: hidden;
}
.site-name {
font-weight: 600;
font-size: 0.9rem;
margin-bottom: 4px;
width: 100%;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
line-height: 1.3;
flex-shrink: 0;
}
.site-description {
font-size: 0.75rem;
color: var(--gray-color);
width: 100%;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
line-height: 1.3;
flex: 1;
min-height: 0;
}
/* 标签固定在最下方,不受名称和描述影响 */
.site-tags {
width: 100%;
margin-top: auto;
flex-shrink: 0;
display: flex;
flex-wrap: nowrap;
gap: 4px;
justify-content: center;
overflow: hidden;
}
.site-card:hover {
transform: translateY(-3px);
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.12);
border-color: rgba(67, 97, 238, 0.2);
}
.tag {
background: #eef2ff;
color: var(--primary-color);
font-size: 0.65rem;
padding: 2px 6px;
border-radius: 10px;
font-weight: 500;
white-space: nowrap;
flex-shrink: 0;
}
.empty-state {
text-align: center;
padding: 30px 20px;
color: var(--gray-color);
}
.empty-state p {
margin-top: 8px;
font-size: 1rem;
}
.empty-state button {
margin-top: 12px;
}
.top-bar {
background: rgba(255, 255, 255, var(--site-glass-opacity, 0.78));
border-radius: var(--border-radius);
padding: 8px 14px;
margin-bottom: 12px;
display: flex;
align-items: center;
gap: 12px;
box-shadow: var(--box-shadow);
}
.stats-group {
display: flex;
align-items: center;
gap: 12px;
flex-shrink: 0;
}
.search-container {
max-width: 560px;
margin: 0 auto 16px;
position: relative;
}
.search-container.compact {
margin: 0;
max-width: 500px;
flex: 1;
}
.search-container input {
width: 100%;
padding: 9px 16px 9px 36px;
border-radius: 50px;
border: 1px solid #ddd;
font-size: 0.93rem;
box-shadow: var(--box-shadow);
transition: var(--transition);
}
.search-container input:focus {
border-color: var(--primary-color);
box-shadow: 0 0 0 3px rgba(67, 97, 238, 0.18);
}
.search-icon {
position: absolute;
left: 14px;
top: 50%;
transform: translateY(-50%);
color: var(--gray-color);
font-size: 1.1rem;
}
.stats-item {
display: flex;
align-items: center;
gap: 4px;
font-size: 0.85rem;
font-weight: 500;
white-space: nowrap;
}
.stats-item span {
font-weight: 700;
color: var(--primary-color);
}
@media (max-width: 1024px) {
.sites-grid {
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
}
}
@media (max-width: 768px) {
body {
padding: 10px;
}
h1 {
font-size: 1.85rem;
}
.sites-grid {
grid-template-columns: repeat(auto-fill, minmax(92px, 1fr));
}
.form-row {
flex-direction: column;
gap: 0;
}
.top-bar {
flex-direction: column;
gap: 10px;
}
.stats-group {
width: 100%;
justify-content: space-around;
flex-wrap: wrap;
gap: 8px;
}
.search-container.compact {
width: 100%;
max-width: none;
}
.add-site-btn {
width: 100%;
}
}
@media (max-width: 480px) {
.sites-grid {
grid-template-columns: repeat(auto-fill, minmax(90px, 1fr));
gap: 8px;
}
.site-card {
padding: 10px 6px;
min-height: 120px;
}
.site-icon {
width: 48px;
height: 48px;
}
.site-icon img {
width: 36px;
height: 36px;
}
.site-name {
font-size: 0.85rem;
}
.site-description {
font-size: 0.7rem;
}
.favicon-placeholder {
font-size: 1.1rem;
}
.modal-content {
width: 96%;
margin: 16px;
}
.add-site-btn {
padding: 8px 16px;
font-size: 0.92rem;
}
}
.toast {
position: fixed;
bottom: 20px;
right: 20px;
background: white;
color: var(--dark-color);
padding: 12px 20px;
border-radius: 50px;
box-shadow: 0 5px 18px rgba(0, 0, 0, 0.15);
display: flex;
align-items: center;
gap: 10px;
z-index: 2000;
transform: translateX(320px);
transition: transform 0.25s ease;
font-weight: 500;
}
.toast.show {
transform: translateX(0);
}
.toast.success {
border-left: 4px solid var(--success-color);
}
.toast.error {
border-left: 4px solid #f72585;
}
.toast-icon {
font-size: 1.2rem;
}
.category-selector {
display: none;
}
.category-filter {
padding: 5px 12px;
border-radius: 30px;
background: white;
border: 1px solid #ddd;
cursor: pointer;
transition: var(--transition);
font-weight: 500;
font-size: 0.85rem;
}
.category-filter.active {
background: var(--primary-color);
color: white;
border-color: var(--primary-color);
}
.category-filter:hover:not(.active) {
background: #f8fafc;
border-color: var(--primary-color);
}
.no-results {
text-align: center;
padding: 30px 20px;
color: var(--gray-color);
grid-column: 1 / -1;
}
.no-results p {
font-size: 1.05rem;
margin-top: 12px;
}
.favicon-placeholder {
background: linear-gradient(45deg, #4361ee, #3f37c9);
color: white;
font-weight: bold;
font-size: 1.4rem;
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
}

182
cf-nav-frontend/sw.js Normal file
View File

@@ -0,0 +1,182 @@
const CACHE_VERSION = 'v2';
const STATIC_CACHE = `mengya-static-${CACHE_VERSION}`;
const RUNTIME_CACHE = `mengya-runtime-${CACHE_VERSION}`;
const API_CACHE = `mengya-api-${CACHE_VERSION}`;
const OFFLINE_FALLBACK = '/offline.html';
const APP_SHELL = [
'/',
'/index.html',
'/styles.css',
'/app.js',
'/manifest.webmanifest',
'/logo.png',
'/favicon.ico',
OFFLINE_FALLBACK,
];
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(STATIC_CACHE).then((cache) => cache.addAll(APP_SHELL))
);
self.skipWaiting();
});
self.addEventListener('activate', (event) => {
event.waitUntil(
(async () => {
const cacheNames = await caches.keys();
await Promise.all(
cacheNames
.filter((name) => ![STATIC_CACHE, RUNTIME_CACHE, API_CACHE].includes(name))
.map((name) => caches.delete(name))
);
if ('navigationPreload' in self.registration) {
await self.registration.navigationPreload.enable();
}
await self.clients.claim();
})()
);
});
self.addEventListener('message', (event) => {
if (event.data === 'SKIP_WAITING') {
self.skipWaiting();
}
});
self.addEventListener('fetch', (event) => {
const { request } = event;
if (request.method !== 'GET') {
return;
}
const url = new URL(request.url);
if (request.mode === 'navigate') {
event.respondWith(handleNavigationRequest(event));
return;
}
if (url.origin !== self.location.origin) {
return;
}
if (url.pathname.startsWith('/api/')) {
event.respondWith(networkFirst(request, API_CACHE));
return;
}
if (isStaticAssetRequest(request, url)) {
event.respondWith(staleWhileRevalidate(request, RUNTIME_CACHE));
return;
}
event.respondWith(cacheFirst(request, RUNTIME_CACHE));
});
async function handleNavigationRequest(event) {
try {
const preloadResponse = await event.preloadResponse;
if (preloadResponse) {
return preloadResponse;
}
const networkResponse = await fetch(event.request);
if (networkResponse && networkResponse.ok) {
const cache = await caches.open(RUNTIME_CACHE);
cache.put(event.request, networkResponse.clone());
}
return networkResponse;
} catch (error) {
const cachedPage = await caches.match(event.request);
if (cachedPage) {
return cachedPage;
}
const offlineResponse = await caches.match(OFFLINE_FALLBACK);
return offlineResponse || new Response('Offline', { status: 503 });
}
}
async function networkFirst(request, cacheName) {
const cache = await caches.open(cacheName);
try {
const response = await fetch(request);
if (shouldCacheResponse(response)) {
cache.put(request, response.clone());
}
return response;
} catch (error) {
const cached = await cache.match(request);
if (cached) {
return cached;
}
if (request.mode === 'navigate') {
const offlineResponse = await caches.match(OFFLINE_FALLBACK);
if (offlineResponse) {
return offlineResponse;
}
}
return new Response(JSON.stringify({ message: 'Network unavailable' }), {
status: 503,
headers: { 'Content-Type': 'application/json;charset=UTF-8' },
});
}
}
async function staleWhileRevalidate(request, cacheName) {
const cache = await caches.open(cacheName);
const cached = await cache.match(request);
const networkPromise = fetch(request)
.then((response) => {
if (shouldCacheResponse(response)) {
cache.put(request, response.clone());
}
return response;
})
.catch(() => undefined);
return cached || networkPromise || new Response('Not found', { status: 404 });
}
async function cacheFirst(request, cacheName) {
const cache = await caches.open(cacheName);
const cached = await cache.match(request);
if (cached) {
return cached;
}
const response = await fetch(request);
if (shouldCacheResponse(response)) {
cache.put(request, response.clone());
}
return response;
}
function isStaticAssetRequest(request, url) {
if (request.destination === 'script' || request.destination === 'style' || request.destination === 'image' || request.destination === 'font') {
return true;
}
return (
url.pathname.endsWith('.css') ||
url.pathname.endsWith('.js') ||
url.pathname.endsWith('.png') ||
url.pathname.endsWith('.jpg') ||
url.pathname.endsWith('.jpeg') ||
url.pathname.endsWith('.svg') ||
url.pathname.endsWith('.ico') ||
url.pathname.endsWith('.webmanifest')
);
}
function shouldCacheResponse(response) {
return Boolean(response && (response.status === 200 || response.type === 'opaque'));
}