From e0133b9098ce3e750d6b752bb47ca8e139763581 Mon Sep 17 00:00:00 2001 From: Nexus Agent Date: Fri, 12 Jun 2026 01:58:33 +0800 Subject: [PATCH] =?UTF-8?q?feat(push,terminal):=20=E6=8E=A8=E9=80=81?= =?UTF-8?q?=E6=90=9C=E7=B4=A2=E5=8E=86=E5=8F=B25=E6=9D=A1=E4=B8=8E?= =?UTF-8?q?=E7=BB=88=E7=AB=AF=E4=B8=AD=E9=97=B4=E6=90=9C=E7=B4=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 推送页独立 combobox 历史(MySQL push_search,上限5);终端空态搜索回中间卡片,右侧栏仅列表。 Co-authored-by: Cursor --- docs/audit/2026-06-12-push-terminal-ui.md | 46 ++++++++++++++++ .../2026-06-12-push-server-search-history.md | 37 +++++++++++++ ...6-12-terminal-search-center-empty-state.md | 31 +++++++++++ .../src/components/push/PushServerGrid.vue | 19 +++++-- .../src/components/terminal/TerminalArea.vue | 5 +- .../terminal/TerminalServerPicker.vue | 24 +------- frontend/src/composables/push/usePushPage.ts | 1 + .../src/composables/push/usePushServers.ts | 35 ++++++++++++ .../src/composables/useServerSearchHistory.ts | 20 +++++-- frontend/src/pages/PushPage.vue | 5 +- frontend/src/pages/TerminalPage.vue | 19 +++++-- server/api/servers.py | 55 +++++++++++++++++++ server/utils/server_search_history.py | 19 +++++-- tests/test_server_search_history.py | 31 +++++++++++ 14 files changed, 303 insertions(+), 44 deletions(-) create mode 100644 docs/audit/2026-06-12-push-terminal-ui.md create mode 100644 docs/changelog/2026-06-12-push-server-search-history.md create mode 100644 docs/changelog/2026-06-12-terminal-search-center-empty-state.md diff --git a/docs/audit/2026-06-12-push-terminal-ui.md b/docs/audit/2026-06-12-push-terminal-ui.md new file mode 100644 index 00000000..1611b242 --- /dev/null +++ b/docs/audit/2026-06-12-push-terminal-ui.md @@ -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 | 判定 | 依据 | +|---|------|------| +| H1–H4 | PASS | 复用 admin_ui_preferences | + +## DoD + +- [x] type-check +- [x] pytest test_server_search_history +- [ ] 生产 `#/push` / `#/terminal` 验证 + +## 验证 + +推送:combobox 最近 5 条。终端:无会话时中间搜索,右侧仅列表。 diff --git a/docs/changelog/2026-06-12-push-server-search-history.md b/docs/changelog/2026-06-12-push-server-search-history.md new file mode 100644 index 00000000..c0c8aec3 --- /dev/null +++ b/docs/changelog/2026-06-12-push-server-search-history.md @@ -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` 偏好记录。 diff --git a/docs/changelog/2026-06-12-terminal-search-center-empty-state.md b/docs/changelog/2026-06-12-terminal-search-center-empty-state.md new file mode 100644 index 00000000..795bc743 --- /dev/null +++ b/docs/changelog/2026-06-12-terminal-search-center-empty-state.md @@ -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 +``` + +无会话时:中间有搜索框;右侧仅列表。中间搜索仍过滤两侧列表。 + +## 迁移 + +无。仅前端构建部署。 diff --git a/frontend/src/components/push/PushServerGrid.vue b/frontend/src/components/push/PushServerGrid.vue index e1addbf2..31eaffed 100644 --- a/frontend/src/components/push/PushServerGrid.vue +++ b/frontend/src/components/push/PushServerGrid.vue @@ -9,16 +9,21 @@ class="mr-2" @update:model-value="$emit('toggle-all')" /> - @@ -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()) diff --git a/frontend/src/components/terminal/TerminalArea.vue b/frontend/src/components/terminal/TerminalArea.vue index f2345195..df106a46 100644 --- a/frontend/src/components/terminal/TerminalArea.vue +++ b/frontend/src/components/terminal/TerminalArea.vue @@ -18,9 +18,10 @@ > mdi-console-network
WebSSH 终端
-
- 从右侧列表选择服务器,或点击上方 + 新建标签 +
+ 选择一台服务器开始会话,或从服务器页点击「终端」
+
({{ servers.length }}) -
- -
- 最近 {{ searchHistory.length }} 条与服务器页同步 -
-
- + (), - { mode: 'inline', loading: false, searchHistory: () => [] }, + { mode: 'inline', loading: false, search: '', searchHistory: () => [] }, ) defineEmits<{ diff --git a/frontend/src/composables/push/usePushPage.ts b/frontend/src/composables/push/usePushPage.ts index 3bb28008..4df6550d 100644 --- a/frontend/src/composables/push/usePushPage.ts +++ b/frontend/src/composables/push/usePushPage.ts @@ -34,6 +34,7 @@ export function usePushPage() { onMounted(() => { refreshPush(false) + void servers.loadSearchHistory() servers.startAutoRefresh(() => progress.pushing.value) pushReady = true }) diff --git a/frontend/src/composables/push/usePushServers.ts b/frontend/src/composables/push/usePushServers.ts index 3ea27f6b..7ec0fef9 100644 --- a/frontend/src/composables/push/usePushServers.ts +++ b/frontend/src/composables/push/usePushServers.ts @@ -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>) { const servers = ref([]) const serversLoading = ref(false) const serverSearch = ref('') const selectedIds = ref>(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 | null = null async function loadServers(silent = false) { @@ -113,12 +144,16 @@ export function usePushServers(pushStatus: Ref>) { servers, serversLoading, serverSearch, + serverSearchHistory, selectedIds, pushStatus, filteredServers, selectAll, serversByCategory, loadServers, + loadSearchHistory, + commitServerSearch, + onServerSearchUpdate, startAutoRefresh, stopAutoRefresh, isCategoryAllSelected, diff --git a/frontend/src/composables/useServerSearchHistory.ts b/frontend/src/composables/useServerSearchHistory.ts index 065e174f..206d30e4 100644 --- a/frontend/src/composables/useServerSearchHistory.ts +++ b/frontend/src/composables/useServerSearchHistory.ts @@ -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 = { + 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([]) const bootstrapped = ref(false) + const endpoint = HISTORY_ENDPOINT[scope] async function load(): Promise { - const res = await http.get('/servers/search-history') + const res = await http.get(endpoint) items.value = res.history || [] } async function migrateLegacyIfEmpty(): Promise { + if (scope !== 'servers') return false const legacy = readLegacyLocal() if (!legacy) return false if (items.value.length > 0) { clearLegacyLocal() return false } - await http.put('/servers/search-history', { + await http.put(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('/servers/search-history', { + const res = await http.put(endpoint, { query: normalized, }) items.value = res.history || [] diff --git a/frontend/src/pages/PushPage.vue b/frontend/src/pages/PushPage.vue index 0e760b9e..d4d3998d 100644 --- a/frontend/src/pages/PushPage.vue +++ b/frontend/src/pages/PushPage.vue @@ -62,12 +62,15 @@ + > + + diff --git a/server/api/servers.py b/server/api/servers.py index a3be945a..5101e5d6 100644 --- a/server/api/servers.py +++ b/server/api/servers.py @@ -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"), diff --git a/server/utils/server_search_history.py b/server/utils/server_search_history.py index b8728de3..a1a65a8d 100644 --- a/server/utils/server_search_history.py +++ b/server/utils/server_search_history.py @@ -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) diff --git a/tests/test_server_search_history.py b/tests/test_server_search_history.py index 7c6eb32d..1446f8ab 100644 --- a/tests/test_server_search_history.py +++ b/tests/test_server_search_history.py @@ -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)