新增: 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>
+24
View File
@@ -0,0 +1,24 @@
<!-- =====================================================================
AddGrid.vue 添加元素网格属性面板空态时显示
===================================================================== -->
<script setup lang="ts">
import type { ElementType } from '../../core/types'
import { elementTypes } from '../../core/sample'
const TYPES: ElementType[] = ['title', 'text', 'list', 'stat', 'quote', 'image', 'shape', 'chart', 'card', 'table', 'code', 'formula']
const ICONS: Record<string, string> = {
title: 'T', text: '¶', list: '☰', stat: '#', quote: '“”',
image: '🖼', shape: '▭', chart: '📊', card: '◰',
table: '▦', code: '</>', formula: '∑'
}
const emit = defineEmits<{ (e: 'add', type: ElementType): void }>()
</script>
<template>
<div class="add-grid">
<button v-for="t in TYPES" :key="t" :data-add="t" @click="emit('add', t)">
<span class="ic">{{ ICONS[t] || '·' }}</span>{{ elementTypes[t].label }}
</button>
</div>
</template>
+105
View File
@@ -0,0 +1,105 @@
<!-- =====================================================================
Canvas.vue 中间画布渲染当前页元素 + 拖拽缩放交互
===================================================================== -->
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { store, resolveBg } from '../../core/store'
import { CANVAS_W } from '../../core/sample'
import { useEditor } from '../../composables/useEditor'
import ElementView from './ElementView.vue'
const canvasRef = ref<HTMLElement>()
const canvasFrameRef = ref<HTMLElement>()
const {
drag, editing,
onCanvasMouseDown, onMouseMove, onMouseUp, onContentEditableFocus
} = useEditor()
const slide = computed(() => store.currentSlide.value)
const selectedId = computed(() => store.selectedId.value)
const HANDLES = ['tl', 'tm', 'tr', 'lm', 'rm', 'bl', 'bm', 'br']
function fitCanvas() {
if (!canvasFrameRef.value || !canvasRef.value) return
const rect = canvasFrameRef.value.getBoundingClientRect()
if (!rect.width) return
const scale = rect.width / CANVAS_W
canvasRef.value.style.transform = 'scale(' + scale + ')'
}
function onElementBlur(id: string, field: string, value: string) {
editing.value = false
if (field === 'label') {
store.updateElement(id, { style: { label: value } })
} else if (store.findElement(id)?.type === 'list') {
store.updateElement(id, { content: value })
} else {
store.updateElement(id, { content: value })
}
}
/** 拖拽中元素的实时位置(覆盖 state) */
function liveBox(elId: string) {
const d = drag.value
if (d && d.id === elId && d.result) {
return {
left: d.result.x + '%',
top: d.result.y + '%',
width: d.result.w + '%',
height: d.result.h + '%'
}
}
return null
}
const onResize = () => fitCanvas()
onMounted(() => {
window.addEventListener('resize', onResize)
document.addEventListener('mousemove', onMouseMove)
document.addEventListener('mouseup', onMouseUp)
requestAnimationFrame(fitCanvas)
})
onUnmounted(() => {
window.removeEventListener('resize', onResize)
document.removeEventListener('mousemove', onMouseMove)
document.removeEventListener('mouseup', onMouseUp)
})
</script>
<template>
<main class="canvas-stage" ref="canvasFrameRef">
<div class="canvas-frame">
<div
class="canvas"
ref="canvasRef"
:style="{ background: resolveBg(slide.background) }"
@mousedown="onCanvasMouseDown($event, canvasRef!)"
@focusin="onContentEditableFocus"
>
<!-- 空页占位 -->
<div v-if="slide.elements.length === 0" class="canvas-placeholder">
<div class="ph-icon"></div>
<div>这一页是空的</div>
<div class="ph-sub">在右侧属性面板添加元素或让 AI 生成整套</div>
</div>
<ElementView
v-for="el in slide.elements"
:key="el.id"
:el="el"
:bg="slide.background"
edit
:show-handles="el.id === selectedId"
:class="{ selected: el.id === selectedId, dragging: drag?.id === el.id && !!drag?.result }"
:style="liveBox(el.id) || {}"
@blur="onElementBlur"
/>
</div>
</div>
<div class="canvas-status">
<span>{{ store.currentIndex.value + 1 }} / {{ store.count.value }}</span>
</div>
</main>
</template>
+499
View File
@@ -0,0 +1,499 @@
<!-- =====================================================================
ChartView.vue 图表渲染组件8 种类型 SVG 自绘
ElementView.vue 引用编辑器/演示/缩略图三处复用
支持类型bar / line / area / pie / doughnut / radar / hbar / progress
数据格式
单系列[{ label, value }, ...]
多系列{ series: string[], items: [{ label, values: number[] }, ...] }
===================================================================== -->
<script setup lang="ts">
import { computed } from 'vue'
import type { ChartItem, ChartType, ElementStyle } from '../../core/types'
import { resolveColor } from '../../core/store'
const props = defineProps<{
content: string
style: ElementStyle
/** 是否深色背景(决定文字反相) */
dark: boolean
}>()
/* ---------- 颜色调色板(最多 6 个系列,交替主题色与补色) ---------- */
const PALETTE = computed(() => [
resolveColor(props.style.color, props.dark) || '#4f46e5',
resolveColor('accent', props.dark) || '#06b6d4',
'#f59e0b', '#10b981', '#ef4444', '#8b5cf6'
])
const chartType = computed<ChartType>(() => props.style.chartType || 'bar')
const showLegend = computed(() => props.style.legend !== false)
const showGrid = computed(() => props.style.grid !== false)
/* ---------- 数据解析(兼容单系列和多系列) ---------- */
interface NormalizedItem { label: string; values: number[] }
interface NormalizedData {
series: string[]
items: NormalizedItem[]
/** 单系列标志(渲染逻辑分支用) */
single: boolean
}
const data = computed<NormalizedData>(() => {
let raw: any = props.content
try {
raw = typeof raw === 'string' ? JSON.parse(raw || '[]') : (raw || [])
} catch (e) {
raw = []
}
// 单系列:[{label, value}]
if (Array.isArray(raw)) {
const items: NormalizedItem[] = raw.map((d: any) => ({
label: String((d && d.label) || ''),
values: [Number(d && d.value) || 0]
}))
return { series: [''], items, single: true }
}
// 多系列:{ series, items }
if (raw && typeof raw === 'object' && Array.isArray(raw.items)) {
const series: string[] = Array.isArray(raw.series) ? raw.series.map(String) : []
const items: NormalizedItem[] = raw.items.map((d: any) => ({
label: String((d && d.label) || ''),
values: Array.isArray(d && d.values)
? d.values.map((v: any) => Number(v) || 0)
: [Number(d && d.value) || 0]
}))
return { series, items, single: items[0]?.values.length === 1 }
}
return { series: [], items: [], single: true }
})
/** 所有值的最大值(用于 bar/line/area/hbar 的 y 轴缩放) */
const maxValue = computed(() => {
const m = props.style.max
if (m && m > 0) return m
let max = 0
for (const it of data.value.items) {
for (const v of it.values) if (v > max) max = v
}
return Math.max(max, 1)
})
/* ---------- 通用工具 ---------- */
function esc(s: any): string {
return String(s == null ? '' : s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
}
/** 图例数据(pie/doughnut 用) */
const pieLegend = computed(() => {
const items = data.value.items
const total = items.reduce((s, it) => s + (it.values[0] || 0), 0)
return items.map((it, i) => ({
label: it.label,
pct: total > 0 ? Math.round((it.values[0] / total) * 100) : 0,
color: PALETTE.value[i % PALETTE.value.length]
}))
})
/* ============================================================
* 1. 柱状图 bar
* ============================================================ */
function barSvg(): string {
const { items, series, single } = data.value
const n = items.length
if (!n) return ''
const max = maxValue.value
const seriesCount = single ? 1 : (series.length || items[0]?.values.length || 1)
const groupW = 90 / n
const barW = (groupW * 0.7) / seriesCount
const legendH = 0 // 图例在 SVG 外
let out = ''
// 网格线
if (showGrid.value) {
for (let g = 1; g <= 4; g++) {
const y = 10 + (80 - legendH) * g / 5 + legendH
out += `<line x1="6" y1="${y.toFixed(2)}" x2="96" y2="${y.toFixed(2)}" stroke="currentColor" stroke-opacity="0.1" stroke-width="0.3"/>`
}
}
for (let i = 0; i < n; i++) {
const groupX = 8 + i * groupW
for (let s = 0; s < seriesCount; s++) {
const v = items[i].values[s] || 0
const h = max > 0 ? (v / max) * 78 : 0
const x = groupX + s * barW
const y = 88 - h
const color = PALETTE.value[s % PALETTE.value.length]
out += `<rect x="${x.toFixed(2)}" y="${y.toFixed(2)}" width="${(barW * 0.9).toFixed(2)}" height="${h.toFixed(2)}" fill="${color}" rx="0.6"/>`
}
// x 轴 label
out += `<text class="chart-text" x="${(groupX + groupW * 0.35).toFixed(2)}" y="97" font-size="5" text-anchor="middle">${esc(items[i].label)}</text>`
}
// 单系列时显示数值
if (single) {
for (let i = 0; i < n; i++) {
const v = items[i].values[0] || 0
const h = max > 0 ? (v / max) * 78 : 0
const y = 88 - h
const groupX = 8 + i * groupW
out += `<text class="chart-text" x="${(groupX + groupW * 0.35).toFixed(2)}" y="${(y - 1.5).toFixed(2)}" font-size="5" text-anchor="middle">${esc(v)}</text>`
}
}
return out
}
/* ============================================================
* 2. 条形图 hbar(水平柱状)
* ============================================================ */
function hbarSvg(): string {
const { items, single } = data.value
const n = items.length
if (!n) return ''
const max = maxValue.value
const rowH = 76 / n
const barH = rowH * 0.55
let out = ''
if (showGrid.value) {
for (let g = 1; g <= 4; g++) {
const x = 20 + 76 * g / 5
out += `<line x1="${x.toFixed(2)}" y1="6" x2="${x.toFixed(2)}" y2="94" stroke="currentColor" stroke-opacity="0.1" stroke-width="0.3"/>`
}
}
for (let i = 0; i < n; i++) {
const v = items[i].values[0] || 0
const w = max > 0 ? (v / max) * 70 : 0
const y = 8 + i * rowH + (rowH - barH) / 2
const color = PALETTE.value[i % PALETTE.value.length]
out += `<rect x="20" y="${y.toFixed(2)}" width="${w.toFixed(2)}" height="${barH.toFixed(2)}" fill="${color}" rx="0.6"/>`
out += `<text class="chart-text" x="18" y="${(y + barH * 0.7).toFixed(2)}" font-size="5" text-anchor="end">${esc(items[i].label)}</text>`
if (single) {
out += `<text class="chart-text" x="${(22 + w).toFixed(2)}" y="${(y + barH * 0.7).toFixed(2)}" font-size="5" text-anchor="start">${esc(v)}</text>`
}
}
return out
}
/* ============================================================
* 3. 折线图 line
* ============================================================ */
function lineSvg(): string {
const { items, series, single } = data.value
const n = items.length
if (!n) return ''
const max = maxValue.value
const seriesCount = single ? 1 : (series.length || items[0]?.values.length || 1)
let out = ''
if (showGrid.value) {
for (let g = 1; g <= 4; g++) {
const y = 10 + 78 * g / 5
out += `<line x1="6" y1="${y.toFixed(2)}" x2="96" y2="${y.toFixed(2)}" stroke="currentColor" stroke-opacity="0.1" stroke-width="0.3"/>`
}
}
for (let s = 0; s < seriesCount; s++) {
const color = PALETTE.value[s % PALETTE.value.length]
const pts: Array<{ x: number; y: number; val: number }> = []
for (let i = 0; i < n; i++) {
const v = items[i].values[s] || 0
const x = n === 1 ? 50 : 8 + i * (84 / (n - 1))
const y = 88 - (max > 0 ? (v / max) * 76 : 0)
pts.push({ x, y, val: v })
}
const polyPts = pts.map(p => `${p.x.toFixed(2)},${p.y.toFixed(2)}`).join(' ')
out += `<polyline points="${polyPts}" fill="none" stroke="${color}" stroke-width="1.2" stroke-linejoin="round" stroke-linecap="round"/>`
for (const p of pts) {
out += `<circle cx="${p.x.toFixed(2)}" cy="${p.y.toFixed(2)}" r="1.2" fill="${color}"/>`
}
}
// x 轴 label
for (let i = 0; i < n; i++) {
const x = n === 1 ? 50 : 8 + i * (84 / (n - 1))
out += `<text class="chart-text" x="${x.toFixed(2)}" y="97" font-size="5" text-anchor="middle">${esc(items[i].label)}</text>`
}
// 单系列显示数值
if (single) {
for (let i = 0; i < n; i++) {
const v = items[i].values[0] || 0
const x = n === 1 ? 50 : 8 + i * (84 / (n - 1))
const y = 88 - (max > 0 ? (v / max) * 76 : 0)
out += `<text class="chart-text" x="${x.toFixed(2)}" y="${(y - 2).toFixed(2)}" font-size="5" text-anchor="middle">${esc(v)}</text>`
}
}
return out
}
/* ============================================================
* 4. 面积图 area(折线 + 半透明填充)
* ============================================================ */
function areaSvg(): string {
const { items, series, single } = data.value
const n = items.length
if (!n) return ''
const max = maxValue.value
const seriesCount = single ? 1 : (series.length || items[0]?.values.length || 1)
let out = ''
if (showGrid.value) {
for (let g = 1; g <= 4; g++) {
const y = 10 + 78 * g / 5
out += `<line x1="6" y1="${y.toFixed(2)}" x2="96" y2="${y.toFixed(2)}" stroke="currentColor" stroke-opacity="0.1" stroke-width="0.3"/>`
}
}
for (let s = 0; s < seriesCount; s++) {
const color = PALETTE.value[s % PALETTE.value.length]
const pts: Array<{ x: number; y: number; val: number }> = []
for (let i = 0; i < n; i++) {
const v = items[i].values[s] || 0
const x = n === 1 ? 50 : 8 + i * (84 / (n - 1))
const y = 88 - (max > 0 ? (v / max) * 76 : 0)
pts.push({ x, y, val: v })
}
// 填充区域
const areaPts = `${pts[0].x.toFixed(2)},88 ` + pts.map(p => `${p.x.toFixed(2)},${p.y.toFixed(2)}`).join(' ') + ` ${pts[n - 1].x.toFixed(2)},88`
out += `<polygon points="${areaPts}" fill="${color}" fill-opacity="0.18"/>`
// 折线
const polyPts = pts.map(p => `${p.x.toFixed(2)},${p.y.toFixed(2)}`).join(' ')
out += `<polyline points="${polyPts}" fill="none" stroke="${color}" stroke-width="1.2" stroke-linejoin="round"/>`
}
for (let i = 0; i < n; i++) {
const x = n === 1 ? 50 : 8 + i * (84 / (n - 1))
out += `<text class="chart-text" x="${x.toFixed(2)}" y="97" font-size="5" text-anchor="middle">${esc(items[i].label)}</text>`
}
return out
}
/* ============================================================
* 5. 饼图 pie(沿用原实现,支持多色调色板)
* ============================================================ */
function pieSvg(): string {
return pieLikeSvg(0) // innerRadius=0
}
/* ============================================================
* 6. 环形图 doughnut
* ============================================================ */
function doughnutSvg(): string {
return pieLikeSvg(22) // innerRadius=22
}
/** pie / doughnut 共用:innerRadius=0 是饼图,>0 是环形 */
function pieLikeSvg(innerR: number): string {
const items = data.value.items
const n = items.length
if (!n) return ''
const vals = items.map(it => it.values[0] || 0)
const total = vals.reduce((a, b) => a + b, 0)
if (total <= 0) return ''
const r = 40
let out = ''
let a0 = -Math.PI / 2
for (let i = 0; i < n; i++) {
const ang = (vals[i] / total) * Math.PI * 2
const a1 = a0 + ang
const color = PALETTE.value[i % PALETTE.value.length]
const largeArc = ang > Math.PI ? 1 : 0
const x0 = (r * Math.cos(a0)).toFixed(2)
const y0 = (r * Math.sin(a0)).toFixed(2)
const x1 = (r * Math.cos(a1)).toFixed(2)
const y1 = (r * Math.sin(a1)).toFixed(2)
if (innerR > 0) {
// 环形:外弧 + 内弧
const ix0 = (innerR * Math.cos(a0)).toFixed(2)
const iy0 = (innerR * Math.sin(a0)).toFixed(2)
const ix1 = (innerR * Math.cos(a1)).toFixed(2)
const iy1 = (innerR * Math.sin(a1)).toFixed(2)
if (Math.abs(ang - Math.PI * 2) < 1e-6) {
out += `<circle cx="0" cy="0" r="${r}" fill="${color}"/>`
out += `<circle cx="0" cy="0" r="${innerR}" fill="#fff"/>`
} else {
out += `<path d="M ${x0} ${y0} A ${r} ${r} 0 ${largeArc} 1 ${x1} ${y1} L ${ix1} ${iy1} A ${innerR} ${innerR} 0 ${largeArc} 0 ${ix0} ${iy0} Z" fill="${color}"/>`
}
} else {
// 实心饼
if (Math.abs(ang - Math.PI * 2) < 1e-6) {
out += `<circle cx="0" cy="0" r="${r}" fill="${color}"/>`
} else {
out += `<path d="M 0 0 L ${x0} ${y0} A ${r} ${r} 0 ${largeArc} 1 ${x1} ${y1} Z" fill="${color}"/>`
}
}
a0 = a1
}
return out
}
/* ============================================================
* 7. 雷达图 radar
* ============================================================ */
function radarSvg(): string {
const { items, series, single } = data.value
const n = items.length
if (n < 3) return '<text class="chart-text" x="50" y="50" font-size="6" text-anchor="middle">雷达图至少 3 个维度</text>'
const max = maxValue.value
const seriesCount = single ? 1 : (series.length || items[0]?.values.length || 1)
const cx = 50, cy = 50, r = 36
let out = ''
// 同心多边形网格
if (showGrid.value) {
for (let layer = 1; layer <= 4; layer++) {
const lr = r * layer / 4
const pts: string[] = []
for (let i = 0; i < n; i++) {
const a = -Math.PI / 2 + i * (Math.PI * 2 / n)
pts.push(`${(cx + lr * Math.cos(a)).toFixed(2)},${(cy + lr * Math.sin(a)).toFixed(2)}`)
}
out += `<polygon points="${pts.join(' ')}" fill="none" stroke="currentColor" stroke-opacity="0.12" stroke-width="0.3"/>`
}
// 轴线
for (let i = 0; i < n; i++) {
const a = -Math.PI / 2 + i * (Math.PI * 2 / n)
out += `<line x1="${cx}" y1="${cy}" x2="${(cx + r * Math.cos(a)).toFixed(2)}" y2="${(cy + r * Math.sin(a)).toFixed(2)}" stroke="currentColor" stroke-opacity="0.12" stroke-width="0.3"/>`
}
}
// 数据多边形
for (let s = 0; s < seriesCount; s++) {
const color = PALETTE.value[s % PALETTE.value.length]
const pts: string[] = []
for (let i = 0; i < n; i++) {
const v = items[i].values[s] || 0
const ratio = max > 0 ? v / max : 0
const a = -Math.PI / 2 + i * (Math.PI * 2 / n)
pts.push(`${(cx + r * ratio * Math.cos(a)).toFixed(2)},${(cy + r * ratio * Math.sin(a)).toFixed(2)}`)
}
out += `<polygon points="${pts.join(' ')}" fill="${color}" fill-opacity="0.2" stroke="${color}" stroke-width="1"/>`
// 顶点圆点
for (const p of pts) {
const [px, py] = p.split(',').map(Number)
out += `<circle cx="${px}" cy="${py}" r="1" fill="${color}"/>`
}
}
// 维度 label
for (let i = 0; i < n; i++) {
const a = -Math.PI / 2 + i * (Math.PI * 2 / n)
const lx = cx + (r + 7) * Math.cos(a)
const ly = cy + (r + 7) * Math.sin(a)
out += `<text class="chart-text" x="${lx.toFixed(2)}" y="${ly.toFixed(2)}" font-size="5" text-anchor="middle" dominant-baseline="middle">${esc(items[i].label)}</text>`
}
return out
}
/* ============================================================
* 8. 进度图 progress(环形进度条)
* ============================================================ */
const progressData = computed(() => {
const items = data.value.items
if (!items.length) return { value: 0, label: '', max: 100 }
const v = items[0].values[0] || 0
const max = props.style.max && props.style.max > 0 ? props.style.max : 100
return { value: v, label: items[0].label || '', max }
})
function progressSvg(): string {
const { value, max } = progressData.value
const pct = Math.min(100, max > 0 ? (value / max) * 100 : 0)
const r = 36
const cx = 50, cy = 50
const circumference = 2 * Math.PI * r
const dashLen = (pct / 100) * circumference
const color = PALETTE.value[0]
const trackColor = props.dark ? 'rgba(255,255,255,0.12)' : 'rgba(100,116,139,0.18)'
// 用 stroke-dasharray 画进度弧
let out = ''
out += `<circle cx="${cx}" cy="${cy}" r="${r}" fill="none" stroke="${trackColor}" stroke-width="6"/>`
out += `<circle cx="${cx}" cy="${cy}" r="${r}" fill="none" stroke="${color}" stroke-width="6" stroke-linecap="round"
stroke-dasharray="${dashLen.toFixed(2)} ${(circumference - dashLen).toFixed(2)}"
transform="rotate(-90 ${cx} ${cy})"/>`
// 中心百分比文本
out += `<text class="chart-text" x="${cx}" y="${cy - 1}" font-size="14" font-weight="700" text-anchor="middle">${Math.round(pct)}%</text>`
out += `<text class="chart-text" x="${cx}" y="${cy + 7}" font-size="4" text-anchor="middle">${esc(progressData.value.label)}</text>`
return out
}
/* ---------- 主 SVG 内容分发 ---------- */
const svgContent = computed(() => {
switch (chartType.value) {
case 'bar': return barSvg()
case 'hbar': return hbarSvg()
case 'line': return lineSvg()
case 'area': return areaSvg()
case 'pie': return pieSvg()
case 'doughnut': return doughnutSvg()
case 'radar': return radarSvg()
case 'progress': return progressSvg()
default: return barSvg()
}
})
/** pie/doughnut 用圆心居中的 viewBoxprogress 用正常 viewBox */
const viewBox = computed(() => {
if (chartType.value === 'pie' || chartType.value === 'doughnut') return '-50 -50 100 100'
return '0 0 100 100'
})
/** pie/doughnut 需要外部图例;其他类型用 SVG 内 label,多系列时显示系列图例 */
const showExternalLegend = computed(() => {
if (!showLegend.value) return false
if (chartType.value === 'pie' || chartType.value === 'doughnut') return true
// 多系列非饼图:显示系列图例
return !data.value.single && (data.value.series.length > 1 || (data.value.items[0]?.values.length || 0) > 1)
})
/** 系列图例数据(非饼图用) */
const seriesLegend = computed(() => {
const { series, items } = data.value
const count = series.length || items[0]?.values.length || 1
const arr: Array<{ label: string; color: string }> = []
for (let i = 0; i < count; i++) {
arr.push({
label: series[i] || ('系列 ' + (i + 1)),
color: PALETTE.value[i % PALETTE.value.length]
})
}
return arr
})
</script>
<template>
<div class="el-chart" :class="'chart-' + chartType">
<svg :viewBox="viewBox" :preserveAspectRatio="chartType === 'pie' || chartType === 'doughnut' ? 'xMidYMid meet' : 'none'"
v-html="svgContent" :style="{ width: '100%', height: '100%', display: 'block', flex: chartType === 'pie' || chartType === 'doughnut' ? '1' : undefined, minWidth: chartType === 'pie' || chartType === 'doughnut' ? '0' : undefined }">
</svg>
<!-- 饼图/环形图图例 -->
<div v-if="showExternalLegend && (chartType === 'pie' || chartType === 'doughnut')" class="pie-legend">
<div v-for="(item, i) in pieLegend" :key="i" class="pie-legend-item">
<span class="pie-legend-dot" :style="{ background: item.color }"></span>
<span>{{ item.label }} · {{ item.pct }}%</span>
</div>
</div>
<!-- 多系列图例非饼图 -->
<div v-if="showExternalLegend && chartType !== 'pie' && chartType !== 'doughnut'" class="series-legend">
<span v-for="(s, i) in seriesLegend" :key="i" class="series-legend-item">
<span class="pie-legend-dot" :style="{ background: s.color }"></span>{{ s.label }}
</span>
</div>
</div>
</template>
<style scoped>
.series-legend {
position: absolute; bottom: 2px; left: 50%; transform: translateX(-50%);
display: flex; gap: .8em; font-size: 11px; flex-wrap: wrap; justify-content: center;
}
.series-legend-item { display: inline-flex; align-items: center; gap: .3em; }
</style>
+317
View File
@@ -0,0 +1,317 @@
<!-- =====================================================================
ElementView.vue 单元素渲染替代 editor.js renderElement
editor / present / thumb 三处复用此组件
===================================================================== -->
<script setup lang="ts">
import { computed } from 'vue'
import type { SlideElement, BgKey } from '../../core/types'
import { store, resolveColor } from '../../core/store'
import { segmentsToHtml, markdownToSegments, hasFormatting } from '../../core/richtext'
import ChartView from './ChartView.vue'
/* ---------- LaTeX 子集渲染(公式元素) ---------- */
const LATEX_SYMBOLS: Record<string, string> = {
alpha: 'α', beta: 'β', gamma: 'γ', delta: 'δ', epsilon: 'ε', theta: 'θ', lambda: 'λ', mu: 'μ', pi: 'π', sigma: 'σ', omega: 'ω', phi: 'φ',
sum: '∑', prod: '∏', int: '∫', infty: '∞',
leq: '≤', geq: '≥', neq: '≠', times: '×', pm: '±', cdot: '·', div: '÷',
rightarrow: '→', leftarrow: '←', Rightarrow: '⇒', in: '∈', notin: '∉', subset: '⊂', supset: '⊃', cup: '', cap: '∩',
forall: '∀', exists: '∃', partial: '∂', nabla: '∇'
}
function escapeHtml(s: string): string {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
}
function renderLatex(src: string): string {
let s = escapeHtml(src)
// 1. \frac{a}{b}
s = s.replace(/\\frac\{([^{}]*)\}\{([^{}]*)\}/g, (_m, a: string, b: string) =>
`<span class="frac"><span class="num">${a}</span><span class="den">${b}</span></span>`)
// 2. \sqrt[n]{x} 和 \sqrt{x}
s = s.replace(/\\sqrt\[([^\[\]]+)\]\{([^{}]*)\}/g, (_m, n: string, x: string) =>
`<span class="sqrt"><span class="rad">${n}</span><span class="overline">${x}</span></span>`)
s = s.replace(/\\sqrt\{([^{}]*)\}/g, (_m, x: string) =>
`<span class="sqrt"><span class="overline">${x}</span></span>`)
// 3. \symbol → Unicode
s = s.replace(/\\([a-zA-Z]+)/g, (_m, name: string) => {
if (Object.prototype.hasOwnProperty.call(LATEX_SYMBOLS, name)) return LATEX_SYMBOLS[name]
return name
})
// 4. ^{...} 和 ^x
s = s.replace(/\^\{([^{}]*)\}/g, (_m, x: string) => `<sup>${x}</sup>`)
s = s.replace(/\^([0-9a-zA-Z])/g, (_m, x: string) => `<sup>${x}</sup>`)
// 5. _{...} 和 _x
s = s.replace(/_\{([^{}]*)\}/g, (_m, x: string) => `<sub>${x}</sub>`)
s = s.replace(/_([0-9a-zA-Z])/g, (_m, x: string) => `<sub>${x}</sub>`)
return s
}
const props = defineProps<{
el: SlideElement
bg: BgKey | string
/** 是否为编辑态(启用 contenteditable */
edit?: boolean
/** 是否显示八向缩放手柄 */
showHandles?: boolean
}>()
const emit = defineEmits<{
(e: 'blur', id: string, field: string, value: string): void
}>()
/** 当前页背景是否深色 → 文字是否需要反相 */
const dark = computed(() => {
const d = store.state.deck ? (props.el.type === 'card' ? false : isDarkBg(props.bg)) : false
return d
})
function isDarkBg(bg: string): boolean {
if (!bg) return false
if (bg.charAt(0) === '#') return isDarkHex(bg)
if (bg === 'primary' || bg === 'accent') return true
if (bg.indexOf('g-') === 0) return true
return false
}
function hexToRgb(hex: string) {
const c = String(hex).replace('#', '')
const full = c.length === 3 ? c[0] + c[0] + c[1] + c[1] + c[2] + c[2] : c
const r = parseInt(full.substr(0, 2), 16)
const g = parseInt(full.substr(2, 2), 16)
const b = parseInt(full.substr(4, 2), 16)
return (isNaN(r) || isNaN(g) || isNaN(b)) ? null : { r, g, b }
}
function isDarkHex(hex: string): boolean {
const rgb = hexToRgb(hex); if (!rgb) return false
return (0.299 * rgb.r + 0.587 * rgb.g + 0.114 * rgb.b) < 145
}
const s = computed(() => props.el.style || {})
const boxStyle = computed(() => {
const st = s.value
const css: Record<string, string> = {}
if (st.opacity != null) css.opacity = String(st.opacity)
if (st.fontSize) css.fontSize = st.fontSize + 'px'
if (st.align) css.textAlign = st.align
if (st.bold === true) css.fontWeight = '700'
if (st.bold === false) css.fontWeight = '400'
if (st.italic === true) css.fontStyle = 'italic'
const color = resolveColor(st.color, dark.value)
if (color) css.color = color
return css
})
const dataList = computed(() => (props.el.content || '').split('\n'))
/** 表格解析 */
const tableRows = computed(() => {
const lines = (props.el.content || '').split('\n').map(l => l.trim()).filter(Boolean)
const rows: string[][] = []
for (const line of lines) {
if (/^\|?[\s:-]+\|[\s:-|]+$/.test(line)) continue
const cells = line.replace(/^\||\|$/g, '').split('|').map(c => c.trim())
rows.push(cells)
}
return rows
})
/** 公式渲染 */
const renderedFormula = computed(() => renderLatex(props.el.content || ''))
/** 形状背景 */
const shapeBg = computed(() => {
const st = s.value
const fill = resolveColor(st.fill, false)
if (st.gradient) {
return 'linear-gradient(135deg, ' + fill + ' 0%, ' + resolveColor('accent', false) + ' 100%)'
}
return fill
})
const isCircle = computed(() => s.value.shapeType === 'circle')
const isTriangle = computed(() => s.value.shapeType === 'triangle')
const cardAccentColor = computed(() => resolveColor(s.value.accent, false))
/** 卡片:content 第一行=标题,其余=正文 */
const cardParts = computed(() => {
const lines = (props.el.content || '').split('\n')
return { title: lines[0] || '', body: lines.slice(1).join('\n') }
})
/* ---------- Rich textsegments 结构化富文本)---------- */
/** 是否有 segments(结构化富文本),优先于 content */
const hasSegments = computed(() => {
return !!(props.el.segments && props.el.segments.length && hasFormatting(props.el.segments))
})
/** title/text/quote 的渲染 HTML */
const renderedContent = computed(() => {
if (hasSegments.value) return segmentsToHtml(props.el.segments!)
// 降级:检查 content 是否含 Markdown 语法(兼容旧数据)
return null
})
/** list 每行的渲染 HTMLsegments 或纯文本) */
const renderedListItems = computed(() => {
if (hasSegments.value) {
return props.el.segments!.map(line => segmentsToHtml([line]))
}
return null
})
/** contenteditable 失焦回调 */
function onBlur(e: Event, field: string) {
const node = e.target as HTMLElement
let val: string
if (field === 'label') {
val = node.textContent || ''
} else if (props.el.type === 'list') {
val = node.innerText.replace(/\r/g, '').trim()
} else {
val = node.textContent || ''
}
emit('blur', props.el.id, field, val)
}
</script>
<template>
<div
class="el"
:data-id="el.id"
:data-type="el.type"
:data-anim="s.anim"
:style="{
left: el.x + '%',
top: el.y + '%',
width: el.w + '%',
height: el.h + '%',
...boxStyle
}"
>
<!-- 标题 / 正文 / 金句 -->
<template v-if="el.type === 'title' || el.type === 'text' || el.type === 'quote'">
<!-- 编辑态纯文本 contenteditable -->
<div
v-if="edit"
class="el-text"
style="white-space: pre-wrap; width: 100%"
contenteditable="true"
data-edit="content"
@blur="onBlur($event, 'content')"
>{{ el.content }}</div>
<!-- 非编辑态 + segments渲染结构化富文本 -->
<div
v-else-if="renderedContent"
class="el-text el-text-rich"
v-html="renderedContent"
></div>
<!-- 非编辑态 + 纯文本 -->
<div v-else class="el-text" style="white-space: pre-wrap; width: 100%">{{ el.content }}</div>
</template>
<!-- 列表 -->
<template v-else-if="el.type === 'list'">
<!-- 编辑态 -->
<div v-if="edit" class="el-list" contenteditable="true" data-edit="content" @blur="onBlur($event, 'content')">
<div v-for="(line, i) in dataList" :key="i" class="li">{{ line }}</div>
</div>
<!-- 非编辑态 + segments -->
<div v-else-if="renderedListItems" class="el-list">
<div v-for="(html, i) in renderedListItems" :key="i" class="li" v-html="html"></div>
</div>
<!-- 非编辑态 + 纯文本 -->
<div v-else class="el-list">
<div v-for="(line, i) in dataList" :key="i" class="li">{{ line }}</div>
</div>
</template>
<!-- 数据 -->
<template v-else-if="el.type === 'stat'">
<div class="el-stat">
<div
class="num"
:contenteditable="edit"
data-edit="content"
@blur="edit && onBlur($event, 'content')"
>{{ el.content }}</div>
<div
class="label"
:style="{ fontSize: (s.labelSize || 16) + 'px', color: resolveColor(s.labelColor, dark) }"
:contenteditable="edit"
data-edit="label"
@blur="edit && onBlur($event, 'label')"
>{{ s.label }}</div>
</div>
</template>
<!-- 图片 -->
<template v-else-if="el.type === 'image'">
<img class="el-image" :src="el.content" draggable="false" />
</template>
<!-- 形状 -->
<template v-else-if="el.type === 'shape'">
<div v-if="isCircle" class="el-shape" :class="{ gradient: s.gradient }" :style="{ borderRadius: '50%', background: shapeBg }"></div>
<div v-else-if="isTriangle" class="el-shape">
<svg viewBox="0 0 100 100" preserveAspectRatio="none" style="width:100%;height:100%;display:block">
<polygon points="50,5 95,95 5,95" :fill="shapeBg" />
</svg>
</div>
<div v-else class="el-shape" :class="{ gradient: s.gradient }" :style="{ background: shapeBg, borderRadius: (s.radius != null ? s.radius : 12) + 'px' }"></div>
</template>
<!-- 图表 -->
<template v-else-if="el.type === 'chart'">
<ChartView :content="el.content" :style="s" :dark="dark" />
</template>
<!-- 卡片 -->
<template v-else-if="el.type === 'card'">
<div class="card-bar" :style="{ background: cardAccentColor }"></div>
<div class="el-card">
<div v-if="s.icon" class="card-icon">{{ s.icon }}</div>
<div class="card-title">{{ cardParts.title }}</div>
<div class="card-body">{{ cardParts.body }}</div>
</div>
</template>
<!-- 表格 -->
<template v-else-if="el.type === 'table'">
<table class="el-table">
<thead v-if="s.header !== false && tableRows.length">
<tr><th v-for="(c, i) in tableRows[0]" :key="i">{{ c }}</th></tr>
</thead>
<tbody>
<tr v-for="(row, ri) in tableRows.slice(s.header !== false ? 1 : 0)" :key="ri">
<td v-for="(c, ci) in row" :key="ci">{{ c }}</td>
</tr>
</tbody>
</table>
</template>
<!-- 代码 -->
<template v-else-if="el.type === 'code'">
<pre class="el-code">
<code>{{ el.content }}</code>
<span v-if="s.lang" class="code-lang">{{ s.lang }}</span>
</pre>
</template>
<!-- 公式 -->
<template v-else-if="el.type === 'formula'">
<div class="el-formula" v-html="renderedFormula"></div>
</template>
<!-- 八向缩放手柄仅编辑态选中时 -->
<template v-if="showHandles">
<div
v-for="h in ['tl','tm','tr','lm','rm','bl','bm','br']"
:key="h"
class="handle"
:class="h"
:data-handle="h"
></div>
</template>
</div>
</template>
+355
View File
@@ -0,0 +1,355 @@
<!-- =====================================================================
PropsPanel.vue 右侧属性面板元素样式 + 页面背景
===================================================================== -->
<script setup lang="ts">
import { computed, watch, ref } from 'vue'
import { store } from '../../core/store'
import { elementTypes } from '../../core/sample'
import { generateImage, isImageConfigured } from '../../core/ai'
import { markdownToSegments, segmentsToPlain, hasFormatting } from '../../core/richtext'
import AddGrid from './AddGrid.vue'
import type { ElementType, ChartType } from '../../core/types'
const CHART_TYPES: Array<{ k: ChartType; label: string; icon: string }> = [
{ k: 'bar', label: '柱状图', icon: '📊' },
{ k: 'hbar', label: '条形图', icon: '📋' },
{ k: 'line', label: '折线图', icon: '📈' },
{ k: 'area', label: '面积图', icon: '🌄' },
{ k: 'pie', label: '饼图', icon: '🥧' },
{ k: 'doughnut', label: '环形图', icon: '🍩' },
{ k: 'radar', label: '雷达图', icon: '🕸' },
{ k: 'progress', label: '进度图', icon: '⭕' }
]
const CHART_TYPE_MAP: Record<string, { label: string; icon: string }> = Object.fromEntries(CHART_TYPES.map(t => [t.k, t]))
const selected = computed(() => store.getSelected())
const slide = computed(() => store.currentSlide.value)
/** 临时输入态(v-model 绑定) */
const content = ref('')
const fontSize = ref(24)
const colorSel = ref('primary')
const colorPicker = ref('#000000')
const shape = ref<'rect' | 'circle' | 'triangle'>('rect')
const chartType = ref<ChartType>('bar')
const codeLang = ref('')
const imgBusy = ref(false)
let imgAbort: AbortController | null = null
const BG_OPTIONS = [
{ k: 'bg', label: '白底' },
{ k: 'panel', label: '浅底' },
{ k: 'primary', label: '主色' },
{ k: 'accent', label: '强调' }
]
/** 选中变化时同步输入控件 */
watch(selected, (el) => {
if (!el) return
const s = el.style || {}
if (el.type !== 'stat') content.value = el.content || ''
fontSize.value = s.fontSize || 24
shape.value = (s.shapeType as any) || 'rect'
chartType.value = (s.chartType as ChartType) || 'bar'
codeLang.value = s.lang || ''
// 颜色下拉/picker 同步
const presets = ['primary', 'accent', 'text', 'muted', '#ffffff']
if (presets.includes(s.color || '')) {
colorSel.value = s.color!
colorPicker.value = '#000000'
} else if (s.color && s.color.charAt(0) === '#') {
colorSel.value = 'custom'
colorPicker.value = /^#[0-9a-f]{6}$/i.test(s.color) ? s.color : '#000000'
} else {
colorSel.value = 'primary'
}
}, { immediate: true })
const hasText = computed(() => selected.value && ['title', 'text', 'quote', 'list', 'stat', 'table', 'code', 'formula'].includes(selected.value.type))
const canFont = computed(() => selected.value && ['title', 'text', 'quote', 'list', 'stat', 'table', 'code', 'formula'].includes(selected.value.type))
const canAlign = computed(() => selected.value && ['title', 'text', 'quote', 'stat', 'table', 'formula'].includes(selected.value.type))
const canBI = computed(() => selected.value && ['title', 'text', 'quote', 'list'].includes(selected.value.type))
const canColor = computed(() => selected.value && ['title', 'text', 'quote', 'list', 'stat', 'table', 'code', 'formula'].includes(selected.value.type))
const isShape = computed(() => selected.value?.type === 'shape')
const typeLabel = computed(() => {
const el = selected.value
return el ? (elementTypes[el.type]?.label || el.type) : ''
})
function onAdd(type: ElementType) {
store.addElement(type)
}
function onContentInput() {
if (!selected.value) return
store.updateElement(selected.value.id, { content: content.value })
}
function onFontSizeInput() {
if (!selected.value) return
store.updateElement(selected.value.id, { style: { fontSize: Number(fontSize.value) } })
}
function onColorChange() {
if (!selected.value) return
if (colorSel.value === 'custom') return
store.updateElement(selected.value.id, { style: { color: colorSel.value } })
}
function onColorPickerInput() {
if (!selected.value) return
store.updateElement(selected.value.id, { style: { color: colorPicker.value } })
}
function onShapeChange() {
if (!selected.value) return
store.updateElement(selected.value.id, { style: { shapeType: shape.value } })
}
function onChartTypeChange() {
if (!selected.value) return
store.updateElement(selected.value.id, { style: { chartType: chartType.value } })
}
function toggleLegend() {
if (!selected.value) return
const cur = selected.value.style.legend !== false
store.updateElement(selected.value.id, { style: { legend: !cur } })
}
function toggleGrid() {
if (!selected.value) return
const cur = selected.value.style.grid !== false
store.updateElement(selected.value.id, { style: { grid: !cur } })
}
function toggleHeader() {
if (!selected.value) return
const cur = selected.value.style.header !== false
store.updateElement(selected.value.id, { style: { header: !cur } })
}
function onCodeLangInput() {
if (!selected.value) return
store.updateElement(selected.value.id, { style: { lang: codeLang.value } })
}
function onAlign(a: 'left' | 'center' | 'right') {
if (!selected.value) return
store.updateElement(selected.value.id, { style: { align: a } })
}
function toggleBold() {
if (!selected.value) return
store.updateElement(selected.value.id, { style: { bold: !selected.value.style.bold } })
}
function toggleItalic() {
if (!selected.value) return
store.updateElement(selected.value.id, { style: { italic: !selected.value.style.italic } })
}
function onZ(dir: number) {
if (!selected.value) return
store.moveElementZ(selected.value.id, dir)
}
function onDel() {
if (!selected.value) return
store.delElement(selected.value.id)
}
function onBg(k: string) {
store.setSlideBackground(k)
}
/* ---------- 富文本格式 ---------- */
/** 当前选中元素是否有 segments */
const hasRich = computed(() => {
const el = selected.value
return !!(el?.segments && el.segments.length && hasFormatting(el.segments))
})
/** 从 Markdown 语法生成 segments */
function applyMarkdown() {
const el = selected.value
if (!el) return
const segs = markdownToSegments(content.value)
if (segs.length && hasFormatting(segs)) {
store.updateElement(el.id, { segments: segs } as any)
}
}
/** 清除 segments(降级为纯文本) */
function clearRich() {
const el = selected.value
if (!el) return
store.updateElement(el.id, { segments: undefined } as any)
}
/* ---------- AI 配图 ---------- */
async function onAiImage() {
if (imgBusy.value) return
const el = selected.value
if (!el || el.type !== 'image') return
if (!isImageConfigured()) { alert('请先在「AI 设置」中配置 API Key'); return }
const promptText = window.prompt('描述你想要的图片,例如「现代办公室协作场景,俯拍,柔和光线」')
if (!promptText) return
imgBusy.value = true
imgAbort = new AbortController()
try {
const r = await generateImage({ prompt: promptText, signal: imgAbort.signal })
store.updateElement(el.id, { content: r.url })
} catch (e: any) {
if (e?.name !== 'AbortError') alert('配图失败:' + (e?.message || String(e)))
} finally {
imgBusy.value = false; imgAbort = null
}
}
</script>
<template>
<div class="panel-pane props-pane" id="panelProps">
<!-- 空态 -->
<section v-if="!selected" class="prop-section" id="propEmpty">
<p class="prop-hint">点击画布元素以编辑样式<br />或在下方添加新元素</p>
<AddGrid @add="onAdd" />
</section>
<!-- 选中元素 -->
<section v-else class="prop-section" id="propElement">
<h4 class="prop-title">元素 · <span>{{ typeLabel }}</span></h4>
<!-- 内容 -->
<div v-if="hasText && selected.type !== 'stat'" class="prop-row">
<label>内容</label>
<textarea rows="3" placeholder="输入文字(列表用换行分隔)" v-model="content" @input="onContentInput"></textarea>
<!-- 富文本格式提示 -->
<div v-if="['title','text','quote','list'].includes(selected.type)" class="rich-hint">
<div class="rich-syntax">
**加粗** · *斜体* · ==高亮== · ~~删除线~~ · `代码` · ^上标^ · ~下标~
</div>
<div class="rich-actions">
<button v-if="hasRich" class="rich-btn danger" @click="clearRich">清除格式</button>
<button class="rich-btn" @click="applyMarkdown" title="把上面的 Markdown 语法转为富文本">转换格式</button>
</div>
</div>
</div>
<!-- 字号 -->
<div v-if="canFont" class="prop-row">
<label>字号 <span>{{ fontSize }}px</span></label>
<input type="range" min="12" max="120" v-model.number="fontSize" @input="onFontSizeInput" />
</div>
<!-- 颜色 -->
<div v-if="canColor" class="prop-row">
<label>颜色</label>
<div class="color-row">
<select v-model="colorSel" @change="onColorChange">
<option value="primary">主色</option>
<option value="accent">强调色</option>
<option value="text">正文色</option>
<option value="muted">次要色</option>
<option value="#ffffff">白色</option>
<option value="custom">自定义</option>
</select>
<input type="color" v-model="colorPicker" v-show="colorSel === 'custom'" @input="onColorPickerInput" />
</div>
</div>
<!-- 对齐 -->
<div v-if="canAlign" class="prop-row">
<label>对齐</label>
<div class="seg">
<button :class="{ active: selected.style.align === 'left' }" @click="onAlign('left')"></button>
<button :class="{ active: selected.style.align === 'center' || !selected.style.align }" @click="onAlign('center')"></button>
<button :class="{ active: selected.style.align === 'right' }" @click="onAlign('right')"></button>
</div>
</div>
<!-- 加粗/斜体 -->
<div v-if="canBI" class="prop-row">
<label>样式</label>
<div class="seg">
<button :class="{ active: !!selected.style.bold }" @click="toggleBold" title="加粗"><b>B</b></button>
<button :class="{ active: !!selected.style.italic }" @click="toggleItalic" title="斜体"><i>I</i></button>
</div>
</div>
<!-- 形状 -->
<div v-if="isShape" class="prop-row">
<label>形状</label>
<select v-model="shape" @change="onShapeChange">
<option value="rect">矩形</option>
<option value="circle">圆形</option>
<option value="triangle">三角</option>
</select>
</div>
<!-- 图表配置 -->
<template v-if="selected.type === 'chart'">
<div class="prop-row">
<label>图表类型</label>
<select v-model="chartType" @change="onChartTypeChange">
<option v-for="t in CHART_TYPES" :key="t.k" :value="t.k">{{ t.icon }} {{ t.label }}</option>
</select>
</div>
<div class="prop-row">
<label>显示选项</label>
<div class="seg">
<button :class="{ active: selected.style.legend !== false }" @click="toggleLegend" title="图例">图例</button>
<button :class="{ active: selected.style.grid !== false }" @click="toggleGrid" title="网格线">网格</button>
</div>
</div>
<div class="prop-row">
<label>数据格式</label>
<div style="font-size:11px;color:var(--ui-muted,#64748b);line-height:1.6;padding:.3em 0">
单系列[{"label":"A","value":65}]<br />
多系列{"series":["Q1","Q2"],"items":[{"label":"华东","values":[120,150]}]}
</div>
</div>
</template>
<!-- 表格选项 -->
<div v-if="selected.type === 'table'" class="prop-row">
<label>首行表头</label>
<div class="seg">
<button :class="{ active: selected.style.header !== false }" @click="toggleHeader">表头</button>
</div>
</div>
<!-- 代码语言 -->
<div v-if="selected.type === 'code'" class="prop-row">
<label>语言</label>
<input type="text" v-model="codeLang" @input="onCodeLangInput" placeholder="js / python / ..." />
</div>
<!-- 公式提示 -->
<div v-if="selected.type === 'formula'" class="prop-row">
<label>提示</label>
<div style="font-size:12px;color:var(--ui-muted);line-height:1.6">
LaTeX 语法示例<br />
E = mc^2<br />
\frac{a}{b}<br />
\sum_{i=1}^n x_i
</div>
</div>
<!-- AI 配图仅图片元素 -->
<div v-if="selected.type === 'image'" class="prop-row">
<label>AI 配图</label>
<button class="btn" :disabled="imgBusy" @click="onAiImage">{{ imgBusy ? '生成中' : '🎨 AI 配图' }}</button>
</div>
<!-- 层级 -->
<div class="prop-row">
<label>层级</label>
<div class="seg">
<button @click="onZ(1)" title="上移"></button>
<button @click="onZ(-1)" title="下移"></button>
<button class="danger" @click="onDel" title="删除">🗑</button>
</div>
</div>
</section>
<!-- 当前页背景 -->
<section class="prop-section">
<h4 class="prop-title">当前页背景</h4>
<div class="bg-grid">
<button
v-for="b in BG_OPTIONS"
:key="b.k"
:class="{ active: slide.background === b.k }"
@click="onBg(b.k)"
>{{ b.label }}</button>
</div>
</section>
</div>
</template>
+63
View File
@@ -0,0 +1,63 @@
<!-- =====================================================================
ThumbBar.vue 左侧缩略图列表
===================================================================== -->
<script setup lang="ts">
import { computed } from 'vue'
import { store, resolveBg } from '../../core/store'
import ElementView from './ElementView.vue'
const slides = computed(() => store.slides.value)
const currentIndex = computed(() => store.currentIndex.value)
function onClickItem(i: number) {
store.setCurrentIndex(i)
}
function onClickDel(i: number, e: Event) {
e.stopPropagation()
store.delSlide(i)
}
function onClickAdd() {
store.addSlide(store.getCurrentIndex())
}
</script>
<template>
<aside class="thumb-bar">
<div class="thumb-list">
<div
v-for="(slide, i) in slides"
:key="slide.id"
class="thumb-item"
:class="{ active: i === currentIndex }"
:data-index="i"
@click="onClickItem(i)"
>
<div class="thumb-num">{{ i + 1 }}</div>
<button class="thumb-del" title="删除" :data-index="i" @click="onClickDel(i, $event)">×</button>
<div class="thumb-preview" :style="{ background: resolveBg(slide.background) }">
<div class="thumb-layer">
<ElementView
v-for="el in slide.elements"
:key="el.id"
:el="el"
:bg="slide.background"
/>
</div>
</div>
</div>
<button class="thumb-add" @click="onClickAdd"> 新建幻灯片</button>
</div>
</aside>
</template>
<style scoped>
.thumb-layer {
position: absolute;
top: 0;
left: 0;
width: 1280px;
height: 720px;
transform: scale(0.12);
transform-origin: top left;
}
</style>
+79
View File
@@ -0,0 +1,79 @@
<!-- =====================================================================
Toolbar.vue 顶部工具栏
===================================================================== -->
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { store } from '../../core/store'
import { themes } from '../../core/sample'
const emit = defineEmits<{
(e: 'present'): void
(e: 'open-library'): void
(e: 'open-settings'): void
(e: 'open-ai'): void
(e: 'save'): void
(e: 'open-templates'): void
}>()
const themeKeys = Object.keys(themes)
const currentTheme = ref(store.getTheme())
// store 主题变化时同步下拉
watch(() => store.theme.value, (v) => { currentTheme.value = v })
function onThemeChange() {
store.setTheme(currentTheme.value)
}
function action(a: string) {
switch (a) {
case 'add-slide': store.addSlide(store.getCurrentIndex()); break
case 'dup-slide': store.dupSlide(); break
case 'del-slide':
if (store.delSlide()) { /* ok */ }
break
case 'reset':
if (confirm('重置为内置示例?当前编辑内容将丢失(可用 Ctrl+Z 撤销)。')) {
store.reset()
}
break
case 'present': emit('present'); break
case 'library': emit('open-library'); break
case 'save': emit('save'); break
case 'open-ai': emit('open-ai'); break
case 'settings': emit('open-settings'); break
}
}
defineProps<{ disabledActions?: string[] }>()
</script>
<template>
<header class="toolbar">
<div class="brand">
<span class="logo"></span>
<span class="name">u-ppt</span>
<span class="sub">在线演示工具</span>
</div>
<div class="tools">
<button class="btn" data-action="add-slide" title="新建幻灯片" :disabled="disabledActions?.includes('add-slide')" @click="action('add-slide')"> 幻灯片</button>
<button class="btn" data-action="templates" title="从模板新建页" @click="emit('open-templates')">📋 模板</button>
<button class="btn" data-action="dup-slide" title="复制当前页" :disabled="disabledActions?.includes('dup-slide')" @click="action('dup-slide')"> 复制</button>
<button class="btn" data-action="del-slide" title="删除当前页" :disabled="disabledActions?.includes('del-slide')" @click="action('del-slide')">🗑 删除</button>
<span class="sep"></span>
<label class="theme-select">
主题
<select v-model="currentTheme" @change="onThemeChange">
<option v-for="k in themeKeys" :key="k" :value="k">{{ themes[k].name }}</option>
</select>
</label>
<span class="sep"></span>
<button class="btn" data-action="library" title="我的演示文库" @click="action('library')">📁 文库</button>
<button class="btn" data-action="save" title="保存到文库" :disabled="disabledActions?.includes('save')" @click="action('save')">💾 保存</button>
<button class="btn" data-action="open-ai" title="打开 AI 助手" @click="action('open-ai')">🤖 AI</button>
<button class="btn" data-action="reset" title="重置为内置示例" :disabled="disabledActions?.includes('reset')" @click="action('reset')"> 重置</button>
<button class="btn primary" data-action="present" title="开始演示 (F5)" :disabled="disabledActions?.includes('present')" @click="action('present')"> 演示</button>
<button class="btn ghost" data-action="settings" title="AI 设置" @click="action('settings')"></button>
</div>
</header>
</template>
+154
View File
@@ -0,0 +1,154 @@
<!-- =====================================================================
LibraryModal.vue 演示文库弹窗
===================================================================== -->
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import type { LibItem, Slide } from '../../core/types'
import { store, resolveBg } from '../../core/store'
import ElementView from '../editor/ElementView.vue'
const props = defineProps<{ visible: boolean }>()
const emit = defineEmits<{
(e: 'close'): void
(e: 'toast', msg: string): void
(e: 'switch-tab', tab: string): void
}>()
const libVersion = ref(0)
const nameInput = ref('')
/** 读文库列表(非响应式,靠 libVersion 触发重算) */
const library = computed<LibItem[]>(() => {
void libVersion.value
return store.getLibrary()
})
const activeId = computed(() => {
void libVersion.value
return store.getActiveLibId()
})
const libCount = computed(() => library.value.length)
const placeholder = computed(() => {
const cur = library.value.find(x => x.id === activeId.value)
return cur
? '当前:「' + cur.name + '」,留空同名覆盖,输入新名另存为新份'
: '为当前演示命名后存入文库…'
})
function bump() { libVersion.value++ }
watch(() => props.visible, (v) => {
if (v) { bump(); nameInput.value = '' }
})
function toast(msg: string) { emit('toast', msg) }
function firstSlide(item: LibItem): Slide | null {
const s = item?.deck?.slides
return s && s.length ? s[0] : null
}
function formatTime(ts: number): string {
if (!ts) return ''
const diff = Date.now() - ts
if (diff < 60000) return '刚刚'
if (diff < 3600000) return Math.floor(diff / 60000) + ' 分钟前'
if (diff < 86400000) return Math.floor(diff / 3600000) + ' 小时前'
const d = new Date(ts)
return (d.getMonth() + 1) + '月' + d.getDate() + '日'
}
function onNameKeydown(e: KeyboardEvent) {
if (e.key === 'Enter') { e.preventDefault(); onSave() }
}
function onSave() {
const name = nameInput.value.trim()
if (!name && !store.getActiveLibId()) {
toast('请输入名称')
return
}
store.saveToLibrary(name || null)
toast(name ? ('已存入文库:' + name) : '已更新到文库')
nameInput.value = ''
bump()
}
function onNewBlank() {
store.newBlankDeck()
emit('switch-tab', 'props')
toast('已新建空白演示,在右侧添加元素')
emit('close')
}
function onOpen(id: string) {
if (store.loadFromLibrary(id)) {
toast('已打开')
emit('close')
}
}
function onRename(id: string) {
const cur = store.getLibrary().find(x => x.id === id)
const name = prompt('重命名为', cur ? cur.name : '')
if (name != null && name.trim()) {
store.renameInLibrary(id, name.trim())
bump()
}
}
function onDuplicate(id: string) {
store.duplicateInLibrary(id)
toast('已复制')
bump()
}
function onDelete(id: string) {
if (confirm('删除这份演示?此操作不可撤销。')) {
store.deleteFromLibrary(id)
toast('已删除')
bump()
}
}
</script>
<template>
<div class="modal-mask lib-modal-mask" :class="{ hidden: !visible }" @click.self="emit('close')">
<div class="modal lib-modal">
<h3>演示文库</h3>
<p class="modal-tip">保存多套演示文稿到本地随时切换</p>
<div class="lib-save-row">
<input type="text" v-model="nameInput" :placeholder="placeholder" @keydown="onNameKeydown" />
<button class="btn primary" @click="onSave">存入文库</button>
<button class="btn" @click="onNewBlank">新建空白</button>
</div>
<div class="lib-hint"> {{ libCount }} </div>
<div class="lib-list">
<div v-for="it in library" :key="it.id" class="lib-item" :class="{ active: it.id === activeId }" :data-id="it.id">
<div class="lib-thumb" :style="{ background: firstSlide(it) ? resolveBg(firstSlide(it)!.background) : '#fff' }">
<div v-if="firstSlide(it)" style="position:absolute;top:0;left:0;width:1280px;height:720px;transform:scale(0.05625);transform-origin:top left;pointer-events:none">
<ElementView v-for="el in firstSlide(it)!.elements" :key="el.id" :el="el" :bg="firstSlide(it)!.background" />
</div>
</div>
<div class="lib-info">
<div class="lib-name">{{ it.name }}<span v-if="it.id === activeId"> (当前)</span></div>
<div class="lib-meta">{{ (it.deck && it.deck.slides ? it.deck.slides.length : 0) + ' 页 · ' + formatTime(it.updatedAt || it.createdAt) }}</div>
</div>
<div class="lib-ops">
<button class="btn" @click="onOpen(it.id)">打开</button>
<button class="btn" @click="onRename(it.id)">重命名</button>
<button class="btn" @click="onDuplicate(it.id)">复制</button>
<button class="btn danger" @click="onDelete(it.id)">删除</button>
</div>
</div>
</div>
<div class="modal-actions">
<button class="btn" @click="emit('close')">关闭</button>
</div>
</div>
</div>
</template>
+192
View File
@@ -0,0 +1,192 @@
<!-- =====================================================================
SettingsModal.vue AI 设置弹窗
===================================================================== -->
<script setup lang="ts">
import { ref, watch } from 'vue'
import type { AiCfg } from '../../core/types'
import { store } from '../../core/store'
const props = defineProps<{ visible: boolean }>()
const emit = defineEmits<{
(e: 'close'): void
(e: 'toast', msg: string): void
}>()
const PRESETS: Record<string, { protocol: 'openai' | 'anthropic'; base: string; model: string; label: string }> = {
zhipu: { protocol: 'openai', base: 'https://open.bigmodel.cn/api/paas/v4', model: 'glm-4.6', label: '智谱 GLM (OpenAI 协议)' },
zhipu_anth: { protocol: 'anthropic', base: 'https://open.bigmodel.cn/api/anthropic', model: 'glm-4.6', label: '智谱 GLM (Anthropic 协议)' },
deepseek: { protocol: 'openai', base: 'https://api.deepseek.com', model: 'deepseek-chat', label: 'DeepSeek' },
qwen: { protocol: 'openai', base: 'https://dashscope.aliyuncs.com/compatible-mode/v1', model: 'qwen-plus', label: '通义千问' },
kimi: { protocol: 'openai', base: 'https://api.moonshot.cn/v1', model: 'moonshot-v1-8k', label: 'Kimi' },
doubao: { protocol: 'openai', base: 'https://ark.cn-beijing.volces.com/api/v3', model: 'doubao-pro-32k', label: '豆包' },
openai: { protocol: 'openai', base: 'https://api.openai.com/v1', model: 'gpt-4o-mini', label: 'OpenAI' },
anthropic: { protocol: 'anthropic', base: 'https://api.anthropic.com', model: 'claude-sonnet-5', label: 'Anthropic' },
gemini: { protocol: 'openai', base: 'https://generativelanguage.googleapis.com/v1beta/openai', model: 'gemini-2.0-flash', label: 'Gemini' },
groq: { protocol: 'openai', base: 'https://api.groq.com/openai/v1', model: 'llama-3.3-70b-versatile', label: 'Groq' },
ollama: { protocol: 'openai', base: 'http://localhost:11434/v1', model: 'llama3.1', label: 'Ollama (本地)' }
}
const PROTO_DEFAULTS: Record<string, { base: string; model: string }> = {
openai: { base: 'https://open.bigmodel.cn/api/paas/v4', model: 'glm-4.6' },
anthropic: { base: 'https://open.bigmodel.cn/api/anthropic', model: 'glm-4.6' }
}
const form = ref<AiCfg>({
preset: 'zhipu', protocol: 'openai',
base: '', key: '', model: '', proxy: '',
imgBase: '', imgKey: '', imgModel: ''
})
/** 从 store 读取并填充表单 */
function loadFromStore() {
const c = store.getCfg()
form.value = {
preset: PRESETS[c.preset] ? c.preset : 'custom',
protocol: c.protocol || 'openai',
base: c.base, key: c.key, model: c.model, proxy: c.proxy,
imgBase: c.imgBase || '',
imgKey: c.imgKey || '',
imgModel: c.imgModel || ''
}
}
watch(() => props.visible, (v) => {
if (v) loadFromStore()
}, { immediate: true })
function applyPreset(key: string) {
const p = PRESETS[key]
if (!p) return
form.value.protocol = p.protocol
form.value.base = p.base
form.value.model = p.model
}
function onProviderChange() {
if (form.value.preset !== 'custom') applyPreset(form.value.preset)
}
function applyProtoDefaults(p: string) {
const d = PROTO_DEFAULTS[p] || PROTO_DEFAULTS.openai
const other = PROTO_DEFAULTS[p === 'openai' ? 'anthropic' : 'openai']
if (!form.value.base.trim() || form.value.base.trim() === other.base) form.value.base = d.base
if (!form.value.model.trim() || form.value.model.trim() === other.model) form.value.model = d.model
}
function onProtocolChange() {
applyProtoDefaults(form.value.protocol)
}
function save() {
store.setCfg({
preset: form.value.preset,
protocol: form.value.protocol,
base: form.value.base.trim(),
key: form.value.key.trim(),
model: form.value.model.trim(),
proxy: form.value.proxy.trim(),
imgBase: (form.value.imgBase || '').trim(),
imgKey: (form.value.imgKey || '').trim(),
imgModel: (form.value.imgModel || '').trim()
})
const label = form.value.preset === 'custom'
? (form.value.protocol === 'anthropic' ? 'Anthropic' : 'OpenAI') + ' 自定义'
: (PRESETS[form.value.preset]?.label || form.value.preset)
emit('toast', '已保存 AI 设置(' + label + '')
emit('close')
}
</script>
<template>
<div class="modal-mask" :class="{ hidden: !visible }" @click.self="emit('close')">
<div class="modal">
<h3>AI 设置</h3>
<p class="modal-tip">配置大模型服务商数据仅保存在本地浏览器</p>
<div class="form-row">
<label>服务商</label>
<select v-model="form.preset" @change="onProviderChange">
<optgroup label="国内">
<option value="zhipu">智谱 GLM (OpenAI 协议)</option>
<option value="zhipu_anth">智谱 GLM (Anthropic 协议)</option>
<option value="deepseek">DeepSeek</option>
<option value="qwen">通义千问</option>
<option value="kimi">Kimi</option>
<option value="doubao">豆包</option>
</optgroup>
<optgroup label="海外">
<option value="openai">OpenAI</option>
<option value="anthropic">Anthropic</option>
<option value="gemini">Gemini</option>
<option value="groq">Groq</option>
</optgroup>
<optgroup label="本地">
<option value="ollama">Ollama</option>
</optgroup>
<option value="custom">自定义</option>
</select>
</div>
<div class="form-row">
<label>API 协议</label>
<select v-model="form.protocol" @change="onProtocolChange">
<option value="openai">OpenAI</option>
<option value="anthropic">Anthropic</option>
</select>
</div>
<div class="form-row">
<label>Base URL</label>
<input type="text" v-model="form.base" placeholder="https://..." />
</div>
<div class="form-row">
<label>API Key</label>
<input type="password" v-model="form.key" placeholder="sk-..." autocomplete="off" />
</div>
<div class="form-row">
<label>模型</label>
<input type="text" v-model="form.model" placeholder="模型名称" />
</div>
<div class="form-row">
<label>代理 URL可选</label>
<input type="text" v-model="form.proxy" placeholder="留空则直连" />
</div>
<div class="section-title">图像模型可选用于 AI 配图</div>
<div class="form-row">
<label>图像 Base URL</label>
<input type="text" v-model="form.imgBase" placeholder="留空则复用上方 Base URL" />
</div>
<div class="form-row">
<label>图像 API Key</label>
<input type="password" v-model="form.imgKey" placeholder="留空则复用上方 Key" autocomplete="off" />
</div>
<div class="form-row">
<label>图像模型</label>
<input type="text" v-model="form.imgModel" placeholder="dall-e-3" />
</div>
<div class="modal-actions">
<button class="btn" @click="emit('close')">取消</button>
<button class="btn primary" @click="save">保存</button>
</div>
</div>
</div>
</template>
<style scoped>
.section-title {
margin: 18px 0 10px;
padding-top: 14px;
border-top: 1px solid var(--ui-border);
font-size: 13px;
font-weight: 600;
color: var(--ui-text);
}
</style>
+189
View File
@@ -0,0 +1,189 @@
<!-- =====================================================================
TemplateModal.vue 页面模板基于模板新建页 + 管理自存模板
===================================================================== -->
<script setup lang="ts">
import { ref, computed } from 'vue'
import { store, resolveBg } from '../../core/store'
import type { PageTemplate } from '../../core/types'
import ElementView from '../editor/ElementView.vue'
defineProps<{ visible: boolean }>()
const emit = defineEmits<{
(e: 'close'): void
(e: 'toast', msg: string): void
}>()
/* ---------- 存当前页为模板 ---------- */
const nameInput = ref('')
/* ---------- 非响应式列表的刷新机制(getTemplates 不返回响应式数据) ---------- */
const tick = ref(0)
const templates = computed(() => { tick.value; return store.getTemplates() })
function bump() { tick.value++ }
function onSave() {
const v = nameInput.value.trim()
if (!v) { emit('toast', '请输入模板名'); return }
// 空页存模板没意义
const cur = store.currentSlide.value
if (cur && cur.elements.length === 0) {
emit('toast', '当前页为空,无法存为模板')
return
}
store.saveCurrentAsTemplate(v)
bump()
nameInput.value = ''
emit('toast', '已存为模板:' + v)
}
/* ---------- 基于模板新建页 ---------- */
function onPick(tpl: PageTemplate) {
const ok = store.addSlideFromTemplate(tpl.id)
if (ok) {
emit('toast', '已基于「' + tpl.name + '」新建一页')
emit('close')
} else {
emit('toast', '新建失败:找不到模板')
}
}
/* ---------- 删除自存模板 ---------- */
function onDelete(tpl: PageTemplate) {
if (confirm('删除模板「' + tpl.name + '」?')) {
store.deleteTemplate(tpl.id)
bump()
emit('toast', '已删除')
}
}
</script>
<template>
<div class="modal-mask" :class="{ hidden: !visible }" @click.self="emit('close')">
<div class="modal tpl-modal">
<h3>📋 页面模板</h3>
<!-- 存当前页 -->
<div class="tpl-save-bar">
<input v-model="nameInput" placeholder="存当前页为模板,输入名称…" @keydown.enter="onSave" />
<button class="btn primary" @click="onSave">💾 保存为模板</button>
</div>
<!-- 模板网格 -->
<div class="tpl-grid">
<div v-for="tpl in templates" :key="tpl.id" class="tpl-card" :class="tpl.category" @click="onPick(tpl)">
<div class="tpl-thumb" :style="{ background: resolveBg(tpl.background) }">
<div class="tpl-thumb-layer">
<ElementView v-for="el in tpl.elements" :key="el.id" :el="el" :bg="tpl.background" />
</div>
</div>
<div class="tpl-info">
<span class="tpl-name">{{ tpl.name }}</span>
<span class="tpl-badge" :class="tpl.category">{{ tpl.category === 'built-in' ? '内置' : '自存' }}</span>
<button v-if="tpl.category === 'user'" class="tpl-del" title="删除" @click.stop="onDelete(tpl)">🗑</button>
</div>
</div>
</div>
<div class="modal-actions">
<button class="btn" @click="emit('close')">关闭</button>
</div>
</div>
</div>
</template>
<style scoped>
.tpl-modal {
width: 720px;
max-width: 92vw;
max-height: 86vh;
display: flex;
flex-direction: column;
}
.tpl-save-bar {
display: flex;
gap: .5em;
margin-bottom: 1em;
}
.tpl-save-bar input {
flex: 1;
}
.tpl-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1em;
overflow-y: auto;
padding: .2em;
}
.tpl-card {
cursor: pointer;
border-radius: 8px;
overflow: hidden;
background: #fff;
border: 2px solid rgba(100, 116, 139, .2);
transition: border-color .15s, transform .15s;
}
.tpl-card:hover {
border-color: var(--primary, #4f46e5);
transform: translateY(-2px);
}
.tpl-thumb {
position: relative;
width: 100%;
padding-top: 56.25%;
overflow: hidden;
}
.tpl-thumb-layer {
position: absolute;
top: 0;
left: 0;
width: 1280px;
height: 720px;
transform: scale(0.094); /* 约 120px 宽 */
transform-origin: top left;
pointer-events: none;
}
.tpl-info {
display: flex;
align-items: center;
gap: .4em;
padding: .5em .6em;
font-size: 13px;
}
.tpl-name {
flex: 1;
}
.tpl-badge {
font-size: 11px;
padding: 1px 6px;
border-radius: 3px;
background: rgba(100, 116, 139, .15);
color: var(--muted, #64748b);
}
.tpl-badge.user {
background: rgba(79, 70, 229, .12);
color: var(--primary, #4f46e5);
}
.tpl-del {
font-size: 13px;
opacity: .5;
cursor: pointer;
padding: 2px 4px;
}
.tpl-del:hover {
opacity: 1;
color: #e11d48;
}
</style>
+252
View File
@@ -0,0 +1,252 @@
<!-- =====================================================================
PresentMode.vue 全屏演示模式
===================================================================== -->
<script setup lang="ts">
import { ref, computed, watch, onUnmounted, nextTick } from 'vue'
import type { Slide } from '../../core/types'
import { store, resolveBg } from '../../core/store'
import { CANVAS_W, CANVAS_H } from '../../core/sample'
import ElementView from '../editor/ElementView.vue'
const props = withDefaults(defineProps<{
visible: boolean
startIndex?: number
}>(), {
startIndex: undefined
})
const emit = defineEmits<{ (e: 'exit'): void }>()
const index = ref(0)
const idle = ref(false)
const black = ref(false)
const dir = ref<'next' | 'prev'>('next')
const scale = ref(1)
const showHint = ref(false)
const innerEl = ref<HTMLElement | null>(null)
const slides = computed<Slide[]>(() => store.getSlides())
const currentSlide = computed<Slide | null>(() => {
const arr = slides.value
if (!arr.length) return null
const i = Math.max(0, Math.min(index.value, arr.length - 1))
return arr[i]
})
const total = computed(() => slides.value.length)
let idleTimer: ReturnType<typeof setTimeout> | null = null
let wheelLock = false
let hintTimer: ReturnType<typeof setTimeout> | null = null
let active = false
/* ---------- 全屏 ---------- */
function enterFs() {
const el = document.documentElement as any
const fn = el.requestFullscreen || el.webkitRequestFullscreen || el.msRequestFullscreen
if (fn) { try { fn.call(el) } catch (e) {} }
}
function exitFs() {
const d = document as any
const fn = d.exitFullscreen || d.webkitExitFullscreen || d.msExitFullscreen
if (fn && document.fullscreenElement) { try { fn.call(document) } catch (e) {} }
}
function toggleFs() {
if (document.fullscreenElement) exitFs()
else enterFs()
}
/* ---------- 适配屏幕 ---------- */
function fit() {
const s = Math.min(window.innerWidth / CANVAS_W, window.innerHeight / CANVAS_H)
scale.value = s
}
/* ---------- 翻页 ---------- */
function goto(i: number, d?: 'next' | 'prev') {
const n = total.value
if (i < 0 || i > n - 1) return
if (i === index.value) return
const forward = i > index.value
index.value = i
dir.value = d || (forward ? 'next' : 'prev')
}
function next() {
if (index.value < total.value - 1) {
index.value++
dir.value = 'next'
}
}
function prev() {
if (index.value > 0) {
index.value--
dir.value = 'prev'
}
}
/* ---------- HUD idle ---------- */
function wake() {
idle.value = false
if (idleTimer) clearTimeout(idleTimer)
idleTimer = setTimeout(() => { idle.value = true }, 2200)
}
/* ---------- 黑屏 ---------- */
function toggleBlack() { black.value = !black.value }
/* ---------- 键盘 ---------- */
function onKey(e: KeyboardEvent) {
wake()
switch (e.key) {
case 'ArrowRight': case 'ArrowDown': case ' ': case 'PageDown':
e.preventDefault(); next(); break
case 'ArrowLeft': case 'ArrowUp': case 'PageUp':
e.preventDefault(); prev(); break
case 'Home':
e.preventDefault(); goto(0, 'prev'); break
case 'End':
e.preventDefault(); goto(total.value - 1, 'next'); break
case 'F': case 'f':
e.preventDefault(); toggleFs(); break
case 'B': case 'b':
toggleBlack(); break
case 'Escape':
exit(); break
}
}
/* ---------- 鼠标 ---------- */
function onClick(e: MouseEvent) {
const target = e.target as HTMLElement
if (target.closest('.hud-dot')) {
wake()
const i = Number((target.closest('.hud-dot') as HTMLElement).dataset.idx)
goto(i)
return
}
if (target.closest('[data-action="exit-present"]')) { exit(); return }
wake()
next()
}
function onContext(e: MouseEvent) { e.preventDefault(); wake(); prev() }
function onWheel(e: WheelEvent) {
if (wheelLock) return
wheelLock = true
if (e.deltaY > 0) next(); else prev()
wake()
setTimeout(() => { wheelLock = false }, 450)
}
/* ---------- 圆点(>20 页不显示) ---------- */
const showDots = computed(() => total.value <= 20)
/* ---------- 首次提示 ---------- */
function triggerHint() {
showHint.value = true
if (hintTimer) clearTimeout(hintTimer)
hintTimer = setTimeout(() => { showHint.value = false }, 3600)
}
/* ---------- list 内部逐条入场动画延迟 ----------
* ElementView 不可修改,用 ref + querySelector 设置 .li 的 animation-delay
*/
function applyListDelays() {
const root = innerEl.value
if (!root) return
const nodes = root.querySelectorAll('.el')
nodes.forEach((node, i) => {
if (node.getAttribute('data-type') === 'list') {
const lis = node.querySelectorAll('.li')
lis.forEach((li, j) => {
(li as HTMLElement).style.animationDelay = (0.24 + i * 0.09 + j * 0.08) + 's'
})
}
})
}
/* 翻页后重新应用 list delay */
watch([index, dir], () => {
nextTick(applyListDelays)
})
/* ---------- 进入/退出 ---------- */
function bindEvents() {
document.addEventListener('keydown', onKey)
window.addEventListener('resize', fit)
}
function unbindEvents() {
document.removeEventListener('keydown', onKey)
window.removeEventListener('resize', fit)
}
function start() {
active = true
const start = props.startIndex != null ? props.startIndex : store.getCurrentIndex()
index.value = (start != null && start < store.getCount()) ? start : store.getCurrentIndex()
dir.value = 'next'
black.value = false
idle.value = false
bindEvents()
nextTick(() => { fit() })
enterFs()
triggerHint()
wake()
}
function exit() {
if (!active) return
active = false
exitFs()
unbindEvents()
if (idleTimer) clearTimeout(idleTimer)
if (hintTimer) clearTimeout(hintTimer)
store.setCurrentIndex(index.value)
emit('exit')
}
watch(() => props.visible, (v, old) => {
if (v && !old) start()
})
onUnmounted(() => {
if (active) exit()
})
</script>
<template>
<div v-if="visible" class="app-present">
<div class="present-stage" :class="{ idle, black }" @click="onClick" @contextmenu="onContext" @wheel.passive="onWheel" @mousemove="wake">
<div class="slide-layer" :style="{ transform: 'scale(' + scale + ')' }">
<div v-if="currentSlide" ref="innerEl" class="slide-inner animated" :class="'enter-' + dir" :style="{ background: resolveBg(currentSlide.background) }">
<ElementView
v-for="(el, i) in currentSlide.elements"
:key="el.id"
:el="el"
:bg="currentSlide.background"
:style="{ animationDelay: (0.12 + i * 0.09) + 's' }"
/>
</div>
</div>
<div v-if="showHint" class="present-hint"> 翻页 · F 全屏 · B 黑屏 · Esc 退出</div>
</div>
<div class="present-hud">
<div class="hud-progress">
<div class="hud-progress-bar" :style="{ width: (total <= 1 ? 100 : (index / (total - 1)) * 100) + '%' }"></div>
</div>
<div class="hud-row">
<span>{{ (index + 1) + ' / ' + total }}</span>
<div v-if="showDots" class="hud-dots">
<div
v-for="i in total"
:key="i"
class="hud-dot"
:class="{ active: (i - 1) === index }"
:data-idx="i - 1"
></div>
</div>
<button class="btn ghost" data-action="exit-present" @click.stop="exit">退出 (Esc)</button>
</div>
</div>
</div>
</template>