支持 agent 拖动排序
This commit is contained in:
1 parent
df5685535e
commit
a4ec00b47e
12 files changed
+346
-59
No files matched your search
@@ -6,3 +6,4 @@
|
|||||||
- 需要 Tailwind 布局或视觉样式时,在 Ant Design 组件外层包裹原生 `div`、`span` 等元素,并将 Tailwind `className` 放在这些原生元素上。
|
- 需要 Tailwind 布局或视觉样式时,在 Ant Design 组件外层包裹原生 `div`、`span` 等元素,并将 Tailwind `className` 放在这些原生元素上。
|
||||||
- 需要调整 Ant Design 组件本身时,优先使用组件自身的 props、`style`、`styles`、`classNames`、设计 Token,或 Ant Design 的 `Flex`、`Space`、`Row`、`Col` 等布局组件。
|
- 需要调整 Ant Design 组件本身时,优先使用组件自身的 props、`style`、`styles`、`classNames`、设计 Token,或 Ant Design 的 `Flex`、`Space`、`Row`、`Col` 等布局组件。
|
||||||
- 如果一个样式同时涉及 Ant Design 组件外部布局和组件内部外观,将两者分开:外部布局由包裹元素承担,组件内部外观由 Ant Design API 承担。
|
- 如果一个样式同时涉及 Ant Design 组件外部布局和组件内部外观,将两者分开:外部布局由包裹元素承担,组件内部外观由 Ant Design API 承担。
|
||||||
|
- 使用 Ant Design 组件时,必须参考 https://ant.design/llms.txt 中的最新文档,不要使用已废弃的 API 或过时写法。
|
||||||
@@ -204,6 +204,7 @@ func setupApi(app *orz.App, components *AppComponents) error {
|
|||||||
adminApi.GET("/agents", components.AgentHandler.Paging)
|
adminApi.GET("/agents", components.AgentHandler.Paging)
|
||||||
adminApi.GET("/agents/statistics", components.AgentHandler.GetStatistics)
|
adminApi.GET("/agents/statistics", components.AgentHandler.GetStatistics)
|
||||||
adminApi.GET("/agents/tags", components.AgentHandler.GetTags)
|
adminApi.GET("/agents/tags", components.AgentHandler.GetTags)
|
||||||
|
adminApi.PUT("/agents/order", components.AgentHandler.UpdateOrder)
|
||||||
adminApi.GET("/agents/:id", components.AgentHandler.GetForAdmin)
|
adminApi.GET("/agents/:id", components.AgentHandler.GetForAdmin)
|
||||||
adminApi.GET("/agents/:id/metrics/latest", components.AgentHandler.GetAdminLatestMetrics)
|
adminApi.GET("/agents/:id/metrics/latest", components.AgentHandler.GetAdminLatestMetrics)
|
||||||
adminApi.PUT("/agents/:id", components.AgentHandler.UpdateInfo)
|
adminApi.PUT("/agents/:id", components.AgentHandler.UpdateInfo)
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"cmp"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -11,21 +13,20 @@ import (
|
|||||||
"github.com/labstack/echo/v5"
|
"github.com/labstack/echo/v5"
|
||||||
"github.com/pika-monitor/pika/internal/models"
|
"github.com/pika-monitor/pika/internal/models"
|
||||||
"github.com/pika-monitor/pika/internal/protocol"
|
"github.com/pika-monitor/pika/internal/protocol"
|
||||||
|
"github.com/pika-monitor/pika/internal/service"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
|
|
||||||
func SortAgents(agents []models.Agent) {
|
func SortAgents(agents []models.Agent) {
|
||||||
// 复用排序逻辑
|
|
||||||
slices.SortFunc(agents, func(a, b models.Agent) int {
|
slices.SortFunc(agents, func(a, b models.Agent) int {
|
||||||
// 先按照状态排序
|
// 拖动排序生成的权重优先,保证管理员设置的全局顺序不会被在线状态打乱。
|
||||||
if a.Status != b.Status {
|
|
||||||
return b.Status - a.Status
|
|
||||||
}
|
|
||||||
// 再按权重排序(数字越大越靠前)
|
|
||||||
if a.Weight != b.Weight {
|
if a.Weight != b.Weight {
|
||||||
return b.Weight - a.Weight
|
return cmp.Compare(b.Weight, a.Weight)
|
||||||
|
}
|
||||||
|
// 兼容尚未生成唯一权重的历史数据。
|
||||||
|
if a.Status != b.Status {
|
||||||
|
return cmp.Compare(b.Status, a.Status)
|
||||||
}
|
}
|
||||||
// 权重相同时按名称排序
|
|
||||||
return strings.Compare(a.Name, b.Name)
|
return strings.Compare(a.Name, b.Name)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -144,7 +145,7 @@ func (h *AgentHandler) ListAuditResults(c *echo.Context) error {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateInfo 更新探针信息(名称、标签、到期时间、可见性、权重、备注)
|
// UpdateInfo 更新探针信息(名称、标签、到期时间、可见性、备注)
|
||||||
func (h *AgentHandler) UpdateInfo(c *echo.Context) error {
|
func (h *AgentHandler) UpdateInfo(c *echo.Context) error {
|
||||||
agentID := c.Param("id")
|
agentID := c.Param("id")
|
||||||
|
|
||||||
@@ -153,7 +154,6 @@ func (h *AgentHandler) UpdateInfo(c *echo.Context) error {
|
|||||||
Tags []string `json:"tags"`
|
Tags []string `json:"tags"`
|
||||||
ExpireTime int64 `json:"expireTime"`
|
ExpireTime int64 `json:"expireTime"`
|
||||||
Visibility string `json:"visibility"`
|
Visibility string `json:"visibility"`
|
||||||
Weight int `json:"weight"`
|
|
||||||
Remark string `json:"remark"`
|
Remark string `json:"remark"`
|
||||||
}
|
}
|
||||||
if err := c.Bind(&req); err != nil {
|
if err := c.Bind(&req); err != nil {
|
||||||
@@ -170,7 +170,6 @@ func (h *AgentHandler) UpdateInfo(c *echo.Context) error {
|
|||||||
agent.Tags = req.Tags
|
agent.Tags = req.Tags
|
||||||
agent.ExpireTime = req.ExpireTime
|
agent.ExpireTime = req.ExpireTime
|
||||||
agent.Visibility = req.Visibility
|
agent.Visibility = req.Visibility
|
||||||
agent.Weight = req.Weight
|
|
||||||
agent.Remark = req.Remark
|
agent.Remark = req.Remark
|
||||||
agent.UpdatedAt = time.Now().UnixMilli()
|
agent.UpdatedAt = time.Now().UnixMilli()
|
||||||
|
|
||||||
@@ -181,6 +180,27 @@ func (h *AgentHandler) UpdateInfo(c *echo.Context) error {
|
|||||||
return orz.Ok(c, orz.Map{})
|
return orz.Ok(c, orz.Map{})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UpdateOrder 更新探针的全局展示顺序。
|
||||||
|
func (h *AgentHandler) UpdateOrder(c *echo.Context) error {
|
||||||
|
var req struct {
|
||||||
|
AgentIDs []string `json:"agentIds"`
|
||||||
|
}
|
||||||
|
if err := c.Bind(&req); err != nil {
|
||||||
|
return orz.NewError(400, "请求参数错误")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.agentService.UpdateAgentOrder(c.Request().Context(), req.AgentIDs); err != nil {
|
||||||
|
if errors.Is(err, service.ErrInvalidAgentOrder) {
|
||||||
|
return orz.NewError(400, err.Error())
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return orz.Ok(c, orz.Map{
|
||||||
|
"message": "探针排序更新成功",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// GetStatistics 获取探针统计数据
|
// GetStatistics 获取探针统计数据
|
||||||
func (h *AgentHandler) GetStatistics(c *echo.Context) error {
|
func (h *AgentHandler) GetStatistics(c *echo.Context) error {
|
||||||
ctx := c.Request().Context()
|
ctx := c.Request().Context()
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ type Agent struct {
|
|||||||
ExpireTime int64 `json:"expireTime"` // 到期时间(时间戳毫秒)
|
ExpireTime int64 `json:"expireTime"` // 到期时间(时间戳毫秒)
|
||||||
Status int `json:"status"` // 状态: 0-离线, 1-在线
|
Status int `json:"status"` // 状态: 0-离线, 1-在线
|
||||||
Visibility string `gorm:"default:public" json:"visibility"` // 可见性: public-匿名可见, private-登录可见
|
Visibility string `gorm:"default:public" json:"visibility"` // 可见性: public-匿名可见, private-登录可见
|
||||||
Weight int `gorm:"default:0;index" json:"weight"` // 权重排序(数字越大越靠前)
|
Weight int `gorm:"default:0;index" json:"weight"` // 展示顺序(由拖动排序维护,数字越大越靠前)
|
||||||
Remark string `json:"remark"` // 备注信息
|
Remark string `json:"remark"` // 备注信息
|
||||||
LastSeenAt int64 `gorm:"index" json:"lastSeenAt"` // 最后上线时间(时间戳毫秒)
|
LastSeenAt int64 `gorm:"index" json:"lastSeenAt"` // 最后上线时间(时间戳毫秒)
|
||||||
CreatedAt int64 `json:"createdAt"` // 创建时间(时间戳毫秒)
|
CreatedAt int64 `json:"createdAt"` // 创建时间(时间戳毫秒)
|
||||||
|
|||||||
@@ -29,6 +29,9 @@ type AgentService struct {
|
|||||||
geoipService *GeoIPService
|
geoipService *GeoIPService
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ErrInvalidAgentOrder 表示提交的排序列表无法完整、唯一地对应当前探针列表。
|
||||||
|
var ErrInvalidAgentOrder = errors.New("无效的探针排序")
|
||||||
|
|
||||||
func NewAgentService(logger *zap.Logger, db *gorm.DB, apiKeyService *ApiKeyService, metricService *MetricService, geoipService *GeoIPService) *AgentService {
|
func NewAgentService(logger *zap.Logger, db *gorm.DB, apiKeyService *ApiKeyService, metricService *MetricService, geoipService *GeoIPService) *AgentService {
|
||||||
return &AgentService{
|
return &AgentService{
|
||||||
logger: logger,
|
logger: logger,
|
||||||
@@ -162,6 +165,51 @@ func (s *AgentService) ListAgents(ctx context.Context) ([]models.Agent, error) {
|
|||||||
return s.AgentRepo.FindAll(ctx)
|
return s.AgentRepo.FindAll(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UpdateAgentOrder 按从前到后的 ID 顺序重新分配探针权重。
|
||||||
|
// 请求必须包含当前全部探针,避免筛选状态下排序时意外覆盖未展示探针的相对顺序。
|
||||||
|
func (s *AgentService) UpdateAgentOrder(ctx context.Context, agentIDs []string) error {
|
||||||
|
if len(agentIDs) == 0 {
|
||||||
|
return fmt.Errorf("%w: 探针ID列表不能为空", ErrInvalidAgentOrder)
|
||||||
|
}
|
||||||
|
|
||||||
|
agents, err := s.AgentRepo.FindAll(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(agentIDs) != len(agents) {
|
||||||
|
return fmt.Errorf("%w: 排序列表必须包含全部探针", ErrInvalidAgentOrder)
|
||||||
|
}
|
||||||
|
|
||||||
|
existingIDs := make(map[string]struct{}, len(agents))
|
||||||
|
for _, agent := range agents {
|
||||||
|
existingIDs[agent.ID] = struct{}{}
|
||||||
|
}
|
||||||
|
|
||||||
|
seenIDs := make(map[string]struct{}, len(agentIDs))
|
||||||
|
for _, agentID := range agentIDs {
|
||||||
|
if _, exists := existingIDs[agentID]; !exists {
|
||||||
|
return fmt.Errorf("%w: 探针 %q 不存在", ErrInvalidAgentOrder, agentID)
|
||||||
|
}
|
||||||
|
if _, duplicated := seenIDs[agentID]; duplicated {
|
||||||
|
return fmt.Errorf("%w: 探针 %q 重复", ErrInvalidAgentOrder, agentID)
|
||||||
|
}
|
||||||
|
seenIDs[agentID] = struct{}{}
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now().UnixMilli()
|
||||||
|
return s.Transaction(ctx, func(ctx context.Context) error {
|
||||||
|
for index, agentID := range agentIDs {
|
||||||
|
if err := s.AgentRepo.UpdateColumnsById(ctx, agentID, map[string]interface{}{
|
||||||
|
"weight": len(agentIDs) - index,
|
||||||
|
"updated_at": now,
|
||||||
|
}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// ListOnlineAgents 列出所有在线探针
|
// ListOnlineAgents 列出所有在线探针
|
||||||
func (s *AgentService) ListOnlineAgents(ctx context.Context) ([]models.Agent, error) {
|
func (s *AgentService) ListOnlineAgents(ctx context.Context) ([]models.Agent, error) {
|
||||||
return s.AgentRepo.FindOnlineAgents(ctx)
|
return s.AgentRepo.FindOnlineAgents(ctx)
|
||||||
|
|||||||
Generated
+72
-3
@@ -8,6 +8,10 @@
|
|||||||
"name": "@pika/admin",
|
"name": "@pika/admin",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
"@dnd-kit/modifiers": "^9.0.0",
|
||||||
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@tanstack/react-query": "^5.101.2",
|
"@tanstack/react-query": "^5.101.2",
|
||||||
"@types/qs": "^6.15.1",
|
"@types/qs": "^6.15.1",
|
||||||
"antd": "^6.5.0",
|
"antd": "^6.5.0",
|
||||||
@@ -138,6 +142,73 @@
|
|||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@dnd-kit/accessibility": {
|
||||||
|
"version": "3.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
|
||||||
|
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@dnd-kit/core": {
|
||||||
|
"version": "6.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
|
||||||
|
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@dnd-kit/accessibility": "^3.1.1",
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.8.0",
|
||||||
|
"react-dom": ">=16.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@dnd-kit/modifiers": {
|
||||||
|
"version": "9.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@dnd-kit/modifiers/-/modifiers-9.0.0.tgz",
|
||||||
|
"integrity": "sha512-ybiLc66qRGuZoC20wdSSG6pDXFikui/dCNGthxv4Ndy8ylErY0N3KVxY2bgo7AWwIbxDmXDg3ylAFmnrjcbVvw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@dnd-kit/core": "^6.3.0",
|
||||||
|
"react": ">=16.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@dnd-kit/sortable": {
|
||||||
|
"version": "10.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz",
|
||||||
|
"integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@dnd-kit/core": "^6.3.0",
|
||||||
|
"react": ">=16.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@dnd-kit/utilities": {
|
||||||
|
"version": "3.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
|
||||||
|
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@emnapi/core": {
|
"node_modules/@emnapi/core": {
|
||||||
"version": "1.11.1",
|
"version": "1.11.1",
|
||||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz",
|
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz",
|
||||||
@@ -3805,9 +3876,7 @@
|
|||||||
"version": "2.8.1",
|
"version": "2.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||||
"dev": true,
|
"license": "0BSD"
|
||||||
"license": "0BSD",
|
|
||||||
"optional": true
|
|
||||||
},
|
},
|
||||||
"node_modules/tw-animate-css": {
|
"node_modules/tw-animate-css": {
|
||||||
"version": "1.4.0",
|
"version": "1.4.0",
|
||||||
|
|||||||
@@ -9,6 +9,10 @@
|
|||||||
"lint": "eslint src"
|
"lint": "eslint src"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
"@dnd-kit/modifiers": "^9.0.0",
|
||||||
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@tanstack/react-query": "^5.101.2",
|
"@tanstack/react-query": "^5.101.2",
|
||||||
"@types/qs": "^6.15.1",
|
"@types/qs": "^6.15.1",
|
||||||
"antd": "^6.5.0",
|
"antd": "^6.5.0",
|
||||||
|
|||||||
+11
-1
@@ -448,18 +448,28 @@ export const updateAgentName = (agentId: string, name: string) => {
|
|||||||
return put(`/admin/agents/${agentId}/name`, {name});
|
return put(`/admin/agents/${agentId}/name`, {name});
|
||||||
};
|
};
|
||||||
|
|
||||||
// 更新探针信息(名称、标签、到期时间、可见性)
|
// 更新探针信息(排序通过独立的拖动排序接口维护)
|
||||||
export interface UpdateAgentInfoRequest {
|
export interface UpdateAgentInfoRequest {
|
||||||
name?: string;
|
name?: string;
|
||||||
tags?: string[];
|
tags?: string[];
|
||||||
expireTime?: number;
|
expireTime?: number;
|
||||||
visibility?: string;
|
visibility?: string;
|
||||||
|
remark?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const updateAgentInfo = (agentId: string, data: UpdateAgentInfoRequest) => {
|
export const updateAgentInfo = (agentId: string, data: UpdateAgentInfoRequest) => {
|
||||||
return put(`/admin/agents/${agentId}`, data);
|
return put(`/admin/agents/${agentId}`, data);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export interface UpdateAgentOrderResponse {
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按从前到后的顺序更新全部探针。
|
||||||
|
export const updateAgentOrder = (agentIds: string[]) => {
|
||||||
|
return put<UpdateAgentOrderResponse>('/admin/agents/order', {agentIds});
|
||||||
|
};
|
||||||
|
|
||||||
// 获取探针统计数据
|
// 获取探针统计数据
|
||||||
export interface AgentStatistics {
|
export interface AgentStatistics {
|
||||||
total: number;
|
total: number;
|
||||||
|
|||||||
@@ -105,8 +105,6 @@ const AgentBasicInfo = ({agentId}: AgentBasicInfoProps) => {
|
|||||||
{agent.visibility === 'public' ? '匿名可见' : '登录可见'}
|
{agent.visibility === 'public' ? '匿名可见' : '登录可见'}
|
||||||
</Tag>
|
</Tag>
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
<Descriptions.Item label="排序权重">{agent.weight || 0}</Descriptions.Item>
|
|
||||||
|
|
||||||
<Descriptions.Item label="标签" span={{xs: 1, sm: 2, lg: 3}}>
|
<Descriptions.Item label="标签" span={{xs: 1, sm: 2, lg: 3}}>
|
||||||
{agent.tags && agent.tags.length > 0 ? (
|
{agent.tags && agent.tags.length > 0 ? (
|
||||||
<div className="flex flex-wrap gap-1">
|
<div className="flex flex-wrap gap-1">
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import {useEffect} from 'react';
|
import {useEffect} from 'react';
|
||||||
import {App, DatePicker, Form, Input, InputNumber, Modal, Select} from 'antd';
|
import {App, DatePicker, Form, Input, Modal, Select} from 'antd';
|
||||||
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query';
|
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import {getAgentForAdmin, updateAgentInfo} from '@/api/agent.ts';
|
import {getAgentForAdmin, updateAgentInfo} from '@/api/agent.ts';
|
||||||
@@ -55,7 +55,6 @@ const AgentEditModal = ({open, agentId, existingTags, onCancel, onSuccess}: Agen
|
|||||||
tags: agent.tags || [],
|
tags: agent.tags || [],
|
||||||
expireTime: agent.expireTime ? dayjs(agent.expireTime) : null,
|
expireTime: agent.expireTime ? dayjs(agent.expireTime) : null,
|
||||||
visibility: agent.visibility || 'public',
|
visibility: agent.visibility || 'public',
|
||||||
weight: agent.weight ?? 0,
|
|
||||||
remark: agent.remark ?? '',
|
remark: agent.remark ?? '',
|
||||||
});
|
});
|
||||||
}, [open, isEditMode, agent, form]);
|
}, [open, isEditMode, agent, form]);
|
||||||
@@ -84,7 +83,6 @@ const AgentEditModal = ({open, agentId, existingTags, onCancel, onSuccess}: Agen
|
|||||||
name: values.name,
|
name: values.name,
|
||||||
visibility: values.visibility || 'public',
|
visibility: values.visibility || 'public',
|
||||||
tags: values.tags || [],
|
tags: values.tags || [],
|
||||||
weight: values.weight || 0,
|
|
||||||
remark: values.remark || '',
|
remark: values.remark || '',
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -156,19 +154,6 @@ const AgentEditModal = ({open, agentId, existingTags, onCancel, onSuccess}: Agen
|
|||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item
|
|
||||||
label="权重排序"
|
|
||||||
name="weight"
|
|
||||||
extra="数字越大排序越靠前,默认为0"
|
|
||||||
>
|
|
||||||
<InputNumber
|
|
||||||
min={0}
|
|
||||||
step={1}
|
|
||||||
precision={0}
|
|
||||||
placeholder="请输入权重"
|
|
||||||
style={{width: '100%'}}
|
|
||||||
/>
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item
|
<Form.Item
|
||||||
label="备注"
|
label="备注"
|
||||||
name="remark"
|
name="remark"
|
||||||
|
|||||||
@@ -1,12 +1,24 @@
|
|||||||
import {useMemo, useState} from 'react';
|
import {createContext, useContext, useMemo, useState} from 'react';
|
||||||
|
import type {CSSProperties, HTMLAttributes} from 'react';
|
||||||
import {Link, useNavigate} from 'react-router-dom';
|
import {Link, useNavigate} from 'react-router-dom';
|
||||||
import type {MenuProps} from 'antd';
|
import type {MenuProps} from 'antd';
|
||||||
import {App, Button, Divider, Dropdown, Form, Input, Select, Space, Tag, message} from 'antd';
|
import {App, Button, Dropdown, Form, Input, Select, Space, Tag} from 'antd';
|
||||||
import type {ColumnsType} from 'antd/es/table';
|
import type {ColumnsType} from 'antd/es/table';
|
||||||
import {Edit, Eye, EyeOff, FileWarning, Lock, MoreVertical, Plus, RefreshCw, Shield, Tags, Trash2} from 'lucide-react';
|
import {Edit, Eye, EyeOff, FileWarning, GripVertical, Lock, MoreVertical, Plus, RefreshCw, Shield, Tags, Trash2} from 'lucide-react';
|
||||||
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query';
|
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query';
|
||||||
|
import type {DragEndEvent} from '@dnd-kit/core';
|
||||||
|
import {DndContext, KeyboardSensor, PointerSensor, useSensor, useSensors} from '@dnd-kit/core';
|
||||||
|
import {restrictToVerticalAxis} from '@dnd-kit/modifiers';
|
||||||
|
import {
|
||||||
|
arrayMove,
|
||||||
|
SortableContext,
|
||||||
|
sortableKeyboardCoordinates,
|
||||||
|
useSortable,
|
||||||
|
verticalListSortingStrategy,
|
||||||
|
} from '@dnd-kit/sortable';
|
||||||
|
import {CSS} from '@dnd-kit/utilities';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import {deleteAgent, getTags, listAgentsByAdmin} from '@/api/agent.ts';
|
import {deleteAgent, getTags, listAgentsByAdmin, updateAgentOrder} from '@/api/agent.ts';
|
||||||
import type {Agent} from '@/types';
|
import type {Agent} from '@/types';
|
||||||
import {getErrorMessage} from '@/lib/utils';
|
import {getErrorMessage} from '@/lib/utils';
|
||||||
import {PageHeader} from '@/components/PageHeader';
|
import {PageHeader} from '@/components/PageHeader';
|
||||||
@@ -17,6 +29,68 @@ import BatchTamperProtectionModal from './BatchTamperProtectionModal';
|
|||||||
import BatchSSHLoginConfigModal from './BatchSSHLoginConfigModal';
|
import BatchSSHLoginConfigModal from './BatchSSHLoginConfigModal';
|
||||||
import BatchVisibilityModal from './BatchVisibilityModal';
|
import BatchVisibilityModal from './BatchVisibilityModal';
|
||||||
|
|
||||||
|
type SortableRowHook = ReturnType<typeof useSortable>;
|
||||||
|
|
||||||
|
interface SortableRowContextValue {
|
||||||
|
listeners?: SortableRowHook['listeners'];
|
||||||
|
setActivatorNodeRef?: SortableRowHook['setActivatorNodeRef'];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SortableRowProps extends HTMLAttributes<HTMLTableRowElement> {
|
||||||
|
'data-row-key': string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SortableRowContext = createContext<SortableRowContextValue>({});
|
||||||
|
const SortingDisabledContext = createContext(false);
|
||||||
|
|
||||||
|
const DragHandle = () => {
|
||||||
|
const disabled = useContext(SortingDisabledContext);
|
||||||
|
const {listeners, setActivatorNodeRef} = useContext(SortableRowContext);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
ref={setActivatorNodeRef}
|
||||||
|
type="text"
|
||||||
|
size="small"
|
||||||
|
icon={<GripVertical size={16}/>}
|
||||||
|
disabled={disabled}
|
||||||
|
aria-label="拖动调整探针顺序"
|
||||||
|
title="拖动调整顺序"
|
||||||
|
style={{cursor: disabled ? 'not-allowed' : 'grab', touchAction: 'none'}}
|
||||||
|
{...listeners}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const SortableRow = (props: SortableRowProps) => {
|
||||||
|
const disabled = useContext(SortingDisabledContext);
|
||||||
|
const {
|
||||||
|
attributes,
|
||||||
|
listeners,
|
||||||
|
setNodeRef,
|
||||||
|
setActivatorNodeRef,
|
||||||
|
transform,
|
||||||
|
transition,
|
||||||
|
isDragging,
|
||||||
|
} = useSortable({id: props['data-row-key'], disabled});
|
||||||
|
const style: CSSProperties = {
|
||||||
|
...props.style,
|
||||||
|
transform: CSS.Translate.toString(transform),
|
||||||
|
transition,
|
||||||
|
...(isDragging ? {position: 'relative', zIndex: 2} : {}),
|
||||||
|
};
|
||||||
|
const contextValue = useMemo(
|
||||||
|
() => ({listeners, setActivatorNodeRef}),
|
||||||
|
[listeners, setActivatorNodeRef],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SortableRowContext.Provider value={contextValue}>
|
||||||
|
<tr {...props} ref={setNodeRef} style={style} {...attributes}/>
|
||||||
|
</SortableRowContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const AgentList = () => {
|
const AgentList = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const {message: messageApi, modal} = App.useApp();
|
const {message: messageApi, modal} = App.useApp();
|
||||||
@@ -35,6 +109,10 @@ const AgentList = () => {
|
|||||||
const [keyword, setKeyword] = useState('');
|
const [keyword, setKeyword] = useState('');
|
||||||
const [status, setStatus] = useState<string | undefined>(undefined);
|
const [status, setStatus] = useState<string | undefined>(undefined);
|
||||||
const [selectedTags, setSelectedTags] = useState<string[]>([]);
|
const [selectedTags, setSelectedTags] = useState<string[]>([]);
|
||||||
|
const sensors = useSensors(
|
||||||
|
useSensor(PointerSensor, {activationConstraint: {distance: 4}}),
|
||||||
|
useSensor(KeyboardSensor, {coordinateGetter: sortableKeyboardCoordinates}),
|
||||||
|
);
|
||||||
|
|
||||||
const {data: tags = []} = useQuery({
|
const {data: tags = []} = useQuery({
|
||||||
queryKey: ['admin', 'agents', 'tags'],
|
queryKey: ['admin', 'agents', 'tags'],
|
||||||
@@ -68,6 +146,36 @@ const AgentList = () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const orderMutation = useMutation({
|
||||||
|
mutationFn: (agentIds: string[]) => updateAgentOrder(agentIds),
|
||||||
|
onMutate: async (agentIds) => {
|
||||||
|
await queryClient.cancelQueries({queryKey: ['admin', 'agents'], exact: true});
|
||||||
|
const previousAgents = queryClient.getQueryData<Agent[]>(['admin', 'agents']);
|
||||||
|
if (previousAgents) {
|
||||||
|
const agentsByID = new Map(previousAgents.map(agent => [agent.id, agent]));
|
||||||
|
const reorderedAgents = agentIds
|
||||||
|
.map(agentID => agentsByID.get(agentID))
|
||||||
|
.filter((agent): agent is Agent => agent !== undefined);
|
||||||
|
if (reorderedAgents.length === previousAgents.length) {
|
||||||
|
queryClient.setQueryData(['admin', 'agents'], reorderedAgents);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {previousAgents};
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
messageApi.success('探针排序已保存');
|
||||||
|
},
|
||||||
|
onError: (error: unknown, _agentIds, context) => {
|
||||||
|
if (context?.previousAgents) {
|
||||||
|
queryClient.setQueryData(['admin', 'agents'], context.previousAgents);
|
||||||
|
}
|
||||||
|
messageApi.error(getErrorMessage(error, '保存探针排序失败'));
|
||||||
|
},
|
||||||
|
onSettled: () => {
|
||||||
|
queryClient.invalidateQueries({queryKey: ['admin', 'agents'], exact: true});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const handleSearch = () => {
|
const handleSearch = () => {
|
||||||
const values = searchForm.getFieldsValue();
|
const values = searchForm.getFieldsValue();
|
||||||
setKeyword(values.keyword?.trim() || '');
|
setKeyword(values.keyword?.trim() || '');
|
||||||
@@ -177,7 +285,40 @@ const AgentList = () => {
|
|||||||
return result;
|
return result;
|
||||||
}, [agents, keyword, status, selectedTags]);
|
}, [agents, keyword, status, selectedTags]);
|
||||||
|
|
||||||
|
const handleDragEnd = ({active, over}: DragEndEvent) => {
|
||||||
|
if (!over || active.id === over.id || orderMutation.isPending) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeIndex = filteredAgents.findIndex(agent => agent.id === active.id);
|
||||||
|
const overIndex = filteredAgents.findIndex(agent => agent.id === over.id);
|
||||||
|
if (activeIndex < 0 || overIndex < 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const reorderedVisibleAgents = arrayMove(filteredAgents, activeIndex, overIndex);
|
||||||
|
const visibleAgentIDs = new Set(filteredAgents.map(agent => agent.id));
|
||||||
|
let visibleIndex = 0;
|
||||||
|
const reorderedAgents = agents.map(agent => {
|
||||||
|
if (!visibleAgentIDs.has(agent.id)) {
|
||||||
|
return agent;
|
||||||
|
}
|
||||||
|
const reorderedAgent = reorderedVisibleAgents[visibleIndex];
|
||||||
|
visibleIndex += 1;
|
||||||
|
return reorderedAgent;
|
||||||
|
});
|
||||||
|
orderMutation.mutate(reorderedAgents.map(agent => agent.id));
|
||||||
|
};
|
||||||
|
|
||||||
const columns: ColumnsType<Agent> = [
|
const columns: ColumnsType<Agent> = [
|
||||||
|
{
|
||||||
|
title: '排序',
|
||||||
|
key: 'sort',
|
||||||
|
fixed: 'left',
|
||||||
|
align: 'center',
|
||||||
|
width: 56,
|
||||||
|
render: () => <DragHandle/>,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: '名称',
|
title: '名称',
|
||||||
dataIndex: 'name',
|
dataIndex: 'name',
|
||||||
@@ -356,12 +497,6 @@ const AgentList = () => {
|
|||||||
return <Tag color="green" variant={'filled'}>已启用</Tag>;
|
return <Tag color="green" variant={'filled'}>已启用</Tag>;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
|
||||||
title: '排序权重',
|
|
||||||
dataIndex: 'weight',
|
|
||||||
key: 'weight',
|
|
||||||
width: 100,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
title: '备注',
|
title: '备注',
|
||||||
dataIndex: 'remark',
|
dataIndex: 'remark',
|
||||||
@@ -549,20 +684,36 @@ const AgentList = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<AdminDataTable<Agent>
|
<SortingDisabledContext.Provider
|
||||||
columns={columns}
|
value={isLoading || isFetching || orderMutation.isPending || filteredAgents.length < 2}
|
||||||
dataSource={filteredAgents}
|
>
|
||||||
loading={isLoading || isFetching}
|
<DndContext
|
||||||
rowKey="id"
|
sensors={sensors}
|
||||||
scroll={{x: 2600}}
|
modifiers={[restrictToVerticalAxis]}
|
||||||
tableLayout="fixed"
|
onDragEnd={handleDragEnd}
|
||||||
rowSelection={{
|
>
|
||||||
selectedRowKeys,
|
<SortableContext
|
||||||
onChange: (keys) => setSelectedRowKeys(keys),
|
items={filteredAgents.map(agent => agent.id)}
|
||||||
preserveSelectedRowKeys: true,
|
strategy={verticalListSortingStrategy}
|
||||||
}}
|
>
|
||||||
pagination={false}
|
<AdminDataTable<Agent>
|
||||||
/>
|
columns={columns}
|
||||||
|
dataSource={filteredAgents}
|
||||||
|
loading={isLoading || isFetching}
|
||||||
|
rowKey="id"
|
||||||
|
components={{body: {row: SortableRow}}}
|
||||||
|
scroll={{x: 2600}}
|
||||||
|
tableLayout="fixed"
|
||||||
|
rowSelection={{
|
||||||
|
selectedRowKeys,
|
||||||
|
onChange: (keys) => setSelectedRowKeys(keys),
|
||||||
|
preserveSelectedRowKeys: true,
|
||||||
|
}}
|
||||||
|
pagination={false}
|
||||||
|
/>
|
||||||
|
</SortableContext>
|
||||||
|
</DndContext>
|
||||||
|
</SortingDisabledContext.Provider>
|
||||||
|
|
||||||
<AgentEditModal
|
<AgentEditModal
|
||||||
open={editModalVisible}
|
open={editModalVisible}
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ export interface Agent {
|
|||||||
expireTime?: number; // 到期时间(时间戳毫秒)
|
expireTime?: number; // 到期时间(时间戳毫秒)
|
||||||
status: number;
|
status: number;
|
||||||
visibility?: string; // 可见性: public-匿名可见, private-登录可见
|
visibility?: string; // 可见性: public-匿名可见, private-登录可见
|
||||||
weight?: number; // 权重排序(数字越大越靠前)
|
weight?: number; // 展示顺序(由拖动排序维护)
|
||||||
remark?: string; // 备注信息
|
remark?: string; // 备注信息
|
||||||
lastSeenAt: string | number; // 支持字符串或时间戳
|
lastSeenAt: string | number; // 支持字符串或时间戳
|
||||||
createdAt?: string;
|
createdAt?: string;
|
||||||
|
|||||||
Reference in new issue
Block a user