fix: 修复 CSRF 缺失 + 重写编辑器为三模式(所见即所得/源码/分屏)

CSRF 修复:
- /posts/new 和 /posts/:id/edit 原在独立 postPages group 中,仅含 Required 中间件,缺少 CSRF 下发
- 移动到 pages group(含 CSRF 中间件),用 per-route d.authMdw.Required() 弥补登录校验
- 同时消除 /posts/:id 与 /posts/new 潜在的路由冲突

编辑器重写:
- 添加三种编辑模式切换标签:所见即所得 / 源码 / 分屏预览
- 所见即所得(默认):contenteditable 编辑区 + 工具条(execCommand)
  支持加粗、斜体、H2/H3、链接、图片、引用、代码、列表、分割线
- 源码:纯 textarea,textarea 作为数据源始终保存 markdown 原文
- 分屏:左 textarea 编辑 + 右实时渲染预览(API 渲染)
- 模式切换时自动同步:WYSIWYG → HTML → Markdown → textarea
- 工具条在 WYSIWYG 模式用 execCommand,源码模式用文本包裹语法

实现细节:
- contenteditable ← textarea 双向同步(WYSIWYG 模式)
- 基本 HTML→Markdown 转换器(strong/em/h1-h6/a/pre/code/li/blockquote/img)
- 分屏布局用 JS toggled .split-mode class(非 :has(),兼容 Firefox)
- 编辑器初始时自动进 WYSIWYG 模式渲染已有内容
This commit is contained in:
2026-05-27 18:35:13 +08:00
parent 03a01a09be
commit 174d715e50
4 changed files with 588 additions and 71 deletions

View File

