feat: 添加文稿管理、素材库、设置页面及对照检查重写功能

- 新增 DocumentsPanel.vue 文稿管理页面
- 新增 MaterialsPanel.vue 素材库管理页面
- 新增 SettingsPanel.vue 设置页面
- 新增 DocumentSelectorModal.vue 文稿选择弹窗
- 新增 MaterialSelectorModal.vue 素材选择弹窗
- 集成 SQLite 数据库持久化 (sql.js)
- 对照检查页面支持从文稿库选取内容
- 对照检查页面新增一键重写及差异对比功能
- 修复对照检查页面布局问题
- MainContent 支持文稿编辑功能
This commit is contained in:
empty
2026-01-09 00:21:52 +08:00
parent a0faaf4157
commit 1a1d7dabdf
23 changed files with 5808 additions and 64 deletions

View File

@@ -58,15 +58,42 @@
</div>
<!-- 主体内容区 -->
<div class="flex-1 flex min-h-0">
<div class="flex-1 flex overflow-hidden">
<!-- 左侧要求原文 -->
<div class="flex-1 flex flex-col border-r border-slate-700">
<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>
<span class="text-xs text-slate-500">{{ leftParagraphs.length }} </span>
</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
v-if="activeParadigm?.defaultReference"
@click="loadParadigmReference"
: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">
@@ -121,13 +148,29 @@
</div>
<!-- 右侧写作内容 -->
<div class="flex-1 flex flex-col">
<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>
<span class="text-xs text-slate-500">{{ rightParagraphs.length }} </span>
</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">
@@ -147,12 +190,41 @@
]"
>
<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',
<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="[
@@ -201,6 +273,41 @@
</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">
@@ -209,13 +316,23 @@
<!-- 操作按钮 -->
<div class="flex gap-2 shrink-0">
<button
<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
<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"
@@ -229,21 +346,290 @@
</div>
</div>
</footer>
<!-- 素材选择弹窗 -->
<MaterialSelectorModal
:visible="showMaterialSelector"
@close="showMaterialSelector = false"
@select="handleMaterialSelect"
/>
<!-- 文稿选择弹窗左侧 -->
<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>
</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>
<!-- 浮窗底部操作 -->
<div class="p-4 border-t border-slate-700 flex items-center justify-between">
<!-- 左侧全选/取消全选 -->
<div v-if="rewritingSuggestionIdx === null && 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></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="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
@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 } from 'vue'
import { ref, computed, watch } from 'vue'
import { storeToRefs } from 'pinia'
import { useAppStore } from '../stores/app'
import { useDatabaseStore } from '../stores/database'
import { SECTION_TYPES, getSectionTypeById, getSectionTypeClasses } from '../config/sectionTypes'
import { getLogicParadigmById, buildLogicPrompt } from '../config/logicParadigms'
import { computeDiff, applySelectedChanges as applyDiffChanges, getDiffStats } from '../utils/textDiff'
import MaterialSelectorModal from './MaterialSelectorModal.vue'
import DocumentSelectorModal from './DocumentSelectorModal.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 showMaterialSelector = ref(false)
const showLeftDocSelector = ref(false)
// 右侧来源相关
const rightSourceType = ref('paste') // 'paste' | 'document'
const showRightDocSelector = ref(false)
// 段落类型识别相关
const isDetectingTypes = ref(false)
const paragraphTypes = ref({}) // { [idx]: { sectionType, sectionLabel, confidence, userConfirmed } }
const showTypeDropdown = ref(null) // 当前显示下拉菜单的段落索引
// 选中状态(改为数组支持多选)
const selectedLeftIdxs = ref([])
const selectedRightIdxs = ref([])
@@ -253,6 +639,22 @@ 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 showParadigmRules = ref(false)
const checkMode = ref('paragraph') // 'paragraph' | 'document'
@@ -374,64 +776,439 @@ const goBack = () => {
appStore.switchPage('writer')
}
// 执行对照检查
const runCompare = async () => {
if (!canCompare.value) return
const requirement = getSelectedLeftText()
const content = getSelectedRightText()
const paradigmRules = getParadigmRulesText()
isComparing.value = true
lastCheckResult.value = null
// 加载范式默认参考范文
const loadParadigmReference = () => {
if (activeParadigm.value?.defaultReference) {
leftContent.value = activeParadigm.value.defaultReference.content
leftSourceType.value = 'paradigm'
}
}
// 处理素材选择
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'
showLeftDocSelector.value = false
}
// 处理右侧文稿选择
const handleRightDocSelect = (doc) => {
rightContent.value = doc.content || ''
rightSourceType.value = 'document'
showRightDocSelector.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 {
// 构建包含要求原文 + 范式规则的完整对照依据
let prompt = `你是一个严格的写作质检专家。请对比以下"对照依据"和"写作内容",判断内容是否符合要求。
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)
await appStore.callApi(prompt, (chunk) => {
rewritePreview.value += chunk
}, { temperature: 0.5 })
// 重写完成后自动计算差异
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
const finalContent = applyDiffChanges(
originalContent.value,
diffSegments.value,
acceptedChanges.value
)
// 执行替换
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 = ''
}
// 构建段落类型识别 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 = `你是一个严格的写作质检专家。请根据以下检查标准,对写作内容进行精确检查。
# 对照依据
## 一、要求原文(共 ${selectedLeftIdxs.value.length} 段)
## 一、要求原文
${requirement}
`
// 如果有范式规则,追加到对照依据中
if (paradigmRules) {
prompt += `
## 二、写作范式专家规则(共 ${paradigmRulesCount.value} 条)
${paradigmRules}
`
// 根据段落类型注入对应的逻辑范式规则
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`
}
}
}
}
}
prompt += `
# 写作内容(共 ${selectedRightIdxs.value.length} 段)
${content}
// 注入段落级专家规则
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`
})
}
# 检查说明
请综合"要求原文"${paradigmRules ? '和"写作范式专家规则"' : ''},对写作内容进行全面检查。
// 添加待检查内容
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": "具体说明"}
{"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] = {