Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d78ed8e2fe | ||
|
|
f5439ae93a | ||
|
|
134943e4fb | ||
|
|
6216b9af13 |
No files matched your search
+4
-2
@@ -119,15 +119,17 @@ func applyFormToData(data gin.H, f articleForm) {
|
|||||||
data["SessionToken"] = f.SessionToken
|
data["SessionToken"] = f.SessionToken
|
||||||
}
|
}
|
||||||
|
|
||||||
// renderArticleForm 使用给定的表单值和可选的错误消息渲染共享的文章表单模板。
|
// renderArticleForm 使用给定的表单值和可选的错误消息渲染管理员工作区的
|
||||||
|
// 文章表单(与作者工作区共用一份模板,FormIsMy=false 表示管理员变体)。
|
||||||
func renderArticleForm(c *gin.Context, db *gorm.DB, f articleForm, errMsg string) {
|
func renderArticleForm(c *gin.Context, db *gorm.DB, f articleForm, errMsg string) {
|
||||||
data := DefaultData(c)
|
data := DefaultData(c)
|
||||||
data["Title"] = f.TitleText
|
data["Title"] = f.TitleText
|
||||||
|
data["FormIsMy"] = false
|
||||||
if errMsg != "" {
|
if errMsg != "" {
|
||||||
data["Error"] = errMsg
|
data["Error"] = errMsg
|
||||||
}
|
}
|
||||||
applyFormToData(data, f)
|
applyFormToData(data, f)
|
||||||
c.HTML(http.StatusOK, "article_create", data)
|
c.HTML(http.StatusOK, "article_form", data)
|
||||||
}
|
}
|
||||||
|
|
||||||
// sessionAuthorID 从会话中提取已登录用户的 ID,兼容 int/uint/int64/float64
|
// sessionAuthorID 从会话中提取已登录用户的 ID,兼容 int/uint/int64/float64
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -248,6 +248,23 @@ func renderArticleDetail(c *gin.Context, db *gorm.DB, article *models.Article, f
|
|||||||
data["CommentError"] = formErr
|
data["CommentError"] = formErr
|
||||||
data["CommentNotice"] = notice
|
data["CommentNotice"] = notice
|
||||||
data["MaxCommentLength"] = MaxCommentLength
|
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)
|
c.HTML(http.StatusOK, "article", data)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
func renderMyArticleForm(c *gin.Context, db *gorm.DB, f articleForm, errMsg string) {
|
||||||
data := DefaultData(c)
|
data := DefaultData(c)
|
||||||
data["Title"] = f.TitleText
|
data["Title"] = f.TitleText
|
||||||
|
data["FormIsMy"] = true
|
||||||
if errMsg != "" {
|
if errMsg != "" {
|
||||||
data["Error"] = errMsg
|
data["Error"] = errMsg
|
||||||
}
|
}
|
||||||
applyFormToData(data, f)
|
applyFormToData(data, f)
|
||||||
c.HTML(http.StatusOK, "my_article_form", data)
|
c.HTML(http.StatusOK, "article_form", data)
|
||||||
}
|
}
|
||||||
@@ -90,6 +90,7 @@ func newSecurityTestEnv(t *testing.T) *securityTestEnv {
|
|||||||
r.GET("/login", LoginPage())
|
r.GET("/login", LoginPage())
|
||||||
r.GET("/register", RegisterPage(db))
|
r.GET("/register", RegisterPage(db))
|
||||||
r.GET("/rss", RSSFeed(db))
|
r.GET("/rss", RSSFeed(db))
|
||||||
|
r.GET("/article/:slug", ArticleDetail(db))
|
||||||
|
|
||||||
api := r.Group("/api")
|
api := r.Group("/api")
|
||||||
{
|
{
|
||||||
@@ -105,6 +106,7 @@ func newSecurityTestEnv(t *testing.T) *securityTestEnv {
|
|||||||
uid, _ := sessionAuthorID(c)
|
uid, _ := sessionAuthorID(c)
|
||||||
c.String(http.StatusOK, "uid=%d", uid)
|
c.String(http.StatusOK, "uid=%d", uid)
|
||||||
})
|
})
|
||||||
|
protected.GET("/articles/new", MyArticleCreatePage(db))
|
||||||
}
|
}
|
||||||
|
|
||||||
myAPI := r.Group("/api/my/articles", middleware.AuthRequired(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("/users/:id/edit", UserEditPage(db))
|
||||||
admin.GET("/comments", CommentListPage(db))
|
admin.GET("/comments", CommentListPage(db))
|
||||||
|
admin.GET("/articles/new", ArticleCreatePage(db))
|
||||||
}
|
}
|
||||||
|
|
||||||
usersAPI := r.Group("/api/admin/users", middleware.AuthRequired(db), middleware.AdminRequired(db))
|
usersAPI := r.Group("/api/admin/users", middleware.AuthRequired(db), middleware.AdminRequired(db))
|
||||||
|
|||||||
@@ -185,6 +185,8 @@ var translations = map[Lang]map[string]string{
|
|||||||
"article_att_uploading": "Uploading...",
|
"article_att_uploading": "Uploading...",
|
||||||
"article_att_error": "Upload failed. Please try again.",
|
"article_att_error": "Upload failed. Please try again.",
|
||||||
"article_att_delete_confirm": "Delete this attachment?",
|
"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",
|
"article_list_title": "Articles",
|
||||||
@@ -626,6 +628,8 @@ var translations = map[Lang]map[string]string{
|
|||||||
"article_att_uploading": "上传中...",
|
"article_att_uploading": "上传中...",
|
||||||
"article_att_error": "上传失败,请重试。",
|
"article_att_error": "上传失败,请重试。",
|
||||||
"article_att_delete_confirm": "删除该附件?",
|
"article_att_delete_confirm": "删除该附件?",
|
||||||
|
"article_image_upload": "上传图片",
|
||||||
|
"article_image_not_image": "该文件不是有效的图片。",
|
||||||
|
|
||||||
// 文章管理
|
// 文章管理
|
||||||
"article_list_title": "文章管理",
|
"article_list_title": "文章管理",
|
||||||
|
|||||||
@@ -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 + ')';
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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 + ')';
|
|
||||||
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 submit(JSON 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}}
|
|
||||||
@@ -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 数据对象:
|
// 将表单序列化为 JSON 数据对象:
|
||||||
// - 文本/select/textarea 从 FormData 取值(checkboxes 在下述循环覆盖)
|
// - 文本/select/textarea 从 FormData 取值(checkboxes 在下述循环覆盖)
|
||||||
// - checkbox 一律转 bool(未选中也发送 false,匹配服务端 JSON 绑定)
|
// - checkbox 一律转 bool(未选中也发送 false,匹配服务端 JSON 绑定)
|
||||||
|
|||||||
@@ -7,8 +7,8 @@
|
|||||||
<a href="/" class="inline-flex items-center gap-1 text-sm text-gray-500 hover:text-blue-600 transition-colors">
|
<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"}}
|
← {{index .Tr "article_back_home"}}
|
||||||
</a>
|
</a>
|
||||||
{{if eq .Role "admin"}}
|
{{if .CanEdit}}
|
||||||
<a href="/admin/articles/{{.Article.ID}}/edit"
|
<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"
|
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"}}">
|
title="{{index .Tr "article_edit"}}">
|
||||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||||
|
|||||||
@@ -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}}
|
||||||
@@ -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('\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 submit(JSON API;admin 的草稿/发布按钮由 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}}
|
||||||
@@ -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 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