终端模块初版
This commit is contained in:
@@ -0,0 +1,190 @@
|
||||
<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>
|
||||
Reference in New Issue
Block a user