chore: sync local changes (2026-03-12)

This commit is contained in:
2026-03-12 18:58:55 +08:00
parent 4573a21f88
commit c903101d86
39 changed files with 1112 additions and 483 deletions

View File

@@ -21,7 +21,7 @@
- 技术定位
- 个人座右铭
### 2. 精选项目模块
### 2. 全部项目模块
以卡片形式展示项目:
- 项目标题
- 项目简介

View File

@@ -64,7 +64,6 @@
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz",
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.5",
@@ -714,7 +713,6 @@
"resolved": "https://registry.npmjs.org/@babel/plugin-syntax-flow/-/plugin-syntax-flow-7.27.1.tgz",
"integrity": "sha512-p9OkPbZ5G7UT1MofwYFigGebnrzGJacoBSQM0/6bi/PUMVE+qlWDD/OalvQKbwgQzU6dl0xAv6r4X7Jme0RYxA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/helper-plugin-utils": "^7.27.1"
},
@@ -1598,7 +1596,6 @@
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.27.1.tgz",
"integrity": "sha512-2KH4LWGSrJIkVf5tSiBFYuXDAoWRq2MMwgivCf+93dd0GQi8RXLjKA/0EvRnVV5G0hrHczsquXuD01L8s6dmBw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/helper-annotate-as-pure": "^7.27.1",
"@babel/helper-module-imports": "^7.27.1",
@@ -3428,7 +3425,6 @@
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
"integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.10.4",
"@babel/runtime": "^7.12.5",
@@ -3915,7 +3911,6 @@
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.62.0.tgz",
"integrity": "sha512-TiZzBSJja/LbhNPvk6yc0JrX9XqhQ0hdh6M2svYfsHGejaKFIAGd9MQ+ERIMzLGlN/kZoYIgdxFV0PuljTKXag==",
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/regexpp": "^4.4.0",
"@typescript-eslint/scope-manager": "5.62.0",
@@ -3969,7 +3964,6 @@
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.62.0.tgz",
"integrity": "sha512-VlJEV0fOQ7BExOsHYAGrgbEiZoi8D+Bl2+f6V2RrXerRSylnp+ZBHmPvaIa8cz0Ajx7WO7Z5RqfgYg7ED1nRhA==",
"license": "BSD-2-Clause",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "5.62.0",
"@typescript-eslint/types": "5.62.0",
@@ -4339,7 +4333,6 @@
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -4438,7 +4431,6 @@
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
"license": "MIT",
"peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.1",
"fast-json-stable-stringify": "^2.0.0",
@@ -5349,7 +5341,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.8.19",
"caniuse-lite": "^1.0.30001751",
@@ -7189,7 +7180,6 @@
"integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==",
"deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.6.1",
@@ -9966,7 +9956,6 @@
"resolved": "https://registry.npmjs.org/jest/-/jest-27.5.1.tgz",
"integrity": "sha512-Yn0mADZB89zTtjkPJEXwrac3LHudkQMR+Paqa8uxJHCBr9agxztUifWCyiYrjhMPBoUVBjyny0I7XH6ozDr7QQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@jest/core": "^27.5.1",
"import-local": "^3.0.2",
@@ -10864,7 +10853,6 @@
"resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz",
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
"license": "MIT",
"peer": true,
"bin": {
"jiti": "bin/jiti.js"
}
@@ -12248,7 +12236,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@@ -13383,7 +13370,6 @@
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz",
"integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==",
"license": "MIT",
"peer": true,
"dependencies": {
"cssesc": "^3.0.0",
"util-deprecate": "^1.0.2"
@@ -13743,7 +13729,6 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz",
"integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -13875,7 +13860,6 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz",
"integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
@@ -13900,7 +13884,6 @@
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.11.0.tgz",
"integrity": "sha512-F27qZr8uUqwhWZboondsPx8tnC3Ct3SxZA3V5WyEvujRyyNv0VYPhoBg1gZ8/MV5tubQp76Trw8lTv9hzRBa+A==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -14347,7 +14330,6 @@
"resolved": "https://registry.npmjs.org/rollup/-/rollup-2.79.2.tgz",
"integrity": "sha512-fS6iqSPZDs3dr/y7Od6y5nha8dW1YnbgtsyotCVvoFGKbERG++CVRFv1meyGDE1SNItQA8BrnCw7ScdAhRJ3XQ==",
"license": "MIT",
"peer": true,
"bin": {
"rollup": "dist/bin/rollup"
},
@@ -14590,7 +14572,6 @@
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
"license": "MIT",
"peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
@@ -16264,7 +16245,6 @@
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz",
"integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==",
"license": "(MIT OR CC0-1.0)",
"peer": true,
"engines": {
"node": ">=10"
},
@@ -16694,7 +16674,6 @@
"resolved": "https://registry.npmjs.org/webpack/-/webpack-5.102.1.tgz",
"integrity": "sha512-7h/weGm9d/ywQ6qzJ+Xy+r9n/3qgp/thalBbpOi5i223dPXKi04IBtqPN9nTd+jBc7QKfvDbaBnFipYp4sJAUQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/eslint-scope": "^3.7.7",
"@types/estree": "^1.0.8",
@@ -16766,7 +16745,6 @@
"resolved": "https://registry.npmjs.org/webpack-dev-server/-/webpack-dev-server-4.15.2.tgz",
"integrity": "sha512-0XavAZbNJ5sDrCbkpWL8mia0o5WPOd2YGtxrEiZkBK9FjLppIUK2TgxK6qGD2P3hUXTJNNPVibrerKcx5WkR1g==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/bonjour": "^3.5.9",
"@types/connect-history-api-fallback": "^1.3.5",
@@ -17179,7 +17157,6 @@
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
"license": "MIT",
"peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",

View File

@@ -13,6 +13,7 @@
"web-vitals": "^2.1.4"
},
"scripts": {
"dev": "react-scripts start",
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.6 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 822 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 977 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 25 KiB

View File

@@ -4,7 +4,10 @@
<meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#667eea" />
<meta name="theme-color" content="#52b788" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="萌芽主页" />
<meta
name="description"
content="萌芽主页 - Full-Stack / Backend / DevOps"

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.4 KiB

View File

@@ -1,25 +1,32 @@
{
"short_name": "React App",
"name": "Create React App Sample",
"short_name": "萌芽主页",
"name": "萌芽主页",
"description": "萌芽个人主页 - 全栈 / 后端 / DevOps",
"icons": [
{
"src": "favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
"type": "image/x-icon",
"purpose": "any"
},
{
"src": "logo192.png",
"type": "image/png",
"sizes": "192x192"
"sizes": "192x192",
"purpose": "any maskable"
},
{
"src": "logo512.png",
"src": "logo192.png",
"type": "image/png",
"sizes": "512x512"
"sizes": "512x512",
"purpose": "any maskable"
}
],
"start_url": ".",
"display": "standalone",
"theme_color": "#000000",
"background_color": "#ffffff"
"orientation": "portrait-primary",
"theme_color": "#52b788",
"background_color": "#a8e6cf",
"categories": ["personalization", "productivity"],
"prefer_related_applications": false
}

View File

@@ -0,0 +1,53 @@
/* PWA Service Worker - 萌芽主页 */
const CACHE_NAME = 'mengyaprofile-v1';
const urlsToCache = [
'/',
'/index.html',
'/manifest.json',
'/favicon.ico',
'/logo192.png'
];
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME)
.then((cache) => cache.addAll(urlsToCache))
.then(() => self.skipWaiting())
.catch((err) => console.log('SW install cache addAll failed', err))
);
});
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((cacheNames) =>
Promise.all(
cacheNames
.filter((name) => name !== CACHE_NAME)
.map((name) => caches.delete(name))
)
).then(() => self.clients.claim())
);
});
self.addEventListener('fetch', (event) => {
const { request } = event;
const url = new URL(request.url);
if (url.origin !== location.origin) return;
if (request.mode === 'navigate') {
event.respondWith(
fetch(request).catch(() => caches.match('/index.html'))
);
return;
}
event.respondWith(
caches.match(request).then((cached) =>
cached || fetch(request).then((response) => {
if (response.ok && response.type === 'basic') {
const clone = response.clone();
caches.open(CACHE_NAME).then((cache) => cache.put(request, clone));
}
return response;
})
)
);
});

