feat: 服务器搜索历史持久化到 MySQL
按管理员将 Servers 页搜索记录存入 admin_ui_preferences,换设备登录仍可恢复;首次自动迁移 localStorage。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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 }
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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
@@ -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"),
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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}
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user