第07章 状态管理与数据持久化

7.1 状态管理概述

map-360-demo 不使用 Pinia,而是采用 组合式函数(Composables)+ 模块级单例 的方式管理全局状态。这种方案轻量、简洁,非常适合中小型项目。

项目包含三个 composable:

文件 职责
useAppState.ts 全局响应式状态 + localStorage 持久化 + 导入导出
useAddMarkerFlow.ts 添加/编辑标记状态机
useToast.ts Toast 单例状态

7.2 useAppState:全局状态

7.2.1 模块级单例

// useAppState.ts:59-62
const scenes = ref<Scene[]>(SCENES)
const currentSceneId = ref(SCENES[0].id)
const markers = ref<MarkerData[]>(loadMarkers())

scenescurrentSceneIdmarkers 都是模块顶层 ref,多处调用 useAppState() 共享同一份数据。这是”模块级单例”模式的关键。

7.2.2 localStorage 持久化

// useAppState.ts:5, 74-80
const STORAGE_KEY = 'map-360-demo:markers:v1'

watch(markers, (val) => {
  try {
    localStorage.setItem(STORAGE_KEY, JSON.stringify(val))
  } catch {
    /* 超出配额等异常时静默忽略 */
  }
}, { deep: true })

关键设计

  • 模块级 watch 与应用同生命周期,不随组件卸载停止
  • deep: true 深度监听,标记的任何变化都会触发持久化
  • 首次不写盘(默认数据仅在首次变更后落盘)
  • 异常静默忽略(如超出配额)

7.2.3 数据校验

// useAppState.ts:7-26
function isValidMarker(m: unknown): m is MarkerData {
  // 逐字段校验 id/sceneId/name/description/createdAt
  // /position.yaw/position.pitch/coordinates 数组/type/targetSceneId
}

isValidMarker 是类型守卫,逐字段校验标记数据的合法性。

7.2.4 数据加载

// useAppState.ts:29-42
function loadMarkers(): MarkerData[] {
  const raw = localStorage.getItem(STORAGE_KEY)
  if (!raw) return deepClone(DEFAULT_MARKERS)
  try {
    const parsed = JSON.parse(raw)
    if (Array.isArray(parsed)) {
      return parsed.filter(isValidMarker)
    }
  } catch { /* 解析失败回退默认 */ }
  return deepClone(DEFAULT_MARKERS)
}

从 localStorage 读取并 filter(isValidMarker),失败或非法回退到 DEFAULT_MARKERS 深拷贝。

7.2.5 id 生成

// useAppState.ts:45-57, 83-85
function nextIdFrom(markers: MarkerData[]): number {
  // 扫描现有标记 id(m 前缀 + 数字)取最大值 +1 作为自增起点
}

function generateId(): string {
  return 'm' + idCounter++
}

nextIdFrom 扫描现有标记 id 取最大值 +1 作为自增起点,避免刷新后冲突。

7.2.6 computed 状态

// useAppState.ts:64-70
const currentScene = computed(() => {
  return scenes.value.find(s => s.id === currentSceneId.value) ?? scenes.value[0]
})

const currentMarkers = computed(() => {
  return markers.value.filter(m => m.sceneId === currentSceneId.value)
})
  • currentScene:按 id 查找当前场景(找不到回退第一个)
  • currentMarkers:过滤当前场景的标记

7.2.7 CRUD 操作

// useAppState.ts:87-116
function switchScene(id: string) { /* 校验存在后切换 */ }
function addMarker(payload: MarkerPayload) { /* 补 id + createdAt */ }
function updateMarker(id: string, patch: Partial<MarkerData>) { /* 按 id 原位替换 */ }
function removeMarker(id: string) { /* splice 移除 */ }

7.2.8 恢复默认

// useAppState.ts:119-128
function resetMarkers() {
  markers.value = deepClone(DEFAULT_MARKERS)
}

深拷贝 DEFAULT_MARKERS(嵌套 position/coordinates 也拷贝),防止污染常量,watch 自动持久化。

7.2.9 导出

// useAppState.ts:131-133
function exportMarkers(): string {
  return JSON.stringify(markers.value, null, 2)  // 美化输出
}

7.2.10 导入合并

