- 修复 Submit API 缺少 uid 权限检查,任意登录用户可提交他人帖子审核 - 前端 handleSubmit 添加 submitting 锁和按钮禁用/恢复,防止重复提交 - 分页计算 TotalPages/PrevPage/NextPage 提取为 Pagination 方法,消除三处重复代码
402 lines
13 KiB
JavaScript
402 lines
13 KiB
JavaScript
/**
|
||
* MetaLab Editor — Tiptap 所见即所得编辑器
|
||
*
|
||
* Tiptap 基于 ProseMirror,输出 HTML,无需 Markdown 知识即可使用。
|
||
* 后端存储 HTML,通过 bluemonday 消毒确保安全。
|
||
*
|
||
* 安全模型:Tiptap → HTML(富文本)→ 后端 bluemonday 消毒 → 存储
|
||
*/
|
||
import { Editor } from '@tiptap/core'
|
||
import StarterKit from '@tiptap/starter-kit'
|
||
import Placeholder from '@tiptap/extension-placeholder'
|
||
import Image from '@tiptap/extension-image'
|
||
import CodeBlockLowlight from '@tiptap/extension-code-block-lowlight'
|
||
import { common, createLowlight } from 'lowlight'
|
||
|
||
// ---- 状态 ----
|
||
let editor = null
|
||
let currentHTML = ''
|
||
let draftTimer = null
|
||
let submitting = false
|
||
|
||
// ---- 初始化 ----
|
||
document.addEventListener('DOMContentLoaded', () => {
|
||
const container = document.getElementById('editor-container')
|
||
if (!container) return
|
||
|
||
const postId = document.getElementById('postId')?.value
|
||
const isEdit = !!postId
|
||
|
||
// 从容器 data-content 属性获取初始 HTML 内容(由后端 SSR 渲染)
|
||
const initialContent = container.dataset.content || ''
|
||
currentHTML = initialContent
|
||
|
||
editor = new Editor({
|
||
element: container,
|
||
extensions: [
|
||
StarterKit.configure({
|
||
heading: { levels: [1, 2, 3] },
|
||
link: {
|
||
openOnClick: false,
|
||
HTMLAttributes: { rel: 'noopener noreferrer', target: '_blank' },
|
||
},
|
||
codeBlock: false,
|
||
}),
|
||
CodeBlockLowlight.configure({
|
||
lowlight: createLowlight(common),
|
||
defaultLanguage: null,
|
||
}),
|
||
Placeholder.configure({
|
||
placeholder: '在此输入正文...',
|
||
}),
|
||
Image.configure({
|
||
inline: true,
|
||
}),
|
||
],
|
||
content: initialContent,
|
||
onUpdate: ({ editor }) => {
|
||
const html = editor.getHTML()
|
||
// 忽略空内容(<p></p>)
|
||
currentHTML = html === '<p></p>' ? '' : html
|
||
updateWordCount(editor.getText())
|
||
scheduleDraft(currentHTML)
|
||
},
|
||
})
|
||
|
||
// 初始字数统计
|
||
updateWordCount(editor.getText())
|
||
|
||
// 恢复草稿(延迟确保编辑器完全初始化)
|
||
if (!isEdit) {
|
||
setTimeout(() => restoreDraft(), 100)
|
||
}
|
||
|
||
// 工具栏按钮绑定
|
||
setupToolbar()
|
||
|
||
// 全屏切换
|
||
document.getElementById('btnFullscreen')?.addEventListener('click', toggleFullscreen)
|
||
|
||
// 表单提交
|
||
document.getElementById('postForm')?.addEventListener('submit', handleSubmit)
|
||
|
||
// Ctrl+S / F11 快捷键
|
||
document.addEventListener('keydown', handleKeyboard)
|
||
})
|
||
|
||
// ---- 工具栏 ----
|
||
function setupToolbar() {
|
||
const toolbar = document.getElementById('toolbar')
|
||
if (!toolbar) return
|
||
|
||
const actionMap = {
|
||
bold: () => editor.chain().focus().toggleBold().run(),
|
||
italic: () => editor.chain().focus().toggleItalic().run(),
|
||
strike: () => editor.chain().focus().toggleStrike().run(),
|
||
code: () => editor.chain().focus().toggleCode().run(),
|
||
h1: () => editor.chain().focus().toggleHeading({ level: 1 }).run(),
|
||
h2: () => editor.chain().focus().toggleHeading({ level: 2 }).run(),
|
||
h3: () => editor.chain().focus().toggleHeading({ level: 3 }).run(),
|
||
bulletList: () => editor.chain().focus().toggleBulletList().run(),
|
||
orderedList: () => editor.chain().focus().toggleOrderedList().run(),
|
||
blockquote: () => editor.chain().focus().toggleBlockquote().run(),
|
||
codeBlock: () => editor.chain().focus().toggleCodeBlock().run(),
|
||
horizontalRule: () => editor.chain().focus().setHorizontalRule().run(),
|
||
undo: () => editor.chain().focus().undo().run(),
|
||
redo: () => editor.chain().focus().redo().run(),
|
||
}
|
||
|
||
const activeCheck = {
|
||
bold: () => editor.isActive('bold'),
|
||
italic: () => editor.isActive('italic'),
|
||
strike: () => editor.isActive('strike'),
|
||
code: () => editor.isActive('code'),
|
||
h1: () => editor.isActive('heading', { level: 1 }),
|
||
h2: () => editor.isActive('heading', { level: 2 }),
|
||
h3: () => editor.isActive('heading', { level: 3 }),
|
||
bulletList: () => editor.isActive('bulletList'),
|
||
orderedList: () => editor.isActive('orderedList'),
|
||
blockquote: () => editor.isActive('blockquote'),
|
||
codeBlock: () => editor.isActive('codeBlock'),
|
||
}
|
||
|
||
function updateActive() {
|
||
toolbar.querySelectorAll('[data-action]').forEach(btn => {
|
||
const action = btn.dataset.action
|
||
const check = activeCheck[action]
|
||
btn.classList.toggle('is-active', check ? check() : false)
|
||
})
|
||
}
|
||
|
||
toolbar.addEventListener('click', (e) => {
|
||
const btn = e.target.closest('[data-action]')
|
||
if (!btn) return
|
||
const action = btn.dataset.action
|
||
if (actionMap[action]) {
|
||
actionMap[action]()
|
||
updateActive()
|
||
}
|
||
})
|
||
|
||
// ---- 代码块语言选择器 ----
|
||
const langSelect = document.getElementById('codeLangSelect')
|
||
if (langSelect) {
|
||
// 当光标在代码块内时显示语言选择器,移出时隐藏
|
||
editor.on('selectionUpdate', () => {
|
||
if (editor.isActive('codeBlock')) {
|
||
langSelect.style.display = ''
|
||
const attrs = editor.getAttributes('codeBlock')
|
||
langSelect.value = attrs.language || ''
|
||
} else {
|
||
langSelect.style.display = 'none'
|
||
}
|
||
})
|
||
|
||
// 语言切换
|
||
langSelect.addEventListener('change', () => {
|
||
const lang = langSelect.value
|
||
editor.chain().focus().updateAttributes('codeBlock', { language: lang || null }).run()
|
||
editor.commands.focus()
|
||
})
|
||
}
|
||
|
||
// ---- 代码块语言标签注入 ----
|
||
// 为每个 <pre> 注入语言标签 DOM 元素(因为 CSS ::before 在 overflow:auto 容器中不可靠)
|
||
function injectCodeLangLabels() {
|
||
const editorEl = document.querySelector('#editor-container .tiptap')
|
||
if (!editorEl) return
|
||
editorEl.querySelectorAll('pre').forEach(pre => {
|
||
// 移除旧标签
|
||
const old = pre.querySelector('.code-lang-label')
|
||
if (old) old.remove()
|
||
|
||
const lang = pre.getAttribute('data-language')
|
||
if (lang) {
|
||
const label = document.createElement('div')
|
||
label.className = 'code-lang-label'
|
||
label.textContent = lang
|
||
pre.insertBefore(label, pre.firstChild)
|
||
pre.style.paddingTop = '32px'
|
||
} else {
|
||
pre.style.paddingTop = ''
|
||
}
|
||
})
|
||
}
|
||
|
||
editor.on('update', injectCodeLangLabels)
|
||
// 初始注入
|
||
setTimeout(injectCodeLangLabels, 50)
|
||
|
||
editor.on('selectionUpdate', updateActive)
|
||
editor.on('update', updateActive)
|
||
}
|
||
|
||
// ---- 图片上传(拖拽/粘贴) ----
|
||
// Tiptap 默认支持图片拖入,但我们需要上传到服务器并替换 src
|
||
// 通过监听 editor 的 drop/paste 事件处理图片
|
||
document.addEventListener('DOMContentLoaded', () => {
|
||
// 延迟绑定,等 editor 初始化
|
||
setTimeout(() => {
|
||
const editorEl = document.querySelector('#editor-container .tiptap')
|
||
if (!editorEl) return
|
||
|
||
// 粘贴图片
|
||
editorEl.addEventListener('paste', async (e) => {
|
||
const items = e.clipboardData?.items
|
||
if (!items) return
|
||
for (const item of items) {
|
||
if (item.type.startsWith('image/')) {
|
||
e.preventDefault()
|
||
const file = item.getAsFile()
|
||
const url = await uploadSingleImage(file)
|
||
if (url) {
|
||
editor.chain().focus().setImage({ src: url }).run()
|
||
}
|
||
break
|
||
}
|
||
}
|
||
})
|
||
|
||
// 拖入图片
|
||
editorEl.addEventListener('drop', async (e) => {
|
||
const files = e.dataTransfer?.files
|
||
if (!files || files.length === 0) return
|
||
const imageFiles = Array.from(files).filter(f => f.type.startsWith('image/'))
|
||
if (imageFiles.length === 0) return
|
||
|
||
e.preventDefault()
|
||
for (const file of imageFiles) {
|
||
const url = await uploadSingleImage(file)
|
||
if (url) {
|
||
editor.chain().focus().setImage({ src: url }).run()
|
||
}
|
||
}
|
||
})
|
||
}, 200)
|
||
})
|
||
|
||
async function uploadSingleImage(file) {
|
||
const csrfMeta = document.querySelector('meta[name="csrf-token"]')
|
||
const csrf = csrfMeta ? csrfMeta.getAttribute('content') : ''
|
||
|
||
const formData = new FormData()
|
||
formData.append('file', file)
|
||
|
||
try {
|
||
const resp = await fetch('/api/posts/upload-image', {
|
||
method: 'POST',
|
||
headers: { 'X-CSRF-Token': csrf },
|
||
body: formData,
|
||
})
|
||
const data = await resp.json()
|
||
if (data.success || data.code === 200) {
|
||
return data.data?.url || data.url || ''
|
||
}
|
||
} catch (e) {
|
||
// 上传失败,静默忽略
|
||
}
|
||
return ''
|
||
}
|
||
|
||
// ---- 字数统计 ----
|
||
function updateWordCount(text) {
|
||
const el = document.getElementById('wordCount')
|
||
if (!el) return
|
||
const len = (text || '').replace(/\s/g, '').length
|
||
el.textContent = len + ' 字'
|
||
}
|
||
|
||
// ---- 草稿保存/恢复 ----
|
||
function scheduleDraft(html) {
|
||
clearTimeout(draftTimer)
|
||
draftTimer = setTimeout(() => saveDraft(html), 2000)
|
||
}
|
||
|
||
function saveDraft(html) {
|
||
const title = document.getElementById('postTitle')?.value || ''
|
||
if (!html.trim() && !title.trim()) return
|
||
|
||
try {
|
||
localStorage.setItem('draft_post_title', title)
|
||
localStorage.setItem('draft_post_body_html', html)
|
||
const statusEl = document.getElementById('draftStatus')
|
||
if (statusEl) {
|
||
statusEl.style.display = ''
|
||
setTimeout(() => { statusEl.style.display = 'none' }, 2000)
|
||
}
|
||
} catch (e) {
|
||
// localStorage 不可用,静默忽略
|
||
}
|
||
}
|
||
|
||
function restoreDraft() {
|
||
try {
|
||
const title = localStorage.getItem('draft_post_title')
|
||
const html = localStorage.getItem('draft_post_body_html')
|
||
|
||
if (title) {
|
||
document.getElementById('postTitle').value = title
|
||
}
|
||
if (html && editor) {
|
||
editor.commands.setContent(html)
|
||
currentHTML = html
|
||
updateWordCount(editor.getText())
|
||
}
|
||
} catch (e) {
|
||
// 静默忽略
|
||
}
|
||
}
|
||
|
||
function clearDraft() {
|
||
try {
|
||
localStorage.removeItem('draft_post_title')
|
||
localStorage.removeItem('draft_post_body_html')
|
||
} catch (e) { /* ignore */ }
|
||
}
|
||
|
||
// ---- 全屏 ----
|
||
function toggleFullscreen() {
|
||
const layout = document.querySelector('.editor-layout')
|
||
if (!layout) return
|
||
layout.classList.toggle('fullscreen')
|
||
}
|
||
|
||
// ---- 键盘快捷键 ----
|
||
function handleKeyboard(e) {
|
||
if (e.key === 'F11') {
|
||
e.preventDefault()
|
||
toggleFullscreen()
|
||
}
|
||
|
||
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
|
||
e.preventDefault()
|
||
document.getElementById('postForm')?.dispatchEvent(new Event('submit'))
|
||
}
|
||
}
|
||
|
||
// ---- 表单提交 ----
|
||
async function handleSubmit(e) {
|
||
e.preventDefault()
|
||
|
||
if (submitting) return
|
||
submitting = true
|
||
|
||
const submitBtn = document.querySelector('.btn-submit')
|
||
if (submitBtn) {
|
||
submitBtn.disabled = true
|
||
submitBtn.textContent = '提交中...'
|
||
}
|
||
|
||
try {
|
||
const postId = document.getElementById('postId')?.value
|
||
const title = document.getElementById('postTitle')?.value.trim()
|
||
|
||
if (!title) { alert('请输入标题'); return }
|
||
|
||
// 获取编辑器 HTML 内容
|
||
const body = editor ? editor.getHTML() : ''
|
||
// 忽略空段落
|
||
const cleanBody = body === '<p></p>' ? '' : body
|
||
if (!cleanBody.trim()) { alert('请输入正文'); return }
|
||
|
||
const isEdit = !!postId
|
||
const url = isEdit ? '/api/posts/' + postId : '/api/posts'
|
||
const method = isEdit ? 'PUT' : 'POST'
|
||
|
||
const csrfMeta = document.querySelector('meta[name="csrf-token"]')
|
||
const csrf = csrfMeta ? csrfMeta.getAttribute('content') : ''
|
||
|
||
const resp = await fetch(url, {
|
||
method,
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'X-CSRF-Token': csrf,
|
||
},
|
||
body: JSON.stringify({ title, body: cleanBody }),
|
||
})
|
||
const data = await resp.json()
|
||
|
||
if (data.success || data.code === 200) {
|
||
clearDraft()
|
||
const newPostId = data.data?.id || data.data?.ID || postId
|
||
window.location.href = newPostId ? '/posts/' + newPostId : '/posts'
|
||
} else {
|
||
alert(data.message || '操作失败')
|
||
}
|
||
} catch (err) {
|
||
if (err && err.message === 'refresh_failed') {
|
||
alert('登录已过期,请重新登录')
|
||
window.location.href = '/auth/login?redirect=' + encodeURIComponent(window.location.pathname)
|
||
} else {
|
||
alert('请求失败,请重试')
|
||
}
|
||
} finally {
|
||
submitting = false
|
||
if (submitBtn) {
|
||
submitBtn.disabled = false
|
||
submitBtn.textContent = submitBtn.textContent === '提交中...' ?
|
||
(document.getElementById('postId')?.value ? '保存修改' : '发布帖子') : submitBtn.textContent
|
||
}
|
||
}
|
||
}
|