diff --git a/goblog.db b/goblog.db index 9a211df..f6c3ad9 100644 Binary files a/goblog.db and b/goblog.db differ diff --git a/handlers/upload.go b/handlers/upload.go new file mode 100644 index 0000000..37eadbe --- /dev/null +++ b/handlers/upload.go @@ -0,0 +1,217 @@ +package handlers + +import ( + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "sort" + "strings" + "time" + + "github.com/gin-gonic/gin" +) + +const ( + // uploadDir 上传文件保存目录 + uploadDir = "./static/uploads" + // uploadURLPrefix 上传文件对外访问的 URL 前缀 + uploadURLPrefix = "/static/uploads/" + // maxUploadSize 单个文件最大大小(5MB) + maxUploadSize = 5 << 20 +) + +// allowedImageExts 允许上传的图片扩展名 +var allowedImageExts = map[string]bool{ + ".jpg": true, + ".jpeg": true, + ".png": true, + ".gif": true, + ".webp": true, +} + +// allowedImageMIMEs 允许上传的图片真实 MIME 类型 +var allowedImageMIMEs = map[string]bool{ + "image/jpeg": true, + "image/png": true, + "image/gif": true, + "image/webp": true, +} + +// UploadedFile 上传文件信息 +type UploadedFile struct { + Name string `json:"name"` + URL string `json:"url"` + Size int64 `json:"size"` + ModTime string `json:"mod_time"` +} + +// sanitizeBaseName 清理文件名(去除扩展名和不安全字符) +func sanitizeBaseName(filename string) string { + base := strings.TrimSuffix(filepath.Base(filename), filepath.Ext(filename)) + base = strings.ReplaceAll(base, " ", "-") + + var b strings.Builder + for _, r := range base { + if r == '-' || r == '_' || + (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') { + b.WriteRune(r) + } + } + + res := b.String() + if res == "" { + res = "image" + } + if len(res) > 40 { + res = res[:40] + } + return res +} + +// UploadImage 上传图片(校验格式与大小) +func UploadImage(c *gin.Context) { + file, err := c.FormFile("file") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "请选择要上传的文件"}) + return + } + + // 校验文件大小 + if file.Size > maxUploadSize { + c.JSON(http.StatusBadRequest, gin.H{ + "error": fmt.Sprintf("文件大小超过限制(最大 %d MB)", maxUploadSize>>20), + }) + return + } + + // 校验扩展名 + ext := strings.ToLower(filepath.Ext(file.Filename)) + if !allowedImageExts[ext] { + c.JSON(http.StatusBadRequest, gin.H{"error": "不支持的图片格式,仅支持 jpg/jpeg/png/gif/webp"}) + return + } + + // 打开文件并检测真实 MIME 类型(防止伪造扩展名) + src, err := file.Open() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "读取文件失败"}) + return + } + defer src.Close() + + head := make([]byte, 512) + n, _ := src.Read(head) + contentType := http.DetectContentType(head[:n]) + if !allowedImageMIMEs[contentType] { + c.JSON(http.StatusBadRequest, gin.H{"error": "文件内容不是有效的图片"}) + return + } + + // 重置读取位置,准备写入 + if _, err := src.Seek(0, io.SeekStart); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "读取文件失败"}) + return + } + + // 确保上传目录存在 + if err := os.MkdirAll(uploadDir, 0o755); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "创建上传目录失败"}) + return + } + + // 生成唯一文件名:时间戳_原始名.扩展名 + filename := fmt.Sprintf("%d_%s%s", time.Now().UnixNano(), sanitizeBaseName(file.Filename), ext) + dstPath := filepath.Join(uploadDir, filename) + + dst, err := os.Create(dstPath) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "保存文件失败"}) + return + } + defer dst.Close() + + if _, err := io.Copy(dst, src); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "保存文件失败"}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "url": uploadURLPrefix + filename, + "name": filename, + "size": file.Size, + }) +} + +// ListUploads 列出已上传的图片 +func ListUploads(c *gin.Context) { + if err := os.MkdirAll(uploadDir, 0o755); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "读取上传目录失败"}) + return + } + + entries, err := os.ReadDir(uploadDir) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "读取上传目录失败"}) + return + } + + files := make([]UploadedFile, 0, len(entries)) + for _, e := range entries { + if e.IsDir() { + continue + } + ext := strings.ToLower(filepath.Ext(e.Name())) + if !allowedImageExts[ext] { + continue + } + info, err := e.Info() + if err != nil { + continue + } + files = append(files, UploadedFile{ + Name: e.Name(), + URL: uploadURLPrefix + e.Name(), + Size: info.Size(), + ModTime: info.ModTime().Format("2006-01-02 15:04:05"), + }) + } + + // 按修改时间倒序(最新的在前) + sort.Slice(files, func(i, j int) bool { + return files[i].ModTime > files[j].ModTime + }) + + c.JSON(http.StatusOK, gin.H{"data": files}) +} + +// DeleteUpload 删除已上传的图片 +func DeleteUpload(c *gin.Context) { + name := c.Param("name") + + // 防止路径穿越 + if name == "" || strings.Contains(name, "..") || strings.ContainsAny(name, `/\`) { + c.JSON(http.StatusBadRequest, gin.H{"error": "非法的文件名"}) + return + } + + ext := strings.ToLower(filepath.Ext(name)) + if !allowedImageExts[ext] { + c.JSON(http.StatusBadRequest, gin.H{"error": "非法的文件类型"}) + return + } + + path := filepath.Join(uploadDir, name) + if _, err := os.Stat(path); err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "文件不存在"}) + return + } + + if err := os.Remove(path); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "删除文件失败"}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "删除成功"}) +} diff --git a/routers/router.go b/routers/router.go index a27c64a..925dd4d 100644 --- a/routers/router.go +++ b/routers/router.go @@ -96,6 +96,11 @@ func SetupRoutes(r *gin.Engine, cfg interface{}) { admin.PUT("/comments/:id/approve", handlers.ApproveComment) admin.PUT("/comments/:id/spam", handlers.MarkSpamComment) admin.DELETE("/comments/:id", handlers.DeleteComment) + + // 文件管理(图片上传) + admin.POST("/upload", handlers.UploadImage) + admin.GET("/uploads", handlers.ListUploads) + admin.DELETE("/uploads/:name", handlers.DeleteUpload) } } } diff --git a/static/admin/app.js b/static/admin/app.js index 63233b0..b9e8c3c 100644 --- a/static/admin/app.js +++ b/static/admin/app.js @@ -42,6 +42,18 @@ document.addEventListener('DOMContentLoaded', () => { // 模态框表单 document.getElementById('modal-form').addEventListener('submit', handleModalSubmit); + + // 封面图上传 + const coverFileInput = document.getElementById('post-cover-file'); + if (coverFileInput) { + coverFileInput.addEventListener('change', handleCoverFileChange); + } + + // 文件管理上传 + const fileUploadInput = document.getElementById('file-upload-input'); + if (fileUploadInput) { + fileUploadInput.addEventListener('change', handleFileUpload); + } }); // 显示登录页 @@ -92,6 +104,9 @@ function showPage(pageName) { case 'comments': loadComments(); break; + case 'files': + loadFiles(); + break; case 'themes': loadThemes(); break; @@ -196,6 +211,7 @@ async function showPostForm(isEdit = false) { document.getElementById('post-form-title').textContent = isEdit ? '编辑文章' : '新建文章'; document.getElementById('post-form').reset(); document.getElementById('post-id').value = ''; + setCover(''); // 加载分类选项 const res = await fetch(`${API_BASE}/categories`); @@ -217,7 +233,7 @@ 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 || ''; - document.getElementById('post-cover').value = post.cover || ''; + setCover(post.cover || ''); document.getElementById('post-status').value = post.status; document.getElementById('post-istop').checked = post.is_top; document.getElementById('post-tags').value = post.tags?.map(t => t.name).join(', ') || ''; @@ -986,3 +1002,175 @@ async function saveCurrentFile() { showThemeMessage('保存文件失败', 'error'); } } + +// ==================== 文件上传与管理 ==================== + +// 允许的图片类型与大小限制(与后端保持一致) +const ALLOWED_IMAGE_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']; +const MAX_IMAGE_SIZE = 5 * 1024 * 1024; // 5MB + +// 校验图片文件,通过返回 null,否则返回错误信息 +function validateImageFile(file) { + if (!ALLOWED_IMAGE_TYPES.includes(file.type)) { + return '不支持的图片格式,仅支持 jpg/png/gif/webp'; + } + if (file.size > MAX_IMAGE_SIZE) { + return '图片大小超过限制(最大 5MB)'; + } + return null; +} + +// 上传图片到服务器,返回 { url, name, size } +async function uploadImageFile(file) { + const formData = new FormData(); + formData.append('file', file); + + const res = await fetch(`${API_BASE}/admin/upload`, { + method: 'POST', + // 注意:上传 FormData 时不能手动设置 Content-Type,浏览器会自动处理 boundary + headers: { 'Authorization': 'Bearer ' + localStorage.getItem('token') }, + body: formData + }); + + const data = await res.json(); + if (!res.ok) { + throw new Error(data.error || '上传失败'); + } + return data; +} + +// 封面图:选择文件后上传 +async function handleCoverFileChange(e) { + const file = e.target.files[0]; + if (!file) return; + + const err = validateImageFile(file); + if (err) { + alert(err); + e.target.value = ''; + return; + } + + try { + const data = await uploadImageFile(file); + setCover(data.url); + } catch (uErr) { + alert(uErr.message); + } + e.target.value = ''; +} + +// 设置封面图 URL 并更新预览 +function setCover(url) { + document.getElementById('post-cover').value = url || ''; + const preview = document.getElementById('post-cover-preview'); + const removeBtn = document.getElementById('post-cover-remove'); + if (!preview || !removeBtn) return; + + if (url) { + preview.src = url; + preview.classList.remove('hidden'); + removeBtn.classList.remove('hidden'); + } else { + preview.src = ''; + preview.classList.add('hidden'); + removeBtn.classList.add('hidden'); + } +} + +// 移除封面图 +function removeCover() { + setCover(''); +} + +// 加载已上传文件列表 +async function loadFiles() { + try { + const res = await fetch(`${API_BASE}/admin/uploads`, { headers: getHeaders() }); + const data = await res.json(); + const grid = document.getElementById('file-grid'); + + if (!data.data || data.data.length === 0) { + grid.innerHTML = '

暂无上传文件

'; + return; + } + + grid.innerHTML = data.data.map(f => ` +
+
${f.name}
+
+
${f.name}
+
${formatFileSize(f.size)} · ${f.mod_time}
+
+
+ + +
+
+ `).join(''); + } catch (err) { + console.error('加载文件失败:', err); + } +} + +// 文件管理:批量上传 +async function handleFileUpload(e) { + const files = Array.from(e.target.files); + if (files.length === 0) return; + + for (const file of files) { + const err = validateImageFile(file); + if (err) { + alert(`${file.name}:${err}`); + continue; + } + try { + await uploadImageFile(file); + } catch (uErr) { + alert(`${file.name}:${uErr.message}`); + } + } + + e.target.value = ''; + loadFiles(); +} + +// 删除已上传文件 +async function deleteFile(name) { + if (!confirm('确定要删除这个文件吗?')) return; + + try { + const res = await fetch(`${API_BASE}/admin/uploads/${encodeURIComponent(name)}`, { + method: 'DELETE', + headers: getHeaders() + }); + + if (res.ok) { + loadFiles(); + } else { + const data = await res.json().catch(() => ({})); + alert(data.error || '删除失败'); + } + } catch (err) { + alert('网络错误'); + } +} + +// 复制文件链接 +function copyFileURL(url) { + const full = window.location.origin + url; + if (navigator.clipboard && navigator.clipboard.writeText) { + navigator.clipboard.writeText(full) + .then(() => alert('链接已复制:' + full)) + .catch(() => prompt('复制链接:', full)); + } else { + prompt('复制链接:', full); + } +} + +// 格式化文件大小 +function formatFileSize(bytes) { + if (bytes < 1024) return bytes + ' B'; + if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'; + return (bytes / (1024 * 1024)).toFixed(1) + ' MB'; +} diff --git a/static/admin/index.html b/static/admin/index.html index fe311e8..1b2cad9 100644 --- a/static/admin/index.html +++ b/static/admin/index.html @@ -21,6 +21,7 @@ 标签管理 页面管理 评论管理 + 文件管理 主题管理
@@ -123,8 +124,17 @@
- - + + + +
+ +
+ + + 支持 jpg/png/gif/webp,最大 5MB +
+
@@ -221,6 +231,19 @@
+ + + - + diff --git a/static/admin/style.css b/static/admin/style.css index 5f78c5b..f03c71f 100644 --- a/static/admin/style.css +++ b/static/admin/style.css @@ -662,3 +662,111 @@ body { min-height: 500px; } } + +/* 通用隐藏 */ +.hidden { + display: none !important; +} + +/* 封面图上传 */ +.cover-uploader { + display: flex; + align-items: flex-start; + gap: 16px; + flex-wrap: wrap; +} + +.cover-preview { + width: 160px; + height: 100px; + object-fit: cover; + border: 1px solid #d9d9d9; + border-radius: 6px; + background: #fafafa; +} + +.cover-uploader-actions { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; +} + +.upload-hint { + color: #999; + font-size: 12px; +} + +/* 文件管理网格 */ +.file-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap: 16px; +} + +.file-card { + background: #fff; + border-radius: 8px; + overflow: hidden; + box-shadow: 0 2px 8px rgba(0,0,0,0.08); + display: flex; + flex-direction: column; + transition: all 0.3s; +} + +.file-card:hover { + box-shadow: 0 4px 12px rgba(0,0,0,0.15); + transform: translateY(-2px); +} + +.file-thumb { + width: 100%; + height: 140px; + background: #f5f5f5; + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; +} + +.file-thumb img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.file-info { + padding: 10px 12px; + flex: 1; +} + +.file-name { + font-size: 13px; + color: #333; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.file-meta { + margin-top: 6px; + font-size: 12px; + color: #999; +} + +.file-actions { + display: flex; + gap: 8px; + padding: 10px 12px; + border-top: 1px solid #f0f0f0; +} + +.file-actions .btn-small { + flex: 1; +} + +@media (max-width: 768px) { + .file-grid { + grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); + } +} diff --git a/static/uploads/1784775619594481200_0722.jpg b/static/uploads/1784775619594481200_0722.jpg new file mode 100644 index 0000000..f58875e Binary files /dev/null and b/static/uploads/1784775619594481200_0722.jpg differ