fix(ui): 服务器/终端/推送搜索历史互不共享

终端独立 terminal_search 上下文(10条),与 servers_search、push_search 隔离。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Nexus Agent
2026-06-12 02:03:45 +08:00
parent e0133b9098
commit f6e8b29640
7 changed files with 185 additions and 10 deletions
@@ -0,0 +1,38 @@
# 审计 — 各页搜索历史互不共享
**Changelog**: `docs/changelog/2026-06-12-search-history-isolated-scopes.md`
## 范围(文件清单)
| 文件 | 变更 |
|------|------|
| `server/utils/server_search_history.py` | terminal 上下文,上限 10 |
| `server/api/servers.py` | terminal-search-history API |
| `frontend/src/composables/useServerSearchHistory.ts` | terminal scope |
| `frontend/src/composables/terminal/useTerminalSessions.ts` | 独立历史 |
| `tests/test_server_search_history.py` | 隔离与 API 测试 |
## Step 3 规则扫描
| H | 规则 | 结论 |
|---|------|------|
| H1 | 安全 | PASS — admin 隔离 |
| H2 | 无静默吞错 | PASS |
| H3 | 无新密钥 | PASS |
| H4 | CUD | N/A |
## Closure
| H | 判定 | 依据 |
|---|------|------|
| H1H4 | PASS | admin_ui_preferences 分 context |
## DoD
- [x] type-check
- [x] pytest test_server_search_history
- [ ] 生产三页互不串历史验证
## 验证
servers / terminal / push 各搜一词,下拉互不可见。
@@ -0,0 +1,35 @@
# 各页搜索历史互不共享
**日期**2026-06-12
## 动机
服务器页、终端页、推送页的搜索历史应各自独立,避免在一页搜索后其他页面下拉出现相同记录。
## 变更
- **终端**`GET/PUT /api/servers/terminal-search-history`,上下文 `terminal_search`,上限 10 条
- **推送**:已有 `push_search`5 条),不变
- **服务器页**:仍为 `servers_search`12 条),不变
- 前端 `useServerSearchHistory('terminal')` 替代原先与服务器页共用的默认 scope
## 涉及文件
- `server/utils/server_search_history.py`
- `server/api/servers.py`
- `frontend/src/composables/useServerSearchHistory.ts`
- `frontend/src/composables/terminal/useTerminalSessions.ts`
- `tests/test_server_search_history.py`
## 验证
```bash
cd frontend && npm run type-check
.venv/bin/pytest tests/test_server_search_history.py -q
```
三页分别搜索后,各自下拉仅见本页历史。
## 迁移
无新表;终端首次写入创建 `terminal_search` 偏好。原写入 `servers_search` 的终端记录不再显示(需用户在终端重新搜索)。
@@ -32,7 +32,6 @@ 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()
@@ -49,10 +48,8 @@ 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 { items: terminalSearchHistory, loadHistoryOnly, record: recordTerminalSearch } =
useServerSearchHistory('terminal')
const activeSession = computed(
() => sessions.value.find((s) => s.id === activeSessionId.value) || null,
@@ -726,19 +723,19 @@ export function useTerminalSessions(nexusDrawer: Ref<boolean>) {
}
function commitServerSearch() {
void recordServerSearch(serverSearch.value)
void recordTerminalSearch(serverSearch.value)
}
function onServerSearchUpdate(val: string | null) {
if (val == null || val === '') {
serverSearch.value = ''
void recordServerSearch('')
void recordTerminalSearch('')
return
}
const picked = String(val).trim()
serverSearch.value = picked
if (serverSearchHistory.value.includes(picked)) {
void recordServerSearch(picked)
if (terminalSearchHistory.value.includes(picked)) {
void recordTerminalSearch(picked)
}
}
@@ -34,11 +34,12 @@ function clearLegacyLocal() {
}
/** Servers / push page search history — persisted per admin in MySQL. */
export type ServerSearchHistoryScope = 'servers' | 'push'
export type ServerSearchHistoryScope = 'servers' | 'push' | 'terminal'
const HISTORY_ENDPOINT: Record<ServerSearchHistoryScope, string> = {
servers: '/servers/search-history',
push: '/servers/push-search-history',
terminal: '/servers/terminal-search-history',
}
/** Per-admin search history in MySQL (servers or push scope). */
+55
View File
@@ -399,6 +399,61 @@ async def update_push_search_history(
return state
@router.get("/terminal-search-history", response_model=dict)
async def get_terminal_search_history(
admin: Admin = Depends(get_current_admin),
db: AsyncSession = Depends(get_db),
):
"""Return terminal page server search history (max 10) for the current admin."""
from server.infrastructure.database.admin_ui_preference_repo import AdminUiPreferenceRepositoryImpl
from server.utils.server_search_history import (
MAX_TERMINAL_SEARCH_HISTORY,
TERMINAL_SEARCH_CONTEXT,
parse_search_state,
)
repo = AdminUiPreferenceRepositoryImpl(db)
raw = await repo.get(admin.id, TERMINAL_SEARCH_CONTEXT)
return parse_search_state(raw, max_history=MAX_TERMINAL_SEARCH_HISTORY)
@router.put("/terminal-search-history", response_model=dict)
async def update_terminal_search_history(
payload: ServerSearchHistoryUpdate,
admin: Admin = Depends(get_current_admin),
db: AsyncSession = Depends(get_db),
):
"""Record a terminal page search query or replace full history (max 10)."""
from server.infrastructure.database.admin_ui_preference_repo import AdminUiPreferenceRepositoryImpl
from server.utils.server_search_history import (
MAX_TERMINAL_SEARCH_HISTORY,
TERMINAL_SEARCH_CONTEXT,
apply_search_record,
apply_search_replace,
parse_search_state,
)
repo = AdminUiPreferenceRepositoryImpl(db)
state = parse_search_state(
await repo.get(admin.id, TERMINAL_SEARCH_CONTEXT),
max_history=MAX_TERMINAL_SEARCH_HISTORY,
)
if payload.history is not None:
state = apply_search_replace(
history=payload.history,
last=payload.last,
max_history=MAX_TERMINAL_SEARCH_HISTORY,
)
else:
state = apply_search_record(
state,
payload.query or "",
max_history=MAX_TERMINAL_SEARCH_HISTORY,
)
await repo.upsert(admin.id, TERMINAL_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"),
+2
View File
@@ -4,9 +4,11 @@ from __future__ import annotations
MAX_SERVER_SEARCH_HISTORY = 12
MAX_PUSH_SEARCH_HISTORY = 5
MAX_TERMINAL_SEARCH_HISTORY = 10
MAX_SERVER_SEARCH_QUERY_LEN = 200
SERVERS_SEARCH_CONTEXT = "servers_search"
PUSH_SEARCH_CONTEXT = "push_search"
TERMINAL_SEARCH_CONTEXT = "terminal_search"
def normalize_search_query(query: str | None) -> str:
+47
View File
@@ -7,6 +7,7 @@ import pytest
from server.utils.server_search_history import (
MAX_PUSH_SEARCH_HISTORY,
MAX_SERVER_SEARCH_HISTORY,
MAX_TERMINAL_SEARCH_HISTORY,
apply_search_record,
parse_search_state,
)
@@ -68,6 +69,52 @@ async def test_push_search_history_api_roundtrip(integration_env, api_client, au
assert body["history"][0] == "push-5"
@pytest.mark.asyncio
async def test_terminal_search_history_api_roundtrip(integration_env, api_client, auth_headers):
r = await api_client.get("/api/servers/terminal-search-history", headers=auth_headers)
assert r.status_code == 200
assert r.json() == {"history": [], "last": None}
for i in range(12):
r = await api_client.put(
"/api/servers/terminal-search-history",
headers=auth_headers,
json={"query": f"term-{i}"},
)
assert r.status_code == 200
body = r.json()
assert len(body["history"]) == MAX_TERMINAL_SEARCH_HISTORY
assert body["history"][0] == "term-11"
@pytest.mark.asyncio
async def test_search_scopes_do_not_share(integration_env, api_client, auth_headers):
await api_client.put(
"/api/servers/search-history",
headers=auth_headers,
json={"query": "servers-only"},
)
await api_client.put(
"/api/servers/terminal-search-history",
headers=auth_headers,
json={"query": "terminal-only"},
)
await api_client.put(
"/api/servers/push-search-history",
headers=auth_headers,
json={"query": "push-only"},
)
servers = (await api_client.get("/api/servers/search-history", headers=auth_headers)).json()
terminal = (await api_client.get("/api/servers/terminal-search-history", headers=auth_headers)).json()
push = (await api_client.get("/api/servers/push-search-history", headers=auth_headers)).json()
assert servers["history"] == ["servers-only"]
assert terminal["history"] == ["terminal-only"]
assert push["history"] == ["push-only"]
@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)