fix(panel): 服务器列表每页 20 台,可按名称、状态或在线人数排序,轮询时行序不再跳动

This commit is contained in:
Lemon-miaow committed 2026-09-27 15:19:45 +08:00
1 parent 9910c78709
commit f6d586ed0c
4 files changed
+169 -6

No files matched your search

+4
View File
@@ -13,6 +13,10 @@
"fleet_search_placeholder": "Search name, subdomain or owner…", "fleet_search_placeholder": "Search name, subdomain or owner…",
"fleet_filter_all": "All phases", "fleet_filter_all": "All phases",
"fleet_filter_phase": "Filter by phase", "fleet_filter_phase": "Filter by phase",
"fleet_sort": "Sort order",
"fleet_sort_name": "Sort by name",
"fleet_sort_status": "Sort by status",
"fleet_sort_players": "Most players first",
"fleet_count": "{{count}} servers", "fleet_count": "{{count}} servers",
"fleet_count_filtered": "{{shown}} / {{total}} servers", "fleet_count_filtered": "{{shown}} / {{total}} servers",
"fleet_col_status": "Status", "fleet_col_status": "Status",
+4
View File
@@ -13,6 +13,10 @@
"fleet_search_placeholder": "搜索名称、域名或所有者…", "fleet_search_placeholder": "搜索名称、域名或所有者…",
"fleet_filter_all": "全部状态", "fleet_filter_all": "全部状态",
"fleet_filter_phase": "按状态筛选", "fleet_filter_phase": "按状态筛选",
"fleet_sort": "排序方式",
"fleet_sort_name": "按名称排序",
"fleet_sort_status": "按状态排序",
"fleet_sort_players": "在线人数多的在前",
"fleet_count": "共 {{count}} 台", "fleet_count": "共 {{count}} 台",
"fleet_count_filtered": "{{shown}} / {{total}} 台", "fleet_count_filtered": "{{shown}} / {{total}} 台",
"fleet_col_status": "状态", "fleet_col_status": "状态",
+99 -1
View File
@@ -1,6 +1,7 @@
// @vitest-environment jsdom // @vitest-environment jsdom
import { describe, it, expect, vi, beforeEach } from "vitest"; import { describe, it, expect, vi, beforeEach, beforeAll } from "vitest";
import { render, screen, within } from "@testing-library/react"; import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter } from "react-router-dom"; import { MemoryRouter } from "react-router-dom";
import type { FleetServer, MyServerView } from "@/lib/types"; import type { FleetServer, MyServerView } from "@/lib/types";
import { ServersPage } from "./ServersPage"; import { ServersPage } from "./ServersPage";
@@ -44,6 +45,37 @@ async function tableRow(name: string) {
return within(tr); return within(tr);
} }
// Radix Select opens with pointer capture and scrolls the picked item into view,
// neither of which jsdom implements.
beforeAll(() => {
Element.prototype.hasPointerCapture ??= () => false;
Element.prototype.releasePointerCapture ??= () => {};
Element.prototype.scrollIntoView ??= () => {};
});
// The labels of the desktop table's rows, top to bottom.
async function tableOrder() {
const table = await screen.findByRole("table");
return within(table)
.getAllByRole("row")
.slice(1)
.map((tr) => tr.querySelector("td span.font-medium")?.textContent);
}
function renderPage() {
render(
<MemoryRouter>
<ServersPage />
</MemoryRouter>,
);
return userEvent.setup();
}
async function sortBy(user: ReturnType<typeof userEvent.setup>, option: string) {
await user.click(screen.getByRole("combobox", { name: "Sort order" }));
await user.click(await screen.findByRole("option", { name: option }));
}
beforeEach(() => { beforeEach(() => {
tier.isAdmin = true; tier.isAdmin = true;
calls.fleet.mockReset(); calls.fleet.mockReset();
@@ -243,3 +275,69 @@ describe("ServersPage retiring servers", () => {
expect(survival.queryByRole("button", { name: /Wake/ })).toBeNull(); expect(survival.queryByRole("button", { name: /Wake/ })).toBeNull();
}); });
}); });
describe("ServersPage order and paging", () => {
it("lists by name, and on request by status or by players online", async () => {
calls.fleet.mockResolvedValue([
row("survival", { phase: "Running", ready: true, playersOnline: 3 }),
row("creative", { phase: "Stopped" }),
row("skyblock", { phase: "Running", ready: true, playersOnline: 7 }),
// Ordered by the label it shows, and counted as starting once asked to wake.
row("lobby", { displayName: "Arcade", phase: "Stopped", desiredState: "Running" }),
row("node-10", { phase: "Failed" }),
row("node-2", { phase: "Failed" }),
]);
const user = renderPage();
expect(await tableOrder()).toEqual(["Arcade", "creative", "node-2", "node-10", "skyblock", "survival"]);
await sortBy(user, "Sort by status");
expect(await tableOrder()).toEqual(["skyblock", "survival", "Arcade", "node-2", "node-10", "creative"]);
await sortBy(user, "Most players first");
expect(await tableOrder()).toEqual(["skyblock", "survival", "Arcade", "creative", "node-2", "node-10"]);
});
it("leads a search with the best match and ranks equal matches by the picked order", async () => {
calls.fleet.mockResolvedValue([
row("my-surv", { phase: "Running", ready: true, playersOnline: 9 }),
row("survival-b", { phase: "Running", ready: true, playersOnline: 5 }),
row("survival-a", { phase: "Running", ready: true, playersOnline: 2 }),
]);
const user = renderPage();
await tableOrder();
await user.type(screen.getByPlaceholderText(/Search/), "surv");
// "surv" opens both survival names and sits inside my-surv, which ranks it below.
expect(await tableOrder()).toEqual(["survival-a", "survival-b", "my-surv"]);
await sortBy(user, "Most players first");
expect(await tableOrder()).toEqual(["survival-b", "survival-a", "my-surv"]);
await user.clear(screen.getByPlaceholderText(/Search/));
expect(await tableOrder()).toEqual(["my-surv", "survival-b", "survival-a"]);
});
it("keeps servers that show the same label in name order", async () => {
// The fleet comes off an informer cache, which lists in no fixed order.
calls.fleet.mockResolvedValue([
row("zeta", { displayName: "Survival" }),
row("alpha", { displayName: "Survival" }),
]);
renderPage();
const table = await screen.findByRole("table");
expect(within(table).getAllByText(/^(alpha|zeta)$/).map((e) => e.textContent)).toEqual(["alpha", "zeta"]);
});
it("shows twenty servers to a page", async () => {
const names = Array.from({ length: 21 }, (_, i) => `srv-${String(i + 1).padStart(2, "0")}`);
calls.fleet.mockResolvedValue(names.map((n) => row(n, {})));
const user = renderPage();
expect(await tableOrder()).toEqual(names.slice(0, 20));
expect(screen.getByText("Page 1 of 2")).toBeTruthy();
await user.click(screen.getByRole("button", { name: /Next/ }));
expect(await tableOrder()).toEqual(["srv-21"]);
// A new order starts again from its first page.
await sortBy(user, "Sort by status");
expect(await tableOrder()).toEqual(names.slice(0, 20));
});
});
+62 -5
View File
@@ -67,7 +67,43 @@ function isLive(phase: Phase): boolean {
return phase === "Running" || phase === "Starting" || phase === "Stopping"; return phase === "Running" || phase === "Starting" || phase === "Stopping";
} }
const PAGE_SIZE = 6; // Twenty to a page: an even count fills the two-column cards, and a fleet of a
// dozen or so needs no paging at all.
const PAGE_SIZE = 20;
type SortKey = "name" | "status" | "players";
const SORT_KEYS: SortKey[] = ["name", "status", "players"];
const SORT_LABEL: Record<SortKey, string> = {
name: "fleet_sort_name",
status: "fleet_sort_status",
players: "fleet_sort_players",
};
const labelOrder = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" });
function byName(a: UnifiedServer, b: UnifiedServer): number {
return (
labelOrder.compare(a.displayName || a.name, b.displayName || b.name) ||
(a.name < b.name ? -1 : a.name > b.name ? 1 : 0)
);
}
/** compareBy orders servers by the picked key, then by name. The name settles
* every tie, so a list that rereads every few seconds keeps each row in place
* until the key itself changes. Status runs live to idle, the order of the
* phase filter. */
function compareBy(key: SortKey): (a: UnifiedServer, b: UnifiedServer) => number {
switch (key) {
case "status":
return (a, b) => PHASES.indexOf(shownPhase(a)) - PHASES.indexOf(shownPhase(b)) || byName(a, b);
case "players":
return (a, b) => b.playersOnline - a.playersOnline || byName(a, b);
default:
return byName;
}
}
interface UnifiedServer { interface UnifiedServer {
name: string; name: string;
@@ -107,6 +143,7 @@ export function ServersPage() {
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [phaseFilter, setPhaseFilter] = useState<Phase | "all">("all"); const [phaseFilter, setPhaseFilter] = useState<Phase | "all">("all");
const [sort, setSort] = useState<SortKey>("name");
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
usePolling(reload, REFRESH_MS); usePolling(reload, REFRESH_MS);
@@ -181,16 +218,18 @@ export function ServersPage() {
const visible = useMemo(() => { const visible = useMemo(() => {
const terms = query.trim().toLowerCase().split(/\s+/).filter(Boolean); const terms = query.trim().toLowerCase().split(/\s+/).filter(Boolean);
const phaseOk = (s: UnifiedServer) => phaseFilter === "all" || shownPhase(s) === phaseFilter; const phaseOk = (s: UnifiedServer) => phaseFilter === "all" || shownPhase(s) === phaseFilter;
if (terms.length === 0) return servers.filter(phaseOk); const order = compareBy(sort);
if (terms.length === 0) return servers.filter(phaseOk).sort(order);
const scored: { s: UnifiedServer; score: number }[] = []; const scored: { s: UnifiedServer; score: number }[] = [];
for (const s of servers) { for (const s of servers) {
if (!phaseOk(s)) continue; if (!phaseOk(s)) continue;
const score = matchScore([s.name, s.displayName ?? "", s.subdomain ?? "", s.owner ?? ""], terms); const score = matchScore([s.name, s.displayName ?? "", s.subdomain ?? "", s.owner ?? ""], terms);
if (score >= 0) scored.push({ s, score }); if (score >= 0) scored.push({ s, score });
} }
scored.sort((a, b) => b.score - a.score); // A search leads with the best match; the picked order ranks equal matches.
scored.sort((a, b) => b.score - a.score || order(a.s, b.s));
return scored.map((x) => x.s); return scored.map((x) => x.s);
}, [servers, query, phaseFilter]); }, [servers, query, phaseFilter, sort]);
const totalPages = Math.max(1, Math.ceil(visible.length / PAGE_SIZE)); const totalPages = Math.max(1, Math.ceil(visible.length / PAGE_SIZE));
const safePage = Math.min(page, totalPages); const safePage = Math.min(page, totalPages);
@@ -315,7 +354,7 @@ export function ServersPage() {
setPage(1); setPage(1);
}} }}
> >
<SelectTrigger className="w-44" aria-label={t("fleet_filter_phase")}> <SelectTrigger className="w-[calc(50%-0.375rem)] sm:w-44" aria-label={t("fleet_filter_phase")}>
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@@ -327,6 +366,24 @@ export function ServersPage() {
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
<Select
value={sort}
onValueChange={(v) => {
setSort(v as SortKey);
setPage(1);
}}
>
<SelectTrigger className="w-[calc(50%-0.375rem)] sm:w-44" aria-label={t("fleet_sort")}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{SORT_KEYS.map((k) => (
<SelectItem key={k} value={k}>
{t(SORT_LABEL[k])}
</SelectItem>
))}
</SelectContent>
</Select>
<span className="shrink-0 text-xs text-muted-foreground"> <span className="shrink-0 text-xs text-muted-foreground">
{visible.length === servers.length {visible.length === servers.length
? t("fleet_count", { count: servers.length }) ? t("fleet_count", { count: servers.length })