From ec8fa3eb56196538cea1cf6a296dbd26944dc46a Mon Sep 17 00:00:00 2001 From: Victor_Jay Date: Wed, 27 May 2026 23:01:59 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=20wangEditor=20?= =?UTF-8?q?=E5=B8=83=E5=B1=80=E3=80=81=E9=A2=84=E8=A7=88=E3=80=81=E5=9B=BE?= =?UTF-8?q?=E7=89=87=E4=B8=8A=E4=BC=A0=E5=92=8C=E9=93=BE=E6=8E=A5=E6=A0=B7?= =?UTF-8?q?=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 修复编辑区高度未撑满问题 - 预览改用 getHtml() 直接展示 - 图片上传改用 fetch + credentials:same-origin(修复 CSRF 403) - 添加帖子正文链接蓝色+下划线样式 - 草稿改用 getHtml() 保存,恢复时正确还原格式 - 草稿改为每 300 字 + 停笔 10 秒双重触发 - 空编辑区不保存草稿 - 代码块语言标签通过 DOMParser 注入(编辑器 + 预览) --- templates/MetaLab-2026/html/posts/new.html | 8 + templates/MetaLab-2026/static/css/posts.css | 90 ++++++++++ templates/MetaLab-2026/static/js/editor.js | 172 ++++++++++++++------ 3 files changed, 217 insertions(+), 53 deletions(-) diff --git a/templates/MetaLab-2026/html/posts/new.html b/templates/MetaLab-2026/html/posts/new.html index a0002a9..0e0d93d 100644 --- a/templates/MetaLab-2026/html/posts/new.html +++ b/templates/MetaLab-2026/html/posts/new.html @@ -8,6 +8,7 @@ flex-direction: column; overflow: hidden; min-height: 0; + height: 100%; } #toolbar-container { flex-shrink: 0; @@ -16,6 +17,13 @@ flex: 1; overflow-y: auto; 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%; } diff --git a/templates/MetaLab-2026/static/css/posts.css b/templates/MetaLab-2026/static/css/posts.css index c41dc11..43f8c7e 100644 --- a/templates/MetaLab-2026/static/css/posts.css +++ b/templates/MetaLab-2026/static/css/posts.css @@ -104,15 +104,63 @@ 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 { + position: relative; background: #f3f4f6; padding: 16px; + padding-top: 36px; border-radius: 8px; overflow-x: auto; font-size: 14px; 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 { background: #f3f4f6; padding: 2px 6px; @@ -223,6 +271,12 @@ /* 编辑器整体容器 */ #editor-container { background: #fff; + height: 100%; +} + +/* wangEditor 内部文本容器撑满 */ +#editor-container .w-e-text-container { + height: 100% !important; } /* 编辑区域 */ @@ -379,6 +433,42 @@ 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 { width: 0; diff --git a/templates/MetaLab-2026/static/js/editor.js b/templates/MetaLab-2026/static/js/editor.js index f6cd942..8f723fa 100644 --- a/templates/MetaLab-2026/static/js/editor.js +++ b/templates/MetaLab-2026/static/js/editor.js @@ -31,12 +31,17 @@ let previewVisible = false; let syncTimer = null; let saveTimer = null; let splitTimer = null; +let draftCharCount = 0; let editor = null; /* ================================================================ CSRF Token ================================================================ */ 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"]'); return meta ? meta.getAttribute('content') : ''; } @@ -56,35 +61,58 @@ function initEditor() { const editorConfig = { placeholder: '在此输入正文...', onChange() { + // 同步更新代码块语言标签(立即生效) + updateCodeLabels(); + clearTimeout(syncTimer); syncTimer = setTimeout(() => { updateStats(); if (previewVisible) updatePreview(); }, 200); + // 字数触发:每新增 300 字符保存 + // 时间触发:停止输入 10 秒后自动保存(防止末尾内容丢失) + const currentLen = (editor ? editor.getText().length : 0); + if (currentLen - draftCharCount >= 300) { + draftCharCount = currentLen; + saveDraft(); + } clearTimeout(saveTimer); - saveTimer = setTimeout(saveDraft, 30000); + saveTimer = setTimeout(saveDraft, 10000); }, MENU_CONF: {}, }; // 配置上传图片 editorConfig.MENU_CONF['uploadImage'] = { - server: '/api/posts/upload-image', - fieldName: 'file', - maxFileSize: 10 * 1024 * 1024, - maxNumberOfFiles: 10, + maxFileSize: 5 * 1024 * 1024, allowedFileTypes: ['image/jpeg', 'image/png', 'image/gif', 'image/webp'], - headers: { - 'X-CSRF-Token': getCSRFToken(), - }, - customInsert(res, insertFn) { - if (res.success && res.data && res.data.url) { - insertFn(res.data.url, res.data.url, res.data.url); - } - }, - onFailed(xhr) { - console.error('图片上传失败', xhr); + // 自定义上传逻辑,适配后端 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: { + 'X-CSRF-Token': csrf, + }, + body: fd, + }) + .then(r => r.json()) + .then(res => { + if (res.success && res.data && res.data.url) { + insertFn(res.data.url, res.data.url, res.data.url); + } else { + alert(res.message || '上传失败'); + } + }) + .catch(() => { + alert('上传请求失败,请检查网络'); + }); }, }; @@ -109,12 +137,11 @@ function initEditor() { // 初始化 updateStats(); + updateCodeLabels(); loadDraft(); - saveTimer = setTimeout(saveDraft, 30000); titleEl.addEventListener('input', () => { - clearTimeout(saveTimer); - saveTimer = setTimeout(saveDraft, 30000); + saveDraft(); }); // 滚动同步 @@ -134,6 +161,48 @@ function getEditorText() { return editor ? editor.getText() : ''; } +function getEditorHtml() { + return editor ? editor.getHtml() : ''; +} + +/* ================================================================ + 代码块语言标签 — 从 提取并注入标签 + ================================================================ */ +function updateCodeLabels() { + if (!editor) return; + + // wangEditor 编辑区 不带 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() { const text = getEditorText(); 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() { if (!previewVisible) return; - const md = getEditorText(); - if (!md.trim()) { + const html = editor ? editor.getHtml() : ''; + if (html === '


