chore: sync local updates

This commit is contained in:
2026-05-16 19:03:34 +08:00
parent acba445550
commit 42100c2d0a
102 changed files with 4007 additions and 1477 deletions

View File

@@ -14,6 +14,7 @@
"@testing-library/user-event": "^13.5.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-router-dom": "^6.30.3",
"react-scripts": "5.0.1",
"web-vitals": "^2.1.4"
}
@@ -3072,6 +3073,15 @@
}
}
},
"node_modules/@remix-run/router": {
"version": "1.23.2",
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz",
"integrity": "sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w==",
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/@rollup/plugin-babel": {
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/@rollup/plugin-babel/-/plugin-babel-5.3.1.tgz",
@@ -13888,6 +13898,38 @@
"node": ">=0.10.0"
}
},
"node_modules/react-router": {
"version": "6.30.3",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.3.tgz",
"integrity": "sha512-XRnlbKMTmktBkjCLE8/XcZFlnHvr2Ltdr1eJX4idL55/9BbORzyZEaIkBFDhFGCEWBBItsVrDxwx3gnisMitdw==",
"license": "MIT",
"dependencies": {
"@remix-run/router": "1.23.2"
},
"engines": {
"node": ">=14.0.0"
},
"peerDependencies": {
"react": ">=16.8"
}
},
"node_modules/react-router-dom": {
"version": "6.30.3",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.3.tgz",
"integrity": "sha512-pxPcv1AczD4vso7G4Z3TKcvlxK7g7TNt3/FNGMhfqyntocvYKj+GCatfigGDjbLozC4baguJ0ReCigoDJXb0ag==",
"license": "MIT",
"dependencies": {
"@remix-run/router": "1.23.2",
"react-router": "6.30.3"
},
"engines": {
"node": ">=14.0.0"
},
"peerDependencies": {
"react": ">=16.8",
"react-dom": ">=16.8"
}
},
"node_modules/react-scripts": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/react-scripts/-/react-scripts-5.0.1.tgz",

View File

@@ -9,6 +9,7 @@
"@testing-library/user-event": "^13.5.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-router-dom": "^6.30.3",
"react-scripts": "5.0.1",
"web-vitals": "^2.1.4"
},

View File

@@ -1,10 +1,16 @@
<!DOCTYPE html>
<html lang="en">
<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
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Noto+Sans+SC:wght@400;500;600;700&display=swap"
rel="stylesheet"
/>
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#52b788" />
<meta name="theme-color" content="#e8897a" />
<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="萌芽主页" />

View File

@@ -25,8 +25,8 @@
"start_url": ".",
"display": "standalone",
"orientation": "portrait-primary",
"theme_color": "#52b788",
"background_color": "#a8e6cf",
"theme_color": "#e8897a",
"background_color": "#fff5ef",
"categories": ["personalization", "productivity"],
"prefer_related_applications": false
}

View File

@@ -0,0 +1,658 @@
.admin-page {
min-height: 100vh;
background: var(--warm-gradient-page);
padding: 16px;
box-sizing: border-box;
}
.admin-top {
max-width: 960px;
margin: 0 auto 16px;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px;
justify-content: space-between;
}
.admin-top h1 {
font-size: 1.25rem;
margin: 0;
color: #5c3d42;
}
.admin-top-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.admin-top-actions button {
padding: 8px 14px;
border-radius: 8px;
border: none;
cursor: pointer;
font-size: 14px;
background: #fff;
color: #5c3d42;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
}
.admin-top-actions button.danger {
background: #e07a5f;
color: #fff;
}
.admin-tabs {
max-width: 960px;
margin: 0 auto 16px;
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.admin-tabs button {
padding: 8px 14px;
border: none;
border-radius: 999px;
cursor: pointer;
background: rgba(255, 255, 255, 0.55);
color: #5c3d42;
font-size: 14px;
}
.admin-tabs button.active {
background: var(--accent);
color: #fff;
}
.admin-panel {
max-width: 960px;
margin: 0 auto;
background: rgba(255, 255, 255, 0.92);
border-radius: 12px;
padding: 20px;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.08);
}
.admin-msg {
margin: 0 0 12px;
font-size: 13px;
color: var(--accent-link);
}
.admin-msg.err {
color: #c0392b;
}
.admin-form-grid label {
display: block;
font-size: 12px;
color: #555;
margin-bottom: 4px;
}
.admin-form-grid input,
.admin-form-grid textarea {
width: 100%;
box-sizing: border-box;
margin-bottom: 12px;
padding: 8px 10px;
border: 1px solid #ccc;
border-radius: 8px;
font-size: 14px;
}
/* 复选框/单选框不应被拉成整行宽,否则文字会「飞到」行尾 */
.admin-form-grid input[type='checkbox'],
.admin-form-grid input[type='radio'] {
width: auto;
min-width: 1.125rem;
max-width: none;
margin-bottom: 0;
padding: 0;
border: none;
border-radius: 4px;
flex-shrink: 0;
align-self: center;
}
/* 覆盖 .admin-form-grid label { display:block },与勾选文字同一行对齐 */
.admin-form-grid label.admin-check {
display: flex !important;
align-items: center;
gap: 8px;
margin-top: 10px;
margin-bottom: 0;
width: 100%;
box-sizing: border-box;
cursor: pointer;
}
.admin-form-grid textarea {
min-height: 72px;
resize: vertical;
}
.admin-form-actions {
margin-top: 8px;
}
.admin-form-actions button {
padding: 10px 20px;
border: none;
border-radius: 8px;
background: var(--accent);
color: #fff;
cursor: pointer;
font-size: 14px;
}
/* 技术栈「已有项」:多列网格,减少纵向滚动 */
.admin-tech-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 10px;
align-items: start;
}
.admin-tech-card {
border: 1px solid #e0e0e0;
border-radius: 10px;
padding: 8px 10px;
background: #fafafa;
min-width: 0;
}
.admin-tech-card .admin-row-head {
margin-bottom: 0;
flex-wrap: nowrap;
justify-content: flex-start;
}
.admin-tech-card__ico {
flex-shrink: 0;
}
.admin-tech-card__ph {
font-size: 13px;
font-weight: 700;
line-height: 1;
}
.admin-tech-card__name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: #5c3d42;
}
.admin-tech-card .admin-row-btns {
flex-shrink: 0;
}
.admin-row {
border: 1px solid #e0e0e0;
border-radius: 10px;
padding: 12px;
margin-bottom: 12px;
background: #fafafa;
}
.admin-row-head {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 8px;
}
.admin-row-head strong {
color: #5c3d42;
}
.admin-row-btns {
display: flex;
gap: 6px;
}
.admin-row-btns button {
padding: 6px 12px;
font-size: 13px;
border-radius: 6px;
border: none;
cursor: pointer;
background: #fff;
border: 1px solid #ccc;
}
.admin-row-btns button.warn {
background: #fde8e4;
border-color: #e07a5f;
color: #922b21;
}
.admin-check {
display: flex;
align-items: center;
gap: 6px;
margin-right: 12px;
font-size: 13px;
}
.admin-check-row {
display: flex;
flex-wrap: wrap;
margin-bottom: 8px;
}
.admin-summary {
margin-bottom: 16px;
padding: 14px;
background: #fff8f5;
border-radius: 10px;
border: 1px solid #fce0d8;
}
.admin-summary p {
margin: 0 0 6px;
}
.admin-summary-meta {
font-size: 13px;
color: #555;
}
.admin-toolbar {
margin-bottom: 16px;
}
.admin-toolbar-btn {
padding: 10px 18px;
border: none;
border-radius: 8px;
font-size: 14px;
cursor: pointer;
background: #e8e8e8;
color: #333;
}
.admin-toolbar-btn.primary {
background: var(--accent);
color: #fff;
}
.admin-toolbar-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.admin-list-heading {
margin: 0 0 12px;
font-size: 1rem;
color: #5c3d42;
}
.admin-row-sub {
font-size: 13px;
color: #555;
}
/* 管理后台 · 项目列表卡片 */
.admin-project-card {
display: flex;
align-items: flex-start;
gap: 14px;
}
.admin-project-card__icon-wrap {
flex-shrink: 0;
width: 52px;
height: 52px;
border-radius: 12px;
background: #fff;
border: 1px solid #e0e0e0;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.admin-project-card__icon {
width: 36px;
height: 36px;
object-fit: contain;
}
.admin-project-card__main {
flex: 1;
min-width: 0;
}
.admin-project-card__head {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
justify-content: space-between;
gap: 10px;
margin-bottom: 6px;
}
.admin-project-card__title {
font-size: 1rem;
color: #5c3d42;
line-height: 1.3;
}
.admin-project-card__desc {
margin: 0 0 8px;
font-size: 13px;
color: #555;
line-height: 1.45;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.admin-project-card__link {
display: inline-block;
max-width: 100%;
font-size: 12px;
color: var(--accent-link);
word-break: break-all;
text-decoration: none;
margin-bottom: 8px;
}
.admin-project-card__link:hover {
text-decoration: underline;
}
.admin-project-card__link--empty {
color: #999;
cursor: default;
}
.admin-project-card__meta {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
}
.admin-project-card__tag {
font-size: 11px;
padding: 2px 8px;
border-radius: 999px;
background: #fff0eb;
color: #9d4e56;
border: 1px solid #f5cfc4;
}
.admin-project-card__tag--more {
background: #f5f5f5;
color: #666;
border-color: #ddd;
}
.admin-project-card__tag--tech {
background: #e3f2fd;
color: #1565c0;
border-color: #90caf9;
}
.admin-project-card__pill {
font-size: 11px;
padding: 2px 8px;
border-radius: 999px;
font-weight: 600;
}
.admin-project-card__pill--dev {
background: #e3f2fd;
color: #1565c0;
border: 1px solid #90caf9;
}
.admin-project-card__pill--deploy {
background: #fff3e0;
color: #e65100;
border: 1px solid #ffcc80;
}
.admin-project-card__pill--admin {
background: #fce4ec;
color: #c2185b;
border: 1px solid #f48fb1;
}
.admin-project-card__pill--hidden {
background: #eceff1;
color: #546e7a;
border: 1px solid #b0bec5;
}
.admin-inline-title {
margin-bottom: 16px;
}
.admin-title-row {
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: center;
}
.admin-title-row input {
flex: 1;
min-width: 160px;
box-sizing: border-box;
padding: 8px 10px;
border: 1px solid #ccc;
border-radius: 8px;
font-size: 14px;
}
.admin-tech-picker {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 12px;
max-height: 240px;
overflow-y: auto;
padding: 4px 0;
}
/* 项目弹窗加宽时:技术栈格子与图标放大,便于点击 */
.admin-modal-panel--wide .admin-tech-picker {
gap: 12px;
max-height: min(52vh, 480px);
padding: 8px 4px;
}
.admin-modal-panel--wide .admin-tech-pick {
width: 104px;
min-width: 104px;
min-height: 96px;
padding: 12px 8px;
gap: 8px;
border-radius: 12px;
font-size: 12px;
}
.admin-modal-panel--wide .admin-tech-pick__img {
width: 44px;
height: 44px;
}
.admin-modal-panel--wide .admin-tech-pick__ph {
width: 44px;
height: 44px;
border-radius: 8px;
font-size: 18px;
}
.admin-modal-panel--wide .admin-tech-pick__name {
font-size: 12px;
line-height: 1.25;
}
.admin-tech-picker-empty {
margin: 0;
font-size: 13px;
color: #888;
}
.admin-tech-pick {
display: inline-flex;
flex-direction: column;
align-items: center;
gap: 4px;
width: 76px;
padding: 8px 6px;
border: 2px solid #e0e0e0;
border-radius: 10px;
background: #fff;
cursor: pointer;
font-size: 11px;
color: #333;
transition: border-color 0.15s ease, box-shadow 0.15s ease, background 0.15s ease;
}
.admin-tech-pick:hover {
border-color: var(--accent);
}
.admin-tech-pick--on {
border-color: var(--accent);
background: var(--accent-alpha-14);
box-shadow: 0 0 0 1px var(--accent-alpha-35);
}
.admin-tech-pick__img {
width: 28px;
height: 28px;
object-fit: contain;
}
.admin-tech-pick__ph {
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
background: #eee;
border-radius: 6px;
font-weight: 700;
color: #999;
}
.admin-tech-pick__name {
text-align: center;
line-height: 1.2;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.admin-project-card__tech-ico {
display: inline-flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
padding: 3px;
border-radius: 6px;
box-sizing: border-box;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.08);
}
.admin-project-card__tech-ico img {
width: 100%;
height: 100%;
object-fit: contain;
}
/* 技术栈项「主题色」:取色器 + 文本 + 快捷色块 */
.admin-color-row {
display: flex;
align-items: stretch;
gap: 10px;
margin-bottom: 8px;
width: 100%;
box-sizing: border-box;
}
.admin-form-grid .admin-color-row input {
margin-bottom: 0;
}
/* 取色器宽度需压过 .admin-form-grid input { width:100% } */
.admin-form-grid .admin-color-row input[type='color'] {
width: 52px;
min-width: 52px;
max-width: 52px;
flex-shrink: 0;
padding: 3px;
}
.admin-color-native {
width: 52px;
min-width: 52px;
height: 42px;
padding: 3px;
border: 1px solid #ccc;
border-radius: 8px;
cursor: pointer;
background: #fff;
box-sizing: border-box;
}
.admin-color-native::-webkit-color-swatch-wrapper {
padding: 2px;
}
.admin-color-native::-webkit-color-swatch {
border-radius: 4px;
border: none;
}
.admin-color-hex-input {
flex: 1;
min-width: 0;
}
.admin-color-swatches {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 12px;
}
.admin-color-swatch {
width: 30px;
height: 30px;
border-radius: 6px;
border: 2px solid rgba(0, 0, 0, 0.12);
cursor: pointer;
padding: 0;
box-sizing: border-box;
transition: transform 0.12s ease, box-shadow 0.12s ease;
}
.admin-color-swatch:hover {
transform: scale(1.1);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.18);
z-index: 1;
}
.admin-color-swatch--light {
border-color: #bbb;
}

