fix: 修复帖子系统5项缺陷 + 代码块插入位置/language-class/分割线占位
- 作者显示 用户名(UID): 修正Post.AuthorName GORM标签从 -:all 改为 -:migration;<-:false;column:author_name
- 审核流程: 帖子详情页新增"提交审核"按钮(draft/rejected时作者可见)
- 图片上传: 新增 POST /api/posts/upload-image 端点,限制5MB/jpg/png/gif/webp
- 代码块语言标注: 工具栏插入代码块时弹出语言输入框,生成 language-xxx class
- 状态中文显示: 管理后台/帖子详情页状态改为 PostStatusDisplayNames 映射
- 修复代码块插入位置错误: init时调用switchMode同步DOM; 无选区时appendChild; 防<pre>嵌套
- 修复 bluemonday.UGCPolicy() 剥离 code/pre 的 class 属性,显式 AllowAttrs("class")
- 修复分割线工具栏插入多余text占位符: hr/link/image以外不强制填占位文本
This commit is contained in:
@ -29,7 +29,7 @@
|
||||
<a href="/posts/{{.ID}}">{{.Title}}</a>
|
||||
</h2>
|
||||
<div class="post-card-meta">
|
||||
<span class="post-author">{{.AuthorName}}</span>
|
||||
<span class="post-author">{{if .AuthorName}}{{.AuthorName}}({{.UserID}}){{else}}UID{{.UserID}}{{end}}</span>
|
||||
<span class="post-time">{{.CreatedAt.Format "2006-01-02 15:04"}}</span>
|
||||
</div>
|
||||
<p class="post-card-summary">{{printf "%.200s" .Body}}</p>
|
||||
|
||||
@ -77,6 +77,9 @@
|
||||
<button type="submit" class="btn btn-primary">{{if .Post}}保存修改{{else}}发布帖子{{end}}</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{{/* 隐藏的图片上传控件 */}}
|
||||
<input type="file" id="imageFileInput" accept="image/jpeg,image/png,image/gif,image/webp" style="display:none;">
|
||||
</div>
|
||||
|
||||
{{template "layout/footer.html" .}}
|
||||
|
||||
@ -8,10 +8,10 @@
|
||||
<header class="post-detail-header">
|
||||
<h1 class="post-detail-title">{{.Post.Title}}</h1>
|
||||
<div class="post-detail-meta">
|
||||
<span class="post-author">{{.Post.AuthorName}}</span>
|
||||
<span class="post-author">{{if .Post.AuthorName}}{{.Post.AuthorName}}({{.Post.UserID}}){{else}}UID{{.Post.UserID}}{{end}}</span>
|
||||
<span class="post-time">{{.Post.CreatedAt.Format "2006-01-02 15:04"}}</span>
|
||||
{{if ne .Post.Status "approved"}}
|
||||
<span class="post-status post-status-{{.Post.Status}}">{{printf "%s" .Post.Status}}</span>
|
||||
<span class="post-status post-status-{{.Post.Status}}">{{index $.StatusNames .Post.Status}}</span>
|
||||
{{end}}
|
||||
</div>
|
||||
</header>
|
||||
@ -36,6 +36,9 @@
|
||||
{{if and $.Post (or (eq $.Post.UserID $.UID) $.CanAccessAdmin) (ne $.Post.Status "pending") (ne $.Post.Status "locked")}}
|
||||
<a href="/posts/{{$.Post.ID}}/edit" class="btn btn-secondary">编辑</a>
|
||||
{{end}}
|
||||
{{if and $.Post (eq $.Post.UserID $.UID) (or (eq $.Post.Status "draft") (eq $.Post.Status "rejected"))}}
|
||||
<button class="btn btn-primary" id="submitAuditBtn" data-id="{{$.Post.ID}}">提交审核</button>
|
||||
{{end}}
|
||||
{{if $.CanAccessAdmin}}
|
||||
<a href="/admin/posts" class="btn btn-secondary">管理</a>
|
||||
{{end}}
|
||||
@ -46,5 +49,28 @@
|
||||
{{template "layout/footer.html" .}}
|
||||
|
||||
<script src="/static/js/common.js"></script>
|
||||
<script>
|
||||
(function() {
|
||||
var btn = document.getElementById('submitAuditBtn');
|
||||
if (!btn) return;
|
||||
btn.addEventListener('click', function() {
|
||||
if (!confirm('确认提交审核?审核通过后将公开发布。')) return;
|
||||
var csrf = document.querySelector('meta[name="csrf-token"]');
|
||||
fetch('/api/posts/' + btn.dataset.id + '/submit', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-Token': csrf ? csrf.getAttribute('content') : ''
|
||||
}
|
||||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(d) {
|
||||
if (d.success) { window.location.reload(); }
|
||||
else { alert(d.message || '提交失败'); }
|
||||
})
|
||||
.catch(function() { alert('请求失败'); });
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@ -15,6 +15,7 @@
|
||||
var currentMode = 'wysiwyg';
|
||||
var wysiwygDirty = false; // WYSIWYG 是否已被用户编辑过(首次进入时不覆盖 textarea)
|
||||
var syncTimer = null;
|
||||
var imageInput = document.getElementById('imageFileInput');
|
||||
|
||||
// ===================== HELPERS =====================
|
||||
|
||||
@ -235,8 +236,8 @@
|
||||
if (url) document.execCommand('createLink', false, url);
|
||||
break;
|
||||
case 'image':
|
||||
var imgUrl = prompt('输入图片链接:', 'https://');
|
||||
if (imgUrl) document.execCommand('insertImage', false, imgUrl);
|
||||
imagePickerTarget = 'wysiwyg';
|
||||
imageInput.click();
|
||||
break;
|
||||
case 'quote':
|
||||
document.execCommand('formatBlock', false, 'blockquote');
|
||||
@ -245,7 +246,9 @@
|
||||
wrapSelectionWysiwyg('`', '`');
|
||||
break;
|
||||
case 'pre':
|
||||
document.execCommand('formatBlock', false, 'pre');
|
||||
var lang = prompt('输入代码语言(如 python / go / text,留空默认为 text):', '');
|
||||
lang = lang ? lang.trim() : 'text';
|
||||
insertCodeBlockWysiwyg(lang);
|
||||
break;
|
||||
case 'ul':
|
||||
document.execCommand('insertUnorderedList', false, null);
|
||||
@ -285,17 +288,31 @@
|
||||
case 'h2': wrap = { b: '## ', a: '' }; break;
|
||||
case 'h3': wrap = { b: '### ',a: '' }; break;
|
||||
case 'link': wrap = { b: '[', a: '](https://)' }; break;
|
||||
case 'image': wrap = { b: '' }; break;
|
||||
case 'image':
|
||||
// 源码/分屏模式下图片使用上传
|
||||
imagePickerTarget = currentMode;
|
||||
imageInput.click();
|
||||
return;
|
||||
case 'quote': wrap = { b: '> ', a: '' }; break;
|
||||
case 'code': wrap = { b: '`', a: '`' }; break;
|
||||
case 'pre': wrap = { b: '\n```\n', a: '\n```\n' }; break;
|
||||
case 'pre':
|
||||
var lang = prompt('输入代码语言(如 python / go / text,留空默认为 text):', '');
|
||||
lang = lang ? lang.trim() : 'text';
|
||||
wrap = { b: '\n```' + lang + '\n', a: '\n```\n' };
|
||||
break;
|
||||
case 'ul': wrap = { b: '- ', a: '' }; break;
|
||||
case 'ol': wrap = { b: '1. ', a: '' }; break;
|
||||
case 'hr': wrap = { b: '\n---\n', a: '' }; break;
|
||||
}
|
||||
|
||||
if (wrap.b !== undefined) {
|
||||
var replacement = wrap.b + (text || (action === 'link' ? 'link' : action === 'image' ? 'image' : 'text')) + wrap.a;
|
||||
var placeholder = '';
|
||||
if (!text) {
|
||||
if (action === 'link') placeholder = 'link';
|
||||
else if (action === 'image') placeholder = 'image';
|
||||
// hr 和其他无内容操作不加占位文本
|
||||
}
|
||||
var replacement = wrap.b + placeholder + wrap.a;
|
||||
bodyEl.value = bodyEl.value.substring(0, start) + replacement + bodyEl.value.substring(end);
|
||||
bodyEl.focus();
|
||||
var cursor = start + wrap.b.length + (text ? text.length : 0);
|
||||
@ -306,6 +323,119 @@
|
||||
}
|
||||
}
|
||||
|
||||
// WYSIWYG 中插入带语言标注的代码块
|
||||
function insertCodeBlockWysiwyg(lang) {
|
||||
if (wysiwygEl.style.display === 'none') {
|
||||
// WYSIWYG 不可见时直接向 textarea 插入 markdown 代码块
|
||||
var start = bodyEl.selectionStart;
|
||||
var end = bodyEl.selectionEnd;
|
||||
var text = bodyEl.value.substring(start, end);
|
||||
var b = '\n```' + lang + '\n';
|
||||
var a = '\n```\n';
|
||||
bodyEl.value = bodyEl.value.substring(0, start) + b + (text || '') + a + bodyEl.value.substring(end);
|
||||
bodyEl.focus();
|
||||
var cursor = start + b.length + (text ? text.length : 0);
|
||||
bodyEl.setSelectionRange(cursor, cursor + (text ? 0 : a.length));
|
||||
if (currentMode === 'split') updateSplitPreview();
|
||||
return;
|
||||
}
|
||||
wysiwygEl.focus();
|
||||
var sel = window.getSelection();
|
||||
|
||||
var pre = document.createElement('pre');
|
||||
var code = document.createElement('code');
|
||||
if (lang && lang !== 'text') code.className = 'language-' + lang;
|
||||
code.textContent = '\n';
|
||||
pre.appendChild(code);
|
||||
|
||||
if (!sel.rangeCount) {
|
||||
// 无选区:追加到编辑器末尾
|
||||
wysiwygEl.appendChild(pre);
|
||||
} else {
|
||||
var range = sel.getRangeAt(0);
|
||||
// 防止在已有 pre 内部嵌套插入:把插入点移到 pre 之后
|
||||
var ancestor = range.commonAncestorContainer;
|
||||
while (ancestor && ancestor !== wysiwygEl) {
|
||||
if (ancestor.nodeType === 1 && ancestor.tagName === 'PRE') {
|
||||
range.setStartAfter(ancestor);
|
||||
range.setEndAfter(ancestor);
|
||||
break;
|
||||
}
|
||||
ancestor = ancestor.parentNode;
|
||||
}
|
||||
// 确保插入点落在 WYSIWYG 内部
|
||||
if (!wysiwygEl.contains(range.commonAncestorContainer)) {
|
||||
wysiwygEl.appendChild(pre);
|
||||
} else {
|
||||
range.deleteContents();
|
||||
range.insertNode(pre);
|
||||
}
|
||||
}
|
||||
|
||||
// 在代码块后加换行,方便继续输入
|
||||
var br = document.createElement('br');
|
||||
pre.parentNode.insertBefore(br, pre.nextSibling);
|
||||
|
||||
// 光标移到 code 内
|
||||
var newRange = document.createRange();
|
||||
newRange.selectNodeContents(code);
|
||||
newRange.collapse(true);
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(newRange);
|
||||
|
||||
wysiwygDirty = true;
|
||||
}
|
||||
|
||||
// ==================== IMAGE UPLOAD ====================
|
||||
var imagePickerTarget = null;
|
||||
|
||||
if (imageInput) {
|
||||
imageInput.addEventListener('change', function() {
|
||||
var file = imageInput.files[0];
|
||||
if (!file) { imagePickerTarget = null; return; }
|
||||
uploadImage(file, function(url) {
|
||||
if (!url) { imagePickerTarget = null; return; }
|
||||
if (imagePickerTarget === 'wysiwyg') {
|
||||
wysiwygEl.focus();
|
||||
document.execCommand('insertImage', false, url);
|
||||
wysiwygDirty = true;
|
||||
} else {
|
||||
var start = bodyEl.selectionStart;
|
||||
var end = bodyEl.selectionEnd;
|
||||
var alt = bodyEl.value.substring(start, end) || 'image';
|
||||
var md = '';
|
||||
bodyEl.value = bodyEl.value.substring(0, start) + md + bodyEl.value.substring(end);
|
||||
bodyEl.focus();
|
||||
bodyEl.setSelectionRange(start + md.length, start + md.length);
|
||||
if (currentMode === 'split') updateSplitPreview();
|
||||
}
|
||||
imagePickerTarget = null;
|
||||
});
|
||||
imageInput.value = '';
|
||||
});
|
||||
}
|
||||
|
||||
function uploadImage(file, callback) {
|
||||
var fd = new FormData();
|
||||
fd.append('file', file);
|
||||
|
||||
fetch('/api/posts/upload-image', {
|
||||
method: 'POST',
|
||||
headers: { 'X-CSRF-Token': getCSRFToken() },
|
||||
body: fd
|
||||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(d) {
|
||||
if (d.success && d.data && d.data.url) {
|
||||
callback(d.data.url);
|
||||
} else {
|
||||
alert(d.message || '图片上传失败');
|
||||
callback(null);
|
||||
}
|
||||
})
|
||||
.catch(function() { alert('图片上传请求失败'); callback(null); });
|
||||
}
|
||||
|
||||
// ==================== FORM SUBMISSION ====================
|
||||
|
||||
form.addEventListener('submit', function(e) {
|
||||
@ -353,8 +483,6 @@
|
||||
});
|
||||
|
||||
// ==================== INIT ====================
|
||||
if (bodyEl.value.trim()) {
|
||||
// 如果已有内容(编辑帖子),渲染到 WYSIWYG 但不写入 textarea
|
||||
syncMarkdownToWysiwyg();
|
||||
}
|
||||
// 同步 DOM 状态与默认 mode(textarea 隐藏、WYSIWYG 可见),并渲染已有内容
|
||||
switchMode('wysiwyg');
|
||||
})();
|
||||
|
||||
@ -42,8 +42,8 @@
|
||||
<tr class="post-row">
|
||||
<td>{{.ID}}</td>
|
||||
<td><a href="/posts/{{.ID}}" target="_blank" class="post-link">{{.Title}}</a></td>
|
||||
<td>{{.AuthorName}}</td>
|
||||
<td><span class="status-badge status-{{.Status}}">{{.Status}}</span></td>
|
||||
<td>{{if .AuthorName}}{{.AuthorName}}({{.UserID}}){{else}}UID{{.UserID}}{{end}}</td>
|
||||
<td><span class="status-badge status-{{.Status}}">{{index $.StatusNames .Status}}</span></td>
|
||||
<td>{{.CreatedAt.Format "2006-01-02 15:04"}}</td>
|
||||
<td class="actions-cell">
|
||||
<div class="action-btns">
|
||||
|
||||
Reference in New Issue
Block a user