diff --git a/web/App.vue b/web/App.vue index caf0d74..81b6a40 100644 --- a/web/App.vue +++ b/web/App.vue @@ -100,8 +100,24 @@ const busyAct = ref(false) const historyOpen = ref(false) const askRestart = ref(false) +/** + * 撤回/重做各自的冷却:窗内的连点算一次。手抖不该多退一步;顺带避开 + * 重做的备份名同秒覆盖(备份名精确到秒,窗比一秒宽就撞不上)。 + * 两个方向分开冷却,撤回错了马上重做这条路不受影响。 + */ +const HISTORY_COOLDOWN_MS = 2_500 +const cooling = ref({ undo: false, redo: false }) + +function coolDown(kind: 'undo' | 'redo') { + cooling.value = { ...cooling.value, [kind]: true } + window.setTimeout(() => { + cooling.value = { ...cooling.value, [kind]: false } + }, HISTORY_COOLDOWN_MS) +} + async function undo() { - if (busyAct.value) return + if (busyAct.value || cooling.value.undo) return + coolDown('undo') busyAct.value = true try { const result = await api.undoConfig() @@ -115,7 +131,8 @@ async function undo() { } async function redo() { - if (busyAct.value) return + if (busyAct.value || cooling.value.redo) return + coolDown('redo') busyAct.value = true try { const result = await api.redoConfig() @@ -239,7 +256,7 @@ const current = computed(() => NAV.find((n) => n.key === active.value)) type="button" class="act" title="撤回" - :disabled="busyAct || !snapshot || history.backups.length === 0" + :disabled="busyAct || cooling.undo || !snapshot || history.backups.length === 0" @click="undo" > @@ -248,7 +265,7 @@ const current = computed(() => NAV.find((n) => n.key === active.value)) type="button" class="act" title="重做" - :disabled="busyAct || !snapshot || history.redo === 0" + :disabled="busyAct || cooling.redo || !snapshot || history.redo === 0" @click="redo" > @@ -411,14 +428,18 @@ const current = computed(() => NAV.find((n) => n.key === active.value)) /* ── 侧栏 ───────────────────────────────────── */ /* - * 钉在视口里。不加 sticky 的话它会被网格行撑到和主区一样高,页面一长, - * 导航就跟着滚出屏幕外,而它是全局导航,任何时候都该够得着。 + * 钉在视口里。用 fixed 而不是 sticky:sticky 元素的位置跟着 scrollTop 走, + * 而滚动到底时 scrollTop = scrollHeight - clientHeight,在 Windows 显示缩放下 + * 是小数——侧栏落在小数像素上,图标和文字取整方向不同,部分图标会跳 1px。 + * fixed 按视口摆放,不参与滚动偏移的运算。网格的第一列只当占位。 */ .side { - position: sticky; + position: fixed; top: 0; - align-self: start; + left: 0; + width: var(--side-w); height: 100vh; + transition: width 0.3s ease; display: flex; flex-direction: column; gap: var(--s-5); @@ -696,7 +717,9 @@ const current = computed(() => NAV.find((n) => n.key === active.value)) /* ── 主区 ───────────────────────────────────── */ +/* 侧栏是 fixed、不占网格位,主区要自己站到第二列,否则会顶进第一列 */ .main { + grid-column: 2; padding: var(--s-6) var(--s-6) var(--s-7); max-width: 1600px; } diff --git a/web/components/ToastHost.vue b/web/components/ToastHost.vue index 384238d..ee98ec1 100644 --- a/web/components/ToastHost.vue +++ b/web/components/ToastHost.vue @@ -1,12 +1,45 @@