新增: 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,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, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
}
|
||||
|
||||
/** 尝试把 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
}
|
||||
|
||||
/** 图例数据(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 用圆心居中的 viewBox;progress 用正常 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>
|
||||
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
}
|
||||
|
||||
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 text(segments 结构化富文本)---------- */
|
||||
/** 是否有 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 每行的渲染 HTML(segments 或纯文本) */
|
||||
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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user