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