新增: 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:
702
src/core/store.ts
Normal file
702
src/core/store.ts
Normal file
@@ -0,0 +1,702 @@
|
||||
/* =====================================================================
|
||||
* store.ts — 状态管理:deck 数据 / localStorage 持久化 / CRUD / undo-redo
|
||||
* 由 store.js 迁移:用 Vue reactive 替代手写 pub/sub,组件自动追踪
|
||||
* ===================================================================== */
|
||||
import { reactive, computed } from 'vue'
|
||||
import type { AiCfg, Deck, LibItem, PageTemplate, Slide, SlideElement, ThemeKey } from './types'
|
||||
import { DECK_VERSION, SAMPLE_DECK, createElement, themes, uid } from './sample'
|
||||
|
||||
/* ---------- localStorage 键 ---------- */
|
||||
const LS_KEY = 'u-ppt.deck.v1'
|
||||
const LS_CFG = 'u-ppt.ai-cfg.v1'
|
||||
const LIB_KEY = 'u-ppt.library.v1'
|
||||
const ACTIVE_KEY = 'u-ppt.activeLib.v1'
|
||||
const CHAT_PREFIX = 'u-ppt.chat.'
|
||||
const SAVE_DELAY = 400
|
||||
|
||||
/* ---------- 内置页面模板(版式预设) ---------- */
|
||||
const BUILTIN_TEMPLATES: PageTemplate[] = [
|
||||
{
|
||||
id: 'tpl-blank', name: '空白页', category: 'built-in', background: 'bg',
|
||||
elements: []
|
||||
},
|
||||
{
|
||||
id: 'tpl-title', name: '标题页', category: 'built-in', background: 'g-primary',
|
||||
elements: [
|
||||
{ id: 'b1', type: 'title', x: 10, y: 38, w: 80, h: 20, content: '标题', style: { fontSize: 64, color: 'text', align: 'center', anim: 'pop' } },
|
||||
{ id: 'b2', type: 'text', x: 20, y: 60, w: 60, h: 8, content: '副标题', style: { fontSize: 24, color: 'muted', align: 'center', anim: 'fade-up' } }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'tpl-section', name: '章节页', category: 'built-in', background: 'g-deep',
|
||||
elements: [
|
||||
{ id: 's1', type: 'shape', x: 0, y: 0, w: 6, h: 100, content: '', style: { shapeType: 'rect', fill: 'accent', radius: 0, anim: 'slide-l' } },
|
||||
{ id: 's2', type: 'title', x: 12, y: 38, w: 70, h: 18, content: '章节标题', style: { fontSize: 56, color: 'text', align: 'left', anim: 'fade-up' } },
|
||||
{ id: 's3', type: 'text', x: 12, y: 58, w: 60, h: 8, content: '章节描述', style: { fontSize: 22, color: 'muted', align: 'left', anim: 'fade-up' } }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'tpl-cards3', name: '三卡片', category: 'built-in', background: 'bg',
|
||||
elements: [
|
||||
{ id: 'c1', type: 'title', x: 8, y: 8, w: 80, h: 12, content: '标题', style: { fontSize: 40, color: 'primary', align: 'left', anim: 'fade-up' } },
|
||||
{ id: 'c2', type: 'card', x: 6, y: 28, w: 28, h: 60, content: '卡片一\n要点描述', style: { accent: 'primary', icon: '', anim: 'fade-up' } },
|
||||
{ id: 'c3', type: 'card', x: 36, y: 28, w: 28, h: 60, content: '卡片二\n要点描述', style: { accent: 'accent', icon: '', anim: 'fade-up' } },
|
||||
{ id: 'c4', type: 'card', x: 66, y: 28, w: 28, h: 60, content: '卡片三\n要点描述', style: { accent: 'primary', icon: '', anim: 'fade-up' } }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'tpl-compare', name: '对比页', category: 'built-in', background: 'bg',
|
||||
elements: [
|
||||
{ id: 'p1', type: 'title', x: 10, y: 7, w: 80, h: 11, content: '对比标题', style: { fontSize: 36, color: 'primary', align: 'center', anim: 'fade-up' } },
|
||||
{ id: 'p2', type: 'card', x: 7, y: 24, w: 40, h: 65, content: '左\n要点一\n要点二', style: { accent: 'muted', icon: '', anim: 'slide-l' } },
|
||||
{ id: 'p3', type: 'card', x: 53, y: 24, w: 40, h: 65, content: '右\n要点一\n要点二', style: { accent: 'primary', icon: '', anim: 'slide-r' } }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'tpl-quote', name: '金句页', category: 'built-in', background: 'g-deep',
|
||||
elements: [
|
||||
{ id: 'q1', type: 'shape', x: 8, y: 24, w: 4, h: 52, content: '', style: { shapeType: 'rect', fill: 'accent', radius: 4, anim: 'slide-l' } },
|
||||
{ id: 'q2', type: 'quote', x: 16, y: 28, w: 76, h: 34, content: '金句内容', style: { fontSize: 48, color: 'text', italic: true, align: 'left', anim: 'scale' } },
|
||||
{ id: 'q3', type: 'text', x: 16, y: 70, w: 60, h: 8, content: '— 出处', style: { fontSize: 22, color: 'muted', align: 'left', anim: 'fade-up' } }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'tpl-data', name: '数据页', category: 'built-in', background: 'panel',
|
||||
elements: [
|
||||
{ id: 'd1', type: 'title', x: 8, y: 8, w: 80, h: 12, content: '数据标题', style: { fontSize: 40, color: 'primary', align: 'left', anim: 'fade-up' } },
|
||||
{ id: 'd2', type: 'stat', x: 8, y: 28, w: 28, h: 30, content: '65%', style: { fontSize: 76, color: 'primary', label: '说明', labelColor: 'muted', labelSize: 18, anim: 'scale' } },
|
||||
{ id: 'd3', type: 'chart', x: 42, y: 26, w: 52, h: 40, content: '[{"label":"A","value":65},{"label":"B","value":45},{"label":"C","value":30}]', style: { color: 'primary', max: 100, chartType: 'bar', anim: 'fade-up' } }
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
/* ---------- 历史快照 ---------- */
|
||||
interface Snapshot { deck: Deck; index: number; sel: string | null }
|
||||
const HIST_MAX = 40
|
||||
let hist: Snapshot[] = []
|
||||
let future: Snapshot[] = []
|
||||
let suppressHistory = false
|
||||
let touchKey: string | null = null
|
||||
let touchTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
/* ---------- 响应式状态(单一数据源) ---------- */
|
||||
const state = reactive<{
|
||||
deck: Deck
|
||||
currentIndex: number
|
||||
selectedId: string | null
|
||||
activeLibId: string | null
|
||||
}>({
|
||||
deck: JSON.parse(JSON.stringify(SAMPLE_DECK)),
|
||||
currentIndex: 0,
|
||||
selectedId: null,
|
||||
activeLibId: null
|
||||
})
|
||||
|
||||
let saveTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
/* ---------- 工具 ---------- */
|
||||
function clone<T>(o: T): T { return JSON.parse(JSON.stringify(o)) }
|
||||
|
||||
function scheduleSave() {
|
||||
if (saveTimer) clearTimeout(saveTimer)
|
||||
saveTimer = setTimeout(() => {
|
||||
try { localStorage.setItem(LS_KEY, JSON.stringify(state.deck)) } catch (e) { /* 配额满等忽略 */ }
|
||||
}, SAVE_DELAY)
|
||||
}
|
||||
|
||||
/* ---------- 颜色/背景解析(纯函数,供组件调用) ---------- */
|
||||
export function hexToRgb(hex: string): { r: number; g: number; b: number } | null {
|
||||
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 }
|
||||
}
|
||||
|
||||
export 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
|
||||
}
|
||||
|
||||
export function shade(hex: string, pct: number): string {
|
||||
const rgb = hexToRgb(hex); if (!rgb) return hex
|
||||
const f = pct < 0 ? 0 : 255, p = Math.abs(pct) / 100
|
||||
const r = Math.round((f - rgb.r) * p + rgb.r)
|
||||
const g = Math.round((f - rgb.g) * p + rgb.g)
|
||||
const b = Math.round((f - rgb.b) * p + rgb.b)
|
||||
return '#' + [r, g, b].map(x => { const s = x.toString(16); return s.length < 2 ? '0' + s : s }).join('')
|
||||
}
|
||||
|
||||
/** 背景是否深色:primary/accent/渐变键视为深;bg/panel 视为浅;hex 按亮度判断 */
|
||||
export 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
|
||||
}
|
||||
|
||||
export function isValidHex(s: string): boolean {
|
||||
return typeof s === 'string' && /^#[0-9a-f]{3,8}$/i.test(s)
|
||||
}
|
||||
|
||||
/**
|
||||
* 颜色解析:dark=true 时把深色文字键反相为浅色,确保深底可见。
|
||||
* 安全:非主题键、非合法 hex 一律回退,杜绝任意 CSS/HTML 经 color 注入
|
||||
*/
|
||||
export function resolveColor(key: string | undefined, dark: boolean): string {
|
||||
const t = (getAllThemes()[state.deck.theme] || {}) as unknown as Record<string, string>
|
||||
if (!key) return ''
|
||||
if (key.charAt(0) === '#') {
|
||||
if (!isValidHex(key)) return dark ? '#ffffff' : (t.text || '#1e293b')
|
||||
if (dark && isDarkHex(key)) return '#ffffff'
|
||||
return key
|
||||
}
|
||||
if (dark) {
|
||||
if (key === 'text') return '#ffffff'
|
||||
if (key === 'muted') return 'rgba(255,255,255,0.72)'
|
||||
if (key === 'primary') return '#ffffff'
|
||||
return t[key] || '#ffffff'
|
||||
}
|
||||
return t[key] || t.text || '#1e293b'
|
||||
}
|
||||
|
||||
/** 背景解析:渐变键 → CSS gradient;其余 → 主题色或合法 hex;未知键回退白 */
|
||||
export function resolveBg(key: string): string {
|
||||
const t = (getAllThemes()[state.deck.theme] || {}) as unknown as Record<string, string>
|
||||
if (!key) return '#ffffff'
|
||||
if (key.charAt(0) === '#') return isValidHex(key) ? key : '#ffffff'
|
||||
if (key === 'g-primary') return 'linear-gradient(135deg, ' + t.primary + ' 0%, ' + t.accent + ' 100%)'
|
||||
if (key === 'g-deep') return 'linear-gradient(160deg, ' + shade(t.primary, -28) + ' 0%, ' + t.primary + ' 100%)'
|
||||
if (key === 'g-soft') return 'linear-gradient(135deg, ' + (t.panel || '#f8fafc') + ' 0%, ' + (t.bg || '#ffffff') + ' 100%)'
|
||||
return t[key] || '#ffffff'
|
||||
}
|
||||
|
||||
/* ---------- 历史快照 ---------- */
|
||||
function pushHistory() {
|
||||
if (suppressHistory) return
|
||||
hist.push({ deck: clone(state.deck), index: state.currentIndex, sel: state.selectedId })
|
||||
if (hist.length > HIST_MAX) hist.shift()
|
||||
future.length = 0
|
||||
}
|
||||
|
||||
/** 连续「同一元素同一字段」的操作合并为一条历史(如拖字号滑块、连续敲字) */
|
||||
function touchHistory(key: string) {
|
||||
if (suppressHistory) return
|
||||
if (touchKey !== key) { pushHistory(); touchKey = key }
|
||||
if (touchTimer) clearTimeout(touchTimer)
|
||||
touchTimer = setTimeout(() => { touchKey = null }, 900)
|
||||
}
|
||||
|
||||
/* ---------- 初始化 ---------- */
|
||||
function init() {
|
||||
let loaded: Deck | null = null
|
||||
try { loaded = JSON.parse(localStorage.getItem(LS_KEY) || 'null') } catch (e) { /* ignore */ }
|
||||
const curV = SAMPLE_DECK.v
|
||||
const valid = loaded && loaded.slides && loaded.slides.length && loaded.v === curV
|
||||
if (!valid && loaded && loaded.slides) {
|
||||
try { localStorage.setItem(LS_KEY + '.bak', JSON.stringify(loaded)) } catch (e) {} // 旧版备份
|
||||
}
|
||||
Object.assign(state, {
|
||||
deck: valid ? loaded! : clone(SAMPLE_DECK),
|
||||
currentIndex: 0,
|
||||
selectedId: null,
|
||||
activeLibId: null
|
||||
})
|
||||
try { state.activeLibId = localStorage.getItem(ACTIVE_KEY) || null } catch (e) {}
|
||||
}
|
||||
|
||||
/* ---------- 读(computed 自动追踪) ---------- */
|
||||
const deck = computed(() => state.deck)
|
||||
const slides = computed(() => state.deck.slides)
|
||||
const count = computed(() => state.deck.slides.length)
|
||||
const currentIndex = computed(() => state.currentIndex)
|
||||
const theme = computed(() => state.deck.theme)
|
||||
const selectedId = computed(() => state.selectedId)
|
||||
const activeLibId = computed(() => state.activeLibId)
|
||||
const currentSlide = computed(() => state.deck.slides[state.currentIndex])
|
||||
|
||||
function getCurrentIndex() { return state.currentIndex }
|
||||
function getTheme() { return state.deck.theme }
|
||||
function getSelectedId() { return state.selectedId }
|
||||
function getDeck() { return state.deck }
|
||||
function getSlides() { return state.deck.slides }
|
||||
function getCount() { return state.deck.slides.length }
|
||||
|
||||
function getSelected(): SlideElement | null {
|
||||
const s = currentSlide.value
|
||||
if (!s || !state.selectedId) return null
|
||||
return s.elements.find(e => e.id === state.selectedId) || null
|
||||
}
|
||||
|
||||
const canUndo = computed(() => hist.length > 0)
|
||||
const canRedo = computed(() => future.length > 0)
|
||||
|
||||
/* ---------- 选择 / 翻页 ---------- */
|
||||
function setCurrentIndex(i: number) {
|
||||
i = Math.max(0, Math.min(state.deck.slides.length - 1, i))
|
||||
if (i === state.currentIndex) return
|
||||
state.currentIndex = i
|
||||
state.selectedId = null
|
||||
scheduleSave()
|
||||
}
|
||||
function selectElement(id: string | null) { state.selectedId = id || null }
|
||||
|
||||
/* ---------- 主题 ---------- */
|
||||
function setTheme(name: string) {
|
||||
// 允许内置主题键与已存的自定义主题键
|
||||
if (!themes[name] && !getCustomThemes()[name]) return
|
||||
pushHistory()
|
||||
state.deck.theme = name
|
||||
scheduleSave()
|
||||
}
|
||||
|
||||
/* ---------- 幻灯片 CRUD ---------- */
|
||||
function addSlide(atIndex?: number) {
|
||||
pushHistory()
|
||||
const s: Slide = { id: uid('s'), background: 'bg', elements: [] }
|
||||
const idx = atIndex != null ? atIndex + 1 : state.deck.slides.length
|
||||
state.deck.slides.splice(idx, 0, s)
|
||||
state.currentIndex = idx
|
||||
state.selectedId = null
|
||||
scheduleSave()
|
||||
return s
|
||||
}
|
||||
|
||||
function dupSlide() {
|
||||
pushHistory()
|
||||
const cur = currentSlide.value; if (!cur) return
|
||||
const copy = clone(cur); copy.id = uid('s')
|
||||
copy.elements.forEach(e => { e.id = uid('el') })
|
||||
state.deck.slides.splice(state.currentIndex + 1, 0, copy)
|
||||
state.currentIndex++
|
||||
state.selectedId = null
|
||||
scheduleSave()
|
||||
}
|
||||
|
||||
function delSlide(i?: number): boolean {
|
||||
if (state.deck.slides.length <= 1) return false
|
||||
pushHistory()
|
||||
const idx = i != null ? i : state.currentIndex
|
||||
state.deck.slides.splice(idx, 1)
|
||||
if (state.currentIndex >= state.deck.slides.length) state.currentIndex = state.deck.slides.length - 1
|
||||
state.selectedId = null
|
||||
scheduleSave()
|
||||
return true
|
||||
}
|
||||
|
||||
function moveSlide(from: number, to: number) {
|
||||
if (from === to || from < 0 || to < 0) return
|
||||
const arr = state.deck.slides
|
||||
if (to >= arr.length) return
|
||||
pushHistory()
|
||||
const item = arr.splice(from, 1)[0]
|
||||
arr.splice(to, 0, item)
|
||||
state.currentIndex = to
|
||||
scheduleSave()
|
||||
}
|
||||
|
||||
function setSlideBackground(bg: string) {
|
||||
pushHistory()
|
||||
currentSlide.value.background = bg as Slide['background']
|
||||
scheduleSave()
|
||||
}
|
||||
|
||||
/* ---------- 元素 CRUD ---------- */
|
||||
function addElement(type: SlideElement['type'], over?: Parameters<typeof createElement>[1]) {
|
||||
pushHistory()
|
||||
const el = createElement(type, over)
|
||||
currentSlide.value.elements.push(el)
|
||||
state.selectedId = el.id
|
||||
scheduleSave()
|
||||
return el
|
||||
}
|
||||
|
||||
function updateElement(id: string, patch: Partial<SlideElement> & { style?: Record<string, unknown> }) {
|
||||
const key = id + ':' + (patch.style ? Object.keys(patch.style).sort().join(',') : Object.keys(patch).sort().join(','))
|
||||
touchHistory(key)
|
||||
const el = findElement(id); if (!el) return
|
||||
if (patch.style) { Object.assign(el.style, patch.style) }
|
||||
const rest = { ...patch }; delete (rest as Record<string, unknown>).style
|
||||
Object.assign(el, rest)
|
||||
scheduleSave()
|
||||
}
|
||||
|
||||
function delElement(id: string) {
|
||||
pushHistory()
|
||||
const s = currentSlide.value
|
||||
s.elements = s.elements.filter(e => e.id !== id)
|
||||
if (state.selectedId === id) state.selectedId = null
|
||||
scheduleSave()
|
||||
}
|
||||
|
||||
function moveElementZ(id: string, dir: number) {
|
||||
pushHistory()
|
||||
const s = currentSlide.value
|
||||
const i = s.elements.findIndex(e => e.id === id)
|
||||
if (i < 0) return
|
||||
const j = dir > 0 ? i + 1 : i - 1
|
||||
if (j < 0 || j >= s.elements.length) return
|
||||
const tmp = s.elements[i]; s.elements[i] = s.elements[j]; s.elements[j] = tmp
|
||||
scheduleSave()
|
||||
}
|
||||
|
||||
function findElement(id: string): SlideElement | null {
|
||||
const s = currentSlide.value
|
||||
return s.elements.find(e => e.id === id) || null
|
||||
}
|
||||
|
||||
/* ---------- undo / redo ---------- */
|
||||
function undo(): boolean {
|
||||
if (!hist.length) return false
|
||||
future.push({ deck: clone(state.deck), index: state.currentIndex, sel: state.selectedId })
|
||||
const prev = hist.pop()!
|
||||
suppressHistory = true
|
||||
state.deck = prev.deck
|
||||
state.currentIndex = Math.min(prev.index, state.deck.slides.length - 1)
|
||||
state.selectedId = prev.sel
|
||||
suppressHistory = false
|
||||
scheduleSave()
|
||||
return true
|
||||
}
|
||||
|
||||
function redo(): boolean {
|
||||
if (!future.length) return false
|
||||
hist.push({ deck: clone(state.deck), index: state.currentIndex, sel: state.selectedId })
|
||||
const nxt = future.pop()!
|
||||
suppressHistory = true
|
||||
state.deck = nxt.deck
|
||||
state.currentIndex = Math.min(nxt.index, state.deck.slides.length - 1)
|
||||
state.selectedId = nxt.sel
|
||||
suppressHistory = false
|
||||
scheduleSave()
|
||||
return true
|
||||
}
|
||||
|
||||
/* ---------- 整体替换(AI 生成 / 导入 / 重置用) ---------- */
|
||||
function replaceDeck(newDeck: Deck | Record<string, unknown>, opts?: { keepHistory?: boolean; newChat?: boolean }) {
|
||||
if (!opts?.keepHistory) pushHistory()
|
||||
suppressHistory = true
|
||||
let d = newDeck as Deck
|
||||
if (!d || typeof d !== 'object') d = {} as Deck
|
||||
d.v = DECK_VERSION // 维护版本号
|
||||
// 健壮性:损坏/空数据兜底为一页空白,避免下游 forEach 崩溃
|
||||
if (!Array.isArray(d.slides) || !d.slides.length) {
|
||||
d.slides = [{ id: uid('s'), background: 'bg', elements: [] }]
|
||||
}
|
||||
// 会话 ID 绑定:newChat=true 开新会话;否则沿用 deck 自带的 chatId(文库加载时);都没有则生成新的
|
||||
if (opts?.newChat || !d.chatId) {
|
||||
d.chatId = newChatId()
|
||||
}
|
||||
state.deck = d
|
||||
state.activeLibId = null
|
||||
state.currentIndex = Math.min(state.currentIndex, d.slides.length - 1)
|
||||
if (state.currentIndex < 0) state.currentIndex = 0
|
||||
state.selectedId = null
|
||||
suppressHistory = false
|
||||
scheduleSave()
|
||||
}
|
||||
|
||||
function replaceSlide(index: number, newSlide: Slide) {
|
||||
pushHistory()
|
||||
newSlide.id = state.deck.slides[index] ? state.deck.slides[index].id : uid('s')
|
||||
newSlide.elements = newSlide.elements || []
|
||||
newSlide.elements.forEach(e => { if (!e.id) e.id = uid('el') })
|
||||
state.deck.slides[index] = newSlide
|
||||
state.selectedId = null
|
||||
scheduleSave()
|
||||
}
|
||||
|
||||
function appendSlide(newSlide: Slide) {
|
||||
pushHistory()
|
||||
newSlide.id = uid('s')
|
||||
newSlide.elements = newSlide.elements || []
|
||||
newSlide.elements.forEach(e => { if (!e.id) e.id = uid('el') })
|
||||
state.deck.slides.push(newSlide)
|
||||
state.currentIndex = state.deck.slides.length - 1
|
||||
state.selectedId = null
|
||||
scheduleSave()
|
||||
}
|
||||
|
||||
function insertSlideAt(index: number, newSlide: Slide) {
|
||||
pushHistory()
|
||||
newSlide.id = uid('s')
|
||||
newSlide.elements = newSlide.elements || []
|
||||
newSlide.elements.forEach(e => { if (!e.id) e.id = uid('el') })
|
||||
state.deck.slides.splice(index, 0, newSlide)
|
||||
state.currentIndex = index
|
||||
state.selectedId = null
|
||||
scheduleSave()
|
||||
}
|
||||
|
||||
function reset() {
|
||||
replaceDeck(clone(SAMPLE_DECK), { newChat: true })
|
||||
hist = []; future = []
|
||||
}
|
||||
|
||||
function exportJSON(): string { return JSON.stringify(state.deck, null, 2) }
|
||||
|
||||
function importJSON(str: string) {
|
||||
let d: Deck
|
||||
try { d = JSON.parse(str) } catch (e) { throw new Error('数据格式无效:不是合法 JSON') }
|
||||
if (!d || !d.slides || !d.slides.length) throw new Error('数据格式无效:缺少 slides')
|
||||
if (!themes[d.theme]) d.theme = 'indigo'
|
||||
replaceDeck(d)
|
||||
}
|
||||
|
||||
/* ---------- AI 配置持久化 ---------- */
|
||||
const DEFAULT_CFG: AiCfg = {
|
||||
preset: 'zhipu', protocol: 'openai',
|
||||
base: 'https://open.bigmodel.cn/api/paas/v4', key: '', model: 'glm-4.6', proxy: '',
|
||||
imgBase: '', imgKey: '', imgModel: ''
|
||||
}
|
||||
|
||||
function getCfg(): AiCfg {
|
||||
try {
|
||||
const c = JSON.parse(localStorage.getItem(LS_CFG) || 'null')
|
||||
return { ...DEFAULT_CFG, ...(c || {}) }
|
||||
} catch (e) { return clone(DEFAULT_CFG) }
|
||||
}
|
||||
|
||||
function setCfg(cfg: AiCfg) {
|
||||
localStorage.setItem(LS_CFG, JSON.stringify({ ...DEFAULT_CFG, ...cfg }))
|
||||
}
|
||||
|
||||
/* ---------- 自定义主题:AI 配色建议应用后存储 ---------- */
|
||||
const CUSTOM_THEME_KEY = 'u-ppt.themes.v1'
|
||||
|
||||
/** 内置 + 自定义主题全量返回(自定义优先在前) */
|
||||
function getAllThemes(): Record<string, import('./types').Theme> {
|
||||
let custom: Record<string, import('./types').Theme> = {}
|
||||
try { custom = JSON.parse(localStorage.getItem(CUSTOM_THEME_KEY) || '{}') || {} } catch (e) {}
|
||||
return { ...custom, ...themes }
|
||||
}
|
||||
|
||||
/** 获取自定义主题列表(仅自定义的) */
|
||||
function getCustomThemes(): Record<string, import('./types').Theme> {
|
||||
try { return JSON.parse(localStorage.getItem(CUSTOM_THEME_KEY) || '{}') || {} } catch (e) { return {} }
|
||||
}
|
||||
|
||||
/** 保存一个自定义主题(key 形式 'custom-<ts>-<rand>') */
|
||||
function saveCustomTheme(t: import('./types').Theme): string {
|
||||
const key = 'custom-' + Date.now() + '-' + Math.random().toString(36).slice(2, 6)
|
||||
const all = getCustomThemes()
|
||||
all[key] = t
|
||||
try { localStorage.setItem(CUSTOM_THEME_KEY, JSON.stringify(all)) } catch (e) {}
|
||||
return key
|
||||
}
|
||||
|
||||
/** 应用一个主题(内置 key 或自定义对象)到当前 deck */
|
||||
function applyTheme(nameOrObj: string | import('./types').Theme) {
|
||||
pushHistory()
|
||||
if (typeof nameOrObj === 'string') {
|
||||
state.deck.theme = nameOrObj
|
||||
} else {
|
||||
const key = saveCustomTheme(nameOrObj)
|
||||
state.deck.theme = key
|
||||
}
|
||||
scheduleSave()
|
||||
}
|
||||
|
||||
/* ---------- 页面模板(版式复用) ---------- */
|
||||
const TEMPLATE_KEY = 'u-ppt.templates.v1'
|
||||
|
||||
/** 获取全部模板(内置 + 用户) */
|
||||
function getTemplates(): PageTemplate[] {
|
||||
let user: PageTemplate[] = []
|
||||
try { user = JSON.parse(localStorage.getItem(TEMPLATE_KEY) || '[]') || [] } catch (e) {}
|
||||
return [...BUILTIN_TEMPLATES, ...user]
|
||||
}
|
||||
|
||||
/** 仅用户模板 */
|
||||
function getUserTemplates(): PageTemplate[] {
|
||||
try { return JSON.parse(localStorage.getItem(TEMPLATE_KEY) || '[]') || [] } catch (e) { return [] }
|
||||
}
|
||||
|
||||
function setTemplates(arr: PageTemplate[]) {
|
||||
// 只存用户模板,不存内置
|
||||
const user = arr.filter(t => t.category === 'user')
|
||||
try { localStorage.setItem(TEMPLATE_KEY, JSON.stringify(user)) } catch (e) {}
|
||||
}
|
||||
|
||||
/** 存当前页为模板(仅复制元素结构,不复制具体 id) */
|
||||
function saveCurrentAsTemplate(name: string): PageTemplate {
|
||||
const slide = currentSlide.value
|
||||
const tpl: PageTemplate = {
|
||||
id: 'tpl-' + Date.now() + '-' + Math.random().toString(36).slice(2, 6),
|
||||
name,
|
||||
category: 'user',
|
||||
background: slide.background,
|
||||
elements: clone(slide.elements).map(e => ({ ...e, id: uid('el') })),
|
||||
createdAt: Date.now()
|
||||
}
|
||||
const user = getUserTemplates()
|
||||
user.unshift(tpl)
|
||||
setTemplates(user)
|
||||
return tpl
|
||||
}
|
||||
|
||||
/** 从模板新建一页(插入到当前页之后) */
|
||||
function addSlideFromTemplate(tplId: string): boolean {
|
||||
const tpl = getTemplates().find(t => t.id === tplId)
|
||||
if (!tpl) return false
|
||||
pushHistory()
|
||||
const newSlide: Slide = {
|
||||
id: uid('s'),
|
||||
background: tpl.background,
|
||||
elements: clone(tpl.elements).map(e => ({ ...e, id: uid('el') }))
|
||||
}
|
||||
const idx = state.currentIndex + 1
|
||||
state.deck.slides.splice(idx, 0, newSlide)
|
||||
state.currentIndex = idx
|
||||
state.selectedId = null
|
||||
scheduleSave()
|
||||
return true
|
||||
}
|
||||
|
||||
/** 删除用户模板 */
|
||||
function deleteTemplate(tplId: string) {
|
||||
const user = getUserTemplates().filter(t => t.id !== tplId)
|
||||
setTemplates(user)
|
||||
}
|
||||
|
||||
/* ---------- 演示文库:多套 PPT 命名存储 ---------- */
|
||||
function getLibrary(): LibItem[] {
|
||||
try { return JSON.parse(localStorage.getItem(LIB_KEY) || '[]') || [] } catch (e) { return [] }
|
||||
}
|
||||
function setLibrary(arr: LibItem[]) {
|
||||
try { localStorage.setItem(LIB_KEY, JSON.stringify(arr)) } catch (e) {}
|
||||
}
|
||||
function getActiveLibId() { return state.activeLibId }
|
||||
function setActive(id: string | null) {
|
||||
state.activeLibId = id
|
||||
try { id ? localStorage.setItem(ACTIVE_KEY, id) : localStorage.removeItem(ACTIVE_KEY) } catch (e) {}
|
||||
}
|
||||
|
||||
/** name 非空:按名字找同名覆盖,否则新建;name 空:覆盖当前 active(无 active 则新建"未命名") */
|
||||
function saveToLibrary(name?: string | null): string {
|
||||
const lib = getLibrary()
|
||||
const now = Date.now()
|
||||
const deckCopy = clone(state.deck)
|
||||
let item: LibItem | undefined
|
||||
if (name) item = lib.find(x => x.name === name)
|
||||
else if (state.activeLibId) item = lib.find(x => x.id === state.activeLibId)
|
||||
if (item) { item.deck = deckCopy; if (name) item.name = name; item.updatedAt = now }
|
||||
else {
|
||||
const id = 'lib-' + now + '-' + Math.random().toString(36).slice(2, 6)
|
||||
lib.unshift({ id, name: name || '未命名演示', deck: deckCopy, createdAt: now, updatedAt: now })
|
||||
item = lib[0]
|
||||
}
|
||||
setLibrary(lib)
|
||||
setActive(item.id)
|
||||
return item.id
|
||||
}
|
||||
|
||||
function loadFromLibrary(id: string): boolean {
|
||||
const item = getLibrary().find(x => x.id === id)
|
||||
if (!item) return false
|
||||
replaceDeck(clone(item.deck))
|
||||
setActive(id)
|
||||
return true
|
||||
}
|
||||
|
||||
function renameInLibrary(id: string, name: string) {
|
||||
if (!name) return
|
||||
const lib = getLibrary()
|
||||
const item = lib.find(x => x.id === id)
|
||||
if (!item) return
|
||||
item.name = name; item.updatedAt = Date.now()
|
||||
setLibrary(lib)
|
||||
}
|
||||
|
||||
function deleteFromLibrary(id: string) {
|
||||
setLibrary(getLibrary().filter(x => x.id !== id))
|
||||
if (state.activeLibId === id) setActive(null)
|
||||
}
|
||||
|
||||
function duplicateInLibrary(id: string): string | null {
|
||||
const item = getLibrary().find(x => x.id === id)
|
||||
if (!item) return null
|
||||
const now = Date.now()
|
||||
const copy: LibItem = {
|
||||
id: 'lib-' + now + '-' + Math.random().toString(36).slice(2, 6),
|
||||
name: item.name + ' 副本', deck: clone(item.deck), createdAt: now, updatedAt: now
|
||||
}
|
||||
const lib = getLibrary(); lib.unshift(copy); setLibrary(lib)
|
||||
return copy.id
|
||||
}
|
||||
|
||||
function newBlankDeck() {
|
||||
replaceDeck({ v: DECK_VERSION, theme: state.deck.theme as ThemeKey, slides: [{ id: uid('s'), background: 'bg', elements: [] }] }, { newChat: true })
|
||||
setActive(null)
|
||||
}
|
||||
|
||||
/* ---------- 会话管理(按 chatId 隔离) ---------- */
|
||||
import type { ChatMessage } from './types'
|
||||
|
||||
function newChatId(): string {
|
||||
return 'chat-' + Date.now() + '-' + Math.random().toString(36).slice(2, 8)
|
||||
}
|
||||
|
||||
/** 当前 deck 的 chatId(没有则生成一个并存入 deck) */
|
||||
function getChatId(): string {
|
||||
if (!state.deck.chatId) {
|
||||
state.deck.chatId = newChatId()
|
||||
scheduleSave()
|
||||
}
|
||||
return state.deck.chatId
|
||||
}
|
||||
|
||||
/** 读取某个 chatId 的会话 */
|
||||
function getChat(chatId: string): ChatMessage[] {
|
||||
try { return JSON.parse(localStorage.getItem(CHAT_PREFIX + chatId) || '[]') || [] } catch (e) { return [] }
|
||||
}
|
||||
|
||||
/** 写入某个 chatId 的会话 */
|
||||
function setChat(chatId: string, msgs: ChatMessage[]) {
|
||||
try { localStorage.setItem(CHAT_PREFIX + chatId, JSON.stringify(msgs)) } catch (e) {}
|
||||
}
|
||||
|
||||
/** 清除某个 chatId 的会话 */
|
||||
function clearChat(chatId: string) {
|
||||
try { localStorage.removeItem(CHAT_PREFIX + chatId) } catch (e) {}
|
||||
}
|
||||
|
||||
/* ---------- 导出 ---------- */
|
||||
export const store = {
|
||||
// 状态(响应式)
|
||||
state,
|
||||
// computed
|
||||
deck, slides, count, currentIndex, theme, selectedId, activeLibId,
|
||||
currentSlide, canUndo, canRedo,
|
||||
// 初始化
|
||||
init,
|
||||
// 读
|
||||
getDeck, getSlides, getCount, getCurrentIndex, getTheme,
|
||||
getSelectedId, getSelected,
|
||||
// 选择/翻页
|
||||
setCurrentIndex, selectElement,
|
||||
// 主题
|
||||
setTheme,
|
||||
// 幻灯片 CRUD
|
||||
addSlide, dupSlide, delSlide, moveSlide, setSlideBackground,
|
||||
// 元素 CRUD
|
||||
addElement, updateElement, delElement, moveElementZ, findElement,
|
||||
// undo/redo
|
||||
undo, redo,
|
||||
// 整体替换
|
||||
replaceDeck, replaceSlide, appendSlide, insertSlideAt,
|
||||
reset, exportJSON, importJSON,
|
||||
// AI 配置
|
||||
getCfg, setCfg,
|
||||
// 文库
|
||||
getLibrary, getActiveLibId, saveToLibrary, loadFromLibrary,
|
||||
renameInLibrary, deleteFromLibrary, duplicateInLibrary, newBlankDeck,
|
||||
// 自定义主题
|
||||
getAllThemes, getCustomThemes, saveCustomTheme, applyTheme,
|
||||
// 页面模板
|
||||
getTemplates, getUserTemplates, saveCurrentAsTemplate, addSlideFromTemplate, deleteTemplate,
|
||||
// 会话管理
|
||||
getChatId, getChat, setChat, clearChat, newChatId,
|
||||
}
|
||||
Reference in New Issue
Block a user