Files
ai-write/src/components/panels/DiffAnnotationPanel.vue

751 lines
21 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="h-screen w-full flex flex-col bg-slate-950">
<!-- 顶部工具栏 -->
<header class="h-14 border-b border-slate-800 flex items-center justify-between px-6 bg-slate-950 shrink-0">
<div class="flex items-center gap-4">
<h1 class="text-lg font-bold text-white flex items-center gap-2">
<IconLibrary name="chart" :size="20" /> 文稿差异标注
</h1>
<button
@click="goBack"
class="text-xs px-3 py-1 rounded bg-slate-700 text-slate-300 hover:bg-slate-600 transition"
>
返回写作
</button>
</div>
<div class="flex items-center gap-4">
<!-- 图例说明 -->
<div class="text-xs text-slate-500 flex items-center gap-3">
<span class="flex items-center gap-1"><span class="w-3 h-3 rounded bg-amber-500/50"></span> 修改</span>
<span class="flex items-center gap-1"><span class="w-3 h-3 rounded bg-green-500/50"></span> 新增</span>
<span class="flex items-center gap-1"><span class="w-3 h-3 rounded bg-red-500/50"></span> 删除</span>
</div>
<!-- 差异统计 -->
<div v-if="diffStats.total > 0" class="text-xs text-slate-400 flex items-center gap-3 border-l border-slate-700 pl-4">
<span class="text-amber-400">{{ diffStats.modified }} 处修改</span>
<span class="text-green-400">{{ diffStats.added }} 处新增</span>
<span class="text-red-400">{{ diffStats.removed }} 处删除</span>
</div>
<!-- 导出按钮 -->
<button
@click="exportToWord"
:disabled="!leftContent || !rightContent"
class="text-sm px-4 py-2 rounded bg-blue-600 text-white hover:bg-blue-500 disabled:opacity-50 disabled:cursor-not-allowed transition flex items-center gap-2"
>
<IconLibrary name="download" :size="14" /> 导出 Word
</button>
</div>
</header>
<!-- 主内容区左右对比 -->
<div class="flex-1 flex w-full overflow-hidden">
<!-- 左侧原文 -->
<div class="flex-1 flex flex-col border-r border-slate-700 min-w-0">
<div class="p-3 bg-slate-800 border-b border-slate-700 flex items-center justify-between shrink-0">
<h2 class="text-sm font-medium text-amber-400 flex items-center gap-2">
<IconLibrary name="document" :size="14" /> 原文
</h2>
<div class="flex items-center gap-2">
<span class="text-xs text-slate-500">来源</span>
<div class="flex bg-slate-900 rounded p-0.5 border border-slate-700">
<button
@click="leftSourceType = 'paste'"
:class="['text-xs px-2 py-0.5 rounded transition',
leftSourceType === 'paste' ? 'bg-amber-600 text-white' : 'text-slate-400 hover:text-white']"
>粘贴</button>
<button
@click="showLeftDocSelector = true"
:class="['text-xs px-2 py-0.5 rounded transition',
leftSourceType === 'document' ? 'bg-amber-600 text-white' : 'text-slate-400 hover:text-white']"
>文稿库</button>
</div>
</div>
</div>
<!-- 差异高亮显示区 -->
<div class="flex-1 overflow-y-auto p-4 bg-slate-900/50 min-h-0" :style="{ minHeight: `calc(100% - ${inputHeight}px - 60px)` }">
<div v-if="diffSegments.length > 0" class="text-sm leading-relaxed whitespace-pre-wrap">
<template v-for="(segment, idx) in diffSegments" :key="'left-' + 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>
</template>
</div>
<div v-else class="h-full flex flex-col items-center justify-center text-slate-500">
<div class="w-16 h-16 flex items-center justify-center mb-3 opacity-30">
<IconLibrary name="edit" :size="40" />
</div>
<p class="text-sm">在下方输入原文内容</p>
<p class="text-xs text-slate-600 mt-1">或从文稿库选择</p>
</div>
</div>
<!-- 可拖动分割线 -->
<div
class="h-2 bg-slate-700 cursor-ns-resize hover:bg-slate-600 flex items-center justify-center transition shrink-0"
@mousedown="startResize"
>
<div class="w-12 h-1 bg-slate-500 rounded"></div>
</div>
<!-- 输入区 -->
<div class="p-3 border-t border-slate-700 bg-slate-800 shrink-0" :style="{ height: inputHeight + 'px' }">
<textarea
v-model="leftContent"
class="w-full h-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-sm text-slate-200 focus:ring-2 focus:ring-amber-500 focus:border-transparent outline-none resize-none"
placeholder="粘贴或输入原文内容..."
></textarea>
</div>
</div>
<!-- 右侧修改文 -->
<div class="flex-1 flex flex-col min-w-0">
<div class="p-3 bg-slate-800 border-b border-slate-700 flex items-center justify-between shrink-0">
<h2 class="text-sm font-medium text-blue-400 flex items-center gap-2">
<IconLibrary name="edit" :size="14" /> 修改文
</h2>
<div class="flex items-center gap-2">
<span class="text-xs text-slate-500">来源</span>
<div class="flex bg-slate-900 rounded p-0.5 border border-slate-700">
<button
@click="rightSourceType = 'paste'"
:class="['text-xs px-2 py-0.5 rounded transition',
rightSourceType === 'paste' ? 'bg-blue-600 text-white' : 'text-slate-400 hover:text-white']"
>粘贴</button>
<button
@click="showRightDocSelector = true"
:class="['text-xs px-2 py-0.5 rounded transition',
rightSourceType === 'document' ? 'bg-blue-600 text-white' : 'text-slate-400 hover:text-white']"
>文稿库</button>
</div>
</div>
</div>
<!-- 差异高亮显示区 -->
<div class="flex-1 overflow-y-auto p-4 bg-slate-900/50 min-h-0" :style="{ minHeight: `calc(100% - ${inputHeight}px - 60px)` }">
<div v-if="diffSegments.length > 0" class="text-sm leading-relaxed whitespace-pre-wrap">
<template v-for="(segment, idx) in diffSegments" :key="'right-' + 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>
</template>
</div>
<div v-else class="h-full flex flex-col items-center justify-center text-slate-500">
<div class="w-16 h-16 flex items-center justify-center mb-3 opacity-30">
<IconLibrary name="edit" :size="40" />
</div>
<p class="text-sm">在下方输入修改后的内容</p>
<p class="text-xs text-slate-600 mt-1">或从文稿库选择</p>
</div>
</div>
<!-- 可拖动分割线 -->
<div
class="h-2 bg-slate-700 cursor-ns-resize hover:bg-slate-600 flex items-center justify-center transition shrink-0"
@mousedown="startResize"
>
<div class="w-12 h-1 bg-slate-500 rounded"></div>
</div>
<!-- 输入区 -->
<div class="p-3 border-t border-slate-700 bg-slate-800 shrink-0" :style="{ height: inputHeight + 'px' }">
<textarea
v-model="rightContent"
class="w-full h-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-sm text-slate-200 focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none resize-none"
placeholder="粘贴或输入修改后的内容..."
></textarea>
</div>
</div>
</div>
<!-- 文稿选择弹窗 - 左侧 -->
<DocumentSelectorModal
:visible="showLeftDocSelector"
@close="showLeftDocSelector = false"
@select="handleLeftDocSelect"
/>
<!-- 文稿选择弹窗 - 右侧 -->
<DocumentSelectorModal
:visible="showRightDocSelector"
@close="showRightDocSelector = false"
@select="handleRightDocSelect"
/>
</div>
</template>
<script setup>
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
import { useAppStore } from '../../stores/app'
import { computePreciseDiff, getPreciseDiffStats } from '../../utils/preciseDiff.js'
import IconLibrary from '../icons/IconLibrary.vue'
import { Document, Packer, Paragraph, TextRun } from 'docx'
import { saveAs } from 'file-saver'
import DocumentSelectorModal from '../modals/DocumentSelectorModal.vue'
const appStore = useAppStore()
// 内容
const leftContent = ref('')
const rightContent = ref('')
// 来源类型
const leftSourceType = ref('paste')
const rightSourceType = ref('paste')
// 文稿选择器
const showLeftDocSelector = ref(false)
const showRightDocSelector = ref(false)
// 差异计算
const diffSegments = ref([])
// 可拖动分割线状态
const inputHeight = ref(160)
const isResizing = ref(false)
const startY = ref(0)
const startHeight = ref(0)
// 监听内容变化,实时计算差异(使用精确算法)
watch([leftContent, rightContent], () => {
if (leftContent.value && rightContent.value) {
diffSegments.value = computePreciseDiff(leftContent.value, rightContent.value)
} else {
diffSegments.value = []
}
}, { immediate: true })
// 差异统计
const diffStats = computed(() => {
return getPreciseDiffStats(diffSegments.value)
})
// 处理左侧文稿选择
const handleLeftDocSelect = (doc) => {
leftContent.value = doc.content || ''
leftSourceType.value = 'document'
showLeftDocSelector.value = false
}
// 处理右侧文稿选择
const handleRightDocSelect = (doc) => {
rightContent.value = doc.content || ''
rightSourceType.value = 'document'
showRightDocSelector.value = false
}
// 分割线拖动处理
const startResize = (e) => {
isResizing.value = true
startY.value = e.clientY
startHeight.value = inputHeight.value
document.addEventListener('mousemove', doResize)
document.addEventListener('mouseup', stopResize)
document.body.style.cursor = 'ns-resize'
document.body.style.userSelect = 'none'
}
const doResize = (e) => {
if (!isResizing.value) return
const delta = startY.value - e.clientY
const newHeight = Math.max(80, Math.min(400, startHeight.value + delta))
inputHeight.value = newHeight
}
const stopResize = () => {
isResizing.value = false
document.removeEventListener('mousemove', doResize)
document.removeEventListener('mouseup', stopResize)
document.body.style.cursor = ''
document.body.style.userSelect = ''
}
// 清理事件监听
onUnmounted(() => {
document.removeEventListener('mousemove', doResize)
document.removeEventListener('mouseup', stopResize)
})
// 返回写作页面
const goBack = () => {
appStore.setCurrentPage('writer')
}
// 导出 Word 文档
const exportToWord = async () => {
if (!leftContent.value || !rightContent.value) {
alert('请先填写原文和修改文内容')
return
}
try {
// 创建文档段落
const children = []
// 标题
children.push(
new Paragraph({
children: [
new TextRun({
text: '文稿差异对比报告',
bold: true,
size: 32
})
],
spacing: { after: 400 }
})
)
// 统计信息
children.push(
new Paragraph({
children: [
new TextRun({
text: `差异统计:修改 ${diffStats.value.modified} 处,新增 ${diffStats.value.added} 处,删除 ${diffStats.value.removed}`,
size: 20,
color: '666666'
})
],
spacing: { after: 400 }
})
)
// 分隔线
children.push(
new Paragraph({
children: [new TextRun({ text: '─'.repeat(50) })],
spacing: { after: 200 }
})
)
// 原文标题
children.push(
new Paragraph({
children: [
new TextRun({
text: '【原文】',
bold: true,
size: 24
})
],
spacing: { after: 200 }
})
)
// 原文内容(带高亮)
const leftRuns = []
for (const segment of diffSegments.value) {
if (segment.type === 'unchanged' && segment.original) {
leftRuns.push(new TextRun({ text: segment.original, size: 22 }))
} else if (segment.type === 'modified' && segment.original) {
leftRuns.push(new TextRun({
text: segment.original,
size: 22,
color: 'D97706', // 橙色
highlight: 'yellow'
}))
} else if (segment.type === 'removed' && segment.original) {
leftRuns.push(new TextRun({
text: segment.original,
size: 22,
color: 'DC2626', // 红色
strike: true,
highlight: 'red'
}))
}
}
if (leftRuns.length > 0) {
children.push(new Paragraph({ children: leftRuns, spacing: { after: 400 } }))
}
// 分隔线
children.push(
new Paragraph({
children: [new TextRun({ text: '─'.repeat(50) })],
spacing: { after: 200 }
})
)
// 修改文标题
children.push(
new Paragraph({
children: [
new TextRun({
text: '【修改文】',
bold: true,
size: 24
})
],
spacing: { after: 200 }
})
)
// 修改文内容(带高亮)
const rightRuns = []
for (const segment of diffSegments.value) {
if (segment.type === 'unchanged' && segment.rewritten) {
rightRuns.push(new TextRun({ text: segment.rewritten, size: 22 }))
} else if (segment.type === 'modified' && segment.rewritten) {
rightRuns.push(new TextRun({
text: segment.rewritten,
size: 22,
color: 'D97706', // 橙色
highlight: 'yellow'
}))
} else if (segment.type === 'added' && segment.rewritten) {
rightRuns.push(new TextRun({
text: segment.rewritten,
size: 22,
color: '16A34A', // 绿色
highlight: 'green'
}))
}
}
if (rightRuns.length > 0) {
children.push(new Paragraph({ children: rightRuns, spacing: { after: 400 } }))
}
// 创建文档
const doc = new Document({
sections: [{
children: children
}]
})
// 生成并下载
const blob = await Packer.toBlob(doc)
const timestamp = new Date().toISOString().slice(0, 10)
saveAs(blob, `文稿差异对比_${timestamp}.docx`)
alert('导出成功!')
} catch (error) {
console.error('导出失败:', error)
alert('导出失败,请重试')
}
}
</script>
<style scoped>
/* ========== 使用设计令牌系统 ========== */
/* 页面容器 */
.diff-page {
height: 100vh;
width: 100%;
display: flex;
flex-direction: column;
background: var(--bg-primary);
}
/* 头部工具栏 */
.diff-header {
height: 56px;
border-bottom: 1px solid var(--border-default);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 var(--space-6);
background: var(--bg-primary);
flex-shrink: 0;
}
.diff-header-title {
font-size: var(--text-lg);
font-weight: var(--font-semibold);
color: var(--text-primary);
display: flex;
align-items: center;
gap: var(--space-2);
}
/* 图例说明 */
.diff-legend {
font-size: var(--text-xs);
color: var(--text-muted);
display: flex;
align-items: center;
gap: var(--space-3);
}
.diff-legend-item {
display: flex;
align-items: center;
gap: var(--space-1);
}
.diff-legend-dot {
width: 12px;
height: 12px;
border-radius: var(--radius-md);
}
.diff-legend-dot.modified {
background: rgba(245, 158, 11, 0.5);
}
.diff-legend-dot.added {
background: rgba(34, 197, 94, 0.5);
}
.diff-legend-dot.removed {
background: rgba(239, 68, 68, 0.5);
}
/* 差异统计 */
.diff-stats {
font-size: var(--text-xs);
color: var(--text-secondary);
display: flex;
align-items: center;
gap: var(--space-3);
border-left: 1px solid var(--border-default);
padding-left: var(--space-4);
}
.diff-stats .modified {
color: var(--accent-warning);
}
.diff-stats .added {
color: var(--accent-success);
}
.diff-stats .removed {
color: var(--accent-danger);
}
/* 主内容区 */
.diff-main {
flex: 1;
display: flex;
width: 100%;
overflow: hidden;
}
/* 面板区域 */
.diff-panel {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
}
.diff-panel-left {
border-right: 1px solid var(--border-default);
}
/* 面板头部 */
.diff-panel-header {
padding: var(--space-3);
background: var(--bg-secondary);
border-bottom: 1px solid var(--border-default);
display: flex;
align-items: center;
justify-content: space-between;
flex-shrink: 0;
}
.diff-panel-title {
font-size: var(--text-sm);
font-weight: var(--font-medium);
display: flex;
align-items: center;
gap: var(--space-2);
}
.diff-panel-title.left {
color: var(--accent-warning);
}
.diff-panel-title.right {
color: var(--accent-primary);
}
/* 差异显示区 */
.diff-display {
flex: 1;
overflow-y: auto;
padding: var(--space-4);
background: rgba(15, 23, 42, 0.5);
min-height: 0;
}
.diff-content {
font-size: var(--text-sm);
line-height: 1.6;
white-space: pre-wrap;
}
/* 差异片段样式 */
.diff-unchanged {
color: var(--text-secondary);
}
.diff-modified {
background: rgba(245, 158, 11, 0.3);
color: #fed7aa;
padding: 2px 4px;
border-radius: var(--radius-sm);
}
.diff-removed {
background: rgba(239, 68, 68, 0.3);
color: #fecaca;
padding: 2px 4px;
border-radius: var(--radius-sm);
text-decoration: line-through;
}
.diff-added {
background: rgba(34, 197, 94, 0.3);
color: #bbf7d0;
padding: 2px 4px;
border-radius: var(--radius-sm);
}
/* 空状态 */
.diff-empty-state {
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: var(--text-muted);
}
.diff-empty-icon {
font-size: 3rem;
margin-bottom: var(--space-3);
opacity: 0.3;
}
/* 可拖动分割线 */
.diff-resizer {
height: 8px;
background: var(--bg-elevated);
cursor: ns-resize;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
transition: background var(--transition-fast);
}
.diff-resizer:hover {
background: var(--border-strong);
}
.diff-resizer-handle {
width: 48px;
height: 4px;
background: var(--text-muted);
border-radius: var(--radius-full);
}
/* 输入区 */
.diff-input-area {
padding: var(--space-3);
border-top: 1px solid var(--border-default);
background: var(--bg-secondary);
flex-shrink: 0;
}
.diff-textarea {
width: 100%;
height: 100%;
background: var(--bg-primary);
border: 1px solid var(--border-default);
border-radius: var(--radius-lg);
padding: var(--space-3);
font-size: var(--text-sm);
color: var(--text-primary);
}
.diff-textarea:focus {
outline: none;
border-color: var(--accent-primary);
box-shadow: 0 0 0 2px var(--info-bg);
}
/* 按钮样式 */
.btn-diff {
font-size: var(--text-xs);
padding: var(--space-2) var(--space-3);
border-radius: var(--radius-md);
transition: all var(--transition-fast);
border: none;
cursor: pointer;
}
.btn-diff-secondary {
background: var(--bg-elevated);
color: var(--text-secondary);
}
.btn-diff-secondary:hover {
background: var(--bg-sunken);
color: var(--text-primary);
}
.btn-diff-primary {
background: var(--accent-primary);
color: var(--text-inverse);
}
.btn-diff-primary:hover {
background: var(--accent-primary-hover);
}
.btn-diff:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* 来源切换器 */
.source-toggle {
display: flex;
background: var(--bg-primary);
padding: var(--space-1);
border-radius: var(--radius-md);
border: 1px solid var(--border-default);
}
.source-toggle-btn {
padding: var(--space-2) var(--space-3);
font-size: var(--text-xs);
border-radius: var(--radius-sm);
transition: all var(--transition-fast);
}
.source-toggle-btn.active {
background: var(--accent-primary);
color: var(--text-inverse);
}
.source-toggle-btn:not(.active) {
color: var(--text-secondary);
}
.source-toggle-btn:not(.active):hover {
color: var(--text-primary);
}
</style>