feat: 重构布局与模型配置
- 写作范式分析页面改为左中右三栏布局 - 范式分段写作页面改为左中右三栏布局 - 模型设置移至设置中心,支持多服务商选择 - API 配置通过 .env 文件管理,提升安全性 - 支持 DeepSeek、OpenAI、Claude、自定义服务商
This commit is contained in:
644
src/components/ParadigmWriterPanel.vue
Normal file
644
src/components/ParadigmWriterPanel.vue
Normal file
@@ -0,0 +1,644 @@
|
||||
<template>
|
||||
<aside class="paradigm-writer-panel">
|
||||
<!-- 头部 -->
|
||||
<header class="writer-header">
|
||||
<h1 class="writer-header-title">
|
||||
<span style="font-size: var(--text-xl)">📝</span> 范式分段写作
|
||||
</h1>
|
||||
<span class="badge badge-primary">新功能</span>
|
||||
</header>
|
||||
|
||||
<!-- 内容区 -->
|
||||
<div class="writer-content">
|
||||
<!-- 1. 选择范式 -->
|
||||
<section class="writer-section">
|
||||
<label class="writer-label">1. 选择写作范式</label>
|
||||
<select
|
||||
v-model="selectedParadigmId"
|
||||
@change="handleParadigmChange"
|
||||
class="writer-select"
|
||||
>
|
||||
<option value="" disabled>请选择范式...</option>
|
||||
<option
|
||||
v-for="p in paradigmList"
|
||||
:key="p.id"
|
||||
:value="p.id"
|
||||
>
|
||||
{{ p.icon }} {{ p.name }}
|
||||
</option>
|
||||
</select>
|
||||
<p v-if="activeParadigm" class="text-xs text-muted mt-2">
|
||||
{{ activeParadigm.description }}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<!-- 2. 分段编写 -->
|
||||
<section v-if="paradigmWriterState.selectedParadigmId" class="writer-section">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<label class="writer-label">2. 细化大纲与生成</label>
|
||||
<span class="text-[10px] text-muted">{{ paradigmWriterState.sections.length }} 个章节</span>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div
|
||||
v-for="(section, index) in paradigmWriterState.sections"
|
||||
:key="index"
|
||||
:class="['section-card', { 'active': currentSectionIndex === index }]"
|
||||
@click="currentSectionIndex = index"
|
||||
>
|
||||
<div class="section-card-header">
|
||||
<span :class="['section-type-badge', section.type === 'h2' ? 'primary' : 'secondary']">
|
||||
{{ section.type === 'h2' ? '主' : '子' }}
|
||||
</span>
|
||||
<h4 class="section-title text-sm truncate">{{ section.title }}</h4>
|
||||
|
||||
<div class="flex items-center gap-1">
|
||||
<span v-if="section.generatedContent" class="text-[10px] text-success">已生成</span>
|
||||
<span v-if="section.isGenerating" class="animate-spin text-xs">↻</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="currentSectionIndex === index" class="section-card-body mt-2 space-y-3">
|
||||
<!-- 输入类型选择 -->
|
||||
<div class="flex gap-2 mb-2">
|
||||
<button
|
||||
@click.stop="section.inputType = 'material'"
|
||||
:class="['input-type-btn', section.inputType === 'material' ? 'active' : '']"
|
||||
>
|
||||
📊 素材型
|
||||
</button>
|
||||
<button
|
||||
@click.stop="section.inputType = 'idea'"
|
||||
:class="['input-type-btn', section.inputType === 'idea' || !section.inputType ? 'active' : '']"
|
||||
>
|
||||
💡 思路型
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 结构化输入:核心论点 -->
|
||||
<div>
|
||||
<label class="text-[10px] text-muted block mb-1">📌 核心论点(必填)</label>
|
||||
<textarea
|
||||
v-model="section.corePoint"
|
||||
class="writer-textarea min-h-[50px]"
|
||||
placeholder="本章节的核心观点或主题..."
|
||||
@click.stop
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<!-- 结构化输入:数据/案例 -->
|
||||
<div>
|
||||
<label class="text-[10px] text-muted block mb-1">
|
||||
📊 数据/案例
|
||||
<span class="text-warning">({{ section.inputType === 'material' ? '严格引用' : '可润色' }})</span>
|
||||
</label>
|
||||
<textarea
|
||||
v-model="section.materialData"
|
||||
class="writer-textarea min-h-[50px]"
|
||||
placeholder="具体的数据、案例、引用内容..."
|
||||
@click.stop
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<!-- 结构化输入:补充说明 -->
|
||||
<div>
|
||||
<label class="text-[10px] text-muted block mb-1">💡 补充说明(可自由发挥)</label>
|
||||
<textarea
|
||||
v-model="section.supplementNote"
|
||||
class="writer-textarea min-h-[40px]"
|
||||
:placeholder="section.placeholder || '写作风格、语气要求等...'"
|
||||
@click.stop
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-between items-center gap-2 pt-1">
|
||||
<button
|
||||
@click.stop="generateSection(index)"
|
||||
:disabled="section.isGenerating"
|
||||
class="btn btn-primary flex-1 text-xs py-1.5"
|
||||
>
|
||||
{{ section.isGenerating ? '正在生成...' : (section.generatedContent ? '重新生成本节' : '生成本节') }}
|
||||
</button>
|
||||
<button
|
||||
v-if="section.generatedContent"
|
||||
@click.stop="copySectionContent(section)"
|
||||
class="btn btn-secondary text-[10px] py-1.5"
|
||||
title="复制内容"
|
||||
>
|
||||
📋
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 提示:未选择范式 -->
|
||||
<div v-else class="empty-state">
|
||||
<div class="empty-icon text-center">📂</div>
|
||||
<p class="text-sm text-muted text-center">请先选择一个写作范式以加载大纲</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 底部:全局操作 -->
|
||||
<footer class="writer-footer">
|
||||
<div v-if="paradigmWriterState.selectedParadigmId" class="space-y-3">
|
||||
<div class="flex items-center justify-between text-xs mb-1">
|
||||
<span class="text-muted">总进度</span>
|
||||
<span class="text-accent">{{ generatedSectionsCount }} / {{ paradigmWriterState.sections.length }}</span>
|
||||
</div>
|
||||
<div class="progress-bar-bg">
|
||||
<div
|
||||
class="progress-bar-fill"
|
||||
:style="{ width: `${(generatedSectionsCount / paradigmWriterState.sections.length) * 100}%` }"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
@click="generateAllSections"
|
||||
:disabled="paradigmWriterState.isGeneratingOverall"
|
||||
class="generate-button primary"
|
||||
>
|
||||
{{ paradigmWriterState.isGeneratingOverall ? '正在批量生成中...' : '一键生成全文 (Beta)' }}
|
||||
</button>
|
||||
</div>
|
||||
</footer>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useAppStore } from '../stores/app'
|
||||
import { getParadigmList } from '../config/paradigms.js'
|
||||
import { marked } from 'marked'
|
||||
import { Document, Packer, Paragraph, TextRun, HeadingLevel } from 'docx'
|
||||
import { saveAs } from 'file-saver'
|
||||
|
||||
const appStore = useAppStore()
|
||||
const { paradigmWriterState, activeParadigm, generatedContent } = storeToRefs(appStore)
|
||||
|
||||
const selectedParadigmId = ref('')
|
||||
const currentSectionIndex = ref(0)
|
||||
const paradigmList = ref([])
|
||||
|
||||
// 加载完整的范式列表(包含自定义范式)
|
||||
const loadAllParadigms = () => {
|
||||
const defaultList = getParadigmList()
|
||||
const savedCustomParadigms = localStorage.getItem('customParadigms')
|
||||
const customList = savedCustomParadigms ? JSON.parse(savedCustomParadigms) : []
|
||||
paradigmList.value = [...defaultList, ...customList]
|
||||
}
|
||||
|
||||
// 已生成章节数
|
||||
const generatedSectionsCount = computed(() => {
|
||||
return paradigmWriterState.value.sections.filter(s => s.generatedContent).length
|
||||
})
|
||||
|
||||
// 总生成内容
|
||||
const totalGeneratedContent = computed(() => {
|
||||
return paradigmWriterState.value.sections
|
||||
.filter(s => s.generatedContent)
|
||||
.map(s => `## ${s.title}\n\n${s.generatedContent}`)
|
||||
.join('\n\n')
|
||||
})
|
||||
|
||||
// 总字数
|
||||
const totalWordCount = computed(() => {
|
||||
return totalGeneratedContent.value.replace(/[#\n\s]/g, '').length
|
||||
})
|
||||
|
||||
// 渲染 Markdown
|
||||
const renderMarkdown = (text) => {
|
||||
if (!text) return ''
|
||||
return marked(text)
|
||||
}
|
||||
|
||||
// 返回上一页
|
||||
const goBack = () => {
|
||||
appStore.setCurrentPage('writer')
|
||||
}
|
||||
|
||||
// 处理范式选择切换
|
||||
const handleParadigmChange = () => {
|
||||
if (selectedParadigmId.value) {
|
||||
appStore.loadParadigmForWriting(selectedParadigmId.value)
|
||||
currentSectionIndex.value = 0
|
||||
}
|
||||
}
|
||||
|
||||
// 生成单个章节
|
||||
const generateSection = async (index) => {
|
||||
await appStore.generateSectionContentAction(index)
|
||||
}
|
||||
|
||||
// 批量生成所有章节 (串行执行以保证连贯性)
|
||||
const generateAllSections = async () => {
|
||||
if (paradigmWriterState.value.isGeneratingOverall) return
|
||||
|
||||
paradigmWriterState.value.isGeneratingOverall = true
|
||||
try {
|
||||
for (let i = 0; i < paradigmWriterState.value.sections.length; i++) {
|
||||
currentSectionIndex.value = i
|
||||
await appStore.generateSectionContentAction(i)
|
||||
}
|
||||
} finally {
|
||||
paradigmWriterState.value.isGeneratingOverall = false
|
||||
}
|
||||
}
|
||||
|
||||
// 复制章节内容
|
||||
const copySectionContent = (section) => {
|
||||
navigator.clipboard.writeText(section.generatedContent)
|
||||
.then(() => alert('已复制到剪贴板'))
|
||||
}
|
||||
|
||||
// 复制全文
|
||||
const copyFullContent = () => {
|
||||
navigator.clipboard.writeText(totalGeneratedContent.value)
|
||||
.then(() => alert('全文已复制到剪贴板'))
|
||||
}
|
||||
|
||||
// 导出为 Word
|
||||
const exportToWord = async () => {
|
||||
const sections = paradigmWriterState.value.sections.filter(s => s.generatedContent)
|
||||
|
||||
const children = []
|
||||
|
||||
sections.forEach(section => {
|
||||
// 添加标题
|
||||
children.push(
|
||||
new Paragraph({
|
||||
text: section.title,
|
||||
heading: section.type === 'h2' ? HeadingLevel.HEADING_2 : HeadingLevel.HEADING_3,
|
||||
})
|
||||
)
|
||||
|
||||
// 添加内容段落
|
||||
const paragraphs = section.generatedContent.split('\n\n')
|
||||
paragraphs.forEach(p => {
|
||||
if (p.trim()) {
|
||||
children.push(
|
||||
new Paragraph({
|
||||
children: [new TextRun(p.trim())],
|
||||
spacing: { after: 200 },
|
||||
})
|
||||
)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const doc = new Document({
|
||||
sections: [{
|
||||
properties: {},
|
||||
children: children
|
||||
}]
|
||||
})
|
||||
|
||||
const blob = await Packer.toBlob(doc)
|
||||
saveAs(blob, `${activeParadigm.value?.name || '范式写作'}_${new Date().toLocaleDateString()}.docx`)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadAllParadigms()
|
||||
|
||||
if (paradigmWriterState.value.selectedParadigmId) {
|
||||
selectedParadigmId.value = paradigmWriterState.value.selectedParadigmId
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 侧边面板 */
|
||||
.paradigm-writer-panel {
|
||||
width: 400px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-right: 1px solid var(--border-default);
|
||||
background: var(--bg-secondary);
|
||||
flex-shrink: 0;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
/* 右侧预览面板 */
|
||||
.writer-preview-panel {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--bg-primary);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.preview-header {
|
||||
padding: var(--space-4);
|
||||
border-bottom: 1px solid var(--border-default);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.preview-title {
|
||||
font-weight: var(--font-semibold);
|
||||
font-size: var(--text-lg);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.preview-stats {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.preview-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: var(--space-6);
|
||||
}
|
||||
|
||||
.preview-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.preview-empty-icon {
|
||||
font-size: 64px;
|
||||
margin-bottom: var(--space-4);
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.preview-markdown {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.preview-section {
|
||||
margin-bottom: var(--space-6);
|
||||
}
|
||||
|
||||
.preview-h2 {
|
||||
font-size: var(--text-xl);
|
||||
font-weight: var(--font-bold);
|
||||
color: var(--text-primary);
|
||||
margin-bottom: var(--space-3);
|
||||
padding-bottom: var(--space-2);
|
||||
border-bottom: 2px solid var(--accent-primary);
|
||||
}
|
||||
|
||||
.preview-h3 {
|
||||
font-size: var(--text-lg);
|
||||
font-weight: var(--font-semibold);
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.preview-text {
|
||||
font-size: var(--text-base);
|
||||
line-height: 1.8;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.preview-text :deep(p) {
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
.preview-footer {
|
||||
padding: var(--space-4);
|
||||
border-top: 1px solid var(--border-default);
|
||||
display: flex;
|
||||
gap: var(--space-3);
|
||||
justify-content: flex-end;
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.export-btn {
|
||||
padding: var(--space-2) var(--space-4);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--font-medium);
|
||||
border: 1px solid var(--border-default);
|
||||
background: var(--bg-elevated);
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.export-btn:hover {
|
||||
background: var(--bg-sunken);
|
||||
}
|
||||
|
||||
.export-btn.primary {
|
||||
background: var(--accent-primary);
|
||||
color: var(--text-inverse);
|
||||
border-color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.export-btn.primary:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
/* 输入类型按钮 */
|
||||
.input-type-btn {
|
||||
flex: 1;
|
||||
padding: var(--space-1) var(--space-2);
|
||||
font-size: 10px;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--border-default);
|
||||
background: var(--bg-elevated);
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.input-type-btn.active {
|
||||
background: var(--accent-primary);
|
||||
color: var(--text-inverse);
|
||||
border-color: var(--accent-primary);
|
||||
}
|
||||
|
||||
/* 原有样式保留 */
|
||||
.writer-header {
|
||||
padding: var(--space-4);
|
||||
border-bottom: 1px solid var(--border-default);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.writer-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);
|
||||
}
|
||||
|
||||
.writer-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: var(--space-4);
|
||||
}
|
||||
|
||||
.writer-section {
|
||||
margin-bottom: var(--space-6);
|
||||
}
|
||||
|
||||
.writer-label {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--font-medium);
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: var(--space-2);
|
||||
display: block;
|
||||
}
|
||||
|
||||
.writer-select {
|
||||
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);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.writer-select:focus {
|
||||
border-color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.section-card {
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-3);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.section-card:hover {
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
|
||||
.section-card.active {
|
||||
border-color: var(--accent-primary);
|
||||
background: var(--bg-primary);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.section-card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.section-type-badge {
|
||||
font-size: 8px;
|
||||
padding: 1px 4px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.section-type-badge.primary {
|
||||
background: var(--info-bg);
|
||||
color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.section-type-badge.secondary {
|
||||
background: var(--bg-sunken);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.section-title {
|
||||
flex: 1;
|
||||
font-weight: var(--font-medium);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.section-card-body {
|
||||
border-top: 1px solid var(--border-default);
|
||||
padding-top: var(--space-2);
|
||||
}
|
||||
|
||||
.writer-textarea {
|
||||
width: 100%;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-2);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-primary);
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.writer-textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding-top: var(--space-20);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
font-size: 48px;
|
||||
margin-bottom: var(--space-4);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.progress-bar-bg {
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
background: var(--bg-elevated);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-bar-fill {
|
||||
height: 100%;
|
||||
background: var(--accent-primary);
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.writer-footer {
|
||||
padding: var(--space-4);
|
||||
border-top: 1px solid var(--border-default);
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.generate-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, var(--accent-primary), #6366f1);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-2);
|
||||
transition: all var(--transition-normal);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
|
||||
.generate-button:hover:not(:disabled) {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.generate-button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user