路由页动效与细节一批
基调是简单快速: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()
|
||||
},
|
||||
)
|
||||
|
||||
/** 点在遮罩上就关闭。内容区的点击 target 是 .body 里的元素,到不了 dialog 本身。 */
|
||||
function onBackdrop(event: MouseEvent) {
|
||||
if (event.target === dialog.value) emit('close')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<dialog ref="dialog" class="dlg" @close="emit('close')" @cancel.prevent="emit('close')">
|
||||
<p class="msg">{{ message }}</p>
|
||||
<footer class="acts">
|
||||
<button type="button" class="btn" @click="emit('close')">取消</button>
|
||||
<button type="button" class="btn go" @click="emit('confirm')">{{ action }}</button>
|
||||
</footer>
|
||||
<dialog
|
||||
ref="dialog"
|
||||
class="dlg"
|
||||
@click="onBackdrop"
|
||||
@close="emit('close')"
|
||||
@cancel.prevent="emit('close')"
|
||||
>
|
||||
<div class="body">
|
||||
<p class="msg">{{ message }}</p>
|
||||
<footer class="acts">
|
||||
<button type="button" class="btn" @click="emit('close')">取消</button>
|
||||
<button type="button" class="btn go" @click="emit('confirm')">{{ action }}</button>
|
||||
</footer>
|
||||
</div>
|
||||
</dialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.dlg {
|
||||
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-radius: var(--r-lg);
|
||||
background: var(--c-surface);
|
||||
color: var(--c-text);
|
||||
}
|
||||
|
||||
.body {
|
||||
padding: var(--s-5);
|
||||
}
|
||||
|
||||
.dlg::backdrop {
|
||||
background: rgb(0 0 0 / 0.45);
|
||||
}
|
||||
|
||||
@@ -100,6 +100,9 @@ function delayClass(ms: number | null): string {
|
||||
border: 1px solid var(--c-border);
|
||||
border-radius: var(--r-sm);
|
||||
background: var(--c-surface-sunken);
|
||||
transition:
|
||||
border-color 0.15s ease,
|
||||
background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.card.tone-chain {
|
||||
|
||||
@@ -92,7 +92,8 @@ function onFocusOut(event: FocusEvent) {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ul v-if="open" class="menu" role="listbox" aria-multiselectable="true">
|
||||
<Transition name="menu">
|
||||
<ul v-if="open" class="menu" role="listbox" aria-multiselectable="true">
|
||||
<li v-if="options.length === 0" class="none">没有可选的订阅</li>
|
||||
<li v-else-if="filtered.length === 0" class="none">没有匹配的订阅</li>
|
||||
<li v-for="(name, i) in filtered" :key="name">
|
||||
@@ -108,7 +109,8 @@ function onFocusOut(event: FocusEvent) {
|
||||
{{ name }}
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</ul>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -128,6 +130,7 @@ function onFocusOut(event: FocusEvent) {
|
||||
border-radius: var(--r-sm);
|
||||
background: var(--c-surface);
|
||||
cursor: text;
|
||||
transition: border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.box:focus-within {
|
||||
@@ -230,4 +233,17 @@ function onFocusOut(event: FocusEvent) {
|
||||
color: var(--c-text-faint);
|
||||
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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
+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;
|
||||
|
||||
@@ -123,7 +123,7 @@ function submit() {
|
||||
<dialog ref="dialog" class="dlg" @close="emit('close')" @cancel.prevent="emit('close')">
|
||||
<form method="dialog" @submit.prevent="submit">
|
||||
<header class="dlg-head">
|
||||
<h2>{{ editing ? '编辑入站' : '新增入站' }}</h2>
|
||||
<h2>{{ editing ? '编辑路由' : '新增路由' }}</h2>
|
||||
<button type="button" class="x" @click="emit('close')"><IconClose /></button>
|
||||
</header>
|
||||
|
||||
@@ -218,8 +218,9 @@ function submit() {
|
||||
}
|
||||
|
||||
.x {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: 1px solid transparent;
|
||||
@@ -270,6 +271,15 @@ select {
|
||||
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,
|
||||
select:focus {
|
||||
border-color: var(--c-accent);
|
||||
|
||||
Reference in New Issue
Block a user