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

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

View File

@@ -1,41 +0,0 @@
# Node 相关
mengyaprofile-frontend/node_modules
mengyaprofile-frontend/build
mengyaprofile-frontend/.env.local
mengyaprofile-frontend/.env.development.local
mengyaprofile-frontend/.env.test.local
mengyaprofile-frontend/.env.production.local
mengyaprofile-frontend/npm-debug.log*
mengyaprofile-frontend/yarn-debug.log*
mengyaprofile-frontend/yarn-error.log*
# Python 相关
mengyaprofile-backend/__pycache__
mengyaprofile-backend/*.pyc
mengyaprofile-backend/*.pyo
mengyaprofile-backend/*.pyd
mengyaprofile-backend/.Python
mengyaprofile-backend/env
mengyaprofile-backend/venv
mengyaprofile-backend/.env
# Git 相关
.git
.gitignore
.gitattributes
# IDE 相关
.vscode
.idea
*.swp
*.swo
*~
# OS 相关
.DS_Store
Thumbs.db
# 其他
*.bat
*.md
README.md

47
AGENTS.md Normal file
View File

@@ -0,0 +1,47 @@
# Repository Guidelines
## Project Structure & Module Organization
- `mengyaprofile-frontend/`: React (Create React App) UI (`src/`, `public/`).
- `mengyaprofile-backend/`: Flask API (`app.py`) plus site content in `data/*.json` and assets in `data/logo/`, `data/background/`.
- Root `*.bat`: Windows helper scripts for starting/building locally.
## Build, Test, and Development Commands
```bash
# Backend (Flask API)
cd mengyaprofile-backend
python -m pip install -r requirements.txt
python app.py # http://localhost:5000
# Frontend (React)
cd ../mengyaprofile-frontend
npm install
npm start # http://localhost:3000
npm test # Jest/RTL in watch mode
npm run build # production build to ./build
```
- Windows shortcuts: `start-backend.bat`, `start-frontend.bat`, `build-frontend.bat`.
- Docker (optional): `docker compose -f mengyaprofile-backend/docker-compose.yml up -d --build` (adjust the volume path for your machine).
## Coding Style & Naming Conventions
- Python: PEP 8, 4-space indents; keep API routes under `/api/*` in `mengyaprofile-backend/app.py`.
- React: 2-space indents; components live in `mengyaprofile-frontend/src/components/` with `PascalCase` filenames (e.g., `TechStackSection.js`).
- Data files: edit `mengyaprofile-backend/data/*.json` (UTF-8). Prefer stable keys and keep lists ordered to produce readable diffs.
## Testing Guidelines
- Frontend tests live in `mengyaprofile-frontend/src/**/*.test.js` (example: `src/App.test.js`); run via `npm test`.
- Backend currently has no test suite; if adding one, use `pytest` and place tests under `mengyaprofile-backend/tests/`.
## Commit & Pull Request Guidelines
- Current Git history uses short subjects (e.g., “Initial commit”, “初始化提交”); keep messages concise and scoped (`frontend: ...`, `backend: ...`).
- PRs: describe behavior changes, link issues, include screenshots for UI changes, and call out any `data/*.json` schema updates.
## Security & Configuration Tips
- “Admin mode” is client-side (`/admin?token=...`) and not a security boundary—do not store secrets in this repo.
- Useful env vars: backend `RUN_MODE`, `DATA_DIR`, `BACKGROUND_DIR`, `PORT`; frontend `REACT_APP_API_URL` (use `.env.local`).

View File

@@ -1,42 +0,0 @@
# 多阶段构建 Dockerfile
# 阶段1: 构建前端
FROM node:18-alpine AS frontend-builder
WORKDIR /app/frontend
# 复制前端文件
COPY mengyaprofile-frontend/package*.json ./
RUN npm install
COPY mengyaprofile-frontend/ ./
RUN npm run build
# 阶段2: 构建后端并整合
FROM python:3.11-slim
WORKDIR /app
# 安装 Python 依赖
COPY mengyaprofile-backend/requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt
# 复制后端代码
COPY mengyaprofile-backend/ ./backend/
# 从前端构建阶段复制构建产物
COPY --from=frontend-builder /app/frontend/build ./frontend/build
# 创建数据目录(用于持久化)
RUN mkdir -p /app/data
# 暴露端口
EXPOSE 5000
# 设置环境变量
ENV PYTHONUNBUFFERED=1
ENV DATA_DIR=/app/data
ENV RUN_MODE=production
# 启动命令
WORKDIR /app/backend
CMD ["python", "app.py"]

View File

@@ -32,7 +32,7 @@ docker-compose up -d --build
## 项目简介 ## 项目简介
这是一个功能完整、设计精美的个人主页系统,展示个人信息、精选项目和联系方式。 这是一个功能完整、设计精美的个人主页系统,展示个人信息、全部项目和联系方式。
### 特性亮点 ### 特性亮点
@@ -42,7 +42,7 @@ docker-compose up -d --build
- 🐳 **Docker 支持**: 一键部署,开箱即用 - 🐳 **Docker 支持**: 一键部署,开箱即用
- 💾 **数据持久化**: 配置文件外部存储 - 💾 **数据持久化**: 配置文件外部存储
-**快速灵活**: 通过 JSON 配置文件轻松管理内容 -**快速灵活**: 通过 JSON 配置文件轻松管理内容
- 🎯 **三大模块**: 个人信息、精选项目、联系方式 - 🎯 **三大模块**: 个人信息、全部项目、联系方式
- 🔐 **权限控制**: 管理员模式隐藏私密项目 - 🔐 **权限控制**: 管理员模式隐藏私密项目
## 项目结构 ## 项目结构
@@ -124,7 +124,7 @@ npm start
**配置文件**: `mengyaprofile-backend/data/profile.json` **配置文件**: `mengyaprofile-backend/data/profile.json`
### 2精选项目模块 ### 2全部项目模块
以卡片形式展示项目: 以卡片形式展示项目:
- 📦 项目标题 - 📦 项目标题

View File

@@ -1,28 +0,0 @@
version: '3.8'
services:
mengya-profile:
build: .
container_name: mengya-profile
restart: unless-stopped
ports:
- "5000:5000" # 后端 API 端口
volumes:
- /shumengya/docker/storage/mengyaprofile/data:/app/data:rw
- /shumengya/docker/storage/mengyaprofile/background:/app/frontend/build/background:rw
environment:
- PYTHONUNBUFFERED=1
- DATA_DIR=/app/data
- RUN_MODE=production
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:5000/api/all')"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
networks:
- mengya-network
networks:
mengya-network:
driver: bridge

View File

@@ -1,176 +0,0 @@
#!/bin/bash
# 萌芽个人主页 - Docker 部署脚本 (Linux/Mac)
set -e
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
# 检查 Docker 是否安装
if ! command -v docker &> /dev/null; then
echo -e "${RED}[ERROR]${NC} Docker 未安装"
echo "请先安装 Docker: https://docs.docker.com/get-docker/"
exit 1
fi
# 检查 Docker 是否运行
if ! docker ps &> /dev/null; then
echo -e "${RED}[ERROR]${NC} Docker 服务未运行"
echo "请启动 Docker 后重试"
exit 1
fi
# 菜单函数
show_menu() {
clear
echo "========================================"
echo " 萌芽个人主页 - Docker 部署菜单"
echo "========================================"
echo
echo "[1] 构建并启动容器 (首次部署)"
echo "[2] 启动容器"
echo "[3] 停止容器"
echo "[4] 重启容器"
echo "[5] 查看日志"
echo "[6] 查看容器状态"
echo "[7] 停止并删除容器"
echo "[8] 重新构建镜像"
echo "[0] 退出"
echo
}
# 构建并启动
build_and_start() {
echo
echo -e "${YELLOW}[INFO]${NC} 创建持久化目录..."
mkdir -p /shumengya/docker/storage/mengyaprofile/data
mkdir -p /shumengya/docker/storage/mengyaprofile/background
echo -e "${YELLOW}[INFO]${NC} 复制初始数据..."
if [ ! -f /shumengya/docker/storage/mengyaprofile/data/profile.json ]; then
cp -r mengyaprofile-backend/data/* /shumengya/docker/storage/mengyaprofile/data/
fi
if [ -d mengyaprofile-frontend/public/background ] && [ "$(ls -A mengyaprofile-frontend/public/background)" ]; then
cp -r mengyaprofile-frontend/public/background/* /shumengya/docker/storage/mengyaprofile/background/ 2>/dev/null || true
fi
echo -e "${YELLOW}[INFO]${NC} 正在构建并启动容器..."
if docker-compose up -d --build; then
echo
echo -e "${GREEN}[SUCCESS]${NC} 启动成功!"
echo "访问地址: http://localhost:5000"
echo "管理员模式: http://localhost:5000/admin?token=shumengya520"
else
echo -e "${RED}[ERROR]${NC} 启动失败"
fi
echo
read -p "按回车键继续..."
}
# 启动容器
start() {
echo
echo -e "${YELLOW}[INFO]${NC} 正在启动容器..."
if docker-compose up -d; then
echo -e "${GREEN}[SUCCESS]${NC} 启动成功!"
else
echo -e "${RED}[ERROR]${NC} 启动失败"
fi
read -p "按回车键继续..."
}
# 停止容器
stop() {
echo
echo -e "${YELLOW}[INFO]${NC} 正在停止容器..."
docker-compose stop
echo -e "${GREEN}[SUCCESS]${NC} 已停止"
read -p "按回车键继续..."
}
# 重启容器
restart() {
echo
echo -e "${YELLOW}[INFO]${NC} 正在重启容器..."
docker-compose restart
echo -e "${GREEN}[SUCCESS]${NC} 已重启"
read -p "按回车键继续..."
}
# 查看日志
logs() {
echo
echo -e "${YELLOW}[INFO]${NC} 显示日志 (按 Ctrl+C 退出)..."
echo
docker-compose logs -f
read -p "按回车键继续..."
}
# 查看状态
status() {
echo
echo -e "${YELLOW}[INFO]${NC} 容器状态:"
echo
docker-compose ps
echo
echo -e "${YELLOW}[INFO]${NC} 资源占用:"
docker stats --no-stream mengya-profile
echo
read -p "按回车键继续..."
}
# 删除容器
remove() {
echo
echo -e "${YELLOW}[WARNING]${NC} 将停止并删除容器(数据不会丢失)"
read -p "确认删除? (y/n): " confirm
if [ "$confirm" = "y" ] || [ "$confirm" = "Y" ]; then
echo
echo -e "${YELLOW}[INFO]${NC} 正在删除容器..."
docker-compose down
echo -e "${GREEN}[SUCCESS]${NC} 已删除"
fi
read -p "按回车键继续..."
}
# 重新构建
rebuild() {
echo
echo -e "${YELLOW}[INFO]${NC} 正在重新构建镜像..."
docker-compose down
if docker-compose build --no-cache && docker-compose up -d; then
echo -e "${GREEN}[SUCCESS]${NC} 重新构建完成!"
else
echo -e "${RED}[ERROR]${NC} 构建失败"
fi
read -p "按回车键继续..."
}
# 主循环
while true; do
show_menu
read -p "请选择操作 (0-8): " choice
case $choice in
1) build_and_start ;;
2) start ;;
3) stop ;;
4) restart ;;
5) logs ;;
6) status ;;
7) remove ;;
8) rebuild ;;
0)
echo
echo "感谢使用萌芽个人主页 Docker 部署工具!"
exit 0
;;
*)
echo -e "${RED}无效选择${NC}"
sleep 1
;;
esac
done

View File

@@ -0,0 +1,18 @@
__pycache__
*.pyc
*.pyo
*.pyd
.Python
*.so
*.egg
*.egg-info
dist
build
.env
.venv
venv/
ENV/
.git
.gitignore
README.md
*.md

View File

@@ -0,0 +1,28 @@
# 使用 Python 官方镜像
FROM python:3.11-slim
# 设置工作目录
WORKDIR /app
# 设置环境变量
ENV PYTHONUNBUFFERED=1
ENV RUN_MODE=production
ENV DATA_DIR=/app/data
# 复制依赖文件
COPY requirements.txt .
# 安装依赖
RUN pip install --no-cache-dir -r requirements.txt
# 复制应用代码
COPY app.py .
# 创建数据目录(如果挂载了外部卷,这个目录会被覆盖)
RUN mkdir -p /app/data/logo
# 暴露端口
EXPOSE 5000
# 启动应用
CMD ["python", "app.py"]

View File

@@ -19,7 +19,7 @@ python app.py
## API 接口 ## API 接口
- `GET /api/profile` - 获取个人基本信息 - `GET /api/profile` - 获取个人基本信息
- `GET /api/projects` - 获取精选项目列表 - `GET /api/projects` - 获取全部项目列表
- `GET /api/contacts` - 获取联系方式 - `GET /api/contacts` - 获取联系方式
- `GET /api/all` - 获取所有数据 - `GET /api/all` - 获取所有数据

View File

@@ -7,22 +7,27 @@ import random
# 检测运行模式:通过环境变量控制 # 检测运行模式:通过环境变量控制
RUN_MODE = os.environ.get('RUN_MODE', 'development') # development 或 production RUN_MODE = os.environ.get('RUN_MODE', 'development') # development 或 production
# 数据文件路径 - 支持环境变量配置(需要先定义,因为后面会用到)
DATA_DIR = os.environ.get('DATA_DIR', os.path.join(os.path.dirname(__file__), 'data'))
# 根据运行模式配置 # 根据运行模式配置
if RUN_MODE == 'production': # 检查是否有前端构建文件(前后端分离时可能没有)
# 生产环境:使用构建后的前端 FRONTEND_BUILD_PATH = os.path.join(os.path.dirname(__file__), '..', 'frontend', 'build')
FRONTEND_BUILD_PATH = os.path.join(os.path.dirname(__file__), '..', 'frontend', 'build') HAS_FRONTEND_BUILD = os.path.exists(FRONTEND_BUILD_PATH) and os.path.isdir(FRONTEND_BUILD_PATH)
# 背景图片目录 - 固定使用数据目录中的 background 文件夹
# 支持通过环境变量配置,默认在数据目录中
BACKGROUND_DIR = os.environ.get('BACKGROUND_DIR', os.path.join(DATA_DIR, 'background'))
if RUN_MODE == 'production' and HAS_FRONTEND_BUILD:
# 生产环境:使用构建后的前端(如果存在)
app = Flask(__name__, static_folder=FRONTEND_BUILD_PATH, static_url_path='') app = Flask(__name__, static_folder=FRONTEND_BUILD_PATH, static_url_path='')
BACKGROUND_DIR = os.path.join(FRONTEND_BUILD_PATH, 'background')
else: else:
# 开发环境:不服务前端,只提供 API # 开发环境或纯后端模式:只提供 API
app = Flask(__name__) app = Flask(__name__)
BACKGROUND_DIR = os.path.join(os.path.dirname(__file__), '..', 'mengyaprofile-frontend', 'public', 'background')
CORS(app) # 允许跨域请求 CORS(app) # 允许跨域请求
# 数据文件路径 - 支持环境变量配置
DATA_DIR = os.environ.get('DATA_DIR', os.path.join(os.path.dirname(__file__), 'data'))
def load_json_file(filename): def load_json_file(filename):
"""加载JSON文件""" """加载JSON文件"""
try: try:
@@ -44,7 +49,7 @@ def get_profile():
@app.route('/api/projects', methods=['GET']) @app.route('/api/projects', methods=['GET'])
def get_projects(): def get_projects():
"""获取精选项目列表""" """获取全部项目列表"""
data = load_json_file('projects.json') data = load_json_file('projects.json')
if data: if data:
return jsonify(data) return jsonify(data)
@@ -66,22 +71,78 @@ def get_techstack():
return jsonify(data) return jsonify(data)
return jsonify({"error": "Tech stack没有找到"}), 404 return jsonify({"error": "Tech stack没有找到"}), 404
@app.route('/api/logo/<filename>', methods=['GET'])
def get_logo(filename):
"""提供技术栈图标文件"""
logo_dir = os.path.join(DATA_DIR, 'logo')
try:
# 安全检查:防止路径遍历攻击
if '..' in filename or '/' in filename or '\\' in filename:
return jsonify({"error": "无效的文件名"}), 400
# 检查文件是否存在
file_path = os.path.join(logo_dir, filename)
if not os.path.exists(file_path):
print(f"图标文件不存在: {file_path}")
return jsonify({"error": f"图标文件未找到: {filename}"}), 404
# 检查目录是否存在
if not os.path.exists(logo_dir):
print(f"图标目录不存在: {logo_dir}")
return jsonify({"error": "图标目录未找到"}), 404
return send_from_directory(logo_dir, filename)
except Exception as e:
print(f"获取图标文件出错: {e}")
print(f"尝试访问的文件: {os.path.join(logo_dir, filename)}")
return jsonify({"error": f"图标文件未找到: {filename}"}), 404
@app.route('/api/random-background', methods=['GET']) @app.route('/api/random-background', methods=['GET'])
def get_random_background(): def get_random_background():
"""获取随机背景图片""" """获取随机背景图片"""
try: try:
# 获取背景图片目录中的所有图片 # 获取背景图片目录中的所有图片
if os.path.exists(BACKGROUND_DIR): if os.path.exists(BACKGROUND_DIR) and os.path.isdir(BACKGROUND_DIR):
images = [f for f in os.listdir(BACKGROUND_DIR) images = [f for f in os.listdir(BACKGROUND_DIR)
if f.lower().endswith(('.png', '.jpg', '.jpeg', '.webp', '.gif'))] if f.lower().endswith(('.png', '.jpg', '.jpeg', '.webp', '.gif'))]
if images: if images:
random_image = random.choice(images) random_image = random.choice(images)
return jsonify({"image": f"/background/{random_image}"}) # 返回完整的 API 路径
return jsonify({"image": f"/api/background/{random_image}"})
else:
print(f"背景图片目录不存在: {BACKGROUND_DIR}")
return jsonify({"image": None}) return jsonify({"image": None})
except Exception as e: except Exception as e:
print(f"获取随机背景出错: {e}") print(f"获取随机背景出错: {e}")
print(f"背景目录路径: {BACKGROUND_DIR}")
import traceback
traceback.print_exc()
return jsonify({"image": None}) return jsonify({"image": None})
@app.route('/api/background/<filename>', methods=['GET'])
def get_background_image(filename):
"""提供背景图片文件"""
try:
# 安全检查:防止路径遍历攻击
if '..' in filename or '/' in filename or '\\' in filename:
return jsonify({"error": "无效的文件名"}), 400
# 检查目录是否存在
if not os.path.exists(BACKGROUND_DIR):
print(f"背景图片目录不存在: {BACKGROUND_DIR}")
return jsonify({"error": "背景图片目录未找到"}), 404
# 检查文件是否存在
file_path = os.path.join(BACKGROUND_DIR, filename)
if not os.path.exists(file_path):
print(f"背景图片文件不存在: {file_path}")
return jsonify({"error": f"背景图片未找到: {filename}"}), 404
return send_from_directory(BACKGROUND_DIR, filename)
except Exception as e:
print(f"获取背景图片出错: {e}")
return jsonify({"error": f"背景图片未找到: {filename}"}), 404
@app.route('/api/all', methods=['GET']) @app.route('/api/all', methods=['GET'])
def get_all(): def get_all():
"""获取所有数据""" """获取所有数据"""
@@ -100,17 +161,21 @@ def get_all():
@app.route('/', methods=['GET']) @app.route('/', methods=['GET'])
def index(): def index():
"""服务前端页面或API信息""" """服务前端页面或API信息"""
if RUN_MODE == 'production' and app.static_folder: if RUN_MODE == 'production' and app.static_folder and os.path.exists(os.path.join(app.static_folder, 'index.html')):
# 生产环境,返回前端页面 # 生产环境,返回前端页面(如果存在)
try:
return send_from_directory(app.static_folder, 'index.html') return send_from_directory(app.static_folder, 'index.html')
else: except:
# 开发环境,返回API信息 pass
# 返回API信息
return jsonify({ return jsonify({
"message": "萌芽主页 后端API - 开发模式", "message": "萌芽主页 后端API",
"author": "树萌芽", "author": "树萌芽",
"version": "1.0.0", "version": "1.0.0",
"mode": "development", "mode": RUN_MODE,
"note": "前端开发服务器运行在 http://localhost:3000", "note": "这是一个纯后端API服务前端请访问独立的前端应用",
"api_base": "https://nav.api.shumengya.top/api",
"endpoints": { "endpoints": {
"/api/profile": "获取个人信息", "/api/profile": "获取个人信息",
"/api/techstack": "获取技术栈", "/api/techstack": "获取技术栈",
@@ -124,12 +189,16 @@ def index():
@app.route('/admin') @app.route('/admin')
def admin(): def admin():
"""服务管理员页面(也是前端)""" """服务管理员页面(也是前端)"""
if RUN_MODE == 'production' and app.static_folder: if RUN_MODE == 'production' and app.static_folder and os.path.exists(os.path.join(app.static_folder, 'index.html')):
try:
return send_from_directory(app.static_folder, 'index.html') return send_from_directory(app.static_folder, 'index.html')
else: except:
pass
return jsonify({ return jsonify({
"error": "开发模式", "error": "管理员页面未找到",
"note": "请访问 http://localhost:3000/admin?token=shumengya520" "note": "这是一个纯后端API服务请访问独立的前端应用",
"api_base": "https://nav.api.shumengya.top/api"
}), 404 }), 404
@app.route('/api') @app.route('/api')
@@ -149,7 +218,7 @@ def api_info():
} }
}) })
# 处理前端路由 - 所有非API请求都返回 index.html # 处理404错误
@app.errorhandler(404) @app.errorhandler(404)
def not_found(e): def not_found(e):
"""处理404错误""" """处理404错误"""
@@ -157,17 +226,32 @@ def not_found(e):
if request.path.startswith('/api'): if request.path.startswith('/api'):
return jsonify({"error": "API endpoint not found"}), 404 return jsonify({"error": "API endpoint not found"}), 404
# 非API请求 # 非API请求 - 如果是前后端分离返回API信息
if RUN_MODE == 'production' and app.static_folder: if RUN_MODE == 'production' and app.static_folder and os.path.exists(os.path.join(app.static_folder, 'index.html')):
# 生产环境返回前端页面(支持前端路由) # 如果有前端构建文件,尝试返回
try:
return send_from_directory(app.static_folder, 'index.html') return send_from_directory(app.static_folder, 'index.html')
except:
pass
# 开发环境 # 返回API信息
return jsonify({ return jsonify({
"error": "页面未找到", "error": "页面未找到",
"mode": "development", "message": "这是一个纯后端API服务",
"note": "开发环境请访问 http://localhost:3000" "api_base": "https://nav.api.shumengya.top/api",
"endpoints": {
"/api/profile": "获取个人信息",
"/api/techstack": "获取技术栈",
"/api/projects": "获取项目列表",
"/api/contacts": "获取联系方式",
"/api/random-background": "获取随机背景图片",
"/api/all": "获取所有数据"
}
}), 404 }), 404
if __name__ == '__main__': if __name__ == '__main__':
app.run(debug=True, host='0.0.0.0', port=5000) # 从环境变量获取端口,默认为 5000
port = int(os.environ.get('PORT', 5000))
# 生产环境关闭 debug 模式
debug_mode = RUN_MODE != 'production'
app.run(debug=debug_mode, host='0.0.0.0', port=port)

View File

@@ -0,0 +1,22 @@
version: '3.8'
services:
mengyaprofile-backend:
build:
context: .
dockerfile: Dockerfile
container_name: mengyaprofile-backend
restart: unless-stopped
ports:
- "1616:5000"
volumes:
- /shumengya/docker/mengyaprofile-backend/data:/app/data
environment:
- RUN_MODE=production
- DATA_DIR=/app/data
networks:
- mengyaprofile-network
networks:
mengyaprofile-network:
driver: bridge

View File

@@ -0,0 +1,59 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
技术栈排序和更新脚本
- 按照技术栈名称的首字母排序
- 添加新的技术栈项
"""
import json
import os
def sort_and_update_techstack():
# 文件路径
file_path = os.path.join(os.path.dirname(__file__), 'data', 'techstack.json')
# 读取JSON文件
with open(file_path, 'r', encoding='utf-8') as f:
data = json.load(f)
# 新添加的技术栈项
new_items = [
{
"name": "Spring",
"icon": "https://img.shields.io/badge/-Spring-6DB33F?style=flat&logo=spring&logoColor=white",
"link": "https://spring.io/"
},
{
"name": "Gin",
"icon": "https://img.shields.io/badge/-Gin-00ADD8?style=flat&logo=go&logoColor=white",
"link": "https://gin-gonic.com/"
}
]
# 获取现有项的名称集合,用于检查是否已存在
existing_names = {item['name'] for item in data['items']}
# 添加新项(如果不存在)
for new_item in new_items:
if new_item['name'] not in existing_names:
data['items'].append(new_item)
print(f"已添加: {new_item['name']}")
else:
print(f"已存在,跳过: {new_item['name']}")
# 按照名称的首字母排序(不区分大小写)
data['items'].sort(key=lambda x: x['name'].upper())
# 写回文件,保持格式美观
with open(file_path, 'w', encoding='utf-8') as f:
json.dump(data, f, ensure_ascii=False, indent=2)
print(f"\n排序完成!共 {len(data['items'])} 个技术栈项")
print("技术栈列表(按首字母排序):")
for i, item in enumerate(data['items'], 1):
print(f" {i}. {item['name']}")
if __name__ == '__main__':
sort_and_update_techstack()

View File

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

View File

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

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.6 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 822 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 977 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 25 KiB

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.4 KiB

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,6 +4,21 @@ import './TechStackSection.css';
function TechStackSection({ techstack }) { function TechStackSection({ techstack }) {
if (!techstack || !techstack.items) return null; 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 ( return (
<section className="techstack-section"> <section className="techstack-section">
<h2 className="section-title"> <h2 className="section-title">
@@ -13,7 +28,36 @@ function TechStackSection({ techstack }) {
<div className="techstack-container"> <div className="techstack-container">
<div className="tech-items"> <div className="tech-items">
{techstack.items.map((item, idx) => ( {techstack.items
.filter(item => item.show !== false)
.map((item, idx) => {
const iconUrl = getIconUrl(item);
const backgroundColor = item.color || '#555555';
const badgeContent = (
<div
className="tech-badge"
style={{
backgroundColor: backgroundColor,
color: backgroundColor === '#FFFFFF' ? '#000000' : 'white'
}}
>
<div className="badge-icon">
{iconUrl ? (
<img
src={iconUrl}
alt={item.name}
loading="lazy"
/>
) : (
<span className="badge-icon-placeholder">?</span>
)}
</div>
<span className="badge-text">{item.name}</span>
</div>
);
return (
<div key={idx} className="tech-item"> <div key={idx} className="tech-item">
{item.link ? ( {item.link ? (
<a <a
@@ -21,23 +65,16 @@ function TechStackSection({ techstack }) {
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
title={item.name} title={item.name}
className="tech-badge-link"
> >
<img {badgeContent}
src={item.icon}
alt={item.name}
loading="lazy"
/>
</a> </a>
) : ( ) : (
<img badgeContent
src={item.icon}
alt={item.name}
title={item.name}
loading="lazy"
/>
)} )}
</div> </div>
))} );
})}
</div> </div>
</div> </div>
</section> </section>

View File

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

View File

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

Before

Width:  |  Height:  |  Size: 2.6 KiB

View File

@@ -1,8 +1,4 @@
@echo off @echo off
cd /d "%~dp0mengyaprofile-backend" cd /d "%~dp0mengyaprofile-backend"
set RUN_MODE=development set RUN_MODE=development
echo Starting backend in DEVELOPMENT mode...
echo Backend API: http://localhost:5000
echo Frontend should run on: http://localhost:3000
echo.
python app.py python app.py

0
萌芽主页 Normal file
View File