diff --git a/handlers/article.go b/handlers/article.go index a607ad3..4bb36b7 100644 --- a/handlers/article.go +++ b/handlers/article.go @@ -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 diff --git a/handlers/article_edit_test.go b/handlers/article_edit_test.go new file mode 100644 index 0000000..c16b4f1 --- /dev/null +++ b/handlers/article_edit_test.go @@ -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") + } +} diff --git a/handlers/article_form_template_test.go b/handlers/article_form_template_test.go new file mode 100644 index 0000000..58127be --- /dev/null +++ b/handlers/article_form_template_test.go @@ -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) + } + } +} diff --git a/handlers/home.go b/handlers/home.go index 601acc1..6225d4c 100644 --- a/handlers/home.go +++ b/handlers/home.go @@ -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) } diff --git a/handlers/my_articles.go b/handlers/my_articles.go index 178c9f8..cbe1c93 100644 --- a/handlers/my_articles.go +++ b/handlers/my_articles.go @@ -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) } diff --git a/handlers/security_test.go b/handlers/security_test.go index a4b7670..271d176 100644 --- a/handlers/security_test.go +++ b/handlers/security_test.go @@ -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)) diff --git a/i18n/i18n.go b/i18n/i18n.go index 7112455..3be0d70 100644 --- a/i18n/i18n.go +++ b/i18n/i18n.go @@ -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": "文章管理", diff --git a/static/js/article-attachments.js b/static/js/article-attachments.js new file mode 100644 index 0000000..145b286 --- /dev/null +++ b/static/js/article-attachments.js @@ -0,0 +1,134 @@ +// 文章附件区共享逻辑(管理员与普通用户的文章新建/编辑页共用): +// 上传(multipart → 附件接口,写 files 表 type=attachments)、编辑页回填列表、 +// 插入正文(图片 ![]() / 其他 []())、图片一键设封面、删除(引用计数由服务端处理)。 +// +// 由页面调用:initArticleAttachments(cfg) +// cfg: +// uploadURL 上传接口,如 "/api/my/articles/attachments"(DELETE 为 uploadURL + "/") +// 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); + }); + } +}; diff --git a/templates/admin/article_create.html b/templates/admin/article_create.html deleted file mode 100644 index 6cedc96..0000000 --- a/templates/admin/article_create.html +++ /dev/null @@ -1,279 +0,0 @@ -{{define "article_create"}} -{{template "header" .}} -{{template "markdown_assets" .}} - -
-

{{.FormTitleText}}

- -
- {{.Error}} -
- -
- - - - - -
- - -
- - -
- - -

{{index .Tr "article_slug_hint"}}

-
- - -
- - -
- - -
- - -
- - -
- - -
- - -
- - -

{{index .Tr "article_tags_hint"}}

-
- - -
- -
- - - -
- - - - - - - - - -
{{index .Tr "article_att_name"}}{{index .Tr "article_att_size"}}{{index .Tr "settings_actions"}}
-
- - -
- - -

{{index .Tr "article_published_at_hint"}}

-
- - -
- - -
- - -
- - -
-
-
- -{{template "footer" .}} - - - -{{end}} diff --git a/templates/layouts/base.html b/templates/layouts/base.html index 86a6b51..df46d27 100644 --- a/templates/layouts/base.html +++ b/templates/layouts/base.html @@ -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 绑定) diff --git a/templates/pages/article.html b/templates/pages/article.html index 00540ea..1be6421 100644 --- a/templates/pages/article.html +++ b/templates/pages/article.html @@ -7,8 +7,8 @@ ← {{index .Tr "article_back_home"}} - {{if eq .Role "admin"}} - diff --git a/templates/partials/article_attachments.html b/templates/partials/article_attachments.html new file mode 100644 index 0000000..3f62030 --- /dev/null +++ b/templates/partials/article_attachments.html @@ -0,0 +1,27 @@ +{{define "article_attachments"}} + +
+ +
+ + + +
+ + + + + + + + + +
{{index .Tr "article_att_name"}}{{index .Tr "article_att_size"}}{{index .Tr "settings_actions"}}
+
+{{end}} diff --git a/templates/partials/article_form.html b/templates/partials/article_form.html new file mode 100644 index 0000000..4143491 --- /dev/null +++ b/templates/partials/article_form.html @@ -0,0 +1,221 @@ +{{define "article_form"}} +{{template "header" .}} +{{template "markdown_assets" .}} + +
+

{{.FormTitleText}}

+ +
+ {{.Error}} +
+ +
+ + + + + +
+ + +
+ + +
+ + +

{{index .Tr "article_slug_hint"}}

+
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + + {{if not .FormIsMy}} +
+ + +

{{index .Tr "article_tags_hint"}}

+
+ {{end}} + + + {{template "article_attachments" .}} + + +
+ + +

{{index .Tr "article_published_at_hint"}}

+
+ + + {{if not .FormIsMy}} +
+ + +
+ {{end}} + + {{if .FormIsMy}} + +
+ + +
+
+ {{else}} + +
+ + +
+ {{end}} +
+
+ +{{template "footer" .}} + + + + + +{{end}} diff --git a/templates/user/my_article_form.html b/templates/user/my_article_form.html deleted file mode 100644 index 4dc583d..0000000 --- a/templates/user/my_article_form.html +++ /dev/null @@ -1,119 +0,0 @@ -{{define "my_article_form"}} -{{template "header" .}} -{{template "markdown_assets" .}} -
-
-

{{.FormTitleText}}

-
- -
- {{.Error}} -
- -
- - {{if .SessionToken}} - - {{end}} - -
- - -
- -
- - -

{{index .Tr "article_slug_help"}}

-
- -
- - -
- -
- - -
- -
- - -

{{index .Tr "article_cover_help"}}

-
- -
- - -

{{index .Tr "article_published_at_hint"}}

-
- -
-
- - -
-
- -
- - - {{index .Tr "article_cancel"}} - -
-
-
- - - -{{template "footer" .}} -{{end}}