feat(panel): add search and status filtering to my servers page

This commit is contained in:
Lemon-miaow committed 2026-07-02 03:30:50 +08:00
1 parent 4ecaf3c6cb
commit a9dbc8bc61
3 files changed
+104 -5

No files matched your search

@@ -1,6 +1,10 @@
{ {
"my_servers_title": "My servers", "my_servers_title": "My servers",
"my_servers_subtitle": "Wake, stop, or claim the servers you can manage.", "my_servers_subtitle": "Wake, stop, or claim the servers you can manage.",
"search_placeholder": "Search server name or address…",
"filter_status_all": "All statuses",
"search_no_match": "No matching servers found.",
"search_clear_btn": "Clear filters",
"no_servers_linked": "No servers linked to you", "no_servers_linked": "No servers linked to you",
"no_servers_hint": "Claim an unowned server you have access to, or ask an admin to provision one.", "no_servers_hint": "Claim an unowned server you have access to, or ask an admin to provision one.",
"my_servers_footer": "Servers and game types are provisioned and managed by the platform. You claim a node to operate it; raw cluster config is never exposed here.", "my_servers_footer": "Servers and game types are provisioned and managed by the platform. You claim a node to operate it; raw cluster config is never exposed here.",
@@ -1,6 +1,10 @@
{ {
"my_servers_title": "我的服务器", "my_servers_title": "我的服务器",
"my_servers_subtitle": "管理你拥有或可用的服务器。", "my_servers_subtitle": "管理你拥有或可用的服务器。",
"search_placeholder": "搜索服务器名称或地址…",
"filter_status_all": "全部状态",
"search_no_match": "没有匹配的服务器。",
"search_clear_btn": "清除筛选",
"no_servers_linked": "暂无关联的服务器", "no_servers_linked": "暂无关联的服务器",
"no_servers_hint": "认领一台你拥有访问权限的服务器,或联系管理员为你分配。", "no_servers_hint": "认领一台你拥有访问权限的服务器,或联系管理员为你分配。",
"my_servers_footer": "服务器及游戏类型由平台统一管理。认领后即可操作节点,原始集群配置不会暴露在此。", "my_servers_footer": "服务器及游戏类型由平台统一管理。认领后即可操作节点,原始集群配置不会暴露在此。",
+96 -5
View File
@@ -1,12 +1,16 @@
import { useState } from "react"; import { useState } from "react";
import { Server } from "lucide-react"; import { Server, Search, X } from "lucide-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import { ServerCard } from "@/components/ServerCard"; import { ServerCard } from "@/components/ServerCard";
import { Pagination } from "@/components/Pagination"; import { Pagination } from "@/components/Pagination";
import { Loading, ErrorState, EmptyState } from "@/components/States"; import { Loading, ErrorState, EmptyState } from "@/components/States";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Button } from "@/components/ui/button";
import { api } from "@/lib/api"; import { api } from "@/lib/api";
import { useAsync, useConfig } from "@/lib/hooks"; import { useAsync, useConfig } from "@/lib/hooks";
import { hostFor } from "@/lib/config";
const PAGE_SIZE = 12; const PAGE_SIZE = 12;
@@ -16,8 +20,29 @@ export function MyServers() {
const { t } = useTranslation("servers"); const { t } = useTranslation("servers");
const servers = data ?? []; const servers = data ?? [];
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const totalPages = Math.max(1, Math.ceil(servers.length / PAGE_SIZE)); const [searchQuery, setSearchQuery] = useState("");
const paged = servers.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE); const [statusFilter, setStatusFilter] = useState("all");
const filtered = servers.filter((s) => {
if (searchQuery.trim()) {
const q = searchQuery.toLowerCase().trim();
const host = cfg ? hostFor(s.subdomain, cfg).toLowerCase() : "";
const name = s.name.toLowerCase();
const displayName = (s.displayName || "").toLowerCase();
if (!name.includes(q) && !displayName.includes(q) && !host.includes(q)) {
return false;
}
}
if (statusFilter !== "all") {
if (s.phase !== statusFilter) {
return false;
}
}
return true;
});
const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE));
const paged = filtered.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE);
return ( return (
<> <>
@@ -31,6 +56,56 @@ export function MyServers() {
</div> </div>
</div> </div>
{servers.length > 0 && cfg && (
<div className="flex flex-col gap-3 sm:flex-row sm:items-center my-4">
<div className="relative flex-1 max-w-md">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder={t("search_placeholder")}
className="pl-9 pr-8"
value={searchQuery}
onChange={(e) => {
setSearchQuery(e.target.value);
setPage(1);
}}
/>
{searchQuery && (
<button
type="button"
onClick={() => {
setSearchQuery("");
setPage(1);
}}
className="absolute right-2.5 top-2.5 text-muted-foreground hover:text-foreground"
>
<X className="h-4 w-4" />
</button>
)}
</div>
<div className="w-[180px]">
<Select
value={statusFilter}
onValueChange={(val) => {
setStatusFilter(val);
setPage(1);
}}
>
<SelectTrigger className="w-full">
<SelectValue placeholder={t("filter_status_all")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t("filter_status_all")}</SelectItem>
<SelectItem value="Running">{t("phase_running")}</SelectItem>
<SelectItem value="Starting">{t("phase_starting")}</SelectItem>
<SelectItem value="Stopping">{t("phase_stopping")}</SelectItem>
<SelectItem value="Stopped">{t("phase_stopped")}</SelectItem>
<SelectItem value="Failed">{t("phase_failed")}</SelectItem>
</SelectContent>
</Select>
</div>
</div>
)}
{loading && !data ? ( {loading && !data ? (
<Loading /> <Loading />
) : error ? ( ) : error ? (
@@ -42,6 +117,22 @@ export function MyServers() {
title={t("no_servers_linked")} title={t("no_servers_linked")}
hint={t("no_servers_hint")} hint={t("no_servers_hint")}
/> />
) : filtered.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-center border border-dashed rounded-lg border-border bg-card/50">
<p className="text-sm font-medium text-foreground">{t("search_no_match")}</p>
<Button
variant="outline"
size="sm"
className="mt-4"
onClick={() => {
setSearchQuery("");
setStatusFilter("all");
setPage(1);
}}
>
{t("search_clear_btn")}
</Button>
</div>
) : ( ) : (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3"> <div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
{paged.map((s) => ( {paged.map((s) => (
@@ -56,11 +147,11 @@ export function MyServers() {
</CardContent> </CardContent>
</Card> </Card>
{servers.length > 0 && ( {filtered.length > 0 && (
<Pagination <Pagination
page={page} page={page}
pageSize={PAGE_SIZE} pageSize={PAGE_SIZE}
total={servers.length} total={filtered.length}
onChange={(p) => { setPage(p); if (p > totalPages) setPage(totalPages); }} onChange={(p) => { setPage(p); if (p > totalPages) setPage(totalPages); }}
/> />
)} )}