- 作者显示 用户名(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以外不强制填占位文本
489 lines
18 KiB
JavaScript
489 lines
18 KiB
JavaScript
(function() {
|
||
var form = document.getElementById('postForm');
|
||
if (!form) return;
|
||
|
||
var postId = document.getElementById('postId');
|
||
var titleEl = document.getElementById('postTitle');
|
||
var bodyEl = document.getElementById('postBody'); // textarea (唯一数据源)
|
||
var wysiwygEl = document.getElementById('wysiwygEditor'); // contenteditable
|
||
var splitPrev = document.getElementById('splitPreview');
|
||
var splitCont = splitPrev ? splitPrev.querySelector('.split-preview-content') : null;
|
||
var toolbar = document.getElementById('toolbar');
|
||
var modeTabs = document.querySelectorAll('.mode-tab');
|
||
|
||
var isEdit = postId && postId.value;
|
||
var currentMode = 'wysiwyg';
|
||
var wysiwygDirty = false; // WYSIWYG 是否已被用户编辑过(首次进入时不覆盖 textarea)
|
||
var syncTimer = null;
|
||
var imageInput = document.getElementById('imageFileInput');
|
||
|
||
// ===================== HELPERS =====================
|
||
|
||
function getCSRFToken() {
|
||
var meta = document.querySelector('meta[name="csrf-token"]');
|
||
return meta ? meta.getAttribute('content') : '';
|
||
}
|
||
|
||
// ================== MARKDOWN ↔ HTML ==================
|
||
|
||
// 通过 API 渲染 markdown 为 HTML(服务端 Goldmark + bluemonday 消毒)
|
||
function renderMarkdown(md, callback) {
|
||
fetch('/api/posts/preview', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'X-CSRF-Token': getCSRFToken()
|
||
},
|
||
body: JSON.stringify({ body: md })
|
||
})
|
||
.then(function(r) { return r.json(); })
|
||
.then(function(d) { callback(d.success ? d.data.html : md); })
|
||
.catch(function() { callback(md); });
|
||
}
|
||
|
||
// 将 contenteditable 的 HTML 转回 Markdown(基本转换)
|
||
function htmlToMarkdown(html) {
|
||
var el = document.createElement('div');
|
||
el.innerHTML = html;
|
||
|
||
function walk(node) {
|
||
if (node.nodeType === 3) return node.textContent;
|
||
if (node.nodeType !== 1) return '';
|
||
var tag = node.tagName.toLowerCase();
|
||
var children = '';
|
||
for (var i = 0; i < node.childNodes.length; i++) {
|
||
children += walk(node.childNodes[i]);
|
||
}
|
||
switch (tag) {
|
||
case 'br': return '\n';
|
||
case 'p': return children + '\n\n';
|
||
case 'strong': case 'b': return '**' + children + '**';
|
||
case 'em': case 'i': return '_' + children + '_';
|
||
case 'h1': return '# ' + children + '\n\n';
|
||
case 'h2': return '## ' + children + '\n\n';
|
||
case 'h3': return '### ' + children + '\n\n';
|
||
case 'h4': return '#### ' + children + '\n\n';
|
||
case 'h5': return '##### ' + children + '\n\n';
|
||
case 'h6': return '###### ' + children + '\n\n';
|
||
case 'a':
|
||
var href = node.getAttribute('href') || '';
|
||
return '[' + children + '](' + href + ')';
|
||
case 'pre':
|
||
var code = node.querySelector('code');
|
||
var lang = code ? (code.className.replace('language-','') || '') : '';
|
||
return '\n```' + lang + '\n' + node.textContent + '\n```\n';
|
||
case 'code':
|
||
if (node.parentNode.tagName.toLowerCase() !== 'pre')
|
||
return '`' + children + '`';
|
||
return children;
|
||
case 'li':
|
||
if (node.parentNode.tagName.toLowerCase() === 'ul') return '- ' + children + '\n';
|
||
if (node.parentNode.tagName.toLowerCase() === 'ol') return '1. ' + children + '\n';
|
||
return children + '\n';
|
||
case 'ul': case 'ol': return children + '\n';
|
||
case 'blockquote':
|
||
return '> ' + children.replace(/\n$/, '').replace(/\n/g, '\n> ') + '\n\n';
|
||
case 'hr': return '\n---\n';
|
||
case 'img':
|
||
return ' || '') + ')';
|
||
case 'div':
|
||
case 'section':
|
||
return children;
|
||
default:
|
||
return children || node.textContent;
|
||
}
|
||
}
|
||
return walk(el).replace(/\n{3,}/g, '\n\n').trim();
|
||
}
|
||
|
||
// 将 markdown 从 textarea 渲染到 WYSIWYG
|
||
function syncMarkdownToWysiwyg() {
|
||
if (!bodyEl.value.trim()) {
|
||
wysiwygEl.innerHTML = '';
|
||
return;
|
||
}
|
||
renderMarkdown(bodyEl.value, function(html) {
|
||
wysiwygEl.innerHTML = html;
|
||
});
|
||
}
|
||
|
||
// WYSIWYG 编辑后延迟将 HTML 转为 markdown 保存回 textarea
|
||
function debounceSync() {
|
||
clearTimeout(syncTimer);
|
||
syncTimer = setTimeout(function() {
|
||
var md = htmlToMarkdown(wysiwygEl.innerHTML);
|
||
if (md !== bodyEl.value) {
|
||
bodyEl.value = md;
|
||
}
|
||
}, 600);
|
||
}
|
||
|
||
// markdown 保存回 textarea 后触发分屏预览更新
|
||
bodyEl.addEventListener('input', function() {
|
||
if (currentMode === 'split') {
|
||
clearTimeout(splitTimer);
|
||
splitTimer = setTimeout(updateSplitPreview, 300);
|
||
}
|
||
});
|
||
var splitTimer = null;
|
||
|
||
function updateSplitPreview() {
|
||
if (!splitCont || !bodyEl.value.trim()) {
|
||
if (splitCont) splitCont.innerHTML = '<em style="color:#9ca3af">输入正文后将在此处预览...</em>';
|
||
return;
|
||
}
|
||
renderMarkdown(bodyEl.value, function(html) {
|
||
splitCont.innerHTML = html;
|
||
});
|
||
}
|
||
|
||
// WYSIWYG 输入实时同步
|
||
wysiwygEl.addEventListener('input', function() {
|
||
if (currentMode === 'wysiwyg') {
|
||
wysiwygDirty = true;
|
||
debounceSync();
|
||
}
|
||
});
|
||
|
||
// ==================== MODE SWITCHING ====================
|
||
|
||
function switchMode(mode) {
|
||
// 仅在离开 WYSIWYG 时同步 WYSIWYG → textarea
|
||
if (currentMode === 'wysiwyg' && mode !== 'wysiwyg' && wysiwygDirty) {
|
||
var md = htmlToMarkdown(wysiwygEl.innerHTML);
|
||
bodyEl.value = md;
|
||
}
|
||
|
||
currentMode = mode;
|
||
wysiwygDirty = false; // 切换到新模式下重置 dirty 标记
|
||
|
||
// 更新标签
|
||
modeTabs.forEach(function(tab) {
|
||
tab.classList.toggle('active', tab.dataset.mode === mode);
|
||
});
|
||
|
||
// 切换布局 class
|
||
var editorBody = document.querySelector('.editor-body');
|
||
if (editorBody) {
|
||
editorBody.classList.toggle('split-mode', mode === 'split');
|
||
}
|
||
|
||
// 工具栏在 split 模式下也显示(源码风格操作)
|
||
if (toolbar) {
|
||
toolbar.style.display = (mode === 'source' || mode === 'split') ? '' : '';
|
||
}
|
||
|
||
// 切换面板
|
||
if (mode === 'wysiwyg') {
|
||
bodyEl.style.display = 'none';
|
||
wysiwygEl.style.display = '';
|
||
if (splitPrev) splitPrev.style.display = 'none';
|
||
syncMarkdownToWysiwyg();
|
||
wysiwygEl.focus();
|
||
} else if (mode === 'source') {
|
||
bodyEl.style.display = '';
|
||
wysiwygEl.style.display = 'none';
|
||
if (splitPrev) splitPrev.style.display = 'none';
|
||
bodyEl.focus();
|
||
} else if (mode === 'split') {
|
||
bodyEl.style.display = '';
|
||
wysiwygEl.style.display = 'none';
|
||
if (splitPrev) splitPrev.style.display = '';
|
||
bodyEl.focus();
|
||
updateSplitPreview();
|
||
}
|
||
}
|
||
|
||
modeTabs.forEach(function(tab) {
|
||
tab.addEventListener('click', function() {
|
||
switchMode(tab.dataset.mode);
|
||
});
|
||
});
|
||
|
||
// ==================== TOOLBAR ====================
|
||
|
||
if (toolbar) {
|
||
toolbar.addEventListener('click', function(e) {
|
||
var btn = e.target.closest('.tb-btn');
|
||
if (!btn) return;
|
||
var action = btn.dataset.action;
|
||
if (currentMode === 'wysiwyg') {
|
||
execWysiwygAction(action);
|
||
} else {
|
||
// source 和 split 模式都用源码工具栏(操作 textarea 中的 markdown)
|
||
execSourceAction(action);
|
||
}
|
||
});
|
||
}
|
||
|
||
function execWysiwygAction(action) {
|
||
wysiwygEl.focus();
|
||
switch (action) {
|
||
case 'bold':
|
||
document.execCommand('bold', false, null);
|
||
break;
|
||
case 'italic':
|
||
document.execCommand('italic', false, null);
|
||
break;
|
||
case 'h2':
|
||
document.execCommand('formatBlock', false, 'h2');
|
||
break;
|
||
case 'h3':
|
||
document.execCommand('formatBlock', false, 'h3');
|
||
break;
|
||
case 'link':
|
||
var url = prompt('输入链接地址:', 'https://');
|
||
if (url) document.execCommand('createLink', false, url);
|
||
break;
|
||
case 'image':
|
||
imagePickerTarget = 'wysiwyg';
|
||
imageInput.click();
|
||
break;
|
||
case 'quote':
|
||
document.execCommand('formatBlock', false, 'blockquote');
|
||
break;
|
||
case 'code':
|
||
wrapSelectionWysiwyg('`', '`');
|
||
break;
|
||
case 'pre':
|
||
var lang = prompt('输入代码语言(如 python / go / text,留空默认为 text):', '');
|
||
lang = lang ? lang.trim() : 'text';
|
||
insertCodeBlockWysiwyg(lang);
|
||
break;
|
||
case 'ul':
|
||
document.execCommand('insertUnorderedList', false, null);
|
||
break;
|
||
case 'ol':
|
||
document.execCommand('insertOrderedList', false, null);
|
||
break;
|
||
case 'hr':
|
||
document.execCommand('insertHorizontalRule', false, null);
|
||
break;
|
||
}
|
||
wysiwygDirty = true;
|
||
}
|
||
|
||
// WYSIWYG 中包裹选中文本
|
||
function wrapSelectionWysiwyg(before, after) {
|
||
var sel = window.getSelection();
|
||
if (!sel.rangeCount) return;
|
||
var range = sel.getRangeAt(0);
|
||
var text = range.toString();
|
||
if (!text) text = 'code';
|
||
range.deleteContents();
|
||
range.insertNode(document.createTextNode(before + text + after));
|
||
wysiwygDirty = true;
|
||
}
|
||
|
||
// 源码/分屏模式下插入 markdown 语法
|
||
function execSourceAction(action) {
|
||
var start = bodyEl.selectionStart;
|
||
var end = bodyEl.selectionEnd;
|
||
var text = bodyEl.value.substring(start, end);
|
||
var wrap = {};
|
||
|
||
switch (action) {
|
||
case 'bold': wrap = { b: '**', a: '**' }; break;
|
||
case 'italic': wrap = { b: '_', a: '_' }; break;
|
||
case 'h2': wrap = { b: '## ', a: '' }; break;
|
||
case 'h3': wrap = { b: '### ',a: '' }; break;
|
||
case 'link': wrap = { b: '[', a: '](https://)' }; break;
|
||
case 'image':
|
||
// 源码/分屏模式下图片使用上传
|
||
imagePickerTarget = currentMode;
|
||
imageInput.click();
|
||
return;
|
||
case 'quote': wrap = { b: '> ', a: '' }; break;
|
||
case 'code': wrap = { b: '`', a: '`' }; 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 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);
|
||
bodyEl.setSelectionRange(cursor, cursor + (text ? 0 : replacement.length - wrap.b.length - wrap.a.length));
|
||
|
||
// 分屏模式下立刻更新预览
|
||
if (currentMode === 'split') updateSplitPreview();
|
||
}
|
||
}
|
||
|
||
// 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) {
|
||
e.preventDefault();
|
||
|
||
// WYSIWYG 模式下先同步到 textarea
|
||
if (currentMode === 'wysiwyg') {
|
||
var md = htmlToMarkdown(wysiwygEl.innerHTML);
|
||
bodyEl.value = md;
|
||
}
|
||
|
||
var title = titleEl.value.trim();
|
||
var body = bodyEl.value.trim();
|
||
if (!title) { alert('请输入标题'); return; }
|
||
if (!body) { alert('请输入正文'); return; }
|
||
|
||
var url = '/api/posts';
|
||
var method = 'POST';
|
||
if (isEdit) {
|
||
url = '/api/posts/' + postId.value;
|
||
method = 'PUT';
|
||
}
|
||
|
||
fetch(url, {
|
||
method: method,
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'X-CSRF-Token': getCSRFToken()
|
||
},
|
||
body: JSON.stringify({ title: title, body: body })
|
||
})
|
||
.then(function(r) { return r.json(); })
|
||
.then(function(data) {
|
||
if (data.success) {
|
||
if (data.data && data.data.id) {
|
||
window.location.href = '/posts/' + data.data.id;
|
||
} else {
|
||
window.location.href = '/posts';
|
||
}
|
||
} else {
|
||
alert(data.message || '操作失败');
|
||
}
|
||
})
|
||
.catch(function() { alert('请求失败'); });
|
||
});
|
||
|
||
// ==================== INIT ====================
|
||
// 同步 DOM 状态与默认 mode(textarea 隐藏、WYSIWYG 可见),并渲染已有内容
|
||
switchMode('wysiwyg');
|
||
})();
|