File diff suppressed because it is too large Load Diff

View File

@@ -17,9 +17,8 @@
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
font-family: 'Inter', 'Noto Sans SC', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei',
'Segoe UI', system-ui, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
overflow-y: scroll;
@@ -28,21 +27,21 @@ body {
.App {
min-height: 100vh;
background: linear-gradient(135deg, #a8e6cf 0%, #dcedc1 50%, #ffd3b6 100%);
background: var(--warm-gradient-page);
position: relative;
overflow-x: hidden;
}
/* 背景图片高斯模糊遮罩层 */
.background-overlay {
/* 全站随机背景(约 10% 强度blur 10px */
.app-background {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
backdrop-filter: blur(3px);
-webkit-backdrop-filter: blur(3px);
inset: 0;
z-index: 0;
background-size: cover;
background-position: center;
filter: blur(10px);
-webkit-filter: blur(10px);
transform: scale(1.06);
pointer-events: none;
}
@@ -54,9 +53,10 @@ body {
left: 0;
right: 0;
bottom: 0;
background:
radial-gradient(circle at 20% 50%, rgba(255, 255, 255, 0.2) 0%, transparent 50%),
radial-gradient(circle at 80% 80%, rgba(255, 255, 255, 0.2) 0%, transparent 50%);
z-index: 1;
background:
radial-gradient(circle at 20% 45%, rgba(255, 236, 228, 0.55) 0%, transparent 52%),
radial-gradient(circle at 82% 78%, rgba(255, 218, 228, 0.45) 0%, transparent 50%);
pointer-events: none;
animation: bgFloat 20s ease-in-out infinite;
}
@@ -71,7 +71,7 @@ body {
margin: 0 auto;
padding: 30px 20px;
position: relative;
z-index: 1;
z-index: 2;
}
/* 加载状态 */
@@ -116,7 +116,7 @@ body {
.pwa-launch-bg {
position: absolute;
inset: 0;
background: linear-gradient(135deg, #a8e6cf 0%, #dcedc1 40%, #ffd3b6 100%);
background: var(--warm-gradient-page-pwa);
animation: pwaLaunchBgPulse 3s ease-in-out infinite;
}
@@ -178,7 +178,7 @@ body {
position: absolute;
top: 50%;
left: 50%;
border: 2px solid rgba(82, 183, 136, 0.4);
border: 2px solid var(--accent-alpha-40);
border-radius: 50%;
transform: translate(-50%, -50%);
}
@@ -236,7 +236,7 @@ body {
width: 8px;
height: 8px;
border-radius: 50%;
background: #52b788;
background: var(--accent);
animation: pwaLaunchDot 1.2s ease-in-out infinite both;
}
@@ -285,7 +285,7 @@ body {
color: rgba(255, 255, 255, 0.8);
font-size: 14px;
position: relative;
z-index: 1;
z-index: 2;
}
.footer-visitor {
@@ -303,10 +303,82 @@ body {
-webkit-backdrop-filter: blur(8px);
}
.admin-token-backdrop {
position: fixed;
inset: 0;
z-index: 10000;
background: rgba(0, 0, 0, 0.35);
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
}
.admin-token-box {
width: 100%;
max-width: 320px;
background: rgba(255, 255, 255, 0.96);
border-radius: 12px;
padding: 20px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.18);
}
.admin-token-title {
margin: 0 0 12px;
font-size: 15px;
font-weight: 600;
color: #1a1a1a;
}
.admin-token-input {
width: 100%;
box-sizing: border-box;
padding: 10px 12px;
border: 1px solid #ccc;
border-radius: 8px;
font-size: 14px;
}
.admin-token-input:focus {
outline: none;
border-color: var(--accent);
}
.admin-token-err {
margin: 8px 0 0;
font-size: 12px;
color: #c0392b;
}
.admin-token-actions {
display: flex;
gap: 10px;
justify-content: flex-end;
margin-top: 16px;
}
.admin-token-btn {
padding: 8px 16px;
border-radius: 8px;
font-size: 14px;
cursor: pointer;
border: none;
}
.admin-token-btn.secondary {
background: #eee;
color: #333;
}
.admin-token-btn.primary {
background: var(--accent);
color: #fff;
}
/* 响应式设计 */
@media (max-width: 768px) {
.container {
padding: 20px 15px;
padding: 20px 10px;
}
.error-container h2 {

View File

@@ -1,244 +1,13 @@
import React, { useState, useEffect } from 'react';
import './App.css';
import ProfileSection from './components/ProfileSection';
import TechStackSection from './components/TechStackSection';
import ProjectsSection from './components/ProjectsSection';
import ContactsSection from './components/ContactsSection';
import ClickParticle from './components/ClickParticle';
function App() {
const [data, setData] = useState({
profile: null,
techstack: null,
projects: null,
contacts: null
});
const [loading, setLoading] = useState(true);
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 模式
const urlParams = new URLSearchParams(window.location.search);
const token = urlParams.get('token');
const pathname = window.location.pathname;
if (pathname.includes('/admin') && token === 'shumengya520') {
setIsAdminMode(true);
}
// 从后端API获取所有数据
// 使用环境变量配置的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');
fetch(`${apiBaseUrl}/all`)
.then(response => {
if (!response.ok) {
throw new Error('网络响应失败');
}
return response.json();
})
.then(data => {
setData(data);
// 设置 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 = 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) {
fetch(`${apiBaseUrl}/random-background`)
.then(res => res.json())
.then(bgData => {
if (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));
}
setLoading(false);
})
.catch(error => {
console.error('获取数据失败:', error);
setError(error.message);
setLoading(false);
});
}, []);
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="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>
);
}
if (error) {
return (
<div className="error-container">
<h2>加载失败</h2>
<p>{error}</p>
<p className="error-hint">请确保后端服务已启动运行 python app.py</p>
</div>
);
}
import React from 'react';
import { Routes, Route } from 'react-router-dom';
import HomePage from './HomePage';
import AdminDashboard from './AdminDashboard';
export default function App() {
return (
<div className="App" style={backgroundImage ? {
backgroundImage: `url(${backgroundImage})`,
backgroundSize: 'cover',
backgroundPosition: 'center',
backgroundAttachment: 'fixed'
} : {}}>
{/* 鼠标点击粒子效果 */}
<ClickParticle />
{backgroundImage && <div className="background-overlay"></div>}
{/* 管理员模式标识 */}
{isAdminMode && (
<div style={{
position: 'fixed',
top: '10px',
right: '10px',
background: 'rgba(82, 183, 136, 0.9)',
color: 'white',
padding: '8px 16px',
borderRadius: '20px',
fontSize: '14px',
fontWeight: 'bold',
zIndex: 9999,
boxShadow: '0 2px 8px rgba(0,0,0,0.2)'
}}>
🔐 管理员模式
</div>
)}
<div className="container">
{/* 个人信息模块 */}
{data.profile && <ProfileSection profile={data.profile} />}
{/* 技术栈模块 */}
{data.techstack && <TechStackSection techstack={data.techstack} />}
{/* 全部项目模块 */}
{data.projects && <ProjectsSection projects={data.projects.projects} />}
{/* 联系方式模块 */}
{data.contacts && <ContactsSection contacts={data.contacts.contacts} />}
</div>
{/* 页脚 */}
<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>
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/admin" element={<AdminDashboard />} />
</Routes>
);
}
export default App;

View File

@@ -1,4 +1,5 @@
import { render, screen } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import App from './App';
const mockAllData = {
@@ -6,7 +7,6 @@ const mockAllData = {
nickname: 'Test User',
avatar: null,
introduction: 'Hello',
showlocalbackground: false,
footer: '© Test Footer'
},
techstack: null,
@@ -17,7 +17,14 @@ const mockAllData = {
const mockVisitorInfo = {
ip: '66.90.99.202',
ipVersion: 'ipv4',
geo: { country: 'JP', region: 'Tokyo', city: 'Ebara' }
geo: {
country: 'JP',
countryName: '日本',
region: 'Tokyo',
regionName: '东京都',
city: 'Ebara',
cityName: '东京'
}
};
beforeEach(() => {
@@ -34,6 +41,12 @@ beforeEach(() => {
json: () => Promise.resolve(mockVisitorInfo)
});
}
if (typeof url === 'string' && url.includes('randbg.api.smyhub.com')) {
return Promise.resolve({
ok: true,
json: () => Promise.resolve({ url: 'https://example.com/bg.webp' })
});
}
return Promise.resolve({
ok: true,
json: () => Promise.resolve({})
@@ -46,8 +59,13 @@ afterEach(() => {
});
test('renders visitor ip and geo in footer', async () => {
render(<App />);
render(
<MemoryRouter initialEntries={['/']}>
<App />
</MemoryRouter>
);
const visitorLine = await screen.findByText(/访客 IP66\.90\.99\.202/i);
expect(visitorLine).toBeInTheDocument();
expect(visitorLine).toHaveTextContent('JP');
expect(visitorLine).toHaveTextContent('日本');
expect(visitorLine).toHaveTextContent('东京都');
});

View File

@@ -0,0 +1,290 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import './App.css';
import ProfileSection from './components/ProfileSection';
import TechStackSection from './components/TechStackSection';
import ProjectsSection from './components/ProjectsSection';
import ContactsSection from './components/ContactsSection';
import ClickParticle from './components/ClickParticle';
import { ADMIN_TOKEN_KEY, adminFetch, adminPing, getApiBase, getFaviconUrlForSite } from './api';
const RANDOM_BG_JSON = 'https://randbg.api.smyhub.com/api/random?format=json';
const AVATAR_TAP_RESET_MS = 2000;
const AVATAR_TAPS_REQUIRED = 5;
function formatVisitorGeoLine(geo) {
if (!geo) return null;
const parts = [
geo.countryName || geo.country,
geo.regionName || geo.region,
geo.cityName || geo.city
].filter(Boolean);
return parts.length ? parts.join(' · ') : null;
}
function VisitorIpLine({ ip, geo }) {
const geoLine = formatVisitorGeoLine(geo);
return (
<span>
访客 IP{ip}
{geoLine ? <> · {geoLine}</> : null}
</span>
);
}
export default function HomePage() {
const navigate = useNavigate();
const [data, setData] = useState({
profile: null,
techstack: null,
projects: null,
contacts: null
});
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [backgroundImage, setBackgroundImage] = useState(null);
const [visitorInfo, setVisitorInfo] = useState(null);
const [visitorInfoLoading, setVisitorInfoLoading] = useState(true);
const [tokenModalOpen, setTokenModalOpen] = useState(false);
const [tokenInput, setTokenInput] = useState('');
const [tokenError, setTokenError] = useState('');
const avatarTapRef = useRef({ count: 0, lastAt: 0 });
useEffect(() => {
const token = sessionStorage.getItem(ADMIN_TOKEN_KEY);
if (!token) return;
adminFetch('/admin/ping').then((r) => {
if (!r.ok) sessionStorage.removeItem(ADMIN_TOKEN_KEY);
});
}, []);
useEffect(() => {
const apiBaseUrl = getApiBase();
fetch(`${apiBaseUrl}/all`)
.then((response) => {
if (!response.ok) throw new Error('网络响应失败');
return response.json();
})
.then((d) => {
setData(d);
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 = url;
};
const fallbackFavicon = d.profile?.favicon || '';
const siteUrl =
d.profile?.site ||
d.profile?.homepage ||
d.contacts?.contacts?.find((c) => c.type === 'personprofile')?.link ||
d.contacts?.contacts?.find((c) => c.link?.startsWith('https://'))?.link ||
window.location.origin;
const apiFaviconUrl = getFaviconUrlForSite(siteUrl);
const img = new Image();
img.onload = () => applyFavicon(apiFaviconUrl);
img.onerror = () => {
if (fallbackFavicon) applyFavicon(fallbackFavicon);
};
img.src = apiFaviconUrl;
if (fallbackFavicon && !siteUrl) applyFavicon(fallbackFavicon);
setLoading(false);
})
.catch((err) => {
console.error('获取数据失败:', err);
setError(err.message);
setLoading(false);
});
}, []);
useEffect(() => {
const ac = new AbortController();
fetch(RANDOM_BG_JSON, { signal: ac.signal })
.then((r) => {
if (!r.ok) throw new Error(`HTTP ${r.status}`);
return r.json();
})
.then((j) => {
if (j?.url) setBackgroundImage(j.url);
})
.catch((err) => {
if (err?.name === 'AbortError') return;
console.warn('随机背景加载失败:', err);
});
return () => ac.abort();
}, []);
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();
}, []);
const onAvatarAdminTap = useCallback(() => {
const now = Date.now();
if (now - avatarTapRef.current.lastAt > AVATAR_TAP_RESET_MS) {
avatarTapRef.current.count = 0;
}
avatarTapRef.current.lastAt = now;
avatarTapRef.current.count += 1;
if (avatarTapRef.current.count >= AVATAR_TAPS_REQUIRED) {
avatarTapRef.current.count = 0;
setTokenInput('');
setTokenError('');
setTokenModalOpen(true);
}
}, []);
const submitAdminToken = async () => {
const t = tokenInput.trim();
if (!t) {
setTokenError('请输入 token');
return;
}
const ok = await adminPing(t);
if (!ok) {
setTokenError('token 无效');
return;
}
sessionStorage.setItem(ADMIN_TOKEN_KEY, t);
setTokenModalOpen(false);
navigate('/admin');
};
if (loading) {
return (
<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>
);
}
if (error) {
return (
<div className="error-container">
<h2>加载失败</h2>
<p>{error}</p>
<p className="error-hint">请确保后端服务已启动运行 python app.py</p>
</div>
);
}
return (
<div className="App">
{backgroundImage ? (
<div
className="app-background"
style={{ backgroundImage: `url(${backgroundImage})` }}
aria-hidden
/>
) : null}
<ClickParticle />
<div className="container">
{data.profile && (
<ProfileSection profile={data.profile} onAvatarAdminTap={onAvatarAdminTap} />
)}
{data.techstack && <TechStackSection techstack={data.techstack} />}
{data.projects && (
<ProjectsSection
projects={data.projects.projects}
techStackItems={data.techstack?.items || []}
/>
)}
{data.contacts && <ContactsSection contacts={data.contacts.contacts} />}
</div>
<footer className="footer">
<p>
<strong>
{data.profile?.footer || '© 2025 萌芽个人主页. All rights reserved.'}
</strong>
</p>
<div className="footer-visitor">
{visitorInfoLoading ? (
<span>访客信息加载中</span>
) : visitorInfo?.ip ? (
<VisitorIpLine ip={visitorInfo.ip} geo={visitorInfo.geo} />
) : (
<span>访客信息获取失败</span>
)}
</div>
</footer>
{tokenModalOpen ? (
<div
className="admin-token-backdrop"
role="dialog"
aria-modal="true"
aria-label="管理入口"
onMouseDown={(e) => {
if (e.target === e.currentTarget) setTokenModalOpen(false);
}}
>
<div className="admin-token-box">
<p className="admin-token-title">管理验证</p>
<input
type="password"
className="admin-token-input"
placeholder="Token"
value={tokenInput}
onChange={(e) => setTokenInput(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && submitAdminToken()}
autoFocus
/>
{tokenError ? <p className="admin-token-err">{tokenError}</p> : null}
<div className="admin-token-actions">
<button type="button" className="admin-token-btn secondary" onClick={() => setTokenModalOpen(false)}>
取消
</button>
<button type="button" className="admin-token-btn primary" onClick={submitAdminToken}>
进入
</button>
</div>
</div>
</div>
) : null}
</div>
);
}

View File

@@ -0,0 +1,61 @@
export const ADMIN_TOKEN_KEY = 'mengya_admin_token';
/** 站点 logo / favicon 解析(固定) */
export const FAVICON_API_URL = 'https://favicon.smyhub.com/api/favicon?url=';
export function getFaviconUrlForSite(siteUrl) {
return `${FAVICON_API_URL}${encodeURIComponent(siteUrl)}`;
}
export function getApiBase() {
return (
process.env.REACT_APP_API_URL ||
(process.env.NODE_ENV === 'development'
? 'http://localhost:5000/api'
: 'https://nav.api.shumengya.top/api')
);
}
/** 技术栈项 svg外链 URL 或 data/logo 下文件名 */
export function getTechstackIconUrl(item) {
const s = (item?.svg || '').trim();
if (!s) return null;
if (/^https?:\/\//i.test(s)) return s;
if (s.startsWith('//')) return `https:${s}`;
return `${getApiBase()}/logo/${s}`;
}
/** 技术栈徽章底色:支持 #hex 或 CSS linear-gradient(...) */
export function techBadgeSurfaceStyle(color) {
const c = String(color || '#555555').trim();
if (c.includes('gradient(')) {
return { background: c };
}
return { backgroundColor: c };
}
/** 与 techBadgeSurfaceStyle 配套的文字颜色 */
export function techBadgeLabelColor(color) {
const c = String(color || '').trim();
if (c.includes('gradient(')) return '#ffffff';
const u = c.toUpperCase();
if (u === '#FFFFFF' || u === '#FFF') return '#000000';
return '#ffffff';
}
export function adminFetch(path, options = {}) {
const token = sessionStorage.getItem(ADMIN_TOKEN_KEY);
const headers = {
'Content-Type': 'application/json',
...(options.headers || {}),
'X-Admin-Token': token || '',
};
return fetch(`${getApiBase()}${path}`, { ...options, headers });
}
export async function adminPing(token) {
const res = await fetch(`${getApiBase()}/admin/ping`, {
headers: { 'X-Admin-Token': token },
});
return res.ok;
}

View File

@@ -5,11 +5,11 @@ const ClickParticle = () => {
useEffect(() => {
const handleClick = (e) => {
const colors = [
'#ff6b9d', '#c44569', '#f8b500', '#ffd93d',
'#a8e6cf', '#95d5b2', '#74c69d', '#52b788',
'#6bcf7f', '#4dd599', '#38ada9', '#3fc1c9',
'#5f27cd', '#341f97', '#ee5a6f', '#fc5c65',
'#fed330', '#f7b731', '#fa8231', '#fd79a8'
'#ffd8cc', '#fbc4ab', '#f5c4d0', '#f4a574', '#e8897a',
'#ffb4a2', '#fce8e4', '#fdbcb4', '#ff9a8b',
'#ff6b9d', '#c44569', '#ee5a6f', '#fc5c65',
'#f8b500', '#ffd93d', '#fed330', '#fa8231',
'#fd79a8', '#f472b6', '#e07a5f', '#f59e0b',
];
// 更密集的均匀辐射一次点击360°均匀分布

View File

@@ -2,10 +2,10 @@
margin-bottom: 40px;
animation: fadeInUp 0.8s ease-out 0.4s both;
padding: 24px;
border: 2px solid rgba(82, 183, 136, 0.3);
border: 2px solid var(--accent-alpha-30);
border-radius: 20px;
background: rgba(255, 255, 255, 0.05);
box-shadow: 0 4px 20px rgba(82, 183, 136, 0.1);
box-shadow: 0 4px 20px var(--accent-alpha-10);
}
@keyframes fadeInUp {
@@ -40,20 +40,40 @@
}
.contact-card {
background: rgba(255, 255, 255, 0.95);
background: linear-gradient(
145deg,
rgba(255, 242, 236, 0.52) 0%,
rgba(255, 255, 255, 0.28) 48%,
rgba(var(--accent-r), var(--accent-g), var(--accent-b), 0.12) 100%
);
border: 1px solid rgba(245, 190, 175, 0.55);
border-radius: 16px;
padding: 20px;
display: flex;
align-items: center;
gap: 16px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
transition: all 0.3s ease;
box-shadow:
0 4px 24px var(--warm-deep-alpha-08),
0 1px 0 rgba(255, 232, 226, 0.62) inset;
backdrop-filter: blur(14px) saturate(1.2);
-webkit-backdrop-filter: blur(14px) saturate(1.2);
transition: background 0.3s ease, border-color 0.3s ease, box-shadow 0.3s ease, transform 0.3s ease;
position: relative;
overflow: hidden;
}
.contact-card:hover {
transform: translateY(-4px);
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.15);
background: linear-gradient(
145deg,
rgba(255, 228, 232, 0.62) 0%,
rgba(255, 255, 255, 0.32) 48%,
rgba(var(--accent-r), var(--accent-g), var(--accent-b), 0.2) 100%
);
border-color: rgba(232, 170, 158, 0.72);
box-shadow:
0 14px 36px var(--warm-deep-alpha-14),
0 1px 0 rgba(255, 236, 240, 0.68) inset;
}
.contact-icon {
@@ -63,10 +83,13 @@
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #52b788, #95d5b2);
background: rgba(255, 236, 232, 0.55);
border: 1px solid rgba(var(--accent-2-r), var(--accent-2-g), var(--accent-2-b), 0.42);
border-radius: 10px;
flex-shrink: 0;
box-shadow: 0 4px 12px rgba(82, 183, 136, 0.3);
box-shadow:
0 3px 14px var(--warm-deep-alpha-08),
0 1px 0 rgba(255, 255, 255, 0.55) inset;
position: relative;
overflow: hidden;
}
@@ -93,16 +116,18 @@
.contact-label {
font-size: 15px;
font-weight: 600;
color: #2d3748;
color: #1a202c;
margin-bottom: 4px;
text-shadow: 0 1px 0 rgba(255, 255, 255, 0.45);
}
.contact-value {
font-size: 13px;
color: #4a5568;
color: #2d3748;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
text-shadow: 0 1px 0 rgba(255, 255, 255, 0.35);
}
.contact-actions {
@@ -115,9 +140,9 @@
width: 32px;
height: 32px;
border-radius: 8px;
border: none;
background: rgba(82, 183, 136, 0.1);
color: #52b788;
border: 1px solid rgba(var(--accent-r), var(--accent-g), var(--accent-b), 0.28);
background: rgba(255, 238, 232, 0.42);
color: var(--accent-link);
font-size: 16px;
cursor: pointer;
transition: all 0.2s ease;
@@ -128,7 +153,12 @@
}
.contact-button:hover {
background: linear-gradient(135deg, #52b788, #95d5b2);
background: linear-gradient(
135deg,
rgba(var(--accent-r), var(--accent-g), var(--accent-b), 0.88),
rgba(var(--accent-2-r), var(--accent-2-g), var(--accent-2-b), 0.82)
);
border-color: rgba(255, 255, 255, 0.5);
color: white;
transform: scale(1.1);
}
@@ -141,13 +171,15 @@
position: absolute;
top: -36px;
right: 20px;
background: #48bb78;
background: rgba(184, 77, 95, 0.9);
color: white;
padding: 6px 12px;
border-radius: 8px;
font-size: 13px;
font-weight: 600;
box-shadow: 0 4px 12px rgba(72, 187, 120, 0.4);
box-shadow: 0 4px 16px var(--warm-deep-alpha-32);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
animation: toastSlideIn 0.3s ease-out;
}
@@ -168,51 +200,51 @@
grid-template-columns: 1fr;
gap: 12px;
}
.section-title {
font-size: 24px;
margin-bottom: 20px;
}
.contact-card {
padding: 14px;
border-radius: 12px;
gap: 12px;
}
.contact-icon {
width: 40px;
height: 40px;
font-size: 22px;
border-radius: 8px;
}
.contact-icon-img {
border-radius: 8px;
}
.contact-icon-emoji {
font-size: 22px;
}
.contact-label {
font-size: 14px;
}
.contact-value {
font-size: 12px;
}
.contact-actions {
gap: 6px;
}
.contact-button {
width: 28px;
height: 28px;
font-size: 14px;
}
.copy-toast {
right: 50%;
transform: translateX(50%);

View File

@@ -4,6 +4,8 @@ import './ContactsSection.css';
function ContactsSection({ contacts }) {
const [copiedType, setCopiedType] = useState(null);
const visibleContacts = (contacts || []).filter((c) => c.show !== false);
const handleCopy = (value, type) => {
navigator.clipboard.writeText(value).then(() => {
setCopiedType(type);
@@ -33,22 +35,32 @@ function ContactsSection({ contacts }) {
const icon = contact.icon || getContactIcon(contact.type);
if (isImageUrl(icon)) {
const fallback = getContactIcon(contact.type);
return (
<img
src={icon}
alt={contact.label}
className="contact-icon-img"
onError={(e) => {
e.target.style.display = 'none';
e.target.nextSibling.style.display = 'block';
}}
/>
<>
<img
src={icon}
alt={contact.label}
className="contact-icon-img"
onError={(e) => {
const img = e.currentTarget;
img.style.display = 'none';
const el = img.nextElementSibling;
if (el) el.style.display = 'block';
}}
/>
<span className="contact-icon-emoji" style={{ display: 'none' }} aria-hidden>
{fallback}
</span>
</>
);
}
return <span className="contact-icon-emoji">{icon}</span>;
};
if (!visibleContacts.length) return null;
return (
<section className="contacts-section">
<h2 className="section-title">
@@ -57,8 +69,8 @@ function ContactsSection({ contacts }) {
</h2>
<div className="contacts-grid">
{contacts.map((contact, index) => (
<div key={index} className="contact-card">
{visibleContacts.map((contact, index) => (
<div key={contact.id != null ? contact.id : index} className="contact-card">
<div className="contact-icon">
{renderIcon(contact)}
</div>

View File

@@ -39,6 +39,7 @@
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
position: relative;
z-index: 1;
cursor: pointer;
animation: avatarPulse 3s ease-in-out infinite;
}
@@ -54,7 +55,7 @@
right: -10px;
bottom: -10px;
border-radius: 50%;
background: linear-gradient(135deg, #52b788, #95d5b2);
background: var(--accent-gradient);
opacity: 0.3;
animation: ringPulse 3s ease-in-out infinite;
}
@@ -69,7 +70,7 @@
font-weight: 700;
color: #2d3748;
margin-bottom: 12px;
background: linear-gradient(135deg, #52b788, #95d5b2);
background: var(--accent-gradient);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
@@ -82,14 +83,14 @@
.position-badge {
display: inline-block;
background: linear-gradient(135deg, #52b788, #95d5b2);
background: var(--accent-gradient);
color: white;
padding: 8px 24px;
border-radius: 20px;
font-size: 14px;
font-weight: 600;
letter-spacing: 0.5px;
box-shadow: 0 4px 15px rgba(82, 183, 136, 0.4);
box-shadow: 0 4px 15px var(--accent-alpha-40);
}
.profile-introduction {
@@ -110,7 +111,7 @@
justify-content: center;
gap: 12px;
padding: 16px 24px;
background: linear-gradient(135deg, rgba(82, 183, 136, 0.1), rgba(149, 213, 178, 0.1));
background: linear-gradient(135deg, var(--accent-alpha-10), var(--accent-2-alpha-10));
border-radius: 12px;
margin-top: 24px;
max-width: 500px;
@@ -124,7 +125,7 @@
.motto-text {
font-size: 16px;
color: #2d6a4f;
color: var(--accent-link);
font-weight: 500;
font-style: italic;
}

View File

@@ -1,15 +1,16 @@
import React from 'react';
import './ProfileSection.css';
function ProfileSection({ profile }) {
function ProfileSection({ profile, onAvatarAdminTap }) {
return (
<section className="profile-section">
<div className="profile-card">
<div className="profile-avatar-container">
<img
src={profile.avatar}
<img
src={profile.avatar}
alt={profile.nickname}
className="profile-avatar"
onClick={() => onAvatarAdminTap?.()}
/>
<div className="avatar-ring"></div>
</div>

View File

@@ -2,10 +2,10 @@
margin-bottom: 40px;
animation: fadeInUp 0.8s ease-out 0.2s both;
padding: 24px;
border: 2px solid rgba(82, 183, 136, 0.3);
border: 2px solid var(--accent-alpha-30);
border-radius: 20px;
background: rgba(255, 255, 255, 0.05);
box-shadow: 0 4px 20px rgba(82, 183, 136, 0.1);
box-shadow: 0 4px 20px var(--accent-alpha-10);
}
@keyframes fadeInUp {
@@ -63,8 +63,8 @@
align-items: center;
gap: 6px;
padding: 8px 18px;
border: 2px solid rgba(82, 183, 136, 0.3);
border-radius: 20px;
border: 2px solid var(--accent-alpha-30);
border-radius: 8px;
background: rgba(255, 255, 255, 0.1);
color: white;
font-size: 14px;
@@ -76,58 +76,59 @@
}
.category-btn:hover {
background: rgba(82, 183, 136, 0.2);
border-color: rgba(82, 183, 136, 0.5);
background: var(--accent-alpha-20);
border-color: var(--accent-alpha-50);
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(82, 183, 136, 0.3);
box-shadow: 0 4px 12px var(--accent-alpha-30);
}
.category-btn.active {
background: linear-gradient(135deg, #52b788, #95d5b2);
border-color: #52b788;
background: var(--accent-gradient);
border-color: var(--accent);
color: white;
box-shadow: 0 4px 16px rgba(82, 183, 136, 0.4);
box-shadow: 0 4px 16px var(--accent-alpha-40);
}
.projects-grid {
display: grid;
grid-template-columns: repeat(5, 1fr);
grid-auto-rows: auto;
grid-template-columns: repeat(4, minmax(0, 1fr));
grid-auto-rows: 1fr;
align-items: stretch;
justify-items: stretch;
gap: 16px;
}
.project-card {
background: rgba(255, 255, 255, 0.95);
border-radius: 12px;
position: relative;
z-index: 0;
box-sizing: border-box;
width: 100%;
min-width: 0;
min-height: 0;
/* 当前页所有卡片跟随最大 Grid 行高,填满单元格。 */
height: 100%;
align-self: stretch;
padding: 20px;
text-decoration: none;
color: inherit;
display: flex;
flex-direction: column;
transition: all 0.3s ease;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
position: relative;
transition: background 0.3s ease, border-color 0.3s ease, box-shadow 0.3s ease, transform 0.3s ease;
border-radius: 14px;
overflow: hidden;
}
.develop-badge {
position: absolute;
top: 10px;
right: 10px;
z-index: 10;
cursor: help;
color: #52b788;
opacity: 0.85;
transition: opacity 0.2s ease;
}
.develop-badge:hover {
opacity: 1;
}
.develop-badge svg {
display: block;
filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.1));
isolation: isolate;
background: linear-gradient(
145deg,
rgba(255, 242, 236, 0.44) 0%,
rgba(255, 255, 255, 0.22) 52%,
rgba(var(--accent-r), var(--accent-g), var(--accent-b), 0.08) 100%
);
border: 1px solid rgba(255, 255, 255, 0.55);
box-shadow:
0 4px 24px rgba(0, 0, 0, 0.07),
0 1px 0 rgba(255, 255, 255, 0.65) inset;
backdrop-filter: blur(14px) saturate(1.12);
-webkit-backdrop-filter: blur(14px) saturate(1.12);
}
.project-card::before {
@@ -137,9 +138,11 @@
left: 0;
right: 0;
height: 3px;
background: linear-gradient(90deg, #52b788, #95d5b2);
background: var(--accent-gradient-90);
transform: scaleX(0);
transition: transform 0.3s ease;
z-index: 0;
pointer-events: none;
}
.project-card:hover::before {
@@ -148,14 +151,26 @@
.project-card:hover {
transform: translateY(-5px);
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.12);
background: linear-gradient(
145deg,
rgba(255, 228, 232, 0.52) 0%,
rgba(255, 255, 255, 0.32) 52%,
rgba(var(--accent-r), var(--accent-g), var(--accent-b), 0.14) 100%
);
border-color: rgba(255, 230, 235, 0.82);
box-shadow:
0 14px 36px var(--warm-deep-alpha-12),
0 1px 0 rgba(255, 248, 246, 0.78) inset;
}
.project-header {
position: relative;
z-index: 1;
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 12px;
flex-shrink: 0;
}
.project-icon {
@@ -163,8 +178,9 @@
height: 40px;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.08), 0 1px 0 rgba(255, 255, 255, 0.4) inset;
flex-shrink: 0;
background: rgba(255, 255, 255, 0.35);
}
.project-icon img {
@@ -176,34 +192,89 @@
.project-title {
font-size: 18px;
font-weight: 700;
color: #2d3748;
color: #1a202c;
margin: 0;
flex: 1;
text-shadow: 0 1px 0 rgba(255, 255, 255, 0.45);
}
.project-description {
font-size: 14px;
line-height: 1.5;
color: #4a5568;
color: #2d3748;
margin-bottom: 12px;
flex-grow: 1;
min-height: 0;
flex: 1 1 auto;
text-shadow: 0 1px 0 rgba(255, 255, 255, 0.35);
}
.project-tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 12px;
align-items: center;
gap: 8px;
height: 27px;
min-height: 27px;
margin-bottom: 0;
flex-shrink: 0;
overflow: hidden;
}
.project-tag {
display: inline-block;
padding: 3px 10px;
background: linear-gradient(135deg, rgba(82, 183, 136, 0.15), rgba(149, 213, 178, 0.15));
color: #2d6a4f;
border-radius: 10px;
font-size: 12px;
padding: 4px 11px;
border-radius: 6px;
font-size: 13px;
font-weight: 500;
border: 1px solid transparent;
}
/* 业务/分类标签:背景/文字/边框由 ProjectsSection 内联按标签名哈希着色 */
.project-tag--category {
border-style: solid;
}
/* 与技术栈板块中的名称对齐的条目(无图标配置时的回退) */
.project-tag--tech {
background: linear-gradient(135deg, rgba(66, 153, 225, 0.22), rgba(129, 178, 245, 0.12));
color: #2c5282;
border-color: rgba(66, 153, 225, 0.4);
}
/* 与 TechStackSection 中 .badge-icon img 一致36×36 */
.project-tech-icons {
display: flex;
flex-wrap: wrap-reverse;
align-content: flex-start;
align-items: center;
gap: 4px;
height: 76px;
min-height: 76px;
margin-bottom: 8px;
flex-shrink: 0;
overflow: hidden;
}
.project-tech-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
min-width: 36px;
min-height: 36px;
padding: 0;
border-radius: 4px;
box-sizing: border-box;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.12), 0 1px 2px rgba(0, 0, 0, 0.24);
flex-shrink: 0;
}
.project-tech-icon img {
width: 100%;
height: 100%;
object-fit: contain;
display: block;
}
.project-link-indicator {
@@ -211,10 +282,11 @@
align-items: center;
justify-content: space-between;
padding-top: 12px;
border-top: 1px solid #e2e8f0;
color: #52b788;
border-top: 1px solid rgba(255, 255, 255, 0.4);
color: var(--accent-link);
font-weight: 600;
font-size: 13px;
text-shadow: 0 1px 0 rgba(255, 255, 255, 0.35);
}
.arrow {
@@ -228,9 +300,13 @@
/* 响应式设计 */
@media (max-width: 768px) {
.projects-section {
padding: 16px 10px;
}
.projects-grid {
grid-template-columns: repeat(2, 1fr);
gap: 12px;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
.section-header {
@@ -256,17 +332,7 @@
}
.project-card {
padding: 16px;
}
.develop-badge {
top: 8px;
right: 8px;
}
.develop-badge svg {
width: 16px;
height: 16px;
padding: 14px 11px;
}
.project-header {
@@ -288,27 +354,24 @@
}
.project-tag {
font-size: 11px;
padding: 2px 8px;
font-size: 12px;
padding: 3px 9px;
border-radius: 5px;
}
}
/* 桌面端固定为5列 */
@media (min-width: 1441px) {
.projects-grid {
grid-template-columns: repeat(5, 1fr);
}
}
@media (min-width: 1025px) and (max-width: 1440px) {
.projects-grid {
grid-template-columns: repeat(4, 1fr);
/* 移动端略小于桌面,仍接近上方技术栈徽章的可读尺寸 */
.project-tech-icon {
width: 32px;
height: 32px;
min-width: 32px;
min-height: 32px;
border-radius: 4px;
}
}
@media (min-width: 769px) and (max-width: 1024px) {
.projects-grid {
grid-template-columns: repeat(3, 1fr);
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
@@ -340,13 +403,13 @@
}
.pagination-dot.active {
background: #52b788;
background: var(--accent);
width: 14px;
height: 14px;
box-shadow: 0 0 10px rgba(82, 183, 136, 0.5);
box-shadow: 0 0 10px var(--accent-alpha-50);
}
.pagination-dot:focus {
outline: 2px solid rgba(82, 183, 136, 0.5);
outline: 2px solid var(--accent-alpha-50);
outline-offset: 2px;
}

View File

@@ -1,30 +1,86 @@
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect, useMemo } from 'react';
import { useLocation } from 'react-router-dom';
import {
ADMIN_TOKEN_KEY,
getFaviconUrlForSite,
getTechstackIconUrl,
techBadgeSurfaceStyle,
} from '../api';
import './ProjectsSection.css';
function ProjectsSection({ projects }) {
function normTag(s) {
return String(s || '').trim().toLowerCase();
}
/** 分类标签:按文案稳定哈希选色,同一标签全局颜色一致 */
const CATEGORY_TAG_PALETTE = [
{ background: 'rgba(255, 236, 230, 0.95)', color: '#6b3d38', borderColor: 'rgba(232, 137, 122, 0.45)' },
{ background: 'rgba(219, 234, 254, 0.95)', color: '#1e3a8a', borderColor: 'rgba(59, 130, 246, 0.4)' },
{ background: 'rgba(254, 243, 199, 0.95)', color: '#78350f', borderColor: 'rgba(245, 158, 11, 0.45)' },
{ background: 'rgba(243, 232, 255, 0.95)', color: '#581c87', borderColor: 'rgba(168, 85, 247, 0.4)' },
{ background: 'rgba(255, 228, 230, 0.95)', color: '#881337', borderColor: 'rgba(244, 63, 94, 0.4)' },
{ background: 'rgba(224, 242, 254, 0.95)', color: '#0c4a6e', borderColor: 'rgba(14, 165, 233, 0.4)' },
{ background: 'rgba(237, 233, 254, 0.95)', color: '#4c1d95', borderColor: 'rgba(139, 92, 246, 0.4)' },
{ background: 'rgba(254, 249, 195, 0.95)', color: '#713f12', borderColor: 'rgba(234, 179, 8, 0.45)' },
{ background: 'rgba(209, 250, 229, 0.95)', color: '#064e3b', borderColor: 'rgba(16, 185, 129, 0.4)' },
{ background: 'rgba(255, 237, 213, 0.95)', color: '#7c2d12', borderColor: 'rgba(249, 115, 22, 0.4)' },
{ background: 'rgba(224, 231, 255, 0.95)', color: '#312e81', borderColor: 'rgba(99, 102, 241, 0.4)' },
{ background: 'rgba(252, 231, 243, 0.95)', color: '#831843', borderColor: 'rgba(236, 72, 153, 0.4)' },
{ background: 'rgba(204, 251, 241, 0.95)', color: '#134e4a', borderColor: 'rgba(20, 184, 166, 0.4)' },
{ background: 'rgba(254, 240, 138, 0.75)', color: '#422006', borderColor: 'rgba(202, 138, 4, 0.45)' },
{ background: 'rgba(233, 213, 255, 0.95)', color: '#4a044e', borderColor: 'rgba(192, 38, 211, 0.35)' },
];
function hashTagString(s) {
const str = String(s);
let h = 2166136261;
for (let i = 0; i < str.length; i += 1) {
h ^= str.charCodeAt(i);
h = Math.imul(h, 16777619);
}
return Math.abs(h);
}
function categoryTagStyle(tag) {
const idx = hashTagString(tag) % CATEGORY_TAG_PALETTE.length;
return CATEGORY_TAG_PALETTE[idx];
}
function ProjectsSection({ projects, techStackItems = [] }) {
const location = useLocation();
const techByNormName = useMemo(() => {
const m = new Map();
for (const item of techStackItems) {
const k = normTag(item.name);
if (k) m.set(k, item);
}
return m;
}, [techStackItems]);
const techNameSet = useMemo(() => new Set(techByNormName.keys()), [techByNormName]);
const tagIsTech = (tag) => techNameSet.has(normTag(tag));
const resolveTechItem = (name) => techByNormName.get(normTag(name));
const [hoveredIndex, setHoveredIndex] = useState(null);
const [isAdmin, setIsAdmin] = useState(false);
const [isAdmin, setIsAdmin] = useState(() => !!sessionStorage.getItem(ADMIN_TOKEN_KEY));
const [currentPage, setCurrentPage] = useState(0);
const [category, setCategory] = useState('all'); // 'all', 'develop', 'deploy'
const [itemsPerPage, setItemsPerPage] = useState(15); // 默认桌面端 3行 × 5列 = 15个项目
const [itemsPerPage, setItemsPerPage] = useState(12); // 默认桌面端 3行 × 4列 = 12个项目
useEffect(() => {
// 检查 URL 参数
const urlParams = new URLSearchParams(window.location.search);
const token = urlParams.get('token');
const pathname = window.location.pathname;
// 检查是否为 /admin 路径且 token 正确
if (pathname.includes('/admin') && token === 'shumengya520') {
setIsAdmin(true);
}
setIsAdmin(!!sessionStorage.getItem(ADMIN_TOKEN_KEY));
}, [location.key, location.pathname]);
useEffect(() => {
// 根据屏幕宽度设置每页显示数量
const updateItemsPerPage = () => {
if (window.innerWidth <= 768) {
setItemsPerPage(8); // 移动端4行 × 2列 = 8个项目
} else {
setItemsPerPage(15); // 桌面端3行 × 5列 = 15个项目
setItemsPerPage(12); // 桌面端3行 × 4列 = 12个项目
}
};
@@ -34,25 +90,64 @@ function ProjectsSection({ projects }) {
return () => window.removeEventListener('resize', updateItemsPerPage);
}, []);
// 优先使用 cf-favicon API,失败时 onError 会切到 project.icon 或通用图标
// 优先使用 favicon.smyhub.com,失败时 onError 会切到 project.icon 或通用图标
const getProjectIconUrl = (link) => {
try {
new URL(link);
return `https://cf-favicon.pages.dev/api/favicon?url=${encodeURIComponent(link)}`;
return getFaviconUrlForSite(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');
const img = e.currentTarget;
if (!img) return;
const isApiUrl = img.src && img.src.includes('favicon.smyhub.com');
if (isApiUrl && project.icon) {
e.target.src = project.icon;
img.src = project.icon;
} else {
e.target.src = 'https://api.iconify.design/mdi:web.svg';
img.src = 'https://api.iconify.design/mdi:web.svg';
}
};
const handleTechIconError = (e) => {
const img = e.currentTarget;
if (img) img.style.visibility = 'hidden';
};
const renderTechIcon = (name, key) => {
const item = resolveTechItem(name);
const iconUrl = item ? getTechstackIconUrl(item) : null;
const paint = item?.color || '#64748b';
if (item && iconUrl) {
return (
<span
key={key}
className="project-tech-icon"
style={techBadgeSurfaceStyle(paint)}
title={item.name}
>
<img src={iconUrl} alt="" loading="lazy" onError={handleTechIconError} />
</span>
);
}
return null;
};
const techNamesForProject = (project) => {
const useLegacy = project.techTags === undefined && techNameSet.size > 0;
return useLegacy
? (project.tags || []).filter((t) => tagIsTech(t))
: project.techTags || [];
};
const renderTechRow = (project) => (
<div className="project-tech-icons">
{techNamesForProject(project).map((n, i) => renderTechIcon(n, `t-${i}`))}
</div>
);
// 过滤项目
// 1. 如果 show 为 false,则不显示
// 2. 如果 admin 为 true 且不是管理员模式,则不显示
@@ -103,6 +198,14 @@ function ProjectsSection({ projects }) {
setCurrentPage(0); // 切换分类时重置到第一页
};
const categoryTagsForProject = (project) => {
const useLegacy = project.techTags === undefined && techNameSet.size > 0;
if (useLegacy) {
return (project.tags || []).filter((t) => !tagIsTech(t));
}
return project.tags || [];
};
return (
<section className="projects-section">
<div className="section-header">
@@ -147,14 +250,6 @@ function ProjectsSection({ projects }) {
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">
<path d="M13.7803 3.03039L20.4697 9.71982C21.0508 10.3009 21.0508 11.2451 20.4697 11.8262L11.8262 20.4697C11.2451 21.0508 10.3009 21.0508 9.71982 20.4697L3.03039 13.7803C2.44928 13.1992 2.44928 12.255 3.03039 11.6739L11.6739 3.03039C12.255 2.44928 13.1992 2.44928 13.7803 3.03039Z" fill="currentColor"/>
<path d="M5 20L9 16L13 20L9 24L5 20Z" fill="currentColor"/>
</svg>
</div>
)}
<div className="project-header">
<div className="project-icon">
<img
@@ -168,13 +263,25 @@ function ProjectsSection({ projects }) {
<p className="project-description">{project.description}</p>
{project.tags && project.tags.length > 0 && (
<div className="project-tags">
{project.tags.map((tag, index) => (
<span key={index} className="project-tag">{tag}</span>
))}
</div>
)}
{renderTechRow(project)}
<div className="project-tags">
{categoryTagsForProject(project).map((tag, index) => {
const cs = categoryTagStyle(tag);
return (
<span
key={`c-${index}`}
className="project-tag project-tag--category"
style={{
background: cs.background,
color: cs.color,
borderColor: cs.borderColor,
}}
>
{tag}
</span>
);
})}
</div>
</a>
);

View File

@@ -2,10 +2,10 @@
margin-bottom: 40px;
animation: fadeInUp 0.8s ease-out 0.15s both;
padding: 24px;
border: 2px solid rgba(82, 183, 136, 0.3);
border: 2px solid var(--accent-alpha-30);
border-radius: 20px;
background: rgba(255, 255, 255, 0.05);
box-shadow: 0 4px 20px rgba(82, 183, 136, 0.1);
box-shadow: 0 4px 20px var(--accent-alpha-10);
}
@keyframes fadeInUp {
@@ -77,7 +77,8 @@
max-height: 36px;
font-size: 20px;
font-weight: 600;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Noto Sans', Helvetica, Arial, sans-serif;
font-family: 'Inter', 'Noto Sans SC', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei',
'Segoe UI', system-ui, sans-serif;
line-height: 1;
white-space: nowrap;
overflow: hidden;
@@ -136,16 +137,19 @@
/* 响应式设计 */
@media (max-width: 768px) {
.techstack-section {
padding: 16px 10px;
}
.section-title {
font-size: 28px;
}
.techstack-container {
padding: 20px;
padding: 12px 10px;
}
.tech-items {
grid-template-columns: repeat(3, 1fr);
gap: 4px;
}
@@ -179,7 +183,11 @@
}
@media (max-width: 480px) {
.tech-items {
grid-template-columns: repeat(2, 1fr);
.techstack-section {
padding: 14px 8px;
}
.techstack-container {
padding: 10px 8px;
}
}

View File

@@ -1,24 +1,10 @@
import React from 'react';
import { getTechstackIconUrl, techBadgeSurfaceStyle, techBadgeLabelColor } from '../api';
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">
@@ -31,15 +17,15 @@ function TechStackSection({ techstack }) {
{techstack.items
.filter(item => item.show !== false)
.map((item, idx) => {
const iconUrl = getIconUrl(item);
const backgroundColor = item.color || '#555555';
const iconUrl = getTechstackIconUrl(item);
const badgePaint = item.color || '#555555';
const badgeContent = (
<div
className="tech-badge"
style={{
backgroundColor: backgroundColor,
color: backgroundColor === '#FFFFFF' ? '#000000' : 'white'
<div
className="tech-badge"
style={{
...techBadgeSurfaceStyle(badgePaint),
color: techBadgeLabelColor(badgePaint),
}}
>
<div className="badge-icon">

View File

@@ -0,0 +1,129 @@
.admin-modal-backdrop {
position: fixed;
inset: 0;
z-index: 20000;
background: rgba(0, 0, 0, 0.45);
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
box-sizing: border-box;
}
/* 宽版弹窗:左右留白略减,让内容区更宽 */
.admin-modal-backdrop:has(.admin-modal-panel--wide) {
padding: 12px;
}
.admin-modal-panel {
width: 100%;
max-width: 520px;
max-height: min(90vh, 720px);
background: #fff;
border-radius: 14px;
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.2);
display: flex;
flex-direction: column;
overflow: hidden;
}
/* 项目编辑:表单更宽、更高,便于技术栈多选 */
.admin-modal-panel--wide {
max-width: min(98vw, 1120px);
max-height: min(94vh, 920px);
}
.admin-modal-panel--wide .admin-modal-header,
.admin-modal-panel--wide .admin-modal-footer {
padding-left: 24px;
padding-right: 24px;
}
.admin-modal-panel--wide .admin-modal-body {
padding: 20px 32px;
}
.admin-modal-header {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 14px 16px;
border-bottom: 1px solid #eee;
}
.admin-modal-title {
margin: 0;
font-size: 1.05rem;
font-weight: 600;
color: #5c3d42;
}
.admin-modal-close {
border: none;
background: #f0f0f0;
width: 32px;
height: 32px;
border-radius: 8px;
font-size: 22px;
line-height: 1;
cursor: pointer;
color: #333;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
}
.admin-modal-close:hover {
background: #e0e0e0;
}
.admin-modal-body {
flex: 1;
overflow-y: auto;
padding: 16px;
-webkit-overflow-scrolling: touch;
}
.admin-modal-footer {
flex-shrink: 0;
display: flex;
justify-content: flex-end;
gap: 10px;
padding: 12px 16px;
border-top: 1px solid #eee;
background: #fafafa;
}
.admin-modal-footer button {
padding: 10px 18px;
border-radius: 8px;
font-size: 14px;
cursor: pointer;
border: none;
}
.admin-modal-footer button.secondary {
background: #e8e8e8;
color: #333;
}
.admin-modal-footer button.secondary:disabled,
.admin-modal-footer button.primary:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.admin-modal-footer button.primary {
background: var(--accent);
color: #fff;
}
.admin-field-hint {
margin: -8px 0 10px;
font-size: 12px;
color: #666;
line-height: 1.4;
}

View File

@@ -0,0 +1,51 @@
import React, { useEffect, useRef } from 'react';
import './AdminModal.css';
export default function AdminModal({ open, title, onClose, children, footer, panelClassName }) {
const closeRef = useRef(onClose);
closeRef.current = onClose;
useEffect(() => {
if (!open) return;
const onKey = (e) => {
if (e.key === 'Escape') closeRef.current();
};
document.addEventListener('keydown', onKey);
return () => document.removeEventListener('keydown', onKey);
}, [open]);
if (!open) return null;
return (
<div
className="admin-modal-backdrop"
onMouseDown={(e) => {
if (e.target === e.currentTarget) onClose();
}}
>
<div
className={['admin-modal-panel', panelClassName].filter(Boolean).join(' ')}
role="dialog"
aria-modal="true"
aria-labelledby="admin-modal-title"
onMouseDown={(e) => e.stopPropagation()}
>
<div className="admin-modal-header">
<h2 id="admin-modal-title" className="admin-modal-title">
{title}
</h2>
<button
type="button"
className="admin-modal-close"
onClick={onClose}
aria-label="关闭"
>
×
</button>
</div>
<div className="admin-modal-body">{children}</div>
{footer ? <div className="admin-modal-footer">{footer}</div> : null}
</div>
</div>
);
}

View File

@@ -1,8 +1,51 @@
/*
* 暖色主题(淡橙 + 淡粉):全局变量,供 App 与各组件 CSS 引用
*/
:root {
--warm-bg-1: #fff5ef;
--warm-bg-2: #ffe8ec;
--warm-bg-3: #ffe4d4;
--accent: #e8897a;
--accent-soft: #f4b8a8;
--accent-secondary: #f5c4d0;
--accent-link: #b84d5f;
--accent-r: 232;
--accent-g: 137;
--accent-b: 122;
--accent-2-r: 245;
--accent-2-g: 196;
--accent-2-b: 208;
--warm-shadow-rgb: 120, 65, 55;
--warm-gradient-page: linear-gradient(135deg, var(--warm-bg-1) 0%, var(--warm-bg-2) 50%, var(--warm-bg-3) 100%);
--warm-gradient-page-pwa: linear-gradient(135deg, var(--warm-bg-1) 0%, var(--warm-bg-2) 40%, var(--warm-bg-3) 100%);
--accent-gradient: linear-gradient(135deg, var(--accent), var(--accent-secondary));
--accent-gradient-90: linear-gradient(90deg, var(--accent), var(--accent-secondary));
--accent-alpha-10: rgba(var(--accent-r), var(--accent-g), var(--accent-b), 0.1);
--accent-alpha-14: rgba(var(--accent-r), var(--accent-g), var(--accent-b), 0.14);
--accent-alpha-20: rgba(var(--accent-r), var(--accent-g), var(--accent-b), 0.2);
--accent-alpha-30: rgba(var(--accent-r), var(--accent-g), var(--accent-b), 0.3);
--accent-alpha-35: rgba(var(--accent-r), var(--accent-g), var(--accent-b), 0.35);
--accent-alpha-40: rgba(var(--accent-r), var(--accent-g), var(--accent-b), 0.4);
--accent-alpha-50: rgba(var(--accent-r), var(--accent-g), var(--accent-b), 0.5);
--accent-2-alpha-10: rgba(var(--accent-2-r), var(--accent-2-g), var(--accent-2-b), 0.1);
--accent-2-alpha-85: rgba(var(--accent-2-r), var(--accent-2-g), var(--accent-2-b), 0.85);
--warm-deep-alpha-08: rgba(var(--warm-shadow-rgb), 0.08);
--warm-deep-alpha-12: rgba(var(--warm-shadow-rgb), 0.12);
--warm-deep-alpha-14: rgba(var(--warm-shadow-rgb), 0.14);
--warm-deep-alpha-32: rgba(var(--warm-shadow-rgb), 0.32);
}
/*
* 西文Inter独立站/产品站常用)
* 中文Noto Sans SC思源黑体与 Inter 混排常见)
* 回退:系统无衬线,保证无网络时仍可读
*/
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
font-family: 'Inter', 'Noto Sans SC', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei',
'Segoe UI', system-ui, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}

View File

@@ -1,5 +1,6 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import './index.css';
import App from './App';
import reportWebVitals from './reportWebVitals';
@@ -7,7 +8,9 @@ import reportWebVitals from './reportWebVitals';
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
<React.StrictMode>
<App />
<BrowserRouter>
<App />
</BrowserRouter>
</React.StrictMode>
);