外壳:侧栏改 fixed,请求页改名日志,错误提示点击复制,弹窗锁滚动

侧栏从 sticky 改 fixed:sticky 的位置跟着 scrollTop 走,滚到底时
scrollTop 是 scrollHeight 减 clientHeight,在 Windows 显示缩放下是
小数,部分图标会跳 1px;fixed 按视口摆放,不参与这个运算。网格第一列
只当占位,主区显式站到第二列。

错误提示是拿去粘贴排查的,点击改成复制而不是关闭,关闭交给自动消失;
局域网 http 下没有 clipboard API,退回 execCommand。撤回/重做各加
2.5 秒冷却,窗内连点算一次,顺带避开重做备份名的同秒覆盖。弹窗打开
时锁住背景滚动,滚动条位置由 scrollbar-gutter 常驻,不横向跳。
This commit is contained in:
2026-08-12 13:17:35 +08:00
parent 129a76696b
commit f57eb55328
4 changed files with 78 additions and 14 deletions
+31 -8
View File
@@ -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"
>
<IconUndo />
@@ -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"
>
<IconRedo />
@@ -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;
}
+39 -4
View File
@@ -1,12 +1,45 @@
<script setup lang="ts">
/**
* 轻提示的渲染宿主,挂在外壳根部。进场从右滑入,退场上移淡出;
* 点一下立刻消掉。内容来自 web/toast.ts 的单例队列。
* 轻提示的渲染宿主,挂在外壳根部。进场从右滑入,退场上移淡出。
* 点一下:普通提示立刻消掉;错误提示是要拿去粘贴求助的,点击复制而不是关闭,
* 消失交给 toast.ts 的定时器。内容来自 web/toast.ts 的单例队列。
*/
import { ref } from 'vue'
import { dismiss, toasts } from '../toast'
import type { Toast } from '../toast'
import IconOk from '~icons/ri/checkbox-circle-line'
import IconErr from '~icons/ri/error-warning-line'
import IconInfo from '~icons/ri/information-line'
/** 已复制的错误提示:hint 原地换成「已复制到剪贴板」作为反馈。 */
const copied = ref(new Set<number>())
async function onClick(t: Toast) {
if (t.kind !== 'error') {
dismiss(t.id)
return
}
const ok = await copyText([t.message, t.hint].filter(Boolean).join('\n'))
if (ok) copied.value = new Set(copied.value).add(t.id)
}
/** 经 http 从局域网访问时没有 navigator.clipboard(非安全上下文),退回 execCommand。 */
async function copyText(text: string): Promise<boolean> {
try {
await navigator.clipboard.writeText(text)
return true
} catch {
const area = document.createElement('textarea')
area.value = text
area.style.position = 'fixed'
area.style.opacity = '0'
document.body.appendChild(area)
area.select()
const ok = document.execCommand('copy')
area.remove()
return ok
}
}
</script>
<template>
@@ -16,14 +49,16 @@ import IconInfo from '~icons/ri/information-line'
v-for="t in toasts"
:key="t.id"
:class="['tst', t.kind === 'error' && 'is-error']"
@click="dismiss(t.id)"
:title="t.kind === 'error' ? '点击复制错误信息' : undefined"
@click="onClick(t)"
>
<IconErr v-if="t.kind === 'error'" class="tico" />
<IconInfo v-else-if="t.kind === 'info'" class="tico is-info" />
<IconOk v-else class="tico" />
<div class="tbody">
<strong class="tmsg">{{ t.message }}</strong>
<span v-if="t.hint" class="thint">{{ t.hint }}</span>
<span v-if="copied.has(t.id)" class="thint">已复制到剪贴板</span>
<span v-else-if="t.hint" class="thint">{{ t.hint }}</span>
</div>
</div>
</TransitionGroup>
+2 -2
View File
@@ -7,7 +7,7 @@ import IconRoute from '~icons/ri/route-line'
import IconDashboard from '~icons/ri/dashboard-line'
import IconConfig from '~icons/ri/file-list-3-line'
import IconRules from '~icons/ri/filter-3-line'
import IconRequests from '~icons/ri/pulse-line'
import IconLogs from '~icons/ri/terminal-line'
import IconConnections from '~icons/ri/links-line'
import IconSettings from '~icons/ri/settings-3-line'
import type { Component } from 'vue'
@@ -23,7 +23,7 @@ export const NAV: NavItem[] = [
{ key: 'router', label: '路由', icon: IconRoute },
{ key: 'config', label: '配置', icon: IconConfig },
{ key: 'rules', label: '规则', icon: IconRules },
{ key: 'requests', label: '请求', icon: IconRequests },
{ key: 'requests', label: '日志', icon: IconLogs },
{ key: 'connections', label: '连接', icon: IconConnections },
{ key: 'settings', label: '设置', icon: IconSettings },
]
+6
View File
@@ -182,6 +182,12 @@ html {
scrollbar-gutter: stable;
}
/* 模态框开着时锁住背后的页面滚动。:modal 只匹配 showModal() 打开的 dialog;
滚动条的位置由上面的 scrollbar-gutter 常驻留出,锁定解锁都不会横向跳动 */
html:has(:modal) {
overflow: hidden;
}
body {
margin: 0;
background: var(--c-bg);