feat: 新增范式润色功能页面

- 新增 ArticleRewritePanel.vue 组件
- 支持从文稿库选择文章或粘贴输入
- 支持句子多选(连续文本流展示)
- 支持基于范式 Prompt 进行 AI 检查
- 支持 AI 重写及选择性替换
- 新增全选/反选按钮
- 新增保存功能(创建版本历史)
- 新增复检功能(对重写内容再次检查)
- 在 GlobalSidebar.vue 添加导航入口
- 在 App.vue 添加路由渲染
This commit is contained in:
empty
2026-01-10 00:16:51 +08:00
parent e153e10d48
commit 56fda55709
3 changed files with 879 additions and 28 deletions

View File

@@ -1,11 +1,22 @@
<template>
<div class="flex h-full">
<div class="flex h-screen w-full bg-slate-950 overflow-hidden">
<!-- 全局导航侧边栏 -->
<GlobalSidebar v-if="currentPage !== 'compare' && currentPage !== 'diffAnnotation' && currentPage !== 'rewrite'" />
<!-- 主体区域 -->
<main class="flex-1 flex overflow-hidden relative">
<!-- 对照检查页面全屏独占 -->
<ComparePanel v-if="currentPage === 'compare'" />
<!-- 常规布局 -->
<!-- 差异标注页面全屏独占 -->
<DiffAnnotationPanel v-else-if="currentPage === 'diffAnnotation'" />
<!-- 范式润色页面全屏独占 -->
<ArticleRewritePanel v-else-if="currentPage === 'rewrite'" />
<!-- 持久化布局面板 -->
<template v-else>
<!-- 左侧面板 -->
<!-- 左侧/中间配置侧边栏 -->
<WriterPanel v-if="currentPage === 'writer'" />
<AnalysisPanel v-else-if="currentPage === 'analysis'" />
<DocumentsPanel
@@ -16,10 +27,10 @@
<MaterialsPanel v-else-if="currentPage === 'materials'" />
<SettingsPanel v-else-if="currentPage === 'settings'" />
<!-- 右侧内容区 -->
<!-- 右侧核心内容区 -->
<MainContent />
<!-- 版本历史面板仅文稿管理页面显示 -->
<!-- 侧滑浮层面板 (仅文稿页) -->
<DocumentVersionPanel
v-if="currentPage === 'documents'"
:visible="showVersionPanel"
@@ -29,12 +40,14 @@
@restore="handleVersionRestore"
/>
</template>
</main>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
import { useAppStore } from './stores/app'
import GlobalSidebar from './components/GlobalSidebar.vue'
import WriterPanel from './components/WriterPanel.vue'
import AnalysisPanel from './components/AnalysisPanel.vue'
import DocumentsPanel from './components/DocumentsPanel.vue'
@@ -43,6 +56,8 @@ import SettingsPanel from './components/SettingsPanel.vue'
import MainContent from './components/MainContent.vue'
import ComparePanel from './components/ComparePanel.vue'
import DocumentVersionPanel from './components/DocumentVersionPanel.vue'
import DiffAnnotationPanel from './components/DiffAnnotationPanel.vue'
import ArticleRewritePanel from './components/ArticleRewritePanel.vue'
const appStore = useAppStore()
const currentPage = computed(() => appStore.currentPage)
@@ -72,3 +87,4 @@ const handleVersionRestore = (content) => {
}
}
</script>

View File

