feat: 添加大纲写作功能与服务器端改进
- 新增 OutlineWriterPanel 和 OutlineResultPanel 组件 - 重构服务器端数据库接口 (server/db.js) - 添加 LLM 服务模块 (server/llm.js) - 更新配置和设置面板 - 优化文档选择器和素材面板 - 更新部署文档和环境变量示例
This commit is contained in:
@@ -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' }
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
302
src/components/OutlineResultPanel.vue
Normal file
302
src/components/OutlineResultPanel.vue
Normal 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>
|
||||
845
src/components/OutlineWriterPanel.vue
Normal file
845
src/components/OutlineWriterPanel.vue
Normal 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>
|
||||
@@ -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 = ''
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user