feat(push,terminal): 推送搜索历史5条与终端中间搜索

推送页独立 combobox 历史(MySQL push_search,上限5);终端空态搜索回中间卡片,右侧栏仅列表。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Nexus Agent
2026-06-12 01:58:33 +08:00
parent 724375c884
commit e0133b9098
14 changed files with 303 additions and 44 deletions
+46
View File
@@ -0,0 +1,46 @@
# 审计 — 推送搜索历史 + 终端搜索回中间
**Changelog**:
- `docs/changelog/2026-06-12-push-server-search-history.md`
- `docs/changelog/2026-06-12-terminal-search-center-empty-state.md`
## 范围(文件清单)
| 文件 | 变更 |
|------|------|
| `server/utils/server_search_history.py` | push 上下文,上限 5 |
| `server/api/servers.py` | push-search-history API |
| `frontend/src/composables/useServerSearchHistory.ts` | scope 参数 |
| `frontend/src/composables/push/usePushServers.ts` | 推送搜索历史 |
| `frontend/src/composables/push/usePushPage.ts` | 加载历史 |
| `frontend/src/components/push/PushServerGrid.vue` | combobox |
| `frontend/src/pages/PushPage.vue` | 绑定历史 |
| `frontend/src/pages/TerminalPage.vue` | 中间空态搜索 |
| `frontend/src/components/terminal/TerminalArea.vue` | empty-picker 插槽 |
| `frontend/src/components/terminal/TerminalServerPicker.vue` | 侧栏无搜索 |
| `tests/test_server_search_history.py` | push 上限与 API |
## Step 3 规则扫描
| H | 规则 | 结论 |
|---|------|------|
| H1 | 安全 | PASS — 按 admin 隔离偏好 |
| H2 | 无静默吞错 | PASS |
| H3 | 无新密钥 | PASS |
| H4 | CUD | N/A — 仅 UI 偏好 |
## Closure
| H | 判定 | 依据 |
|---|------|------|
| H1H4 | PASS | 复用 admin_ui_preferences |
## DoD
- [x] type-check
- [x] pytest test_server_search_history
- [ ] 生产 `#/push` / `#/terminal` 验证
## 验证
推送:combobox 最近 5 条。终端:无会话时中间搜索,右侧仅列表。
@@ -0,0 +1,37 @@
# 推送页服务器搜索历史(5 条)
**日期**2026-06-12
## 动机
推送页搜索服务器需记住最近查询,便于重复筛选;与服务器页历史独立,最多保留 5 条。
## 变更
- **后端**`GET/PUT /api/servers/push-search-history``admin_ui_preferences` 上下文 `push_search`,上限 5 条
- **前端**:搜索框改为 combobox;回车或选历史项写入;与服务器页历史互不影响
- **复用**`useServerSearchHistory('push')` 与现有 MySQL 偏好存储一致
## 涉及文件
- `server/utils/server_search_history.py`
- `server/api/servers.py`
- `frontend/src/composables/useServerSearchHistory.ts`
- `frontend/src/composables/push/usePushServers.ts`
- `frontend/src/composables/push/usePushPage.ts`
- `frontend/src/components/push/PushServerGrid.vue`
- `frontend/src/pages/PushPage.vue`
- `tests/test_server_search_history.py`
## 验证
```bash
cd frontend && npm run type-check
pytest tests/test_server_search_history.py -q
```
推送页:输入关键词回车 → 下拉可见最近 5 条;换浏览器登录同一账号仍可恢复。
## 迁移
无新表;首次写入自动创建 `push_search` 偏好记录。
@@ -0,0 +1,31 @@
# 终端搜索框回到中间空态
**日期**2026-06-12
## 动机
终端页右侧栏保留服务器列表,但搜索框应仍在中间空态卡片(与改版前一致),不在右侧栏重复。
## 变更
- 恢复 `TerminalArea``#empty-picker` 插槽与中间 inline 选服卡片(含搜索 + 历史)
- 右侧 `sidebar` 模式仅展示服务器列表,移除顶部 combobox
- 空态文案恢复为「选择一台服务器开始会话…」
## 涉及文件
- `frontend/src/pages/TerminalPage.vue`
- `frontend/src/components/terminal/TerminalArea.vue`
- `frontend/src/components/terminal/TerminalServerPicker.vue`
## 验证
```bash
cd frontend && npm run type-check
```
无会话时:中间有搜索框;右侧仅列表。中间搜索仍过滤两侧列表。
## 迁移
无。仅前端构建部署。
@@ -9,16 +9,21 @@
class="mr-2"
@update:model-value="$emit('toggle-all')"
/>
<v-text-field
<v-combobox
:model-value="serverSearch"
:items="searchHistory"
prepend-inner-icon="mdi-magnify"
label="搜索服务器..."
density="compact"
hide-details
variant="outlined"
rounded
clearable
:auto-select-first="false"
placeholder="输入后回车,或从历史选择"
style="max-width: 240px"
@update:model-value="$emit('update:serverSearch', $event)"
@update:model-value="onSearchUpdate"
@keydown.enter.prevent="$emit('search-commit')"
/>
<v-btn
icon="mdi-refresh"
@@ -177,6 +182,7 @@ import type { PushServerItem } from '@/composables/push/types'
defineProps<{
selectAll: boolean
serverSearch: string
searchHistory: string[]
serversLoading: boolean
serversByCategory: { category: string; label: string; servers: PushServerItem[] }[]
selectedIds: Set<number>
@@ -184,8 +190,9 @@ defineProps<{
isCategoryAllSelected: (category: string) => boolean
}>()
defineEmits<{
'update:serverSearch': [value: string]
const emit = defineEmits<{
'update:serverSearch': [value: string | null]
'search-commit': []
'toggle-all': []
refresh: []
'toggle-category': [category: string]
@@ -194,6 +201,10 @@ defineEmits<{
const { viewMode } = usePushServerViewMode()
function onSearchUpdate(val: string | null) {
emit('update:serverSearch', val)
}
function serverLabelTitle(s: PushServerItem): string {
const parts = [s.name]
if (s.domain?.trim()) parts.push(s.domain.trim())
@@ -18,9 +18,10 @@
>
<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 text-center">
从右侧列表选择服务器或点击上方 + 新建标签
<div class="text-body-2 text-medium-emphasis mb-4 text-center">
选择一台服务器开始会话或从服务器页点击终端
</div>
<slot name="empty-picker" />
</div>
<v-overlay
@@ -4,25 +4,7 @@
服务器
<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="搜索名称/域名…"
variant="outlined"
density="compact"
hide-details
clearable
:auto-select-first="false"
@update:model-value="$emit('update:search', $event)"
@keydown.enter.prevent="$emit('search-commit')"
/>
<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-skeleton-loader v-if="loading" type="list-item@8" class="px-2 pt-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"
@@ -81,12 +63,12 @@ import type { TerminalServerItem } from '@/composables/terminal/types'
withDefaults(
defineProps<{
servers: TerminalServerItem[]
search: string
search?: string
searchHistory?: string[]
loading?: boolean
mode?: 'inline' | 'dialog' | 'sidebar'
}>(),
{ mode: 'inline', loading: false, searchHistory: () => [] },
{ mode: 'inline', loading: false, search: '', searchHistory: () => [] },
)
defineEmits<{
@@ -34,6 +34,7 @@ export function usePushPage() {
onMounted(() => {
refreshPush(false)
void servers.loadSearchHistory()
servers.startAutoRefresh(() => progress.pushing.value)
pushReady = true
})
@@ -1,14 +1,45 @@
import { ref, computed, type Ref } from 'vue'
import { categoryDisplayLabel } from '@/composables/useServerCategories'
import { useServerSearchHistory } from '@/composables/useServerSearchHistory'
import { fetchPagePerPage } from '@/utils/paginatedFetch'
import type { PushServerItem } from './types'
const PUSH_SEARCH_HISTORY = 5
export function usePushServers(pushStatus: Ref<Record<number, string>>) {
const servers = ref<PushServerItem[]>([])
const serversLoading = ref(false)
const serverSearch = ref('')
const selectedIds = ref<Set<number>>(new Set())
const { items: pushSearchHistory, loadHistoryOnly, record: recordPushSearch } =
useServerSearchHistory('push')
const serverSearchHistory = computed(() =>
pushSearchHistory.value.slice(0, PUSH_SEARCH_HISTORY),
)
function commitServerSearch() {
void recordPushSearch(serverSearch.value)
}
function onServerSearchUpdate(val: string | null) {
serverSearch.value = val ?? ''
if (val == null || val === '') {
void recordPushSearch('')
return
}
const picked = String(val).trim()
if (pushSearchHistory.value.includes(picked)) {
serverSearch.value = picked
void recordPushSearch(picked)
}
}
async function loadSearchHistory() {
await loadHistoryOnly()
}
let refreshTimer: ReturnType<typeof setInterval> | null = null
async function loadServers(silent = false) {
@@ -113,12 +144,16 @@ export function usePushServers(pushStatus: Ref<Record<number, string>>) {
servers,
serversLoading,
serverSearch,
serverSearchHistory,
selectedIds,
pushStatus,
filteredServers,
selectAll,
serversByCategory,
loadServers,
loadSearchHistory,
commitServerSearch,
onServerSearchUpdate,
startAutoRefresh,
stopAutoRefresh,
isCategoryAllSelected,
@@ -33,24 +33,34 @@ function clearLegacyLocal() {
}
}
/** Servers page search history — persisted per admin in MySQL. */
export function useServerSearchHistory() {
/** Servers / push page search history — persisted per admin in MySQL. */
export type ServerSearchHistoryScope = 'servers' | 'push'
const HISTORY_ENDPOINT: Record<ServerSearchHistoryScope, string> = {
servers: '/servers/search-history',
push: '/servers/push-search-history',
}
/** Per-admin search history in MySQL (servers or push scope). */
export function useServerSearchHistory(scope: ServerSearchHistoryScope = 'servers') {
const items = ref<string[]>([])
const bootstrapped = ref(false)
const endpoint = HISTORY_ENDPOINT[scope]
async function load(): Promise<void> {
const res = await http.get<ServerSearchHistoryResponse>('/servers/search-history')
const res = await http.get<ServerSearchHistoryResponse>(endpoint)
items.value = res.history || []
}
async function migrateLegacyIfEmpty(): Promise<boolean> {
if (scope !== 'servers') return false
const legacy = readLegacyLocal()
if (!legacy) return false
if (items.value.length > 0) {
clearLegacyLocal()
return false
}
await http.put<ServerSearchHistoryResponse>('/servers/search-history', {
await http.put<ServerSearchHistoryResponse>(endpoint, {
history: legacy.history,
last: legacy.last || legacy.history[0] || '',
})
@@ -76,7 +86,7 @@ export function useServerSearchHistory() {
async function record(query: string | null | undefined) {
const normalized = String(query ?? '').trim()
try {
const res = await http.put<ServerSearchHistoryResponse>('/servers/search-history', {
const res = await http.put<ServerSearchHistoryResponse>(endpoint, {
query: normalized,
})
items.value = res.history || []
+4 -1
View File
@@ -62,12 +62,15 @@
<PushServerGrid
:select-all="servers.selectAll"
v-model:server-search="servers.serverSearch"
:server-search="servers.serverSearch"
:search-history="servers.serverSearchHistory"
:servers-loading="servers.serversLoading"
:servers-by-category="servers.serversByCategory"
:selected-ids="servers.selectedIds"
:push-status="servers.pushStatus"
:is-category-all-selected="servers.isCategoryAllSelected"
@update:server-search="servers.onServerSearchUpdate"
@search-commit="servers.commitServerSearch"
@toggle-all="servers.toggleAll"
@refresh="servers.loadServers"
@toggle-category="servers.toggleCategory"
+14 -5
View File
@@ -67,7 +67,20 @@
@pane-ref="setTermRef"
@reconnect="reconnect"
@go-servers="router.push('/servers')"
/>
>
<template #empty-picker>
<TerminalServerPicker
:servers="filteredServers"
:search="serverSearch"
:search-history="terminalSearchHistory"
:loading="serversLoading"
mode="inline"
@update:search="onServerSearchUpdate"
@search-commit="commitServerSearch"
@select="(id) => newSession(id)"
/>
</template>
</TerminalArea>
<TerminalQuickBar
:commands="quickCmds"
@@ -88,11 +101,7 @@
<TerminalServerPicker
mode="sidebar"
:servers="filteredServers"
:search="serverSearch"
:search-history="terminalSearchHistory"
:loading="serversLoading"
@update:search="onServerSearchUpdate"
@search-commit="commitServerSearch"
@select="(id) => newSession(id)"
/>
</div>
+55
View File
@@ -344,6 +344,61 @@ async def update_server_search_history(
return state
@router.get("/push-search-history", response_model=dict)
async def get_push_search_history(
admin: Admin = Depends(get_current_admin),
db: AsyncSession = Depends(get_db),
):
"""Return push page server search history (max 5) for the current admin."""
from server.infrastructure.database.admin_ui_preference_repo import AdminUiPreferenceRepositoryImpl
from server.utils.server_search_history import (
MAX_PUSH_SEARCH_HISTORY,
PUSH_SEARCH_CONTEXT,
parse_search_state,
)
repo = AdminUiPreferenceRepositoryImpl(db)
raw = await repo.get(admin.id, PUSH_SEARCH_CONTEXT)
return parse_search_state(raw, max_history=MAX_PUSH_SEARCH_HISTORY)
@router.put("/push-search-history", response_model=dict)
async def update_push_search_history(
payload: ServerSearchHistoryUpdate,
admin: Admin = Depends(get_current_admin),
db: AsyncSession = Depends(get_db),
):
"""Record a push page search query or replace full history (max 5)."""
from server.infrastructure.database.admin_ui_preference_repo import AdminUiPreferenceRepositoryImpl
from server.utils.server_search_history import (
MAX_PUSH_SEARCH_HISTORY,
PUSH_SEARCH_CONTEXT,
apply_search_record,
apply_search_replace,
parse_search_state,
)
repo = AdminUiPreferenceRepositoryImpl(db)
state = parse_search_state(
await repo.get(admin.id, PUSH_SEARCH_CONTEXT),
max_history=MAX_PUSH_SEARCH_HISTORY,
)
if payload.history is not None:
state = apply_search_replace(
history=payload.history,
last=payload.last,
max_history=MAX_PUSH_SEARCH_HISTORY,
)
else:
state = apply_search_record(
state,
payload.query or "",
max_history=MAX_PUSH_SEARCH_HISTORY,
)
await repo.upsert(admin.id, PUSH_SEARCH_CONTEXT, state)
return state
@router.get("/logs", response_model=dict)
async def get_all_sync_logs(
server_id: Optional[int] = Query(None, description="Filter by server ID"),
+13 -6
View File
@@ -3,8 +3,10 @@
from __future__ import annotations
MAX_SERVER_SEARCH_HISTORY = 12
MAX_PUSH_SEARCH_HISTORY = 5
MAX_SERVER_SEARCH_QUERY_LEN = 200
SERVERS_SEARCH_CONTEXT = "servers_search"
PUSH_SEARCH_CONTEXT = "push_search"
def normalize_search_query(query: str | None) -> str:
@@ -15,7 +17,7 @@ def empty_search_state() -> dict:
return {"history": [], "last": None}
def parse_search_state(raw: dict | None) -> dict:
def parse_search_state(raw: dict | None, *, max_history: int = MAX_SERVER_SEARCH_HISTORY) -> dict:
if not raw:
return empty_search_state()
history_raw = raw.get("history")
@@ -27,7 +29,7 @@ def parse_search_state(raw: dict | None) -> dict:
q = normalize_search_query(item)
if q and q not in history:
history.append(q)
if len(history) >= MAX_SERVER_SEARCH_HISTORY:
if len(history) >= max_history:
break
last_raw = raw.get("last")
last = normalize_search_query(last_raw) if isinstance(last_raw, str) else ""
@@ -35,22 +37,27 @@ def parse_search_state(raw: dict | None) -> dict:
return {"history": history, "last": last}
def apply_search_record(state: dict, query: str) -> dict:
def apply_search_record(state: dict, query: str, *, max_history: int = MAX_SERVER_SEARCH_HISTORY) -> dict:
q = normalize_search_query(query)
history = list(state.get("history") or [])
if not q:
return {"history": history, "last": None}
history = [q] + [x for x in history if x != q][:MAX_SERVER_SEARCH_HISTORY - 1]
history = [q] + [x for x in history if x != q][: max_history - 1]
return {"history": history, "last": q}
def apply_search_replace(*, history: list[str], last: str | None = None) -> dict:
def apply_search_replace(
*,
history: list[str],
last: str | None = None,
max_history: int = MAX_SERVER_SEARCH_HISTORY,
) -> dict:
cleaned: list[str] = []
for item in history:
q = normalize_search_query(item)
if q and q not in cleaned:
cleaned.append(q)
if len(cleaned) >= MAX_SERVER_SEARCH_HISTORY:
if len(cleaned) >= max_history:
break
last_norm = normalize_search_query(last) if last else ""
last_val = last_norm or (cleaned[0] if cleaned else None)
+31
View File
@@ -5,6 +5,7 @@ from __future__ import annotations
import pytest
from server.utils.server_search_history import (
MAX_PUSH_SEARCH_HISTORY,
MAX_SERVER_SEARCH_HISTORY,
apply_search_record,
parse_search_state,
@@ -37,6 +38,36 @@ def test_parse_search_state_normalizes():
assert parsed["last"] == "x"
def test_push_search_history_caps_at_five():
raw = {"history": [f"q{i}" for i in range(8)], "last": "q0"}
parsed = parse_search_state(raw, max_history=MAX_PUSH_SEARCH_HISTORY)
assert len(parsed["history"]) == MAX_PUSH_SEARCH_HISTORY
state = {"history": [f"q{i}" for i in range(MAX_PUSH_SEARCH_HISTORY)], "last": "q0"}
capped = apply_search_record(state, "new", max_history=MAX_PUSH_SEARCH_HISTORY)
assert capped["history"][0] == "new"
assert len(capped["history"]) == MAX_PUSH_SEARCH_HISTORY
@pytest.mark.asyncio
async def test_push_search_history_api_roundtrip(integration_env, api_client, auth_headers):
r = await api_client.get("/api/servers/push-search-history", headers=auth_headers)
assert r.status_code == 200
assert r.json() == {"history": [], "last": None}
for i in range(6):
r = await api_client.put(
"/api/servers/push-search-history",
headers=auth_headers,
json={"query": f"push-{i}"},
)
assert r.status_code == 200
body = r.json()
assert len(body["history"]) == MAX_PUSH_SEARCH_HISTORY
assert body["history"][0] == "push-5"
@pytest.mark.asyncio
async def test_search_history_api_roundtrip(integration_env, api_client, auth_headers):
r = await api_client.get("/api/servers/search-history", headers=auth_headers)