' || !html.trim()) { previewCont.innerHTML = '预览将在此处显示...'; return; } clearTimeout(splitTimer); 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); } @@ -207,27 +279,17 @@ function syncScrollToEditor() { ================================================================ */ function saveDraft() { const title = titleEl.value.trim(); - const body = getEditorText(); + const bodyHtml = getEditorHtml(); + const bodyText = getEditorText().trim(); - if (!body && !title) return; + // 空编辑区不保存(wangEditor 空内容 HTML 是


) + 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)); draftStatus.style.display = ''; 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() { @@ -237,11 +299,15 @@ function loadDraft() { try { const draft = JSON.parse(raw); if (draft.body || draft.title) { - if (!isEdit || !getEditorText()) { - if (draft.body && confirm('检测到未保存的草稿(' + new Date(draft.updatedAt).toLocaleString() + '),是否恢复?')) { - if (editor) editor.setHtml(draft.body); - if (draft.title && !titleEl.value) titleEl.value = draft.title; - } + // 如果编辑器已有内容(编辑模式从 DB 加载),跳过草稿恢复 + if (isEdit && getEditorText().trim()) return; + + if (draft.body && confirm('检测到未保存的草稿(' + new Date(draft.updatedAt).toLocaleString() + '),是否恢复?')) { + if (editor) editor.setHtml(draft.body); + if (draft.title && !titleEl.value) titleEl.value = draft.title; + } else { + // 用户拒绝恢复,清除旧草稿 + clearDraft(); } } } catch (_) { localStorage.removeItem(key); }