834 lines
34 KiB
Vue
834 lines
34 KiB
Vue
<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 = '端口需在 1–65535 之间'
|
||
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>
|