新增: 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:
@@ -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>
|
||||
Reference in New Issue
Block a user