路由页动效与细节一批
基调是简单快速:hover 颜色 0.15s 过渡(全局规则,只过渡颜色不碰布局)、 对话框入场淡入加 6px 上移、tab 下划线改成共享的一条并在切换时滑动、tab 内容 out-in 淡切、配置折叠箭头居右随展开旋转 90 度、订阅下拉 0.12s 出入场。 不引 animate.css:这些效果各自十几行 CSS,引库只多不少。 配置展开和拨开链式代理时,若详情底部长出窗口则平滑滚进视野;tab 切换 不触发,来回切时页面跟着跳只会晕。二次确认对话框点遮罩可关闭,内边距 挪进内层,否则点在 dialog 自己的 padding 上也会被当成遮罩。新增入站的 表单不做遮罩关闭:误点会丢掉填了一半的内容。对话框标题改为「新增路由」 「编辑路由」。select 换自绘箭头,原生箭头贴边且位置调不了。
This commit is contained in:
+131
-53
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user