Browse Source

状态管理器

master
修宁 7 months ago
parent
commit
150e85cc7e
  1. 399
      src/designer/StateManager.ts
  2. 20
      src/designer/Viewport.ts
  3. 5
      src/runtime/System.ts
  4. 52
      src/types/Types.d.ts
  5. 67
      src/utils/webutils.ts

399
src/designer/StateManager.ts

@ -0,0 +1,399 @@
import _ from 'lodash'
import localforage from 'localforage'
import type Viewport from '@/designer/Viewport.ts'
import { markRaw, reactive, ref } from 'vue'
import type { VData, VDataItem } from '@/types/Types'
/**
* THREE.js .
* threejs
* 1. ,
* 2.
* 3.
* - 1. beginUserWrite
* - 2. vdata
* - 3. endUserWrite
* 4. syncDataState() vdata viewport
* 5. syncDataState vdata viewport
* -
* 6. isLoading = true
*
*
*
* // 初始化
* const stateManager = new StateManager('scene-1', viewport)
*
* // 加载大数据
* await stateManager.load(largeDataSet) // 5万+ items
*
* // 修改数据
* stateManager.beginUserWrite()
*
* // 直接修改状态(实际项目应通过封装方法)
* stateManager.vdata.items.push(newItem)
* stateManager.vdata.items[0].name = 'updated'
* stateManager.vdata.items = stateManager.vdata.items.filter(i => i.id !== 'remove-id')
*
* stateManager.endUserWrite() // 自动计算差异并保存
*
* // 撤销操作
* stateManager.undo()
*
* // 保存到云端
* const data = await stateManager.save()
*
*/
export default class StateManager {
/**
*
*/
isChanged = ref(false)
/**
*
*/
isLoading = ref(false)
/**
*
*/
vdata: VData = { items: [], isChanged: false }
/**
* , key
*/
id: string
/**
* ,
*/
viewport: Viewport
/**
* 使
*/
private historySteps: HistoryStep[] = []
private historyIndex = -1
private readonly maxHistorySteps = 20
private readonly historyBufferSize: number
// 变化追踪器
private changeTracker: DataDiff = {
added: [],
removed: [],
updated: []
}
/**
*
*/
private lastSnapshot: Map<string, VDataItem> = new Map()
/**
* @param id , key
* @param viewport ,
* @param bufferSize 50
*/
constructor(id: string, viewport: Viewport, bufferSize = 50) {
this.id = id
this.viewport = markRaw(viewport)
this.historyBufferSize = bufferSize
// 初始化固定大小的历史缓冲区
this.historySteps = new Array(this.maxHistorySteps)
}
/**
*
*/
beginUserWrite() {
// 创建当前状态快照(非深拷贝)
this.lastSnapshot = new Map(
this.vdata.items.map(item => [item.id, item])
)
this.changeTracker = { added: [], removed: [], updated: [] }
}
/**
*
*/
endUserWrite() {
this.calculateDiff()
this.saveStep()
this.syncDataState()
this.isChanged.value = true
}
/**
*
*/
private calculateDiff() {
const currentMap = new Map(
this.vdata.items.map(item => [item.id, item])
)
// 检测删除的项目
for (const [id] of this.lastSnapshot) {
if (!currentMap.has(id)) {
this.changeTracker.removed.push(id)
}
}
// 检测新增和更新的项目
for (const [id, currentItem] of currentMap) {
const lastItem = this.lastSnapshot.get(id)
if (!lastItem) {
this.changeTracker.added.push(currentItem)
} else if (!_.isEqual(lastItem, currentItem)) {
this.changeTracker.updated.push(currentItem)
}
}
// 清除空变更
if (this.changeTracker.added.length === 0) delete this.changeTracker.added
if (this.changeTracker.removed.length === 0) delete this.changeTracker.removed
if (this.changeTracker.updated.length === 0) delete this.changeTracker.updated
}
/**
*
*/
private saveStep() {
// 跳过空变更
if (
(!this.changeTracker.added || this.changeTracker.added.length === 0) &&
(!this.changeTracker.removed || this.changeTracker.removed.length === 0) &&
(!this.changeTracker.updated || this.changeTracker.updated.length === 0)
) {
return
}
// 使用循环缓冲区存储历史记录
const nextIndex = (this.historyIndex + 1) % this.maxHistorySteps
this.historySteps[nextIndex] = {
diff: _.cloneDeep(this.changeTracker),
timestamp: Date.now()
}
this.historyIndex = nextIndex
this.saveToLocalstore()
}
/**
* viewport
* - viewport.beginSync()
* - viewport.syncOnRemove(id)
* - viewport.syncOnAppend(vdataItem)
* - viewport.syncOnUpdate(id)
* - viewport.endSync()
*/
syncDataState() {
// 没有变化时跳过同步
if (
(!this.changeTracker.added || this.changeTracker.added.length === 0) &&
(!this.changeTracker.removed || this.changeTracker.removed.length === 0) &&
(!this.changeTracker.updated || this.changeTracker.updated.length === 0)
) {
return
}
this.viewport.beginSync()
// 处理删除
if (this.changeTracker.removed) {
for (const id of this.changeTracker.removed) {
this.viewport.syncOnRemove(id)
}
}
// 处理新增
if (this.changeTracker.added) {
for (const item of this.changeTracker.added) {
this.viewport.syncOnAppend(item)
}
}
// 处理更新
if (this.changeTracker.updated) {
for (const item of this.changeTracker.updated) {
this.viewport.syncOnUpdate(item)
}
}
this.viewport.endSync()
}
/**
*
*/
async load(items: VDataItem[]) {
this.isLoading.value = true
try {
// 直接替换数组引用(避免响应式开销)
this.vdata.items = items
// 初始状态作为第一步
this.beginUserWrite()
this.endUserWrite()
this.isChanged.value = false
} finally {
this.isLoading.value = false
}
}
/**
*
*/
async save(): Promise<Object> {
return _.cloneDeep(this.vdata)
}
/**
*
*/
undo() {
if (!this.undoEnabled()) return
const step = this.historySteps[this.historyIndex]
if (!step) return
this.applyReverseDiff(step.diff)
this.historyIndex = (this.historyIndex - 1 + this.maxHistorySteps) % this.maxHistorySteps
this.isChanged.value = true
this.syncDataState()
}
/**
*
*/
redo() {
if (!this.redoEnabled()) return
this.historyIndex = (this.historyIndex + 1) % this.maxHistorySteps
const step = this.historySteps[this.historyIndex]
if (!step) return
this.applyDiff(step.diff)
this.isChanged.value = true
this.syncDataState()
}
/**
*
*/
private applyDiff(diff: DataDiff) {
// 处理删除
if (diff.removed) {
this.vdata.items = this.vdata.items.filter(item => !diff.removed.includes(item.id))
}
// 处理新增
if (diff.added) {
this.vdata.items.push(...diff.added)
}
// 处理更新
if (diff.updated) {
const updateMap = new Map(diff.updated.map(item => [item.id, item]))
this.vdata.items = this.vdata.items.map(item =>
updateMap.has(item.id) ? updateMap.get(item.id)! : item
)
}
}
/**
*
*/
private applyReverseDiff(diff: DataDiff) {
// 反向处理:删除 → 添加
if (diff.removed) {
// 从历史快照恢复被删除的项目
const restoredItems = diff.removed
.map(id => this.lastSnapshot.get(id))
.filter(Boolean) as VDataItem[]
this.vdata.items.push(...restoredItems)
}
// 反向处理:添加 → 删除
if (diff.added) {
const addedIds = new Set(diff.added.map(item => item.id))
this.vdata.items = this.vdata.items.filter(item => !addedIds.has(item.id))
}
// 反向处理:更新 → 恢复旧值
if (diff.updated) {
const restoreMap = new Map(
diff.updated
.map(item => [item.id, this.lastSnapshot.get(item.id)])
.filter(([, item]) => !!item) as [string, VDataItem][]
)
this.vdata.items = this.vdata.items.map(item =>
restoreMap.has(item.id) ? restoreMap.get(item.id)! : item
)
}
}
/**
* ()
*/
async saveToLocalstore() {
// 只保存变化部分和关键元数据
const saveData = {
diff: this.changeTracker,
timestamp: Date.now(),
itemsCount: this.vdata.items.length
}
await localforage.setItem(`scene-tmp-${this.id}`, saveData)
}
/**
*
*/
async loadFromLocalstore() {
const saved: any = await localforage.getItem(`scene-tmp-${this.id}`)
if (saved && saved.diff) {
this.applyDiff(saved.diff)
this.isChanged.value = true
}
}
undoEnabled() {
return this.historyIndex >= 0 && this.historySteps[this.historyIndex] !== undefined
}
redoEnabled() {
const nextIndex = (this.historyIndex + 1) % this.maxHistorySteps
return this.historySteps[nextIndex] !== undefined
}
/**
*
*/
async removeLocalstore() {
await localforage.removeItem(`scene-tmp-${this.id}`)
}
}
// 差异类型定义
interface DataDiff {
added: VDataItem[]
removed: string[]
updated: VDataItem[]
}
// 历史记录项
interface HistoryStep {
diff: DataDiff
timestamp: number
}

