新增: 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:
2026-07-12 13:12:09 +08:00
commit cfeabf2d37
35 changed files with 7919 additions and 0 deletions

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>

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>

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>