feat: 添加大纲写作功能与服务器端改进

- 新增 OutlineWriterPanel 和 OutlineResultPanel 组件
- 重构服务器端数据库接口 (server/db.js)
- 添加 LLM 服务模块 (server/llm.js)
- 更新配置和设置面板
- 优化文档选择器和素材面板
- 更新部署文档和环境变量示例
This commit is contained in:
empty
2026-01-21 17:23:48 +08:00
parent d7f1664766
commit 94301c81a6
29 changed files with 3430 additions and 1373 deletions

View File

@@ -175,7 +175,6 @@ 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'
import IconLibrary from './icons/IconLibrary.vue'
@@ -303,6 +302,18 @@ const openEditModal = (paradigm) => {
form.tagsInput = (paradigm.tags || []).join(', ')
form.tagClass = paradigm.tagClass || 'bg-blue-900/30 text-blue-300'
form.specializedPrompt = paradigm.specializedPrompt || ''
// 加载章节大纲
form.sections = (paradigm.sections || []).map(s => ({
title: s.title || '',
description: s.description || '',
weight: s.weight || 20
}))
// 加载检查规范
form.expertGuidelines = (paradigm.expertGuidelines || []).map(g =>
typeof g === 'object'
? { title: g.title || '', description: g.description || '', scope: g.scope || 'document' }
: { title: '', description: g, scope: 'document' }
)
}

View File

