Files
Thing/src/modules/terminal/components/TerminalStatusBar.vue
T
2026-09-18 18:28:13 +08:00

191 lines
7.1 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">
/**
* 终端状态栏:把「当前会话的客观事实」摆在最下面一行。
*
* 为什么需要它:终端是全屏沉浸式的界面,用户容易忘记自己在哪台机器、
* 用哪个 shell、当前目录是什么。状态栏用最低的视觉权重承担这个职责。
*
* 设计取舍:这里**只放只读事实 + 一个例外**(编码选择器)。
* 编码之所以破例:它是「读出来的事实」(当前用什么编码)和「需要就地改的开关」
* (看到乱码要立刻切)这两重身份的合体。若挪进设置页,用户在 GBK 服务器上看到
* 乱码时得先想「设置在哪」,而这个动作天然是「就在我看到乱码的那一行下面改」。
* 其余任何可写设置都不该进状态栏 —— 那会让它退化成一个迷你设置面板。
*/
import { computed, ref } from 'vue'
import { Globe, TerminalSquare, FolderOpen, Hash, Wifi, WifiOff, Cpu, Languages, Loader2 } from '@lucide/vue'
import { useTerminalStore } from '@/stores/terminalStore'
import { toast } from 'vue-sonner'
import {
ENCODING_OPTIONS,
encodingShortLabel,
isNonDefaultEncoding,
type SessionInfo
} from '@/types/terminal'
const store = useTerminalStore()
const props = defineProps<{
session: SessionInfo | undefined
/** 当前字号(含临时覆盖) */
fontSize: number
/** 终端实际行列数(由 xterm 上报,父组件透传) */
cols?: number
rows?: number
}>()
const isSsh = computed(() => props.session?.kind === 'ssh')
const stateText = computed(() => {
switch (props.session?.state) {
case 'connecting':
return '连接中'
case 'authenticating':
return '认证中'
case 'established':
return '已连接'
case 'degraded':
return '连接不稳定'
case 'closed':
return '已断开'
case 'failed':
return '连接失败'
default:
return '空闲'
}
})
const stateColor = computed(() => {
switch (props.session?.state) {
case 'established':
return 'text-emerald-600 dark:text-emerald-400'
case 'connecting':
case 'authenticating':
case 'degraded':
return 'text-amber-600 dark:text-amber-400'
case 'failed':
return 'text-red-600 dark:text-red-400'
default:
return 'text-muted-foreground'
}
})
/** 会话标题(与标签栏一致的口径,统一走 store 的解析) */
const title = computed(() => store.sessionLabel(props.session) || '无会话')
/** SSH 目标串 / 本地 shell 名 */
const target = computed(() => store.sessionTarget(props.session))
// ===== 编码选择器 =====
/** 当前会话的编码(缺省按 utf-8 显示,避免旧会话快照没有该字段时闪空) */
const encoding = computed(() => props.session?.encoding || 'utf-8')
/** 非默认编码时给视觉提示 —— 见 `isNonDefaultEncoding` 的说明 */
const encodingAlert = computed(() => isNonDefaultEncoding(encoding.value))
/** 切换中(防止连点造成多次广播与状态错乱) */
const switching = ref(false)
/**
* 切换编码。
*
* 成功后**不清屏**:已渲染的历史字符是旧编码的解释结果,重绘需要后端保留的
* 原始字节缓冲配合(当前 `OutputBatch` 的 ring buffer 尚未接到前端),
* 所以新编码只对「之后的输出」生效。这一点必须让用户知道 ——
* 否则他会看到「切了还是乱码」并认为功能坏了。
*/
async function onEncodingChange(value: unknown) {
const sid = props.session?.id
const next = String(value ?? '')
if (!sid || !next || next === encoding.value) return
switching.value = true
try {
const norm = await store.setEncoding(sid, next)
toast.success(`编码已切换为 ${norm}`, {
description: '新编码对之后的输出生效;已显示的历史内容不会自动重绘,可执行 clear 后重新查看'
})
} catch (e) {
// 失败时不改本地状态:后端返回的是权威值,UI 跟着后端走
toast.error(`切换编码失败:${String(e)}`)
} finally {
switching.value = false
}
}
</script>
<template>
<div
class="shrink-0 h-6 flex items-center gap-3 px-2.5 border-t border-border bg-card/20
text-[10px] text-muted-foreground select-none"
>
<div class="flex items-center gap-1.5 min-w-0">
<component :is="isSsh ? Globe : TerminalSquare" class="size-3 shrink-0" />
<span class="truncate max-w-[200px]">{{ title }}</span>
</div>
<span v-if="target" class="truncate max-w-[220px] opacity-80">{{ target }}</span>
<!-- shrink-0 + nowrap状态栏 flex 项多不加会被压缩到已连接逐字换行成竖排 -->
<div class="flex items-center gap-1 shrink-0 whitespace-nowrap">
<Wifi v-if="session?.state === 'established'" class="size-3" />
<WifiOff v-else class="size-3 opacity-50" />
<span :class="stateColor">{{ stateText }}</span>
</div>
<div class="flex-1" />
<!--
编码选择器非默认编码时整块变琥珀色
只换文字不够 —— GBKUTF-8 10px 字号下形状差异不明显
用户扫一眼不会注意到自己正在用非默认编码
-->
<div
v-if="session"
class="flex items-center gap-0.5 shrink-0 rounded px-1 -mx-0.5 transition-colors"
:class="encodingAlert
? 'bg-amber-500/15 text-amber-700 dark:text-amber-400'
: 'text-muted-foreground'"
:title="encodingAlert
? `当前使用非默认编码 ${encoding},中文乱码时可在此切换`
: '切换字符编码'"
>
<Loader2 v-if="switching" class="size-3 animate-spin" />
<Languages v-else class="size-3" />
<!--
用原生 select 而不是 shadcn Select后者是 Radix 实现会在 body 末尾
挂一个 portal 浮层而状态栏处在终端容器的裁剪区内10px 字号 + 状态栏
这种工具条语境下原生 select 的外观差异可以接受换来的是不受
浮层层级与裁剪影响 —— 这是稳定性优先于观感的一处取舍
-->
<select
:value="encoding"
:disabled="switching"
class="bg-transparent border-0 outline-none cursor-pointer appearance-none
text-[10px] text-inherit pr-0.5 disabled:cursor-wait"
@change="onEncodingChange(($event.target as HTMLSelectElement).value)"
>
<option v-for="opt in ENCODING_OPTIONS" :key="opt.value" :value="opt.value">
{{ opt.label }}
</option>
</select>
<span class="tabular-nums opacity-70">{{ encodingShortLabel(encoding) }}</span>
</div>
<div v-if="session?.cwd" class="flex items-center gap-1 min-w-0 max-w-[45%]">
<FolderOpen class="size-3 shrink-0" />
<span class="truncate" :title="session.cwd">{{ session.cwd }}</span>
</div>
<div v-if="cols && rows" class="flex items-center gap-1 shrink-0">
<Hash class="size-3" />
<span>{{ cols }}×{{ rows }}</span>
</div>
<div class="flex items-center gap-1 shrink-0">
<Cpu class="size-3" />
<span>{{ fontSize }}px</span>
</div>
</div>
</template>