From 9d4eb3827298f09df361e9bfee1e709b6e62c546 Mon Sep 17 00:00:00 2001 From: Kerwin Date: Fri, 15 Aug 2025 18:41:09 +0800 Subject: [PATCH] =?UTF-8?q?add:=20=E6=96=B0=E5=A2=9E=E6=96=87=E4=BB=B6?= =?UTF-8?q?=E4=B8=8A=E4=BC=A0=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- db/connection.go | 2 + db/converter/file_converter.go | 53 +++ db/dto/file.go | 70 ++++ db/entity/file.go | 44 +++ db/entity/system_config_constants.go | 4 +- db/repo/file_repository.go | 144 +++++++ db/repo/manager.go | 2 + env.example | 2 +- handlers/file_handler.go | 382 ++++++++++++++++++ main.go | 20 + nginx/conf.d/default.conf | 9 + web/components.d.ts | 7 + web/components/Admin/NewSidebar.vue | 45 ++- web/components/FileUpload.vue | 235 +++++++++++ web/composables/useFileApi.ts | 36 ++ web/config/adminNewNavigation.ts | 16 +- web/layouts/admin.vue | 10 +- web/pages/admin/files.vue | 563 +++++++++++++++++++++++++++ 18 files changed, 1633 insertions(+), 11 deletions(-) create mode 100644 db/converter/file_converter.go create mode 100644 db/dto/file.go create mode 100644 db/entity/file.go create mode 100644 db/repo/file_repository.go create mode 100644 handlers/file_handler.go create mode 100644 web/components/FileUpload.vue create mode 100644 web/composables/useFileApi.ts create mode 100644 web/pages/admin/files.vue diff --git a/db/connection.go b/db/connection.go index 61717b5..873755a 100644 --- a/db/connection.go +++ b/db/connection.go @@ -81,6 +81,7 @@ func InitDB() error { &entity.ResourceView{}, &entity.Task{}, &entity.TaskItem{}, + &entity.File{}, ) if err != nil { utils.Fatal("数据库迁移失败: %v", err) @@ -144,6 +145,7 @@ func autoMigrate() error { &entity.User{}, &entity.SearchStat{}, &entity.HotDrama{}, + &entity.File{}, ) } diff --git a/db/converter/file_converter.go b/db/converter/file_converter.go new file mode 100644 index 0000000..ce4820b --- /dev/null +++ b/db/converter/file_converter.go @@ -0,0 +1,53 @@ +package converter + +import ( + "github.com/ctwj/urldb/db/dto" + "github.com/ctwj/urldb/db/entity" + "github.com/ctwj/urldb/utils" +) + +// FileToResponse 将文件实体转换为响应DTO +func FileToResponse(file *entity.File) dto.FileResponse { + response := dto.FileResponse{ + ID: file.ID, + CreatedAt: utils.FormatTime(file.CreatedAt, "2006-01-02 15:04:05"), + UpdatedAt: utils.FormatTime(file.UpdatedAt, "2006-01-02 15:04:05"), + OriginalName: file.OriginalName, + FileName: file.FileName, + FilePath: file.FilePath, + FileSize: file.FileSize, + FileType: file.FileType, + MimeType: file.MimeType, + AccessURL: file.AccessURL, + UserID: file.UserID, + Status: file.Status, + IsPublic: file.IsPublic, + IsDeleted: file.IsDeleted, + } + + // 添加用户名 + if file.User.ID > 0 { + response.User = file.User.Username + } + + return response +} + +// FilesToResponse 将文件实体列表转换为响应DTO列表 +func FilesToResponse(files []entity.File) []dto.FileResponse { + var responses []dto.FileResponse + for _, file := range files { + responses = append(responses, FileToResponse(&file)) + } + return responses +} + +// FileListToResponse 将文件列表转换为列表响应 +func FileListToResponse(files []entity.File, total int64, page, pageSize int) dto.FileListResponse { + return dto.FileListResponse{ + Files: FilesToResponse(files), + Total: total, + Page: page, + Size: pageSize, + } +} diff --git a/db/dto/file.go b/db/dto/file.go new file mode 100644 index 0000000..3a0d1c3 --- /dev/null +++ b/db/dto/file.go @@ -0,0 +1,70 @@ +package dto + +// FileUploadRequest 文件上传请求 +type FileUploadRequest struct { + IsPublic bool `json:"is_public" form:"is_public"` // 是否公开 +} + +// FileResponse 文件响应 +type FileResponse struct { + ID uint `json:"id"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + + // 文件信息 + OriginalName string `json:"original_name"` + FileName string `json:"file_name"` + FilePath string `json:"file_path"` + FileSize int64 `json:"file_size"` + FileType string `json:"file_type"` + MimeType string `json:"mime_type"` + + // 访问信息 + AccessURL string `json:"access_url"` + + // 用户信息 + UserID uint `json:"user_id"` + User string `json:"user"` // 用户名 + + // 状态信息 + Status string `json:"status"` + IsPublic bool `json:"is_public"` + IsDeleted bool `json:"is_deleted"` +} + +// FileListRequest 文件列表请求 +type FileListRequest struct { + Page int `json:"page" form:"page"` + PageSize int `json:"page_size" form:"page_size"` + Search string `json:"search" form:"search"` + FileType string `json:"file_type" form:"file_type"` + Status string `json:"status" form:"status"` + UserID uint `json:"user_id" form:"user_id"` +} + +// FileListResponse 文件列表响应 +type FileListResponse struct { + Files []FileResponse `json:"files"` + Total int64 `json:"total"` + Page int `json:"page"` + Size int `json:"size"` +} + +// FileUploadResponse 文件上传响应 +type FileUploadResponse struct { + File FileResponse `json:"file"` + Message string `json:"message"` + Success bool `json:"success"` +} + +// FileDeleteRequest 文件删除请求 +type FileDeleteRequest struct { + IDs []uint `json:"ids" binding:"required"` +} + +// FileUpdateRequest 文件更新请求 +type FileUpdateRequest struct { + ID uint `json:"id" binding:"required"` + IsPublic *bool `json:"is_public"` + Status string `json:"status"` +} diff --git a/db/entity/file.go b/db/entity/file.go new file mode 100644 index 0000000..19ba4d0 --- /dev/null +++ b/db/entity/file.go @@ -0,0 +1,44 @@ +package entity + +import ( + "time" +) + +// File 文件实体 +type File struct { + ID uint `json:"id" gorm:"primaryKey"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + + // 文件信息 + OriginalName string `json:"original_name" gorm:"size:255;not null;comment:原始文件名"` + FileName string `json:"file_name" gorm:"size:255;not null;unique;comment:存储文件名"` + FilePath string `json:"file_path" gorm:"size:500;not null;comment:文件路径"` + FileSize int64 `json:"file_size" gorm:"not null;comment:文件大小(字节)"` + FileType string `json:"file_type" gorm:"size:100;not null;comment:文件类型"` + MimeType string `json:"mime_type" gorm:"size:100;comment:MIME类型"` + + // 访问信息 + AccessURL string `json:"access_url" gorm:"size:500;comment:访问URL"` + + // 用户信息 + UserID uint `json:"user_id" gorm:"comment:上传用户ID"` + User User `json:"user" gorm:"foreignKey:UserID"` + + // 状态信息 + Status string `json:"status" gorm:"size:20;default:'active';comment:文件状态"` + IsPublic bool `json:"is_public" gorm:"default:true;comment:是否公开"` + IsDeleted bool `json:"is_deleted" gorm:"default:false;comment:是否已删除"` +} + +// TableName 指定表名 +func (File) TableName() string { + return "files" +} + +// FileStatus 文件状态常量 +const ( + FileStatusActive = "active" // 正常 + FileStatusInactive = "inactive" // 禁用 + FileStatusDeleted = "deleted" // 已删除 +) diff --git a/db/entity/system_config_constants.go b/db/entity/system_config_constants.go index 48dfe81..7173fa2 100644 --- a/db/entity/system_config_constants.go +++ b/db/entity/system_config_constants.go @@ -24,8 +24,8 @@ const ( ConfigKeyForbiddenWords = "forbidden_words" // 广告配置 - ConfigKeyAdKeywords = "ad_keywords" // 广告关键词 - ConfigKeyAutoInsertAd = "auto_insert_ad" // 自动插入广告 + ConfigKeyAdKeywords = "ad_keywords" // 广告关键词 + ConfigKeyAutoInsertAd = "auto_insert_ad" // 自动插入广告 // 其他配置 ConfigKeyPageSize = "page_size" diff --git a/db/repo/file_repository.go b/db/repo/file_repository.go new file mode 100644 index 0000000..41f3671 --- /dev/null +++ b/db/repo/file_repository.go @@ -0,0 +1,144 @@ +package repo + +import ( + "github.com/ctwj/urldb/db/entity" + "gorm.io/gorm" +) + +// FileRepository 文件Repository接口 +type FileRepository interface { + BaseRepository[entity.File] + FindByFileName(fileName string) (*entity.File, error) + FindByUserID(userID uint, page, pageSize int) ([]entity.File, int64, error) + FindPublicFiles(page, pageSize int) ([]entity.File, int64, error) + SearchFiles(search string, fileType, status string, userID uint, page, pageSize int) ([]entity.File, int64, error) + SoftDeleteByIDs(ids []uint) error + UpdateFileStatus(id uint, status string) error + UpdateFilePublic(id uint, isPublic bool) error +} + +// FileRepositoryImpl 文件Repository实现 +type FileRepositoryImpl struct { + BaseRepositoryImpl[entity.File] +} + +// NewFileRepository 创建文件Repository +func NewFileRepository(db *gorm.DB) FileRepository { + return &FileRepositoryImpl{ + BaseRepositoryImpl: BaseRepositoryImpl[entity.File]{db: db}, + } +} + +// FindByFileName 根据文件名查找文件 +func (r *FileRepositoryImpl) FindByFileName(fileName string) (*entity.File, error) { + var file entity.File + err := r.db.Where("file_name = ? AND is_deleted = ?", fileName, false).First(&file).Error + if err != nil { + return nil, err + } + return &file, nil +} + +// FindByUserID 根据用户ID查找文件 +func (r *FileRepositoryImpl) FindByUserID(userID uint, page, pageSize int) ([]entity.File, int64, error) { + var files []entity.File + var total int64 + + offset := (page - 1) * pageSize + + // 获取总数 + err := r.db.Model(&entity.File{}).Where("user_id = ? AND is_deleted = ?", userID, false).Count(&total).Error + if err != nil { + return nil, 0, err + } + + // 获取文件列表 + err = r.db.Where("user_id = ? AND is_deleted = ?", userID, false). + Preload("User"). + Order("created_at DESC"). + Offset(offset). + Limit(pageSize). + Find(&files).Error + + return files, total, err +} + +// FindPublicFiles 查找公开文件 +func (r *FileRepositoryImpl) FindPublicFiles(page, pageSize int) ([]entity.File, int64, error) { + var files []entity.File + var total int64 + + offset := (page - 1) * pageSize + + // 获取总数 + err := r.db.Model(&entity.File{}).Where("is_public = ? AND is_deleted = ? AND status = ?", true, false, entity.FileStatusActive).Count(&total).Error + if err != nil { + return nil, 0, err + } + + // 获取文件列表 + err = r.db.Where("is_public = ? AND is_deleted = ? AND status = ?", true, false, entity.FileStatusActive). + Preload("User"). + Order("created_at DESC"). + Offset(offset). + Limit(pageSize). + Find(&files).Error + + return files, total, err +} + +// SearchFiles 搜索文件 +func (r *FileRepositoryImpl) SearchFiles(search string, fileType, status string, userID uint, page, pageSize int) ([]entity.File, int64, error) { + var files []entity.File + var total int64 + + offset := (page - 1) * pageSize + query := r.db.Model(&entity.File{}).Where("is_deleted = ?", false) + + // 添加搜索条件 + if search != "" { + query = query.Where("original_name LIKE ? OR file_name LIKE ?", "%"+search+"%", "%"+search+"%") + } + + if fileType != "" { + query = query.Where("file_type = ?", fileType) + } + + if status != "" { + query = query.Where("status = ?", status) + } + + if userID > 0 { + query = query.Where("user_id = ?", userID) + } + + // 获取总数 + err := query.Count(&total).Error + if err != nil { + return nil, 0, err + } + + // 获取文件列表 + err = query.Preload("User"). + Order("created_at DESC"). + Offset(offset). + Limit(pageSize). + Find(&files).Error + + return files, total, err +} + +// SoftDeleteByIDs 软删除文件 +func (r *FileRepositoryImpl) SoftDeleteByIDs(ids []uint) error { + return r.db.Model(&entity.File{}).Where("id IN ?", ids).Update("is_deleted", true).Error +} + +// UpdateFileStatus 更新文件状态 +func (r *FileRepositoryImpl) UpdateFileStatus(id uint, status string) error { + return r.db.Model(&entity.File{}).Where("id = ?", id).Update("status", status).Error +} + +// UpdateFilePublic 更新文件公开状态 +func (r *FileRepositoryImpl) UpdateFilePublic(id uint, isPublic bool) error { + return r.db.Model(&entity.File{}).Where("id = ?", id).Update("is_public", isPublic).Error +} diff --git a/db/repo/manager.go b/db/repo/manager.go index 326fde2..bda4ca1 100644 --- a/db/repo/manager.go +++ b/db/repo/manager.go @@ -19,6 +19,7 @@ type RepositoryManager struct { ResourceViewRepository ResourceViewRepository TaskRepository TaskRepository TaskItemRepository TaskItemRepository + FileRepository FileRepository } // NewRepositoryManager 创建Repository管理器 @@ -37,5 +38,6 @@ func NewRepositoryManager(db *gorm.DB) *RepositoryManager { ResourceViewRepository: NewResourceViewRepository(db), TaskRepository: NewTaskRepository(db), TaskItemRepository: NewTaskItemRepository(db), + FileRepository: NewFileRepository(db), } } diff --git a/env.example b/env.example index ada9647..b86bf6a 100644 --- a/env.example +++ b/env.example @@ -14,4 +14,4 @@ TIMEZONE=Asia/Shanghai # 文件上传配置 UPLOAD_DIR=./uploads -MAX_FILE_SIZE=100MB \ No newline at end of file +MAX_FILE_SIZE=5MB \ No newline at end of file diff --git a/handlers/file_handler.go b/handlers/file_handler.go new file mode 100644 index 0000000..2289d46 --- /dev/null +++ b/handlers/file_handler.go @@ -0,0 +1,382 @@ +package handlers + +import ( + "crypto/rand" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strconv" + "strings" + "time" + + "github.com/ctwj/urldb/db/converter" + "github.com/ctwj/urldb/db/dto" + "github.com/ctwj/urldb/db/entity" + "github.com/ctwj/urldb/db/repo" + "github.com/ctwj/urldb/utils" + + "github.com/gin-gonic/gin" +) + +// FileHandler 文件处理器 +type FileHandler struct { + fileRepo repo.FileRepository + systemConfigRepo repo.SystemConfigRepository + userRepo repo.UserRepository +} + +// NewFileHandler 创建文件处理器 +func NewFileHandler(fileRepo repo.FileRepository, systemConfigRepo repo.SystemConfigRepository, userRepo repo.UserRepository) *FileHandler { + return &FileHandler{ + fileRepo: fileRepo, + systemConfigRepo: systemConfigRepo, + userRepo: userRepo, + } +} + +// UploadFile 上传文件 +func (h *FileHandler) UploadFile(c *gin.Context) { + // 获取当前用户ID + userID, exists := c.Get("user_id") + if !exists { + ErrorResponse(c, "用户未登录", http.StatusUnauthorized) + return + } + + // 从数据库获取用户信息 + currentUser, err := h.userRepo.FindByID(userID.(uint)) + if err != nil { + ErrorResponse(c, "用户不存在", http.StatusUnauthorized) + return + } + + // 获取上传目录配置(从环境变量或使用默认值) + uploadDir := os.Getenv("UPLOAD_DIR") + if uploadDir == "" { + uploadDir = "./uploads" // 默认值 + } + + // 创建年月子文件夹 + now := time.Now() + yearMonth := now.Format("200601") // 格式:202508 + monthlyDir := filepath.Join(uploadDir, yearMonth) + + // 确保年月目录存在 + if err := os.MkdirAll(monthlyDir, 0755); err != nil { + ErrorResponse(c, "创建年月目录失败", http.StatusInternalServerError) + return + } + + // 获取上传的文件 + file, header, err := c.Request.FormFile("file") + if err != nil { + ErrorResponse(c, "获取上传文件失败", http.StatusBadRequest) + return + } + defer file.Close() + + // 检查文件大小(5MB) + maxFileSize := int64(5 * 1024 * 1024) // 5MB + if header.Size > maxFileSize { + ErrorResponse(c, "文件大小不能超过5MB", http.StatusBadRequest) + return + } + + // 检查文件类型,只允许图片 + allowedTypes := []string{ + "image/jpeg", + "image/jpg", + "image/png", + "image/gif", + "image/webp", + "image/bmp", + "image/svg+xml", + } + + contentType := header.Header.Get("Content-Type") + isAllowedType := false + for _, allowedType := range allowedTypes { + if contentType == allowedType { + isAllowedType = true + break + } + } + + if !isAllowedType { + ErrorResponse(c, "只支持图片格式文件 (JPEG, PNG, GIF, WebP, BMP, SVG)", http.StatusBadRequest) + return + } + + // 生成随机文件名 + fileName := h.generateRandomFileName(header.Filename) + filePath := filepath.Join(monthlyDir, fileName) + + // 创建目标文件 + dst, err := os.Create(filePath) + if err != nil { + ErrorResponse(c, "创建文件失败", http.StatusInternalServerError) + return + } + defer dst.Close() + + // 复制文件内容 + if _, err := io.Copy(dst, file); err != nil { + ErrorResponse(c, "保存文件失败", http.StatusInternalServerError) + return + } + + // 获取文件类型 + fileType := h.getFileType(header.Filename) + mimeType := header.Header.Get("Content-Type") + + // 获取是否公开 + isPublic := true + if isPublicStr := c.PostForm("is_public"); isPublicStr != "" { + if isPublicBool, err := strconv.ParseBool(isPublicStr); err == nil { + isPublic = isPublicBool + } + } + + // 构建访问URL(使用绝对路径,不包含域名) + accessURL := fmt.Sprintf("/uploads/%s/%s", yearMonth, fileName) + + // 创建文件记录 + fileEntity := &entity.File{ + OriginalName: header.Filename, + FileName: fileName, + FilePath: filePath, + FileSize: header.Size, + FileType: fileType, + MimeType: mimeType, + AccessURL: accessURL, + UserID: currentUser.ID, + Status: entity.FileStatusActive, + IsPublic: isPublic, + IsDeleted: false, + } + + // 保存到数据库 + if err := h.fileRepo.Create(fileEntity); err != nil { + // 删除已上传的文件 + os.Remove(filePath) + ErrorResponse(c, "保存文件记录失败", http.StatusInternalServerError) + return + } + + // 返回响应 + response := dto.FileUploadResponse{ + File: converter.FileToResponse(fileEntity), + Message: "文件上传成功", + Success: true, + } + + SuccessResponse(c, response) +} + +// GetFileList 获取文件列表 +func (h *FileHandler) GetFileList(c *gin.Context) { + var req dto.FileListRequest + if err := c.ShouldBindQuery(&req); err != nil { + ErrorResponse(c, "请求参数错误", http.StatusBadRequest) + return + } + + // 设置默认值 + if req.Page <= 0 { + req.Page = 1 + } + if req.PageSize <= 0 { + req.PageSize = 20 + } + + // 获取当前用户ID和角色 + userIDInterface, exists := c.Get("user_id") + roleInterface, _ := c.Get("role") + + var userID uint + var role string + if exists { + userID = userIDInterface.(uint) + } + if roleInterface != nil { + role = roleInterface.(string) + } + + // 根据用户角色决定查询范围 + var files []entity.File + var total int64 + var err error + + if exists && role == "admin" { + // 管理员可以查看所有文件 + files, total, err = h.fileRepo.SearchFiles(req.Search, req.FileType, req.Status, req.UserID, req.Page, req.PageSize) + } else if userID > 0 { + // 普通用户只能查看自己的文件 + files, total, err = h.fileRepo.SearchFiles(req.Search, req.FileType, req.Status, userID, req.Page, req.PageSize) + } else { + // 未登录用户只能查看公开文件 + files, total, err = h.fileRepo.FindPublicFiles(req.Page, req.PageSize) + } + + if err != nil { + ErrorResponse(c, "获取文件列表失败", http.StatusInternalServerError) + return + } + + response := converter.FileListToResponse(files, total, req.Page, req.PageSize) + SuccessResponse(c, response) +} + +// DeleteFiles 删除文件 +func (h *FileHandler) DeleteFiles(c *gin.Context) { + var req dto.FileDeleteRequest + if err := c.ShouldBindJSON(&req); err != nil { + ErrorResponse(c, "请求参数错误", http.StatusBadRequest) + return + } + + // 获取当前用户ID和角色 + userIDInterface, exists := c.Get("user_id") + roleInterface, _ := c.Get("role") + if !exists { + ErrorResponse(c, "用户未登录", http.StatusUnauthorized) + return + } + + userID := userIDInterface.(uint) + role := "" + if roleInterface != nil { + role = roleInterface.(string) + } + + // 检查权限 + if role != "admin" { + // 普通用户只能删除自己的文件 + for _, id := range req.IDs { + file, err := h.fileRepo.FindByID(id) + if err != nil { + ErrorResponse(c, "文件不存在", http.StatusNotFound) + return + } + if file.UserID != userID { + ErrorResponse(c, "没有权限删除此文件", http.StatusForbidden) + return + } + } + } + + // 获取要删除的文件信息 + var filesToDelete []entity.File + for _, id := range req.IDs { + file, err := h.fileRepo.FindByID(id) + if err != nil { + ErrorResponse(c, "文件不存在", http.StatusNotFound) + return + } + filesToDelete = append(filesToDelete, *file) + } + + // 删除本地文件 + for _, file := range filesToDelete { + if err := os.Remove(file.FilePath); err != nil { + utils.Error("删除本地文件失败: %s, 错误: %v", file.FilePath, err) + // 继续删除其他文件,不因为单个文件删除失败而中断 + } + } + + // 删除数据库记录 + for _, id := range req.IDs { + if err := h.fileRepo.Delete(id); err != nil { + utils.Error("删除文件记录失败: ID=%d, 错误: %v", id, err) + // 继续删除其他文件,不因为单个文件删除失败而中断 + } + } + + SuccessResponse(c, gin.H{"message": "文件删除成功"}) +} + +// UpdateFile 更新文件信息 +func (h *FileHandler) UpdateFile(c *gin.Context) { + var req dto.FileUpdateRequest + if err := c.ShouldBindJSON(&req); err != nil { + ErrorResponse(c, "请求参数错误", http.StatusBadRequest) + return + } + + // 获取当前用户ID和角色 + userIDInterface, exists := c.Get("user_id") + roleInterface, _ := c.Get("role") + if !exists { + ErrorResponse(c, "用户未登录", http.StatusUnauthorized) + return + } + + userID := userIDInterface.(uint) + role := "" + if roleInterface != nil { + role = roleInterface.(string) + } + + // 查找文件 + file, err := h.fileRepo.FindByID(req.ID) + if err != nil { + ErrorResponse(c, "文件不存在", http.StatusNotFound) + return + } + + // 检查权限 + if role != "admin" && userID != file.UserID { + ErrorResponse(c, "没有权限修改此文件", http.StatusForbidden) + return + } + + // 更新文件信息 + if req.IsPublic != nil { + if err := h.fileRepo.UpdateFilePublic(req.ID, *req.IsPublic); err != nil { + ErrorResponse(c, "更新文件状态失败", http.StatusInternalServerError) + return + } + } + + if req.Status != "" { + if err := h.fileRepo.UpdateFileStatus(req.ID, req.Status); err != nil { + ErrorResponse(c, "更新文件状态失败", http.StatusInternalServerError) + return + } + } + + SuccessResponse(c, gin.H{"message": "文件更新成功"}) +} + +// generateRandomFileName 生成随机文件名 +func (h *FileHandler) generateRandomFileName(originalName string) string { + // 获取文件扩展名 + ext := filepath.Ext(originalName) + + // 生成随机字符串 + bytes := make([]byte, 16) + rand.Read(bytes) + randomStr := fmt.Sprintf("%x", bytes) + + // 添加时间戳 + timestamp := time.Now().Unix() + + return fmt.Sprintf("%d_%s%s", timestamp, randomStr, ext) +} + +// getFileType 获取文件类型 +func (h *FileHandler) getFileType(filename string) string { + ext := strings.ToLower(filepath.Ext(filename)) + + // 图片类型 + imageExts := []string{".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp", ".svg"} + for _, imgExt := range imageExts { + if ext == imgExt { + return "image" + } + } + + return "image" // 默认返回image,因为只支持图片格式 +} diff --git a/main.go b/main.go index e7bb0cd..ddde72a 100644 --- a/main.go +++ b/main.go @@ -3,6 +3,7 @@ package main import ( "log" "os" + "strings" "github.com/ctwj/urldb/db" "github.com/ctwj/urldb/db/repo" @@ -106,6 +107,9 @@ func main() { // 创建任务处理器 taskHandler := handlers.NewTaskHandler(repoManager, taskManager) + // 创建文件处理器 + fileHandler := handlers.NewFileHandler(repoManager.FileRepository, repoManager.SystemConfigRepository, repoManager.UserRepository) + // API路由 api := r.Group("/api") { @@ -236,11 +240,27 @@ func main() { api.GET("/version/string", handlers.GetVersionString) api.GET("/version/full", handlers.GetFullVersionInfo) api.GET("/version/check-update", handlers.CheckUpdate) + + // 文件上传相关路由 + api.POST("/files/upload", middleware.AuthMiddleware(), fileHandler.UploadFile) + api.GET("/files", fileHandler.GetFileList) + api.DELETE("/files", middleware.AuthMiddleware(), fileHandler.DeleteFiles) + api.PUT("/files", middleware.AuthMiddleware(), fileHandler.UpdateFile) } // 静态文件服务 r.Static("/uploads", "./uploads") + // 添加CORS头到静态文件 + r.Use(func(c *gin.Context) { + if strings.HasPrefix(c.Request.URL.Path, "/uploads/") { + c.Header("Access-Control-Allow-Origin", "*") + c.Header("Access-Control-Allow-Methods", "GET, OPTIONS") + c.Header("Access-Control-Allow-Headers", "Origin, Content-Type, Accept") + } + c.Next() + }) + port := os.Getenv("PORT") if port == "" { port = "8080" diff --git a/nginx/conf.d/default.conf b/nginx/conf.d/default.conf index 032ab26..6d86745 100644 --- a/nginx/conf.d/default.conf +++ b/nginx/conf.d/default.conf @@ -60,6 +60,15 @@ server { proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; + + # 缓存设置 + expires 1y; + add_header Cache-Control "public, immutable"; + + # 允许跨域访问 + add_header Access-Control-Allow-Origin "*"; + add_header Access-Control-Allow-Methods "GET, OPTIONS"; + add_header Access-Control-Allow-Headers "Origin, Content-Type, Accept"; } # 健康检查 diff --git a/web/components.d.ts b/web/components.d.ts index d77db8c..37a76e1 100644 --- a/web/components.d.ts +++ b/web/components.d.ts @@ -23,12 +23,17 @@ declare module 'vue' { NEmpty: typeof import('naive-ui')['NEmpty'] NForm: typeof import('naive-ui')['NForm'] NFormItem: typeof import('naive-ui')['NFormItem'] + NIcon: typeof import('naive-ui')['NIcon'] + NImage: typeof import('naive-ui')['NImage'] + NImageGroup: typeof import('naive-ui')['NImageGroup'] NInput: typeof import('naive-ui')['NInput'] + NInputGroup: typeof import('naive-ui')['NInputGroup'] NList: typeof import('naive-ui')['NList'] NListItem: typeof import('naive-ui')['NListItem'] NMessageProvider: typeof import('naive-ui')['NMessageProvider'] NModal: typeof import('naive-ui')['NModal'] NNotificationProvider: typeof import('naive-ui')['NNotificationProvider'] + NP: typeof import('naive-ui')['NP'] NPagination: typeof import('naive-ui')['NPagination'] NProgress: typeof import('naive-ui')['NProgress'] NQrCode: typeof import('naive-ui')['NQrCode'] @@ -41,6 +46,8 @@ declare module 'vue' { NTag: typeof import('naive-ui')['NTag'] NText: typeof import('naive-ui')['NText'] NThing: typeof import('naive-ui')['NThing'] + NUpload: typeof import('naive-ui')['NUpload'] + NUploadDragger: typeof import('naive-ui')['NUploadDragger'] NVirtualList: typeof import('naive-ui')['NVirtualList'] RouterLink: typeof import('vue-router')['RouterLink'] RouterView: typeof import('vue-router')['RouterView'] diff --git a/web/components/Admin/NewSidebar.vue b/web/components/Admin/NewSidebar.vue index 67dd430..b46f456 100644 --- a/web/components/Admin/NewSidebar.vue +++ b/web/components/Admin/NewSidebar.vue @@ -12,6 +12,25 @@ {{ dashboardItem.label }} + +
+
+ 数据管理 +
+
+ + + {{ item.label }} + +
+
+
@@ -82,8 +101,8 @@ const dashboardItem = ref({ active: (route: any) => route.path === '/admin' }) -// 运营管理分组 -const operationItems = ref([ +// 数据管理分组 +const dataItems = ref([ { to: '/admin/resources', label: '资源管理', @@ -108,6 +127,16 @@ const operationItems = ref([ icon: 'fas fa-tags', active: (route: any) => route.path.startsWith('/admin/tags') }, + { + to: '/admin/files', + label: '文件管理', + icon: 'fas fa-file-upload', + active: (route: any) => route.path.startsWith('/admin/files') + } +]) + +// 运营管理分组 +const operationItems = ref([ { to: '/admin/platforms', label: '平台管理', @@ -126,6 +155,12 @@ const operationItems = ref([ icon: 'fas fa-film', active: (route: any) => route.path.startsWith('/admin/hot-dramas') }, + { + to: '/admin/data-transfer', + label: '数据转存管理', + icon: 'fas fa-exchange-alt', + active: (route: any) => route.path.startsWith('/admin/data-transfer') + }, { to: '/admin/seo', label: 'SEO', @@ -175,6 +210,12 @@ const systemItems = ref([ label: '系统配置', icon: 'fas fa-cog', active: (route: any) => route.path.startsWith('/admin/system-config') + }, + { + to: '/admin/version', + label: '版本信息', + icon: 'fas fa-code-branch', + active: (route: any) => route.path.startsWith('/admin/version') } ]) diff --git a/web/components/FileUpload.vue b/web/components/FileUpload.vue new file mode 100644 index 0000000..1eb4b27 --- /dev/null +++ b/web/components/FileUpload.vue @@ -0,0 +1,235 @@ + + + + + \ No newline at end of file diff --git a/web/composables/useFileApi.ts b/web/composables/useFileApi.ts new file mode 100644 index 0000000..890e228 --- /dev/null +++ b/web/composables/useFileApi.ts @@ -0,0 +1,36 @@ +import { useApiFetch } from './useApiFetch' + +export const useFileApi = () => { + const getFileList = (params?: any) => useApiFetch('/files', { params }).then(parseApiResponse) + const uploadFile = (data: FormData) => useApiFetch('/files/upload', { + method: 'POST', + body: data, + headers: { + // 不设置Content-Type,让浏览器自动设置multipart/form-data + } + }).then(parseApiResponse) + const deleteFiles = (ids: number[]) => useApiFetch('/files', { + method: 'DELETE', + body: { ids } + }).then(parseApiResponse) + const updateFile = (data: any) => useApiFetch('/files', { + method: 'PUT', + body: data + }).then(parseApiResponse) + + return { + getFileList, + uploadFile, + deleteFiles, + updateFile + } +} + +// 解析API响应 +function parseApiResponse(response: any) { + if (response.success) { + return response + } else { + throw new Error(response.message || '请求失败') + } +} \ No newline at end of file diff --git a/web/config/adminNewNavigation.ts b/web/config/adminNewNavigation.ts index 1023b39..d4d9689 100644 --- a/web/config/adminNewNavigation.ts +++ b/web/config/adminNewNavigation.ts @@ -26,7 +26,7 @@ export const adminNewNavigationItems = [ icon: 'fas fa-database', to: '/admin/resources', active: (route: any) => route.path.startsWith('/admin/resources'), - group: 'operation' + group: 'data' }, { key: 'ready-resources', @@ -34,7 +34,7 @@ export const adminNewNavigationItems = [ icon: 'fas fa-clock', to: '/admin/ready-resources', active: (route: any) => route.path.startsWith('/admin/ready-resources'), - group: 'operation' + group: 'data' }, { key: 'categories', @@ -42,7 +42,7 @@ export const adminNewNavigationItems = [ icon: 'fas fa-folder', to: '/admin/categories', active: (route: any) => route.path.startsWith('/admin/categories'), - group: 'operation' + group: 'data' }, { key: 'tags', @@ -50,7 +50,7 @@ export const adminNewNavigationItems = [ icon: 'fas fa-tags', to: '/admin/tags', active: (route: any) => route.path.startsWith('/admin/tags'), - group: 'operation' + group: 'data' }, { key: 'platforms', @@ -100,6 +100,14 @@ export const adminNewNavigationItems = [ active: (route: any) => route.path.startsWith('/admin/data-push'), group: 'operation' }, + { + key: 'files', + label: '文件管理', + icon: 'fas fa-file-upload', + to: '/admin/files', + active: (route: any) => route.path.startsWith('/admin/files'), + group: 'data' + }, { key: 'bot', label: '机器人', diff --git a/web/layouts/admin.vue b/web/layouts/admin.vue index 6991d7c..41b6149 100644 --- a/web/layouts/admin.vue +++ b/web/layouts/admin.vue @@ -464,6 +464,12 @@ const dataManagementItems = ref([ label: '平台账号', icon: 'fas fa-user-shield', active: (route: any) => route.path.startsWith('/admin/accounts') + }, + { + to: '/admin/files', + label: '文件管理', + icon: 'fas fa-file-upload', + active: (route: any) => route.path.startsWith('/admin/files') } ]) @@ -544,7 +550,7 @@ const autoExpandCurrentGroup = () => { const currentPath = useRoute().path // 检查当前页面属于哪个分组并展开 - if (currentPath.startsWith('/admin/resources') || currentPath.startsWith('/admin/ready-resources') || currentPath.startsWith('/admin/tasks') || currentPath.startsWith('/admin/tags') || currentPath.startsWith('/admin/categories') || currentPath.startsWith('/admin/accounts')) { + if (currentPath.startsWith('/admin/resources') || currentPath.startsWith('/admin/ready-resources') || currentPath.startsWith('/admin/tags') || currentPath.startsWith('/admin/categories') || currentPath.startsWith('/admin/accounts') || currentPath.startsWith('/admin/files')) { expandedGroups.value.dataManagement = true } else if (currentPath.startsWith('/admin/site-config') || currentPath.startsWith('/admin/feature-config') || currentPath.startsWith('/admin/dev-config') || currentPath.startsWith('/admin/users') || currentPath.startsWith('/admin/version')) { expandedGroups.value.systemConfig = true @@ -566,7 +572,7 @@ watch(() => useRoute().path, (newPath) => { } // 根据新路径展开对应分组 - if (newPath.startsWith('/admin/resources') || newPath.startsWith('/admin/ready-resources') || newPath.startsWith('/admin/tasks') || newPath.startsWith('/admin/tags') || newPath.startsWith('/admin/categories') || newPath.startsWith('/admin/accounts')) { + if (newPath.startsWith('/admin/resources') || newPath.startsWith('/admin/ready-resources') || newPath.startsWith('/admin/tags') || newPath.startsWith('/admin/categories') || newPath.startsWith('/admin/accounts') || newPath.startsWith('/admin/files')) { expandedGroups.value.dataManagement = true } else if (newPath.startsWith('/admin/site-config') || newPath.startsWith('/admin/feature-config') || newPath.startsWith('/admin/dev-config') || newPath.startsWith('/admin/users') || newPath.startsWith('/admin/version')) { expandedGroups.value.systemConfig = true diff --git a/web/pages/admin/files.vue b/web/pages/admin/files.vue new file mode 100644 index 0000000..065a20e --- /dev/null +++ b/web/pages/admin/files.vue @@ -0,0 +1,563 @@ + + + + + \ No newline at end of file