diff --git a/config.yaml b/config.yaml index 20d8f2e..ecff253 100644 --- a/config.yaml +++ b/config.yaml @@ -39,7 +39,13 @@ upload: - image/jpeg - image/png - image/gif + - image/webp + - image/svg+xml + - image/x-icon + - image/bmp - video/mp4 + - video/webm + - application/pdf # ========== 存储配置 ========== # 支持 local / minio / cos 三种存储方式 diff --git a/internal/handler/dashboard.go b/internal/handler/dashboard.go index 045c0a6..7abb594 100644 --- a/internal/handler/dashboard.go +++ b/internal/handler/dashboard.go @@ -55,6 +55,62 @@ var tableLabels = map[string]string{ "music_categories": "音乐分类", } +// tablesWithSort 拥有 sort 列的表(用于排序) +var tablesWithSort = map[string]bool{ + "enterprise_services": true, + "enterprise_team": true, + "shop_products": true, + "shop_categories": true, + "food_restaurants": true, + "food_categories": true, + "food_menu_items": true, + "food_menu_categories": true, + "game_games": true, + "game_categories": true, + "app_apps": true, + "app_categories": true, + "music_songs": true, + "music_categories": true, +} + +// tablesWithName 拥有 name 列的表(用于关键词搜索) +var tablesWithName = map[string]bool{ + "enterprise_company": true, + "enterprise_team": true, + "enterprise_services": false, // 有 title 无 name + "enterprise_news": false, + "shop_products": true, + "shop_categories": true, + "food_restaurants": true, + "food_categories": true, + "food_menu_items": true, + "food_menu_categories": true, + "game_games": true, + "game_categories": true, + "app_apps": true, + "app_categories": true, + "music_songs": false, // 有 title 无 name + "music_categories": true, +} + +// tablesWithTitle 拥有 title 列的表(用于关键词搜索) +var tablesWithTitle = map[string]bool{ + "enterprise_news": true, + "enterprise_services": true, + "music_songs": true, +} + +// tablesWithStatus 拥有 status 列的表(用于状态过滤) +var tablesWithStatus = map[string]bool{ + "enterprise_news": true, + "shop_products": true, + "food_restaurants": true, + "food_menu_items": true, + "game_games": true, + "app_apps": true, + "music_songs": true, +} + // DashboardHandler 管理后台仪表盘处理器(通用 CRUD + 统计) type DashboardHandler struct { db *gorm.DB @@ -174,21 +230,36 @@ func (h *DashboardHandler) ListItems(c *gin.Context) { query := h.db.Model(modelType) - // 关键词搜索(按 name 或 title 字段) + // 关键词搜索(根据表实际拥有的列动态构建条件) if keyword != "" { - query = query.Where("name LIKE ? OR title LIKE ?", "%"+keyword+"%", "%"+keyword+"%") + hasName := tablesWithName[table] + hasTitle := tablesWithTitle[table] + switch { + case hasName && hasTitle: + query = query.Where("name LIKE ? OR title LIKE ?", "%"+keyword+"%", "%"+keyword+"%") + case hasName: + query = query.Where("name LIKE ?", "%"+keyword+"%") + case hasTitle: + query = query.Where("title LIKE ?", "%"+keyword+"%") + } } - // 状态过滤 - if status != "" { + // 状态过滤(仅对拥有 status 列的表生效) + if status != "" && tablesWithStatus[table] { query = query.Where("status = ?", status) } var total int64 query.Count(&total) + // 排序:有 sort 列的表按 sort ASC, id DESC;否则按 id DESC + orderClause := "id DESC" + if tablesWithSort[table] { + orderClause = "sort ASC, id DESC" + } + var results []map[string]interface{} - if err := query.Order("sort ASC, id DESC").Offset(offset).Limit(pageSize).Find(&results).Error; err != nil { + if err := query.Order(orderClause).Offset(offset).Limit(pageSize).Find(&results).Error; err != nil { FailInternal(c, "查询失败: "+err.Error()) return } diff --git a/internal/handler/upload.go b/internal/handler/upload.go index a9f33ce..4c09fda 100644 --- a/internal/handler/upload.go +++ b/internal/handler/upload.go @@ -16,8 +16,11 @@ func NewUploadHandler(uploadService *service.UploadService) *UploadHandler { return &UploadHandler{uploadService: uploadService} } -// Upload 文件上传 POST /api/v1/upload +// Upload 文件上传 POST /api/v1/upload?module=enterprise +// module 参数指定上传到哪个模块(enterprise/shop/food/game/app/music/common) func (h *UploadHandler) Upload(c *gin.Context) { + module := c.DefaultQuery("module", "common") + file, err := c.FormFile("file") if err != nil { FailBadRequest(c, "请选择要上传的文件") @@ -31,7 +34,7 @@ func (h *UploadHandler) Upload(c *gin.Context) { } defer src.Close() - url, err := h.uploadService.Upload(file.Filename, file.Header.Get("Content-Type"), file.Size, src) + url, err := h.uploadService.Upload(module, file.Filename, file.Header.Get("Content-Type"), file.Size, src) if err != nil { FailBadRequest(c, err.Error()) return @@ -39,3 +42,42 @@ func (h *UploadHandler) Upload(c *gin.Context) { Success(c, gin.H{"url": url}) } + +// ListFiles 文件列表 GET /api/v1/upload/list?module=enterprise +func (h *UploadHandler) ListFiles(c *gin.Context) { + module := c.DefaultQuery("module", "common") + + files, err := h.uploadService.ListFiles(module) + if err != nil { + FailInternal(c, "获取文件列表失败: "+err.Error()) + return + } + + Success(c, gin.H{ + "list": files, + "total": len(files), + }) +} + +// DeleteFile 删除文件 DELETE /api/v1/upload?module=enterprise&filename=xxx.jpg +func (h *UploadHandler) DeleteFile(c *gin.Context) { + module := c.DefaultQuery("module", "common") + filename := c.Query("filename") + if filename == "" { + FailBadRequest(c, "文件名不能为空") + return + } + + if err := h.uploadService.DeleteFile(module, filename); err != nil { + FailBadRequest(c, "删除失败: "+err.Error()) + return + } + + SuccessMsg(c, "删除成功") +} + +// GetModules 获取支持的模块列表 GET /api/v1/upload/modules +func (h *UploadHandler) GetModules(c *gin.Context) { + modules := h.uploadService.GetModules() + Success(c, gin.H{"modules": modules}) +} diff --git a/internal/router/router.go b/internal/router/router.go index fffbf6f..c763050 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -63,6 +63,9 @@ func Setup( }) }) + // 静态文件服务:上传的文件可通过 /uploads/* 访问 + r.Static("/uploads", cfg.Upload.Path) + // API v1 路由组 api := r.Group("/api/v1") { @@ -155,8 +158,11 @@ func Setup( user.PUT("/profile", userH.UpdateProfile) user.PUT("/password", userH.ChangePassword) } - // 文件上传 + // 文件上传(统一上传接口,支持按模块分类) authed.POST("/upload", uploadH.Upload) + authed.GET("/upload/list", uploadH.ListFiles) + authed.GET("/upload/modules", uploadH.GetModules) + authed.DELETE("/upload", uploadH.DeleteFile) } // 管理端路由(需要登录 + admin 角色) diff --git a/internal/service/upload.go b/internal/service/upload.go index b08e945..54106b0 100644 --- a/internal/service/upload.go +++ b/internal/service/upload.go @@ -3,7 +3,10 @@ package service import ( "fmt" "io" + "os" "path/filepath" + "sort" + "strings" "time" "gotest/internal/config" @@ -21,9 +24,21 @@ func NewUploadService(s storage.Storage, cfg *config.Config) *UploadService { return &UploadService{storage: s, cfg: cfg} } +// allowedModules 允许的模块列表 +var allowedModules = map[string]string{ + "enterprise": "enterprise", + "shop": "shop", + "food": "food", + "game": "game", + "app": "app", + "music": "music", + "common": "common", +} + // Upload 上传文件,返回访问 URL -// filename 原始文件名(用于提取扩展名),contentType MIME 类型,fileSize 文件大小(字节),reader 文件内容流 -func (s *UploadService) Upload(filename string, contentType string, fileSize int64, reader io.Reader) (string, error) { +// module 指定上传到的模块(enterprise/shop/food/game/app/music/common) +// filename 原始文件名,contentType MIME 类型,fileSize 文件大小,reader 文件内容流 +func (s *UploadService) Upload(module string, filename string, contentType string, fileSize int64, reader io.Reader) (string, error) { // 校验文件大小 if fileSize > s.cfg.Upload.MaxSize { return "", fmt.Errorf("文件大小超过限制(最大 %d 字节)", s.cfg.Upload.MaxSize) @@ -41,9 +56,15 @@ func (s *UploadService) Upload(filename string, contentType string, fileSize int return "", fmt.Errorf("不支持的文件类型: %s", contentType) } - // 生成存储 key:按日期分目录 + 时间戳文件名 + // 校验并规范化模块名 + mod := allowedModules[module] + if mod == "" { + mod = "common" + } + + // 生成存储 key:模块名/日期/时间戳文件名 ext := filepath.Ext(filename) - key := fmt.Sprintf("%s%s", time.Now().Format("2006/01/02/150405.000000"), ext) + key := fmt.Sprintf("%s/%s%s", mod, time.Now().Format("2006/01/02/150405.000000"), ext) url, err := s.storage.Upload(key, reader, contentType) if err != nil { @@ -52,3 +73,109 @@ func (s *UploadService) Upload(filename string, contentType string, fileSize int return url, nil } + +// FileInfo 文件信息 +type FileInfo struct { + Name string `json:"name"` + URL string `json:"url"` + Size int64 `json:"size"` + ModTime string `json:"mod_time"` + IsDir bool `json:"is_dir"` +} + +// ListFiles 列出指定模块下的所有文件(递归遍历子目录) +func (s *UploadService) ListFiles(module string) ([]FileInfo, error) { + mod := allowedModules[module] + if mod == "" { + mod = "common" + } + + dirPath := filepath.Join(s.cfg.Upload.Path, mod) + var result []FileInfo + + // 递归遍历 + err := filepath.Walk(dirPath, func(path string, info os.FileInfo, err error) error { + if err != nil { + return nil // 跳过错误 + } + if info.IsDir() { + return nil // 跳过目录 + } + // 计算相对路径作为 key + relPath, err := filepath.Rel(s.cfg.Upload.Path, path) + if err != nil { + return nil + } + relPath = filepath.ToSlash(relPath) // 统一用 / + url := s.storage.GetURL(relPath) + result = append(result, FileInfo{ + Name: info.Name(), + URL: url, + Size: info.Size(), + ModTime: info.ModTime().Format("2006-01-02 15:04:05"), + IsDir: false, + }) + return nil + }) + if err != nil { + if os.IsNotExist(err) { + return []FileInfo{}, nil + } + return nil, fmt.Errorf("读取目录失败: %w", err) + } + + // 按修改时间倒序 + sort.Slice(result, func(i, j int) bool { + return result[i].ModTime > result[j].ModTime + }) + + return result, nil +} + +// DeleteFile 删除指定模块下的文件 +// filename 可以是文件名或完整相对路径(如 2026/07/10/xxx.png) +func (s *UploadService) DeleteFile(module, filename string) error { + mod := allowedModules[module] + if mod == "" { + mod = "common" + } + + // 防止路径穿越:去掉模块名前缀(如果有的话)和 .. + cleanName := strings.ReplaceAll(filename, "..", "") + cleanName = strings.TrimPrefix(cleanName, mod+"/") + cleanName = strings.TrimPrefix(cleanName, mod) + + key := filepath.Join(mod, cleanName) + key = filepath.ToSlash(key) + + return s.storage.Delete(key) +} + +// GetModules 返回所有支持的模块 +func (s *UploadService) GetModules() []map[string]string { + return []map[string]string{ + {"key": "enterprise", "label": "企业官网"}, + {"key": "shop", "label": "跨境电商"}, + {"key": "food", "label": "外卖平台"}, + {"key": "game", "label": "游戏平台"}, + {"key": "app", "label": "应用商店"}, + {"key": "music", "label": "音乐平台"}, + {"key": "common", "label": "公共资源"}, + } +} + +// EnsureUploadDir 确保上传目录和各模块子目录存在 +func (s *UploadService) EnsureUploadDir() { + basePath := s.cfg.Upload.Path + for mod := range allowedModules { + os.MkdirAll(filepath.Join(basePath, mod), 0755) + } +} + +// sanitizeFilename 清理文件名中的危险字符 +func sanitizeFilename(name string) string { + name = strings.ReplaceAll(name, "..", "") + name = strings.ReplaceAll(name, "/", "_") + name = strings.ReplaceAll(name, "\\", "_") + return name +} diff --git a/main.go b/main.go index 5c3c623..e41cfa9 100644 --- a/main.go +++ b/main.go @@ -68,6 +68,7 @@ func main() { userService := service.NewUserService(userRepo) adminService := service.NewAdminService(userRepo) uploadService := service.NewUploadService(storageInstance, cfg) + uploadService.EnsureUploadDir() // 创建各模块子目录 enterpriseService := service.NewEnterpriseService(enterpriseRepo) shopService := service.NewShopService(shopRepo) foodService := service.NewFoodService(foodRepo) diff --git a/web/dist/admin/index.html b/web/dist/admin/index.html index f1551c3..5de3081 100644 --- a/web/dist/admin/index.html +++ b/web/dist/admin/index.html @@ -139,11 +139,61 @@ tbody tr:last-child td{border-bottom:none} .spinner{display:inline-block;width:32px;height:32px;border:3px solid var(--border);border-top-color:var(--primary);border-radius:50%;animation:spin .8s linear infinite} @keyframes spin{to{transform:rotate(360deg)}} -/* Content nav tabs */ -.content-tabs{display:flex;gap:4px;margin-bottom:16px;flex-wrap:wrap} -.content-tab{padding:8px 16px;border-radius:var(--radius);background:#fff;border:1px solid var(--border);cursor:pointer;font-size:13px;font-weight:600;transition:all .2s;color:var(--text-muted)} -.content-tab:hover{background:#f8fafc} -.content-tab.active{background:var(--primary);color:#fff;border-color:var(--primary)} +/* Content layout: sidebar + main */ +.content-layout{display:flex;gap:16px;align-items:flex-start} +.content-sidebar{width:220px;flex-shrink:0;background:var(--card-bg);border-radius:var(--radius);box-shadow:var(--shadow);overflow:hidden;position:sticky;top:80px} +.content-group{border-bottom:1px solid var(--border)} +.content-group:last-child{border-bottom:none} +.content-group-header{display:flex;align-items:center;gap:8px;padding:12px 16px;cursor:pointer;font-size:13px;font-weight:700;color:var(--text);transition:background .2s;user-select:none} +.content-group-header:hover{background:#f8fafc} +.content-group-header .arrow{margin-left:auto;transition:transform .25s;font-size:12px;color:var(--text-muted)} +.content-group.expanded .content-group-header .arrow{transform:rotate(90deg)} +.content-group-items{max-height:0;overflow:hidden;transition:max-height .3s ease} +.content-group.expanded .content-group-items{max-height:400px} +.content-item{padding:8px 16px 8px 40px;cursor:pointer;font-size:13px;color:var(--text-muted);transition:all .2s;border-left:3px solid transparent} +.content-item:hover{background:var(--primary-light);color:var(--primary)} +.content-item.active{background:var(--primary);color:#fff;border-left-color:#818cf8;font-weight:600} +.content-item .count{float:right;font-size:11px;opacity:.7} +.content-main{flex:1;min-width:0} + +/* Upload page */ +.upload-area{border:2px dashed var(--border);border-radius:12px;padding:40px;text-align:center;cursor:pointer;transition:all .2s;background:#fafbfc} +.upload-area:hover{border-color:var(--primary);background:var(--primary-light)} +.upload-area.dragover{border-color:var(--primary);background:var(--primary-light);transform:scale(1.01)} +.upload-area .upload-icon{font-size:48px;margin-bottom:12px} +.upload-area .upload-text{font-size:15px;color:var(--text-muted)} +.upload-area .upload-hint{font-size:12px;color:var(--text-muted);margin-top:8px} +.upload-formats{display:flex;gap:8px;justify-content:center;margin-top:12px} +.fmt-tag{display:inline-block;padding:3px 12px;border-radius:6px;font-size:12px;font-weight:700;letter-spacing:.5px} +.fmt-tag:nth-child(1){background:#fef3c7;color:#92400e} +.fmt-tag:nth-child(2){background:#dbeafe;color:#1e40af} +.fmt-tag:nth-child(3){background:#fce7f3;color:#9d174d} +.fmt-tag:nth-child(4){background:#d1fae5;color:#065f46} +.fmt-tag:nth-child(5){background:#ede9fe;color:#5b21b6} +.fmt-tag:nth-child(6){background:#ffedd5;color:#9a3412} +.fmt-tag:nth-child(7){background:#fee2e2;color:#991b1b} +.module-tabs{display:flex;gap:4px;margin-bottom:16px;flex-wrap:wrap} +.module-tab{padding:8px 16px;border-radius:var(--radius);background:#fff;border:1px solid var(--border);cursor:pointer;font-size:13px;font-weight:600;transition:all .2s;color:var(--text-muted)} +.module-tab:hover{background:#f8fafc} +.module-tab.active{background:var(--primary);color:#fff;border-color:var(--primary)} +.file-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(160px,1fr));gap:12px;margin-top:16px} +.file-card{background:var(--card-bg);border-radius:var(--radius);box-shadow:var(--shadow);overflow:hidden;transition:transform .2s,box-shadow .2s;position:relative} +.file-card:hover{transform:translateY(-2px);box-shadow:var(--shadow-lg)} +.file-card .file-preview{width:100%;height:120px;background:#f8fafc;display:flex;align-items:center;justify-content:center;overflow:hidden} +.file-card .file-preview img{width:100%;height:100%;object-fit:cover} +.file-card .file-preview .file-icon{font-size:36px} +.file-card .file-info{padding:8px 12px} +.file-card .file-name{font-size:12px;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.file-card .file-meta{font-size:11px;color:var(--text-muted);margin-top:2px} +.file-card .file-actions{position:absolute;top:4px;right:4px;display:flex;gap:4px;opacity:0;transition:opacity .2s} +.file-card:hover .file-actions{opacity:1} +.file-card .file-actions button{background:rgba(255,255,255,.9);border:none;border-radius:4px;width:28px;height:28px;cursor:pointer;font-size:14px;display:flex;align-items:center;justify-content:center} +.file-card .file-actions button:hover{background:#fff} +.file-card .copy-url{position:absolute;bottom:4px;right:4px;opacity:0;transition:opacity .2s} +.file-card:hover .copy-url{opacity:1} +.upload-progress{margin-top:12px} +.upload-progress-bar{height:6px;background:var(--border);border-radius:3px;overflow:hidden} +.upload-progress-fill{height:100%;background:var(--primary);transition:width .3s} /* Image cell */ .cell-img{width:40px;height:40px;border-radius:6px;object-fit:cover} @@ -190,10 +240,6 @@ tbody tr:last-child td{border-bottom:none}

Gotest