Files
Thing/src/modules/terminal/components/HostManager.vue
T
2026-09-22 19:10:16 +08:00

834 lines
34 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">
/**
* SSH 主机管理。
*
* # 两个刻意的取舍
*
* 1. **密码与配置分开保存**。配置走 `terminal_save_host`(落 settings.json),
* 密码走 `terminal_set_host_password`(落系统凭据管理器)。看似麻烦,
* 但这是唯一能保证「密码永不明文落盘」的路子——合并成一个保存动作时,
* 前端必须把密码回传给后端,而那个 payload 会经过 IPC、可能进日志。
*
* 2. **新建时先向要一个 id**。密码按 hostId 存取,若等 save_host 之后再生成 id
* 用户在新建对话框里填的密码就没有归属。因此用 `terminal_new_host_id`
* 预取 id,使「新建」与「编辑」走完全相同的流程。
*
* # 已知主机(known_hosts)在这里而不是独立页(2026-09-22)
*
* 「这台主机我信任过吗、指纹变过没有」是**浏览主机列表时顺手核对**的信息,
* 此前它占着「凭据」页的一个分区,于是核对信任要跨 tab,而每个 tab 里都只有
* 半张信息。现在三层都落在本页:
*
* - 主机行的**信任徽标**(未连接过 / 已信任 / 变更 N 次)——一眼可见;
* - 徽标点开的 **HostTrustPopover**——各算法指纹、变更历史、「忘记」;
* - 列表末尾的 **OrphanKnownHosts**——没有对应主机配置的记录(配置已删或
* 从未保存过配置),它们照样会拦截连接,不能失去入口。
*
* 整份 known_hosts 的维护动作(导入 / 导出 / 清空)是低频操作,收进工具条的
* 「已知主机」下拉(`KnownHostsMenu`),不占页面主线。
*/
import { computed, ref, watch } from 'vue'
import { open as openDialog, save as saveDialog } from '@tauri-apps/plugin-dialog'
import {
AlertTriangle,
ArrowDown,
ArrowUp,
Download,
Eye,
EyeOff,
Globe,
KeyRound,
Loader2,
Pencil,
Plus,
Star,
Trash2,
Upload
} from '@lucide/vue'
import { toast } from 'vue-sonner'
import { useTerminalStore } from '@/stores/terminalStore'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from '@/components/ui/select'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Card, CardContent } from '@/components/ui/card'
import { StateBlock, StatusBadge } from '@/components/common'
import { createLogger } from '@/lib/logger'
import { blankHost, type SshHost } from './hostForm'
import HostTrustBadge from './HostTrustBadge.vue'
import KnownHostsMenu from './KnownHostsMenu.vue'
import OrphanKnownHosts from './OrphanKnownHosts.vue'
import { addressLabel, groupByAddress, recordsForHost, trustState } from './hostTrust'
import PageToolbar from '@/components/layout/PageToolbar.vue'
import type { HostView, KnownHost } from '@/types/terminal'
const logger = createLogger('terminal')
const store = useTerminalStore()
// 只声明 connect:本页现在是模块级 Tabs 的一页,不再需要「返回终端」语义
// (导航由 Tabs 承担),此前声明的 close 事件从未被触发过。
const emit = defineEmits<{ (e: 'connect', hostId: string): void }>()
// ===== 编辑对话框 =====
const dialogOpen = ref(false)
const editing = ref<SshHost | null>(null)
/** 编辑中的密码(空串 = 不修改;新建时为用户输入的初值) */
const password = ref('')
const showPassword = ref(false)
const saving = ref(false)
const isNew = ref(false)
/** 表单校验错误(按字段) */
const errors = ref<Record<string, string>>({})
function validate(h: SshHost): boolean {
const e: Record<string, string> = {}
if (!h.name.trim()) e.name = '请填写显示名称'
if (!h.host.trim()) e.host = '请填写主机地址'
else if (/\s/.test(h.host.trim())) e.host = '主机地址不能包含空格'
if (!h.username.trim()) e.username = '请填写登录用户名'
if (h.port < 1 || h.port > 65535) e.port = '端口需在 165535 之间'
if (h.authMethod === 'key' && !h.keyId) e.keyId = '请选择用于认证的密钥'
// 跳板链:与后端 validate_jump_refs 的规则保持一致(不自引用、不成环)
if (h.jumpIds.some(id => id === h.id)) e.jumpIds = '跳板链不能包含主机自身'
else if (new Set(h.jumpIds).size !== h.jumpIds.length) e.jumpIds = '跳板链中有重复项'
errors.value = e
return Object.keys(e).length === 0
}
async function openNew() {
try {
const id = await store.newHostId()
editing.value = blankHost(id)
isNew.value = true
password.value = ''
errors.value = {}
dialogOpen.value = true
} catch (e) {
toast.error(`生成主机 ID 失败:${String(e)}`)
}
}
function openEdit(hostId: string) {
const v = store.hosts.find(h => h.config.id === hostId)
if (!v) return
// 深拷贝:取消时不能污染 store 中的原对象
editing.value = JSON.parse(JSON.stringify(v.config)) as SshHost
isNew.value = false
// 已有密码时留空表示「不修改」,避免用户误以为要重新输入
password.value = ''
errors.value = {}
dialogOpen.value = true
}
/** 下拉选择密钥时,把 SSH 认证方式自动切到 key(用户选了密钥却还用密码认证是矛盾的) */
watch(
() => editing.value?.keyId,
v => {
if (v && editing.value && editing.value.authMethod !== 'key') {
editing.value.authMethod = 'key'
}
}
)
async function save() {
const h = editing.value
if (!h || !validate(h)) return
saving.value = true
try {
await store.saveHost(h)
// 密码单独保存:仅当用户实际输入了内容
if (password.value) {
const r = await store.setHostPassword(h.id, password.value)
if (!r.ok) {
toast.warning(`主机已保存,但密码未能写入凭据管理器:${r.message}`)
} else {
toast.success(isNew.value ? '主机已添加' : '主机已更新')
}
} else {
toast.success(isNew.value ? '主机已添加' : '主机已更新')
}
dialogOpen.value = false
} catch (e) {
logger.error(`保存主机失败:${String(e)}`)
toast.error(`保存失败:${String(e)}`)
} finally {
saving.value = false
}
}
async function remove(v: HostView) {
const ok = await confirmRemove(v)
if (!ok) return
try {
await store.deleteHost(v.config.id)
toast.success(`已删除主机「${v.config.name || v.config.host}」`)
} catch (e) {
toast.error(`删除失败:${String(e)}`)
}
}
/** 删除确认(用简单的模态状态而非 window.confirm:后者在 WebView 里样式不可控) */
const pendingRemove = ref<HostView | null>(null)
function confirmRemove(v: HostView): Promise<boolean> {
return new Promise(resolve => {
pendingRemove.value = v
removeResolve = resolve
})
}
let removeResolve: ((ok: boolean) => void) | null = null
function resolveRemove(ok: boolean) {
removeResolve?.(ok)
removeResolve = null
pendingRemove.value = null
}
async function importConfig() {
try {
const r = await store.importSshConfig()
if (r.ok) toast.success(r.message)
else toast.error(r.message)
} catch (e) {
toast.error(`导入失败:${String(e)}`)
}
}
/**
* 导出主机配置(JSON,不含密码与私钥)。
*
* 路径交给系统保存对话框(前端选路径、后端写内容——与其他文件
* 写出操作的分工一致)。导出提示里明确说「密码不在备份内」,
* 避免用户以为换机后不用重填凭据。
*/
async function exportHosts() {
try {
const stamp = new Date().toISOString().slice(0, 10)
const path = await saveDialog({
title: '导出主机配置',
defaultPath: `terminal-hosts-${stamp}.json`,
filters: [{ name: 'JSON', extensions: ['json'] }]
})
if (typeof path !== 'string') return
const r = await store.exportHosts(path)
if (r.ok) toast.success(r.message)
else toast.warning(r.message)
} catch (e) {
toast.error(`导出失败:${String(e)}`)
}
}
/** 从 JSON 备份导入主机(自动重编 id、重写跳板链、去重) */
async function importHosts() {
try {
const picked = await openDialog({
multiple: false,
directory: false,
filters: [{ name: '主机备份', extensions: ['json'] }]
})
if (typeof picked !== 'string') return
const r = await store.importHosts(picked)
if (r.ok) toast.success(r.message)
else toast.warning(r.message)
} catch (e) {
toast.error(`导入失败:${String(e)}`)
}
}
async function toggleFavorite(v: HostView) {
try {
await store.saveHost({ ...v.config, favorited: !v.config.favorited })
} catch (e) {
toast.error(`更新收藏状态失败:${String(e)}`)
}
}
/** 可用密钥(供认证方式下拉使用) */
const availableKeys = computed(() => store.keys.filter(k => k.fileExists))
// ===== 主机密钥信任(known_hosts =====
/**
* 记录按地址归组一次,供本页反复查(每行徽标 + 详情 popover + 顶部提示)。
* 匹配规则与「孤儿」判定共用 `hostTrust.ts` 里的同一套实现 —— 分头实现迟早
* 会出现「徽标说已信任、点开却找不到记录」这类自相矛盾。
*/
const groupedKnownHosts = computed(() => groupByAddress(store.knownHosts))
/** 某台主机对应的指纹记录(可能为空 = 还没成功连过) */
function recordsOf(v: HostView): KnownHost[] {
return recordsForHost(groupedKnownHosts.value, v.config)
}
/** 指纹曾变更过的主机数(顶部风险提示的数据源) */
const changedHostCount = computed(
() => store.hosts.filter(v => trustState(recordsOf(v)) === 'changed').length
)
// ===== 忘记某台主机(信任详情 popover 的破坏性动作) =====
const pendingForget = ref<KnownHost | null>(null)
const forgetBusy = ref(false)
async function submitForget() {
const r = pendingForget.value
if (!r) return
forgetBusy.value = true
try {
await store.forgetHost(r.host, r.port, r.keyType)
toast.success(`已忘记 ${addressLabel(r.host, r.port)}`)
pendingForget.value = null
} catch (e) {
toast.error(`操作失败:${String(e)}`)
} finally {
forgetBusy.value = false
}
}
// ===== 跳板机链(ProxyJump=====
/**
* 跳板候选:除正在编辑的主机外的全部主机。
*
* 候选直接复用既有主机条目(地址+账号+凭据),而不是让用户在表单里
* 重新抄一遍跳板机的地址密码 —— 后者会造成同一台跳板机多份凭据副本,
* 改密码时漏改一处就是连接事故(与后端 `jump_ids` 的设计注释一致)。
*/
const hopCandidates = computed(() =>
store.hosts.filter(v => v.config.id !== editing.value?.id)
)
function hostLabel(id: string): string {
const v = store.hosts.find(x => x.config.id === id)
if (!v) return '(已删除的主机)'
return `${v.config.name || v.config.host}${v.config.username}@${v.config.host}`
}
function addHop() {
if (!editing.value) return
// 默认选第一个还没被用掉的候选,省一次点击
const used = new Set(editing.value.jumpIds)
const next = hopCandidates.value.find(v => !used.has(v.config.id))
if (!next) return
editing.value.jumpIds.push(next.config.id)
}
function removeHop(i: number) {
editing.value?.jumpIds.splice(i, 1)
}
/** 上移/下移:链的顺序就是连接顺序,靠前的先连 */
function moveHop(i: number, dir: -1 | 1) {
const arr = editing.value?.jumpIds
if (!arr) return
const j = i + dir
if (j < 0 || j >= arr.length) return
;[arr[i], arr[j]] = [arr[j], arr[i]]
}
</script>
<template>
<div class="flex flex-col h-full overflow-hidden">
<PageToolbar :icon="Globe" title="SSH 主机" :meta="`共 ${store.hosts.length} 台`">
<KnownHostsMenu />
<Button variant="outline" size="sm" @click="importHosts">
<Upload class="size-3.5" />导入备份
</Button>
<Button variant="outline" size="sm" @click="exportHosts">
<Download class="size-3.5" />导出
</Button>
<Button variant="outline" size="sm" @click="importConfig">
<Download class="size-3.5" />导入 ~/.ssh/config
</Button>
<Button size="sm" @click="openNew">
<Plus class="size-3.5" />新建主机
</Button>
</PageToolbar>
<!-- 风险提示有主机指纹变更时始终可见信任详情 popover 是同一条信息的展开 -->
<div
v-if="changedHostCount > 0"
class="shrink-0 mb-3 flex items-start gap-2 p-2.5 rounded-md
border border-warning/40 bg-warning-soft"
>
<AlertTriangle class="size-3.5 shrink-0 mt-0.5 text-warning" />
<div class="text-[11px] text-warning-text">
<p class="font-medium">{{ changedHostCount }} 台主机曾发生指纹变更</p>
<p class="mt-0.5 opacity-90">
若不是您本人重装或更换了服务器密钥请联系管理员核实点主机行的信任徽标可查看详情
</p>
</div>
</div>
<!--
列表卡片承载 proxy 订阅列表同口径),行间距 8px
`-mx-3` + 内层 `px-3 pb-3`滚动视口会裁掉超出自身盒子的内容卡片阴影
shadow-sm贴着视口边缘就会被切一半 —— 表现为终端模块的卡片比别的
页面扁」。负外边距把视口向两侧各外扩 12px内层等量内缩卡片位置与宽度
不变却留出了阴影余量**内边距必须在内层**根元素上的 pb 不属于滚动内容
滚到底时最后一张卡片的底部阴影仍会被裁同一份说明见 TerminalSettingsPanel)。
-->
<ScrollArea class="flex-1 min-h-0 -mx-3">
<div class="px-3 pb-3">
<Card v-if="store.hosts.length === 0">
<CardContent>
<StateBlock
variant="empty"
title="还没有配置 SSH 主机"
description="可以手工新建,也可以直接从 ~/.ssh/config 导入"
:min-height="220"
>
<template #action>
<div class="flex items-center justify-center gap-2">
<Button size="sm" class="gap-1.5" @click="openNew">
<Plus class="size-3.5" />新建主机
</Button>
<Button variant="outline" size="sm" class="gap-1.5" @click="importConfig">
<Download class="size-3.5" />导入配置
</Button>
</div>
</template>
</StateBlock>
</CardContent>
</Card>
<Card v-else>
<CardContent class="space-y-2">
<div
v-for="v in store.hosts"
:key="v.config.id"
class="group flex items-center gap-3 rounded-md border border-border p-3 transition-colors hover:bg-hover"
>
<button
class="shrink-0 size-7 rounded flex items-center justify-center transition-colors"
:class="
v.config.favorited
? 'text-warning'
: 'text-muted-foreground/30 hover:text-warning'
"
:title="v.config.favorited ? '取消收藏' : '收藏'"
@click="toggleFavorite(v)"
>
<Star class="size-3.5" :class="{ 'fill-current': v.config.favorited }" />
</button>
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<span class="text-sm font-medium truncate">
{{ v.config.name || v.config.host }}
</span>
<span v-if="v.config.group" class="shrink-0 text-[11px] text-muted-foreground">
{{ v.config.group }}
</span>
<!-- 不可连接时给出明确原因而不是让用户点下去才发现失败 -->
<StatusBadge
v-if="!v.ready"
variant="warning"
size="sm"
:title="v.issue ?? ''"
>
{{ v.issue || '配置不完整' }}
</StatusBadge>
<!--
主机密钥信任徽标即查看详情的入口有记录时才可点),
详情与忘记都在 HostTrustBadge 行内只传记录集合
-->
<HostTrustBadge
:label="v.config.name || v.config.host"
:address="addressLabel(v.config.host, v.config.port)"
:records="recordsOf(v)"
@forget="pendingForget = $event"
/>
</div>
<div class="text-xs text-muted-foreground truncate mt-0.5">
{{ v.config.username }}@{{ v.config.host
}}<span v-if="v.config.port !== 22">:{{ v.config.port }}</span>
<span class="mx-1.5">·</span>
<span v-if="v.config.authMethod === 'key' && v.config.keyId">
<KeyRound class="inline size-2.5 -mt-0.5" />
{{ store.keys.find(k => k.meta.id === v.config.keyId)?.meta.name ?? '密钥' }}
</span>
<span v-else-if="v.config.authMethod === 'password'">
<!-- 不回显掩码密码不是 API key保留前 3 4 也是在泄露真实内容
是否已保存由 hasPassword 表达明文/掩码一律不出现在列表 -->
{{ v.hasPassword ? '密码已保存' : '密码未保存' }}
</span>
<span v-else>{{ v.config.authMethod }}</span>
</div>
</div>
<div class="shrink-0 flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<Button
size="sm"
:disabled="!v.ready"
:title="v.ready ? '连接' : (v.issue ?? '')"
@click="emit('connect', v.config.id)"
>
连接
</Button>
<Button variant="ghost" size="icon-sm" title="编辑" @click="openEdit(v.config.id)">
<Pencil class="size-3.5" />
</Button>
<Button
variant="ghost"
size="icon-sm"
class="text-destructive hover:text-destructive"
title="删除"
@click="remove(v)"
>
<Trash2 class="size-3.5" />
</Button>
</div>
</div>
</CardContent>
</Card>
<!--
没有对应主机配置的指纹记录配置已删 / 从未保存过配置)。
放在主机列表之后的**同一滚动区**它属于同一份 known_hosts 的另一半」,
独立成一屏会让用户在两个位置之间来回找
-->
<OrphanKnownHosts @forget="pendingForget = $event" />
</div>
</ScrollArea>
<!-- ===== 编辑对话框 ===== -->
<Dialog v-model:open="dialogOpen">
<DialogContent class="max-w-xl max-h-[85vh]">
<DialogHeader>
<DialogTitle>{{ isNew ? '新建 SSH 主机' : '编辑 SSH 主机' }}</DialogTitle>
<DialogDescription class="text-xs">
密码保存在系统凭据管理器中不会写入配置文件
</DialogDescription>
</DialogHeader>
<!--
表单很长基本信息 + 认证 + 可展开的高级选项」),必须滚动
ScrollArea 而不是给 DialogContent overflow-y-auto后者会带上
浏览器原生滚动条系统样式与主题不一致),项目其余长对话框
monitor / downloader都用 ScrollArea + max-h 的写法
-->
<ScrollArea class="max-h-[60vh] pr-4">
<div v-if="editing" class="space-y-4">
<!-- 基本信息 -->
<div class="grid grid-cols-2 gap-3">
<div class="space-y-1.5">
<Label class="text-xs">显示名称</Label>
<Input v-model="editing.name" placeholder="生产服务器" class="h-8 text-sm" />
<p v-if="errors.name" class="text-[11px] text-destructive">{{ errors.name }}</p>
</div>
<div class="space-y-1.5">
<Label class="text-xs">分组</Label>
<Input v-model="editing.group" placeholder="工作 / 个人(留空为未分组)" class="h-8 text-sm" />
</div>
</div>
<div class="grid grid-cols-[1fr_100px] gap-3">
<div class="space-y-1.5">
<Label class="text-xs">主机地址</Label>
<Input
v-model="editing.host"
placeholder="192.168.1.10 或 example.com"
class="h-8 text-sm font-mono"
/>
<p v-if="errors.host" class="text-[11px] text-destructive">{{ errors.host }}</p>
</div>
<div class="space-y-1.5">
<Label class="text-xs">端口</Label>
<Input v-model.number="editing.port" type="number" class="h-8 text-sm font-mono" />
<p v-if="errors.port" class="text-[11px] text-destructive">{{ errors.port }}</p>
</div>
</div>
<div class="space-y-1.5">
<Label class="text-xs">登录用户名</Label>
<Input v-model="editing.username" placeholder="root" class="h-8 text-sm font-mono" />
<p v-if="errors.username" class="text-[11px] text-destructive">{{ errors.username }}</p>
</div>
<!-- 认证 -->
<div class="space-y-3 rounded-md border border-border p-3">
<div class="space-y-1.5">
<Label class="text-xs">认证方式</Label>
<Select v-model="editing.authMethod">
<SelectTrigger class="h-8 text-sm">
<SelectValue placeholder="选择认证方式" />
</SelectTrigger>
<SelectContent>
<SelectItem value="key">密钥认证推荐</SelectItem>
<SelectItem value="password">密码认证</SelectItem>
</SelectContent>
</Select>
</div>
<div v-if="editing.authMethod === 'key'" class="space-y-1.5">
<Label class="text-xs">使用密钥</Label>
<Select v-model="editing.keyId">
<SelectTrigger class="h-8 text-sm">
<SelectValue placeholder="选择密钥" />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="k in availableKeys" :key="k.meta.id" :value="k.meta.id">
{{ k.meta.name }} · {{ k.meta.algorithm
}}{{ k.meta.bits ? ` ${k.meta.bits}` : '' }}
</SelectItem>
</SelectContent>
</Select>
<p v-if="errors.keyId" class="text-[11px] text-destructive">{{ errors.keyId }}</p>
<p v-if="availableKeys.length === 0" class="text-[11px] text-warning">
还没有可用密钥请先到密钥管理生成或导入
</p>
</div>
<div v-if="editing.authMethod === 'password'" class="space-y-1.5">
<Label class="text-xs">
密码
<span class="text-muted-foreground font-normal">留空表示不修改</span>
</Label>
<div class="relative">
<Input
v-model="password"
:type="showPassword ? 'text' : 'password'"
placeholder="写入系统凭据管理器"
class="h-8 text-sm pr-8"
/>
<button
class="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
type="button"
@click="showPassword = !showPassword"
>
<Eye v-if="showPassword" class="size-3.5" />
<EyeOff v-else class="size-3.5" />
</button>
</div>
</div>
</div>
<!-- 高级 -->
<details class="rounded-md border border-border">
<summary class="px-3 py-2 text-xs cursor-pointer select-none hover:bg-accent/40">
高级选项
</summary>
<div class="px-3 pb-3 pt-1 space-y-3">
<div class="grid grid-cols-2 gap-3">
<div class="space-y-1.5">
<Label class="text-xs">连接超时毫秒</Label>
<Input v-model.number="editing.connectTimeoutMs" type="number" class="h-8 text-sm" />
</div>
<div class="space-y-1.5">
<Label class="text-xs">心跳间隔0 = 关闭</Label>
<Input v-model.number="editing.keepaliveSecs" type="number" class="h-8 text-sm" />
</div>
</div>
<div class="space-y-1.5">
<Label class="text-xs">登录后执行可选</Label>
<Input
v-model="editing.startupCommand"
placeholder="cd /var/log && ls -al"
class="h-8 text-sm font-mono"
/>
<p class="text-[11px] text-muted-foreground">
会在交互式 shell 建立后发送适合固定进入某目录
</p>
</div>
<div class="space-y-1.5">
<Label class="text-xs">远端工作目录可选</Label>
<Input v-model="editing.remoteCwd" placeholder="/home/user" class="h-8 text-sm font-mono" />
</div>
<div class="space-y-1.5">
<Label class="text-xs">远端编码</Label>
<Select v-model="editing.encoding">
<SelectTrigger class="h-8 text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="utf-8">UTF-8默认</SelectItem>
<SelectItem value="gbk">GBK部分老系统中文环境</SelectItem>
</SelectContent>
</Select>
</div>
<!-- 跳板机链ProxyJump):候选复用既有主机条目凭据跟着条目走 -->
<div class="space-y-1.5">
<div class="flex items-center justify-between">
<Label class="text-xs">跳板机链</Label>
<Button
variant="ghost"
size="sm"
class="h-6 gap-1 px-1.5 text-[11px] text-muted-foreground hover:text-foreground"
:disabled="hopCandidates.length === 0 || editing.jumpIds.length >= 5"
@click="addHop"
>
<Plus class="size-3" />添加跳板
</Button>
</div>
<p v-if="editing.jumpIds.length === 0" class="text-[11px] text-muted-foreground">
不经过跳板直连目标主机需要经堡垒机/跳板机中转时从这里添加
</p>
<div v-else class="space-y-1.5">
<div
v-for="(id, i) in editing.jumpIds"
:key="i"
class="flex items-center gap-1.5"
>
<span class="text-[11px] text-muted-foreground w-10 shrink-0">
{{ i + 1 }}
</span>
<Select v-model="editing.jumpIds[i]">
<SelectTrigger class="h-8 text-xs flex-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="v in hopCandidates"
:key="v.config.id"
:value="v.config.id"
>
{{ v.config.name || v.config.host }}{{ v.config.username }}@{{ v.config.host }}
</SelectItem>
<!-- 当前值对应的主机可能已被删除仍要能显示出来 -->
<SelectItem v-if="!hopCandidates.some(v => v.config.id === id)" :value="id">
{{ hostLabel(id) }}
</SelectItem>
</SelectContent>
</Select>
<Button
variant="ghost"
size="sm"
class="h-7 w-7 p-0"
:disabled="i === 0"
@click="moveHop(i, -1)"
>
<ArrowUp class="size-3.5" />
</Button>
<Button
variant="ghost"
size="sm"
class="h-7 w-7 p-0"
:disabled="i === editing.jumpIds.length - 1"
@click="moveHop(i, 1)"
>
<ArrowDown class="size-3.5" />
</Button>
<Button
variant="ghost"
size="sm"
class="h-7 w-7 p-0 text-destructive hover:text-destructive"
@click="removeHop(i)"
>
<Trash2 class="size-3.5" />
</Button>
</div>
<p class="text-[11px] text-muted-foreground">
连接方向本机 第1跳 目标主机每一跳使用对应主机条目里保存的账号与凭据
</p>
</div>
<p v-if="errors.jumpIds" class="text-[11px] text-destructive">{{ errors.jumpIds }}</p>
</div>
<div class="flex items-center justify-between">
<div>
<Label class="text-xs">收藏</Label>
<p class="text-[11px] text-muted-foreground">置顶显示在侧栏</p>
</div>
<Switch v-model="editing.favorited" />
</div>
<div class="flex items-center justify-between gap-3">
<div>
<Label class="text-xs">注入 shell 集成</Label>
<p class="text-[11px] text-muted-foreground">
让远端上报命令与目录命令历史与 cwd 跟踪才可用不落盘只影响本次会话)。
登录后执行 exec shell 的配置冲突时可关掉
</p>
</div>
<Switch v-model="editing.injectShellIntegration" />
</div>
</div>
</details>
</div>
</ScrollArea>
<DialogFooter>
<Button variant="outline" size="sm" @click="dialogOpen = false">取消</Button>
<Button size="sm" :disabled="saving" @click="save">
<Loader2 v-if="saving" class="size-3.5 mr-1.5 animate-spin" />
{{ isNew ? '添加' : '保存' }}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<!-- ===== 删除确认 ===== -->
<Dialog :open="pendingRemove !== null" @update:open="v => !v && resolveRemove(false)">
<DialogContent class="max-w-sm" :show-close-button="false">
<DialogHeader>
<DialogTitle class="text-base">删除主机</DialogTitle>
<DialogDescription class="text-xs">
将删除{{ pendingRemove?.config.name || pendingRemove?.config.host }}的配置
与已保存的密码此操作不可撤销
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" size="sm" @click="resolveRemove(false)">取消</Button>
<Button variant="destructive" size="sm" @click="resolveRemove(true)">删除</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<!--
===== 忘记主机密钥指纹记录 =====
对话框放在页级而不是信任 popover 内部popover 会因失焦关闭
而确认框是用户明确同意降低校验强度的关键一步不能被关在中间
-->
<Dialog :open="pendingForget !== null" @update:open="v => !v && (pendingForget = null)">
<DialogContent class="max-w-sm" :show-close-button="false">
<DialogHeader>
<DialogTitle class="text-base">忘记该主机</DialogTitle>
<DialogDescription class="text-xs">
将删除
<span class="font-mono">
{{ pendingForget ? addressLabel(pendingForget.host, pendingForget.port) : '' }}
</span>
<span class="font-mono">{{ pendingForget?.keyType }}</span>
指纹记录下次连接时将重新提示核对
</DialogDescription>
</DialogHeader>
<div class="flex items-start gap-2 p-2 rounded bg-muted text-[11px] text-muted-foreground">
<AlertTriangle class="size-3.5 shrink-0 mt-0.5" />
<span>仅在确认该主机密钥确实已更换时执行若出于连不上就删掉的动机请先核实原因</span>
</div>
<DialogFooter>
<Button variant="outline" size="sm" @click="pendingForget = null">取消</Button>
<Button variant="destructive" size="sm" :disabled="forgetBusy" @click="submitForget">
<Loader2 v-if="forgetBusy" class="size-3.5 mr-1.5 animate-spin" />
忘记
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</template>