diff --git a/.gitignore b/.gitignore index 44bdec8..7b0ad03 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,8 @@ works/ .env .env.* !.env.example +!.env.production.example +!.env.development.example !.env.sample *.pem *.key diff --git a/README.md b/README.md index c7e9809..eb02491 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,19 @@ ### 📦 快速开始 +### 🔧 环境变量(本地 / 生产) + +为避免将真实配置提交到 Git,请在本地创建 `*.local` 文件,生产环境单独配置: + +**后端(Go)** +- 本地开发:`SproutWorkCollect-Backend-Golang/.env.local` +- 生产环境:`SproutWorkCollect-Backend-Golang/.env.production.local` + - 启动时会自动读取上述文件(存在则生效) + +**前端(React)** +- 本地开发:`SproutWorkCollect-Frontend/.env.local` +- 生产环境:`SproutWorkCollect-Frontend/.env.production.local` + #### 方式一:使用批处理文件(Windows) 1. **启动后端服务** @@ -124,9 +137,9 @@ #### 公共API - `GET /api/settings` - 获取网站设置 -- `GET /api/works` - 获取所有作品 +- `GET /api/works` - 获取作品ID列表(支持分页:`page` / `page_size`) - `GET /api/works/{work_id}` - 获取作品详情 -- `GET /api/search` - 搜索作品 +- `GET /api/search` - 搜索作品ID列表(支持分页:`page` / `page_size`) - `GET /api/categories` - 获取分类 - `POST /api/like/{work_id}` - 点赞作品 @@ -162,6 +175,8 @@ - `platform/` - 各平台文件目录 - `video/` - 作品视频目录(可选) +说明:`作品截图` / `作品视频` 支持填写本地文件名或 `http/https` 外链。 + #### work_config.json 示例 ```json @@ -169,6 +184,7 @@ "作品ID": "example", "作品作品": "示例作品", "作品描述": "这是一个示例作品的描述", + "更新公告": "新增演示视频与下载镜像", "作者": "树萌芽", "作品版本号": "1.0.0", "作品分类": "工具", diff --git a/SproutWorkCollect-Backend-Golang/docker-compose.yml b/SproutWorkCollect-Backend-Golang/docker-compose.yml index 3589305..66b16f7 100644 --- a/SproutWorkCollect-Backend-Golang/docker-compose.yml +++ b/SproutWorkCollect-Backend-Golang/docker-compose.yml @@ -13,7 +13,7 @@ services: - PORT=5000 - SPROUTWORKCOLLECT_DATA_DIR=/data # Override the default admin token (strongly recommended in production): - # - ADMIN_TOKEN=your_secure_token_here + - ADMIN_TOKEN=shumengya520 # Enable verbose Gin logging (set to 1 for debugging): # - GIN_DEBUG=0 volumes: diff --git a/SproutWorkCollect-Backend-Golang/internal/config/config.go b/SproutWorkCollect-Backend-Golang/internal/config/config.go index 8f47bbf..57e399e 100644 --- a/SproutWorkCollect-Backend-Golang/internal/config/config.go +++ b/SproutWorkCollect-Backend-Golang/internal/config/config.go @@ -22,6 +22,8 @@ type Config struct { // 2. SPROUTWORKCOLLECT_DATA_DIR / DATA_DIR (data root, works/ and config/ appended) // 3. ./data/works and ./data/config (relative to current working directory) func Load() *Config { + loadDotEnv() + cfg := &Config{ Port: 5000, // Do not commit real admin tokens; override via ADMIN_TOKEN / SPROUTWORKCOLLECT_ADMIN_TOKEN. diff --git a/SproutWorkCollect-Backend-Golang/internal/config/dotenv.go b/SproutWorkCollect-Backend-Golang/internal/config/dotenv.go new file mode 100644 index 0000000..fc78715 --- /dev/null +++ b/SproutWorkCollect-Backend-Golang/internal/config/dotenv.go @@ -0,0 +1,60 @@ +package config + +import ( + "os" + "strings" +) + +func loadDotEnv() { + filename := ".env.local" + if isProductionEnv() { + filename = ".env.production.local" + } + + loadDotEnvFile(filename) +} + +func isProductionEnv() bool { + for _, key := range []string{"SPROUTWORKCOLLECT_ENV", "ENV", "GIN_MODE"} { + if v := strings.ToLower(strings.TrimSpace(os.Getenv(key))); v != "" { + return v == "production" || v == "prod" || v == "release" + } + } + return false +} + +func loadDotEnvFile(filename string) { + data, err := os.ReadFile(filename) + if err != nil { + return + } + + lines := strings.Split(string(data), "\n") + for _, line := range lines { + trimmed := strings.TrimSpace(line) + if trimmed == "" || strings.HasPrefix(trimmed, "#") { + continue + } + if strings.HasPrefix(trimmed, "export ") { + trimmed = strings.TrimSpace(strings.TrimPrefix(trimmed, "export ")) + } + key, value, ok := strings.Cut(trimmed, "=") + if !ok { + continue + } + key = strings.TrimSpace(key) + value = strings.TrimSpace(value) + if key == "" { + continue + } + if len(value) >= 2 { + if (value[0] == '"' && value[len(value)-1] == '"') || (value[0] == '\'' && value[len(value)-1] == '\'') { + value = value[1 : len(value)-1] + } + } + if _, exists := os.LookupEnv(key); exists { + continue + } + _ = os.Setenv(key, value) + } +} diff --git a/SproutWorkCollect-Backend-Golang/internal/handler/public.go b/SproutWorkCollect-Backend-Golang/internal/handler/public.go index 0031d6b..0d53424 100644 --- a/SproutWorkCollect-Backend-Golang/internal/handler/public.go +++ b/SproutWorkCollect-Backend-Golang/internal/handler/public.go @@ -2,9 +2,11 @@ package handler import ( "net/http" + "strconv" "github.com/gin-gonic/gin" + "sproutworkcollect-backend/internal/model" "sproutworkcollect-backend/internal/service" ) @@ -46,16 +48,26 @@ func (h *PublicHandler) GetWorks(c *gin.Context) { return } - responses := make([]any, len(works)) - for i, w := range works { - responses[i] = h.workSvc.BuildResponse(w) + page, pageSize, paged := resolvePageParams(c) + total := len(works) + worksPage := paginateWorks(works, page, pageSize) + + ids := make([]string, len(worksPage)) + for i, w := range worksPage { + ids[i] = w.WorkID } - c.JSON(http.StatusOK, gin.H{ + resp := gin.H{ "success": true, - "data": responses, - "total": len(responses), - }) + "data": ids, + "total": total, + } + if paged { + resp["page"] = page + resp["page_size"] = pageSize + resp["total_pages"] = calcTotalPages(total, pageSize) + } + c.JSON(http.StatusOK, resp) } // GetWorkDetail handles GET /api/works/:work_id @@ -91,16 +103,90 @@ func (h *PublicHandler) SearchWorks(c *gin.Context) { return } - responses := make([]any, len(works)) - for i, w := range works { - responses[i] = h.workSvc.BuildResponse(w) + page, pageSize, paged := resolvePageParams(c) + total := len(works) + worksPage := paginateWorks(works, page, pageSize) + + ids := make([]string, len(worksPage)) + for i, w := range worksPage { + ids[i] = w.WorkID } - c.JSON(http.StatusOK, gin.H{ + resp := gin.H{ "success": true, - "data": responses, - "total": len(responses), - }) + "data": ids, + "total": total, + } + if paged { + resp["page"] = page + resp["page_size"] = pageSize + resp["total_pages"] = calcTotalPages(total, pageSize) + } + c.JSON(http.StatusOK, resp) +} + +func resolvePageParams(c *gin.Context) (int, int, bool) { + page, hasPage := parsePositiveInt(c, "page") + pageSize, hasSize := parsePositiveInt(c, "page_size") + if !hasSize { + pageSize, hasSize = parsePositiveInt(c, "pageSize") + } + if !hasSize { + pageSize, hasSize = parsePositiveInt(c, "limit") + } + if !hasPage && !hasSize { + return 0, 0, false + } + if page <= 0 { + page = 1 + } + if pageSize <= 0 { + pageSize = 12 + } + if pageSize > 200 { + pageSize = 200 + } + return page, pageSize, true +} + +func parsePositiveInt(c *gin.Context, key string) (int, bool) { + raw, ok := c.GetQuery(key) + if !ok { + return 0, false + } + val, err := strconv.Atoi(raw) + if err != nil { + return 0, false + } + return val, true +} + +func paginateWorks(works []*model.WorkConfig, page, pageSize int) []*model.WorkConfig { + if pageSize <= 0 { + return works + } + if page <= 0 { + page = 1 + } + start := (page - 1) * pageSize + if start >= len(works) { + return []*model.WorkConfig{} + } + end := start + pageSize + if end > len(works) { + end = len(works) + } + return works[start:end] +} + +func calcTotalPages(total, pageSize int) int { + if pageSize <= 0 { + return 1 + } + if total == 0 { + return 0 + } + return (total + pageSize - 1) / pageSize } // GetCategories handles GET /api/categories diff --git a/SproutWorkCollect-Backend-Golang/internal/model/work.go b/SproutWorkCollect-Backend-Golang/internal/model/work.go index e6eb5fe..4be9487 100644 --- a/SproutWorkCollect-Backend-Golang/internal/model/work.go +++ b/SproutWorkCollect-Backend-Golang/internal/model/work.go @@ -7,6 +7,7 @@ type WorkConfig struct { WorkID string `json:"作品ID"` WorkName string `json:"作品作品"` WorkDesc string `json:"作品描述"` + UpdateNotice string `json:"更新公告,omitempty"` Author string `json:"作者"` Version string `json:"作品版本号"` Category string `json:"作品分类"` diff --git a/SproutWorkCollect-Backend-Golang/internal/router/router.go b/SproutWorkCollect-Backend-Golang/internal/router/router.go index d18127d..f82a1e1 100644 --- a/SproutWorkCollect-Backend-Golang/internal/router/router.go +++ b/SproutWorkCollect-Backend-Golang/internal/router/router.go @@ -52,6 +52,14 @@ func Setup(cfg *config.Config) *gin.Engine { admin := handler.NewAdminHandler(workSvc) // ─── Public routes ──────────────────────────────────────────────────────── + r.GET("/", func(c *gin.Context) { + c.JSON(200, gin.H{ + "service": "SproutWorkCollect API", + "version": "v1", + "endpoints": []string{"/api/settings", "/api/works", "/api/works/{work_id}", "/api/search", "/api/categories", "/api/like/{work_id}"}, + }) + }) + api := r.Group("/api") { api.GET("/settings", pub.GetSettings) diff --git a/SproutWorkCollect-Backend-Golang/internal/service/work_service.go b/SproutWorkCollect-Backend-Golang/internal/service/work_service.go index d0de6ea..36ad5aa 100644 --- a/SproutWorkCollect-Backend-Golang/internal/service/work_service.go +++ b/SproutWorkCollect-Backend-Golang/internal/service/work_service.go @@ -35,6 +35,11 @@ func (s *WorkService) WorkExists(workID string) bool { return err == nil } +func isExternalURL(value string) bool { + trimmed := strings.TrimSpace(value) + return strings.HasPrefix(trimmed, "http://") || strings.HasPrefix(trimmed, "https://") +} + // ─── Read operations ────────────────────────────────────────────────────────── // LoadWork loads and returns a single work config from disk (read-locked). @@ -191,14 +196,22 @@ func (s *WorkService) BuildResponse(w *model.WorkConfig) *model.WorkResponse { if len(w.Screenshots) > 0 { resp.ImageLinks = make([]string, len(w.Screenshots)) for i, img := range w.Screenshots { - resp.ImageLinks[i] = fmt.Sprintf("/api/image/%s/%s", w.WorkID, img) + if isExternalURL(img) { + resp.ImageLinks[i] = strings.TrimSpace(img) + } else { + resp.ImageLinks[i] = fmt.Sprintf("/api/image/%s/%s", w.WorkID, img) + } } } if len(w.VideoFiles) > 0 { resp.VideoLinks = make([]string, len(w.VideoFiles)) for i, vid := range w.VideoFiles { - resp.VideoLinks[i] = fmt.Sprintf("/api/video/%s/%s", w.WorkID, vid) + if isExternalURL(vid) { + resp.VideoLinks[i] = strings.TrimSpace(vid) + } else { + resp.VideoLinks[i] = fmt.Sprintf("/api/video/%s/%s", w.WorkID, vid) + } } } diff --git a/SproutWorkCollect-Backend-Python/.dockerignore b/SproutWorkCollect-Backend-Python/.dockerignore deleted file mode 100644 index 3816e6a..0000000 --- a/SproutWorkCollect-Backend-Python/.dockerignore +++ /dev/null @@ -1,48 +0,0 @@ -# Git -.git -.gitignore - -# Node -node_modules -npm-debug.log - -# Python -__pycache__ -*.pyc -*.pyo -*.pyd -.Python -*.egg-info -dist -build - -# IDE -.vscode -.idea -*.swp -*.swo -*~ - -# 环境变量 -.env -.env.local -.env.backup -**/.env.local -**/.env.production.local -**/.env.development.local - -# 日志 -*.log -logs - -# 测试 -test -*.test.js - -# 临时文件 -*.tmp -.DS_Store -Thumbs.db - -# README -README.md diff --git a/SproutWorkCollect-Backend-Python/Dockerfile b/SproutWorkCollect-Backend-Python/Dockerfile deleted file mode 100644 index bdecf6e..0000000 --- a/SproutWorkCollect-Backend-Python/Dockerfile +++ /dev/null @@ -1,29 +0,0 @@ -# 后端 Dockerfile -FROM python:3.11-slim - -WORKDIR /app/backend - -ENV PYTHONUNBUFFERED=1 - -# 安装系统依赖 -RUN apt-get update && apt-get install -y \ - gcc \ - && rm -rf /var/lib/apt/lists/* - -# 复制依赖文件 -COPY requirements.txt . - -# 安装 Python 依赖 -RUN pip install --no-cache-dir -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple - -# 复制后端代码 -COPY . . - -# 默认数据目录(可在运行时通过挂载覆盖) -RUN mkdir -p /data/works /data/config - -# 暴露端口 -EXPOSE 5000 - -# 启动后端服务 -CMD ["python", "app.py"] diff --git a/SproutWorkCollect-Backend-Python/app.py b/SproutWorkCollect-Backend-Python/app.py deleted file mode 100644 index 9e29035..0000000 --- a/SproutWorkCollect-Backend-Python/app.py +++ /dev/null @@ -1,838 +0,0 @@ -from flask import Flask, jsonify, send_from_directory, request -from flask_cors import CORS -import json -import os -import shutil -import hashlib -import time -import logging -from datetime import datetime, timedelta -from werkzeug.utils import secure_filename -import tempfile -import re -import unicodedata - -import string -import math -import calendar - - -# 配置日志 -logging.basicConfig(level=logging.INFO) -logger = logging.getLogger(__name__) - -app = Flask(__name__) -CORS(app) # 允许跨域请求 - -# 设置上传文件大小限制为5000MB -app.config['MAX_CONTENT_LENGTH'] = 5000 * 1024 * 1024 # 5000MB - -# 优化Flask配置以处理大文件 -app.config['SEND_FILE_MAX_AGE_DEFAULT'] = 0 -app.config['MAX_FORM_MEMORY_SIZE'] = 1024 * 1024 * 1024 # 1GB -app.config['MAX_FORM_PARTS'] = 1000 - -# 获取项目根目录 -BASE_DIR = os.path.dirname(os.path.abspath(__file__)) - -def _normalize_dir_path(path): - if not path: - return None - - expanded = os.path.expandvars(os.path.expanduser(path)) - return os.path.abspath(expanded) - -# 数据目录(用于 Docker 持久化)。 -# 推荐在容器内设置 SPROUTWORKCOLLECT_DATA_DIR=/data,并挂载宿主机目录到 /data -DATA_DIR = _normalize_dir_path( - os.environ.get('SPROUTWORKCOLLECT_DATA_DIR') or os.environ.get('DATA_DIR') -) - -DEFAULT_WORKS_DIR = os.path.join(DATA_DIR, 'works') if DATA_DIR else os.path.join(BASE_DIR, 'works') -DEFAULT_CONFIG_DIR = os.path.join(DATA_DIR, 'config') if DATA_DIR else os.path.join(BASE_DIR, 'config') - -# works/config 目录(可分别覆盖) -WORKS_DIR = ( - _normalize_dir_path(os.environ.get('SPROUTWORKCOLLECT_WORKS_DIR') or os.environ.get('WORKS_DIR')) - or DEFAULT_WORKS_DIR -) -CONFIG_DIR = ( - _normalize_dir_path(os.environ.get('SPROUTWORKCOLLECT_CONFIG_DIR') or os.environ.get('CONFIG_DIR')) - or DEFAULT_CONFIG_DIR -) - -# 管理员 token(不要硬编码到仓库中;请通过环境变量配置) -ADMIN_TOKEN = ( - os.environ.get('SPROUTWORKCOLLECT_ADMIN_TOKEN') - or os.environ.get('ADMIN_TOKEN') - or '' -).strip() - -# 允许的文件扩展名 -ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif', 'mp4', 'avi', 'mov', 'zip', 'rar', 'apk', 'exe', 'dmg'} - -# 防刷机制:存储用户操作记录 -# 格式: {user_fingerprint: {action_type: {work_id: last_action_time}}} -user_actions = {} - -# 防刷时间间隔(秒) -RATE_LIMITS = { - 'view': 60, # 浏览:1分钟内同一用户同一作品只能计数一次 - 'download': 300, # 下载:5分钟内同一用户同一作品只能计数一次 - 'like': 3600 # 点赞:1小时内同一用户同一作品只能计数一次 -} - -def allowed_file(filename): - return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS - -def safe_filename(filename): - """ - 安全处理文件名,支持中文字符 - """ - if not filename: - return '' - - # 保留原始文件名用于显示 - original_name = filename - - # 规范化Unicode字符 - filename = unicodedata.normalize('NFKC', filename) - - # 移除或替换危险字符,但保留中文、英文、数字、点、下划线、连字符 - # 允许的字符:中文字符、英文字母、数字、点、下划线、连字符、空格 - safe_chars = re.sub(r'[^\w\s\-_.\u4e00-\u9fff]', '', filename) - - # 将多个空格替换为单个下划线 - safe_chars = re.sub(r'\s+', '_', safe_chars) - - # 移除开头和结尾的点和空格 - safe_chars = safe_chars.strip('. ') - - # 确保文件名不为空 - if not safe_chars: - return 'unnamed_file' - - # 限制文件名长度(不包括扩展名) - name_part, ext_part = os.path.splitext(safe_chars) - if len(name_part.encode('utf-8')) > 200: # 限制为200字节 - # 截断文件名但保持完整的字符 - name_bytes = name_part.encode('utf-8')[:200] - # 确保不会截断中文字符 - try: - name_part = name_bytes.decode('utf-8') - except UnicodeDecodeError: - # 如果截断位置在中文字符中间,向前查找完整字符 - for i in range(len(name_bytes) - 1, -1, -1): - try: - name_part = name_bytes[:i].decode('utf-8') - break - except UnicodeDecodeError: - continue - - return name_part + ext_part - -def verify_admin_token(): - """验证管理员token""" - token = request.args.get('token') or request.headers.get('Authorization') - return token == ADMIN_TOKEN - -def get_user_fingerprint(): - """生成用户指纹,用于防刷""" - # 使用IP地址和User-Agent生成指纹 - ip = request.environ.get('HTTP_X_FORWARDED_FOR', request.environ.get('REMOTE_ADDR', '')) - user_agent = request.headers.get('User-Agent', '') - fingerprint_string = f"{ip}:{user_agent}" - return hashlib.md5(fingerprint_string.encode()).hexdigest() - -def can_perform_action(action_type, work_id): - """检查用户是否可以执行某个操作(防刷检查)""" - fingerprint = get_user_fingerprint() - current_time = time.time() - - # 如果用户从未记录过,允许操作 - if fingerprint not in user_actions: - user_actions[fingerprint] = {} - - if action_type not in user_actions[fingerprint]: - user_actions[fingerprint][action_type] = {} - - # 检查这个作品的上次操作时间 - last_action_time = user_actions[fingerprint][action_type].get(work_id, 0) - time_diff = current_time - last_action_time - - # 如果时间间隔足够,允许操作 - if time_diff >= RATE_LIMITS.get(action_type, 0): - user_actions[fingerprint][action_type][work_id] = current_time - return True - - return False - -def update_work_stats(work_id, stat_type, increment=1): - """更新作品统计数据""" - work_dir = os.path.join(WORKS_DIR, work_id) - config_path = os.path.join(work_dir, 'work_config.json') - - if not os.path.exists(config_path): - return False - - try: - with open(config_path, 'r', encoding='utf-8') as f: - config = json.load(f) - - # 确保统计字段存在 - stat_fields = ['作品下载量', '作品浏览量', '作品点赞量', '作品更新次数'] - for field in stat_fields: - if field not in config: - config[field] = 0 - - # 更新指定统计数据 - if stat_type in config: - config[stat_type] += increment - config['更新时间'] = datetime.now().isoformat() - - with open(config_path, 'w', encoding='utf-8') as f: - json.dump(config, f, ensure_ascii=False, indent=2) - - return True - except Exception as e: - print(f"更新统计数据失败: {e}") - return False - - return False - -#加载网站设置 -def load_settings(): - """加载网站设置""" - settings_path = os.path.join(CONFIG_DIR, 'settings.json') - try: - with open(settings_path, 'r', encoding='utf-8') as f: - return json.load(f) - except FileNotFoundError: - return { - "网站名字": "✨ 树萌芽の作品集 ✨", - "网站描述": "🎨 展示个人制作的一些小创意和小项目,欢迎交流讨论 💬", - "站长": "👨‍💻 by-树萌芽", - "联系邮箱": "3205788256@qq.com", - "主题颜色": "#81c784", - "每页作品数量": 6, - "启用搜索": True, - "启用分类": True, - "备案号": "📄 蜀ICP备2025151694号", - "网站页尾": "🌱 树萌芽の作品集 | Copyright © 2025-2025 smy ✨", - "网站logo": "assets/logo.png" - } - -#加载单个作品配置 -def load_work_config(work_id): - """加载单个作品配置""" - work_path = os.path.join(WORKS_DIR, work_id, 'work_config.json') - try: - with open(work_path, 'r', encoding='utf-8') as f: - config = json.load(f) - # 添加下载链接 - config['下载链接'] = {} - if '支持平台' in config and '文件名称' in config: - for platform in config['支持平台']: - if platform in config['文件名称']: - files = config['文件名称'][platform] - config['下载链接'][platform] = [ - f"/api/download/{work_id}/{platform}/{file}" - for file in files - ] - - # 添加图片链接 - if '作品截图' in config: - config['图片链接'] = [ - f"/api/image/{work_id}/{img}" - for img in config['作品截图'] - ] - - # 添加视频链接 - if '作品视频' in config: - config['视频链接'] = [ - f"/api/video/{work_id}/{video}" - for video in config['作品视频'] - ] - - return config - except FileNotFoundError: - return None - - -#==============================公开API接口=============================== -#获取所有作品 -def get_all_works(): - """获取所有作品""" - works = [] - if not os.path.exists(WORKS_DIR): - return works - - for work_id in os.listdir(WORKS_DIR): - work_dir = os.path.join(WORKS_DIR, work_id) - if os.path.isdir(work_dir): - config = load_work_config(work_id) - if config: - works.append(config) - - # 按更新时间排序 - works.sort(key=lambda x: x.get('更新时间', ''), reverse=True) - return works - -#获取网站设置 -@app.route('/api/settings') -def get_settings(): - """获取网站设置""" - return jsonify(load_settings()) - -#获取所有作品列表 -@app.route('/api/works') -def get_works(): - """获取所有作品列表""" - works = get_all_works() - return jsonify({ - 'success': True, - 'data': works, - 'total': len(works) - }) - -#获取单个作品详情 -@app.route('/api/works/') -def get_work_detail(work_id): - """获取单个作品详情""" - config = load_work_config(work_id) - if config: - # 增加浏览量(防刷检查) - if can_perform_action('view', work_id): - update_work_stats(work_id, '作品浏览量') - # 重新加载配置获取最新数据 - config = load_work_config(work_id) - - return jsonify({ - 'success': True, - 'data': config - }) - else: - return jsonify({ - 'success': False, - 'message': '作品不存在' - }), 404 - -#提供作品图片 -@app.route('/api/image//') -def serve_image(work_id, filename): - """提供作品图片""" - image_dir = os.path.join(WORKS_DIR, work_id, 'image') - if os.path.exists(os.path.join(image_dir, filename)): - return send_from_directory(image_dir, filename) - return jsonify({'error': '图片不存在'}), 404 - -#提供作品视频 -@app.route('/api/video//') -def serve_video(work_id, filename): - """提供作品视频""" - video_dir = os.path.join(WORKS_DIR, work_id, 'video') - if os.path.exists(os.path.join(video_dir, filename)): - return send_from_directory(video_dir, filename) - return jsonify({'error': '视频不存在'}), 404 - -#提供作品下载 -@app.route('/api/download///') -def download_file(work_id, platform, filename): - """提供作品下载""" - platform_dir = os.path.join(WORKS_DIR, work_id, 'platform', platform) - if os.path.exists(os.path.join(platform_dir, filename)): - # 增加下载量(防刷检查) - if can_perform_action('download', work_id): - update_work_stats(work_id, '作品下载量') - - return send_from_directory(platform_dir, filename, as_attachment=True) - return jsonify({'error': '文件不存在'}), 404 - -#搜索作品 -@app.route('/api/search') -def search_works(): - """搜索作品""" - from flask import request - query = request.args.get('q', '').lower() - category = request.args.get('category', '') - - works = get_all_works() - - if query: - filtered_works = [] - for work in works: - # 在作品名称、描述、标签中搜索 - if (query in work.get('作品作品', '').lower() or - query in work.get('作品描述', '').lower() or - any(query in tag.lower() for tag in work.get('作品标签', []))): - filtered_works.append(work) - works = filtered_works - - if category: - works = [work for work in works if work.get('作品分类', '') == category] - - return jsonify({ - 'success': True, - 'data': works, - 'total': len(works) - }) - -#获取所有分类 -@app.route('/api/categories') -def get_categories(): - """获取所有分类""" - works = get_all_works() - categories = list(set(work.get('作品分类', '') for work in works if work.get('作品分类'))) - return jsonify({ - 'success': True, - 'data': categories - }) - -@app.route('/api/like/', methods=['POST']) -def like_work(work_id): - """点赞作品""" - # 检查作品是否存在 - config = load_work_config(work_id) - if not config: - return jsonify({ - 'success': False, - 'message': '作品不存在' - }), 404 - - # 防刷检查 - if not can_perform_action('like', work_id): - return jsonify({ - 'success': False, - 'message': '操作太频繁,请稍后再试' - }), 429 - - # 增加点赞量 - if update_work_stats(work_id, '作品点赞量'): - # 获取最新的点赞数 - updated_config = load_work_config(work_id) - return jsonify({ - 'success': True, - 'message': '点赞成功', - 'likes': updated_config.get('作品点赞量', 0) - }) - else: - return jsonify({ - 'success': False, - 'message': '点赞失败' - }), 500 -#==============================公开API接口=============================== - - - -# =========================================== -# 管理员API接口 -# =========================================== - -@app.route('/api/admin/works', methods=['GET']) -def admin_get_works(): - """管理员获取所有作品(包含更多详细信息)""" - if not verify_admin_token(): - return jsonify({'success': False, 'message': '权限不足'}), 403 - - works = get_all_works() - return jsonify({ - 'success': True, - 'data': works, - 'total': len(works) - }) - -@app.route('/api/admin/works/', methods=['PUT']) -def admin_update_work(work_id): - """管理员更新作品信息""" - if not verify_admin_token(): - return jsonify({'success': False, 'message': '权限不足'}), 403 - - try: - data = request.get_json() - work_dir = os.path.join(WORKS_DIR, work_id) - config_path = os.path.join(work_dir, 'work_config.json') - - if not os.path.exists(config_path): - return jsonify({'success': False, 'message': '作品不存在'}), 404 - - # 读取现有配置获取当前统计数据 - with open(config_path, 'r', encoding='utf-8') as f: - current_config = json.load(f) - - # 确保统计字段存在并保持原值 - stat_fields = ['作品下载量', '作品浏览量', '作品点赞量', '作品更新次数'] - for field in stat_fields: - if field not in data: - data[field] = current_config.get(field, 0) - - # 更新时间和更新次数 - data['更新时间'] = datetime.now().isoformat() - data['作品更新次数'] = current_config.get('作品更新次数', 0) + 1 - - # 保存配置文件 - with open(config_path, 'w', encoding='utf-8') as f: - json.dump(data, f, ensure_ascii=False, indent=2) - - return jsonify({'success': True, 'message': '更新成功'}) - - except Exception as e: - return jsonify({'success': False, 'message': f'更新失败: {str(e)}'}), 500 - -@app.route('/api/admin/works/', methods=['DELETE']) -def admin_delete_work(work_id): - """管理员删除作品""" - if not verify_admin_token(): - return jsonify({'success': False, 'message': '权限不足'}), 403 - - try: - work_dir = os.path.join(WORKS_DIR, work_id) - - if not os.path.exists(work_dir): - return jsonify({'success': False, 'message': '作品不存在'}), 404 - - # 删除整个作品目录 - shutil.rmtree(work_dir) - - return jsonify({'success': True, 'message': '删除成功'}) - - except Exception as e: - return jsonify({'success': False, 'message': f'删除失败: {str(e)}'}), 500 - -@app.route('/api/admin/works', methods=['POST']) -def admin_create_work(): - """管理员创建新作品""" - if not verify_admin_token(): - return jsonify({'success': False, 'message': '权限不足'}), 403 - - try: - data = request.get_json() - work_id = data.get('作品ID') - - if not work_id: - return jsonify({'success': False, 'message': '作品ID不能为空'}), 400 - - work_dir = os.path.join(WORKS_DIR, work_id) - - # 检查作品是否已存在 - if os.path.exists(work_dir): - return jsonify({'success': False, 'message': '作品ID已存在'}), 409 - - # 创建作品目录结构 - os.makedirs(work_dir, exist_ok=True) - os.makedirs(os.path.join(work_dir, 'image'), exist_ok=True) - os.makedirs(os.path.join(work_dir, 'video'), exist_ok=True) - os.makedirs(os.path.join(work_dir, 'platform'), exist_ok=True) - - # 创建平台子目录 - platforms = data.get('支持平台', []) - for platform in platforms: - platform_dir = os.path.join(work_dir, 'platform', platform) - os.makedirs(platform_dir, exist_ok=True) - - # 设置默认值 - current_time = datetime.now().isoformat() - config = { - '作品ID': work_id, - '作品作品': data.get('作品作品', ''), - '作品描述': data.get('作品描述', ''), - '作者': data.get('作者', '树萌芽'), - '作品版本号': data.get('作品版本号', '1.0.0'), - '作品分类': data.get('作品分类', '其他'), - '作品标签': data.get('作品标签', []), - '上传时间': current_time, - '更新时间': current_time, - '支持平台': platforms, - '文件名称': {}, - '作品截图': [], - '作品视频': [], - '作品封面': '', - '作品下载量': 0, - '作品浏览量': 0, - '作品点赞量': 0, - '作品更新次数': 0 - } - - # 保存配置文件 - config_path = os.path.join(work_dir, 'work_config.json') - with open(config_path, 'w', encoding='utf-8') as f: - json.dump(config, f, ensure_ascii=False, indent=2) - - return jsonify({'success': True, 'message': '创建成功', 'work_id': work_id}) - - except Exception as e: - return jsonify({'success': False, 'message': f'创建失败: {str(e)}'}), 500 - -@app.route('/api/admin/upload//', methods=['POST']) -def admin_upload_file(work_id, file_type): - """管理员上传文件(优化大文件处理)""" - if not verify_admin_token(): - return jsonify({'success': False, 'message': '权限不足'}), 403 - - temp_file_path = None - try: - logger.info(f"开始上传文件 - 作品ID: {work_id}, 文件类型: {file_type}") - - work_dir = os.path.join(WORKS_DIR, work_id) - if not os.path.exists(work_dir): - logger.error(f"作品目录不存在: {work_dir}") - return jsonify({'success': False, 'message': '作品不存在'}), 404 - - if 'file' not in request.files: - logger.error("请求中没有文件") - return jsonify({'success': False, 'message': '没有文件'}), 400 - - file = request.files['file'] - if file.filename == '': - logger.error("没有选择文件") - return jsonify({'success': False, 'message': '没有选择文件'}), 400 - - # 保存原始文件名(包含中文) - original_filename = file.filename - logger.info(f"原始文件名: {original_filename}") - - # 检查文件格式 - if not allowed_file(original_filename): - logger.error(f"不支持的文件格式: {original_filename}") - return jsonify({'success': False, 'message': '不支持的文件格式'}), 400 - - # 使用安全的文件名处理函数 - safe_original_filename = safe_filename(original_filename) - file_extension = safe_original_filename.rsplit('.', 1)[1].lower() if '.' in safe_original_filename else 'unknown' - - logger.info(f"安全处理后的文件名: {safe_original_filename}") - - # 读取现有配置来生成新文件名 - config_path = os.path.join(work_dir, 'work_config.json') - if not os.path.exists(config_path): - logger.error(f"配置文件不存在: {config_path}") - return jsonify({'success': False, 'message': '作品配置不存在'}), 404 - - with open(config_path, 'r', encoding='utf-8') as f: - config = json.load(f) - - # 根据文件类型确定保存目录和文件名 - if file_type == 'image': - save_dir = os.path.join(work_dir, 'image') - existing_images = config.get('作品截图', []) - - # 尝试使用原始文件名,如果重复则添加序号 - base_name = safe_original_filename - filename = base_name - counter = 1 - while filename in existing_images: - name_part, ext_part = os.path.splitext(base_name) - filename = f"{name_part}_{counter}{ext_part}" - counter += 1 - - elif file_type == 'video': - save_dir = os.path.join(work_dir, 'video') - existing_videos = config.get('作品视频', []) - - # 尝试使用原始文件名,如果重复则添加序号 - base_name = safe_original_filename - filename = base_name - counter = 1 - while filename in existing_videos: - name_part, ext_part = os.path.splitext(base_name) - filename = f"{name_part}_{counter}{ext_part}" - counter += 1 - - elif file_type == 'platform': - platform = request.form.get('platform') - if not platform: - logger.error("平台参数缺失") - return jsonify({'success': False, 'message': '平台参数缺失'}), 400 - save_dir = os.path.join(work_dir, 'platform', platform) - - # 对于平台文件,也尝试保留原始文件名 - existing_files = config.get('文件名称', {}).get(platform, []) - base_name = safe_original_filename - filename = base_name - counter = 1 - while filename in existing_files: - name_part, ext_part = os.path.splitext(base_name) - filename = f"{name_part}_{counter}{ext_part}" - counter += 1 - else: - logger.error(f"不支持的文件类型: {file_type}") - return jsonify({'success': False, 'message': '不支持的文件类型'}), 400 - - # 确保目录存在 - os.makedirs(save_dir, exist_ok=True) - final_file_path = os.path.join(save_dir, filename) - - logger.info(f"目标文件路径: {final_file_path}") - - # 使用临时文件进行流式保存,避免内存溢出 - with tempfile.NamedTemporaryFile(delete=False) as temp_file: - temp_file_path = temp_file.name - logger.info(f"临时文件路径: {temp_file_path}") - - # 分块读取和写入文件,减少内存使用 - chunk_size = 8192 # 8KB chunks - total_size = 0 - - while True: - chunk = file.stream.read(chunk_size) - if not chunk: - break - temp_file.write(chunk) - total_size += len(chunk) - - # 检查文件大小 - max_size = 5000 * 1024 * 1024 # 5000MB - if total_size > max_size: - logger.error(f"文件太大: {total_size} bytes") - return jsonify({ - 'success': False, - 'message': f'文件太大,最大支持 {max_size // (1024*1024)}MB,当前文件大小:{total_size // (1024*1024)}MB' - }), 413 - - logger.info(f"文件写入临时文件完成,总大小: {total_size} bytes") - - # 移动临时文件到最终位置 - shutil.move(temp_file_path, final_file_path) - temp_file_path = None # 标记已移动,避免重复删除 - - logger.info(f"文件移动到最终位置完成: {final_file_path}") - - # 更新配置文件 - if file_type == 'image': - if filename not in config.get('作品截图', []): - config.setdefault('作品截图', []).append(filename) - # 记录原始文件名映射 - config.setdefault('原始文件名', {}) - config['原始文件名'][filename] = original_filename - if not config.get('作品封面'): - config['作品封面'] = filename - elif file_type == 'video': - if filename not in config.get('作品视频', []): - config.setdefault('作品视频', []).append(filename) - # 记录原始文件名映射 - config.setdefault('原始文件名', {}) - config['原始文件名'][filename] = original_filename - elif file_type == 'platform': - platform = request.form.get('platform') - config.setdefault('文件名称', {}).setdefault(platform, []) - if filename not in config['文件名称'][platform]: - config['文件名称'][platform].append(filename) - # 记录原始文件名映射 - config.setdefault('原始文件名', {}) - config['原始文件名'][filename] = original_filename - - config['更新时间'] = datetime.now().isoformat() - - # 原子性更新配置文件 - temp_config_path = config_path + '.tmp' - try: - with open(temp_config_path, 'w', encoding='utf-8') as f: - json.dump(config, f, ensure_ascii=False, indent=2) - shutil.move(temp_config_path, config_path) - except Exception as e: - # 清理临时配置文件 - if os.path.exists(temp_config_path): - os.remove(temp_config_path) - raise e - - logger.info(f"文件上传成功: {filename}, 大小: {total_size} bytes") - - return jsonify({ - 'success': True, - 'message': '上传成功', - 'filename': filename, - 'file_size': total_size - }) - - except Exception as e: - # 清理临时文件 - if temp_file_path and os.path.exists(temp_file_path): - try: - os.remove(temp_file_path) - except: - pass - - logger.error(f"文件上传错误: {str(e)}") - logger.error(f"错误类型: {type(e)}") - import traceback - traceback.print_exc() - - # 特殊处理文件大小超限错误 - if 'Request Entity Too Large' in str(e) or 'exceeded maximum allowed payload' in str(e): - return jsonify({ - 'success': False, - 'message': '文件太大,请选择小于5000MB的文件' - }), 413 - - return jsonify({'success': False, 'message': f'上传失败: {str(e)}'}), 500 - -@app.route('/api/admin/delete-file///', methods=['DELETE']) -def admin_delete_file(work_id, file_type, filename): - """管理员删除文件""" - if not verify_admin_token(): - return jsonify({'success': False, 'message': '权限不足'}), 403 - - try: - work_dir = os.path.join(WORKS_DIR, work_id) - config_path = os.path.join(work_dir, 'work_config.json') - - if not os.path.exists(config_path): - return jsonify({'success': False, 'message': '作品不存在'}), 404 - - # 确定文件路径 - if file_type == 'image': - file_path = os.path.join(work_dir, 'image', filename) - elif file_type == 'video': - file_path = os.path.join(work_dir, 'video', filename) - elif file_type == 'platform': - platform = request.args.get('platform') - if not platform: - return jsonify({'success': False, 'message': '平台参数缺失'}), 400 - file_path = os.path.join(work_dir, 'platform', platform, filename) - else: - return jsonify({'success': False, 'message': '不支持的文件类型'}), 400 - - # 删除文件 - if os.path.exists(file_path): - os.remove(file_path) - - # 更新配置文件 - with open(config_path, 'r', encoding='utf-8') as f: - config = json.load(f) - - if file_type == 'image': - if filename in config.get('作品截图', []): - config['作品截图'].remove(filename) - # 清理原始文件名映射 - if '原始文件名' in config and filename in config['原始文件名']: - del config['原始文件名'][filename] - if config.get('作品封面') == filename: - config['作品封面'] = config['作品截图'][0] if config['作品截图'] else '' - elif file_type == 'video': - if filename in config.get('作品视频', []): - config['作品视频'].remove(filename) - # 清理原始文件名映射 - if '原始文件名' in config and filename in config['原始文件名']: - del config['原始文件名'][filename] - elif file_type == 'platform': - platform = request.args.get('platform') - if platform in config.get('文件名称', {}): - if filename in config['文件名称'][platform]: - config['文件名称'][platform].remove(filename) - # 清理原始文件名映射 - if '原始文件名' in config and filename in config['原始文件名']: - del config['原始文件名'][filename] - - config['更新时间'] = datetime.now().isoformat() - - with open(config_path, 'w', encoding='utf-8') as f: - json.dump(config, f, ensure_ascii=False, indent=2) - - return jsonify({'success': True, 'message': '删除成功'}) - - except Exception as e: - return jsonify({'success': False, 'message': f'删除失败: {str(e)}'}), 500 - -if __name__ == '__main__': - port = int(os.environ.get('PORT', '5000')) - debug = os.environ.get('FLASK_DEBUG', '').lower() in {'1', 'true', 'yes'} - app.run(debug=debug, host='0.0.0.0', port=port) diff --git a/SproutWorkCollect-Backend-Python/docker-compose.yml b/SproutWorkCollect-Backend-Python/docker-compose.yml deleted file mode 100644 index e4c45f3..0000000 --- a/SproutWorkCollect-Backend-Python/docker-compose.yml +++ /dev/null @@ -1,24 +0,0 @@ -version: '3.8' - -services: - sproutworkcollect-api: - build: - context: . - dockerfile: Dockerfile - container_name: sproutworkcollect-backend - restart: unless-stopped - ports: - - "${SPROUTWORKCOLLECT_PORT:-5000}:5000" - environment: - - PORT=5000 - - SPROUTWORKCOLLECT_DATA_DIR=/data - volumes: - # 默认后端持久化路径:/shumengya/docker/sproutworkcollect/data/ - - "${SPROUTWORKCOLLECT_DATA_PATH:-/shumengya/docker/sproutworkcollect/data}/works:/data/works" - - "${SPROUTWORKCOLLECT_DATA_PATH:-/shumengya/docker/sproutworkcollect/data}/config:/data/config" - healthcheck: - test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:5000/api/settings').read()"] - interval: 30s - timeout: 10s - retries: 3 - start_period: 40s diff --git a/SproutWorkCollect-Backend-Python/requirements.txt b/SproutWorkCollect-Backend-Python/requirements.txt deleted file mode 100644 index 9a83813..0000000 --- a/SproutWorkCollect-Backend-Python/requirements.txt +++ /dev/null @@ -1,8 +0,0 @@ -Flask==3.0.0 -Flask-CORS==4.0.0 -Werkzeug==3.0.1 -Jinja2==3.1.2 -MarkupSafe==2.1.3 -itsdangerous==2.1.2 -click==8.1.7 -blinker==1.7.0 diff --git a/SproutWorkCollect-Backend-Python/后端返回接口.json b/SproutWorkCollect-Backend-Python/后端返回接口.json deleted file mode 100644 index a81e43f..0000000 --- a/SproutWorkCollect-Backend-Python/后端返回接口.json +++ /dev/null @@ -1,167 +0,0 @@ -{ - "data": [ - { - "上传时间": "2025-01-01T00:00:00", - "下载链接": { - "Android": [ - "/api/download/aicodevartool/Android/aicodevartool_android.zip" - ], - "Linux": [ - "/api/download/aicodevartool/Linux/aicodevartool_linux.zip" - ], - "Windows": [ - "/api/download/aicodevartool/Windows/aicodevartool_windows.zip" - ] - }, - "作品ID": "aicodevartool", - "作品作品": "AI代码变量工具", - "作品分类": "开发工具", - "作品封面": "image1.jpg", - "作品截图": [ - "image1.jpg", - "image2.jpg", - "image3.jpg" - ], - "作品描述": "一个强大的AI辅助代码变量命名和管理工具,帮助开发者提高编程效率", - "作品标签": [ - "AI", - "开发工具", - "代码助手", - "原创" - ], - "作品版本号": "1.0.0", - "作品视频": [], - "作者": "树萌芽", - "图片链接": [ - "/api/image/aicodevartool/image1.jpg", - "/api/image/aicodevartool/image2.jpg", - "/api/image/aicodevartool/image3.jpg" - ], - "支持平台": [ - "Windows", - "Android", - "Linux" - ], - "文件名称": { - "Android": [ - "aicodevartool_android.zip" - ], - "Linux": [ - "aicodevartool_linux.zip" - ], - "Windows": [ - "aicodevartool_windows.zip" - ] - }, - "更新时间": "2025-01-01T00:00:00", - "视频链接": [] - }, - { - "上传时间": "2024-12-15T00:00:00", - "下载链接": { - "Android": [ - "/api/download/mengyafarm/Android/mengyafarm_android.apk" - ], - "Windows": [ - "/api/download/mengyafarm/Windows/mengyafarm_windows.zip" - ] - }, - "作品ID": "mengyafarm", - "作品作品": "萌芽农场", - "作品分类": "游戏", - "作品封面": "image1.png", - "作品截图": [ - "image1.png", - "image2.png", - "image3.png", - "image4.png", - "image5.png", - "image6.png" - ], - "作品描述": "一款可爱的模拟经营类游戏,体验种植的乐趣,建设属于你的梦想农场", - "作品标签": [ - "模拟经营", - "农场", - "休闲游戏", - "原创" - ], - "作品版本号": "2.1.0", - "作品视频": [], - "作者": "树萌芽", - "图片链接": [ - "/api/image/mengyafarm/image1.png", - "/api/image/mengyafarm/image2.png", - "/api/image/mengyafarm/image3.png", - "/api/image/mengyafarm/image4.png", - "/api/image/mengyafarm/image5.png", - "/api/image/mengyafarm/image6.png" - ], - "支持平台": [ - "Windows", - "Android" - ], - "文件名称": { - "Android": [ - "mengyafarm_android.apk" - ], - "Windows": [ - "mengyafarm_windows.zip" - ] - }, - "更新时间": "2025-01-01T00:00:00", - "视频链接": [] - }, - { - "上传时间": "2025-01-01T00:00:00", - "下载链接": { - "Windows": [ - "/api/download/mml_cgj2025/Windows/mml_cgj2025_windows.zip" - ] - }, - "作品ID": "mml_cgj2025", - "作品作品": "MML创意游戏大赛2025", - "作品分类": "游戏", - "作品封面": "image1.jpg", - "作品截图": [ - "image1.jpg", - "image2.jpg", - "image3.jpg", - "image4.jpg", - "image5.jpg" - ], - "作品描述": "参加2025年MML创意游戏大赛的参赛作品,展现独特的游戏创意和技术实力", - "作品标签": [ - "比赛作品", - "创意游戏", - "2025", - "原创" - ], - "作品版本号": "1.0.0", - "作品视频": [ - "video1.mp4" - ], - "作者": "树萌芽", - "图片链接": [ - "/api/image/mml_cgj2025/image1.jpg", - "/api/image/mml_cgj2025/image2.jpg", - "/api/image/mml_cgj2025/image3.jpg", - "/api/image/mml_cgj2025/image4.jpg", - "/api/image/mml_cgj2025/image5.jpg" - ], - "支持平台": [ - "Windows" - ], - "文件名称": { - "Windows": [ - "mml_cgj2025_windows.zip" - ] - }, - "更新时间": "2025-01-01T00:00:00", - "视频链接": [ - "/api/video/mml_cgj2025/video1.mp4" - ] - } - ], - "success": true, - "total": 3 - } \ No newline at end of file diff --git a/SproutWorkCollect-Frontend/package.json b/SproutWorkCollect-Frontend/package.json index a6c3ede..bac11a1 100644 --- a/SproutWorkCollect-Frontend/package.json +++ b/SproutWorkCollect-Frontend/package.json @@ -15,6 +15,7 @@ "web-vitals": "^2.1.4" }, "scripts": { + "dev": "react-scripts start", "start": "react-scripts start", "build": "react-scripts build", "test": "react-scripts test", diff --git a/SproutWorkCollect-Frontend/public/assets/icon.png b/SproutWorkCollect-Frontend/public/assets/icon.png new file mode 100644 index 0000000..e6205dc Binary files /dev/null and b/SproutWorkCollect-Frontend/public/assets/icon.png differ diff --git a/SproutWorkCollect-Frontend/src/App.js b/SproutWorkCollect-Frontend/src/App.js index 8061fee..443f623 100644 --- a/SproutWorkCollect-Frontend/src/App.js +++ b/SproutWorkCollect-Frontend/src/App.js @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useRef } from 'react'; import { HashRouter as Router, Routes, Route } from 'react-router-dom'; import styled from 'styled-components'; import Header from './components/Header'; @@ -10,7 +10,7 @@ import CategoryFilter from './components/CategoryFilter'; import LoadingSpinner from './components/LoadingSpinner'; import Footer from './components/Footer'; import Pagination from './components/Pagination'; -import { getWorks, getSettings, getCategories, searchWorks } from './services/api'; +import { getWorks, getSettings, getCategories, searchWorks, getWorkDetail } from './services/api'; import { BACKGROUND_CONFIG, pickBackgroundImage } from './config/background'; const AppContainer = styled.div` @@ -107,12 +107,13 @@ const NoResults = styled.div` // 首页组件 const HomePage = ({ settings }) => { const [works, setWorks] = useState([]); - const [allWorks, setAllWorks] = useState([]); // 存储所有作品数据 + const [totalWorks, setTotalWorks] = useState(0); const [categories, setCategories] = useState([]); const [loading, setLoading] = useState(true); const [searchQuery, setSearchQuery] = useState(''); const [selectedCategory, setSelectedCategory] = useState(''); const [currentPage, setCurrentPage] = useState(1); + const pageSizeInitRef = useRef(false); // 从设置中获取每页作品数量,默认12(三行四列) const itemsPerPage = settings['每页作品数量'] || 12; @@ -121,17 +122,44 @@ const HomePage = ({ settings }) => { loadInitialData(); }, []); + useEffect(() => { + if (!pageSizeInitRef.current) { + pageSizeInitRef.current = true; + return; + } + setCurrentPage(1); + performSearch(searchQuery, selectedCategory, 1); + }, [itemsPerPage]); + + const fetchWorksByIds = async (ids) => { + if (!Array.isArray(ids) || ids.length === 0) return []; + const results = await Promise.all( + ids.map(async (id) => { + try { + const detail = await getWorkDetail(id); + return detail?.data || null; + } catch (error) { + console.error('加载作品详情失败:', id, error); + return null; + } + }) + ); + return results.filter(Boolean); + }; + const loadInitialData = async () => { try { setLoading(true); const [worksData, categoriesData] = await Promise.all([ - getWorks(), + getWorks(1, itemsPerPage), getCategories() ]); - - const allWorksData = worksData.data || []; - setAllWorks(allWorksData); - setWorks(allWorksData); + const rawData = worksData.data || []; + const resolvedWorks = Array.isArray(rawData) && typeof rawData[0] === 'string' + ? await fetchWorksByIds(rawData) + : rawData; + setWorks(resolvedWorks); + setTotalWorks(worksData.total || 0); setCategories(categoriesData.data || []); setCurrentPage(1); // 重置到第一页 } catch (error) { @@ -143,27 +171,36 @@ const HomePage = ({ settings }) => { const handleSearch = async (query) => { setSearchQuery(query); - await performSearch(query, selectedCategory); + setCurrentPage(1); + await performSearch(query, selectedCategory, 1); }; const handleCategoryChange = async (category) => { setSelectedCategory(category); - await performSearch(searchQuery, category); + setCurrentPage(1); + await performSearch(searchQuery, category, 1); }; - const performSearch = async (query, category) => { + const performSearch = async (query, category, page) => { try { setLoading(true); if (query || category) { - const searchData = await searchWorks(query, category); - setAllWorks(searchData.data || []); - setWorks(searchData.data || []); + const searchData = await searchWorks(query, category, page, itemsPerPage); + const rawData = searchData.data || []; + const resolvedWorks = Array.isArray(rawData) && typeof rawData[0] === 'string' + ? await fetchWorksByIds(rawData) + : rawData; + setWorks(resolvedWorks); + setTotalWorks(searchData.total || 0); } else { - const worksData = await getWorks(); - setAllWorks(worksData.data || []); - setWorks(worksData.data || []); + const worksData = await getWorks(page, itemsPerPage); + const rawData = worksData.data || []; + const resolvedWorks = Array.isArray(rawData) && typeof rawData[0] === 'string' + ? await fetchWorksByIds(rawData) + : rawData; + setWorks(resolvedWorks); + setTotalWorks(worksData.total || 0); } - setCurrentPage(1); // 搜索后重置到第一页 } catch (error) { console.error('搜索失败:', error); } finally { @@ -172,14 +209,13 @@ const HomePage = ({ settings }) => { }; // 分页相关的计算 - const totalPages = Math.ceil(works.length / itemsPerPage); - const startIndex = (currentPage - 1) * itemsPerPage; - const endIndex = startIndex + itemsPerPage; - const currentWorks = works.slice(startIndex, endIndex); + const totalPages = Math.ceil(totalWorks / itemsPerPage); + const currentWorks = works; // 处理页面变化 const handlePageChange = (page) => { setCurrentPage(page); + performSearch(searchQuery, selectedCategory, page); // 滚动到顶部 window.scrollTo({ top: 0, behavior: 'smooth' }); }; @@ -207,7 +243,7 @@ const HomePage = ({ settings }) => { diff --git a/SproutWorkCollect-Frontend/src/components/AdminPanel.js b/SproutWorkCollect-Frontend/src/components/AdminPanel.js index fc593cc..d8141b1 100644 --- a/SproutWorkCollect-Frontend/src/components/AdminPanel.js +++ b/SproutWorkCollect-Frontend/src/components/AdminPanel.js @@ -272,7 +272,7 @@ const AdminPanel = () => { + 添加新作品 diff --git a/SproutWorkCollect-Frontend/src/components/Pagination.js b/SproutWorkCollect-Frontend/src/components/Pagination.js index bd76c4d..dc5d6ca 100644 --- a/SproutWorkCollect-Frontend/src/components/Pagination.js +++ b/SproutWorkCollect-Frontend/src/components/Pagination.js @@ -124,7 +124,7 @@ const Pagination = ({ onClick={() => handlePageClick(currentPage - 1)} disabled={currentPage === 1} > - ← 上一页 + 上一页 {/* 页码按钮 */} @@ -147,7 +147,7 @@ const Pagination = ({ onClick={() => handlePageClick(currentPage + 1)} disabled={currentPage === totalPages} > - 下一页 → + 下一页 {/* 页面信息 */} diff --git a/SproutWorkCollect-Frontend/src/components/WorkCard.js b/SproutWorkCollect-Frontend/src/components/WorkCard.js index 82d9151..70c33b9 100644 --- a/SproutWorkCollect-Frontend/src/components/WorkCard.js +++ b/SproutWorkCollect-Frontend/src/components/WorkCard.js @@ -1,7 +1,7 @@ import React from 'react'; import { useNavigate } from 'react-router-dom'; import styled from 'styled-components'; -import { getApiOrigin } from '../config/apiBase'; +import { getApiOrigin, resolveMediaUrl, isAbsoluteUrl } from '../config/apiBase'; import { getGlassOpacity } from '../config/background'; const glassOpacity = getGlassOpacity(); @@ -160,16 +160,23 @@ const WorkCard = ({ work }) => { }; const getCoverImage = () => { + if (work.作品封面 && isAbsoluteUrl(work.作品封面)) { + return work.作品封面; + } if (work.作品封面 && work.图片链接) { const coverIndex = work.作品截图?.indexOf(work.作品封面); if (coverIndex >= 0 && work.图片链接[coverIndex]) { - return `${getApiOrigin()}${work.图片链接[coverIndex]}`; + return resolveMediaUrl(work.图片链接[coverIndex]); } } return null; }; const handleCardClick = () => { + if (!work?.作品ID) { + console.warn('作品ID缺失,无法进入详情页', work); + return; + } navigate(`/work/${work.作品ID}`); }; @@ -244,7 +251,7 @@ const WorkCard = ({ work }) => { - 🌟 点击查看详情 → + 点击查看详情 diff --git a/SproutWorkCollect-Frontend/src/components/WorkDetail.js b/SproutWorkCollect-Frontend/src/components/WorkDetail.js index 9976064..adc91ba 100644 --- a/SproutWorkCollect-Frontend/src/components/WorkDetail.js +++ b/SproutWorkCollect-Frontend/src/components/WorkDetail.js @@ -3,13 +3,17 @@ import { useParams, useNavigate } from 'react-router-dom'; import styled from 'styled-components'; import LoadingSpinner from './LoadingSpinner'; import { getWorkDetail, likeWork } from '../services/api'; -import { getApiOrigin } from '../config/apiBase'; +import { getApiOrigin, resolveMediaUrl } from '../config/apiBase'; const DetailContainer = styled.div` - max-width: 1000px; + max-width: 1200px; margin: 0 auto; padding: 20px; min-height: 100vh; + + @media (min-width: 1400px) { + max-width: 1320px; + } @media (max-width: 768px) { padding: 10px; @@ -104,6 +108,18 @@ const WorkDescription = styled.p` } `; +const UpdateNotice = styled.div` + background: #f7f7f7; + border: 1px solid #eee; + border-radius: 10px; + padding: 12px 14px; + color: #333; + font-size: 0.95rem; + line-height: 1.6; + margin: -8px 0 20px 0; + white-space: pre-line; +`; + const TagsContainer = styled.div` display: flex; flex-wrap: wrap; @@ -312,6 +328,7 @@ const StatCard = styled.div` const StatIcon = styled.div` font-size: 1.5rem; margin-bottom: 8px; + color: #111; @media (max-width: 768px) { font-size: 1.2rem; @@ -319,6 +336,66 @@ const StatIcon = styled.div` } `; +const StatSvg = styled.svg` + width: 22px; + height: 22px; + display: block; + margin: 0 auto; +`; + +const IconEye = () => ( + +); + +const IconDownload = () => ( + +); + +const IconHeart = () => ( + +); + +const IconRefresh = () => ( + +); + const StatValue = styled.div` font-size: 1.4rem; font-weight: 700; @@ -514,6 +591,11 @@ const WorkDetail = () => { const [modalTitle, setModalTitle] = useState(''); useEffect(() => { + if (!workId || workId === 'undefined') { + setLoading(false); + setError('作品ID无效'); + return; + } loadWorkDetail(); }, [workId]); @@ -547,7 +629,7 @@ const WorkDetail = () => { // 打开图片模态框 const handleImageClick = (imageUrl, index) => { setModalType('image'); - setModalSrc(`${getApiOrigin()}${imageUrl}`); + setModalSrc(resolveMediaUrl(imageUrl)); setModalTitle(`${work.作品作品} - 截图 ${index + 1}`); setModalOpen(true); }; @@ -555,7 +637,7 @@ const WorkDetail = () => { // 打开视频模态框 const handleVideoClick = (videoUrl, index) => { setModalType('video'); - setModalSrc(`${getApiOrigin()}${videoUrl}`); + setModalSrc(resolveMediaUrl(videoUrl)); setModalTitle(`${work.作品作品} - 视频 ${index + 1}`); setModalOpen(true); }; @@ -634,7 +716,7 @@ const WorkDetail = () => { return ( navigate('/')}> - 返回首页 + 返回 {error} @@ -645,7 +727,7 @@ const WorkDetail = () => { return ( navigate('/')}> - 返回首页 + 返回 作品不存在 @@ -655,7 +737,7 @@ const WorkDetail = () => { return ( navigate('/')}> - ← 返回首页 + 返回 @@ -680,6 +762,9 @@ const WorkDetail = () => { {work.作品描述} + {work.更新公告 && work.更新公告.trim() && ( + {work.更新公告} + )} {work.作品标签 && work.作品标签.length > 0 && ( @@ -700,22 +785,22 @@ const WorkDetail = () => { {/* 统计数据 */} - 👁️‍🗨️ + {work.作品浏览量 || 0} 浏览量 - 📥 + {work.作品下载量 || 0} 下载量 - 💖 + {work.作品点赞量 || 0} 点赞量 - 🔄 + {work.作品更新次数 || 0} 更新次数 @@ -732,7 +817,7 @@ const WorkDetail = () => { }} > 💖 - {liking ? '💫 点赞中...' : '点赞作品'} + {liking ? '💫 点赞中...' : '点赞'} {likeMessage && (
{ onClick={() => handleVideoClick(videoUrl, index)} style={{ cursor: 'pointer' }} > - + 您的浏览器不支持视频播放 @@ -826,7 +911,7 @@ const WorkDetail = () => { {work.图片链接.map((imageUrl, index) => ( handleImageClick(imageUrl, index)} onError={(e) => { diff --git a/SproutWorkCollect-Frontend/src/components/WorkEditor.js b/SproutWorkCollect-Frontend/src/components/WorkEditor.js index 49d884b..b129d76 100644 --- a/SproutWorkCollect-Frontend/src/components/WorkEditor.js +++ b/SproutWorkCollect-Frontend/src/components/WorkEditor.js @@ -2,7 +2,7 @@ import React, { useState, useEffect } from 'react'; import styled from 'styled-components'; import { adminCreateWork, adminUpdateWork, adminUploadFile, adminDeleteFile } from '../services/adminApi'; import UploadProgressModal from './UploadProgressModal'; -import { getApiOrigin } from '../config/apiBase'; +import { getApiOrigin, isAbsoluteUrl } from '../config/apiBase'; const EditorContainer = styled.div` max-width: 1000px; @@ -449,6 +449,7 @@ const WorkEditor = ({ work, onClose }) => { 作品ID: '', 作品作品: '', 作品描述: '', + 更新公告: '', 作者: '树萌芽', 作品版本号: '1.0.0', 作品分类: '其他', @@ -469,6 +470,8 @@ const WorkEditor = ({ work, onClose }) => { const [uploading, setUploading] = useState(false); const [showUploadModal, setShowUploadModal] = useState(false); const [newExternalLinks, setNewExternalLinks] = useState({}); + const [newImageUrl, setNewImageUrl] = useState(''); + const [newVideoUrl, setNewVideoUrl] = useState(''); const platforms = ['Windows', 'Android', 'Linux', 'iOS', 'macOS']; const categories = ['游戏', '工具', '应用', '网站', '其他']; @@ -477,6 +480,7 @@ const WorkEditor = ({ work, onClose }) => { if (work) { setFormData({ ...work, + 更新公告: work.更新公告 || '', 作品标签: work.作品标签 || [], 支持平台: work.支持平台 || [], 作品截图: work.作品截图 || [], @@ -558,6 +562,48 @@ const WorkEditor = ({ work, onClose }) => { setSuccess(`已添加 ${platform} 外部下载链接`); }; + const handleAddExternalImage = () => { + const url = newImageUrl.trim(); + if (!url) { + setError('图片链接不能为空'); + return; + } + if (!isAbsoluteUrl(url)) { + setError('图片链接需要以 http 或 https 开头'); + return; + } + if (formData.作品截图.includes(url)) { + setError('该图片链接已存在'); + return; + } + const next = [...formData.作品截图, url]; + handleInputChange('作品截图', next); + if (!formData.作品封面) { + handleInputChange('作品封面', url); + } + setNewImageUrl(''); + setSuccess('外链图片已添加'); + }; + + const handleAddExternalVideo = () => { + const url = newVideoUrl.trim(); + if (!url) { + setError('视频链接不能为空'); + return; + } + if (!isAbsoluteUrl(url)) { + setError('视频链接需要以 http 或 https 开头'); + return; + } + if (formData.作品视频.includes(url)) { + setError('该视频链接已存在'); + return; + } + handleInputChange('作品视频', [...formData.作品视频, url]); + setNewVideoUrl(''); + setSuccess('外链视频已添加'); + }; + const handleDeleteExternalLink = (platform, index) => { const list = Array.isArray(formData.外部下载?.[platform]) ? formData.外部下载[platform] : []; const next = { ...(formData.外部下载 || {}) }; @@ -702,6 +748,20 @@ const WorkEditor = ({ work, onClose }) => { return; } + if ((fileType === 'image' || fileType === 'video') && isAbsoluteUrl(filename)) { + if (fileType === 'image') { + const newImages = formData.作品截图.filter(img => img !== filename); + handleInputChange('作品截图', newImages); + if (formData.作品封面 === filename) { + handleInputChange('作品封面', newImages[0] || ''); + } + } else { + handleInputChange('作品视频', formData.作品视频.filter(video => video !== filename)); + } + setSuccess(`外链已移除: ${filename}`); + return; + } + setLoading(true); try { const response = await adminDeleteFile(formData.作品ID, fileType, filename, platform); @@ -769,12 +829,6 @@ const WorkEditor = ({ work, onClose }) => { if (response.success) { setSuccess(work ? '作品更新成功' : '作品创建成功'); - // 如果是创建新作品,不要自动关闭,让用户可以继续上传文件 - if (work) { - setTimeout(() => { - onClose(); - }, 1500); - } } else { setError(response.message); } @@ -879,6 +933,17 @@ const WorkEditor = ({ work, onClose }) => { /> + + +