终端模块初版

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,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" />
<!--
编码选择器非默认编码时整块变琥珀色
只换文字不够 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>