新增: u-ppt Vue 3 在线演示工具初始版本
- 核心架构: Vue 3 + Vite + TypeScript,零运行时依赖(仅 Vue) - 编辑器: 幻灯片增删排序、元素拖拽八向缩放、双击编辑、12 种元素类型 (标题/正文/列表/数据/金句/图片/形状/图表/卡片/表格/代码/公式) - 图表: 8 种 SVG 自绘(柱状/条形/折线/面积/饼图/环形/雷达/进度) - 富文本: 结构化 segments(加粗/斜体/颜色/高亮/上下标/代码/链接) - AI 能力: 对话编辑、生成整套、润色本页、大纲→逐页生成、一键美化、AI 配图 - 会话绑定: 每份 PPT 独立会话,切换 PPT 自动切换对话历史 - 模板系统: 7 个内置版式 + 用户自存模板 - 演示模式: 全屏播放、键盘/鼠标/滚轮导航、入场动画 - 持久化: localStorage 存 deck/文库/会话/模板/配置 - 支持多服务商: 智谱/DeepSeek/通义/Kimi/豆包/OpenAI/Anthropic/Gemini/Groq/Ollama
This commit is contained in:
@@ -0,0 +1,455 @@
|
||||
<!-- =====================================================================
|
||||
AiPanel.vue — AI 聊天面板
|
||||
发送/停止/生成整套/润色本页/流式渲染/操作应用
|
||||
===================================================================== -->
|
||||
<script setup lang="ts">
|
||||
import { ref, nextTick, computed, watch } from 'vue'
|
||||
import type { ChatMessage, AiOp, Slide } from '../../core/types'
|
||||
import { store } from '../../core/store'
|
||||
import { generate, polish, chat, beautifyPage, isConfigured } from '../../core/ai'
|
||||
import { elementTypes } from '../../core/sample'
|
||||
import OutlinePanel from './OutlinePanel.vue'
|
||||
|
||||
/* ---------- 消息内容渲染:提取 JSON 代码块并格式化 ---------- */
|
||||
|
||||
function escapeHtml(s: string): string {
|
||||
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
}
|
||||
|
||||
/** 尝试把 JSON 字符串格式化(缩进),失败则返回原文 */
|
||||
function tryFormatJson(raw: string): string {
|
||||
try { return JSON.stringify(JSON.parse(raw), null, 2) } catch (e) { return raw }
|
||||
}
|
||||
|
||||
/** 对格式化后的 JSON 做轻量语法着色(不引依赖,纯正则) */
|
||||
function highlightJson(code: string): string {
|
||||
return escapeHtml(code)
|
||||
.replace(/("(?:\\.|[^"\\])*"\s*:)/g, '<span class="jk">$1</span>')
|
||||
.replace(/:\s*("(?:\\.|[^"\\])*")/g, ': <span class="js">$1</span>')
|
||||
.replace(/:\s*(-?\d+\.?\d*)/g, ': <span class="jn">$1</span>')
|
||||
.replace(/:\s*(true|false|null)/g, ': <span class="jb">$1</span>')
|
||||
}
|
||||
|
||||
interface RenderedPart {
|
||||
type: 'text' | 'code'
|
||||
html: string
|
||||
lang?: string
|
||||
}
|
||||
|
||||
/** 把消息内容拆分为文本段 + JSON 代码块段 */
|
||||
function renderMessageContent(content: string): RenderedPart[] {
|
||||
if (!content) return []
|
||||
const parts: RenderedPart[] = []
|
||||
// 匹配 ```json ... ``` 或 ``` ... ``` 代码块
|
||||
const codeBlockRe = /```(\w*)\n?([\s\S]*?)```/g
|
||||
let lastIdx = 0
|
||||
let m: RegExpExecArray | null
|
||||
while ((m = codeBlockRe.exec(content)) !== null) {
|
||||
// 代码块前的文本
|
||||
if (m.index > lastIdx) {
|
||||
const text = content.slice(lastIdx, m.index)
|
||||
if (text.trim()) parts.push({ type: 'text', html: escapeHtml(text) })
|
||||
}
|
||||
const lang = m[1] || ''
|
||||
const code = m[2]
|
||||
const trimmed = code.trim()
|
||||
// JSON 代码块 → 格式化 + 着色
|
||||
if (!lang || lang === 'json') {
|
||||
parts.push({ type: 'code', lang: 'json', html: highlightJson(tryFormatJson(trimmed)) })
|
||||
} else {
|
||||
parts.push({ type: 'code', lang, html: escapeHtml(trimmed) })
|
||||
}
|
||||
lastIdx = m.index + m[0].length
|
||||
}
|
||||
// 尾部文本
|
||||
if (lastIdx < content.length) {
|
||||
const text = content.slice(lastIdx)
|
||||
if (text.trim()) parts.push({ type: 'text', html: escapeHtml(text) })
|
||||
}
|
||||
// 没有代码块 → 返回整个为文本段
|
||||
if (!parts.length) parts.push({ type: 'text', html: escapeHtml(content) })
|
||||
return parts
|
||||
}
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'busy-change', busy: boolean): void
|
||||
(e: 'toast', msg: string): void
|
||||
(e: 'open-settings'): void
|
||||
(e: 'switch-tab', tab: string): void
|
||||
}>()
|
||||
|
||||
const CHAT_KEY = 'u-ppt.chat.v1' // 旧 key,仅用于迁移
|
||||
|
||||
/** 当前会话绑定的 chatId(跟随 deck.chatId) */
|
||||
const currentChatId = computed(() => store.getChatId())
|
||||
|
||||
/** 渲染用消息条目(带可选的流式/标签/error 状态) */
|
||||
interface RenderMsg {
|
||||
key: number
|
||||
role: 'user' | 'assistant' | 'system'
|
||||
content: string
|
||||
streaming?: boolean
|
||||
tag?: string
|
||||
error?: boolean
|
||||
}
|
||||
|
||||
const renderMsgs = ref<RenderMsg[]>([])
|
||||
const chatLog = ref<ChatMessage[]>(loadChat())
|
||||
const messagesEl = ref<HTMLElement | null>(null)
|
||||
const inputEl = ref<HTMLTextAreaElement | null>(null)
|
||||
|
||||
const inputText = ref('')
|
||||
const busy = ref(false)
|
||||
const showOutline = ref(false)
|
||||
|
||||
/* ---------- 当前选中元素(用于上下文对话) ---------- */
|
||||
const selectedEl = computed(() => store.getSelected())
|
||||
const selectedHint = computed(() => {
|
||||
const el = selectedEl.value
|
||||
if (!el) return ''
|
||||
const label = elementTypes[el.type]?.label || el.type
|
||||
const preview = (el.content || '').replace(/\n/g, ' ').slice(0, 40)
|
||||
return label + (preview ? ' · ' + preview : '')
|
||||
})
|
||||
let abortCtrl: AbortController | null = null
|
||||
let chatSaveTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let keySeq = 0
|
||||
|
||||
function loadChat(): ChatMessage[] {
|
||||
// 按 chatId 加载该 PPT 关联的会话
|
||||
return store.getChat(currentChatId.value)
|
||||
}
|
||||
|
||||
function persistChat() {
|
||||
if (chatLog.value.length > 200) chatLog.value.splice(0, chatLog.value.length - 200)
|
||||
if (chatSaveTimer) clearTimeout(chatSaveTimer)
|
||||
chatSaveTimer = setTimeout(() => {
|
||||
store.setChat(currentChatId.value, chatLog.value)
|
||||
}, 400)
|
||||
}
|
||||
|
||||
function setBusy(b: boolean) {
|
||||
busy.value = b
|
||||
emit('busy-change', b)
|
||||
}
|
||||
|
||||
function scrollBottom() {
|
||||
nextTick(() => {
|
||||
const el = messagesEl.value
|
||||
if (el) el.scrollTop = el.scrollHeight
|
||||
})
|
||||
}
|
||||
|
||||
function toast(msg: string) { emit('toast', msg) }
|
||||
|
||||
/** 从持久化 chatLog 重建渲染列表 */
|
||||
function rebuildFromChatLog() {
|
||||
renderMsgs.value = chatLog.value.map(m => ({
|
||||
key: ++keySeq,
|
||||
role: m.role,
|
||||
content: m.content
|
||||
}))
|
||||
}
|
||||
|
||||
/** 追加一条静态消息(同时进 chatLog) */
|
||||
function addPersisted(role: ChatMessage['role'], text: string) {
|
||||
chatLog.value.push({ role, content: text })
|
||||
persistChat()
|
||||
renderMsgs.value.push({ key: ++keySeq, role, content: text })
|
||||
scrollBottom()
|
||||
}
|
||||
|
||||
/** 创建一条流式气泡(返回控制器对象) */
|
||||
interface StreamCtrl {
|
||||
msg: RenderMsg
|
||||
started: boolean
|
||||
}
|
||||
function streamBubble(placeholder?: string): StreamCtrl {
|
||||
const msg: RenderMsg = {
|
||||
key: ++keySeq,
|
||||
role: 'assistant',
|
||||
content: placeholder || '',
|
||||
streaming: true
|
||||
}
|
||||
renderMsgs.value.push(msg)
|
||||
scrollBottom()
|
||||
return { msg, started: !placeholder }
|
||||
}
|
||||
function streamOnVisible(s: StreamCtrl) {
|
||||
return (t: string) => {
|
||||
if (!s.started) { s.msg.content = ''; s.started = true }
|
||||
s.msg.content += t
|
||||
scrollBottom()
|
||||
}
|
||||
}
|
||||
function streamDone(s: StreamCtrl) {
|
||||
s.msg.streaming = false
|
||||
if (!s.msg.content) s.msg.content = ''
|
||||
}
|
||||
function streamSetText(s: StreamCtrl, txt: string) {
|
||||
s.msg.content = txt
|
||||
s.msg.streaming = false
|
||||
}
|
||||
function streamError(s: StreamCtrl, msg: string) {
|
||||
s.msg.error = true
|
||||
s.msg.content = '⚠ ' + msg
|
||||
s.msg.streaming = false
|
||||
}
|
||||
function streamTag(s: StreamCtrl, txt: string) {
|
||||
if (txt) s.msg.tag = txt
|
||||
}
|
||||
|
||||
/** 应用 AI 返回的操作到 store */
|
||||
function applyOp(op: AiOp, lockedIdx: number): string {
|
||||
const slides = op.slides
|
||||
if (op.action === 'create_all' && slides.length) {
|
||||
store.replaceDeck({ theme: store.theme.value, slides }, { newChat: true })
|
||||
return '已替换为 ' + slides.length + ' 页新演示'
|
||||
}
|
||||
if (op.action === 'add_page' && slides.length) {
|
||||
const idx = (op.target != null ? op.target : lockedIdx) + 1
|
||||
store.insertSlideAt(Math.min(idx, store.getCount()), slides[0])
|
||||
return '已新增 ' + slides.length + ' 页'
|
||||
}
|
||||
if (op.action === 'update_page' && slides.length) {
|
||||
let t = op.target != null ? op.target : lockedIdx
|
||||
t = Math.max(0, Math.min(t, store.getCount() - 1))
|
||||
store.replaceSlide(t, slides[0])
|
||||
return '已更新第 ' + (t + 1) + ' 页'
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
/** 把流式气泡固化为持久化记录 */
|
||||
function persistStream(s: StreamCtrl) {
|
||||
chatLog.value.push({ role: 'assistant', content: s.msg.content + (s.msg.tag ? '\n[✓ ' + s.msg.tag + ']' : '') })
|
||||
persistChat()
|
||||
}
|
||||
|
||||
/* ---------- 发送 ---------- */
|
||||
function onSend() {
|
||||
if (busy.value) return
|
||||
const text = inputText.value.trim()
|
||||
if (!text) {
|
||||
if (!isConfigured()) { emit('open-settings'); return }
|
||||
return
|
||||
}
|
||||
if (!isConfigured()) { toast('请先配置 API Key'); emit('open-settings'); return }
|
||||
inputText.value = ''
|
||||
runChat(text)
|
||||
}
|
||||
|
||||
async function runChat(input: string) {
|
||||
addPersisted('user', input)
|
||||
const history = chatLog.value.slice(-10).map(m => ({ role: m.role, content: m.content }))
|
||||
const idx0 = store.getCurrentIndex()
|
||||
const stream = streamBubble()
|
||||
|
||||
setBusy(true)
|
||||
abortCtrl = new AbortController()
|
||||
|
||||
try {
|
||||
const r = await chat({
|
||||
history,
|
||||
input,
|
||||
onVisible: streamOnVisible(stream),
|
||||
signal: abortCtrl.signal,
|
||||
selectedElement: selectedEl.value
|
||||
})
|
||||
streamDone(stream)
|
||||
if (!r.reply) {
|
||||
streamSetText(stream, '(已完成)')
|
||||
}
|
||||
if (r.op && r.op.action !== 'answer' && r.op.slides.length) {
|
||||
const applied = applyOp(r.op, idx0)
|
||||
streamTag(stream, applied)
|
||||
}
|
||||
persistStream(stream)
|
||||
} catch (e: any) {
|
||||
if (e?.name === 'AbortError') streamSetText(stream, '(已停止)')
|
||||
else streamError(stream, e?.message || String(e))
|
||||
persistStream(stream)
|
||||
} finally {
|
||||
setBusy(false); abortCtrl = null
|
||||
}
|
||||
}
|
||||
|
||||
function onStop() {
|
||||
if (abortCtrl) abortCtrl.abort()
|
||||
}
|
||||
|
||||
/* ---------- 生成整套 ---------- */
|
||||
async function onGenerate() {
|
||||
if (busy.value) return
|
||||
const topic = inputText.value.trim() || prompt('请输入演示主题,例如「远程办公的兴起与未来」')
|
||||
if (!topic) return
|
||||
if (!isConfigured()) { toast('请先配置 API Key'); emit('open-settings'); return }
|
||||
inputText.value = ''
|
||||
const stream = streamBubble('正在创作「' + topic + '」…')
|
||||
setBusy(true); abortCtrl = new AbortController()
|
||||
|
||||
try {
|
||||
const r = await generate({ topic, signal: abortCtrl.signal })
|
||||
streamDone(stream)
|
||||
// 生成整套 → 开新会话,旧会话保留在 localStorage
|
||||
store.replaceDeck({ theme: store.theme.value, slides: r.slides }, { newChat: true })
|
||||
// watch(currentChatId) 会自动清空 renderMsgs 并加载新会话(空的)
|
||||
// 在新会话里记录这次生成
|
||||
addPersisted('user', '✨ 生成整套:' + topic)
|
||||
addPersisted('assistant', '✅ 已生成 ' + r.slides.length + ' 页演示。可在画布查看与微调,Ctrl+Z 可撤销。')
|
||||
} catch (e: any) {
|
||||
if (e?.name === 'AbortError') streamSetText(stream, '(已停止)')
|
||||
else streamError(stream, e?.message || String(e))
|
||||
persistStream(stream)
|
||||
} finally {
|
||||
setBusy(false); abortCtrl = null
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- 润色本页 ---------- */
|
||||
async function onPolish() {
|
||||
if (busy.value) return
|
||||
if (!isConfigured()) { toast('请先配置 API Key'); emit('open-settings'); return }
|
||||
const slide = store.currentSlide.value
|
||||
if (!slide) return
|
||||
const idx0 = store.getCurrentIndex()
|
||||
const idx = idx0 + 1
|
||||
addPersisted('user', '🪄 润色第 ' + idx + ' 页')
|
||||
const stream = streamBubble('正在润色第 ' + idx + ' 页…')
|
||||
setBusy(true); abortCtrl = new AbortController()
|
||||
|
||||
try {
|
||||
const r = await polish({ slide: slide as Slide, instruction: '让内容更有吸引力、表达更精炼,保持布局合理', signal: abortCtrl.signal })
|
||||
streamDone(stream)
|
||||
if (idx0 < store.getCount()) store.replaceSlide(idx0, r.slide)
|
||||
streamSetText(stream, '🪄 已润色第 ' + idx + ' 页' + (r.note ? ':' + r.note : '') + '。Ctrl+Z 可撤销。')
|
||||
persistStream(stream)
|
||||
} catch (e: any) {
|
||||
if (e?.name === 'AbortError') streamSetText(stream, '(已停止)')
|
||||
else streamError(stream, e?.message || String(e))
|
||||
persistStream(stream)
|
||||
} finally {
|
||||
setBusy(false); abortCtrl = null
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- 清空 ---------- */
|
||||
function onClear() {
|
||||
if (!chatLog.value.length) { toast('对话已是空的'); return }
|
||||
if (!confirm('清空当前 PPT 的对话记录?')) return
|
||||
chatLog.value = []
|
||||
store.clearChat(currentChatId.value)
|
||||
renderMsgs.value = []
|
||||
}
|
||||
|
||||
/* ---------- 大纲面板 ---------- */
|
||||
function onToggleOutline() {
|
||||
showOutline.value = !showOutline.value
|
||||
}
|
||||
|
||||
/* ---------- 一键美化 ---------- */
|
||||
async function onBeautify() {
|
||||
if (busy.value) return
|
||||
if (!isConfigured()) { toast('请先配置 API Key'); emit('open-settings'); return }
|
||||
const deck = store.getDeck()
|
||||
const slides = deck.slides
|
||||
if (!slides || !slides.length) { toast('当前没有幻灯片可美化'); return }
|
||||
addPersisted('user', '🎨 一键美化全部 (' + slides.length + ' 页)')
|
||||
const stream = streamBubble('正在美化第 1/' + slides.length + ' 页…')
|
||||
setBusy(true); abortCtrl = new AbortController()
|
||||
|
||||
let done = 0
|
||||
try {
|
||||
for (let i = 0; i < slides.length; i++) {
|
||||
if (abortCtrl.signal.aborted) break
|
||||
stream.msg.content = '正在美化 ' + (i + 1) + '/' + slides.length + ' 页…'
|
||||
scrollBottom()
|
||||
const r = await beautifyPage({ slide: store.getDeck().slides[i], signal: abortCtrl.signal })
|
||||
store.replaceSlide(i, r.slide)
|
||||
done++
|
||||
}
|
||||
streamDone(stream)
|
||||
streamSetText(stream, '✅ 已美化 ' + done + ' 页')
|
||||
persistStream(stream)
|
||||
} catch (e: any) {
|
||||
if (e?.name === 'AbortError') streamSetText(stream, '(已停止,已美化 ' + done + ' 页)')
|
||||
else streamError(stream, e?.message || String(e))
|
||||
persistStream(stream)
|
||||
} finally {
|
||||
setBusy(false); abortCtrl = null
|
||||
}
|
||||
}
|
||||
|
||||
function onInputKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); onSend() }
|
||||
}
|
||||
|
||||
/** 供父组件查询/聚焦 */
|
||||
function isBusy() { return busy.value }
|
||||
function focus() { nextTick(() => inputEl.value?.focus()) }
|
||||
defineExpose({ isBusy, focus })
|
||||
|
||||
/* ---------- 初始化:从持久化记录重建 ---------- */
|
||||
rebuildFromChatLog()
|
||||
|
||||
/* ---------- 会话切换:chatId 变化时重新加载对话 ---------- */
|
||||
watch(currentChatId, () => {
|
||||
chatLog.value = loadChat()
|
||||
rebuildFromChatLog()
|
||||
scrollBottom()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="panel-pane ai-pane">
|
||||
<div class="ai-actions">
|
||||
<button class="ai-action" :disabled="busy" @click="onGenerate">✨ 生成整套</button>
|
||||
<button class="ai-action" :disabled="busy" @click="onPolish">🪄 润色本页</button>
|
||||
<button class="ai-action" :disabled="busy" @click="onToggleOutline">📋 大纲</button>
|
||||
<button class="ai-action" :disabled="busy" @click="onBeautify">🎨 美化</button>
|
||||
<button class="ai-action ghost" :disabled="busy" @click="onClear">🗑 清空</button>
|
||||
</div>
|
||||
|
||||
<OutlinePanel v-if="showOutline" :visible="showOutline" @busy-change="setBusy" @toast="toast" @open-settings="emit('open-settings')" />
|
||||
|
||||
<!-- 选中元素提示条:告诉用户 AI 会围绕这个元素对话 -->
|
||||
<div v-if="selectedHint" class="selected-hint" title="AI 对话将基于此选中元素">
|
||||
<span class="selected-hint-icon">④</span>
|
||||
<span class="selected-hint-text">已选中:{{ selectedHint }}</span>
|
||||
<span class="selected-hint-flag">AI 将围绕它对话</span>
|
||||
</div>
|
||||
|
||||
<div class="chat-messages" ref="messagesEl">
|
||||
<div v-if="!renderMsgs.length" class="chat-empty">
|
||||
告诉我你的主题,例如:<br />
|
||||
「生成一份关于<b>远程办公趋势</b>的演示」
|
||||
</div>
|
||||
<div v-for="m in renderMsgs" :key="m.key" class="msg" :class="m.role">
|
||||
<div class="bubble" :class="{ error: m.error }">
|
||||
<span v-if="m.tag" class="diff-tag">✓ {{ m.tag }}</span>
|
||||
<template v-for="(p, pi) in renderMessageContent(m.content)" :key="pi">
|
||||
<pre v-if="p.type === 'code'" class="msg-code" :data-lang="p.lang"><code v-html="p.html"></code></pre>
|
||||
<span v-else class="stream-text" v-html="p.html"></span>
|
||||
</template>
|
||||
<span v-if="m.streaming" class="cursor"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="chat-input-bar">
|
||||
<textarea
|
||||
ref="inputEl"
|
||||
v-model="inputText"
|
||||
id="chatInput"
|
||||
rows="3"
|
||||
placeholder="输入指令,回车发送(Shift+Enter 换行)"
|
||||
:disabled="busy"
|
||||
@keydown="onInputKeydown"
|
||||
></textarea>
|
||||
<div class="btns">
|
||||
<button v-if="!busy" class="btn primary" @click="onSend">发送</button>
|
||||
<button v-else class="btn danger" @click="onStop">停止</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,352 @@
|
||||
<!-- =====================================================================
|
||||
OutlinePanel.vue — 大纲编辑器
|
||||
生成大纲 → 逐条编辑 → 逐页/全部生成 → 应用到文稿
|
||||
===================================================================== -->
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import type { Outline, OutlineItem, Slide } from '../../core/types'
|
||||
import { store } from '../../core/store'
|
||||
import { outline as genOutline, generatePage, isConfigured } from '../../core/ai'
|
||||
|
||||
defineProps<{ visible: boolean }>()
|
||||
const emit = defineEmits<{
|
||||
(e: 'busy-change', busy: boolean): void
|
||||
(e: 'toast', msg: string): void
|
||||
(e: 'open-settings'): void
|
||||
}>()
|
||||
|
||||
const outline = ref<Outline | null>(null)
|
||||
const topicInput = ref('')
|
||||
const titleInput = ref('')
|
||||
const editingTitle = ref(false)
|
||||
const busy = ref(false)
|
||||
const progress = ref({ cur: 0, total: 0 })
|
||||
const generatedSlides = ref<Slide[]>([])
|
||||
let abortCtrl: AbortController | null = null
|
||||
|
||||
/** kind → 徽标文本与颜色 */
|
||||
const KIND_META: Record<OutlineItem['kind'], { label: string; color: string }> = {
|
||||
cover: { label: '封面', color: '#4f46e5' },
|
||||
toc: { label: '目录', color: '#06b6d4' },
|
||||
content: { label: '内容', color: '#64748b' },
|
||||
quote: { label: '金句', color: '#f59e0b' },
|
||||
end: { label: '结尾', color: '#4f46e5' }
|
||||
}
|
||||
|
||||
function setBusy(b: boolean) {
|
||||
busy.value = b
|
||||
emit('busy-change', b)
|
||||
}
|
||||
|
||||
const doneCount = computed(() => outline.value?.items.filter(it => it.done).length || 0)
|
||||
const total = computed(() => outline.value?.items.length || 0)
|
||||
const allDone = computed(() => total.value > 0 && doneCount.value === total.value)
|
||||
|
||||
/* ---------- 生成大纲 ---------- */
|
||||
async function onGenOutline() {
|
||||
if (busy.value) return
|
||||
const topic = topicInput.value.trim()
|
||||
if (!topic) { emit('toast', '请先输入主题'); return }
|
||||
if (!isConfigured()) { emit('open-settings'); return }
|
||||
setBusy(true)
|
||||
abortCtrl = new AbortController()
|
||||
try {
|
||||
const r = await genOutline({ topic, signal: abortCtrl.signal })
|
||||
outline.value = r
|
||||
titleInput.value = r.title
|
||||
generatedSlides.value = []
|
||||
emit('toast', '已生成 ' + r.items.length + ' 条大纲')
|
||||
} catch (e: any) {
|
||||
if (e?.name === 'AbortError') emit('toast', '已停止')
|
||||
else emit('toast', e?.message || String(e))
|
||||
} finally {
|
||||
setBusy(false); abortCtrl = null
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- 编辑标题 ---------- */
|
||||
function startEditTitle() { editingTitle.value = true }
|
||||
function commitTitle() {
|
||||
if (outline.value) outline.value.title = titleInput.value.trim() || outline.value.title
|
||||
editingTitle.value = false
|
||||
}
|
||||
|
||||
/* ---------- 要点编辑 ---------- */
|
||||
function addPoint(it: OutlineItem) {
|
||||
it.points.push('')
|
||||
}
|
||||
function removePoint(it: OutlineItem, idx: number) {
|
||||
it.points.splice(idx, 1)
|
||||
}
|
||||
|
||||
/* ---------- 删除条目 ---------- */
|
||||
function removeItem(idx: number) {
|
||||
if (!outline.value) return
|
||||
outline.value.items.splice(idx, 1)
|
||||
}
|
||||
|
||||
/* ---------- 生成单页 ---------- */
|
||||
async function onGenOne(idx: number) {
|
||||
if (busy.value || !outline.value) return
|
||||
const item = outline.value.items[idx]
|
||||
if (!item) return
|
||||
setBusy(true)
|
||||
abortCtrl = new AbortController()
|
||||
try {
|
||||
const slide = await generatePage({
|
||||
item,
|
||||
index: idx,
|
||||
total: outline.value.items.length,
|
||||
signal: abortCtrl.signal
|
||||
})
|
||||
// 保持 generatedSlides 与 items 顺序对齐
|
||||
generatedSlides.value[idx] = slide
|
||||
item.done = true
|
||||
emit('toast', '已生成第 ' + (idx + 1) + ' 页')
|
||||
} catch (e: any) {
|
||||
if (e?.name === 'AbortError') emit('toast', '已停止')
|
||||
else emit('toast', e?.message || String(e))
|
||||
} finally {
|
||||
setBusy(false); abortCtrl = null
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- 全部生成(串行) ---------- */
|
||||
async function onGenAll() {
|
||||
if (busy.value || !outline.value) return
|
||||
const items = outline.value.items
|
||||
setBusy(true)
|
||||
abortCtrl = new AbortController()
|
||||
progress.value = { cur: 0, total: items.length }
|
||||
try {
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
if (abortCtrl.signal.aborted) break
|
||||
progress.value = { cur: i + 1, total: items.length }
|
||||
const slide = await generatePage({
|
||||
item: items[i],
|
||||
index: i,
|
||||
total: items.length,
|
||||
signal: abortCtrl.signal
|
||||
})
|
||||
generatedSlides.value[i] = slide
|
||||
items[i].done = true
|
||||
}
|
||||
emit('toast', '全部生成完成(' + items.filter(it => it.done).length + '/' + items.length + ')')
|
||||
} catch (e: any) {
|
||||
if (e?.name === 'AbortError') emit('toast', '已停止')
|
||||
else emit('toast', e?.message || String(e))
|
||||
} finally {
|
||||
progress.value = { cur: 0, total: 0 }
|
||||
setBusy(false); abortCtrl = null
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- 停止 ---------- */
|
||||
function onStop() {
|
||||
if (abortCtrl) abortCtrl.abort()
|
||||
}
|
||||
|
||||
/* ---------- 应用到文稿 ---------- */
|
||||
function onApply() {
|
||||
if (!outline.value) return
|
||||
const slides = generatedSlides.value.filter(Boolean)
|
||||
if (!slides.length) { emit('toast', '请先生成至少一页'); return }
|
||||
store.replaceDeck({ theme: store.theme.value, slides }, { newChat: true })
|
||||
emit('toast', '已应用 ' + slides.length + ' 页到文稿')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="outline-panel">
|
||||
<!-- 主题输入 + 生成大纲 -->
|
||||
<div class="outline-top">
|
||||
<input type="text" v-model="topicInput" placeholder="输入主题,例如「人工智能的产业落地」" :disabled="busy" @keydown.enter="onGenOutline" />
|
||||
<button class="btn primary" :disabled="busy" @click="onGenOutline">✨ 生成大纲</button>
|
||||
<button v-if="busy" class="btn danger" @click="onStop">停止</button>
|
||||
</div>
|
||||
|
||||
<!-- 无大纲时的空态 -->
|
||||
<div v-if="!outline" class="outline-empty">
|
||||
输入主题后点击「生成大纲」,<br />AI 会先拟定大纲,再逐页生成。
|
||||
</div>
|
||||
|
||||
<!-- 大纲内容 -->
|
||||
<div v-else class="outline-body">
|
||||
<!-- 标题 -->
|
||||
<div class="outline-title">
|
||||
<input v-if="editingTitle" type="text" v-model="titleInput" @blur="commitTitle" @keydown.enter="commitTitle" />
|
||||
<h4 v-else @click="startEditTitle" title="点击编辑">{{ outline.title || '(未命名)' }} <span class="edit-hint">✎</span></h4>
|
||||
</div>
|
||||
|
||||
<!-- 条目列表 -->
|
||||
<div class="outline-items">
|
||||
<div v-for="(it, i) in outline.items" :key="it.id" class="outline-item">
|
||||
<div class="item-head">
|
||||
<span class="kind-badge" :style="{ background: KIND_META[it.kind].color }">{{ KIND_META[it.kind].label }}</span>
|
||||
<input class="item-title" type="text" v-model="it.title" :disabled="busy" />
|
||||
<span class="status" :class="{ done: it.done }">{{ it.done ? '✓ 已生成' : '待生成' }}</span>
|
||||
<button class="btn small" :disabled="busy" @click="onGenOne(i)" :title="'生成第 ' + (i + 1) + ' 页'">生成此页</button>
|
||||
<button class="btn small danger" :disabled="busy" @click="removeItem(i)" title="删除">🗑</button>
|
||||
</div>
|
||||
|
||||
<!-- 要点列表 -->
|
||||
<div class="item-points">
|
||||
<div v-for="(p, pi) in it.points" :key="pi" class="point-row">
|
||||
<input type="text" v-model="it.points[pi]" :disabled="busy" placeholder="要点内容" />
|
||||
<button class="btn small ghost" :disabled="busy" @click="removePoint(it, pi)" title="删除要点">✕</button>
|
||||
</div>
|
||||
<button class="btn small ghost add-point" :disabled="busy" @click="addPoint(it)">+ 添加要点</button>
|
||||
</div>
|
||||
|
||||
<!-- hint -->
|
||||
<div class="item-hint">
|
||||
<input type="text" v-model="it.hint" :disabled="busy" placeholder="补充指令(可选,如「用对比卡片」「数据页」" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 底部操作 -->
|
||||
<div class="outline-footer">
|
||||
<span class="progress-text" v-if="progress.total">{{ progress.cur }}/{{ progress.total }}</span>
|
||||
<span class="done-count" v-else>{{ doneCount }}/{{ total }} 页已生成</span>
|
||||
<button class="btn primary" :disabled="busy || allDone" @click="onGenAll">全部生成</button>
|
||||
<button class="btn" :disabled="busy || !doneCount" @click="onApply">应用到文稿</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.outline-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
max-height: 60vh;
|
||||
overflow-y: auto;
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
.outline-top {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.outline-top input { flex: 1; }
|
||||
|
||||
.outline-empty {
|
||||
color: var(--ui-muted);
|
||||
font-size: 13px;
|
||||
text-align: center;
|
||||
padding: 24px 10px;
|
||||
line-height: 1.9;
|
||||
}
|
||||
|
||||
.outline-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* 标题 */
|
||||
.outline-title h4 {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.edit-hint {
|
||||
font-size: 11px;
|
||||
color: var(--ui-muted);
|
||||
font-weight: 400;
|
||||
}
|
||||
.outline-title input {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 条目 */
|
||||
.outline-items {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.outline-item {
|
||||
border: 1px solid var(--ui-border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 8px 10px;
|
||||
background: var(--ui-panel);
|
||||
}
|
||||
.item-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.kind-badge {
|
||||
display: inline-block;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
padding: 1px 6px;
|
||||
border-radius: 99px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.item-title {
|
||||
flex: 1;
|
||||
font-weight: 500;
|
||||
}
|
||||
.status {
|
||||
font-size: 11px;
|
||||
color: var(--ui-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.status.done {
|
||||
color: var(--ui-success);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 要点 */
|
||||
.item-points {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.point-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.point-row input {
|
||||
flex: 1;
|
||||
font-size: 12px;
|
||||
}
|
||||
.add-point {
|
||||
align-self: flex-start;
|
||||
font-size: 11px;
|
||||
color: var(--ui-primary);
|
||||
}
|
||||
|
||||
/* hint */
|
||||
.item-hint input {
|
||||
font-size: 12px;
|
||||
color: var(--ui-muted);
|
||||
}
|
||||
|
||||
/* 底部 */
|
||||
.outline-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid var(--ui-border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.progress-text, .done-count {
|
||||
font-size: 12px;
|
||||
color: var(--ui-muted);
|
||||
margin-right: auto;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user