Files
ai-write/src/components/ParadigmWriterPanel.vue
empty a6efb5a7e7 feat: 重构布局与模型配置
- 写作范式分析页面改为左中右三栏布局
- 范式分段写作页面改为左中右三栏布局
- 模型设置移至设置中心,支持多服务商选择
- API 配置通过 .env 文件管理,提升安全性
- 支持 DeepSeek、OpenAI、Claude、自定义服务商
2026-01-11 22:05:28 +08:00

645 lines
17 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>