@ -164,7 +164,7 @@
/* ========== 编辑器 ========== */
.editor-container {
max-width: 900px;
max-width: 1024px;
margin: 0 auto;
padding: 32px 16px;
}
@ -176,7 +176,7 @@
}
.post-form .form-group {
margin-bottom: 20px;
margin-bottom: 16px;
}
.post-form label {
@ -196,38 +196,229 @@
box-sizing: border-box;
}
/* ========== 模式标签 ========== */
.editor-modes {
display: flex;
gap: 4px;
margin-bottom: 0;
}
.mode-tab {
padding: 8px 18px;
border: 1px solid #d1d5db;
border-bottom: none;
border-radius: 8px 8px 0 0;
background: #f9fafb;
color: #6b7280;
font-size: 13px;
font-weight: 500;
cursor: pointer;
position: relative;
z-index: 1;
}
.mode-tab.active {
background: #fff;
color: #6366f1;
border-color: #d1d5db;
z-index: 2;
margin-bottom: -1px;
padding-bottom: 9px;
}
/* ========== 工具条 ========== */
.editor-toolbar {
display: flex;
align-items: center;
gap: 2px;
padding: 6px 8px;
background: #fff;
border: 1px solid #d1d5db;
border-top: none;
border-radius: 0 0 8px 8px;
min-height: 40px;
flex-wrap: wrap;
}
/* 编辑区上边距 */
.editor-toolbar + .editor-body,
.editor-modes + .editor-body {
margin-top: 0;
}
.tb-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 30px;
border: none;
border-radius: 4px;
background: transparent;
color: #4b5563;
cursor: pointer;
font-size: 13px;
font-weight: 600;
transition: background 0.15s;
}
.tb-btn:hover {
background: #f3f4f6;
color: #111;
}
.tb-btn svg {
width: 18px;
height: 18px;
}
.tb-sep {
width: 1px;
height: 20px;
background: #e5e7eb;
margin: 0 4px;
}
/* ========== 编辑区 ========== */
.editor-body {
position: relative;
clear: both;
overflow: hidden;
}
.form-textarea {
width: 100%;
padding: 12px 14px;
border: 1px solid #d1d5db;
border-radius: 8px;
font-size: 15px;
font-family: 'Menlo', 'Consolas', monospace;
line-height: 1.6;
font-family: 'Menlo', 'Consolas', 'Monaco', monospace;
line-height: 1.7;
resize: vertical;
box-sizing: border-box;
min-height: 400px;
}
.editor-actions {
display: flex;
gap: 12px;
margin-top: 16px;
/* WYSIWYG 编辑器 */
.wysiwyg-editor {
min-height: 400px;
padding: 14px 18px;
border: 1px solid #d1d5db;
border-radius: 8px;
font-size: 16px;
line-height: 1.8;
color: #1f2937;
outline: none;
word-wrap: break-word;
overflow-wrap: break-word;
}
.post-preview {
margin-top: 32px;
padding: 24px;
background: #f9fafb;
border: 1px solid #e5e7eb;
border-radius: 10px;
.wysiwyg-editor:focus {
border-color: #6366f1;
box-shadow: 0 0 0 2px rgba(99,102,241,0.15);
}
.post-preview h3 {
margin-top: 0;
color: #6b7280;
.wysiwyg-editor h1 { font-size: 2em; margin: 0.5em 0 0.3em; }
.wysiwyg-editor h2 { font-size: 1.6em; margin: 0.5em 0 0.3em; }
.wysiwyg-editor h3 { font-size: 1.3em; margin: 0.4em 0 0.25em; }
.wysiwyg-editor p { margin: 0 0 0.5em; }
.wysiwyg-editor pre {
background: #f3f4f6;
padding: 12px 16px;
border-radius: 6px;
font-family: 'Menlo','Consolas',monospace;
font-size: 14px;
text-transform: uppercase;
letter-spacing: 0.5px;
overflow-x: auto;
margin: 0.5em 0;
}
.wysiwyg-editor code {
background: #f3f4f6;
padding: 1px 5px;
border-radius: 3px;
font-family: 'Menlo','Consolas',monospace;
font-size: 0.9em;
}
.wysiwyg-editor blockquote {
border-left: 4px solid #6366f1;
padding: 2px 14px;
color: #6b7280;
margin: 0.5em 0;
}
.wysiwyg-editor ul, .wysiwyg-editor ol {
padding-left: 24px;
margin: 0.4em 0;
}
.wysiwyg-editor li {
margin-bottom: 2px;
}
.wysiwyg-editor img {
max-width: 100%;
border-radius: 6px;
}
.wysiwyg-editor a {
color: #6366f1;
}
.wysiwyg-editor hr {
border: none;
border-top: 2px solid #e5e7eb;
margin: 1em 0;
}
.wysiwyg-editor table {
border-collapse: collapse;
width: 100%;
margin: 0.5em 0;
}
.wysiwyg-editor th, .wysiwyg-editor td {
border: 1px solid #d1d5db;
padding: 6px 12px;
text-align: left;
}
/* ========== 分屏预览 ========== */
.editor-body.split-mode .form-textarea {
width: 50%;
float: left;
border-radius: 8px 0 0 8px;
border-right: none;
}
.split-preview {
width: 50%;
float: right;
padding: 0;
box-sizing: border-box;
overflow-y: auto;
}
.split-preview .split-preview-content {
padding: 14px 18px;
background: #f9fafb;
border: 1px solid #d1d5db;
border-radius: 0 8px 8px 0;
min-height: 400px;
font-size: 15px;
line-height: 1.8;
}
.split-preview .split-preview-content h1,
.split-preview .split-preview-content h2,
.split-preview .split-preview-content h3 {
margin-top: 18px;
margin-bottom: 8px;
}
.split-preview .split-preview-content pre {
background: #e5e7eb;
padding: 12px;
border-radius: 6px;
overflow-x: auto;
font-size: 13px;
}
.split-preview .split-preview-content blockquote {
border-left: 4px solid #6366f1;
padding-left: 14px;
color: #6b7280;
}
/* ========== 404 ========== */

View File

@ -1,57 +1,336 @@
(function() {
var form = document.getElementById('postForm');
var form = document.getElementById('postForm');
if (!form) return;
var postId = document.getElementById('postId');
var titleEl = document.getElementById('postTitle');
var bodyEl = document.getElementById('postBody');
var previewBtn = document.getElementById('previewBtn');
var previewArea = document.getElementById('previewArea');
var previewContent = document.getElementById('previewContent');
var postId = document.getElementById('postId');
var titleEl = document.getElementById('postTitle');
var bodyEl = document.getElementById('postBody'); // textarea (source of truth)
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 syncTimer = null;
// ===================== HELPERS =====================
// 获取 CSRF token
function getCSRFToken() {
var meta = document.querySelector('meta[name="csrf-token"]');
return meta ? meta.getAttribute('content') : '';
}
// 预览
if (previewBtn) {
previewBtn.addEventListener('click', function() {
var body = bodyEl.value.trim();
if (!body) { alert('请输入正文'); return; }
function getBodyValue() {
return currentMode === 'wysiwyg' ? bodyEl.value : bodyEl.value;
}
fetch('/api/posts/preview', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': getCSRFToken()
},
body: JSON.stringify({ body: body })
})
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.success) {
previewContent.innerHTML = data.data.html;
previewArea.style.display = 'block';
} else {
alert(data.message || '预览失败');
}
})
.catch(function() { alert('预览请求失败'); });
// 保存到 textarea 并同步 WYSIWYG
function saveToTextarea(md) {
bodyEl.value = md;
if (currentMode === 'wysiwyg') syncMarkdownToWysiwyg();
}
// ================== MARKDOWN ↔ HTML ==================
// 通过 API 渲染 markdown 为 HTML
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) {
var md = '';
if (node.nodeType === 3) { // text
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-','') || '') : '';
var text = node.textContent;
return '\n```' + lang + '\n' + text + '\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':
var src = node.getAttribute('src') || '';
var alt = node.getAttribute('alt') || '';
return '![' + alt + '](' + src + ')';
case 'div':
case 'section':
return children;
default:
return children || node.textContent;
}
}
return walk(el).replace(/\n{3,}/g, '\n\n').trim();
}
// 将 markdown 渲染到 WYSIWYG
function syncMarkdownToWysiwyg() {
if (!bodyEl.value.trim()) {
wysiwygEl.innerHTML = '';
return;
}
renderMarkdown(bodyEl.value, function(html) {
var cur = wysiwygEl.innerHTML;
// 只在真的变化时更新,避免闪烁
if (cur !== html) {
wysiwygEl.innerHTML = html;
}
});
}
// 提交
// WYSIWYG 编辑后延迟同步回 textarea
function debounceSync() {
clearTimeout(syncTimer);
syncTimer = setTimeout(function() {
var md = htmlToMarkdown(wysiwygEl.innerHTML);
bodyEl.value = md;
}, 500);
}
// ==================== MODE SWITCHING ====================
function switchMode(mode) {
// 先同步当前状态
if (currentMode === 'wysiwyg') {
var md = htmlToMarkdown(wysiwygEl.innerHTML);
bodyEl.value = md;
}
currentMode = mode;
// 更新标签
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');
}
// 切换面板
if (mode === 'wysiwyg') {
bodyEl.style.display = 'none';
wysiwygEl.style.display = '';
if (splitPrev) splitPrev.style.display = 'none';
if (toolbar) toolbar.style.display = '';
syncMarkdownToWysiwyg();
} else if (mode === 'source') {
bodyEl.style.display = '';
wysiwygEl.style.display = 'none';
if (splitPrev) splitPrev.style.display = 'none';
if (toolbar) toolbar.style.display = '';
} else if (mode === 'split') {
bodyEl.style.display = '';
wysiwygEl.style.display = 'none';
if (splitPrev) splitPrev.style.display = '';
if (toolbar) toolbar.style.display = 'none';
updateSplitPreview();
}
}
modeTabs.forEach(function(tab) {
tab.addEventListener('click', function() {
switchMode(tab.dataset.mode);
});
});
// 分屏实时预览(防抖)
var splitTimer = null;
bodyEl.addEventListener('input', function() {
if (currentMode === 'split') {
clearTimeout(splitTimer);
splitTimer = setTimeout(updateSplitPreview, 300);
}
});
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') debounceSync();
});
// ==================== 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 if (currentMode === 'source') {
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':
var imgUrl = prompt('输入图片链接:', 'https://');
if (imgUrl) document.execCommand('insertImage', false, imgUrl);
break;
case 'quote':
document.execCommand('formatBlock', false, 'blockquote');
break;
case 'code':
wrapSelectionWysiwyg('`', '`');
break;
case 'pre':
document.execCommand('formatBlock', false, 'pre');
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;
}
debounceSync();
}
// 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));
} else {
range.deleteContents();
range.insertNode(document.createTextNode(before + text + after));
}
debounceSync();
}
// 源码模式下插入 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': wrap = { b: '![', a: '](https://)' }; break;
case 'quote': wrap = { b: '> ', a: '' }; break;
case 'code': wrap = { b: '`', a: '`' }; break;
case 'pre': wrap = { b: '\n```\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;
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 ? wrap.a.length : 0));
}
}
// ==================== FORM SUBMISSION ====================
form.addEventListener('submit', function(e) {
e.preventDefault();
var title = titleEl.value.trim();
var body = bodyEl.value.trim();
// WYSIWYG 模式下先同步
if (currentMode === 'wysiwyg') {
var md = htmlToMarkdown(wysiwygEl.innerHTML);
bodyEl.value = md;
currentMode = 'source';
}
var title = titleEl.value.trim();
var body = bodyEl.value.trim();
if (!title) { alert('请输入标题'); return; }
if (!body) { alert('请输入正文'); return; }
if (!body) { alert('请输入正文'); return; }
var url = '/api/posts';
var method = 'POST';
@ -82,4 +361,9 @@
})
.catch(function() { alert('请求失败'); });
});
// ==================== INIT ====================
if (bodyEl.value.trim()) {
switchMode('wysiwyg');
}
})();