Files
mihomo-patchbay/web/components/OutboundCard.vue
T
im0o a4d08892f7 路由页一轮交互细化:草稿预览、预选、若干修正
上半部分收敛成一条规则:草稿和已保存一致时显示内核现状;不一致时显示
按草稿保存后的样子,将移除的画删除线、新增的虚线描边,即将被移除的当前
选中用危险色警示。点击语义只看卡片:现在就存在的成员点了立刻生效,保存
后才加入的点了是预选,保存成功后再作为运行时动作选上。预览的集合运算在
web/member-preview.ts,组件里只留接线。

其余:订阅选择器重写成可搜索多选(原平铺标签订阅一多就是一面墙);删除
入站换成页面内确认对话框;列表右列解析到最终出口并标注链式;「高级配置」
更名「配置」;表单重置改按内容比较(按引用的话任何一次运行时刷新都会冲掉
未保存的草稿,watch 传返回数组的 getter 同样触发这个问题);保存后先刷
运行时再换配置数据,避免卡片区闪空。
2026-08-11 14:39:47 +08:00

255 lines
5.6 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);
}
.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>