fix: 修复 wangEditor 布局、预览、图片上传和链接样式

- 修复编辑区高度未撑满问题
- 预览改用 getHtml() 直接展示
- 图片上传改用 fetch + credentials:same-origin(修复 CSRF 403)
- 添加帖子正文链接蓝色+下划线样式
- 草稿改用 getHtml() 保存,恢复时正确还原格式
- 草稿改为每 300 字 + 停笔 10 秒双重触发
- 空编辑区不保存草稿
- 代码块语言标签通过 DOMParser 注入(编辑器 + 预览)
This commit is contained in:
2026-05-27 23:01:59 +08:00
parent 9d17185e11
commit ec8fa3eb56
3 changed files with 217 additions and 53 deletions

View File

@ -8,6 +8,7 @@
flex-direction: column; flex-direction: column;
overflow: hidden; overflow: hidden;
min-height: 0; min-height: 0;
height: 100%;
} }
#toolbar-container { #toolbar-container {
flex-shrink: 0; flex-shrink: 0;
@ -16,6 +17,13 @@
flex: 1; flex: 1;
overflow-y: auto; overflow-y: auto;
min-height: 0; min-height: 0;
height: 100%;
}
.editor-pane .w-e-text-container {
height: 100% !important;
}
.editor-pane .w-e-text-container [data-slate-editor] {
min-height: 100%;
} }
</style> </style>
<body> <body>

View File

