From 130cc840c71318f767e1caacdb4e0889b8bb6d7b Mon Sep 17 00:00:00 2001 From: Nexus Agent Date: Thu, 11 Jun 2026 17:46:52 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=9C=8D=E5=8A=A1=E5=99=A8=E6=90=9C?= =?UTF-8?q?=E7=B4=A2=E5=8E=86=E5=8F=B2=E6=8C=81=E4=B9=85=E5=8C=96=E5=88=B0?= =?UTF-8?q?=20MySQL?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按管理员将 Servers 页搜索记录存入 admin_ui_preferences,换设备登录仍可恢复;首次自动迁移 localStorage。 Co-authored-by: Cursor --- .../2026-06-11-servers-search-history.md | 44 ++++++++++ .../src/composables/useServerSearchHistory.ts | 87 +++++++++++++++++++ frontend/src/pages/ServersPage.vue | 12 ++- server/api/schemas.py | 13 +++ server/api/servers.py | 44 +++++++++- server/domain/models/__init__.py | 10 +++ .../database/admin_ui_preference_repo.py | 55 ++++++++++++ server/infrastructure/database/migrations.py | 8 ++ server/utils/server_search_history.py | 57 ++++++++++++ tests/test_server_search_history.py | 65 ++++++++++++++ 10 files changed, 393 insertions(+), 2 deletions(-) create mode 100644 docs/changelog/2026-06-11-servers-search-history.md create mode 100644 frontend/src/composables/useServerSearchHistory.ts create mode 100644 server/infrastructure/database/admin_ui_preference_repo.py create mode 100644 server/utils/server_search_history.py create mode 100644 tests/test_server_search_history.py diff --git a/docs/changelog/2026-06-11-servers-search-history.md b/docs/changelog/2026-06-11-servers-search-history.md new file mode 100644 index 00000000..068f0093 --- /dev/null +++ b/docs/changelog/2026-06-11-servers-search-history.md @@ -0,0 +1,44 @@ +# Changelog — 服务器列表搜索记录存数据库 + +**日期**:2026-06-11 + +## 摘要 + +服务器页搜索历史与上次搜索词改为按管理员存入 MySQL `admin_ui_preferences`,换浏览器/清缓存仍可恢复;首次加载自动迁移旧版 localStorage。 + +## 动机 + +用户要求搜索记录持久化到数据库,而非仅本机 localStorage。 + +## 涉及文件 + +- `server/domain/models/__init__.py` — `AdminUiPreference` +- `server/infrastructure/database/admin_ui_preference_repo.py` +- `server/infrastructure/database/migrations.py` — 建表 +- `server/utils/server_search_history.py` — 归一化/合并规则 +- `server/api/servers.py` — `GET/PUT /api/servers/search-history` +- `server/api/schemas.py` — `ServerSearchHistoryUpdate` +- `frontend/src/composables/useServerSearchHistory.ts` +- `frontend/src/pages/ServersPage.vue` +- `tests/test_server_search_history.py` + +## API + +| 方法 | 路径 | 说明 | +|------|------|------| +| GET | `/api/servers/search-history` | `{ history: string[], last: string \| null }` | +| PUT | `/api/servers/search-history` | `{ query }` 记录一条,或 `{ history, last? }` 整表替换 | + +## 迁移 / 重启 + +- 启动时 schema migration 自动建表 `admin_ui_preferences` +- 需重启/部署后端;前端需构建部署 + +## 验证 + +```bash +pytest tests/test_server_search_history.py -q +cd frontend && npm run type-check +``` + +服务器页搜索 → 换浏览器同账号登录 → 历史与上次搜索仍在。 diff --git a/frontend/src/composables/useServerSearchHistory.ts b/frontend/src/composables/useServerSearchHistory.ts new file mode 100644 index 00000000..4a093d6c --- /dev/null +++ b/frontend/src/composables/useServerSearchHistory.ts @@ -0,0 +1,87 @@ +import { ref, type Ref } from 'vue' +import { http } from '@/api' + +const LEGACY_HISTORY = 'nexus_servers_search_history' +const LEGACY_LAST = 'nexus_servers_search_last' + +export interface ServerSearchHistoryResponse { + history: string[] + last: string | null +} + +function readLegacyLocal(): { history: string[]; last: string } | null { + try { + const raw = localStorage.getItem(LEGACY_HISTORY) + const last = localStorage.getItem(LEGACY_LAST) || '' + if (!raw) return last.trim() ? { history: [], last } : null + const parsed = JSON.parse(raw) as unknown + if (!Array.isArray(parsed)) return last.trim() ? { history: [], last } : null + const history = parsed.filter((x): x is string => typeof x === 'string' && x.trim().length > 0) + if (!history.length && !last.trim()) return null + return { history, last } + } catch { + return null + } +} + +function clearLegacyLocal() { + try { + localStorage.removeItem(LEGACY_HISTORY) + localStorage.removeItem(LEGACY_LAST) + } catch { + // ignore + } +} + +/** Servers page search history — persisted per admin in MySQL. */ +export function useServerSearchHistory() { + const items = ref([]) + const bootstrapped = ref(false) + + async function load(): Promise { + const res = await http.get('/servers/search-history') + items.value = res.history || [] + return res.last || '' + } + + async function migrateLegacyIfEmpty(): Promise { + const legacy = readLegacyLocal() + if (!legacy) return false + if (items.value.length > 0) { + clearLegacyLocal() + return false + } + await http.put('/servers/search-history', { + history: legacy.history, + last: legacy.last || legacy.history[0] || '', + }) + clearLegacyLocal() + return true + } + + async function bootstrap(searchRef: Ref) { + if (bootstrapped.value) return + try { + let last = await load() + if (!items.value.length && (await migrateLegacyIfEmpty())) { + last = await load() + } + if (last) searchRef.value = last + } catch { + // offline / auth — keep empty search + } finally { + bootstrapped.value = true + } + } + + async function record(query: string) { + try { + const res = await http.put('/servers/search-history', { query }) + items.value = res.history || [] + } catch { + // non-blocking + } + } + + return { items, bootstrapped, load, bootstrap, record } +} diff --git a/frontend/src/pages/ServersPage.vue b/frontend/src/pages/ServersPage.vue index ff09fb77..e6959a2e 100644 --- a/frontend/src/pages/ServersPage.vue +++ b/frontend/src/pages/ServersPage.vue @@ -38,15 +38,19 @@ 服务器列表 - @@ -649,6 +653,7 @@ import { useRouter, useRoute } from 'vue-router' import { http } from '@/api' import { useSnackbar } from '@/composables/useSnackbar' import { useServerPagination } from '@/composables/useServerPagination' +import { useServerSearchHistory } from '@/composables/useServerSearchHistory' import { categoryDisplayLabel, useServerCategories } from '@/composables/useServerCategories' import { statusChipColor, statusLabel, formatRelativeTime } from '@/utils/status' import { fetchPagePerPage } from '@/utils/paginatedFetch' @@ -752,10 +757,14 @@ const { loadAllServers, listQueryParams, } = useServerPagination({ targetPathUnset: false }) +const { items: serverSearchHistory, record: recordServerSearch, bootstrap: bootstrapServerSearch } = + useServerSearchHistory() + let searchDebounceTimer: ReturnType function onSearch() { clearTimeout(searchDebounceTimer) searchDebounceTimer = setTimeout(() => { + void recordServerSearch(search.value) page.value = 1 unsetPathPage.value = 1 void loadServers() @@ -1686,6 +1695,7 @@ async function doDelete() { } async function refreshServersPage(silent = false) { + await bootstrapServerSearch(search) await Promise.all([ loadStats(silent), loadCategories(silent), diff --git a/server/api/schemas.py b/server/api/schemas.py index 1ee0065a..7dfbb56c 100644 --- a/server/api/schemas.py +++ b/server/api/schemas.py @@ -155,6 +155,19 @@ class BatchCategoryUpdate(BaseModel): category: str = Field("", max_length=100, description="分类名称;留空表示清除分类") +class ServerSearchHistoryUpdate(BaseModel): + """Record or replace servers page search history for current admin.""" + query: Optional[str] = Field(None, max_length=200, description="记录一次搜索;空字符串清除 last") + history: Optional[List[str]] = Field(None, max_length=12, description="整表替换历史(迁移/恢复)") + last: Optional[str] = Field(None, max_length=200, description="与 history 联用时指定 last") + + @model_validator(mode="after") + def _require_mutation(self) -> "ServerSearchHistoryUpdate": + if self.query is None and self.history is None: + raise ValueError("query 或 history 至少提供一个") + return self + + class BatchCategoryResult(BaseModel): updated: int = 0 not_found: List[int] = [] diff --git a/server/api/servers.py b/server/api/servers.py index 376cde52..9099a8b4 100644 --- a/server/api/servers.py +++ b/server/api/servers.py @@ -18,7 +18,7 @@ from server.api.auth_jwt import get_current_admin from server.api.schemas import ( ServerCreate, ServerUpdate, ServerCheck, ApiKeyRevealRequest, ServerImportResult, BatchAgentAction, AddByIpRequest, AddByIpsBatchRequest, AddByIpsBatchResult, AddByIpsBatchItemResult, - BatchCategoryUpdate, BatchJobStarted, BatchJobStatus, + BatchCategoryUpdate, BatchJobStarted, BatchJobStatus, ServerSearchHistoryUpdate, ) from server.application.server_batch_common import ( install_error_msg as _install_error_msg, @@ -289,6 +289,48 @@ async def server_categories( return {"categories": categories, "platforms": platforms} +@router.get("/search-history", response_model=dict) +async def get_server_search_history( + admin: Admin = Depends(get_current_admin), + db: AsyncSession = Depends(get_db), +): + """Return servers page search history for the current admin.""" + from server.infrastructure.database.admin_ui_preference_repo import AdminUiPreferenceRepositoryImpl + from server.utils.server_search_history import ( + SERVERS_SEARCH_CONTEXT, + parse_search_state, + ) + + repo = AdminUiPreferenceRepositoryImpl(db) + raw = await repo.get(admin.id, SERVERS_SEARCH_CONTEXT) + return parse_search_state(raw) + + +@router.put("/search-history", response_model=dict) +async def update_server_search_history( + payload: ServerSearchHistoryUpdate, + admin: Admin = Depends(get_current_admin), + db: AsyncSession = Depends(get_db), +): + """Record a search query or replace full history for the current admin.""" + from server.infrastructure.database.admin_ui_preference_repo import AdminUiPreferenceRepositoryImpl + from server.utils.server_search_history import ( + SERVERS_SEARCH_CONTEXT, + apply_search_record, + apply_search_replace, + parse_search_state, + ) + + repo = AdminUiPreferenceRepositoryImpl(db) + state = parse_search_state(await repo.get(admin.id, SERVERS_SEARCH_CONTEXT)) + if payload.history is not None: + state = apply_search_replace(history=payload.history, last=payload.last) + else: + state = apply_search_record(state, payload.query or "") + await repo.upsert(admin.id, SERVERS_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/domain/models/__init__.py b/server/domain/models/__init__.py index 555a6a5c..06e84aa6 100644 --- a/server/domain/models/__init__.py +++ b/server/domain/models/__init__.py @@ -202,6 +202,16 @@ class Admin(Base): token_version = Column(Integer, default=0, comment="令牌版本(重用时递增使旧token失效)") +class AdminUiPreference(Base): + """Per-admin UI state (search history, etc.) — JSON payload keyed by context.""" + __tablename__ = "admin_ui_preferences" + + admin_id = Column(Integer, ForeignKey("admins.id", ondelete="CASCADE"), primary_key=True) + context = Column(String(50), primary_key=True, comment="UI 上下文,如 servers_search") + payload = Column(JSON, nullable=False, comment='JSON: {"history":[],"last":null}') + updated_at = Column(DateTime, default=_utcnow, onupdate=_utcnow) + + class LoginAttempt(Base): """Login attempt log for brute-force protection""" __tablename__ = "login_attempts" diff --git a/server/infrastructure/database/admin_ui_preference_repo.py b/server/infrastructure/database/admin_ui_preference_repo.py new file mode 100644 index 00000000..be76bd50 --- /dev/null +++ b/server/infrastructure/database/admin_ui_preference_repo.py @@ -0,0 +1,55 @@ +"""Admin UI preference repository — per-admin JSON blobs (search history, etc.).""" + +from __future__ import annotations + +from datetime import datetime, timezone + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from server.domain.models import AdminUiPreference + + +def _utcnow() -> datetime: + return datetime.now(timezone.utc).replace(tzinfo=None) + + +class AdminUiPreferenceRepositoryImpl: + def __init__(self, session: AsyncSession): + self.session = session + + async def get(self, admin_id: int, context: str) -> dict | None: + result = await self.session.execute( + select(AdminUiPreference).where( + AdminUiPreference.admin_id == admin_id, + AdminUiPreference.context == context, + ) + ) + row = result.scalar_one_or_none() + if not row or not isinstance(row.payload, dict): + return None + return dict(row.payload) + + async def upsert(self, admin_id: int, context: str, payload: dict) -> dict: + result = await self.session.execute( + select(AdminUiPreference).where( + AdminUiPreference.admin_id == admin_id, + AdminUiPreference.context == context, + ) + ) + row = result.scalar_one_or_none() + now = _utcnow() + if row: + row.payload = payload + row.updated_at = now + else: + self.session.add( + AdminUiPreference( + admin_id=admin_id, + context=context, + payload=payload, + updated_at=now, + ) + ) + await self.session.commit() + return payload diff --git a/server/infrastructure/database/migrations.py b/server/infrastructure/database/migrations.py index dd13fed5..624acc3d 100644 --- a/server/infrastructure/database/migrations.py +++ b/server/infrastructure/database/migrations.py @@ -210,6 +210,14 @@ async def run_schema_migrations(): INDEX `idx_admin_refresh_admin_expires` (`admin_id`, `expires_at`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4""", "DROP TABLE IF EXISTS `rdp_hosts`", + """CREATE TABLE IF NOT EXISTS `admin_ui_preferences` ( + `admin_id` INT NOT NULL COMMENT '管理员 ID', + `context` VARCHAR(50) NOT NULL COMMENT 'UI 上下文', + `payload` JSON NOT NULL COMMENT 'JSON 状态', + `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`admin_id`, `context`), + CONSTRAINT `fk_admin_ui_pref_admin` FOREIGN KEY (`admin_id`) REFERENCES `admins` (`id`) ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4""", ] async with AsyncSessionLocal() as session: for sql in migrations: diff --git a/server/utils/server_search_history.py b/server/utils/server_search_history.py new file mode 100644 index 00000000..b8728de3 --- /dev/null +++ b/server/utils/server_search_history.py @@ -0,0 +1,57 @@ +"""Server list search history — normalize and merge rules (DB payload shape).""" + +from __future__ import annotations + +MAX_SERVER_SEARCH_HISTORY = 12 +MAX_SERVER_SEARCH_QUERY_LEN = 200 +SERVERS_SEARCH_CONTEXT = "servers_search" + + +def normalize_search_query(query: str | None) -> str: + return (query or "").strip()[:MAX_SERVER_SEARCH_QUERY_LEN] + + +def empty_search_state() -> dict: + return {"history": [], "last": None} + + +def parse_search_state(raw: dict | None) -> dict: + if not raw: + return empty_search_state() + history_raw = raw.get("history") + history: list[str] = [] + if isinstance(history_raw, list): + for item in history_raw: + if not isinstance(item, str): + continue + q = normalize_search_query(item) + if q and q not in history: + history.append(q) + if len(history) >= MAX_SERVER_SEARCH_HISTORY: + break + last_raw = raw.get("last") + last = normalize_search_query(last_raw) if isinstance(last_raw, str) else "" + last = last or None + return {"history": history, "last": last} + + +def apply_search_record(state: dict, query: str) -> 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] + return {"history": history, "last": q} + + +def apply_search_replace(*, history: list[str], last: str | None = None) -> 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: + break + last_norm = normalize_search_query(last) if last else "" + last_val = last_norm or (cleaned[0] if cleaned else None) + return {"history": cleaned, "last": last_val} diff --git a/tests/test_server_search_history.py b/tests/test_server_search_history.py new file mode 100644 index 00000000..7c6eb32d --- /dev/null +++ b/tests/test_server_search_history.py @@ -0,0 +1,65 @@ +"""Tests for servers page search history payload rules and API.""" + +from __future__ import annotations + +import pytest + +from server.utils.server_search_history import ( + MAX_SERVER_SEARCH_HISTORY, + apply_search_record, + parse_search_state, +) + + +def test_apply_search_record_dedupes_and_caps(): + state = {"history": ["b", "c"], "last": "b"} + next_state = apply_search_record(state, "a") + assert next_state["last"] == "a" + assert next_state["history"] == ["a", "b", "c"] + + long_state = {"history": [f"q{i}" for i in range(MAX_SERVER_SEARCH_HISTORY)], "last": "q0"} + capped = apply_search_record(long_state, "new") + assert capped["history"][0] == "new" + assert len(capped["history"]) == MAX_SERVER_SEARCH_HISTORY + + +def test_apply_search_record_clear_last_on_empty_query(): + state = {"history": ["a"], "last": "a"} + cleared = apply_search_record(state, " ") + assert cleared["last"] is None + assert cleared["history"] == ["a"] + + +def test_parse_search_state_normalizes(): + raw = {"history": [" 1.2.3.4 ", 123, "1.2.3.4"], "last": " x "} + parsed = parse_search_state(raw) + assert parsed["history"] == ["1.2.3.4"] + assert parsed["last"] == "x" + + +@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) + assert r.status_code == 200 + assert r.json() == {"history": [], "last": None} + + r = await api_client.put( + "/api/servers/search-history", + headers=auth_headers, + json={"query": "10.0.0.1"}, + ) + assert r.status_code == 200 + body = r.json() + assert body["last"] == "10.0.0.1" + assert body["history"] == ["10.0.0.1"] + + r = await api_client.put( + "/api/servers/search-history", + headers=auth_headers, + json={"query": "web-01"}, + ) + body = r.json() + assert body["history"] == ["web-01", "10.0.0.1"] + + r = await api_client.get("/api/servers/search-history", headers=auth_headers) + assert r.json()["last"] == "web-01"