@@ -0,0 +1,755 @@
<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 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 v-if="selectedParadigm" class="text-white text-sm font-medium flex items-center gap-1">
{{ selectedParadigm.icon }} {{ selectedParadigm.name }}
</span>
<span v-else class="text-slate-400 text-sm">未选择</span>
</div>
<button
@click="showParadigmSelector = true"
class="text-xs px-3 py-1 rounded bg-indigo-600 text-white hover:bg-indigo-500 transition"
>
{{ selectedParadigm ? '切换范式' : '选择范式' }}
</button>
</div>
<!-- 主体内容区三栏布局 -->
<div class="flex-1 flex overflow-hidden">
<!-- 左侧原文区 -->
<div class="w-[35%] 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">{{ sentences.length }} </span>
<span v-if="selectedSentenceIdxs.length > 0" class="text-xs text-amber-400">
已选 {{ selectedSentenceIdxs.length }}
</span>
<!-- 全选/反选按钮 -->
<div v-if="sentences.length > 0" class="flex gap-1 ml-2">
<button
@click="selectAllOriginal"
class="text-xs px-2 py-0.5 rounded bg-amber-700/50 text-amber-200 hover:bg-amber-600/50 transition"
>全选</button>
<button
@click="invertOriginalSelection"
class="text-xs px-2 py-0.5 rounded bg-slate-700 text-slate-300 hover:bg-slate-600 transition"
>反选</button>
</div>
<!-- 保存按钮 -->
<button
v-if="articleSourceType === 'document' && sourceDocId && hasContentChanged"
@click="saveToDocument"
:disabled="isSaving"
class="text-xs px-2 py-0.5 rounded bg-green-600 text-white hover:bg-green-500 transition flex items-center gap-1 disabled:opacity-50"
>
<span v-if="isSaving" class="animate-spin"></span>
<span>💾 {{ isSaving ? '保存中...' : '保存' }}</span>
</button>
</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="articleSourceType = 'paste'"
:class="['text-xs px-2 py-0.5 rounded transition',
articleSourceType === 'paste' ? 'bg-amber-600 text-white' : 'text-slate-400 hover:text-white']"
>粘贴</button>
<button
@click="showDocSelector = true"
:class="['text-xs px-2 py-0.5 rounded transition',
articleSourceType === 'document' ? 'bg-amber-600 text-white' : 'text-slate-400 hover:text-white']"
>文稿库</button>
</div>
<span v-if="articleSourceType === 'document' && sourceDocTitle" class="text-xs text-amber-400 ml-2">
📄 {{ sourceDocTitle }}
</span>
</div>
<!-- 连续文本流 -->
<div class="flex-1 overflow-y-auto p-4 min-h-0">
<div v-if="sentences.length === 0" 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>
<!-- 连续文本展示句子用 inline span -->
<div v-else class="bg-slate-800/30 rounded-lg p-4 border border-slate-700 leading-relaxed">
<template v-for="(sentence, idx) in sentences" :key="'s-' + idx">
<span
@click="toggleSentence(idx)"
:class="[
'cursor-pointer transition-all rounded px-0.5 py-0.5 hover:bg-amber-900/30',
selectedSentenceIdxs.includes(idx)
? 'bg-amber-500/40 text-amber-100 ring-1 ring-amber-400'
: 'text-slate-300 hover:text-amber-200'
]"
>{{ sentence.text }}</span>
</template>
</div>
<!-- 提示文字 -->
<p v-if="sentences.length > 0" class="text-xs text-slate-500 mt-3 text-center">
💡 点击句子可选中/取消选中的句子将被检查
</p>
</div>
<!-- 输入框 -->
<div class="p-3 border-t border-slate-700 bg-slate-800">
<textarea
v-model="articleContent"
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="粘贴或输入文章内容..."
@input="splitToSentences"
></textarea>
</div>
</div>
<!-- 中间检查结果 -->
<div class="w-[30%] flex flex-col border-r border-slate-700 min-w-0">
<div class="p-3 bg-slate-800 border-b border-slate-700">
<h2 class="text-sm font-medium text-blue-400 flex items-center gap-2">
🔍 检查结果
</h2>
</div>
<div class="flex-1 overflow-y-auto p-4 space-y-3 min-h-0">
<div v-if="!isChecking && checkResults.length === 0" 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-if="isChecking" class="flex items-center gap-2 text-blue-300">
<span class="animate-spin"></span>
<span class="text-sm">AI 正在检查中...</span>
</div>
<!-- 检查结果列表 -->
<div
v-for="(result, idx) in checkResults"
:key="'check-' + idx"
:class="[
'p-3 rounded-lg border',
result.status === 'pass' ? 'bg-green-900/20 border-green-700/50' :
result.status === 'warning' ? 'bg-yellow-900/20 border-yellow-700/50' :
'bg-red-900/20 border-red-700/50'
]"
>
<div class="flex items-start gap-2">
<span class="text-lg">
{{ result.status === 'pass' ? '✅' : result.status === 'warning' ? '⚠️' : '❌' }}
</span>
<div class="flex-1 min-w-0">
<p class="text-xs text-slate-400 mb-1">句子 {{ result.sentenceIdx + 1 }}</p>
<p class="text-sm text-slate-200">{{ result.message }}</p>
</div>
</div>
</div>
</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-emerald-400 flex items-center gap-2">
重写预览
</h2>
<div class="flex items-center gap-2">
<span class="text-xs text-slate-500">{{ rewrittenSentences.length }} </span>
<span v-if="selectedRewriteIdxs.length > 0" class="text-xs text-emerald-400">
已选 {{ selectedRewriteIdxs.length }}
</span>
<!-- 全选/反选按钮 -->
<div v-if="rewrittenSentences.length > 0" class="flex gap-1 ml-2">
<button
@click="selectAllRewrite"
class="text-xs px-2 py-0.5 rounded bg-emerald-700/50 text-emerald-200 hover:bg-emerald-600/50 transition"
>全选</button>
<button
@click="invertRewriteSelection"
class="text-xs px-2 py-0.5 rounded bg-slate-700 text-slate-300 hover:bg-slate-600 transition"
>反选</button>
<!-- 复检按钮对重写内容再次检查 -->
<button
@click="recheckRewrittenContent"
:disabled="selectedRewriteIdxs.length === 0 || isChecking"
class="text-xs px-2 py-0.5 rounded bg-purple-600 text-white hover:bg-purple-500 transition disabled:opacity-50"
title="对选中的重写内容再次进行范式检查"
>🔄 复检</button>
</div>
</div>
</div>
<div class="flex-1 overflow-y-auto p-4 min-h-0">
<div v-if="!isRewriting && rewrittenSentences.length === 0" 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">检查完成后点击AI 重写</p>
</div>
</div>
<!-- 重写中状态 -->
<div v-if="isRewriting" class="flex flex-col gap-2">
<div class="flex items-center gap-2 text-emerald-300">
<span class="animate-spin"></span>
<span class="text-sm">AI 正在重写中...</span>
</div>
<div v-if="rewriteStreamContent" class="bg-slate-800/50 rounded-lg p-3 border border-slate-700">
<p class="text-sm text-slate-200 whitespace-pre-wrap">{{ rewriteStreamContent }}</p>
</div>
</div>
<!-- 连续文本展示句子用 inline span -->
<div v-else-if="rewrittenSentences.length > 0" class="bg-slate-800/30 rounded-lg p-4 border border-slate-700 leading-relaxed">
<template v-for="(sentence, idx) in rewrittenSentences" :key="'rw-' + idx">
<span
@click="toggleRewriteSentence(idx)"
:class="[
'cursor-pointer transition-all rounded px-0.5 py-0.5 hover:bg-emerald-900/30',
selectedRewriteIdxs.includes(idx)
? 'bg-emerald-500/40 text-emerald-100 ring-1 ring-emerald-400'
: 'text-slate-200 hover:text-emerald-200'
]"
>{{ sentence.text }}</span>
</template>
</div>
<!-- 提示文字 -->
<p v-if="rewrittenSentences.length > 0" class="text-xs text-slate-500 mt-3 text-center">
💡 点击句子可选中/取消选中的句子将替换原文
</p>
</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 class="flex-1 text-sm text-slate-500">
<span v-if="selectedSentenceIdxs.length === 0">请先在左侧选择要检查的句子</span>
<span v-else-if="!selectedParadigm">请选择一个写作范式</span>
<span v-else-if="checkResults.length === 0">准备就绪点击范式检查开始</span>
<span v-else-if="rewrittenSentences.length === 0">检查完成可点击AI 重写</span>
<span v-else>选择满意的重写句子点击应用替换</span>
</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="runParadigmCheck"
:disabled="!canCheck || isChecking"
class="px-4 py-2 rounded-lg text-sm font-medium text-white transition disabled:opacity-50 disabled:cursor-not-allowed bg-blue-600 hover:bg-blue-500"
>
<span v-if="isChecking" class="flex items-center gap-2">
<span class="animate-spin"></span> 检查中...
</span>
<span v-else>🔍 范式检查</span>
</button>
<button
@click="runAIRewrite"
:disabled="!canRewrite || isRewriting"
class="px-4 py-2 rounded-lg text-sm font-medium text-white transition disabled:opacity-50 disabled:cursor-not-allowed bg-purple-600 hover:bg-purple-500"
>
<span v-if="isRewriting" class="flex items-center gap-2">
<span class="animate-spin"></span> 重写中...
</span>
<span v-else> AI 重写</span>
</button>
<button
@click="applySelectedRewrites"
:disabled="!canApply"
class="px-4 py-2 rounded-lg text-sm font-medium text-white transition disabled:opacity-50 disabled:cursor-not-allowed bg-gradient-to-r from-amber-600 to-emerald-600 hover:from-amber-500 hover:to-emerald-500"
>
🔄 应用替换
</button>
</div>
</div>
</footer>
<!-- 范式选择弹窗 -->
<ParadigmSelectorModal
:visible="showParadigmSelector"
@close="showParadigmSelector = false"
@select="handleParadigmSelect"
/>
<!-- 文稿选择弹窗 -->
<DocumentSelectorModal
:visible="showDocSelector"
@close="showDocSelector = false"
@select="handleDocSelect"
/>
</div>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
import { useAppStore } from '../stores/app'
import { splitIntoSentencesWithPosition, computeDiff } from '../utils/textDiff'
import { updateDocument, saveDocumentVersion } from '../db/index.js'
import ParadigmSelectorModal from './ParadigmSelectorModal.vue'
import DocumentSelectorModal from './DocumentSelectorModal.vue'
const appStore = useAppStore()
// ========== 数据状态 ==========
// 原文相关
const articleContent = ref('')
const articleSourceType = ref('paste') // 'paste' | 'document'
const sourceDocId = ref(null)
const sourceDocTitle = ref('')
const sentences = ref([]) // 拆分后的句子 {text, start, end}
const selectedSentenceIdxs = ref([])
// 范式相关
const selectedParadigm = ref(null)
const showParadigmSelector = ref(false)
// 文稿选择
const showDocSelector = ref(false)
// 检查相关
const isChecking = ref(false)
const checkResults = ref([]) // {sentenceIdx, status, message}
// 重写相关
const isRewriting = ref(false)
const rewriteStreamContent = ref('')
const rewrittenSentences = ref([]) // {text, start, end}
const selectedRewriteIdxs = ref([])
// 保存相关
const isSaving = ref(false)
const originalContent = ref('') // 初始内容,用于判断是否有更改
// ========== 计算属性 ==========
const canCheck = computed(() => {
return selectedSentenceIdxs.value.length > 0 && selectedParadigm.value !== null
})
const canRewrite = computed(() => {
return checkResults.value.length > 0 && selectedParadigm.value !== null
})
const canApply = computed(() => {
return selectedRewriteIdxs.value.length > 0 && selectedSentenceIdxs.value.length > 0
})
// 判断内容是否有更改
const hasContentChanged = computed(() => {
return articleContent.value !== originalContent.value && originalContent.value !== ''
})
// ========== 方法 ==========
// 返回上一页
const goBack = () => {
appStore.setCurrentPage('writer')
}
// 拆分文章为句子
const splitToSentences = () => {
sentences.value = splitIntoSentencesWithPosition(articleContent.value)
// 清空之前的选择和结果
selectedSentenceIdxs.value = []
checkResults.value = []
rewrittenSentences.value = []
selectedRewriteIdxs.value = []
}
// 切换句子选中状态
const toggleSentence = (idx) => {
const index = selectedSentenceIdxs.value.indexOf(idx)
if (index === -1) {
selectedSentenceIdxs.value.push(idx)
} else {
selectedSentenceIdxs.value.splice(index, 1)
}
}
// 切换重写句子选中状态
const toggleRewriteSentence = (idx) => {
const index = selectedRewriteIdxs.value.indexOf(idx)
if (index === -1) {
selectedRewriteIdxs.value.push(idx)
} else {
selectedRewriteIdxs.value.splice(index, 1)
}
}
// 处理范式选择
const handleParadigmSelect = (paradigm) => {
selectedParadigm.value = paradigm
showParadigmSelector.value = false
}
// 处理文稿选择
const handleDocSelect = (doc) => {
if (doc && doc.content) {
articleContent.value = doc.content
originalContent.value = doc.content // 记录原始内容用于差异检测
articleSourceType.value = 'document'
sourceDocId.value = doc.id
sourceDocTitle.value = doc.title
splitToSentences()
}
showDocSelector.value = false
}
// 执行范式检查
const runParadigmCheck = async () => {
if (!canCheck.value) return
isChecking.value = true
checkResults.value = []
try {
// 获取选中的句子文本
const selectedSentences = selectedSentenceIdxs.value
.sort((a, b) => a - b)
.map(idx => sentences.value[idx])
// 构建检查 Prompt
const paradigmPrompt = selectedParadigm.value.specializedPrompt || ''
const guidelinesText = (selectedParadigm.value.expertGuidelines || [])
.map((g, i) => `${i + 1}. 【${g.title}${g.description}`)
.join('\n')
const checkPrompt = `你是一个写作质量检查专家。请基于以下范式规则和专家指令,逐条检查给定的句子。
# 范式规则
${paradigmPrompt}
# 专家检查项
${guidelinesText || '(无特定检查项)'}
# 待检查的句子
${selectedSentences.map((s, i) => `${i + 1}. ${s.text}`).join('\n')}
# 输出要求
请严格按照以下 JSON 格式输出检查结果(仅输出 JSON不要其他内容
{
"results": [
{"sentenceIdx": 0, "status": "pass|warning|fail", "message": "评价说明"}
]
}`
let fullResponse = ''
await appStore.callApi(checkPrompt, (chunk) => {
fullResponse += chunk
}, { temperature: 0.3 })
// 解析结果
try {
const jsonMatch = fullResponse.match(/\{[\s\S]*\}/)
if (jsonMatch) {
const parsed = JSON.parse(jsonMatch[0])
// 映射回原始句子索引
checkResults.value = (parsed.results || []).map((r, i) => ({
sentenceIdx: selectedSentenceIdxs.value[r.sentenceIdx] ?? selectedSentenceIdxs.value[i],
status: r.status || 'warning',
message: r.message || '检查完成'
}))
}
} catch (e) {
console.warn('检查结果解析失败:', e)
// 降级:为每个句子生成通用结果
checkResults.value = selectedSentenceIdxs.value.map((idx) => ({
sentenceIdx: idx,
status: 'warning',
message: '解析异常,建议人工复核'
}))
}
} catch (error) {
console.error('范式检查失败:', error)
alert('检查失败: ' + error.message)
} finally {
isChecking.value = false
}
}
// 执行 AI 重写
const runAIRewrite = async () => {
if (!canRewrite.value) return
isRewriting.value = true
rewriteStreamContent.value = ''
rewrittenSentences.value = []
selectedRewriteIdxs.value = []
try {
// 获取选中的句子和检查结果
const selectedSentences = selectedSentenceIdxs.value
.sort((a, b) => a - b)
.map(idx => sentences.value[idx])
const checkResultsText = checkResults.value
.map(r => `句子${r.sentenceIdx + 1}: ${r.status} - ${r.message}`)
.join('\n')
// 构建重写 Prompt
const paradigmPrompt = selectedParadigm.value.specializedPrompt || ''
const rewritePrompt = `你是一个专业的写作润色专家。请基于以下范式规则和检查结果,重写给定的句子。
# 范式规则
${paradigmPrompt}
# 检查结果
${checkResultsText}
# 原句
${selectedSentences.map((s, i) => `${i + 1}. ${s.text}`).join('\n')}
# 输出要求
请直接按照顺序输出重写后的句子,每句一行,格式如下(仅输出重写内容,不要其他说明):
1. 重写后的句子1
2. 重写后的句子2
...`
let fullResponse = ''
await appStore.callApi(rewritePrompt, (chunk) => {
fullResponse += chunk
// 实时显示时尝试提取 draft 内容
const draftMatch = fullResponse.match(/<draft>([\s\S]*?)(?:<\/draft>|$)/)
if (draftMatch) {
rewriteStreamContent.value = draftMatch[1].trim()
} else {
// 如果还没有 draft 标签,显示完整内容(可能在 thinking 阶段)
rewriteStreamContent.value = fullResponse
}
}, { temperature: 0.7 })
// 解析重写结果:提取 <draft> 标签内的内容
let contentToParse = fullResponse
const draftMatch = fullResponse.match(/<draft>([\s\S]*?)<\/draft>/)
if (draftMatch) {
contentToParse = draftMatch[1].trim()
}
// 按行拆分并处理
const lines = contentToParse.split('\n').filter(line => line.trim())
rewrittenSentences.value = lines.map((line, i) => {
// 移除行首的序号
const text = line.replace(/^\d+\.\s*/, '').trim()
return { text, start: 0, end: text.length }
}).filter(s => s.text) // 过滤空行
} catch (error) {
console.error('AI 重写失败:', error)
alert('重写失败: ' + error.message)
} finally {
isRewriting.value = false
rewriteStreamContent.value = ''
}
}
// 应用选中的重写
const applySelectedRewrites = () => {
if (!canApply.value) return
// 获取选中的原句索引(排序)
const sortedOrigIdxs = [...selectedSentenceIdxs.value].sort((a, b) => b - a)
// 获取选中的重写索引(排序)
const sortedRewriteIdxs = [...selectedRewriteIdxs.value].sort((a, b) => a - b)
// 从后往前替换,避免位置偏移
let newContent = articleContent.value
const minLen = Math.min(sortedOrigIdxs.length, sortedRewriteIdxs.length)
for (let i = 0; i < minLen; i++) {
const origIdx = sortedOrigIdxs[i]
const rewriteIdx = sortedRewriteIdxs[minLen - 1 - i]
const origSentence = sentences.value[origIdx]
const rewriteSentence = rewrittenSentences.value[rewriteIdx]
if (origSentence && rewriteSentence) {
newContent = newContent.slice(0, origSentence.start) +
rewriteSentence.text +
newContent.slice(origSentence.end)
}
}
// 更新内容并重新拆分
articleContent.value = newContent
splitToSentences()
// 提示完成
alert(`已替换 ${minLen} 个句子`)
}
// 清空选择
const clearSelection = () => {
selectedSentenceIdxs.value = []
selectedRewriteIdxs.value = []
checkResults.value = []
rewrittenSentences.value = []
}
// 监听内容变化自动拆分
watch(articleContent, () => {
if (articleSourceType.value === 'paste') {
splitToSentences()
}
}, { immediate: false })
// ========== 全选/反选方法 ==========
// 原文区全选
const selectAllOriginal = () => {
selectedSentenceIdxs.value = sentences.value.map((_, idx) => idx)
}
// 原文区反选
const invertOriginalSelection = () => {
const allIdxs = sentences.value.map((_, idx) => idx)
selectedSentenceIdxs.value = allIdxs.filter(idx => !selectedSentenceIdxs.value.includes(idx))
}
// 重写预览区全选
const selectAllRewrite = () => {
selectedRewriteIdxs.value = rewrittenSentences.value.map((_, idx) => idx)
}
// 重写预览区反选
const invertRewriteSelection = () => {
const allIdxs = rewrittenSentences.value.map((_, idx) => idx)
selectedRewriteIdxs.value = allIdxs.filter(idx => !selectedRewriteIdxs.value.includes(idx))
}
// ========== 保存方法 ==========
// 保存到文稿库
const saveToDocument = async () => {
if (!sourceDocId.value || !hasContentChanged.value) return
isSaving.value = true
try {
// 计算差异
const diff = computeDiff(originalContent.value, articleContent.value)
const changedCount = diff.filter(d => d.type !== 'unchanged').length
// 保存版本历史
const changeNote = `范式润色:修改了 ${changedCount}`
saveDocumentVersion(sourceDocId.value, articleContent.value, changeNote)
// 更新文稿内容
updateDocument(sourceDocId.value, {
content: articleContent.value
})
// 更新原始内容基准
originalContent.value = articleContent.value
alert(`保存成功!修改了 ${changedCount} 处,已创建新版本。`)
} catch (error) {
console.error('保存失败:', error)
alert('保存失败: ' + error.message)
} finally {
isSaving.value = false
}
}
// ========== 复检方法 ==========
// 对重写内容再次进行范式检查
const recheckRewrittenContent = async () => {
if (selectedRewriteIdxs.value.length === 0 || !selectedParadigm.value) {
alert('请先在重写预览区选中要复检的句子')
return
}
isChecking.value = true
checkResults.value = []
try {
// 获取选中的重写句子
const selectedSentences = selectedRewriteIdxs.value
.sort((a, b) => a - b)
.map(idx => rewrittenSentences.value[idx])
// 构建检查 Prompt
const paradigmPrompt = selectedParadigm.value.specializedPrompt || ''
const guidelinesText = (selectedParadigm.value.expertGuidelines || [])
.map((g, i) => `${i + 1}. 【${g.title}${g.description}`)
.join('\n')
const checkPrompt = `你是一个写作质量检查专家。请基于以下范式规则和专家指令,逐条检查给定的句子。
# 范式规则
${paradigmPrompt}
# 专家检查项
${guidelinesText || '(无特定检查项)'}
# 待检查的句子(重写后的内容)
${selectedSentences.map((s, i) => `${i + 1}. ${s.text}`).join('\n')}
# 输出要求
请严格按照以下 JSON 格式输出检查结果(仅输出 JSON不要其他内容
{
"results": [
{"sentenceIdx": 0, "status": "pass|warning|fail", "message": "评价说明"}
]
}`
let fullResponse = ''
await appStore.callApi(checkPrompt, (chunk) => {
fullResponse += chunk
}, { temperature: 0.3 })
// 解析结果
try {
const jsonMatch = fullResponse.match(/\{[\s\S]*\}/)
if (jsonMatch) {
const parsed = JSON.parse(jsonMatch[0])
// 映射回重写句子索引,并标记为「复检」
checkResults.value = (parsed.results || []).map((r, i) => ({
sentenceIdx: selectedRewriteIdxs.value[r.sentenceIdx] ?? selectedRewriteIdxs.value[i],
status: r.status || 'warning',
message: `[复检] ${r.message || '检查完成'}`,
isRecheck: true // 标记为复检结果
}))
}
} catch (e) {
console.warn('检查结果解析失败:', e)
checkResults.value = selectedRewriteIdxs.value.map((idx) => ({
sentenceIdx: idx,
status: 'warning',
message: '[复检] 解析异常,建议人工复核',
isRecheck: true
}))
}
} catch (error) {
console.error('复检失败:', error)
alert('复检失败: ' + error.message)
} finally {
isChecking.value = false
}
}
</script>

