Files
u-ppt/src/core/store.ts
绝尘 2f9c545503 重构: Op Log 数据模型,为协同预留基础(P2)
- 新增 op.ts: Op 类型定义 + 纯函数 reducer(applyOp) + 逆操作计算(invertOp)
- 支持的 Op 类型: add_slide/del_slide/move_slide/replace_slide/set_slide_bg/
  add_element/update_element/del_element/move_element_z/set_theme/replace_deck
- undo/redo 从快照栈改为 Op 回滚(forward/backward 双向 Op)
- store 所有写操作内部改为 execOp,对外接口完全不变(88 处调用零改动)
- update_element 保留连续操作合并(拖滑块/连续输入不膨胀历史)
- 每个 Op 带 clientId + timestamp,为未来协同预留
- replace_deck 作为特殊 Op 进 undo 历史
2026-07-12 14:59:05 +08:00

729 lines
28 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* =====================================================================
* 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<typeof setTimeout> | 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<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'
}
/* ---------- 内部:执行 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<typeof createElement>[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<SlideElement> & { style?: Record<string, unknown> }) {
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<string, unknown>, 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<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) {
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,
}