fix(terminal): 右下角快捷键与右键菜单中文化并放大

快捷键区改为中文大按钮;工具栏字号/回滚/全屏中文化;右键菜单加大并在命令栏拦截英文系统菜单。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Your Name
2026-06-01 22:00:49 +08:00
parent e3eb0e65a5
commit 9e924562d2
2 changed files with 135 additions and 30 deletions
@@ -25,6 +25,11 @@
- 前端:重新 `vite build` 并部署 `web/app/`
- 后端:拉取代码后 `supervisorctl restart nexus`reorder 端点)。
## 后续(同日)
- 终端右下角「快捷键」区改为中文大按钮(中断/EOF/补全);工具栏字号/回滚/全屏中文化。
- 右键菜单加大(comfortable 密度)、靠近底部时向上弹出;命令栏右键同样显示中文菜单。
## 验证
1. 打开 `https://api.synaglobal.vip/app/#/terminal`:左侧仍为全局运维菜单,无第二列服务器列表。
+130 -30
View File
@@ -4,25 +4,37 @@
class="pa-0 d-flex flex-column terminal-root"
:class="theme.global.current.value.dark ? 'terminal-root--dark' : 'terminal-root--light'"
>
<!-- Context Menu -->
<v-menu v-model="showTermMenu" :target="[menuX, menuY]" location="bottom start">
<v-list density="compact" nav>
<v-list-item prepend-icon="mdi-content-copy" @click="termCopy">
<!-- 终端/命令栏右键菜单中文加大避免浏览器英文菜单 -->
<v-menu
v-model="showTermMenu"
:target="[menuX, menuY]"
:location="termMenuLocation"
content-class="terminal-ctx-menu"
>
<v-list density="comfortable" nav class="py-1">
<v-list-item prepend-icon="mdi-content-copy" rounded="lg" @click="termCopy">
<v-list-item-title>复制</v-list-item-title>
</v-list-item>
<v-list-item prepend-icon="mdi-content-paste" @click="termPaste">
<v-list-item prepend-icon="mdi-content-paste" rounded="lg" @click="termPaste">
<v-list-item-title>粘贴</v-list-item-title>
</v-list-item>
<v-list-item prepend-icon="mdi-select-all" @click="termSelectAll">
<v-list-item prepend-icon="mdi-select-all" rounded="lg" @click="termSelectAll">
<v-list-item-title>全选</v-list-item-title>
</v-list-item>
<v-divider />
<v-list-item prepend-icon="mdi-delete-sweep" @click="termClear">
<v-list-item-title>清屏</v-list-item-title>
</v-list-item>
<v-list-item prepend-icon="mdi-logout" @click="termDisconnect" :disabled="!activeSession">
<v-list-item-title class="text-error">断开连接</v-list-item-title>
</v-list-item>
<template v-if="termMenuContext === 'terminal'">
<v-divider class="my-1" />
<v-list-item prepend-icon="mdi-delete-sweep" rounded="lg" @click="termClear">
<v-list-item-title>清屏</v-list-item-title>
</v-list-item>
<v-list-item
prepend-icon="mdi-logout"
rounded="lg"
:disabled="!activeSession"
@click="termDisconnect"
>
<v-list-item-title class="text-error">断开连接</v-list-item-title>
</v-list-item>
</template>
</v-list>
</v-menu>
@@ -77,23 +89,27 @@
<span v-if="activeSession?.uptime" class="text-caption text-medium-emphasis">{{ activeSession.uptime }}</span>
<span v-if="activeSession?.pingMs !== null && activeSession?.pingMs !== undefined" class="text-caption text-medium-emphasis">{{ activeSession.pingMs }}ms</span>
</div>
<div class="d-flex align-center ga-2">
<v-btn variant="text" size="x-small" density="compact" @click="changeFontSize(-1)">A</v-btn>
<span class="text-caption">{{ fontSize }}</span>
<v-btn variant="text" size="x-small" density="compact" @click="changeFontSize(1)">A+</v-btn>
<div class="d-flex align-center ga-2 terminal-toolbar-actions">
<span class="text-body-2 text-medium-emphasis shrink-0">字号</span>
<v-btn variant="tonal" size="small" @click="changeFontSize(-1)">缩小</v-btn>
<span class="text-body-2 font-weight-medium" style="min-width: 1.5rem; text-align: center">{{ fontSize }}</span>
<v-btn variant="tonal" size="small" @click="changeFontSize(1)">放大</v-btn>
<v-select
v-model="scrollback"
:items="[1000, 5000, 10000, 50000]"
density="compact"
:items="scrollbackOptions"
item-title="title"
item-value="value"
label="回滚行数"
density="comfortable"
hide-details
variant="outlined"
style="max-width: 90px"
class="terminal-toolbar-scrollback"
@update:model-value="changeScrollback"
/>
<v-btn variant="text" size="x-small" density="compact" @click="toggleFullscreen">
<v-icon size="14">mdi-fullscreen</v-icon>
<v-btn variant="tonal" size="small" prepend-icon="mdi-fullscreen" @click="toggleFullscreen">
全屏
</v-btn>
<v-btn variant="text" size="x-small" density="compact" color="error" @click="disconnect">
<v-btn variant="tonal" size="small" color="error" prepend-icon="mdi-link-off" @click="disconnect">
断开
</v-btn>
</div>
@@ -204,6 +220,7 @@
<div
class="d-flex align-start ga-2 px-3 py-2 shrink-0 cmd-bar cmd-bar--tall"
:class="{ 'opacity-50 pointer-events-none': !activeSession?.ws }"
@contextmenu="onCmdContext"
>
<span class="cmd-bar__prompt font-mono text-primary shrink-0"></span>
<div class="flex-grow-1 cmd-bar__input-wrap">
@@ -217,11 +234,14 @@
@keydown="onCmdKeydown"
/>
</div>
<v-btn size="small" variant="tonal" color="primary" class="mt-1" @click="sendCmd" :disabled="!activeSession?.ws">发送</v-btn>
<div class="d-flex ga-1 pl-2" style="border-left: 1px solid rgb(var(--v-theme-surface-variant))">
<v-btn size="x-small" variant="outlined" density="compact" @click="sendCtrl('c')" title="Ctrl+C">^C</v-btn>
<v-btn size="x-small" variant="outlined" density="compact" @click="sendCtrl('d')" title="Ctrl+D">^D</v-btn>
<v-btn size="x-small" variant="outlined" density="compact" @click="sendKey('\t')" title="Tab补全">Tab</v-btn>
<v-btn size="default" variant="tonal" color="primary" class="mt-1" @click="sendCmd" :disabled="!activeSession?.ws">发送</v-btn>
<div class="terminal-hotkeys shrink-0 pl-3">
<div class="text-body-2 font-weight-medium text-medium-emphasis mb-2">快捷键</div>
<div class="d-flex flex-column ga-2">
<v-btn size="default" variant="outlined" block @click="sendCtrl('c')">中断 (Ctrl+C)</v-btn>
<v-btn size="default" variant="outlined" block @click="sendCtrl('d')">EOF (Ctrl+D)</v-btn>
<v-btn size="default" variant="outlined" block @click="sendKey('\t')">补全 (Tab)</v-btn>
</div>
</div>
</div>
</div>
@@ -356,6 +376,15 @@ const servers = ref<{ id: number; name: string; domain: string; is_online: boole
const showTermMenu = ref(false)
const menuX = ref(0)
const menuY = ref(0)
const termMenuContext = ref<'terminal' | 'command'>('terminal')
const termMenuLocation = computed(() => (menuY.value > window.innerHeight * 0.55 ? 'top end' : 'bottom end'))
const scrollbackOptions = [
{ title: '1 千行', value: 1000 },
{ title: '5 千行', value: 5000 },
{ title: '1 万行', value: 10000 },
{ title: '5 万行', value: 50000 },
]
// ── Quick commands(只读展示,管理在系统设置) ──
const quickCmds = ref<QuickCmdItem[]>([])
@@ -779,18 +808,43 @@ function disconnect() {
}
// ── Context menu handlers ──
function onTermContext(e: MouseEvent) {
function openTermMenu(e: MouseEvent, context: 'terminal' | 'command') {
e.preventDefault()
termMenuContext.value = context
menuX.value = e.clientX
menuY.value = e.clientY
showTermMenu.value = true
}
function onTermContext(e: MouseEvent) {
openTermMenu(e, 'terminal')
}
function onCmdContext(e: MouseEvent) {
openTermMenu(e, 'command')
}
function getCmdInputEl(): HTMLTextAreaElement | null {
const root = document.querySelector('.cmd-bar--tall .sh-field__input--multi')
return root instanceof HTMLTextAreaElement ? root : null
}
function termCopy() {
showTermMenu.value = false
if (termMenuContext.value === 'command') {
const el = getCmdInputEl()
const text = el?.value.substring(el.selectionStart ?? 0, el.selectionEnd ?? 0) || ''
if (text) {
navigator.clipboard.writeText(text).catch(() => snackbar('复制失败', 'error'))
return
}
}
const sel = window.getSelection()?.toString()
if (sel) {
navigator.clipboard.writeText(sel).catch(() => snackbar('复制失败', 'error'))
} else if (activeSession.value?.term) {
const buffer = activeSession.value.term.getSelection()
if (buffer) navigator.clipboard.writeText(buffer).catch(() => snackbar('复制失败', 'error'))
}
}
@@ -798,7 +852,22 @@ async function termPaste() {
showTermMenu.value = false
try {
const text = await navigator.clipboard.readText()
if (activeSession.value?.term && text) {
if (!text) return
if (termMenuContext.value === 'command') {
const el = getCmdInputEl()
if (el) {
const start = el.selectionStart ?? el.value.length
const end = el.selectionEnd ?? start
const next = el.value.slice(0, start) + text + el.value.slice(end)
cmdInput.value = next
await nextTick()
el.focus()
const pos = start + text.length
el.setSelectionRange(pos, pos)
}
return
}
if (activeSession.value?.term) {
activeSession.value.term.paste(text)
}
} catch {
@@ -808,6 +877,14 @@ async function termPaste() {
function termSelectAll() {
showTermMenu.value = false
if (termMenuContext.value === 'command') {
const el = getCmdInputEl()
if (el) {
el.focus()
el.select()
}
return
}
if (activeSession.value?.term) {
activeSession.value.term.selectAll()
}
@@ -1111,6 +1188,29 @@ onBeforeUnmount(() => {
min-width: 0;
}
.terminal-hotkeys {
border-left: 1px solid rgb(var(--v-theme-surface-variant));
min-width: 9.5rem;
}
.terminal-toolbar-scrollback {
min-width: 8.5rem;
max-width: 10rem;
}
:deep(.terminal-ctx-menu) {
min-width: 11.5rem;
}
:deep(.terminal-ctx-menu .v-list-item-title) {
font-size: 1rem;
font-weight: 500;
}
:deep(.terminal-ctx-menu .v-icon) {
font-size: 1.35rem;
}
/* Shell 语法高亮(命令栏 + 快捷命令编辑) */
.terminal-root--dark :deep(.sh-keyword) { color: #60a5fa; font-weight: 500; }
.terminal-root--dark :deep(.sh-builtin) { color: #38bdf8; }