feat: 重构布局与模型配置

- 写作范式分析页面改为左中右三栏布局
- 范式分段写作页面改为左中右三栏布局
- 模型设置移至设置中心,支持多服务商选择
- API 配置通过 .env 文件管理,提升安全性
- 支持 DeepSeek、OpenAI、Claude、自定义服务商
This commit is contained in:
empty
2026-01-11 22:05:28 +08:00
parent d1c9a4a5dd
commit a6efb5a7e7
12 changed files with 2030 additions and 368 deletions

View File

@@ -8,22 +8,47 @@
<span class="badge badge-primary">Pro版</span>
</header>
<!-- 内容区 -->
<div class="analysis-content">
<!-- 写作范式库 -->
<section class="analysis-section">
<div class="flex items-center justify-between mb-4">
<h3 class="text-sm font-medium text-secondary">📚 写作范式库</h3>
<button
@click="openAddModal"
class="btn btn-analysis btn-success flex items-center gap-1"
>
<span>+</span> 新增范式
</button>
</div>
<!-- 标签页切换 -->
<div class="analysis-tabs">
<button
:class="['analysis-tab', activeTab === 'paradigms' ? 'active' : '']"
@click="activeTab = 'paradigms'"
>
📚 范式库
</button>
<button
:class="['analysis-tab', activeTab === 'tools' ? 'active' : '']"
@click="activeTab = 'tools'"
>
🔍 分析工具
</button>
</div>
<div class="space-y-3">
<div
<!-- 内容区 -->
<div class="analysis-content">
<!-- 写作范式库 -->
<section v-show="activeTab === 'paradigms'" class="analysis-section">
<div class="flex items-center justify-between mb-4">
<span class="text-xs text-muted">{{ paradigms.length }} 个范式</span>
<div class="flex items-center gap-2">
<button
@click="openRequirementParser"
class="btn btn-analysis btn-primary flex items-center gap-1"
title="根据需求文档自动提取范式"
>
<span>🎯</span> AI 提取
</button>
<button
@click="openAddModal"
class="btn btn-analysis btn-success flex items-center gap-1"
>
<span>+</span> 新增
</button>
</div>
</div>
<div class="space-y-3">
<div
v-for="paradigm in paradigms"
:key="paradigm.id"
@click="selectParadigm(paradigm)"
@@ -33,7 +58,10 @@
<h4 class="paradigm-card-title">
{{ paradigm.icon }} {{ paradigm.name }}
<span v-if="paradigm.isNew" class="badge-new animate-pulse">NEW</span>
<span v-if="paradigm.isCustom" class="badge-custom">自定义</span>
<span v-if="isCustomParadigm(paradigm)" class="badge-custom">自定义</span>
<span v-if="paradigm.createdAt" class="text-[10px] text-muted ml-auto opacity-70">
{{ formatDate(paradigm.createdAt) }}
</span>
</h4>
<div class="flex items-center gap-1">
<button
@@ -44,7 +72,7 @@
</button>
<button
v-if="paradigm.isCustom"
v-if="isCustomParadigm(paradigm)"
@click.stop="deleteParadigm(paradigm)"
class="btn-action danger"
title="删除范式"
@@ -54,7 +82,7 @@
<button
v-if="selectedParadigm?.id === paradigm.id"
@click.stop="applyParadigm(paradigm)"
class="btn-action primary"
class="btn-action btn-action-text primary"
>
应用到写作
</button>
@@ -71,194 +99,71 @@
</span>
</div>
</div>
</div>
</section>
<!-- 范式分析工具 -->
<section class="analysis-section">
<h3 class="text-sm font-medium text-secondary mb-4">🔍 范式分析工具</h3>
<textarea
v-model="analysisText"
class="analysis-textarea"
placeholder="粘贴文章内容,分析其写作范式..."
></textarea>
<div class="flex gap-2 mt-2">
<button
@click="analyzeArticle"
:disabled="isAnalyzing || !analysisText"
class="btn btn-analysis primary flex-1"
>
{{ isAnalyzing ? '正在分析...' : '分析文章范式' }}
</button>
<button
@click="clearAnalysis"
class="btn btn-analysis secondary"
>
清空
</button>
</div>
</section>
<!-- 分析历史 -->
<section v-if="analysisHistory.length > 0" class="analysis-section">
<h3 class="text-sm font-medium text-secondary mb-4">📝 分析历史</h3>
<div class="space-y-2">
<div
v-for="(item, index) in analysisHistory"
:key="index"
@click="loadHistoryItem(item)"
class="history-item"
>
<div class="flex justify-between items-center">
<span class="text-secondary">{{ item.paradigm }}</span>
<span class="text-muted">{{ formatDate(item.timestamp) }}</span>
</div>
<p class="text-muted mt-1 truncate">{{ item.preview }}</p>
</div>
</div>
</section>
</section>
<!-- 范式分析工具 -->
<section v-show="activeTab === 'tools'" class="analysis-section">
<h3 class="text-sm font-medium text-secondary mb-4">🔍 范式分析工具</h3>
<textarea
v-model="analysisText"
class="analysis-textarea"
placeholder="粘贴文章内容,分析其写作范式..."
></textarea>
<div class="flex gap-2 mt-2">
<button
@click="analyzeArticle"
:disabled="isAnalyzing || !analysisText"
class="btn btn-analysis primary flex-1"
>
{{ isAnalyzing ? '正在分析...' : '分析文章范式' }}
</button>
<button
@click="clearAnalysis"
class="btn btn-analysis secondary"
>
清空
</button>
</div>
<!-- 分析历史 -->
<div v-if="analysisHistory.length > 0" class="mt-6">
<h4 class="text-xs font-medium text-muted mb-3">📝 分析历史</h4>
<div class="space-y-2">
<div
v-for="(item, index) in analysisHistory"
:key="index"
@click="loadHistoryItem(item)"
class="history-item"
>
<div class="flex justify-between items-center">
<span class="text-secondary">{{ item.paradigm }}</span>
<span class="text-muted">{{ formatDate(item.timestamp) }}</span>
</div>
<p class="text-muted mt-1 truncate">{{ item.preview }}</p>
</div>
</div>
</div>
</section>
</div>
<!-- 编辑/新增范式弹窗 -->
<div
v-if="showEditModal"
class="paradigm-modal-backdrop"
@click.self="closeEditModal"
>
<div class="paradigm-modal">
<div class="p-4 border-b border-default flex items-center justify-between">
<h3 class="font-medium text-primary">{{ isAddMode ? '新增写作范式' : '编辑写作范式' }}</h3>
<button @click="closeEditModal" class="text-muted hover:text-primary transition"></button>
</div>
<div class="p-4 space-y-4">
<!-- 图标选择 -->
<div>
<label class="block text-sm text-secondary mb-2">图标</label>
<div class="icon-selector">
<button
v-for="icon in iconOptions"
:key="icon"
@click="editForm.icon = icon"
:class="['icon-option', { 'selected': editForm.icon === icon }]"
>
{{ icon }}
</button>
</div>
</div>
<!-- 名称 -->
<div>
<label class="block text-sm text-secondary mb-2">范式名称</label>
<input
v-model="editForm.name"
class="analysis-input"
placeholder="如:技术博客范式"
/>
</div>
<!-- 描述 -->
<div>
<label class="block text-sm text-secondary mb-2">描述</label>
<textarea
v-model="editForm.description"
rows="2"
class="analysis-textarea"
placeholder="简要描述此范式的适用场景"
></textarea>
</div>
<!-- 标签 -->
<div>
<label class="block text-sm text-secondary mb-2">标签逗号分隔</label>
<input
v-model="editForm.tagsInput"
class="analysis-input"
placeholder="如:问题引入,解决方案,代码示例"
/>
<div class="flex flex-wrap gap-1 mt-2" v-if="editForm.tagsInput">
<span
v-for="tag in editForm.tagsInput.split(',').map(t => t.trim()).filter(t => t)"
:key="tag"
:class="['text-xs px-2 py-1 rounded', editForm.tagClass]"
>
{{ tag }}
</span>
</div>
</div>
<!-- 标签颜色 -->
<div>
<label class="block text-sm text-secondary mb-2">标签颜色</label>
<div class="flex gap-2 flex-wrap">
<button
v-for="color in colorOptions"
:key="color.class"
@click="editForm.tagClass = color.class"
:class="['color-option', color.class, { 'selected': editForm.tagClass === color.class }]"
>
{{ color.label }}
</button>
</div>
</div>
<!-- 核心字段完整 Prompt -->
<div class="border-t border-default pt-4 mt-4">
<label class="block text-sm text-secondary mb-2 flex items-center gap-2">
<span></span> 完整 Prompt核心字段
<span class="text-xs text-muted font-normal">粘贴您的完整提示词</span>
</label>
<textarea
v-model="editForm.specializedPrompt"
rows="12"
class="analysis-textarea resize-y font-mono leading-relaxed"
placeholder="在此粘贴您的完整 Prompt...
示例:
# Role
你是一个具有较高政治敏感性的AI助手...
# 任务目标
你需要逐一分析文章中的每一条问题...
# 检查步骤
1. 检查问题表述的清晰度
2. 深度分析思想根源
..."
></textarea>
<p class="text-xs text-muted mt-2">
💡 提示这是 AI 调用时的核心指令完整的 Prompt 应包含角色目标步骤和输出格式
</p>
</div>
</div>
<div class="p-4 border-t border-default flex justify-end gap-2">
<button
@click="closeEditModal"
class="btn btn-analysis secondary"
>
取消
</button>
<button
@click="saveParadigm"
:disabled="!editForm.name"
class="btn btn-analysis primary"
>
{{ isAddMode ? '添加' : '保存' }}
</button>
</div>
</div>
</div>
<!-- 需求解析面板 -->
<RequirementParserPanel
v-if="showRequirementParser"
@close="showRequirementParser = false"
@paradigm-created="handleParadigmCreated"
/>
</aside>
</template>
<script setup>
import { ref, reactive, onMounted, computed, watch } from 'vue'
import { ref, reactive, onMounted, onUnmounted, computed, watch } from 'vue'
import { storeToRefs } from 'pinia'
import { useAppStore } from '../stores/app'
import { useDatabaseStore } from '../stores/database.js'
import DeepSeekAPI from '../api/deepseek.js'
import { getParadigmList } from '../config/paradigms.js'
import RequirementParserPanel from './RequirementParserPanel.vue'
const appStore = useAppStore()
const { analysisText, isAnalyzing } = storeToRefs(appStore)
@@ -276,10 +181,11 @@ const analysisHistory = ref([])
// 范式列表(响应式,支持编辑)
const paradigms = ref([])
// 编辑弹窗状态
const showEditModal = ref(false)
const isAddMode = ref(false)
const editingParadigmId = ref(null)
const showRequirementParser = ref(false)
const activeTab = ref('paradigms') // 'paradigms' or 'tools'
// 编辑表单
const editForm = reactive({
@@ -331,28 +237,55 @@ const initParadigms = () => {
paradigms.value = [...mergedParadigms, ...customParadigms]
}
// 打开新增弹窗
// 打开新增(在右侧面板显示)
const openAddModal = () => {
isAddMode.value = true
editingParadigmId.value = null
resetEditForm()
showEditModal.value = true
// 更新 app store 中的编辑状态
appStore.paradigmEditState.isEditing = true
appStore.paradigmEditState.isAddMode = true
appStore.paradigmEditState.editingParadigmId = null
// 重置表单
const form = appStore.paradigmEditState.editForm
form.icon = '📝'
form.name = ''
form.description = ''
form.tagsInput = ''
form.tagClass = 'bg-blue-900/30 text-blue-300'
form.specializedPrompt = ''
}
// 打开编辑弹窗
// 打开需求解析面板
const openRequirementParser = () => {
showRequirementParser.value = true
}
// 处理 AI 解析创建的范式
const handleParadigmCreated = (paradigm) => {
// 刷新列表以显示新范式
initParadigms()
showRequirementParser.value = false
// 自动选中新创建的范式
const newRef = paradigms.value.find(p => p.id === paradigm.id)
if (newRef) {
selectParadigm(newRef)
}
}
// 打开编辑(在右侧面板显示)
const openEditModal = (paradigm) => {
isAddMode.value = false
editingParadigmId.value = paradigm.id
// 更新 app store 中的编辑状态
appStore.paradigmEditState.isEditing = true
appStore.paradigmEditState.isAddMode = false
appStore.paradigmEditState.editingParadigmId = paradigm.id
editForm.icon = paradigm.icon || '📝'
editForm.name = paradigm.name || ''
editForm.description = paradigm.description || ''
editForm.tagsInput = (paradigm.tags || []).join(', ')
editForm.tagClass = paradigm.tagClass || 'bg-blue-900/30 text-blue-300'
// ⭐ 核心字段:加载完整 Prompt
editForm.specializedPrompt = paradigm.specializedPrompt || ''
showEditModal.value = true
// 填充表单数据
const form = appStore.paradigmEditState.editForm
form.icon = paradigm.icon || '📝'
form.name = paradigm.name || ''
form.description = paradigm.description || ''
form.tagsInput = (paradigm.tags || []).join(', ')
form.tagClass = paradigm.tagClass || 'bg-blue-900/30 text-blue-300'
form.specializedPrompt = paradigm.specializedPrompt || ''
}
@@ -389,6 +322,7 @@ const saveParadigm = () => {
tags,
tagClass: editForm.tagClass,
isCustom: true,
createdAt: new Date().toISOString(),
// ⭐ 核心字段:完整 Prompt
specializedPrompt: editForm.specializedPrompt
}
@@ -447,10 +381,19 @@ const saveParadigm = () => {
closeEditModal()
}
// 判断范式是否为自定义(兼容旧数据)
const isCustomParadigm = (paradigm) => {
// 优先检查 isCustom 字段
if (paradigm.isCustom) return true
// 兼容旧数据:检查 type 字段或 id 前缀
if (paradigm.type === 'custom') return true
if (paradigm.id && paradigm.id.startsWith('custom-')) return true
return false
}
// 删除自定义范式
const deleteParadigm = (paradigm) => {
if (!paradigm.isCustom) return
if (!isCustomParadigm(paradigm)) return
if (!confirm(`确定要删除"${paradigm.name}"吗?`)) return
@@ -571,6 +514,16 @@ const formatDate = (date) => {
return new Date(date).toLocaleDateString()
}
// 返回上一页
const goBack = () => {
appStore.setCurrentPage('writer')
}
// 监听保存事件并刷新
const handleParadigmSaved = () => {
initParadigms()
}
// 组件挂载时加载历史记录和范式列表
onMounted(() => {
// 初始化范式列表
@@ -581,13 +534,18 @@ onMounted(() => {
if (saved) {
analysisHistory.value = JSON.parse(saved)
}
// 监听保存事件
window.addEventListener('paradigm-saved', handleParadigmSaved)
})
onUnmounted(() => {
window.removeEventListener('paradigm-saved', handleParadigmSaved)
})
</script>
<style scoped>
/* ========== 使用设计令牌系统 ========== */
/* 侧边栏容器 */
/* ========== 侧边栏面板 ========== */
.analysis-panel {
width: 400px;
display: flex;
@@ -617,6 +575,52 @@ onMounted(() => {
gap: var(--space-2);
}
/* 返回按钮 */
.back-btn {
padding: var(--space-2) var(--space-3);
font-size: var(--text-sm);
color: var(--text-muted);
background: var(--bg-elevated);
border: 1px solid var(--border-default);
border-radius: var(--radius-md);
cursor: pointer;
transition: all var(--transition-fast);
}
.back-btn:hover {
color: var(--text-primary);
background: var(--bg-sunken);
}
/* 标签页 */
.analysis-tabs {
display: flex;
border-bottom: 1px solid var(--border-default);
flex-shrink: 0;
}
.analysis-tab {
flex: 1;
padding: var(--space-3);
font-size: var(--text-sm);
color: var(--text-muted);
background: transparent;
border: none;
cursor: pointer;
transition: all var(--transition-fast);
}
.analysis-tab:hover {
color: var(--text-primary);
background: var(--bg-elevated);
}
.analysis-tab.active {
color: var(--accent-primary);
background: var(--bg-primary);
border-bottom: 2px solid var(--accent-primary);
}
/* 内容区 */
.analysis-content {
flex: 1;
@@ -632,6 +636,144 @@ onMounted(() => {
margin-bottom: 0;
}
/* 右侧编辑面板内容 */
.edit-panel-content {
display: flex;
flex-direction: column;
height: 100%;
}
.edit-panel-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);
flex-shrink: 0;
}
.edit-panel-title {
font-weight: var(--font-semibold);
font-size: var(--text-lg);
color: var(--text-primary);
}
.close-btn {
padding: var(--space-2);
font-size: var(--text-lg);
color: var(--text-muted);
background: transparent;
border: none;
cursor: pointer;
transition: all var(--transition-fast);
}
.close-btn:hover {
color: var(--text-primary);
}
.edit-panel-body {
flex: 1;
overflow-y: auto;
padding: var(--space-6);
}
.edit-field {
margin-bottom: var(--space-5);
}
.edit-field.prompt-field {
flex: 1;
display: flex;
flex-direction: column;
}
.edit-label {
display: block;
font-size: var(--text-sm);
font-weight: var(--font-medium);
color: var(--text-secondary);
margin-bottom: var(--space-2);
}
.edit-input {
width: 100%;
padding: var(--space-3);
background: var(--bg-elevated);
border: 1px solid var(--border-default);
border-radius: var(--radius-md);
color: var(--text-primary);
font-size: var(--text-sm);
}
.edit-input:focus {
outline: none;
border-color: var(--accent-primary);
}
.edit-textarea {
width: 100%;
padding: var(--space-3);
background: var(--bg-elevated);
border: 1px solid var(--border-default);
border-radius: var(--radius-md);
color: var(--text-primary);
font-size: var(--text-sm);
resize: vertical;
min-height: 80px;
}
.edit-textarea:focus {
outline: none;
border-color: var(--accent-primary);
}
/* 模态弹窗 */
.paradigm-modal-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.paradigm-modal {
width: 560px;
max-width: 90vw;
max-height: 85vh;
background: var(--bg-secondary);
border-radius: var(--radius-lg);
border: 1px solid var(--border-default);
display: flex;
flex-direction: column;
overflow: hidden;
}
.modal-header {
padding: var(--space-4);
border-bottom: 1px solid var(--border-default);
display: flex;
align-items: center;
justify-content: space-between;
}
.modal-body {
flex: 1;
overflow-y: auto;
padding: var(--space-4);
}
.modal-footer {
padding: var(--space-4);
border-top: 1px solid var(--border-default);
display: flex;
justify-content: flex-end;
gap: var(--space-3);
}
/* 范式卡片 */
.paradigm-card {
background: var(--bg-elevated);
@@ -830,14 +972,21 @@ onMounted(() => {
.btn-action {
font-size: var(--text-xs);
padding: var(--space-2);
background: var(--bg-elevated);
background: var(--bg-sunken); /* 统一使用较深背景,确保在所有状态下可见 */
color: var(--text-primary);
border-radius: var(--radius-md);
transition: all var(--transition-fast);
border: 1px solid var(--border-default);
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
}
.btn-action:hover {
background: var(--bg-sunken);
background: var(--bg-primary);
border-color: var(--accent-primary);
}
.btn-action.primary {
@@ -849,6 +998,14 @@ onMounted(() => {
background: var(--accent-primary-hover);
}
/* 带文字的动作按钮(自适应宽度) */
.btn-action.btn-action-text {
width: auto;
height: auto;
padding: var(--space-2) var(--space-3);
white-space: nowrap;
}
.btn-action.danger {
background: var(--accent-danger);
color: var(--text-inverse);

View File

@@ -54,6 +54,7 @@ const currentPage = computed(() => appStore.currentPage)
const navItems = [
{ id: 'writer', label: 'AI 写作', icon: '✍️' },
{ id: 'analysis', label: '范式库', icon: '🎯' },
{ id: 'paradigmWriter', label: '范式写作', icon: '📝' },
{ id: 'documents', label: '文稿库', icon: '📂' },
{ id: 'materials', label: '素材库', icon: '📚' },
{ id: 'rewrite', label: '范式润色', icon: '🎨' },

View File

@@ -213,75 +213,252 @@
</div>
</div>
<!-- 范式分析页面内容 -->
<div v-else class="space-y-8">
<div v-if="!analysisResult" class="h-full flex flex-col items-center justify-center text-slate-700 mt-20">
<span class="text-6xl mb-4 opacity-20">📊</span>
<p>选择左侧的写作范式或使用分析工具</p>
<!-- 范式写作页面内容 -->
<div v-else-if="currentPage === 'paradigmWriter'" class="h-full flex flex-col">
<div v-if="!totalGeneratedContent" class="h-full flex flex-col items-center justify-center text-slate-700 mt-20">
<span class="text-6xl mb-4 opacity-20"></span>
<p>生成的内容将在这里实时预览</p>
<p class="text-xs text-slate-500 mt-2">选择章节并点击生成本节开始</p>
</div>
<div v-else class="space-y-6">
<div v-if="analysisResult.error" class="bg-red-900/20 rounded-lg p-6 border border-red-700">
<h3 class="text-lg font-bold text-red-400 mb-4">分析失败</h3>
<p class="text-red-300">{{ analysisResult.message }}</p>
<!-- 统计信息 -->
<div class="flex items-center justify-between text-sm text-slate-400">
<span>{{ totalWordCount }} </span>
<span>预计阅读 {{ Math.ceil(totalWordCount / 300) }} 分钟</span>
</div>
<div v-else class="space-y-6">
<!-- 分析结果头部 -->
<div class="bg-slate-900 rounded-lg p-6 border border-slate-700">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-bold text-white">分析结果</h3>
<span class="text-xs px-3 py-1 rounded-full bg-blue-900/30 text-blue-300 border border-blue-700">
{{ analysisResult.paradigm }}
</span>
</div>
<!-- 分析内容 -->
<div class="prose prose-invert max-w-none">
<div v-html="marked.parse(analysisResult.analysis)"></div>
</div>
<!-- 操作按钮 -->
<div class="mt-6 flex gap-3">
<button
@click="copyAnalysis"
class="text-xs px-4 py-2 bg-slate-700 hover:bg-slate-600 text-white rounded transition"
<!-- 生成内容预览 -->
<div class="prose prose-invert max-w-none">
<div
v-for="(section, index) in paradigmWriterState.sections"
:key="index"
v-show="section.generatedContent"
class="mb-6"
>
<h3 :class="section.type === 'h2' ? 'text-xl font-bold text-white mb-3' : 'text-lg font-semibold text-slate-200 mb-2'">
{{ section.title }}
</h3>
<div v-html="renderSectionMarkdown(section.generatedContent)"></div>
</div>
</div>
<!-- 操作按钮 -->
<div class="flex gap-3 pt-4 border-t border-slate-700">
<button @click="copyParadigmContent" class="text-xs px-4 py-2 bg-slate-700 hover:bg-slate-600 text-white rounded transition">
📋 复制全文
</button>
<button @click="exportParadigmToWord" class="text-xs px-4 py-2 bg-blue-600 hover:bg-blue-500 text-white rounded transition">
📥 导出 Word
</button>
</div>
</div>
</div>
<!-- 范式分析页面内容 -->
<div v-else-if="currentPage === 'analysis'" class="space-y-8">
<!-- 范式编辑表单 -->
<div v-if="paradigmEditState.isEditing" class="bg-slate-900 rounded-lg border border-slate-700 overflow-hidden">
<div class="px-6 py-4 border-b border-slate-700 flex items-center justify-between">
<h3 class="text-lg font-bold text-white">
{{ paradigmEditState.isAddMode ? ' 新增写作范式' : '✏️ 编辑写作范式' }}
</h3>
<button
@click="closeParadigmEdit"
class="text-slate-400 hover:text-white transition"
>
</button>
</div>
<div class="p-6 space-y-5">
<!-- 图标选择 -->
<div>
<label class="block text-sm text-slate-400 mb-2">图标</label>
<div class="flex flex-wrap gap-2">
<button
v-for="icon in iconOptions"
:key="icon"
@click="paradigmEditState.editForm.icon = icon"
:class="['w-10 h-10 text-xl rounded border transition',
paradigmEditState.editForm.icon === icon
? 'border-blue-500 bg-blue-500/20'
: 'border-slate-600 hover:border-slate-500']"
>
📋 复制分析结果
</button>
<button
@click="applyToWriting"
class="text-xs px-4 py-2 bg-blue-600 hover:bg-blue-500 text-white rounded transition"
>
应用到写作
{{ icon }}
</button>
</div>
</div>
<!-- 范式详情卡片 -->
<div v-if="getParadigmDetails()" class="bg-slate-900/50 rounded-lg p-6 border border-slate-700">
<h4 class="text-md font-bold text-white mb-4">{{ getParadigmDetails().name }}特点</h4>
<div class="grid grid-cols-2 gap-4">
<div>
<h5 class="text-xs font-medium text-slate-400 mb-2">结构要素</h5>
<div class="flex flex-wrap gap-1">
<span
v-for="tag in getParadigmDetails().tags"
:key="tag"
:class="['text-xs px-2 py-1 rounded', getParadigmDetails().tagClass]"
>
{{ tag }}
</span>
</div>
<!-- 名称 -->
<div>
<label class="block text-sm text-slate-400 mb-2">范式名称</label>
<input
v-model="paradigmEditState.editForm.name"
class="w-full px-4 py-3 bg-slate-800 border border-slate-600 rounded-lg text-white outline-none focus:border-blue-500"
placeholder="如:技术博客范式"
/>
</div>
<!-- 描述 -->
<div>
<label class="block text-sm text-slate-400 mb-2">描述</label>
<textarea
v-model="paradigmEditState.editForm.description"
rows="2"
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-none"
placeholder="简要描述此范式的适用场景"
></textarea>
</div>
<!-- 标签 -->
<div>
<label class="block text-sm text-slate-400 mb-2">标签逗号分隔</label>
<input
v-model="paradigmEditState.editForm.tagsInput"
class="w-full px-4 py-3 bg-slate-800 border border-slate-600 rounded-lg text-white outline-none focus:border-blue-500"
placeholder="如:问题引入,解决方案,代码示例"
/>
<div class="flex flex-wrap gap-1 mt-2" v-if="paradigmEditState.editForm.tagsInput">
<span
v-for="tag in paradigmEditState.editForm.tagsInput.split(',').map(t => t.trim()).filter(t => t)"
:key="tag"
:class="['text-xs px-2 py-1 rounded', paradigmEditState.editForm.tagClass]"
>
{{ tag }}
</span>
</div>
</div>
<!-- 标签颜色 -->
<div>
<label class="block text-sm text-slate-400 mb-2">标签颜色</label>
<div class="flex gap-2 flex-wrap">
<button
v-for="color in colorOptions"
:key="color.class"
@click="paradigmEditState.editForm.tagClass = color.class"
:class="['px-3 py-1 text-xs rounded border transition', color.class,
paradigmEditState.editForm.tagClass === color.class
? 'ring-2 ring-white/50'
: 'opacity-60 hover:opacity-100']"
>
{{ color.label }}
</button>
</div>
</div>
<!-- 核心字段完整 Prompt -->
<div>
<label class="block text-sm text-slate-400 mb-2 flex items-center gap-2">
<span></span> 完整 Prompt核心字段
<span class="text-xs text-slate-500">粘贴您的完整提示词</span>
</label>
<textarea
v-model="paradigmEditState.editForm.specializedPrompt"
rows="16"
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">
💡 提示这是 AI 调用时的核心指令完整的 Prompt 应包含角色目标步骤和输出格式
</p>
</div>
</div>
<div class="px-6 py-4 border-t border-slate-700 flex justify-end gap-3">
<button
@click="closeParadigmEdit"
class="px-4 py-2 text-sm text-slate-400 hover:text-white transition"
>
取消
</button>
<button
@click="saveParadigmEdit"
:disabled="!paradigmEditState.editForm.name"
class="px-6 py-2 text-sm bg-blue-600 hover:bg-blue-500 text-white rounded-lg transition disabled:opacity-50 disabled:cursor-not-allowed"
>
{{ paradigmEditState.isAddMode ? '添加范式' : '保存修改' }}
</button>
</div>
</div>
<!-- 默认显示或分析结果 -->
<template v-else>
<div v-if="!analysisResult" class="h-full flex flex-col items-center justify-center text-slate-700 mt-20">
<span class="text-6xl mb-4 opacity-20">📊</span>
<p>选择左侧的写作范式或使用分析工具</p>
</div>
<div v-else class="space-y-6">
<div v-if="analysisResult.error" class="bg-red-900/20 rounded-lg p-6 border border-red-700">
<h3 class="text-lg font-bold text-red-400 mb-4">分析失败</h3>
<p class="text-red-300">{{ analysisResult.message }}</p>
</div>
<div v-else class="space-y-6">
<!-- 分析结果头部 -->
<div class="bg-slate-900 rounded-lg p-6 border border-slate-700">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-bold text-white">分析结果</h3>
<span class="text-xs px-3 py-1 rounded-full bg-blue-900/30 text-blue-300 border border-blue-700">
{{ analysisResult.paradigm }}
</span>
</div>
<div>
<h5 class="text-xs font-medium text-slate-400 mb-2">适用场景</h5>
<p class="text-xs text-slate-300">{{ getParadigmDetails().description }}</p>
<!-- 分析内容 -->
<div class="prose prose-invert max-w-none">
<div v-html="marked.parse(analysisResult.analysis)"></div>
</div>
<!-- 操作按钮 -->
<div class="mt-6 flex gap-3">
<button
@click="copyAnalysis"
class="text-xs px-4 py-2 bg-slate-700 hover:bg-slate-600 text-white rounded transition"
>
📋 复制分析结果
</button>
<button
@click="applyToWriting"
class="text-xs px-4 py-2 bg-blue-600 hover:bg-blue-500 text-white rounded transition"
>
应用到写作
</button>
</div>
</div>
<!-- 范式详情卡片 -->
<div v-if="getParadigmDetails()" class="bg-slate-900/50 rounded-lg p-6 border border-slate-700">
<h4 class="text-md font-bold text-white mb-4">{{ getParadigmDetails().name }}特点</h4>
<div class="grid grid-cols-2 gap-4">
<div>
<h5 class="text-xs font-medium text-slate-400 mb-2">结构要素</h5>
<div class="flex flex-wrap gap-1">
<span
v-for="tag in getParadigmDetails().tags"
:key="tag"
:class="['text-xs px-2 py-1 rounded', getParadigmDetails().tagClass]"
>
{{ tag }}
</span>
</div>
</div>
<div>
<h5 class="text-xs font-medium text-slate-400 mb-2">适用场景</h5>
<p class="text-xs text-slate-300">{{ getParadigmDetails().description }}</p>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
</div>
<!-- 其他页面默认内容 -->
<div v-else class="h-full flex flex-col items-center justify-center text-slate-700 mt-20">
<span class="text-6xl mb-4 opacity-20">📄</span>
<p>选择左侧面板查看内容</p>
</div>
</div>
</div>
@@ -308,13 +485,144 @@ const {
inputTask,
selectedTags,
customConstraint,
references
references,
paradigmEditState
} = storeToRefs(appStore)
// AI 思考过程折叠状态
const isThinkingExpanded = ref(false)
const isQualityReportExpanded = ref(true) // 质检报告默认展开
// 范式写作预览相关
const { paradigmWriterState } = storeToRefs(appStore)
// 获取总生成内容
const totalGeneratedContent = computed(() => {
if (!paradigmWriterState.value?.sections) return ''
return paradigmWriterState.value.sections
.filter(s => s.generatedContent)
.map(s => `## ${s.title}\n\n${s.generatedContent}`)
.join('\n\n')
})
// 字数统计
const totalWordCount = computed(() => {
return totalGeneratedContent.value.length
})
// 渲染章节 Markdown
const renderSectionMarkdown = (content) => {
return marked.parse(content || '')
}
// 复制范式写作内容
const copyParadigmContent = () => {
if (totalGeneratedContent.value) {
navigator.clipboard.writeText(totalGeneratedContent.value)
alert('已复制到剪贴板')
}
}
// 导出 Word
const exportParadigmToWord = async () => {
if (!totalGeneratedContent.value) return
try {
const { Document, Paragraph, TextRun, Packer, HeadingLevel } = await import('docx')
const { saveAs } = await import('file-saver')
const children = []
paradigmWriterState.value.sections.forEach(section => {
if (section.generatedContent) {
children.push(
new Paragraph({
text: section.title,
heading: section.type === 'h2' ? HeadingLevel.HEADING_2 : HeadingLevel.HEADING_3
}),
new Paragraph({
children: [new TextRun(section.generatedContent)]
})
)
}
})
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('导出失败')
}
}
// 图标选项
const iconOptions = ['📝', '📄', '📊', '📈', '📉', '🏛️', '🍂', '💻', '📌', '💡']
// 颜色选项
const colorOptions = [
{ label: '蓝色', class: 'bg-blue-900/30 text-blue-300' },
{ label: '绿色', class: 'bg-green-900/30 text-green-300' },
{ label: '红色', class: 'bg-red-900/30 text-red-300' },
{ label: '紫色', class: 'bg-purple-900/30 text-purple-300' },
{ label: '橙色', class: 'bg-orange-900/30 text-orange-300' },
{ label: '青色', class: 'bg-cyan-900/30 text-cyan-300' }
]
// 关闭编辑
const closeParadigmEdit = () => {
paradigmEditState.value.isEditing = false
paradigmEditState.value.isAddMode = false
paradigmEditState.value.editingParadigmId = null
}
// 保存范式编辑
const saveParadigmEdit = () => {
const form = paradigmEditState.value.editForm
// 构建范式对象
const paradigm = {
id: paradigmEditState.value.isAddMode
? `custom_${Date.now()}`
: paradigmEditState.value.editingParadigmId,
icon: form.icon,
name: form.name,
description: form.description,
tags: form.tagsInput.split(',').map(t => t.trim()).filter(t => t),
tagClass: form.tagClass,
specializedPrompt: form.specializedPrompt,
isCustom: true,
createdAt: paradigmEditState.value.isAddMode ? new Date().toISOString() : undefined
}
// 保存到 localStorage
const customParadigms = JSON.parse(localStorage.getItem('customParadigms') || '[]')
if (paradigmEditState.value.isAddMode) {
customParadigms.push(paradigm)
} else {
const index = customParadigms.findIndex(p => p.id === paradigm.id)
if (index >= 0) {
customParadigms[index] = { ...customParadigms[index], ...paradigm }
} else {
// 可能是系统范式的修改,添加为新的自定义范式
customParadigms.push(paradigm)
}
}
localStorage.setItem('customParadigms', JSON.stringify(customParadigms))
// 关闭编辑
closeParadigmEdit()
// 触发刷新事件
window.dispatchEvent(new CustomEvent('paradigm-saved'))
alert('范式保存成功!')
}
// 文稿编辑状态
const currentDocument = ref(null)
const documentTitle = ref('')

View File

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

View File

@@ -11,6 +11,52 @@
<!-- 设置内容 -->
<div class="flex-1 overflow-y-auto p-4 space-y-6 min-h-0">
<!-- AI 模型设置 -->
<section class="settings-section">
<h3 class="settings-section-title">
<span>🤖</span> AI 模型设置
</h3>
<div class="space-y-3">
<div>
<label class="settings-label">模型服务商</label>
<select
v-model="selectedProviderId"
class="settings-select"
>
<option
v-for="provider in availableProviders"
:key="provider.id"
:value="provider.id"
>
{{ provider.name }} - {{ provider.description }}
</option>
</select>
</div>
<!-- 当前配置状态 -->
<div class="provider-status">
<div class="status-row">
<span class="status-label">API 端点</span>
<span class="status-value truncate">{{ currentProvider?.apiUrl || '未配置' }}</span>
</div>
<div class="status-row">
<span class="status-label">API Key</span>
<span :class="['status-value', currentProvider?.apiKey ? 'configured' : 'not-configured']">
{{ currentProvider?.apiKey ? '✓ 已配置' : '✗ 未配置' }}
</span>
</div>
<div class="status-row" v-if="currentProvider?.model">
<span class="status-label">默认模型</span>
<span class="status-value">{{ currentProvider.model }}</span>
</div>
</div>
<p class="text-xs text-muted">
💡 模型配置通过 <code>.env</code> 文件设置详见项目文档
</p>
</div>
</section>
<!-- 数据统计 -->
<section class="settings-section">
<h3 class="settings-section-title">
@@ -150,10 +196,18 @@
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { ref, reactive, onMounted, computed } from 'vue'
import { storeToRefs } from 'pinia'
import { useAppStore } from '../stores/app'
import { modelProviders, getConfiguredProviders } from '../utils/config.js'
const appStore = useAppStore()
const { selectedProviderId, currentProvider } = storeToRefs(appStore)
// 获取所有可选的服务商(包括已配置和未配置的)
const availableProviders = computed(() => {
return Object.values(modelProviders)
})
// 状态
const stats = reactive({
@@ -391,6 +445,99 @@ onMounted(() => {
color: var(--text-muted);
}
/* 设置输入框和标签 */
.settings-label {
display: block;
font-size: var(--text-xs);
color: var(--text-muted);
margin-bottom: var(--space-1);
}
.settings-input {
width: 100%;
background: var(--bg-elevated);
border: 1px solid var(--border-default);
border-radius: var(--radius-md);
padding: var(--space-2) var(--space-3);
font-size: var(--text-sm);
color: var(--text-primary);
outline: none;
transition: border-color var(--transition-fast);
}
.settings-input:focus {
border-color: var(--accent-primary);
}
.settings-input::placeholder {
color: var(--text-muted);
}
/* 选择框 */
.settings-select {
width: 100%;
background: var(--bg-elevated);
border: 1px solid var(--border-default);
border-radius: var(--radius-md);
padding: var(--space-2) var(--space-3);
font-size: var(--text-sm);
color: var(--text-primary);
outline: none;
cursor: pointer;
transition: border-color var(--transition-fast);
}
.settings-select:focus {
border-color: var(--accent-primary);
}
.settings-select option {
background: var(--bg-secondary);
color: var(--text-primary);
}
/* 服务商状态显示 */
.provider-status {
background: var(--bg-elevated);
border-radius: var(--radius-md);
padding: var(--space-3);
font-size: var(--text-xs);
}
.status-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--space-1) 0;
}
.status-row:not(:last-child) {
border-bottom: 1px solid var(--border-default);
}
.status-label {
color: var(--text-muted);
}
.status-value {
color: var(--text-secondary);
max-width: 200px;
}
.status-value.configured {
color: var(--accent-success);
}
.status-value.not-configured {
color: var(--accent-danger);
}
.status-value.truncate {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* 设置按钮 */
.settings-btn {
width: 100%;

View File

@@ -165,7 +165,7 @@
<!-- 底部操作区 -->
<footer class="writer-footer">
<div class="flex items-center justify-between">
<div class="flex items-center justify-between mb-3">
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" v-model="showPromptDebug" class="hidden">
<div class="toggle-switch" :class="{'active': showPromptDebug}">
@@ -176,22 +176,6 @@
<span class="text-xs text-muted">deepseek</span>
</div>
<!-- API配置 -->
<div class="space-y-2">
<input
v-model="apiUrl"
type="text"
class="writer-input"
placeholder="API 地址"
>
<input
v-model="apiKey"
type="password"
class="writer-input"
placeholder="API Key"
>
</div>
<button
@click="generateContent"
:disabled="!canGenerate"
@@ -223,8 +207,6 @@ const {
expertGuidelines,
isGenerating,
showPromptDebug,
apiUrl,
apiKey,
isDeepMode,
inputType,
outlinePoints