View File

@@ -0,0 +1,80 @@
<template>
<aside class="w-16 h-screen flex flex-col items-center py-6 bg-slate-900 border-r border-slate-800 shrink-0 z-50">
<!-- Logo/Home -->
<div class="mb-8 text-2xl filter drop-shadow-[0_0_8px_rgba(59,130,246,0.5)]">
🚀
</div>
<!-- 导航项 -->
<nav class="flex-1 w-full flex flex-col items-center gap-4">
<button
v-for="item in navItems"
:key="item.id"
@click="switchPage(item.id)"
:class="[
'group relative w-12 h-12 rounded-xl flex items-center justify-center transition-all duration-300',
currentPage === item.id
? 'bg-blue-600 text-white shadow-[0_0_15px_rgba(37,99,235,0.4)]'
: 'text-slate-500 hover:bg-slate-800 hover:text-slate-300'
]"
:title="item.label"
>
<span class="text-xl">{{ item.icon }}</span>
<!-- Tooltip -->
<div class="absolute left-full ml-3 px-2 py-1 bg-slate-800 text-white text-xs rounded opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all whitespace-nowrap z-50 shadow-xl border border-slate-700 pointer-events-none">
{{ item.label }}
<div class="absolute left-[-4px] top-1/2 -translate-y-1/2 w-2 h-2 bg-slate-800 border-l border-b border-slate-700 rotate-45"></div>
</div>
<!-- Active Indicator -->
<div
v-if="currentPage === item.id"
class="absolute -left-0 w-1 h-6 bg-blue-400 rounded-r-full"
></div>
</button>
</nav>
<!-- 底部设置 -->
<div class="mt-auto">
<button
@click="switchPage('settings')"
:class="[
'group relative w-12 h-12 rounded-xl flex items-center justify-center transition-all duration-300',
currentPage === 'settings'
? 'bg-slate-700 text-white'
: 'text-slate-500 hover:bg-slate-800 hover:text-slate-300'
]"
title="设置"
>
<span class="text-xl"></span>
<div class="absolute left-full ml-3 px-2 py-1 bg-slate-800 text-white text-xs rounded opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all whitespace-nowrap z-50 shadow-xl border border-slate-700 pointer-events-none">
设置
<div class="absolute left-[-4px] top-1/2 -translate-y-1/2 w-2 h-2 bg-slate-800 border-l border-b border-slate-700 rotate-45"></div>
</div>
</button>
</div>
</aside>
</template>
<script setup>
import { computed } from 'vue'
import { useAppStore } from '../stores/app'
const appStore = useAppStore()
const currentPage = computed(() => appStore.currentPage)
const navItems = [
{ id: 'writer', label: 'AI 写作', icon: '✍️' },
{ id: 'analysis', label: '范式库', icon: '🎯' },
{ id: 'documents', label: '文稿库', icon: '📂' },
{ id: 'materials', label: '素材库', icon: '📚' },
{ id: 'rewrite', label: '范式润色', icon: '🎨' },
{ id: 'compare', label: '对照检查', icon: '🔍' },
{ id: 'diffAnnotation', label: '差异标注', icon: '📊' }
]
const switchPage = (page) => {
appStore.setCurrentPage(page)
}
</script>