feat:撤销功能

This commit is contained in:
2026-01-20 14:38:47 +08:00
parent 738deb04df
commit bbb42cd1e6

View File

@@ -1,6 +1,16 @@
<template>
<xn-form-container title="编辑项目阶段" :width="700" v-model:open="open" :destroy-on-close="true" @close="onClose">
<div class="stage-form">
<div class="stage-form__toolbar">
<a-space>
<a-tooltip title="撤销上一步修改">
<a-button :disabled="!canUndo" @click="undo">撤销</a-button>
</a-tooltip>
<a-tooltip title="恢复撤销的修改">
<a-button :disabled="!canRedo" @click="redo">恢复</a-button>
</a-tooltip>
</a-space>
</div>
<div class="stage-form__header">
<div>阶段名称</div>
<div>阶段序号</div>
@@ -32,7 +42,7 @@
import { createVNode } from 'vue'
import { Modal } from 'ant-design-vue'
import { ExclamationCircleOutlined } from '@ant-design/icons-vue'
import { cloneDeep } from 'lodash-es'
import { cloneDeep, isEqual } from 'lodash-es'
// 抽屉状态
const open = ref(false)
const emit = defineEmits({ successful: null })
@@ -42,10 +52,56 @@
const visibleStages = computed(() => formData.value.filter((item) => !item.isDeleted))
const submitLoading = ref(false)
// 撤销/恢复(类似 WPS / Ctrl+Z / Ctrl+Y
const undoStack = ref([])
const redoStack = ref([])
const isRestoring = ref(false)
let historyTimer = null
const MAX_HISTORY = 50
const snapshotOf = (list) => cloneDeep(Array.isArray(list) ? list : [])
const initHistory = () => {
undoStack.value = [snapshotOf(formData.value)]
redoStack.value = []
}
const commitHistory = () => {
if (isRestoring.value) return
const next = snapshotOf(formData.value)
const last = undoStack.value[undoStack.value.length - 1]
if (last && isEqual(last, next)) return
undoStack.value.push(next)
if (undoStack.value.length > MAX_HISTORY) undoStack.value.shift()
redoStack.value = []
}
const canUndo = computed(() => undoStack.value.length > 1)
const canRedo = computed(() => redoStack.value.length > 0)
const applySnapshot = (snap) => {
isRestoring.value = true
formData.value = snapshotOf(snap)
nextTick(() => {
isRestoring.value = false
})
}
const undo = () => {
if (!canUndo.value) return
const current = snapshotOf(formData.value)
const prev = undoStack.value[undoStack.value.length - 2]
redoStack.value.push(current)
undoStack.value.pop()
applySnapshot(prev)
}
const redo = () => {
if (!canRedo.value) return
const current = snapshotOf(formData.value)
const next = redoStack.value.pop()
undoStack.value.push(current)
applySnapshot(next)
}
const createEmptyStage = () => ({
stageId: '',
stageName: '',
projectId: projectId,
projectId: projectId.value,
stageSeq: visibleStages.value.length + 1,
isDeleted: false,
_key: `${Date.now()}-${Math.random()}`
@@ -69,15 +125,19 @@
if (!formData.value.length) {
formData.value.push(createEmptyStage())
}
initHistory()
}
// 关闭抽屉
const onClose = () => {
formData.value = []
open.value = false
undoStack.value = []
redoStack.value = []
}
const addRow = () => {
formData.value.push(createEmptyStage())
commitHistory()
}
const removeRow = (stage) => {
@@ -85,8 +145,22 @@
if (!visibleStages.value.length) {
formData.value.push(createEmptyStage())
}
commitHistory()
}
// 输入编辑用 watch 做快照(做个轻量防抖,避免每个字符都入栈)
watch(
formData,
() => {
if (isRestoring.value) return
if (historyTimer) clearTimeout(historyTimer)
historyTimer = setTimeout(() => {
commitHistory()
}, 300)
},
{ deep: true }
)
const submitStages = () => {
submitLoading.value = true
const submitList = formData.value.map(({ _key, ...rest }) => rest)
@@ -128,6 +202,10 @@
flex-direction: column;
gap: 12px;
}
.stage-form__toolbar {
display: flex;
justify-content: flex-end;
}
.stage-form__header,
.stage-form__row {
display: grid;