feat(terminal): 右侧服务器列表与搜索历史

终端与会话并行:右侧常驻选服列表,combobox 共用服务器页最近 10 条搜索历史。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Nexus Agent
2026-06-12 01:47:46 +08:00
parent 441aa0b9d2
commit 989b17902b
8 changed files with 289 additions and 103 deletions
@@ -0,0 +1,38 @@
# 审计 — 终端页右侧服务器列表与搜索历史
**Changelog**: `docs/changelog/2026-06-12-terminal-server-rail-search-history.md`
## 范围(文件清单)
| 文件 | 变更 |
|------|------|
| `frontend/src/pages/TerminalPage.vue` | 左右分栏,右侧常驻列表 |
| `frontend/src/components/terminal/TerminalArea.vue` | 空态文案 |
| `frontend/src/components/terminal/TerminalServerPicker.vue` | sidebar 模式 + combobox |
| `frontend/src/components/terminal/TerminalServerPickerSearch.vue` | 新建 |
| `frontend/src/components/terminal/TerminalServerPickerList.vue` | 新建 |
| `frontend/src/composables/terminal/useTerminalSessions.ts` | search-history API |
## Step 3 规则扫描
| H | 规则 | 结论 |
|---|------|------|
| H1 | 安全 | PASS — 复用已有 search-history API |
| H2 | 无静默吞错 | PASS |
| H3 | 无新密钥 | PASS |
| H4 | CUD | N/A |
## Closure
| H | 判定 | 依据 |
|---|------|------|
| H1H4 | PASS | 纯前端布局 + 已有 API |
## DoD
- [x] type-check
- [ ] 生产 `#/terminal` 验证
## 验证
右侧列表 + 搜索历史 10 条;与会话并行。
@@ -0,0 +1,34 @@
# 终端页右侧服务器列表与搜索历史
**日期**2026-06-12
## 动机
终端与会话选服需同时进行:左侧操作 SSH,右侧随时选下一台;搜索词与服务器页共用最近记录。
## 变更
- 终端页 **右侧常驻** 服务器列表(300px),有会话时也可切换目标
- 搜索框改为 **v-combobox**,与服务器页共用 `/api/servers/search-history`(展示最近 **10** 条)
- 回车或选历史项写入历史;空终端区提示「从右侧列表选择」
## 涉及文件
- `frontend/src/pages/TerminalPage.vue`
- `frontend/src/components/terminal/TerminalArea.vue`
- `frontend/src/components/terminal/TerminalServerPicker.vue`
- `frontend/src/components/terminal/TerminalServerPickerSearch.vue`(新建)
- `frontend/src/components/terminal/TerminalServerPickerList.vue`(新建)
- `frontend/src/composables/terminal/useTerminalSessions.ts`
## 验证
```bash
cd frontend && npm run type-check
```
打开 `#/terminal`:右侧列表;搜索下拉见历史;左侧终端可并行使用。
## 迁移
无。仅前端构建部署。
@@ -18,10 +18,9 @@
>
<v-icon size="56" color="primary" class="mb-3">mdi-console-network</v-icon>
<div class="text-h6 mb-1 text-center">WebSSH 终端</div>
<div class="text-body-2 text-medium-emphasis mb-4 text-center">
选择一台服务器开始会话或从服务器页点击终端
<div class="text-body-2 text-medium-emphasis text-center">
从右侧列表选择服务器或点击上方 + 新建标签
</div>
<slot name="empty-picker" />
</div>
<v-overlay
@@ -1,82 +1,97 @@
<template>
<v-card v-if="mode === 'inline'" border rounded="lg" class="empty-server-picker" max-width="520" width="100%">
<v-card-text class="pa-3">
<v-text-field
<aside v-if="mode === 'sidebar'" class="terminal-server-rail d-flex flex-column h-100">
<div class="terminal-server-rail__head px-3 py-2 text-subtitle-2 font-weight-medium border-b">
服务器
<span class="text-caption text-medium-emphasis ml-1">({{ servers.length }})</span>
</div>
<div class="px-3 py-2 flex-shrink-0">
<v-combobox
:model-value="search"
:items="searchHistory"
prepend-inner-icon="mdi-magnify"
placeholder="搜索服务器…"
placeholder="搜索名称/域名…"
variant="outlined"
density="compact"
hide-details
class="mb-2"
clearable
:auto-select-first="false"
@update:model-value="$emit('update:search', $event)"
@keydown.enter.prevent="$emit('search-commit')"
/>
<v-skeleton-loader v-if="loading" type="list-item@6" class="mb-2" />
<v-list v-else density="compact" class="empty-server-picker__list" nav>
<v-list-item
v-for="s in servers"
:key="s.id"
:title="s.name"
:subtitle="s.domain"
rounded="lg"
@click="$emit('select', s.id)"
>
<template #prepend>
<v-icon :color="s.is_online ? 'success' : 'grey'" size="8">mdi-circle</v-icon>
</template>
</v-list-item>
<v-list-item v-if="servers.length === 0" class="text-medium-emphasis">
暂无匹配的服务器
</v-list-item>
</v-list>
</v-card-text>
</v-card>
<template v-else>
<v-text-field
:model-value="search"
prepend-inner-icon="mdi-magnify"
placeholder="搜索…"
variant="outlined"
density="compact"
class="mb-3"
@update:model-value="$emit('update:search', $event)"
/>
<v-skeleton-loader v-if="loading" type="list-item@6" class="mb-2" />
<v-list v-else density="compact" max-height="300" style="overflow-y: auto">
<div v-if="searchHistory.length" class="text-caption text-medium-emphasis mt-1">
最近 {{ searchHistory.length }} 条与服务器页同步
</div>
</div>
<v-skeleton-loader v-if="loading" type="list-item@8" class="px-2" />
<v-list v-else density="compact" nav class="terminal-server-rail__list flex-grow-1 py-0">
<v-list-item
v-for="s in servers"
:key="s.id"
:title="s.name"
:subtitle="s.domain"
rounded="lg"
class="mb-1"
@click="$emit('select', s.id)"
>
<template #prepend>
<v-icon :color="s.is_online ? 'success' : 'grey'" size="8">mdi-circle</v-icon>
</template>
<v-list-item-title class="text-body-2 text-truncate" :title="s.name">{{ s.name }}</v-list-item-title>
<v-list-item-subtitle class="text-truncate" :title="s.domain">{{ s.domain }}</v-list-item-subtitle>
</v-list-item>
<v-list-item v-if="servers.length === 0" class="text-medium-emphasis">
暂无匹配的服务器
</v-list-item>
</v-list>
</aside>
<v-card v-else-if="mode === 'inline'" border rounded="lg" class="empty-server-picker" max-width="520" width="100%">
<v-card-text class="pa-3">
<PickerSearch
:search="search"
:search-history="searchHistory"
@update:search="$emit('update:search', $event)"
@search-commit="$emit('search-commit')"
/>
<v-skeleton-loader v-if="loading" type="list-item@6" class="mb-2" />
<v-list v-else density="compact" class="empty-server-picker__list" nav>
<PickerListItems :servers="servers" @select="$emit('select', $event)" />
</v-list>
</v-card-text>
</v-card>
<template v-else>
<PickerSearch
:search="search"
:search-history="searchHistory"
class="mb-3"
@update:search="$emit('update:search', $event)"
@search-commit="$emit('search-commit')"
/>
<v-skeleton-loader v-if="loading" type="list-item@6" class="mb-2" />
<v-list v-else density="compact" max-height="300" style="overflow-y: auto">
<PickerListItems :servers="servers" @select="$emit('select', $event)" />
</v-list>
</template>
</template>
<script setup lang="ts">
import PickerListItems from '@/components/terminal/TerminalServerPickerList.vue'
import PickerSearch from '@/components/terminal/TerminalServerPickerSearch.vue'
import type { TerminalServerItem } from '@/composables/terminal/types'
withDefaults(
defineProps<{
servers: TerminalServerItem[]
search: string
searchHistory?: string[]
loading?: boolean
mode?: 'inline' | 'dialog'
mode?: 'inline' | 'dialog' | 'sidebar'
}>(),
{ mode: 'inline', loading: false },
{ mode: 'inline', loading: false, searchHistory: () => [] },
)
defineEmits<{
'update:search': [value: string]
'update:search': [value: string | null]
'search-commit': []
select: [serverId: number]
}>()
</script>
@@ -86,4 +101,15 @@ defineEmits<{
max-height: min(42vh, 320px);
overflow-y: auto;
}
.terminal-server-rail {
width: 300px;
flex-shrink: 0;
min-height: 0;
background: rgb(var(--v-theme-surface));
border-left: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
}
.terminal-server-rail__list {
overflow-y: auto;
min-height: 0;
}
</style>
@@ -0,0 +1,29 @@
<template>
<v-list-item
v-for="s in servers"
:key="s.id"
:title="s.name"
:subtitle="s.domain"
rounded="lg"
@click="$emit('select', s.id)"
>
<template #prepend>
<v-icon :color="s.is_online ? 'success' : 'grey'" size="8">mdi-circle</v-icon>
</template>
</v-list-item>
<v-list-item v-if="servers.length === 0" class="text-medium-emphasis">
暂无匹配的服务器
</v-list-item>
</template>
<script setup lang="ts">
import type { TerminalServerItem } from '@/composables/terminal/types'
defineProps<{
servers: TerminalServerItem[]
}>()
defineEmits<{
select: [serverId: number]
}>()
</script>
@@ -0,0 +1,27 @@
<template>
<v-combobox
:model-value="search"
:items="searchHistory"
prepend-inner-icon="mdi-magnify"
placeholder="搜索名称/域名…"
variant="outlined"
density="compact"
hide-details
clearable
:auto-select-first="false"
@update:model-value="$emit('update:search', $event)"
@keydown.enter.prevent="$emit('search-commit')"
/>
</template>
<script setup lang="ts">
defineProps<{
search: string
searchHistory: string[]
}>()
defineEmits<{
'update:search': [value: string | null]
'search-commit': []
}>()
</script>
@@ -29,8 +29,10 @@ import { XTERM_CLASSIC_THEME } from './xtermTheme'
import { decodeTerminalPayload } from '@/utils/terminalPayload'
import { fetchPagePerPage } from '@/utils/paginatedFetch'
import type { ServerApiItem } from '@/types/api'
import { useServerSearchHistory } from '@/composables/useServerSearchHistory'
const MAX_TABS = 10
const TERMINAL_SEARCH_HISTORY = 10
export function useTerminalSessions(nexusDrawer: Ref<boolean>) {
const route = useRoute()
@@ -47,6 +49,10 @@ export function useTerminalSessions(nexusDrawer: Ref<boolean>) {
const serversLoading = ref(false)
const serverSearch = ref('')
const quickCmds = ref<QuickCmdItem[]>([])
const { items: serverSearchHistory, loadHistoryOnly, record: recordServerSearch } =
useServerSearchHistory()
const terminalSearchHistory = computed(() => serverSearchHistory.value.slice(0, TERMINAL_SEARCH_HISTORY))
const activeSession = computed(
() => sessions.value.find((s) => s.id === activeSessionId.value) || null,
@@ -708,6 +714,7 @@ export function useTerminalSessions(nexusDrawer: Ref<boolean>) {
async function loadServers() {
serversLoading.value = true
try {
await loadHistoryOnly()
const { items } = await fetchPagePerPage<ServerApiItem>('/servers/', {}, 1, -1)
servers.value = items.map(toTerminalServerItem)
} catch {
@@ -718,6 +725,23 @@ export function useTerminalSessions(nexusDrawer: Ref<boolean>) {
}
}
function commitServerSearch() {
void recordServerSearch(serverSearch.value)
}
function onServerSearchUpdate(val: string | null) {
if (val == null || val === '') {
serverSearch.value = ''
void recordServerSearch('')
return
}
const picked = String(val).trim()
serverSearch.value = picked
if (serverSearchHistory.value.includes(picked)) {
void recordServerSearch(picked)
}
}
function onGlobalKeydown(e: KeyboardEvent) {
if (e.ctrlKey && e.key === 'l') {
e.preventDefault()
@@ -771,6 +795,7 @@ export function useTerminalSessions(nexusDrawer: Ref<boolean>) {
servers,
serversLoading,
serverSearch,
terminalSearchHistory,
filteredServers,
showEmptyPicker,
quickCmds,
@@ -792,6 +817,8 @@ export function useTerminalSessions(nexusDrawer: Ref<boolean>) {
loadQuickCmds,
execQuickCmd,
loadServers,
commitServerSearch,
onServerSearchUpdate,
applyRouteQuery,
onGlobalKeydown,
disposeAllSessions,
+63 -57
View File
@@ -36,62 +36,64 @@
</v-list>
</v-menu>
<div class="d-flex flex-column flex-grow-1" style="min-height: 0">
<TerminalTabBar
:sessions="sessions"
:active-session-id="activeSessionId"
@switch="switchTab"
@close="closeTab"
@new="newSession()"
/>
<div class="d-flex flex-grow-1 min-h-0 terminal-main-row">
<div class="d-flex flex-column flex-grow-1 min-w-0 min-h-0">
<TerminalTabBar
:sessions="sessions"
:active-session-id="activeSessionId"
@switch="switchTab"
@close="closeTab"
@new="newSession()"
/>
<TerminalToolbar
v-if="sessions.length > 0"
:session="activeSession"
:font-size="fontSize"
:scrollback="scrollback"
@font-change="changeFontSize"
@scrollback-change="changeScrollback"
@fullscreen="toggleFullscreen"
@disconnect="disconnect"
/>
<TerminalToolbar
v-if="sessions.length > 0"
:session="activeSession"
:font-size="fontSize"
:scrollback="scrollback"
@font-change="changeFontSize"
@scrollback-change="changeScrollback"
@fullscreen="toggleFullscreen"
@disconnect="disconnect"
/>
<TerminalArea
ref="areaRef"
:sessions="sessions"
:active-session-id="activeSessionId"
:active-session="activeSession"
:show-empty="showEmptyPicker"
@context-menu="onTermContext"
@pane-ref="setTermRef"
@reconnect="reconnect"
@go-servers="router.push('/servers')"
>
<template #empty-picker>
<TerminalServerPicker
:servers="filteredServers"
:search="serverSearch"
:loading="serversLoading"
mode="inline"
@update:search="onServerSearch"
@select="(id) => newSession(id)"
/>
</template>
</TerminalArea>
<TerminalArea
ref="areaRef"
:sessions="sessions"
:active-session-id="activeSessionId"
:active-session="activeSession"
:show-empty="showEmptyPicker"
@context-menu="onTermContext"
@pane-ref="setTermRef"
@reconnect="reconnect"
@go-servers="router.push('/servers')"
/>
<TerminalQuickBar
:commands="quickCmds"
:disconnect-disabled="!activeSession"
@exec="execQuickCmd"
@disconnect="disconnect"
/>
<TerminalQuickBar
:commands="quickCmds"
:disconnect-disabled="!activeSession"
@exec="execQuickCmd"
@disconnect="disconnect"
/>
<TerminalCmdBar
ref="cmdBarRef"
v-model="cmdInput"
:disabled="cmdBarDisabled"
@keydown="onCmdKeydown"
@context-menu="onCmdContext"
<TerminalCmdBar
ref="cmdBarRef"
v-model="cmdInput"
:disabled="cmdBarDisabled"
@keydown="onCmdKeydown"
@context-menu="onCmdContext"
/>
</div>
<TerminalServerPicker
mode="sidebar"
:servers="filteredServers"
:search="serverSearch"
:search-history="terminalSearchHistory"
:loading="serversLoading"
@update:search="onServerSearchUpdate"
@search-commit="commitServerSearch"
@select="(id) => newSession(id)"
/>
</div>
@@ -102,9 +104,11 @@
<TerminalServerPicker
:servers="filteredServers"
:search="serverSearch"
:search-history="terminalSearchHistory"
:loading="serversLoading"
mode="dialog"
@update:search="onServerSearch"
@update:search="onServerSearchUpdate"
@search-commit="commitServerSearch"
@select="onDialogSelect"
/>
</v-card-text>
@@ -140,6 +144,7 @@ const {
termContainer,
showServerDialog,
serverSearch,
terminalSearchHistory,
filteredServers,
showEmptyPicker,
quickCmds,
@@ -160,6 +165,8 @@ const {
execQuickCmd,
loadServers,
serversLoading,
commitServerSearch,
onServerSearchUpdate,
applyRouteQuery,
onGlobalKeydown,
disposeAllSessions,
@@ -185,10 +192,6 @@ const termMenuLocation = computed(() =>
menuY.value > window.innerHeight * 0.55 ? 'top end' : 'bottom end',
)
function onServerSearch(v: string) {
serverSearch.value = v
}
function openTermMenu(e: MouseEvent, context: 'terminal' | 'command') {
e.preventDefault()
termMenuContext.value = context
@@ -293,6 +296,9 @@ onBeforeUnmount(() => {
.terminal-root--classic {
background: #000000;
}
.terminal-main-row {
min-height: 0;
}
.font-mono {
font-family: 'Cascadia Code', 'Fira Code', 'JetBrains Mono', Menlo, monospace;
}