重构: 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 历史
This commit is contained in:
2026-07-12 14:59:05 +08:00
parent 9933a1711a
commit 2f9c545503
2 changed files with 357 additions and 100 deletions

View File

@@ -3,8 +3,9 @@
* 由 store.js 迁移:用 Vue reactive 替代手写 pub/sub组件自动追踪
* ===================================================================== */
import { reactive, computed } from 'vue'
import type { AiCfg, Deck, LibItem, PageTemplate, Slide, SlideElement, ThemeKey } from './types'
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'
@@ -70,15 +71,17 @@ const BUILTIN_TEMPLATES: PageTemplate[] = [
}
]
/* ---------- 历史快照 ---------- */
interface Snapshot { deck: Deck; index: number; sel: string | null }
/* ---------- Op 历史栈undo/redo 基于 Op 回滚) ---------- */
const HIST_MAX = 40
let hist: Snapshot[] = []
let future: Snapshot[] = []
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
@@ -173,20 +176,40 @@ export function resolveBg(key: string): string {
return t[key] || '#ffffff'
}
/* ---------- 历史快照 ---------- */
function pushHistory() {
if (suppressHistory) return
hist.push({ deck: clone(state.deck), index: state.currentIndex, sel: state.selectedId })
/* ---------- 内部:执行 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
}
/** 连续「同一元素同一字段」的操作合并为一条历史(如拖字号滑块、连续敲字) */
function touchHistory(key: string) {
if (suppressHistory) return
if (touchKey !== key) { pushHistory(); touchKey = key }
if (touchTimer) clearTimeout(touchTimer)
touchTimer = setTimeout(() => { touchKey = null }, 900)
// coalesce 逻辑
if (opts?.coalesceKey) {
touchKey = opts.coalesceKey
if (touchTimer) clearTimeout(touchTimer)
touchTimer = setTimeout(() => { touchKey = null }, 900)
}
scheduleSave()
}
/* ---------- 数据版本迁移(旧版数据补字段升级,不丢弃) ---------- */
@@ -269,101 +292,73 @@ 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()
execOp({ type: 'set_theme', theme: name, clientId: CLIENT_ID, timestamp: Date.now() })
}
/* ---------- 幻灯片 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)
execOp({ type: 'add_slide', atIndex: idx, slide: s, clientId: CLIENT_ID, timestamp: Date.now() })
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++
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
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)
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
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)
if (to >= state.deck.slides.length) return
execOp({ type: 'move_slide', from, to, clientId: CLIENT_ID, timestamp: Date.now() })
state.currentIndex = to
scheduleSave()
}
function setSlideBackground(bg: string) {
pushHistory()
currentSlide.value.background = bg as Slide['background']
scheduleSave()
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]) {
pushHistory()
const el = createElement(type, over)
currentSlide.value.elements.push(el)
execOp({ type: 'add_element', slideIdx: state.currentIndex, element: el, clientId: CLIENT_ID, timestamp: Date.now() })
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()
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) {
pushHistory()
const s = currentSlide.value
s.elements = s.elements.filter(e => e.id !== id)
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
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()
execOp({ type: 'move_element_z', slideIdx: state.currentIndex, elementId: id, dir, clientId: CLIENT_ID, timestamp: Date.now() })
}
function findElement(id: string): SlideElement | null {
@@ -371,91 +366,83 @@ function findElement(id: string): SlideElement | null {
return s.elements.find(e => e.id === id) || null
}
/* ---------- undo / redo ---------- */
/* ---------- undo / redo(基于 Op 回滚) ---------- */
function undo(): boolean {
if (!hist.length) return false
future.push({ deck: clone(state.deck), index: state.currentIndex, sel: state.selectedId })
const prev = hist.pop()!
const entry = hist.pop()!
suppressHistory = true
state.deck = prev.deck
state.currentIndex = Math.min(prev.index, state.deck.slides.length - 1)
state.selectedId = prev.sel
state.deck = applyOp(state.deck, entry.backward)
suppressHistory = false
future.push(entry)
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()!
const entry = future.pop()!
suppressHistory = true
state.deck = nxt.deck
state.currentIndex = Math.min(nxt.index, state.deck.slides.length - 1)
state.selectedId = nxt.sel
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 }) {
if (!opts?.keepHistory) pushHistory()
// 自动备份newChat=true 时说明是生成整套/重置等“覆盖性操作”,把当前 deck 存入 .bak
if (opts?.newChat && state.deck?.slides?.length) {
try { localStorage.setItem(LS_KEY + '.bak', JSON.stringify(state.deck)) } catch (e) {}
}
suppressHistory = true
let d = newDeck as Deck
if (!d || typeof d !== 'object') d = {} as Deck
d.v = DECK_VERSION // 维护版本号
// 健壮性:损坏/空数据兜底为一页空白,避免下游 forEach 崩溃
d.v = DECK_VERSION
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
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
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
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
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
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
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)
execOp({ type: 'add_slide', atIndex: index, slide: newSlide, clientId: CLIENT_ID, timestamp: Date.now() })
state.currentIndex = index
state.selectedId = null
scheduleSave()
}
function reset() {
@@ -517,14 +504,12 @@ function saveCustomTheme(t: import('./types').Theme): string {
/** 应用一个主题(内置 key 或自定义对象)到当前 deck */
function applyTheme(nameOrObj: string | import('./types').Theme) {
pushHistory()
if (typeof nameOrObj === 'string') {
state.deck.theme = nameOrObj
execOp({ type: 'set_theme', theme: nameOrObj, clientId: CLIENT_ID, timestamp: Date.now() })
} else {
const key = saveCustomTheme(nameOrObj)
state.deck.theme = key
execOp({ type: 'set_theme', theme: key, clientId: CLIENT_ID, timestamp: Date.now() })
}
scheduleSave()
}
/* ---------- 页面模板(版式复用) ---------- */
@@ -569,20 +554,19 @@ function saveCurrentAsTemplate(name: string): PageTemplate {
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)
execOp({ type: 'add_slide', atIndex: idx, slide: newSlide, clientId: CLIENT_ID, timestamp: Date.now() })
state.currentIndex = idx
state.selectedId = null
scheduleSave()
return true
}
/* ---------- 删除用户模板 ---------- */
/** 删除用户模板 */
function deleteTemplate(tplId: string) {
const user = getUserTemplates().filter(t => t.id !== tplId)
@@ -661,7 +645,6 @@ function newBlankDeck() {
}
/* ---------- 会话管理(按 chatId 隔离) ---------- */
import type { ChatMessage } from './types'
function newChatId(): string {
return 'chat-' + Date.now() + '-' + Math.random().toString(36).slice(2, 8)