diff --git a/handlers/backend/auth.go b/handlers/backend/auth.go index b81a57f..7fd0452 100644 --- a/handlers/backend/auth.go +++ b/handlers/backend/auth.go @@ -38,12 +38,12 @@ var adminMenus = []AdminMenuGroup{ {Key: "posts", Name: "文章管理"}, {Key: "categories", Name: "分类管理"}, {Key: "tags", Name: "标签管理"}, + {Key: "files", Name: "文件管理"}, {Key: "pages", Name: "页面管理"}, {Key: "comments", Name: "评论管理"}, }}, {Group: "系统管理", Items: []AdminMenuItem{ {Key: "users", Name: "用户管理"}, - {Key: "files", Name: "文件管理"}, {Key: "themes", Name: "主题管理"}, {Key: "settings", Name: "基础设置"}, }}, diff --git a/handlers/backend/post.go b/handlers/backend/post.go index 844ae18..abcb052 100644 --- a/handlers/backend/post.go +++ b/handlers/backend/post.go @@ -2,6 +2,8 @@ package backend import ( "net/http" + "os" + "path/filepath" "strconv" "strings" "time" @@ -34,6 +36,7 @@ type CreatePostRequest struct { Tags []string `json:"tags"` Status string `json:"status"` IsTop bool `json:"is_top"` + FileIDs []uint `json:"file_ids"` // 正文中使用的文件ID列表(按出现顺序) } // 更新文章请求 @@ -46,6 +49,7 @@ type UpdatePostRequest struct { Tags []string `json:"tags"` Status string `json:"status"` IsTop bool `json:"is_top"` + FileIDs []uint `json:"file_ids"` // 正文中使用的文件ID列表(按出现顺序) } // Markdown 预览请求 @@ -221,6 +225,15 @@ func CreatePost(c *gin.Context) { return } + // 处理文件关联(包含封面) + if len(req.FileIDs) > 0 { + var files []models.File + database.DB.Where("id IN ?", req.FileIDs).Find(&files) + if len(files) > 0 { + database.DB.Model(&post).Association("Files").Replace(files) + } + } + c.JSON(http.StatusCreated, gin.H{"data": post}) } @@ -260,9 +273,8 @@ func UpdatePost(c *gin.Context) { if req.Summary != "" { updates["summary"] = req.Summary } - if req.Cover != "" { - updates["cover"] = req.Cover - } + // 封面始终更新(空字符串表示移除封面) + updates["cover"] = req.Cover if req.CategoryID > 0 { updates["category_id"] = req.CategoryID } @@ -297,6 +309,20 @@ func UpdatePost(c *gin.Context) { return } + // 处理文件关联(包含封面) + if req.FileIDs != nil { + if len(req.FileIDs) > 0 { + var files []models.File + database.DB.Where("id IN ?", req.FileIDs).Find(&files) + if len(files) > 0 { + database.DB.Model(&post).Association("Files").Replace(files) + } + } else { + // 空数组,清除所有关联 + database.DB.Model(&post).Association("Files").Clear() + } + } + c.JSON(http.StatusOK, gin.H{"data": post}) } @@ -319,6 +345,26 @@ func DeletePost(c *gin.Context) { } } + // 获取关联文件,删除物理文件和数据库记录 + var files []models.File + database.DB.Model(&post).Association("Files").Find(&files) + for _, f := range files { + // 检查文件是否还被其他文章使用 + var count int64 + database.DB.Table("post_files").Where("file_id = ? AND post_id != ?", f.ID, post.ID).Count(&count) + if count == 0 { + // 没有其他文章使用,删除物理文件 + physPath := filepath.Join("./static/uploads", f.FilePath) + if _, err := os.Stat(physPath); err == nil { + os.Remove(physPath) + } + // 删除 files 表记录 + database.DB.Delete(&f) + } + } + // 清除关联关系 + database.DB.Model(&post).Association("Files").Clear() + if err := database.DB.Delete(&post).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "删除文章失败"}) return diff --git a/handlers/backend/upload.go b/handlers/backend/upload.go index e51b471..37e3df7 100644 --- a/handlers/backend/upload.go +++ b/handlers/backend/upload.go @@ -156,6 +156,7 @@ func UploadImage(c *gin.Context) { } c.JSON(http.StatusOK, gin.H{ + "id": fileRecord.ID, "url": uploadURLPrefix + strings.ReplaceAll(relPath, "\\", "/"), "name": filename, "size": file.Size, @@ -189,11 +190,38 @@ func ListUploads(c *gin.Context) { MimeType string `json:"mime_type"` UserID uint `json:"user_id"` CreatedAt string `json:"created_at"` + PostTitle string `json:"post_title"` // 关联文章标题,为空表示未关联 + PostID uint `json:"post_id"` // 关联文章ID + } + + // 批量查询文件关联信息 + fileIDs := make([]uint, 0, len(files)) + for _, f := range files { + fileIDs = append(fileIDs, f.ID) + } + + // 查询关联关系(带文章标题) + type PostFileResult struct { + FileID uint `json:"file_id"` + PostID uint `json:"post_id"` + PostTitle string `json:"post_title"` + } + var postFileResults []PostFileResult + database.DB.Table("post_files"). + Select("post_files.file_id, post_files.post_id, posts.title as post_title"). + Joins("LEFT JOIN posts ON posts.id = post_files.post_id"). + Where("post_files.file_id IN ?", fileIDs). + Find(&postFileResults) + + // 构建 fileID -> PostFileResult 映射 + postFileMap := make(map[uint]PostFileResult) + for _, pf := range postFileResults { + postFileMap[pf.FileID] = pf } items := make([]FileItem, 0, len(files)) for _, f := range files { - items = append(items, FileItem{ + item := FileItem{ ID: f.ID, Name: f.FileName, OrigName: f.OrigName, @@ -202,7 +230,13 @@ func ListUploads(c *gin.Context) { MimeType: f.MimeType, UserID: f.UserID, CreatedAt: f.CreatedAt.Format("2006-01-02 15:04:05"), - }) + } + // 填充关联信息 + if pf, ok := postFileMap[f.ID]; ok { + item.PostTitle = pf.PostTitle + item.PostID = pf.PostID + } + items = append(items, item) } c.JSON(http.StatusOK, gin.H{"data": items}) @@ -230,6 +264,14 @@ func DeleteUpload(c *gin.Context) { return } + // 检查文件是否被文章使用 + var count int64 + database.DB.Table("post_files").Where("file_id = ?", fileID).Count(&count) + if count > 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "该文件已被文章使用,无法删除"}) + return + } + // 删除物理文件 physPath := filepath.Join(uploadDir, file.FilePath) if _, err := os.Stat(physPath); err == nil { diff --git a/handlers/frontend/view.go b/handlers/frontend/view.go index 9a88c86..a8b7a62 100644 --- a/handlers/frontend/view.go +++ b/handlers/frontend/view.go @@ -91,7 +91,7 @@ func IndexView(c *gin.Context) { if page < 1 { page = 1 } - pageSize := 10 + pageSize := 5 categoryID, _ := strconv.Atoi(c.Query("category")) tagID, _ := strconv.Atoi(c.Query("tag")) diff --git a/models/models.go b/models/models.go index 72bdf31..e740ac0 100644 --- a/models/models.go +++ b/models/models.go @@ -86,6 +86,7 @@ type Post struct { CategoryID uint `gorm:"index" json:"category_id"` // 增加索引 Category Category `gorm:"foreignKey:CategoryID" json:"category"` Tags []Tag `gorm:"many2many:post_tags;" json:"tags"` + Files []File `gorm:"many2many:post_files;" json:"files,omitempty"` // 关联文件 Views int `gorm:"default:0" json:"views"` Status string `gorm:"index;size:20;default:'draft'" json:"status"` // 增加索引 IsTop bool `gorm:"default:false" json:"is_top"` diff --git a/static/admin/app.js b/static/admin/app.js index 13c134d..9940448 100644 --- a/static/admin/app.js +++ b/static/admin/app.js @@ -298,6 +298,8 @@ async function showPostForm(isEdit = false) { document.getElementById('post-form-title').textContent = isEdit ? '编辑文章' : '新建文章'; document.getElementById('post-form').reset(); document.getElementById('post-id').value = ''; + _coverFileID = null; + _contentFileIDs.clear(); setCover(''); mdShowEdit(); @@ -327,6 +329,13 @@ async function editPost(id) { document.getElementById('post-title').value = post.title; document.getElementById('post-content').value = post.content; document.getElementById('post-summary').value = post.summary || ''; + // 重置文件追踪 + _coverFileID = null; + _contentFileIDs.clear(); + // 从关联文件中恢复追踪 + if (post.files && Array.isArray(post.files)) { + post.files.forEach(f => _contentFileIDs.add(f.id)); + } setCover(post.cover || ''); document.getElementById('post-status').value = post.status; document.getElementById('post-istop').checked = post.is_top; @@ -364,7 +373,8 @@ async function handlePostSubmit(e) { category_id: parseInt(document.getElementById('post-category').value), status: document.getElementById('post-status').value, is_top: document.getElementById('post-istop').checked, - tags: tags + tags: tags, + file_ids: buildFileIDs() }; try { @@ -430,6 +440,87 @@ function mdInsertTable() { ta.selectionStart = ta.selectionEnd = start + table.length; } +// ==================== 图片插入对话框 ==================== + +let _imgFile = null; // 当前选择的图片文件 + +// 打开图片插入对话框 +function mdInsertImage() { + _imgFile = null; + document.getElementById('img-desc').value = ''; + document.getElementById('img-file-name').textContent = '未选择'; + document.getElementById('img-preview-box').style.display = 'none'; + document.getElementById('img-file-input').value = ''; + document.getElementById('img-upload-btn').disabled = true; + document.getElementById('image-modal').classList.remove('hidden'); +} + +// 关闭图片插入对话框 +function hideImageModal() { + document.getElementById('image-modal').classList.add('hidden'); + _imgFile = null; +} + +// 选择图片文件后:校验、预览、启用按钮 +function onImageFileSelected(input) { + const file = input.files[0]; + if (!file) return; + + const err = validateImageFile(file); + if (err) { + alert(err); + input.value = ''; + _imgFile = null; + document.getElementById('img-upload-btn').disabled = true; + return; + } + + _imgFile = file; + document.getElementById('img-file-name').textContent = file.name; + + // 预览 + const reader = new FileReader(); + reader.onload = function(e) { + document.getElementById('img-preview-thumb').src = e.target.result; + document.getElementById('img-preview-box').style.display = 'block'; + }; + reader.readAsDataURL(file); + + document.getElementById('img-upload-btn').disabled = false; +} + +// 确认上传并插入编辑器 +async function confirmInsertImage() { + if (!_imgFile) return; + + const desc = document.getElementById('img-desc').value.trim() || _imgFile.name.replace(/\.[^.]+$/, ''); + const btn = document.getElementById('img-upload-btn'); + btn.disabled = true; + btn.textContent = '上传中...'; + + try { + const data = await uploadImageFile(_imgFile); + // 记录文件 ID + if (data.id) { + _contentFileIDs.add(data.id); + } + // 插入 Markdown 图片语法到编辑器 + mdShowEdit(); + const ta = document.getElementById('post-content'); + const imgMd = ``; + const start = ta.selectionStart; + ta.value = ta.value.substring(0, start) + imgMd + ta.value.substring(ta.selectionEnd); + ta.focus(); + ta.selectionStart = ta.selectionEnd = start + imgMd.length; + + hideImageModal(); + } catch (err) { + alert('上传失败:' + err.message); + btn.disabled = false; + btn.textContent = '上传并插入'; + } +} + // 切换到编辑模式 function mdShowEdit() { document.getElementById('post-content').classList.remove('hidden'); @@ -1478,6 +1569,11 @@ async function saveCurrentFile() { // ==================== 文件上传与管理 ==================== +// 当前封面文件 ID(上传封面时记录) +let _coverFileID = null; +// 正文中插入的图片文件 ID 集合 +let _contentFileIDs = new Set(); + // 允许的图片类型与大小限制(与后端保持一致) const ALLOWED_IMAGE_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']; const MAX_IMAGE_SIZE = 5 * 1024 * 1024; // 5MB @@ -1526,6 +1622,7 @@ async function handleCoverFileChange(e) { try { const data = await uploadImageFile(file); + _coverFileID = data.id || null; setCover(data.url); } catch (uErr) { alert(uErr.message); @@ -1553,9 +1650,19 @@ function setCover(url) { // 移除封面图 function removeCover() { + _coverFileID = null; setCover(''); } +// 构建提交时的 file_ids(封面 + 正文图片,去重) +function buildFileIDs() { + const ids = new Set(_contentFileIDs); + if (_coverFileID) { + ids.add(_coverFileID); + } + return Array.from(ids); +} + // 加载已上传文件列表 async function loadFiles() { try { @@ -1568,19 +1675,33 @@ async function loadFiles() { return; } - grid.innerHTML = data.data.map(f => ` + grid.innerHTML = data.data.map(f => { + // 根据是否关联文章显示不同操作 + let actionsHTML; + if (f.post_title) { + // 已关联文章,显示文章标题 + actionsHTML = `