From 174d715e504450d8a9ba2521650ed5f9fab58e31 Mon Sep 17 00:00:00 2001 From: Victor_Jay Date: Wed, 27 May 2026 18:35:13 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=20CSRF=20=E7=BC=BA?= =?UTF-8?q?=E5=A4=B1=20+=20=E9=87=8D=E5=86=99=E7=BC=96=E8=BE=91=E5=99=A8?= =?UTF-8?q?=E4=B8=BA=E4=B8=89=E6=A8=A1=E5=BC=8F=EF=BC=88=E6=89=80=E8=A7=81?= =?UTF-8?q?=E5=8D=B3=E6=89=80=E5=BE=97/=E6=BA=90=E7=A0=81/=E5=88=86?= =?UTF-8?q?=E5=B1=8F=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 模式渲染已有内容 --- internal/router/frontend.go | 10 +- templates/MetaLab-2026/html/posts/new.html | 66 +++- templates/MetaLab-2026/static/css/posts.css | 229 +++++++++++-- templates/MetaLab-2026/static/js/editor.js | 354 ++++++++++++++++++-- 4 files changed, 588 insertions(+), 71 deletions(-) diff --git a/internal/router/frontend.go b/internal/router/frontend.go index 09a796f..a136ff9 100644 --- a/internal/router/frontend.go +++ b/internal/router/frontend.go @@ -36,15 +36,9 @@ func setupFrontendRoutes(r *gin.Engine, cfg *config.Config, d *dependencies) { // 帖子页面 pages.GET("/posts", d.postCtrl.ListPage) + pages.GET("/posts/new", d.authMdw.Required(), d.postCtrl.NewPage) pages.GET("/posts/:id", d.postCtrl.ShowPage) - } - - // 帖子页面(需登录) - postPages := r.Group("/posts") - postPages.Use(d.authMdw.Required()) - { - postPages.GET("/new", d.postCtrl.NewPage) - postPages.GET("/:id/edit", d.postCtrl.EditPage) + pages.GET("/posts/:id/edit", d.authMdw.Required(), d.postCtrl.EditPage) } // 认证页面(已登录自动跳走) diff --git a/templates/MetaLab-2026/html/posts/new.html b/templates/MetaLab-2026/html/posts/new.html index c9d5e4a..554a0bf 100644 --- a/templates/MetaLab-2026/html/posts/new.html +++ b/templates/MetaLab-2026/html/posts/new.html @@ -14,21 +14,69 @@ -
- - + {{/* 模式切换标签 */}} +
+ + + +
+ + {{/* 工具条 */}} +
+ + + + + + + + + + + + + + + + +
+ + {{/* 源码编辑器 */}} +
+ + + {{/* WYSIWYG 编辑器 */}} + + + {{/* 分屏预览 */}} +
-
- -
{{template "layout/footer.html" .}} diff --git a/templates/MetaLab-2026/static/css/posts.css b/templates/MetaLab-2026/static/css/posts.css index 906fdc0..79f2ae3 100644 --- a/templates/MetaLab-2026/static/css/posts.css +++ b/templates/MetaLab-2026/static/css/posts.css @@ -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 ========== */ diff --git a/templates/MetaLab-2026/static/js/editor.js b/templates/MetaLab-2026/static/js/editor.js index fc18403..ad0c427 100644 --- a/templates/MetaLab-2026/static/js/editor.js +++ b/templates/MetaLab-2026/static/js/editor.js @@ -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 = '输入正文后将在此处预览...'; + 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'); + } })();