@ -104,15 +104,63 @@
margin-bottom: 12px; margin-bottom: 12px;
} }
.post-detail-body a {
color: var(--color-accent);
text-decoration: underline;
text-underline-offset: 2px;
transition: color 0.2s;
}
.post-detail-body a:hover {
color: #2980b9;
}
.post-detail-body pre { .post-detail-body pre {
position: relative;
background: #f3f4f6; background: #f3f4f6;
padding: 16px; padding: 16px;
padding-top: 36px;
border-radius: 8px; border-radius: 8px;
overflow-x: auto; overflow-x: auto;
font-size: 14px; font-size: 14px;
line-height: 1.5; line-height: 1.5;
} }
/* 代码块语言标签(编辑器内 + 预览页通用) */
.post-detail-body pre,
#editor-container [data-slate-editor] pre {
position: relative;
background: #f3f4f6;
padding: 16px;
padding-top: 36px;
border-radius: 8px;
overflow-x: auto;
font-size: 14px;
line-height: 1.5;
}
/* 通过 JS 动态注入语言标签,因为 CSS attr() 无法从 class 中提取 */
.code-lang-label {
position: absolute;
top: 0;
left: 0;
right: 0;
padding: 4px 16px;
background: #e5e7eb;
color: #6b7280;
font-size: 11px;
font-weight: 600;
border-radius: 8px 8px 0 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Helvetica Neue', Arial, sans-serif;
}
.post-detail-body pre code,
#editor-container [data-slate-editor] pre code {
background: transparent !important;
padding: 0 !important;
font-size: inherit !important;
}
.post-detail-body code { .post-detail-body code {
background: #f3f4f6; background: #f3f4f6;
padding: 2px 6px; padding: 2px 6px;
@ -223,6 +271,12 @@
/* 编辑器整体容器 */ /* 编辑器整体容器 */
#editor-container { #editor-container {
background: #fff; background: #fff;
height: 100%;
}
/* wangEditor 内部文本容器撑满 */
#editor-container .w-e-text-container {
height: 100% !important;
} }
/* 编辑区域 */ /* 编辑区域 */
@ -379,6 +433,42 @@
font-style: normal !important; font-style: normal !important;
} }
/* 编辑器内代码块样式 */
#editor-container [data-slate-editor] pre {
position: relative;
background: #f3f4f6 !important;
padding: 12px 16px !important;
padding-top: 32px !important;
border-radius: 8px !important;
font-family: 'Menlo', 'Consolas', 'Monaco', 'Liberation Mono', monospace !important;
font-size: 13px !important;
line-height: 1.6 !important;
overflow-x: auto !important;
margin: 12px 0 !important;
}
/* 编辑器内代码块语言标签 */
#editor-container [data-slate-editor] pre::before {
content: attr(data-language);
position: absolute;
top: 0;
left: 0;
right: 0;
padding: 3px 16px;
background: #e5e7eb;
color: #6b7280;
font-size: 11px;
font-weight: 600;
border-radius: 8px 8px 0 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Helvetica Neue', Arial, sans-serif;
}
#editor-container [data-slate-editor] pre code {
background: transparent !important;
padding: 0 !important;
font-size: inherit !important;
}
/* 预览区 */ /* 预览区 */
.preview-pane { .preview-pane {
width: 0; width: 0;

View File

@ -31,12 +31,17 @@ let previewVisible = false;
let syncTimer = null; let syncTimer = null;
let saveTimer = null; let saveTimer = null;
let splitTimer = null; let splitTimer = null;
let draftCharCount = 0;
let editor = null; let editor = null;
/* ================================================================ /* ================================================================
CSRF Token CSRF Token
================================================================ */ ================================================================ */
function getCSRFToken() { function getCSRFToken() {
// 优先从 cookie 读取(与 CSRF Double Submit Cookie 一致)
const match = document.cookie.match(/(?:^|;\s*)mlb_csrf=([^;]*)/);
if (match) return match[1];
// fallback: 从 meta 标签读取
const meta = document.querySelector('meta[name="csrf-token"]'); const meta = document.querySelector('meta[name="csrf-token"]');
return meta ? meta.getAttribute('content') : ''; return meta ? meta.getAttribute('content') : '';
} }
@ -56,35 +61,58 @@ function initEditor() {
const editorConfig = { const editorConfig = {
placeholder: '在此输入正文...', placeholder: '在此输入正文...',
onChange() { onChange() {
// 同步更新代码块语言标签(立即生效)
updateCodeLabels();
clearTimeout(syncTimer); clearTimeout(syncTimer);
syncTimer = setTimeout(() => { syncTimer = setTimeout(() => {
updateStats(); updateStats();
if (previewVisible) updatePreview(); if (previewVisible) updatePreview();
}, 200); }, 200);
// 字数触发:每新增 300 字符保存
// 时间触发:停止输入 10 秒后自动保存(防止末尾内容丢失)
const currentLen = (editor ? editor.getText().length : 0);
if (currentLen - draftCharCount >= 300) {
draftCharCount = currentLen;
saveDraft();
}
clearTimeout(saveTimer); clearTimeout(saveTimer);
saveTimer = setTimeout(saveDraft, 30000); saveTimer = setTimeout(saveDraft, 10000);
}, },
MENU_CONF: {}, MENU_CONF: {},
}; };
// 配置上传图片 // 配置上传图片
editorConfig.MENU_CONF['uploadImage'] = { editorConfig.MENU_CONF['uploadImage'] = {
server: '/api/posts/upload-image', maxFileSize: 5 * 1024 * 1024,
fieldName: 'file',
maxFileSize: 10 * 1024 * 1024,
maxNumberOfFiles: 10,
allowedFileTypes: ['image/jpeg', 'image/png', 'image/gif', 'image/webp'], allowedFileTypes: ['image/jpeg', 'image/png', 'image/gif', 'image/webp'],
// 自定义上传逻辑,适配后端 API
customUpload(file, insertFn) {
const fd = new FormData();
fd.append('file', file);
const csrf = getCSRFToken();
fetch('/api/posts/upload-image', {
method: 'POST',
credentials: 'same-origin',
headers: { headers: {
'X-CSRF-Token': getCSRFToken(), 'X-CSRF-Token': csrf,
}, },
customInsert(res, insertFn) { body: fd,
})
.then(r => r.json())
.then(res => {
if (res.success && res.data && res.data.url) { if (res.success && res.data && res.data.url) {
insertFn(res.data.url, res.data.url, res.data.url); insertFn(res.data.url, res.data.url, res.data.url);
} else {
alert(res.message || '上传失败');
} }
}, })
onFailed(xhr) { .catch(() => {
console.error('图片上传失败', xhr); alert('上传请求失败,请检查网络');
});
}, },
}; };
@ -109,12 +137,11 @@ function initEditor() {
// 初始化 // 初始化
updateStats(); updateStats();
updateCodeLabels();
loadDraft(); loadDraft();
saveTimer = setTimeout(saveDraft, 30000);
titleEl.addEventListener('input', () => { titleEl.addEventListener('input', () => {
clearTimeout(saveTimer); saveDraft();
saveTimer = setTimeout(saveDraft, 30000);
}); });
// 滚动同步 // 滚动同步
@ -134,6 +161,48 @@ function getEditorText() {
return editor ? editor.getText() : ''; return editor ? editor.getText() : '';
} }
function getEditorHtml() {
return editor ? editor.getHtml() : '';
}
/* ================================================================
代码块语言标签 — 从 <code class="language-xxx"> 提取并注入标签
================================================================ */
function updateCodeLabels() {
if (!editor) return;
// wangEditor 编辑区 <code> 不带 class="language-xxx"
// 但 getHtml() 输出带 class="language-xxx"。
// 用 DOMParser 解析 HTML 获取语言,再注入到编辑区对应 pre。
try {
const html = editor.getHtml();
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
const htmlPres = doc.querySelectorAll('pre');
const editorEl = document.getElementById('editor-container');
if (!editorEl) return;
const domPres = editorEl.querySelectorAll('pre');
htmlPres.forEach((htmlPre, idx) => {
const domPre = domPres[idx];
if (!domPre || domPre.querySelector('.code-lang-label')) return;
const code = htmlPre.querySelector('code[class*="language-"]');
if (code) {
const match = code.className.match(/language-(\w+)/);
if (match && match[1]) {
const label = document.createElement('span');
label.className = 'code-lang-label';
label.textContent = match[1];
domPre.style.position = 'relative';
domPre.insertBefore(label, domPre.firstChild);
}
}
});
} catch (_) { /* 忽略 */ }
}
function updateStats() { function updateStats() {
const text = getEditorText(); const text = getEditorText();
const chars = text.length; const chars = text.length;
@ -147,29 +216,32 @@ function updateStats() {
} }
/* ================================================================ /* ================================================================
Markdown 预览(服务端渲染) 预览(wangEditor 直接展示 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(r => r.json())
.then(d => callback(d.success ? d.data.html : md))
.catch(() => callback(md));
}
function updatePreview() { function updatePreview() {
if (!previewVisible) return; if (!previewVisible) return;
const md = getEditorText(); const html = editor ? editor.getHtml() : '';
if (!md.trim()) { if (html === '<p><br></p>' || !html.trim()) {
previewCont.innerHTML = '<em style="color:#9ca3af">预览将在此处显示...</em>'; previewCont.innerHTML = '<em style="color:#9ca3af">预览将在此处显示...</em>';
return; return;
} }
clearTimeout(splitTimer); clearTimeout(splitTimer);
splitTimer = setTimeout(() => { splitTimer = setTimeout(() => {
renderMarkdown(md, html => { previewCont.innerHTML = html; }); previewCont.innerHTML = html;
// 预览区也注入语言标签
previewCont.querySelectorAll('pre').forEach(pre => {
if (pre.querySelector('.code-lang-label')) return;
const code = pre.querySelector('code[class*="language-"]');
if (code) {
const match = code.className.match(/language-(\w+)/);
if (match) {
const label = document.createElement('span');
label.className = 'code-lang-label';
label.textContent = match[1];
pre.insertBefore(label, pre.firstChild);
}
}
});
}, 300); }, 300);
} }
@ -207,27 +279,17 @@ function syncScrollToEditor() {
================================================================ */ ================================================================ */
function saveDraft() { function saveDraft() {
const title = titleEl.value.trim(); const title = titleEl.value.trim();
const body = getEditorText(); const bodyHtml = getEditorHtml();
const bodyText = getEditorText().trim();
if (!body && !title) return; // 空编辑区不保存wangEditor 空内容 HTML 是 <p><br></p>
if (!bodyText && !title) return;
const draft = { title, body, updatedAt: Date.now() }; const draft = { title, body: bodyHtml, updatedAt: Date.now() };
localStorage.setItem('mlb_draft_' + (isEdit ? postIdEl.value : 'new'), JSON.stringify(draft)); localStorage.setItem('mlb_draft_' + (isEdit ? postIdEl.value : 'new'), JSON.stringify(draft));
draftStatus.style.display = ''; draftStatus.style.display = '';
draftStatus.textContent = '草稿已保存 ' + new Date().toLocaleTimeString(); draftStatus.textContent = '草稿已保存 ' + new Date().toLocaleTimeString();
if (isEdit && postIdEl.value) {
silentSave(title, body);
}
}
function silentSave(title, body) {
fetch('/api/posts/' + postIdEl.value, {
method: 'PUT',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': getCSRFToken() },
body: JSON.stringify({ title, body })
}).catch(() => {});
} }
function loadDraft() { function loadDraft() {
@ -237,11 +299,15 @@ function loadDraft() {
try { try {
const draft = JSON.parse(raw); const draft = JSON.parse(raw);
if (draft.body || draft.title) { if (draft.body || draft.title) {
if (!isEdit || !getEditorText()) { // 如果编辑器已有内容(编辑模式从 DB 加载),跳过草稿恢复
if (isEdit && getEditorText().trim()) return;
if (draft.body && confirm('检测到未保存的草稿(' + new Date(draft.updatedAt).toLocaleString() + '),是否恢复?')) { if (draft.body && confirm('检测到未保存的草稿(' + new Date(draft.updatedAt).toLocaleString() + '),是否恢复?')) {
if (editor) editor.setHtml(draft.body); if (editor) editor.setHtml(draft.body);
if (draft.title && !titleEl.value) titleEl.value = draft.title; if (draft.title && !titleEl.value) titleEl.value = draft.title;
} } else {
// 用户拒绝恢复,清除旧草稿
clearDraft();
} }
} }
} catch (_) { localStorage.removeItem(key); } } catch (_) { localStorage.removeItem(key); }