View File

@@ -102,6 +102,153 @@ body {
font-size: 18px;
}
/* PWA 启动画面 */
.pwa-launch {
position: fixed;
inset: 0;
z-index: 9999;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.pwa-launch-bg {
position: absolute;
inset: 0;
background: linear-gradient(135deg, #a8e6cf 0%, #dcedc1 40%, #ffd3b6 100%);
animation: pwaLaunchBgPulse 3s ease-in-out infinite;
}
.pwa-launch-bg::before {
content: '';
position: absolute;
inset: 0;
background:
radial-gradient(circle at 30% 30%, rgba(255, 255, 255, 0.4) 0%, transparent 45%),
radial-gradient(circle at 70% 70%, rgba(255, 255, 255, 0.3) 0%, transparent 45%);
animation: pwaLaunchShine 4s ease-in-out infinite;
}
@keyframes pwaLaunchBgPulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.92; }
}
@keyframes pwaLaunchShine {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.8; transform: scale(1.05); }
}
.pwa-launch-content {
position: relative;
z-index: 1;
text-align: center;
animation: pwaLaunchFadeIn 0.6s ease-out;
}
@keyframes pwaLaunchFadeIn {
from { opacity: 0; transform: scale(0.9); }
to { opacity: 1; transform: scale(1); }
}
.pwa-launch-logo-wrap {
position: relative;
width: 120px;
height: 120px;
margin: 0 auto 24px;
}
.pwa-launch-logo {
position: relative;
z-index: 2;
width: 88px;
height: 88px;
border-radius: 22px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12);
animation: pwaLaunchLogoFloat 2.5s ease-in-out infinite;
}
@keyframes pwaLaunchLogoFloat {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-8px); }
}
.pwa-launch-ring {
position: absolute;
top: 50%;
left: 50%;
border: 2px solid rgba(82, 183, 136, 0.4);
border-radius: 50%;
transform: translate(-50%, -50%);
}
.pwa-launch-ring-1 {
width: 100px;
height: 100px;
animation: pwaLaunchRing 2s ease-out infinite;
}
.pwa-launch-ring-2 {
width: 120px;
height: 120px;
animation: pwaLaunchRing 2s ease-out 0.3s infinite;
}
.pwa-launch-ring-3 {
width: 140px;
height: 140px;
animation: pwaLaunchRing 2s ease-out 0.6s infinite;
}
@keyframes pwaLaunchRing {
0% {
transform: translate(-50%, -50%) scale(0.6);
opacity: 0.8;
}
100% {
transform: translate(-50%, -50%) scale(1.4);
opacity: 0;
}
}
.pwa-launch-title {
font-size: 28px;
font-weight: 700;
color: rgba(0, 0, 0, 0.75);
margin: 0 0 8px;
letter-spacing: 2px;
}
.pwa-launch-subtitle {
font-size: 14px;
color: rgba(0, 0, 0, 0.5);
margin: 0 0 20px;
}
.pwa-launch-dots {
display: flex;
justify-content: center;
gap: 8px;
}
.pwa-launch-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #52b788;
animation: pwaLaunchDot 1.2s ease-in-out infinite both;
}
.pwa-launch-dot:nth-child(1) { animation-delay: 0s; }
.pwa-launch-dot:nth-child(2) { animation-delay: 0.2s; }
.pwa-launch-dot:nth-child(3) { animation-delay: 0.4s; }
@keyframes pwaLaunchDot {
0%, 80%, 100% { transform: scale(0.6); opacity: 0.5; }
40% { transform: scale(1); opacity: 1; }
}
/* 错误状态 */
.error-container {
display: flex;
@@ -141,6 +288,21 @@ body {
z-index: 1;
}
.footer-visitor {
margin-top: 8px;
font-size: 12px;
color: rgba(255, 255, 255, 0.75);
}
.footer-visitor span {
display: inline-block;
padding: 6px 12px;
border-radius: 999px;
background: rgba(0, 0, 0, 0.12);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
}
/* 响应式设计 */
@media (max-width: 768px) {
.container {
@@ -155,4 +317,3 @@ body {
font-size: 16px;
}
}

View File

@@ -17,6 +17,8 @@ function App() {
const [error, setError] = useState(null);
const [backgroundImage, setBackgroundImage] = useState(null);
const [isAdminMode, setIsAdminMode] = useState(false);
const [visitorInfo, setVisitorInfo] = useState(null);
const [visitorInfoLoading, setVisitorInfoLoading] = useState(true);
useEffect(() => {
// 检查是否为 admin 模式
@@ -29,9 +31,11 @@ function App() {
}
// 从后端API获取所有数据
// 开发环境使用完整URL,生产环境使用相对路径
// 使用环境变量配置的API地址默认为 nav.api.shumengya.top
const apiBaseUrl = process.env.REACT_APP_API_URL ||
(process.env.NODE_ENV === 'development' ? 'http://localhost:5000/api' : '/api');
(process.env.NODE_ENV === 'development'
? 'http://localhost:5000/api'
: 'https://nav.api.shumengya.top/api');
fetch(`${apiBaseUrl}/all`)
.then(response => {
if (!response.ok) {
@@ -42,16 +46,28 @@ function App() {
.then(data => {
setData(data);
// 设置 favicon
if (data.profile?.favicon) {
// 设置 favicon:优先使用 cf-favicon API失败则用后端返回的 logo
const applyFavicon = (url) => {
let faviconTag = document.querySelector('link[rel="icon"]');
if (!faviconTag) {
faviconTag = document.createElement('link');
faviconTag.rel = 'icon';
document.head.appendChild(faviconTag);
}
faviconTag.href = data.profile.favicon;
}
faviconTag.href = url;
};
const fallbackFavicon = data.profile?.favicon || '';
const siteUrl = data.profile?.site
|| data.profile?.homepage
|| (data.contacts?.contacts?.find((c) => c.type === 'personprofile')?.link)
|| (data.contacts?.contacts?.find((c) => c.link?.startsWith('https://'))?.link)
|| window.location.origin;
const apiFaviconUrl = `https://cf-favicon.pages.dev/api/favicon?url=${encodeURIComponent(siteUrl)}`;
const img = new Image();
img.onload = () => applyFavicon(apiFaviconUrl);
img.onerror = () => { if (fallbackFavicon) applyFavicon(fallbackFavicon); };
img.src = apiFaviconUrl;
if (fallbackFavicon && !siteUrl) applyFavicon(fallbackFavicon);
// 如果启用了本地背景,则获取随机背景图
if (data.profile?.showlocalbackground) {
@@ -59,7 +75,18 @@ function App() {
.then(res => res.json())
.then(bgData => {
if (bgData.image) {
setBackgroundImage(bgData.image);
// 如果返回的是相对路径(以 /api/ 开头),转换为完整的 API URL
let imageUrl = bgData.image;
if (imageUrl.startsWith('/api/')) {
// 相对路径,需要添加域名
const baseUrl = apiBaseUrl.replace('/api', '');
imageUrl = `${baseUrl}${imageUrl}`;
} else if (imageUrl.startsWith('/')) {
// 其他相对路径
const baseUrl = apiBaseUrl.replace('/api', '');
imageUrl = `${baseUrl}${imageUrl}`;
}
setBackgroundImage(imageUrl);
}
})
.catch(err => console.error('获取背景图片失败:', err));
@@ -74,11 +101,58 @@ function App() {
});
}, []);
useEffect(() => {
const controller = new AbortController();
setVisitorInfoLoading(true);
fetch('https://cf-ip-geo.smyhub.com/api', {
signal: controller.signal,
headers: { Accept: 'application/json' },
cache: 'no-store'
})
.then((res) => {
if (!res.ok) {
throw new Error(`HTTP ${res.status}`);
}
return res.json();
})
.then((payload) => {
setVisitorInfo(payload);
setVisitorInfoLoading(false);
})
.catch((err) => {
if (err?.name === 'AbortError') return;
console.warn('获取访客 IP/地理位置失败:', err);
setVisitorInfo(null);
setVisitorInfoLoading(false);
});
return () => controller.abort();
}, []);
if (loading) {
return (
<div className="loading-container">
<div className="loading-spinner"></div>
<p>加载中...</p>
<div className="pwa-launch">
<div className="pwa-launch-bg" />
<div className="pwa-launch-content">
<div className="pwa-launch-logo-wrap">
<img
src={`${process.env.PUBLIC_URL || ''}/logo192.png`}
alt="萌芽"
className="pwa-launch-logo"
/>
<div className="pwa-launch-ring pwa-launch-ring-1" />
<div className="pwa-launch-ring pwa-launch-ring-2" />
<div className="pwa-launch-ring pwa-launch-ring-3" />
</div>
<h1 className="pwa-launch-title">萌芽主页</h1>
<p className="pwa-launch-subtitle">加载中</p>
<div className="pwa-launch-dots">
<span className="pwa-launch-dot" />
<span className="pwa-launch-dot" />
<span className="pwa-launch-dot" />
</div>
</div>
</div>
);
}
@@ -131,7 +205,7 @@ function App() {
{/* 技术栈模块 */}
{data.techstack && <TechStackSection techstack={data.techstack} />}
{/* 精选项目模块 */}
{/* 全部项目模块 */}
{data.projects && <ProjectsSection projects={data.projects.projects} />}
{/* 联系方式模块 */}
@@ -141,6 +215,27 @@ function App() {
{/* 页脚 */}
<footer className="footer">
<p><strong>{data.profile?.footer || '© 2025 萌芽个人主页. All rights reserved.'}</strong></p>
<div className="footer-visitor">
{visitorInfoLoading ? (
<span>访客信息加载中</span>
) : visitorInfo?.ip ? (
<span>
访客 IP{visitorInfo.ip}
{visitorInfo.geo ? (
<>
{' · '}
{[
visitorInfo.geo.country,
visitorInfo.geo.region,
visitorInfo.geo.city
].filter(Boolean).join(' · ')}
</>
) : null}
</span>
) : (
<span>访客信息获取失败</span>
)}
</div>
</footer>
</div>
);

View File

@@ -1,8 +1,53 @@
import { render, screen } from '@testing-library/react';
import App from './App';
test('renders learn react link', () => {
render(<App />);
const linkElement = screen.getByText(/learn react/i);
expect(linkElement).toBeInTheDocument();
const mockAllData = {
profile: {
nickname: 'Test User',
avatar: null,
introduction: 'Hello',
showlocalbackground: false,
footer: '© Test Footer'
},
techstack: null,
projects: null,
contacts: null
};
const mockVisitorInfo = {
ip: '66.90.99.202',
ipVersion: 'ipv4',
geo: { country: 'JP', region: 'Tokyo', city: 'Ebara' }
};
beforeEach(() => {
jest.spyOn(global, 'fetch').mockImplementation((url) => {
if (typeof url === 'string' && url.includes('/api/all')) {
return Promise.resolve({
ok: true,
json: () => Promise.resolve(mockAllData)
});
}
if (url === 'https://cf-ip-geo.smyhub.com/api') {
return Promise.resolve({
ok: true,
json: () => Promise.resolve(mockVisitorInfo)
});
}
return Promise.resolve({
ok: true,
json: () => Promise.resolve({})
});
});
});
afterEach(() => {
global.fetch.mockRestore();
});
test('renders visitor ip and geo in footer', async () => {
render(<App />);
const visitorLine = await screen.findByText(/访客 IP66\.90\.99\.202/i);
expect(visitorLine).toBeInTheDocument();
expect(visitorLine).toHaveTextContent('JP');
});

View File

@@ -19,11 +19,21 @@
}
}
/* 标题和按钮容器 */
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20px;
gap: 20px;
flex-wrap: wrap;
}
.section-title {
font-size: 32px;
font-weight: 700;
color: white;
margin-bottom: 20px;
margin: 0;
display: flex;
align-items: center;
gap: 12px;
@@ -40,9 +50,49 @@
50% { transform: translateY(-10px); }
}
/* 分类按钮容器 */
.category-buttons {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
/* 分类按钮 */
.category-btn {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 18px;
border: 2px solid rgba(82, 183, 136, 0.3);
border-radius: 20px;
background: rgba(255, 255, 255, 0.1);
color: white;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
backdrop-filter: blur(10px);
white-space: nowrap;
}
.category-btn:hover {
background: rgba(82, 183, 136, 0.2);
border-color: rgba(82, 183, 136, 0.5);
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(82, 183, 136, 0.3);
}
.category-btn.active {
background: linear-gradient(135deg, #52b788, #95d5b2);
border-color: #52b788;
color: white;
box-shadow: 0 4px 16px rgba(82, 183, 136, 0.4);
}
.projects-grid {
display: grid;
grid-template-columns: repeat(5, 1fr);
grid-auto-rows: auto;
gap: 16px;
}
@@ -182,10 +232,28 @@
grid-template-columns: repeat(2, 1fr);
gap: 12px;
}
.section-header {
flex-direction: column;
align-items: flex-start;
gap: 16px;
}
.section-title {
font-size: 28px;
}
.category-buttons {
gap: 8px;
width: 100%;
}
.category-btn {
padding: 8px 14px;
font-size: 13px;
flex: 1;
justify-content: center;
}
.project-card {
padding: 16px;
@@ -225,9 +293,10 @@
}
}
@media (min-width: 769px) and (max-width: 1024px) {
/* 桌面端固定为5列 */
@media (min-width: 1441px) {
.projects-grid {
grid-template-columns: repeat(3, 1fr);
grid-template-columns: repeat(5, 1fr);
}
}
@@ -236,3 +305,48 @@
grid-template-columns: repeat(4, 1fr);
}
}
@media (min-width: 769px) and (max-width: 1024px) {
.projects-grid {
grid-template-columns: repeat(3, 1fr);
}
}
/* 分页指示器样式 */
.pagination-dots {
display: flex;
justify-content: center;
align-items: center;
gap: 12px;
margin-top: 30px;
padding: 20px 0;
}
.pagination-dot {
width: 12px;
height: 12px;
border-radius: 50%;
border: none;
background: rgba(255, 255, 255, 0.3);
cursor: pointer;
transition: all 0.3s ease;
padding: 0;
outline: none;
}
.pagination-dot:hover {
background: rgba(255, 255, 255, 0.5);
transform: scale(1.2);
}
.pagination-dot.active {
background: #52b788;
width: 14px;
height: 14px;
box-shadow: 0 0 10px rgba(82, 183, 136, 0.5);
}
.pagination-dot:focus {
outline: 2px solid rgba(82, 183, 136, 0.5);
outline-offset: 2px;
}

View File

@@ -2,8 +2,11 @@ import React, { useState, useEffect } from 'react';
import './ProjectsSection.css';
function ProjectsSection({ projects }) {
const [hoveredId, setHoveredId] = useState(null);
const [hoveredIndex, setHoveredIndex] = useState(null);
const [isAdmin, setIsAdmin] = useState(false);
const [currentPage, setCurrentPage] = useState(0);
const [category, setCategory] = useState('all'); // 'all', 'develop', 'deploy'
const [itemsPerPage, setItemsPerPage] = useState(15); // 默认桌面端 3行 × 5列 = 15个项目
useEffect(() => {
// 检查 URL 参数
@@ -15,20 +18,45 @@ function ProjectsSection({ projects }) {
if (pathname.includes('/admin') && token === 'shumengya520') {
setIsAdmin(true);
}
// 根据屏幕宽度设置每页显示数量
const updateItemsPerPage = () => {
if (window.innerWidth <= 768) {
setItemsPerPage(8); // 移动端4行 × 2列 = 8个项目
} else {
setItemsPerPage(15); // 桌面端3行 × 5列 = 15个项目
}
};
updateItemsPerPage();
window.addEventListener('resize', updateItemsPerPage);
return () => window.removeEventListener('resize', updateItemsPerPage);
}, []);
const getFavicon = (url) => {
// 优先使用 cf-favicon API失败时 onError 会切到 project.icon 或通用图标
const getProjectIconUrl = (link) => {
try {
const domain = new URL(url).origin;
return `${domain}/favicon.ico`;
new URL(link);
return `https://cf-favicon.pages.dev/api/favicon?url=${encodeURIComponent(link)}`;
} catch {
return 'https://api.iconify.design/mdi:web.svg';
}
};
const handleProjectIconError = (e, project) => {
const isApiUrl = e.target.src && e.target.src.includes('cf-favicon.pages.dev');
if (isApiUrl && project.icon) {
e.target.src = project.icon;
} else {
e.target.src = 'https://api.iconify.design/mdi:web.svg';
}
};
// 过滤项目
// 1. 如果 show 为 false,则不显示
// 2. 如果 admin 为 true 且不是管理员模式,则不显示
// 3. 根据分类过滤
const filteredProjects = projects.filter(project => {
// 首先检查 show 字段,如果为 false 则直接不显示
if (project.show === false) {
@@ -40,27 +68,85 @@ function ProjectsSection({ projects }) {
return false; // 隐藏需要 admin 权限的项目
}
// 根据分类过滤
if (category === 'develop' && project.develop !== true) {
return false; // 只显示自制项目
}
if (category === 'deploy' && project.develop !== false) {
return false; // 只显示自部署项目
}
return true; // 显示其他所有项目
});
// 计算总页数
const totalPages = Math.ceil(filteredProjects.length / itemsPerPage);
// 获取当前页的项目
const startIndex = currentPage * itemsPerPage;
const endIndex = startIndex + itemsPerPage;
const currentPageProjects = filteredProjects.slice(startIndex, endIndex);
// 处理分页点击
const handlePageClick = (pageIndex) => {
setCurrentPage(pageIndex);
// 滚动到项目区域顶部
window.scrollTo({
top: document.querySelector('.projects-section').offsetTop - 20,
behavior: 'smooth'
});
};
// 处理分类切换
const handleCategoryChange = (newCategory) => {
setCategory(newCategory);
setCurrentPage(0); // 切换分类时重置到第一页
};
return (
<section className="projects-section">
<h2 className="section-title">
<span className="title-icon">🎯</span>
精选项目
</h2>
<div className="section-header">
<h2 className="section-title">
<span className="title-icon">🎯</span>
全部项目
</h2>
{/* 分类按钮 */}
<div className="category-buttons">
<button
className={`category-btn ${category === 'all' ? 'active' : ''}`}
onClick={() => handleCategoryChange('all')}
>
全部项目
</button>
<button
className={`category-btn ${category === 'develop' ? 'active' : ''}`}
onClick={() => handleCategoryChange('develop')}
>
自制项目
</button>
<button
className={`category-btn ${category === 'deploy' ? 'active' : ''}`}
onClick={() => handleCategoryChange('deploy')}
>
自部署项目
</button>
</div>
</div>
<div className="projects-grid">
{filteredProjects.map(project => (
<a
key={project.id}
href={project.link}
target="_blank"
rel="noopener noreferrer"
className={`project-card ${hoveredId === project.id ? 'hovered' : ''}`}
onMouseEnter={() => setHoveredId(project.id)}
onMouseLeave={() => setHoveredId(null)}
>
{currentPageProjects.map((project, index) => {
const globalIndex = startIndex + index;
return (
<a
key={globalIndex}
href={project.link}
target="_blank"
rel="noopener noreferrer"
className={`project-card ${hoveredIndex === globalIndex ? 'hovered' : ''}`}
onMouseEnter={() => setHoveredIndex(globalIndex)}
onMouseLeave={() => setHoveredIndex(null)}
>
{project.develop === true && (
<div className="develop-badge" title="独立开发">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
@@ -71,12 +157,10 @@ function ProjectsSection({ projects }) {
)}
<div className="project-header">
<div className="project-icon">
<img
src={project.icon || getFavicon(project.link)}
<img
src={getProjectIconUrl(project.link)}
alt={project.title}
onError={(e) => {
e.target.src = 'https://api.iconify.design/mdi:web.svg';
}}
onError={(e) => handleProjectIconError(e, project)}
/>
</div>
<h3 className="project-title">{project.title}</h3>
@@ -92,9 +176,24 @@ function ProjectsSection({ projects }) {
</div>
)}
</a>
))}
</a>
);
})}
</div>
{/* 分页指示器 */}
{totalPages > 1 && (
<div className="pagination-dots">
{Array.from({ length: totalPages }, (_, index) => (
<button
key={index}
className={`pagination-dot ${currentPage === index ? 'active' : ''}`}
onClick={() => handlePageClick(index)}
aria-label={`跳转到第 ${index + 1}`}
/>
))}
</div>
)}
</section>
);
}

View File

@@ -46,60 +46,95 @@
-webkit-backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 16px;
padding: 24px;
padding: 16px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
}
.tech-items {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 12px;
display: flex;
flex-wrap: wrap;
gap: 4px;
align-items: center;
justify-items: center;
}
.tech-item {
transition: all 0.3s ease;
cursor: pointer;
width: 100%;
display: flex;
justify-content: center;
padding: 12px;
background: rgba(255, 255, 255, 0.15);
backdrop-filter: blur(5px);
-webkit-backdrop-filter: blur(5px);
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.25);
display: inline-flex;
}
.tech-item a {
display: flex;
justify-content: center;
align-items: center;
width: 100%;
.tech-badge-link {
text-decoration: none;
display: inline-flex;
}
.tech-item img {
height: 32px;
max-width: 100%;
.tech-badge {
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 4px;
padding: 0;
height: 36px;
min-height: 36px;
max-height: 36px;
font-size: 20px;
font-weight: 600;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Noto Sans', Helvetica, Arial, sans-serif;
line-height: 1;
white-space: nowrap;
overflow: hidden;
transition: all 0.2s ease;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.12), 0 1px 2px rgba(0, 0, 0, 0.24);
}
.tech-badge:hover {
transform: translateY(-1px);
box-shadow: 0 3px 6px rgba(0, 0, 0, 0.16), 0 3px 6px rgba(0, 0, 0, 0.23);
}
.badge-icon {
display: flex;
align-items: center;
justify-content: center;
padding: 0 6px 0 8px;
height: 36px;
min-height: 36px;
max-height: 36px;
background-color: rgba(0, 0, 0, 0.15);
flex-shrink: 0;
width: 52px;
}
.badge-icon img {
width: 36px;
height: 36px;
display: block;
transition: transform 0.3s ease, filter 0.3s ease;
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.1));
object-fit: contain;
}
.tech-item:hover {
transform: translateY(-3px);
background: rgba(255, 255, 255, 0.25);
box-shadow: 0 4px 15px rgba(82, 183, 136, 0.2);
.badge-icon-placeholder {
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
font-weight: bold;
}
.tech-item:hover img {
filter: brightness(1.1) drop-shadow(0 4px 8px rgba(0, 0, 0, 0.15));
.badge-text {
padding: 0 8px 0 6px;
height: 36px;
min-height: 36px;
max-height: 36px;
display: flex;
align-items: center;
justify-content: center;
line-height: 36px;
text-align: center;
white-space: nowrap;
}
/* 响应式设计 */
@media (max-width: 768px) {
.section-title {
font-size: 28px;
@@ -110,11 +145,41 @@
}
.tech-items {
grid-template-columns: repeat(auto-fill, minmax(110px, 1fr));
gap: 10px;
grid-template-columns: repeat(3, 1fr);
gap: 4px;
}
.tech-item img {
.tech-badge {
height: 30px;
font-size: 16px;
}
.badge-icon {
padding: 0 4px 0 5px;
height: 30px;
width: 42px;
}
.badge-icon img {
width: 28px;
height: 28px;
}
.badge-icon-placeholder {
width: 28px;
height: 28px;
font-size: 14px;
}
.badge-text {
padding: 0 6px 0 4px;
height: 30px;
line-height: 30px;
}
}
@media (max-width: 480px) {
.tech-items {
grid-template-columns: repeat(2, 1fr);
}
}

View File

@@ -4,6 +4,21 @@ import './TechStackSection.css';
function TechStackSection({ techstack }) {
if (!techstack || !techstack.items) return null;
// 获取API基础URL用于处理图标路径
// 使用环境变量配置的API地址默认为 nav.api.shumengya.top
const apiBaseUrl = process.env.REACT_APP_API_URL ||
(process.env.NODE_ENV === 'development'
? 'http://localhost:5000/api'
: 'https://nav.api.shumengya.top/api');
// 获取图标URL
const getIconUrl = (item) => {
if (item.svg) {
return `${apiBaseUrl}/logo/${item.svg}`;
}
return null;
};
return (
<section className="techstack-section">
<h2 className="section-title">
@@ -13,31 +28,53 @@ function TechStackSection({ techstack }) {
<div className="techstack-container">
<div className="tech-items">
{techstack.items.map((item, idx) => (
<div key={idx} className="tech-item">
{item.link ? (
<a
href={item.link}
target="_blank"
rel="noopener noreferrer"
title={item.name}
{techstack.items
.filter(item => item.show !== false)
.map((item, idx) => {
const iconUrl = getIconUrl(item);
const backgroundColor = item.color || '#555555';
const badgeContent = (
<div
className="tech-badge"
style={{
backgroundColor: backgroundColor,
color: backgroundColor === '#FFFFFF' ? '#000000' : 'white'
}}
>
<img
src={item.icon}
alt={item.name}
loading="lazy"
/>
</a>
) : (
<img
src={item.icon}
alt={item.name}
title={item.name}
loading="lazy"
/>
)}
</div>
))}
<div className="badge-icon">
{iconUrl ? (
<img
src={iconUrl}
alt={item.name}
loading="lazy"
/>
) : (
<span className="badge-icon-placeholder">?</span>
)}
</div>
<span className="badge-text">{item.name}</span>
</div>
);
return (
<div key={idx} className="tech-item">
{item.link ? (
<a
href={item.link}
target="_blank"
rel="noopener noreferrer"
title={item.name}
className="tech-badge-link"
>
{badgeContent}
</a>
) : (
badgeContent
)}
</div>
);
})}
</div>
</div>
</section>

View File

@@ -11,7 +11,13 @@ root.render(
</React.StrictMode>
);
// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker
.register(`${process.env.PUBLIC_URL || ''}/service-worker.js`)
.then((reg) => console.log('PWA SW registered', reg.scope))
.catch((e) => console.log('PWA SW registration failed', e));
});
}
reportWebVitals();

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 841.9 595.3"><g fill="#61DAFB"><path d="M666.3 296.5c0-32.5-40.7-63.3-103.1-82.4 14.4-63.6 8-114.2-20.2-130.4-6.5-3.8-14.1-5.6-22.4-5.6v22.3c4.6 0 8.3.9 11.4 2.6 13.6 7.8 19.5 37.5 14.9 75.7-1.1 9.4-2.9 19.3-5.1 29.4-19.6-4.8-41-8.5-63.5-10.9-13.5-18.5-27.5-35.3-41.6-50 32.6-30.3 63.2-46.9 84-46.9V78c-27.5 0-63.5 19.6-99.9 53.6-36.4-33.8-72.4-53.2-99.9-53.2v22.3c20.7 0 51.4 16.5 84 46.6-14 14.7-28 31.4-41.3 49.9-22.6 2.4-44 6.1-63.6 11-2.3-10-4-19.7-5.2-29-4.7-38.2 1.1-67.9 14.6-75.8 3-1.8 6.9-2.6 11.5-2.6V78.5c-8.4 0-16 1.8-22.6 5.6-28.1 16.2-34.4 66.7-19.9 130.1-62.2 19.2-102.7 49.9-102.7 82.3 0 32.5 40.7 63.3 103.1 82.4-14.4 63.6-8 114.2 20.2 130.4 6.5 3.8 14.1 5.6 22.5 5.6 27.5 0 63.5-19.6 99.9-53.6 36.4 33.8 72.4 53.2 99.9 53.2 8.4 0 16-1.8 22.6-5.6 28.1-16.2 34.4-66.7 19.9-130.1 62-19.1 102.5-49.9 102.5-82.3zm-130.2-66.7c-3.7 12.9-8.3 26.2-13.5 39.5-4.1-8-8.4-16-13.1-24-4.6-8-9.5-15.8-14.4-23.4 14.2 2.1 27.9 4.7 41 7.9zm-45.8 106.5c-7.8 13.5-15.8 26.3-24.1 38.2-14.9 1.3-30 2-45.2 2-15.1 0-30.2-.7-45-1.9-8.3-11.9-16.4-24.6-24.2-38-7.6-13.1-14.5-26.4-20.8-39.8 6.2-13.4 13.2-26.8 20.7-39.9 7.8-13.5 15.8-26.3 24.1-38.2 14.9-1.3 30-2 45.2-2 15.1 0 30.2.7 45 1.9 8.3 11.9 16.4 24.6 24.2 38 7.6 13.1 14.5 26.4 20.8 39.8-6.3 13.4-13.2 26.8-20.7 39.9zm32.3-13c5.4 13.4 10 26.8 13.8 39.8-13.1 3.2-26.9 5.9-41.2 8 4.9-7.7 9.8-15.6 14.4-23.7 4.6-8 8.9-16.1 13-24.1zM421.2 430c-9.3-9.6-18.6-20.3-27.8-32 9 .4 18.2.7 27.5.7 9.4 0 18.7-.2 27.8-.7-9 11.7-18.3 22.4-27.5 32zm-74.4-58.9c-14.2-2.1-27.9-4.7-41-7.9 3.7-12.9 8.3-26.2 13.5-39.5 4.1 8 8.4 16 13.1 24 4.7 8 9.5 15.8 14.4 23.4zM420.7 163c9.3 9.6 18.6 20.3 27.8 32-9-.4-18.2-.7-27.5-.7-9.4 0-18.7.2-27.8.7 9-11.7 18.3-22.4 27.5-32zm-74 58.9c-4.9 7.7-9.8 15.6-14.4 23.7-4.6 8-8.9 16-13 24-5.4-13.4-10-26.8-13.8-39.8 13.1-3.1 26.9-5.8 41.2-7.9zm-90.5 125.2c-35.4-15.1-58.3-34.9-58.3-50.6 0-15.7 22.9-35.6 58.3-50.6 8.6-3.7 18-7 27.7-10.1 5.7 19.6 13.2 40 22.5 60.9-9.2 20.8-16.6 41.1-22.2 60.6-9.9-3.1-19.3-6.5-28-10.2zM310 490c-13.6-7.8-19.5-37.5-14.9-75.7 1.1-9.4 2.9-19.3 5.1-29.4 19.6 4.8 41 8.5 63.5 10.9 13.5 18.5 27.5 35.3 41.6 50-32.6 30.3-63.2 46.9-84 46.9-4.5-.1-8.3-1-11.3-2.7zm237.2-76.2c4.7 38.2-1.1 67.9-14.6 75.8-3 1.8-6.9 2.6-11.5 2.6-20.7 0-51.4-16.5-84-46.6 14-14.7 28-31.4 41.3-49.9 22.6-2.4 44-6.1 63.6-11 2.3 10.1 4.1 19.8 5.2 29.1zm38.5-66.7c-8.6 3.7-18 7-27.7 10.1-5.7-19.6-13.2-40-22.5-60.9 9.2-20.8 16.6-41.1 22.2-60.6 9.9 3.1 19.3 6.5 28.1 10.2 35.4 15.1 58.3 34.9 58.3 50.6-.1 15.7-23 35.6-58.4 50.6zM320.8 78.4z"/><circle cx="420.9" cy="296.5" r="45.7"/><path d="M520.5 78.1z"/></g></svg>

Before

Width:  |  Height:  |  Size: 2.6 KiB