路由页动效与细节一批

基调是简单快速: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
+131 -53
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 {
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<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 chainLines = computed(() => (props.chain ? membersOf(props.chain) : []))
@@ -110,6 +129,24 @@ const throughChain = computed(() => {
// ── 配置 ────────────────────────────────────────────────
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 })
/**
@@ -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>
<template>
<aside class="detail">
<aside ref="rootEl" class="detail">
<header class="top">
<div class="title">
<span class="port mono">{{ inbound.port }}</span>
@@ -362,55 +404,61 @@ const complete = computed(
<button
v-for="t in panel.groups"
:key="t.name"
:ref="(el) => setTabEl(t.name, el)"
type="button"
:class="['tab', activeTab === t.name && 'is-on']"
@click="activeTab = t.name"
>
{{ t.label }}
</button>
<span class="ink" :style="ink"></span>
</div>
<p v-if="!kernel.online" class="hint"><IconOffline /> 内核未连接</p>
<p v-else-if="!proxyDraft && proxyLines.length === 0" class="hint">
这个策略组现在没有成员
</p>
<div v-else-if="!proxyDraft" class="grid">
<OutboundCard
v-for="line in proxyLines"
:key="line.name"
:name="displayName(line.name)"
:sub="line.info?.type ?? ''"
:delay="latestDelay(line.info)"
:selected="line.name === nowOf(activeGroup)"
:disabled-reason="activeGroup?.pickable ? undefined : '这个组自己决定用哪条'"
:testing="isTesting(line.name)"
testable
tone="proxy"
@pick="emit('select', activeTab, line.name)"
@test="emit('test', line.name, line.provider)"
/>
</div>
<!-- 草稿态:显示保存后的成员。现有成员点了立刻生效,保存后才加入的点了是预选 -->
<div v-else class="grid">
<OutboundCard
v-for="card in proxyCards"
:key="card.name"
:name="displayName(card.name)"
:sub="card.info?.type ?? ''"
:delay="latestDelay(card.info)"
:selected="card.name === (pendingPick ?? nowOf(activeGroup))"
:disabled-reason="
card.removed
? '保存后将从组里移除'
: activeGroup?.pickable
? undefined
: '这个组自己决定用哪条'
"
:draft="card.added"
:removed="card.removed"
tone="proxy"
@pick="pickProxyCard(card)"
/>
</div>
<Transition name="pane" mode="out-in">
<div :key="activeTab">
<p v-if="!kernel.online" class="hint"><IconOffline /> 内核未连接</p>
<p v-else-if="!proxyDraft && proxyLines.length === 0" class="hint">
这个策略组现在没有成员
</p>
<div v-else-if="!proxyDraft" class="grid">
<OutboundCard
v-for="line in proxyLines"
:key="line.name"
:name="displayName(line.name)"
:sub="line.info?.type ?? ''"
:delay="latestDelay(line.info)"
:selected="line.name === nowOf(activeGroup)"
:disabled-reason="activeGroup?.pickable ? undefined : '这个组自己决定用哪条'"
:testing="isTesting(line.name)"
testable
tone="proxy"
@pick="emit('select', activeTab, line.name)"
@test="emit('test', line.name, line.provider)"
/>
</div>
<!-- 草稿态:显示保存后的成员。现有成员点了立刻生效,保存后才加入的点了是预选 -->
<div v-else class="grid">
<OutboundCard
v-for="card in proxyCards"
:key="card.name"
:name="displayName(card.name)"
:sub="card.info?.type ?? ''"
:delay="latestDelay(card.info)"
:selected="card.name === (pendingPick ?? nowOf(activeGroup))"
:disabled-reason="
card.removed
? '保存后将从组里移除'
: activeGroup?.pickable
? undefined
: '这个组自己决定用哪条'
"
:draft="card.added"
:removed="card.removed"
tone="proxy"
@pick="pickProxyCard(card)"
/>
</div>
</div>
</Transition>
</section>
<!-- 跟着表单里的开关走:拨开立刻出现,拨掉(哪怕还没保存)立刻消失 -->
@@ -462,9 +510,9 @@ const complete = computed(
</section>
<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>
<component :is="open ? IconUp : IconDown" />
<IconRight :class="['chev', open && 'is-open']" />
</button>
<div v-if="open" class="form">
@@ -498,7 +546,7 @@ const complete = computed(
role="switch"
:aria-checked="chainOn"
:class="['switch', chainOn && 'is-on']"
@click="chainOn = !chainOn"
@click="toggleChain"
>
<span class="knob"></span>
</button>
@@ -686,16 +734,16 @@ h3 {
}
.tabs {
position: relative;
display: flex;
gap: var(--s-4);
margin-bottom: var(--s-4);
}
/* 选中态是一条离文字有距离的下划线,不画边框 */
/* 选中态是一条离文字有距离的下划线。线是共享的 .ink,切换时滑到活动 tab 底下 */
.tab {
padding: 0 0 var(--s-2);
border: none;
border-bottom: 2px solid transparent;
background: none;
color: var(--c-text-faint);
font-size: var(--t-sm);
@@ -707,10 +755,30 @@ h3 {
}
.tab.is-on {
border-bottom-color: var(--c-accent);
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 {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(168px, 1fr));
@@ -749,7 +817,8 @@ h3 {
.toggle {
display: flex;
align-items: center;
gap: var(--s-2);
justify-content: space-between;
width: 100%;
padding: 0;
border: none;
background: none;
@@ -763,6 +832,15 @@ h3 {
color: var(--c-text);
}
/* 收起时朝右,展开顺时针转成朝下,收起再逆时针转回去 */
.chev {
transition: transform 0.15s ease;
}
.chev.is-open {
transform: rotate(90deg);
}
/* 表单不套盒子,靠小标题和一条分割线分段(设计基调第 2 条:不要卡片套卡片) */
.form {
display: flex;