feat: 支持服务器主机名排序
- 服务器列表默认按主机名升序并支持表头切换 - 后端按白名单排序并区分分页缓存键
This commit is contained in:
1 parent
6b5fc6a5c3
commit
09e6271bb4
5 files changed
+82
-6
No files matched your search
@@ -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 获取指定服务器的详细信息
|
||||
|
||||
@@ -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 [searchQuery, setSearchQuery] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState<string>("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<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 (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}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-secondary)]">
|
||||
{t("server.hostname")}
|
||||
<TableHead
|
||||
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 className="text-[var(--dg-text-secondary)]">
|
||||
{t("server.ipAddress")}
|
||||
|
||||
@@ -18,6 +18,8 @@ type ListParams = {
|
||||
page_size?: number;
|
||||
keyword?: string;
|
||||
status?: string;
|
||||
sort?: 'hostname';
|
||||
order?: 'asc' | 'desc';
|
||||
};
|
||||
|
||||
type ServerListResult = {
|
||||
|
||||
Reference in new issue
Block a user