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")
|
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 获取指定服务器的详细信息
|
||||||
|
|||||||
@@ -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")}
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
Reference in new issue
Block a user