Files
mihomo-patchbay/web/views/InboundDetail.vue
T
im0o 5a04968e17 详情切换动画与侧栏折叠动画
详情换选中:内容 out-in 淡切,容器高度从旧值滑到新值。auto 高度不可
插值,做法是离开前钉住当前高度,新内容进场时量出目标值过渡过去,结束
后清掉内联样式交还给内容。

侧栏折叠改成裁切式:只过渡外层宽度,内层内容按展开宽度定死、被 overflow
裁掉,文字用透明度淡出(比宽度动画快,先消失再收边,不露半截字)。原来
用 display: none 藏文字加居中图标,动画中途会重排跳位。折叠宽度取 60px
是因为图标中心恰好在 30px,收拢后天然居中,全程不用挪任何东西;折叠
按钮钉在右下角,跟着边缘走。
2026-08-11 16:34:20 +08:00

1026 lines
32 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">
/**
* 选中那一路入站的详情。
*
* 上下两半,界限就是「配置」那一段上面的分割线:
* 上半 现实与操作。草稿和已保存一致时显示内核现状,点卡片立刻生效(决策记录 0007);
* 不一致时切换成「按草稿保存后的样子」,标「未保存」。现有成员点了照旧立刻
* 生效,只有保存后才加入的成员点了是预选
* 下半 配置草稿。改的是组从哪些订阅取成员,写配置文件,要校验和重载
*
* 两半的反馈方式必须不一样。它们长得一样的时候,用户分不清哪个动作要等、哪个不用。
*/
import { computed, nextTick, ref, watch } from 'vue'
import type { Inbound } from '@shared/inbounds.ts'
import {
autoGroupName,
displayName,
fallbackGroupName,
linesMatching,
proxyGroupName,
} from '@shared/inbound-groups.ts'
import type { GroupSpec, OutboundSetup, PanelGroup } from '@shared/inbound-groups.ts'
import { resolveChain } from '@shared/outbound-chain.ts'
import { latestDelay } from '../api'
import type { GroupView } from '../runtime'
import { isChainedLine, kernel, linesFrom, membersOf, providers, runtime } from '../runtime'
import { mergeMemberPreview } from '../member-preview'
import type { MemberPreview } from '../member-preview'
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 IconRight from '~icons/ri/arrow-right-s-line'
import IconOffline from '~icons/ri/cloud-off-line'
const props = defineProps<{
inbound: Inbound
/** 能操作哪些策略组、各自能不能点。groups 为空表示这一路没有能点的组 */
panel: { groups: PanelGroup[]; shared: boolean }
/** 还有哪些入站也走这个共用组。空数组表示只有自己在用,不需要提醒 */
sharedWith: string[]
/** 链式代理的入口组。null 表示没开 */
chain: GroupView | null
/** 配置文件里定义过的订阅,配置表单里从这里挑来源 */
subscriptions: string[]
/** 当前的来源和筛选,配置表单的初值 */
setup: OutboundSetup | null
/** 正在测延迟的线路 */
testing: string[]
/** 配置正在保存 */
saving: boolean
}>()
const emit = defineEmits<{
edit: []
remove: []
/** 切换某个策略组当前选中谁 */
select: [group: string, member: string]
test: [name: string, provider: string]
/** 保存配置。picks 是预选:保存成功后由父组件对相应组发起真正的选中 */
save: [setup: OutboundSetup, picks: { proxy: string | null; chain: string | null }]
/** 把这一路改成专属代理组模式 */
adopt: []
}>()
/** 默认入站由配置顶层的端口字段声明,没有 proxy 字段可写,也不能删。 */
const locked = computed(() => props.inbound.isDefault)
const activeTab = ref('')
watch(
() => props.panel.groups,
(list) => {
if (!list.some((t) => t.name === activeTab.value)) activeTab.value = list[0]?.name ?? ''
},
{ immediate: true },
)
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) : []))
const nowOf = (group: { name: string } | null) =>
group ? (runtime.value[group.name]?.now ?? '') : ''
const isTesting = (name: string) => props.testing.includes(name)
/**
* 流量最终走到哪。
*
* 从代理组当前选中的那个开始一路跟着「当前选中」查下去。选中的是一条线路就到此为止,
* 选中的是 `AUTO` 这种自动测速组就还要再查一层,否则只显示一个组名等于没回答。
*/
const exitChain = computed(() =>
resolveChain(runtime.value, nowOf(props.panel.groups[0] ?? null) || null),
)
/**
* 这一路的出口(预选时按预选算)是不是真的经过入口。
*
* 开了链式代理不等于每条出口都走中转:`DIRECT` 是点名加进代理组的成员,不属于任何订阅,
* 身上没有 `dialer-proxy`,选中它就是直连。这时画一跳入口是在骗人。
*/
const throughChain = computed(() => {
const exit = flowExit.value.at(-1)
return exit !== undefined && props.chain !== null && isChainedLine(exit)
})
// ── 配置 ────────────────────────────────────────────────
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()
}
/**
* 切换选中的入站时,内容淡出淡入,容器高度从旧值滑到新值。
* 高度过渡没法纯 CSS 做:auto 不可插值。所以离开前把当前高度钉住,
* 新内容进场时量出目标高度、过渡过去,结束后清掉内联样式让内容重新做主。
*/
function lockHeight() {
const el = rootEl.value
if (!el) return
el.style.height = `${String(el.offsetHeight)}px`
el.style.overflow = 'hidden'
}
function settleHeight() {
const el = rootEl.value
if (!el || !el.style.height) return
const from = el.style.height
el.style.height = 'auto'
const to = `${String(el.offsetHeight)}px`
if (to === from) {
el.style.height = ''
el.style.overflow = ''
return
}
el.style.height = from
void el.offsetHeight
el.style.transition = 'height 0.15s ease'
el.style.height = to
el.addEventListener('transitionend', function done(event) {
if (event.propertyName !== 'height') return
el.removeEventListener('transitionend', done)
el.style.transition = ''
el.style.height = ''
el.style.overflow = ''
})
}
const form = ref<OutboundSetup>({ proxy: { sources: [], filter: null }, chain: null })
/**
* 草稿态里预先点中的线路:entry 是链式代理的入口组,pick 是代理组。
*
* 它们不是表单的一部分——不写进配置文件,语义是「保存之后帮我选上它」。保存成功、
* 组按新配置就位之后,父组件把它们作为运行时动作选上。不预选也没关系,选中如果
* 在保存后仍是成员就保持不变,否则落到内核的默认上。
*
* 必须声明在 reset 之前:下面那个 watch 带 immediate,setup 阶段就会执行 reset,
* 这时引用还没初始化的 const 会直接抛错。
*/
const pendingEntry = ref<string | null>(null)
const pendingPick = ref<string | null>(null)
function reset() {
form.value = props.setup
? (JSON.parse(JSON.stringify(props.setup)) as OutboundSetup)
: { proxy: { sources: [...props.subscriptions], filter: null }, chain: null }
pendingEntry.value = null
pendingPick.value = null
}
// 切到别的入站、或保存后配置内容变了,表单要跟着换,否则会把上一路的设置保存到这一路上。
// 两个坑都踩过,都会让随手换一次线就冲掉没保存的草稿:
// 按引用比较不行——setup 是 computed,任何一次运行时刷新都返回内容相同的新对象;
// 包成一个返回数组的 getter 也不行——Vue 对这种源只做整体引用比较,数组每次都是新的。
// 必须是「源数组」,Vue 才逐项比较,字符串相等就不触发。
watch([() => props.inbound.id, () => JSON.stringify(props.setup)], reset, { immediate: true })
const chainOn = computed({
get: () => form.value.chain !== null,
set: (on: boolean) => {
form.value.chain = on ? { sources: [...props.subscriptions], filter: null } : null
},
})
function toggleChain() {
chainOn.value = !chainOn.value
if (chainOn.value) void revealBottom()
}
// ── 草稿预览 ────────────────────────────────────────────
// 上半部分的显示规则只有一条:草稿和已保存一致时显示现实;不一致时显示「按草稿保存后
// 的样子」。点击的语义看卡片本身:现在就存在的成员任何时候点了都立刻生效,只有保存后
// 才加入的成员点了是预选。删除预览、新增预览、链式代理开关的预览都是推论,不是特例。
// 这个块必须放在 form 声明之后:watch 会在 setup 阶段立刻求值一次数据源,放在前面
// 就是对暂时性死区里的 form 取值,整个组件直接抛错。
const specDirty = (a: GroupSpec | null | undefined, b: GroupSpec | null | undefined) =>
JSON.stringify(a ?? null) !== JSON.stringify(b ?? null)
const proxyDraft = computed(
() => props.setup !== null && specDirty(form.value.proxy, props.setup.proxy),
)
const chainDraft = computed(
() =>
form.value.chain !== null && (props.chain === null || specDirty(form.value.chain, props.setup?.chain)),
)
/** 按草稿保存后这个来源规格会给组留下哪些线路。筛选写错时按全要画,反正保存被数字预览拦着。 */
function futureLines(spec: GroupSpec): string[] {
const lines = linesFrom(spec.sources)
return linesMatching(spec.filter, lines) ?? lines
}
/** 给预览里新增的名字找线路信息:先按来源订阅找,找不到再查全局名字表。 */
const lookupIn = (sources: string[]) => (name: string) => {
for (const source of sources) {
const line = (providers.value[source] ?? []).find((item) => item.name === name)
if (line) return line
}
return runtime.value[name]
}
const chainFuture = computed(() => (form.value.chain ? futureLines(form.value.chain) : []))
/** 代理组保存后的完整成员:三个点名成员加订阅线路,顺序和 applySetup 生成的一致。
AUTO 和 FALLBACK 两个 tab 只有线路。 */
const proxyFutureAll = computed(() => [
autoGroupName(props.inbound.name),
fallbackGroupName(props.inbound.name),
'DIRECT',
...futureLines(form.value.proxy),
])
const proxyFuture = computed(() =>
activeTab.value === proxyGroupName(props.inbound.name)
? proxyFutureAll.value
: futureLines(form.value.proxy),
)
// 草稿一变,预选可能已经不在保存后的名单里,留着会在保存后选一条不存在的线
watch(chainFuture, (lines) => {
if (pendingEntry.value !== null && !lines.includes(pendingEntry.value)) pendingEntry.value = null
})
watch(proxyFutureAll, (names) => {
if (pendingPick.value !== null && !names.includes(pendingPick.value)) pendingPick.value = null
})
// 预选只在草稿态里有意义。退出草稿的路一共四条:取消、切换入站、保存成功(这三条走
// reset),以及把表单改回和已保存一致(走这里)。漏掉这条的话,预选和「未保存」标记
// 会留在一个没有东西可保存的界面上。
watch(proxyDraft, (on) => {
if (!on) pendingPick.value = null
})
watch(chainDraft, (on) => {
if (!on) pendingEntry.value = null
})
const proxyCards = computed(() =>
mergeMemberPreview(proxyLines.value, proxyFuture.value, lookupIn(form.value.proxy.sources)),
)
const chainCards = computed(() =>
mergeMemberPreview(
props.chain ? chainLines.value : [],
chainFuture.value,
lookupIn(form.value.chain?.sources ?? []),
),
)
/**
* 草稿态里点一张卡是什么动作,取决于这张卡现在存不存在:
* 现在就是成员的 和平时一样立刻换线。真实切换让预选失去意义,一并清掉
* 保存后才加入的 预选,保存成功后由父组件真正选上
* 只有后者才需要「等保存」,把两者都做成预选的话,撤销草稿会让用户以为切换被回退了。
*/
function pickProxyCard(card: MemberPreview) {
if (card.added) {
pendingPick.value = card.name
} else {
pendingPick.value = null
emit('select', activeTab.value, card.name)
}
}
function pickChainCard(card: MemberPreview) {
if (card.added || props.chain === null) {
pendingEntry.value = card.name
} else {
pendingEntry.value = null
emit('select', props.chain.name, card.name)
}
}
/** 当前筛选条件会留下几条线路。写错了 kept 是 null,界面据此提示而不是显示错的数字。 */
function preview(spec: { sources: string[]; filter: string | null }) {
const names = linesFrom(spec.sources)
return { total: names.length, kept: linesMatching(spec.filter, names)?.length ?? null }
}
const dirty = computed(() => JSON.stringify(form.value) !== JSON.stringify(props.setup))
/**
* 流量去向里入口那一跳画不画、画谁。null 表示不画。
*
* 以表单里的开关为准而不是已保存的状态:拨动开关立刻在链条上看到差别,「保存后会变成
* 什么样」在按保存前就有答案。差别还没生效这件事由末尾的「未保存」标记交代。
*/
const flowEntry = computed<string | null>(() => {
if (form.value.chain === null) return null
// 预选优先;入口组还不存在且没预选时画一个空位
if (pendingEntry.value) return pendingEntry.value
if (props.chain === null) return ''
return throughChain.value ? nowOf(props.chain) : null
})
/** 出口链。预选了新的出口时按预选解析,这也是「保存后的样子」。 */
const flowExit = computed(() =>
pendingPick.value ? resolveChain(runtime.value, pendingPick.value) : exitChain.value,
)
/** 流量去向画的是保存后的样子而不是现状:开关没保存,或者有预选。 */
const flowPreview = computed(
() =>
(form.value.chain !== null) !== (props.chain !== null) ||
pendingPick.value !== null ||
pendingEntry.value !== null,
)
/**
* 至少要选一个订阅。AUTO 和 FALLBACK 只有 `use:` 没有点名成员,一个订阅都不选就是空组,
* 内核会直接拒绝整份配置(内核源码 adapter/outboundgroup/parser.go:117-119)。
* 在这里拦住,比让用户按下保存再读一段内核报错好。
*/
const filled = (spec: { sources: string[] }) => spec.sources.length > 0
const complete = computed(
() => filled(form.value.proxy) && (form.value.chain === null || filled(form.value.chain)),
)
</script>
<template>
<aside ref="rootEl" class="detail">
<!-- 换一路入站:淡出旧内容、淡入新内容,高度的过渡由上面两个钩子做 -->
<Transition
name="swap"
mode="out-in"
@before-leave="lockHeight"
@enter="settleHeight"
@after-enter="moveInk"
>
<div :key="inbound.id">
<header class="top">
<div class="title">
<span class="port mono">{{ inbound.port }}</span>
<span class="name">{{ inbound.name }}</span>
<div v-if="!locked" class="acts">
<button type="button" @click="emit('edit')"><IconEdit /> 编辑</button>
<button type="button" class="danger" @click="emit('remove')">
<IconDelete /> 删除
</button>
</div>
</div>
<dl class="facts">
<dt>类型</dt>
<dd class="mono">{{ inbound.type }}</dd>
<dt>监听</dt>
<dd class="mono">{{ inbound.listen ?? '0.0.0.0' }}</dd>
<template v-if="inbound.ruleSet">
<dt>附加规则</dt>
<dd class="mono">{{ inbound.ruleSet }}</dd>
</template>
<template v-if="flowExit.length">
<dt>流量去向</dt>
<dd class="flow mono">
<template v-if="flowEntry !== null">
<span class="hop is-chain">{{ displayName(flowEntry) || '未选择' }}</span>
<span class="arrow">→</span>
</template>
<template v-for="(hop, i) in flowExit" :key="hop">
<span v-if="i" class="arrow">→</span>
<span :class="['hop', i === flowExit.length - 1 && 'is-proxy']">{{
displayName(hop)
}}</span>
</template>
<span v-if="flowPreview" class="unsaved">(未保存)</span>
</dd>
</template>
</dl>
</header>
<!-- 链条上一个能点的组都没有:只能说明现状 -->
<section v-if="panel.groups.length === 0" class="block">
<p class="hint">没有可切换的策略组。</p>
<button v-if="!locked" type="button" class="adopt" :disabled="saving" @click="emit('adopt')">
改成逐条选线路
</button>
</section>
<template v-else>
<section class="block">
<h3>
代理
<span
v-if="panel.shared && sharedWith.length > 0"
class="tag"
:title="`换的是 ${panel.groups[0]?.name},${sharedWith.join('、')} 也走它`"
>共用</span
>
<span v-if="proxyDraft" class="tag">未保存</span>
</h3>
<div v-if="panel.groups.length > 1" class="tabs">
<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>
<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>
<!-- 跟着表单里的开关走:拨开立刻出现,拨掉(哪怕还没保存)立刻消失 -->
<section v-if="form.chain" class="block">
<h3>
链式代理
<span v-if="chainDraft" class="tag">未保存</span>
</h3>
<!-- 草稿态:显示保存后的成员。现有成员点了立刻生效,保存后才加入的点了是预选 -->
<div v-if="chainDraft" class="grid">
<OutboundCard
v-for="card in chainCards"
:key="card.name"
:name="card.name"
:sub="card.info?.type ?? ''"
:delay="latestDelay(card.info)"
:selected="card.name === (pendingEntry ?? nowOf(chain))"
:disabled-reason="card.removed ? '保存后将从组里移除' : undefined"
:draft="card.added"
:removed="card.removed"
tone="chain"
@pick="pickChainCard(card)"
/>
</div>
<div v-else-if="chain" class="grid">
<OutboundCard
v-for="line in chainLines"
:key="line.name"
:name="line.name"
:sub="line.info?.type ?? ''"
:delay="latestDelay(line.info)"
:selected="line.name === nowOf(chain)"
:testing="isTesting(line.name)"
testable
tone="chain"
@pick="emit('select', chain.name, line.name)"
@test="emit('test', line.name, line.provider)"
/>
</div>
</section>
<!-- 共用的组只能换选中,改不了它的成员来源:那是别人的组 -->
<section v-if="panel.shared && !locked" class="block">
<button type="button" class="adopt" :disabled="saving" @click="emit('adopt')">
改成逐条选线路
</button>
</section>
<section v-if="!panel.shared" class="block setup">
<button type="button" class="toggle" @click="toggleOpen">
<span>配置</span>
<IconRight :class="['chev', open && 'is-open']" />
</button>
<div v-if="open" class="form">
<h4>代理</h4>
<div class="row">
<span class="label">订阅</span>
<SourcePicker v-model="form.proxy.sources" :options="subscriptions" />
</div>
<div class="row">
<span class="label">筛选</span>
<div class="field">
<input v-model="form.proxy.filter" placeholder="正则,留空表示全部保留" />
<p class="count">
<template v-if="preview(form.proxy).kept === null">筛选条件写法有误</template>
<template v-else>
{{ preview(form.proxy).total }} 条线路里匹配到 {{ preview(form.proxy).kept }} 条
</template>
</p>
</div>
</div>
<h4>链式代理</h4>
<div class="row">
<span class="label">启用</span>
<div class="field">
<button
type="button"
role="switch"
:aria-checked="chainOn"
:class="['switch', chainOn && 'is-on']"
@click="toggleChain"
>
<span class="knob"></span>
</button>
</div>
</div>
<template v-if="form.chain">
<div class="row">
<span class="label">订阅</span>
<SourcePicker v-model="form.chain!.sources" :options="subscriptions" />
</div>
<div class="row">
<span class="label">筛选</span>
<div class="field">
<input v-model="form.chain.filter" placeholder="正则,建议筛到作为中转的那一条" />
<p class="count">
<template v-if="preview(form.chain).kept === null">筛选条件写法有误</template>
<template v-else>
{{ preview(form.chain).total }} 条线路里匹配到
{{ preview(form.chain).kept }} 条
</template>
</p>
</div>
</div>
</template>
<div class="form-acts">
<button type="button" :disabled="!dirty || saving" @click="reset">取消</button>
<button
type="button"
class="primary"
:disabled="!dirty || !complete || saving"
@click="emit('save', form, { proxy: pendingPick, chain: pendingEntry })"
>
{{ saving ? '保存中' : '保存' }}
</button>
</div>
</div>
</section>
</template>
</div>
</Transition>
</aside>
</template>
<style scoped>
.detail {
border: 1px solid var(--c-border);
border-radius: var(--r-md);
background: var(--c-surface);
padding: var(--s-4) var(--s-5) var(--s-5);
align-self: start;
position: sticky;
top: var(--s-6);
}
.top {
padding-bottom: var(--s-4);
border-bottom: 1px solid var(--c-border);
}
.title {
display: flex;
align-items: baseline;
gap: var(--s-3);
margin-bottom: var(--s-3);
}
.port {
font-size: var(--t-xl);
font-variant-numeric: tabular-nums;
}
.name {
color: var(--c-text-soft);
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.acts {
display: flex;
gap: var(--s-2);
margin-left: auto;
}
.acts button {
display: flex;
align-items: center;
gap: var(--s-1);
padding: var(--s-1) var(--s-3);
border: 1px solid var(--c-border-strong);
border-radius: var(--r-sm);
background: var(--c-surface);
font-size: var(--t-sm);
cursor: pointer;
}
.acts button:hover {
border-color: var(--c-accent);
color: var(--c-accent-text);
}
.acts button.danger:hover {
border-color: var(--c-danger);
color: var(--c-danger);
}
.facts {
display: grid;
grid-template-columns: auto 1fr;
gap: var(--s-1) var(--s-4);
margin: 0;
font-size: var(--t-sm);
}
.facts dt {
color: var(--c-text-faint);
}
.facts dd {
margin: 0;
min-width: 0;
}
.flow {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--s-2);
}
.arrow {
color: var(--c-text-faint);
}
.hop {
color: var(--c-text-soft);
}
/* 两跳用两种颜色,和下面卡片的选中态对得上 */
.hop.is-proxy {
color: var(--c-accent-text);
font-weight: 500;
}
.hop.is-chain {
color: var(--c-chain-text);
font-weight: 500;
}
.unsaved {
color: var(--c-warn);
font-size: var(--t-xs);
}
.block {
padding-top: var(--s-4);
}
.block + .block {
margin-top: var(--s-5);
border-top: 1px solid var(--c-border);
}
h3 {
display: flex;
align-items: center;
gap: var(--s-2);
font-size: var(--t-sm);
font-weight: 600;
color: var(--c-text);
letter-spacing: 0.02em;
margin-bottom: var(--s-4);
}
.tag {
padding: 0 var(--s-2);
border: 1px solid var(--c-border-strong);
border-radius: 999px;
font-size: var(--t-xs);
font-weight: 400;
color: var(--c-text-faint);
cursor: help;
}
.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;
background: none;
color: var(--c-text-faint);
font-size: var(--t-sm);
cursor: pointer;
}
.tab:hover {
color: var(--c-text-soft);
}
.tab.is-on {
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;
}
/* 切换入站的内容淡切。高度的过渡不在这里,由 lockHeight/settleHeight 用内联样式做 */
.swap-enter-active,
.swap-leave-active {
transition: opacity 0.12s ease;
}
.swap-enter-from,
.swap-leave-to {
opacity: 0;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(168px, 1fr));
gap: var(--s-2);
}
.hint {
display: flex;
align-items: center;
gap: var(--s-2);
color: var(--c-text-faint);
font-size: var(--t-sm);
line-height: 1.6;
}
.adopt {
padding: var(--s-1) var(--s-4);
border: 1px solid var(--c-border-strong);
border-radius: var(--r-sm);
background: var(--c-surface);
font-size: var(--t-sm);
cursor: pointer;
}
.adopt:hover:not(:disabled) {
border-color: var(--c-accent);
color: var(--c-accent-text);
}
.adopt:disabled {
color: var(--c-text-faint);
cursor: not-allowed;
}
/* ── 配置 ───────────────────────────────── */
.toggle {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 0;
border: none;
background: none;
color: var(--c-text-soft);
font-size: var(--t-sm);
font-weight: 600;
cursor: pointer;
}
.toggle:hover {
color: var(--c-text);
}
/* 收起时朝右,展开顺时针转成朝下,收起再逆时针转回去 */
.chev {
transition: transform 0.15s ease;
}
.chev.is-open {
transform: rotate(90deg);
}
/* 表单不套盒子,靠小标题和一条分割线分段(设计基调第 2 条:不要卡片套卡片) */
.form {
display: flex;
flex-direction: column;
gap: var(--s-3);
margin-top: var(--s-4);
}
.form h4 {
font-size: var(--t-sm);
font-weight: 600;
letter-spacing: 0.04em;
color: var(--c-text-faint);
/* .form 的 flex gap 是统一的 12px,负 margin 单独收窄标题和它第一行之间的距离 */
margin-bottom: calc(-1 * var(--s-1));
}
.form h4:not(:first-child) {
margin-top: var(--s-4);
}
.row {
display: grid;
grid-template-columns: 4.5em minmax(0, 1fr);
align-items: start;
gap: var(--s-3);
}
/* 标签和第一行控件对齐。控件本身高 34px,所以标签也按 34px 居中 */
.label {
display: flex;
align-items: center;
min-height: 34px;
color: var(--c-text-faint);
font-size: var(--t-sm);
}
.field {
display: flex;
min-height: 34px;
flex-direction: column;
justify-content: center;
}
select,
input {
height: 34px;
padding: 0 var(--s-3);
border: 1px solid var(--c-border-strong);
border-radius: var(--r-sm);
background: var(--c-surface);
font-size: var(--t-base);
}
input {
width: 100%;
font-family: var(--f-mono);
}
.count {
margin-top: var(--s-1);
color: var(--c-text-faint);
font-size: var(--t-xs);
}
.switch {
flex: none;
align-self: flex-start;
width: 38px;
height: 22px;
padding: 2px;
border: 1px solid var(--c-border-strong);
border-radius: 999px;
background: var(--c-surface-sunken);
cursor: pointer;
}
.switch .knob {
display: block;
width: 16px;
height: 16px;
border-radius: 50%;
background: var(--c-text-faint);
transition:
transform 0.15s ease,
background 0.15s ease;
}
.switch.is-on {
border-color: var(--c-chain);
background: var(--c-chain-soft);
}
.switch.is-on .knob {
background: var(--c-chain);
transform: translateX(16px);
}
.form-acts {
display: flex;
justify-content: flex-end;
gap: var(--s-2);
}
.form-acts button {
padding: var(--s-1) var(--s-4);
border: 1px solid var(--c-border-strong);
border-radius: var(--r-sm);
background: var(--c-surface);
font-size: var(--t-sm);
cursor: pointer;
}
.form-acts button:disabled {
color: var(--c-text-faint);
cursor: not-allowed;
}
.form-acts .primary:not(:disabled) {
border-color: var(--c-accent);
background: var(--c-accent-soft);
color: var(--c-accent-text);
}
</style>