- 新增 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 历史
729 lines
28 KiB
TypeScript
729 lines
28 KiB
TypeScript
/* =====================================================================
|
||
* 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,
|
||
}
|