Files
mihomo-patchbay/web/components/OutboundCard.vue
T
im0o 8bc97eb47a 卡片 foot 的空白处点击透传为选择
foot 属于卡片,点了没反应像是卡片坏了。延迟按钮 stop 掉冒泡,测延迟
不会连带换线;禁用的卡片照样不可点,光标跟着语义走。
2026-08-11 16:57:28 +08:00

271 lines
6.1 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'
const props = 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: [] }>()
/**
* foot 的空白处也算选择。它属于卡片,点了没反应会让人以为卡片坏了;
* 延迟按钮自己 stop 掉冒泡,测延迟不会连带换线。
*/
function pickFromFoot() {
if (!props.disabledReason) emit('pick')
}
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" @click="pickFromFoot">
<button
v-if="testable"
type="button"
:class="['delay', delayClass(delay)]"
:disabled="testing"
:title="delay === null ? '测试延迟' : '重新测试'"
@click.stop="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);
}
/* 空白处的点击是选择,光标也照选择的样子给 */
.card:not(.is-off) .foot {
cursor: pointer;
}
.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>