feat: 支持服务器主机名排序

- 服务器列表默认按主机名升序并支持表头切换

- 后端按白名单排序并区分分页缓存键
This commit is contained in:
Logan.Li committed 2026-07-26 23:21:10 +08:00
1 parent 6b5fc6a5c3
commit 09e6271bb4
5 files changed
+82 -6

No files matched your search

+14 -2
View File
@@ -74,8 +74,9 @@ func (h *ServerHandler) ListServers(c *gin.Context) {
status := c.Query("status") status := c.Query("status")
regionID := c.Query("region_id") regionID := c.Query("region_id")
// 构建缓存键(按查询参数区分) // 构建缓存键(按查询参数区分) // Logan.Li
cacheKey := "servers:list:" + query.Keyword + ":" + status + ":" + regionID + cacheKey := "servers:list:" + query.Keyword + ":" + status + ":" + regionID +
":" + query.Sort + ":" + query.Order +
":" + strconv.Itoa(query.Page) + ":" + strconv.Itoa(query.PageSize) ":" + strconv.Itoa(query.Page) + ":" + strconv.Itoa(query.PageSize)
const cacheTTL = 10 * time.Second const cacheTTL = 10 * time.Second
@@ -114,7 +115,7 @@ func (h *ServerHandler) ListServers(c *gin.Context) {
var servers []model.Server var servers []model.Server
db.Preload("Region").Preload("Cloud"). db.Preload("Region").Preload("Cloud").
Offset(query.GetOffset()).Limit(query.GetLimit()). Offset(query.GetOffset()).Limit(query.GetLimit()).
Order("created_at DESC"). Order(serverOrderClause(query.Sort, query.Order)).
Find(&servers) Find(&servers)
// 填充 has_auth 字段(密码和SSH密钥在JSON中隐藏,但前端需知道是否有凭据) // Logan.Li // 填充 has_auth 字段(密码和SSH密钥在JSON中隐藏,但前端需知道是否有凭据) // Logan.Li
@@ -142,6 +143,17 @@ func (h *ServerHandler) ListServers(c *gin.Context) {
c.JSON(http.StatusOK, resp) c.JSON(http.StatusOK, resp)
} }
// serverOrderClause 返回服务器列表允许使用的排序表达式,避免直接拼接请求参数。 // Logan.Li
func serverOrderClause(sortField, order string) string {
if sortField != "hostname" {
return "created_at DESC"
}
if order == "desc" {
return "LOWER(hostname) DESC, id DESC"
}
return "LOWER(hostname) ASC, id ASC"
}
// GetServer 获取服务器详情 // GetServer 获取服务器详情
// @Summary 获取服务器详情 // @Summary 获取服务器详情
// @Description 获取指定服务器的详细信息 // @Description 获取指定服务器的详细信息
+27
View File
@@ -0,0 +1,27 @@
package controller
import "testing"
// TestServerOrderClause 验证服务器排序字段仅使用后端白名单表达式。 // Logan.Li
func TestServerOrderClause(t *testing.T) {
tests := []struct {
name string
sortField string
order string
want string
}{
{name: "默认排序", want: "created_at DESC"},
{name: "主机名升序", sortField: "hostname", order: "asc", want: "LOWER(hostname) ASC, id ASC"},
{name: "主机名降序", sortField: "hostname", order: "desc", want: "LOWER(hostname) DESC, id DESC"},
{name: "非法方向回退升序", sortField: "hostname", order: "invalid", want: "LOWER(hostname) ASC, id ASC"},
{name: "非法字段回退默认", sortField: "hostname; DROP TABLE sys_servers", order: "asc", want: "created_at DESC"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := serverOrderClause(tt.sortField, tt.order); got != tt.want {
t.Fatalf("serverOrderClause(%q, %q) = %q, want %q", tt.sortField, tt.order, got, tt.want)
}
})
}
}
@@ -42,6 +42,7 @@ export function ServerManager() {
const [selectedServers, setSelectedServers] = useState<string[]>([]); const [selectedServers, setSelectedServers] = useState<string[]>([]);
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
const [statusFilter, setStatusFilter] = useState<string>("all"); const [statusFilter, setStatusFilter] = useState<string>("all");
const [hostnameOrder, setHostnameOrder] = useState<"asc" | "desc">("asc");
const [cloudImportOpen, setCloudImportOpen] = useState(false); const [cloudImportOpen, setCloudImportOpen] = useState(false);
const [excelImportOpen, setExcelImportOpen] = useState(false); const [excelImportOpen, setExcelImportOpen] = useState(false);
@@ -79,11 +80,16 @@ export function ServerManager() {
const [pageSize, setPageSize] = useState(10); const [pageSize, setPageSize] = useState(10);
const queryParams = useMemo(() => { const queryParams = useMemo(() => {
const params: Record<string, unknown> = { page, page_size: pageSize }; const params: Record<string, unknown> = {
page,
page_size: pageSize,
sort: "hostname",
order: hostnameOrder,
};
if (searchQuery) params.keyword = searchQuery; if (searchQuery) params.keyword = searchQuery;
if (statusFilter && statusFilter !== "all") params.status = statusFilter; if (statusFilter && statusFilter !== "all") params.status = statusFilter;
return params; return params;
}, [page, pageSize, searchQuery, statusFilter]); }, [page, pageSize, searchQuery, statusFilter, hostnameOrder]);
const { const {
data, data,
@@ -130,6 +136,12 @@ export function ServerManager() {
setPage(1); setPage(1);
}; };
// 切换主机名排序后回到第一页,保持跨页顺序连续。 // Logan.Li
const handleHostnameSort = () => {
setHostnameOrder((current) => (current === "asc" ? "desc" : "asc"));
setPage(1);
};
const toggleSelectAll = () => { const toggleSelectAll = () => {
if (selectedServers.length === servers.length) { if (selectedServers.length === servers.length) {
setSelectedServers([]); setSelectedServers([]);
@@ -370,8 +382,10 @@ export function ServerManager() {
}} }}
searchQuery={searchQuery} searchQuery={searchQuery}
statusFilter={statusFilter} statusFilter={statusFilter}
hostnameOrder={hostnameOrder}
onSearchChange={handleSearch} onSearchChange={handleSearch}
onStatusFilterChange={handleStatusFilter} onStatusFilterChange={handleStatusFilter}
onHostnameSort={handleHostnameSort}
selectedServers={selectedServers} selectedServers={selectedServers}
onToggleSelectAll={toggleSelectAll} onToggleSelectAll={toggleSelectAll}
onToggleSelect={toggleSelect} onToggleSelect={toggleSelect}
@@ -24,6 +24,8 @@ import {
RefreshCw, RefreshCw,
ChevronLeft, ChevronLeft,
ChevronRight, ChevronRight,
ArrowUp,
ArrowDown,
} from "lucide-react"; } from "lucide-react";
import { ContentPanel } from "@/components/layout/ContentPanel"; import { ContentPanel } from "@/components/layout/ContentPanel";
import { ServerTableRow } from "./ServerTableRow"; import { ServerTableRow } from "./ServerTableRow";
@@ -44,8 +46,10 @@ export interface ServerTableSectionProps {
/** 搜索/筛选 */ /** 搜索/筛选 */
searchQuery: string; searchQuery: string;
statusFilter: string; statusFilter: string;
hostnameOrder: "asc" | "desc";
onSearchChange: (val: string) => void; onSearchChange: (val: string) => void;
onStatusFilterChange: (val: string) => void; onStatusFilterChange: (val: string) => void;
onHostnameSort: () => void;
/** 选择 */ /** 选择 */
selectedServers: string[]; selectedServers: string[];
onToggleSelectAll: () => void; onToggleSelectAll: () => void;
@@ -72,8 +76,10 @@ export function ServerTableSection({
onPageSizeChange, onPageSizeChange,
searchQuery, searchQuery,
statusFilter, statusFilter,
hostnameOrder,
onSearchChange, onSearchChange,
onStatusFilterChange, onStatusFilterChange,
onHostnameSort,
selectedServers, selectedServers,
onToggleSelectAll, onToggleSelectAll,
onToggleSelect, onToggleSelect,
@@ -152,8 +158,23 @@ export function ServerTableSection({
onCheckedChange={onToggleSelectAll} onCheckedChange={onToggleSelectAll}
/> />
</TableHead> </TableHead>
<TableHead className="text-[var(--dg-text-secondary)]"> <TableHead
{t("server.hostname")} className="text-[var(--dg-text-secondary)]"
aria-sort={hostnameOrder === "asc" ? "ascending" : "descending"}
>
<button
type="button"
onClick={onHostnameSort}
className="inline-flex h-8 items-center gap-1 rounded px-1 hover:bg-[var(--dg-bg-hover)] hover:text-[var(--dg-text-primary)]"
title={`${t("server.hostname")} - ${t(`common.${hostnameOrder === "asc" ? "ascending" : "descending"}`)}`}
>
{t("server.hostname")}
{hostnameOrder === "asc" ? (
<ArrowUp className="h-3.5 w-3.5 dg-text-blue" />
) : (
<ArrowDown className="h-3.5 w-3.5 dg-text-blue" />
)}
</button>
</TableHead> </TableHead>
<TableHead className="text-[var(--dg-text-secondary)]"> <TableHead className="text-[var(--dg-text-secondary)]">
{t("server.ipAddress")} {t("server.ipAddress")}
+2
View File
@@ -18,6 +18,8 @@ type ListParams = {
page_size?: number; page_size?: number;
keyword?: string; keyword?: string;
status?: string; status?: string;
sort?: 'hostname';
order?: 'asc' | 'desc';
}; };
type ServerListResult = { type ServerListResult = {