refactor: 按功能分类重组 components 目录结构

This commit is contained in:
empty
2026-01-22 00:02:05 +08:00
parent 44848cd40f
commit 6843328b0b
30 changed files with 155 additions and 140 deletions

View File

@@ -0,0 +1,750 @@
<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>