Files
go_blog/templates/admin/article_create.html
T
kevin 5b227b71ef feat: 文章 CRUD 接口 JSON 化——/api/admin|my/articles
- article.go:articleForm 加 json tags,新增 parseArticleFormJSON(绑定+
  去白);ArticleCreate/ArticleUpdate/ArticleDelete 增加 redirectPath 参数,
  校验失败改 APIError 400、DB 错误 500,成功 {ok,redirect}(原 renderForm
  分支移除);id 改用 parseUintParam 数值化
- my_articles.go:MyArticleCreate 复用 ArticleCreate(db, /my/articles);
  MyArticleUpdate/MyArticleDelete 保留 author_id 所有权约束(404
  article_not_found);my 更新不触碰标签(表单无该字段,与旧行为一致)
- main.go:admin/my 文章旧 POST 路由移除,改 POST/PUT/DELETE
  /api/admin/articles[/:id] 与 /api/my/articles[/:id]
- 模板:article_create.html/my_article_form.html 表单改 blogAPI 提交
  (easyMDE.value() 同步正文、editor e.submitter 分流草稿/发布),错误内联
  articleError/myArticleError;article_list.html/my_articles.html 删除改
  blogDelete 委托(base.html 新公共函数,DELETE + reload 保筛选状态)
- main_test 冒烟补文章 CRUD 断言;go build/vet/test 全绿
2026-08-27 19:40:02 +08:00

