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

@ -14,21 +14,69 @@
<input type="text" id="postTitle" class="form-input" value="{{if .Post}}{{.Post.Title}}{{end}}" maxlength="200" required>
</div>
<div class="form-group">
<label for="postBody">正文 (Markdown)</label>
<textarea id="postBody" class="form-textarea" rows="20" required>{{if .Post}}{{.Post.Body}}{{end}}</textarea>
{{/* 模式切换标签 */}}
<div class="editor-modes">
<button type="button" class="mode-tab active" data-mode="wysiwyg">所见即所得</button>
<button type="button" class="mode-tab" data-mode="source">源码</button>
<button type="button" class="mode-tab" data-mode="split">分屏预览</button>
</div>
{{/* 工具条 */}}
<div class="editor-toolbar" id="toolbar">
<button type="button" class="tb-btn" data-action="bold" title="加粗 (Ctrl+B)">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M15.6 10.79c.97-.67 1.65-1.77 1.65-2.79 0-2.26-1.75-4-4-4H7v14h7.04c2.09 0 3.71-1.7 3.71-3.79 0-1.52-.86-2.82-2.15-3.42zM10 6.5h3c.83 0 1.5.67 1.5 1.5s-.67 1.5-1.5 1.5h-3v-3zm3.5 9H10v-3h3.5c.83 0 1.5.67 1.5 1.5s-.67 1.5-1.5 1.5z"/></svg>
</button>
<button type="button" class="tb-btn" data-action="italic" title="斜体 (Ctrl+I)">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M10 4v3h2.21l-3.42 8H6v3h8v-3h-2.21l3.42-8H18V4z"/></svg>
</button>
<span class="tb-sep"></span>
<button type="button" class="tb-btn" data-action="h2" title="标题">H2</button>
<button type="button" class="tb-btn" data-action="h3" title="子标题">H3</button>
<span class="tb-sep"></span>
<button type="button" class="tb-btn" data-action="link" title="链接">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M3.9 12c0-1.71 1.39-3.1 3.1-3.1h4V7H7c-2.76 0-5 2.24-5 5s2.24 5 5 5h4v-1.9H7c-1.71 0-3.1-1.39-3.1-3.1zM8 13h8v-2H8v2zm9-6h-4v1.9h4c1.71 0 3.1 1.39 3.1 3.1s-1.39 3.1-3.1 3.1h-4V17h4c2.76 0 5-2.24 5-5s-2.24-5-5-5z"/></svg>
</button>
<button type="button" class="tb-btn" data-action="image" title="图片">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M21 19V5c0-1.1-.9-2-2-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z"/></svg>
</button>
<span class="tb-sep"></span>
<button type="button" class="tb-btn" data-action="quote" title="引用">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M6 17h3l2-4V7H5v6h3zm8 0h3l2-4V7h-6v6h3z"/></svg>
</button>
<button type="button" class="tb-btn" data-action="code" title="行内代码">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M9.4 16.6 4.8 12l4.6-4.6L8 6l-6 6 6 6 1.4-1.4zm5.2 0 4.6-4.6-4.6-4.6L16 6l6 6-6 6-1.4-1.4z"/></svg>
</button>
<button type="button" class="tb-btn" data-action="pre" title="代码块">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M3 3h18v18H3V3zm2 2v14h14V5H5zm4 2h6v2H9V7zm0 4h6v2H9v-2z"/></svg>
</button>
<span class="tb-sep"></span>
<button type="button" class="tb-btn" data-action="ul" title="无序列表">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M4 10.5c-.83 0-1.5.67-1.5 1.5s.67 1.5 1.5 1.5 1.5-.67 1.5-1.5-.67-1.5-1.5-1.5zm0-6c-.83 0-1.5.67-1.5 1.5S3.17 7.5 4 7.5 5.5 6.83 5.5 6 4.83 4.5 4 4.5zm0 12c-.83 0-1.5.68-1.5 1.5s.68 1.5 1.5 1.5 1.5-.68 1.5-1.5-.67-1.5-1.5-1.5zM7 19h14v-2H7v2zm0-6h14v-2H7v2zm0-8v2h14V5H7z"/></svg>
</button>
<button type="button" class="tb-btn" data-action="ol" title="有序列表">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M2 17h2v.5H3v1h1v.5H2v1h3v-4H2v1zm1-9h1V4H2v1h1v3zm-1 3h1.8L2 13.1v.9h3v-1H3.2L5 10.9V10H2v1zm5-6v2h14V5H7zm0 14h14v-2H7v2zm0-6h14v-2H7v2z"/></svg>
</button>
<button type="button" class="tb-btn" data-action="hr" title="分割线"></button>
</div>
{{/* 源码编辑器 */}}
<div class="editor-body">
<textarea id="postBody" class="form-textarea" rows="20" required
placeholder="使用 Markdown 编写正文...">{{if .Post}}{{.Post.Body}}{{end}}</textarea>
{{/* WYSIWYG 编辑器 */}}
<div id="wysiwygEditor" class="wysiwyg-editor" contenteditable="true" style="display:none;"></div>
{{/* 分屏预览 */}}
<div id="splitPreview" class="split-preview" style="display:none;">
<div class="split-preview-content post-detail-body"></div>
</div>
</div>
<div class="editor-actions">
<button type="button" id="previewBtn" class="btn btn-secondary">预览</button>
<button type="submit" class="btn btn-primary">{{if .Post}}保存修改{{else}}发布帖子{{end}}</button>
</div>
</form>
<div id="previewArea" class="post-preview" style="display:none;">
<h3>预览</h3>
<div id="previewContent" class="post-detail-body"></div>
</div>
</div>
{{template "layout/footer.html" .}}

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');
}
})();