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 断言两个变体渲染差异;
  测试环境注册两个工作区的新建页路由
This commit is contained in:
dsh
2026-08-28 20:02:46 +08:00
parent f5439ae93a
commit d78ed8e2fe
6 changed files with 104 additions and 188 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
+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)
}
}
}
+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)
}
+2
View File
@@ -106,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))
@@ -139,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))
@@ -1,4 +1,4 @@
{{define "article_create"}}
{{define "article_form"}}
{{template "header" .}}
{{template "markdown_assets" .}}
@@ -12,12 +12,12 @@
<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}}">
<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}}"
<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>
@@ -53,7 +53,8 @@
placeholder="https://...">
</div>
<!-- Tags -->
<!-- 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}}"
@@ -61,6 +62,7 @@
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" .}}
@@ -73,14 +75,37 @@
<p class="text-xs text-gray-400 mt-1">{{index .Tr "article_published_at_hint"}}</p>
</div>
<!-- IsTop -->
<!-- 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}}
<!-- Submit Buttons -->
{{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">
@@ -91,6 +116,7 @@
{{index .Tr "article_publish"}}
</button>
</div>
{{end}}
</form>
</section>
@@ -99,15 +125,19 @@
<script src="/static/js/article-attachments.js?v=1"></script>
<script>
// 文章附件接口的上下文:新建页用 session_token,编辑页用 article_id
// 工作区上下文:管理员 /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: "{{ .SessionToken }}",
uploadURL: "/api/admin/articles/attachments"
sessionToken: sessEl ? sessEl.value : '',
uploadURL: API_BASE + '/attachments',
listURL: API_BASE + '/:id/attachments'
};
// 顶层作用域:供表单提交(PUT/POST 路由选择)与附件共享逻辑共同使用。
var articleID = ATT.articleID;
var sessionToken = ATT.sessionToken;
var redirectBase = "{{if .FormIsMy}}/my/articles{{else}}/admin{{end}}";
// 编辑器“上传图片”按钮:选择本地图片 → 上传(files 表,type=attachments)→ 插入正文光标处。
function uploadImageAction(editor) {
@@ -151,7 +181,7 @@ var easyMDE = new EasyMDE({
// ---- Attachments(共享逻辑,见 static/js/article-attachments.js ----
initArticleAttachments({
uploadURL: ATT.uploadURL,
listURL: '/api/admin/articles/:id/attachments',
listURL: ATT.listURL,
editor: easyMDE,
articleID: ATT.articleID,
sessionToken: ATT.sessionToken,
@@ -167,7 +197,8 @@ initArticleAttachments({
}
});
// ---- Article form submitJSON API;草稿/发布按钮由 e.submitter 分流 ----
// ---- Article form submitJSON APIadmin 的草稿/发布按钮由 e.submitter 分流
// my 的 status 取自 select 字段) ----
(function () {
var form = document.getElementById('articleForm');
if (!form) return;
@@ -178,9 +209,9 @@ initArticleAttachments({
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';
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 || '/admin'; }
if (r.ok) { window.location.href = r.redirect || redirectBase; }
else { blogShowError('articleError', r.error || 'Failed to save article.'); }
});
});
-170
View File
@@ -1,170 +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" id="myArticleSessionToken" 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>
<!-- Attachments -->
{{template "article_attachments" .}}
<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 src="/static/js/article-attachments.js?v=1"></script>
<script>
var myEasyMDE = null;
document.addEventListener('DOMContentLoaded', function() {
// 文章附件接口的上下文:新建页用 session_token,编辑页用 article_id。
var sessEl = document.getElementById('myArticleSessionToken');
var ATT = {
articleID: {{ if .FormArticleID }}{{ .FormArticleID }}{{ else }}0{{ end }},
sessionToken: sessEl ? sessEl.value : '',
uploadURL: "/api/my/articles/attachments"
};
// 编辑器“上传图片”按钮:选择本地图片 → 上传(files 表)→ 插入正文光标处。
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('myArticleError', r.error); return; }
if (!r.is_image) { blogShowError('myArticleError', '{{index .Tr "article_image_not_image"}}'); return; }
editor.codemirror.replaceSelection('![' + r.filename + '](' + r.url + ')\n');
editor.codemirror.focus();
});
};
input.click();
}
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", { name: 'uploadImage', className: 'fa fa-image', title: '{{index .Tr "article_image_upload"}}', action: uploadImageAction }, "|", "preview", "side-by-side", "fullscreen", "|", "guide"]
});
// ---- Attachments(共享逻辑,见 static/js/article-attachments.js ----
initArticleAttachments({
uploadURL: ATT.uploadURL,
listURL: '/api/my/articles/:id/attachments',
editor: myEasyMDE,
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"}}'
}
});
});
// ---- 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}}