chore: sync local changes (2026-03-12)
@@ -21,7 +21,7 @@
|
||||
- 技术定位
|
||||
- 个人座右铭
|
||||
|
||||
### 2. 精选项目模块
|
||||
### 2. 全部项目模块
|
||||
以卡片形式展示项目:
|
||||
- 项目标题
|
||||
- 项目简介
|
||||
|
||||
23
mengyaprofile-frontend/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
|
Before Width: | Height: | Size: 4.0 MiB |
|
Before Width: | Height: | Size: 6.5 MiB |
|
Before Width: | Height: | Size: 6.6 MiB |
|
Before Width: | Height: | Size: 822 KiB |
|
Before Width: | Height: | Size: 3.7 MiB |
|
Before Width: | Height: | Size: 6.3 MiB |
|
Before Width: | Height: | Size: 977 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 25 KiB |
@@ -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"
|
||||
|
||||
|
Before Width: | Height: | Size: 5.2 KiB After Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 9.4 KiB |
@@ -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
|
||||
}
|
||||
|
||||
53
mengyaprofile-frontend/public/service-worker.js
Normal 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;
|
||||
})
|
||||
)
|
||||
);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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(/访客 IP:66\.90\.99\.202/i);
|
||||
expect(visitorLine).toBeInTheDocument();
|
||||
expect(visitorLine).toHaveTextContent('JP');
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 |