diff --git a/backend/controller/server.go b/backend/controller/server.go index a3167e0..ea30a5a 100644 --- a/backend/controller/server.go +++ b/backend/controller/server.go @@ -74,8 +74,9 @@ func (h *ServerHandler) ListServers(c *gin.Context) { status := c.Query("status") regionID := c.Query("region_id") - // 构建缓存键(按查询参数区分) + // 构建缓存键(按查询参数区分) // Logan.Li cacheKey := "servers:list:" + query.Keyword + ":" + status + ":" + regionID + + ":" + query.Sort + ":" + query.Order + ":" + strconv.Itoa(query.Page) + ":" + strconv.Itoa(query.PageSize) const cacheTTL = 10 * time.Second @@ -114,7 +115,7 @@ func (h *ServerHandler) ListServers(c *gin.Context) { var servers []model.Server db.Preload("Region").Preload("Cloud"). Offset(query.GetOffset()).Limit(query.GetLimit()). - Order("created_at DESC"). + Order(serverOrderClause(query.Sort, query.Order)). Find(&servers) // 填充 has_auth 字段(密码和SSH密钥在JSON中隐藏,但前端需知道是否有凭据) // Logan.Li @@ -142,6 +143,17 @@ func (h *ServerHandler) ListServers(c *gin.Context) { 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 获取服务器详情 // @Summary 获取服务器详情 // @Description 获取指定服务器的详细信息 diff --git a/backend/controller/server_sort_test.go b/backend/controller/server_sort_test.go new file mode 100644 index 0000000..3095bcd --- /dev/null +++ b/backend/controller/server_sort_test.go @@ -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) + } + }) + } +} diff --git a/frontend/src/components/server/ServerManager.tsx b/frontend/src/components/server/ServerManager.tsx index b3985c5..059fe17 100644 --- a/frontend/src/components/server/ServerManager.tsx +++ b/frontend/src/components/server/ServerManager.tsx @@ -42,6 +42,7 @@ export function ServerManager() { const [selectedServers, setSelectedServers] = useState([]); const [searchQuery, setSearchQuery] = useState(""); const [statusFilter, setStatusFilter] = useState("all"); + const [hostnameOrder, setHostnameOrder] = useState<"asc" | "desc">("asc"); const [cloudImportOpen, setCloudImportOpen] = useState(false); const [excelImportOpen, setExcelImportOpen] = useState(false); @@ -79,11 +80,16 @@ export function ServerManager() { const [pageSize, setPageSize] = useState(10); const queryParams = useMemo(() => { - const params: Record = { page, page_size: pageSize }; + const params: Record = { + page, + page_size: pageSize, + sort: "hostname", + order: hostnameOrder, + }; if (searchQuery) params.keyword = searchQuery; if (statusFilter && statusFilter !== "all") params.status = statusFilter; return params; - }, [page, pageSize, searchQuery, statusFilter]); + }, [page, pageSize, searchQuery, statusFilter, hostnameOrder]); const { data, @@ -130,6 +136,12 @@ export function ServerManager() { setPage(1); }; + // 切换主机名排序后回到第一页,保持跨页顺序连续。 // Logan.Li + const handleHostnameSort = () => { + setHostnameOrder((current) => (current === "asc" ? "desc" : "asc")); + setPage(1); + }; + const toggleSelectAll = () => { if (selectedServers.length === servers.length) { setSelectedServers([]); @@ -370,8 +382,10 @@ export function ServerManager() { }} searchQuery={searchQuery} statusFilter={statusFilter} + hostnameOrder={hostnameOrder} onSearchChange={handleSearch} onStatusFilterChange={handleStatusFilter} + onHostnameSort={handleHostnameSort} selectedServers={selectedServers} onToggleSelectAll={toggleSelectAll} onToggleSelect={toggleSelect} diff --git a/frontend/src/components/server/ServerTableSection.tsx b/frontend/src/components/server/ServerTableSection.tsx index 9e22e50..dcffb81 100644 --- a/frontend/src/components/server/ServerTableSection.tsx +++ b/frontend/src/components/server/ServerTableSection.tsx @@ -24,6 +24,8 @@ import { RefreshCw, ChevronLeft, ChevronRight, + ArrowUp, + ArrowDown, } from "lucide-react"; import { ContentPanel } from "@/components/layout/ContentPanel"; import { ServerTableRow } from "./ServerTableRow"; @@ -44,8 +46,10 @@ export interface ServerTableSectionProps { /** 搜索/筛选 */ searchQuery: string; statusFilter: string; + hostnameOrder: "asc" | "desc"; onSearchChange: (val: string) => void; onStatusFilterChange: (val: string) => void; + onHostnameSort: () => void; /** 选择 */ selectedServers: string[]; onToggleSelectAll: () => void; @@ -72,8 +76,10 @@ export function ServerTableSection({ onPageSizeChange, searchQuery, statusFilter, + hostnameOrder, onSearchChange, onStatusFilterChange, + onHostnameSort, selectedServers, onToggleSelectAll, onToggleSelect, @@ -152,8 +158,23 @@ export function ServerTableSection({ onCheckedChange={onToggleSelectAll} /> - - {t("server.hostname")} + + {t("server.ipAddress")} diff --git a/frontend/src/hooks/api/useServers.ts b/frontend/src/hooks/api/useServers.ts index 8843b25..16c4ef3 100644 --- a/frontend/src/hooks/api/useServers.ts +++ b/frontend/src/hooks/api/useServers.ts @@ -18,6 +18,8 @@ type ListParams = { page_size?: number; keyword?: string; status?: string; + sort?: 'hostname'; + order?: 'asc' | 'desc'; }; type ServerListResult = { @@ -65,4 +67,4 @@ export function useServerMutations() { }); return { createMutation, updateMutation, deleteMutation, batchDeleteMutation, invalidate }; -} \ No newline at end of file +}