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

582 lines
16 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<aside class="writer-panel">
<!-- 头部 -->
<header class="writer-header">
<h1 class="writer-header-title">
<span style="font-size: var(--text-xl)"></span> AI 写作工坊
</h1>
<span class="badge badge-default">Pro版</span>
</header>
<!-- 内容区 -->
<div class="writer-content">
<!-- 写作任务 -->
<section class="writer-section">
<div class="flex justify-between items-center mb-2">
<label class="writer-label">1. 写作任务 (User Input)</label>
<div class="input-type-toggle">
<button
@click="inputType = 'text'"
:class="['input-type-btn', { active: inputType === 'text' }]"
>自由文本</button>
<button
@click="inputType = 'outline'"
:class="['input-type-btn', { active: inputType === 'outline' }]"
>大纲模式</button>
</div>
</div>
<div v-if="inputType === 'text'">
<textarea
v-model="inputTask"
class="writer-textarea"
placeholder="请输入具体的写作要求、主题、核心观点..."
></textarea>
<div class="text-right mt-1">
<span class="text-xs text-muted">{{ inputTask.length }} </span>
</div>
</div>
<div v-else class="space-y-2">
<input v-model="outlinePoints.topic" placeholder="核心主题" class="writer-input">
<input v-model="outlinePoints.audience" placeholder="目标受众" class="writer-input">
<textarea v-model="outlinePoints.keyPoints" placeholder="关键观点(每行一个)" class="writer-textarea" style="height: 80px"></textarea>
</div>
</section>
<!-- 参考案例 -->
<section class="writer-section">
<div class="flex justify-between items-center mb-2">
<label class="writer-label">2. 参考案例 (Style Ref)</label>
<button @click="showRefInput = !showRefInput" class="text-accent hover:opacity-80" style="font-size: var(--text-xs)">
{{ showRefInput ? '取消' : '+ 添加案例' }}
</button>
</div>
<div v-if="showRefInput" class="mb-3 p-3 bg-primary border border-accent rounded-lg" style="border-color: var(--accent-primary)">
<input v-model="newRefTitle" placeholder="案例标题" class="writer-input mb-2">
<textarea v-model="newRefContent" placeholder="粘贴优秀的参考文本..." class="writer-textarea mb-2" style="height: 96px"></textarea>
<button @click="addReference" class="btn btn-primary w-full text-xs">确认添加</button>
</div>
<div class="space-y-2">
<div v-for="(ref, index) in references" :key="index" class="ref-card">
<div class="ref-card-header">
<div class="ref-card-title">
<span style="font-size: var(--text-base)">📄</span>
<span class="ref-card-title-text">{{ ref.title }}</span>
</div>
<button @click="removeReference(index)" class="ref-remove-btn px-2">×</button>
</div>
<div style="padding-left: var(--space-5)">
<p class="text-xs text-muted truncate mb-1">{{ ref.content.substring(0, 30) }}...</p>
<!-- 风格分析结果 -->
<div v-if="ref.isAnalyzing" class="flex items-center gap-1 text-xs animate-pulse" style="color: var(--accent-primary)">
<span style="width: 4px; height: 4px; background: var(--accent-primary); border-radius: 50%"></span>
正在分析风格特征...
</div>
<div v-else-if="ref.styleTags && ref.styleTags.length > 0" class="flex flex-wrap gap-1">
<span
v-for="tag in ref.styleTags"
:key="tag"
class="style-tag"
>
{{ tag }}
</span>
</div>
</div>
</div>
</div>
</section>
<!-- 专家指令范式预设时显示 -->
<section v-if="activeParadigm" class="writer-section">
<div class="flex justify-between items-center mb-2">
<label class="writer-label writer-label-alt">
专家指令 (Expert Guidelines)
</label>
<button
@click="clearParadigm"
class="text-xs text-muted hover:text-danger transition"
>
清除范式
</button>
</div>
<div class="expert-section">
<div class="flex items-center gap-2 mb-2">
<span class="text-accent">{{ activeParadigm.icon }}</span>
<span class="text-xs font-medium text-accent" style="color: var(--accent-warning)">已加载{{ activeParadigm.name }}专家标准</span>
</div>
<div class="space-y-1.5">
<div
v-for="(guideline, idx) in expertGuidelines"
:key="idx"
class="expert-item"
>
<span style="color: var(--accent-warning); opacity: 0.7;" class="shrink-0">{{ idx + 1 }}.</span>
<div>
<span class="font-medium" style="color: var(--accent-warning); opacity: 0.8;">{{ guideline.title }}</span>
<span class="text-secondary">{{ guideline.description }}</span>
</div>
</div>
</div>
</div>
</section>
<!-- 输出规范 -->
<section class="writer-section">
<label class="writer-label block mb-2">3. 输出规范 (Constraints)</label>
<div class="flex flex-wrap gap-2 mb-3">
<button
v-for="tag in presetTags"
:key="tag"
@click="toggleTag(tag)"
:class="['tag-button', { selected: selectedTags.includes(tag) }]"
>
{{ tag }}
</button>
</div>
<input
v-model="customConstraint"
type="text"
class="writer-input"
placeholder="补充其他要求"
>
<!-- 深度模式开关 -->
<div class="deep-mode-toggle mt-4">
<div class="flex flex-col">
<span class="text-sm font-bold flex items-center gap-1" style="color: var(--accent-primary)">
🧠 深度模式 (Deep Mode)
</span>
<span class="text-[10px] text-muted">模拟人类"初稿-反思-润色"的思维链</span>
</div>
<label class="relative inline-flex items-center cursor-pointer">
<input type="checkbox" v-model="isDeepMode" class="hidden peer">
<div style="width: 36px; height: 20px; background: var(--bg-elevated); border-radius: 9999px; position: relative; transition: all var(--transition-normal);" class="peer-checked:bg-indigo-600">
<div style="width: 16px; height: 16px; background: white; border-radius: 50%; position: absolute; top: 2px; left: 2px; transition: all var(--transition-normal);" class="peer-checked:translate-x-4"></div>
</div>
</label>
</div>
</section>
</div>
<!-- 底部操作区 -->
<footer class="writer-footer">
<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}">
<div class="toggle-thumb" :class="{'active': showPromptDebug}"></div>
</div>
<span class="text-xs text-muted select-none">预览构建的 Prompt</span>
</label>
<span class="text-xs text-muted">deepseek</span>
</div>
<button
@click="generateContent"
:disabled="!canGenerate"
class="generate-button primary"
:class="{'generating': isGenerating}"
>
<span v-if="isGenerating" class="animate-spin text-lg"></span>
{{ isGenerating ? '正在思考与撰写...' : '开始生成文稿' }}
</button>
</footer>
</aside>
</template>
<script setup>
import { computed } from 'vue'
import { storeToRefs } from 'pinia'
import { useAppStore } from '../stores/app'
const appStore = useAppStore()
const {
inputTask,
references,
showRefInput,
newRefTitle,
newRefContent,
selectedTags,
customConstraint,
activeParadigm,
expertGuidelines,
isGenerating,
showPromptDebug,
isDeepMode,
inputType,
outlinePoints
} = storeToRefs(appStore)
const { switchPage, clearParadigm } = appStore
const presetTags = ['Markdown格式', '总分总结构', '数据支撑', '语气幽默', '严禁被动语态', '引用权威来源']
// 添加参考案例
const addReference = () => {
if (!newRefTitle.value || !newRefContent.value) return
appStore.addReferenceFromAnalysis(newRefTitle.value, newRefContent.value)
newRefTitle.value = ''
newRefContent.value = ''
showRefInput.value = false
}
// 删除参考案例
const removeReference = (index) => {
references.value.splice(index, 1)
}
// 切换标签
const toggleTag = (tag) => {
if (selectedTags.value.includes(tag)) {
selectedTags.value = selectedTags.value.filter(t => t !== tag)
} else {
selectedTags.value.push(tag)
}
}
// 生成内容
const generateContent = async () => {
try {
await appStore.generateContentAction()
} catch (error) {
alert(error.message)
}
}
const canGenerate = computed(() => {
if (isGenerating.value) return false
if (inputType.value === 'text') {
return !!inputTask.value?.trim()
} else {
return !!outlinePoints.value?.topic?.trim()
}
})
</script>
<style scoped>
/* ========== 使用设计令牌系统 ========== */
/* 侧边栏容器 */
.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;
}
/* 头部 */
.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-section:last-child {
margin-bottom: 0;
}
/* 标签 */
.writer-label {
font-size: var(--text-sm);
font-weight: var(--font-medium);
color: var(--text-secondary);
margin-bottom: var(--space-2);
}
.writer-label-alt {
color: var(--accent-warning);
}
/* 输入框样式 */
.writer-textarea {
width: 100%;
height: 128px;
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);
transition: all var(--transition-fast);
}
.writer-textarea::placeholder {
color: var(--text-muted);
}
.writer-textarea:focus {
outline: none;
border-color: var(--accent-primary);
box-shadow: 0 0 0 2px var(--info-bg);
}
.writer-input {
width: 100%;
background: var(--bg-primary);
border: 1px solid var(--border-default);
border-radius: var(--radius-md);
padding: var(--space-3);
font-size: var(--text-xs);
color: var(--text-primary);
}
.writer-input::placeholder {
color: var(--text-muted);
}
.writer-input:focus {
outline: none;
border-color: var(--accent-primary);
}
/* 参考案例卡片 */
.ref-card {
display: flex;
flex-direction: column;
background: var(--bg-elevated);
padding: var(--space-2);
border-radius: var(--radius-md);
border: 1px solid var(--border-default);
transition: all var(--transition-fast);
}
.ref-card:hover {
border-color: var(--border-strong);
}
.ref-card-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--space-1);
}
.ref-card-title {
display: flex;
align-items: center;
gap: var(--space-2);
overflow: hidden;
}
.ref-card-title-text {
font-size: var(--text-xs);
font-weight: var(--font-medium);
color: var(--text-primary);
}
.ref-remove-btn {
color: var(--text-muted);
opacity: 0;
transition: all var(--transition-fast);
}
.ref-card:hover .ref-remove-btn {
opacity: 1;
}
.ref-remove-btn:hover {
color: var(--accent-danger);
}
/* 风格标签 */
.style-tag {
font-size: 10px;
padding: 2px 6px;
border-radius: var(--radius-sm);
background: var(--info-bg);
color: var(--accent-primary);
border: 1px solid rgba(96, 165, 250, 0.3);
}
/* 专家指令区域 */
.expert-section {
background: var(--warning-bg);
border: 1px solid rgba(245, 158, 11, 0.3);
border-radius: var(--radius-lg);
padding: var(--space-3);
}
.expert-item {
font-size: 11px;
color: var(--text-secondary);
display: flex;
align-items: flex-start;
gap: var(--space-2);
}
/* 标签按钮 */
.tag-button {
padding: var(--space-1) var(--space-2);
border-radius: var(--radius-md);
font-size: var(--text-xs);
border: 1px solid var(--border-default);
transition: all var(--transition-fast);
cursor: pointer;
}
.tag-button.selected {
background: var(--info-bg);
border-color: var(--accent-primary);
color: var(--accent-primary);
}
.tag-button:not(.selected) {
background: var(--bg-primary);
color: var(--text-muted);
}
.tag-button:not(.selected):hover {
border-color: var(--border-strong);
}
/* 深度模式开关 */
.deep-mode-toggle {
display: flex;
align-items: center;
justify-content: space-between;
background: var(--bg-primary);
padding: var(--space-3);
border-radius: var(--radius-md);
border: 1px solid var(--accent-primary);
}
/* 底部操作区 */
.writer-footer {
padding: var(--space-4);
background: var(--bg-secondary);
border-top: 1px solid var(--border-default);
}
/* 切换开关 */
.toggle-switch {
width: 32px;
height: 16px;
background: var(--bg-primary);
border-radius: var(--radius-full);
border: 1px solid var(--border-default);
position: relative;
transition: all var(--transition-normal);
}
.toggle-switch.active {
background: var(--info-bg);
border-color: var(--accent-primary);
}
.toggle-thumb {
width: 10px;
height: 10px;
background: var(--text-muted);
border-radius: 50%;
position: absolute;
top: 2px;
left: 2px;
transition: all var(--transition-normal);
}
.toggle-thumb.active {
transform: translateX(16px);
background: var(--accent-primary);
}
/* 生成按钮 */
.generate-button {
width: 100%;
padding: var(--space-3);
border-radius: var(--radius-lg);
font-weight: var(--font-semibold);
color: var(--text-inverse);
box-shadow: var(--shadow-md);
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-2);
transition: all var(--transition-normal);
transform: translateY(0);
border: none;
cursor: pointer;
}
.generate-button:not(:disabled):active {
transform: translateY(1px);
}
.generate-button.primary {
background: linear-gradient(135deg, var(--accent-primary), #6366f1);
}
.generate-button.primary:hover:not(:disabled) {
background: linear-gradient(135deg, var(--accent-primary-hover), #4f46e5);
}
.generate-button.generating {
background: var(--bg-elevated);
color: var(--text-muted);
cursor: wait;
}
.generate-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* 切换器 */
.input-type-toggle {
display: flex;
background: var(--bg-primary);
padding: var(--space-1);
border-radius: var(--radius-md);
border: 1px solid var(--border-default);
}
.input-type-btn {
padding: var(--space-2) var(--space-3);
font-size: var(--text-xs);
border-radius: var(--radius-sm);
transition: all var(--transition-fast);
}
.input-type-btn.active {
background: var(--bg-elevated);
color: var(--text-primary);
}
.input-type-btn:not(.active) {
color: var(--text-muted);
}
.input-type-btn:not(.active):hover {
color: var(--text-secondary);
}
</style>