fix(panel): 服务器列表每页 20 台,可按名称、状态或在线人数排序,轮询时行序不再跳动
This commit is contained in:
4 files changed
+169
-6
No files matched your search
@@ -13,6 +13,10 @@
|
||||
"fleet_search_placeholder": "Search name, subdomain or owner…",
|
||||
"fleet_filter_all": "All phases",
|
||||
"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_filtered": "{{shown}} / {{total}} servers",
|
||||
"fleet_col_status": "Status",
|
||||
|
||||
@@ -13,6 +13,10 @@
|
||||
"fleet_search_placeholder": "搜索名称、域名或所有者…",
|
||||
"fleet_filter_all": "全部状态",
|
||||
"fleet_filter_phase": "按状态筛选",
|
||||
"fleet_sort": "排序方式",
|
||||
"fleet_sort_name": "按名称排序",
|
||||
"fleet_sort_status": "按状态排序",
|
||||
"fleet_sort_players": "在线人数多的在前",
|
||||
"fleet_count": "共 {{count}} 台",
|
||||
"fleet_count_filtered": "{{shown}} / {{total}} 台",
|
||||
"fleet_col_status": "状态",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// @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 userEvent from "@testing-library/user-event";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import type { FleetServer, MyServerView } from "@/lib/types";
|
||||
import { ServersPage } from "./ServersPage";
|
||||
@@ -44,6 +45,37 @@ async function tableRow(name: string) {
|
||||
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(() => {
|
||||
tier.isAdmin = true;
|
||||
calls.fleet.mockReset();
|
||||
@@ -243,3 +275,69 @@ describe("ServersPage retiring servers", () => {
|
||||
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));
|
||||
});
|
||||
});
|
||||
@@ -67,7 +67,43 @@ function isLive(phase: Phase): boolean {
|
||||
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 {
|
||||
name: string;
|
||||
@@ -107,6 +143,7 @@ export function ServersPage() {
|
||||
|
||||
const [query, setQuery] = useState("");
|
||||
const [phaseFilter, setPhaseFilter] = useState<Phase | "all">("all");
|
||||
const [sort, setSort] = useState<SortKey>("name");
|
||||
const [page, setPage] = useState(1);
|
||||
|
||||
usePolling(reload, REFRESH_MS);
|
||||
@@ -181,16 +218,18 @@ export function ServersPage() {
|
||||
const visible = useMemo(() => {
|
||||
const terms = query.trim().toLowerCase().split(/\s+/).filter(Boolean);
|
||||
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 }[] = [];
|
||||
for (const s of servers) {
|
||||
if (!phaseOk(s)) continue;
|
||||
const score = matchScore([s.name, s.displayName ?? "", s.subdomain ?? "", s.owner ?? ""], terms);
|
||||
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);
|
||||
}, [servers, query, phaseFilter]);
|
||||
}, [servers, query, phaseFilter, sort]);
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(visible.length / PAGE_SIZE));
|
||||
const safePage = Math.min(page, totalPages);
|
||||
@@ -315,7 +354,7 @@ export function ServersPage() {
|
||||
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 />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -327,6 +366,24 @@ export function ServersPage() {
|
||||
))}
|
||||
</SelectContent>
|
||||
</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">
|
||||
{visible.length === servers.length
|
||||
? t("fleet_count", { count: servers.length })
|
||||
|
||||
Reference in new issue
Block a user