chore: sync local changes to Gitea

This commit is contained in:
shumengya
2026-06-24 22:10:28 +08:00
parent c5af0cc946
commit 7477986036
66 changed files with 8009 additions and 5631 deletions

13
frontend/.env.desktop Normal file
View File

@@ -0,0 +1,13 @@
# 桌面壳加载本地 dist-desktopAPI 指向线上 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
View 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
# 01面板白底不透明度越高越不透明、越少「玻璃感」
VITE_SITE_GLASS_OPACITY=0.92

16
frontend/.env.production Normal file
View 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
# 01面板白底不透明度越高越不透明、越少「玻璃感」
VITE_SITE_GLASS_OPACITY=0.92

25
frontend/.gitignore vendored Normal file
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

34
frontend/package.json Normal file
View 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"
}
}

BIN
frontend/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

BIN
frontend/public/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

View File

@@ -0,0 +1,37 @@
{
"name": "萌芽导航",
"short_name": "萌芽导航",
"description": "萌芽导航",
"lang": "zh-CN",
"dir": "ltr",
"id": "/",
"start_url": "/?source=pwa",
"scope": "/",
"display": "standalone",
"display_override": ["standalone", "minimal-ui", "browser"],
"orientation": "portrait-primary",
"theme_color": "#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"
}
]
}

View 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>

181
frontend/public/sw.js Normal file
View File

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

31
frontend/src/App.tsx Normal file
View 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>
);
}

View 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;
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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';
/** 面板白底不透明度 01数值越高越「实」越少玻璃感 */
export const SITE_GLASS_OPACITY = Number(import.meta.env.VITE_SITE_GLASS_OPACITY ?? '0.92');

View 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 };
}

View 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);
}
})();
}, []);
}

View 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
View 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>
);

View 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)}>
&times;
</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
View 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)}>
&times;
</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>
</>
);
}

View 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);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,9 @@
export interface Site {
id: string;
name: string;
url: string;
description?: string;
category?: string;
tags?: string[];
clicks?: number;
}

View 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
View 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;
}

View 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
View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View 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
View 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,
},
},
},
};
});