feat: 新增以稿写稿和文章融合功能
- 新增以稿写稿 (MimicWriter) 功能:支持分析文章风格并仿写,包含风格分析、逐段仿写等模式 - 新增文章融合 (ArticleFusion) 功能:支持智能分析两篇文章优劣并生成融合版本 - 新增后端 API 服务器 (Express + SQLite) 用于范式管理 - 更新 .gitignore 忽略运行时数据文件 (data/, *.db) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -174,6 +174,7 @@ import { ref, reactive, onMounted, onUnmounted, computed, watch } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useAppStore } from '../stores/app'
|
||||
import { useDatabaseStore } from '../stores/database.js'
|
||||
import { useParadigmStore } from '../stores/paradigm.js'
|
||||
import DeepSeekAPI from '../api/deepseek.js'
|
||||
import { getParadigmList } from '../config/paradigms.js'
|
||||
import RequirementParserPanel from './RequirementParserPanel.vue'
|
||||
@@ -186,6 +187,9 @@ const { analysisText, isAnalyzing } = storeToRefs(appStore)
|
||||
const dbStore = useDatabaseStore()
|
||||
const { isInitialized: dbInitialized } = storeToRefs(dbStore)
|
||||
|
||||
// 范式 Store
|
||||
const paradigmStore = useParadigmStore()
|
||||
|
||||
// 选中的范式
|
||||
const selectedParadigm = ref(null)
|
||||
|
||||
@@ -231,13 +235,12 @@ const initParadigms = () => {
|
||||
// 先加载默认范式
|
||||
const defaultParadigms = getParadigmList()
|
||||
|
||||
// 从本地存储加载自定义修改
|
||||
// 从本地存储加载自定义修改(仅用于默认范式的个性化配置)
|
||||
const savedCustomizations = localStorage.getItem('paradigmCustomizations')
|
||||
const customizations = savedCustomizations ? JSON.parse(savedCustomizations) : {}
|
||||
|
||||
// 从本地存储加载自定义范式
|
||||
const savedCustomParadigms = localStorage.getItem('customParadigms')
|
||||
const customParadigms = savedCustomParadigms ? JSON.parse(savedCustomParadigms) : []
|
||||
// 从数据库 store 加载自定义范式(唯一来源)
|
||||
const customParadigms = paradigmStore.customParadigms || []
|
||||
|
||||
// 合并默认范式和自定义修改
|
||||
const mergedParadigms = defaultParadigms.map(p => {
|
||||
@@ -323,7 +326,7 @@ const resetEditForm = () => {
|
||||
|
||||
|
||||
// 保存范式
|
||||
const saveParadigm = () => {
|
||||
const saveParadigm = async () => {
|
||||
const tags = editForm.tagsInput.split(',').map(t => t.trim()).filter(t => t)
|
||||
|
||||
if (isAddMode.value) {
|
||||
@@ -337,17 +340,12 @@ const saveParadigm = () => {
|
||||
tagClass: editForm.tagClass,
|
||||
isCustom: true,
|
||||
createdAt: new Date().toISOString(),
|
||||
// ⭐ 核心字段:完整 Prompt
|
||||
specializedPrompt: editForm.specializedPrompt
|
||||
}
|
||||
|
||||
// 保存到数据库(唯一来源)
|
||||
await paradigmStore.addCustomParadigm(newParadigm)
|
||||
paradigms.value.push(newParadigm)
|
||||
|
||||
// 保存到本地存储
|
||||
const savedCustomParadigms = localStorage.getItem('customParadigms')
|
||||
const customParadigms = savedCustomParadigms ? JSON.parse(savedCustomParadigms) : []
|
||||
customParadigms.push(newParadigm)
|
||||
localStorage.setItem('customParadigms', JSON.stringify(customParadigms))
|
||||
} else {
|
||||
// 编辑现有范式
|
||||
const index = paradigms.value.findIndex(p => p.id === editingParadigmId.value)
|
||||
@@ -359,24 +357,16 @@ const saveParadigm = () => {
|
||||
description: editForm.description,
|
||||
tags,
|
||||
tagClass: editForm.tagClass,
|
||||
// ⭐ 核心字段:完整 Prompt
|
||||
specializedPrompt: editForm.specializedPrompt
|
||||
}
|
||||
|
||||
paradigms.value[index] = updatedParadigm
|
||||
|
||||
// 根据是否是自定义范式决定存储位置
|
||||
if (updatedParadigm.isCustom) {
|
||||
// 更新自定义范式
|
||||
const savedCustomParadigms = localStorage.getItem('customParadigms')
|
||||
const customParadigms = savedCustomParadigms ? JSON.parse(savedCustomParadigms) : []
|
||||
const customIndex = customParadigms.findIndex(p => p.id === editingParadigmId.value)
|
||||
if (customIndex !== -1) {
|
||||
customParadigms[customIndex] = updatedParadigm
|
||||
localStorage.setItem('customParadigms', JSON.stringify(customParadigms))
|
||||
}
|
||||
// 更新数据库中的自定义范式
|
||||
await paradigmStore.addCustomParadigm(updatedParadigm)
|
||||
} else {
|
||||
// 保存对默认范式的自定义修改
|
||||
// 保存对默认范式的自定义修改(仍用 localStorage)
|
||||
const savedCustomizations = localStorage.getItem('paradigmCustomizations')
|
||||
const customizations = savedCustomizations ? JSON.parse(savedCustomizations) : {}
|
||||
customizations[editingParadigmId.value] = {
|
||||
@@ -406,7 +396,7 @@ const isCustomParadigm = (paradigm) => {
|
||||
}
|
||||
|
||||
// 删除自定义范式
|
||||
const deleteParadigm = (paradigm) => {
|
||||
const deleteParadigm = async (paradigm) => {
|
||||
if (!isCustomParadigm(paradigm)) return
|
||||
|
||||
if (!confirm(`确定要删除"${paradigm.name}"吗?`)) return
|
||||
@@ -414,11 +404,8 @@ const deleteParadigm = (paradigm) => {
|
||||
// 从列表中移除
|
||||
paradigms.value = paradigms.value.filter(p => p.id !== paradigm.id)
|
||||
|
||||
// 从本地存储中移除
|
||||
const savedCustomParadigms = localStorage.getItem('customParadigms')
|
||||
const customParadigms = savedCustomParadigms ? JSON.parse(savedCustomParadigms) : []
|
||||
const filtered = customParadigms.filter(p => p.id !== paradigm.id)
|
||||
localStorage.setItem('customParadigms', JSON.stringify(filtered))
|
||||
// 从数据库中删除(唯一来源)
|
||||
await paradigmStore.deleteCustomParadigm(paradigm.id)
|
||||
|
||||
// 如果正在选中该范式,清除选中状态
|
||||
if (selectedParadigm.value?.id === paradigm.id) {
|
||||
|
||||
360
src/components/ArticleFusionPanel.vue
Normal file
360
src/components/ArticleFusionPanel.vue
Normal file
@@ -0,0 +1,360 @@
|
||||
<template>
|
||||
<aside class="fusion-panel">
|
||||
<!-- 头部 -->
|
||||
<header class="panel-header">
|
||||
<h1 class="header-title">
|
||||
<IconLibrary name="sparkles" :size="20" />
|
||||
<span>文章融合</span>
|
||||
</h1>
|
||||
<span class="badge badge-primary">Beta</span>
|
||||
</header>
|
||||
|
||||
<!-- 内容区 -->
|
||||
<div class="panel-content">
|
||||
<!-- 文章 A -->
|
||||
<section class="article-section">
|
||||
<div class="section-header">
|
||||
<label class="section-label">
|
||||
<span class="label-badge a">A</span>
|
||||
文章 A
|
||||
</label>
|
||||
<span class="char-count">{{ articleA.length }} 字</span>
|
||||
</div>
|
||||
<input
|
||||
v-model="titleA"
|
||||
type="text"
|
||||
class="title-input"
|
||||
placeholder="文章 A 标题(可选)"
|
||||
/>
|
||||
<textarea
|
||||
v-model="articleA"
|
||||
class="article-textarea"
|
||||
placeholder="粘贴第一篇文章内容..."
|
||||
rows="8"
|
||||
></textarea>
|
||||
</section>
|
||||
|
||||
<!-- 文章 B -->
|
||||
<section class="article-section">
|
||||
<div class="section-header">
|
||||
<label class="section-label">
|
||||
<span class="label-badge b">B</span>
|
||||
文章 B
|
||||
</label>
|
||||
<span class="char-count">{{ articleB.length }} 字</span>
|
||||
</div>
|
||||
<input
|
||||
v-model="titleB"
|
||||
type="text"
|
||||
class="title-input"
|
||||
placeholder="文章 B 标题(可选)"
|
||||
/>
|
||||
<textarea
|
||||
v-model="articleB"
|
||||
class="article-textarea"
|
||||
placeholder="粘贴第二篇文章内容..."
|
||||
rows="8"
|
||||
></textarea>
|
||||
</section>
|
||||
|
||||
<!-- 融合选项 -->
|
||||
<section class="options-section">
|
||||
<label class="section-label">融合偏好</label>
|
||||
<div class="options-grid">
|
||||
<label class="option-item" :class="{ active: fusionMode === 'balanced' }">
|
||||
<input type="radio" v-model="fusionMode" value="balanced" />
|
||||
<IconLibrary name="compare" :size="16" />
|
||||
<span>均衡融合</span>
|
||||
</label>
|
||||
<label class="option-item" :class="{ active: fusionMode === 'preferA' }">
|
||||
<input type="radio" v-model="fusionMode" value="preferA" />
|
||||
<span class="label-badge a small">A</span>
|
||||
<span>偏重 A</span>
|
||||
</label>
|
||||
<label class="option-item" :class="{ active: fusionMode === 'preferB' }">
|
||||
<input type="radio" v-model="fusionMode" value="preferB" />
|
||||
<span class="label-badge b small">B</span>
|
||||
<span>偏重 B</span>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- 底部操作 -->
|
||||
<footer class="panel-footer">
|
||||
<button
|
||||
@click="startFusion"
|
||||
:disabled="!canFusion || isAnalyzing"
|
||||
class="fusion-button"
|
||||
:class="{ 'loading': isAnalyzing }"
|
||||
>
|
||||
<IconLibrary v-if="!isAnalyzing" name="sparkles" :size="16" />
|
||||
<span v-else class="animate-spin">↻</span>
|
||||
{{ buttonText }}
|
||||
</button>
|
||||
</footer>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useAppStore } from '../stores/app'
|
||||
import IconLibrary from './icons/IconLibrary.vue'
|
||||
|
||||
const appStore = useAppStore()
|
||||
const { articleFusionState } = storeToRefs(appStore)
|
||||
|
||||
// 本地状态
|
||||
const titleA = ref('')
|
||||
const titleB = ref('')
|
||||
const articleA = ref('')
|
||||
const articleB = ref('')
|
||||
const fusionMode = ref('balanced') // balanced | preferA | preferB
|
||||
|
||||
// 计算属性
|
||||
const isAnalyzing = computed(() => articleFusionState.value.isAnalyzing)
|
||||
const canFusion = computed(() => articleA.value.trim().length > 50 && articleB.value.trim().length > 50)
|
||||
const buttonText = computed(() => {
|
||||
if (isAnalyzing.value) {
|
||||
return articleFusionState.value.stage === 'analyzing' ? '分析中...' : '融合中...'
|
||||
}
|
||||
return '开始分析融合'
|
||||
})
|
||||
|
||||
// 开始融合
|
||||
const startFusion = async () => {
|
||||
if (!canFusion.value || isAnalyzing.value) return
|
||||
|
||||
try {
|
||||
await appStore.startArticleFusionAction({
|
||||
titleA: titleA.value,
|
||||
titleB: titleB.value,
|
||||
articleA: articleA.value,
|
||||
articleB: articleB.value,
|
||||
fusionMode: fusionMode.value
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('融合失败:', error)
|
||||
alert('融合失败: ' + error.message)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fusion-panel {
|
||||
width: 400px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-right: 1px solid var(--border-default);
|
||||
background: var(--bg-secondary);
|
||||
flex-shrink: 0;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
padding: var(--space-4);
|
||||
border-bottom: 1px solid var(--border-default);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.header-title {
|
||||
font-weight: var(--font-semibold);
|
||||
font-size: var(--text-lg);
|
||||
color: var(--text-primary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.panel-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: var(--space-4);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.article-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.section-label {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--font-medium);
|
||||
color: var(--text-secondary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.label-badge {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 10px;
|
||||
font-weight: bold;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.label-badge.a {
|
||||
background: linear-gradient(135deg, #3b82f6, #6366f1);
|
||||
}
|
||||
|
||||
.label-badge.b {
|
||||
background: linear-gradient(135deg, #10b981, #06b6d4);
|
||||
}
|
||||
|
||||
.label-badge.small {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
font-size: 8px;
|
||||
}
|
||||
|
||||
.char-count {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.title-input {
|
||||
width: 100%;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--text-primary);
|
||||
font-size: var(--text-sm);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.title-input:focus {
|
||||
border-color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.article-textarea {
|
||||
width: 100%;
|
||||
padding: var(--space-3);
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-lg);
|
||||
color: var(--text-primary);
|
||||
font-size: var(--text-sm);
|
||||
resize: vertical;
|
||||
min-height: 120px;
|
||||
line-height: 1.6;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.article-textarea:focus {
|
||||
border-color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.options-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.options-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.option-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
padding: var(--space-2);
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.option-item input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.option-item:hover {
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
|
||||
.option-item.active {
|
||||
border-color: var(--accent-primary);
|
||||
background: var(--info-bg);
|
||||
color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.panel-footer {
|
||||
padding: var(--space-4);
|
||||
border-top: 1px solid var(--border-default);
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.fusion-button {
|
||||
width: 100%;
|
||||
padding: var(--space-3);
|
||||
border-radius: var(--radius-lg);
|
||||
font-weight: var(--font-semibold);
|
||||
color: var(--text-inverse);
|
||||
background: linear-gradient(135deg, #8b5cf6, #ec4899);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-2);
|
||||
transition: all var(--transition-normal);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.fusion-button:hover:not(:disabled) {
|
||||
opacity: 0.9;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.fusion-button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.fusion-button.loading {
|
||||
background: linear-gradient(135deg, #6366f1, #8b5cf6);
|
||||
}
|
||||
|
||||
.badge-primary {
|
||||
font-size: 10px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(135deg, #8b5cf6, #ec4899);
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.animate-spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
</style>
|
||||
595
src/components/FusionResultPanel.vue
Normal file
595
src/components/FusionResultPanel.vue
Normal file
@@ -0,0 +1,595 @@
|
||||
<template>
|
||||
<main class="fusion-result-panel">
|
||||
<!-- 空状态 -->
|
||||
<div v-if="!hasResult && !isAnalyzing" class="empty-state">
|
||||
<div class="empty-icon">
|
||||
<IconLibrary name="sparkles" :size="48" />
|
||||
</div>
|
||||
<h3>文章融合</h3>
|
||||
<p>在左侧输入两篇文章,AI 将分析优劣并生成融合文章</p>
|
||||
</div>
|
||||
|
||||
<!-- 分析/生成中 -->
|
||||
<div v-else-if="isAnalyzing" class="loading-state">
|
||||
<div class="loading-spinner"></div>
|
||||
<h3>{{ stageText }}</h3>
|
||||
<p class="loading-tip">{{ stageTip }}</p>
|
||||
|
||||
<!-- 进度流式显示 -->
|
||||
<div v-if="thinkingContent" class="thinking-preview">
|
||||
<div class="thinking-header">
|
||||
<IconLibrary name="lightbulb" :size="14" />
|
||||
<span>AI 分析过程</span>
|
||||
</div>
|
||||
<div class="thinking-content" v-html="renderedThinking"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 结果展示 -->
|
||||
<div v-else class="result-container">
|
||||
<!-- 分析报告 -->
|
||||
<section class="analysis-section">
|
||||
<div class="section-header">
|
||||
<h2>
|
||||
<IconLibrary name="chart" :size="18" />
|
||||
优劣分析报告
|
||||
</h2>
|
||||
<button @click="toggleAnalysis" class="toggle-btn">
|
||||
{{ showAnalysis ? '收起' : '展开' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-show="showAnalysis" class="analysis-content">
|
||||
<!-- 文章 A 分析 -->
|
||||
<div class="article-analysis">
|
||||
<h3><span class="badge a">A</span> {{ fusionState.titleA || '文章 A' }}</h3>
|
||||
<div class="pros-cons">
|
||||
<div class="pros">
|
||||
<h4><IconLibrary name="check" :size="14" /> 优点</h4>
|
||||
<ul>
|
||||
<li v-for="(pro, i) in analysisResult.articleA?.pros" :key="i">{{ pro }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="cons">
|
||||
<h4><IconLibrary name="warning" :size="14" /> 不足</h4>
|
||||
<ul>
|
||||
<li v-for="(con, i) in analysisResult.articleA?.cons" :key="i">{{ con }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 文章 B 分析 -->
|
||||
<div class="article-analysis">
|
||||
<h3><span class="badge b">B</span> {{ fusionState.titleB || '文章 B' }}</h3>
|
||||
<div class="pros-cons">
|
||||
<div class="pros">
|
||||
<h4><IconLibrary name="check" :size="14" /> 优点</h4>
|
||||
<ul>
|
||||
<li v-for="(pro, i) in analysisResult.articleB?.pros" :key="i">{{ pro }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="cons">
|
||||
<h4><IconLibrary name="warning" :size="14" /> 不足</h4>
|
||||
<ul>
|
||||
<li v-for="(con, i) in analysisResult.articleB?.cons" :key="i">{{ con }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 融合策略 -->
|
||||
<div class="fusion-strategy">
|
||||
<h4><IconLibrary name="sparkles" :size="14" /> 融合策略</h4>
|
||||
<p>{{ analysisResult.fusionStrategy }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 融合结果 -->
|
||||
<section class="fusion-result-section">
|
||||
<div class="section-header">
|
||||
<h2>
|
||||
<IconLibrary name="article" :size="18" />
|
||||
融合文章
|
||||
</h2>
|
||||
<div class="header-actions">
|
||||
<span class="word-count">{{ wordCount }} 字</span>
|
||||
<button @click="copyResult" class="action-btn">
|
||||
<IconLibrary name="clipboard" :size="14" />
|
||||
复制
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="fusion-content">
|
||||
<!-- 段落级选择 -->
|
||||
<div
|
||||
v-for="(para, index) in paragraphs"
|
||||
:key="index"
|
||||
:class="['paragraph-item', { 'excluded': !para.included }]"
|
||||
>
|
||||
<div class="paragraph-controls">
|
||||
<input
|
||||
type="checkbox"
|
||||
v-model="para.included"
|
||||
class="paragraph-checkbox"
|
||||
/>
|
||||
<span class="paragraph-source" :class="para.source">
|
||||
{{ para.source === 'A' ? 'A' : para.source === 'B' ? 'B' : '融' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="paragraph-content" v-html="renderMarkdown(para.content)"></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 底部操作 -->
|
||||
<footer class="result-footer">
|
||||
<button @click="regenerate" class="btn secondary">
|
||||
<IconLibrary name="refresh" :size="14" />
|
||||
重新生成
|
||||
</button>
|
||||
<button @click="exportResult" class="btn primary">
|
||||
<IconLibrary name="download" :size="14" />
|
||||
导出文章
|
||||
</button>
|
||||
</footer>
|
||||
</div>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useAppStore } from '../stores/app'
|
||||
import { marked } from 'marked'
|
||||
import IconLibrary from './icons/IconLibrary.vue'
|
||||
|
||||
const appStore = useAppStore()
|
||||
const { articleFusionState } = storeToRefs(appStore)
|
||||
|
||||
// UI 状态
|
||||
const showAnalysis = ref(true)
|
||||
|
||||
// 计算属性
|
||||
const fusionState = computed(() => articleFusionState.value)
|
||||
const isAnalyzing = computed(() => fusionState.value.isAnalyzing)
|
||||
const hasResult = computed(() => fusionState.value.fusionResult)
|
||||
const analysisResult = computed(() => fusionState.value.analysisResult || {})
|
||||
const thinkingContent = computed(() => fusionState.value.thinkingContent || '')
|
||||
|
||||
const stageText = computed(() => {
|
||||
const stage = fusionState.value.stage
|
||||
if (stage === 'analyzing') return '正在分析两篇文章...'
|
||||
if (stage === 'generating') return '正在生成融合文章...'
|
||||
return '处理中...'
|
||||
})
|
||||
|
||||
const stageTip = computed(() => {
|
||||
const stage = fusionState.value.stage
|
||||
if (stage === 'analyzing') return 'AI 正在识别每篇文章的优点和不足'
|
||||
if (stage === 'generating') return 'AI 正在融合两篇文章的精华内容'
|
||||
return ''
|
||||
})
|
||||
|
||||
const renderedThinking = computed(() => {
|
||||
return marked.parse(thinkingContent.value)
|
||||
})
|
||||
|
||||
// 段落处理
|
||||
const paragraphs = computed(() => {
|
||||
if (!fusionState.value.fusionResult) return []
|
||||
|
||||
// 将融合结果按段落分割,分析来源
|
||||
const text = fusionState.value.fusionResult
|
||||
const paras = text.split(/\n{2,}/).filter(p => p.trim())
|
||||
|
||||
return paras.map((content, index) => ({
|
||||
id: index,
|
||||
content: content.trim(),
|
||||
included: true,
|
||||
source: detectSource(content, index) // A, B, or 'mixed'
|
||||
}))
|
||||
})
|
||||
|
||||
const wordCount = computed(() => {
|
||||
const included = paragraphs.value.filter(p => p.included)
|
||||
return included.reduce((sum, p) => sum + p.content.replace(/[#*\n\s]/g, '').length, 0)
|
||||
})
|
||||
|
||||
// 检测段落来源(简化逻辑)
|
||||
const detectSource = (content, index) => {
|
||||
// 实际应用中可以通过 AI 标注或文本相似度判断
|
||||
// 这里简化为根据位置交替
|
||||
if (index % 3 === 0) return 'A'
|
||||
if (index % 3 === 1) return 'B'
|
||||
return 'mixed'
|
||||
}
|
||||
|
||||
// 渲染 Markdown
|
||||
const renderMarkdown = (text) => {
|
||||
return marked.parse(text || '')
|
||||
}
|
||||
|
||||
// 切换分析报告显示
|
||||
const toggleAnalysis = () => {
|
||||
showAnalysis.value = !showAnalysis.value
|
||||
}
|
||||
|
||||
// 复制结果
|
||||
const copyResult = () => {
|
||||
const included = paragraphs.value.filter(p => p.included)
|
||||
const text = included.map(p => p.content).join('\n\n')
|
||||
navigator.clipboard.writeText(text)
|
||||
alert('已复制到剪贴板')
|
||||
}
|
||||
|
||||
// 重新生成
|
||||
const regenerate = () => {
|
||||
appStore.regenerateFusionAction()
|
||||
}
|
||||
|
||||
// 导出文章
|
||||
const exportResult = async () => {
|
||||
const included = paragraphs.value.filter(p => p.included)
|
||||
const text = included.map(p => p.content).join('\n\n')
|
||||
|
||||
try {
|
||||
const { Document, Packer, Paragraph, TextRun } = await import('docx')
|
||||
const { saveAs } = await import('file-saver')
|
||||
|
||||
const children = text.split('\n\n').map(para =>
|
||||
new Paragraph({ children: [new TextRun(para)] })
|
||||
)
|
||||
|
||||
const doc = new Document({
|
||||
sections: [{ properties: {}, children }]
|
||||
})
|
||||
|
||||
const blob = await Packer.toBlob(doc)
|
||||
saveAs(blob, `融合文章_${new Date().toLocaleDateString()}.docx`)
|
||||
} catch (error) {
|
||||
console.error('导出失败:', error)
|
||||
alert('导出失败')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fusion-result-panel {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--bg-primary);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.empty-state, .loading-state {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
padding: var(--space-8);
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 20px;
|
||||
background: var(--bg-secondary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: var(--space-4);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border: 3px solid var(--border-default);
|
||||
border-top-color: var(--accent-primary);
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.loading-tip {
|
||||
font-size: var(--text-sm);
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
|
||||
.thinking-preview {
|
||||
margin-top: var(--space-6);
|
||||
width: 100%;
|
||||
max-width: 600px;
|
||||
background: var(--bg-secondary);
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--border-default);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.thinking-header {
|
||||
padding: var(--space-2) var(--space-3);
|
||||
background: var(--bg-elevated);
|
||||
border-bottom: 1px solid var(--border-default);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.thinking-content {
|
||||
padding: var(--space-3);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-primary);
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.result-container {
|
||||
padding: var(--space-6);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-6);
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.section-header h2 {
|
||||
font-size: var(--text-lg);
|
||||
font-weight: var(--font-semibold);
|
||||
color: var(--text-primary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.toggle-btn {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-muted);
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.toggle-btn:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.analysis-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.article-analysis {
|
||||
background: var(--bg-secondary);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-4);
|
||||
border: 1px solid var(--border-default);
|
||||
}
|
||||
|
||||
.article-analysis h3 {
|
||||
font-size: var(--text-base);
|
||||
font-weight: var(--font-medium);
|
||||
color: var(--text-primary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
.badge {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 10px;
|
||||
font-weight: bold;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.badge.a { background: linear-gradient(135deg, #3b82f6, #6366f1); }
|
||||
.badge.b { background: linear-gradient(135deg, #10b981, #06b6d4); }
|
||||
|
||||
.pros-cons {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.pros h4 { color: var(--success-text); }
|
||||
.cons h4 { color: var(--warning-text); }
|
||||
|
||||
.pros h4, .cons h4 {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--font-medium);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.pros ul, .cons ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.pros li, .cons li {
|
||||
padding: var(--space-1) 0;
|
||||
padding-left: var(--space-3);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.pros li::before, .cons li::before {
|
||||
content: '•';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.fusion-strategy {
|
||||
background: var(--info-bg);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-3);
|
||||
border: 1px solid var(--accent-primary);
|
||||
}
|
||||
|
||||
.fusion-strategy h4 {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--accent-primary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.fusion-strategy p {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.word-count {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
font-size: var(--text-xs);
|
||||
padding: var(--space-1) var(--space-2);
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
background: var(--bg-elevated);
|
||||
}
|
||||
|
||||
.fusion-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.paragraph-item {
|
||||
display: flex;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-3);
|
||||
background: var(--bg-secondary);
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--border-default);
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.paragraph-item.excluded {
|
||||
opacity: 0.4;
|
||||
background: var(--bg-sunken);
|
||||
}
|
||||
|
||||
.paragraph-controls {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.paragraph-checkbox {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.paragraph-source {
|
||||
font-size: 10px;
|
||||
font-weight: bold;
|
||||
padding: 2px 6px;
|
||||
border-radius: 8px;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.paragraph-source.A { background: #6366f1; }
|
||||
.paragraph-source.B { background: #10b981; }
|
||||
.paragraph-source.mixed { background: #8b5cf6; }
|
||||
|
||||
.paragraph-content {
|
||||
flex: 1;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-primary);
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.paragraph-content :deep(p) {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.result-footer {
|
||||
display: flex;
|
||||
gap: var(--space-3);
|
||||
justify-content: flex-end;
|
||||
padding-top: var(--space-4);
|
||||
border-top: 1px solid var(--border-default);
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: var(--space-2) var(--space-4);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--font-medium);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
transition: all var(--transition-fast);
|
||||
border: none;
|
||||
}
|
||||
|
||||
.btn.secondary {
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-secondary);
|
||||
border: 1px solid var(--border-default);
|
||||
}
|
||||
|
||||
.btn.secondary:hover {
|
||||
background: var(--bg-elevated);
|
||||
}
|
||||
|
||||
.btn.primary {
|
||||
background: linear-gradient(135deg, #8b5cf6, #ec4899);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn.primary:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
</style>
|
||||
@@ -61,8 +61,10 @@ const currentPage = computed(() => appStore.currentPage)
|
||||
|
||||
const navItems = [
|
||||
{ id: 'writer', label: 'AI 写作', icon: 'edit' },
|
||||
{ id: 'mimicWriter', label: '以稿写稿', icon: 'copy' },
|
||||
{ id: 'analysis', label: '范式库', icon: 'analysis' },
|
||||
{ id: 'paradigmWriter', label: '范式写作', icon: 'article' },
|
||||
{ id: 'articleFusion', label: '文章融合', icon: 'sparkles' },
|
||||
{ id: 'documents', label: '文稿库', icon: 'folder' },
|
||||
{ id: 'materials', label: '素材库', icon: 'chart' },
|
||||
{ id: 'rewrite', label: '范式润色', icon: 'sparkles' },
|
||||
|
||||
@@ -100,11 +100,13 @@
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useAppStore } from '../stores/app'
|
||||
import { useDatabaseStore } from '../stores/database'
|
||||
import { useParadigmStore } from '../stores/paradigm'
|
||||
import { getParadigmList } from '../config/paradigms'
|
||||
import IconLibrary from './icons/IconLibrary.vue'
|
||||
|
||||
const appStore = useAppStore()
|
||||
const dbStore = useDatabaseStore()
|
||||
const paradigmStore = useParadigmStore()
|
||||
|
||||
// 统计数据
|
||||
const stats = ref({
|
||||
@@ -141,12 +143,20 @@ const quickActions = [
|
||||
description: '管理和创建写作范式',
|
||||
icon: 'analysis',
|
||||
gradient: 'linear-gradient(135deg, #10b981, #06b6d4)'
|
||||
},
|
||||
{
|
||||
id: 'mimicWriter',
|
||||
title: '以稿写稿',
|
||||
description: '模仿范文风格创作新内容',
|
||||
icon: 'copy',
|
||||
gradient: 'linear-gradient(135deg, #8b5cf6, #ec4899)'
|
||||
}
|
||||
]
|
||||
|
||||
// 全部功能
|
||||
const features = [
|
||||
{ id: 'writer', name: 'AI 写作', icon: 'edit' },
|
||||
{ id: 'mimicWriter', name: '以稿写稿', icon: 'copy' },
|
||||
{ id: 'analysis', name: '范式库', icon: 'analysis' },
|
||||
{ id: 'paradigmWriter', name: '范式写作', icon: 'article' },
|
||||
{ id: 'documents', name: '文稿库', icon: 'folder' },
|
||||
@@ -163,9 +173,9 @@ const navigateTo = (page) => {
|
||||
|
||||
// 加载统计数据
|
||||
onMounted(() => {
|
||||
// 获取范式数量
|
||||
// 获取范式数量(默认 + 数据库自定义)
|
||||
const defaultParadigms = getParadigmList()
|
||||
const customParadigms = JSON.parse(localStorage.getItem('customParadigms') || '[]')
|
||||
const customParadigms = paradigmStore.customParadigms || []
|
||||
stats.value.paradigms = defaultParadigms.length + customParadigms.length
|
||||
|
||||
// 获取文稿和素材数量
|
||||
|
||||
@@ -494,11 +494,13 @@
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useAppStore } from '../stores/app'
|
||||
import { useParadigmStore } from '../stores/paradigm'
|
||||
import { buildPrompt } from '../utils/promptBuilder.js'
|
||||
import { marked } from 'marked'
|
||||
import IconLibrary from './icons/IconLibrary.vue'
|
||||
|
||||
const appStore = useAppStore()
|
||||
const paradigmStore = useParadigmStore()
|
||||
const {
|
||||
currentPage,
|
||||
showPromptDebug,
|
||||
@@ -605,7 +607,7 @@ const closeParadigmEdit = () => {
|
||||
}
|
||||
|
||||
// 保存范式编辑
|
||||
const saveParadigmEdit = () => {
|
||||
const saveParadigmEdit = async () => {
|
||||
const form = paradigmEditState.value.editForm
|
||||
|
||||
// 构建范式对象
|
||||
@@ -623,22 +625,8 @@ const saveParadigmEdit = () => {
|
||||
createdAt: paradigmEditState.value.isAddMode ? new Date().toISOString() : undefined
|
||||
}
|
||||
|
||||
// 保存到 localStorage
|
||||
const customParadigms = JSON.parse(localStorage.getItem('customParadigms') || '[]')
|
||||
|
||||
if (paradigmEditState.value.isAddMode) {
|
||||
customParadigms.push(paradigm)
|
||||
} else {
|
||||
const index = customParadigms.findIndex(p => p.id === paradigm.id)
|
||||
if (index >= 0) {
|
||||
customParadigms[index] = { ...customParadigms[index], ...paradigm }
|
||||
} else {
|
||||
// 可能是系统范式的修改,添加为新的自定义范式
|
||||
customParadigms.push(paradigm)
|
||||
}
|
||||
}
|
||||
|
||||
localStorage.setItem('customParadigms', JSON.stringify(customParadigms))
|
||||
// 保存到数据库(唯一来源)
|
||||
await paradigmStore.addCustomParadigm(paradigm)
|
||||
|
||||
// 关闭编辑
|
||||
closeParadigmEdit()
|
||||
|
||||
595
src/components/MimicWriterPanel.vue
Normal file
595
src/components/MimicWriterPanel.vue
Normal file
@@ -0,0 +1,595 @@
|
||||
<template>
|
||||
<div class="mimic-container">
|
||||
<!-- 左侧配置面板 -->
|
||||
<aside class="mimic-sidebar glass">
|
||||
<header class="sidebar-header">
|
||||
<h2 class="sidebar-title">
|
||||
<IconLibrary name="copy" :size="20" />
|
||||
<span>以稿写稿</span>
|
||||
</h2>
|
||||
<span class="badge badge-purple">AI 模仿</span>
|
||||
</header>
|
||||
|
||||
<div class="sidebar-content custom-scrollbar">
|
||||
<!-- 1. 原稿输入 -->
|
||||
<section class="config-section">
|
||||
<div class="flex justify-between items-center mb-2">
|
||||
<label class="config-label">1. 提供原稿 (Reference)</label>
|
||||
<button @click="openDocSelector" class="text-xs text-accent-primary hover:underline flex items-center gap-1">
|
||||
<IconLibrary name="folder" :size="12" />
|
||||
文稿库导入
|
||||
</button>
|
||||
</div>
|
||||
<textarea
|
||||
v-model="mimicState.sourceArticle"
|
||||
placeholder="粘贴你要模仿的原稿内容..."
|
||||
class="mimic-textarea h-40"
|
||||
></textarea>
|
||||
|
||||
<button
|
||||
@click="analyzeStyle"
|
||||
:disabled="mimicState.isAnalyzing || !mimicState.sourceArticle"
|
||||
class="btn btn-secondary w-full mt-2 py-2 text-xs flex items-center justify-center gap-2"
|
||||
>
|
||||
<IconLibrary v-if="!mimicState.isAnalyzing" name="analysis" :size="14" />
|
||||
<span v-else class="animate-spin">↻</span>
|
||||
{{ mimicState.isAnalyzing ? '正在分析风格指纹...' : '分析原稿风格' }}
|
||||
</button>
|
||||
|
||||
<!-- 风格分析结果展示 -->
|
||||
<div v-if="mimicState.styleAnalysis" class="style-result mt-3 glass-card p-3">
|
||||
<h4 class="text-[10px] text-muted uppercase tracking-wider mb-2 flex items-center gap-1">
|
||||
<IconLibrary name="sparkles" :size="10" />
|
||||
已提取风格指纹
|
||||
</h4>
|
||||
<div class="style-description" v-html="renderedStyleAnalysis"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 2. 写作方向 -->
|
||||
<section class="config-section">
|
||||
<label class="config-label mb-2 block">2. 写作方向 (User Direction)</label>
|
||||
<textarea
|
||||
v-model="mimicState.writingDirection"
|
||||
placeholder="描述你想写的新主题、核心内容或大纲..."
|
||||
class="mimic-textarea h-32"
|
||||
></textarea>
|
||||
</section>
|
||||
|
||||
<!-- 3. 高级配置 -->
|
||||
<section class="config-section">
|
||||
<label class="config-label mb-2 block">3. 模仿设置 (Options)</label>
|
||||
|
||||
<!-- 风格强度 -->
|
||||
<div class="mb-4">
|
||||
<div class="flex justify-between items-center mb-1.5">
|
||||
<span class="text-xs text-muted">风格模仿强度</span>
|
||||
<span class="text-xs font-mono text-purple-400">{{ mimicState.styleIntensity }}%</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
v-model.number="mimicState.styleIntensity"
|
||||
min="0" max="100"
|
||||
class="w-full h-1.5 bg-slate-800 rounded-lg appearance-none cursor-pointer accent-purple-500"
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- 保留元素 -->
|
||||
<div class="space-y-2">
|
||||
<span class="text-xs text-muted block mb-1">重点保留元素</span>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<label
|
||||
v-for="item in ['结构', '语气', '用词', '修辞', '行文节奏']"
|
||||
:key="item"
|
||||
class="flex items-center gap-1.5 px-2.5 py-1.5 rounded-md bg-slate-800/50 border border-slate-700/50 cursor-pointer transition-all hover:bg-slate-700/50"
|
||||
:class="{ 'border-purple-500/50 bg-purple-500/10 text-purple-300': mimicState.preserveElements.includes(item) }"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
:value="item"
|
||||
v-model="mimicState.preserveElements"
|
||||
class="hidden"
|
||||
>
|
||||
<span class="text-[11px]">{{ item }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<footer class="sidebar-footer">
|
||||
<!-- 段落进度显示 -->
|
||||
<div v-if="mimicState.totalParagraphs > 0" class="progress-bar mb-3">
|
||||
<div class="flex justify-between text-xs text-muted mb-1">
|
||||
<span>段落进度</span>
|
||||
<span>{{ mimicState.currentParagraphIndex + 1 }} / {{ mimicState.totalParagraphs }}</span>
|
||||
</div>
|
||||
<div class="h-1.5 bg-slate-800 rounded-full overflow-hidden">
|
||||
<div
|
||||
class="h-full bg-gradient-to-r from-purple-500 to-pink-500 transition-all duration-300"
|
||||
:style="{ width: `${((mimicState.currentParagraphIndex + 1) / mimicState.totalParagraphs) * 100}%` }"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<button
|
||||
@click="splitParagraphs"
|
||||
:disabled="!mimicState.sourceArticle.trim() || mimicState.isGenerating"
|
||||
class="split-button"
|
||||
>
|
||||
<IconLibrary name="analysis" :size="16" />
|
||||
<span>拆分段落</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
@click="startParagraphMimic"
|
||||
:disabled="isButtonDisabled || mimicState.paragraphs.length === 0"
|
||||
class="generate-button"
|
||||
:class="{ 'generating': mimicState.isGenerating }"
|
||||
>
|
||||
<IconLibrary v-if="!mimicState.isGenerating" name="copy" :size="16" />
|
||||
<span v-else class="animate-spin">↻</span>
|
||||
{{ mimicState.isGenerating ? `第${mimicState.currentParagraphIndex + 1}段...` : '分段仿写' }}
|
||||
</button>
|
||||
</div>
|
||||
</footer>
|
||||
</aside>
|
||||
|
||||
<!-- 中间内容预览区 -->
|
||||
<main class="mimic-main">
|
||||
<div v-if="!mimicState.generatedContent && !mimicState.isGenerating" class="empty-preview">
|
||||
<div class="empty-icon-box">
|
||||
<IconLibrary name="copy" :size="48" />
|
||||
</div>
|
||||
<h3>准备好模仿了吗?</h3>
|
||||
<p>提供原稿并给出方向,AI 将为您复刻经典</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="preview-container custom-scrollbar">
|
||||
<header class="preview-header">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="preview-dot"></div>
|
||||
<h3 class="text-sm font-medium">生成结果预览 (Markdown)</h3>
|
||||
<span v-if="wordCount > 0" class="text-xs text-muted font-mono bg-slate-800/50 px-2 py-0.5 rounded">
|
||||
{{ wordCount }} 字
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button @click="copyToClipboard" class="icon-btn" title="复制全文">
|
||||
<IconLibrary name="clipboard" :size="16" />
|
||||
</button>
|
||||
<button @click="saveToDocuments" class="icon-btn" title="保存到文稿库">
|
||||
<IconLibrary name="save" :size="16" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="markdown-body" v-html="renderedContent"></div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- 右侧思考面板 -->
|
||||
<aside v-if="mimicState.thinkingContent" class="thinking-panel glass">
|
||||
<header class="thinking-header">
|
||||
<IconLibrary name="sparkles" :size="16" />
|
||||
<span>AI 思路分析</span>
|
||||
</header>
|
||||
<div class="thinking-content custom-scrollbar" v-html="renderedThinking"></div>
|
||||
</aside>
|
||||
|
||||
<!-- 文稿选择对话框 -->
|
||||
<DocumentSelectorModal
|
||||
v-if="showDocSelector"
|
||||
@close="showDocSelector = false"
|
||||
@select="handleDocSelected"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useAppStore } from '../stores/app'
|
||||
import IconLibrary from './icons/IconLibrary.vue'
|
||||
import DocumentSelectorModal from './DocumentSelectorModal.vue'
|
||||
import { marked } from 'marked'
|
||||
|
||||
const appStore = useAppStore()
|
||||
const { mimicWriterState: mimicState } = storeToRefs(appStore)
|
||||
|
||||
const showDocSelector = ref(false)
|
||||
|
||||
// 计算属性
|
||||
const isButtonDisabled = computed(() => {
|
||||
return mimicState.value.isGenerating ||
|
||||
!mimicState.value.sourceArticle.trim() ||
|
||||
!mimicState.value.writingDirection.trim()
|
||||
})
|
||||
|
||||
const renderedContent = computed(() => {
|
||||
return mimicState.value.generatedContent ? marked(mimicState.value.generatedContent) : ''
|
||||
})
|
||||
|
||||
const renderedThinking = computed(() => {
|
||||
return mimicState.value.thinkingContent ? marked(mimicState.value.thinkingContent) : ''
|
||||
})
|
||||
|
||||
const renderedStyleAnalysis = computed(() => {
|
||||
return mimicState.value.styleAnalysis ? marked(mimicState.value.styleAnalysis) : ''
|
||||
})
|
||||
|
||||
const wordCount = computed(() => {
|
||||
const content = mimicState.value.generatedContent || ''
|
||||
// 简单的中文字数 + 英文单词统计
|
||||
const cn = (content.match(/[\u4e00-\u9fa5]/g) || []).length
|
||||
const en = (content.match(/[a-zA-Z0-9]+/g) || []).length
|
||||
return cn + en
|
||||
})
|
||||
|
||||
// 方法
|
||||
const analyzeStyle = async () => {
|
||||
try {
|
||||
await appStore.analyzeMimicStyleAction()
|
||||
} catch (err) {
|
||||
alert('分析风格失败: ' + err.message)
|
||||
}
|
||||
}
|
||||
|
||||
const generateContent = async () => {
|
||||
try {
|
||||
await appStore.mimicGenerateAction()
|
||||
} catch (err) {
|
||||
alert('生成内容失败: ' + err.message)
|
||||
}
|
||||
}
|
||||
|
||||
const splitParagraphs = () => {
|
||||
const paragraphs = appStore.splitParagraphsAction()
|
||||
if (paragraphs.length === 0) {
|
||||
alert('未检测到有效段落,请检查原稿内容')
|
||||
}
|
||||
}
|
||||
|
||||
const startParagraphMimic = async () => {
|
||||
try {
|
||||
await appStore.mimicAllParagraphsAction()
|
||||
} catch (err) {
|
||||
alert('分段仿写失败: ' + err.message)
|
||||
}
|
||||
}
|
||||
|
||||
const openDocSelector = () => {
|
||||
showDocSelector.value = true
|
||||
}
|
||||
|
||||
const handleDocSelected = (doc) => {
|
||||
mimicState.value.sourceArticle = doc.content
|
||||
showDocSelector.value = false
|
||||
}
|
||||
|
||||
const copyToClipboard = () => {
|
||||
navigator.clipboard.writeText(mimicState.value.generatedContent)
|
||||
.then(() => alert('已复制到剪贴板'))
|
||||
}
|
||||
|
||||
const saveToDocuments = () => {
|
||||
// 模拟保存逻辑,实际项目中可能需要调用 storage/database Action
|
||||
console.log('保存内容:', mimicState.value.generatedContent)
|
||||
alert('功能开发中:已将内容暂存')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.mimic-container {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 侧边栏样式 */
|
||||
.mimic-sidebar {
|
||||
width: 320px;
|
||||
border-right: 1px solid var(--border-default);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-shrink: 0;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
padding: var(--space-5) var(--space-6);
|
||||
border-bottom: 1px solid var(--border-default);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.sidebar-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
font-size: var(--text-base);
|
||||
font-weight: var(--font-bold);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.sidebar-content {
|
||||
flex: 1;
|
||||
padding: var(--space-6);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.config-section {
|
||||
margin-bottom: var(--space-8);
|
||||
}
|
||||
|
||||
.config-label {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--font-semibold);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.mimic-textarea {
|
||||
width: 100%;
|
||||
padding: var(--space-3);
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-lg);
|
||||
color: var(--text-primary);
|
||||
font-size: var(--text-sm);
|
||||
resize: none;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.mimic-textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-primary);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.sidebar-footer {
|
||||
padding: var(--space-5) var(--space-6);
|
||||
border-top: 1px solid var(--border-default);
|
||||
}
|
||||
|
||||
.generate-button {
|
||||
width: 100%;
|
||||
padding: var(--space-3.5);
|
||||
background: linear-gradient(135deg, #8b5cf6, #ec4899);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: var(--radius-xl);
|
||||
font-weight: var(--font-bold);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-2);
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
box-shadow: 0 4px 15px rgba(139, 92, 246, 0.3);
|
||||
}
|
||||
|
||||
.continue-button {
|
||||
width: 100%;
|
||||
padding: var(--space-3.5);
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-xl);
|
||||
font-weight: var(--font-bold);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-2);
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.continue-button:hover:not(:disabled) {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
border-color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.continue-button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
filter: grayscale(1);
|
||||
}
|
||||
|
||||
.split-button {
|
||||
width: 100%;
|
||||
padding: var(--space-3);
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-lg);
|
||||
font-weight: var(--font-semibold);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-2);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.split-button:hover:not(:disabled) {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
border-color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.split-button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.generate-button:hover:not(:disabled) {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 20px rgba(139, 92, 246, 0.4);
|
||||
}
|
||||
|
||||
.generate-button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
filter: grayscale(1);
|
||||
}
|
||||
|
||||
.generate-button.generating {
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% { opacity: 1; }
|
||||
50% { opacity: 0.7; }
|
||||
100% { opacity: 1; }
|
||||
}
|
||||
|
||||
/* 主预览区样式 */
|
||||
.mimic-main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: rgba(15, 23, 42, 0.2);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.empty-preview {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.empty-icon-box {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
border-radius: 30px;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: var(--space-6);
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
color: #8b5cf6;
|
||||
}
|
||||
|
||||
.preview-container {
|
||||
flex: 1;
|
||||
padding: var(--space-8);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.preview-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: var(--space-8);
|
||||
padding-bottom: var(--space-4);
|
||||
border-bottom: 1px solid var(--border-default);
|
||||
}
|
||||
|
||||
.preview-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: #8b5cf6;
|
||||
box-shadow: 0 0 8px #8b5cf6;
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
padding: var(--space-2);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.icon-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: var(--text-primary);
|
||||
border-color: var(--accent-primary);
|
||||
}
|
||||
|
||||
/* 思考面板样式 */
|
||||
.thinking-panel {
|
||||
width: 300px;
|
||||
border-left: 1px solid var(--border-default);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-shrink: 0;
|
||||
background: rgba(15, 23, 42, 0.4);
|
||||
}
|
||||
|
||||
.thinking-header {
|
||||
padding: var(--space-4) var(--space-5);
|
||||
border-bottom: 1px solid var(--border-default);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: var(--font-bold);
|
||||
color: var(--accent-warning);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.thinking-content {
|
||||
flex: 1;
|
||||
padding: var(--space-5);
|
||||
overflow-y: auto;
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.style-result {
|
||||
border-left: 2px solid #8b5cf6;
|
||||
background: rgba(139, 92, 246, 0.03);
|
||||
}
|
||||
|
||||
.style-description {
|
||||
font-size: 11px;
|
||||
line-height: 1.5;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.style-description :deep(p) {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
/* Markdown 样式补偿 */
|
||||
.markdown-body {
|
||||
color: var(--text-primary);
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
.markdown-body :deep(h1),
|
||||
.markdown-body :deep(h2),
|
||||
.markdown-body :deep(h3) {
|
||||
margin-top: 1.5em;
|
||||
margin-bottom: 0.5em;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.markdown-body :deep(p) {
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
|
||||
/* Badge 样式 */
|
||||
.badge-purple {
|
||||
background: rgba(139, 92, 246, 0.2);
|
||||
color: #a78bfa;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
@@ -113,8 +113,11 @@
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { getParadigmList } from '../config/paradigms.js'
|
||||
import { useParadigmStore } from '../stores/paradigm.js'
|
||||
import IconLibrary from './icons/IconLibrary.vue'
|
||||
|
||||
const paradigmStore = useParadigmStore()
|
||||
|
||||
const props = defineProps({
|
||||
visible: Boolean
|
||||
})
|
||||
@@ -130,9 +133,8 @@ const searchQuery = ref('')
|
||||
const loadParadigms = () => {
|
||||
const defaultList = getParadigmList()
|
||||
|
||||
// 从本地存储加载自定义范式
|
||||
const savedCustomParadigms = localStorage.getItem('customParadigms')
|
||||
const customList = savedCustomParadigms ? JSON.parse(savedCustomParadigms) : []
|
||||
// 从数据库加载自定义范式(唯一来源)
|
||||
const customList = paradigmStore.customParadigms || []
|
||||
|
||||
// 合并并处理
|
||||
paradigms.value = [...defaultList, ...customList]
|
||||
|
||||
@@ -184,6 +184,7 @@
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useAppStore } from '../stores/app'
|
||||
import { useParadigmStore } from '../stores/paradigm.js'
|
||||
import IconLibrary from './icons/IconLibrary.vue'
|
||||
import { getParadigmList } from '../config/paradigms.js'
|
||||
import { marked } from 'marked'
|
||||
@@ -191,6 +192,7 @@ import { Document, Packer, Paragraph, TextRun, HeadingLevel } from 'docx'
|
||||
import { saveAs } from 'file-saver'
|
||||
|
||||
const appStore = useAppStore()
|
||||
const paradigmStore = useParadigmStore()
|
||||
const { paradigmWriterState, activeParadigm, generatedContent } = storeToRefs(appStore)
|
||||
|
||||
const selectedParadigmId = ref('')
|
||||
@@ -227,9 +229,11 @@ onUnmounted(() => {
|
||||
// 加载完整的范式列表(包含自定义范式)
|
||||
const loadAllParadigms = () => {
|
||||
const defaultList = getParadigmList()
|
||||
const savedCustomParadigms = localStorage.getItem('customParadigms')
|
||||
const customList = savedCustomParadigms ? JSON.parse(savedCustomParadigms) : []
|
||||
paradigmList.value = [...defaultList, ...customList]
|
||||
|
||||
// 从数据库 store 加载自定义范式(唯一来源)
|
||||
const customParadigms = paradigmStore.customParadigms || []
|
||||
|
||||
paradigmList.value = [...defaultList, ...customParadigms]
|
||||
}
|
||||
|
||||
// 已生成章节数
|
||||
|
||||
@@ -413,7 +413,7 @@ function removeGuideline(index) {
|
||||
/**
|
||||
* 保存范式
|
||||
*/
|
||||
function saveParadigm() {
|
||||
async function saveParadigm() {
|
||||
if (!parsedParadigm.value) return
|
||||
|
||||
// 再次验证
|
||||
@@ -424,11 +424,16 @@ function saveParadigm() {
|
||||
}
|
||||
}
|
||||
|
||||
// 保存到 store
|
||||
paradigmStore.addCustomParadigm(parsedParadigm.value)
|
||||
try {
|
||||
// 保存到 store(等待异步操作完成)
|
||||
await paradigmStore.addCustomParadigm(parsedParadigm.value)
|
||||
|
||||
// 通知父组件
|
||||
emit('paradigm-created', parsedParadigm.value)
|
||||
emit('close')
|
||||
// 通知父组件
|
||||
emit('paradigm-created', parsedParadigm.value)
|
||||
emit('close')
|
||||
} catch (error) {
|
||||
console.error('保存范式失败:', error)
|
||||
alert('保存失败: ' + error.message)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user