UI/细节调整

This commit is contained in:
zhongluofeng
2026-09-22 19:10:16 +08:00
parent b018abd922
commit bccd21fde5
135 changed files with 11393 additions and 5104 deletions
+275 -113
View File
@@ -12,10 +12,25 @@
* 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,
@@ -51,14 +66,24 @@ import {
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 type { HostView } from '@/types/terminal'
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()
const emit = defineEmits<{ (e: 'close'): void; (e: 'connect', hostId: string): void }>()
// 只声明 connect:本页现在是模块级 Tabs 的一页,不再需要「返回终端」语义
// (导航由 Tabs 承担),此前声明的 close 事件从未被触发过。
const emit = defineEmits<{ (e: 'connect', hostId: string): void }>()
// ===== 编辑对话框 =====
@@ -237,6 +262,45 @@ async function toggleFavorite(v: HostView) {
/** 可用密钥(供认证方式下拉使用) */
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=====
/**
@@ -280,129 +344,175 @@ function moveHop(i: number, dir: -1 | 1) {
</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">
<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" class="h-7 gap-1.5 text-xs" @click="exportHosts">
<Button variant="outline" size="sm" @click="exportHosts">
<Download class="size-3.5" />导出
</Button>
<Button variant="outline" size="sm" class="h-7 gap-1.5 text-xs" @click="importConfig">
<Button variant="outline" size="sm" @click="importConfig">
<Download class="size-3.5" />导入 ~/.ssh/config
</Button>
<Button size="sm" class="h-7 gap-1.5 text-xs" @click="openNew">
<Button size="sm" @click="openNew">
<Plus class="size-3.5" />新建主机
</Button>
</div>
</PageToolbar>
<!-- 列表 -->
<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>
<!-- 风险提示有主机指纹变更时始终可见信任详情 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 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>
<!--
列表卡片承载 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] overflow-y-auto">
<DialogContent class="max-w-xl max-h-[85vh]">
<DialogHeader>
<DialogTitle>{{ isNew ? '新建 SSH 主机' : '编辑 SSH 主机' }}</DialogTitle>
<DialogDescription class="text-xs">
@@ -410,7 +520,14 @@ function moveHop(i: number, dir: -1 | 1) {
</DialogDescription>
</DialogHeader>
<div v-if="editing" class="space-y-4">
<!--
表单很长基本信息 + 认证 + 可展开的高级选项必须滚动
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">
@@ -476,7 +593,7 @@ function moveHop(i: number, dir: -1 | 1) {
</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 v-if="availableKeys.length === 0" class="text-[11px] text-warning">
还没有可用密钥请先到密钥管理生成或导入
</p>
</div>
@@ -637,9 +754,21 @@ function moveHop(i: number, dir: -1 | 1) {
</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>
</div>
</ScrollArea>
<DialogFooter>
<Button variant="outline" size="sm" @click="dialogOpen = false">取消</Button>
@@ -667,5 +796,38 @@ function moveHop(i: number, dir: -1 | 1) {
</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>