chore: sync local updates
This commit is contained in:
42
mengyaprofile-frontend/package-lock.json
generated
42
mengyaprofile-frontend/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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="萌芽主页" />
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
658
mengyaprofile-frontend/src/AdminDashboard.css
Normal file
658
mengyaprofile-frontend/src/AdminDashboard.css
Normal 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;
|
||||
}
|
||||
1053
mengyaprofile-frontend/src/AdminDashboard.js
Normal file
1053
mengyaprofile-frontend/src/AdminDashboard.js
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(/访客 IP:66\.90\.99\.202/i);
|
||||
expect(visitorLine).toBeInTheDocument();
|
||||
expect(visitorLine).toHaveTextContent('JP');
|
||||
expect(visitorLine).toHaveTextContent('日本');
|
||||
expect(visitorLine).toHaveTextContent('东京都');
|
||||
});
|
||||
|
||||
290
mengyaprofile-frontend/src/HomePage.js
Normal file
290
mengyaprofile-frontend/src/HomePage.js
Normal 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>
|
||||
);
|
||||
}
|
||||
61
mengyaprofile-frontend/src/api.js
Normal file
61
mengyaprofile-frontend/src/api.js
Normal 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;
|
||||
}
|
||||
@@ -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°均匀分布)
|
||||
|
||||
@@ -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%);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
129
mengyaprofile-frontend/src/components/admin/AdminModal.css
Normal file
129
mengyaprofile-frontend/src/components/admin/AdminModal.css
Normal 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;
|
||||
}
|
||||
51
mengyaprofile-frontend/src/components/admin/AdminModal.js
Normal file
51
mengyaprofile-frontend/src/components/admin/AdminModal.js
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user