路由页动效与细节一批

基调是简单快速:hover 颜色 0.15s 过渡(全局规则,只过渡颜色不碰布局)、
对话框入场淡入加 6px 上移、tab 下划线改成共享的一条并在切换时滑动、tab
内容 out-in 淡切、配置折叠箭头居右随展开旋转 90 度、订阅下拉 0.12s 出入场。
不引 animate.css:这些效果各自十几行 CSS,引库只多不少。

配置展开和拨开链式代理时,若详情底部长出窗口则平滑滚进视野;tab 切换
不触发,来回切时页面跟着跳只会晕。二次确认对话框点遮罩可关闭,内边距
挪进内层,否则点在 dialog 自己的 padding 上也会被当成遮罩。新增入站的
表单不做遮罩关闭:误点会丢掉填了一半的内容。对话框标题改为「新增路由」
「编辑路由」。select 换自绘箭头,原生箭头贴边且位置调不了。
This commit is contained in:
2026-08-11 16:23:03 +08:00
parent a44f410074
commit de3a4c9bcd
6 changed files with 222 additions and 65 deletions
+20 -2
View File
@@ -25,28 +25,46 @@ watch(
else dialog.value?.close() else dialog.value?.close()
}, },
) )
/** 点在遮罩上就关闭。内容区的点击 target 是 .body 里的元素,到不了 dialog 本身。 */
function onBackdrop(event: MouseEvent) {
if (event.target === dialog.value) emit('close')
}
</script> </script>
<template> <template>
<dialog ref="dialog" class="dlg" @close="emit('close')" @cancel.prevent="emit('close')"> <dialog
ref="dialog"
class="dlg"
@click="onBackdrop"
@close="emit('close')"
@cancel.prevent="emit('close')"
>
<div class="body">
<p class="msg">{{ message }}</p> <p class="msg">{{ message }}</p>
<footer class="acts"> <footer class="acts">
<button type="button" class="btn" @click="emit('close')">取消</button> <button type="button" class="btn" @click="emit('close')">取消</button>
<button type="button" class="btn go" @click="emit('confirm')">{{ action }}</button> <button type="button" class="btn go" @click="emit('confirm')">{{ action }}</button>
</footer> </footer>
</div>
</dialog> </dialog>
</template> </template>
<style scoped> <style scoped>
.dlg { .dlg {
width: min(400px, calc(100vw - 2 * var(--s-5))); width: min(400px, calc(100vw - 2 * var(--s-5)));
padding: var(--s-5); /* 内边距放在 .body 上:dialog 自己的内边距区域点了会被当成遮罩点击 */
padding: 0;
border: 1px solid var(--c-border-strong); border: 1px solid var(--c-border-strong);
border-radius: var(--r-lg); border-radius: var(--r-lg);
background: var(--c-surface); background: var(--c-surface);
color: var(--c-text); color: var(--c-text);
} }
.body {
padding: var(--s-5);
}
.dlg::backdrop { .dlg::backdrop {
background: rgb(0 0 0 / 0.45); background: rgb(0 0 0 / 0.45);
} }
+3
View File
@@ -100,6 +100,9 @@ function delayClass(ms: number | null): string {
border: 1px solid var(--c-border); border: 1px solid var(--c-border);
border-radius: var(--r-sm); border-radius: var(--r-sm);
background: var(--c-surface-sunken); background: var(--c-surface-sunken);
transition:
border-color 0.15s ease,
background-color 0.15s ease;
} }
.card.tone-chain { .card.tone-chain {
+16
View File
@@ -92,6 +92,7 @@ function onFocusOut(event: FocusEvent) {
/> />
</div> </div>
<Transition name="menu">
<ul v-if="open" class="menu" role="listbox" aria-multiselectable="true"> <ul v-if="open" class="menu" role="listbox" aria-multiselectable="true">
<li v-if="options.length === 0" class="none">没有可选的订阅</li> <li v-if="options.length === 0" class="none">没有可选的订阅</li>
<li v-else-if="filtered.length === 0" class="none">没有匹配的订阅</li> <li v-else-if="filtered.length === 0" class="none">没有匹配的订阅</li>
@@ -109,6 +110,7 @@ function onFocusOut(event: FocusEvent) {
</button> </button>
</li> </li>
</ul> </ul>
</Transition>
</div> </div>
</template> </template>
@@ -128,6 +130,7 @@ function onFocusOut(event: FocusEvent) {
border-radius: var(--r-sm); border-radius: var(--r-sm);
background: var(--c-surface); background: var(--c-surface);
cursor: text; cursor: text;
transition: border-color 0.15s ease;
} }
.box:focus-within { .box:focus-within {
@@ -230,4 +233,17 @@ function onFocusOut(event: FocusEvent) {
color: var(--c-text-faint); color: var(--c-text-faint);
font-size: var(--t-sm); font-size: var(--t-sm);
} }
.menu-enter-active,
.menu-leave-active {
transition:
opacity 0.12s ease,
transform 0.12s ease;
}
.menu-enter-from,
.menu-leave-to {
opacity: 0;
transform: translateY(-4px);
}
</style> </style>
+32
View File
@@ -150,6 +150,38 @@ kbd,
outline-offset: 2px; 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 { ::selection {
background: var(--c-accent-soft); background: var(--c-accent-soft);
color: var(--c-accent-text); color: var(--c-accent-text);
+89 -11
View File
@@ -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 type { Inbound } from '@shared/inbounds.ts'
import { import {
autoGroupName, autoGroupName,
@@ -30,8 +30,7 @@ import OutboundCard from '../components/OutboundCard.vue'
import SourcePicker from '../components/SourcePicker.vue' import SourcePicker from '../components/SourcePicker.vue'
import IconEdit from '~icons/ri/edit-line' import IconEdit from '~icons/ri/edit-line'
import IconDelete from '~icons/ri/delete-bin-line' import IconDelete from '~icons/ri/delete-bin-line'
import IconDown from '~icons/ri/arrow-down-s-line' import IconRight from '~icons/ri/arrow-right-s-line'
import IconUp from '~icons/ri/arrow-up-s-line'
import IconOffline from '~icons/ri/cloud-off-line' import IconOffline from '~icons/ri/cloud-off-line'
const props = defineProps<{ const props = defineProps<{
@@ -80,6 +79,26 @@ const activeGroup = computed(
() => props.panel.groups.find((g) => g.name === activeTab.value) ?? null, () => props.panel.groups.find((g) => g.name === activeTab.value) ?? null,
) )
// ── tab 下划线 ──────────────────────────────────────────
// 一条绝对定位的线,位置按活动 tab 的实际尺寸算,切换时靠 transition 滑过去。
// 每个 tab 各画一条做不出「移动」:线是各自的边框,只能一条消失一条出现。
const tabEls = new Map<string, HTMLElement>()
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 proxyLines = computed(() => (activeGroup.value ? membersOf(activeGroup.value) : []))
const chainLines = computed(() => (props.chain ? membersOf(props.chain) : [])) const chainLines = computed(() => (props.chain ? membersOf(props.chain) : []))
@@ -110,6 +129,24 @@ const throughChain = computed(() => {
// ── 配置 ──────────────────────────────────────────────── // ── 配置 ────────────────────────────────────────────────
const open = ref(false) const open = ref(false)
const rootEl = ref<HTMLElement | null>(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<OutboundSetup>({ proxy: { sources: [], filter: null }, chain: null }) const form = ref<OutboundSetup>({ 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(
</script> </script>
<template> <template>
<aside class="detail"> <aside ref="rootEl" class="detail">
<header class="top"> <header class="top">
<div class="title"> <div class="title">
<span class="port mono">{{ inbound.port }}</span> <span class="port mono">{{ inbound.port }}</span>
@@ -362,13 +404,17 @@ const complete = computed(
<button <button
v-for="t in panel.groups" v-for="t in panel.groups"
:key="t.name" :key="t.name"
:ref="(el) => setTabEl(t.name, el)"
type="button" type="button"
:class="['tab', activeTab === t.name && 'is-on']" :class="['tab', activeTab === t.name && 'is-on']"
@click="activeTab = t.name" @click="activeTab = t.name"
> >
{{ t.label }} {{ t.label }}
</button> </button>
<span class="ink" :style="ink"></span>
</div> </div>
<Transition name="pane" mode="out-in">
<div :key="activeTab">
<p v-if="!kernel.online" class="hint"><IconOffline /> 内核未连接</p> <p v-if="!kernel.online" class="hint"><IconOffline /> 内核未连接</p>
<p v-else-if="!proxyDraft && proxyLines.length === 0" class="hint"> <p v-else-if="!proxyDraft && proxyLines.length === 0" class="hint">
这个策略组现在没有成员 这个策略组现在没有成员
@@ -411,6 +457,8 @@ const complete = computed(
@pick="pickProxyCard(card)" @pick="pickProxyCard(card)"
/> />
</div> </div>
</div>
</Transition>
</section> </section>
<!-- 跟着表单里的开关走:拨开立刻出现,拨掉(哪怕还没保存)立刻消失 --> <!-- 跟着表单里的开关走:拨开立刻出现,拨掉(哪怕还没保存)立刻消失 -->
@@ -462,9 +510,9 @@ const complete = computed(
</section> </section>
<section v-if="!panel.shared" class="block setup"> <section v-if="!panel.shared" class="block setup">
<button type="button" class="toggle" @click="open = !open"> <button type="button" class="toggle" @click="toggleOpen">
<span>配置</span> <span>配置</span>
<component :is="open ? IconUp : IconDown" /> <IconRight :class="['chev', open && 'is-open']" />
</button> </button>
<div v-if="open" class="form"> <div v-if="open" class="form">
@@ -498,7 +546,7 @@ const complete = computed(
role="switch" role="switch"
:aria-checked="chainOn" :aria-checked="chainOn"
:class="['switch', chainOn && 'is-on']" :class="['switch', chainOn && 'is-on']"
@click="chainOn = !chainOn" @click="toggleChain"
> >
<span class="knob"></span> <span class="knob"></span>
</button> </button>
@@ -686,16 +734,16 @@ h3 {
} }
.tabs { .tabs {
position: relative;
display: flex; display: flex;
gap: var(--s-4); gap: var(--s-4);
margin-bottom: var(--s-4); margin-bottom: var(--s-4);
} }
/* 选中态是一条离文字有距离的下划线,不画边框 */ /* 选中态是一条离文字有距离的下划线。线是共享的 .ink,切换时滑到活动 tab 底下 */
.tab { .tab {
padding: 0 0 var(--s-2); padding: 0 0 var(--s-2);
border: none; border: none;
border-bottom: 2px solid transparent;
background: none; background: none;
color: var(--c-text-faint); color: var(--c-text-faint);
font-size: var(--t-sm); font-size: var(--t-sm);
@@ -707,10 +755,30 @@ h3 {
} }
.tab.is-on { .tab.is-on {
border-bottom-color: var(--c-accent);
color: var(--c-text); color: var(--c-text);
} }
.ink {
position: absolute;
bottom: 0;
height: 2px;
background: var(--c-accent);
transition:
left 0.15s ease,
width 0.15s ease,
opacity 0.15s ease;
}
.pane-enter-active,
.pane-leave-active {
transition: opacity 0.12s ease;
}
.pane-enter-from,
.pane-leave-to {
opacity: 0;
}
.grid { .grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(168px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(168px, 1fr));
@@ -749,7 +817,8 @@ h3 {
.toggle { .toggle {
display: flex; display: flex;
align-items: center; align-items: center;
gap: var(--s-2); justify-content: space-between;
width: 100%;
padding: 0; padding: 0;
border: none; border: none;
background: none; background: none;
@@ -763,6 +832,15 @@ h3 {
color: var(--c-text); color: var(--c-text);
} }
/* 收起时朝右,展开顺时针转成朝下,收起再逆时针转回去 */
.chev {
transition: transform 0.15s ease;
}
.chev.is-open {
transform: rotate(90deg);
}
/* 表单不套盒子,靠小标题和一条分割线分段(设计基调第 2 条:不要卡片套卡片) */ /* 表单不套盒子,靠小标题和一条分割线分段(设计基调第 2 条:不要卡片套卡片) */
.form { .form {
display: flex; display: flex;
+13 -3
View File
@@ -123,7 +123,7 @@ function submit() {
<dialog ref="dialog" class="dlg" @close="emit('close')" @cancel.prevent="emit('close')"> <dialog ref="dialog" class="dlg" @close="emit('close')" @cancel.prevent="emit('close')">
<form method="dialog" @submit.prevent="submit"> <form method="dialog" @submit.prevent="submit">
<header class="dlg-head"> <header class="dlg-head">
<h2>{{ editing ? '编辑入站' : '新增入站' }}</h2> <h2>{{ editing ? '编辑路由' : '新增路由' }}</h2>
<button type="button" class="x" @click="emit('close')"><IconClose /></button> <button type="button" class="x" @click="emit('close')"><IconClose /></button>
</header> </header>
@@ -218,8 +218,9 @@ function submit() {
} }
.x { .x {
display: grid; display: flex;
place-items: center; align-items: center;
justify-content: center;
width: 28px; width: 28px;
height: 28px; height: 28px;
border: 1px solid transparent; border: 1px solid transparent;
@@ -270,6 +271,15 @@ select {
background: var(--c-bg); background: var(--c-bg);
} }
/* 自绘下拉箭头:原生箭头贴着右边缘,位置调不了。灰色取两套主题中间值 */
select {
appearance: none;
padding-right: var(--s-6);
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' width='16' height='16'%3E%3Cpath fill='%23898f98' d='M12 15.6 5.7 9.3l1.4-1.4 4.9 4.9 4.9-4.9 1.4 1.4z'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right var(--s-3) center;
}
input:focus, input:focus,
select:focus { select:focus {
border-color: var(--c-accent); border-color: var(--c-accent);