Files
ai-write/src/components/ComparePanel.vue
empty f9f0785106 feat: 完善范式润色功能和相关组件
- 新增范式选择模态框 (ParadigmSelectorModal)
- 新增差异标注面板 (DiffAnnotationPanel)
- 新增精确差异处理工具 (preciseDiff.js)
- 更新各面板组件支持范式润色功能
- 更新范式配置文件 (paradigms.js)
- 更新依赖配置

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 03:02:21 +08:00

1768 lines
71 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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-900">
<!-- 头部 -->
<header class="p-4 border-b border-slate-700 bg-slate-800 flex items-center justify-between shrink-0">
<div class="flex items-center gap-4">
<h1 class="font-bold text-lg text-white flex items-center gap-2">
<span class="text-2xl">🔍</span> 对照检查
</h1>
<span class="text-xs text-slate-500">选中左右两侧对应段落进行检查</span>
</div>
<button
@click="goBack"
class="text-xs px-3 py-1.5 rounded bg-slate-700 text-slate-300 hover:bg-slate-600 transition"
>
返回写作
</button>
</header>
<!-- 范式上下文提示条 -->
<div v-if="hasParadigmRules" class="px-4 py-2 bg-indigo-900/30 border-b border-indigo-700/50 flex items-center justify-between shrink-0">
<div class="flex items-center gap-3">
<span class="text-indigo-400 text-sm">📚 当前写作范式</span>
<span class="text-white text-sm font-medium">{{ activeParadigmName }}</span>
<!-- 检查模式切换 -->
<div class="flex bg-slate-900/50 rounded p-0.5 border border-indigo-700/50">
<button
@click="checkMode = 'paragraph'"
:class="['text-xs px-2 py-0.5 rounded transition', checkMode === 'paragraph' ? 'bg-indigo-600 text-white' : 'text-indigo-300 hover:text-white']"
>逐段检查</button>
<button
@click="checkMode = 'document'"
:class="['text-xs px-2 py-0.5 rounded transition', checkMode === 'document' ? 'bg-indigo-600 text-white' : 'text-indigo-300 hover:text-white']"
>全文检查</button>
</div>
<span class="text-xs text-indigo-300/70">{{ currentScopeRulesCount }} {{ checkMode === 'paragraph' ? '段落级' : '全文级' }}规则生效</span>
</div>
<button
@click="showParadigmRules = !showParadigmRules"
class="text-xs px-2 py-1 rounded bg-indigo-800/50 text-indigo-300 hover:bg-indigo-700/50 transition"
>
{{ showParadigmRules ? '收起规则' : '查看规则' }}
</button>
</div>
<!-- 范式规则展开区 -->
<div v-if="showParadigmRules && hasParadigmRules" class="px-4 py-3 bg-indigo-950/50 border-b border-indigo-800/30 max-h-56 overflow-y-auto shrink-0">
<!-- 操作栏 -->
<div class="flex items-center justify-between mb-2 pb-2 border-b border-indigo-800/30">
<span class="text-xs text-indigo-300">已启用 {{ enabledRulesCount }}/{{ paradigmRulesCount }} 条规则</span>
<button
@click="toggleAllRules"
class="text-xs px-2 py-0.5 rounded bg-indigo-700/50 text-indigo-200 hover:bg-indigo-600/50 transition"
>
{{ enabledRulesCount === paradigmRulesCount ? '取消全选' : '全选' }}
</button>
</div>
<!-- 规则列表 -->
<div class="grid grid-cols-2 gap-2">
<div v-for="rule in allRulesWithIdx" :key="rule.idx"
@click="toggleRule(rule.idx)"
:class="['text-xs px-2 py-1.5 rounded flex items-start gap-2 cursor-pointer transition border',
enabledRuleIdxs.has(rule.idx)
? (rule.scope === checkMode ? 'bg-indigo-800/50 text-indigo-100 border-indigo-600/50' : 'bg-slate-800/50 text-slate-300 border-slate-600/50')
: 'bg-slate-900/50 text-slate-500 border-slate-700/30 opacity-60'
]">
<!-- 勾选框 -->
<div :class="['w-4 h-4 rounded border flex items-center justify-center shrink-0 mt-0.5 transition',
enabledRuleIdxs.has(rule.idx) ? 'bg-indigo-600 border-indigo-500' : 'bg-slate-800 border-slate-600'
]">
<span v-if="enabledRuleIdxs.has(rule.idx)" class="text-white text-[10px]"></span>
</div>
<!-- 规则标签和文本 -->
<div class="flex-1 min-w-0">
<span :class="['shrink-0 px-1 rounded text-[10px] mr-1',
rule.scope === 'paragraph' ? 'bg-amber-600/50 text-amber-200' : 'bg-blue-600/50 text-blue-200'
]">{{ rule.scope === 'paragraph' ? '段' : '文' }}</span>
<span class="break-words">{{ rule.text }}</span>
</div>
</div>
</div>
</div>
<!-- 主体内容区 -->
<div class="flex-1 flex overflow-hidden">
<!-- 左侧要求原文 -->
<div class="w-[40%] 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">
<h2 class="text-sm font-medium text-amber-400 flex items-center gap-2">
📋 要求原文
</h2>
<div class="flex items-center gap-2">
<span class="text-xs text-slate-500">{{ leftParagraphs.length }} </span>
<!-- 保存按钮仅当内容来自文稿库时显示 -->
<button
v-if="leftSourceType === 'document' && leftSourceDocId"
@click="saveLeftContent"
:disabled="isLeftSaving || !hasLeftContentChanged"
class="text-xs px-2 py-1 rounded flex items-center gap-1 transition"
:class="hasLeftContentChanged
? 'bg-green-600 text-white hover:bg-green-500'
: 'bg-slate-700 text-slate-400 cursor-not-allowed'"
>
<span v-if="isLeftSaving" class="animate-spin"></span>
<span v-else>💾</span>
{{ isLeftSaving ? '保存中...' : (hasLeftContentChanged ? '保存版本' : '已保存') }}
</button>
<span v-if="leftSourceType === 'document' && leftSourceDocTitle" class="text-xs text-amber-400">
📄 {{ leftSourceDocTitle }}
</span>
</div>
</div>
<!-- 来源选择器 -->
<div class="px-3 py-2 bg-slate-800/50 border-b border-slate-700 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="showParadigmSelector = true"
:class="['text-xs px-2 py-0.5 rounded transition',
leftSourceType === 'paradigm' ? 'bg-amber-600 text-white' : 'text-slate-400 hover:text-white']"
>范式库</button>
<button
@click="showMaterialSelector = true"
:class="['text-xs px-2 py-0.5 rounded transition',
leftSourceType === 'material' ? '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 class="flex-1 overflow-y-auto p-4 space-y-3 min-h-0">
<div v-if="!leftContent" class="h-full flex items-center justify-center">
<div class="text-center">
<span class="text-4xl opacity-20 block mb-2">📝</span>
<p class="text-slate-600 text-sm">请在下方粘贴写作要求的原文</p>
<p class="text-slate-700 text-xs mt-1">红头文件通知要求等</p>
</div>
</div>
<div
v-for="(para, idx) in leftParagraphs"
:key="'left-' + idx"
@click="selectLeftParagraph(idx)"
:class="[
'p-3 rounded-lg border cursor-pointer transition-all',
selectedLeftIdxs.includes(idx)
? 'bg-amber-900/30 border-amber-500 ring-2 ring-amber-500/30'
: 'bg-slate-800/50 border-slate-700 hover:border-amber-500/50'
]"
>
<div class="flex items-start gap-2">
<span :class="['text-xs shrink-0 mt-0.5 w-5 h-5 rounded flex items-center justify-center',
selectedLeftIdxs.includes(idx) ? 'bg-amber-500 text-white' : 'text-amber-500/70']">
{{ selectedLeftIdxs.includes(idx) ? '✓' : (idx + 1) }}
</span>
<p class="text-sm text-slate-300 whitespace-pre-wrap">{{ para }}</p>
</div>
</div>
</div>
<div class="p-3 border-t border-slate-700 bg-slate-800">
<textarea
v-model="leftContent"
class="w-full h-24 bg-slate-900 border border-slate-700 rounded-lg p-3 text-sm focus:ring-2 focus:ring-amber-500 focus:border-transparent outline-none resize-none"
placeholder="粘贴或输入写作要求(每段用空行分隔)..."
></textarea>
</div>
</div>
<!-- 中间对应关系指示器 -->
<div class="w-20 flex flex-col items-center justify-center bg-slate-800/50 shrink-0">
<div v-if="selectedLeftIdxs.length > 0 && selectedRightIdxs.length > 0" class="flex flex-col items-center gap-2">
<div class="text-xs text-amber-400 font-medium">{{ selectedLeftIdxs.length }} </div>
<div class="w-3 h-3 rounded-full bg-amber-500"></div>
<div class="w-0.5 h-6 bg-gradient-to-b from-amber-500 to-blue-500"></div>
<span class="text-xs text-slate-400 px-1 py-0.5 bg-slate-700 rounded">对照</span>
<div class="w-0.5 h-6 bg-gradient-to-b from-blue-500 to-blue-500"></div>
<div class="w-3 h-3 rounded-full bg-blue-500"></div>
<div class="text-xs text-blue-400 font-medium">{{ selectedRightIdxs.length }} </div>
</div>
<div v-else class="text-center px-2">
<span class="text-xs text-slate-600">点击选中<br/>多个段落</span>
</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">
<h2 class="text-sm font-medium text-blue-400 flex items-center gap-2">
写作内容
</h2>
<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">
<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 class="flex-1 overflow-y-auto p-4 space-y-3 min-h-0">
<div v-if="!rightContent" class="h-full flex items-center justify-center">
<div class="text-center">
<span class="text-4xl opacity-20 block mb-2">📄</span>
<p class="text-slate-600 text-sm">请在下方输入写作内容</p>
</div>
</div>
<div
v-for="(para, idx) in rightParagraphs"
:key="'right-' + idx"
@click="selectRightParagraph(idx)"
:class="[
'p-3 rounded-lg border cursor-pointer transition-all',
selectedRightIdxs.includes(idx)
? 'bg-blue-900/30 border-blue-500 ring-2 ring-blue-500/30'
: 'bg-slate-800/50 border-slate-700 hover:border-blue-500/50'
]"
>
<div class="flex items-start gap-2">
<span :class="['text-xs shrink-0 mt-0.5 w-5 h-5 rounded flex items-center justify-center',
selectedRightIdxs.includes(idx) ? 'bg-blue-500 text-white' : 'text-blue-500/70']">
{{ selectedRightIdxs.includes(idx) ? '✓' : (idx + 1) }}
</span>
<p class="text-sm text-slate-300 whitespace-pre-wrap">{{ para }}</p>
</div>
<!-- 段落类型标签 -->
<div v-if="paragraphTypes[idx]" class="mt-2 flex items-center gap-2 relative">
<button
@click.stop="toggleTypeDropdown(idx)"
:class="['text-xs px-2 py-0.5 rounded flex items-center gap-1',
getSectionTypeClasses(paragraphTypes[idx].sectionType)]"
>
<span>{{ paragraphTypes[idx].sectionLabel }}</span>
<span v-if="!paragraphTypes[idx].userConfirmed" class="text-[10px] opacity-70">
({{ Math.round(paragraphTypes[idx].confidence * 100) }}%)
</span>
<span class="text-[10px]"></span>
</button>
<!-- 类型选择下拉菜单 -->
<div
v-if="showTypeDropdown === idx"
class="absolute top-full left-0 mt-1 bg-slate-800 border border-slate-600 rounded-lg shadow-lg z-10 py-1"
>
<button
v-for="type in Object.values(SECTION_TYPES)"
:key="type.id"
@click.stop="selectParagraphType(idx, type)"
:class="['w-full text-left text-xs px-3 py-1.5 hover:bg-slate-700 flex items-center gap-2',
paragraphTypes[idx].sectionType === type.id ? 'bg-slate-700' : '']"
>
<span :class="[type.bgClass, type.textClass, 'px-1.5 py-0.5 rounded text-[10px]']">{{ type.label }}</span>
</button>
</div>
</div>
<!-- 检查结果标记 -->
<div v-if="checkResults[idx]" class="mt-2 pt-2 border-t border-slate-700">
<div :class="[
'text-xs px-2 py-1 rounded inline-flex items-center gap-1',
checkResults[idx].status === 'pass' ? 'bg-green-900/30 text-green-400' :
checkResults[idx].status === 'warning' ? 'bg-yellow-900/30 text-yellow-400' :
'bg-red-900/30 text-red-400'
]">
<span>{{ checkResults[idx].status === 'pass' ? '✅' : checkResults[idx].status === 'warning' ? '⚠️' : '❌' }}</span>
{{ checkResults[idx].message }}
</div>
</div>
</div>
</div>
<div class="p-3 border-t border-slate-700 bg-slate-800">
<textarea
v-model="rightContent"
class="w-full h-24 bg-slate-900 border border-slate-700 rounded-lg p-3 text-sm focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none resize-none"
placeholder="粘贴或输入写作内容(每段用空行分隔)..."
></textarea>
</div>
</div>
</div>
<!-- 底部操作区 -->
<footer class="p-4 bg-slate-800 border-t border-slate-700 shrink-0">
<div class="flex items-center justify-between gap-4">
<!-- 检查结果摘要 -->
<div v-if="lastCheckResult" class="flex-1">
<div :class="[
'p-3 rounded-lg border',
lastCheckResult.overall === 'pass' ? 'bg-green-900/20 border-green-500/30' :
lastCheckResult.overall === 'warning' ? 'bg-yellow-900/20 border-yellow-500/30' :
'bg-red-900/20 border-red-500/30'
]">
<div class="flex items-center gap-2 mb-1">
<span class="text-lg">
{{ lastCheckResult.overall === 'pass' ? '✅' : lastCheckResult.overall === 'warning' ? '⚠️' : '❌' }}
</span>
<span :class="[
'font-medium',
lastCheckResult.overall === 'pass' ? 'text-green-400' :
lastCheckResult.overall === 'warning' ? 'text-yellow-400' : 'text-red-400'
]">
{{ lastCheckResult.overall === 'pass' ? '符合要求' : lastCheckResult.overall === 'warning' ? '部分符合' : '不符合要求' }}
</span>
</div>
<p class="text-xs text-slate-400">{{ lastCheckResult.summary }}</p>
<!-- 展开/收起按钮 -->
<button
v-if="lastCheckResult.suggestions?.length"
@click="showDetailedSuggestions = !showDetailedSuggestions"
class="text-xs text-indigo-400 hover:text-indigo-300 mt-2"
>
{{ showDetailedSuggestions ? '收起详情 ▲' : '展开详情 ▼' }}
</button>
</div>
<!-- 详细建议区域 -->
<div v-if="showDetailedSuggestions && lastCheckResult.suggestions?.length"
class="mt-3 p-3 bg-slate-800/50 rounded-lg border border-slate-700">
<h4 class="text-xs text-slate-400 mb-2 flex items-center gap-1">
<span>💡</span> 改进建议
</h4>
<div class="space-y-2">
<div v-for="(suggestion, idx) in lastCheckResult.suggestions" :key="idx"
class="flex items-center justify-between bg-slate-900/50 rounded px-3 py-2">
<span class="text-xs text-slate-300 flex-1">{{ idx + 1 }}. {{ suggestion }}</span>
<button
@click="executeRewrite(idx, suggestion)"
:disabled="rewritingSuggestionIdx !== null || selectedRightIdxs.length === 0"
class="text-xs px-2 py-1 rounded bg-indigo-600/50 text-indigo-200
hover:bg-indigo-500 transition flex items-center gap-1
disabled:opacity-50 disabled:cursor-not-allowed ml-2 shrink-0">
<span v-if="rewritingSuggestionIdx === idx" class="animate-spin"></span>
<span v-else>🔄</span>
{{ rewritingSuggestionIdx === idx ? '重写中...' : '一键重写' }}
</button>
</div>
</div>
<p v-if="selectedRightIdxs.length === 0"
class="text-xs text-amber-400/70 mt-2">
提示请先选中右侧要重写的段落
</p>
</div>
</div>
<div v-else class="flex-1 text-sm text-slate-500">
选中左侧要求和右侧内容点击"对照检查"按钮进行分析
</div>
<!-- 操作按钮 -->
<div class="flex gap-2 shrink-0">
<button
@click="clearSelection"
class="px-4 py-2 rounded-lg text-sm bg-slate-700 text-slate-300 hover:bg-slate-600 transition"
>
清除选择
</button>
<button
@click="detectParagraphTypes"
:disabled="rightParagraphs.length === 0 || isDetectingTypes"
class="px-4 py-2 rounded-lg text-sm bg-purple-700 text-white hover:bg-purple-600 transition disabled:opacity-50 disabled:cursor-not-allowed"
>
<span v-if="isDetectingTypes" class="flex items-center gap-2">
<span class="animate-spin"></span> 识别中...
</span>
<span v-else>🧠 智能识别</span>
</button>
<button
@click="runCompare"
:disabled="!canCompare || isComparing"
class="px-6 py-2 rounded-lg text-sm font-medium text-white transition disabled:opacity-50 disabled:cursor-not-allowed"
:class="isComparing ? 'bg-slate-600' : 'bg-gradient-to-r from-amber-600 to-blue-600 hover:from-amber-500 hover:to-blue-500'"
>
<span v-if="isComparing" class="flex items-center gap-2">
<span class="animate-spin"></span> 检查中...
</span>
<span v-else>🔍 对照检查</span>
</button>
</div>
</div>
</footer>
<!-- 素材选择弹窗 -->
<MaterialSelectorModal
:visible="showMaterialSelector"
@close="showMaterialSelector = false"
@select="handleMaterialSelect"
/>
<!-- 范式选择弹窗 -->
<ParadigmSelectorModal
:visible="showParadigmSelector"
@close="showParadigmSelector = false"
@select="handleParadigmSelect"
/>
<!-- 文稿选择弹窗左侧 -->
<DocumentSelectorModal
:visible="showLeftDocSelector"
@close="showLeftDocSelector = false"
@select="handleLeftDocSelect"
/>
<!-- 文稿选择弹窗右侧 -->
<DocumentSelectorModal
:visible="showRightDocSelector"
@close="showRightDocSelector = false"
@select="handleRightDocSelect"
/>
<!-- 重写预览浮窗 -->
<div
v-if="showRewriteModal"
class="fixed inset-0 bg-black/60 flex items-center justify-center z-50"
>
<div class="bg-slate-800 rounded-lg w-[900px] max-h-[85vh] flex flex-col shadow-2xl">
<!-- 浮窗头部 -->
<div class="p-4 border-b border-slate-700 flex items-center justify-between">
<h3 class="text-lg font-medium text-white flex items-center gap-2">
<span>🔄</span> AI 重写预览
</h3>
<button
@click="resetRewriteModal"
class="text-slate-400 hover:text-white text-xl"
></button>
</div>
<!-- 建议信息 + 视图切换 -->
<div class="px-4 py-2 bg-indigo-900/30 border-b border-indigo-700/50 flex items-center justify-between">
<div>
<span class="text-xs text-indigo-300">📋 根据建议</span>
<span class="text-xs text-white ml-1">{{ currentSuggestion }}</span>
</div>
<!-- 视图切换按钮 -->
<div v-if="rewritingSuggestionIdx === null && diffSegments.length > 0" class="flex bg-slate-900/50 rounded p-0.5">
<button
@click="rewriteViewMode = 'result'"
:class="['text-xs px-2 py-1 rounded transition', rewriteViewMode === 'result' ? 'bg-indigo-600 text-white' : 'text-slate-400 hover:text-white']"
>仅看结果</button>
<button
@click="rewriteViewMode = 'diff'"
:class="['text-xs px-2 py-1 rounded transition', rewriteViewMode === 'diff' ? 'bg-indigo-600 text-white' : 'text-slate-400 hover:text-white']"
>差异对比</button>
<button
@click="rewriteViewMode = 'review'; currentReviewIdx = 0"
:class="['text-xs px-2 py-1 rounded transition', rewriteViewMode === 'review' ? 'bg-indigo-600 text-white' : 'text-slate-400 hover:text-white']"
>逐句审核</button>
<button
@click="switchToPartialReplace"
:class="['text-xs px-2 py-1 rounded transition', rewriteViewMode === 'partial' ? 'bg-indigo-600 text-white' : 'text-slate-400 hover:text-white']"
>部分替换</button>
</div>
</div>
<!-- 差异统计信息 -->
<div v-if="rewritingSuggestionIdx === null && diffStats.total > 0" class="px-4 py-2 bg-slate-900/30 border-b border-slate-700 flex items-center gap-4 text-xs">
<span class="text-slate-400"> {{ diffStats.total }} </span>
<span v-if="diffStats.modified > 0" class="text-amber-400">{{ diffStats.modified }} 处修改</span>
<span v-if="diffStats.added > 0" class="text-green-400">{{ diffStats.added }} 处新增</span>
<span v-if="diffStats.removed > 0" class="text-red-400">{{ diffStats.removed }} 处删除</span>
<span class="text-slate-500">|</span>
<span class="text-indigo-400">已选中 {{ acceptedChanges.size }} 处修改</span>
</div>
<!-- 重写内容区域 -->
<div class="flex-1 overflow-y-auto min-h-[300px]">
<!-- 加载中状态 -->
<div v-if="rewritingSuggestionIdx !== null" class="p-4">
<div class="flex items-center gap-2 text-indigo-300 mb-3">
<span class="animate-spin"></span>
<span class="text-sm">AI 正在重写中...</span>
</div>
<div class="bg-slate-900 rounded-lg p-4 border border-slate-700">
<p class="text-sm text-slate-200 whitespace-pre-wrap leading-relaxed">
{{ rewritePreview || '等待生成...' }}
</p>
</div>
</div>
<!-- 仅看结果视图 -->
<div v-else-if="rewriteViewMode === 'result'" class="p-4">
<div class="bg-slate-900 rounded-lg p-4 border border-slate-700">
<p class="text-sm text-slate-200 whitespace-pre-wrap leading-relaxed">
{{ rewritePreview || '等待生成...' }}
</p>
</div>
</div>
<!-- 差异对比视图 -->
<div v-else-if="rewriteViewMode === 'diff'" class="p-4">
<div class="flex gap-4">
<!-- 左边原文 -->
<div class="flex-1">
<div class="text-xs text-amber-400 mb-2 font-medium">📄 原文点击选中需要替换的部分</div>
<div class="bg-slate-900 rounded-lg p-3 border border-slate-700">
<template v-for="segment in diffSegments" :key="'orig-' + segment.idx">
<span
v-if="segment.type === 'unchanged'"
class="text-sm text-slate-400"
>{{ segment.original }}</span>
<span
v-else-if="segment.type === 'modified' || segment.type === 'removed'"
@click="toggleChangeAccepted(segment.idx)"
:class="[
'text-sm px-0.5 rounded cursor-pointer transition-all',
acceptedChanges.has(segment.idx)
? 'bg-amber-500/40 text-amber-100 ring-2 ring-amber-400'
: 'bg-amber-900/30 text-amber-200 hover:bg-amber-800/40',
segment.type === 'removed' ? 'line-through' : ''
]"
>{{ segment.original }}</span>
</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">
<template v-for="segment in diffSegments" :key="'new-' + segment.idx">
<span
v-if="segment.type === 'unchanged'"
class="text-sm text-slate-400"
>{{ segment.rewritten }}</span>
<span
v-else-if="segment.type === 'modified' || segment.type === 'added'"
:class="[
'text-sm px-0.5 rounded transition-all',
acceptedChanges.has(segment.idx)
? 'bg-blue-500/40 text-blue-100 ring-2 ring-blue-400'
: 'bg-blue-900/30 text-blue-200'
]"
>{{ segment.rewritten }}</span>
</template>
</div>
</div>
</div>
<p class="text-xs text-slate-500 mt-3 text-center">在左边原文中点击句子可选中/取消选中的部分将被右边对应内容替换</p>
</div>
<!-- 逐句审核视图 -->
<div v-else-if="rewriteViewMode === 'review'" class="p-4">
<div v-if="currentReviewItem" class="space-y-4">
<!-- 进度指示 -->
<div class="flex items-center justify-between text-xs text-slate-400">
<span> {{ currentReviewIdx + 1 }} / {{ reviewableChanges.length }} 处修改</span>
<span :class="[
'px-2 py-0.5 rounded',
currentReviewItem.type === 'modified' ? 'bg-amber-900/30 text-amber-300' :
currentReviewItem.type === 'added' ? 'bg-green-900/30 text-green-300' :
'bg-red-900/30 text-red-300'
]">
{{ currentReviewItem.type === 'modified' ? '修改' : currentReviewItem.type === 'added' ? '新增' : '删除' }}
</span>
</div>
<!-- 原文 -->
<div v-if="currentReviewItem.original">
<div class="text-xs text-amber-400 mb-1">原文</div>
<div class="bg-slate-900 rounded-lg p-3 border border-amber-700/30">
<p class="text-sm text-slate-300">{{ currentReviewItem.original }}</p>
</div>
</div>
<!-- 重写后 -->
<div v-if="currentReviewItem.rewritten">
<div class="text-xs text-blue-400 mb-1">重写后</div>
<div class="bg-slate-900 rounded-lg p-3 border border-blue-700/30">
<p class="text-sm text-slate-200">{{ currentReviewItem.rewritten }}</p>
</div>
</div>
<!-- 操作按钮 -->
<div class="flex items-center justify-between pt-2">
<button
@click="prevReviewItem"
:disabled="currentReviewIdx === 0"
class="text-xs px-3 py-1.5 rounded bg-slate-700 text-slate-300 hover:bg-slate-600 disabled:opacity-50"
> 上一处</button>
<div class="flex gap-2">
<button
@click="toggleChangeAccepted(currentReviewItem.idx)"
:class="[
'text-xs px-3 py-1.5 rounded transition',
acceptedChanges.has(currentReviewItem.idx)
? 'bg-green-600 text-white'
: 'bg-slate-700 text-slate-300 hover:bg-slate-600'
]"
>
{{ acceptedChanges.has(currentReviewItem.idx) ? '✓ 已接受' : '接受修改' }}
</button>
</div>
<button
@click="nextReviewItem"
:disabled="currentReviewIdx >= reviewableChanges.length - 1"
class="text-xs px-3 py-1.5 rounded bg-slate-700 text-slate-300 hover:bg-slate-600 disabled:opacity-50"
>下一处 </button>
</div>
</div>
<div v-else class="text-center text-slate-500 py-8">
没有需要审核的修改
</div>
</div>
<!-- 部分替换视图 -->
<div v-else-if="rewriteViewMode === 'partial'" class="p-4">
<div class="flex gap-4">
<!-- 左边原文句子 -->
<div class="flex-1">
<div class="text-xs text-amber-400 mb-2 font-medium flex items-center justify-between">
<span>📄 原文点击选中要被替换的句子</span>
<span class="text-slate-500">已选 {{ partialLeftSelected.size }} </span>
</div>
<div class="bg-slate-900 rounded-lg p-3 border border-slate-700 max-h-[400px] overflow-y-auto">
<template v-for="(sentence, idx) in originalSentences" :key="'partial-orig-' + idx">
<span
@click="togglePartialLeftSentence(idx)"
:class="[
'text-sm px-0.5 rounded cursor-pointer transition-all inline',
partialLeftSelected.has(idx)
? 'bg-amber-500/40 text-amber-100 ring-2 ring-amber-400'
: 'text-slate-300 hover:bg-amber-900/30 hover:text-amber-200'
]"
>{{ sentence.text }}</span>
</template>
</div>
</div>
<!-- 右边重写后句子 -->
<div class="flex-1">
<div class="text-xs text-blue-400 mb-2 font-medium flex items-center justify-between">
<span> 重写后点击选中用于替换的句子</span>
<span class="text-slate-500">已选 {{ partialRightSelected.size }} </span>
</div>
<div class="bg-slate-900 rounded-lg p-3 border border-slate-700 max-h-[400px] overflow-y-auto">
<template v-for="(sentence, idx) in rewrittenSentences" :key="'partial-new-' + idx">
<span
@click="togglePartialRightSentence(idx)"
:class="[
'text-sm px-0.5 rounded cursor-pointer transition-all inline',
partialRightSelected.has(idx)
? 'bg-blue-500/40 text-blue-100 ring-2 ring-blue-400'
: 'text-slate-300 hover:bg-blue-900/30 hover:text-blue-200'
]"
>{{ sentence.text }}</span>
</template>
</div>
</div>
</div>
<p class="text-xs text-slate-500 mt-3 text-center">在左边选中要被替换的句子在右边选中用于替换的内容点击"应用选中"完成替换</p>
</div>
</div>
<!-- 浮窗底部操作 -->
<div class="p-4 border-t border-slate-700 flex items-center justify-between">
<!-- 左侧全选/取消全选差异对比模式 -->
<div v-if="rewritingSuggestionIdx === null && rewriteViewMode !== 'partial' && diffSegments.length > 0" class="flex gap-2">
<button
@click="toggleAllChanges(true)"
class="text-xs px-2 py-1 rounded bg-slate-700 text-slate-300 hover:bg-slate-600"
>全选修改</button>
<button
@click="toggleAllChanges(false)"
class="text-xs px-2 py-1 rounded bg-slate-700 text-slate-300 hover:bg-slate-600"
>取消全选</button>
</div>
<!-- 左侧部分替换模式的操作按钮 -->
<div v-else-if="rewriteViewMode === 'partial'" class="flex gap-2">
<button
@click="partialLeftSelected = new Set([...Array(originalSentences.length).keys()])"
class="text-xs px-2 py-1 rounded bg-slate-700 text-slate-300 hover:bg-slate-600"
>全选左侧</button>
<button
@click="partialLeftSelected = new Set()"
class="text-xs px-2 py-1 rounded bg-slate-700 text-slate-300 hover:bg-slate-600"
>取消左侧</button>
<button
@click="partialRightSelected = new Set([...Array(rewrittenSentences.length).keys()])"
class="text-xs px-2 py-1 rounded bg-slate-700 text-slate-300 hover:bg-slate-600"
>全选右侧</button>
<button
@click="partialRightSelected = new Set()"
class="text-xs px-2 py-1 rounded bg-slate-700 text-slate-300 hover:bg-slate-600"
>取消右侧</button>
</div>
<div v-else></div>
<!-- 右侧主操作按钮 -->
<div class="flex gap-3">
<button
@click="resetRewriteModal"
class="px-4 py-2 text-sm bg-slate-700 text-slate-300 rounded-lg hover:bg-slate-600"
>取消</button>
<!-- 差异对比模式的应用选中按钮 -->
<button
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'"
@click="applyPartialReplace"
:disabled="partialLeftSelected.size === 0 || partialRightSelected.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"
>应用选中 ({{ partialLeftSelected.size }} {{ partialRightSelected.size }})</button>
<button
@click="confirmRewrite"
:disabled="rewritingSuggestionIdx !== null || !rewritePreview"
class="px-4 py-2 text-sm bg-indigo-600 text-white rounded-lg hover:bg-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed"
>全部替换</button>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
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'
import MaterialSelectorModal from './MaterialSelectorModal.vue'
import DocumentSelectorModal from './DocumentSelectorModal.vue'
import ParadigmSelectorModal from './ParadigmSelectorModal.vue'
const appStore = useAppStore()
const dbStore = useDatabaseStore()
const { activeParadigm } = storeToRefs(appStore)
// 内容
const leftContent = ref('')
const rightContent = ref('')
// 左侧来源相关
const leftSourceType = ref('paste') // 'paste' | 'paradigm' | 'material' | 'document'
const showParadigmSelector = ref(false)
const showMaterialSelector = ref(false)
const showLeftDocSelector = ref(false)
const leftSourceDocId = ref(null) // 来源文稿ID
const leftSourceDocTitle = ref('') // 来源文稿标题
const leftOriginalContent = ref('') // 来源文稿原始内容(用于差异比对)
const isLeftSaving = 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)
const paragraphTypes = ref({}) // { [idx]: { sectionType, sectionLabel, confidence, userConfirmed } }
const showTypeDropdown = ref(null) // 当前显示下拉菜单的段落索引
// 选中状态(改为数组支持多选)
const selectedLeftIdxs = ref([])
const selectedRightIdxs = ref([])
// 检查状态
const isComparing = ref(false)
const lastCheckResult = ref(null)
const checkResults = ref({})
// 重写相关状态
const showDetailedSuggestions = ref(false) // 是否展开详细建议
const rewritingParagraphIdx = ref(null) // 正在重写的段落索引
const rewritingSuggestionIdx = ref(null) // 正在处理的建议索引
const rewritePreview = ref('') // 重写预览内容
const showRewriteModal = ref(false) // 是否显示重写预览浮窗
const currentSuggestion = ref('') // 当前处理的建议文本
const targetParagraphIdxs = ref([]) // 目标段落索引
// 差异对比相关状态
const rewriteViewMode = ref('result') // 'result' | 'diff' | 'review' - 视图模式
const diffSegments = ref([]) // 差异片段数组
const currentReviewIdx = ref(0) // 当前审核的片段索引
const acceptedChanges = ref(new Set()) // 已接受的修改索引集合
const originalContent = ref('') // 原始内容(用于对比)
// 部分替换相关状态
const partialLeftSelected = ref(new Set()) // 左侧选中的句子索引
const partialRightSelected = ref(new Set()) // 右侧选中的句子索引
const originalSentences = ref([]) // 原文句子列表
const rewrittenSentences = ref([]) // 重写后句子列表
// 范式相关
const showParadigmRules = ref(false)
const checkMode = ref('paragraph') // 'paragraph' | 'document'
const enabledRuleIdxs = ref(new Set()) // 已启用的规则索引集合
const hasParadigmRules = computed(() => {
const p = activeParadigm.value
return p && (p.expertGuidelines || p.systemConstraints || p.validationRules)
})
const activeParadigmName = computed(() => {
return activeParadigm.value?.name || ''
})
// 提取范式规则列表(根据 scope 过滤,添加索引用于勾选)
const getFilteredRules = (scope) => {
const paradigm = activeParadigm.value
if (!paradigm) return []
const rules = []
let idx = 0
if (paradigm.expertGuidelines) {
paradigm.expertGuidelines
.filter(g => !scope || g.scope === scope || !g.scope) // 无 scope 的规则默认都包含
.forEach(g => {
rules.push({
idx: idx++,
text: `${g.title}${g.description}`,
scope: g.scope || 'both'
})
})
}
// systemConstraints 默认为全文级
if (paradigm.systemConstraints && (scope === 'document' || !scope)) {
paradigm.systemConstraints.forEach(c => {
rules.push({ idx: idx++, text: c, scope: 'document' })
})
}
return rules
}
// 获取所有规则(带索引)
const allRulesWithIdx = computed(() => getFilteredRules(null))
// 初始化规则勾选状态(默认全部勾选)
const initializeRuleSelection = () => {
const rules = allRulesWithIdx.value
enabledRuleIdxs.value = new Set(rules.map(r => r.idx))
}
// 切换单个规则的勾选状态
const toggleRule = (idx) => {
if (enabledRuleIdxs.value.has(idx)) {
enabledRuleIdxs.value.delete(idx)
} else {
enabledRuleIdxs.value.add(idx)
}
enabledRuleIdxs.value = new Set(enabledRuleIdxs.value) // 触发响应式更新
}
// 全选/取消全选规则
const toggleAllRules = () => {
const allRules = allRulesWithIdx.value
if (enabledRuleIdxs.value.size === allRules.length) {
enabledRuleIdxs.value = new Set()
} else {
enabledRuleIdxs.value = new Set(allRules.map(r => r.idx))
}
}
// 监听范式变化,重新初始化勾选状态
watch(() => activeParadigm.value, () => {
initializeRuleSelection()
}, { immediate: true })
// 当前模式下的规则列表
const paradigmRulesList = computed(() => getFilteredRules(null).map(r => r.text))
const paragraphRules = computed(() => getFilteredRules('paragraph'))
const documentRules = computed(() => getFilteredRules('document'))
const paradigmRulesCount = computed(() => paradigmRulesList.value.length)
const enabledRulesCount = computed(() => enabledRuleIdxs.value.size)
const currentScopeRulesCount = computed(() => {
const rules = checkMode.value === 'paragraph' ? paragraphRules.value : documentRules.value
return rules.filter(r => enabledRuleIdxs.value.has(r.idx)).length
})
// 获取范式规则文本(根据当前检查模式过滤,只包含勾选的规则)
const getParadigmRulesText = () => {
const rules = checkMode.value === 'paragraph' ? paragraphRules.value : documentRules.value
const enabledRules = rules.filter(r => enabledRuleIdxs.value.has(r.idx))
if (enabledRules.length === 0) return ''
return enabledRules.map(r => r.text).join('\n')
}
// 解析段落
const leftParagraphs = computed(() => {
return leftContent.value
.split(/\n\s*\n/)
.map(p => p.trim())
.filter(p => p.length > 0)
})
const rightParagraphs = computed(() => {
return rightContent.value
.split(/\n\s*\n/)
.map(p => p.trim())
.filter(p => p.length > 0)
})
// 是否可以检查(两侧都至少选中一段)
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 hasLeftContentChanged = computed(() => {
if (leftSourceType.value !== 'document' || !leftSourceDocId.value) return false
return leftContent.value !== leftOriginalContent.value
})
// 选择段落(多选切换)
const selectLeftParagraph = (idx) => {
const i = selectedLeftIdxs.value.indexOf(idx)
if (i === -1) {
selectedLeftIdxs.value.push(idx)
} else {
selectedLeftIdxs.value.splice(i, 1)
}
}
const selectRightParagraph = (idx) => {
const i = selectedRightIdxs.value.indexOf(idx)
if (i === -1) {
selectedRightIdxs.value.push(idx)
} else {
selectedRightIdxs.value.splice(i, 1)
}
}
// 获取选中的段落文本(多选合并)
const getSelectedLeftText = () => {
return selectedLeftIdxs.value
.sort((a, b) => a - b)
.map(i => leftParagraphs.value[i])
.join('\n\n')
}
const getSelectedRightText = () => {
return selectedRightIdxs.value
.sort((a, b) => a - b)
.map(i => rightParagraphs.value[i])
.join('\n\n')
}
// 清除选择
const clearSelection = () => {
selectedLeftIdxs.value = []
selectedRightIdxs.value = []
lastCheckResult.value = null
}
// 返回写作页面
const goBack = () => {
appStore.switchPage('writer')
}
// 加载范式范文或规则
const handleParadigmSelect = (p) => {
appStore.activeParadigm = p
leftSourceType.value = 'paradigm'
// 填充内容:优先使用系统约束作为要求原文,如果没有则用范文内容
if (p.systemConstraints && p.systemConstraints.length > 0) {
leftContent.value = p.systemConstraints.join('\n\n')
} else if (p.defaultReference?.content) {
leftContent.value = p.defaultReference.content
} else {
leftContent.value = p.description || ''
}
showParadigmSelector.value = false
}
// 处理素材选择
const handleMaterialSelect = (material) => {
if (material.excerpts?.length > 0) {
leftContent.value = material.excerpts.map(e => e.content).join('\n\n')
}
leftSourceType.value = 'material'
showMaterialSelector.value = false
}
// 处理左侧文稿选择
const handleLeftDocSelect = (doc) => {
leftContent.value = doc.content || ''
leftSourceType.value = 'document'
leftSourceDocId.value = doc.id // 保存文稿ID
leftSourceDocTitle.value = doc.title // 保存文稿标题
leftOriginalContent.value = doc.content || '' // 保存原始内容用于差异比对
showLeftDocSelector.value = false
}
// 处理右侧文稿选择
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 saveLeftContent = async () => {
if (leftSourceType.value !== 'document' || !leftSourceDocId.value) {
alert('当前内容不是来自文稿库,无需保存')
return
}
if (!hasLeftContentChanged.value) {
alert('内容未修改,无需保存')
return
}
isLeftSaving.value = true
try {
// 计算差异用于生成版本说明
const changes = computeDiff(leftOriginalContent.value, leftContent.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(leftSourceDocId.value, leftContent.value, changeNote)
// 同时更新文稿主内容
updateDocument(leftSourceDocId.value, { content: leftContent.value })
// 更新原始内容为当前内容(标记为已保存)
leftOriginalContent.value = leftContent.value
console.log(`左侧文稿保存成功,版本号: ${versionNumber}${changeNote}`)
alert(`保存成功!新版本: v${versionNumber}\n${changeNote}`)
} catch (error) {
console.error('保存失败:', error)
alert('保存失败,请重试')
} finally {
isLeftSaving.value = false
}
}
// 切换段落类型下拉菜单
const toggleTypeDropdown = (idx) => {
showTypeDropdown.value = showTypeDropdown.value === idx ? null : idx
}
// 手动选择段落类型
const selectParagraphType = (idx, type) => {
paragraphTypes.value[idx] = {
...paragraphTypes.value[idx],
sectionType: type.id,
sectionLabel: type.label,
userConfirmed: true
}
showTypeDropdown.value = null
}
// 智能识别段落类型
const detectParagraphTypes = async () => {
if (rightParagraphs.value.length === 0) return
isDetectingTypes.value = true
try {
const prompt = buildParagraphTypeDetectionPrompt(rightParagraphs.value)
let result = ''
await appStore.callApi(prompt, (chunk) => {
result += chunk
}, { temperature: 0.3 })
// 解析结果
const jsonMatch = result.match(/\{[\s\S]*\}/)
if (jsonMatch) {
const parsed = JSON.parse(jsonMatch[0])
parsed.results?.forEach(r => {
paragraphTypes.value[r.paragraphIdx] = {
sectionType: r.sectionType,
sectionLabel: r.sectionLabel,
confidence: r.confidence || 0.8,
userConfirmed: false
}
})
}
} catch (error) {
console.error('段落类型识别失败:', error)
} finally {
isDetectingTypes.value = false
}
}
// 构建重写 Prompt
const buildRewritePrompt = (originalParagraph, suggestion, paragraphTypeInfo) => {
const paradigm = activeParadigm.value
let prompt = `你是一名专业的写作润色专家。请根据以下检查建议,重写这段内容。
# 原文内容
${originalParagraph}
# 检查建议
${suggestion}
# 重写要求
1. 保持原文的核心观点和信息不变
2. 针对检查建议进行针对性改进
3. 保持与上下文的连贯性
`
// 注入段落类型对应的逻辑范式
if (paragraphTypeInfo?.sectionType && paradigm) {
const sectionType = getSectionTypeById(paragraphTypeInfo.sectionType)
if (sectionType?.logicKey && paradigm.logicParadigms?.[sectionType.logicKey]) {
const logicId = paradigm.logicParadigms[sectionType.logicKey]
const logic = getLogicParadigmById(logicId)
if (logic) {
prompt += `
# 写作规范(${logic.name}
**结构公式**${logic.structureFormula}
**必须遵循的层次**
${logic.layers?.map((l, i) => `${i + 1}. ${l.name}${l.question}`).join('\n') || ''}
`
if (logic.languageStyle?.vocabulary) {
prompt += `**推荐术语**${logic.languageStyle.vocabulary.slice(0, 8).join('、')}\n`
}
}
}
}
prompt += `
# 输出要求
请直接输出重写后的段落内容,不要包含任何解释或前缀。`
return prompt
}
// 执行重写(显示浮窗)
const executeRewrite = async (suggestionIdx, suggestion) => {
if (selectedRightIdxs.value.length === 0) {
alert('请先选中要重写的段落')
return
}
// 保存目标信息
targetParagraphIdxs.value = [...selectedRightIdxs.value].sort((a, b) => a - b)
currentSuggestion.value = suggestion
// 保存原始内容到状态变量(用于差异对比)
originalContent.value = targetParagraphIdxs.value.map(i => rightParagraphs.value[i]).join('\n\n')
const paragraphTypeInfo = paragraphTypes.value[targetParagraphIdxs.value[0]]
// 重置差异相关状态
rewriteViewMode.value = 'result'
diffSegments.value = []
currentReviewIdx.value = 0
acceptedChanges.value = new Set()
// 显示浮窗
showRewriteModal.value = true
rewritingSuggestionIdx.value = suggestionIdx
rewritePreview.value = ''
try {
const prompt = buildRewritePrompt(originalContent.value, suggestion, paragraphTypeInfo)
// 用于过滤 <thinking> 内容的缓冲区
let rawBuffer = ''
await appStore.callApi(prompt, (chunk) => {
rawBuffer += chunk
// 过滤掉 <thinking> 标签内的内容
// 1. 如果还在 thinking 阶段,不显示
// 2. 如果 thinking 结束了,只显示 </thinking> 之后的内容
// 检查是否有完整的 </thinking> 标签
const thinkingEndIdx = rawBuffer.indexOf('</thinking>')
if (thinkingEndIdx !== -1) {
// thinking 已结束,显示 </thinking> 之后的内容
const afterThinking = rawBuffer.slice(thinkingEndIdx + '</thinking>'.length).trim()
rewritePreview.value = afterThinking
} else if (!rawBuffer.includes('<thinking>')) {
// 没有 thinking 标签,直接显示(可能 AI 没有使用结构化输出)
rewritePreview.value = rawBuffer
}
// 如果有 <thinking> 但还没有 </thinking>,暂时不显示,等待完成
}, { temperature: 0.5 })
// 流式完成后,最终清理结果
let finalContent = rawBuffer
// 移除 <thinking>...</thinking> 内容
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
// 重写完成后自动计算差异
computeDiffSegments()
} catch (error) {
console.error('重写失败:', error)
alert('重写失败,请重试')
showRewriteModal.value = false
} finally {
rewritingSuggestionIdx.value = null
}
}
// 更新右侧段落内容
const updateRightContent = (targetIdxs, newContent) => {
const paragraphs = rightContent.value.split(/\n\s*\n/).filter(p => p.trim())
const firstIdx = targetIdxs[0]
// 替换目标段落
paragraphs.splice(firstIdx, targetIdxs.length, newContent.trim())
// 更新 rightContent
rightContent.value = paragraphs.join('\n\n')
// 清除被替换段落的检查结果和类型信息
targetIdxs.forEach(idx => {
delete checkResults.value[idx]
delete paragraphTypes.value[idx]
})
// 清除选中状态
selectedRightIdxs.value = []
}
// 确认重写(替换内容)
const confirmRewrite = () => {
if (!rewritePreview.value || targetParagraphIdxs.value.length === 0) return
// 执行替换
updateRightContent(targetParagraphIdxs.value, rewritePreview.value)
// 关闭浮窗
showRewriteModal.value = false
rewritePreview.value = ''
currentSuggestion.value = ''
targetParagraphIdxs.value = []
}
// 计算差异片段
const computeDiffSegments = () => {
if (!originalContent.value || !rewritePreview.value) return
diffSegments.value = computeDiff(originalContent.value, rewritePreview.value)
// 默认全部接受修改
acceptedChanges.value = new Set(
diffSegments.value
.filter(s => s.type !== 'unchanged')
.map(s => s.idx)
)
}
// 获取差异统计信息
const diffStats = computed(() => {
return getDiffStats(diffSegments.value)
})
// 获取需要审核的修改(排除 unchanged
const reviewableChanges = computed(() => {
return diffSegments.value.filter(s => s.type !== 'unchanged')
})
// 当前审核的修改项
const currentReviewItem = computed(() => {
return reviewableChanges.value[currentReviewIdx.value] || null
})
// 切换修改的接受状态
const toggleChangeAccepted = (idx) => {
if (acceptedChanges.value.has(idx)) {
acceptedChanges.value.delete(idx)
} else {
acceptedChanges.value.add(idx)
}
// 触发响应式更新
acceptedChanges.value = new Set(acceptedChanges.value)
}
// 全选/取消全选修改
const toggleAllChanges = (accept) => {
if (accept) {
acceptedChanges.value = new Set(
diffSegments.value
.filter(s => s.type !== 'unchanged')
.map(s => s.idx)
)
} else {
acceptedChanges.value = new Set()
}
}
// 逐句审核:下一个
const nextReviewItem = () => {
if (currentReviewIdx.value < reviewableChanges.value.length - 1) {
currentReviewIdx.value++
}
}
// 逐句审核:上一个
const prevReviewItem = () => {
if (currentReviewIdx.value > 0) {
currentReviewIdx.value--
}
}
// 应用选中的修改
const applySelectedChangesToContent = () => {
if (diffSegments.value.length === 0) return
// 如果没有选中任何修改,提示用户并返回
if (acceptedChanges.value.size === 0) {
alert('请先选中需要应用的修改')
return
}
const finalContent = applyDiffChanges(
originalContent.value,
diffSegments.value,
acceptedChanges.value
)
// 防止结果为空(可能是差异计算的边界情况)
if (!finalContent || finalContent.trim() === '') {
console.error('applySelectedChanges 返回空结果,保持原内容不变')
alert('应用修改失败,请尝试使用"全部替换"功能')
return
}
// 执行替换
updateRightContent(targetParagraphIdxs.value, finalContent)
// 关闭浮窗并重置状态
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
rewritePreview.value = ''
currentSuggestion.value = ''
targetParagraphIdxs.value = []
rewriteViewMode.value = 'result'
diffSegments.value = []
currentReviewIdx.value = 0
acceptedChanges.value = new Set()
originalContent.value = ''
// 重置部分替换状态
partialLeftSelected.value = new Set()
partialRightSelected.value = new Set()
originalSentences.value = []
rewrittenSentences.value = []
}
// 切换到部分替换视图模式
const switchToPartialReplace = () => {
rewriteViewMode.value = 'partial'
// 初始化句子列表
originalSentences.value = splitIntoSentencesWithPosition(originalContent.value)
rewrittenSentences.value = splitIntoSentencesWithPosition(rewritePreview.value)
// 重置选择状态
partialLeftSelected.value = new Set()
partialRightSelected.value = new Set()
}
// 切换左侧句子选择状态
const togglePartialLeftSentence = (idx) => {
if (partialLeftSelected.value.has(idx)) {
partialLeftSelected.value.delete(idx)
} else {
partialLeftSelected.value.add(idx)
}
partialLeftSelected.value = new Set(partialLeftSelected.value)
}
// 切换右侧句子选择状态
const togglePartialRightSentence = (idx) => {
if (partialRightSelected.value.has(idx)) {
partialRightSelected.value.delete(idx)
} else {
partialRightSelected.value.add(idx)
}
partialRightSelected.value = new Set(partialRightSelected.value)
}
// 应用部分替换
const applyPartialReplace = () => {
// 验证必须选中内容
if (partialLeftSelected.value.size === 0) {
alert('请在左侧选中需要被替换的句子')
return
}
if (partialRightSelected.value.size === 0) {
alert('请在右侧选中用于替换的句子')
return
}
// 获取右侧选中的句子文本(按顺序拼接)
const sortedRightIdxs = [...partialRightSelected.value].sort((a, b) => a - b)
const replacementText = sortedRightIdxs.map(idx => rewrittenSentences.value[idx].text).join('')
// 构建最终内容:未选中的原文句子保留,选中的部分用右侧替换
const sortedLeftIdxs = [...partialLeftSelected.value].sort((a, b) => a - b)
const firstSelectedIdx = sortedLeftIdxs[0]
const lastSelectedIdx = sortedLeftIdxs[sortedLeftIdxs.length - 1]
let finalContent = ''
// 添加第一个选中句子之前的内容
if (firstSelectedIdx > 0) {
const firstSentence = originalSentences.value[firstSelectedIdx]
finalContent += originalContent.value.slice(0, firstSentence.start)
}
// 添加替换内容
finalContent += replacementText
// 添加最后一个选中句子之后的内容
if (lastSelectedIdx < originalSentences.value.length - 1) {
const lastSentence = originalSentences.value[lastSelectedIdx]
finalContent += originalContent.value.slice(lastSentence.end)
}
// 添加选中句子之间的未选中内容(如果有)
// 重新计算:遍历所有句子,未选中的保留,选中的用替换内容
let result = ''
let replacementInserted = false
for (let i = 0; i < originalSentences.value.length; i++) {
const sentence = originalSentences.value[i]
if (partialLeftSelected.value.has(i)) {
// 选中的句子:在第一个选中位置插入替换内容
if (!replacementInserted) {
result += replacementText
replacementInserted = true
}
// 其他选中的句子跳过(已被替换)
} else {
// 未选中的句子:保留原文
result += sentence.text
}
}
// 执行替换
updateRightContent(targetParagraphIdxs.value, result)
// 关闭浮窗并重置状态
resetRewriteModal()
}
// 构建段落类型识别 Prompt
const buildParagraphTypeDetectionPrompt = (paragraphs) => {
return `你是一名专业的公文写作分析专家。请分析以下段落,识别每个段落属于哪种章节类型。
# 可识别的章节类型
1. **problem** (存在问题):问题查摆、不足之处,通常采用"定性判断+具体表现+后果"结构
2. **analysis** (原因剖析):原因分析、根源探究,通常从思想/政治/作风/能力/纪律等维度溯源
3. **remediation** (整改措施):整改方案、改进措施,通常包含具体行动和量化指标
4. **case** (典型案例):案例剖析,通常采用"以案说德/纪/法/责"结构
5. **intro** (开篇引言):背景介绍、会议说明等
6. **conclusion** (结尾表态):表态发言、决心表述等
# 待分析的段落
${paragraphs.map((p, i) => `【段落${i}\n${p.substring(0, 500)}${p.length > 500 ? '...' : ''}`).join('\n\n')}
# 输出要求
请严格按照以下 JSON 格式输出(不要输出其他内容):
{
"results": [
{"paragraphIdx": 0, "sectionType": "problem", "sectionLabel": "存在问题", "confidence": 0.85}
]
}`
}
// 构建段落级精确检查 Prompt
const buildParagraphCheckPrompt = (requirement, selectedParagraphs) => {
const paradigm = activeParadigm.value
let prompt = `你是一个严格的写作质检专家。请根据以下检查标准,对写作内容进行精确检查。
# 对照依据
## 一、要求原文
${requirement}
`
// 根据段落类型注入对应的逻辑范式规则
const typeGroups = {}
selectedParagraphs.forEach(p => {
const type = p.typeInfo?.sectionType || 'unknown'
if (!typeGroups[type]) typeGroups[type] = []
typeGroups[type].push(p)
})
// 为每种类型注入对应的逻辑范式
if (paradigm && Object.keys(typeGroups).length > 0) {
prompt += `\n## 二、段落级写作规范\n`
for (const [type, paragraphs] of Object.entries(typeGroups)) {
const sectionType = getSectionTypeById(type)
if (!sectionType) continue
prompt += `\n### ${sectionType.label}类段落(共${paragraphs.length}段)\n`
// 获取对应的逻辑范式
const logicKey = sectionType.logicKey
if (logicKey && paradigm.logicParadigms?.[logicKey]) {
const logicId = paradigm.logicParadigms[logicKey]
const logic = getLogicParadigmById(logicId)
if (logic) {
prompt += `**适用逻辑范式**${logic.name}\n`
prompt += `**结构公式**${logic.structureFormula}\n`
prompt += `**必须遵循的层次**\n`
logic.layers?.forEach((l, i) => {
prompt += `${i + 1}. ${l.name}${l.question}\n`
})
if (logic.languageStyle?.vocabulary) {
prompt += `**推荐术语**${logic.languageStyle.vocabulary.slice(0, 5).join('、')}\n`
}
}
}
}
}
// 注入段落级专家规则
const paragraphRules = paradigm?.expertGuidelines?.filter(g => g.scope === 'paragraph') || []
if (paragraphRules.length > 0) {
prompt += `\n## 三、专家评价标准(段落级)\n`
paragraphRules.forEach((r, i) => {
prompt += `${i + 1}. 【${r.title}${r.description}\n`
})
}
// 添加待检查内容
prompt += `\n# 待检查的写作内容\n`
selectedParagraphs.forEach(p => {
const typeLabel = p.typeInfo?.sectionLabel || '未识别'
prompt += `\n【段落${p.idx + 1}】(${typeLabel})\n${p.content}\n`
})
prompt += `
# 输出要求
请严格按照以下 JSON 格式输出检查结果(不要输出其他内容):
{
"overall": "pass|warning|fail",
"summary": "一句话总结检查结果",
"details": [
{"aspect": "检查维度", "status": "pass|warning|fail", "message": "具体说明"}
],
"suggestions": ["改进建议1", "改进建议2"]
}`
return prompt
}
// 执行对照检查
const runCompare = async () => {
if (!canCompare.value) return
const requirement = getSelectedLeftText()
isComparing.value = true
lastCheckResult.value = null
try {
// 获取选中的右侧段落信息
const selectedParagraphs = selectedRightIdxs.value.map(idx => ({
idx,
content: rightParagraphs.value[idx],
typeInfo: paragraphTypes.value[idx] || null
}))
// 构建段落级精确检查 Prompt
const prompt = buildParagraphCheckPrompt(requirement, selectedParagraphs)
let result = ''
await appStore.callApi(prompt, (chunk) => {
result += chunk
}, { temperature: 0.3 })
// 解析结果
const jsonMatch = result.match(/\{[\s\S]*\}/)
if (jsonMatch) {
const parsed = JSON.parse(jsonMatch[0])
lastCheckResult.value = parsed
// 保存到所有选中段落的检查结果
selectedRightIdxs.value.forEach(idx => {
checkResults.value[idx] = {
status: parsed.overall,
message: parsed.summary
}
})
}
} catch (error) {
console.error('对照检查失败:', error)
lastCheckResult.value = {
overall: 'warning',
summary: '检查过程出现异常,请重试',
details: [],
suggestions: []
}
} finally {
isComparing.value = false
}
}
</script>
<style scoped>
.writing-mode-vertical {
writing-mode: vertical-rl;
text-orientation: mixed;
}
</style>