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:
+4
-2
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 submit(JSON API;草稿/发布按钮由 e.submitter 分流) ----
|
||||
// ---- Article form submit(JSON API;admin 的草稿/发布按钮由 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.'); }
|
||||
});
|
||||
});
|
||||
@@ -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('\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 submit(JSON 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}}
|
||||
Reference in New Issue
Block a user