feat: 普通用户文章页补齐附件区(上传/列表/插入正文/设封面/删除)

- 新增共享部分模板 templates/partials/article_attachments.html
  (附件区标记:文件名/大小/操作列 + 上传按钮)
- 新增共享 static/js/article-attachments.js:上传(multipart→files 表
  type=attachments)、编辑页回填、插入正文、图片一键设封面、删除;
  uploadURL/listURL/editor/i18n 文案均由页面注入
- 管理员页改用共享 partial+JS(行为不变);同时修复既有 bug:
  表单提交闭包引用未声明的 articleID(作用域错误导致保存静默失败)
- 普通用户页加入附件区,走 /api/my/articles/attachments(新建页
  session_token / 编辑页 article_id),支持一键设封面/插入正文
This commit is contained in:
dsh
2026-08-28 20:01:20 +08:00
parent 84756e03b7
commit 5c55a76acb
4 files changed
+208 -138

No files matched your search

+134
View File
@@ -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 + ')'
: '[' + 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);
});
}
};
+23 -138
View File
@@ -63,28 +63,7 @@
</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>
{{template "article_attachments" .}}
<!-- Published At -->
<div>
@@ -117,6 +96,8 @@
{{template "footer" .}}
<script src="/static/js/article-attachments.js?v=1"></script>
<script>
// 文章附件接口的上下文:新建页用 session_token,编辑页用 article_id。
var ATT = {
@@ -124,6 +105,9 @@ var ATT = {
sessionToken: "{{ .SessionToken }}",
uploadURL: "/api/admin/articles/attachments"
};
// 顶层作用域:供表单提交(PUT/POST 路由选择)与附件共享逻辑共同使用。
var articleID = ATT.articleID;
var sessionToken = ATT.sessionToken;
// 编辑器“上传图片”按钮:选择本地图片 → 上传(files 表,type=attachments)→ 插入正文光标处。
function uploadImageAction(editor) {
@@ -164,123 +148,24 @@ var easyMDE = new EasyMDE({
minHeight: '300px'
});
// ---- Attachments ----
(function () {
var articleID = ATT.articleID;
var sessionToken = ATT.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];
// ---- Attachments(共享逻辑,见 static/js/article-attachments.js ----
initArticleAttachments({
uploadURL: ATT.uploadURL,
listURL: '/api/admin/articles/:id/attachments',
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"}}'
}
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 () {
@@ -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}}
+24
View File
@@ -48,6 +48,9 @@
<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}}"
@@ -79,6 +82,8 @@
</form>
</section>
<script src="/static/js/article-attachments.js?v=1"></script>
<script>
var myEasyMDE = null;
document.addEventListener('DOMContentLoaded', function() {
@@ -120,6 +125,25 @@ document.addEventListener('DOMContentLoaded', function() {
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 ----