diff --git a/.dockerignore b/.dockerignore deleted file mode 100644 index f4f4676..0000000 --- a/.dockerignore +++ /dev/null @@ -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 diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..decb915 --- /dev/null +++ b/AGENTS.md @@ -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`). diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index a8a3249..0000000 --- a/Dockerfile +++ /dev/null @@ -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"] diff --git a/README.md b/README.md index d9c4149..9dbae7b 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ docker-compose up -d --build ## 项目简介 -这是一个功能完整、设计精美的个人主页系统,展示个人信息、精选项目和联系方式。 +这是一个功能完整、设计精美的个人主页系统,展示个人信息、全部项目和联系方式。 ### 特性亮点 @@ -42,7 +42,7 @@ docker-compose up -d --build - 🐳 **Docker 支持**: 一键部署,开箱即用 - 💾 **数据持久化**: 配置文件外部存储 - ⚡ **快速灵活**: 通过 JSON 配置文件轻松管理内容 -- 🎯 **三大模块**: 个人信息、精选项目、联系方式 +- 🎯 **三大模块**: 个人信息、全部项目、联系方式 - 🔐 **权限控制**: 管理员模式隐藏私密项目 ## 项目结构 @@ -124,7 +124,7 @@ npm start **配置文件**: `mengyaprofile-backend/data/profile.json` -### 2️⃣ 精选项目模块 +### 2️⃣ 全部项目模块 以卡片形式展示项目: - 📦 项目标题 diff --git a/docker-compose.yml b/docker-compose.yml deleted file mode 100644 index 25f97cb..0000000 --- a/docker-compose.yml +++ /dev/null @@ -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 diff --git a/docker-deploy.sh b/docker-deploy.sh deleted file mode 100644 index 9f34f4d..0000000 --- a/docker-deploy.sh +++ /dev/null @@ -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 diff --git a/mengyaprofile-backend/.dockerignore b/mengyaprofile-backend/.dockerignore new file mode 100644 index 0000000..1dc31c7 --- /dev/null +++ b/mengyaprofile-backend/.dockerignore @@ -0,0 +1,18 @@ +__pycache__ +*.pyc +*.pyo +*.pyd +.Python +*.so +*.egg +*.egg-info +dist +build +.env +.venv +venv/ +ENV/ +.git +.gitignore +README.md +*.md diff --git a/mengyaprofile-backend/Dockerfile b/mengyaprofile-backend/Dockerfile new file mode 100644 index 0000000..9f9bbd1 --- /dev/null +++ b/mengyaprofile-backend/Dockerfile @@ -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"] diff --git a/mengyaprofile-backend/README.md b/mengyaprofile-backend/README.md index 2ae70b2..39a0884 100644 --- a/mengyaprofile-backend/README.md +++ b/mengyaprofile-backend/README.md @@ -19,7 +19,7 @@ python app.py ## API 接口 - `GET /api/profile` - 获取个人基本信息 -- `GET /api/projects` - 获取精选项目列表 +- `GET /api/projects` - 获取全部项目列表 - `GET /api/contacts` - 获取联系方式 - `GET /api/all` - 获取所有数据 diff --git a/mengyaprofile-backend/app.py b/mengyaprofile-backend/app.py index f1b4b61..a8f8103 100644 --- a/mengyaprofile-backend/app.py +++ b/mengyaprofile-backend/app.py @@ -7,22 +7,27 @@ import random # 检测运行模式:通过环境变量控制 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='') - BACKGROUND_DIR = os.path.join(FRONTEND_BUILD_PATH, 'background') else: - # 开发环境:不服务前端,只提供 API + # 开发环境或纯后端模式:只提供 API app = Flask(__name__) - BACKGROUND_DIR = os.path.join(os.path.dirname(__file__), '..', 'mengyaprofile-frontend', 'public', 'background') CORS(app) # 允许跨域请求 -# 数据文件路径 - 支持环境变量配置 -DATA_DIR = os.environ.get('DATA_DIR', os.path.join(os.path.dirname(__file__), 'data')) - def load_json_file(filename): """加载JSON文件""" try: @@ -44,7 +49,7 @@ def get_profile(): @app.route('/api/projects', methods=['GET']) def get_projects(): - """获取精选项目列表""" + """获取全部项目列表""" data = load_json_file('projects.json') if data: return jsonify(data) @@ -66,22 +71,78 @@ def get_techstack(): return jsonify(data) return jsonify({"error": "Tech stack没有找到"}), 404 +@app.route('/api/logo/', 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']) def get_random_background(): """获取随机背景图片""" 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) if f.lower().endswith(('.png', '.jpg', '.jpeg', '.webp', '.gif'))] if 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}) except Exception as e: print(f"获取随机背景出错: {e}") + print(f"背景目录路径: {BACKGROUND_DIR}") + import traceback + traceback.print_exc() return jsonify({"image": None}) +@app.route('/api/background/', 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']) def get_all(): """获取所有数据""" @@ -100,37 +161,45 @@ def get_all(): @app.route('/', methods=['GET']) def index(): """服务前端页面或API信息""" - if RUN_MODE == 'production' and app.static_folder: - # 生产环境,返回前端页面 - return send_from_directory(app.static_folder, 'index.html') - else: - # 开发环境,返回API信息 - return jsonify({ - "message": "萌芽主页 后端API - 开发模式", - "author": "树萌芽", - "version": "1.0.0", - "mode": "development", - "note": "前端开发服务器运行在 http://localhost:3000", - "endpoints": { - "/api/profile": "获取个人信息", - "/api/techstack": "获取技术栈", - "/api/projects": "获取项目列表", - "/api/contacts": "获取联系方式", - "/api/random-background": "获取随机背景图片", - "/api/all": "获取所有数据" - } - }) + 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') + except: + pass + + # 返回API信息 + return jsonify({ + "message": "萌芽主页 后端API", + "author": "树萌芽", + "version": "1.0.0", + "mode": RUN_MODE, + "note": "这是一个纯后端API服务,前端请访问独立的前端应用", + "api_base": "https://nav.api.shumengya.top/api", + "endpoints": { + "/api/profile": "获取个人信息", + "/api/techstack": "获取技术栈", + "/api/projects": "获取项目列表", + "/api/contacts": "获取联系方式", + "/api/random-background": "获取随机背景图片", + "/api/all": "获取所有数据" + } + }) @app.route('/admin') def admin(): """服务管理员页面(也是前端)""" - if RUN_MODE == 'production' and app.static_folder: - return send_from_directory(app.static_folder, 'index.html') - else: - return jsonify({ - "error": "开发模式", - "note": "请访问 http://localhost:3000/admin?token=shumengya520" - }), 404 + 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') + except: + pass + + return jsonify({ + "error": "管理员页面未找到", + "note": "这是一个纯后端API服务,请访问独立的前端应用", + "api_base": "https://nav.api.shumengya.top/api" + }), 404 @app.route('/api') def api_info(): @@ -149,7 +218,7 @@ def api_info(): } }) -# 处理前端路由 - 所有非API请求都返回 index.html +# 处理404错误 @app.errorhandler(404) def not_found(e): """处理404错误""" @@ -157,17 +226,32 @@ def not_found(e): if request.path.startswith('/api'): return jsonify({"error": "API endpoint not found"}), 404 - # 非API请求 - if RUN_MODE == 'production' and app.static_folder: - # 生产环境返回前端页面(支持前端路由) - return send_from_directory(app.static_folder, 'index.html') + # 非API请求 - 如果是前后端分离,返回API信息 + 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') + except: + pass - # 开发环境 + # 返回API信息 return jsonify({ "error": "页面未找到", - "mode": "development", - "note": "开发环境请访问 http://localhost:3000" + "message": "这是一个纯后端API服务", + "api_base": "https://nav.api.shumengya.top/api", + "endpoints": { + "/api/profile": "获取个人信息", + "/api/techstack": "获取技术栈", + "/api/projects": "获取项目列表", + "/api/contacts": "获取联系方式", + "/api/random-background": "获取随机背景图片", + "/api/all": "获取所有数据" + } }), 404 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) diff --git a/mengyaprofile-backend/docker-compose.yml b/mengyaprofile-backend/docker-compose.yml new file mode 100644 index 0000000..ce461e2 --- /dev/null +++ b/mengyaprofile-backend/docker-compose.yml @@ -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 diff --git a/mengyaprofile-backend/sort_techstack.py b/mengyaprofile-backend/sort_techstack.py new file mode 100644 index 0000000..ed9aafd --- /dev/null +++ b/mengyaprofile-backend/sort_techstack.py @@ -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() + diff --git a/mengyaprofile-frontend/README.md b/mengyaprofile-frontend/README.md index 22629e8..2eb78e9 100644 --- a/mengyaprofile-frontend/README.md +++ b/mengyaprofile-frontend/README.md @@ -21,7 +21,7 @@ - 技术定位 - 个人座右铭 -### 2. 精选项目模块 +### 2. 全部项目模块 以卡片形式展示项目: - 项目标题 - 项目简介 diff --git a/mengyaprofile-frontend/package-lock.json b/mengyaprofile-frontend/package-lock.json index 8793b60..49ecc19 100644 --- a/mengyaprofile-frontend/package-lock.json +++ b/mengyaprofile-frontend/package-lock.json @@ -64,7 +64,6 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz", "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -714,7 +713,6 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-flow/-/plugin-syntax-flow-7.27.1.tgz", "integrity": "sha512-p9OkPbZ5G7UT1MofwYFigGebnrzGJacoBSQM0/6bi/PUMVE+qlWDD/OalvQKbwgQzU6dl0xAv6r4X7Jme0RYxA==", "license": "MIT", - "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, @@ -1598,7 +1596,6 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.27.1.tgz", "integrity": "sha512-2KH4LWGSrJIkVf5tSiBFYuXDAoWRq2MMwgivCf+93dd0GQi8RXLjKA/0EvRnVV5G0hrHczsquXuD01L8s6dmBw==", "license": "MIT", - "peer": true, "dependencies": { "@babel/helper-annotate-as-pure": "^7.27.1", "@babel/helper-module-imports": "^7.27.1", @@ -3428,7 +3425,6 @@ "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", @@ -3915,7 +3911,6 @@ "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.62.0.tgz", "integrity": "sha512-TiZzBSJja/LbhNPvk6yc0JrX9XqhQ0hdh6M2svYfsHGejaKFIAGd9MQ+ERIMzLGlN/kZoYIgdxFV0PuljTKXag==", "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/regexpp": "^4.4.0", "@typescript-eslint/scope-manager": "5.62.0", @@ -3969,7 +3964,6 @@ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.62.0.tgz", "integrity": "sha512-VlJEV0fOQ7BExOsHYAGrgbEiZoi8D+Bl2+f6V2RrXerRSylnp+ZBHmPvaIa8cz0Ajx7WO7Z5RqfgYg7ED1nRhA==", "license": "BSD-2-Clause", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "5.62.0", "@typescript-eslint/types": "5.62.0", @@ -4339,7 +4333,6 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4438,7 +4431,6 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -5349,7 +5341,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.19", "caniuse-lite": "^1.0.30001751", @@ -7189,7 +7180,6 @@ "integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==", "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -9966,7 +9956,6 @@ "resolved": "https://registry.npmjs.org/jest/-/jest-27.5.1.tgz", "integrity": "sha512-Yn0mADZB89zTtjkPJEXwrac3LHudkQMR+Paqa8uxJHCBr9agxztUifWCyiYrjhMPBoUVBjyny0I7XH6ozDr7QQ==", "license": "MIT", - "peer": true, "dependencies": { "@jest/core": "^27.5.1", "import-local": "^3.0.2", @@ -10864,7 +10853,6 @@ "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "license": "MIT", - "peer": true, "bin": { "jiti": "bin/jiti.js" } @@ -12248,7 +12236,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -13383,7 +13370,6 @@ "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", "license": "MIT", - "peer": true, "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" @@ -13743,7 +13729,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -13875,7 +13860,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz", "integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -13900,7 +13884,6 @@ "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.11.0.tgz", "integrity": "sha512-F27qZr8uUqwhWZboondsPx8tnC3Ct3SxZA3V5WyEvujRyyNv0VYPhoBg1gZ8/MV5tubQp76Trw8lTv9hzRBa+A==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -14347,7 +14330,6 @@ "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.79.2.tgz", "integrity": "sha512-fS6iqSPZDs3dr/y7Od6y5nha8dW1YnbgtsyotCVvoFGKbERG++CVRFv1meyGDE1SNItQA8BrnCw7ScdAhRJ3XQ==", "license": "MIT", - "peer": true, "bin": { "rollup": "dist/bin/rollup" }, @@ -14590,7 +14572,6 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -16264,7 +16245,6 @@ "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", "license": "(MIT OR CC0-1.0)", - "peer": true, "engines": { "node": ">=10" }, @@ -16694,7 +16674,6 @@ "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.102.1.tgz", "integrity": "sha512-7h/weGm9d/ywQ6qzJ+Xy+r9n/3qgp/thalBbpOi5i223dPXKi04IBtqPN9nTd+jBc7QKfvDbaBnFipYp4sJAUQ==", "license": "MIT", - "peer": true, "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", @@ -16766,7 +16745,6 @@ "resolved": "https://registry.npmjs.org/webpack-dev-server/-/webpack-dev-server-4.15.2.tgz", "integrity": "sha512-0XavAZbNJ5sDrCbkpWL8mia0o5WPOd2YGtxrEiZkBK9FjLppIUK2TgxK6qGD2P3hUXTJNNPVibrerKcx5WkR1g==", "license": "MIT", - "peer": true, "dependencies": { "@types/bonjour": "^3.5.9", "@types/connect-history-api-fallback": "^1.3.5", @@ -17179,7 +17157,6 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", diff --git a/mengyaprofile-frontend/package.json b/mengyaprofile-frontend/package.json index 1418ac5..e691eef 100644 --- a/mengyaprofile-frontend/package.json +++ b/mengyaprofile-frontend/package.json @@ -13,6 +13,7 @@ "web-vitals": "^2.1.4" }, "scripts": { + "dev": "react-scripts start", "start": "react-scripts start", "build": "react-scripts build", "test": "react-scripts test", diff --git a/mengyaprofile-frontend/public/background/image1.png b/mengyaprofile-frontend/public/background/image1.png deleted file mode 100644 index 628e677..0000000 Binary files a/mengyaprofile-frontend/public/background/image1.png and /dev/null differ diff --git a/mengyaprofile-frontend/public/background/image2.png b/mengyaprofile-frontend/public/background/image2.png deleted file mode 100644 index 33f7730..0000000 Binary files a/mengyaprofile-frontend/public/background/image2.png and /dev/null differ diff --git a/mengyaprofile-frontend/public/background/image3.png b/mengyaprofile-frontend/public/background/image3.png deleted file mode 100644 index dc53aed..0000000 Binary files a/mengyaprofile-frontend/public/background/image3.png and /dev/null differ diff --git a/mengyaprofile-frontend/public/background/image4.png b/mengyaprofile-frontend/public/background/image4.png deleted file mode 100644 index 5058533..0000000 Binary files a/mengyaprofile-frontend/public/background/image4.png and /dev/null differ diff --git a/mengyaprofile-frontend/public/background/image5.png b/mengyaprofile-frontend/public/background/image5.png deleted file mode 100644 index 8dd5f41..0000000 Binary files a/mengyaprofile-frontend/public/background/image5.png and /dev/null differ diff --git a/mengyaprofile-frontend/public/background/image6.png b/mengyaprofile-frontend/public/background/image6.png deleted file mode 100644 index 64c2425..0000000 Binary files a/mengyaprofile-frontend/public/background/image6.png and /dev/null differ diff --git a/mengyaprofile-frontend/public/background/image7.png b/mengyaprofile-frontend/public/background/image7.png deleted file mode 100644 index f282db4..0000000 Binary files a/mengyaprofile-frontend/public/background/image7.png and /dev/null differ diff --git a/mengyaprofile-frontend/public/favicon.ico b/mengyaprofile-frontend/public/favicon.ico index a11777c..e229dec 100644 Binary files a/mengyaprofile-frontend/public/favicon.ico and b/mengyaprofile-frontend/public/favicon.ico differ diff --git a/mengyaprofile-frontend/public/index.html b/mengyaprofile-frontend/public/index.html index 345dd20..671b0a2 100644 --- a/mengyaprofile-frontend/public/index.html +++ b/mengyaprofile-frontend/public/index.html @@ -4,7 +4,10 @@ - + + + + { + 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; + }) + ) + ); +}); diff --git a/mengyaprofile-frontend/src/App.css b/mengyaprofile-frontend/src/App.css index 57e29b7..5f1cc22 100644 --- a/mengyaprofile-frontend/src/App.css +++ b/mengyaprofile-frontend/src/App.css @@ -102,6 +102,153 @@ body { font-size: 18px; } +/* PWA 启动画面 */ +.pwa-launch { + position: fixed; + inset: 0; + z-index: 9999; + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; +} + +.pwa-launch-bg { + position: absolute; + inset: 0; + background: linear-gradient(135deg, #a8e6cf 0%, #dcedc1 40%, #ffd3b6 100%); + animation: pwaLaunchBgPulse 3s ease-in-out infinite; +} + +.pwa-launch-bg::before { + content: ''; + position: absolute; + inset: 0; + background: + radial-gradient(circle at 30% 30%, rgba(255, 255, 255, 0.4) 0%, transparent 45%), + radial-gradient(circle at 70% 70%, rgba(255, 255, 255, 0.3) 0%, transparent 45%); + animation: pwaLaunchShine 4s ease-in-out infinite; +} + +@keyframes pwaLaunchBgPulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.92; } +} + +@keyframes pwaLaunchShine { + 0%, 100% { opacity: 1; transform: scale(1); } + 50% { opacity: 0.8; transform: scale(1.05); } +} + +.pwa-launch-content { + position: relative; + z-index: 1; + text-align: center; + animation: pwaLaunchFadeIn 0.6s ease-out; +} + +@keyframes pwaLaunchFadeIn { + from { opacity: 0; transform: scale(0.9); } + to { opacity: 1; transform: scale(1); } +} + +.pwa-launch-logo-wrap { + position: relative; + width: 120px; + height: 120px; + margin: 0 auto 24px; +} + +.pwa-launch-logo { + position: relative; + z-index: 2; + width: 88px; + height: 88px; + border-radius: 22px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12); + animation: pwaLaunchLogoFloat 2.5s ease-in-out infinite; +} + +@keyframes pwaLaunchLogoFloat { + 0%, 100% { transform: translateY(0); } + 50% { transform: translateY(-8px); } +} + +.pwa-launch-ring { + position: absolute; + top: 50%; + left: 50%; + border: 2px solid rgba(82, 183, 136, 0.4); + border-radius: 50%; + transform: translate(-50%, -50%); +} + +.pwa-launch-ring-1 { + width: 100px; + height: 100px; + animation: pwaLaunchRing 2s ease-out infinite; +} + +.pwa-launch-ring-2 { + width: 120px; + height: 120px; + animation: pwaLaunchRing 2s ease-out 0.3s infinite; +} + +.pwa-launch-ring-3 { + width: 140px; + height: 140px; + animation: pwaLaunchRing 2s ease-out 0.6s infinite; +} + +@keyframes pwaLaunchRing { + 0% { + transform: translate(-50%, -50%) scale(0.6); + opacity: 0.8; + } + 100% { + transform: translate(-50%, -50%) scale(1.4); + opacity: 0; + } +} + +.pwa-launch-title { + font-size: 28px; + font-weight: 700; + color: rgba(0, 0, 0, 0.75); + margin: 0 0 8px; + letter-spacing: 2px; +} + +.pwa-launch-subtitle { + font-size: 14px; + color: rgba(0, 0, 0, 0.5); + margin: 0 0 20px; +} + +.pwa-launch-dots { + display: flex; + justify-content: center; + gap: 8px; +} + +.pwa-launch-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: #52b788; + animation: pwaLaunchDot 1.2s ease-in-out infinite both; +} + +.pwa-launch-dot:nth-child(1) { animation-delay: 0s; } +.pwa-launch-dot:nth-child(2) { animation-delay: 0.2s; } +.pwa-launch-dot:nth-child(3) { animation-delay: 0.4s; } + +@keyframes pwaLaunchDot { + 0%, 80%, 100% { transform: scale(0.6); opacity: 0.5; } + 40% { transform: scale(1); opacity: 1; } +} + /* 错误状态 */ .error-container { display: flex; @@ -141,6 +288,21 @@ body { z-index: 1; } +.footer-visitor { + margin-top: 8px; + font-size: 12px; + color: rgba(255, 255, 255, 0.75); +} + +.footer-visitor span { + display: inline-block; + padding: 6px 12px; + border-radius: 999px; + background: rgba(0, 0, 0, 0.12); + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); +} + /* 响应式设计 */ @media (max-width: 768px) { .container { @@ -155,4 +317,3 @@ body { font-size: 16px; } } - diff --git a/mengyaprofile-frontend/src/App.js b/mengyaprofile-frontend/src/App.js index a8531b6..56bd005 100644 --- a/mengyaprofile-frontend/src/App.js +++ b/mengyaprofile-frontend/src/App.js @@ -17,6 +17,8 @@ function App() { const [error, setError] = useState(null); const [backgroundImage, setBackgroundImage] = useState(null); const [isAdminMode, setIsAdminMode] = useState(false); + const [visitorInfo, setVisitorInfo] = useState(null); + const [visitorInfoLoading, setVisitorInfoLoading] = useState(true); useEffect(() => { // 检查是否为 admin 模式 @@ -29,9 +31,11 @@ function App() { } // 从后端API获取所有数据 - // 开发环境使用完整URL,生产环境使用相对路径 + // 使用环境变量配置的API地址,默认为 nav.api.shumengya.top const apiBaseUrl = process.env.REACT_APP_API_URL || - (process.env.NODE_ENV === 'development' ? 'http://localhost:5000/api' : '/api'); + (process.env.NODE_ENV === 'development' + ? 'http://localhost:5000/api' + : 'https://nav.api.shumengya.top/api'); fetch(`${apiBaseUrl}/all`) .then(response => { if (!response.ok) { @@ -42,16 +46,28 @@ function App() { .then(data => { setData(data); - // 设置 favicon - if (data.profile?.favicon) { + // 设置 favicon:优先使用 cf-favicon API,失败则用后端返回的 logo + const applyFavicon = (url) => { let faviconTag = document.querySelector('link[rel="icon"]'); if (!faviconTag) { faviconTag = document.createElement('link'); faviconTag.rel = 'icon'; document.head.appendChild(faviconTag); } - faviconTag.href = data.profile.favicon; - } + faviconTag.href = url; + }; + const fallbackFavicon = data.profile?.favicon || ''; + const siteUrl = data.profile?.site + || data.profile?.homepage + || (data.contacts?.contacts?.find((c) => c.type === 'personprofile')?.link) + || (data.contacts?.contacts?.find((c) => c.link?.startsWith('https://'))?.link) + || window.location.origin; + const apiFaviconUrl = `https://cf-favicon.pages.dev/api/favicon?url=${encodeURIComponent(siteUrl)}`; + const img = new Image(); + img.onload = () => applyFavicon(apiFaviconUrl); + img.onerror = () => { if (fallbackFavicon) applyFavicon(fallbackFavicon); }; + img.src = apiFaviconUrl; + if (fallbackFavicon && !siteUrl) applyFavicon(fallbackFavicon); // 如果启用了本地背景,则获取随机背景图 if (data.profile?.showlocalbackground) { @@ -59,7 +75,18 @@ function App() { .then(res => res.json()) .then(bgData => { if (bgData.image) { - setBackgroundImage(bgData.image); + // 如果返回的是相对路径(以 /api/ 开头),转换为完整的 API URL + let imageUrl = bgData.image; + if (imageUrl.startsWith('/api/')) { + // 相对路径,需要添加域名 + const baseUrl = apiBaseUrl.replace('/api', ''); + imageUrl = `${baseUrl}${imageUrl}`; + } else if (imageUrl.startsWith('/')) { + // 其他相对路径 + const baseUrl = apiBaseUrl.replace('/api', ''); + imageUrl = `${baseUrl}${imageUrl}`; + } + setBackgroundImage(imageUrl); } }) .catch(err => console.error('获取背景图片失败:', err)); @@ -74,11 +101,58 @@ function App() { }); }, []); + useEffect(() => { + const controller = new AbortController(); + setVisitorInfoLoading(true); + + fetch('https://cf-ip-geo.smyhub.com/api', { + signal: controller.signal, + headers: { Accept: 'application/json' }, + cache: 'no-store' + }) + .then((res) => { + if (!res.ok) { + throw new Error(`HTTP ${res.status}`); + } + return res.json(); + }) + .then((payload) => { + setVisitorInfo(payload); + setVisitorInfoLoading(false); + }) + .catch((err) => { + if (err?.name === 'AbortError') return; + console.warn('获取访客 IP/地理位置失败:', err); + setVisitorInfo(null); + setVisitorInfoLoading(false); + }); + + return () => controller.abort(); + }, []); + if (loading) { return ( -
-
-

