- 新增范式选择模态框 (ParadigmSelectorModal) - 新增差异标注面板 (DiffAnnotationPanel) - 新增精确差异处理工具 (preciseDiff.js) - 更新各面板组件支持范式润色功能 - 更新范式配置文件 (paradigms.js) - 更新依赖配置 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1768 lines
71 KiB
Vue
1768 lines
71 KiB
Vue
<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>
|