20
src/designer/Viewport.ts

@ -16,6 +16,7 @@ import type Toolbox from '@/model/itemType/Toolbox.ts'
import { calcPositionUseSnap } from '@/model/ModelUtils.ts' import { calcPositionUseSnap } from '@/model/ModelUtils.ts'
import SelectInspect from '@/designer/model2DEditor/tools/SelectInspect.ts' import SelectInspect from '@/designer/model2DEditor/tools/SelectInspect.ts'
import MouseMoveInspect from '@/designer/model2DEditor/tools/MouseMoveInspect.ts' import MouseMoveInspect from '@/designer/model2DEditor/tools/MouseMoveInspect.ts'
import type { CursorMode, VDataItem } from '@/types/Types'
/** /**
* *
@ -32,7 +33,7 @@ export default class Viewport {
controls: OrbitControls controls: OrbitControls
worldModel: WorldModel worldModel: WorldModel
raycaster: THREE.Raycaster raycaster: THREE.Raycaster
dragControl: EsDragControls dragControl: any // EsDragControls
animationFrameId: any = null animationFrameId: any = null
//搭配 state.cursorMode = xxx 之后, currentTool.start(第一个参数) 使用 //搭配 state.cursorMode = xxx 之后, currentTool.start(第一个参数) 使用
@ -44,6 +45,23 @@ export default class Viewport {
] ]
toolbox: Record<string, Toolbox> = {} toolbox: Record<string, Toolbox> = {}
objectMap: Map<string, THREE.Object3D> = new Map()
beginSync() {
}
syncOnRemove(id: string) {
}
syncOnUpdate(item: VDataItem) {
}
syncOnAppend(item: VDataItem) {
}
endSync() {
}
/** /**
* *
*/ */

