feat: 添加文稿版本管理、差异对比高亮和多项功能优化

- 修复AI重写输出过滤(过滤thinking/draft标签)
- 添加文稿保存与版本管理功能
- 新增插入差异功能(差异对比模式)
- 实现版本历史侧边抽屉(DocumentVersionPanel)
- 添加版本对比差异高亮显示
- 调整文稿编辑区高度适应屏幕
- 新增MarkdownEditor组件(暂未启用)
This commit is contained in:
empty
2026-01-09 02:44:13 +08:00
parent 5a22477075
commit e153e10d48
8 changed files with 1547 additions and 5 deletions

View File

@@ -8,18 +8,32 @@
<!-- 左侧面板 -->
<WriterPanel v-if="currentPage === 'writer'" />
<AnalysisPanel v-else-if="currentPage === 'analysis'" />
<DocumentsPanel v-else-if="currentPage === 'documents'" />
<DocumentsPanel
v-else-if="currentPage === 'documents'"
@toggle-version-panel="toggleVersionPanel"
@document-selected="handleDocumentSelected"
/>
<MaterialsPanel v-else-if="currentPage === 'materials'" />
<SettingsPanel v-else-if="currentPage === 'settings'" />
<!-- 右侧主内容区 -->
<MainContent />
<!-- 版本历史面板仅文稿管理页面显示 -->
<DocumentVersionPanel
v-if="currentPage === 'documents'"
:visible="showVersionPanel"
:document-id="selectedDocumentId"
:current-content="selectedDocumentContent"
@close="showVersionPanel = false"
@restore="handleVersionRestore"
/>
</template>
</div>
</template>
<script setup>
import { computed } from 'vue'
import { ref, computed } from 'vue'
import { useAppStore } from './stores/app'
import WriterPanel from './components/WriterPanel.vue'
import AnalysisPanel from './components/AnalysisPanel.vue'
@@ -28,7 +42,33 @@ import MaterialsPanel from './components/MaterialsPanel.vue'
import SettingsPanel from './components/SettingsPanel.vue'
import MainContent from './components/MainContent.vue'
import ComparePanel from './components/ComparePanel.vue'
import DocumentVersionPanel from './components/DocumentVersionPanel.vue'
const appStore = useAppStore()
const currentPage = computed(() => appStore.currentPage)
// 版本历史面板状态
const showVersionPanel = ref(false)
const selectedDocumentId = ref(null)
const selectedDocumentContent = ref('')
// 切换版本面板
const toggleVersionPanel = () => {
showVersionPanel.value = !showVersionPanel.value
}
// 处理文稿选择
const handleDocumentSelected = (doc) => {
selectedDocumentId.value = doc?.id || null
selectedDocumentContent.value = doc?.content || ''
}
// 处理版本恢复
const handleVersionRestore = (content) => {
selectedDocumentContent.value = content
// 刷新 MainContent 显示
if (appStore.currentDocument) {
appStore.currentDocument.content = content
}
}
</script>

View File

