feat(terminal): 右侧服务器列表与搜索历史
终端与会话并行:右侧常驻选服列表,combobox 共用服务器页最近 10 条搜索历史。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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 | 判定 | 依据 |
|
||||
|---|------|------|
|
||||
| H1–H4 | 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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user