5
src/runtime/System.ts

@ -6,7 +6,7 @@ import hotkeys from 'hotkeys-js'
import { defineComponent, h, markRaw, nextTick, reactive, toRaw, unref, type App, createApp, type Component } from 'vue' import { defineComponent, h, markRaw, nextTick, reactive, toRaw, unref, type App, createApp, type Component } from 'vue'
import { ElMessage, ElMessageBox, ElNotification } from 'element-plus' import { ElMessage, ElMessageBox, ElNotification } from 'element-plus'
import { QuestionFilled } from '@element-plus/icons-vue' import { QuestionFilled } from '@element-plus/icons-vue'
import { renderIcon } from '@/utils/webutils.ts' import { decompressUUID, renderIcon, createShortUUID } from '@/utils/webutils.ts'
import type { showDialogOption } from '@/SystemOption' import type { showDialogOption } from '@/SystemOption'
import ShowDialogWrap from '@/components/ShowDialogWrap.vue' import ShowDialogWrap from '@/components/ShowDialogWrap.vue'
import LoadingDialog from '@/components/LoadingDialog.vue' import LoadingDialog from '@/components/LoadingDialog.vue'
@ -38,6 +38,9 @@ export default class System {
*/ */
rootElementList: { cmp: Component, props: any }[] = reactive([]) rootElementList: { cmp: Component, props: any }[] = reactive([])
createUUID = createShortUUID
decompressUUID = decompressUUID
constructor(app: App) { constructor(app: App) {
this.app = app this.app = app
window['_'] = _ window['_'] = _

52
src/types/Types.d.ts

@ -1,4 +1,4 @@
type CursorMode = export type CursorMode =
'normal' 'normal'
| 'ALink' | 'ALink'
| 'SLink' | 'SLink'
@ -18,3 +18,53 @@ type PointType =
| 'pointMarker3' | 'pointMarker3'
| 'pointMarker4' | 'pointMarker4'
| 'pointMarker5' | 'pointMarker5'
export export interface VData {
/**
*
*/
items: VDataItem[]
/**
*
*/
isChanged: boolean
}
export interface VDataItem {
/**
* {
* id: 'p1', // 物体ID, 唯一标识, 需保证唯一, three.js 中的 uuid
* t: 'measure', // 物体类型, measure表示测量, 需交给 itemType.name == 'measure' 的组件处理
* l: '测量1', // 标签名称, 显示用
* c: '#ff0000', // 颜色, 显示用. 十六进制颜色值, three.js 中的材质颜色
* tf: [ // 变换矩阵, 3x3矩阵, 采用Y轴向上为正, X轴向右, Z轴向前的右手坐标系
* [-9.0, 0, -1.0], // 平移向量 position
* [0, 0, 0], // 旋转向量 rotation, 表示绕Y轴旋转的角度, 单位为度。对应 three.js 应进行"角度"转"弧度"的换算
* [0.25, 0.1, 0.25] // 缩放向量 scale
* ],
* dt: { // 用户数据, 可自定义, 一般用在 three.js 的 userData 中
* center: ['p2'], // 用于 a='ln' 的测量线段, 关联的点对象(uuid)
* in: [], // 物流入方向关联的对象(uuid)
* out: [] // 物流出方向关联的对象(uuid)
* ... // 其他自定义数据
* }
* }
*/
id: string
t: string
l: string
c: string
tf: [
[number, number, number],
[number, number, number],
[number, number, number],
]
dt: {
center?: string[] // 用于 a='ln' 的测量线段, 关联的点对象(uuid)
in?: string[] // 物流入方向关联的对象(uuid)
out?: string[] // 物流出方向关联的对象(uuid)
[key: string]: any // 其他自定义数据
}
}

67
src/utils/webutils.ts

@ -4,6 +4,7 @@ import * as AntdIcon from '@vicons/antd'
import { ElIcon } from 'element-plus' import { ElIcon } from 'element-plus'
import * as FaIcon from '@vicons/fa' import * as FaIcon from '@vicons/fa'
import * as ElementPlusIconsVue from '@element-plus/icons-vue' import * as ElementPlusIconsVue from '@element-plus/icons-vue'
import * as THREE from 'three'
/** /**
* 0x409EFF '#409EFF' * 0x409EFF '#409EFF'
@ -21,6 +22,72 @@ export function getColorFromCode(value: number): string {
} }
/** /**
* UUID
*/
export function createShortUUID() {
// 生成一个标准的 UUID
return compressUUID(THREE.MathUtils.generateUUID())
}
/**
* UUID
*/
export function compressUUID(uuid) {
// 移除连字符并转换为 ArrayBuffer
const raw = uuid.replace(/-/g, '')
const buf = new Uint8Array(16)
for (let i = 0; i < 32; i += 2) {
buf[i / 2] = parseInt(raw.substr(i, 2), 16)
}
// 将字节数组转换为 Base64 字符串
const base64 = btoa(String.fromCharCode.apply(null, buf))
// 去掉 Base64 中的填充字符 '=' 并替换 '/' 为 '_', '+' 为 '-' 以便 URL 安全
return base64
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '')
}
/**
* UUID
*/
export function decompressUUID(shortUuid: string) {
// 补全 Base64 填充字符
let padded = shortUuid
padded = padded.replace(/-/g, '+').replace(/_/g, '/')
while (padded.length % 4 !== 0) {
padded += '='
}
// 解码 Base64
const binStr = atob(padded)
const buf = new Uint8Array(binStr.length)
for (let i = 0; i < binStr.length; i++) {
buf[i] = binStr.charCodeAt(i)
}
// 转换为标准 UUID 格式
const hex = []
for (let i = 0; i < 16; i++) {
hex.push((buf[i] >> 4).toString(16))
hex.push((buf[i] & 0x0f).toString(16))
}
const raw = hex.join('')
return (
raw.substr(0, 8) + '-' +
raw.substr(8, 4) + '-' +
raw.substr(12, 4) + '-' +
raw.substr(16, 4) + '-' +
raw.substr(20, 12)
)
}
/**
* '#409EFF' 0x409EFF * '#409EFF' 0x409EFF
*/ */
export function getColorFromString(value: string): number { export function getColorFromString(value: string): number {

Loading…
Cancel
Save