Author SHA1 Message Date
dsh cc49152f1b fix: 更换头像后 header 头像样式异常——只替换头像容器内容
旧逻辑在头像上传成功后把整个 avatarBtn 按钮的 innerHTML 换成单个
<img>:头像圆形容器(w-8 h-8 rounded-full)、用户名 span 全部丢失,
头像失去圆形裁剪与边框、布局错乱,直到刷新后服务端重新渲染才恢复。

- base.html:头像是像圆形容器加 id="headerAvatarWrap"(服务器渲染结构
  不变,仅加标记)
- profile.html:上传成功后仅更新容器内容(img/svg → 新头像),
  保留按钮结构与用户名文本
2026-08-28 20:26:00 +08:00
kevin a3122b04a4 Merge pull request 'feat: 头像上传纳入统一上传引擎——files 表登记 type=avatars,链接 /uploads/avatars/哈希' (#8) from dsh/go_blog:feat/avatar-unified-upload into main
Reviewed-on: kevin/go_blog#8
2026-08-28 20:18:04 +08:00
dsh 942e3174b9 feat: 头像上传纳入统一上传引擎——files 表登记 type=avatars
- 提取共享上传引擎 saveUploadedFile(handlers/attachment.go):
  内容 SHA-256 寻址写入 + 磁盘去重 + files 表登记,供附件/头像共用
- UploadAvatar 改用该引擎:处理后 JPEG 以哈希命名存储,登记
  models.FileTypeAvatar('avatars')记录;用户 Avatar 字段 = 哈希,
  公开链接 /uploads/avatars/<哈希>(沿用原有 avatars/ 目录与路由)
- 更换头像时:旧登记行软删除;仅当无其他 files 记录或用户引用时
  删除旧磁盘文件(与附件一致的引用计数语义)
- 既有头像安全测试断言从 <uid>.jpg 改为 64 位哈希;
  新增 TestAvatarUploadRegistersFileRow 覆盖登记/磁盘/替换清理全流程
2026-08-28 20:13:13 +08:00
kevin a6c1f92197 Merge pull request 'fix: attachments→files 迁移增加内容回退策略,防重建表后 id 撞号漏搬' (#7) from dsh/go_blog:fix/attachment-migration-content-fallback into main
Reviewed-on: kevin/go_blog#7
2026-08-28 20:04:57 +08:00
kevin 73412e51b4 Merge pull request 'feat: 文章编辑体验升级——编辑器图片上传、作者附件区、作者编辑按钮、表单模板统一' (#6) from dsh/go_blog:feat/article-upload-ui into main
Reviewed-on: kevin/go_blog#6
2026-08-28 20:04:47 +08:00
dsh 30af358dd7 fix: 迁移 attachments→files 增加内容回退策略,防重建表后 id 撞号漏搬
场景:中途部署过旧版二进制时 AutoMigrate 会把 attachments 以
id=1 重新编号;此前按 id 对齐的 NOT EXISTS 会误判已迁移,
导致新上传行静默丢失。现在第二段按 stored_name(内容 SHA-256)
补齐 id 冲突的行(省略 id 由数据库重新分配,内容已登记则跳过)。
2026-08-28 20:03:39 +08:00
dsh d78ed8e2fe refactor: 文章新建/编辑表单收敛为单一模板 article_form,删除两套重复模板
- 新增 templates/partials/article_form.html(define article_form):
  按 .FormIsMy 分支渲染——管理员变体(标签/置顶勾选/草稿发布双按钮/
  /api/admin/articles)与作者变体(状态下拉/保存取消//api/my/articles)
- renderArticleForm / renderMyArticleForm 均渲染 article_form,各自
  设置 FormIsMy=false/true;JS 统一(API_BASE/redirectBase/editor/
  错误提示均单份)
- 删除 templates/admin/article_create.html 与
  templates/user/my_article_form.html(约 360 行 → 一份 ~230 行)
- 新增 TestArticleFormTemplateVariants 断言两个变体渲染差异;
  测试环境注册两个工作区的新建页路由
2026-08-28 20:02:46 +08:00
dsh f5439ae93a feat: 文章详情页编辑按钮按角色放开——作者可编辑自己的文章
- renderArticleDetail 计算 CanEdit/EditURL:管理员(/admin/articles/:id/edit)
  或登录用户且为文章作者(/my/articles/:id/edit)可见
- templates/pages/article.html 改用 .CanEdit/.EditURL 渲染
- my 编辑页/接口本身有 author_id 所有权约束,暴露链接无越权风险
- 新增 TestArticleDetailEditButton:作者/管理员可见、他人/匿名不可见
2026-08-28 20:02:46 +08:00
dsh 134943e4fb feat: 普通用户文章页补齐附件区(上传/列表/插入正文/设封面/删除)
- 新增共享部分模板 templates/partials/article_attachments.html
  (附件区标记:文件名/大小/操作列 + 上传按钮)
- 新增共享 static/js/article-attachments.js:上传(multipart→files 表
  type=attachments)、编辑页回填、插入正文、图片一键设封面、删除;
  uploadURL/listURL/editor/i18n 文案均由页面注入
- 管理员页改用共享 partial+JS(行为不变);同时修复既有 bug:
  表单提交闭包引用未声明的 articleID(作用域错误导致保存静默失败)
- 普通用户页加入附件区,走 /api/my/articles/attachments(新建页
  session_token / 编辑页 article_id),支持一键设封面/插入正文
2026-08-28 20:02:46 +08:00
dsh 6216b9af13 feat: 文章新建/编辑页编辑器支持本地图片上传并插入正文
- 两套页面(admin + 普通用户)EasyMDE 的 image 按钮改为“上传图片”:
  选择本地图片 → 走既有附件接口(files 表 type=attachments,新建页
  session_token / 编辑页 article_id)→ 在正文光标处插入 ![](url)
- templates/layouts/base.html:新增共享 window.blogUploadImage 上传助手
- templates/user/my_article_form.html:新建页 session_token 输入框加 id
  (编辑器上传需要读取);补齐原本缺失的上传能力
- i18n:新增 article_image_upload / article_image_not_image(中英)
- 服务端依既有上传策略校验(扩展名/MIME/魔数/大小),客户端限 image/*
2026-08-28 20:02:46 +08:00
kevin 39609ebf03 Merge pull request 'feat: 全站统一上传文件表 files——上传/下载接口迁移 + attachments 旧表下线' (#5) from dsh/go_blog:feat/unified-files-table into main
Reviewed-on: kevin/go_blog#5
2026-08-28 19:28:21 +08:00
20 changed files with 798 additions and 448 deletions
+4 -2
View File
@@ -119,15 +119,17 @@ func applyFormToData(data gin.H, f articleForm) {
data["SessionToken"] = f.SessionToken
}
// renderArticleForm 使用给定的表单值和可选的错误消息渲染共享的文章表单模板。
// renderArticleForm 使用给定的表单值和可选的错误消息渲染管理员工作区的
// 文章表单(与作者工作区共用一份模板,FormIsMy=false 表示管理员变体)。
func renderArticleForm(c *gin.Context, db *gorm.DB, f articleForm, errMsg string) {
data := DefaultData(c)
data["Title"] = f.TitleText
data["FormIsMy"] = false
if errMsg != "" {
data["Error"] = errMsg
}
applyFormToData(data, f)
c.HTML(http.StatusOK, "article_create", data)
c.HTML(http.StatusOK, "article_form", data)
}
// sessionAuthorID 从会话中提取已登录用户的 ID,兼容 int/uint/int64/float64
+68
View File
@@ -0,0 +1,68 @@
package handlers
import (
"net/http"
"strconv"
"strings"
"testing"
"go_blog/models"
)
// TestArticleDetailEditButton 验证文章页编辑按钮的可见性:
// - 文章作者(普通用户)可见,链接指向 /my/articles/:id/edit
// - 管理员对所有文章可见,链接指向 /admin/articles/:id/edit
// - 其他登录用户与未登录访客不可见
func TestArticleDetailEditButton(t *testing.T) {
e := newSecurityTestEnv(t)
var aliceArt models.Article
if err := e.db.Where("slug = ?", "alice-post").First(&aliceArt).Error; err != nil {
t.Fatalf("alice article not found: %v", err)
}
id := strconv.FormatUint(uint64(aliceArt.ID), 10)
myEdit := "/my/articles/" + id + "/edit"
adminEdit := "/admin/articles/" + id + "/edit"
// 未登录访客:两种链接都不得出现。
w := e.do(http.MethodGet, "/article/alice-post", "", nil, "")
if w.Code != http.StatusOK {
t.Fatalf("anonymous GET article: status = %d", w.Code)
}
if strings.Contains(w.Body.String(), myEdit) || strings.Contains(w.Body.String(), adminEdit) {
t.Fatal("anonymous viewer must not see any edit button")
}
// 文章作者:看到 /my/articles/:id/edit。
alice := e.login(t, "alice")
w = e.do(http.MethodGet, "/article/alice-post", alice, nil, "")
if w.Code != http.StatusOK {
t.Fatalf("author GET article: status = %d", w.Code)
}
if !strings.Contains(w.Body.String(), myEdit) {
t.Fatal("author should see their own edit button")
}
if strings.Contains(w.Body.String(), adminEdit) {
t.Fatal("author must not see the admin edit button")
}
// 其他作者:不可见。
bob := e.login(t, "bob")
w = e.do(http.MethodGet, "/article/alice-post", bob, nil, "")
if w.Code != http.StatusOK {
t.Fatalf("other author GET article: status = %d", w.Code)
}
if strings.Contains(w.Body.String(), myEdit) || strings.Contains(w.Body.String(), adminEdit) {
t.Fatal("other author must not see the edit button")
}
// 管理员:看到 /admin/articles/:id/edit。
admin := e.login(t, "admin")
w = e.do(http.MethodGet, "/article/alice-post", admin, nil, "")
if w.Code != http.StatusOK {
t.Fatalf("admin GET article: status = %d", w.Code)
}
if !strings.Contains(w.Body.String(), adminEdit) {
t.Fatal("admin should see the admin edit button")
}
}
+49
View File
@@ -0,0 +1,49 @@
package handlers
import (
"net/http"
"strings"
"testing"
)
// TestArticleFormTemplateVariants 验证管理员与作者工作区共用同一份
// article_form 模板(templates/partials/article_form.html)时的两个变体:
// - 管理员:含置顶勾选(is_top/ /api/admin/articles 前缀,作者 API 不出现
// - 作者:含状态下拉 / /api/my/articles 前缀,置顶与管理员 API 不出现
func TestArticleFormTemplateVariants(t *testing.T) {
e := newSecurityTestEnv(t)
// 管理员新建页(FormIsMy=false 变体)。
admin := e.login(t, "admin")
w := e.do(http.MethodGet, "/admin/articles/new", admin, nil, "")
if w.Code != http.StatusOK {
t.Fatalf("admin GET /admin/articles/new: status = %d", w.Code)
}
body := w.Body.String()
for _, want := range []string{`id="articleForm"`, `id="articleSessionToken"`, "isTopCheckbox", `"/api/admin/articles"`} {
if !strings.Contains(body, want) {
t.Fatalf("admin variant missing %q", want)
}
}
if strings.Contains(body, "/api/my/articles") {
t.Fatal("admin variant must not reference the author API")
}
// 作者新建页(FormIsMy=true 变体)。
alice := e.login(t, "alice")
w = e.do(http.MethodGet, "/my/articles/new", alice, nil, "")
if w.Code != http.StatusOK {
t.Fatalf("author GET /my/articles/new: status = %d", w.Code)
}
body = w.Body.String()
for _, want := range []string{`id="articleForm"`, `id="articleSessionToken"`, `"/api/my/articles"`, `name="status"`} {
if !strings.Contains(body, want) {
t.Fatalf("author variant missing %q", want)
}
}
for _, forbid := range []string{"isTopCheckbox", "/api/admin/articles"} {
if strings.Contains(body, forbid) {
t.Fatalf("author variant must not contain %q", forbid)
}
}
}
+26 -16
View File
@@ -139,20 +139,6 @@ func UploadAttachment(db *gorm.DB, storagePath string) gin.HandlerFunc {
sum := sha256.Sum256(content)
stored := hex.EncodeToString(sum[:])
// 磁盘去重:仅在文件不存在时才写入。
dir := attachmentsDir(storagePath)
if err := os.MkdirAll(dir, 0755); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create storage dir"})
return
}
dstPath := filepath.Join(dir, stored)
if _, err := os.Stat(dstPath); os.IsNotExist(err) {
if err := os.WriteFile(dstPath, content, 0644); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save file"})
return
}
}
ext := strings.ToLower(filepath.Ext(header.Filename))
att := models.File{
Type: models.FileTypeAttachment,
@@ -166,8 +152,9 @@ func UploadAttachment(db *gorm.DB, storagePath string) gin.HandlerFunc {
Size: header.Size,
Category: check.Type.Category,
}
if err := db.Create(&att).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to record attachment"})
// 统一上传引擎:磁盘去重写入(SHA-256 内容寻址)+ files 表登记。
if _, err := saveUploadedFile(db, att, attachmentsDir(storagePath), content); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save attachment"})
return
}
@@ -182,6 +169,29 @@ func UploadAttachment(db *gorm.DB, storagePath string) gin.HandlerFunc {
}
}
// saveUploadedFile 是全站统一上传引擎(files 表 + 内容寻址磁盘存储):
// - 确保 dir 存在;
// - 以 f.StoredName(内容 SHA-256 十六进制)为磁盘文件名,文件已存在则
// 跳过写入(磁盘去重,内容寻址文件可被多行记录共享);
// - 在 files 表登记一条记录(Type/UploaderID/ArticleID 等由调用方给定)。
//
// 附件、头像等所有上传类型共用本引擎。
func saveUploadedFile(db *gorm.DB, f models.File, dir string, content []byte) (models.File, error) {
if err := os.MkdirAll(dir, 0755); err != nil {
return f, err
}
dstPath := filepath.Join(dir, f.StoredName)
if _, err := os.Stat(dstPath); os.IsNotExist(err) {
if err := os.WriteFile(dstPath, content, 0644); err != nil {
return f, err
}
}
if err := db.Create(&f).Error; err != nil {
return f, err
}
return f, nil
}
// ---------------- 删除 ----------------
// DeleteAttachment 软删除附件记录(files 表,Type=attachments),仅当
+167
View File
@@ -0,0 +1,167 @@
package handlers
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"image"
"image/color"
"image/png"
"mime/multipart"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"go_blog/models"
)
// newTestPNG 生成一张指定大小的纯色 PNG。
func newTestPNG(t *testing.T, w, h int, c color.RGBA) []byte {
t.Helper()
img := image.NewRGBA(image.Rect(0, 0, w, h))
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
img.Set(x, y, c)
}
}
var buf bytes.Buffer
if err := png.Encode(&buf, img); err != nil {
t.Fatalf("encode png: %v", err)
}
return buf.Bytes()
}
// isHexString 报告字符串是否全部为十六进制字符。
func isHexString(s string) bool {
if s == "" {
return false
}
for _, r := range s {
if !strings.ContainsRune("0123456789abcdef", r) {
return false
}
}
return true
}
// avatarPOST 以 multipart 提交头像上传(表单字段名 avatar)。
func avatarPOST(e *securityTestEnv, cookie, csrf string, img []byte, filename string) *httptest.ResponseRecorder {
var buf bytes.Buffer
mw := multipart.NewWriter(&buf)
fw, _ := mw.CreateFormFile("avatar", filename)
_, _ = fw.Write(img)
_ = mw.Close()
req := httptest.NewRequest(http.MethodPost, "/api/profile/avatar", &buf)
req.Header.Set("Content-Type", mw.FormDataContentType())
req.Header.Set("X-CSRF-Token", csrf)
req.Header.Set("Cookie", cookie)
w := httptest.NewRecorder()
e.router.ServeHTTP(w, req)
return w
}
// TestAvatarUploadRegistersFileRow 验证头像上传走统一上传引擎:
// files 表登记 type=avatars、stored_name=处理内容 SHA-256 哈希、
// 用户 Avatar 字段与磁盘文件 avatars/<哈希>、公开 URL /uploads/avatars/<哈希>。
// 更换头像时:旧登记行软删除;旧磁盘文件无其他引用即清理。
func TestAvatarUploadRegistersFileRow(t *testing.T) {
e := newSecurityTestEnv(t)
// 允许 .png(平台策略允许的图片类型)。
_ = e.db.Create(&models.UploadFileType{Extension: ".png", MimeType: "image/png", Category: models.CategoryImage, Enabled: true})
models.LoadConfigCache(e.db)
alice := e.login(t, "alice")
token := e.csrfTokenFor(t, alice)
aliceID := userIDByUsername(t, e.db, "alice")
// --- 第一次上传 ---
img1 := newTestPNG(t, 16, 16, color.RGBA{R: 200, G: 30, B: 30, A: 255})
w := avatarPOST(e, alice, token, img1, "avatar.png")
if w.Code != http.StatusOK {
t.Fatalf("avatar upload: status = %d, body %s", w.Code, w.Body.String())
}
var resp struct {
Avatar string `json:"avatar"`
}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode response: %v", err)
}
if len(resp.Avatar) != 64 || !isHexString(resp.Avatar) {
t.Fatalf("avatar stored name = %q, want 64-char sha256 hex", resp.Avatar)
}
name1 := resp.Avatar
// files 表记录:type=avatars + 哈希存储名 + 归属上传者。
var f models.File
if err := e.db.Where("type = ? AND stored_name = ?", models.FileTypeAvatar, name1).First(&f).Error; err != nil {
t.Fatalf("files row (type=avatars) not found: %v", err)
}
if f.UploaderID != aliceID {
t.Fatalf("uploader_id = %d, want %d", f.UploaderID, aliceID)
}
if f.Category != models.CategoryImage || f.Ext != ".jpg" || f.MIME != "image/jpeg" {
t.Fatalf("avatar row category/ext/mime = %q/%q/%q", f.Category, f.Ext, f.MIME)
}
// 用户记录头像 = 哈希。
var u models.User
if err := e.db.First(&u, aliceID).Error; err != nil {
t.Fatalf("load user: %v", err)
}
if u.Avatar != name1 {
t.Fatalf("user.Avatar = %q, want %q", u.Avatar, name1)
}
// 磁盘文件位于 avatars/<哈希>,内容为处理后的 256x256 JPEG,且与哈希一致。
diskPath := filepath.Join(e.storageDir, "avatars", name1)
raw, err := os.ReadFile(diskPath)
if err != nil {
t.Fatalf("avatar disk file missing: %v", err)
}
if len(raw) == 0 {
t.Fatal("avatar disk file is empty")
}
if sum := sha256.Sum256(raw); hex.EncodeToString(sum[:]) != name1 {
t.Fatal("disk file content does not match stored_name (sha256)")
}
// --- 更换头像 ---
img2 := newTestPNG(t, 16, 16, color.RGBA{R: 30, G: 30, B: 200, A: 255})
w = avatarPOST(e, alice, token, img2, "avatar2.png")
if w.Code != http.StatusOK {
t.Fatalf("second avatar upload: status = %d, body %s", w.Code, w.Body.String())
}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode second response: %v", err)
}
if resp.Avatar == "" || resp.Avatar == name1 {
t.Fatalf("second avatar name = %q, want a new hash", resp.Avatar)
}
name2 := resp.Avatar
// 旧的 avatars 登记行已软删除(活动查询不可见,Unscoped 可见且 DeletedAt 非空)。
var oldRow models.File
if err := e.db.Unscoped().Where("type = ? AND stored_name = ?", models.FileTypeAvatar, name1).First(&oldRow).Error; err != nil {
t.Fatalf("old avatar row not found (unscoped): %v", err)
}
if !oldRow.DeletedAt.Valid {
t.Fatal("old avatar row should be soft-deleted")
}
var active models.File
if err := e.db.Where("type = ? AND stored_name = ?", models.FileTypeAvatar, name2).First(&active).Error; err != nil {
t.Fatalf("new avatar row not found: %v", err)
}
// 旧磁盘文件无其他引用,应被清理。
if _, err := os.Stat(filepath.Join(e.storageDir, "avatars", name1)); !os.IsNotExist(err) {
t.Fatal("old avatar disk file should have been removed")
}
if _, err := os.Stat(filepath.Join(e.storageDir, "avatars", name2)); err != nil {
t.Fatalf("new avatar disk file missing: %v", err)
}
}
+17
View File
@@ -248,6 +248,23 @@ func renderArticleDetail(c *gin.Context, db *gorm.DB, article *models.Article, f
data["CommentError"] = formErr
data["CommentNotice"] = notice
data["MaxCommentLength"] = MaxCommentLength
// 文章页编辑按钮:管理员可编辑全部文章;登录用户仅可编辑自己的文章
// (普通作者跳转 /my/articles/:id/edit,编辑页/接口均有 author_id 所有权约束)。
canEdit := false
editURL := ""
uid := userIDFromSession(c)
role, _ := c.Get("role")
if r, _ := role.(string); r == models.RoleAdmin {
canEdit = true
editURL = fmt.Sprintf("/admin/articles/%d/edit", article.ID)
} else if uid != 0 && uid == article.AuthorID {
canEdit = true
editURL = fmt.Sprintf("/my/articles/%d/edit", article.ID)
}
data["CanEdit"] = canEdit
data["EditURL"] = editURL
c.HTML(http.StatusOK, "article", data)
}
+4 -2
View File
@@ -177,13 +177,15 @@ func MyArticleDelete(db *gorm.DB) gin.HandlerFunc {
}
}
// renderMyArticleForm 为普通用户渲染文章表单
// renderMyArticleForm 为普通用户渲染文章表单
// (与管理员工作区共用一份模板,FormIsMy=true 表示作者变体)。
func renderMyArticleForm(c *gin.Context, db *gorm.DB, f articleForm, errMsg string) {
data := DefaultData(c)
data["Title"] = f.TitleText
data["FormIsMy"] = true
if errMsg != "" {
data["Error"] = errMsg
}
applyFormToData(data, f)
c.HTML(http.StatusOK, "my_article_form", data)
c.HTML(http.StatusOK, "article_form", data)
}
+38 -18
View File
@@ -2,6 +2,8 @@ package handlers
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"fmt"
"image"
"image/jpeg"
@@ -219,33 +221,51 @@ func UploadAvatar(db *gorm.DB, storagePath string) gin.HandlerFunc {
return
}
// 确保头像目录存在。
avatarDir := filepath.Join(storagePath, "avatars")
os.MkdirAll(avatarDir, 0755)
// 统一上传引擎:SHA-256 内容寻址写入 avatars/ 目录,并在 files 表
// 登记一条记录(Type=avatars)。公开链接 /uploads/avatars/<哈希>。
sum := sha256.Sum256(processedBytes)
storedName := hex.EncodeToString(sum[:])
// 删除旧头像文件。
if user.Avatar != "" {
oldPath := filepath.Join(avatarDir, user.Avatar)
os.Remove(oldPath)
oldAvatar := user.Avatar // 替换前的旧头像(旧命名 <uid>.jpg 或哈希)
f := models.File{
Type: models.FileTypeAvatar,
UploaderID: user.ID,
Filename: header.Filename,
StoredName: storedName,
Ext: finalExt,
MIME: "image/jpeg",
Size: int64(len(processedBytes)),
Category: models.CategoryImage,
}
// 保存处理后的头像。
savedName := fmt.Sprintf("%d%s", user.ID, finalExt)
savedPath := filepath.Join(avatarDir, savedName)
if err := os.WriteFile(savedPath, processedBytes, 0644); err != nil {
if _, err := saveUploadedFile(db, f, filepath.Join(storagePath, "avatars"), processedBytes); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save avatar"})
return
}
// 更新用户记录。
user.Avatar = savedName
// 更新用户记录与会话(Avatar 存哈希,公开 URL /uploads/avatars/<哈希>
user.Avatar = storedName
db.Save(&user)
// 更新会话。
session.Set("avatar", savedName)
session.Set("avatar", storedName)
session.Save()
c.JSON(http.StatusOK, gin.H{"avatar": savedName})
// 清理旧的头像记录与文件:
// - 软删除旧的 avatars 登记行(保留历史查询痕迹,仅从活动查询隐藏);
// - 仅当无其他 files 记录或其他用户引用时删除磁盘文件,
// 避免误删被共享的内容寻址文件(引用计数语义与附件一致)。
if oldAvatar != "" && oldAvatar != storedName {
db.Where("type = ? AND stored_name = ?", models.FileTypeAvatar, oldAvatar).
Delete(&models.File{})
var refs int64
db.Model(&models.File{}).Where("type = ? AND stored_name = ?", models.FileTypeAvatar, oldAvatar).Count(&refs)
var otherUsers int64
db.Model(&models.User{}).Where("avatar = ? AND id <> ?", oldAvatar, user.ID).Count(&otherUsers)
if refs == 0 && otherUsers == 0 {
os.Remove(filepath.Join(storagePath, "avatars", oldAvatar)) // 忽略错误
}
}
c.JSON(http.StatusOK, gin.H{"avatar": storedName})
}
}
+3
View File
@@ -90,6 +90,7 @@ func newSecurityTestEnv(t *testing.T) *securityTestEnv {
r.GET("/login", LoginPage())
r.GET("/register", RegisterPage(db))
r.GET("/rss", RSSFeed(db))
r.GET("/article/:slug", ArticleDetail(db))
api := r.Group("/api")
{
@@ -105,6 +106,7 @@ func newSecurityTestEnv(t *testing.T) *securityTestEnv {
uid, _ := sessionAuthorID(c)
c.String(http.StatusOK, "uid=%d", uid)
})
protected.GET("/articles/new", MyArticleCreatePage(db))
}
myAPI := r.Group("/api/my/articles", middleware.AuthRequired(db))
@@ -138,6 +140,7 @@ func newSecurityTestEnv(t *testing.T) *securityTestEnv {
{
admin.GET("/users/:id/edit", UserEditPage(db))
admin.GET("/comments", CommentListPage(db))
admin.GET("/articles/new", ArticleCreatePage(db))
}
usersAPI := r.Group("/api/admin/users", middleware.AuthRequired(db), middleware.AdminRequired(db))
+4 -5
View File
@@ -2,7 +2,6 @@ package handlers
import (
"bytes"
"fmt"
"image"
"image/color"
"image/png"
@@ -239,8 +238,8 @@ func TestUploadAvatarRejectsNonImage(t *testing.T) {
t.Fatalf("upload valid png: status=%d body=%s", w.Code, w.Body.String())
}
alice := reloadAlice(t, e)
if want := fmt.Sprintf("%d.jpg", alice.ID); alice.Avatar != want {
t.Fatalf("avatar = %q, want %q", alice.Avatar, want)
if len(alice.Avatar) != 64 || !isHexString(alice.Avatar) {
t.Fatalf("avatar = %q, want 64-char sha256 hex (hash-based name)", alice.Avatar)
}
if _, err := os.Stat(filepath.Join(e.storageDir, "avatars", alice.Avatar)); err != nil {
t.Fatalf("processed avatar file missing: %v", err)
@@ -272,8 +271,8 @@ func TestUpdateProfileAvatarRejectsNonImage(t *testing.T) {
t.Fatalf("upload valid avatar: status=%d body=%s", w.Code, w.Body.String())
}
alice := reloadAlice(t, e)
if want := fmt.Sprintf("%d.jpg", alice.ID); alice.Avatar != want {
t.Fatalf("avatar = %q, want %q", alice.Avatar, want)
if len(alice.Avatar) != 64 || !isHexString(alice.Avatar) {
t.Fatalf("avatar = %q, want 64-char sha256 hex (hash-based name)", alice.Avatar)
}
if _, err := os.Stat(filepath.Join(e.storageDir, "avatars", alice.Avatar)); err != nil {
t.Fatalf("processed avatar file missing: %v", err)
+4
View File
@@ -185,6 +185,8 @@ var translations = map[Lang]map[string]string{
"article_att_uploading": "Uploading...",
"article_att_error": "Upload failed. Please try again.",
"article_att_delete_confirm": "Delete this attachment?",
"article_image_upload": "Upload image",
"article_image_not_image": "The file is not a valid image.",
// 文章管理
"article_list_title": "Articles",
@@ -626,6 +628,8 @@ var translations = map[Lang]map[string]string{
"article_att_uploading": "上传中...",
"article_att_error": "上传失败,请重试。",
"article_att_delete_confirm": "删除该附件?",
"article_image_upload": "上传图片",
"article_image_not_image": "该文件不是有效的图片。",
// 文章管理
"article_list_title": "文章管理",
+4
View File
@@ -45,6 +45,10 @@ func (File) TableName() string {
// FileTypeAttachment 是文件归属类型常量:文章附件(原 attachments 表历史数据)。
const FileTypeAttachment = "attachments"
// FileTypeAvatar 是文件归属类型常量:用户头像(存储于 avatars/ 目录,
// 公开下载链接为 /uploads/avatars/<stored_name>)。
const FileTypeAvatar = "avatars"
// IsImage 报告该文件是否为图片(用于决定 Markdown 插入形式:![]() 还是 []())。
func (f *File) IsImage() bool {
return f.Category == CategoryImage
+134
View File
@@ -0,0 +1,134 @@
// 文章附件区共享逻辑(管理员与普通用户的文章新建/编辑页共用):
// 上传(multipart → 附件接口,写 files 表 type=attachments)、编辑页回填列表、
// 插入正文(图片 ![]() / 其他 []())、图片一键设封面、删除(引用计数由服务端处理)。
//
// 由页面调用:initArticleAttachments(cfg)
// cfg:
// uploadURL 上传接口,如 "/api/my/articles/attachments"DELETE 为 uploadURL + "/<id>"
// listURL 列表接口模板,":id" 会被替换为文章 id,如 "/api/my/articles/:id/attachments"
// editor EasyMDE 实例(用于在光标处插入 Markdown)
// articleID 文章 id(编辑页);新建页为 0
// sessionToken 新建页的临时归属令牌
// texts 页面 i18n 文案:{pick, uploading, insert, setCover, coverSet, del, delConfirm, err}
window.initArticleAttachments = function (cfg) {
var uploadBtn = document.getElementById('attachmentUploadBtn');
var fileInput = document.getElementById('attachmentInput');
var msgEl = document.getElementById('attachmentMsg');
var listEl = document.getElementById('attachmentList');
var texts = cfg.texts || {};
if (!uploadBtn || !listEl || !fileInput) { return; }
var meta = document.querySelector('meta[name="csrf-token"]');
var csrfToken = meta ? meta.getAttribute('content') : '';
// Enable the uploader (uploads allowed only while logged in, which is true here).
uploadBtn.disabled = false;
fileInput.disabled = false;
function fmtSize(b) {
if (b < 1024) { return b + ' B'; }
var u = ['KiB', 'MiB', 'GiB'], i = -1;
do { b /= 1024; i++; } while (b >= 1024 && i < u.length - 1);
return b.toFixed(1) + ' ' + u[i];
}
function insertMd(md) {
var cm = cfg.editor && cfg.editor.codemirror;
if (!cm) { return; }
cm.replaceSelection(md + '\n');
cm.focus();
}
function addRow(att) {
var tr = document.createElement('tr');
tr.className = 'hover:bg-gray-50';
tr.dataset.id = att.id;
var nameTd = document.createElement('td');
nameTd.className = 'px-3 py-2 text-sm text-gray-800';
var link = document.createElement('a');
link.href = att.url; link.target = '_blank'; link.textContent = att.filename;
nameTd.appendChild(link);
var sizeTd = document.createElement('td');
sizeTd.className = 'px-3 py-2 text-sm text-gray-500';
sizeTd.textContent = fmtSize(att.size);
var actTd = document.createElement('td');
actTd.className = 'px-3 py-2 text-sm text-right whitespace-nowrap';
var insBtn = document.createElement('button');
insBtn.type = 'button';
insBtn.textContent = texts.insert || 'Insert';
insBtn.className = 'text-blue-600 hover:text-blue-800 font-medium mr-3 cursor-pointer bg-transparent border-none';
insBtn.onclick = function () {
var md = att.is_image
? '![' + att.filename + '](' + att.url + ')'
: '[' + att.filename + '](' + att.url + ')';
insertMd(md);
};
// Images: extra button to copy the URL into the cover field.
var coverBtn = null;
if (att.is_image) {
coverBtn = document.createElement('button');
coverBtn.type = 'button';
coverBtn.textContent = texts.setCover || 'Cover';
coverBtn.className = 'text-green-600 hover:text-green-800 font-medium mr-3 cursor-pointer bg-transparent border-none';
coverBtn.onclick = function () {
var cover = document.querySelector('input[name="cover"]');
if (cover) { cover.value = att.url; msgEl.textContent = texts.coverSet || ''; }
};
}
var delBtn = document.createElement('button');
delBtn.type = 'button';
delBtn.textContent = texts.del || 'Delete';
delBtn.className = 'text-red-600 hover:text-red-800 font-medium cursor-pointer bg-transparent border-none';
delBtn.onclick = function () {
if (!confirm(texts.delConfirm || 'Delete?')) { return; }
fetch(cfg.uploadURL + '/' + att.id, {
method: 'DELETE',
headers: { 'X-CSRF-Token': csrfToken }
})
.then(function (r) { return r.json(); })
.then(function (r) {
if (r.ok) { tr.remove(); }
else { msgEl.textContent = r.error || 'error'; }
});
};
actTd.appendChild(insBtn);
if (coverBtn) { actTd.appendChild(coverBtn); }
actTd.appendChild(delBtn);
tr.appendChild(nameTd);
tr.appendChild(sizeTd);
tr.appendChild(actTd);
listEl.appendChild(tr);
}
uploadBtn.addEventListener('click', function () {
if (!fileInput.files.length) { msgEl.textContent = texts.pick || 'Pick a file.'; return; }
var fd = new FormData();
fd.append('file', fileInput.files[0]);
if (cfg.articleID) { fd.append('article_id', cfg.articleID); }
else if (cfg.sessionToken) { fd.append('session_token', cfg.sessionToken); }
msgEl.textContent = texts.uploading || 'Uploading...';
fetch(cfg.uploadURL, {
method: 'POST',
headers: { 'X-CSRF-Token': csrfToken },
body: fd
})
.then(function (r) { return r.json(); })
.then(function (r) {
if (r.error) { msgEl.textContent = r.error; return; }
msgEl.textContent = '';
addRow(r);
fileInput.value = '';
})
.catch(function () { msgEl.textContent = texts.err || 'Upload failed.'; });
});
// Edit page: load existing attachments.
if (cfg.articleID) {
fetch(cfg.listURL.replace(':id', cfg.articleID))
.then(function (r) { return r.json(); })
.then(function (r) {
(r.attachments || []).forEach(addRow);
});
}
};
-279
View File
@@ -1,279 +0,0 @@
{{define "article_create"}}
{{template "header" .}}
{{template "markdown_assets" .}}
<section class="max-w-3xl mx-auto px-4 py-10">
<h2 class="text-2xl font-bold text-gray-900 mb-8">{{.FormTitleText}}</h2>
<div id="articleError" class="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg mb-6 text-sm {{if not .Error}}hidden{{end}}">
{{.Error}}
</div>
<form id="articleForm" action="{{.FormAction}}" method="post" class="space-y-6">
<input type="hidden" name="_csrf" value="{{.CSRFToken}}">
<!-- Hidden: attachment ownership (token on create, id on edit) -->
<input type="hidden" name="session_token" value="{{.SessionToken}}">
<!-- Title -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">{{index .Tr "article_title"}}</label>
<input type="text" name="title" value="{{.FormTitle}}"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-colors"
placeholder="{{index .Tr "article_title"}}">
</div>
<!-- Slug -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">{{index .Tr "article_slug"}}</label>
<input type="text" name="slug" value="{{.FormSlug}}"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-colors"
placeholder="{{index .Tr "article_slug_hint"}}">
<p class="text-xs text-gray-400 mt-1">{{index .Tr "article_slug_hint"}}</p>
</div>
<!-- Summary -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">{{index .Tr "article_summary"}}</label>
<textarea name="summary" rows="3"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-colors resize-y"
placeholder="{{index .Tr "article_summary"}}">{{.FormSummary}}</textarea>
</div>
<!-- Content (EasyMDE Markdown Editor) -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">{{index .Tr "article_content"}}</label>
<textarea id="articleContent" name="content">{{.FormContent}}</textarea>
</div>
<!-- Cover -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">{{index .Tr "article_cover"}}</label>
<input type="text" name="cover" value="{{.FormCover}}"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-colors"
placeholder="https://...">
</div>
<!-- Tags -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">{{index .Tr "article_tags"}}</label>
<input type="text" name="tags" value="{{.FormTags}}"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-colors"
placeholder="{{index .Tr "article_tags_hint"}}">
<p class="text-xs text-gray-400 mt-1">{{index .Tr "article_tags_hint"}}</p>
</div>
<!-- Attachments -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">{{index .Tr "article_attachments"}}</label>
<div class="flex items-center gap-3 mb-3">
<input type="file" id="attachmentInput" class="text-sm text-gray-600" disabled>
<button type="button" id="attachmentUploadBtn"
class="px-4 py-2 bg-gray-200 text-gray-700 rounded-lg font-medium hover:bg-gray-300 transition-colors cursor-pointer disabled:opacity-50"
disabled>
{{index .Tr "article_upload"}}
</button>
<span id="attachmentMsg" class="text-xs text-gray-400"></span>
</div>
<table class="w-full text-left border border-gray-200 rounded-lg overflow-hidden">
<thead class="bg-gray-50 border-b border-gray-200">
<tr>
<th class="px-3 py-2 text-xs font-semibold text-gray-600">{{index .Tr "article_att_name"}}</th>
<th class="px-3 py-2 text-xs font-semibold text-gray-600">{{index .Tr "article_att_size"}}</th>
<th class="px-3 py-2 text-xs font-semibold text-gray-600 text-right">{{index .Tr "settings_actions"}}</th>
</tr>
</thead>
<tbody id="attachmentList" class="divide-y divide-gray-100"></tbody>
</table>
</div>
<!-- Published At -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">{{index .Tr "article_published_at"}}</label>
<input type="datetime-local" name="published_at" value="{{.FormPublishedAt}}"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-colors">
<p class="text-xs text-gray-400 mt-1">{{index .Tr "article_published_at_hint"}}</p>
</div>
<!-- IsTop -->
<div class="flex items-center gap-2">
<input type="checkbox" name="is_top" value="1" id="isTopCheckbox" {{if .FormIsTop}}checked{{end}}
class="w-4 h-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500">
<label for="isTopCheckbox" class="text-sm font-medium text-gray-700">{{index .Tr "article_is_top"}}</label>
</div>
<!-- Submit Buttons -->
<div class="flex gap-3">
<button type="submit" name="status" value="0"
class="px-6 py-3 bg-gray-200 text-gray-700 rounded-lg font-medium hover:bg-gray-300 transition-colors cursor-pointer">
{{index .Tr "article_save_draft"}}
</button>
<button type="submit" name="status" value="1"
class="px-6 py-3 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 transition-colors cursor-pointer">
{{index .Tr "article_publish"}}
</button>
</div>
</form>
</section>
{{template "footer" .}}
<script>
var easyMDE = new EasyMDE({
element: document.getElementById('articleContent'),
autoDownloadFontAwesome: false,
spellChecker: false,
autosave: { enabled: false },
placeholder: '{{index .Tr "article_content"}}',
previewRender: function (plainText, preview) {
return '<div class="md-body">' + BlogMD.render(plainText) + '</div>';
},
toolbar: [
'bold', 'italic', 'heading', '|',
'quote', 'unordered-list', 'ordered-list', '|',
'link', 'image', 'code', 'table', '|',
'preview', 'side-by-side', 'fullscreen', '|',
'guide'
],
status: false,
minHeight: '300px'
});
// ---- Attachments ----
(function () {
var articleID = {{ if .FormArticleID }}{{ .FormArticleID }}{{ else }}0{{ end }};
var sessionToken = "{{ .SessionToken }}";
var csrfTokenEl = document.querySelector('meta[name="csrf-token"]');
var csrfToken = csrfTokenEl ? csrfTokenEl.getAttribute('content') : '';
var uploadBtn = document.getElementById('attachmentUploadBtn');
var fileInput = document.getElementById('attachmentInput');
var msgEl = document.getElementById('attachmentMsg');
var listEl = document.getElementById('attachmentList');
// Enable the uploader (uploads allowed only while logged in, which is true here).
uploadBtn.disabled = false;
fileInput.disabled = false;
function fmtSize(b) {
if (b < 1024) return b + ' B';
var u = ['KiB', 'MiB', 'GiB'], i = -1;
do { b /= 1024; i++; } while (b >= 1024 && i < u.length - 1);
return b.toFixed(1) + ' ' + u[i];
}
function addRow(att) {
var tr = document.createElement('tr');
tr.className = 'hover:bg-gray-50';
tr.dataset.id = att.id;
var nameTd = document.createElement('td');
nameTd.className = 'px-3 py-2 text-sm text-gray-800';
var link = document.createElement('a');
link.href = att.url; link.target = '_blank'; link.textContent = att.filename;
nameTd.appendChild(link);
var sizeTd = document.createElement('td');
sizeTd.className = 'px-3 py-2 text-sm text-gray-500';
sizeTd.textContent = fmtSize(att.size);
var actTd = document.createElement('td');
actTd.className = 'px-3 py-2 text-sm text-right whitespace-nowrap';
var insBtn = document.createElement('button');
insBtn.type = 'button';
insBtn.textContent = "{{index .Tr "article_att_insert"}}";
insBtn.className = 'text-blue-600 hover:text-blue-800 font-medium mr-3 cursor-pointer bg-transparent border-none';
insBtn.onclick = function () {
var md = att.is_image
? '![' + att.filename + '](' + att.url + ')'
: '[' + att.filename + '](' + att.url + ')';
var cm = easyMDE.codemirror;
cm.replaceSelection(md + '\n');
cm.focus();
};
// Images: extra button to copy the URL into the cover field.
var coverBtn = null;
if (att.is_image) {
coverBtn = document.createElement('button');
coverBtn.type = 'button';
coverBtn.textContent = "{{index .Tr "article_att_set_cover"}}";
coverBtn.className = 'text-green-600 hover:text-green-800 font-medium mr-3 cursor-pointer bg-transparent border-none';
coverBtn.onclick = function () {
var cover = document.querySelector('input[name="cover"]');
if (cover) { cover.value = att.url; msgEl.textContent = "{{index .Tr "article_att_cover_set"}}"; }
};
}
var delBtn = document.createElement('button');
delBtn.type = 'button';
delBtn.textContent = "{{index .Tr "settings_delete"}}";
delBtn.className = 'text-red-600 hover:text-red-800 font-medium cursor-pointer bg-transparent border-none';
delBtn.onclick = function () {
if (!confirm("{{index .Tr "article_att_delete_confirm"}}")) return;
fetch('/api/admin/articles/attachments/' + att.id, {
method: 'DELETE',
headers: { 'X-CSRF-Token': csrfToken }
})
.then(function (r) { return r.json(); })
.then(function (r) {
if (r.ok) { tr.remove(); }
else { msgEl.textContent = r.error || 'error'; }
});
};
actTd.appendChild(insBtn);
if (coverBtn) { actTd.appendChild(coverBtn); }
actTd.appendChild(delBtn);
tr.appendChild(nameTd);
tr.appendChild(sizeTd);
tr.appendChild(actTd);
listEl.appendChild(tr);
}
uploadBtn.addEventListener('click', function () {
if (!fileInput.files.length) { msgEl.textContent = "{{index .Tr "article_att_pick"}}"; return; }
var fd = new FormData();
fd.append('file', fileInput.files[0]);
if (articleID) { fd.append('article_id', articleID); }
else { fd.append('session_token', sessionToken); }
msgEl.textContent = "{{index .Tr "article_att_uploading"}}";
fetch('/api/admin/articles/attachments', {
method: 'POST',
headers: { 'X-CSRF-Token': csrfToken },
body: fd
})
.then(function (r) { return r.json(); })
.then(function (r) {
if (r.error) { msgEl.textContent = r.error; return; }
msgEl.textContent = '';
addRow(r);
fileInput.value = '';
})
.catch(function () { msgEl.textContent = "{{index .Tr "article_att_error"}}"; });
});
// Edit page: load existing attachments.
if (articleID) {
fetch('/api/admin/articles/' + articleID + '/attachments')
.then(function (r) { return r.json(); })
.then(function (r) {
(r.attachments || []).forEach(addRow);
});
}
})();
// ---- Article form submitJSON API;草稿/发布按钮由 e.submitter 分流) ----
(function () {
var form = document.getElementById('articleForm');
if (!form) return;
form.addEventListener('submit', function (e) {
e.preventDefault();
var btn = e.submitter || null;
// EasyMDE 隐藏了 textarea:先同步编辑器内容再提交。
var ta = document.getElementById('articleContent');
if (ta && easyMDE) { ta.value = easyMDE.value(); }
var method = articleID ? 'PUT' : 'POST';
var url = articleID ? '/api/admin/articles/' + articleID : '/api/admin/articles';
blogAPI(method, url, blogForm(form, btn)).then(function (r) {
if (r.ok) { window.location.href = r.redirect || '/admin'; }
else { blogShowError('articleError', r.error || 'Failed to save article.'); }
});
});
})();
</script>
{{end}}
+21 -1
View File
@@ -58,7 +58,7 @@
<!-- Avatar Dropdown (click to toggle) -->
<div class="relative" id="avatarDropdown">
<button type="button" id="avatarBtn" class="flex items-center gap-2 hover:opacity-80 transition-opacity cursor-pointer" onclick="toggleDropdown()">
<div class="w-8 h-8 rounded-full overflow-hidden border-2 border-gray-300 hover:border-blue-500 transition-colors flex items-center justify-center bg-gray-200 text-gray-600 font-bold text-sm">
<div id="headerAvatarWrap" class="w-8 h-8 rounded-full overflow-hidden border-2 border-gray-300 hover:border-blue-500 transition-colors flex items-center justify-center bg-gray-200 text-gray-600 font-bold text-sm">
{{if .Avatar}}
<img src="/uploads/avatars/{{.Avatar}}" alt="avatar" class="w-full h-full object-cover">
{{else}}
@@ -203,6 +203,26 @@
});
};
// 上传本地图片(multipart)到文章附件接口(files 表,type=attachments)。
// 新建页传 session_token,编辑页传 article_id;成功 resolve {…, url, is_image}。
// 供文章新建/编辑页编辑器“上传图片”按钮使用。
window.blogUploadImage = function (opts) {
var meta = document.querySelector('meta[name="csrf-token"]');
var csrf = meta ? meta.getAttribute('content') : '';
var fd = new FormData();
fd.append('file', opts.file);
if (opts.articleID) { fd.append('article_id', opts.articleID); }
if (!opts.articleID && opts.sessionToken) { fd.append('session_token', opts.sessionToken); }
return fetch(opts.url, {
method: 'POST',
headers: { 'X-CSRF-Token': csrf, 'Accept': 'application/json' },
credentials: 'same-origin',
body: fd
}).then(function (r) {
return r.json();
});
};
// 将表单序列化为 JSON 数据对象:
// - 文本/select/textarea 从 FormData 取值(checkboxes 在下述循环覆盖)
// - checkbox 一律转 bool(未选中也发送 false,匹配服务端 JSON 绑定)
+2 -2
View File
@@ -7,8 +7,8 @@
<a href="/" class="inline-flex items-center gap-1 text-sm text-gray-500 hover:text-blue-600 transition-colors">
← {{index .Tr "article_back_home"}}
</a>
{{if eq .Role "admin"}}
<a href="/admin/articles/{{.Article.ID}}/edit"
{{if .CanEdit}}
<a href="{{.EditURL}}"
class="inline-flex items-center gap-1 text-sm px-3 py-1.5 rounded-md border border-blue-200 bg-blue-50 text-blue-700 hover:bg-blue-100 hover:border-blue-300 transition-colors"
title="{{index .Tr "article_edit"}}">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
+5 -4
View File
@@ -212,10 +212,11 @@
previewContainer.innerHTML =
'<img src="' + avatarUrl + '" alt="avatar" class="w-full h-full object-cover">';
// Update nav bar avatar
var navAvatarBtn = document.getElementById('avatarBtn');
if (navAvatarBtn) {
navAvatarBtn.innerHTML =
// Update nav bar avatar:只替换头像圆形容器内的内容,
// 保留按钮结构与用户名文本,避免样式被破坏。
var navAvatarWrap = document.getElementById('headerAvatarWrap');
if (navAvatarWrap) {
navAvatarWrap.innerHTML =
'<img src="' + avatarUrl + '" alt="avatar" class="w-full h-full object-cover">';
}
@@ -0,0 +1,27 @@
{{define "article_attachments"}}
<!-- Attachments:全站统一上传(files 表,type=attachments)。
上传/删除/列表走当前页面所属的角色 API/api/admin/... 或 /api/my/...),
行为与文案由 static/js/article-attachments.js 的 initArticleAttachments 提供。 -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">{{index .Tr "article_attachments"}}</label>
<div class="flex items-center gap-3 mb-3">
<input type="file" id="attachmentInput" class="text-sm text-gray-600" disabled>
<button type="button" id="attachmentUploadBtn"
class="px-4 py-2 bg-gray-200 text-gray-700 rounded-lg font-medium hover:bg-gray-300 transition-colors cursor-pointer disabled:opacity-50"
disabled>
{{index .Tr "article_upload"}}
</button>
<span id="attachmentMsg" class="text-xs text-gray-400"></span>
</div>
<table class="w-full text-left border border-gray-200 rounded-lg overflow-hidden">
<thead class="bg-gray-50 border-b border-gray-200">
<tr>
<th class="px-3 py-2 text-xs font-semibold text-gray-600">{{index .Tr "article_att_name"}}</th>
<th class="px-3 py-2 text-xs font-semibold text-gray-600">{{index .Tr "article_att_size"}}</th>
<th class="px-3 py-2 text-xs font-semibold text-gray-600 text-right">{{index .Tr "settings_actions"}}</th>
</tr>
</thead>
<tbody id="attachmentList" class="divide-y divide-gray-100"></tbody>
</table>
</div>
{{end}}
+221
View File
@@ -0,0 +1,221 @@
{{define "article_form"}}
{{template "header" .}}
{{template "markdown_assets" .}}
<section class="max-w-3xl mx-auto px-4 py-10">
<h2 class="text-2xl font-bold text-gray-900 mb-8">{{.FormTitleText}}</h2>
<div id="articleError" class="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg mb-6 text-sm {{if not .Error}}hidden{{end}}">
{{.Error}}
</div>
<form id="articleForm" action="{{.FormAction}}" method="post" class="space-y-6">
<input type="hidden" name="_csrf" value="{{.CSRFToken}}">
<!-- Hidden: attachment ownership (token on create, id on edit) -->
<input type="hidden" name="session_token" id="articleSessionToken" value="{{.SessionToken}}">
<!-- Title -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">{{index .Tr "article_title"}}</label>
<input type="text" name="title" value="{{.FormTitle}}" required
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-colors"
placeholder="{{index .Tr "article_title"}}">
</div>
<!-- Slug -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">{{index .Tr "article_slug"}}</label>
<input type="text" name="slug" value="{{.FormSlug}}"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-colors"
placeholder="{{index .Tr "article_slug_hint"}}">
<p class="text-xs text-gray-400 mt-1">{{index .Tr "article_slug_hint"}}</p>
</div>
<!-- Summary -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">{{index .Tr "article_summary"}}</label>
<textarea name="summary" rows="3"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-colors resize-y"
placeholder="{{index .Tr "article_summary"}}">{{.FormSummary}}</textarea>
</div>
<!-- Content (EasyMDE Markdown Editor) -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">{{index .Tr "article_content"}}</label>
<textarea id="articleContent" name="content">{{.FormContent}}</textarea>
</div>
<!-- Cover -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">{{index .Tr "article_cover"}}</label>
<input type="text" name="cover" value="{{.FormCover}}"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-colors"
placeholder="https://...">
</div>
<!-- Tags(管理员工作区;后端两者均支持,作者页暂无入口) -->
{{if not .FormIsMy}}
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">{{index .Tr "article_tags"}}</label>
<input type="text" name="tags" value="{{.FormTags}}"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-colors"
placeholder="{{index .Tr "article_tags_hint"}}">
<p class="text-xs text-gray-400 mt-1">{{index .Tr "article_tags_hint"}}</p>
</div>
{{end}}
<!-- Attachments -->
{{template "article_attachments" .}}
<!-- Published At -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">{{index .Tr "article_published_at"}}</label>
<input type="datetime-local" name="published_at" value="{{.FormPublishedAt}}"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-colors">
<p class="text-xs text-gray-400 mt-1">{{index .Tr "article_published_at_hint"}}</p>
</div>
<!-- IsTop(管理员工作区:普通作者不可置顶全站文章,SECURITY_TODO #31 -->
{{if not .FormIsMy}}
<div class="flex items-center gap-2">
<input type="checkbox" name="is_top" value="1" id="isTopCheckbox" {{if .FormIsTop}}checked{{end}}
class="w-4 h-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500">
<label for="isTopCheckbox" class="text-sm font-medium text-gray-700">{{index .Tr "article_is_top"}}</label>
</div>
{{end}}
{{if .FormIsMy}}
<!-- My workspace:状态下拉 + 保存/取消 -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">{{index .Tr "article_field_status"}}</label>
<select name="status"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-colors">
<option value="0" {{if eq .FormStatus "0"}}selected{{end}}>{{index .Tr "article_draft"}}</option>
<option value="1" {{if eq .FormStatus "1"}}selected{{end}}>{{index .Tr "article_published"}}</option>
</select>
</div>
<div class="flex gap-3">
<button type="submit"
class="px-6 py-3 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 transition-colors cursor-pointer">
{{index .Tr "article_save"}}
</button>
<a href="/my/articles"
class="px-6 py-3 bg-gray-200 text-gray-700 rounded-lg font-medium hover:bg-gray-300 transition-colors">
{{index .Tr "article_cancel"}}
</a>
</div>
{{else}}
<!-- Admin workspace:草稿/发布双按钮 -->
<div class="flex gap-3">
<button type="submit" name="status" value="0"
class="px-6 py-3 bg-gray-200 text-gray-700 rounded-lg font-medium hover:bg-gray-300 transition-colors cursor-pointer">
{{index .Tr "article_save_draft"}}
</button>
<button type="submit" name="status" value="1"
class="px-6 py-3 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 transition-colors cursor-pointer">
{{index .Tr "article_publish"}}
</button>
</div>
{{end}}
</form>
</section>
{{template "footer" .}}
<script src="/static/js/article-attachments.js?v=1"></script>
<script>
// 工作区上下文:管理员 /api/admin/articles,作者 /api/my/articles。
// 新建页用 session_token,编辑页用 article_id。
var API_BASE = "{{if .FormIsMy}}/api/my/articles{{else}}/api/admin/articles{{end}}";
var sessEl = document.getElementById('articleSessionToken');
var ATT = {
articleID: {{ if .FormArticleID }}{{ .FormArticleID }}{{ else }}0{{ end }},
sessionToken: sessEl ? sessEl.value : '',
uploadURL: API_BASE + '/attachments',
listURL: API_BASE + '/:id/attachments'
};
// 顶层作用域:供表单提交(PUT/POST 路由选择)与附件共享逻辑共同使用。
var articleID = ATT.articleID;
var redirectBase = "{{if .FormIsMy}}/my/articles{{else}}/admin{{end}}";
// 编辑器“上传图片”按钮:选择本地图片 → 上传(files 表,type=attachments)→ 插入正文光标处。
function uploadImageAction(editor) {
var input = document.createElement('input');
input.type = 'file';
input.accept = 'image/*';
input.onchange = function () {
var f = input.files && input.files[0];
if (!f) return;
blogUploadImage({ url: ATT.uploadURL, file: f, articleID: ATT.articleID, sessionToken: ATT.sessionToken })
.then(function (r) {
if (r.error) { blogShowError('articleError', r.error); return; }
if (!r.is_image) { blogShowError('articleError', '{{index .Tr "article_image_not_image"}}'); return; }
editor.codemirror.replaceSelection('![' + r.filename + '](' + r.url + ')\n');
editor.codemirror.focus();
});
};
input.click();
}
var easyMDE = new EasyMDE({
element: document.getElementById('articleContent'),
autoDownloadFontAwesome: false,
spellChecker: false,
autosave: { enabled: false },
placeholder: '{{index .Tr "article_content"}}',
previewRender: function (plainText, preview) {
return '<div class="md-body">' + BlogMD.render(plainText) + '</div>';
},
toolbar: [
'bold', 'italic', 'heading', '|',
'quote', 'unordered-list', 'ordered-list', '|',
'link', { name: 'uploadImage', className: 'fa fa-image', title: '{{index .Tr "article_image_upload"}}', action: uploadImageAction }, 'code', 'table', '|',
'preview', 'side-by-side', 'fullscreen', '|',
'guide'
],
status: false,
minHeight: '300px'
});
// ---- Attachments(共享逻辑,见 static/js/article-attachments.js ----
initArticleAttachments({
uploadURL: ATT.uploadURL,
listURL: ATT.listURL,
editor: easyMDE,
articleID: ATT.articleID,
sessionToken: ATT.sessionToken,
texts: {
pick: '{{index .Tr "article_att_pick"}}',
uploading: '{{index .Tr "article_att_uploading"}}',
insert: '{{index .Tr "article_att_insert"}}',
setCover: '{{index .Tr "article_att_set_cover"}}',
coverSet: '{{index .Tr "article_att_cover_set"}}',
del: '{{index .Tr "settings_delete"}}',
delConfirm: '{{index .Tr "article_att_delete_confirm"}}',
err: '{{index .Tr "article_att_error"}}'
}
});
// ---- Article form submitJSON APIadmin 的草稿/发布按钮由 e.submitter 分流,
// my 的 status 取自 select 字段) ----
(function () {
var form = document.getElementById('articleForm');
if (!form) return;
form.addEventListener('submit', function (e) {
e.preventDefault();
var btn = e.submitter || null;
// EasyMDE 隐藏了 textarea:先同步编辑器内容再提交。
var ta = document.getElementById('articleContent');
if (ta && easyMDE) { ta.value = easyMDE.value(); }
var method = articleID ? 'PUT' : 'POST';
var url = articleID ? API_BASE + '/' + articleID : API_BASE;
blogAPI(method, url, blogForm(form, btn)).then(function (r) {
if (r.ok) { window.location.href = r.redirect || redirectBase; }
else { blogShowError('articleError', r.error || 'Failed to save article.'); }
});
});
})();
</script>
{{end}}
-119
View File
@@ -1,119 +0,0 @@
{{define "my_article_form"}}
{{template "header" .}}
{{template "markdown_assets" .}}
<section class="max-w-4xl mx-auto px-4 py-12">
<div class="mb-8">
<h2 class="text-3xl font-bold text-gray-900">{{.FormTitleText}}</h2>
</div>
<div id="myArticleError" class="mb-6 bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg {{if not .Error}}hidden{{end}}">
{{.Error}}
</div>
<form id="myArticleForm" action="{{.FormAction}}" method="post" class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 space-y-6">
<input type="hidden" name="_csrf" value="{{.CSRFToken}}">
{{if .SessionToken}}
<input type="hidden" name="session_token" value="{{.SessionToken}}">
{{end}}
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">{{index .Tr "article_field_title"}}</label>
<input type="text" name="title" value="{{.FormTitle}}" required
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">{{index .Tr "article_field_slug"}}</label>
<input type="text" name="slug" value="{{.FormSlug}}"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
<p class="mt-1 text-sm text-gray-500">{{index .Tr "article_slug_help"}}</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">{{index .Tr "article_field_summary"}}</label>
<textarea name="summary" rows="3"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">{{.FormSummary}}</textarea>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">{{index .Tr "article_field_content"}}</label>
<textarea id="content" name="content" rows="20"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">{{.FormContent}}</textarea>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">{{index .Tr "article_field_cover"}}</label>
<input type="text" name="cover" value="{{.FormCover}}"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
<p class="mt-1 text-sm text-gray-500">{{index .Tr "article_cover_help"}}</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">{{index .Tr "article_published_at"}}</label>
<input type="datetime-local" name="published_at" value="{{.FormPublishedAt}}"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
<p class="mt-1 text-sm text-gray-500">{{index .Tr "article_published_at_hint"}}</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">{{index .Tr "article_field_status"}}</label>
<select name="status"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
<option value="0" {{if eq .FormStatus "0"}}selected{{end}}>{{index .Tr "article_draft"}}</option>
<option value="1" {{if eq .FormStatus "1"}}selected{{end}}>{{index .Tr "article_published"}}</option>
</select>
</div>
</div>
<div class="flex gap-3">
<button type="submit"
class="bg-blue-600 text-white px-6 py-2 rounded-lg font-medium hover:bg-blue-700 transition-colors">
{{index .Tr "article_save"}}
</button>
<a href="/my/articles"
class="bg-gray-200 text-gray-700 px-6 py-2 rounded-lg font-medium hover:bg-gray-300 transition-colors">
{{index .Tr "article_cancel"}}
</a>
</div>
</form>
</section>
<script>
var myEasyMDE = null;
document.addEventListener('DOMContentLoaded', function() {
myEasyMDE = new EasyMDE({
element: document.getElementById('content'),
autoDownloadFontAwesome: false,
spellChecker: false,
status: false,
previewRender: function (plainText, preview) {
return '<div class="md-body">' + BlogMD.render(plainText) + '</div>';
},
toolbar: ["bold", "italic", "heading", "|", "quote", "unordered-list", "ordered-list", "|",
"link", "image", "|", "preview", "side-by-side", "fullscreen", "|", "guide"]
});
});
// ---- Form submitJSON API ----
(function () {
var form = document.getElementById('myArticleForm');
if (!form) return;
var articleId = {{ if .FormArticleID }}{{ .FormArticleID }}{{ else }}0{{ end }};
form.addEventListener('submit', function (e) {
e.preventDefault();
var btn = e.submitter || null;
var ta = document.getElementById('content');
if (ta && myEasyMDE) { ta.value = myEasyMDE.value(); }
var method = articleId ? 'PUT' : 'POST';
var url = articleId ? '/api/my/articles/' + articleId : '/api/my/articles';
blogAPI(method, url, blogForm(form, btn)).then(function (r) {
if (r.ok) { window.location.href = r.redirect || '/my/articles'; }
else { blogShowError('myArticleError', r.error || 'Failed to save article.'); }
});
});
})();
</script>
{{template "footer" .}}
{{end}}