@@ -153,7 +153,26 @@
<h2 class="text-sm font-medium text-blue-400 flex items-center gap-2">
写作内容
</h2>
<span class="text-xs text-slate-500">{{ rightParagraphs.length }} </span>
<div class="flex items-center gap-2">
<span class="text-xs text-slate-500">{{ rightParagraphs.length }} </span>
<!-- 保存按钮仅当内容来自文稿库时显示 -->
<button
v-if="rightSourceType === 'document' && rightSourceDocId"
@click="saveRightContent"
:disabled="isSaving || !hasContentChanged"
class="text-xs px-2 py-1 rounded flex items-center gap-1 transition"
:class="hasContentChanged
? 'bg-green-600 text-white hover:bg-green-500'
: 'bg-slate-700 text-slate-400 cursor-not-allowed'"
>
<span v-if="isSaving" class="animate-spin"></span>
<span v-else>💾</span>
{{ isSaving ? '保存中...' : (hasContentChanged ? '保存版本' : '已保存') }}
</button>
<span v-if="rightSourceType === 'document' && rightSourceDocTitle" class="text-xs text-blue-400">
📄 {{ rightSourceDocTitle }}
</span>
</div>
</div>
<!-- 来源选择器 -->
<div class="px-3 py-2 bg-slate-800/50 border-b border-slate-700 flex items-center gap-2">
@@ -651,11 +670,18 @@
>取消</button>
<!-- 差异对比模式的应用选中按钮 -->
<button
v-if="rewriteViewMode !== 'partial' && diffSegments.length > 0 && acceptedChanges.size < reviewableChanges.length"
v-if="rewriteViewMode === 'diff' && diffSegments.length > 0 && acceptedChanges.size < reviewableChanges.length"
@click="applySelectedChangesToContent"
:disabled="rewritingSuggestionIdx !== null || acceptedChanges.size === 0"
class="px-4 py-2 text-sm bg-amber-600 text-white rounded-lg hover:bg-amber-500 disabled:opacity-50 disabled:cursor-not-allowed"
>应用选中 ({{ acceptedChanges.size }})</button>
<!-- 差异对比模式的插入差异按钮 -->
<button
v-if="rewriteViewMode === 'diff' && diffSegments.length > 0"
@click="insertSelectedChangesToContent"
:disabled="rewritingSuggestionIdx !== null || acceptedChanges.size === 0"
class="px-4 py-2 text-sm bg-cyan-600 text-white rounded-lg hover:bg-cyan-500 disabled:opacity-50 disabled:cursor-not-allowed"
>插入差异</button>
<!-- 部分替换模式的应用按钮 -->
<button
v-if="rewriteViewMode === 'partial'"
@@ -680,6 +706,7 @@ import { ref, computed, watch } from 'vue'
import { storeToRefs } from 'pinia'
import { useAppStore } from '../stores/app'
import { useDatabaseStore } from '../stores/database'
import { updateDocument, saveDocumentVersion, getDocumentById } from '../db/index.js'
import { SECTION_TYPES, getSectionTypeById, getSectionTypeClasses } from '../config/sectionTypes'
import { getLogicParadigmById, buildLogicPrompt } from '../config/logicParadigms'
import { computeDiff, applySelectedChanges as applyDiffChanges, getDiffStats, splitIntoSentencesWithPosition } from '../utils/textDiff'
@@ -702,6 +729,10 @@ const showLeftDocSelector = ref(false)
// 右侧来源相关
const rightSourceType = ref('paste') // 'paste' | 'document'
const showRightDocSelector = ref(false)
const rightSourceDocId = ref(null) // 来源文稿ID
const rightSourceDocTitle = ref('') // 来源文稿标题
const rightOriginalContent = ref('') // 来源文稿原始内容(用于差异比对)
const isSaving = ref(false) // 保存中状态
// 段落类型识别相关
const isDetectingTypes = ref(false)
@@ -814,6 +845,12 @@ const canCompare = computed(() => {
return selectedLeftIdxs.value.length > 0 && selectedRightIdxs.value.length > 0
})
// 右侧内容是否已修改(与原始内容对比)
const hasContentChanged = computed(() => {
if (rightSourceType.value !== 'document' || !rightSourceDocId.value) return false
return rightContent.value !== rightOriginalContent.value
})
// 选择段落(多选切换)
const selectLeftParagraph = (idx) => {
const i = selectedLeftIdxs.value.indexOf(idx)
@@ -888,9 +925,55 @@ const handleLeftDocSelect = (doc) => {
const handleRightDocSelect = (doc) => {
rightContent.value = doc.content || ''
rightSourceType.value = 'document'
rightSourceDocId.value = doc.id // 保存文稿ID
rightSourceDocTitle.value = doc.title // 保存文稿标题
rightOriginalContent.value = doc.content || '' // 保存原始内容用于差异比对
showRightDocSelector.value = false
}
// 保存右侧内容到文稿
const saveRightContent = async () => {
if (rightSourceType.value !== 'document' || !rightSourceDocId.value) {
alert('当前内容不是来自文稿库,无需保存')
return
}
if (!hasContentChanged.value) {
alert('内容未修改,无需保存')
return
}
isSaving.value = true
try {
// 计算差异用于生成版本说明
const changes = computeDiff(rightOriginalContent.value, rightContent.value)
const stats = getDiffStats(changes)
// 生成简短的变更说明
let changeNote = `修改了 ${stats.modified}`
if (stats.added > 0) changeNote += `,新增了 ${stats.added}`
if (stats.removed > 0) changeNote += `,删除了 ${stats.removed}`
// 保存新版本
const versionNumber = saveDocumentVersion(rightSourceDocId.value, rightContent.value, changeNote)
// 同时更新文稿主内容
updateDocument(rightSourceDocId.value, { content: rightContent.value })
// 更新原始内容为当前内容(标记为已保存)
rightOriginalContent.value = rightContent.value
console.log(`文稿保存成功,版本号: ${versionNumber}${changeNote}`)
alert(`保存成功!新版本: v${versionNumber}\n${changeNote}`)
} catch (error) {
console.error('保存失败:', error)
alert('保存失败,请重试')
} finally {
isSaving.value = false
}
}
// 切换段落类型下拉菜单
const toggleTypeDropdown = (idx) => {
showTypeDropdown.value = showTypeDropdown.value === idx ? null : idx
@@ -1042,6 +1125,14 @@ const executeRewrite = async (suggestionIdx, suggestion) => {
finalContent = finalContent.replace(/<thinking>[\s\S]*?<\/thinking>/g, '').trim()
// 也移除可能残留的单独标签
finalContent = finalContent.replace(/<\/?thinking>/g, '').trim()
// 提取 <draft>...</draft> 中的内容,或移除标签
const draftMatch = finalContent.match(/<draft>([\s\S]*?)<\/draft>/)
if (draftMatch) {
finalContent = draftMatch[1].trim()
} else {
// 移除残留的 <draft> 或 </draft> 标签
finalContent = finalContent.replace(/<\/?draft>/g, '').trim()
}
rewritePreview.value = finalContent
// 重写完成后自动计算差异
@@ -1186,6 +1277,59 @@ const applySelectedChangesToContent = () => {
resetRewriteModal()
}
// 将选中的修改插入到原文(不替换原文内容)
const insertSelectedChangesToContent = () => {
if (diffSegments.value.length === 0) return
// 如果没有选中任何修改,提示用户并返回
if (acceptedChanges.value.size === 0) {
alert('请先选中需要插入的内容')
return
}
// 收集选中的修改片段的重写内容
const selectedSegments = diffSegments.value.filter(seg =>
acceptedChanges.value.has(seg.idx) &&
(seg.type === 'modified' || seg.type === 'added')
)
if (selectedSegments.length === 0) {
alert('没有可插入的内容(请选择"修改"或"新增"类型的差异)')
return
}
// 获取要插入的内容
const insertContent = selectedSegments.map(seg => seg.rewritten).join('')
// 找到第一个选中修改对应的原文位置,在其后插入
const firstSegment = selectedSegments[0]
let finalContent = originalContent.value
if (firstSegment.originalEnd !== undefined) {
// 在原文对应位置后插入新内容
const insertPosition = firstSegment.originalEnd
finalContent = originalContent.value.slice(0, insertPosition) +
insertContent +
originalContent.value.slice(insertPosition)
} else {
// 如果没有位置信息,追加到末尾
finalContent = originalContent.value + '\n\n' + insertContent
}
// 防止结果为空
if (!finalContent || finalContent.trim() === '') {
console.error('插入操作返回空结果,保持原内容不变')
alert('插入失败,请尝试其他方式')
return
}
// 执行更新
updateRightContent(targetParagraphIdxs.value, finalContent)
// 关闭浮窗并重置状态
resetRewriteModal()
}
// 重置重写浮窗状态
const resetRewriteModal = () => {
showRewriteModal.value = false

View File

@@ -0,0 +1,270 @@
<template>
<aside
v-if="visible && documentId"
class="w-80 h-screen flex flex-col border-l border-slate-700 bg-slate-800 shrink-0"
>
<!-- 头部 -->
<header class="p-4 border-b border-slate-700 flex items-center justify-between">
<h2 class="font-bold text-white flex items-center gap-2">
<span class="text-xl">🕐</span> 版本历史
</h2>
<button
@click="$emit('close')"
class="text-slate-400 hover:text-white transition"
>
</button>
</header>
<!-- 加载状态 -->
<div v-if="isLoading" class="flex-1 flex items-center justify-center">
<span class="text-slate-500 animate-pulse">加载中...</span>
</div>
<!-- 版本列表 -->
<div v-else-if="versions.length > 0" class="flex-1 overflow-y-auto p-4 space-y-3">
<div
v-for="(version, idx) in versions"
:key="version.id"
:class="[
'p-3 rounded-lg border transition',
idx === 0
? 'bg-blue-900/30 border-blue-500'
: 'bg-slate-900/50 border-slate-700 hover:border-slate-500'
]"
>
<!-- 版本号和时间 -->
<div class="flex items-center justify-between mb-2">
<div class="flex items-center gap-2">
<span class="font-bold text-white">v{{ version.version_number }}</span>
<span v-if="idx === 0" class="text-xs px-1.5 py-0.5 rounded bg-blue-600 text-white">
当前
</span>
</div>
<span class="text-xs text-slate-500">{{ formatDate(version.created_at) }}</span>
</div>
<!-- 变更说明 -->
<p class="text-xs text-slate-400 mb-3">
{{ version.change_note || '无变更说明' }}
</p>
<!-- 操作按钮 -->
<div v-if="idx > 0" class="flex gap-2">
<button
@click="restoreVersion(version)"
class="flex-1 text-xs py-1.5 rounded bg-amber-600 text-white hover:bg-amber-500 transition"
>
恢复
</button>
<button
@click="compareVersion(version)"
class="flex-1 text-xs py-1.5 rounded bg-slate-600 text-white hover:bg-slate-500 transition"
>
对比
</button>
</div>
</div>
</div>
<!-- 无版本记录 -->
<div v-else class="flex-1 flex flex-col items-center justify-center text-slate-500">
<span class="text-4xl mb-2">📋</span>
<p class="text-sm">暂无版本记录</p>
<p class="text-xs text-slate-600 mt-1">保存文稿后将自动记录版本</p>
</div>
<!-- 版本对比弹窗 -->
<div
v-if="showCompareModal"
class="fixed inset-0 bg-black/70 flex items-center justify-center z-50"
@click.self="showCompareModal = false"
>
<div class="bg-slate-800 rounded-lg w-[900px] max-h-[80vh] flex flex-col border border-slate-600">
<header class="p-4 border-b border-slate-700 flex items-center justify-between">
<div>
<h3 class="font-bold text-white">
版本对比v{{ comparingVersion?.version_number }} 当前版本
</h3>
<div class="text-xs text-slate-500 mt-1 flex gap-4">
<span><span class="inline-block w-3 h-3 rounded bg-red-500/50 mr-1"></span>删除</span>
<span><span class="inline-block w-3 h-3 rounded bg-green-500/50 mr-1"></span>新增</span>
<span><span class="inline-block w-3 h-3 rounded bg-amber-500/50 mr-1"></span>修改</span>
</div>
</div>
<button
@click="showCompareModal = false"
class="text-slate-400 hover:text-white"
></button>
</header>
<div class="flex-1 overflow-y-auto p-4">
<div class="flex gap-4">
<!-- 旧版本带高亮 -->
<div class="flex-1">
<div class="text-xs text-amber-400 mb-2 font-medium">
v{{ comparingVersion?.version_number }} 版本
</div>
<div class="bg-slate-900 rounded-lg p-3 border border-slate-700 text-sm text-slate-300 max-h-[400px] overflow-y-auto leading-relaxed">
<template v-for="(segment, idx) in diffSegments" :key="'old-' + idx">
<span
v-if="segment.type === 'unchanged'"
class="text-slate-300"
>{{ segment.original }}</span>
<span
v-else-if="segment.type === 'modified'"
class="bg-amber-500/30 text-amber-200 px-0.5 rounded"
>{{ segment.original }}</span>
<span
v-else-if="segment.type === 'removed'"
class="bg-red-500/30 text-red-200 px-0.5 rounded line-through"
>{{ segment.original }}</span>
<!-- added 类型在旧版本中不显示 -->
</template>
</div>
</div>
<!-- 当前版本带高亮 -->
<div class="flex-1">
<div class="text-xs text-blue-400 mb-2 font-medium">
当前版本
</div>
<div class="bg-slate-900 rounded-lg p-3 border border-slate-700 text-sm text-slate-300 max-h-[400px] overflow-y-auto leading-relaxed">
<template v-for="(segment, idx) in diffSegments" :key="'new-' + idx">
<span
v-if="segment.type === 'unchanged'"
class="text-slate-300"
>{{ segment.rewritten }}</span>
<span
v-else-if="segment.type === 'modified'"
class="bg-amber-500/30 text-amber-200 px-0.5 rounded"
>{{ segment.rewritten }}</span>
<span
v-else-if="segment.type === 'added'"
class="bg-green-500/30 text-green-200 px-0.5 rounded"
>{{ segment.rewritten }}</span>
<!-- removed 类型在新版本中不显示 -->
</template>
</div>
</div>
</div>
</div>
<footer class="p-4 border-t border-slate-700 flex justify-between items-center">
<div class="text-xs text-slate-500">
{{ diffStats.total }} 处差异
<span v-if="diffStats.modified > 0" class="text-amber-400 ml-2">{{ diffStats.modified }} 处修改</span>
<span v-if="diffStats.added > 0" class="text-green-400 ml-2">{{ diffStats.added }} 处新增</span>
<span v-if="diffStats.removed > 0" class="text-red-400 ml-2">{{ diffStats.removed }} 处删除</span>
</div>
<button
@click="showCompareModal = false"
class="px-4 py-2 text-sm bg-slate-600 text-white rounded-lg hover:bg-slate-500"
>
关闭
</button>
</footer>
</div>
</div>
</aside>
</template>
<script setup>
import { ref, watch, computed, defineProps, defineEmits } from 'vue'
import { getDocumentVersions, getDocumentById, saveDocumentVersion, updateDocument } from '../db/index.js'
import { computeDiff, getDiffStats } from '../utils/textDiff.js'
const props = defineProps({
visible: Boolean,
documentId: String,
currentContent: String
})
const emit = defineEmits(['close', 'restore'])
// 状态
const isLoading = ref(false)
const versions = ref([])
const showCompareModal = ref(false)
const comparingVersion = ref(null)
const diffSegments = ref([])
// 差异统计
const diffStats = computed(() => {
return getDiffStats(diffSegments.value)
})
// 加载版本历史
const loadVersions = async () => {
if (!props.documentId) {
versions.value = []
return
}
isLoading.value = true
try {
versions.value = getDocumentVersions(props.documentId)
} catch (error) {
console.error('加载版本历史失败:', error)
versions.value = []
} finally {
isLoading.value = false
}
}
// 格式化日期
const formatDate = (dateStr) => {
if (!dateStr) return ''
const date = new Date(dateStr)
return date.toLocaleDateString('zh-CN', {
month: 'numeric',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
})
}
// 恢复版本
const restoreVersion = async (version) => {
if (!confirm(`确定要恢复到 v${version.version_number} 版本吗?\n当前内容将保存为新版本。`)) {
return
}
try {
// 先保存当前内容为新版本
saveDocumentVersion(props.documentId, props.currentContent, '恢复前自动保存')
// 更新文稿内容为旧版本
updateDocument(props.documentId, { content: version.content })
// 触发恢复事件,通知父组件刷新
emit('restore', version.content)
// 重新加载版本列表
await loadVersions()
alert(`已恢复到 v${version.version_number} 版本`)
} catch (error) {
console.error('恢复版本失败:', error)
alert('恢复失败,请重试')
}
}
// 对比版本
const compareVersion = (version) => {
comparingVersion.value = version
// 计算差异
diffSegments.value = computeDiff(version.content || '', props.currentContent || '')
showCompareModal.value = true
}
// 监听文稿ID变化
watch(() => props.documentId, () => {
loadVersions()
}, { immediate: true })
// 监听面板显示
watch(() => props.visible, (newVal) => {
if (newVal) {
loadVersions()
}
})
</script>

View File

@@ -86,6 +86,12 @@
>
📋 复制
</button>
<button
@click="toggleVersionPanel"
class="flex-1 text-xs py-2 rounded bg-cyan-600 text-white hover:bg-cyan-500 transition"
>
🕰 版本
</button>
<button
@click="confirmDelete"
class="text-xs px-3 py-2 rounded bg-red-900/50 text-red-300 hover:bg-red-800/50 transition"
@@ -128,6 +134,9 @@ import { storeToRefs } from 'pinia'
const appStore = useAppStore()
const dbStore = useDatabaseStore()
// 定义事件
const emit = defineEmits(['toggle-version-panel', 'document-selected'])
// 状态
const selectedDocId = ref(null)
const currentFilter = ref('all')
@@ -185,6 +194,14 @@ const loadDocuments = async () => {
// 选择文稿
const selectDocument = (doc) => {
selectedDocId.value = selectedDocId.value === doc.id ? null : doc.id
// 通知父组件
const selectedDoc = selectedDocId.value ? doc : null
emit('document-selected', selectedDoc)
}
// 切换版本面板
const toggleVersionPanel = () => {
emit('toggle-version-panel')
}
// 创建新文稿

View File

@@ -175,7 +175,7 @@
</div>
<!-- 文稿内容编辑器 -->
<div class="flex-1 min-h-[400px]">
<div class="flex-1 min-h-[60vh]">
<textarea
v-model="documentContent"
class="w-full h-full bg-slate-900/50 border border-slate-700 rounded-lg p-4 text-slate-200 outline-none focus:border-blue-500 resize-none font-mono text-sm leading-relaxed"

View File

@@ -0,0 +1,260 @@
<template>
<div class="markdown-editor h-full flex flex-col">
<!-- 工具栏 -->
<div class="toolbar flex items-center gap-1 p-2 bg-slate-800 border border-slate-700 rounded-t-lg">
<button
@click="editor?.chain().focus().toggleBold().run()"
:class="['toolbar-btn', { 'is-active': editor?.isActive('bold') }]"
title="粗体 (Ctrl+B)"
>
<span class="font-bold">B</span>
</button>
<button
@click="editor?.chain().focus().toggleItalic().run()"
:class="['toolbar-btn', { 'is-active': editor?.isActive('italic') }]"
title="斜体 (Ctrl+I)"
>
<span class="italic">I</span>
</button>
<button
@click="editor?.chain().focus().toggleStrike().run()"
:class="['toolbar-btn', { 'is-active': editor?.isActive('strike') }]"
title="删除线"
>
<span class="line-through">S</span>
</button>
<div class="h-4 w-px bg-slate-600 mx-1"></div>
<button
@click="editor?.chain().focus().toggleHeading({ level: 1 }).run()"
:class="['toolbar-btn', { 'is-active': editor?.isActive('heading', { level: 1 }) }]"
title="一级标题"
>
H1
</button>
<button
@click="editor?.chain().focus().toggleHeading({ level: 2 }).run()"
:class="['toolbar-btn', { 'is-active': editor?.isActive('heading', { level: 2 }) }]"
title="二级标题"
>
H2
</button>
<button
@click="editor?.chain().focus().toggleHeading({ level: 3 }).run()"
:class="['toolbar-btn', { 'is-active': editor?.isActive('heading', { level: 3 }) }]"
title="三级标题"
>
H3
</button>
<div class="h-4 w-px bg-slate-600 mx-1"></div>
<button
@click="editor?.chain().focus().toggleBulletList().run()"
:class="['toolbar-btn', { 'is-active': editor?.isActive('bulletList') }]"
title="无序列表"
>
</button>
<button
@click="editor?.chain().focus().toggleOrderedList().run()"
:class="['toolbar-btn', { 'is-active': editor?.isActive('orderedList') }]"
title="有序列表"
>
1.
</button>
<div class="h-4 w-px bg-slate-600 mx-1"></div>
<button
@click="editor?.chain().focus().toggleBlockquote().run()"
:class="['toolbar-btn', { 'is-active': editor?.isActive('blockquote') }]"
title="引用"
>
</button>
<button
@click="editor?.chain().focus().toggleCodeBlock().run()"
:class="['toolbar-btn', { 'is-active': editor?.isActive('codeBlock') }]"
title="代码块"
>
&lt;/&gt;
</button>
<button
@click="editor?.chain().focus().setHorizontalRule().run()"
class="toolbar-btn"
title="分割线"
>
</button>
<div class="flex-1"></div>
<button
@click="editor?.chain().focus().undo().run()"
:disabled="!editor?.can().undo()"
class="toolbar-btn disabled:opacity-30"
title="撤销 (Ctrl+Z)"
>
</button>
<button
@click="editor?.chain().focus().redo().run()"
:disabled="!editor?.can().redo()"
class="toolbar-btn disabled:opacity-30"
title="重做 (Ctrl+Y)"
>
</button>
</div>
<!-- 编辑区域 -->
<div class="flex-1 overflow-y-auto bg-slate-900/50 border border-t-0 border-slate-700 rounded-b-lg">
<editor-content
:editor="editor"
class="h-full"
/>
</div>
</div>
</template>
<script setup>
import { watch, onMounted, onBeforeUnmount } from 'vue'
import { useEditor, EditorContent } from '@tiptap/vue-3'
import StarterKit from '@tiptap/starter-kit'
import Placeholder from '@tiptap/extension-placeholder'
const props = defineProps({
modelValue: {
type: String,
default: ''
},
placeholder: {
type: String,
default: '开始写作...'
}
})
const emit = defineEmits(['update:modelValue'])
// 创建编辑器实例
const editor = useEditor({
content: props.modelValue,
extensions: [
StarterKit.configure({
heading: {
levels: [1, 2, 3]
}
}),
Placeholder.configure({
placeholder: props.placeholder
})
],
editorProps: {
attributes: {
class: 'prose prose-invert max-w-none p-4 h-full outline-none focus:outline-none'
}
},
onUpdate: ({ editor }) => {
emit('update:modelValue', editor.getHTML())
}
})
// 监听外部值变化
watch(() => props.modelValue, (newValue) => {
if (editor.value && editor.value.getHTML() !== newValue) {
editor.value.commands.setContent(newValue || '')
}
})
onBeforeUnmount(() => {
editor.value?.destroy()
})
</script>
<style scoped>
.toolbar-btn {
@apply w-8 h-8 flex items-center justify-center rounded text-sm text-slate-300 hover:bg-slate-700 hover:text-white transition;
}
.toolbar-btn.is-active {
@apply bg-blue-600 text-white;
}
/* 编辑器内容样式 */
:deep(.ProseMirror) {
min-height: 100%;
padding: 1rem;
}
:deep(.ProseMirror:focus) {
outline: none;
}
:deep(.ProseMirror p.is-editor-empty:first-child::before) {
content: attr(data-placeholder);
float: left;
color: #64748b;
pointer-events: none;
height: 0;
}
/* Prose 样式覆盖 */
:deep(.ProseMirror h1) {
@apply text-2xl font-bold text-white mt-6 mb-4;
}
:deep(.ProseMirror h2) {
@apply text-xl font-bold text-white mt-5 mb-3;
}
:deep(.ProseMirror h3) {
@apply text-lg font-bold text-white mt-4 mb-2;
}
:deep(.ProseMirror p) {
@apply text-slate-200 leading-relaxed mb-4;
}
:deep(.ProseMirror ul),
:deep(.ProseMirror ol) {
@apply pl-6 mb-4;
}
:deep(.ProseMirror li) {
@apply text-slate-200 mb-1;
}
:deep(.ProseMirror blockquote) {
@apply border-l-4 border-blue-500 pl-4 italic text-slate-400 my-4;
}
:deep(.ProseMirror code) {
@apply bg-slate-700 px-1 py-0.5 rounded text-blue-300 text-sm;
}
:deep(.ProseMirror pre) {
@apply bg-slate-800 p-4 rounded-lg my-4 overflow-x-auto;
}
:deep(.ProseMirror pre code) {
@apply bg-transparent p-0;
}
:deep(.ProseMirror hr) {
@apply border-slate-700 my-6;
}
:deep(.ProseMirror strong) {
@apply font-bold text-white;
}
:deep(.ProseMirror em) {
@apply italic text-slate-300;
}
:deep(.ProseMirror s) {
@apply line-through text-slate-500;
}
</style>