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

配置展开和拨开链式代理时,若详情底部长出窗口则平滑滚进视野;tab 切换
不触发,来回切时页面跟着跳只会晕。二次确认对话框点遮罩可关闭,内边距
挪进内层,否则点在 dialog 自己的 padding 上也会被当成遮罩。新增入站的
表单不做遮罩关闭:误点会丢掉填了一半的内容。对话框标题改为「新增路由」
「编辑路由」。select 换自绘箭头,原生箭头贴边且位置调不了。
2026-08-11 16:23:03 +08:00

258 lines
5.7 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
/**
* 一个出站选项:策略组、线路,或者 RULE/DIRECT 这类内置出站。
*
* 左下角那格是延迟:测过就显示毫秒数,没测过显示一个闪电。两种情况都能点,
* 点了就测这一条——「没数字」和「想重测」需要的是同一个动作,没必要做成两个控件。
*/
import IconFlash from '~icons/ri/flashlight-line'
import IconLoad from '~icons/ri/loader-4-line'
defineProps<{
name: string
/** 第二行。策略组显示「类型(当前选中)」,线路显示协议 */
sub: string
/** 最近一次延迟,毫秒。没测过或测失败是 null */
delay: number | null
selected: boolean
/** 不可选时的原因。有值就说明这张卡点不了 */
disabledReason?: string
/** 这张卡的改动正在提交 */
pending?: boolean
/** 草稿预览里保存后才加入的成员 */
draft?: boolean
/** 草稿预览里保存后将移除的成员 */
removed?: boolean
/** 这条线路正在测延迟 */
testing?: boolean
/** 能不能测延迟。内置出站没得测 */
testable?: boolean
/**
* 选中态用哪一组颜色。同一屏上有两种「当前选中」,一种是流量最后从哪出去,
* 一种是流量先经过哪,不区分颜色就分不清哪张卡属于哪一件事。
*/
tone?: 'proxy' | 'chain'
}>()
const emit = defineEmits<{ pick: []; test: [] }>()
function delayClass(ms: number | null): string {
if (ms === null) return ''
if (ms < 200) return 'd-ok'
if (ms < 500) return 'd-warn'
return 'd-bad'
}
</script>
<template>
<div
:class="[
'card',
tone === 'chain' && 'tone-chain',
selected && 'is-on',
disabledReason && 'is-off',
pending && 'is-pending',
draft && 'is-draft',
removed && 'is-removed',
!testable && !pending && 'no-foot',
]"
>
<button
type="button"
class="hit"
:disabled="!!disabledReason"
:title="disabledReason"
@click="emit('pick')"
>
<span class="name">{{ name }}</span>
<span class="sub">{{ sub || '—' }}</span>
</button>
<!-- 没内容就不渲染:空的 foot 会盖在卡片下缘,挡住主按钮的点击 -->
<div v-if="testable || pending" class="foot">
<button
v-if="testable"
type="button"
:class="['delay', delayClass(delay)]"
:disabled="testing"
:title="delay === null ? '测试延迟' : '重新测试'"
@click="emit('test')"
>
<IconLoad v-if="testing" class="spin" />
<template v-else-if="delay !== null">{{ delay }} ms</template>
<IconFlash v-else />
</button>
<span v-else class="delay is-blank"></span>
<IconLoad v-if="pending" class="spin pend" />
</div>
</div>
</template>
<style scoped>
.card {
--tone: var(--c-accent);
--tone-soft: var(--c-accent-soft);
--tone-text: var(--c-accent-text);
display: flex;
flex-direction: column;
min-width: 0;
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 {
--tone: var(--c-chain);
--tone-soft: var(--c-chain-soft);
--tone-text: var(--c-chain-text);
}
.card.is-on {
border-color: var(--tone);
background: var(--tone-soft);
}
.card:not(.is-off):hover {
border-color: var(--tone);
}
.card.is-off {
opacity: 0.5;
}
.card.is-pending {
border-style: dashed;
}
/* 草稿态:虚线区分于实线的现实。将移除的再加删除线,和「只是禁用」分开 */
.card.is-draft,
.card.is-removed {
border-style: dashed;
}
.card.is-removed .name {
text-decoration: line-through;
}
/* 当前选中的线路即将被移除:危险色警示,这是草稿里最该被看见的一张卡 */
.card.is-removed.is-on {
border-color: var(--c-danger);
background: var(--c-danger-soft);
opacity: 1;
}
.card.is-removed.is-on .name {
color: var(--c-danger);
}
.hit {
display: flex;
flex: 1;
flex-direction: column;
gap: var(--s-1);
min-width: 0;
padding: var(--s-3) var(--s-3) var(--s-1);
border: none;
background: none;
text-align: left;
cursor: pointer;
}
/* 没有 foot 时主按钮补回它的全部高度(延迟行 20px + 它的下边距),
草稿卡和真实卡等高,网格不跳动,而且整张卡都是点击区 */
.card.no-foot .hit {
padding-bottom: calc(var(--s-1) + 20px + var(--s-2));
}
.hit:disabled {
cursor: not-allowed;
}
.name {
font-size: var(--t-sm);
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.card.is-on .name {
color: var(--tone-text);
}
.sub {
font-size: var(--t-xs);
color: var(--c-text-faint);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.foot {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--s-2);
padding: 0 var(--s-3) var(--s-2);
}
.delay {
display: flex;
align-items: center;
min-height: 20px;
padding: 0 var(--s-1);
margin-left: calc(-1 * var(--s-1));
border: 1px solid transparent;
border-radius: var(--r-sm);
background: none;
font-family: var(--f-mono);
font-size: var(--t-xs);
font-variant-numeric: tabular-nums;
color: var(--c-text-faint);
cursor: pointer;
}
.delay:hover:not(:disabled) {
background: var(--c-surface);
color: var(--tone-text);
}
.delay:disabled {
cursor: progress;
}
.delay.is-blank {
cursor: default;
}
.d-ok {
color: var(--c-ok);
}
.d-warn {
color: var(--c-warn);
}
.d-bad {
color: var(--c-danger);
}
.spin {
animation: spin 0.9s linear infinite;
}
.pend {
color: var(--c-text-faint);
width: 14px;
height: 14px;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
</style>