279 lines
13 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{{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'),
spellChecker: false,
autosave: { enabled: false },
placeholder: '{{index .Tr "article_content"}}',
previewRender: function (plainText, preview) {
return '<div class="md-body">' + BlogMD.render(plainText) + '</div>';
},
toolbar: [
'bold', 'italic', 'heading', '|',
'quote', 'unordered-list', 'ordered-list', '|',
'link', 'image', 'code', 'table', '|',
'preview', 'side-by-side', 'fullscreen', '|',
'guide'
],
status: false,
minHeight: '300px'
});
// ---- Attachments ----
(function () {
var articleID = {{ if .FormArticleID }}{{ .FormArticleID }}{{ else }}0{{ end }};
var sessionToken = "{{ .SessionToken }}";
var csrfTokenEl = document.querySelector('meta[name="csrf-token"]');
var csrfToken = csrfTokenEl ? csrfTokenEl.getAttribute('content') : '';
var uploadBtn = document.getElementById('attachmentUploadBtn');
var fileInput = document.getElementById('attachmentInput');
var msgEl = document.getElementById('attachmentMsg');
var listEl = document.getElementById('attachmentList');
// Enable the uploader (uploads allowed only while logged in, which is true here).
uploadBtn.disabled = false;
fileInput.disabled = false;
function fmtSize(b) {
if (b < 1024) return b + ' B';
var u = ['KiB', 'MiB', 'GiB'], i = -1;
do { b /= 1024; i++; } while (b >= 1024 && i < u.length - 1);
return b.toFixed(1) + ' ' + u[i];
}
function addRow(att) {
var tr = document.createElement('tr');
tr.className = 'hover:bg-gray-50';
tr.dataset.id = att.id;
var nameTd = document.createElement('td');
nameTd.className = 'px-3 py-2 text-sm text-gray-800';
var link = document.createElement('a');
link.href = att.url; link.target = '_blank'; link.textContent = att.filename;
nameTd.appendChild(link);
var sizeTd = document.createElement('td');
sizeTd.className = 'px-3 py-2 text-sm text-gray-500';
sizeTd.textContent = fmtSize(att.size);
var actTd = document.createElement('td');
actTd.className = 'px-3 py-2 text-sm text-right whitespace-nowrap';
var insBtn = document.createElement('button');
insBtn.type = 'button';
insBtn.textContent = "{{index .Tr "article_att_insert"}}";
insBtn.className = 'text-blue-600 hover:text-blue-800 font-medium mr-3 cursor-pointer bg-transparent border-none';
insBtn.onclick = function () {
var md = att.is_image
? '![' + att.filename + '](' + att.url + ')'
: '[' + att.filename + '](' + att.url + ')';
var cm = easyMDE.codemirror;
cm.replaceSelection(md + '\n');
cm.focus();
};
// Images: extra button to copy the URL into the cover field.
var coverBtn = null;
if (att.is_image) {
coverBtn = document.createElement('button');
coverBtn.type = 'button';
coverBtn.textContent = "{{index .Tr "article_att_set_cover"}}";
coverBtn.className = 'text-green-600 hover:text-green-800 font-medium mr-3 cursor-pointer bg-transparent border-none';
coverBtn.onclick = function () {
var cover = document.querySelector('input[name="cover"]');
if (cover) { cover.value = att.url; msgEl.textContent = "{{index .Tr "article_att_cover_set"}}"; }
};
}
var delBtn = document.createElement('button');
delBtn.type = 'button';
delBtn.textContent = "{{index .Tr "settings_delete"}}";
delBtn.className = 'text-red-600 hover:text-red-800 font-medium cursor-pointer bg-transparent border-none';
delBtn.onclick = function () {
if (!confirm("{{index .Tr "article_att_delete_confirm"}}")) return;
fetch('/api/admin/articles/attachments/' + att.id, {
method: 'DELETE',
headers: { 'X-CSRF-Token': csrfToken }
})
.then(function (r) { return r.json(); })
.then(function (r) {
if (r.ok) { tr.remove(); }
else { msgEl.textContent = r.error || 'error'; }
});
};
actTd.appendChild(insBtn);
if (coverBtn) { actTd.appendChild(coverBtn); }
actTd.appendChild(delBtn);
tr.appendChild(nameTd);
tr.appendChild(sizeTd);
tr.appendChild(actTd);
listEl.appendChild(tr);
}
uploadBtn.addEventListener('click', function () {
if (!fileInput.files.length) { msgEl.textContent = "{{index .Tr "article_att_pick"}}"; return; }
var fd = new FormData();
fd.append('file', fileInput.files[0]);
if (articleID) { fd.append('article_id', articleID); }
else { fd.append('session_token', sessionToken); }
msgEl.textContent = "{{index .Tr "article_att_uploading"}}";
fetch('/api/admin/articles/attachments', {
method: 'POST',
headers: { 'X-CSRF-Token': csrfToken },
body: fd
})
.then(function (r) { return r.json(); })
.then(function (r) {
if (r.error) { msgEl.textContent = r.error; return; }
msgEl.textContent = '';
addRow(r);
fileInput.value = '';
})
.catch(function () { msgEl.textContent = "{{index .Tr "article_att_error"}}"; });
});
// Edit page: load existing attachments.
if (articleID) {
fetch('/api/admin/articles/' + articleID + '/attachments')
.then(function (r) { return r.json(); })
.then(function (r) {
(r.attachments || []).forEach(addRow);
});
}
})();
// ---- Article form submitJSON API;草稿/发布按钮由 e.submitter 分流) ----
(function () {
var form = document.getElementById('articleForm');
if (!form) return;
form.addEventListener('submit', function (e) {
e.preventDefault();
var btn = e.submitter || null;
// EasyMDE 隐藏了 textarea:先同步编辑器内容再提交。
var ta = document.getElementById('articleContent');
if (ta && easyMDE) { ta.value = easyMDE.value(); }
var method = articleID ? 'PUT' : 'POST';
var url = articleID ? '/api/admin/articles/' + articleID : '/api/admin/articles';
blogAPI(method, url, blogForm(form, btn)).then(function (r) {
if (r.ok) { window.location.href = r.redirect || '/admin'; }
else { blogShowError('articleError', r.error || 'Failed to save article.'); }
});
});
})();
</script>
{{end}}