重构: 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

274
src/core/op.ts Normal file
View File

@@ -0,0 +1,274 @@
/* =====================================================================
* op.ts — 操作日志Op Log数据模型
*
* 所有对 deck 的变更都表达为 Op通过纯函数 reducer 应用。
* Op 是可序列化、可回放、可逆的——为未来协同预留基础。
*
* 设计:
* - 元素级粒度update_element 带字段级 patch
* - replace_deck 作为特殊 Op进 undo 历史
* - 每个 Op 可计算逆 Op用于 undo
* - reducer 是纯函数:(deck, op) → newDeck
* ===================================================================== */
import type { Deck, Slide, SlideElement, ElementStyle, BgKey } from './types'
/* ---------- Op 类型定义 ---------- */
export type OpType =
| 'add_slide'
| 'del_slide'
| 'move_slide'
| 'replace_slide'
| 'set_slide_bg'
| 'add_element'
| 'update_element'
| 'del_element'
| 'move_element_z'
| 'set_theme'
| 'replace_deck'
export interface Op {
type: OpType
// 通用元数据(为协同预留)
clientId?: string
timestamp?: number
// 各类型的 payload按 type 不同,只填对应字段)
[key: string]: unknown
}
/** 带逆操作的历史条目 */
export interface HistoryEntry {
forward: Op
backward: Op
}
/* ---------- 工具 ---------- */
function clone<T>(o: T): T { return JSON.parse(JSON.stringify(o)) }
/** 在 deck 的某页里找元素 */
function findEl(deck: Deck, slideIdx: number, elId: string): SlideElement | null {
const s = deck.slides[slideIdx]
if (!s) return null
return s.elements.find(e => e.id === elId) || null
}
/* ============================================================
* 纯函数 reducerapplyOp(deck, op) → newDeck
* 不 mutate 输入,返回新 deck
* ============================================================ */
export function applyOp(inputDeck: Deck, op: Op): Deck {
const deck = clone(inputDeck) // 深拷贝,不 mutate 原数据
switch (op.type) {
case 'add_slide': {
const atIndex = op.atIndex as number | undefined
const slide = op.slide as Slide
const idx = atIndex != null ? atIndex : deck.slides.length
deck.slides.splice(idx, 0, slide)
break
}
case 'del_slide': {
const index = op.index as number
if (deck.slides.length <= 1) break
deck.slides.splice(index, 1)
break
}
case 'move_slide': {
const from = op.from as number
const to = op.to as number
if (from === to || from < 0 || to < 0 || to >= deck.slides.length) break
const item = deck.slides.splice(from, 1)[0]
deck.slides.splice(to, 0, item)
break
}
case 'replace_slide': {
const index = op.index as number
const slide = op.slide as Slide
if (deck.slides[index]) {
slide.id = deck.slides[index].id // 保持 id 不变
}
deck.slides[index] = slide
break
}
case 'set_slide_bg': {
const index = op.index as number
const bg = op.bg as BgKey
if (deck.slides[index]) {
deck.slides[index].background = bg
}
break
}
case 'add_element': {
const slideIdx = op.slideIdx as number
const element = op.element as SlideElement
const s = deck.slides[slideIdx]
if (s) s.elements.push(element)
break
}
case 'update_element': {
const slideIdx = op.slideIdx as number
const elementId = op.elementId as string
const patch = op.patch as Partial<SlideElement> & { style?: Record<string, unknown> }
const el = findEl(deck, slideIdx, elementId)
if (!el) break
if (patch.style) { Object.assign(el.style, patch.style) }
const rest = { ...patch }; delete (rest as Record<string, unknown>).style
Object.assign(el, rest)
break
}
case 'del_element': {
const slideIdx = op.slideIdx as number
const elementId = op.elementId as string
const s = deck.slides[slideIdx]
if (s) s.elements = s.elements.filter(e => e.id !== elementId)
break
}
case 'move_element_z': {
const slideIdx = op.slideIdx as number
const elementId = op.elementId as string
const dir = op.dir as number
const s = deck.slides[slideIdx]
if (!s) break
const i = s.elements.findIndex(e => e.id === elementId)
if (i < 0) break
const j = dir > 0 ? i + 1 : i - 1
if (j < 0 || j >= s.elements.length) break
const tmp = s.elements[i]; s.elements[i] = s.elements[j]; s.elements[j] = tmp
break
}
case 'set_theme': {
const theme = op.theme as string
deck.theme = theme
break
}
case 'replace_deck': {
const newDeck = op.deck as Deck
// 全量替换,但保留版本号
newDeck.v = deck.v
return newDeck
}
default:
// 未知 Op 类型,不修改
break
}
return deck
}
/* ============================================================
* 逆操作计算invertOp(deck, op) → Op
* 根据当前 deck 状态,计算能撤销 op 的逆 Op
* ============================================================ */
export function invertOp(inputDeck: Deck, op: Op): Op {
const deck = inputDeck // 只读,不 clone
switch (op.type) {
case 'add_slide': {
// 逆:删除加的那页
const slide = op.slide as Slide
const idx = deck.slides.findIndex(s => s.id === slide.id)
return { type: 'del_slide', index: idx >= 0 ? idx : (op.atIndex as number || deck.slides.length - 1) }
}
case 'del_slide': {
// 逆:把删掉的页插回原位
const index = op.index as number
const deletedSlide = op.deletedSlide as Slide | undefined
if (deletedSlide) {
return { type: 'add_slide', atIndex: index, slide: deletedSlide }
}
// 没有 deletedSlide 信息(兼容),返回 no-op
return { type: 'replace_deck', deck: clone(deck) }
}
case 'move_slide': {
// 逆from/to 反过来
return { type: 'move_slide', from: op.to as number, to: op.from as number }
}
case 'replace_slide': {
// 逆:换回原来的 slide
const index = op.index as number
const oldSlide = deck.slides[index] ? clone(deck.slides[index]) : undefined
if (oldSlide) {
return { type: 'replace_slide', index, slide: (op.oldSlide as Slide) || oldSlide }
}
return { type: 'replace_deck', deck: clone(deck) }
}
case 'set_slide_bg': {
// 逆:换回原来的背景
const index = op.index as number
const oldBg = deck.slides[index]?.background || 'bg'
return { type: 'set_slide_bg', index, bg: oldBg }
}
case 'add_element': {
// 逆:删除加的元素
return { type: 'del_element', slideIdx: op.slideIdx, elementId: (op.element as SlideElement).id }
}
case 'update_element': {
// 逆:把被改的字段恢复原值
const slideIdx = op.slideIdx as number
const elementId = op.elementId as string
const patch = op.patch as Record<string, unknown>
const el = findEl(deck, slideIdx, elementId)
if (!el) return { type: 'replace_deck', deck: clone(deck) }
// 构造反向 patch取当前值
const backwardPatch: Record<string, unknown> = {}
if (patch.style) {
const oldStyle: Record<string, unknown> = {}
for (const k of Object.keys(patch.style as object)) {
oldStyle[k] = (el.style as Record<string, unknown>)[k]
}
backwardPatch.style = oldStyle
}
for (const k of Object.keys(patch)) {
if (k === 'style') continue
backwardPatch[k] = (el as unknown as Record<string, unknown>)[k]
}
return { type: 'update_element', slideIdx, elementId, patch: backwardPatch }
}
case 'del_element': {
// 逆:把删掉的元素加回去
const slideIdx = op.slideIdx as number
const deletedElement = op.deletedElement as SlideElement | undefined
if (deletedElement) {
return { type: 'add_element', slideIdx, element: deletedElement }
}
return { type: 'replace_deck', deck: clone(deck) }
}
case 'move_element_z': {
// 逆:方向反过来
return { type: 'move_element_z', slideIdx: op.slideIdx, elementId: op.elementId, dir: -(op.dir as number) }
}
case 'set_theme': {
// 逆:换回原来的主题
return { type: 'set_theme', theme: deck.theme }
}
case 'replace_deck': {
// 逆:换回整个旧 deck
return { type: 'replace_deck', deck: clone(deck) }
}
default:
return { type: 'replace_deck', deck: clone(deck) }
}
}

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 })
if (hist.length > HIST_MAX) hist.shift()
future.length = 0
}
/* ---------- 内部:执行 Op 并推入历史 ---------- */
/** 连续「同一元素同一字段」的操作合并为一条历史(如拖字号滑块、连续敲字) */
function touchHistory(key: string) {
if (suppressHistory) return
if (touchKey !== key) { pushHistory(); touchKey = key }
/** 执行一个 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()
}
/* ---------- 数据版本迁移(旧版数据补字段升级,不丢弃) ---------- */
@@ -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()
}
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)