feat: 新增以稿写稿和文章融合功能

- 新增以稿写稿 (MimicWriter) 功能:支持分析文章风格并仿写,包含风格分析、逐段仿写等模式
- 新增文章融合 (ArticleFusion) 功能:支持智能分析两篇文章优劣并生成融合版本
- 新增后端 API 服务器 (Express + SQLite) 用于范式管理
- 更新 .gitignore 忽略运行时数据文件 (data/, *.db)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
empty
2026-01-20 13:45:02 +08:00
parent 4e1c2776c6
commit d7f1664766
22 changed files with 4059 additions and 238 deletions

View File

@@ -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) {

View 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>

View 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>

View File

@@ -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' },

View File

@@ -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
// 获取文稿和素材数量

View File

@@ -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()

View 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>

View File

@@ -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]

View File

@@ -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]
}
// 已生成章节数

View File

@@ -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>