新增: 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:
lxy
2026-07-12 13:12:09 +08:00
commit cfeabf2d37
35 changed files with 7919 additions and 0 deletions
+455
View File
@@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
}
/** 尝试把 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>
+352
View File
@@ -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>