// useAppState.ts:139-170
function importMarkers(json: string): { ok: boolean, message: string } {
  // 解析 → 校验数组 → filter(isValidMarker)
  // → 丢弃场景不存在的标记、link 标记目标场景不存在的也丢弃
  // → 重新分配 id 避免冲突 → push(...imported) 合并
  return { ok, message }
}

导入合并流程:

  1. 解析 JSON
  2. 校验是否为数组
  3. 过滤非法标记
  4. 丢弃场景不存在的标记
  5. 丢弃 link 标记目标场景不存在的
  6. 重新分配 id 避免冲突
  7. 合并到现有标记

7.3 useAddMarkerFlow:添加/编辑状态机

7.3.1 状态

// useAddMarkerFlow.ts:24-30
const adding = ref(false)          // 是否在添加模式
const showModal = ref(false)       // 是否显示弹窗
const editingId = ref('')          // 编辑中的标记 id(空=新增)
const pendingPosition = ref<{ yaw: number, pitch: number } | null>(null)
const pendingCoords = ref<[number, number] | null>(null)

7.3.2 派生状态

// useAddMarkerFlow.ts:33-42
const modalCoords = computed(() => {
  // 优先选点坐标 → 其次编辑中标记原坐标 → 兜底场景中心
})

const editingMarker = computed(() => {
  return currentMarkers.value.find(m => m.id === editingId.value)
})

7.3.3 预览 pin

// useAddMarkerFlow.ts:48-69
const previewMarker = computed(() => {
  // 仅当有 pendingPosition 和 pendingCoords 时返回
  // 编辑模式下若位置与原有标记完全相同则不显示预览(避免与蓝 pin 重叠)
  return { id: PREVIEW_ID, ... }
})

7.3.4 关键方法

方法 职责
startAddMarker 进入添加模式,清空编辑状态
openEdit 进入编辑模式,预填位置和坐标
exitAdding 完全退出并清理预览
onClickEmpty 全景点击 → 估算经纬度 → 打开弹窗
onMapPick 地图点击 → 反推 yaw → 打开弹窗
onModalCancel 只关弹窗,保留预览和添加模式
onModalCoordsChange 滑块改坐标时实时同步预览
onModalConfirm 确认后新增或更新

7.3.5 坐标同步逆式

// useAddMarkerFlow.ts:126-132
function onModalCoordsChange(coords: [number, number]) {
  // 滑块沿固定方位角移动方向不变故 yaw 无需重算
  // 但距离变化需按 dist = 450 + pitch*800 逆式回推 pitch
  // 钳制到 [120, 1100],否则预览 pin 纵向位置不随滑块移动
}

这是滑块移动时保持预览同步的关键逻辑。

7.4 useToast:Toast 单例

7.4.1 模块级单例

// useToast.ts:10-13
const toasts = ref<Toast[]>([])
let toastSeq = 0
const timers = new Map<number, ReturnType<typeof setTimeout>>()

7.4.2 show

// useToast.ts:16-23
function show(message: string, type: 'success' | 'error' | 'info' = 'info', duration = 3000) {
  const id = ++toastSeq
  toasts.value.push({ id, message, type })
  if (duration > 0) {
    const timer = setTimeout(() => dismiss(id), duration)
    timers.set(id, timer)
  }
}

7.4.3 dismiss

// useToast.ts:25-35
function dismiss(id: number) {
  const timer = timers.get(id)
  if (timer) {
    clearTimeout(timer)  // 定时器清理
    timers.delete(id)
  }
  toasts.value = toasts.value.filter(t => t.id !== id)
}

关键设计:手动点击 dismiss 时先 clearTimeout 并删除 timer,能正确取消自动关闭定时器。

7.5 状态管理总结

特性 实现方式
全局状态 模块级单例 ref
持久化 watch(deep) + localStorage
数据校验 isValidMarker 类型守卫
id 生成 自增 + 扫描去重
状态机 useAddMarkerFlow
反馈 useToast 单例

设计优势

  1. 轻量:无需引入 Pinia,减少依赖
  2. 复用:组合式函数天然支持逻辑复用
  3. 清晰:状态、状态机、反馈分离,职责单一

7.6 下一步

理解了状态管理后,进入 第08章 坐标换算算法详解 了解全景与地图坐标的换算原理。