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