191 lines
7.1 KiB
Vue
191 lines
7.1 KiB
Vue
<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" />
|
||
|
||
<!--
|
||
编码选择器:非默认编码时整块变琥珀色。
|
||
只换文字不够 —— 「GBK」和「UTF-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>
|