终端模块初版
This commit is contained in:
@@ -0,0 +1,671 @@
|
||||
<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,使「新建」与「编辑」走完全相同的流程。
|
||||
*/
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { open as openDialog, save as saveDialog } from '@tauri-apps/plugin-dialog'
|
||||
import {
|
||||
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 { createLogger } from '@/lib/logger'
|
||||
import { blankHost, type SshHost } from './hostForm'
|
||||
import type { HostView } from '@/types/terminal'
|
||||
|
||||
const logger = createLogger('terminal')
|
||||
const store = useTerminalStore()
|
||||
|
||||
const emit = defineEmits<{ (e: 'close'): void; (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))
|
||||
|
||||
// ===== 跳板机链(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">
|
||||
<!-- 头部 -->
|
||||
<div class="shrink-0 flex items-center gap-2 px-4 h-12 border-b border-border">
|
||||
<Globe class="size-4 text-muted-foreground" />
|
||||
<h3 class="text-sm font-medium">SSH 主机</h3>
|
||||
<span class="text-xs text-muted-foreground">({{ store.hosts.length }})</span>
|
||||
<div class="flex-1" />
|
||||
<Button variant="outline" size="sm" class="h-7 gap-1.5 text-xs" @click="importHosts">
|
||||
<Upload class="size-3.5" />导入备份
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" class="h-7 gap-1.5 text-xs" @click="exportHosts">
|
||||
<Download class="size-3.5" />导出
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" class="h-7 gap-1.5 text-xs" @click="importConfig">
|
||||
<Download class="size-3.5" />导入 ~/.ssh/config
|
||||
</Button>
|
||||
<Button size="sm" class="h-7 gap-1.5 text-xs" @click="openNew">
|
||||
<Plus class="size-3.5" />新建主机
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- 列表 -->
|
||||
<div class="flex-1 min-h-0 overflow-y-auto p-3">
|
||||
<div v-if="store.hosts.length === 0" class="py-16 text-center">
|
||||
<Globe class="size-8 mx-auto text-muted-foreground/30 mb-3" />
|
||||
<p class="text-sm text-muted-foreground mb-1">还没有配置 SSH 主机</p>
|
||||
<p class="text-xs text-muted-foreground/70 mb-4">
|
||||
可以手工新建,也可以直接从 <code class="px-1 rounded bg-muted">~/.ssh/config</code> 导入
|
||||
</p>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-1">
|
||||
<div
|
||||
v-for="v in store.hosts"
|
||||
:key="v.config.id"
|
||||
class="group flex items-center gap-3 px-3 py-2 rounded-md border border-border
|
||||
hover:border-primary/40 hover:bg-accent/30 transition-colors"
|
||||
>
|
||||
<button
|
||||
class="shrink-0 size-6 rounded flex items-center justify-center transition-colors"
|
||||
:class="
|
||||
v.config.favorited
|
||||
? 'text-amber-500'
|
||||
: 'text-muted-foreground/30 hover:text-amber-500'
|
||||
"
|
||||
: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="text-[10px] px-1.5 py-0.5 rounded bg-muted shrink-0">
|
||||
{{ v.config.group }}
|
||||
</span>
|
||||
<!-- 不可连接时给出明确原因,而不是让用户点下去才发现失败 -->
|
||||
<span
|
||||
v-if="!v.ready"
|
||||
class="text-[10px] px-1.5 py-0.5 rounded bg-amber-500/15 text-amber-600 dark:text-amber-400 shrink-0"
|
||||
:title="v.issue ?? ''"
|
||||
>
|
||||
{{ v.issue || '配置不完整' }}
|
||||
</span>
|
||||
</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"
|
||||
class="h-7 text-xs"
|
||||
:disabled="!v.ready"
|
||||
:title="v.ready ? '连接' : (v.issue ?? '')"
|
||||
@click="emit('connect', v.config.id)"
|
||||
>
|
||||
连接
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" class="size-7" title="编辑" @click="openEdit(v.config.id)">
|
||||
<Pencil class="size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="size-7 text-destructive hover:text-destructive"
|
||||
title="删除"
|
||||
@click="remove(v)"
|
||||
>
|
||||
<Trash2 class="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===== 编辑对话框 ===== -->
|
||||
<Dialog v-model:open="dialogOpen">
|
||||
<DialogContent class="max-w-xl max-h-[85vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{{ isNew ? '新建 SSH 主机' : '编辑 SSH 主机' }}</DialogTitle>
|
||||
<DialogDescription class="text-xs">
|
||||
密码保存在系统凭据管理器中,不会写入配置文件。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<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-amber-600 dark:text-amber-400">
|
||||
还没有可用密钥,请先到「密钥管理」生成或导入
|
||||
</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>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
</template>
|
||||
Reference in New Issue
Block a user