新增: 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
+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>