@@ -835,10 +835,10 @@ const saveToDocument = async () => {
// 保存版本历史
const changeNote = `范式润色:修改了 ${changedCount}`
saveDocumentVersion(sourceDocId.value, articleContent.value, changeNote)
await saveDocumentVersion(sourceDocId.value, articleContent.value, changeNote)
// 更新文稿内容
updateDocument(sourceDocId.value, {
await updateDocument(sourceDocId.value, {
content: articleContent.value
})

View File

@@ -1084,10 +1084,10 @@ const saveRightContent = async () => {
if (stats.removed > 0) changeNote += `,删除了 ${stats.removed}`
// 保存新版本
const versionNumber = saveDocumentVersion(rightSourceDocId.value, rightContent.value, changeNote)
const versionNumber = await saveDocumentVersion(rightSourceDocId.value, rightContent.value, changeNote)
// 同时更新文稿主内容
updateDocument(rightSourceDocId.value, { content: rightContent.value })
await updateDocument(rightSourceDocId.value, { content: rightContent.value })
// 更新原始内容为当前内容(标记为已保存)
rightOriginalContent.value = rightContent.value
@@ -1127,10 +1127,10 @@ const saveLeftContent = async () => {
if (stats.removed > 0) changeNote += `,删除了 ${stats.removed}`
// 保存新版本
const versionNumber = saveDocumentVersion(leftSourceDocId.value, leftContent.value, changeNote)
const versionNumber = await saveDocumentVersion(leftSourceDocId.value, leftContent.value, changeNote)
// 同时更新文稿主内容
updateDocument(leftSourceDocId.value, { content: leftContent.value })
await updateDocument(leftSourceDocId.value, { content: leftContent.value })
// 更新原始内容为当前内容(标记为已保存)
leftOriginalContent.value = leftContent.value

View File

@@ -115,7 +115,7 @@ const filteredDocuments = computed(() => {
const loadDocuments = async () => {
try {
const { getAllDocuments } = await import('../db/index.js')
documents.value = getAllDocuments()
documents.value = await getAllDocuments()
} catch (error) {
console.error('加载文稿失败:', error)
}

View File

@@ -170,7 +170,7 @@
<script setup>
import { ref, watch, computed, defineProps, defineEmits } from 'vue'
import { getDocumentVersions, getDocumentById, saveDocumentVersion, updateDocument } from '../db/index.js'
import { getDocumentVersions, saveDocumentVersion, updateDocument } from '../db/index.js'
import { computeDiff, getDiffStats } from '../utils/textDiff.js'
import IconLibrary from './icons/IconLibrary.vue'
@@ -203,7 +203,7 @@ const loadVersions = async () => {
isLoading.value = true
try {
versions.value = getDocumentVersions(props.documentId)
versions.value = await getDocumentVersions(props.documentId)
} catch (error) {
console.error('加载版本历史失败:', error)
versions.value = []
@@ -232,10 +232,10 @@ const restoreVersion = async (version) => {
try {
// 先保存当前内容为新版本
saveDocumentVersion(props.documentId, props.currentContent, '恢复前自动保存')
await saveDocumentVersion(props.documentId, props.currentContent, '恢复前自动保存')
// 更新文稿内容为旧版本
updateDocument(props.documentId, { content: version.content })
await updateDocument(props.documentId, { content: version.content })
// 触发恢复事件,通知父组件刷新
emit('restore', version.content)
@@ -270,4 +270,3 @@ watch(() => props.visible, (newVal) => {
}
})
</script>

View File

@@ -179,7 +179,7 @@ const getCountByStatus = (status) => {
const loadDocuments = async () => {
try {
const { getAllDocuments } = await import('../db/index.js')
documents.value = getAllDocuments()
documents.value = await getAllDocuments()
} catch (error) {
console.error('加载文稿失败:', error)
}
@@ -207,7 +207,7 @@ const toggleVersionPanel = () => {
const createNewDocument = async () => {
try {
const { createDocument } = await import('../db/index.js')
const id = createDocument({
const id = await createDocument({
title: '未命名文稿',
content: '',
status: 'draft'
@@ -237,7 +237,7 @@ const duplicateDocument = async () => {
try {
const { createDocument } = await import('../db/index.js')
createDocument({
await createDocument({
title: doc.title + ' (副本)',
content: doc.content,
paradigmId: doc.paradigm_id,
@@ -262,7 +262,7 @@ const deleteSelectedDocument = async () => {
try {
const { deleteDocument } = await import('../db/index.js')
deleteDocument(selectedDocId.value)
await deleteDocument(selectedDocId.value)
selectedDocId.value = null
showDeleteConfirm.value = false
await loadDocuments()

View File

@@ -64,6 +64,7 @@ const navItems = [
{ id: 'mimicWriter', label: '以稿写稿', icon: 'copy' },
{ id: 'analysis', label: '范式库', icon: 'analysis' },
{ id: 'paradigmWriter', label: '范式写作', icon: 'article' },
{ id: 'outlineWriter', label: '提纲写作', icon: 'folder' },
{ id: 'articleFusion', label: '文章融合', icon: 'sparkles' },
{ id: 'documents', label: '文稿库', icon: 'folder' },
{ id: 'materials', label: '素材库', icon: 'chart' },

View File

@@ -91,7 +91,7 @@
<!-- Footer -->
<footer class="home-footer">
<div class="footer-divider"></div>
<p class="footer-text">Pro · 基于 DeepSeek API · 极简高效</p>
<p class="footer-text">Pro · 服务端 LLM 代理 · 极简高效</p>
</footer>
</div>
</template>

View File

@@ -374,15 +374,110 @@
</label>
<textarea
v-model="paradigmEditState.editForm.specializedPrompt"
rows="16"
rows="12"
class="w-full px-4 py-3 bg-slate-800 border border-slate-600 rounded-lg text-white outline-none focus:border-blue-500 resize-y font-mono text-sm leading-relaxed"
placeholder="在此粘贴您的完整 Prompt..."
></textarea>
<p class="text-xs text-slate-500 mt-2 flex items-start gap-1.5">
<IconLibrary name="info" :size="12" class="mt-0.5" />
<span>提示这是 AI 调用时的核心指令完整的 Prompt 应包含角色目标步骤和输出格式</span>
</p>
</div>
<!-- 章节大纲 -->
<div>
<label class="block text-sm text-slate-400 mb-2 flex items-center justify-between">
<span class="flex items-center gap-2">
<IconLibrary name="folder" :size="14" class="text-green-400" />
<span>章节大纲</span>
<span class="text-xs text-slate-500">拖拽调整顺序</span>
</span>
<button @click="addSection" class="text-xs px-2 py-1 bg-green-600 hover:bg-green-500 text-white rounded">
+ 添加章节
</button>
</label>
<div class="space-y-2">
<div
v-for="(section, index) in paradigmEditState.editForm.sections"
:key="index"
draggable="true"
@dragstart="handleDragStart($event, index, 'section')"
@dragover.prevent="handleDragOver($event, index, 'section')"
@drop="handleDrop($event, index, 'section')"
@dragend="handleDragEnd"
:class="['flex gap-2 items-start bg-slate-800 border rounded p-2 cursor-move transition-all',
dragState.type === 'section' && dragState.overIndex === index
? 'border-green-500 bg-green-900/20'
: 'border-slate-600 hover:border-slate-500']"
>
<div class="flex flex-col items-center justify-center text-slate-500 pr-1">
<span class="text-xs"></span>
</div>
<div class="flex-1 space-y-2">
<input
v-model="section.title"
class="w-full px-2 py-1 bg-slate-700 border border-slate-600 rounded text-white text-sm font-medium"
placeholder="章节标题"
@mousedown.stop
/>
<textarea
v-model="section.description"
rows="2"
class="w-full px-2 py-1 bg-slate-700 border border-slate-500 rounded text-slate-300 text-xs resize-y"
placeholder="章节描述:该章节应包含的主要内容..."
@mousedown.stop
></textarea>
</div>
<button @click="removeSection(index)" class="text-red-400 hover:text-red-300 px-2 py-1 text-lg">×</button>
</div>
<p v-if="!paradigmEditState.editForm.sections?.length" class="text-xs text-slate-500 italic">暂无章节点击"添加章节"创建</p>
</div>
</div>
<!-- 检查规范 -->
<div>
<label class="block text-sm text-slate-400 mb-2 flex items-center justify-between">
<span class="flex items-center gap-2">
<IconLibrary name="check" :size="14" class="text-blue-400" />
<span>检查规范</span>
<span class="text-xs text-slate-500">用于验证生成内容</span>
</span>
<button @click="addGuideline" class="text-xs px-2 py-1 bg-blue-600 hover:bg-blue-500 text-white rounded">
+ 添加规范
</button>
</label>
<div class="space-y-2">
<div
v-for="(guideline, index) in paradigmEditState.editForm.expertGuidelines"
:key="index"
class="flex gap-2 items-start bg-slate-800 border border-slate-600 rounded p-2"
>
<div class="flex-1 space-y-1">
<input
v-model="guideline.title"
class="w-full px-2 py-1 bg-slate-700 border border-slate-600 rounded text-white text-sm"
placeholder="规范名称"
/>
<input
v-model="guideline.description"
class="w-full px-2 py-1 bg-slate-700 border border-slate-500 rounded text-slate-300 text-xs"
placeholder="检查标准描述"
/>
<select
v-model="guideline.scope"
class="px-2 py-1 bg-slate-700 border border-slate-500 rounded text-xs text-slate-300"
>
<option value="sentence">句子级</option>
<option value="paragraph">段落级</option>
<option value="document">全文级</option>
</select>
</div>
<button @click="removeGuideline(index)" class="text-red-400 hover:text-red-300 px-2 py-1">×</button>
</div>
<p v-if="!paradigmEditState.editForm.expertGuidelines?.length" class="text-xs text-slate-500 italic">暂无检查规范</p>
</div>
</div>
<p class="text-xs text-slate-500 mt-2 flex items-start gap-1.5">
<IconLibrary name="info" :size="12" class="mt-0.5" />
<span>提示章节大纲用于"范式分段写作"检查规范用于自动验证生成内容是否合规</span>
</p>
</div>
<div class="px-6 py-4 border-t border-slate-700 flex justify-end gap-3">
@@ -599,6 +694,54 @@ const colorOptions = [
{ label: '青色', class: 'bg-cyan-900/30 text-cyan-300' }
]
// 拖拽状态
const dragState = ref({
type: null, // 'section' | 'guideline'
dragIndex: null,
overIndex: null
})
// 拖拽开始
const handleDragStart = (event, index, type) => {
dragState.value.type = type
dragState.value.dragIndex = index
event.dataTransfer.effectAllowed = 'move'
}
// 拖拽经过
const handleDragOver = (event, index, type) => {
if (dragState.value.type === type) {
dragState.value.overIndex = index
}
}
// 放置
const handleDrop = (event, targetIndex, type) => {
if (dragState.value.type !== type) return
const sourceIndex = dragState.value.dragIndex
if (sourceIndex === targetIndex) return
const list = type === 'section'
? paradigmEditState.value.editForm.sections
: paradigmEditState.value.editForm.expertGuidelines
if (!list) return
// 移动元素
const [movedItem] = list.splice(sourceIndex, 1)
list.splice(targetIndex, 0, movedItem)
handleDragEnd()
}
// 拖拽结束
const handleDragEnd = () => {
dragState.value.type = null
dragState.value.dragIndex = null
dragState.value.overIndex = null
}
// 关闭编辑
const closeParadigmEdit = () => {
paradigmEditState.value.isEditing = false
@@ -606,6 +749,40 @@ const closeParadigmEdit = () => {
paradigmEditState.value.editingParadigmId = null
}
// 添加章节
const addSection = () => {
if (!paradigmEditState.value.editForm.sections) {
paradigmEditState.value.editForm.sections = []
}
paradigmEditState.value.editForm.sections.push({
title: '',
description: '',
weight: 20
})
}
// 删除章节
const removeSection = (index) => {
paradigmEditState.value.editForm.sections.splice(index, 1)
}
// 添加检查规范
const addGuideline = () => {
if (!paradigmEditState.value.editForm.expertGuidelines) {
paradigmEditState.value.editForm.expertGuidelines = []
}
paradigmEditState.value.editForm.expertGuidelines.push({
title: '',
description: '',
scope: 'document'
})
}
// 删除检查规范
const removeGuideline = (index) => {
paradigmEditState.value.editForm.expertGuidelines.splice(index, 1)
}
// 保存范式编辑
const saveParadigmEdit = async () => {
const form = paradigmEditState.value.editForm
@@ -621,6 +798,8 @@ const saveParadigmEdit = async () => {
tags: form.tagsInput.split(',').map(t => t.trim()).filter(t => t),
tagClass: form.tagClass,
specializedPrompt: form.specializedPrompt,
sections: (form.sections || []).filter(s => s.title), // 过滤空标题
expertGuidelines: (form.expertGuidelines || []).filter(g => g.title || g.description),
isCustom: true,
createdAt: paradigmEditState.value.isAddMode ? new Date().toISOString() : undefined
}
@@ -669,7 +848,7 @@ const saveDocument = async () => {
try {
const { updateDocument } = await import('../db/index.js')
updateDocument(currentDocument.value.id, {
await updateDocument(currentDocument.value.id, {
title: documentTitle.value,
content: documentContent.value
})
@@ -693,7 +872,7 @@ const changeDocStatus = async (status) => {
try {
const { updateDocument } = await import('../db/index.js')
updateDocument(currentDocument.value.id, { status })
await updateDocument(currentDocument.value.id, { status })
currentDocument.value.status = status
} catch (error) {
console.error('修改状态失败:', error)

View File

@@ -371,7 +371,7 @@ const getTypeIcon = (type) => {
const loadMaterials = async () => {
try {
const { getAllReferences } = await import('../db/index.js')
materials.value = getAllReferences()
materials.value = await getAllReferences()
} catch (error) {
console.error('加载素材失败:', error)
}
@@ -454,9 +454,9 @@ const saveMaterial = async () => {
}
if (isAddMode.value) {
addReference(materialData)
await addReference(materialData)
} else {
updateReference(editForm.id, materialData)
await updateReference(editForm.id, materialData)
}
await loadMaterials()
@@ -484,7 +484,7 @@ const deleteMaterial = async () => {
try {
const { deleteReference } = await import('../db/index.js')
deleteReference(selectedId.value)
await deleteReference(selectedId.value)
selectedId.value = null
showDeleteConfirm.value = false
await loadMaterials()

View File

@@ -0,0 +1,302 @@
<template>
<div class="outline-result-panel">
<!-- 头部 -->
<header class="result-header">
<h2 class="result-title">生成结果预览</h2>
<div v-if="hasContent" class="header-actions">
<button @click="copyAll" class="action-btn">
<IconLibrary name="copy" :size="14" />
<span>复制全文</span>
</button>
<button @click="exportToWord" class="action-btn primary">
<IconLibrary name="download" :size="14" />
<span>导出 Word</span>
</button>
</div>
</header>
<!-- 内容区 -->
<div class="result-content">
<!-- 空状态 -->
<div v-if="!hasContent" class="empty-state">
<div class="empty-icon">
<IconLibrary name="document" :size="48" />
</div>
<p>输入大纲并点击生成</p>
<p class="empty-hint">生成的内容将在这里实时预览</p>
</div>
<!-- 生成的内容 -->
<div v-else class="generated-sections">
<div
v-for="(section, index) in flatSections"
:key="index"
v-show="section.content"
class="section-block"
>
<div class="section-heading" :class="'level-' + section.level">
{{ getHeadingPrefix(section.level) }}{{ section.title }}
</div>
<div class="section-content" v-html="renderMarkdown(section.content)"></div>
</div>
</div>
</div>
<!-- 统计信息 -->
<footer v-if="hasContent" class="result-footer">
<span>总计 {{ totalWords }} </span>
<span>{{ generatedCount }}/{{ flatSections.length }} 章节已生成</span>
</footer>
</div>
</template>
<script setup>
import { 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 { outlineWriterState } = storeToRefs(appStore)
const parsedSections = computed(() => outlineWriterState.value.parsedSections)
// 扁平化章节列表
const flatSections = computed(() => {
const result = []
const flatten = (sections) => {
sections.forEach(s => {
result.push(s)
if (s.children && s.children.length > 0) {
flatten(s.children)
}
})
}
flatten(parsedSections.value)
return result
})
const hasContent = computed(() =>
flatSections.value.some(s => s.content)
)
const generatedCount = computed(() =>
flatSections.value.filter(s => s.content).length
)
const totalWords = computed(() =>
flatSections.value.reduce((sum, s) => sum + (s.content?.length || 0), 0)
)
// 获取标题前缀
const getHeadingPrefix = (level) => {
if (level === 1) return ''
if (level === 2) return ''
return ''
}
// 渲染 Markdown
const renderMarkdown = (content) => {
if (!content) return ''
return marked.parse(content)
}
// 复制全文
const copyAll = async () => {
const fullText = flatSections.value
.filter(s => s.content)
.map(s => {
const prefix = '#'.repeat(s.level) + ' '
return `${prefix}${s.title}\n\n${s.content}`
})
.join('\n\n')
try {
await navigator.clipboard.writeText(fullText)
alert('已复制到剪贴板')
} catch (e) {
console.error('复制失败:', e)
}
}
// 导出 Word
const exportToWord = async () => {
try {
const { Document, Paragraph, TextRun, Packer, HeadingLevel } = await import('docx')
const { saveAs } = await import('file-saver')
const children = []
flatSections.value.forEach(section => {
if (section.content) {
const headingLevel = section.level === 1 ? HeadingLevel.HEADING_1
: section.level === 2 ? HeadingLevel.HEADING_2
: HeadingLevel.HEADING_3
children.push(
new Paragraph({
text: section.title,
heading: headingLevel
}),
new Paragraph({
children: [new TextRun(section.content)]
})
)
}
})
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>
.outline-result-panel {
flex: 1;
display: flex;
flex-direction: column;
background: var(--bg-primary);
overflow: hidden;
}
.result-header {
padding: var(--space-4) var(--space-6);
border-bottom: 1px solid var(--border-default);
display: flex;
align-items: center;
justify-content: space-between;
background: var(--bg-secondary);
}
.result-title {
font-size: var(--text-lg);
font-weight: var(--font-semibold);
color: var(--text-primary);
}
.header-actions {
display: flex;
gap: var(--space-2);
}
.action-btn {
display: flex;
align-items: center;
gap: var(--space-1);
padding: var(--space-2) var(--space-3);
font-size: var(--text-xs);
border-radius: var(--radius-md);
background: var(--bg-elevated);
color: var(--text-secondary);
border: 1px solid var(--border-default);
cursor: pointer;
transition: all 0.15s;
}
.action-btn:hover {
background: var(--bg-primary);
color: var(--text-primary);
}
.action-btn.primary {
background: var(--accent-primary);
color: white;
border: none;
}
.action-btn.primary:hover {
background: var(--accent-primary-hover);
}
.result-content {
flex: 1;
overflow-y: auto;
padding: var(--space-6);
}
.empty-state {
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: var(--text-muted);
}
.empty-icon {
width: 80px;
height: 80px;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg-secondary);
border: 1px solid var(--border-default);
border-radius: var(--radius-xl);
margin-bottom: var(--space-4);
opacity: 0.5;
}
.empty-hint {
font-size: var(--text-xs);
margin-top: var(--space-1);
}
.generated-sections {
max-width: 800px;
margin: 0 auto;
}
.section-block {
margin-bottom: var(--space-6);
}
.section-heading {
font-weight: var(--font-bold);
color: var(--text-primary);
margin-bottom: var(--space-3);
}
.section-heading.level-1 {
font-size: var(--text-xl);
border-bottom: 2px solid var(--accent-primary);
padding-bottom: var(--space-2);
}
.section-heading.level-2 {
font-size: var(--text-lg);
color: var(--accent-primary);
}
.section-heading.level-3 {
font-size: var(--text-base);
color: var(--text-secondary);
}
.section-content {
font-size: var(--text-sm);
line-height: 1.8;
color: var(--text-primary);
}
.section-content :deep(p) {
margin-bottom: var(--space-3);
}
.result-footer {
padding: var(--space-3) var(--space-6);
border-top: 1px solid var(--border-default);
background: var(--bg-secondary);
display: flex;
justify-content: space-between;
font-size: var(--text-xs);
color: var(--text-muted);
}
</style>

View File

@@ -0,0 +1,845 @@
<template>
<aside class="outline-writer-panel">
<!-- 头部 -->
<header class="panel-header">
<h1 class="panel-title">
<IconLibrary name="folder" :size="20" />
<span>提纲写作</span>
</h1>
<span class="badge badge-primary">新功能</span>
</header>
<!-- 内容区 -->
<div class="panel-content">
<!-- 1. 写作背景 -->
<section class="panel-section">
<label class="section-label">1. 写作背景可选</label>
<textarea
v-model="writingContext"
class="context-textarea"
placeholder="描述文章的整体目标、受众、风格要求..."
rows="2"
></textarea>
</section>
<!-- 2. 素材库 -->
<section class="panel-section">
<div class="flex items-center justify-between mb-2">
<label class="section-label">2. 素材库</label>
<button
@click="addMaterial"
class="text-xs px-2 py-1 bg-purple-600 hover:bg-purple-500 text-white rounded"
>
+ 添加素材
</button>
</div>
<!-- 素材列表 -->
<div v-if="materials.length > 0" class="materials-list">
<div
v-for="(mat, index) in materials"
:key="mat.id"
class="material-item"
>
<div class="material-info">
<span class="material-icon">📄</span>
<span class="material-name">{{ mat.name }}</span>
<span class="material-size">({{ mat.content?.length || 0 }})</span>
</div>
<div class="material-actions">
<button @click="editMaterial(mat)" class="text-blue-400 hover:text-blue-300 text-xs">编辑</button>
<button @click="removeMaterial(index)" class="text-red-400 hover:text-red-300 text-xs">删除</button>
</div>
</div>
</div>
<p v-else class="text-xs text-slate-500 italic">暂无素材点击"添加素材"导入参考资料</p>
<!-- 上下文统计 -->
<div v-if="materials.length > 0" class="context-stats">
<div class="stats-row">
<span>素材总字数</span>
<span :class="{ 'text-yellow-400': totalMaterialChars > 20000, 'text-red-400': totalMaterialChars > 40000 }">
{{ totalMaterialChars.toLocaleString() }}
</span>
<span class="text-slate-500"> {{ estimatedTokens.toLocaleString() }} tokens</span>
</div>
<div v-if="totalMaterialChars > 20000" class="warning-box">
<span v-if="totalMaterialChars > 40000" class="text-red-400">
素材过长可能超出模型上下文限制建议精简
</span>
<span v-else class="text-yellow-400">
素材较长生成时将自动截断
</span>
</div>
</div>
</section>
<!-- 素材编辑弹窗 -->
<div v-if="showMaterialModal" class="material-modal-overlay" @click.self="closeMaterialModal">
<div class="material-modal">
<div class="modal-header">
<h3>{{ editingMaterial?.id ? '编辑素材' : '添加素材' }}</h3>
<button @click="closeMaterialModal" class="text-slate-400 hover:text-white">×</button>
</div>
<div class="modal-body">
<input
v-model="editingMaterial.name"
class="modal-input"
placeholder="素材名称(如:政策文件、去年材料)"
/>
<textarea
v-model="editingMaterial.content"
class="modal-textarea"
placeholder="粘贴素材内容..."
rows="12"
></textarea>
</div>
<div class="modal-footer">
<button @click="closeMaterialModal" class="modal-btn secondary">取消</button>
<button @click="saveMaterial" class="modal-btn primary">保存</button>
</div>
</div>
</div>
<!-- 3. 大纲输入 -->
<section class="panel-section">
<div class="flex items-center justify-between mb-2">
<label class="section-label">3. 输入大纲</label>
<div class="flex items-center gap-2">
<button
@click="parseOutline"
class="text-xs px-2 py-1 bg-blue-600 hover:bg-blue-500 text-white rounded"
:disabled="!rawOutline.trim()"
>
解析大纲
</button>
</div>
</div>
<textarea
v-model="rawOutline"
class="outline-textarea"
placeholder="支持多种格式,例如:
一、引言
1.1 背景介绍
1.2 问题提出
二、主体论述
2.1 论点一
2.2 论点二
三、结论
或使用 # 标记层级:
# 一级标题
## 二级标题"
rows="8"
></textarea>
<p class="text-xs text-slate-500 mt-1">
支持... / 1. 1.1 / # ## / 缩进层级
</p>
</section>
<!-- 3. 解析后的章节列表 -->
<section v-if="parsedSections.length > 0" class="panel-section">
<div class="flex items-center justify-between mb-2">
<label class="section-label">3. 章节列表</label>
<span class="text-xs text-slate-500">{{ flatSections.length }} 个章节</span>
</div>
<div class="sections-list">
<div
v-for="(section, index) in flatSections"
:key="index"
:class="['section-item', {
'active': currentSectionIndex === index,
'generated': section.content
}]"
:style="{ paddingLeft: (section.level - 1) * 16 + 12 + 'px' }"
@click="selectSection(index)"
>
<div class="section-header">
<span class="level-badge" :class="'level-' + section.level">
H{{ section.level }}
</span>
<span class="section-title">{{ section.title }}</span>
<span v-if="section.content" class="status-dot generated"></span>
<span v-else-if="section.isGenerating" class="status-dot generating"></span>
</div>
<!-- 展开的编辑区 -->
<div v-if="currentSectionIndex === index" class="section-editor" @click.stop>
<!-- 关联素材 -->
<div v-if="materials.length > 0" class="linked-materials">
<p class="text-xs text-slate-400 mb-1">📎 关联素材</p>
<div class="material-checkboxes">
<label
v-for="mat in materials"
:key="mat.id"
class="material-checkbox"
>
<input
type="checkbox"
:checked="section.linkedMaterials?.includes(mat.id)"
@change="toggleSectionMaterial(section, mat.id)"
/>
<span>{{ mat.name }}</span>
</label>
</div>
</div>
<textarea
v-model="section.hint"
class="hint-textarea"
placeholder="本节专属写作提示(可选)..."
rows="2"
></textarea>
<button
@click="generateSection(index)"
:disabled="section.isGenerating || isGenerating"
class="generate-section-btn"
>
{{ section.isGenerating ? '生成中...' : (section.content ? '重新生成' : '生成本节') }}
</button>
</div>
</div>
</div>
</section>
</div>
<!-- 底部操作区 -->
<footer class="panel-footer">
<button
v-if="!isGenerating"
@click="generateAll"
:disabled="flatSections.length === 0"
class="generate-all-btn"
>
一键生成全文
</button>
<button
v-else
@click="abortGeneration"
class="abort-btn"
>
停止生成 ({{ currentSectionIndex + 1 }}/{{ flatSections.length }})
</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 { outlineWriterState } = storeToRefs(appStore)
// Local refs synced to store
const rawOutline = computed({
get: () => outlineWriterState.value.rawOutline,
set: (v) => outlineWriterState.value.rawOutline = v
})
const writingContext = computed({
get: () => outlineWriterState.value.writingContext,
set: (v) => outlineWriterState.value.writingContext = v
})
const parsedSections = computed(() => outlineWriterState.value.parsedSections)
const currentSectionIndex = computed({
get: () => outlineWriterState.value.currentSectionIndex,
set: (v) => outlineWriterState.value.currentSectionIndex = v
})
const isGenerating = computed(() => outlineWriterState.value.isGenerating)
// 素材总字数和估算 tokens
const totalMaterialChars = computed(() => {
return materials.value.reduce((sum, m) => sum + (m.content?.length || 0), 0)
})
const estimatedTokens = computed(() => {
// 中文约 1.5 tokens/字
return Math.round(totalMaterialChars.value * 1.5)
})
// 扁平化章节列表
const flatSections = computed(() => {
const result = []
const flatten = (sections) => {
sections.forEach(s => {
result.push(s)
if (s.children && s.children.length > 0) {
flatten(s.children)
}
})
}
flatten(parsedSections.value)
return result
})
// 解析大纲
const parseOutline = () => {
appStore.parseOutlineAction()
}
// 选择章节
const selectSection = (index) => {
currentSectionIndex.value = currentSectionIndex.value === index ? -1 : index
}
// 生成单个章节
const generateSection = async (index) => {
await appStore.generateOutlineSectionAction(index)
}
// 一键生成全文
const generateAll = async () => {
await appStore.generateAllOutlineSectionsAction()
}
// 中断生成
const abortGeneration = () => {
appStore.abortOutlineGenerationAction()
}
// ========== 素材管理 ==========
import {
getAllOutlineMaterials,
addOutlineMaterial,
updateOutlineMaterial,
deleteOutlineMaterial
} from '../db/index.js'
const materials = computed(() => outlineWriterState.value.materials)
const showMaterialModal = computed({
get: () => outlineWriterState.value.showMaterialModal,
set: (v) => outlineWriterState.value.showMaterialModal = v
})
const editingMaterial = computed({
get: () => outlineWriterState.value.editingMaterial,
set: (v) => outlineWriterState.value.editingMaterial = v
})
// 加载素材(从数据库)
const loadMaterials = async () => {
try {
const dbMaterials = await getAllOutlineMaterials()
outlineWriterState.value.materials = dbMaterials
console.log('📦 从数据库加载素材:', dbMaterials.length, '条')
} catch (e) {
console.error('加载素材失败:', e)
}
}
// 组件挂载时加载
import { onMounted } from 'vue'
onMounted(() => {
loadMaterials()
})
// 添加新素材
const addMaterial = () => {
editingMaterial.value = { id: '', name: '', content: '' }
showMaterialModal.value = true
}
// 编辑素材
const editMaterial = (mat) => {
editingMaterial.value = { ...mat }
showMaterialModal.value = true
}
// 删除素材
const removeMaterial = async (index) => {
const mat = materials.value[index]
if (mat?.id) {
await deleteOutlineMaterial(mat.id)
}
outlineWriterState.value.materials.splice(index, 1)
}
// 关闭弹窗
const closeMaterialModal = () => {
showMaterialModal.value = false
editingMaterial.value = null
}
// 保存素材
const saveMaterial = async () => {
const mat = editingMaterial.value
if (!mat.name.trim() || !mat.content.trim()) {
alert('请填写素材名称和内容')
return
}
if (mat.id) {
// 更新已有素材
await updateOutlineMaterial(mat.id, { name: mat.name, content: mat.content })
const idx = materials.value.findIndex(m => m.id === mat.id)
if (idx !== -1) {
materials.value[idx] = { ...mat, word_count: mat.content.length }
}
} else {
// 添加新素材
const newId = await addOutlineMaterial({ name: mat.name, content: mat.content })
mat.id = newId
mat.word_count = mat.content.length
materials.value.push(mat)
}
closeMaterialModal()
}
// 切换章节关联素材
const toggleSectionMaterial = (section, matId) => {
if (!section.linkedMaterials) {
section.linkedMaterials = []
}
const idx = section.linkedMaterials.indexOf(matId)
if (idx !== -1) {
section.linkedMaterials.splice(idx, 1)
} else {
section.linkedMaterials.push(matId)
}
}
</script>
<style scoped>
.outline-writer-panel {
width: 400px;
height: 100vh;
display: flex;
flex-direction: column;
border-right: 1px solid var(--border-default);
background: var(--bg-secondary);
flex-shrink: 0;
}
.panel-header {
padding: var(--space-4);
border-bottom: 1px solid var(--border-default);
display: flex;
align-items: center;
justify-content: space-between;
}
.panel-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);
}
.panel-section {
margin-bottom: var(--space-5);
}
.section-label {
display: block;
font-size: var(--text-sm);
font-weight: var(--font-medium);
color: var(--text-secondary);
margin-bottom: var(--space-2);
}
.context-textarea,
.outline-textarea {
width: 100%;
background: var(--bg-primary);
border: 1px solid var(--border-default);
border-radius: var(--radius-lg);
padding: var(--space-3);
font-size: var(--text-sm);
color: var(--text-primary);
font-family: monospace;
resize: vertical;
}
.context-textarea:focus,
.outline-textarea:focus {
outline: none;
border-color: var(--accent-primary);
}
.sections-list {
max-height: 300px;
overflow-y: auto;
border: 1px solid var(--border-default);
border-radius: var(--radius-lg);
background: var(--bg-primary);
}
.section-item {
padding: var(--space-2) var(--space-3);
border-bottom: 1px solid var(--border-default);
cursor: pointer;
transition: all 0.15s;
}
.section-item:last-child {
border-bottom: none;
}
.section-item:hover {
background: var(--bg-elevated);
}
.section-item.active {
background: var(--bg-elevated);
border-left: 2px solid var(--accent-primary);
}
.section-item.generated {
background: rgba(34, 197, 94, 0.05);
}
.section-header {
display: flex;
align-items: center;
gap: var(--space-2);
}
.level-badge {
font-size: 10px;
padding: 1px 4px;
border-radius: var(--radius-sm);
font-weight: bold;
}
.level-badge.level-1 {
background: rgba(59, 130, 246, 0.2);
color: #60a5fa;
}
.level-badge.level-2 {
background: rgba(168, 85, 247, 0.2);
color: #c084fc;
}
.level-badge.level-3 {
background: rgba(34, 197, 94, 0.2);
color: #4ade80;
}
.section-title {
flex: 1;
font-size: var(--text-sm);
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.status-dot {
width: 6px;
height: 6px;
border-radius: 50%;
}
.status-dot.generated {
background: #22c55e;
}
.status-dot.generating {
background: #f59e0b;
animation: pulse 1s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.section-editor {
margin-top: var(--space-2);
padding-top: var(--space-2);
border-top: 1px dashed var(--border-default);
}
.hint-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: none;
margin-bottom: var(--space-2);
}
.generate-section-btn {
width: 100%;
padding: var(--space-2);
background: var(--accent-primary);
color: white;
border: none;
border-radius: var(--radius-md);
font-size: var(--text-xs);
cursor: pointer;
transition: all 0.15s;
}
.generate-section-btn:hover:not(:disabled) {
background: var(--accent-primary-hover);
}
.generate-section-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.panel-footer {
padding: var(--space-4);
border-top: 1px solid var(--border-default);
background: var(--bg-secondary);
}
.generate-all-btn {
width: 100%;
padding: var(--space-3);
background: linear-gradient(135deg, var(--accent-primary), #6366f1);
color: white;
border: none;
border-radius: var(--radius-lg);
font-weight: var(--font-semibold);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-2);
transition: all 0.2s;
}
.generate-all-btn:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: var(--shadow-md);
}
.generate-all-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.generate-all-btn.generating {
background: var(--bg-elevated);
color: var(--text-muted);
}
.abort-btn {
width: 100%;
padding: var(--space-3);
background: #ef4444; /* Tailwind red-500 */
color: white;
border: none;
border-radius: var(--radius-lg);
font-weight: var(--font-semibold);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-2);
transition: all 0.2s;
}
.abort-btn:hover {
background: #dc2626; /* Tailwind red-600 */
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(239, 68, 68, 0.3);
}
/* ========== 素材库样式 ========== */
.materials-list {
background: var(--bg-primary);
border: 1px solid var(--border-default);
border-radius: var(--radius-lg);
overflow: hidden;
}
.material-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-2) var(--space-3);
border-bottom: 1px solid var(--border-default);
}
.material-item:last-child {
border-bottom: none;
}
.material-info {
display: flex;
align-items: center;
gap: var(--space-2);
}
.material-icon {
font-size: 14px;
}
.material-name {
font-size: var(--text-sm);
color: var(--text-primary);
}
.material-size {
font-size: var(--text-xs);
color: var(--text-muted);
}
.material-actions {
display: flex;
gap: var(--space-2);
}
/* 素材编辑弹窗 */
.material-modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.material-modal {
width: 600px;
max-height: 80vh;
background: var(--bg-secondary);
border: 1px solid var(--border-default);
border-radius: var(--radius-xl);
overflow: hidden;
display: flex;
flex-direction: column;
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-4);
border-bottom: 1px solid var(--border-default);
}
.modal-header h3 {
font-size: var(--text-lg);
font-weight: var(--font-semibold);
color: var(--text-primary);
}
.modal-body {
flex: 1;
padding: var(--space-4);
overflow-y: auto;
}
.modal-input {
width: 100%;
padding: 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);
margin-bottom: var(--space-3);
}
.modal-textarea {
width: 100%;
padding: 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);
resize: vertical;
min-height: 200px;
}
.modal-footer {
display: flex;
justify-content: flex-end;
gap: var(--space-2);
padding: var(--space-4);
border-top: 1px solid var(--border-default);
}
.modal-btn {
padding: var(--space-2) var(--space-4);
border-radius: var(--radius-md);
font-size: var(--text-sm);
cursor: pointer;
transition: all 0.15s;
}
.modal-btn.secondary {
background: var(--bg-elevated);
color: var(--text-secondary);
border: 1px solid var(--border-default);
}
.modal-btn.primary {
background: var(--accent-primary);
color: white;
border: none;
}
/* 章节关联素材 */
.linked-materials {
margin-bottom: var(--space-2);
padding: var(--space-2);
background: rgba(139, 92, 246, 0.1);
border-radius: var(--radius-md);
}
.material-checkboxes {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
}
.material-checkbox {
display: flex;
align-items: center;
gap: var(--space-1);
font-size: var(--text-xs);
color: var(--text-secondary);
cursor: pointer;
}
.material-checkbox input {
cursor: pointer;
}
/* 上下文统计 */
.context-stats {
margin-top: var(--space-3);
padding: var(--space-2);
background: var(--bg-elevated);
border-radius: var(--radius-md);
font-size: var(--text-xs);
}
.stats-row {
display: flex;
align-items: center;
gap: var(--space-2);
color: var(--text-secondary);
}
.warning-box {
margin-top: var(--space-2);
padding: var(--space-2);
background: rgba(234, 179, 8, 0.1);
border-radius: var(--radius-sm);
font-size: var(--text-xs);
}
</style>

View File

@@ -275,10 +275,11 @@
<script setup>
import { ref } from 'vue'
import { storeToRefs } from 'pinia'
import { useAppStore } from '../stores/app'
import { useParadigmStore } from '../stores/paradigm'
import DeepSeekAPI from '../api/deepseek'
import IconLibrary from './icons/IconLibrary.vue'
import { config } from '../utils/config'
import {
buildRequirementParserPrompt,
parseParadigmConfig,
@@ -289,6 +290,8 @@ import {
const emit = defineEmits(['close', 'paradigm-created'])
const paradigmStore = useParadigmStore()
const appStore = useAppStore()
const { selectedProviderId, currentProvider } = storeToRefs(appStore)
// 状态
const step = ref(1) // 1: 输入, 2: 解析中, 3: 编辑确认, 'error': 失败
@@ -340,10 +343,10 @@ async function startParsing() {
parsingProgress.value = '正在调用 AI 分析...'
// 调用 DeepSeek API - 传递配置
const api = new DeepSeekAPI({
url: config.apiUrl,
key: config.apiKey
providerId: selectedProviderId.value,
model: currentProvider.value.model,
appId: currentProvider.value.appId
})
let responseText = ''

View File

@@ -38,15 +38,15 @@
<!-- 当前配置状态 -->
<div class="provider-status">
<div class="status-row">
<span class="status-label">API 端点</span>
<span class="status-value truncate">{{ currentProvider?.apiUrl || '未配置' }}</span>
<span class="status-label">代理模式</span>
<span class="status-value">服务端托管</span>
</div>
<div class="status-row">
<span class="status-label">API Key</span>
<span :class="['status-value flex items-center gap-1', currentProvider?.apiKey ? 'configured' : 'not-configured']">
<IconLibrary v-if="currentProvider?.apiKey" name="check" :size="12" />
<span :class="['status-value flex items-center gap-1', currentProviderStatus?.enabled ? 'configured' : 'not-configured']">
<IconLibrary v-if="currentProviderStatus?.enabled" name="check" :size="12" />
<IconLibrary v-else name="close" :size="12" />
{{ currentProvider?.apiKey ? '已配置' : '未配置' }}
{{ currentProviderStatus?.enabled ? '已托管' : '未配置' }}
</span>
</div>
<div class="status-row" v-if="currentProvider?.model">
@@ -57,7 +57,7 @@
<p class="text-xs text-muted flex items-center gap-1.5">
<IconLibrary name="info" :size="14" />
<span>模型配置通过 <code>.env</code> 文件设置详见项目文档</span>
<span>模型配置在服务端 <code>.env</code> 中设置</span>
</p>
</div>
</section>
@@ -173,7 +173,7 @@
</h3>
<div class="text-xs text-muted space-y-1">
<p>AI 写作工坊 v1.0.0</p>
<p>数据存储浏览器 IndexedDB (SQLite)</p>
<p>数据存储服务端 SQLite</p>
<p>© 2026 AI Writing Workshop</p>
</div>
</section>
@@ -212,11 +212,12 @@
import { ref, reactive, onMounted, computed } from 'vue'
import { storeToRefs } from 'pinia'
import { useAppStore } from '../stores/app'
import { modelProviders, getConfiguredProviders } from '../utils/config.js'
import { modelProviders } from '../utils/config.js'
import IconLibrary from './icons/IconLibrary.vue'
const appStore = useAppStore()
const { selectedProviderId, currentProvider } = storeToRefs(appStore)
const API_BASE = import.meta.env.VITE_API_BASE || 'http://localhost:3001/api'
// 获取所有可选的服务商(包括已配置和未配置的)
const availableProviders = computed(() => {
@@ -240,17 +241,22 @@ const showToast = ref(false)
const toastMessage = ref('')
const importInput = ref(null)
const providerStatusMap = ref({})
const currentProviderStatus = computed(() => {
return providerStatusMap.value[selectedProviderId.value] || null
})
// 加载统计数据
const loadStats = async () => {
try {
const { getAllDocuments, getAllReferences, getAllParadigms, exportDatabase } = await import('../db/index.js')
stats.documents = getAllDocuments().length
stats.materials = getAllReferences().length
stats.paradigms = getAllParadigms().length
stats.documents = (await getAllDocuments()).length
stats.materials = (await getAllReferences()).length
stats.paradigms = (await getAllParadigms()).length
const dbData = exportDatabase()
const dbData = await exportDatabase()
stats.dbSize = dbData.length
} catch (error) {
console.error('加载统计失败:', error)
@@ -268,7 +274,7 @@ const formatSize = (bytes) => {
const exportData = async () => {
try {
const { exportAsJSON } = await import('../db/index.js')
const data = exportAsJSON()
const data = await exportAsJSON()
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' })
const url = URL.createObjectURL(blob)
@@ -319,6 +325,22 @@ const importMaterials = () => {
showToastMessage('素材导入功能开发中...')
}
const loadProviderStatus = async () => {
try {
const response = await fetch(`${API_BASE}/llm/providers`)
if (!response.ok) return
const result = await response.json()
if (result?.success) {
providerStatusMap.value = result.data.reduce((acc, provider) => {
acc[provider.id] = provider
return acc
}, {})
}
} catch (error) {
console.warn('获取服务端模型状态失败:', error)
}
}
// 确认清空文稿
const confirmClearDocuments = () => {
confirmTitle.value = '清空所有文稿'
@@ -341,9 +363,8 @@ const executeConfirmAction = async () => {
try {
if (confirmAction.value === 'clearDocuments') {
const { query, execute } = await import('../db/index.js')
execute('DELETE FROM document_versions')
execute('DELETE FROM documents')
const { clearDocuments } = await import('../db/index.js')
await clearDocuments()
showToastMessage('文稿已清空')
} else if (confirmAction.value === 'resetDatabase') {
const { resetDatabase } = await import('../db/index.js')
@@ -373,6 +394,7 @@ const switchPage = (page) => {
onMounted(() => {
loadStats()
loadProviderStatus()
})
</script>