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

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

890 lines
28 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, 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 IconDown from '~icons/ri/arrow-down-s-line'
import IconUp from '~icons/ri/arrow-up-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,
)
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 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
},
})
// ── 草稿预览 ────────────────────────────────────────────
// 上半部分的显示规则只有一条:草稿和已保存一致时显示现实;不一致时显示「按草稿保存后
// 的样子」。点击的语义看卡片本身:现在就存在的成员任何时候点了都立刻生效,只有保存后
// 才加入的成员点了是预选。删除预览、新增预览、链式代理开关的预览都是推论,不是特例。
// 这个块必须放在 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 class="detail">
<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"
type="button"
:class="['tab', activeTab === t.name && 'is-on']"
@click="activeTab = t.name"
>
{{ t.label }}
</button>
</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>
</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="open = !open">
<span>配置</span>
<component :is="open ? IconUp : IconDown" />
</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="chainOn = !chainOn"
>
<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>
</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 {
display: flex;
gap: var(--s-4);
margin-bottom: var(--s-4);
}
/* 选中态是一条离文字有距离的下划线,不画边框 */
.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);
cursor: pointer;
}
.tab:hover {
color: var(--c-text-soft);
}
.tab.is-on {
border-bottom-color: var(--c-accent);
color: var(--c-text);
}
.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;
gap: var(--s-2);
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);
}
/* 表单不套盒子,靠小标题和一条分割线分段(设计基调第 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>