feat(plan):优化计划保存与切换逻辑,提升性能与用户体验- 在计划组件中引入防抖机制,避免频繁保存操作

- 实现异步保存队列,确保计划切换时数据不丢失
- 优化计划切换逻辑,支持后台保存并提升响应速度
- 在组件卸载前确保所有 pending 保存操作完成
- 修复 MaaPlanTable 中响应式丢失问题,优化选项缓存逻辑
- 为 PlanSelector 添加点击防抖,防止重复触发计划切换- 重构数据同步逻辑,提高表格与计划数据的同步效率
This commit is contained in:
MoeSnowyFox
2025-09-21 23:56:27 +08:00
parent 4f1e49ce85
commit 32df65fb65
3 changed files with 219 additions and 93 deletions

View File

@@ -18,7 +18,7 @@
:key="plan.id" :key="plan.id"
:type="activePlanId === plan.id ? 'primary' : 'default'" :type="activePlanId === plan.id ? 'primary' : 'default'"
size="large" size="large"
@click="$emit('plan-change', plan.id)" @click="handlePlanClick(plan.id)"
class="plan-button" class="plan-button"
> >
<span class="plan-name">{{ plan.name }}</span> <span class="plan-name">{{ plan.name }}</span>
@@ -33,6 +33,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
interface Plan { interface Plan {
id: string id: string
name: string name: string
@@ -48,8 +49,22 @@ interface Emits {
(e: 'plan-change', planId: string): void (e: 'plan-change', planId: string): void
} }
defineProps<Props>() const props = defineProps<Props>()
defineEmits<Emits>() const emit = defineEmits<Emits>()
// 防抖点击处理
const debounce = <T extends (...args: any[]) => any>(func: T, wait: number): T => {
let timeout: NodeJS.Timeout | null = null
return ((...args: any[]) => {
if (timeout) clearTimeout(timeout)
timeout = setTimeout(() => func(...args), wait)
}) as T
}
const handlePlanClick = debounce((planId: string) => {
if (planId === props.activePlanId) return
emit('plan-change', planId)
}, 100)
const getPlanTypeLabel = (planType: string) => { const getPlanTypeLabel = (planType: string) => {
const labelMap: Record<string, string> = { const labelMap: Record<string, string> = {

View File

@@ -63,7 +63,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, nextTick, onMounted, ref, watch } from 'vue' import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import { message } from 'ant-design-vue' import { message } from 'ant-design-vue'
import { usePlanApi } from '@/composables/usePlanApi' import { usePlanApi } from '@/composables/usePlanApi'
@@ -112,6 +112,15 @@ const currentTableComponent = computed(() => {
} }
}) })
// 添加防抖工具函数
const debounce = <T extends (...args: any[]) => any>(func: T, wait: number): T => {
let timeout: NodeJS.Timeout | null = null
return ((...args: any[]) => {
if (timeout) clearTimeout(timeout)
timeout = setTimeout(() => func(...args), wait)
}) as T
}
const handleAddPlan = async (planType: string = 'MaaPlan') => { const handleAddPlan = async (planType: string = 'MaaPlan') => {
try { try {
const response = await createPlan(planType) const response = await createPlan(planType)
@@ -147,7 +156,72 @@ const handleRemovePlan = async (planId: string) => {
} }
} }
// 添加异步保存队列和状态管理
const savingQueue = ref(new Set<string>())
const savePromises = ref(new Map<string, Promise<void>>())
// 异步保存函数
const saveInBackground = async (planId: string) => {
// 如果已经在保存队列中,等待现有的保存完成
if (savingQueue.value.has(planId)) {
const existingPromise = savePromises.value.get(planId)
if (existingPromise) {
await existingPromise
}
return
}
savingQueue.value.add(planId)
const savePromise = (async () => {
try {
const currentPlan = planList.value.find(plan => plan.id === planId)
const planType = currentPlan?.type || 'MaaPlan'
// Start from existing tableData, then overwrite Info explicitly
const planData: Record<string, any> = { ...(tableData.value || {}) }
planData.Info = { Mode: currentMode.value, Name: currentPlanName.value, Type: planType }
await updatePlan(planId, planData)
} catch (error) {
console.error('后台保存计划数据失败:', error)
// 不显示错误消息,避免打断用户操作
} finally {
savingQueue.value.delete(planId)
savePromises.value.delete(planId)
}
})()
savePromises.value.set(planId, savePromise)
return savePromise
}
// 防抖保存函数
const debouncedSave = debounce(async () => {
if (!activePlanId.value) return
await saveInBackground(activePlanId.value)
}, 300)
const handleSave = async () => {
if (!activePlanId.value) {
message.warning('请先选择一个计划')
return
}
await debouncedSave()
}
// 优化计划切换逻辑
const onPlanChange = async (planId: string) => { const onPlanChange = async (planId: string) => {
if (planId === activePlanId.value) return
// 触发当前计划的异步保存,但不等待完成
if (activePlanId.value) {
saveInBackground(activePlanId.value).catch(error => {
console.warn('切换时保存当前计划失败:', error)
})
}
// 立即切换到新计划
activePlanId.value = planId activePlanId.value = planId
await loadPlanData(planId) await loadPlanData(planId)
} }
@@ -232,35 +306,24 @@ const initPlans = async () => {
} }
} }
const savePlanData = async () => { const savePlanData = async (planId?: string) => {
if (!activePlanId.value) return const targetPlanId = planId || activePlanId.value
if (!targetPlanId) return
try { try {
const currentPlan = planList.value.find(plan => plan.id === activePlanId.value) const currentPlan = planList.value.find(plan => plan.id === targetPlanId)
const planType = currentPlan?.type || 'MaaPlan' const planType = currentPlan?.type || 'MaaPlan'
// Start from existing tableData, then overwrite Info explicitly
const planData: Record<string, any> = { ...(tableData.value || {}) } const planData: Record<string, any> = { ...(tableData.value || {}) }
planData.Info = { Mode: currentMode.value, Name: currentPlanName.value, Type: planType } planData.Info = { Mode: currentMode.value, Name: currentPlanName.value, Type: planType }
await updatePlan(activePlanId.value, planData) await updatePlan(targetPlanId, planData)
} catch (error) { } catch (error) {
console.error('保存计划数据失败:', error) console.error('保存计划数据失败:', error)
throw error throw error
} }
} }
const handleSave = async () => {
if (!activePlanId.value) {
message.warning('请先选择一个计划')
return
}
try {
await savePlanData()
} catch (error) {
message.error('保存失败')
}
}
const getDefaultPlanName = (planType: string) => const getDefaultPlanName = (planType: string) =>
( (
({ ({
@@ -282,7 +345,7 @@ watch(
() => [currentPlanName.value, currentMode.value], () => [currentPlanName.value, currentMode.value],
async () => { async () => {
await nextTick() await nextTick()
handleSave() await debouncedSave()
} }
) )
@@ -298,8 +361,24 @@ watch(
} }
) )
// 在组件卸载前确保所有保存操作完成
const ensureAllSaved = async () => {
const pendingPromises = Array.from(savePromises.value.values())
if (pendingPromises.length > 0) {
await Promise.allSettled(pendingPromises)
}
}
onMounted(() => { onMounted(() => {
initPlans() initPlans()
// 监听页面卸载
window.addEventListener('beforeunload', ensureAllSaved)
})
onUnmounted(() => {
window.removeEventListener('beforeunload', ensureAllSaved)
ensureAllSaved()
}) })
</script> </script>

View File

@@ -178,7 +178,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, defineComponent, onMounted, ref, watch } from 'vue' import { computed, defineComponent, ref, watch } from 'vue'
import { message } from 'ant-design-vue' import { message } from 'ant-design-vue'
import { PlusOutlined } from '@ant-design/icons-vue' import { PlusOutlined } from '@ant-design/icons-vue'
@@ -219,6 +219,7 @@ const VNodeRenderer = defineComponent({
}, },
}) })
// 改回使用普通的ref确保响应式正常工作
const rows = ref<TableRow[]>([ const rows = ref<TableRow[]>([
{ {
key: 'MedicineNumb', key: 'MedicineNumb',
@@ -411,38 +412,40 @@ const addCustomStage = (rowKey: string, columnKey: string) => {
message.success('关卡添加成功') message.success('关卡添加成功')
} }
// 缓存计算属性,避免重复计算
const stageOptionsCache = ref(new Map<string, any[]>())
const stageOptions = computed(() => { const stageOptions = computed(() => {
const baseOptions = STAGE_DAILY_INFO.map(stage => ({ const cacheKey = 'base_stage_options'
label: stage.text, if (!stageOptionsCache.value.has(cacheKey)) {
value: stage.value, const baseOptions = STAGE_DAILY_INFO.map(stage => ({
isCustom: false, label: stage.text,
})) value: stage.value,
const customOptions = Object.keys(customStageNames.value).map(key => ({ isCustom: false,
label: customStageNames.value[key], }))
value: key, const customOptions = Object.keys(customStageNames.value).map(key => ({
isCustom: true, label: customStageNames.value[key],
})) value: key,
return [...baseOptions, ...customOptions] isCustom: true,
}))
stageOptionsCache.value.set(cacheKey, [...baseOptions, ...customOptions])
}
return stageOptionsCache.value.get(cacheKey) || []
}) })
const isCustomStage = (value: string, columnKey: string) => { // 优化getSelectOptions函数添加缓存
if (!value || value === '-') return false
const dayNumber = getDayNumber(columnKey)
let availableStages: string[]
if (dayNumber === 0) {
availableStages = STAGE_DAILY_INFO.map(stage => stage.value)
} else {
availableStages = STAGE_DAILY_INFO.filter(stage => stage.days.includes(dayNumber)).map(
stage => stage.value
)
}
return !availableStages.includes(value)
}
const getSelectOptions = (columnKey: string, taskName: string, currentValue?: string) => { const getSelectOptions = (columnKey: string, taskName: string, currentValue?: string) => {
const cacheKey = `${columnKey}_${taskName}_${currentValue || ''}`
if (stageOptionsCache.value.has(cacheKey)) {
return stageOptionsCache.value.get(cacheKey)
}
let options: any[] = []
switch (taskName) { switch (taskName) {
case '连战次数': case '连战次数':
return [ options = [
{ label: 'AUTO', value: '0' }, { label: 'AUTO', value: '0' },
{ label: '1', value: '1' }, { label: '1', value: '1' },
{ label: '2', value: '2' }, { label: '2', value: '2' },
@@ -452,6 +455,7 @@ const getSelectOptions = (columnKey: string, taskName: string, currentValue?: st
{ label: '6', value: '6' }, { label: '6', value: '6' },
{ label: '不切换', value: '-1' }, { label: '不切换', value: '-1' },
] ]
break
case '关卡选择': case '关卡选择':
case '备选关卡-1': case '备选关卡-1':
case '备选关卡-2': case '备选关卡-2':
@@ -486,11 +490,30 @@ const getSelectOptions = (columnKey: string, taskName: string, currentValue?: st
value: customStageNames.value[key], value: customStageNames.value[key],
isCustom: true, isCustom: true,
})) }))
return [...baseOptions, ...customOptions] options = [...baseOptions, ...customOptions]
break
} }
default: default:
return [] options = []
} }
// 缓存结果
stageOptionsCache.value.set(cacheKey, options)
return options
}
const isCustomStage = (value: string, columnKey: string) => {
if (!value || value === '-') return false
const dayNumber = getDayNumber(columnKey)
let availableStages: string[]
if (dayNumber === 0) {
availableStages = STAGE_DAILY_INFO.map(stage => stage.value)
} else {
availableStages = STAGE_DAILY_INFO.filter(stage => stage.days.includes(dayNumber)).map(
stage => stage.value
)
}
return !availableStages.includes(value)
} }
const getPlaceholder = (taskName: string) => { const getPlaceholder = (taskName: string) => {
@@ -549,9 +572,11 @@ const isStageEnabled = (stageValue: string, columnKey: string) => {
const toggleStage = (stageValue: string, columnKey: string, checked: boolean) => { const toggleStage = (stageValue: string, columnKey: string, checked: boolean) => {
const stageSlots = ['Stage', 'Stage_1', 'Stage_2', 'Stage_3'] const stageSlots = ['Stage', 'Stage_1', 'Stage_2', 'Stage_3']
const newRows = [...rows.value]
if (checked) { if (checked) {
for (const slot of stageSlots) { for (const slot of stageSlots) {
const row = rows.value.find(r => r.key === slot) as TableRow | undefined const row = newRows.find(r => r.key === slot) as TableRow | undefined
if (row && ((row as any)[columnKey] === '-' || (row as any)[columnKey] === '')) { if (row && ((row as any)[columnKey] === '-' || (row as any)[columnKey] === '')) {
;(row as any)[columnKey] = stageValue ;(row as any)[columnKey] = stageValue
break break
@@ -559,12 +584,14 @@ const toggleStage = (stageValue: string, columnKey: string, checked: boolean) =>
} }
} else { } else {
for (const slot of stageSlots) { for (const slot of stageSlots) {
const row = rows.value.find(r => r.key === slot) as TableRow | undefined const row = newRows.find(r => r.key === slot) as TableRow | undefined
if (row && (row as any)[columnKey] === stageValue) { if (row && (row as any)[columnKey] === stageValue) {
;(row as any)[columnKey] = '-' ;(row as any)[columnKey] = '-'
} }
} }
} }
rows.value = newRows
} }
const getSimpleTaskTagColor = (taskName: string) => { const getSimpleTaskTagColor = (taskName: string) => {
@@ -626,29 +653,6 @@ const disableAllStages = (stageValue: string) => {
}) })
} }
// 将 props.tableData 映射到 rows
const applyPlanDataToRows = (plan: Record<string, any> | null | undefined) => {
if (!plan) return
const timeKeys = [
'ALL',
'Monday',
'Tuesday',
'Wednesday',
'Thursday',
'Friday',
'Saturday',
'Sunday',
]
rows.value.forEach(row => {
const fieldKey = row.key
timeKeys.forEach(timeKey => {
if (plan[timeKey] && plan[timeKey][fieldKey] !== undefined) {
;(row as any)[timeKey] = plan[timeKey][fieldKey]
}
})
})
}
// 从 rows 组装为 API 所需结构 // 从 rows 组装为 API 所需结构
const buildPlanDataFromRows = (): Record<string, any> => { const buildPlanDataFromRows = (): Record<string, any> => {
const planData: Record<string, any> = {} const planData: Record<string, any> = {}
@@ -671,6 +675,48 @@ const buildPlanDataFromRows = (): Record<string, any> => {
return planData return planData
} }
// 优化数据同步,但保持响应式
const applyPlanDataToRows = (plan: Record<string, any> | null | undefined) => {
if (!plan) return
const timeKeys = [
'ALL',
'Monday',
'Tuesday',
'Wednesday',
'Thursday',
'Friday',
'Saturday',
'Sunday',
]
rows.value.forEach(row => {
const fieldKey = row.key
timeKeys.forEach(timeKey => {
if (plan[timeKey] && plan[timeKey][fieldKey] !== undefined) {
;(row as any)[timeKey] = plan[timeKey][fieldKey]
}
})
})
// 清除缓存以重新计算选项
stageOptionsCache.value.clear()
}
// 简化的防抖函数
const debounce = <T extends (...args: any[]) => any>(func: T, wait: number): T => {
let timeout: NodeJS.Timeout | null = null
return ((...args: any[]) => {
if (timeout) clearTimeout(timeout)
timeout = setTimeout(() => func(...args), wait)
}) as T
}
const debouncedEmitUpdate = debounce(() => {
emit('update-table-data', buildPlanDataFromRows())
}, 150)
// 恢复正常的watch监听
watch( watch(
() => props.tableData, () => props.tableData,
newVal => { newVal => {
@@ -679,28 +725,14 @@ watch(
{ immediate: true, deep: true } { immediate: true, deep: true }
) )
// 监听rows变化并触发更新
watch( watch(
() => rows,
rows.value.map(r => ({
key: r.key,
ALL: r.ALL,
Monday: r.Monday,
Tuesday: r.Tuesday,
Wednesday: r.Wednesday,
Thursday: r.Thursday,
Friday: r.Friday,
Saturday: r.Saturday,
Sunday: r.Sunday,
})),
() => { () => {
emit('update-table-data', buildPlanDataFromRows()) debouncedEmitUpdate()
}, },
{ deep: true } { deep: true }
) )
onMounted(() => {
applyPlanDataToRows(props.tableData || {})
})
</script> </script>
<style scoped> <style scoped>