feat: 服务器搜索历史持久化到 MySQL

按管理员将 Servers 页搜索记录存入 admin_ui_preferences,换设备登录仍可恢复;首次自动迁移 localStorage。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Nexus Agent
2026-06-11 17:46:52 +08:00
parent 704764bd7e
commit 130cc840c7
10 changed files with 393 additions and 2 deletions
@@ -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
```
服务器页搜索 → 换浏览器同账号登录 → 历史与上次搜索仍在。
@@ -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<string[]>([])
const bootstrapped = ref(false)
async function load(): Promise<string> {
const res = await http.get<ServerSearchHistoryResponse>('/servers/search-history')
items.value = res.history || []
return res.last || ''
}
async function migrateLegacyIfEmpty(): Promise<boolean> {
const legacy = readLegacyLocal()
if (!legacy) return false
if (items.value.length > 0) {
clearLegacyLocal()
return false
}
await http.put<ServerSearchHistoryResponse>('/servers/search-history', {
history: legacy.history,
last: legacy.last || legacy.history[0] || '',
})
clearLegacyLocal()
return true
}
async function bootstrap(searchRef: Ref<string>) {
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<ServerSearchHistoryResponse>('/servers/search-history', { query })
items.value = res.history || []
} catch {
// non-blocking
}
}
return { items, bootstrapped, load, bootstrap, record }
}
+11 -1
View File
@@ -38,15 +38,19 @@
<v-card-title class="d-flex align-center">
服务器列表
<v-spacer />
<v-text-field
<v-combobox
v-model="search"
:items="serverSearchHistory"
prepend-inner-icon="mdi-magnify"
label="搜索名称/IP(含未设路径)"
density="compact"
hide-details
rounded
clearable
:auto-select-first="false"
style="max-width: 240px"
variant="outlined"
placeholder="输入或选择历史搜索"
@update:model-value="onSearch"
/>
<v-btn color="primary" variant="flat" class="ml-3" prepend-icon="mdi-plus" size="small" @click="openAddServer">
@@ -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<typeof setTimeout>
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),
+13
View File
@@ -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] = []
+43 -1
View File
@@ -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"),
+10
View File
@@ -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"
@@ -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
@@ -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:
+57
View File
@@ -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}
+65
View File
@@ -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"