diff --git a/AGENTS.md b/AGENTS.md index decb915..d730aa7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,7 +3,7 @@ ## 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/`. +- `mengyaprofile-backend/`: Flask API (`app.py`, `db.py`) plus SQLite(默认 `data/site.db`,由 `schema.sql` 定义)与静态资源 `data/logo/`、`data/background/`。首次启动若库为空会写入最小占位行;内容通过管理后台或直连数据库维护。 - Root `*.bat`: Windows helper scripts for starting/building locally. ## Build, Test, and Development Commands @@ -22,14 +22,14 @@ 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`. +- Windows / 根目录脚本: `dev.bat`、`build.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. +- 站点内容:维护 `data/site.db`(表 `profile`、`project` / `project_tag` / `project_tech_tag`、`contact`、`techstack_section` / `techstack_item`),或通过 `/admin` 与 `X-Admin-Token` 调用 `/api/admin/*`。 ## Testing Guidelines @@ -39,9 +39,9 @@ npm run build # production build to ./build ## 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. +- PRs: describe behavior changes, link issues, include screenshots for UI changes, and call out any `schema.sql` / API shape changes. ## 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`). +- Useful env vars: backend `RUN_MODE`, `DATA_DIR`, `DATABASE_PATH`(可选,默认 `DATA_DIR/site.db`), `BACKGROUND_DIR`, `ADMIN_TOKEN`(管理写接口,默认 `shumengya520`,生产务必修改), `PORT`; frontend `REACT_APP_API_URL` (use `.env.local`)。主页头像连点 5 次(2 秒内累计重置)弹出 token 验证,通过后进入 `/admin` 管理页;写操作须请求头 `X-Admin-Token`。 diff --git a/README.md b/README.md index 9dbae7b..31fc2ca 100644 --- a/README.md +++ b/README.md @@ -40,8 +40,8 @@ docker-compose up -d --build - 📱 **响应式布局**: 完美适配桌面端和移动端 - 🔄 **前后端分离**: React + Python Flask 架构 - 🐳 **Docker 支持**: 一键部署,开箱即用 -- 💾 **数据持久化**: 配置文件外部存储 -- ⚡ **快速灵活**: 通过 JSON 配置文件轻松管理内容 +- 💾 **数据持久化**: SQLite(`data/site.db`)+ 管理后台 +- ⚡ **内容管理**: 通过 `/admin` 或 Admin API 维护站点数据 - 🎯 **三大模块**: 个人信息、全部项目、联系方式 - 🔐 **权限控制**: 管理员模式隐藏私密项目 @@ -52,10 +52,7 @@ docker-compose up -d --build ├── mengyaprofile-backend/ # 后端 Python Flask 项目 │ ├── app.py # Flask 应用主文件 │ ├── requirements.txt # Python 依赖 -│ └── data/ # 数据配置文件 -│ ├── profile.json # 个人信息 -│ ├── projects.json # 项目列表 -│ └── contacts.json # 联系方式 +│ └── data/ # SQLite(site.db)与 logo 等静态资源 │ └── mengyaprofile-frontend/ # 前端 React 项目 ├── public/ # 静态资源 @@ -111,93 +108,14 @@ npm start 在浏览器中打开 `http://localhost:3000` 即可查看你的个人主页! -## 功能模块 +## 功能模块与数据 -### 1️⃣ 个人信息模块 +个人信息、项目列表、联系方式、技术栈等内容均存储在 **`mengyaprofile-backend/data/site.db`**(SQLite),表结构见 `mengyaprofile-backend/schema.sql`。 -展示个人基本信息: -- ✨ 昵称 -- 🖼️ 头像(支持动画效果) -- 📝 个人介绍 -- 💼 技术定位(Full-Stack / Backend / DevOps) -- 💡 个人座右铭 +- **浏览**: 首页读取 `/api/all` 等接口。 +- **编辑**: 打开 **`/admin`**(需管理员 token,见后端环境变量 `ADMIN_TOKEN`),在后台表单中维护;或直接调用 `/api/admin/*` 并携带请求头 `X-Admin-Token`。 -**配置文件**: `mengyaprofile-backend/data/profile.json` - -### 2️⃣ 全部项目模块 - -以卡片形式展示项目: -- 📦 项目标题 -- 📄 项目简介 -- 🔗 项目链接 -- 🏷️ 项目标签 -- 🎯 自动获取网站图标(favicon) - -**配置文件**: `mengyaprofile-backend/data/projects.json` - -### 3️⃣ 联系方式模块 - -展示多种联系方式: -- 💬 QQ -- 📧 邮箱 -- 🐙 GitHub -- 📋 一键复制功能 -- 🔗 直接跳转链接 - -**配置文件**: `mengyaprofile-backend/data/contacts.json` - -## 自定义配置 - -### 修改个人信息 - -编辑 `mengyaprofile-backend/data/profile.json`: - -```json -{ - "nickname": "你的昵称", - "avatar": "头像URL", - "introduction": "个人介绍", - "position": "Full-Stack / Backend / DevOps", - "motto": "你的座右铭" -} -``` - -### 添加项目 - -编辑 `mengyaprofile-backend/data/projects.json`: - -```json -{ - "projects": [ - { - "id": 1, - "title": "项目名称", - "description": "项目简介", - "link": "https://your-project.com", - "icon": "", - "tags": ["标签1", "标签2"] - } - ] -} -``` - -### 更新联系方式 - -编辑 `mengyaprofile-backend/data/contacts.json`: - -```json -{ - "contacts": [ - { - "type": "qq", - "label": "QQ", - "value": "你的QQ号", - "link": "tencent://message/?uin=你的QQ号", - "icon": "💬" - } - ] -} -``` +空库首次启动时只会创建最小占位数据(空昵称、默认「技术栈」标题),其余请在管理后台中填写。 ## 技术栈 diff --git a/build-backend-docker.bat b/build-backend-docker.bat new file mode 100644 index 0000000..34cc6ad --- /dev/null +++ b/build-backend-docker.bat @@ -0,0 +1,2 @@ +@echo off +call "%~dp0mengyaprofile-backend\docker-build.bat" diff --git a/build-frontend.bat b/build-frontend.bat deleted file mode 100644 index 80614aa..0000000 --- a/build-frontend.bat +++ /dev/null @@ -1,42 +0,0 @@ -@echo off -setlocal -title Mengya Profile - Build Frontend -echo [INFO] Building frontend assets... -cd /d "%~dp0mengyaprofile-frontend" - -where npm >nul 2>nul -if errorlevel 1 ( - echo [ERROR] npm not found. Install Node.js and npm. - exit /b 1 -) - -if not exist "package.json" ( - echo [ERROR] package.json not found in %CD% - exit /b 1 -) - -if not exist "node_modules" goto install_deps -goto do_build - -:install_deps -echo [INFO] Installing dependencies... -npm install --prefix "%~dp0mengyaprofile-frontend" -if errorlevel 1 goto deps_fail - -:do_build -echo [INFO] Run build: npm run build -npm run build --prefix "%~dp0mengyaprofile-frontend" -if errorlevel 1 goto build_fail -echo [INFO] Build done at %CD%\build -goto end - -:deps_fail -echo [ERROR] Dependency install failed. -goto end - -:build_fail -echo [ERROR] Build failed. -goto end - -:end -endlocal diff --git a/build.bat b/build.bat new file mode 100644 index 0000000..a4c2c57 --- /dev/null +++ b/build.bat @@ -0,0 +1,3 @@ +@echo off +cd /d "%~dp0mengyaprofile-frontend" +call npm run build diff --git a/dev.bat b/dev.bat new file mode 100644 index 0000000..acfe46b --- /dev/null +++ b/dev.bat @@ -0,0 +1,4 @@ +@echo off +set "ROOT=%~dp0" +start "backend" cmd /k cd /d "%ROOT%mengyaprofile-backend" ^&^& python app.py +start "frontend" cmd /k cd /d "%ROOT%mengyaprofile-frontend" ^&^& npm start diff --git a/mengyaprofile-backend/.dockerignore b/mengyaprofile-backend/.dockerignore index 1dc31c7..44a0434 100644 --- a/mengyaprofile-backend/.dockerignore +++ b/mengyaprofile-backend/.dockerignore @@ -16,3 +16,8 @@ ENV/ .gitignore README.md *.md + +# 运行时数据不入镜像(挂载卷提供持久化) +data/site.db +data/*.db +**/*.db diff --git a/mengyaprofile-backend/.gitattributes b/mengyaprofile-backend/.gitattributes new file mode 100644 index 0000000..90e4911 --- /dev/null +++ b/mengyaprofile-backend/.gitattributes @@ -0,0 +1 @@ +docker-entrypoint.sh text eol=lf diff --git a/mengyaprofile-backend/.gitignore b/mengyaprofile-backend/.gitignore index 598d76d..95c0d66 100644 --- a/mengyaprofile-backend/.gitignore +++ b/mengyaprofile-backend/.gitignore @@ -40,3 +40,8 @@ Thumbs.db # Logs *.log + +# SQLite(站点数据;部署时随卷持久化) +data/site.db +data/site.db-shm +data/site.db-wal diff --git a/mengyaprofile-backend/Dockerfile b/mengyaprofile-backend/Dockerfile index 9f9bbd1..fee7db4 100644 --- a/mengyaprofile-backend/Dockerfile +++ b/mengyaprofile-backend/Dockerfile @@ -1,28 +1,31 @@ -# 使用 Python 官方镜像 +# 萌芽主页 API — 生产镜像(SQLite + 静态 logo) FROM python:3.11-slim -# 设置工作目录 WORKDIR /app -# 设置环境变量 -ENV PYTHONUNBUFFERED=1 -ENV RUN_MODE=production -ENV DATA_DIR=/app/data +ENV PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 \ + RUN_MODE=production \ + DATA_DIR=/app/data \ + PORT=5000 -# 复制依赖文件 +# 依赖 COPY requirements.txt . +RUN pip install --no-cache-dir --no-compile -r requirements.txt -# 安装依赖 -RUN pip install --no-cache-dir -r requirements.txt +# 应用代码 +COPY app.py db.py schema.sql ./ -# 复制应用代码 -COPY app.py . +# 技术栈 SVG:镜像内 ./data/logo;/app/logo-dist 供 entrypoint 在「空数据卷」时复制到挂载目录 +COPY data/logo ./data/logo +COPY data/logo /app/logo-dist +COPY docker-entrypoint.sh /app/docker-entrypoint.sh +RUN chmod +x /app/docker-entrypoint.sh && mkdir -p /app/data/background -# 创建数据目录(如果挂载了外部卷,这个目录会被覆盖) -RUN mkdir -p /app/data/logo - -# 暴露端口 EXPOSE 5000 -# 启动应用 -CMD ["python", "app.py"] +HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \ + CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:5000/api')" || exit 1 + +ENTRYPOINT ["/app/docker-entrypoint.sh"] +CMD ["sh", "-c", "exec gunicorn --bind 0.0.0.0:${PORT:-5000} --workers 2 --threads 2 --timeout 60 --access-logfile - --error-logfile - app:app"] diff --git a/mengyaprofile-backend/README.md b/mengyaprofile-backend/README.md index 39a0884..f52d08f 100644 --- a/mengyaprofile-backend/README.md +++ b/mengyaprofile-backend/README.md @@ -23,11 +23,9 @@ python app.py - `GET /api/contacts` - 获取联系方式 - `GET /api/all` - 获取所有数据 -## 数据配置 +## 数据存储 -数据存储在 `data` 目录下的 JSON 文件中: -- `profile.json` - 个人信息 -- `projects.json` - 项目列表 -- `contacts.json` - 联系方式 +站点内容在 SQLite 数据库中(默认路径 `data/site.db`,可用环境变量 `DATABASE_PATH` / `DATA_DIR` 覆盖),表结构见本目录下 `schema.sql`。 -可根据需要编辑这些文件来更新网站内容。 +- 首次启动若数据库为空,会创建库并写入空的 profile 行与技术栈区块标题占位。 +- 通过前端管理页(`/admin`)或 `PUT/POST /api/admin/*`(请求头 `X-Admin-Token`)维护资料、项目、联系方式与技术栈。 diff --git a/mengyaprofile-backend/app.py b/mengyaprofile-backend/app.py index a8f8103..d458fc0 100644 --- a/mengyaprofile-backend/app.py +++ b/mengyaprofile-backend/app.py @@ -1,9 +1,28 @@ from flask import Flask, jsonify, send_from_directory, request from flask_cors import CORS -import json import os import random +from db import ( + init_storage, + get_profile, + get_projects, + get_contacts, + get_techstack, + get_all, + update_profile, + create_project, + update_project, + delete_project, + create_contact, + update_contact, + delete_contact, + set_techstack_title, + create_techstack_item, + update_techstack_item, + delete_techstack_item, +) + # 检测运行模式:通过环境变量控制 RUN_MODE = os.environ.get('RUN_MODE', 'development') # development 或 production @@ -19,6 +38,8 @@ HAS_FRONTEND_BUILD = os.path.exists(FRONTEND_BUILD_PATH) and os.path.isdir(FRONT # 支持通过环境变量配置,默认在数据目录中 BACKGROUND_DIR = os.environ.get('BACKGROUND_DIR', os.path.join(DATA_DIR, 'background')) +ADMIN_TOKEN = os.environ.get('ADMIN_TOKEN', 'shumengya520') + if RUN_MODE == 'production' and HAS_FRONTEND_BUILD: # 生产环境:使用构建后的前端(如果存在) app = Flask(__name__, static_folder=FRONTEND_BUILD_PATH, static_url_path='') @@ -26,50 +47,39 @@ else: # 开发环境或纯后端模式:只提供 API app = Flask(__name__) -CORS(app) # 允许跨域请求 +CORS(app, allow_headers=['Content-Type', 'X-Admin-Token']) + +init_storage() + + +def require_admin(): + if request.headers.get('X-Admin-Token', '') != ADMIN_TOKEN: + return jsonify({'error': 'unauthorized'}), 401 + return None -def load_json_file(filename): - """加载JSON文件""" - try: - with open(os.path.join(DATA_DIR, filename), 'r', encoding='utf-8') as f: - return json.load(f) - except FileNotFoundError: - return None - except Exception as e: - print(f"Error loading {filename}: {e}") - return None @app.route('/api/profile', methods=['GET']) -def get_profile(): +def get_profile_route(): """获取个人基本信息""" - data = load_json_file('profile.json') + data = get_profile() if data: return jsonify(data) return jsonify({"error": "Profile没有找到"}), 404 @app.route('/api/projects', methods=['GET']) -def get_projects(): +def get_projects_route(): """获取全部项目列表""" - data = load_json_file('projects.json') - if data: - return jsonify(data) - return jsonify({"error": "Projects没有找到"}), 404 + return jsonify(get_projects()) @app.route('/api/contacts', methods=['GET']) -def get_contacts(): +def get_contacts_route(): """获取联系方式""" - data = load_json_file('contacts.json') - if data: - return jsonify(data) - return jsonify({"error": "Contacts没有找到"}), 404 + return jsonify(get_contacts()) @app.route('/api/techstack', methods=['GET']) -def get_techstack(): +def get_techstack_route(): """获取技术栈""" - data = load_json_file('techstack.json') - if data: - return jsonify(data) - return jsonify({"error": "Tech stack没有找到"}), 404 + return jsonify(get_techstack()) @app.route('/api/logo/', methods=['GET']) def get_logo(filename): @@ -144,19 +154,132 @@ def get_background_image(filename): return jsonify({"error": f"背景图片未找到: {filename}"}), 404 @app.route('/api/all', methods=['GET']) -def get_all(): - """获取所有数据""" - profile = load_json_file('profile.json') - projects = load_json_file('projects.json') - contacts = load_json_file('contacts.json') - techstack = load_json_file('techstack.json') - - return jsonify({ - "profile": profile, - "techstack": techstack, - "projects": projects, - "contacts": contacts - }) +def get_all_route(): + """获取聚合站点数据;带管理员令牌时可列出隐藏的联系方式。""" + admin_ok = request.headers.get('X-Admin-Token', '') == ADMIN_TOKEN + return jsonify(get_all(include_hidden_contacts=admin_ok)) + + +@app.route('/api/admin/ping', methods=['GET']) +def admin_ping(): + err = require_admin() + if err: + return err + return ('', 204) + + +@app.route('/api/admin/profile', methods=['PUT']) +def admin_put_profile(): + err = require_admin() + if err: + return err + body = request.get_json(silent=True) or {} + update_profile(body) + return jsonify(get_profile()) + + +@app.route('/api/admin/projects', methods=['POST']) +def admin_post_project(): + err = require_admin() + if err: + return err + body = request.get_json(silent=True) or {} + if not body.get('title'): + return jsonify({'error': 'title required'}), 400 + pid = create_project(body) + return jsonify({'id': pid}), 201 + + +@app.route('/api/admin/projects/', methods=['PUT']) +def admin_put_project(pid): + err = require_admin() + if err: + return err + body = request.get_json(silent=True) or {} + update_project(pid, body) + return jsonify({'ok': True}) + + +@app.route('/api/admin/projects/', methods=['DELETE']) +def admin_delete_project(pid): + err = require_admin() + if err: + return err + delete_project(pid) + return jsonify({'ok': True}) + + +@app.route('/api/admin/contacts', methods=['POST']) +def admin_post_contact(): + err = require_admin() + if err: + return err + body = request.get_json(silent=True) or {} + if not body.get('type'): + return jsonify({'error': 'type required'}), 400 + cid = create_contact(body) + return jsonify({'id': cid}), 201 + + +@app.route('/api/admin/contacts/', methods=['PUT']) +def admin_put_contact(cid): + err = require_admin() + if err: + return err + body = request.get_json(silent=True) or {} + update_contact(cid, body) + return jsonify({'ok': True}) + + +@app.route('/api/admin/contacts/', methods=['DELETE']) +def admin_delete_contact(cid): + err = require_admin() + if err: + return err + delete_contact(cid) + return jsonify({'ok': True}) + + +@app.route('/api/admin/techstack/title', methods=['PUT']) +def admin_put_techstack_title(): + err = require_admin() + if err: + return err + body = request.get_json(silent=True) or {} + set_techstack_title(body.get('title', '')) + return jsonify({'ok': True}) + + +@app.route('/api/admin/techstack/items', methods=['POST']) +def admin_post_tech_item(): + err = require_admin() + if err: + return err + body = request.get_json(silent=True) or {} + if not body.get('name'): + return jsonify({'error': 'name required'}), 400 + iid = create_techstack_item(body) + return jsonify({'id': iid}), 201 + + +@app.route('/api/admin/techstack/items/', methods=['PUT']) +def admin_put_tech_item(iid): + err = require_admin() + if err: + return err + body = request.get_json(silent=True) or {} + update_techstack_item(iid, body) + return jsonify({'ok': True}) + + +@app.route('/api/admin/techstack/items/', methods=['DELETE']) +def admin_delete_tech_item(iid): + err = require_admin() + if err: + return err + delete_techstack_item(iid) + return jsonify({'ok': True}) + @app.route('/', methods=['GET']) def index(): @@ -176,6 +299,7 @@ def index(): "mode": RUN_MODE, "note": "这是一个纯后端API服务,前端请访问独立的前端应用", "api_base": "https://nav.api.shumengya.top/api", + "storage": "SQLite (site.db)", "endpoints": { "/api/profile": "获取个人信息", "/api/techstack": "获取技术栈", @@ -208,6 +332,7 @@ def api_info(): "message": "萌芽主页 后端API", "author":"树萌芽", "version": "1.0.0", + "storage": "SQLite (site.db)", "endpoints": { "/api/profile": "获取个人信息", "/api/techstack": "获取技术栈", diff --git a/mengyaprofile-backend/data/background/image1.png b/mengyaprofile-backend/data/background/image1.png deleted file mode 100644 index 628e677..0000000 Binary files a/mengyaprofile-backend/data/background/image1.png and /dev/null differ diff --git a/mengyaprofile-backend/data/background/image2.png b/mengyaprofile-backend/data/background/image2.png deleted file mode 100644 index 33f7730..0000000 Binary files a/mengyaprofile-backend/data/background/image2.png and /dev/null differ diff --git a/mengyaprofile-backend/data/background/image3.png b/mengyaprofile-backend/data/background/image3.png deleted file mode 100644 index dc53aed..0000000 Binary files a/mengyaprofile-backend/data/background/image3.png and /dev/null differ diff --git a/mengyaprofile-backend/data/background/image4.png b/mengyaprofile-backend/data/background/image4.png deleted file mode 100644 index 5058533..0000000 Binary files a/mengyaprofile-backend/data/background/image4.png and /dev/null differ diff --git a/mengyaprofile-backend/data/background/image5.png b/mengyaprofile-backend/data/background/image5.png deleted file mode 100644 index 8dd5f41..0000000 Binary files a/mengyaprofile-backend/data/background/image5.png and /dev/null differ diff --git a/mengyaprofile-backend/data/background/image6.png b/mengyaprofile-backend/data/background/image6.png deleted file mode 100644 index 64c2425..0000000 Binary files a/mengyaprofile-backend/data/background/image6.png and /dev/null differ diff --git a/mengyaprofile-backend/data/background/image7.png b/mengyaprofile-backend/data/background/image7.png deleted file mode 100644 index f282db4..0000000 Binary files a/mengyaprofile-backend/data/background/image7.png and /dev/null differ diff --git a/mengyaprofile-backend/data/contacts.json b/mengyaprofile-backend/data/contacts.json deleted file mode 100644 index 33c6742..0000000 --- a/mengyaprofile-backend/data/contacts.json +++ /dev/null @@ -1,39 +0,0 @@ -{ - "contacts": [ - { - "type": "email", - "label": "个人邮箱", - "value": "mail@smyhub.com", - "link": "mailto:mail@smyhub.com", - "icon": "https://img.shumengya.top/i/2026/02/10/698b21a5e2c26.png" - }, - { - "type": "email", - "label": "QQ邮箱", - "value": "3205788256@qq.com", - "link": "mailto:3205788256@qq.com", - "icon": "https://img.shumengya.top/i/2025/11/02/690766903514e.webp" - }, - { - "type": "email", - "label": "谷歌邮箱", - "value": "shumengya666@gmail.com", - "link": "mailto:shumengya666@gmail.com", - "icon": "https://img.shumengya.top/i/2025/11/03/6908321840ea6.webp" - }, - { - "type": "personprofile", - "label": "个人主页", - "value": "shumengya.top", - "link": "https://shumengya.top", - "icon": "https://img.shumengya.top/i/2025/11/03/690836f3c87b2.png" - }, - { - "type": "github", - "label": "GitHub", - "value": "github.com/shumengya", - "link": "https://github.com/shumengya", - "icon": "https://img.shumengya.top/i/2025/11/03/69083414ceb01.webp" - } - ] -} diff --git a/mengyaprofile-backend/data/logo/alibabacloud.svg b/mengyaprofile-backend/data/logo/alibabacloud.svg new file mode 100644 index 0000000..56c2078 --- /dev/null +++ b/mengyaprofile-backend/data/logo/alibabacloud.svg @@ -0,0 +1 @@ +AlibabaCloud \ No newline at end of file diff --git a/mengyaprofile-backend/data/logo/antigravity.svg b/mengyaprofile-backend/data/logo/antigravity.svg new file mode 100644 index 0000000..3ed10ab --- /dev/null +++ b/mengyaprofile-backend/data/logo/antigravity.svg @@ -0,0 +1 @@ +Antigravity \ No newline at end of file diff --git a/mengyaprofile-backend/data/logo/apple.svg b/mengyaprofile-backend/data/logo/apple.svg new file mode 100644 index 0000000..e41c537 --- /dev/null +++ b/mengyaprofile-backend/data/logo/apple.svg @@ -0,0 +1 @@ +Apple \ No newline at end of file diff --git a/mengyaprofile-backend/data/logo/chatgpt.svg b/mengyaprofile-backend/data/logo/chatgpt.svg new file mode 100644 index 0000000..28a210a --- /dev/null +++ b/mengyaprofile-backend/data/logo/chatgpt.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/mengyaprofile-backend/data/logo/claude-ai.svg b/mengyaprofile-backend/data/logo/claude-ai.svg new file mode 100644 index 0000000..853a243 --- /dev/null +++ b/mengyaprofile-backend/data/logo/claude-ai.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/mengyaprofile-backend/data/logo/claude.svg b/mengyaprofile-backend/data/logo/claude.svg new file mode 100644 index 0000000..62dc0db --- /dev/null +++ b/mengyaprofile-backend/data/logo/claude.svg @@ -0,0 +1 @@ +Claude \ No newline at end of file diff --git a/mengyaprofile-backend/data/logo/claudecode.svg b/mengyaprofile-backend/data/logo/claudecode.svg new file mode 100644 index 0000000..98163c7 --- /dev/null +++ b/mengyaprofile-backend/data/logo/claudecode.svg @@ -0,0 +1 @@ +Antigravity \ No newline at end of file diff --git a/mengyaprofile-backend/data/logo/cloudflare.svg b/mengyaprofile-backend/data/logo/cloudflare.svg new file mode 100644 index 0000000..d555b6f --- /dev/null +++ b/mengyaprofile-backend/data/logo/cloudflare.svg @@ -0,0 +1 @@ +Cloudflare \ No newline at end of file diff --git a/mengyaprofile-backend/data/logo/codex.svg b/mengyaprofile-backend/data/logo/codex.svg new file mode 100644 index 0000000..c77ccfd --- /dev/null +++ b/mengyaprofile-backend/data/logo/codex.svg @@ -0,0 +1 @@ +Codex \ No newline at end of file diff --git a/mengyaprofile-backend/data/logo/cursor.svg b/mengyaprofile-backend/data/logo/cursor.svg new file mode 100644 index 0000000..a5b2ee3 --- /dev/null +++ b/mengyaprofile-backend/data/logo/cursor.svg @@ -0,0 +1 @@ +Cursor \ No newline at end of file diff --git a/mengyaprofile-backend/data/logo/fastapi.svg b/mengyaprofile-backend/data/logo/fastapi.svg new file mode 100644 index 0000000..8ebc237 --- /dev/null +++ b/mengyaprofile-backend/data/logo/fastapi.svg @@ -0,0 +1,3 @@ + + + diff --git a/mengyaprofile-backend/data/logo/flutter.svg b/mengyaprofile-backend/data/logo/flutter.svg new file mode 100644 index 0000000..302066a --- /dev/null +++ b/mengyaprofile-backend/data/logo/flutter.svg @@ -0,0 +1,2 @@ + +file_type_flutter \ No newline at end of file diff --git a/mengyaprofile-backend/data/logo/gemini.svg b/mengyaprofile-backend/data/logo/gemini.svg new file mode 100644 index 0000000..62681df --- /dev/null +++ b/mengyaprofile-backend/data/logo/gemini.svg @@ -0,0 +1 @@ +Gemini \ No newline at end of file diff --git a/mengyaprofile-backend/data/logo/geminicli.svg b/mengyaprofile-backend/data/logo/geminicli.svg new file mode 100644 index 0000000..e15f1f7 --- /dev/null +++ b/mengyaprofile-backend/data/logo/geminicli.svg @@ -0,0 +1 @@ +Gemini CLI \ No newline at end of file diff --git a/mengyaprofile-backend/data/logo/github.svg b/mengyaprofile-backend/data/logo/github.svg new file mode 100644 index 0000000..7a51b8e --- /dev/null +++ b/mengyaprofile-backend/data/logo/github.svg @@ -0,0 +1 @@ +Github \ No newline at end of file diff --git a/mengyaprofile-backend/data/logo/githubcopilot.svg b/mengyaprofile-backend/data/logo/githubcopilot.svg new file mode 100644 index 0000000..3cbf22a --- /dev/null +++ b/mengyaprofile-backend/data/logo/githubcopilot.svg @@ -0,0 +1 @@ +GithubCopilot \ No newline at end of file diff --git a/mengyaprofile-backend/data/logo/golang2.svg b/mengyaprofile-backend/data/logo/golang2.svg new file mode 100644 index 0000000..3499540 --- /dev/null +++ b/mengyaprofile-backend/data/logo/golang2.svg @@ -0,0 +1,2 @@ + +file_type_go \ No newline at end of file diff --git a/mengyaprofile-backend/data/logo/google.svg b/mengyaprofile-backend/data/logo/google.svg new file mode 100644 index 0000000..e8e0f86 --- /dev/null +++ b/mengyaprofile-backend/data/logo/google.svg @@ -0,0 +1 @@ +Google \ No newline at end of file diff --git a/mengyaprofile-backend/data/logo/grok.svg b/mengyaprofile-backend/data/logo/grok.svg new file mode 100644 index 0000000..efb1a61 --- /dev/null +++ b/mengyaprofile-backend/data/logo/grok.svg @@ -0,0 +1 @@ +Grok \ No newline at end of file diff --git a/mengyaprofile-backend/data/logo/grok2.svg b/mengyaprofile-backend/data/logo/grok2.svg new file mode 100644 index 0000000..6d9e787 --- /dev/null +++ b/mengyaprofile-backend/data/logo/grok2.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/mengyaprofile-backend/data/logo/huawei.svg b/mengyaprofile-backend/data/logo/huawei.svg new file mode 100644 index 0000000..d55df45 --- /dev/null +++ b/mengyaprofile-backend/data/logo/huawei.svg @@ -0,0 +1 @@ +Huawei \ No newline at end of file diff --git a/mengyaprofile-backend/data/logo/javascript.svg b/mengyaprofile-backend/data/logo/javascript.svg new file mode 100644 index 0000000..3c09daf --- /dev/null +++ b/mengyaprofile-backend/data/logo/javascript.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/mengyaprofile-backend/data/logo/json.svg b/mengyaprofile-backend/data/logo/json.svg new file mode 100644 index 0000000..0c5d1af --- /dev/null +++ b/mengyaprofile-backend/data/logo/json.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mengyaprofile-backend/data/logo/lobehub.svg b/mengyaprofile-backend/data/logo/lobehub.svg new file mode 100644 index 0000000..e8d72e5 --- /dev/null +++ b/mengyaprofile-backend/data/logo/lobehub.svg @@ -0,0 +1 @@ +LobeHub \ No newline at end of file diff --git a/mengyaprofile-backend/data/logo/markdown.svg b/mengyaprofile-backend/data/logo/markdown.svg new file mode 100644 index 0000000..b7a30b7 --- /dev/null +++ b/mengyaprofile-backend/data/logo/markdown.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/mengyaprofile-backend/data/logo/mcp.svg b/mengyaprofile-backend/data/logo/mcp.svg new file mode 100644 index 0000000..5cd83a8 --- /dev/null +++ b/mengyaprofile-backend/data/logo/mcp.svg @@ -0,0 +1 @@ +ModelContextProtocol \ No newline at end of file diff --git a/mengyaprofile-backend/data/logo/newapi.svg b/mengyaprofile-backend/data/logo/newapi.svg new file mode 100644 index 0000000..00702bd --- /dev/null +++ b/mengyaprofile-backend/data/logo/newapi.svg @@ -0,0 +1 @@ +New API \ No newline at end of file diff --git a/mengyaprofile-backend/data/logo/nodejs.svg b/mengyaprofile-backend/data/logo/nodejs.svg new file mode 100644 index 0000000..04c064a --- /dev/null +++ b/mengyaprofile-backend/data/logo/nodejs.svg @@ -0,0 +1,74 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mengyaprofile-backend/data/logo/nvidia.svg b/mengyaprofile-backend/data/logo/nvidia.svg new file mode 100644 index 0000000..a9683c2 --- /dev/null +++ b/mengyaprofile-backend/data/logo/nvidia.svg @@ -0,0 +1 @@ +Nvidia \ No newline at end of file diff --git a/mengyaprofile-backend/data/logo/obsidian.svg b/mengyaprofile-backend/data/logo/obsidian.svg new file mode 100644 index 0000000..7f7f163 --- /dev/null +++ b/mengyaprofile-backend/data/logo/obsidian.svg @@ -0,0 +1 @@ +Obsidian \ No newline at end of file diff --git a/mengyaprofile-backend/data/logo/ollama.svg b/mengyaprofile-backend/data/logo/ollama.svg new file mode 100644 index 0000000..cc887e3 --- /dev/null +++ b/mengyaprofile-backend/data/logo/ollama.svg @@ -0,0 +1 @@ +Ollama \ No newline at end of file diff --git a/mengyaprofile-backend/data/logo/openai.svg b/mengyaprofile-backend/data/logo/openai.svg new file mode 100644 index 0000000..78caf4f --- /dev/null +++ b/mengyaprofile-backend/data/logo/openai.svg @@ -0,0 +1 @@ +OpenAI \ No newline at end of file diff --git a/mengyaprofile-backend/data/logo/openclaw.svg b/mengyaprofile-backend/data/logo/openclaw.svg new file mode 100644 index 0000000..bf6acb9 --- /dev/null +++ b/mengyaprofile-backend/data/logo/openclaw.svg @@ -0,0 +1 @@ +OpenClaw \ No newline at end of file diff --git a/mengyaprofile-backend/data/logo/opencode.svg b/mengyaprofile-backend/data/logo/opencode.svg new file mode 100644 index 0000000..9c4cb89 --- /dev/null +++ b/mengyaprofile-backend/data/logo/opencode.svg @@ -0,0 +1 @@ +opencode \ No newline at end of file diff --git a/mengyaprofile-backend/data/logo/rabbitmq.svg b/mengyaprofile-backend/data/logo/rabbitmq.svg new file mode 100644 index 0000000..40ca5e6 --- /dev/null +++ b/mengyaprofile-backend/data/logo/rabbitmq.svg @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/mengyaprofile-backend/data/logo/tailwind.svg b/mengyaprofile-backend/data/logo/tailwind.svg new file mode 100644 index 0000000..3e38e2f --- /dev/null +++ b/mengyaprofile-backend/data/logo/tailwind.svg @@ -0,0 +1,2 @@ + +file_type_tailwind \ No newline at end of file diff --git a/mengyaprofile-backend/data/logo/tencentcloud.svg b/mengyaprofile-backend/data/logo/tencentcloud.svg new file mode 100644 index 0000000..4e7d033 --- /dev/null +++ b/mengyaprofile-backend/data/logo/tencentcloud.svg @@ -0,0 +1 @@ +TencentCloud \ No newline at end of file diff --git a/mengyaprofile-backend/data/logo/trae.svg b/mengyaprofile-backend/data/logo/trae.svg new file mode 100644 index 0000000..08b3ba0 --- /dev/null +++ b/mengyaprofile-backend/data/logo/trae.svg @@ -0,0 +1 @@ +TRAE \ No newline at end of file diff --git a/mengyaprofile-backend/data/logo/typescript.svg b/mengyaprofile-backend/data/logo/typescript.svg new file mode 100644 index 0000000..1ce5524 --- /dev/null +++ b/mengyaprofile-backend/data/logo/typescript.svg @@ -0,0 +1,8 @@ + + \ No newline at end of file diff --git a/mengyaprofile-backend/data/logo/vercel.svg b/mengyaprofile-backend/data/logo/vercel.svg new file mode 100644 index 0000000..486cb95 --- /dev/null +++ b/mengyaprofile-backend/data/logo/vercel.svg @@ -0,0 +1 @@ +Vercel \ No newline at end of file diff --git a/mengyaprofile-backend/data/logo/vite.svg b/mengyaprofile-backend/data/logo/vite.svg new file mode 100644 index 0000000..f14b90f --- /dev/null +++ b/mengyaprofile-backend/data/logo/vite.svg @@ -0,0 +1,2 @@ + +file_type_vite \ No newline at end of file diff --git a/mengyaprofile-backend/data/logo/vscode.svg b/mengyaprofile-backend/data/logo/vscode.svg new file mode 100644 index 0000000..4576dd0 --- /dev/null +++ b/mengyaprofile-backend/data/logo/vscode.svg @@ -0,0 +1,2 @@ + +file_type_vscode \ No newline at end of file diff --git a/mengyaprofile-backend/data/logo/workersai.svg b/mengyaprofile-backend/data/logo/workersai.svg new file mode 100644 index 0000000..c5e0daa --- /dev/null +++ b/mengyaprofile-backend/data/logo/workersai.svg @@ -0,0 +1 @@ +WorkersAI \ No newline at end of file diff --git a/mengyaprofile-backend/data/profile.json b/mengyaprofile-backend/data/profile.json deleted file mode 100644 index 3438c16..0000000 --- a/mengyaprofile-backend/data/profile.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "favicon":"https://img.shumengya.top/i/2025/11/03/690836f3c87b2.png", - "nickname": "树萌芽吖", - "avatar": "https://img.shumengya.top/i/2025/11/02/69073c018174e.webp", - "introduction": "热爱编程,享受创造的过程。持续学习新技术,用代码实现想法。", - "footer":"© 2025 萌芽主页-蜀ICP备2025151694号", - "showlocalbackground":true -} diff --git a/mengyaprofile-backend/data/projects.json b/mengyaprofile-backend/data/projects.json deleted file mode 100644 index 9778dee..0000000 --- a/mengyaprofile-backend/data/projects.json +++ /dev/null @@ -1,306 +0,0 @@ -{ - "projects": [ - { - "title": "萌芽主页", - "description": "一个简洁美观的个人主页,展示个人信息和项目", - "link": "https://shumengya.top", - "icon": "https://img.shumengya.top/i/2025/11/03/690836f3c87b2.png", - "tags": ["个人主页","React","Flask"], - "admin":false, - "show":true, - "develop":true - }, - { - "title": "萌芽笔记", - "description": "展示自己学习过程中的一些MarkDown笔记", - "link": "https://note.shumengya.top", - "icon": "https://image.shumengya.top/i/2025/11/02/mengyanote.png", - "tags": [ "笔记","Obsidion","React","Flask"], - "admin":false, - "show":true, - "develop":true - }, - { - "title": "萌芽作品集", - "description": "展示个人制作的一些小创意和小项目", - "link": "https://work.shumengya.top", - "icon": "https://img.shumengya.top/i/2025/11/02/69074f8f5ed5e.png", - "tags": [ "作品集","React","Flask"], - "admin":false, - "show":true, - "develop":true - }, - { - "title": "万象口袋", - "description": "一款跨平台的聚合式软件", - "link": "https://infogenie.shumengya.top", - "icon": "https://img.shumengya.top/i/2025/11/02/6907516fb77d5.png", - "tags": [ "聚合","工具","React","Flask"], - "admin":false, - "show":true, - "develop":true - }, - { - "title": "萌芽农场", - "description": "一款2D平台联机农场经营游戏", - "link": "https://work.shumengya.top/#/work/mengyafarm", - "icon": "https://img.shumengya.top/i/2025/11/02/6907599cbaf10.png", - "tags": [ "农场","游戏","联机","Godot","Python"], - "admin":false, - "show":true, - "develop":true - }, - { - "title": "萌芽短链", - "description": "一个轻量简单的短链跳转系统", - "link": "https://short.shumengya.top", - "icon": "https://img.shumengya.top/i/2025/11/17/691ad6c78283a.png", - "tags": [ "短链接","React","Flask"], - "admin":false, - "show":true, - "develop":true - }, - { - "title": "萌芽监控面板", - "description": "一个轻量简单的监控系统", - "link": "https://monitor.shumengya.top", - "icon": "https://img.shumengya.top/i/2026/01/12/6964fffd54221.png", - "tags": [ "监控","React","Golang"], - "admin":false, - "show":true, - "develop":true - }, - { - "title": "萌芽Ping", - "description": "网站监控系统", - "link": "https://ping.shumengya.top", - "icon": "https://img.shumengya.top/i/2026/01/22/6971ad8eb9e56.png", - "tags": [ "监控","React","Gin"], - "admin":false, - "show":true, - "develop":true - }, - { - "title": "萌芽密码管理器", - "description": "自制密码库,做的太烂,我都不怎么用(", - "link": "https://keyvault.shumengya.top", - "icon": "https://img.shumengya.top/i/2026/02/10/698b2f4c321ec.ico", - "tags": [ "监控","React","Gin"], - "admin":true, - "show":true, - "develop":true - }, - { - "title": "萌芽漂流瓶", - "description": "匿名漂流瓶系统", - "link": "https://bottle.shumengya.top", - "icon": "https://img.shumengya.top/i/2025/11/17/691ad6cc635d2.png", - "tags": [ "监控","React","Flask"], - "admin":false, - "show":true, - "develop":true - }, - - - - { - "title": "萌芽盘", - "description": "一个轻量级在线网盘,支持文件上传、下载、分享等功能", - "link": "https://pan.shumengya.top", - "icon": "https://img.shumengya.top/i/2026/01/04/695a660870959.png", - "tags": ["网盘","OpenList"], - "admin":false, - "show":true, - "develop":false - }, - { - "title": "萌芽Git仓库", - "description": "自部署私有化Git仓库", - "link": "https://repo.shumengya.top", - "icon": "https://image.shumengya.top/i/2025/11/02/gitea.png", - "tags": ["Gitea", "GitHub"], - "admin":false, - "show":true, - "develop":false - }, - { - "title": "萌芽快传", - "description": "像取快递一样方便的寄送文件", - "link": "https://send.shumengya.top", - "icon": "https://image.shumengya.top/i/2025/11/02/filecodebox.png", - "tags": ["FileCodeBox", "文件快传"], - "admin":false, - "show":true, - "develop":false - }, - { - "title": "萌芽图床", - "description": "简单易用的图床,将您的图片转化为一段网页链接", - "link": "https://img.shumengya.top", - "icon": "https://image.shumengya.top/i/2025/11/02/mengyaimgbed.png", - "tags": [ "图床"], - "admin":false, - "show":true, - "develop":false - }, - { - "title": "萌芽问卷", - "description": "一个轻量简单的问卷系统", - "link": "https://survey.shumengya.top", - "icon": "https://img.shumengya.top/i/2025/11/02/690762865166c.png", - "tags": [ "问卷","调查","SurveyKing"], - "admin":false, - "show":true, - "develop":false - }, - { - "title": "编程速查表[CF]", - "description": "一个编程入门速查表", - "link": "https://reference.smyhub.com", - "icon": "https://img.shumengya.top/i/2026/01/12/6964fd1159f91.png", - "tags": [ "QuickReference","编程速查表"], - "admin":false, - "show":true, - "develop":false - }, - { - "title": "在线思维导图[CF]", - "description": "一个思维导图工具", - "link": "https://mind-map.smyhub.com", - "icon": "https://img.shumengya.top/i/2026/01/12/6964fd1271081.png", - "tags": [ "mindmap","思维导图"], - "admin":false, - "show":true, - "develop":false - }, - { - "title": "it-tools工具集[CF]", - "description": "一个轻量简单的编程工具集合", - "link": "https://it-tools.smyhub.com", - "icon": "https://img.shumengya.top/i/2026/01/12/6964fd1229dc3.png", - "tags": [ "it-tools","工具集合"], - "admin":false, - "show":true, - "develop":false - }, - { - "title": "xtools工具集[CF]", - "description": "一个轻量简单的编程工具集合", - "link": "https://xtools.smyhub.com", - "icon": "https://img.shumengya.top/i/2026/01/12/6964fd133e934.png", - "tags": [ "xtool","工具集合"], - "admin":false, - "show":true, - "develop":false - }, - { - "title": "萌芽备忘录", - "description": "一个轻量简单的备忘录系统", - "link": "https://memos.shumengya.top", - "icon": "https://img.shumengya.top/i/2026/01/13/69663c9f1acc1.png", - "tags": [ "memos","备忘录"], - "admin":false, - "show":true, - "develop":false - }, - { - "title": "网页魔方[CF]", - "description": "就是一个简单的网页魔方", - "link": "https://cube.smyhub.com", - "icon": "https://img.shumengya.top/i/2026/01/14/69678bf409a31.webp", - "tags": [ "web-cube","网页魔方"], - "admin":false, - "show":true, - "develop":false - }, - - - - - - - - - { - "title": "大萌芽1Panel面板[WG]", - "description": "大萌芽1panel面板后台", - "link": "http://10.0.0.233:19132/smy", - "icon": "https://img.shumengya.top/i/2025/11/02/69076213d9200.webp", - "tags": [ "1Panel","面板"], - "admin":true, - "show":true, - "develop":false - }, - { - "title": "小萌芽1Panel面板[WG]", - "description": "小萌芽1panel面板后台", - "link": "http://10.0.0.100:19132/smy", - "icon": "https://img.shumengya.top/i/2025/11/02/69076213d9200.webp", - "tags": [ "1Panel","面板"], - "admin":true, - "show":true, - "develop":false - }, - { - "title": "easytier面板", - "description": "阿里云成都easytier面板后台", - "link": "https://easytier.shumengya.top", - "icon": "https://img.shumengya.top/i/2026/01/12/6964fd1307fdc.png", - "tags": [ "easytier","面板"], - "admin":true, - "show":true, - "develop":false - }, - { - "title": "WireGuard面板[等待复活]", - "description": "阿里云成都WireGuard面板后台", - "link": "https://wireguard.shumengya.top", - "icon": "https://img.shumengya.top/i/2026/01/12/6964fd12baa3c.png", - "tags": [ "WireGuard","面板"], - "admin":true, - "show":true, - "develop":false - }, - { - "title": "大萌芽Portainer面板[WG]", - "description": "大萌芽Portainer面板后台", - "link": "http://10.0.0.233:8484", - "icon": "https://img.shumengya.top/i/2026/01/12/6964fd119f8d1.png", - "tags": [ "Portainer","面板"], - "admin":true, - "show":true, - "develop":false - }, - { - "title": "小萌芽Portainer面板[WG]", - "description": "小萌芽Portainer面板后台", - "link": "https://10.0.0.100:8484", - "icon": "https://img.shumengya.top/i/2026/01/12/6964fd119f8d1.png", - "tags": [ "Portainer","面板"], - "admin":true, - "show":true, - "develop":false - }, - { - "title": "萌芽Docker镜像仓库[等待复活]", - "description": "萌芽Docker镜像仓库后台", - "link": "https://repo.docker.shumengya.top", - "icon": "https://img.shumengya.top/i/2025/11/02/6907621448bac.png", - "tags": [ "Docker","镜像仓库"], - "admin":true, - "show":true, - "develop":false - }, - { - "title": "萌芽通知", - "description": "萌芽通知后台", - "link": "https://notice.shumengya.top", - "icon": "https://img.shumengya.top/i/2026/01/12/6964ff8787011.png", - "tags": [ "通知","gotify"], - "admin":true, - "show":true, - "develop":false - } - - ] -} diff --git a/mengyaprofile-backend/data/techstack.json b/mengyaprofile-backend/data/techstack.json deleted file mode 100644 index 8a79b66..0000000 --- a/mengyaprofile-backend/data/techstack.json +++ /dev/null @@ -1,194 +0,0 @@ -{ - "title": "技术栈", - "items": [ - { - "name": "Android", - "link": "https://developer.android.com/", - "svg": "android.svg", - "color": "#3DDC84", - "show": false - }, - { - "name": "C#", - "link": "https://learn.microsoft.com/en-us/dotnet/csharp/", - "svg": "csharp.svg", - "color": "#68217A", - "show": true - }, - { - "name": "Docker", - "link": "https://www.docker.com/", - "svg": "docker.svg", - "color": "#2496ED", - "show": true - }, - { - "name": "Flask", - "link": "https://flask.palletsprojects.com/", - "svg": "flask.svg", - "color": "#000000", - "show": true - }, - { - "name": "Flutter", - "link": "https://flutter.dev/", - "svg": "flutter.svg", - "color": "#02569B", - "show": false - }, - { - "name": "Gin", - "link": "https://gin-gonic.com/", - "svg": "gin.svg", - "color": "#00ADD8", - "show": true - }, - { - "name": "Git", - "link": "https://git-scm.com/", - "svg": "git.svg", - "color": "#F05032", - "show": true - }, - { - "name": "Godot", - "link": "https://godotengine.org/", - "svg": "godot.svg", - "color": "#478CBF", - "show": true - }, - { - "name": "Golang", - "link": "https://go.dev/", - "svg": "golang.svg", - "color": "#00ADD8", - "show": true - }, - { - "name": "Java", - "link": "https://www.oracle.com/java/", - "svg": "java.svg", - "color": "#ED8B00", - "show": true - }, - { - "name": "JavaScript", - "link": "https://developer.mozilla.org/en-US/docs/Web/JavaScript", - "svg": "javascript.svg", - "color": "#F7DF1E", - "show": false - }, - { - "name": "JSON", - "link": "https://www.json.org/", - "svg": "json.svg", - "color": "#000000", - "show": false - }, - { - "name": "Linux", - "link": "https://www.linux.org/", - "svg": "linux.svg", - "color": "#FCC624", - "show": true - }, - { - "name": "Markdown", - "link": "https://daringfireball.net/projects/markdown/", - "svg": "markdown.svg", - "color": "#000000", - "show": false - }, - { - "name": "Minecraft", - "link": "https://www.minecraft.net/", - "svg": "minecraft.svg", - "color": "#62B74A", - "show": false - }, - { - "name": "MongoDB", - "link": "https://www.mongodb.com/", - "svg": "mongodb.svg", - "color": "#47A248", - "show": true - }, - { - "name": "MySQL", - "link": "https://www.mysql.com/", - "svg": "mysql.svg", - "color": "#00758F", - "show": true - }, - { - "name": "Node.js", - "link": "https://nodejs.org/", - "svg": "nodejs.svg", - "color": "#339933", - "show": false - }, - { - "name": "PostgreSQL", - "link": "https://www.postgresql.org/", - "svg": "postgres.svg", - "color": "#4169E1", - "show": true - }, - { - "name": "Python", - "link": "https://www.python.org/", - "svg": "python.svg", - "color": "#3776AB", - "show": true - }, - { - "name": "React", - "link": "https://react.dev/", - "svg": "react.svg", - "color": "#61DAFB", - "show": true - }, - { - "name": "Redis", - "link": "https://redis.io/", - "svg": "redis.svg", - "color": "#DC382D", - "show": true - }, - { - "name": "Spring", - "link": "https://spring.io/", - "svg": "spring.svg", - "color": "#6DB33F", - "show": true - }, - { - "name": "Unity", - "link": "https://unity.com/", - "svg": "unity.svg", - "color": "#FFFFFF", - "show": true - }, - { - "name": "Vue", - "link": "https://vuejs.org/", - "svg": "vue.svg", - "color": "#4FC08D", - "show": false - }, - { - "name": "WireGuard", - "link": "https://www.wireguard.com/", - "svg": "wireguard.svg", - "color": "#a10808", - "show": true - }, - { - "name": "SQLite", - "link": "https://sqlite.org", - "svg": "sqlite.svg", - "color": "#0671c8", - "show": true - } - ] -} \ No newline at end of file diff --git a/mengyaprofile-backend/data/技术栈旧版图标.json b/mengyaprofile-backend/data/技术栈旧版图标.json deleted file mode 100644 index acd244f..0000000 --- a/mengyaprofile-backend/data/技术栈旧版图标.json +++ /dev/null @@ -1,125 +0,0 @@ -{ - "title": "技术栈", - "items": [ - { - "name": "Android", - "icon": "https://img.shields.io/badge/-Android-3DDC84?style=flat&logo=android&logoColor=white", - "link": "https://developer.android.com/" - }, - { - "name": "C#", - "icon": "https://img.shields.io/badge/-C%23-512BD4?style=flat&logo=csharp&logoColor=white", - "link": "https://learn.microsoft.com/en-us/dotnet/csharp/" - }, - { - "name": "Docker", - "icon": "https://img.shields.io/badge/-Docker-2496ED?style=flat&logo=docker&logoColor=white", - "link": "https://www.docker.com/" - }, - { - "name": "Flask", - "icon": "https://img.shields.io/badge/-Flask-000000?style=flat&logo=flask&logoColor=white", - "link": "https://flask.palletsprojects.com/" - }, - { - "name": "Flutter", - "icon": "https://img.shields.io/badge/-Flutter-02569B?style=flat&logo=flutter&logoColor=white", - "link": "https://flutter.dev/" - }, - { - "name": "Gin", - "icon": "https://img.shields.io/badge/-Gin-00ADD8?style=flat&logo=go&logoColor=white", - "link": "https://gin-gonic.com/" - }, - { - "name": "Git", - "icon": "https://img.shields.io/badge/-Git-F05032?style=flat&logo=git&logoColor=white", - "link": "https://git-scm.com/" - }, - { - "name": "Godot", - "icon": "https://img.shields.io/badge/-Godot-478CBF?style=flat&logo=godot&logoColor=white", - "link": "https://godotengine.org/" - }, - { - "name": "Golang", - "icon": "https://img.shields.io/badge/-Golang-00ADD8?style=flat&logo=go&logoColor=white", - "link": "https://go.dev/" - }, - { - "name": "Java", - "icon": "https://img.shields.io/badge/-Java-007396?style=flat&logo=java&logoColor=white", - "link": "https://www.oracle.com/java/" - }, - { - "name": "JavaScript", - "icon": "https://img.shields.io/badge/-JavaScript-F7DF1E?style=flat&logo=javascript&logoColor=black", - "link": "https://developer.mozilla.org/en-US/docs/Web/JavaScript" - }, - { - "name": "JSON", - "icon": "https://img.shields.io/badge/-JSON-000000?style=flat&logo=json&logoColor=white", - "link": "https://www.json.org/" - }, - { - "name": "Linux", - "icon": "https://img.shields.io/badge/-Linux-000000?style=flat&logo=linux&logoColor=white", - "link": "https://www.linux.org/" - }, - { - "name": "Markdown", - "icon": "https://img.shields.io/badge/-Markdown-083fa1?style=flat&logo=markdown&logoColor=white", - "link": "https://daringfireball.net/projects/markdown/" - }, - { - "name": "Minecraft", - "icon": "https://img.shields.io/badge/-Minecraft-62B34A?style=flat&logo=minecraft&logoColor=white", - "link": "https://www.minecraft.net/" - }, - { - "name": "MongoDB", - "icon": "https://img.shields.io/badge/-MongoDB-47A248?style=flat&logo=mongodb&logoColor=white", - "link": "https://www.mongodb.com/" - }, - { - "name": "MySQL", - "icon": "https://img.shields.io/badge/-MySQL-00758F?style=flat&logo=mysql&logoColor=white", - "link": "https://www.mysql.com/" - }, - { - "name": "Node.js", - "icon": "https://img.shields.io/badge/-Node.js-339933?style=flat&logo=nodedotjs&logoColor=white", - "link": "https://nodejs.org/" - }, - { - "name": "PostgreSQL", - "icon": "https://img.shields.io/badge/-PostgreSQL-4169E1?style=flat&logo=postgresql&logoColor=white", - "link": "https://www.postgresql.org/" - }, - { - "name": "Python", - "icon": "https://img.shields.io/badge/-Python-3776AB?style=flat&logo=python&logoColor=white", - "link": "https://www.python.org/" - }, - { - "name": "React", - "icon": "https://img.shields.io/badge/-React-20232A?style=flat&logo=react&logoColor=61DAFB", - "link": "https://react.dev/" - }, - { - "name": "Redis", - "icon": "https://img.shields.io/badge/-Redis-DC382D?style=flat&logo=redis&logoColor=white", - "link": "https://redis.io/" - }, - { - "name": "Spring", - "icon": "https://img.shields.io/badge/-Spring-6DB33F?style=flat&logo=spring&logoColor=white", - "link": "https://spring.io/" - }, - { - "name": "Unity", - "icon": "https://img.shields.io/badge/-Unity-000000?style=flat&logo=unity&logoColor=white", - "link": "https://unity.com/" - } - ] -} diff --git a/mengyaprofile-backend/db.py b/mengyaprofile-backend/db.py new file mode 100644 index 0000000..b5cfa26 --- /dev/null +++ b/mengyaprofile-backend/db.py @@ -0,0 +1,618 @@ +import os +import sqlite3 +import unicodedata +from contextlib import contextmanager + +DATA_DIR = os.environ.get('DATA_DIR', os.path.join(os.path.dirname(__file__), 'data')) +DATABASE_PATH = os.environ.get('DATABASE_PATH', os.path.join(DATA_DIR, 'site.db')) +_SCHEMA_PATH = os.path.join(os.path.dirname(__file__), 'schema.sql') + + +def _new_connection(): + os.makedirs(os.path.dirname(DATABASE_PATH) or '.', exist_ok=True) + conn = sqlite3.connect(DATABASE_PATH) + conn.row_factory = sqlite3.Row + conn.execute('PRAGMA foreign_keys = ON') + conn.execute('PRAGMA journal_mode = WAL') + return conn + + +@contextmanager +def get_connection(): + conn = _new_connection() + try: + yield conn + conn.commit() + except Exception: + conn.rollback() + raise + finally: + conn.close() + + +def _apply_schema(conn): + with open(_SCHEMA_PATH, 'r', encoding='utf-8') as f: + conn.executescript(f.read()) + + +def _profile_is_empty(conn): + row = conn.execute('SELECT COUNT(*) AS c FROM profile').fetchone() + return row['c'] == 0 + + +def _clean_tag_list(tags): + out = [] + for t in tags or []: + s = str(t).strip() + if s: + out.append(s) + return out + + +def _tech_name_set(conn): + return { + str(r['name']).strip().lower() + for r in conn.execute('SELECT name FROM techstack_item') + if r['name'] and str(r['name']).strip() + } + + +def _split_merged_tags(conn, merged): + tech_set = _tech_name_set(conn) + cat, tech = [], [] + for t in merged or []: + s = str(t).strip() + if not s: + continue + (tech if s.lower() in tech_set else cat).append(s) + return cat, tech + + +def _project_tag_payload_from_body(conn, data): + if 'techTags' in data: + return _clean_tag_list(data.get('tags')), _clean_tag_list(data.get('techTags')) + return _split_merged_tags(conn, data.get('tags') or []) + + +def _replace_project_tags(conn, project_id, tags): + conn.execute('DELETE FROM project_tag WHERE project_id = ?', (project_id,)) + for j, tag in enumerate(_clean_tag_list(tags)): + conn.execute( + 'INSERT INTO project_tag (project_id, sort_order, tag) VALUES (?, ?, ?)', + (project_id, j, tag), + ) + + +def _replace_project_tech_tags(conn, project_id, tags): + conn.execute('DELETE FROM project_tech_tag WHERE project_id = ?', (project_id,)) + for j, tag in enumerate(_clean_tag_list(tags)): + conn.execute( + 'INSERT INTO project_tech_tag (project_id, sort_order, tag) VALUES (?, ?, ?)', + (project_id, j, tag), + ) + + +def _migrate_legacy_project_tags_if_needed(conn): + tech_set = _tech_name_set(conn) + for row in conn.execute('SELECT id FROM project'): + pid = row['id'] + if conn.execute( + 'SELECT 1 FROM project_tech_tag WHERE project_id = ? LIMIT 1', + (pid,), + ).fetchone(): + continue + tag_rows = conn.execute( + 'SELECT tag FROM project_tag WHERE project_id = ? ORDER BY sort_order, id', + (pid,), + ).fetchall() + if not tag_rows: + continue + cat, tech = [], [] + for r in tag_rows: + s = str(r['tag']).strip() + if not s: + continue + if s.lower() in tech_set: + tech.append(s) + else: + cat.append(s) + if not tech: + continue + conn.execute('DELETE FROM project_tag WHERE project_id = ?', (pid,)) + for j, t in enumerate(cat): + conn.execute( + 'INSERT INTO project_tag (project_id, sort_order, tag) VALUES (?,?,?)', + (pid, j, t), + ) + for j, t in enumerate(tech): + conn.execute( + 'INSERT INTO project_tech_tag (project_id, sort_order, tag) VALUES (?,?,?)', + (pid, j, t), + ) + + +def _seed_minimal_defaults(conn): + """空库时写入 profile 与技术栈区块占位行,内容通过管理后台或 SQL 维护。""" + conn.execute('INSERT INTO profile (id, nickname) VALUES (1, ?)', ('',)) + conn.execute('INSERT INTO techstack_section (id, title) VALUES (1, ?)', ('技术栈',)) + + +def _ensure_builtin_tech_items(conn): + """内置技术栈项:按名称幂等追加(无则插入)。""" + builtin = ( + ( + 'Vite', + 'https://vitejs.dev/', + 'https://pan.shumengya.top/d/smy/image/devkit/vite.svg', + '#646CFF', + ), + ( + 'Tailwind CSS', + 'https://tailwindcss.com/', + 'https://pan.shumengya.top/d/smy/image/devkit/tailwind.svg', + '#06B6D4', + ), + ( + 'RabbitMQ', + 'https://www.rabbitmq.com/', + 'https://pan.shumengya.top/d/smy/image/devkit/rabbitmq.svg', + '#FF6600', + ), + ) + for name, link, svg, color in builtin: + if conn.execute( + 'SELECT 1 FROM techstack_item WHERE name = ? LIMIT 1', + (name,), + ).fetchone(): + continue + m = conn.execute('SELECT COALESCE(MAX(sort_order), 0) AS m FROM techstack_item').fetchone()['m'] + sort_order = int(m) + 10 + conn.execute( + '''INSERT INTO techstack_item (sort_order, name, link, svg, color, is_show) + VALUES (?, ?, ?, ?, ?, ?)''', + (sort_order, name, link, svg, color, 1), + ) + + +def _ensure_contact_is_show_column(conn): + """旧库 contact 表无 is_show 时追加列(默认展示)。""" + cols = {str(r['name']) for r in conn.execute('PRAGMA table_info(contact)').fetchall()} + if cols and 'is_show' not in cols: + conn.execute( + 'ALTER TABLE contact ADD COLUMN is_show INTEGER NOT NULL DEFAULT 1' + ) + + +def init_storage(): + with get_connection() as conn: + _apply_schema(conn) + _ensure_contact_is_show_column(conn) + if _profile_is_empty(conn): + _seed_minimal_defaults(conn) + else: + _migrate_legacy_project_tags_if_needed(conn) + _ensure_builtin_tech_items(conn) + _apply_tech_item_display_patches(conn) + _purge_retired_tech_labels(conn) + + +def _normalize_tech_label_key(s): + """仅保留字母数字并小写;NFKC 合并全角/兼容字形,匹配 d1SQL 各类写法。""" + if s is None: + return '' + t = unicodedata.normalize('NFKC', str(s)).strip() + return ''.join(c.lower() for c in t if c.isalnum()) + + +def _purge_retired_tech_labels(conn): + """移除已废弃的技术名:项目标签 + 技术栈表(幂等)。""" + retired_keys = {'d1sql'} + for table in ('project_tech_tag', 'project_tag'): + rows = conn.execute(f'SELECT id, tag FROM {table}').fetchall() + for r in rows: + if _normalize_tech_label_key(r['tag']) in retired_keys: + conn.execute(f'DELETE FROM {table} WHERE id = ?', (r['id'],)) + rows = conn.execute('SELECT id, name FROM techstack_item').fetchall() + for r in rows: + if _normalize_tech_label_key(r['name']) in retired_keys: + conn.execute('DELETE FROM techstack_item WHERE id = ?', (r['id'],)) + + +def _apply_tech_item_display_patches(conn): + """微调技术栈徽章底色(图标为自带颜色的 SVG 时避免与底色撞色)。""" + patches = ( + # Docker.svg 为蓝色填充:深蓝→亮蓝渐变,与白字、鲸鱼标层次更清晰 + ( + 'Docker', + 'linear-gradient(135deg, #052e4a 0%, #0d47a1 42%, #1e88e5 100%)', + ), + # Flask.svg 为 #3babc3 青色,避免纯黑底;深青绿过渡到品牌青 + ( + 'Flask', + 'linear-gradient(135deg, #0f3a40 0%, #1e6570 45%, #2d8fa0 100%)', + ), + # JSON / Markdown:Simple Icons 等品牌资料中的标准色(实心) + # JSON: https://simpleicons.org — #000000(与 Crockford JSON 标识一致) + ('JSON', '#000000'), + # Markdown: Markdown 标识常用主色 — #083FA5 + ('Markdown', '#083FA5'), + ) + for name, color in patches: + conn.execute( + 'UPDATE techstack_item SET color = ? WHERE name = ?', + (color, name), + ) + + +def _row_to_profile(row): + if row is None: + return None + keys = ('favicon', 'nickname', 'avatar', 'introduction', 'footer', 'site', 'homepage') + return {k: row[k] for k in keys if row[k] is not None} + + +def get_profile(): + with get_connection() as conn: + row = conn.execute( + '''SELECT favicon, nickname, avatar, introduction, footer, site, homepage + FROM profile WHERE id = 1''' + ).fetchone() + return _row_to_profile(row) + + +def get_projects(): + with get_connection() as conn: + rows = conn.execute( + 'SELECT id, title, description, link, icon, is_admin, is_show, is_develop FROM project ORDER BY sort_order, id' + ).fetchall() + out = [] + for row in rows: + tags = [ + r['tag'] + for r in conn.execute( + 'SELECT tag FROM project_tag WHERE project_id = ? ORDER BY sort_order, id', + (row['id'],), + ) + ] + tech_tags = [ + r['tag'] + for r in conn.execute( + 'SELECT tag FROM project_tech_tag WHERE project_id = ? ORDER BY sort_order, id', + (row['id'],), + ) + ] + out.append({ + 'id': row['id'], + 'title': row['title'], + 'description': row['description'] or '', + 'link': row['link'] or '', + 'icon': row['icon'] or '', + 'tags': tags, + 'techTags': tech_tags, + 'admin': bool(row['is_admin']), + 'show': bool(row['is_show']), + 'develop': bool(row['is_develop']), + }) + return {'projects': out} + + +def _contact_row_to_dict(row): + return { + 'id': row['id'], + 'type': row['type'], + 'label': row['label'] or '', + 'value': row['value'] or '', + 'link': row['link'] or '', + 'icon': row['icon'] or '', + 'show': bool(row['is_show']), + } + + +def get_contacts(): + """前台公开接口:仅返回首页展示的联系方式。""" + with get_connection() as conn: + rows = conn.execute( + '''SELECT id, type, label, value, link, icon, is_show FROM contact + WHERE COALESCE(is_show, 1) = 1 + ORDER BY sort_order, id''' + ).fetchall() + return { + 'contacts': [_contact_row_to_dict(row) for row in rows], + } + + +def get_techstack(): + """技术栈 svg 字段:可为 data/logo 下文件名,或 http(s) 外链 SVG 地址。""" + with get_connection() as conn: + sec = conn.execute('SELECT title FROM techstack_section WHERE id = 1').fetchone() + title = sec['title'] if sec else '技术栈' + rows = conn.execute( + 'SELECT id, name, link, svg, color, is_show FROM techstack_item ORDER BY sort_order, id' + ).fetchall() + return { + 'title': title, + 'items': [ + { + 'id': row['id'], + 'name': row['name'], + 'link': row['link'] or '', + 'svg': row['svg'] or '', + 'color': row['color'] or '', + 'show': bool(row['is_show']), + } + for row in rows + ], + } + + +def get_all(include_hidden_contacts=False): + with get_connection() as conn: + profile_row = conn.execute( + '''SELECT favicon, nickname, avatar, introduction, footer, site, homepage + FROM profile WHERE id = 1''' + ).fetchone() + profile = _row_to_profile(profile_row) + + tech_sec = conn.execute('SELECT title FROM techstack_section WHERE id = 1').fetchone() + tech_title = tech_sec['title'] if tech_sec else '技术栈' + tech_rows = conn.execute( + 'SELECT id, name, link, svg, color, is_show FROM techstack_item ORDER BY sort_order, id' + ).fetchall() + techstack = { + 'title': tech_title, + 'items': [ + { + 'id': r['id'], + 'name': r['name'], + 'link': r['link'] or '', + 'svg': r['svg'] or '', + 'color': r['color'] or '', + 'show': bool(r['is_show']), + } + for r in tech_rows + ], + } + + proj_rows = conn.execute( + 'SELECT id, title, description, link, icon, is_admin, is_show, is_develop FROM project ORDER BY sort_order, id' + ).fetchall() + projects = [] + for row in proj_rows: + tags = [ + r['tag'] + for r in conn.execute( + 'SELECT tag FROM project_tag WHERE project_id = ? ORDER BY sort_order, id', + (row['id'],), + ) + ] + tech_tags = [ + r['tag'] + for r in conn.execute( + 'SELECT tag FROM project_tech_tag WHERE project_id = ? ORDER BY sort_order, id', + (row['id'],), + ) + ] + projects.append({ + 'id': row['id'], + 'title': row['title'], + 'description': row['description'] or '', + 'link': row['link'] or '', + 'icon': row['icon'] or '', + 'tags': tags, + 'techTags': tech_tags, + 'admin': bool(row['is_admin']), + 'show': bool(row['is_show']), + 'develop': bool(row['is_develop']), + }) + + contact_rows = conn.execute( + 'SELECT id, type, label, value, link, icon, is_show FROM contact ORDER BY sort_order, id' + ).fetchall() + contact_list = [] + for cr in contact_rows: + if not include_hidden_contacts and not bool(cr['is_show']): + continue + contact_list.append(_contact_row_to_dict(cr)) + contacts = {'contacts': contact_list} + + return { + 'profile': profile, + 'techstack': techstack, + 'projects': {'projects': projects}, + 'contacts': contacts, + } + + +def update_profile(payload): + allowed = {'favicon', 'nickname', 'avatar', 'introduction', 'footer', 'site', 'homepage'} + updates = {k: payload[k] for k in allowed if k in payload} + if not updates: + return + sets = ', '.join(f'{k} = ?' for k in updates) + vals = list(updates.values()) + with get_connection() as conn: + conn.execute(f'UPDATE profile SET {sets} WHERE id = 1', vals) + + +def create_project(data): + with get_connection() as conn: + cat_tags, tech_tags = _project_tag_payload_from_body(conn, data) + m = conn.execute('SELECT COALESCE(MAX(sort_order), 0) AS m FROM project').fetchone()['m'] + sort_order = int(m) + 10 + cur = conn.execute( + '''INSERT INTO project (sort_order, title, description, link, icon, is_admin, is_show, is_develop) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)''', + ( + sort_order, + data['title'], + data.get('description') or '', + data.get('link') or '', + data.get('icon') or '', + 1 if data.get('admin') else 0, + 1 if data.get('show', True) else 0, + 1 if data.get('develop') else 0, + ), + ) + pid = cur.lastrowid + _replace_project_tags(conn, pid, cat_tags) + _replace_project_tech_tags(conn, pid, tech_tags) + return pid + + +def update_project(project_id, data): + with get_connection() as conn: + fields = [] + vals = [] + if 'title' in data: + fields.append('title = ?') + vals.append(data['title']) + if 'description' in data: + fields.append('description = ?') + vals.append(data['description'] or '') + if 'link' in data: + fields.append('link = ?') + vals.append(data['link'] or '') + if 'icon' in data: + fields.append('icon = ?') + vals.append(data['icon'] or '') + if 'admin' in data: + fields.append('is_admin = ?') + vals.append(1 if data['admin'] else 0) + if 'show' in data: + fields.append('is_show = ?') + vals.append(1 if data['show'] else 0) + if 'develop' in data: + fields.append('is_develop = ?') + vals.append(1 if data['develop'] else 0) + if fields: + vals.append(project_id) + conn.execute( + f"UPDATE project SET {', '.join(fields)} WHERE id = ?", + vals, + ) + if 'tags' in data or 'techTags' in data: + if 'tags' in data and 'techTags' in data: + _replace_project_tags(conn, project_id, data['tags']) + _replace_project_tech_tags(conn, project_id, data['techTags']) + elif 'tags' in data: + cat, tech = _split_merged_tags(conn, data['tags']) + _replace_project_tags(conn, project_id, cat) + _replace_project_tech_tags(conn, project_id, tech) + else: + _replace_project_tech_tags(conn, project_id, data['techTags']) + + +def delete_project(project_id): + with get_connection() as conn: + conn.execute('DELETE FROM project WHERE id = ?', (project_id,)) + + +def create_contact(data): + with get_connection() as conn: + m = conn.execute('SELECT COALESCE(MAX(sort_order), 0) AS m FROM contact').fetchone()['m'] + sort_order = int(m) + 10 + cur = conn.execute( + '''INSERT INTO contact (sort_order, type, label, value, link, icon, is_show) + VALUES (?, ?, ?, ?, ?, ?, ?)''', + ( + sort_order, + data['type'], + data.get('label') or '', + data.get('value') or '', + data.get('link') or '', + data.get('icon') or '', + 1 if data.get('show', True) else 0, + ), + ) + return cur.lastrowid + + +def update_contact(contact_id, data): + with get_connection() as conn: + fields = [] + vals = [] + for col, key in ( + ('type', 'type'), + ('label', 'label'), + ('value', 'value'), + ('link', 'link'), + ('icon', 'icon'), + ): + if key in data: + fields.append(f'{col} = ?') + vals.append(data[key] or '') + if 'show' in data: + fields.append('is_show = ?') + vals.append(1 if data['show'] else 0) + if fields: + vals.append(contact_id) + conn.execute( + f"UPDATE contact SET {', '.join(fields)} WHERE id = ?", + vals, + ) + + +def delete_contact(contact_id): + with get_connection() as conn: + conn.execute('DELETE FROM contact WHERE id = ?', (contact_id,)) + + +def set_techstack_title(title): + t = title or '技术栈' + with get_connection() as conn: + row = conn.execute('SELECT id FROM techstack_section WHERE id = 1').fetchone() + if row: + conn.execute('UPDATE techstack_section SET title = ? WHERE id = 1', (t,)) + else: + conn.execute('INSERT INTO techstack_section (id, title) VALUES (1, ?)', (t,)) + + +def create_techstack_item(data): + with get_connection() as conn: + m = conn.execute('SELECT COALESCE(MAX(sort_order), 0) AS m FROM techstack_item').fetchone()['m'] + sort_order = int(m) + 10 + cur = conn.execute( + '''INSERT INTO techstack_item (sort_order, name, link, svg, color, is_show) + VALUES (?, ?, ?, ?, ?, ?)''', + ( + sort_order, + data['name'], + data.get('link') or '', + data.get('svg') or '', + data.get('color') or '', + 1 if data.get('show', True) else 0, + ), + ) + return cur.lastrowid + + +def update_techstack_item(item_id, data): + with get_connection() as conn: + fields = [] + vals = [] + if 'name' in data: + fields.append('name = ?') + vals.append(data['name']) + if 'link' in data: + fields.append('link = ?') + vals.append(data['link'] or '') + if 'svg' in data: + fields.append('svg = ?') + vals.append(data['svg'] or '') + if 'color' in data: + fields.append('color = ?') + vals.append(data['color'] or '') + if 'show' in data: + fields.append('is_show = ?') + vals.append(1 if data['show'] else 0) + if fields: + vals.append(item_id) + conn.execute( + f"UPDATE techstack_item SET {', '.join(fields)} WHERE id = ?", + vals, + ) + + +def delete_techstack_item(item_id): + with get_connection() as conn: + conn.execute('DELETE FROM techstack_item WHERE id = ?', (item_id,)) diff --git a/mengyaprofile-backend/docker-build.bat b/mengyaprofile-backend/docker-build.bat new file mode 100644 index 0000000..2931f9f --- /dev/null +++ b/mengyaprofile-backend/docker-build.bat @@ -0,0 +1,10 @@ +@echo off +setlocal +cd /d "%~dp0" +echo Building mengyaprofile-backend ... +docker build -t mengyaprofile-backend:latest . +if errorlevel 1 exit /b 1 +echo. +echo OK: image mengyaprofile-backend:latest +echo Run: docker compose up -d +endlocal diff --git a/mengyaprofile-backend/docker-compose-up.bat b/mengyaprofile-backend/docker-compose-up.bat new file mode 100644 index 0000000..81500f5 --- /dev/null +++ b/mengyaprofile-backend/docker-compose-up.bat @@ -0,0 +1,8 @@ +@echo off +setlocal +cd /d "%~dp0" +docker compose up -d --build +if errorlevel 1 exit /b 1 +echo. +docker compose ps +endlocal diff --git a/mengyaprofile-backend/docker-compose.yml b/mengyaprofile-backend/docker-compose.yml index ce461e2..428c61d 100644 --- a/mengyaprofile-backend/docker-compose.yml +++ b/mengyaprofile-backend/docker-compose.yml @@ -1,19 +1,26 @@ -version: '3.8' +# 后端 API:构建并后台运行 +# 用法(在 mengyaprofile-backend 目录): docker compose up -d --build +# 数据持久化:请把下方卷路径改成你服务器上的目录(Windows 可用 WSL2 或改为命名卷) services: mengyaprofile-backend: build: context: . dockerfile: Dockerfile + image: mengyaprofile-backend:latest container_name: mengyaprofile-backend restart: unless-stopped ports: - "1616:5000" volumes: + # 仅持久化 SQLite 与用户上传背景图等;镜像内已带 data/logo - /shumengya/docker/mengyaprofile-backend/data:/app/data environment: - - RUN_MODE=production - - DATA_DIR=/app/data + RUN_MODE: production + DATA_DIR: /app/data + PORT: "5000" + # 生产务必修改:docker compose 可配合 .env 文件设置 ADMIN_TOKEN + # ADMIN_TOKEN: your-secret-token networks: - mengyaprofile-network diff --git a/mengyaprofile-backend/docker-entrypoint.sh b/mengyaprofile-backend/docker-entrypoint.sh new file mode 100644 index 0000000..14d9203 --- /dev/null +++ b/mengyaprofile-backend/docker-entrypoint.sh @@ -0,0 +1,8 @@ +#!/bin/sh +set -e +# 宿主机挂载 /app/data 时会遮住镜像内 logo;首次或空目录时从镜像内副本灌入 +mkdir -p /app/data/logo /app/data/background +if [ ! "$(ls -A /app/data/logo 2>/dev/null)" ]; then + cp -a /app/logo-dist/. /app/data/logo/ +fi +exec "$@" diff --git a/mengyaprofile-backend/requirements.txt b/mengyaprofile-backend/requirements.txt index 6acd0bf..6119aea 100644 --- a/mengyaprofile-backend/requirements.txt +++ b/mengyaprofile-backend/requirements.txt @@ -1,2 +1,3 @@ Flask==3.0.0 flask-cors==4.0.0 +gunicorn==21.2.0 diff --git a/mengyaprofile-backend/schema.sql b/mengyaprofile-backend/schema.sql new file mode 100644 index 0000000..aeaf5f9 --- /dev/null +++ b/mengyaprofile-backend/schema.sql @@ -0,0 +1,78 @@ +-- 萌芽主页站点数据(SQLite) +PRAGMA foreign_keys = ON; + +-- 基本个人信息 +CREATE TABLE IF NOT EXISTS profile ( + id INTEGER PRIMARY KEY CHECK (id = 1), + favicon TEXT, + nickname TEXT NOT NULL DEFAULT '', + avatar TEXT, + introduction TEXT, + footer TEXT, + site TEXT, + homepage TEXT +); + +-- 项目列表 +CREATE TABLE IF NOT EXISTS project ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + sort_order INTEGER NOT NULL DEFAULT 0, + title TEXT NOT NULL, + description TEXT, + link TEXT, + icon TEXT, + is_admin INTEGER NOT NULL DEFAULT 0 CHECK (is_admin IN (0, 1)), + is_show INTEGER NOT NULL DEFAULT 1 CHECK (is_show IN (0, 1)), + is_develop INTEGER NOT NULL DEFAULT 0 CHECK (is_develop IN (0, 1)) +); + +-- 项目分类 / 业务标签 +CREATE TABLE IF NOT EXISTS project_tag ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + project_id INTEGER NOT NULL REFERENCES project (id) ON DELETE CASCADE, + sort_order INTEGER NOT NULL DEFAULT 0, + tag TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_project_tag_project ON project_tag (project_id, sort_order); + +-- 项目技术栈标签(与 project_tag 分开维护) +CREATE TABLE IF NOT EXISTS project_tech_tag ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + project_id INTEGER NOT NULL REFERENCES project (id) ON DELETE CASCADE, + sort_order INTEGER NOT NULL DEFAULT 0, + tag TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_project_tech_tag_project ON project_tech_tag (project_id, sort_order); + +-- 联系方式 +CREATE TABLE IF NOT EXISTS contact ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + sort_order INTEGER NOT NULL DEFAULT 0, + type TEXT NOT NULL, + label TEXT, + value TEXT, + link TEXT, + icon TEXT, + is_show INTEGER NOT NULL DEFAULT 1 CHECK (is_show IN (0, 1)) +); + +-- 技术栈分类 +CREATE TABLE IF NOT EXISTS techstack_section ( + id INTEGER PRIMARY KEY CHECK (id = 1), + title TEXT NOT NULL DEFAULT '技术栈' +); + +-- 技术栈项 +CREATE TABLE IF NOT EXISTS techstack_item ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + sort_order INTEGER NOT NULL DEFAULT 0, + name TEXT NOT NULL, + link TEXT, + svg TEXT, + color TEXT, + is_show INTEGER NOT NULL DEFAULT 1 CHECK (is_show IN (0, 1)) +); + +CREATE INDEX IF NOT EXISTS idx_techstack_item_order ON techstack_item (sort_order, id); diff --git a/mengyaprofile-backend/sort_techstack.py b/mengyaprofile-backend/sort_techstack.py deleted file mode 100644 index ed9aafd..0000000 --- a/mengyaprofile-backend/sort_techstack.py +++ /dev/null @@ -1,59 +0,0 @@ -#!/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/package-lock.json b/mengyaprofile-frontend/package-lock.json index 49ecc19..5b39ee7 100644 --- a/mengyaprofile-frontend/package-lock.json +++ b/mengyaprofile-frontend/package-lock.json @@ -14,6 +14,7 @@ "@testing-library/user-event": "^13.5.0", "react": "^19.2.0", "react-dom": "^19.2.0", + "react-router-dom": "^6.30.3", "react-scripts": "5.0.1", "web-vitals": "^2.1.4" } @@ -3072,6 +3073,15 @@ } } }, + "node_modules/@remix-run/router": { + "version": "1.23.2", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz", + "integrity": "sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/@rollup/plugin-babel": { "version": "5.3.1", "resolved": "https://registry.npmjs.org/@rollup/plugin-babel/-/plugin-babel-5.3.1.tgz", @@ -13888,6 +13898,38 @@ "node": ">=0.10.0" } }, + "node_modules/react-router": { + "version": "6.30.3", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.3.tgz", + "integrity": "sha512-XRnlbKMTmktBkjCLE8/XcZFlnHvr2Ltdr1eJX4idL55/9BbORzyZEaIkBFDhFGCEWBBItsVrDxwx3gnisMitdw==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router-dom": { + "version": "6.30.3", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.3.tgz", + "integrity": "sha512-pxPcv1AczD4vso7G4Z3TKcvlxK7g7TNt3/FNGMhfqyntocvYKj+GCatfigGDjbLozC4baguJ0ReCigoDJXb0ag==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.2", + "react-router": "6.30.3" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, "node_modules/react-scripts": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/react-scripts/-/react-scripts-5.0.1.tgz", diff --git a/mengyaprofile-frontend/package.json b/mengyaprofile-frontend/package.json index e691eef..0af637d 100644 --- a/mengyaprofile-frontend/package.json +++ b/mengyaprofile-frontend/package.json @@ -9,6 +9,7 @@ "@testing-library/user-event": "^13.5.0", "react": "^19.2.0", "react-dom": "^19.2.0", + "react-router-dom": "^6.30.3", "react-scripts": "5.0.1", "web-vitals": "^2.1.4" }, diff --git a/mengyaprofile-frontend/public/index.html b/mengyaprofile-frontend/public/index.html index 671b0a2..ccff5bf 100644 --- a/mengyaprofile-frontend/public/index.html +++ b/mengyaprofile-frontend/public/index.html @@ -1,10 +1,16 @@ - + + + + - + diff --git a/mengyaprofile-frontend/public/manifest.json b/mengyaprofile-frontend/public/manifest.json index 73efb08..fbcaf69 100644 --- a/mengyaprofile-frontend/public/manifest.json +++ b/mengyaprofile-frontend/public/manifest.json @@ -25,8 +25,8 @@ "start_url": ".", "display": "standalone", "orientation": "portrait-primary", - "theme_color": "#52b788", - "background_color": "#a8e6cf", + "theme_color": "#e8897a", + "background_color": "#fff5ef", "categories": ["personalization", "productivity"], "prefer_related_applications": false } diff --git a/mengyaprofile-frontend/src/AdminDashboard.css b/mengyaprofile-frontend/src/AdminDashboard.css new file mode 100644 index 0000000..72252ad --- /dev/null +++ b/mengyaprofile-frontend/src/AdminDashboard.css @@ -0,0 +1,658 @@ +.admin-page { + min-height: 100vh; + background: var(--warm-gradient-page); + padding: 16px; + box-sizing: border-box; +} + +.admin-top { + max-width: 960px; + margin: 0 auto 16px; + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 12px; + justify-content: space-between; +} + +.admin-top h1 { + font-size: 1.25rem; + margin: 0; + color: #5c3d42; +} + +.admin-top-actions { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.admin-top-actions button { + padding: 8px 14px; + border-radius: 8px; + border: none; + cursor: pointer; + font-size: 14px; + background: #fff; + color: #5c3d42; + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08); +} + +.admin-top-actions button.danger { + background: #e07a5f; + color: #fff; +} + +.admin-tabs { + max-width: 960px; + margin: 0 auto 16px; + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.admin-tabs button { + padding: 8px 14px; + border: none; + border-radius: 999px; + cursor: pointer; + background: rgba(255, 255, 255, 0.55); + color: #5c3d42; + font-size: 14px; +} + +.admin-tabs button.active { + background: var(--accent); + color: #fff; +} + +.admin-panel { + max-width: 960px; + margin: 0 auto; + background: rgba(255, 255, 255, 0.92); + border-radius: 12px; + padding: 20px; + box-shadow: 0 4px 24px rgba(0, 0, 0, 0.08); +} + +.admin-msg { + margin: 0 0 12px; + font-size: 13px; + color: var(--accent-link); +} + +.admin-msg.err { + color: #c0392b; +} + +.admin-form-grid label { + display: block; + font-size: 12px; + color: #555; + margin-bottom: 4px; +} + +.admin-form-grid input, +.admin-form-grid textarea { + width: 100%; + box-sizing: border-box; + margin-bottom: 12px; + padding: 8px 10px; + border: 1px solid #ccc; + border-radius: 8px; + font-size: 14px; +} + +/* 复选框/单选框不应被拉成整行宽,否则文字会「飞到」行尾 */ +.admin-form-grid input[type='checkbox'], +.admin-form-grid input[type='radio'] { + width: auto; + min-width: 1.125rem; + max-width: none; + margin-bottom: 0; + padding: 0; + border: none; + border-radius: 4px; + flex-shrink: 0; + align-self: center; +} + +/* 覆盖 .admin-form-grid label { display:block },与勾选文字同一行对齐 */ +.admin-form-grid label.admin-check { + display: flex !important; + align-items: center; + gap: 8px; + margin-top: 10px; + margin-bottom: 0; + width: 100%; + box-sizing: border-box; + cursor: pointer; +} + +.admin-form-grid textarea { + min-height: 72px; + resize: vertical; +} + +.admin-form-actions { + margin-top: 8px; +} + +.admin-form-actions button { + padding: 10px 20px; + border: none; + border-radius: 8px; + background: var(--accent); + color: #fff; + cursor: pointer; + font-size: 14px; +} + +/* 技术栈「已有项」:多列网格,减少纵向滚动 */ +.admin-tech-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap: 10px; + align-items: start; +} + +.admin-tech-card { + border: 1px solid #e0e0e0; + border-radius: 10px; + padding: 8px 10px; + background: #fafafa; + min-width: 0; +} + +.admin-tech-card .admin-row-head { + margin-bottom: 0; + flex-wrap: nowrap; + justify-content: flex-start; +} + +.admin-tech-card__ico { + flex-shrink: 0; +} + +.admin-tech-card__ph { + font-size: 13px; + font-weight: 700; + line-height: 1; +} + +.admin-tech-card__name { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: #5c3d42; +} + +.admin-tech-card .admin-row-btns { + flex-shrink: 0; +} + +.admin-row { + border: 1px solid #e0e0e0; + border-radius: 10px; + padding: 12px; + margin-bottom: 12px; + background: #fafafa; +} + +.admin-row-head { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 8px; + margin-bottom: 8px; +} + +.admin-row-head strong { + color: #5c3d42; +} + +.admin-row-btns { + display: flex; + gap: 6px; +} + +.admin-row-btns button { + padding: 6px 12px; + font-size: 13px; + border-radius: 6px; + border: none; + cursor: pointer; + background: #fff; + border: 1px solid #ccc; +} + +.admin-row-btns button.warn { + background: #fde8e4; + border-color: #e07a5f; + color: #922b21; +} + +.admin-check { + display: flex; + align-items: center; + gap: 6px; + margin-right: 12px; + font-size: 13px; +} + +.admin-check-row { + display: flex; + flex-wrap: wrap; + margin-bottom: 8px; +} + +.admin-summary { + margin-bottom: 16px; + padding: 14px; + background: #fff8f5; + border-radius: 10px; + border: 1px solid #fce0d8; +} + +.admin-summary p { + margin: 0 0 6px; +} + +.admin-summary-meta { + font-size: 13px; + color: #555; +} + +.admin-toolbar { + margin-bottom: 16px; +} + +.admin-toolbar-btn { + padding: 10px 18px; + border: none; + border-radius: 8px; + font-size: 14px; + cursor: pointer; + background: #e8e8e8; + color: #333; +} + +.admin-toolbar-btn.primary { + background: var(--accent); + color: #fff; +} + +.admin-toolbar-btn:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.admin-list-heading { + margin: 0 0 12px; + font-size: 1rem; + color: #5c3d42; +} + +.admin-row-sub { + font-size: 13px; + color: #555; +} + +/* 管理后台 · 项目列表卡片 */ +.admin-project-card { + display: flex; + align-items: flex-start; + gap: 14px; +} + +.admin-project-card__icon-wrap { + flex-shrink: 0; + width: 52px; + height: 52px; + border-radius: 12px; + background: #fff; + border: 1px solid #e0e0e0; + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; +} + +.admin-project-card__icon { + width: 36px; + height: 36px; + object-fit: contain; +} + +.admin-project-card__main { + flex: 1; + min-width: 0; +} + +.admin-project-card__head { + display: flex; + flex-wrap: wrap; + align-items: flex-start; + justify-content: space-between; + gap: 10px; + margin-bottom: 6px; +} + +.admin-project-card__title { + font-size: 1rem; + color: #5c3d42; + line-height: 1.3; +} + +.admin-project-card__desc { + margin: 0 0 8px; + font-size: 13px; + color: #555; + line-height: 1.45; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.admin-project-card__link { + display: inline-block; + max-width: 100%; + font-size: 12px; + color: var(--accent-link); + word-break: break-all; + text-decoration: none; + margin-bottom: 8px; +} + +.admin-project-card__link:hover { + text-decoration: underline; +} + +.admin-project-card__link--empty { + color: #999; + cursor: default; +} + +.admin-project-card__meta { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; +} + +.admin-project-card__tag { + font-size: 11px; + padding: 2px 8px; + border-radius: 999px; + background: #fff0eb; + color: #9d4e56; + border: 1px solid #f5cfc4; +} + +.admin-project-card__tag--more { + background: #f5f5f5; + color: #666; + border-color: #ddd; +} + +.admin-project-card__tag--tech { + background: #e3f2fd; + color: #1565c0; + border-color: #90caf9; +} + +.admin-project-card__pill { + font-size: 11px; + padding: 2px 8px; + border-radius: 999px; + font-weight: 600; +} + +.admin-project-card__pill--dev { + background: #e3f2fd; + color: #1565c0; + border: 1px solid #90caf9; +} + +.admin-project-card__pill--deploy { + background: #fff3e0; + color: #e65100; + border: 1px solid #ffcc80; +} + +.admin-project-card__pill--admin { + background: #fce4ec; + color: #c2185b; + border: 1px solid #f48fb1; +} + +.admin-project-card__pill--hidden { + background: #eceff1; + color: #546e7a; + border: 1px solid #b0bec5; +} + +.admin-inline-title { + margin-bottom: 16px; +} + +.admin-title-row { + display: flex; + flex-wrap: wrap; + gap: 10px; + align-items: center; +} + +.admin-title-row input { + flex: 1; + min-width: 160px; + box-sizing: border-box; + padding: 8px 10px; + border: 1px solid #ccc; + border-radius: 8px; + font-size: 14px; +} + +.admin-tech-picker { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 12px; + max-height: 240px; + overflow-y: auto; + padding: 4px 0; +} + +/* 项目弹窗加宽时:技术栈格子与图标放大,便于点击 */ +.admin-modal-panel--wide .admin-tech-picker { + gap: 12px; + max-height: min(52vh, 480px); + padding: 8px 4px; +} + +.admin-modal-panel--wide .admin-tech-pick { + width: 104px; + min-width: 104px; + min-height: 96px; + padding: 12px 8px; + gap: 8px; + border-radius: 12px; + font-size: 12px; +} + +.admin-modal-panel--wide .admin-tech-pick__img { + width: 44px; + height: 44px; +} + +.admin-modal-panel--wide .admin-tech-pick__ph { + width: 44px; + height: 44px; + border-radius: 8px; + font-size: 18px; +} + +.admin-modal-panel--wide .admin-tech-pick__name { + font-size: 12px; + line-height: 1.25; +} + +.admin-tech-picker-empty { + margin: 0; + font-size: 13px; + color: #888; +} + +.admin-tech-pick { + display: inline-flex; + flex-direction: column; + align-items: center; + gap: 4px; + width: 76px; + padding: 8px 6px; + border: 2px solid #e0e0e0; + border-radius: 10px; + background: #fff; + cursor: pointer; + font-size: 11px; + color: #333; + transition: border-color 0.15s ease, box-shadow 0.15s ease, background 0.15s ease; +} + +.admin-tech-pick:hover { + border-color: var(--accent); +} + +.admin-tech-pick--on { + border-color: var(--accent); + background: var(--accent-alpha-14); + box-shadow: 0 0 0 1px var(--accent-alpha-35); +} + +.admin-tech-pick__img { + width: 28px; + height: 28px; + object-fit: contain; +} + +.admin-tech-pick__ph { + width: 28px; + height: 28px; + display: flex; + align-items: center; + justify-content: center; + background: #eee; + border-radius: 6px; + font-weight: 700; + color: #999; +} + +.admin-tech-pick__name { + text-align: center; + line-height: 1.2; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; +} + +.admin-project-card__tech-ico { + display: inline-flex; + align-items: center; + justify-content: center; + width: 26px; + height: 26px; + padding: 3px; + border-radius: 6px; + box-sizing: border-box; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.08); +} + +.admin-project-card__tech-ico img { + width: 100%; + height: 100%; + object-fit: contain; +} + +/* 技术栈项「主题色」:取色器 + 文本 + 快捷色块 */ +.admin-color-row { + display: flex; + align-items: stretch; + gap: 10px; + margin-bottom: 8px; + width: 100%; + box-sizing: border-box; +} + +.admin-form-grid .admin-color-row input { + margin-bottom: 0; +} + +/* 取色器宽度需压过 .admin-form-grid input { width:100% } */ +.admin-form-grid .admin-color-row input[type='color'] { + width: 52px; + min-width: 52px; + max-width: 52px; + flex-shrink: 0; + padding: 3px; +} + +.admin-color-native { + width: 52px; + min-width: 52px; + height: 42px; + padding: 3px; + border: 1px solid #ccc; + border-radius: 8px; + cursor: pointer; + background: #fff; + box-sizing: border-box; +} + +.admin-color-native::-webkit-color-swatch-wrapper { + padding: 2px; +} + +.admin-color-native::-webkit-color-swatch { + border-radius: 4px; + border: none; +} + +.admin-color-hex-input { + flex: 1; + min-width: 0; +} + +.admin-color-swatches { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 12px; +} + +.admin-color-swatch { + width: 30px; + height: 30px; + border-radius: 6px; + border: 2px solid rgba(0, 0, 0, 0.12); + cursor: pointer; + padding: 0; + box-sizing: border-box; + transition: transform 0.12s ease, box-shadow 0.12s ease; +} + +.admin-color-swatch:hover { + transform: scale(1.1); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.18); + z-index: 1; +} + +.admin-color-swatch--light { + border-color: #bbb; +} diff --git a/mengyaprofile-frontend/src/AdminDashboard.js b/mengyaprofile-frontend/src/AdminDashboard.js new file mode 100644 index 0000000..63b5aa9 --- /dev/null +++ b/mengyaprofile-frontend/src/AdminDashboard.js @@ -0,0 +1,1053 @@ +import React, { useEffect, useState, useCallback } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { + ADMIN_TOKEN_KEY, + adminFetch, + getApiBase, + getFaviconUrlForSite, + getTechstackIconUrl, + techBadgeLabelColor, + techBadgeSurfaceStyle, +} from './api'; +import AdminModal from './components/admin/AdminModal'; +import './AdminDashboard.css'; + +function parseTags(str) { + if (!str || !String(str).trim()) return []; + return String(str) + .split(/[,,]/) + .map((s) => s.trim()) + .filter(Boolean); +} + +function truncateText(str, max) { + if (!str) return ''; + const s = String(str); + return s.length <= max ? s : `${s.slice(0, max)}…`; +} + +function adminProjectListIconSrc(p) { + try { + if (p.link) new URL(p.link); + else throw new Error(); + return getFaviconUrlForSite(p.link); + } catch { + return p.icon || 'https://api.iconify.design/mdi:web.svg'; + } +} + +function handleAdminProjectIconError(e, p) { + const img = e.currentTarget; + if (!img) return; + if (img.src.includes('favicon.smyhub.com') && p.icon) { + img.src = p.icon; + return; + } + img.src = 'https://api.iconify.design/mdi:web.svg'; +} + +function handleTechCardIconError(e) { + const img = e.currentTarget; + if (!img) return; + img.onerror = null; + img.src = 'https://api.iconify.design/mdi:help-circle-outline.svg'; +} + +function omitId(obj) { + if (!obj || typeof obj !== 'object') return { ...obj }; + const { id: _i, ...rest } = obj; + return rest; +} + +/** 供原生取色器:仅纯色 #rgb/#rrggbb;渐变或其它内容时用占位色 */ +function hexForNativeColorPicker(value) { + const v = String(value || '').trim(); + if (!v || v.toLowerCase().includes('gradient')) return '#e8897a'; + const m3 = /^#([0-9a-f]{3})$/i.exec(v); + if (m3) { + const s = m3[1]; + return `#${s[0]}${s[0]}${s[1]}${s[1]}${s[2]}${s[2]}`.toLowerCase(); + } + const m6 = /^#([0-9a-f]{6})$/i.exec(v); + if (m6) return `#${m6[1].toLowerCase()}`; + return '#e8897a'; +} + +const TECH_THEME_COLOR_SWATCHES = [ + '#000000', + '#ffffff', + '#3DDC84', + '#4285F4', + '#61DAFB', + '#3776AB', + '#2496ED', + '#FF6600', + '#646CFF', + '#06B6D4', + '#083FA5', + '#E88939', + '#68217A', + '#F05032', + '#00ADD8', + '#ED8B00', + '#47A248', + '#DC382D', + '#6DB33F', + '#e8897a', + '#1e293b', + '#f472b6', + '#a78bfa', + '#f59e0b', + '#14b8a6' +]; + +function projectBodyFromDraft(d) { + return { + title: d.title, + description: d.description || '', + link: d.link || '', + icon: d.icon || '', + tags: parseTags(d.tagsStr), + techTags: Array.isArray(d.techTagNames) ? [...d.techTagNames] : [], + admin: !!d.admin, + show: d.show !== false, + develop: !!d.develop + }; +} + +const emptyProfileDraft = () => ({ + favicon: '', + nickname: '', + avatar: '', + introduction: '', + footer: '', + site: '', + homepage: '' +}); + +const emptyProjectDraft = () => ({ + title: '', + description: '', + link: '', + icon: '', + tagsStr: '', + techTagNames: [], + admin: false, + show: true, + develop: true +}); + +const emptyContactDraft = () => ({ + type: 'email', + label: '', + value: '', + link: '', + icon: '', + show: true +}); + +const emptyTechDraft = () => ({ + name: '', + link: '', + svg: '', + color: '#000000', + show: true +}); + +export default function AdminDashboard() { + const navigate = useNavigate(); + const [tab, setTab] = useState('profile'); + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [loadError, setLoadError] = useState(''); + const [msg, setMsg] = useState(''); + const [err, setErr] = useState(''); + const [savingDialog, setSavingDialog] = useState(false); + const [savingTitle, setSavingTitle] = useState(false); + const [profileForm, setProfileForm] = useState(emptyProfileDraft); + const [techTitle, setTechTitle] = useState('技术栈'); + const [dialog, setDialog] = useState(null); + + const updateDraft = (patch) => { + setDialog((d) => (d ? { ...d, draft: { ...d.draft, ...patch } } : null)); + }; + + const refresh = useCallback(async () => { + setLoading(true); + setLoadError(''); + try { + const r = await adminFetch('/all'); + if (r.status === 401) { + sessionStorage.removeItem(ADMIN_TOKEN_KEY); + navigate('/', { replace: true }); + setLoadError('未登录或会话已失效'); + setData(null); + return; + } + if (!r.ok) { + setLoadError(`加载失败(HTTP ${r.status})`); + setData(null); + return; + } + setData(await r.json()); + } catch (e) { + setLoadError(e.message || '网络错误'); + setData(null); + } finally { + setLoading(false); + } + }, [navigate]); + + const apiAdmin = async (path, options = {}) => { + const res = await adminFetch(path, options); + if (res.status === 401) { + sessionStorage.removeItem(ADMIN_TOKEN_KEY); + navigate('/', { replace: true }); + return null; + } + return res; + }; + + useEffect(() => { + if (!sessionStorage.getItem(ADMIN_TOKEN_KEY)) { + navigate('/', { replace: true }); + return; + } + refresh(); + }, [navigate, refresh]); + + useEffect(() => { + if (data?.profile) { + setProfileForm({ + favicon: data.profile.favicon || '', + nickname: data.profile.nickname || '', + avatar: data.profile.avatar || '', + introduction: data.profile.introduction || '', + footer: data.profile.footer || '', + site: data.profile.site || '', + homepage: data.profile.homepage || '' + }); + } + if (data?.techstack?.title) setTechTitle(data.techstack.title); + }, [data]); + + const flashOk = (t) => { + setErr(''); + setMsg(t); + setTimeout(() => setMsg(''), 2500); + }; + + const flashErr = async (r) => { + setMsg(''); + try { + const j = await r.json(); + setErr(j.error || `请求失败 ${r.status}`); + } catch { + setErr(`请求失败 ${r.status}`); + } + }; + + const closeDialog = () => setDialog(null); + + const saveDialog = async () => { + if (!dialog || savingDialog) return; + setSavingDialog(true); + setErr(''); + try { + const { kind, mode, id, draft } = dialog; + if (kind === 'profile') { + const r = await apiAdmin('/admin/profile', { + method: 'PUT', + body: JSON.stringify(draft) + }); + if (!r) return; + if (r.ok) { + flashOk('资料已保存'); + closeDialog(); + refresh(); + } else await flashErr(r); + return; + } + if (kind === 'project') { + if (!draft.title?.trim()) { + setErr('标题必填'); + return; + } + const body = projectBodyFromDraft(draft); + if (mode === 'create') { + const r = await apiAdmin('/admin/projects', { + method: 'POST', + body: JSON.stringify(body) + }); + if (!r) return; + if (r.ok) { + flashOk('项目已添加'); + closeDialog(); + refresh(); + } else await flashErr(r); + } else { + const r = await apiAdmin(`/admin/projects/${id}`, { + method: 'PUT', + body: JSON.stringify(body) + }); + if (!r) return; + if (r.ok) { + flashOk('项目已更新'); + closeDialog(); + refresh(); + } else await flashErr(r); + } + return; + } + if (kind === 'contact') { + if (!draft.type?.trim()) { + setErr('类型必填'); + return; + } + if (mode === 'create') { + const r = await apiAdmin('/admin/contacts', { + method: 'POST', + body: JSON.stringify(draft) + }); + if (!r) return; + if (r.ok) { + flashOk('联系方式已添加'); + closeDialog(); + refresh(); + } else await flashErr(r); + } else { + const r = await apiAdmin(`/admin/contacts/${id}`, { + method: 'PUT', + body: JSON.stringify(omitId(draft)) + }); + if (!r) return; + if (r.ok) { + flashOk('联系方式已更新'); + closeDialog(); + refresh(); + } else await flashErr(r); + } + return; + } + if (kind === 'tech') { + if (!draft.name?.trim()) { + setErr('名称必填'); + return; + } + if (mode === 'create') { + const r = await apiAdmin('/admin/techstack/items', { + method: 'POST', + body: JSON.stringify(omitId(draft)) + }); + if (!r) return; + if (r.ok) { + flashOk('技术栈项已添加'); + closeDialog(); + refresh(); + } else await flashErr(r); + } else { + const r = await apiAdmin(`/admin/techstack/items/${id}`, { + method: 'PUT', + body: JSON.stringify(omitId(draft)) + }); + if (!r) return; + if (r.ok) { + flashOk('已更新'); + closeDialog(); + refresh(); + } else await flashErr(r); + } + } + } finally { + setSavingDialog(false); + } + }; + + const saveTechTitle = async () => { + if (savingTitle) return; + setSavingTitle(true); + setErr(''); + try { + const r = await apiAdmin('/admin/techstack/title', { + method: 'PUT', + body: JSON.stringify({ title: techTitle }) + }); + if (!r) return; + if (r.ok) { + flashOk('板块标题已保存'); + refresh(); + } else await flashErr(r); + } finally { + setSavingTitle(false); + } + }; + + const delProject = async (pid) => { + if (!window.confirm('删除该项目?')) return; + const r = await apiAdmin(`/admin/projects/${pid}`, { method: 'DELETE' }); + if (!r) return; + if (r.ok) { + flashOk('已删除'); + refresh(); + } else flashErr(r); + }; + + const delContact = async (cid) => { + if (!window.confirm('删除该联系方式?')) return; + const r = await apiAdmin(`/admin/contacts/${cid}`, { method: 'DELETE' }); + if (!r) return; + if (r.ok) { + flashOk('已删除'); + refresh(); + } else flashErr(r); + }; + + const delTech = async (tid) => { + if (!window.confirm('删除该项?')) return; + const r = await apiAdmin(`/admin/techstack/items/${tid}`, { method: 'DELETE' }); + if (!r) return; + if (r.ok) { + flashOk('已删除'); + refresh(); + } else flashErr(r); + }; + + const logout = () => { + sessionStorage.removeItem(ADMIN_TOKEN_KEY); + navigate('/'); + }; + + const setTabAndClear = (k) => { + setTab(k); + setDialog(null); + setErr(''); + }; + + const dialogTitle = + dialog && + ({ + profile: '编辑资料', + project: dialog.mode === 'create' ? '添加项目' : '编辑项目', + contact: dialog.mode === 'create' ? '添加联系方式' : '编辑联系方式', + tech: dialog.mode === 'create' ? '添加技术栈项' : '编辑技术栈项' + }[dialog.kind]); + + const renderDialogFields = () => { + if (!dialog) return null; + const { kind, draft } = dialog; + if (kind === 'profile') { + return ( +
+ {[ + ['favicon', '网站图标 favicon URL'], + ['nickname', '昵称'], + ['avatar', '头像图片 URL'], + ['introduction', '个人简介'], + ['footer', '页脚版权文字'], + ['site', '站点 URL(可选,用于 favicon 等)'], + ['homepage', '主页链接(可选)'] + ].map(([field, label]) => ( + + + {field === 'introduction' ? ( +