/* ===================================================================== * 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, ChatMessage } from './types' import { DECK_VERSION, SAMPLE_DECK, createElement, themes, uid } from './sample' import { applyOp, invertOp, type Op, type HistoryEntry } from './op' /* ---------- 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' } } ] } ] /* ---------- Op 历史栈(undo/redo 基于 Op 回滚) ---------- */ const HIST_MAX = 40 let hist: HistoryEntry[] = [] // 过去的操作 let future: HistoryEntry[] = [] // redo 栈 let suppressHistory = false let touchKey: string | null = null let touchTimer: ReturnType | null = null /** 当前端的 client ID(为协同预留) */ const CLIENT_ID = 'client-' + Math.random().toString(36).slice(2, 10) /* ---------- 响应式状态(单一数据源) ---------- */ 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 | null = null /* ---------- 工具 ---------- */ function clone(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 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 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' } /* ---------- 内部:执行 Op 并推入历史 ---------- */ /** 执行一个 Op,计算逆 Op,推入历史栈。返回是否执行成功 */ function execOp(op: Op, opts?: { coalesceKey?: string }): void { if (suppressHistory) { state.deck = applyOp(state.deck, op) scheduleSave() return } // 连续同类操作合并(如拖字号滑块) if (opts?.coalesceKey && touchKey === opts.coalesceKey && hist.length > 0) { // 合并:不推新历史,只执行 forward(已有的 backward 保持不变) state.deck = applyOp(state.deck, op) scheduleSave() if (touchTimer) clearTimeout(touchTimer) touchTimer = setTimeout(() => { touchKey = null }, 900) return } // 计算逆 Op(在执行前) const backward = invertOp(state.deck, op) // 执行正向 state.deck = applyOp(state.deck, op) // 推入历史 const entry: HistoryEntry = { forward: op, backward } hist.push(entry) if (hist.length > HIST_MAX) hist.shift() future.length = 0 // coalesce 逻辑 if (opts?.coalesceKey) { touchKey = opts.coalesceKey if (touchTimer) clearTimeout(touchTimer) touchTimer = setTimeout(() => { touchKey = null }, 900) } scheduleSave() } /* ---------- 数据版本迁移(旧版数据补字段升级,不丢弃) ---------- */ function migrateDeck(d: any): Deck { // v2→v3: 给元素补默认 chartType / 新元素类型无需处理 if (!d.v || d.v < 3) { // 确保每个元素有 style 对象 (d.slides || []).forEach((s: any) => { (s.elements || []).forEach((e: any) => { if (!e.style) e.style = {}; if (e.type === 'chart' && !e.style.chartType) e.style.chartType = 'bar'; }); }); } d.v = DECK_VERSION; return d as Deck; } /* ---------- 初始化 ---------- */ function init() { let loaded: Deck | null = null try { loaded = JSON.parse(localStorage.getItem(LS_KEY) || 'null') } catch (e) { /* ignore */ } const curV = SAMPLE_DECK.v let deck: Deck if (loaded && loaded.slides && loaded.slides.length) { if (loaded.v !== curV) { // 版本不匹配 → 尝试迁移 try { localStorage.setItem(LS_KEY + '.bak', JSON.stringify(loaded)) } catch (e) {} deck = migrateDeck(loaded) } else { deck = loaded } } else { deck = clone(SAMPLE_DECK) } Object.assign(state, { 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 execOp({ type: 'set_theme', theme: name, clientId: CLIENT_ID, timestamp: Date.now() }) } /* ---------- 幻灯片 CRUD ---------- */ function addSlide(atIndex?: number) { const s: Slide = { id: uid('s'), background: 'bg', elements: [] } const idx = atIndex != null ? atIndex + 1 : state.deck.slides.length execOp({ type: 'add_slide', atIndex: idx, slide: s, clientId: CLIENT_ID, timestamp: Date.now() }) state.currentIndex = idx state.selectedId = null return s } function dupSlide() { const cur = currentSlide.value; if (!cur) return const copy = clone(cur); copy.id = uid('s') copy.elements.forEach(e => { e.id = uid('el') }) const idx = state.currentIndex + 1 execOp({ type: 'add_slide', atIndex: idx, slide: copy, clientId: CLIENT_ID, timestamp: Date.now() }) state.currentIndex = idx state.selectedId = null } function delSlide(i?: number): boolean { if (state.deck.slides.length <= 1) return false const idx = i != null ? i : state.currentIndex const deletedSlide = clone(state.deck.slides[idx]) execOp({ type: 'del_slide', index: idx, deletedSlide, clientId: CLIENT_ID, timestamp: Date.now() }) if (state.currentIndex >= state.deck.slides.length) state.currentIndex = state.deck.slides.length - 1 state.selectedId = null return true } function moveSlide(from: number, to: number) { if (from === to || from < 0 || to < 0) return if (to >= state.deck.slides.length) return execOp({ type: 'move_slide', from, to, clientId: CLIENT_ID, timestamp: Date.now() }) state.currentIndex = to } function setSlideBackground(bg: string) { execOp({ type: 'set_slide_bg', index: state.currentIndex, bg: bg as Slide['background'], clientId: CLIENT_ID, timestamp: Date.now() }) } /* ---------- 元素 CRUD ---------- */ function addElement(type: SlideElement['type'], over?: Parameters[1]) { const el = createElement(type, over) execOp({ type: 'add_element', slideIdx: state.currentIndex, element: el, clientId: CLIENT_ID, timestamp: Date.now() }) state.selectedId = el.id return el } function updateElement(id: string, patch: Partial & { style?: Record }) { const coalesceKey = id + ':' + (patch.style ? Object.keys(patch.style).sort().join(',') : Object.keys(patch).sort().join(',')) execOp({ type: 'update_element', slideIdx: state.currentIndex, elementId: id, patch, clientId: CLIENT_ID, timestamp: Date.now() }, { coalesceKey }) } function delElement(id: string) { const el = findElement(id) const deletedElement = el ? clone(el) : undefined execOp({ type: 'del_element', slideIdx: state.currentIndex, elementId: id, deletedElement, clientId: CLIENT_ID, timestamp: Date.now() }) if (state.selectedId === id) state.selectedId = null } function moveElementZ(id: string, dir: number) { execOp({ type: 'move_element_z', slideIdx: state.currentIndex, elementId: id, dir, clientId: CLIENT_ID, timestamp: Date.now() }) } function findElement(id: string): SlideElement | null { const s = currentSlide.value return s.elements.find(e => e.id === id) || null } /* ---------- undo / redo(基于 Op 回滚) ---------- */ function undo(): boolean { if (!hist.length) return false const entry = hist.pop()! suppressHistory = true state.deck = applyOp(state.deck, entry.backward) suppressHistory = false future.push(entry) scheduleSave() return true } function redo(): boolean { if (!future.length) return false const entry = future.pop()! suppressHistory = true state.deck = applyOp(state.deck, entry.forward) suppressHistory = false hist.push(entry) scheduleSave() return true } /* ---------- 整体替换(AI 生成 / 导入 / 重置用) ---------- */ function replaceDeck(newDeck: Deck | Record, opts?: { keepHistory?: boolean; newChat?: boolean }) { // 自动备份:newChat=true 时说明是生成整套/重置等“覆盖性操作”,把当前 deck 存入 .bak if (opts?.newChat && state.deck?.slides?.length) { try { localStorage.setItem(LS_KEY + '.bak', JSON.stringify(state.deck)) } catch (e) {} } let d = newDeck as Deck if (!d || typeof d !== 'object') d = {} as Deck d.v = DECK_VERSION if (!Array.isArray(d.slides) || !d.slides.length) { d.slides = [{ id: uid('s'), background: 'bg', elements: [] }] } if (opts?.newChat || !d.chatId) { d.chatId = newChatId() } if (opts?.keepHistory) { suppressHistory = true state.deck = d suppressHistory = false scheduleSave() } else { execOp({ type: 'replace_deck', deck: d, clientId: CLIENT_ID, timestamp: Date.now() }) } state.activeLibId = null state.currentIndex = Math.min(state.currentIndex, d.slides.length - 1) if (state.currentIndex < 0) state.currentIndex = 0 state.selectedId = null } function replaceSlide(index: number, newSlide: Slide) { newSlide.elements = newSlide.elements || [] newSlide.elements.forEach(e => { if (!e.id) e.id = uid('el') }) const oldSlide = state.deck.slides[index] ? clone(state.deck.slides[index]) : undefined execOp({ type: 'replace_slide', index, slide: newSlide, oldSlide, clientId: CLIENT_ID, timestamp: Date.now() }) state.selectedId = null } function appendSlide(newSlide: Slide) { newSlide.id = uid('s') newSlide.elements = newSlide.elements || [] newSlide.elements.forEach(e => { if (!e.id) e.id = uid('el') }) const idx = state.deck.slides.length execOp({ type: 'add_slide', atIndex: idx, slide: newSlide, clientId: CLIENT_ID, timestamp: Date.now() }) state.currentIndex = idx state.selectedId = null } function insertSlideAt(index: number, newSlide: Slide) { newSlide.id = uid('s') newSlide.elements = newSlide.elements || [] newSlide.elements.forEach(e => { if (!e.id) e.id = uid('el') }) execOp({ type: 'add_slide', atIndex: index, slide: newSlide, clientId: CLIENT_ID, timestamp: Date.now() }) state.currentIndex = index state.selectedId = null } 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 { let custom: Record = {} try { custom = JSON.parse(localStorage.getItem(CUSTOM_THEME_KEY) || '{}') || {} } catch (e) {} return { ...custom, ...themes } } /** 获取自定义主题列表(仅自定义的) */ function getCustomThemes(): Record { try { return JSON.parse(localStorage.getItem(CUSTOM_THEME_KEY) || '{}') || {} } catch (e) { return {} } } /** 保存一个自定义主题(key 形式 'custom--') */ 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) { if (typeof nameOrObj === 'string') { execOp({ type: 'set_theme', theme: nameOrObj, clientId: CLIENT_ID, timestamp: Date.now() }) } else { const key = saveCustomTheme(nameOrObj) execOp({ type: 'set_theme', theme: key, clientId: CLIENT_ID, timestamp: Date.now() }) } } /* ---------- 页面模板(版式复用) ---------- */ 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 const newSlide: Slide = { id: uid('s'), background: tpl.background, elements: clone(tpl.elements).map(e => ({ ...e, id: uid('el') })) } const idx = state.currentIndex + 1 execOp({ type: 'add_slide', atIndex: idx, slide: newSlide, clientId: CLIENT_ID, timestamp: Date.now() }) state.currentIndex = idx state.selectedId = null 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 隔离) ---------- */ 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) {} } /** 恢复上一次被覆盖的 deck(生成整套/重置前的自动备份) */ function recoverBackup(): boolean { try { const bak = localStorage.getItem(LS_KEY + '.bak') if (!bak) return false const d = JSON.parse(bak) if (!d || !d.slides || !d.slides.length) return false replaceDeck(d) localStorage.removeItem(LS_KEY + '.bak') return true } catch (e) { return false } } /* ---------- 导出 ---------- */ 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, // 备份恢复 recoverBackup, }