文章和文件建立关联

This commit is contained in:
2026-08-19 17:39:23 +08:00
parent f1a6c5abaa
commit 4a9ac54963
13 changed files with 256 additions and 28 deletions
+1 -1
View File
@@ -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: "基础设置"},
}},
+49 -3
View File
@@ -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
+44 -2
View File
@@ -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 {
+1 -1
View File
@@ -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"))
+1
View File
@@ -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"`
+128 -7
View File
@@ -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 = `![${desc}](${data.url})`;
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 = `<div class="file-actions">
<button class="btn-small btn-secondary" onclick="copyFileURL('${f.url}')">复制链接</button>
<span class="file-linked">已关联: <a href="#/post/edit/${f.post_id}" title="查看文章">${f.post_title}</a></span>
</div>`;
} else {
// 未关联,显示删除按钮
actionsHTML = `<div class="file-actions">
<button class="btn-small btn-secondary" onclick="copyFileURL('${f.url}')">复制链接</button>
<button class="btn-small btn-danger" onclick="deleteFile(${f.id})">删除</button>
</div>`;
}
return `
<div class="file-card">
<div class="file-thumb"><img src="${f.url}" alt="${f.orig_name || f.name}" loading="lazy"></div>
<div class="file-info">
<div class="file-name" title="${f.orig_name || f.name}">${f.orig_name || f.name}</div>
<div class="file-meta">${formatFileSize(f.size)} · ${f.created_at}</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.id})">删除</button>
</div>
${actionsHTML}
</div>
`).join('');
`;
}).join('');
} catch (err) {
console.error('加载文件失败:', err);
}
+28 -2
View File
@@ -50,7 +50,7 @@
{{if index .PageSet "users"}}{{template "admin_modal_user" .}}{{end}}
{{if index .PageSet "themes"}}{{template "admin_modal_theme" .}}{{end}}
<script>window.__currentUser = {{.CurrentUser}};</script>
<script>window.__currentUser = {{.CurrentUser | json}};</script>
<script src="/static/admin/app.js?v=21"></script>
</body>
</html>
@@ -164,7 +164,7 @@
<button type="button" title="删除线" onclick="mdWrap('~~','~~','删除线文字')"><s>S</s></button>
<button type="button" title="标题" onclick="mdInsertLine('## ','标题')">H</button>
<button type="button" title="链接" onclick="mdWrap('[','](https://)','链接文字')">🔗</button>
<button type="button" title="图片" onclick="mdWrap('![','](https://)','图片描述')">🖼</button>
<button type="button" title="图片" onclick="mdInsertImage()">🖼</button>
<button type="button" title="行内代码" onclick="mdWrap('`','`','代码')">&lt;/&gt;</button>
<button type="button" title="代码块" onclick="mdWrap('\n```\n','\n```\n','代码块')">{ }</button>
<button type="button" title="引用" onclick="mdInsertLine('> ','引用内容')">”</button>
@@ -191,6 +191,32 @@
<button type="submit" class="btn-primary" onclick="mdShowEdit()">保存</button>
</form>
</div>
<!-- 图片插入对话框 -->
<div id="image-modal" class="modal hidden">
<div class="modal-content" style="max-width:420px">
<h3>插入图片</h3>
<div class="form-group">
<label>图片描述</label>
<input type="text" id="img-desc" placeholder="输入图片描述(alt 文本)">
</div>
<div class="form-group">
<label>选择图片</label>
<div style="display:flex;gap:8px;align-items:center">
<button type="button" class="btn-secondary" onclick="document.getElementById('img-file-input').click()">选择文件</button>
<input type="file" id="img-file-input" accept="image/jpeg,image/png,image/gif,image/webp" style="display:none" onchange="onImageFileSelected(this)">
<span id="img-file-name" style="color:#888;font-size:13px">未选择</span>
</div>
<div id="img-preview-box" style="margin-top:8px;display:none">
<img id="img-preview-thumb" style="max-width:100%;max-height:200px;border-radius:4px;border:1px solid #ddd">
</div>
</div>
<div class="modal-actions">
<button type="button" class="btn-secondary" onclick="hideImageModal()">取消</button>
<button type="button" class="btn-primary" id="img-upload-btn" onclick="confirmInsertImage()" disabled>上传并插入</button>
</div>
</div>
</div>
{{end}}
{{define "admin_page_categories"}}
Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

+1 -3
View File
@@ -3,13 +3,11 @@
{{if .Posts}}
{{range .Posts}}
<article class="post-item">
{{if .Cover}}
<div class="post-cover">
<a href="/post/{{.Slug}}">
<img src="{{.Cover}}" alt="{{.Title}}">
<img src="{{if .Cover}}{{.Cover}}{{else}}/static/images/default_cover.webp{{end}}" alt="{{.Title}}">
</a>
</div>
{{end}}
<div class="post-content">
<h2 class="post-title">
<a href="/post/{{.Slug}}">{{.Title}}</a>
+1 -3
View File
@@ -20,11 +20,9 @@
</div>
</header>
{{if .Post.Cover}}
<div class="post-cover">
<img src="{{.Post.Cover}}" alt="{{.Post.Title}}">
<img src="{{if .Post.Cover}}{{.Post.Cover}}{{else}}/static/images/default_cover.webp{{end}}" alt="{{.Post.Title}}">
</div>
{{end}}
<div class="post-body">
{{.Post.Content | markdown}}
+1 -3
View File
@@ -2,13 +2,11 @@
<div class="post-list">
{{range .Posts}}
<article class="post-item">
{{if .Cover}}
<div class="post-cover">
<a href="/post/{{.Slug}}">
<img src="{{.Cover}}" alt="{{.Title}}">
<img src="{{if .Cover}}{{.Cover}}{{else}}/static/images/default_cover.webp{{end}}" alt="{{.Title}}">
</a>
</div>
{{end}}
<div class="post-content">
<h2 class="post-title">
<a href="/post/{{.Slug}}">{{.Title}}</a>
+1 -3
View File
@@ -19,11 +19,9 @@
</div>
</header>
{{if .Post.Cover}}
<div class="post-cover">
<img src="{{.Post.Cover}}" alt="{{.Post.Title}}">
<img src="{{if .Post.Cover}}{{.Post.Cover}}{{else}}/static/images/default_cover.webp{{end}}" alt="{{.Post.Title}}">
</div>
{{end}}
<div class="post-body">
{{.Post.Content | markdown}}