feat: 添加文稿版本管理、差异对比高亮和多项功能优化
- 修复AI重写输出过滤(过滤thinking/draft标签) - 添加文稿保存与版本管理功能 - 新增插入差异功能(差异对比模式) - 实现版本历史侧边抽屉(DocumentVersionPanel) - 添加版本对比差异高亮显示 - 调整文稿编辑区高度适应屏幕 - 新增MarkdownEditor组件(暂未启用)
This commit is contained in:
44
src/App.vue
44
src/App.vue
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
270
src/components/DocumentVersionPanel.vue
Normal file
270
src/components/DocumentVersionPanel.vue
Normal 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>
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
// 创建新文稿
|
||||
|
||||
@@ -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"
|
||||
|
||||
260
src/components/MarkdownEditor.vue
Normal file
260
src/components/MarkdownEditor.vue
Normal 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="代码块"
|
||||
>
|
||||
</>
|
||||
</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>
|
||||
Reference in New Issue
Block a user