增加图片上传管理

This commit is contained in:
2026-07-28 11:23:21 +08:00
parent 8d0b75e484
commit 786f11a2ca
7 changed files with 545 additions and 4 deletions
BIN
View File
Binary file not shown.
+217
View File
@@ -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": "删除成功"})
}
+5
View File
@@ -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)
}
}
}
+189 -1
View File
@@ -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 = '<p class="no-files">暂无上传文件</p>';
return;
}
grid.innerHTML = data.data.map(f => `
<div class="file-card">
<div class="file-thumb"><img src="${f.url}" alt="${f.name}" loading="lazy"></div>
<div class="file-info">
<div class="file-name" title="${f.name}">${f.name}</div>
<div class="file-meta">${formatFileSize(f.size)} · ${f.mod_time}</div>
</div>
<div class="file-actions">
<button class="btn-small btn-secondary" onclick="copyFileURL('${f.url}')">复制链接</button>
<button class="btn-small btn-danger" onclick="deleteFile('${f.name}')">删除</button>
</div>
</div>
`).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';
}
+26 -3
View File
@@ -21,6 +21,7 @@
<a href="#tags" class="menu-item" data-page="tags">标签管理</a>
<a href="#pages" class="menu-item" data-page="pages">页面管理</a>
<a href="#comments" class="menu-item" data-page="comments">评论管理</a>
<a href="#files" class="menu-item" data-page="files">文件管理</a>
<a href="#themes" class="menu-item" data-page="themes">主题管理</a>
</nav>
<div class="user-info">
@@ -123,8 +124,17 @@
<input type="text" id="post-tags" placeholder="标签1, 标签2, 标签3">
</div>
<div class="form-group">
<label>封面图 URL</label>
<input type="text" id="post-cover" placeholder="https://example.com/image.jpg">
<label>封面图</label>
<input type="hidden" id="post-cover">
<input type="file" id="post-cover-file" accept="image/jpeg,image/png,image/gif,image/webp" style="display:none">
<div class="cover-uploader">
<img id="post-cover-preview" class="cover-preview hidden" src="" alt="封面预览">
<div class="cover-uploader-actions">
<button type="button" class="btn-secondary" onclick="document.getElementById('post-cover-file').click()">选择图片</button>
<button type="button" class="btn-danger hidden" id="post-cover-remove" onclick="removeCover()">移除</button>
<span class="upload-hint">支持 jpg/png/gif/webp,最大 5MB</span>
</div>
</div>
</div>
<div class="form-group">
<label>摘要</label>
@@ -221,6 +231,19 @@
</table>
</div>
<!-- 文件管理 -->
<div id="files-page" class="page hidden">
<div class="page-header">
<h1>文件管理</h1>
<div>
<input type="file" id="file-upload-input" accept="image/jpeg,image/png,image/gif,image/webp" style="display:none" multiple>
<button class="btn-primary" onclick="document.getElementById('file-upload-input').click()">上传图片</button>
</div>
</div>
<p class="upload-hint">支持 jpg/png/gif/webp 格式,单个文件最大 5MB</p>
<div class="file-grid" id="file-grid"></div>
</div>
<!-- 主题管理 -->
<div id="themes-page" class="page hidden">
<div class="page-header">
@@ -325,6 +348,6 @@
</div>
</div>
<script src="/static/admin/app.js?v=9"></script>
<script src="/static/admin/app.js?v=10"></script>
</body>
</html>
+108
View File
@@ -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));
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 369 KiB