From de3a4c9bcdf66747b290d15f0ef7ced6ccca9d8c Mon Sep 17 00:00:00 2001 From: Jz0ojiang Date: Tue, 11 Aug 2026 16:23:03 +0800 Subject: [PATCH] =?UTF-8?q?=E8=B7=AF=E7=94=B1=E9=A1=B5=E5=8A=A8=E6=95=88?= =?UTF-8?q?=E4=B8=8E=E7=BB=86=E8=8A=82=E4=B8=80=E6=89=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 基调是简单快速:hover 颜色 0.15s 过渡(全局规则,只过渡颜色不碰布局)、 对话框入场淡入加 6px 上移、tab 下划线改成共享的一条并在切换时滑动、tab 内容 out-in 淡切、配置折叠箭头居右随展开旋转 90 度、订阅下拉 0.12s 出入场。 不引 animate.css:这些效果各自十几行 CSS,引库只多不少。 配置展开和拨开链式代理时,若详情底部长出窗口则平滑滚进视野;tab 切换 不触发,来回切时页面跟着跳只会晕。二次确认对话框点遮罩可关闭,内边距 挪进内层,否则点在 dialog 自己的 padding 上也会被当成遮罩。新增入站的 表单不做遮罩关闭:误点会丢掉填了一半的内容。对话框标题改为「新增路由」 「编辑路由」。select 换自绘箭头,原生箭头贴边且位置调不了。 --- web/components/ConfirmDialog.vue | 32 ++++-- web/components/OutboundCard.vue | 3 + web/components/SourcePicker.vue | 20 +++- web/styles/tokens.css | 32 ++++++ web/views/InboundDetail.vue | 184 ++++++++++++++++++++++--------- web/views/InboundForm.vue | 16 ++- 6 files changed, 222 insertions(+), 65 deletions(-) diff --git a/web/components/ConfirmDialog.vue b/web/components/ConfirmDialog.vue index 98aece5..277df10 100644 --- a/web/components/ConfirmDialog.vue +++ b/web/components/ConfirmDialog.vue @@ -25,28 +25,46 @@ watch( else dialog.value?.close() }, ) + +/** 点在遮罩上就关闭。内容区的点击 target 是 .body 里的元素,到不了 dialog 本身。 */ +function onBackdrop(event: MouseEvent) { + if (event.target === dialog.value) emit('close') +} diff --git a/web/styles/tokens.css b/web/styles/tokens.css index 58c5d38..05bd77b 100644 --- a/web/styles/tokens.css +++ b/web/styles/tokens.css @@ -150,6 +150,38 @@ kbd, outline-offset: 2px; } +/* 交互控件的颜色过渡。只过渡颜色类属性,不碰布局;时长以「感知得到但不等待」为准 */ +button, +input, +select { + transition: + color 0.15s ease, + background-color 0.15s ease, + border-color 0.15s ease; +} + +/* 对话框入场:快速淡入加一点上移。关闭不做动画,动效只留给打开的那一下 */ +dialog[open] { + animation: dialog-in 0.15s ease; +} + +dialog[open]::backdrop { + animation: backdrop-in 0.15s ease; +} + +@keyframes dialog-in { + from { + opacity: 0; + transform: translateY(6px); + } +} + +@keyframes backdrop-in { + from { + opacity: 0; + } +} + ::selection { background: var(--c-accent-soft); color: var(--c-accent-text); diff --git a/web/views/InboundDetail.vue b/web/views/InboundDetail.vue index 3fda836..0dd83a7 100644 --- a/web/views/InboundDetail.vue +++ b/web/views/InboundDetail.vue @@ -10,7 +10,7 @@ * * 两半的反馈方式必须不一样。它们长得一样的时候,用户分不清哪个动作要等、哪个不用。 */ -import { computed, ref, watch } from 'vue' +import { computed, nextTick, ref, watch } from 'vue' import type { Inbound } from '@shared/inbounds.ts' import { autoGroupName, @@ -30,8 +30,7 @@ import OutboundCard from '../components/OutboundCard.vue' import SourcePicker from '../components/SourcePicker.vue' import IconEdit from '~icons/ri/edit-line' import IconDelete from '~icons/ri/delete-bin-line' -import IconDown from '~icons/ri/arrow-down-s-line' -import IconUp from '~icons/ri/arrow-up-s-line' +import IconRight from '~icons/ri/arrow-right-s-line' import IconOffline from '~icons/ri/cloud-off-line' const props = defineProps<{ @@ -80,6 +79,26 @@ const activeGroup = computed( () => props.panel.groups.find((g) => g.name === activeTab.value) ?? null, ) +// ── tab 下划线 ────────────────────────────────────────── +// 一条绝对定位的线,位置按活动 tab 的实际尺寸算,切换时靠 transition 滑过去。 +// 每个 tab 各画一条做不出「移动」:线是各自的边框,只能一条消失一条出现。 +const tabEls = new Map() +function setTabEl(name: string, el: unknown) { + if (el instanceof HTMLElement) tabEls.set(name, el) + else tabEls.delete(name) +} + +const ink = ref({ left: '0px', width: '0px', opacity: '0' }) +function moveInk() { + const el = tabEls.get(activeTab.value) + if (!el) { + ink.value = { ...ink.value, opacity: '0' } + return + } + ink.value = { left: `${String(el.offsetLeft)}px`, width: `${String(el.offsetWidth)}px`, opacity: '1' } +} +watch([activeTab, () => props.panel.groups], () => void nextTick(moveInk), { immediate: true }) + const proxyLines = computed(() => (activeGroup.value ? membersOf(activeGroup.value) : [])) const chainLines = computed(() => (props.chain ? membersOf(props.chain) : [])) @@ -110,6 +129,24 @@ const throughChain = computed(() => { // ── 配置 ──────────────────────────────────────────────── const open = ref(false) +const rootEl = ref(null) + +/** + * 展开配置或拨开链式代理后,详情底部可能长到窗口外,把新出现的内容滚进视野。 + * 只挂在这两个动作上:tab 切换也会变高度,但来回切 tab 时页面跟着跳只会晕。 + */ +async function revealBottom() { + await nextTick() + const el = rootEl.value + if (!el) return + const gap = el.getBoundingClientRect().bottom + 16 - window.innerHeight + if (gap > 0) window.scrollBy({ top: gap, behavior: 'smooth' }) +} + +function toggleOpen() { + open.value = !open.value + if (open.value) void revealBottom() +} const form = ref({ proxy: { sources: [], filter: null }, chain: null }) /** @@ -147,6 +184,11 @@ const chainOn = computed({ }, }) +function toggleChain() { + chainOn.value = !chainOn.value + if (chainOn.value) void revealBottom() +} + // ── 草稿预览 ──────────────────────────────────────────── // 上半部分的显示规则只有一条:草稿和已保存一致时显示现实;不一致时显示「按草稿保存后 // 的样子」。点击的语义看卡片本身:现在就存在的成员任何时候点了都立刻生效,只有保存后 @@ -295,7 +337,7 @@ const complete = computed(