加载中...

+
+
+
+
+ 萌芽 +
+
+
+
+

萌芽主页

+

加载中

+
+ + + +
+
); } @@ -131,7 +205,7 @@ function App() { {/* 技术栈模块 */} {data.techstack && } - {/* 精选项目模块 */} + {/* 全部项目模块 */} {data.projects && } {/* 联系方式模块 */} @@ -141,6 +215,27 @@ function App() { {/* 页脚 */}

{data.profile?.footer || '© 2025 萌芽个人主页. All rights reserved.'}

+
+ {visitorInfoLoading ? ( + 访客信息加载中 + ) : visitorInfo?.ip ? ( + + 访客 IP:{visitorInfo.ip} + {visitorInfo.geo ? ( + <> + {' · '} + {[ + visitorInfo.geo.country, + visitorInfo.geo.region, + visitorInfo.geo.city + ].filter(Boolean).join(' · ')} + + ) : null} + + ) : ( + 访客信息获取失败 + )} +
); diff --git a/mengyaprofile-frontend/src/App.test.js b/mengyaprofile-frontend/src/App.test.js index 1f03afe..ae501ce 100644 --- a/mengyaprofile-frontend/src/App.test.js +++ b/mengyaprofile-frontend/src/App.test.js @@ -1,8 +1,53 @@ import { render, screen } from '@testing-library/react'; import App from './App'; -test('renders learn react link', () => { - render(); - const linkElement = screen.getByText(/learn react/i); - expect(linkElement).toBeInTheDocument(); +const mockAllData = { + profile: { + nickname: 'Test User', + avatar: null, + introduction: 'Hello', + showlocalbackground: false, + footer: '© Test Footer' + }, + techstack: null, + projects: null, + contacts: null +}; + +const mockVisitorInfo = { + ip: '66.90.99.202', + ipVersion: 'ipv4', + geo: { country: 'JP', region: 'Tokyo', city: 'Ebara' } +}; + +beforeEach(() => { + jest.spyOn(global, 'fetch').mockImplementation((url) => { + if (typeof url === 'string' && url.includes('/api/all')) { + return Promise.resolve({ + ok: true, + json: () => Promise.resolve(mockAllData) + }); + } + if (url === 'https://cf-ip-geo.smyhub.com/api') { + return Promise.resolve({ + ok: true, + json: () => Promise.resolve(mockVisitorInfo) + }); + } + return Promise.resolve({ + ok: true, + json: () => Promise.resolve({}) + }); + }); +}); + +afterEach(() => { + global.fetch.mockRestore(); +}); + +test('renders visitor ip and geo in footer', async () => { + render(); + const visitorLine = await screen.findByText(/访客 IP:66\.90\.99\.202/i); + expect(visitorLine).toBeInTheDocument(); + expect(visitorLine).toHaveTextContent('JP'); }); diff --git a/mengyaprofile-frontend/src/components/ProjectsSection.css b/mengyaprofile-frontend/src/components/ProjectsSection.css index b26ffb1..3783f70 100644 --- a/mengyaprofile-frontend/src/components/ProjectsSection.css +++ b/mengyaprofile-frontend/src/components/ProjectsSection.css @@ -19,11 +19,21 @@ } } +/* 标题和按钮容器 */ +.section-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 20px; + gap: 20px; + flex-wrap: wrap; +} + .section-title { font-size: 32px; font-weight: 700; color: white; - margin-bottom: 20px; + margin: 0; display: flex; align-items: center; gap: 12px; @@ -40,9 +50,49 @@ 50% { transform: translateY(-10px); } } +/* 分类按钮容器 */ +.category-buttons { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +/* 分类按钮 */ +.category-btn { + display: flex; + align-items: center; + gap: 6px; + padding: 8px 18px; + border: 2px solid rgba(82, 183, 136, 0.3); + border-radius: 20px; + background: rgba(255, 255, 255, 0.1); + color: white; + font-size: 14px; + font-weight: 600; + cursor: pointer; + transition: all 0.3s ease; + backdrop-filter: blur(10px); + white-space: nowrap; +} + +.category-btn:hover { + background: rgba(82, 183, 136, 0.2); + border-color: rgba(82, 183, 136, 0.5); + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(82, 183, 136, 0.3); +} + +.category-btn.active { + background: linear-gradient(135deg, #52b788, #95d5b2); + border-color: #52b788; + color: white; + box-shadow: 0 4px 16px rgba(82, 183, 136, 0.4); +} + .projects-grid { display: grid; grid-template-columns: repeat(5, 1fr); + grid-auto-rows: auto; gap: 16px; } @@ -182,10 +232,28 @@ grid-template-columns: repeat(2, 1fr); gap: 12px; } + + .section-header { + flex-direction: column; + align-items: flex-start; + gap: 16px; + } .section-title { font-size: 28px; } + + .category-buttons { + gap: 8px; + width: 100%; + } + + .category-btn { + padding: 8px 14px; + font-size: 13px; + flex: 1; + justify-content: center; + } .project-card { padding: 16px; @@ -225,9 +293,10 @@ } } -@media (min-width: 769px) and (max-width: 1024px) { +/* 桌面端固定为5列 */ +@media (min-width: 1441px) { .projects-grid { - grid-template-columns: repeat(3, 1fr); + grid-template-columns: repeat(5, 1fr); } } @@ -236,3 +305,48 @@ grid-template-columns: repeat(4, 1fr); } } + +@media (min-width: 769px) and (max-width: 1024px) { + .projects-grid { + grid-template-columns: repeat(3, 1fr); + } +} + +/* 分页指示器样式 */ +.pagination-dots { + display: flex; + justify-content: center; + align-items: center; + gap: 12px; + margin-top: 30px; + padding: 20px 0; +} + +.pagination-dot { + width: 12px; + height: 12px; + border-radius: 50%; + border: none; + background: rgba(255, 255, 255, 0.3); + cursor: pointer; + transition: all 0.3s ease; + padding: 0; + outline: none; +} + +.pagination-dot:hover { + background: rgba(255, 255, 255, 0.5); + transform: scale(1.2); +} + +.pagination-dot.active { + background: #52b788; + width: 14px; + height: 14px; + box-shadow: 0 0 10px rgba(82, 183, 136, 0.5); +} + +.pagination-dot:focus { + outline: 2px solid rgba(82, 183, 136, 0.5); + outline-offset: 2px; +} diff --git a/mengyaprofile-frontend/src/components/ProjectsSection.js b/mengyaprofile-frontend/src/components/ProjectsSection.js index 9b0f123..96abfe4 100644 --- a/mengyaprofile-frontend/src/components/ProjectsSection.js +++ b/mengyaprofile-frontend/src/components/ProjectsSection.js @@ -2,8 +2,11 @@ import React, { useState, useEffect } from 'react'; import './ProjectsSection.css'; function ProjectsSection({ projects }) { - const [hoveredId, setHoveredId] = useState(null); + const [hoveredIndex, setHoveredIndex] = useState(null); const [isAdmin, setIsAdmin] = useState(false); + const [currentPage, setCurrentPage] = useState(0); + const [category, setCategory] = useState('all'); // 'all', 'develop', 'deploy' + const [itemsPerPage, setItemsPerPage] = useState(15); // 默认桌面端 3行 × 5列 = 15个项目 useEffect(() => { // 检查 URL 参数 @@ -15,20 +18,45 @@ function ProjectsSection({ projects }) { if (pathname.includes('/admin') && token === 'shumengya520') { setIsAdmin(true); } + + // 根据屏幕宽度设置每页显示数量 + const updateItemsPerPage = () => { + if (window.innerWidth <= 768) { + setItemsPerPage(8); // 移动端:4行 × 2列 = 8个项目 + } else { + setItemsPerPage(15); // 桌面端:3行 × 5列 = 15个项目 + } + }; + + updateItemsPerPage(); + window.addEventListener('resize', updateItemsPerPage); + + return () => window.removeEventListener('resize', updateItemsPerPage); }, []); - const getFavicon = (url) => { + // 优先使用 cf-favicon API,失败时 onError 会切到 project.icon 或通用图标 + const getProjectIconUrl = (link) => { try { - const domain = new URL(url).origin; - return `${domain}/favicon.ico`; + new URL(link); + return `https://cf-favicon.pages.dev/api/favicon?url=${encodeURIComponent(link)}`; } catch { return 'https://api.iconify.design/mdi:web.svg'; } }; + const handleProjectIconError = (e, project) => { + const isApiUrl = e.target.src && e.target.src.includes('cf-favicon.pages.dev'); + if (isApiUrl && project.icon) { + e.target.src = project.icon; + } else { + e.target.src = 'https://api.iconify.design/mdi:web.svg'; + } + }; + // 过滤项目 // 1. 如果 show 为 false,则不显示 // 2. 如果 admin 为 true 且不是管理员模式,则不显示 + // 3. 根据分类过滤 const filteredProjects = projects.filter(project => { // 首先检查 show 字段,如果为 false 则直接不显示 if (project.show === false) { @@ -40,27 +68,85 @@ function ProjectsSection({ projects }) { return false; // 隐藏需要 admin 权限的项目 } + // 根据分类过滤 + if (category === 'develop' && project.develop !== true) { + return false; // 只显示自制项目 + } + if (category === 'deploy' && project.develop !== false) { + return false; // 只显示自部署项目 + } + return true; // 显示其他所有项目 }); + // 计算总页数 + const totalPages = Math.ceil(filteredProjects.length / itemsPerPage); + + // 获取当前页的项目 + const startIndex = currentPage * itemsPerPage; + const endIndex = startIndex + itemsPerPage; + const currentPageProjects = filteredProjects.slice(startIndex, endIndex); + + // 处理分页点击 + const handlePageClick = (pageIndex) => { + setCurrentPage(pageIndex); + // 滚动到项目区域顶部 + window.scrollTo({ + top: document.querySelector('.projects-section').offsetTop - 20, + behavior: 'smooth' + }); + }; + + // 处理分类切换 + const handleCategoryChange = (newCategory) => { + setCategory(newCategory); + setCurrentPage(0); // 切换分类时重置到第一页 + }; + return (
-

- 🎯 - 精选项目 -

+
+

+ 🎯 + 全部项目 +

+ + {/* 分类按钮 */} +
+ + + +
+
- {filteredProjects.map(project => ( - setHoveredId(project.id)} - onMouseLeave={() => setHoveredId(null)} - > + {currentPageProjects.map((project, index) => { + const globalIndex = startIndex + index; + return ( + setHoveredIndex(globalIndex)} + onMouseLeave={() => setHoveredIndex(null)} + > {project.develop === true && ( + + {/* 分页指示器 */} + {totalPages > 1 && ( +
+ {Array.from({ length: totalPages }, (_, index) => ( +
+ )}
); } diff --git a/mengyaprofile-frontend/src/components/TechStackSection.css b/mengyaprofile-frontend/src/components/TechStackSection.css index 4c54bac..5b05fc2 100644 --- a/mengyaprofile-frontend/src/components/TechStackSection.css +++ b/mengyaprofile-frontend/src/components/TechStackSection.css @@ -46,60 +46,95 @@ -webkit-backdrop-filter: blur(10px); border: 1px solid rgba(255, 255, 255, 0.2); border-radius: 16px; - padding: 24px; + padding: 16px; box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1); } .tech-items { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); - gap: 12px; + display: flex; + flex-wrap: wrap; + gap: 4px; align-items: center; - justify-items: center; } .tech-item { - transition: all 0.3s ease; - cursor: pointer; - width: 100%; - display: flex; - justify-content: center; - padding: 12px; - background: rgba(255, 255, 255, 0.15); - backdrop-filter: blur(5px); - -webkit-backdrop-filter: blur(5px); - border-radius: 12px; - border: 1px solid rgba(255, 255, 255, 0.25); + display: inline-flex; } -.tech-item a { - display: flex; - justify-content: center; - align-items: center; - width: 100%; +.tech-badge-link { text-decoration: none; + display: inline-flex; } -.tech-item img { - height: 32px; - max-width: 100%; +.tech-badge { + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 4px; + padding: 0; + height: 36px; + min-height: 36px; + max-height: 36px; + font-size: 20px; + font-weight: 600; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Noto Sans', Helvetica, Arial, sans-serif; + line-height: 1; + white-space: nowrap; + overflow: hidden; + transition: all 0.2s ease; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.12), 0 1px 2px rgba(0, 0, 0, 0.24); +} + +.tech-badge:hover { + transform: translateY(-1px); + box-shadow: 0 3px 6px rgba(0, 0, 0, 0.16), 0 3px 6px rgba(0, 0, 0, 0.23); +} + +.badge-icon { + display: flex; + align-items: center; + justify-content: center; + padding: 0 6px 0 8px; + height: 36px; + min-height: 36px; + max-height: 36px; + background-color: rgba(0, 0, 0, 0.15); + flex-shrink: 0; + width: 52px; +} + +.badge-icon img { + width: 36px; + height: 36px; display: block; - transition: transform 0.3s ease, filter 0.3s ease; - filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.1)); object-fit: contain; } -.tech-item:hover { - transform: translateY(-3px); - background: rgba(255, 255, 255, 0.25); - box-shadow: 0 4px 15px rgba(82, 183, 136, 0.2); +.badge-icon-placeholder { + width: 36px; + height: 36px; + display: flex; + align-items: center; + justify-content: center; + font-size: 18px; + font-weight: bold; } -.tech-item:hover img { - filter: brightness(1.1) drop-shadow(0 4px 8px rgba(0, 0, 0, 0.15)); +.badge-text { + padding: 0 8px 0 6px; + height: 36px; + min-height: 36px; + max-height: 36px; + display: flex; + align-items: center; + justify-content: center; + line-height: 36px; + text-align: center; + white-space: nowrap; } /* 响应式设计 */ + @media (max-width: 768px) { .section-title { font-size: 28px; @@ -110,11 +145,41 @@ } .tech-items { - grid-template-columns: repeat(auto-fill, minmax(110px, 1fr)); - gap: 10px; + grid-template-columns: repeat(3, 1fr); + gap: 4px; } - .tech-item img { + .tech-badge { + height: 30px; + font-size: 16px; + } + + .badge-icon { + padding: 0 4px 0 5px; + height: 30px; + width: 42px; + } + + .badge-icon img { + width: 28px; height: 28px; } + + .badge-icon-placeholder { + width: 28px; + height: 28px; + font-size: 14px; + } + + .badge-text { + padding: 0 6px 0 4px; + height: 30px; + line-height: 30px; + } +} + +@media (max-width: 480px) { + .tech-items { + grid-template-columns: repeat(2, 1fr); + } } diff --git a/mengyaprofile-frontend/src/components/TechStackSection.js b/mengyaprofile-frontend/src/components/TechStackSection.js index 4a09dcb..d8be407 100644 --- a/mengyaprofile-frontend/src/components/TechStackSection.js +++ b/mengyaprofile-frontend/src/components/TechStackSection.js @@ -4,6 +4,21 @@ import './TechStackSection.css'; function TechStackSection({ techstack }) { if (!techstack || !techstack.items) return null; + // 获取API基础URL,用于处理图标路径 + // 使用环境变量配置的API地址,默认为 nav.api.shumengya.top + const apiBaseUrl = process.env.REACT_APP_API_URL || + (process.env.NODE_ENV === 'development' + ? 'http://localhost:5000/api' + : 'https://nav.api.shumengya.top/api'); + + // 获取图标URL + const getIconUrl = (item) => { + if (item.svg) { + return `${apiBaseUrl}/logo/${item.svg}`; + } + return null; + }; + return (

@@ -13,31 +28,53 @@ function TechStackSection({ techstack }) {
- {techstack.items.map((item, idx) => ( - + ); + + return ( +
+ {item.link ? ( + + {badgeContent} + + ) : ( + badgeContent + )} +
+ ); + })}

diff --git a/mengyaprofile-frontend/src/index.js b/mengyaprofile-frontend/src/index.js index d563c0f..404937d 100644 --- a/mengyaprofile-frontend/src/index.js +++ b/mengyaprofile-frontend/src/index.js @@ -11,7 +11,13 @@ root.render( ); -// If you want to start measuring performance in your app, pass a function -// to log results (for example: reportWebVitals(console.log)) -// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals +if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) { + window.addEventListener('load', () => { + navigator.serviceWorker + .register(`${process.env.PUBLIC_URL || ''}/service-worker.js`) + .then((reg) => console.log('PWA SW registered', reg.scope)) + .catch((e) => console.log('PWA SW registration failed', e)); + }); +} + reportWebVitals(); diff --git a/mengyaprofile-frontend/src/logo.svg b/mengyaprofile-frontend/src/logo.svg deleted file mode 100644 index 9dfc1c0..0000000 --- a/mengyaprofile-frontend/src/logo.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/start-dev-backend.bat b/start-dev-backend.bat index bb8dd0e..3920b04 100644 --- a/start-dev-backend.bat +++ b/start-dev-backend.bat @@ -1,8 +1,4 @@ @echo off cd /d "%~dp0mengyaprofile-backend" 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 diff --git a/萌芽主页 b/萌芽主页 new file mode 100644 index 0000000..e69de29