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_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",
|
||||||
|
|||||||
@@ -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": "状态",
|
||||||
|
|||||||
@@ -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));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 })
|
||||||
|
|||||||
Reference in new issue
Block a user