chore: sync local changes to Gitea
This commit is contained in:
24
.gitignore
vendored
24
.gitignore
vendored
@@ -1,19 +1,5 @@
|
||||
## Dependencies
|
||||
**/node_modules/
|
||||
|
||||
## Build output
|
||||
**/dist/
|
||||
|
||||
## Cloudflare / Wrangler
|
||||
**/.wrangler/
|
||||
**/.cache/
|
||||
|
||||
## Local env / secrets
|
||||
**/.dev.vars
|
||||
**/.env
|
||||
**/.env.*
|
||||
|
||||
## OS / editor
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
.vscode/
|
||||
node_modules/
|
||||
.dev.vars
|
||||
frontend/dist
|
||||
frontend/dist-desktop
|
||||
.wrangler
|
||||
|
||||
55
README.md
Normal file
55
README.md
Normal file
@@ -0,0 +1,55 @@
|
||||
# 萌芽导航(sproutnav)
|
||||
|
||||
单一 **Cloudflare Worker**:[`worker/src/index.ts`](worker/src/index.ts) 处理 `/api/*`,静态资源由 Wrangler **[assets]** 提供(Vite 构建输出 [`frontend/dist`](frontend/dist))。
|
||||
|
||||
## 目录结构
|
||||
|
||||
- **`worker/`**:导航 API(KV:`sites` / `categories`)、与静态资源回退(SPA)
|
||||
- **`frontend/`**:React + Vite PWA;[`frontend/src/config/site.ts`](frontend/src/config/site.ts) 可配置 `VITE_API_BASE`、站点文案、随机背景、`VITE_FAVICON_API_BASE`(默认 `https://favicon.smyhub.com/api/favicon?url=`)
|
||||
- **[`wrangler.toml`](wrangler.toml)**:`main`、`[[kv_namespaces]]`、`[vars]`(如 `ADMIN_PASSWORD`)、`[assets]` → `./frontend/dist`
|
||||
|
||||
## 本地开发
|
||||
|
||||
```bash
|
||||
# 仅前端(无同源 API 时需自行 mock 或填 VITE_API_BASE)
|
||||
cd frontend && npm install && npm run dev
|
||||
|
||||
# Worker + 资源(需先 build 前端,或由 wrangler 触发)
|
||||
npm install
|
||||
npm run build:frontend
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## 构建产物
|
||||
|
||||
`npm run build`(根目录或 `frontend/`)会连续构建两份前端:
|
||||
|
||||
| 脚本 / 模式 | 输出目录 | `VITE_API_BASE` | 用途 |
|
||||
|-------------|----------|-----------------|------|
|
||||
| 默认 `vite build` | `frontend/dist` | 空(同源 `/api`) | Worker `[assets]` 线上部署 |
|
||||
| `--mode desktop` | `frontend/dist-desktop` | `https://nav.smyhub.com` | 桌面壳(Electron/Tauri 等)加载 |
|
||||
|
||||
单独构建:`npm run build:web` / `npm run build:desktop`(在 `frontend/` 下)。
|
||||
|
||||
桌面版使用 `HashRouter`、`base: './'`,且不注册 Service Worker。
|
||||
|
||||
## 部署
|
||||
|
||||
```bash
|
||||
npm install
|
||||
cd frontend && npm install && cd ..
|
||||
npm run deploy
|
||||
```
|
||||
|
||||
`deploy` 仅打包并上传 **`frontend/dist`**(线上版);桌面版 `dist-desktop` 自行交给桌面工程。
|
||||
|
||||
生产环境请在 Dashboard 或密文中覆盖 **`ADMIN_PASSWORD`**,勿提交真实口令。
|
||||
|
||||
## KV 数据
|
||||
|
||||
沿用 [`wrangler.toml`](wrangler.toml) 中 namespace `id` 即可续用原有 KV 数据。
|
||||
|
||||
## 常见问题
|
||||
|
||||
- **站点卡片图标**:由前端根据每条站点的 `url` 请求 favicon 服务,不经 Worker;可通过 `VITE_FAVICON_API_BASE` 覆盖默认基址。
|
||||
- **跨域 / 缓存**:若 API 已同源但仍见旧请求,尝试硬刷新或注销 Service Worker 后清除站点数据。
|
||||
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"name": "cf-nav-backend",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"deploy": "wrangler deploy",
|
||||
"dev": "wrangler dev"
|
||||
},
|
||||
"devDependencies": {
|
||||
"wrangler": "^4.68.1"
|
||||
}
|
||||
}
|
||||
@@ -1,309 +0,0 @@
|
||||
// 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 });
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
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="
|
||||
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
# 萌芽导航 - 前后端分离版
|
||||
|
||||
- **前端**:静态 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`,即可继续使用同一份数据,无需迁移。
|
||||
@@ -1,417 +0,0 @@
|
||||
<!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">×</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>
|
||||
@@ -1,420 +0,0 @@
|
||||
// 后台管理 JavaScript(API 地址从 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();
|
||||
@@ -1,498 +0,0 @@
|
||||
// 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 = '';
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -1,89 +0,0 @@
|
||||
/**
|
||||
* 根据 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);
|
||||
}
|
||||
})();
|
||||
@@ -1,44 +0,0 @@
|
||||
/**
|
||||
* 前端配置(前后端分离)
|
||||
*
|
||||
* 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;
|
||||
@@ -1,85 +0,0 @@
|
||||
<!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="关闭">×</button>
|
||||
</div>
|
||||
<div class="category-sidebar-list" id="category-filters">
|
||||
<!-- 由 JS 动态填充 -->
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 4.9 MiB |
@@ -1,113 +0,0 @@
|
||||
<!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
1060
cf-nav-frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,14 +0,0 @@
|
||||
{
|
||||
"name": "mengya-nav-frontend",
|
||||
"version": "1.0.0",
|
||||
"description": "萌芽导航 - 前端 PWA(Cloudflare 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"
|
||||
}
|
||||
}
|
||||
13
frontend/.env.desktop
Normal file
13
frontend/.env.desktop
Normal file
@@ -0,0 +1,13 @@
|
||||
# 桌面壳加载本地 dist-desktop;API 指向线上 Worker
|
||||
VITE_APP_TARGET=desktop
|
||||
VITE_API_BASE=https://nav.smyhub.com
|
||||
|
||||
VITE_FAVICON_API_BASE=https://favicon.smyhub.com/api/favicon?url=
|
||||
|
||||
VITE_SITE_NAME=萌芽导航
|
||||
VITE_SITE_SHORT_NAME=萌芽导航
|
||||
VITE_SITE_DESCRIPTION=萌芽导航
|
||||
|
||||
VITE_SITE_RANDOM_BG_API=https://randbg.api.smyhub.com
|
||||
VITE_SITE_BACKGROUND_BLUR=0px
|
||||
VITE_SITE_GLASS_OPACITY=0.92
|
||||
16
frontend/.env.development
Normal file
16
frontend/.env.development
Normal file
@@ -0,0 +1,16 @@
|
||||
# 同源部署 Workers 时保持默认空字符串即可
|
||||
VITE_APP_TARGET=web
|
||||
VITE_API_BASE=
|
||||
|
||||
# 站点卡片 favicon 服务基址
|
||||
VITE_FAVICON_API_BASE=https://favicon.smyhub.com/api/favicon?url=
|
||||
|
||||
VITE_SITE_NAME=萌芽导航
|
||||
VITE_SITE_SHORT_NAME=萌芽导航
|
||||
VITE_SITE_DESCRIPTION=萌芽导航
|
||||
|
||||
# 随机背景 JSON API,留空则不加载背景图
|
||||
VITE_SITE_RANDOM_BG_API=https://randbg.api.smyhub.com
|
||||
VITE_SITE_BACKGROUND_BLUR=0px
|
||||
# 0–1,面板白底不透明度(越高越不透明、越少「玻璃感」)
|
||||
VITE_SITE_GLASS_OPACITY=0.92
|
||||
16
frontend/.env.production
Normal file
16
frontend/.env.production
Normal file
@@ -0,0 +1,16 @@
|
||||
# 同源部署 Workers 时保持默认空字符串即可
|
||||
VITE_APP_TARGET=web
|
||||
VITE_API_BASE=
|
||||
|
||||
# 站点卡片 favicon 服务基址
|
||||
VITE_FAVICON_API_BASE=https://favicon.smyhub.com/api/favicon?url=
|
||||
|
||||
VITE_SITE_NAME=萌芽导航
|
||||
VITE_SITE_SHORT_NAME=萌芽导航
|
||||
VITE_SITE_DESCRIPTION=萌芽导航
|
||||
|
||||
# 随机背景 JSON API,留空则不加载背景图
|
||||
VITE_SITE_RANDOM_BG_API=https://randbg.api.smyhub.com
|
||||
VITE_SITE_BACKGROUND_BLUR=0px
|
||||
# 0–1,面板白底不透明度(越高越不透明、越少「玻璃感」)
|
||||
VITE_SITE_GLASS_OPACITY=0.92
|
||||
25
frontend/.gitignore
vendored
Normal file
25
frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,25 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-desktop
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
73
frontend/README.md
Normal file
73
frontend/README.md
Normal file
@@ -0,0 +1,73 @@
|
||||
# React + TypeScript + Vite
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
|
||||
|
||||
## React Compiler
|
||||
|
||||
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||
|
||||
## Expanding the ESLint configuration
|
||||
|
||||
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
||||
|
||||
```js
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
|
||||
// Remove tseslint.configs.recommended and replace with this
|
||||
tseslint.configs.recommendedTypeChecked,
|
||||
// Alternatively, use this for stricter rules
|
||||
tseslint.configs.strictTypeChecked,
|
||||
// Optionally, add this for stylistic rules
|
||||
tseslint.configs.stylisticTypeChecked,
|
||||
|
||||
// Other configs...
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
|
||||
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
||||
|
||||
```js
|
||||
// eslint.config.js
|
||||
import reactX from 'eslint-plugin-react-x'
|
||||
import reactDom from 'eslint-plugin-react-dom'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
// Enable lint rules for React
|
||||
reactX.configs['recommended-typescript'],
|
||||
// Enable lint rules for React DOM
|
||||
reactDom.configs.recommended,
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
22
frontend/eslint.config.js
Normal file
22
frontend/eslint.config.js
Normal file
@@ -0,0 +1,22 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
reactHooks.configs.flat.recommended,
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
globals: globals.browser,
|
||||
},
|
||||
},
|
||||
])
|
||||
28
frontend/index.html
Normal file
28
frontend/index.html
Normal file
@@ -0,0 +1,28 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@400;500;600;700;800&display=swap"
|
||||
/>
|
||||
<link rel="icon" href="/favicon.ico" sizes="any" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="description" content="萌芽导航 - 轻量好用的网址导航" />
|
||||
<meta name="theme-color" content="#ea580c" />
|
||||
<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="/logo.png" type="image/png" />
|
||||
<link rel="apple-touch-icon" href="/logo.png" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
2830
frontend/package-lock.json
generated
Normal file
2830
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
34
frontend/package.json
Normal file
34
frontend/package.json
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build && vite build --mode desktop",
|
||||
"build:web": "tsc -b && vite build",
|
||||
"build:desktop": "tsc -b && vite build --mode desktop",
|
||||
"dev:desktop": "vite --mode desktop",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.5",
|
||||
"react-router-dom": "^7.14.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@types/node": "^24.12.2",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"eslint": "^10.2.1",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"globals": "^17.5.0",
|
||||
"typescript": "~6.0.2",
|
||||
"typescript-eslint": "^8.58.2",
|
||||
"vite": "^8.0.10"
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 106 KiB After Width: | Height: | Size: 106 KiB |
BIN
frontend/public/logo.png
Normal file
BIN
frontend/public/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.9 MiB |
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "萌芽导航",
|
||||
"short_name": "萌芽导航",
|
||||
"description": "萌芽导航 - 轻量好用的网址导航",
|
||||
"description": "萌芽导航",
|
||||
"lang": "zh-CN",
|
||||
"dir": "ltr",
|
||||
"id": "/",
|
||||
@@ -10,8 +10,8 @@
|
||||
"display": "standalone",
|
||||
"display_override": ["standalone", "minimal-ui", "browser"],
|
||||
"orientation": "portrait-primary",
|
||||
"theme_color": "#10b981",
|
||||
"background_color": "#f8fafc",
|
||||
"theme_color": "#ea580c",
|
||||
"background_color": "#fff7ed",
|
||||
"categories": ["productivity", "utilities"],
|
||||
"prefer_related_applications": false,
|
||||
"icons": [
|
||||
40
frontend/public/offline.html
Normal file
40
frontend/public/offline.html
Normal file
@@ -0,0 +1,40 @@
|
||||
<!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="#ea580c">
|
||||
<title>离线</title>
|
||||
<style>
|
||||
:root { color-scheme: light; --bg: #fff7ed; --card: #ffffff; --text: #1e293b; --muted: #64748b; --brand: #ea580c; }
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; min-height: 100vh; display: grid; place-items: center; padding: 24px;
|
||||
background: linear-gradient(145deg, var(--bg) 0%, #ffedd5 50%, #fed7aa 100%);
|
||||
font-family: "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Segoe UI", system-ui, sans-serif;
|
||||
color: var(--text); }
|
||||
.panel { width: min(520px, 100%); background: var(--card); border-radius: 10px;
|
||||
box-shadow: 0 10px 30px rgba(2, 6, 23, 0.12); padding: 28px 24px; text-align: center; }
|
||||
.logo { width: 72px; height: 72px; border-radius: 10px; margin: 0 auto 14px;
|
||||
background: linear-gradient(145deg, #fb923c, #ea580c); 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: 10px; 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(234, 88, 12, 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" type="button" onclick="window.location.reload()">重新尝试</button>
|
||||
<button class="back" type="button" onclick="window.history.back()">返回上页</button>
|
||||
</div>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,24 +1,17 @@
|
||||
const CACHE_VERSION = 'v2';
|
||||
/**
|
||||
* PWA 缓存策略(与 React/Vite 产物配合;shell 仅预缓存关键静态,其余运行时 stale-while-revalidate)
|
||||
*/
|
||||
const CACHE_VERSION = 'v5';
|
||||
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,
|
||||
];
|
||||
/** Vite 构建后入口由服务器返回 index.html;此处预缓存根路径与离线 fallback */
|
||||
const APP_SHELL = ['/', '/offline.html', '/manifest.webmanifest', '/logo.png', '/favicon.ico'];
|
||||
|
||||
self.addEventListener('install', (event) => {
|
||||
event.waitUntil(
|
||||
caches.open(STATIC_CACHE).then((cache) => cache.addAll(APP_SHELL))
|
||||
);
|
||||
event.waitUntil(caches.open(STATIC_CACHE).then((cache) => cache.addAll(APP_SHELL)));
|
||||
self.skipWaiting();
|
||||
});
|
||||
|
||||
@@ -161,7 +154,12 @@ async function cacheFirst(request, cacheName) {
|
||||
}
|
||||
|
||||
function isStaticAssetRequest(request, url) {
|
||||
if (request.destination === 'script' || request.destination === 'style' || request.destination === 'image' || request.destination === 'font') {
|
||||
if (
|
||||
request.destination === 'script' ||
|
||||
request.destination === 'style' ||
|
||||
request.destination === 'image' ||
|
||||
request.destination === 'font'
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -173,7 +171,8 @@ function isStaticAssetRequest(request, url) {
|
||||
url.pathname.endsWith('.jpeg') ||
|
||||
url.pathname.endsWith('.svg') ||
|
||||
url.pathname.endsWith('.ico') ||
|
||||
url.pathname.endsWith('.webmanifest')
|
||||
url.pathname.endsWith('.webmanifest') ||
|
||||
url.pathname.endsWith('.woff2')
|
||||
);
|
||||
}
|
||||
|
||||
31
frontend/src/App.tsx
Normal file
31
frontend/src/App.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { Suspense, lazy, useMemo } from 'react';
|
||||
import { BrowserRouter, HashRouter, Route, Routes, useLocation } from 'react-router-dom';
|
||||
import { IS_DESKTOP } from './config/site';
|
||||
import { useSiteBranding } from './hooks/useSiteBranding';
|
||||
|
||||
const Home = lazy(() => import('./pages/Home'));
|
||||
const Admin = lazy(() => import('./pages/Admin'));
|
||||
|
||||
function AppRoutes() {
|
||||
const location = useLocation();
|
||||
const isAdmin = useMemo(() => location.pathname.startsWith('/admin'), [location.pathname]);
|
||||
useSiteBranding(isAdmin);
|
||||
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<Routes>
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path="/admin" element={<Admin />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const Router = IS_DESKTOP ? HashRouter : BrowserRouter;
|
||||
return (
|
||||
<Router>
|
||||
<AppRoutes />
|
||||
</Router>
|
||||
);
|
||||
}
|
||||
96
frontend/src/api/client.ts
Normal file
96
frontend/src/api/client.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { API_BASE } from '../config/site';
|
||||
import type { Site } from '../types/site';
|
||||
|
||||
function url(path: string) {
|
||||
return `${API_BASE}${path}`;
|
||||
}
|
||||
|
||||
export async function fetchSites(init?: RequestInit): Promise<Site[]> {
|
||||
const r = await fetch(url('/api/sites'), init);
|
||||
if (!r.ok) throw new Error('加载网站失败');
|
||||
return r.json();
|
||||
}
|
||||
|
||||
export async function postSiteClick(siteId: string): Promise<void> {
|
||||
await fetch(url(`/api/sites/${siteId}/click`), { method: 'POST', keepalive: true }).catch(() => {});
|
||||
}
|
||||
|
||||
export async function authLogin(password: string): Promise<{ ok: boolean; sessionId?: string }> {
|
||||
const r = await fetch(url('/api/auth/login'), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ password }),
|
||||
});
|
||||
const data = await r.json().catch(() => ({}));
|
||||
if (!r.ok || !data?.ok || !data?.sessionId) return { ok: false };
|
||||
return { ok: true, sessionId: data.sessionId as string };
|
||||
}
|
||||
|
||||
export async function authCheck(token: string): Promise<boolean> {
|
||||
const r = await fetch(url('/api/auth/check'), {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (r.status !== 200) return false;
|
||||
const data = await r.json().catch(() => ({}));
|
||||
return Boolean(data?.ok);
|
||||
}
|
||||
|
||||
export async function fetchCategories(token: string): Promise<string[]> {
|
||||
const r = await fetch(url('/api/categories'), {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (r.status === 401) throw new Error('unauthorized');
|
||||
if (!r.ok) return [];
|
||||
return r.json();
|
||||
}
|
||||
|
||||
export async function fetchSiteById(id: string): Promise<Site> {
|
||||
const r = await fetch(url(`/api/sites/${id}`));
|
||||
if (!r.ok) throw new Error('加载站点失败');
|
||||
return r.json();
|
||||
}
|
||||
|
||||
export async function saveSite(token: string, site: Partial<Site>, id?: string): Promise<boolean> {
|
||||
const path = id ? `/api/sites/${id}` : '/api/sites';
|
||||
const method = id ? 'PUT' : 'POST';
|
||||
const r = await fetch(url(path), {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
||||
body: JSON.stringify(site),
|
||||
});
|
||||
return r.ok;
|
||||
}
|
||||
|
||||
export async function deleteSite(token: string, id: string): Promise<boolean> {
|
||||
const r = await fetch(url(`/api/sites/${id}`), {
|
||||
method: 'DELETE',
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
return r.ok;
|
||||
}
|
||||
|
||||
export async function addCategory(token: string, name: string): Promise<boolean> {
|
||||
const r = await fetch(url('/api/categories'), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
||||
body: JSON.stringify({ name }),
|
||||
});
|
||||
return r.ok;
|
||||
}
|
||||
|
||||
export async function renameCategory(token: string, oldName: string, newName: string): Promise<boolean> {
|
||||
const r = await fetch(url(`/api/categories/${encodeURIComponent(oldName)}`), {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
||||
body: JSON.stringify({ name: newName }),
|
||||
});
|
||||
return r.ok;
|
||||
}
|
||||
|
||||
export async function deleteCategory(token: string, name: string): Promise<boolean> {
|
||||
const r = await fetch(url(`/api/categories/${encodeURIComponent(name)}`), {
|
||||
method: 'DELETE',
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
return r.ok;
|
||||
}
|
||||
59
frontend/src/components/AdminSiteCard.tsx
Normal file
59
frontend/src/components/AdminSiteCard.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import { useState } from 'react';
|
||||
import type { Site } from '../types/site';
|
||||
import { getFaviconUrl } from '../utils/favicon';
|
||||
|
||||
interface AdminSiteCardProps {
|
||||
site: Site;
|
||||
onEdit: (id: string) => void;
|
||||
onDelete: (id: string) => void;
|
||||
}
|
||||
|
||||
/** 后台预览:与首页卡片相同的图标与排版,附带编辑 / 删除 / 打开 */
|
||||
export function AdminSiteCard({ site, onEdit, onDelete }: AdminSiteCardProps) {
|
||||
const [imgFailed, setImgFailed] = useState(false);
|
||||
const firstLetter = site.name.charAt(0).toUpperCase();
|
||||
const faviconUrl = site.url ? getFaviconUrl(site.url) : '';
|
||||
const tagsText = site.tags?.length ? site.tags.join('、') : '';
|
||||
const clickCount = typeof site.clicks === 'number' ? site.clicks : 0;
|
||||
|
||||
return (
|
||||
<div className="site-card admin-site-card">
|
||||
<span className="site-card-clicks" title="访问次数">
|
||||
{clickCount}
|
||||
</span>
|
||||
<div className="site-icon">
|
||||
{faviconUrl && !imgFailed ? (
|
||||
<img src={faviconUrl} alt="" onError={() => setImgFailed(true)} />
|
||||
) : (
|
||||
<div className="favicon-placeholder">{firstLetter}</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="site-card-body">
|
||||
<div className="site-name" title={site.name}>
|
||||
{site.name}
|
||||
</div>
|
||||
<div className="site-description" title={site.description || site.url}>
|
||||
{site.description || site.url}
|
||||
</div>
|
||||
</div>
|
||||
<div className="site-tags" title={tagsText}>
|
||||
{site.tags?.map((tag) => (
|
||||
<span key={tag} className="tag">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="admin-site-card-toolbar">
|
||||
<button type="button" className="admin-card-btn edit" onClick={() => onEdit(site.id)}>
|
||||
编辑
|
||||
</button>
|
||||
<button type="button" className="admin-card-btn delete" onClick={() => onDelete(site.id)}>
|
||||
删除
|
||||
</button>
|
||||
<a href={site.url} target="_blank" rel="noreferrer" className="admin-card-open">
|
||||
打开
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
55
frontend/src/components/SiteCard.tsx
Normal file
55
frontend/src/components/SiteCard.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import { useState } from 'react';
|
||||
import type { Site } from '../types/site';
|
||||
import { getFaviconUrl } from '../utils/favicon';
|
||||
|
||||
interface SiteCardProps {
|
||||
site: Site;
|
||||
}
|
||||
|
||||
/** 导航卡片:外链、favicon、占位字母 */
|
||||
export function SiteCard({ site }: SiteCardProps) {
|
||||
const [imgFailed, setImgFailed] = useState(false);
|
||||
|
||||
const firstLetter = site.name.charAt(0).toUpperCase();
|
||||
const faviconUrl = site.url ? getFaviconUrl(site.url) : '';
|
||||
|
||||
const tagsText = site.tags?.length ? site.tags.join('、') : '';
|
||||
const clickCount = typeof site.clicks === 'number' ? site.clicks : 0;
|
||||
|
||||
return (
|
||||
<a
|
||||
href={site.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="site-card"
|
||||
data-url={site.url}
|
||||
data-site-id={site.id}
|
||||
>
|
||||
<span className="site-card-clicks" title="访问次数">
|
||||
{clickCount}
|
||||
</span>
|
||||
<div className="site-icon">
|
||||
{faviconUrl && !imgFailed ? (
|
||||
<img src={faviconUrl} alt={`${site.name}图标`} onError={() => setImgFailed(true)} />
|
||||
) : (
|
||||
<div className="favicon-placeholder">{firstLetter}</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="site-card-body">
|
||||
<div className="site-name" title={site.name}>
|
||||
{site.name}
|
||||
</div>
|
||||
<div className="site-description" title={site.description || ''}>
|
||||
{site.description || ''}
|
||||
</div>
|
||||
</div>
|
||||
<div className="site-tags" title={tagsText}>
|
||||
{site.tags?.map((tag) => (
|
||||
<span key={tag} className="tag">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
14
frontend/src/components/Toast.tsx
Normal file
14
frontend/src/components/Toast.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
interface ToastProps {
|
||||
message: string;
|
||||
type: 'success' | 'error';
|
||||
visible: boolean;
|
||||
}
|
||||
|
||||
export function Toast({ message, type, visible }: ToastProps) {
|
||||
return (
|
||||
<div className={`toast ${type} ${visible ? 'show' : ''}`} id="toast" aria-live="polite">
|
||||
<span className="toast-icon">{type === 'success' ? '✅' : '⚠️'}</span>
|
||||
<span className="toast-message">{message}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
22
frontend/src/config/site.ts
Normal file
22
frontend/src/config/site.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
/** 站点与 API 配置(可通过 .env 中以 VITE_ 前缀覆盖) */
|
||||
export const APP_TARGET = import.meta.env.VITE_APP_TARGET ?? 'web';
|
||||
export const IS_DESKTOP = APP_TARGET === 'desktop';
|
||||
|
||||
export const API_BASE = import.meta.env.VITE_API_BASE ?? '';
|
||||
|
||||
export const SITE_NAME = import.meta.env.VITE_SITE_NAME ?? '萌芽导航';
|
||||
export const SITE_SHORT_NAME = import.meta.env.VITE_SITE_SHORT_NAME ?? '萌芽导航';
|
||||
export const SITE_DESCRIPTION = import.meta.env.VITE_SITE_DESCRIPTION ?? '萌芽导航';
|
||||
|
||||
/** 导航卡片站点图标:请求基址(须以 `url=` 结尾,后跟 encodeURIComponent(页面 http URL)) */
|
||||
export const SITE_FAVICON_API_BASE =
|
||||
import.meta.env.VITE_FAVICON_API_BASE ?? 'https://favicon.smyhub.com/api/favicon?url=';
|
||||
|
||||
/** 随机背景 JSON API,留空则不请求 */
|
||||
export const SITE_RANDOM_BG_API =
|
||||
import.meta.env.VITE_SITE_RANDOM_BG_API ?? 'https://randbg.api.smyhub.com';
|
||||
|
||||
export const SITE_BACKGROUND_BLUR = import.meta.env.VITE_SITE_BACKGROUND_BLUR ?? '0px';
|
||||
|
||||
/** 面板白底不透明度 0–1(数值越高越「实」,越少玻璃感) */
|
||||
export const SITE_GLASS_OPACITY = Number(import.meta.env.VITE_SITE_GLASS_OPACITY ?? '0.92');
|
||||
55
frontend/src/hooks/useInstallPrompt.ts
Normal file
55
frontend/src/hooks/useInstallPrompt.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
/** 最小类型(TS lib 可能未包含) */
|
||||
interface BeforeInstallPromptEvent extends Event {
|
||||
prompt: () => Promise<void>;
|
||||
userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>;
|
||||
}
|
||||
|
||||
function isStandaloneMode() {
|
||||
return (
|
||||
window.matchMedia('(display-mode: standalone)').matches ||
|
||||
(window.navigator as Navigator & { standalone?: boolean }).standalone === true
|
||||
);
|
||||
}
|
||||
|
||||
/** PWA 安装按钮:beforeinstallprompt / appinstalled */
|
||||
export function useInstallPrompt(showToast: (msg: string, type?: 'success' | 'error') => void) {
|
||||
const [visible, setVisible] = useState(false);
|
||||
const deferredRef = useRef<BeforeInstallPromptEvent | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isStandaloneMode()) return;
|
||||
|
||||
const onBeforeInstall = (e: Event) => {
|
||||
e.preventDefault();
|
||||
deferredRef.current = e as BeforeInstallPromptEvent;
|
||||
setVisible(true);
|
||||
};
|
||||
|
||||
const onInstalled = () => {
|
||||
deferredRef.current = null;
|
||||
setVisible(false);
|
||||
showToast('应用安装成功');
|
||||
};
|
||||
|
||||
window.addEventListener('beforeinstallprompt', onBeforeInstall);
|
||||
window.addEventListener('appinstalled', onInstalled);
|
||||
return () => {
|
||||
window.removeEventListener('beforeinstallprompt', onBeforeInstall);
|
||||
window.removeEventListener('appinstalled', onInstalled);
|
||||
};
|
||||
}, [showToast]);
|
||||
|
||||
async function triggerInstall() {
|
||||
const ev = deferredRef.current;
|
||||
if (!ev) return;
|
||||
await ev.prompt();
|
||||
const choice = await ev.userChoice;
|
||||
if (choice.outcome === 'accepted') showToast('已触发安装流程');
|
||||
deferredRef.current = null;
|
||||
setVisible(false);
|
||||
}
|
||||
|
||||
return { installVisible: visible, triggerInstall };
|
||||
}
|
||||
47
frontend/src/hooks/useServiceWorker.ts
Normal file
47
frontend/src/hooks/useServiceWorker.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { useEffect } from 'react';
|
||||
import { IS_DESKTOP } from '../config/site';
|
||||
|
||||
/** 注册 SW:新版本 confirm + SKIP_WAITING;首次 controller 变化不强制刷新 */
|
||||
export function useServiceWorker() {
|
||||
useEffect(() => {
|
||||
if (IS_DESKTOP) return;
|
||||
if (!('serviceWorker' in navigator)) return;
|
||||
|
||||
const pageAlreadyControlled = Boolean(navigator.serviceWorker.controller);
|
||||
let hasRefreshing = false;
|
||||
|
||||
function promptRefresh(worker: ServiceWorker) {
|
||||
const shouldRefresh = window.confirm('发现新版本,是否立即刷新?');
|
||||
if (shouldRefresh) worker.postMessage('SKIP_WAITING');
|
||||
}
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const reg = await navigator.serviceWorker.register('/sw.js', { scope: '/' });
|
||||
|
||||
if (reg.waiting) {
|
||||
promptRefresh(reg.waiting);
|
||||
}
|
||||
|
||||
reg.addEventListener('updatefound', () => {
|
||||
const newWorker = reg.installing;
|
||||
if (!newWorker) return;
|
||||
newWorker.addEventListener('statechange', () => {
|
||||
if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
|
||||
promptRefresh(newWorker);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
navigator.serviceWorker.addEventListener('controllerchange', () => {
|
||||
if (hasRefreshing) return;
|
||||
if (!pageAlreadyControlled) return;
|
||||
hasRefreshing = true;
|
||||
window.location.reload();
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('Service Worker 注册失败:', e);
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
}
|
||||
109
frontend/src/hooks/useSiteBranding.ts
Normal file
109
frontend/src/hooks/useSiteBranding.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { useEffect } from 'react';
|
||||
import {
|
||||
SITE_BACKGROUND_BLUR,
|
||||
SITE_DESCRIPTION,
|
||||
SITE_GLASS_OPACITY,
|
||||
SITE_NAME,
|
||||
SITE_RANDOM_BG_API,
|
||||
SITE_SHORT_NAME,
|
||||
} from '../config/site';
|
||||
|
||||
/** 随路由更新标题与 meta */
|
||||
export function useSiteBranding(isAdminRoute: boolean) {
|
||||
useEffect(() => {
|
||||
const pageTitle = isAdminRoute ? `${SITE_NAME}-管理后台` : SITE_NAME;
|
||||
document.title = pageTitle;
|
||||
const metaDesc = document.querySelector('meta[name="description"]');
|
||||
if (metaDesc) metaDesc.setAttribute('content', SITE_DESCRIPTION);
|
||||
const metaApp = document.querySelector('meta[name="apple-mobile-web-app-title"]');
|
||||
if (metaApp) metaApp.setAttribute('content', SITE_NAME);
|
||||
}, [isAdminRoute]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof SITE_GLASS_OPACITY === 'number' && SITE_GLASS_OPACITY >= 0 && SITE_GLASS_OPACITY <= 1) {
|
||||
document.documentElement.style.setProperty('--site-glass-opacity', String(SITE_GLASS_OPACITY));
|
||||
}
|
||||
if (SITE_BACKGROUND_BLUR) {
|
||||
document.documentElement.style.setProperty('--site-bg-blur', SITE_BACKGROUND_BLUR);
|
||||
}
|
||||
|
||||
const manifest = {
|
||||
name: SITE_NAME,
|
||||
short_name: SITE_SHORT_NAME,
|
||||
description: SITE_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: '#ea580c',
|
||||
background_color: '#fff7ed',
|
||||
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',
|
||||
},
|
||||
],
|
||||
};
|
||||
const blob = new Blob([JSON.stringify(manifest)], { type: 'application/json' });
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
let link = document.querySelector('link[rel="manifest"]') as HTMLLinkElement | null;
|
||||
if (!link) {
|
||||
link = document.createElement('link');
|
||||
link.rel = 'manifest';
|
||||
document.head.appendChild(link);
|
||||
}
|
||||
link.href = blobUrl;
|
||||
|
||||
const apiBase = SITE_RANDOM_BG_API?.trim();
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
function cleanupBg() {
|
||||
document.querySelectorAll('.site-bg').forEach((el) => el.remove());
|
||||
}
|
||||
|
||||
if (apiBase) {
|
||||
const isMobile = window.matchMedia('(max-width: 767px)').matches;
|
||||
const mode = isMobile ? 'mobile' : 'desktop';
|
||||
const jsonUrl = `${apiBase.replace(/\/$/, '')}/api/random?format=json&mode=${encodeURIComponent(mode)}`;
|
||||
|
||||
fetch(jsonUrl)
|
||||
.then((r) => {
|
||||
if (!r.ok) throw new Error('bg api ' + r.status);
|
||||
return r.json();
|
||||
})
|
||||
.then((data: { url?: string; src?: string }) => {
|
||||
if (cancelled || !data) return;
|
||||
const imgUrl = data.url || data.src;
|
||||
if (!imgUrl || typeof imgUrl !== 'string') return;
|
||||
const applyBg = () => {
|
||||
cleanupBg();
|
||||
const bgEl = document.createElement('div');
|
||||
bgEl.className = 'site-bg';
|
||||
bgEl.setAttribute('aria-hidden', 'true');
|
||||
bgEl.style.backgroundImage = `url("${imgUrl.replace(/"/g, '\\"')}")`;
|
||||
document.body.insertBefore(bgEl, document.body.firstChild);
|
||||
};
|
||||
if (document.body) applyBg();
|
||||
else document.addEventListener('DOMContentLoaded', applyBg);
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
URL.revokeObjectURL(blobUrl);
|
||||
cleanupBg();
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
10
frontend/src/main.tsx
Normal file
10
frontend/src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import App from './App';
|
||||
import './styles/global.css';
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>
|
||||
);
|
||||
435
frontend/src/pages/Admin.tsx
Normal file
435
frontend/src/pages/Admin.tsx
Normal file
@@ -0,0 +1,435 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import {
|
||||
addCategory,
|
||||
authCheck,
|
||||
deleteCategory,
|
||||
deleteSite,
|
||||
fetchCategories,
|
||||
fetchSiteById,
|
||||
fetchSites,
|
||||
renameCategory,
|
||||
saveSite,
|
||||
} from '../api/client';
|
||||
import { AdminSiteCard } from '../components/AdminSiteCard';
|
||||
import { Toast } from '../components/Toast';
|
||||
import { SITE_NAME } from '../config/site';
|
||||
import { useServiceWorker } from '../hooks/useServiceWorker';
|
||||
import type { Site } from '../types/site';
|
||||
import '../styles/admin.css';
|
||||
|
||||
const ADMIN_SESSION_KEY = 'mengya_nav_admin_session';
|
||||
|
||||
export default function Admin() {
|
||||
useServiceWorker();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
const [authToken, setAuthToken] = useState<string | null>(null);
|
||||
const [checking, setChecking] = useState(true);
|
||||
|
||||
const [categories, setCategories] = useState<string[]>([]);
|
||||
const [allSites, setAllSites] = useState<Site[]>([]);
|
||||
const [categoryFilter, setCategoryFilter] = useState('');
|
||||
const [newCategoryName, setNewCategoryName] = useState('');
|
||||
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const [editId, setEditId] = useState('');
|
||||
const [editName, setEditName] = useState('');
|
||||
const [editUrl, setEditUrl] = useState('');
|
||||
const [editDescription, setEditDescription] = useState('');
|
||||
const [editCategory, setEditCategory] = useState('');
|
||||
const [editTags, setEditTags] = useState('');
|
||||
|
||||
const [toast, setToast] = useState<{ message: string; type: 'success' | 'error'; visible: boolean }>({
|
||||
message: '',
|
||||
type: 'success',
|
||||
visible: false,
|
||||
});
|
||||
|
||||
const showToast = useCallback((message: string, type: 'success' | 'error' = 'success') => {
|
||||
setToast({ message, type, visible: true });
|
||||
window.setTimeout(() => setToast((t) => ({ ...t, visible: false })), 3000);
|
||||
}, []);
|
||||
|
||||
function redirectHome() {
|
||||
try {
|
||||
sessionStorage.removeItem(ADMIN_SESSION_KEY);
|
||||
} catch (_) {}
|
||||
navigate('/', { replace: true });
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
let token = searchParams.get('token');
|
||||
try {
|
||||
if (!token) token = sessionStorage.getItem(ADMIN_SESSION_KEY);
|
||||
} catch (_) {}
|
||||
|
||||
if (!token) {
|
||||
redirectHome();
|
||||
setChecking(false);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
const ok = await authCheck(token!);
|
||||
if (cancelled) return;
|
||||
if (!ok) {
|
||||
redirectHome();
|
||||
setChecking(false);
|
||||
return;
|
||||
}
|
||||
setAuthToken(token!);
|
||||
setChecking(false);
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- 仅在挂载时校验入口 token
|
||||
}, []);
|
||||
|
||||
const loadSites = useCallback(async () => {
|
||||
if (!authToken) return;
|
||||
try {
|
||||
const sites = await fetchSites({ headers: { Authorization: `Bearer ${authToken}` } });
|
||||
setAllSites(sites || []);
|
||||
} catch {
|
||||
showToast('加载网站列表失败', 'error');
|
||||
}
|
||||
}, [authToken, showToast]);
|
||||
|
||||
const loadCategoriesData = useCallback(async () => {
|
||||
if (!authToken) return;
|
||||
try {
|
||||
const c = await fetchCategories(authToken);
|
||||
setCategories(c);
|
||||
} catch (e) {
|
||||
if ((e as Error)?.message === 'unauthorized') redirectHome();
|
||||
else setCategories([]);
|
||||
}
|
||||
}, [authToken]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!authToken) return;
|
||||
void loadSites();
|
||||
void loadCategoriesData();
|
||||
}, [authToken, loadSites, loadCategoriesData]);
|
||||
|
||||
const mergedCategoryOptions = useMemo(() => {
|
||||
const fromSites = (allSites || []).map((s) => s.category || '默认').filter(Boolean);
|
||||
return Array.from(new Set(['默认', ...categories, ...fromSites]));
|
||||
}, [categories, allSites]);
|
||||
|
||||
const filterSelectOptions = useMemo(() => {
|
||||
return Array.from(new Set(['默认', ...categories, ...(allSites || []).map((s) => s.category || '默认')]));
|
||||
}, [categories, allSites]);
|
||||
|
||||
const displayedSites = useMemo(() => {
|
||||
if (!categoryFilter) return allSites;
|
||||
return allSites.filter((s) => (s.category || '默认') === categoryFilter);
|
||||
}, [allSites, categoryFilter]);
|
||||
|
||||
const sitesByCategory = useMemo(() => {
|
||||
const map: Record<string, Site[]> = {};
|
||||
for (const site of displayedSites) {
|
||||
const c = site.category || '未分类';
|
||||
if (!map[c]) map[c] = [];
|
||||
map[c].push(site);
|
||||
}
|
||||
return map;
|
||||
}, [displayedSites]);
|
||||
|
||||
const sortedCategoryKeys = useMemo(() => Object.keys(sitesByCategory).sort(), [sitesByCategory]);
|
||||
|
||||
function openAddModal() {
|
||||
if (!authToken) return;
|
||||
setEditId('');
|
||||
setEditName('');
|
||||
setEditUrl('');
|
||||
setEditDescription('');
|
||||
setEditTags('');
|
||||
setEditCategory('');
|
||||
setEditOpen(true);
|
||||
}
|
||||
|
||||
function openAddModalForCategory(sectionTitle: string) {
|
||||
if (!authToken) return;
|
||||
setEditId('');
|
||||
setEditName('');
|
||||
setEditUrl('');
|
||||
setEditDescription('');
|
||||
setEditTags('');
|
||||
setEditCategory(sectionTitle === '未分类' ? '' : sectionTitle);
|
||||
setEditOpen(true);
|
||||
}
|
||||
|
||||
async function openEditModal(id: string) {
|
||||
if (!authToken) return;
|
||||
try {
|
||||
const site = await fetchSiteById(id);
|
||||
setEditId(site.id);
|
||||
setEditName(site.name);
|
||||
setEditUrl(site.url);
|
||||
setEditDescription(site.description || '');
|
||||
setEditCategory(site.category || '');
|
||||
setEditTags(site.tags?.join(', ') || '');
|
||||
setEditOpen(true);
|
||||
} catch {
|
||||
showToast('加载网站信息失败', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function onDeleteSite(id: string) {
|
||||
if (!authToken || !confirm('确定要删除这个网站吗?')) return;
|
||||
const ok = await deleteSite(authToken, id);
|
||||
if (ok) {
|
||||
showToast('网站删除成功');
|
||||
await loadSites();
|
||||
} else showToast('删除失败', 'error');
|
||||
}
|
||||
|
||||
async function submitSite(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!authToken) return;
|
||||
let url = editUrl.trim();
|
||||
if (!url.startsWith('http://') && !url.startsWith('https://')) url = 'https://' + url;
|
||||
|
||||
const site: Partial<Site> = {
|
||||
name: editName.trim(),
|
||||
url,
|
||||
description: editDescription.trim(),
|
||||
category: editCategory,
|
||||
tags: editTags
|
||||
.split(',')
|
||||
.map((x) => x.trim())
|
||||
.filter(Boolean),
|
||||
};
|
||||
|
||||
const ok = await saveSite(authToken, site, editId || undefined);
|
||||
if (ok) {
|
||||
showToast(editId ? '网站更新成功' : '网站添加成功');
|
||||
setEditOpen(false);
|
||||
await loadSites();
|
||||
await loadCategoriesData();
|
||||
} else showToast('保存失败', 'error');
|
||||
}
|
||||
|
||||
async function onAddCategory() {
|
||||
const name = newCategoryName.trim();
|
||||
if (!authToken || !name) return;
|
||||
const ok = await addCategory(authToken, name);
|
||||
if (ok) {
|
||||
setNewCategoryName('');
|
||||
await loadCategoriesData();
|
||||
showToast('分类添加成功');
|
||||
} else showToast('分类添加失败', 'error');
|
||||
}
|
||||
|
||||
async function onRenameCategory(oldName: string) {
|
||||
if (!authToken) return;
|
||||
const next = prompt('请输入新的分类名称', oldName);
|
||||
if (!next || next.trim() === oldName) return;
|
||||
const ok = await renameCategory(authToken, oldName, next.trim());
|
||||
if (ok) {
|
||||
await loadCategoriesData();
|
||||
await loadSites();
|
||||
showToast('分类更新成功');
|
||||
} else showToast('分类更新失败', 'error');
|
||||
}
|
||||
|
||||
async function onDeleteCategory(name: string) {
|
||||
if (!authToken || !confirm(`确定删除分类「${name}」吗?`)) return;
|
||||
const ok = await deleteCategory(authToken, name);
|
||||
if (ok) {
|
||||
await loadCategoriesData();
|
||||
showToast('分类删除成功');
|
||||
} else showToast('分类删除失败', 'error');
|
||||
}
|
||||
|
||||
if (checking || !authToken) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container admin-page">
|
||||
<div className="admin-container visible" id="admin-container">
|
||||
<div className="admin-header">
|
||||
<h1 id="admin-title">{SITE_NAME}-管理后台</h1>
|
||||
<button
|
||||
type="button"
|
||||
className="exit-btn"
|
||||
id="logout-btn"
|
||||
onClick={() => {
|
||||
try {
|
||||
sessionStorage.removeItem(ADMIN_SESSION_KEY);
|
||||
} catch (_) {}
|
||||
navigate('/');
|
||||
}}
|
||||
>
|
||||
退出
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="categories-panel">
|
||||
<div className="panel-header">
|
||||
<h2>📁 分类管理</h2>
|
||||
<div className="category-add">
|
||||
<input
|
||||
type="text"
|
||||
id="new-category-name"
|
||||
placeholder="新增分类"
|
||||
value={newCategoryName}
|
||||
onChange={(e) => setNewCategoryName(e.target.value)}
|
||||
/>
|
||||
<button type="button" className="btn-edit" id="add-category-btn" onClick={() => void onAddCategory()}>
|
||||
添加分类
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="category-list" id="category-list">
|
||||
{!categories.length ? (
|
||||
<div style={{ color: '#64748b' }}>暂无分类</div>
|
||||
) : (
|
||||
categories.map((name) => (
|
||||
<div key={name} className="category-item">
|
||||
<span>{name}</span>
|
||||
<span className="category-actions">
|
||||
<button type="button" onClick={() => void onRenameCategory(name)}>
|
||||
编辑
|
||||
</button>
|
||||
<button type="button" onClick={() => void onDeleteCategory(name)}>
|
||||
删除
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: 20 }}>
|
||||
<button type="button" className="add-site-btn" id="add-new-site" onClick={openAddModal}>
|
||||
✚ 添加新网站
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="admin-sites-board">
|
||||
<div className="sites-table-header">
|
||||
<h2>网站预览</h2>
|
||||
<div className="sites-filter">
|
||||
<label htmlFor="site-category-filter">按分类筛选:</label>
|
||||
<select
|
||||
id="site-category-filter"
|
||||
title="选择分类可快速筛选网站"
|
||||
value={categoryFilter}
|
||||
onChange={(e) => setCategoryFilter(e.target.value)}
|
||||
>
|
||||
<option value="">全部</option>
|
||||
{filterSelectOptions.map((name) => (
|
||||
<option key={name} value={name}>
|
||||
{name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
{displayedSites.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<div style={{ fontSize: '3rem', marginBottom: 12 }}>📭</div>
|
||||
<p>{categoryFilter ? '该分类下暂无网站' : '暂无网站,点击上方添加'}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="categories-container admin-preview-categories">
|
||||
{sortedCategoryKeys.map((categoryName) => (
|
||||
<div key={categoryName} className="category-section">
|
||||
<h2 className="category-title">
|
||||
<span className="category-title-label">{categoryName}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="admin-category-quick-add"
|
||||
title={`在此分类下添加网站:${categoryName}`}
|
||||
aria-label={`在「${categoryName}」下添加网站`}
|
||||
onClick={() => openAddModalForCategory(categoryName)}
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</h2>
|
||||
<div className="sites-grid">
|
||||
{sitesByCategory[categoryName].map((site) => (
|
||||
<AdminSiteCard
|
||||
key={site.id}
|
||||
site={site}
|
||||
onEdit={(id) => void openEditModal(id)}
|
||||
onDelete={(id) => void onDeleteSite(id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Toast message={toast.message} type={toast.type} visible={toast.visible} />
|
||||
|
||||
<div
|
||||
className={`modal-overlay${editOpen ? ' active' : ''}`}
|
||||
id="edit-modal"
|
||||
onClick={(e) => {
|
||||
if ((e.target as HTMLElement).id === 'edit-modal') setEditOpen(false);
|
||||
}}
|
||||
>
|
||||
<div className="modal-content">
|
||||
<div className="modal-header">
|
||||
<h2 id="modal-title">{editId ? '编辑网站' : '添加网站'}</h2>
|
||||
<button type="button" className="close-modal" id="close-edit-modal" onClick={() => setEditOpen(false)}>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
<form id="edit-site-form" onSubmit={(e) => void submitSite(e)}>
|
||||
<input type="hidden" id="edit-site-id" value={editId} readOnly />
|
||||
<div className="form-group">
|
||||
<label htmlFor="edit-site-name">网站名称 *</label>
|
||||
<input type="text" id="edit-site-name" required value={editName} onChange={(e) => setEditName(e.target.value)} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="edit-site-url">网站地址 *</label>
|
||||
<input type="url" id="edit-site-url" required value={editUrl} onChange={(e) => setEditUrl(e.target.value)} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="edit-site-description">网站描述</label>
|
||||
<textarea id="edit-site-description" value={editDescription} onChange={(e) => setEditDescription(e.target.value)} />
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label htmlFor="edit-site-category">网站分类</label>
|
||||
<select id="edit-site-category" value={editCategory} onChange={(e) => setEditCategory(e.target.value)}>
|
||||
<option value="">未分类</option>
|
||||
{mergedCategoryOptions
|
||||
.filter((name) => name !== '未分类')
|
||||
.map((name) => (
|
||||
<option key={name} value={name}>
|
||||
{name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="edit-site-tags">网站标签</label>
|
||||
<input id="edit-site-tags" type="text" placeholder="用逗号分隔" value={editTags} onChange={(e) => setEditTags(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" className="submit-btn">
|
||||
保存
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
361
frontend/src/pages/Home.tsx
Normal file
361
frontend/src/pages/Home.tsx
Normal file
@@ -0,0 +1,361 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type { CSSProperties } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { fetchSites, postSiteClick, authLogin } from '../api/client';
|
||||
import { SiteCard } from '../components/SiteCard';
|
||||
import { Toast } from '../components/Toast';
|
||||
import { SITE_NAME } from '../config/site';
|
||||
import { useInstallPrompt } from '../hooks/useInstallPrompt';
|
||||
import { useServiceWorker } from '../hooks/useServiceWorker';
|
||||
import type { Site } from '../types/site';
|
||||
|
||||
const ADMIN_SESSION_STORAGE_KEY = 'mengya_nav_admin_session';
|
||||
const ADMIN_UNLOCK_CLICKS = 5;
|
||||
const ADMIN_UNLOCK_RESET_MS = 2800;
|
||||
|
||||
function performWebSearch(query: string, engine: string) {
|
||||
const encodedQuery = encodeURIComponent(query);
|
||||
const urls: Record<string, string> = {
|
||||
google: `https://www.google.com/search?q=${encodedQuery}`,
|
||||
baidu: `https://www.baidu.com/s?wd=${encodedQuery}`,
|
||||
bing: `https://www.bing.com/search?q=${encodedQuery}`,
|
||||
duckduckgo: `https://duckduckgo.com/?q=${encodedQuery}`,
|
||||
yandex: `https://yandex.com/search/?text=${encodedQuery}`,
|
||||
};
|
||||
window.open(urls[engine] || urls.google, '_blank');
|
||||
}
|
||||
|
||||
/** body 上切换 class,复用原侧边栏 CSS */
|
||||
function BodyClassWhen({ open, className }: { open: boolean; className: string }) {
|
||||
useEffect(() => {
|
||||
if (open) document.body.classList.add(className);
|
||||
else document.body.classList.remove(className);
|
||||
return () => document.body.classList.remove(className);
|
||||
}, [open, className]);
|
||||
return null;
|
||||
}
|
||||
|
||||
const installBtnStyle: CSSProperties = {
|
||||
position: 'fixed',
|
||||
bottom: 20,
|
||||
right: 20,
|
||||
background: 'linear-gradient(145deg, #ea580c, #c2410c)',
|
||||
color: '#fff',
|
||||
border: 'none',
|
||||
padding: '12px 24px',
|
||||
borderRadius: '10px',
|
||||
cursor: 'pointer',
|
||||
fontWeight: 600,
|
||||
fontSize: '0.95rem',
|
||||
boxShadow: '0 4px 14px rgba(234, 88, 12, 0.28)',
|
||||
zIndex: 1000,
|
||||
};
|
||||
|
||||
export default function Home() {
|
||||
useServiceWorker();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [sites, setSites] = useState<Site[]>([]);
|
||||
const [searchInput, setSearchInput] = useState('');
|
||||
const [activeCategory, setActiveCategory] = useState('all');
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
|
||||
const [toast, setToast] = useState<{ message: string; type: 'success' | 'error'; visible: boolean }>({
|
||||
message: '',
|
||||
type: 'success',
|
||||
visible: false,
|
||||
});
|
||||
|
||||
const showToast = useCallback((message: string, type: 'success' | 'error' = 'success') => {
|
||||
setToast({ message, type, visible: true });
|
||||
window.setTimeout(() => setToast((t) => ({ ...t, visible: false })), 3000);
|
||||
}, []);
|
||||
|
||||
const { installVisible, triggerInstall } = useInstallPrompt(showToast);
|
||||
|
||||
const [adminModalOpen, setAdminModalOpen] = useState(false);
|
||||
const [adminPassword, setAdminPassword] = useState('');
|
||||
const [adminUnlockError, setAdminUnlockError] = useState('');
|
||||
const logoClickRef = useRef(0);
|
||||
const logoResetTimerRef = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const data = await fetchSites();
|
||||
if (!cancelled) setSites(data);
|
||||
} catch {
|
||||
if (!cancelled) showToast('加载网站数据失败', 'error');
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [showToast]);
|
||||
|
||||
const categoriesList = useMemo(
|
||||
() => ['all', ...new Set(sites.map((s) => s.category).filter(Boolean))] as string[],
|
||||
[sites]
|
||||
);
|
||||
|
||||
const stats = useMemo(() => {
|
||||
const totalSites = sites.length;
|
||||
const cats = [...new Set(sites.map((s) => s.category).filter(Boolean))];
|
||||
const allTags = sites.flatMap((s) => s.tags || []);
|
||||
const uniqueTags = [...new Set(allTags)];
|
||||
return { totalSites, categoriesCount: cats.length, tagsCount: uniqueTags.length };
|
||||
}, [sites]);
|
||||
|
||||
const filteredGrouped = useMemo(() => {
|
||||
let list = sites;
|
||||
if (searchInput.trim()) {
|
||||
const q = searchInput.toLowerCase();
|
||||
list = list.filter(
|
||||
(site) =>
|
||||
site.name.toLowerCase().includes(q) ||
|
||||
(site.description && site.description.toLowerCase().includes(q)) ||
|
||||
(site.tags && site.tags.some((tag) => tag.toLowerCase().includes(q)))
|
||||
);
|
||||
}
|
||||
if (activeCategory !== 'all') {
|
||||
list = list.filter((s) => s.category === activeCategory);
|
||||
}
|
||||
const map: Record<string, Site[]> = {};
|
||||
list.forEach((site) => {
|
||||
const c = site.category || '未分类';
|
||||
if (!map[c]) map[c] = [];
|
||||
map[c].push(site);
|
||||
});
|
||||
return map;
|
||||
}, [sites, searchInput, activeCategory]);
|
||||
|
||||
function onLogoClick(e: React.MouseEvent) {
|
||||
e.preventDefault();
|
||||
logoClickRef.current += 1;
|
||||
if (logoResetTimerRef.current) window.clearTimeout(logoResetTimerRef.current);
|
||||
logoResetTimerRef.current = window.setTimeout(() => {
|
||||
logoClickRef.current = 0;
|
||||
logoResetTimerRef.current = null;
|
||||
}, ADMIN_UNLOCK_RESET_MS);
|
||||
if (logoClickRef.current >= ADMIN_UNLOCK_CLICKS) {
|
||||
logoClickRef.current = 0;
|
||||
if (logoResetTimerRef.current) window.clearTimeout(logoResetTimerRef.current);
|
||||
setAdminPassword('');
|
||||
setAdminUnlockError('');
|
||||
setAdminModalOpen(true);
|
||||
}
|
||||
}
|
||||
|
||||
async function submitAdminUnlock() {
|
||||
setAdminUnlockError('');
|
||||
try {
|
||||
const data = await authLogin(adminPassword);
|
||||
if (!data.ok || !data.sessionId) {
|
||||
setAdminUnlockError('密码错误或无法登录');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
sessionStorage.setItem(ADMIN_SESSION_STORAGE_KEY, data.sessionId);
|
||||
} catch (_) {}
|
||||
setAdminModalOpen(false);
|
||||
navigate('/admin');
|
||||
} catch {
|
||||
setAdminUnlockError('网络错误,请稍后重试');
|
||||
}
|
||||
}
|
||||
|
||||
function onContainerClick(e: React.MouseEvent<HTMLDivElement>) {
|
||||
const card = (e.target as HTMLElement).closest('.site-card') as HTMLElement | null;
|
||||
if (!card) return;
|
||||
const id = card.dataset.siteId;
|
||||
if (id) void postSiteClick(id);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<BodyClassWhen open={sidebarOpen} className="category-sidebar-open" />
|
||||
|
||||
<div className="container">
|
||||
<header className="main-header">
|
||||
<button type="button" className="site-logo-mark" id="site-logo-mark" aria-label="站点图标" onClick={onLogoClick}>
|
||||
<img src="/logo.png" alt="" width={44} height={44} decoding="async" />
|
||||
</button>
|
||||
<h1 id="site-title">{SITE_NAME}</h1>
|
||||
</header>
|
||||
|
||||
<div className="web-search-box">
|
||||
<form
|
||||
id="web-search-form"
|
||||
autoComplete="off"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
const fd = new FormData(e.currentTarget);
|
||||
const q = String(fd.get('q') || '').trim();
|
||||
const engine = String(fd.get('engine') || 'google');
|
||||
if (q) {
|
||||
performWebSearch(q, engine);
|
||||
e.currentTarget.reset();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="search-wrapper">
|
||||
<span className="search-icon">🔍</span>
|
||||
<input
|
||||
type="search"
|
||||
id="web-search-input"
|
||||
name="q"
|
||||
placeholder="搜索网页..."
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
/>
|
||||
<select id="search-engine" name="engine" className="search-select" defaultValue="google">
|
||||
<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 className="top-bar">
|
||||
<div className="stats-group">
|
||||
<div className="stats-item">
|
||||
📚 网站总数: <span id="total-sites">{stats.totalSites}</span>
|
||||
</div>
|
||||
<div className="stats-item">
|
||||
📁 分类数量: <span id="total-categories">{stats.categoriesCount}</span>
|
||||
</div>
|
||||
<div className="stats-item">
|
||||
🏷️ 标签数量: <span id="total-tags">{stats.tagsCount}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="search-container compact">
|
||||
<span className="search-icon">🔍</span>
|
||||
<input
|
||||
type="search"
|
||||
id="search-input"
|
||||
name="site-filter"
|
||||
placeholder="搜索网站、描述或标签..."
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="categories-container" id="categories-container" onClick={onContainerClick}>
|
||||
{sites.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<div style={{ fontSize: '4rem', marginBottom: '15px' }}>📭</div>
|
||||
<h2>暂无网站</h2>
|
||||
<p>请在后台管理中添加网站</p>
|
||||
</div>
|
||||
) : Object.keys(filteredGrouped).length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<div style={{ fontSize: '4rem', marginBottom: '15px' }}>🔍</div>
|
||||
<h2>没有找到匹配的网站</h2>
|
||||
<p>尝试调整搜索关键词或分类筛选条件</p>
|
||||
</div>
|
||||
) : (
|
||||
Object.keys(filteredGrouped)
|
||||
.sort()
|
||||
.map((category) => (
|
||||
<div key={category} className="category-section">
|
||||
<h2 className="category-title">{category}</h2>
|
||||
<div className="sites-grid">
|
||||
{filteredGrouped[category].map((site) => (
|
||||
<SiteCard key={site.id} site={site} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Toast message={toast.message} type={toast.type} visible={toast.visible} />
|
||||
|
||||
<button
|
||||
type="button"
|
||||
id="category-sidebar-toggle"
|
||||
className="category-toggle-btn category-toggle-fixed"
|
||||
aria-label="打开分类"
|
||||
onClick={() => setSidebarOpen(true)}
|
||||
>
|
||||
📁 分类
|
||||
</button>
|
||||
<div
|
||||
id="category-sidebar-backdrop"
|
||||
className="category-sidebar-backdrop"
|
||||
aria-hidden={!sidebarOpen}
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
/>
|
||||
<aside id="category-sidebar" className="category-sidebar" aria-label="分类筛选">
|
||||
<div className="category-sidebar-header">
|
||||
<h2>分类</h2>
|
||||
<button type="button" className="category-sidebar-close" id="category-sidebar-close" aria-label="关闭" onClick={() => setSidebarOpen(false)}>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div className="category-sidebar-list" id="category-filters">
|
||||
{categoriesList.map((cat) => (
|
||||
<button
|
||||
key={cat}
|
||||
type="button"
|
||||
className={`category-filter${activeCategory === cat ? ' active' : ''}`}
|
||||
data-category={cat}
|
||||
onClick={() => {
|
||||
setActiveCategory(cat);
|
||||
setSidebarOpen(false);
|
||||
}}
|
||||
>
|
||||
{cat === 'all' ? '全部' : cat}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{installVisible ? (
|
||||
<button type="button" className="install-btn" style={installBtnStyle} onClick={() => void triggerInstall()}>
|
||||
📲 安装应用
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
<div className={`admin-unlock-modal${adminModalOpen ? ' is-open' : ''}`} id="admin-unlock-modal" aria-hidden={!adminModalOpen}>
|
||||
<div className="admin-unlock-backdrop" id="admin-unlock-backdrop" onClick={() => setAdminModalOpen(false)} />
|
||||
<div className="admin-unlock-dialog" role="dialog" aria-labelledby="admin-unlock-title">
|
||||
<h3 id="admin-unlock-title">管理入口</h3>
|
||||
{adminUnlockError ? (
|
||||
<p className="admin-unlock-error" id="admin-unlock-error" role="alert">
|
||||
{adminUnlockError}
|
||||
</p>
|
||||
) : null}
|
||||
<input
|
||||
type="password"
|
||||
id="admin-unlock-input"
|
||||
className="admin-unlock-input"
|
||||
placeholder="管理密码"
|
||||
autoComplete="current-password"
|
||||
value={adminPassword}
|
||||
onChange={(e) => setAdminPassword(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') void submitAdminUnlock();
|
||||
}}
|
||||
/>
|
||||
<div className="admin-unlock-actions">
|
||||
<button type="button" className="admin-unlock-btn primary" id="admin-unlock-submit" onClick={() => void submitAdminUnlock()}>
|
||||
进入后台
|
||||
</button>
|
||||
<button type="button" className="admin-unlock-btn" id="admin-unlock-cancel" onClick={() => setAdminModalOpen(false)}>
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
331
frontend/src/styles/admin.css
Normal file
331
frontend/src/styles/admin.css
Normal file
@@ -0,0 +1,331 @@
|
||||
/* 后台管理区块(由原 admin.html 内联样式迁入) */
|
||||
.admin-container {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.admin-container.visible {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.admin-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
padding: 15px 20px;
|
||||
background: rgba(255, 255, 255, var(--site-glass-opacity, 0.92));
|
||||
border-radius: var(--border-radius, 10px);
|
||||
box-shadow: var(--box-shadow, 0 2px 10px rgba(0, 0, 0, 0.08));
|
||||
}
|
||||
|
||||
.logout-btn {
|
||||
background: #dc2626;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 8px 16px;
|
||||
border-radius: 8px;
|
||||
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: 8px;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.exit-btn:hover {
|
||||
background: #475569;
|
||||
}
|
||||
|
||||
.admin-sites-board {
|
||||
background: rgba(255, 255, 255, var(--site-glass-opacity, 0.92));
|
||||
border-radius: var(--border-radius, 10px);
|
||||
padding: 20px;
|
||||
box-shadow: var(--box-shadow, 0 2px 10px rgba(0, 0, 0, 0.08));
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.admin-preview-categories {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.admin-preview-categories .category-title {
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.admin-preview-categories .category-title .category-title-label {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.admin-category-quick-add {
|
||||
flex-shrink: 0;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(var(--primary-rgb, 234, 88, 12), 0.35);
|
||||
background: #fff;
|
||||
color: var(--primary-color, #ea580c);
|
||||
font-size: 1.25rem;
|
||||
line-height: 1;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
transition: all 0.2s ease;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
.admin-category-quick-add:hover {
|
||||
background: var(--primary-color, #ea580c);
|
||||
color: #fff;
|
||||
border-color: var(--primary-color, #ea580c);
|
||||
}
|
||||
|
||||
.admin-category-quick-add:focus-visible {
|
||||
outline: 2px solid var(--primary-color, #ea580c);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* 后台卡片:与首页同款 site-card,禁用整卡跳转,底部为操作条 */
|
||||
.admin-site-card {
|
||||
cursor: default;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
min-height: 158px;
|
||||
}
|
||||
|
||||
.admin-site-card:hover {
|
||||
border-color: rgba(var(--primary-rgb), 0.25);
|
||||
}
|
||||
|
||||
.admin-site-card-toolbar {
|
||||
margin-top: auto;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
.admin-site-card .admin-card-btn {
|
||||
padding: 5px 10px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.admin-site-card .admin-card-btn.edit {
|
||||
background: var(--primary-color, #ea580c);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.admin-site-card .admin-card-btn.edit:hover {
|
||||
background: var(--secondary-color, #c2410c);
|
||||
}
|
||||
|
||||
.admin-site-card .admin-card-btn.delete {
|
||||
background: #ef4444;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.admin-site-card .admin-card-btn.delete:hover {
|
||||
background: #dc2626;
|
||||
}
|
||||
|
||||
.admin-site-card .admin-card-open {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
color: #475569;
|
||||
text-decoration: none;
|
||||
padding: 5px 8px;
|
||||
border-radius: 6px;
|
||||
background: rgba(241, 245, 249, 0.9);
|
||||
}
|
||||
|
||||
.admin-site-card .admin-card-open:hover {
|
||||
color: var(--primary-color, #ea580c);
|
||||
background: #ffedd5;
|
||||
}
|
||||
|
||||
.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: var(--primary-color, #ea580c);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-edit:hover {
|
||||
background: var(--secondary-color, #c2410c);
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.tag-display {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.tag-display .tag {
|
||||
background: #ffedd5;
|
||||
color: var(--secondary-color, #c2410c);
|
||||
padding: 2px 8px;
|
||||
border-radius: 6px;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.categories-panel {
|
||||
background: rgba(255, 255, 255, var(--site-glass-opacity, 0.92));
|
||||
border-radius: var(--border-radius, 10px);
|
||||
padding: 16px 20px;
|
||||
box-shadow: var(--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: #fff7ed;
|
||||
border: 1px solid rgba(var(--primary-rgb), 0.2);
|
||||
padding: 6px 10px;
|
||||
border-radius: 8px;
|
||||
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: var(--primary-color, #ea580c);
|
||||
}
|
||||
|
||||
.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: var(--primary-color, #ea580c);
|
||||
}
|
||||
@@ -5,39 +5,77 @@
|
||||
}
|
||||
|
||||
:root {
|
||||
--primary-color: #4361ee;
|
||||
--secondary-color: #3f37c9;
|
||||
--success-color: #4cc9f0;
|
||||
/* 强制思源黑体(Google Fonts 名:Noto Sans SC);未加载时落到系统无衬线 */
|
||||
--font-sans: "Noto Sans SC", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Segoe UI", system-ui, sans-serif;
|
||||
--font-mono: "Source Code Pro", Menlo, Monaco, Consolas, "Courier New", monospace;
|
||||
--primary-color: #ea580c;
|
||||
--primary-rgb: 234, 88, 12;
|
||||
--secondary-color: #c2410c;
|
||||
--accent-warm: #fb923c;
|
||||
--success-color: #16a34a;
|
||||
--dark-color: #1e293b;
|
||||
--light-color: #f8fafc;
|
||||
--light-color: #fff7ed;
|
||||
--gray-color: #64748b;
|
||||
--border-radius: 10px;
|
||||
--box-shadow: 0 4px 18px rgba(0, 0, 0, 0.08);
|
||||
--transition: all 0.25s ease;
|
||||
--border-radius: 8px;
|
||||
--box-shadow: 0 2px 12px rgba(15, 23, 42, 0.06);
|
||||
--transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
background: linear-gradient(135deg, #f5f7fa 0%, #e4edf5 100%);
|
||||
font-family: var(--font-sans);
|
||||
background: transparent;
|
||||
color: var(--dark-color);
|
||||
line-height: 1.5;
|
||||
padding: 12px;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* 全屏背景图层:仅当 config 中 SITE_BACKGROUND_IMAGES 非空时由 JS 插入,约 30% 高斯模糊 */
|
||||
/* 暖色底放在 html:若 body 带渐变背景,会盖住固定 .site-bg,随机图会「看不见」 */
|
||||
html {
|
||||
min-height: 100%;
|
||||
background: linear-gradient(145deg, #fff7ed 0%, #ffedd5 45%, #fed7aa 100%);
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
#root {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
code,
|
||||
kbd,
|
||||
samp,
|
||||
pre {
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
/* 全屏背景:由 SITE_RANDOM_BG_API 注入;模糊与暖色罩层减轻「AI 人像 + 强玻璃」感 */
|
||||
.site-bg {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: -1;
|
||||
z-index: 0;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
filter: blur(10px);
|
||||
opacity: 0.85;
|
||||
filter: blur(var(--site-bg-blur, 0px));
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
.site-bg::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(
|
||||
165deg,
|
||||
rgba(255, 247, 237, 0.45) 0%,
|
||||
rgba(255, 237, 213, 0.5) 55%,
|
||||
rgba(254, 215, 170, 0.55) 100%
|
||||
);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.container {
|
||||
@@ -55,16 +93,144 @@ header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
padding: 10px 0;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.main-header h1 {
|
||||
margin: 0;
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.site-logo-mark {
|
||||
flex-shrink: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
border-radius: 8px;
|
||||
line-height: 0;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
.site-logo-mark img {
|
||||
display: block;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
object-fit: cover;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
.site-logo-mark:focus-visible {
|
||||
outline: 2px solid var(--primary-color);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* 连点 logo 后的管理入口弹层 */
|
||||
.admin-unlock-modal {
|
||||
display: none;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 10001;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.admin-unlock-modal.is-open {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.admin-unlock-backdrop {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(15, 23, 42, 0.45);
|
||||
}
|
||||
|
||||
.admin-unlock-dialog {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-width: 360px;
|
||||
padding: 24px;
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.admin-unlock-dialog h3 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 1.15rem;
|
||||
color: var(--dark-color);
|
||||
}
|
||||
|
||||
.admin-unlock-hint {
|
||||
margin: 0 0 12px;
|
||||
font-size: 0.875rem;
|
||||
color: var(--gray-color);
|
||||
}
|
||||
|
||||
.admin-unlock-hint code {
|
||||
font-size: 0.8em;
|
||||
background: #f1f5f9;
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.admin-unlock-error {
|
||||
margin: 0 0 10px;
|
||||
font-size: 0.875rem;
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.admin-unlock-input {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
font-size: 1rem;
|
||||
margin-bottom: 16px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.admin-unlock-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: 0 0 0 3px rgba(var(--primary-rgb), 0.22);
|
||||
}
|
||||
|
||||
.admin-unlock-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.admin-unlock-btn {
|
||||
padding: 8px 16px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e2e8f0;
|
||||
background: #f8fafc;
|
||||
color: var(--dark-color);
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.admin-unlock-btn.primary {
|
||||
background: var(--primary-color);
|
||||
color: #fff;
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.admin-unlock-btn:hover {
|
||||
filter: brightness(0.97);
|
||||
}
|
||||
|
||||
/* 左上角固定「分类」按钮:不随页面滚动 */
|
||||
.category-toggle-fixed {
|
||||
position: fixed;
|
||||
@@ -80,21 +246,21 @@ body.category-sidebar-open .category-toggle-fixed {
|
||||
.category-toggle-btn {
|
||||
padding: 8px 14px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(67, 97, 238, 0.35);
|
||||
background: white;
|
||||
border: 1px solid rgba(var(--primary-rgb), 0.35);
|
||||
background: #fff;
|
||||
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);
|
||||
box-shadow: 0 1px 6px rgba(var(--primary-rgb), 0.12);
|
||||
}
|
||||
|
||||
.category-toggle-btn:hover {
|
||||
background: var(--primary-color);
|
||||
color: white;
|
||||
color: #fff;
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: 0 4px 12px rgba(67, 97, 238, 0.25);
|
||||
box-shadow: 0 2px 10px rgba(var(--primary-rgb), 0.22);
|
||||
}
|
||||
|
||||
/* 分类侧边栏:固定不随页面滚动 */
|
||||
@@ -189,7 +355,7 @@ body.category-sidebar-open {
|
||||
width: 100%;
|
||||
padding: 12px 14px;
|
||||
text-align: left;
|
||||
border-radius: 10px;
|
||||
border-radius: 8px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
@@ -199,13 +365,6 @@ body.category-sidebar-open {
|
||||
|
||||
|
||||
/* 隐藏整个页面的滚动条 */
|
||||
html {
|
||||
/* 隐藏IE/Edge滚动条 */
|
||||
-ms-overflow-style: none;
|
||||
/* 隐藏Firefox滚动条 */
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
/* 隐藏Chrome/Safari/Opera滚动条 */
|
||||
html::-webkit-scrollbar {
|
||||
display: none;
|
||||
@@ -227,7 +386,7 @@ body {
|
||||
h1 {
|
||||
font-size: 2.1rem;
|
||||
margin-bottom: 6px;
|
||||
background: linear-gradient(45deg, var(--primary-color), var(--success-color));
|
||||
background: linear-gradient(120deg, var(--secondary-color) 0%, var(--primary-color) 55%, var(--accent-warm) 100%);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
@@ -240,12 +399,12 @@ h1 {
|
||||
}
|
||||
|
||||
.web-search-box {
|
||||
background: rgba(255, 255, 255, var(--site-glass-opacity, 0.78));
|
||||
border-radius: 14px;
|
||||
background: rgba(255, 255, 255, var(--site-glass-opacity, 0.92));
|
||||
border-radius: 10px;
|
||||
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);
|
||||
box-shadow: var(--box-shadow);
|
||||
border: 1px solid rgba(var(--primary-rgb), 0.18);
|
||||
}
|
||||
|
||||
#web-search-form {
|
||||
@@ -262,13 +421,13 @@ h1 {
|
||||
#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;
|
||||
padding: 14px 16px 14px 44px;
|
||||
border: 1px solid rgba(var(--primary-rgb), 0.28);
|
||||
border-radius: 10px;
|
||||
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);
|
||||
background: #fffefb;
|
||||
box-shadow: inset 0 1px 2px rgba(15, 23, 42, 0.04);
|
||||
}
|
||||
|
||||
#web-search-input::placeholder {
|
||||
@@ -276,15 +435,14 @@ h1 {
|
||||
}
|
||||
|
||||
#web-search-input:hover {
|
||||
border-color: var(--primary-color);
|
||||
border-color: rgba(var(--primary-rgb), 0.45);
|
||||
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);
|
||||
box-shadow: 0 0 0 3px rgba(var(--primary-rgb), 0.2);
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
@@ -316,7 +474,7 @@ h1 {
|
||||
.search-select:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: 0 0 0 3px rgba(67, 97, 238, 0.15);
|
||||
box-shadow: 0 0 0 3px rgba(var(--primary-rgb), 0.15);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
@@ -355,18 +513,18 @@ h1 {
|
||||
}
|
||||
|
||||
.add-site-btn {
|
||||
background: linear-gradient(45deg, var(--primary-color), var(--secondary-color));
|
||||
color: white;
|
||||
background: linear-gradient(145deg, var(--primary-color), var(--secondary-color));
|
||||
color: #fff;
|
||||
border: none;
|
||||
padding: 9px 18px;
|
||||
font-size: 0.95rem;
|
||||
border-radius: 50px;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
margin: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 4px 12px rgba(67, 97, 238, 0.28);
|
||||
box-shadow: 0 2px 8px rgba(var(--primary-rgb), 0.25);
|
||||
transition: var(--transition);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.2px;
|
||||
@@ -374,8 +532,8 @@ h1 {
|
||||
}
|
||||
|
||||
.add-site-btn:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 18px rgba(67, 97, 238, 0.4);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(var(--primary-rgb), 0.28);
|
||||
}
|
||||
|
||||
.add-site-btn:active {
|
||||
@@ -474,7 +632,7 @@ h1 {
|
||||
.form-group select:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: 0 0 0 3px rgba(67, 97, 238, 0.18);
|
||||
box-shadow: 0 0 0 3px rgba(var(--primary-rgb), 0.18);
|
||||
}
|
||||
|
||||
.form-group textarea {
|
||||
@@ -508,7 +666,7 @@ h1 {
|
||||
|
||||
.submit-btn:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 10px rgba(67, 97, 238, 0.25);
|
||||
box-shadow: 0 4px 10px rgba(var(--primary-rgb), 0.22);
|
||||
}
|
||||
|
||||
.categories-container {
|
||||
@@ -517,16 +675,26 @@ h1 {
|
||||
}
|
||||
|
||||
.category-section {
|
||||
background: rgba(255, 255, 255, var(--site-glass-opacity, 0.78));
|
||||
background: transparent;
|
||||
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);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
/*
|
||||
* 首页 #categories-container 专用:外层 section 已无底色,站内链接卡若仍近乎实白会像「一大条白底板」;
|
||||
* Admin 预览区同名 class 无该 id,不会套用。
|
||||
*/
|
||||
#categories-container .site-card {
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
#categories-container .site-card:hover {
|
||||
background: rgba(255, 255, 255, 0.62);
|
||||
}
|
||||
|
||||
.category-title {
|
||||
@@ -557,8 +725,8 @@ h1 {
|
||||
}
|
||||
|
||||
.site-card {
|
||||
background: rgba(255, 255, 255, var(--site-glass-opacity, 0.78));
|
||||
border-radius: 12px;
|
||||
background: rgba(255, 255, 255, var(--site-glass-opacity, 0.92));
|
||||
border-radius: 8px;
|
||||
padding: 14px 10px;
|
||||
text-align: center;
|
||||
transition: var(--transition);
|
||||
@@ -590,7 +758,7 @@ h1 {
|
||||
.site-icon {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: 12px;
|
||||
border-radius: 8px;
|
||||
background-color: #f1f5f9;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -654,17 +822,17 @@ h1 {
|
||||
}
|
||||
|
||||
.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);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(15, 23, 42, 0.1);
|
||||
border-color: rgba(var(--primary-rgb), 0.22);
|
||||
}
|
||||
|
||||
.tag {
|
||||
background: #eef2ff;
|
||||
color: var(--primary-color);
|
||||
background: #ffedd5;
|
||||
color: var(--secondary-color);
|
||||
font-size: 0.65rem;
|
||||
padding: 2px 6px;
|
||||
border-radius: 10px;
|
||||
border-radius: 6px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
@@ -686,7 +854,7 @@ h1 {
|
||||
}
|
||||
|
||||
.top-bar {
|
||||
background: rgba(255, 255, 255, var(--site-glass-opacity, 0.78));
|
||||
background: rgba(255, 255, 255, var(--site-glass-opacity, 0.92));
|
||||
border-radius: var(--border-radius);
|
||||
padding: 8px 14px;
|
||||
margin-bottom: 12px;
|
||||
@@ -718,16 +886,17 @@ h1 {
|
||||
.search-container input {
|
||||
width: 100%;
|
||||
padding: 9px 16px 9px 36px;
|
||||
border-radius: 50px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(var(--primary-rgb), 0.2);
|
||||
font-size: 0.93rem;
|
||||
box-shadow: var(--box-shadow);
|
||||
box-shadow: inset 0 1px 2px rgba(15, 23, 42, 0.04);
|
||||
transition: var(--transition);
|
||||
background: #fffefb;
|
||||
}
|
||||
|
||||
.search-container input:focus {
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: 0 0 0 3px rgba(67, 97, 238, 0.18);
|
||||
box-shadow: 0 0 0 3px rgba(var(--primary-rgb), 0.16);
|
||||
}
|
||||
|
||||
.search-icon {
|
||||
@@ -852,7 +1021,7 @@ h1 {
|
||||
background: white;
|
||||
color: var(--dark-color);
|
||||
padding: 12px 20px;
|
||||
border-radius: 50px;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 5px 18px rgba(0, 0, 0, 0.15);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -885,9 +1054,9 @@ h1 {
|
||||
|
||||
.category-filter {
|
||||
padding: 5px 12px;
|
||||
border-radius: 30px;
|
||||
background: white;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
border: 1px solid rgba(var(--primary-rgb), 0.18);
|
||||
cursor: pointer;
|
||||
transition: var(--transition);
|
||||
font-weight: 500;
|
||||
@@ -918,7 +1087,7 @@ h1 {
|
||||
}
|
||||
|
||||
.favicon-placeholder {
|
||||
background: linear-gradient(45deg, #4361ee, #3f37c9);
|
||||
background: linear-gradient(145deg, var(--primary-color), var(--secondary-color));
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
font-size: 1.4rem;
|
||||
9
frontend/src/types/site.ts
Normal file
9
frontend/src/types/site.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export interface Site {
|
||||
id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
description?: string;
|
||||
category?: string;
|
||||
tags?: string[];
|
||||
clicks?: number;
|
||||
}
|
||||
16
frontend/src/utils/favicon.ts
Normal file
16
frontend/src/utils/favicon.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { SITE_FAVICON_API_BASE } from '../config/site';
|
||||
|
||||
export function toHttpPageUrl(siteUrl: string): string {
|
||||
const u = new URL(siteUrl);
|
||||
u.protocol = 'http:';
|
||||
return u.href;
|
||||
}
|
||||
|
||||
export function getFaviconUrl(siteUrl: string): string {
|
||||
try {
|
||||
const pageUrl = toHttpPageUrl(siteUrl);
|
||||
return SITE_FAVICON_API_BASE + encodeURIComponent(pageUrl);
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
17
frontend/src/vite-env.d.ts
vendored
Normal file
17
frontend/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,17 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_APP_TARGET?: string;
|
||||
readonly VITE_API_BASE?: string;
|
||||
readonly VITE_SITE_NAME?: string;
|
||||
readonly VITE_SITE_SHORT_NAME?: string;
|
||||
readonly VITE_SITE_DESCRIPTION?: string;
|
||||
readonly VITE_FAVICON_API_BASE?: string;
|
||||
readonly VITE_SITE_RANDOM_BG_API?: string;
|
||||
readonly VITE_SITE_BACKGROUND_BLUR?: string;
|
||||
readonly VITE_SITE_GLASS_OPACITY?: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
25
frontend/tsconfig.app.json
Normal file
25
frontend/tsconfig.app.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "es2023",
|
||||
"lib": ["ES2023", "DOM"],
|
||||
"module": "esnext",
|
||||
"types": ["vite/client"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
7
frontend/tsconfig.json
Normal file
7
frontend/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
24
frontend/tsconfig.node.json
Normal file
24
frontend/tsconfig.node.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "es2023",
|
||||
"lib": ["ES2023"],
|
||||
"module": "esnext",
|
||||
"types": ["node"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
24
frontend/vite.config.ts
Normal file
24
frontend/vite.config.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig(({ mode }) => {
|
||||
const isDesktop = mode === 'desktop';
|
||||
|
||||
return {
|
||||
plugins: [react()],
|
||||
base: isDesktop ? './' : '/',
|
||||
build: {
|
||||
outDir: isDesktop ? 'dist-desktop' : 'dist',
|
||||
emptyOutDir: true,
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: isDesktop ? 'https://nav.smyhub.com' : 'http://127.0.0.1:8787',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
206
cf-nav-backend/package-lock.json → package-lock.json
generated
206
cf-nav-backend/package-lock.json → package-lock.json
generated
@@ -1,13 +1,14 @@
|
||||
{
|
||||
"name": "cf-nav-backend",
|
||||
"name": "sproutnav",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "cf-nav-backend",
|
||||
"version": "1.0.0",
|
||||
"name": "sproutnav",
|
||||
"devDependencies": {
|
||||
"@cloudflare/workers-types": "^4.20250424.0",
|
||||
"typescript": "^5.8.3",
|
||||
"wrangler": "^4.68.1"
|
||||
}
|
||||
},
|
||||
@@ -22,14 +23,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@cloudflare/unenv-preset": {
|
||||
"version": "2.14.0",
|
||||
"resolved": "https://registry.npmjs.org/@cloudflare/unenv-preset/-/unenv-preset-2.14.0.tgz",
|
||||
"integrity": "sha512-XKAkWhi1nBdNsSEoNG9nkcbyvfUrSjSf+VYVPfOto3gLTZVc3F4g6RASCMh6IixBKCG2yDgZKQIHGKtjcnLnKg==",
|
||||
"version": "2.16.0",
|
||||
"resolved": "https://registry.npmjs.org/@cloudflare/unenv-preset/-/unenv-preset-2.16.0.tgz",
|
||||
"integrity": "sha512-8ovsRpwzPoEqPUzoErAYVv8l3FMZNeBVQfJTvtzP4AgLSRGZISRfuChFxHWUQd3n6cnrwkuTGxT+2cGo8EsyYg==",
|
||||
"dev": true,
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"peerDependencies": {
|
||||
"unenv": "2.0.0-rc.24",
|
||||
"workerd": "^1.20260218.0"
|
||||
"workerd": "1.20260301.1 || ~1.20260302.1 || ~1.20260303.1 || ~1.20260304.1 || >1.20260305.0 <2.0.0-0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"workerd": {
|
||||
@@ -38,9 +39,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@cloudflare/workerd-darwin-64": {
|
||||
"version": "1.20260302.0",
|
||||
"resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260302.0.tgz",
|
||||
"integrity": "sha512-cGtxPByeVrgoqxbmd8qs631wuGwf8yTm/FY44dEW4HdoXrb5jhlE4oWYHFafedkQCvGjY1Vbs3puAiKnuMxTXQ==",
|
||||
"version": "1.20260401.1",
|
||||
"resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260401.1.tgz",
|
||||
"integrity": "sha512-ZSmceM70jH6k+/62VkEcmMNzrpr4kSctkX5Lsgqv38KktfhPY/hsh75y1lRoPWS3H3kgMa4p2pUSlidZR1u2hw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -55,9 +56,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@cloudflare/workerd-darwin-arm64": {
|
||||
"version": "1.20260302.0",
|
||||
"resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260302.0.tgz",
|
||||
"integrity": "sha512-WRGqV6RNXM3xoQblJJw1EHKwx9exyhB18cdnToSCUFPObFhk3fzMLoQh7S+nUHUpto6aUrXPVj6R/4G3UPjCxw==",
|
||||
"version": "1.20260401.1",
|
||||
"resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260401.1.tgz",
|
||||
"integrity": "sha512-7UKWF+IUZ3NXMVPsDg8Cjg0r58b+uYlfvs5Yt8bvtU+geCtW4P2MxRHmRSEo8SryckXOJjb/b8tcncgCykFu8g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -72,9 +73,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@cloudflare/workerd-linux-64": {
|
||||
"version": "1.20260302.0",
|
||||
"resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260302.0.tgz",
|
||||
"integrity": "sha512-gG423mtUjrmlQT+W2+KisLc6qcGcBLR+QcK5x1gje3bu/dF3oNiYuqY7o58A+sQk6IB849UC4UyNclo1RhP2xw==",
|
||||
"version": "1.20260401.1",
|
||||
"resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260401.1.tgz",
|
||||
"integrity": "sha512-MDWUH/0bvL/l9aauN8zEddyYOXId1OueqrUCXXENNJ95R/lSmF6OgGVuXaYhoIhxQkNiEJ/0NOlnVYj9mJq4dw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -89,9 +90,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@cloudflare/workerd-linux-arm64": {
|
||||
"version": "1.20260302.0",
|
||||
"resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260302.0.tgz",
|
||||
"integrity": "sha512-7M25noGI4WlSBOhrIaY8xZrnn87OQKtJg9YWAO2EFqGjF1Su5QXGaLlQVF4fAKbqTywbHnI8BAuIsIlUSNkhCg==",
|
||||
"version": "1.20260401.1",
|
||||
"resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260401.1.tgz",
|
||||
"integrity": "sha512-UgkzpMzVWM/bwbo3vjCTg2aoKfGcUhiEoQoDdo6RGWvbHRJyLVZ4VQCG9ZcISiztkiS2ICCoYOtPy6M/lV6Gcw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -106,9 +107,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@cloudflare/workerd-windows-64": {
|
||||
"version": "1.20260302.0",
|
||||
"resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260302.0.tgz",
|
||||
"integrity": "sha512-jK1L3ADkiWxFzlqZTq2iHW1Bd2Nzu1fmMWCGZw4sMZ2W1B2WCm2wHwO2SX/py4BgylyEN3wuF+5zagbkNKht9A==",
|
||||
"version": "1.20260401.1",
|
||||
"resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260401.1.tgz",
|
||||
"integrity": "sha512-HBLzcQF5iF4Qv20tQ++pG7xs3OsCnaIbc+GAi6fmhUKZhvmzvml/jwrQzLJ+MPm0cQo41K5OO/U3T4S8tvJetQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -122,6 +123,13 @@
|
||||
"node": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/@cloudflare/workers-types": {
|
||||
"version": "4.20260501.1",
|
||||
"resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-4.20260501.1.tgz",
|
||||
"integrity": "sha512-B/VX2w3my/sCqxKyWOX7SxUpFC1uD8Gh7I2zbI1d3zA8p7Tx03AFsnuEx8lYLmcd8yONAA93YsAZb1wAaLK83w==",
|
||||
"dev": true,
|
||||
"license": "MIT OR Apache-2.0"
|
||||
},
|
||||
"node_modules/@cspotcode/source-map-support": {
|
||||
"version": "0.8.1",
|
||||
"resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz",
|
||||
@@ -136,9 +144,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/runtime": {
|
||||
"version": "1.8.1",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz",
|
||||
"integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==",
|
||||
"version": "1.9.2",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz",
|
||||
"integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
@@ -589,9 +597,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@img/colour": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.0.0.tgz",
|
||||
"integrity": "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==",
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
|
||||
"integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -686,6 +694,9 @@
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -703,6 +714,9 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -720,6 +734,9 @@
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -737,6 +754,9 @@
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -754,6 +774,9 @@
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -771,6 +794,9 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -788,6 +814,9 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -805,6 +834,9 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -822,6 +854,9 @@
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -845,6 +880,9 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -868,6 +906,9 @@
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -891,6 +932,9 @@
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -914,6 +958,9 @@
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -937,6 +984,9 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -960,6 +1010,9 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -983,6 +1036,9 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1128,6 +1184,19 @@
|
||||
"supports-color": "^10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@poppinss/dumper/node_modules/supports-color": {
|
||||
"version": "10.2.2",
|
||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz",
|
||||
"integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/supports-color?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/@poppinss/exception": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@poppinss/exception/-/exception-1.2.3.tgz",
|
||||
@@ -1149,9 +1218,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@speed-highlight/core": {
|
||||
"version": "1.2.14",
|
||||
"resolved": "https://registry.npmjs.org/@speed-highlight/core/-/core-1.2.14.tgz",
|
||||
"integrity": "sha512-G4ewlBNhUtlLvrJTb88d2mdy2KRijzs4UhnlrOSRT4bmjh/IqNElZa3zkrZ+TC47TwtlDWzVLFADljF1Ijp5hA==",
|
||||
"version": "1.2.15",
|
||||
"resolved": "https://registry.npmjs.org/@speed-highlight/core/-/core-1.2.15.tgz",
|
||||
"integrity": "sha512-BMq1K3DsElxDWawkX6eLg9+CKJrTVGCBAWVuHXVUV2u0s2711qiChLSId6ikYPfxhdYocLNt3wWwSvDiTvFabw==",
|
||||
"dev": true,
|
||||
"license": "CC0-1.0"
|
||||
},
|
||||
@@ -1264,16 +1333,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/miniflare": {
|
||||
"version": "4.20260302.0",
|
||||
"resolved": "https://registry.npmjs.org/miniflare/-/miniflare-4.20260302.0.tgz",
|
||||
"integrity": "sha512-joGFywlo7HdfHXXGOkc6tDCVkwjEncM0mwEsMOLWcl+vDVJPj9HRV7JtEa0+lCpNOLdYw7mZNHYe12xz9KtJOw==",
|
||||
"version": "4.20260401.0",
|
||||
"resolved": "https://registry.npmjs.org/miniflare/-/miniflare-4.20260401.0.tgz",
|
||||
"integrity": "sha512-lngHPzZFN9sxYG/mhzvnWiBMNVAN5MsO/7g32ttJ07rymtiK/ZBalODTKb8Od+BQdlU5DOR4CjVt9NydjnUyYg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@cspotcode/source-map-support": "0.8.1",
|
||||
"sharp": "^0.34.5",
|
||||
"undici": "7.18.2",
|
||||
"workerd": "1.20260302.0",
|
||||
"undici": "7.24.4",
|
||||
"workerd": "1.20260401.1",
|
||||
"ws": "8.18.0",
|
||||
"youch": "4.1.0-beta.10"
|
||||
},
|
||||
@@ -1356,19 +1425,6 @@
|
||||
"@img/sharp-win32-x64": "0.34.5"
|
||||
}
|
||||
},
|
||||
"node_modules/supports-color": {
|
||||
"version": "10.2.2",
|
||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz",
|
||||
"integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/supports-color?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
@@ -1377,10 +1433,24 @@
|
||||
"license": "0BSD",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/undici": {
|
||||
"version": "7.18.2",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-7.18.2.tgz",
|
||||
"integrity": "sha512-y+8YjDFzWdQlSE9N5nzKMT3g4a5UBX1HKowfdXh0uvAnTaqqwqB92Jt4UXBAeKekDs5IaDKyJFR4X1gYVCgXcw==",
|
||||
"version": "7.24.4",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-7.24.4.tgz",
|
||||
"integrity": "sha512-BM/JzwwaRXxrLdElV2Uo6cTLEjhSb3WXboncJamZ15NgUURmvlXvxa6xkwIOILIjPNo9i8ku136ZvWV0Uly8+w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -1398,9 +1468,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/workerd": {
|
||||
"version": "1.20260302.0",
|
||||
"resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260302.0.tgz",
|
||||
"integrity": "sha512-FhNdC8cenMDllI6bTktFgxP5Bn5ZEnGtofgKipY6pW9jtq708D1DeGI6vGad78KQLBGaDwFy1eThjCoLYgFfog==",
|
||||
"version": "1.20260401.1",
|
||||
"resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260401.1.tgz",
|
||||
"integrity": "sha512-mUYCd+ohaWJWF5nhDzxugWaAD/DM8Dw0ze3B7bu8JaA7S70+XQJXcvcvwE8C4qGcxSdCyqjsrFzqxKubECDwzg==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
@@ -1411,41 +1481,41 @@
|
||||
"node": ">=16"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@cloudflare/workerd-darwin-64": "1.20260302.0",
|
||||
"@cloudflare/workerd-darwin-arm64": "1.20260302.0",
|
||||
"@cloudflare/workerd-linux-64": "1.20260302.0",
|
||||
"@cloudflare/workerd-linux-arm64": "1.20260302.0",
|
||||
"@cloudflare/workerd-windows-64": "1.20260302.0"
|
||||
"@cloudflare/workerd-darwin-64": "1.20260401.1",
|
||||
"@cloudflare/workerd-darwin-arm64": "1.20260401.1",
|
||||
"@cloudflare/workerd-linux-64": "1.20260401.1",
|
||||
"@cloudflare/workerd-linux-arm64": "1.20260401.1",
|
||||
"@cloudflare/workerd-windows-64": "1.20260401.1"
|
||||
}
|
||||
},
|
||||
"node_modules/wrangler": {
|
||||
"version": "4.68.1",
|
||||
"resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.68.1.tgz",
|
||||
"integrity": "sha512-G+TI3k/olEGBAVkPtUlhAX/DIbL/190fv3aK+r+45/wPclNEymjxCc35T8QGTDhc2fEMXiw51L5bH9aNsBg+yQ==",
|
||||
"version": "4.80.0",
|
||||
"resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.80.0.tgz",
|
||||
"integrity": "sha512-2ZKF7uPeOZy65BGk3YfvqBCPo/xH1MrAlMmH9mVP+tCNBrTUMnwOHSj1HrZHgR8LttkAqhko0fGz+I4ax1rzyQ==",
|
||||
"dev": true,
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"dependencies": {
|
||||
"@cloudflare/kv-asset-handler": "0.4.2",
|
||||
"@cloudflare/unenv-preset": "2.14.0",
|
||||
"@cloudflare/unenv-preset": "2.16.0",
|
||||
"blake3-wasm": "2.1.5",
|
||||
"esbuild": "0.27.3",
|
||||
"miniflare": "4.20260302.0",
|
||||
"miniflare": "4.20260401.0",
|
||||
"path-to-regexp": "6.3.0",
|
||||
"unenv": "2.0.0-rc.24",
|
||||
"workerd": "1.20260302.0"
|
||||
"workerd": "1.20260401.1"
|
||||
},
|
||||
"bin": {
|
||||
"wrangler": "bin/wrangler.js",
|
||||
"wrangler2": "bin/wrangler.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
"node": ">=20.3.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "~2.3.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@cloudflare/workers-types": "^4.20260302.0"
|
||||
"@cloudflare/workers-types": "^4.20260401.1"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@cloudflare/workers-types": {
|
||||
18
package.json
Normal file
18
package.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "sproutnav",
|
||||
"private": true,
|
||||
"description": "萌芽导航 — Cloudflare Workers(API)+ React/Vite(静态资源)",
|
||||
"scripts": {
|
||||
"build:frontend": "cd frontend && npm run build",
|
||||
"build": "npm run build:frontend",
|
||||
"deploy": "npm run build && wrangler deploy",
|
||||
"dev": "wrangler dev",
|
||||
"dev:vite": "cd frontend && npm run dev",
|
||||
"check:worker": "tsc -p worker/tsconfig.json"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cloudflare/workers-types": "^4.20250424.0",
|
||||
"typescript": "^5.8.3",
|
||||
"wrangler": "^4.68.1"
|
||||
}
|
||||
}
|
||||
18
worker/src/api/auth.ts
Normal file
18
worker/src/api/auth.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { NavEnv } from '../env';
|
||||
import { ADMIN_SESS_PREFIX } from './constants';
|
||||
|
||||
/** 校验 Bearer:明文密码或 KV 会话 token */
|
||||
export async function resolveAdminBearer(env: NavEnv, provided: string): Promise<boolean> {
|
||||
const secret = (env.ADMIN_PASSWORD || env.ADMIN_TOKEN || '').trim();
|
||||
if (!secret || !provided) return false;
|
||||
if (provided === secret) return true;
|
||||
const v = await env.NAV_KV.get(ADMIN_SESS_PREFIX + provided);
|
||||
return v != null && v !== '';
|
||||
}
|
||||
|
||||
export async function verifyAuth(request: Request, env: NavEnv): Promise<boolean> {
|
||||
const auth = request.headers.get('Authorization');
|
||||
if (!auth || !auth.startsWith('Bearer ')) return false;
|
||||
const provided = auth.slice(7).trim();
|
||||
return resolveAdminBearer(env, provided);
|
||||
}
|
||||
2
worker/src/api/constants.ts
Normal file
2
worker/src/api/constants.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export const ADMIN_SESS_PREFIX = 'admin_sess:';
|
||||
export const ADMIN_SESS_TTL_SEC = 86400;
|
||||
6
worker/src/api/cors.ts
Normal file
6
worker/src/api/cors.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
/** 跨域响应头(与旧版 Pages Functions 保持一致) */
|
||||
export const corsHeaders: Record<string, string> = {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
|
||||
};
|
||||
68
worker/src/api/handlers/auth-handlers.ts
Normal file
68
worker/src/api/handlers/auth-handlers.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import type { NavEnv } from '../../env';
|
||||
import { resolveAdminBearer } from '../auth';
|
||||
import { corsHeaders } from '../cors';
|
||||
import { ADMIN_SESS_PREFIX, ADMIN_SESS_TTL_SEC } from '../constants';
|
||||
|
||||
export async function handleAuthLogin(request: Request, env: NavEnv): Promise<Response> {
|
||||
if (request.method !== 'POST') {
|
||||
return new Response('Method not allowed', { status: 405, headers: corsHeaders });
|
||||
}
|
||||
const secret = (env.ADMIN_PASSWORD || env.ADMIN_TOKEN || '').trim();
|
||||
if (!secret) {
|
||||
return new Response(JSON.stringify({ ok: false }), {
|
||||
status: 503,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
let body: { password?: unknown };
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch (_) {
|
||||
return new Response(JSON.stringify({ ok: false }), {
|
||||
status: 400,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
const password = (body?.password != null ? String(body.password) : '').trim();
|
||||
if (password !== secret) {
|
||||
return new Response(JSON.stringify({ ok: false }), {
|
||||
status: 401,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
const sessionId = crypto.randomUUID();
|
||||
await env.NAV_KV.put(ADMIN_SESS_PREFIX + sessionId, '1', { expirationTtl: ADMIN_SESS_TTL_SEC });
|
||||
return new Response(JSON.stringify({ ok: true, sessionId }), {
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
export async function handleAuthCheck(request: Request, env: NavEnv): Promise<Response> {
|
||||
if (request.method !== 'GET') {
|
||||
return new Response('Method not allowed', { status: 405, headers: corsHeaders });
|
||||
}
|
||||
const secret = (env.ADMIN_PASSWORD || env.ADMIN_TOKEN || '').trim();
|
||||
if (!secret) {
|
||||
return new Response(JSON.stringify({ ok: false }), {
|
||||
status: 401,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
const auth = request.headers.get('Authorization');
|
||||
if (!auth || !auth.startsWith('Bearer ')) {
|
||||
return new Response(JSON.stringify({ ok: false }), {
|
||||
status: 401,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
const provided = auth.slice(7).trim();
|
||||
if (!(await resolveAdminBearer(env, provided))) {
|
||||
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' },
|
||||
});
|
||||
}
|
||||
69
worker/src/api/handlers/categories.ts
Normal file
69
worker/src/api/handlers/categories.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import type { NavEnv } from '../../env';
|
||||
import { verifyAuth } from '../auth';
|
||||
import { corsHeaders } from '../cors';
|
||||
import { getCategories, getSites, putCategories, putSites } from '../../storage/kv-nav';
|
||||
|
||||
export async function handleCategories(request: Request, env: NavEnv): Promise<Response> {
|
||||
if (request.method === 'GET') {
|
||||
let categories = await getCategories(env.NAV_KV);
|
||||
if (!categories.length) {
|
||||
const sites = await getSites(env.NAV_KV);
|
||||
categories = [...new Set(sites.map((s) => s.category).filter(Boolean) as string[])];
|
||||
if (categories.length) await putCategories(env.NAV_KV, categories);
|
||||
}
|
||||
return new Response(JSON.stringify(categories), {
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
if (request.method === 'POST') {
|
||||
if (!(await verifyAuth(request, env))) {
|
||||
return new Response('Unauthorized', { status: 401, headers: corsHeaders });
|
||||
}
|
||||
const { name } = (await request.json()) as { name?: string };
|
||||
if (!name || !name.trim()) {
|
||||
return new Response('Bad Request', { status: 400, headers: corsHeaders });
|
||||
}
|
||||
const categories = await getCategories(env.NAV_KV);
|
||||
if (!categories.includes(name.trim())) {
|
||||
categories.push(name.trim());
|
||||
await putCategories(env.NAV_KV, categories);
|
||||
}
|
||||
return new Response(JSON.stringify({ success: true }), {
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
return new Response('Method not allowed', { status: 405, headers: corsHeaders });
|
||||
}
|
||||
|
||||
export async function handleCategory(request: Request, env: NavEnv, name: string): Promise<Response> {
|
||||
if (!name) return new Response('Bad Request', { status: 400, headers: corsHeaders });
|
||||
if (!(await verifyAuth(request, env))) {
|
||||
return new Response('Unauthorized', { status: 401, headers: corsHeaders });
|
||||
}
|
||||
const categories = await getCategories(env.NAV_KV);
|
||||
|
||||
if (request.method === 'DELETE') {
|
||||
const filtered = categories.filter((c) => c !== name);
|
||||
await putCategories(env.NAV_KV, filtered);
|
||||
return new Response(JSON.stringify({ success: true }), {
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
if (request.method === 'PUT') {
|
||||
const body = (await request.json()) as { name?: string };
|
||||
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 putCategories(env.NAV_KV, unique);
|
||||
const sites = await getSites(env.NAV_KV);
|
||||
const updatedSites = sites.map((site) =>
|
||||
site.category === name ? { ...site, category: newName } : site
|
||||
);
|
||||
await putSites(env.NAV_KV, updatedSites);
|
||||
return new Response(JSON.stringify({ success: true }), {
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
return new Response('Method not allowed', { status: 405, headers: corsHeaders });
|
||||
}
|
||||
97
worker/src/api/handlers/sites.ts
Normal file
97
worker/src/api/handlers/sites.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import type { NavEnv } from '../../env';
|
||||
import { verifyAuth } from '../auth';
|
||||
import { corsHeaders } from '../cors';
|
||||
import { getCategories, getSites, putCategories, putSites, type SiteRecord } from '../../storage/kv-nav';
|
||||
|
||||
export async function handleSites(request: Request, env: NavEnv): Promise<Response> {
|
||||
if (request.method === 'GET') {
|
||||
const sitesData = await getSites(env.NAV_KV);
|
||||
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 (!(await verifyAuth(request, env))) {
|
||||
return new Response('Unauthorized', { status: 401, headers: corsHeaders });
|
||||
}
|
||||
const newSite = (await request.json()) as SiteRecord;
|
||||
const sites = await getSites(env.NAV_KV);
|
||||
const categories = await getCategories(env.NAV_KV);
|
||||
newSite.id = Date.now().toString();
|
||||
newSite.clicks = 0;
|
||||
sites.push(newSite);
|
||||
if (newSite.category && !categories.includes(newSite.category)) {
|
||||
categories.push(newSite.category);
|
||||
await putCategories(env.NAV_KV, categories);
|
||||
}
|
||||
await putSites(env.NAV_KV, sites);
|
||||
return new Response(JSON.stringify(newSite), {
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
return new Response('Method not allowed', { status: 405, headers: corsHeaders });
|
||||
}
|
||||
|
||||
export async function handleSite(request: Request, env: NavEnv, id: string): Promise<Response> {
|
||||
const sites = await getSites(env.NAV_KV);
|
||||
const categories = await getCategories(env.NAV_KV);
|
||||
|
||||
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 (!(await verifyAuth(request, env))) {
|
||||
return new Response('Unauthorized', { status: 401, headers: corsHeaders });
|
||||
}
|
||||
const updatedSite = (await request.json()) as Partial<SiteRecord>;
|
||||
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 putCategories(env.NAV_KV, categories);
|
||||
}
|
||||
await putSites(env.NAV_KV, sites);
|
||||
return new Response(JSON.stringify(sites[index]), {
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
if (request.method === 'DELETE') {
|
||||
if (!(await 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 putSites(env.NAV_KV, sites);
|
||||
return new Response(JSON.stringify({ success: true }), {
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
return new Response('Method not allowed', { status: 405, headers: corsHeaders });
|
||||
}
|
||||
|
||||
export async function handleSiteClick(request: Request, env: NavEnv, id: string): Promise<Response> {
|
||||
if (request.method !== 'POST') {
|
||||
return new Response('Method not allowed', { status: 405, headers: corsHeaders });
|
||||
}
|
||||
const sites = await getSites(env.NAV_KV);
|
||||
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 putSites(env.NAV_KV, sites);
|
||||
return new Response(JSON.stringify({ success: true, clicks: prev + 1 }), {
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
50
worker/src/api/router.ts
Normal file
50
worker/src/api/router.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import type { NavEnv } from '../env';
|
||||
import { corsHeaders } from './cors';
|
||||
import { handleAuthCheck, handleAuthLogin } from './handlers/auth-handlers';
|
||||
import { handleCategories, handleCategory } from './handlers/categories';
|
||||
import { handleSite, handleSites, handleSiteClick } from './handlers/sites';
|
||||
|
||||
/** Worker `/api/*` 路由入口 */
|
||||
export async function handleApiRequest(request: Request, env: NavEnv): Promise<Response> {
|
||||
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);
|
||||
}
|
||||
if (path === '/api/auth/login') {
|
||||
return handleAuthLogin(request, env);
|
||||
}
|
||||
if (path === '/api/auth/check') {
|
||||
return handleAuthCheck(request, env);
|
||||
}
|
||||
if (path.match(/^\/api\/sites\/[^/]+\/click$/)) {
|
||||
const id = path.split('/')[3]!;
|
||||
return handleSiteClick(request, env, id);
|
||||
}
|
||||
if (path.startsWith('/api/sites/')) {
|
||||
const id = path.split('/')[3]!;
|
||||
return handleSite(request, env, id);
|
||||
}
|
||||
if (path === '/api/categories') {
|
||||
return handleCategories(request, env);
|
||||
}
|
||||
if (path.startsWith('/api/categories/')) {
|
||||
const name = decodeURIComponent(path.split('/')[3] || '');
|
||||
return handleCategory(request, env, name);
|
||||
}
|
||||
|
||||
return new Response('Not Found', { status: 404 });
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return new Response('Internal Server Error: ' + message, {
|
||||
status: 500,
|
||||
headers: corsHeaders,
|
||||
});
|
||||
}
|
||||
}
|
||||
7
worker/src/env.d.ts
vendored
Normal file
7
worker/src/env.d.ts
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
/** Worker 环境绑定(KV + 静态资源) */
|
||||
export interface NavEnv {
|
||||
NAV_KV: KVNamespace;
|
||||
ASSETS: Fetcher;
|
||||
ADMIN_PASSWORD?: string;
|
||||
ADMIN_TOKEN?: string;
|
||||
}
|
||||
17
worker/src/index.ts
Normal file
17
worker/src/index.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import type { NavEnv } from './env';
|
||||
import { handleApiRequest } from './api/router';
|
||||
|
||||
export default {
|
||||
async fetch(request: Request, env: NavEnv): Promise<Response> {
|
||||
const url = new URL(request.url);
|
||||
if (url.pathname.startsWith('/api')) {
|
||||
return handleApiRequest(request, env);
|
||||
}
|
||||
if (url.pathname === '/admin.html') {
|
||||
const dest = new URL('/admin', url.origin);
|
||||
dest.search = url.search;
|
||||
return Response.redirect(dest.toString(), 302);
|
||||
}
|
||||
return env.ASSETS.fetch(request);
|
||||
},
|
||||
};
|
||||
32
worker/src/storage/kv-nav.ts
Normal file
32
worker/src/storage/kv-nav.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
/** KV 中站点与分类的读写封装 */
|
||||
|
||||
export interface SiteRecord {
|
||||
id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
description?: string;
|
||||
category?: string;
|
||||
tags?: string[];
|
||||
clicks?: number;
|
||||
}
|
||||
|
||||
const KEY_SITES = 'sites';
|
||||
const KEY_CATEGORIES = 'categories';
|
||||
|
||||
export async function getSites(kv: KVNamespace): Promise<SiteRecord[]> {
|
||||
const sitesData = (await kv.get(KEY_SITES, { type: 'json' })) as SiteRecord[] | null;
|
||||
return sitesData ?? [];
|
||||
}
|
||||
|
||||
export async function putSites(kv: KVNamespace, sites: SiteRecord[]): Promise<void> {
|
||||
await kv.put(KEY_SITES, JSON.stringify(sites));
|
||||
}
|
||||
|
||||
export async function getCategories(kv: KVNamespace): Promise<string[]> {
|
||||
const c = (await kv.get(KEY_CATEGORIES, { type: 'json' })) as string[] | null;
|
||||
return c ?? [];
|
||||
}
|
||||
|
||||
export async function putCategories(kv: KVNamespace, categories: string[]): Promise<void> {
|
||||
await kv.put(KEY_CATEGORIES, JSON.stringify(categories));
|
||||
}
|
||||
13
worker/tsconfig.json
Normal file
13
worker/tsconfig.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ES2022",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true,
|
||||
"types": ["@cloudflare/workers-types"],
|
||||
"lib": ["ES2022"]
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
16
wrangler.toml
Normal file
16
wrangler.toml
Normal file
@@ -0,0 +1,16 @@
|
||||
# Cloudflare Worker:仅打包 API 逻辑;静态资源由 [assets] 提供(Vite 构建输出)
|
||||
name = "sproutnav"
|
||||
main = "worker/src/index.ts"
|
||||
compatibility_date = "2026-04-06"
|
||||
|
||||
[[kv_namespaces]]
|
||||
binding = "NAV_KV"
|
||||
id = "a89f429e1a684d2084eae8619755ee11"
|
||||
|
||||
[vars]
|
||||
ADMIN_PASSWORD = "shumengya520"
|
||||
|
||||
[assets]
|
||||
directory = "./frontend/dist"
|
||||
binding = "ASSETS"
|
||||
not_found_handling = "single-page-application"
|
||||
Reference in New Issue
Block a user