终端模块初版

This commit is contained in:
zhongluofeng
2026-09-18 18:28:13 +08:00
parent f6c1cc250e
commit b018abd922
63 changed files with 26695 additions and 185 deletions
@@ -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 = '端口需在 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))
// ===== 跳板机链(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>