From a4ec00b47ebe12d6ab0b89126232fcb4946c2788 Mon Sep 17 00:00:00 2001 From: dushixiang Date: Wed, 26 Aug 2026 20:06:08 +0800 Subject: [PATCH] =?UTF-8?q?=E6=94=AF=E6=8C=81=20agent=20=E6=8B=96=E5=8A=A8?= =?UTF-8?q?=E6=8E=92=E5=BA=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 1 + internal/app.go | 1 + internal/handler/agent_admin_handler.go | 42 +++-- internal/models/agent.go | 2 +- internal/service/agent_service.go | 48 ++++++ web/package-lock.json | 75 ++++++++- web/package.json | 4 + web/src/api/agent.ts | 12 +- web/src/pages/Agents/AgentBasicInfo.tsx | 2 - web/src/pages/Agents/AgentEditModal.tsx | 17 +- web/src/pages/Agents/AgentList.tsx | 199 +++++++++++++++++++++--- web/src/types/index.ts | 2 +- 12 files changed, 346 insertions(+), 59 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index dfaac45..ab71b34 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,3 +6,4 @@ - 需要 Tailwind 布局或视觉样式时,在 Ant Design 组件外层包裹原生 `div`、`span` 等元素,并将 Tailwind `className` 放在这些原生元素上。 - 需要调整 Ant Design 组件本身时,优先使用组件自身的 props、`style`、`styles`、`classNames`、设计 Token,或 Ant Design 的 `Flex`、`Space`、`Row`、`Col` 等布局组件。 - 如果一个样式同时涉及 Ant Design 组件外部布局和组件内部外观,将两者分开:外部布局由包裹元素承担,组件内部外观由 Ant Design API 承担。 +- 使用 Ant Design 组件时,必须参考 https://ant.design/llms.txt 中的最新文档,不要使用已废弃的 API 或过时写法。 diff --git a/internal/app.go b/internal/app.go index 166e7f6..33f1eb3 100644 --- a/internal/app.go +++ b/internal/app.go @@ -204,6 +204,7 @@ func setupApi(app *orz.App, components *AppComponents) error { adminApi.GET("/agents", components.AgentHandler.Paging) adminApi.GET("/agents/statistics", components.AgentHandler.GetStatistics) 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/metrics/latest", components.AgentHandler.GetAdminLatestMetrics) adminApi.PUT("/agents/:id", components.AgentHandler.UpdateInfo) diff --git a/internal/handler/agent_admin_handler.go b/internal/handler/agent_admin_handler.go index b2c4132..fd83e70 100644 --- a/internal/handler/agent_admin_handler.go +++ b/internal/handler/agent_admin_handler.go @@ -1,7 +1,9 @@ package handler import ( + "cmp" "encoding/json" + "errors" "fmt" "slices" "strings" @@ -11,21 +13,20 @@ import ( "github.com/labstack/echo/v5" "github.com/pika-monitor/pika/internal/models" "github.com/pika-monitor/pika/internal/protocol" + "github.com/pika-monitor/pika/internal/service" "go.uber.org/zap" ) func SortAgents(agents []models.Agent) { - // 复用排序逻辑 slices.SortFunc(agents, func(a, b models.Agent) int { - // 先按照状态排序 - if a.Status != b.Status { - return b.Status - a.Status - } - // 再按权重排序(数字越大越靠前) + // 拖动排序生成的权重优先,保证管理员设置的全局顺序不会被在线状态打乱。 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) }) } @@ -144,7 +145,7 @@ func (h *AgentHandler) ListAuditResults(c *echo.Context) error { }) } -// UpdateInfo 更新探针信息(名称、标签、到期时间、可见性、权重、备注) +// UpdateInfo 更新探针信息(名称、标签、到期时间、可见性、备注) func (h *AgentHandler) UpdateInfo(c *echo.Context) error { agentID := c.Param("id") @@ -153,7 +154,6 @@ func (h *AgentHandler) UpdateInfo(c *echo.Context) error { Tags []string `json:"tags"` ExpireTime int64 `json:"expireTime"` Visibility string `json:"visibility"` - Weight int `json:"weight"` Remark string `json:"remark"` } if err := c.Bind(&req); err != nil { @@ -170,7 +170,6 @@ func (h *AgentHandler) UpdateInfo(c *echo.Context) error { agent.Tags = req.Tags agent.ExpireTime = req.ExpireTime agent.Visibility = req.Visibility - agent.Weight = req.Weight agent.Remark = req.Remark agent.UpdatedAt = time.Now().UnixMilli() @@ -181,6 +180,27 @@ func (h *AgentHandler) UpdateInfo(c *echo.Context) error { 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 获取探针统计数据 func (h *AgentHandler) GetStatistics(c *echo.Context) error { ctx := c.Request().Context() diff --git a/internal/models/agent.go b/internal/models/agent.go index b2c7def..468125c 100644 --- a/internal/models/agent.go +++ b/internal/models/agent.go @@ -22,7 +22,7 @@ type Agent struct { ExpireTime int64 `json:"expireTime"` // 到期时间(时间戳毫秒) Status int `json:"status"` // 状态: 0-离线, 1-在线 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"` // 备注信息 LastSeenAt int64 `gorm:"index" json:"lastSeenAt"` // 最后上线时间(时间戳毫秒) CreatedAt int64 `json:"createdAt"` // 创建时间(时间戳毫秒) diff --git a/internal/service/agent_service.go b/internal/service/agent_service.go index ffcf0bb..fc49d97 100644 --- a/internal/service/agent_service.go +++ b/internal/service/agent_service.go @@ -29,6 +29,9 @@ type AgentService struct { geoipService *GeoIPService } +// ErrInvalidAgentOrder 表示提交的排序列表无法完整、唯一地对应当前探针列表。 +var ErrInvalidAgentOrder = errors.New("无效的探针排序") + func NewAgentService(logger *zap.Logger, db *gorm.DB, apiKeyService *ApiKeyService, metricService *MetricService, geoipService *GeoIPService) *AgentService { return &AgentService{ logger: logger, @@ -162,6 +165,51 @@ func (s *AgentService) ListAgents(ctx context.Context) ([]models.Agent, error) { 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 列出所有在线探针 func (s *AgentService) ListOnlineAgents(ctx context.Context) ([]models.Agent, error) { return s.AgentRepo.FindOnlineAgents(ctx) diff --git a/web/package-lock.json b/web/package-lock.json index d8cbd9e..7cedac2 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -8,6 +8,10 @@ "name": "@pika/admin", "version": "0.0.0", "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", "@types/qs": "^6.15.1", "antd": "^6.5.0", @@ -138,6 +142,73 @@ "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": { "version": "1.11.1", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", @@ -3805,9 +3876,7 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, - "license": "0BSD", - "optional": true + "license": "0BSD" }, "node_modules/tw-animate-css": { "version": "1.4.0", diff --git a/web/package.json b/web/package.json index 2f94d54..7ee33f4 100644 --- a/web/package.json +++ b/web/package.json @@ -9,6 +9,10 @@ "lint": "eslint src" }, "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", "@types/qs": "^6.15.1", "antd": "^6.5.0", diff --git a/web/src/api/agent.ts b/web/src/api/agent.ts index 85b12ef..69011eb 100644 --- a/web/src/api/agent.ts +++ b/web/src/api/agent.ts @@ -448,18 +448,28 @@ export const updateAgentName = (agentId: string, name: string) => { return put(`/admin/agents/${agentId}/name`, {name}); }; -// 更新探针信息(名称、标签、到期时间、可见性) +// 更新探针信息(排序通过独立的拖动排序接口维护) export interface UpdateAgentInfoRequest { name?: string; tags?: string[]; expireTime?: number; visibility?: string; + remark?: string; } export const updateAgentInfo = (agentId: string, data: UpdateAgentInfoRequest) => { return put(`/admin/agents/${agentId}`, data); }; +export interface UpdateAgentOrderResponse { + message: string; +} + +// 按从前到后的顺序更新全部探针。 +export const updateAgentOrder = (agentIds: string[]) => { + return put('/admin/agents/order', {agentIds}); +}; + // 获取探针统计数据 export interface AgentStatistics { total: number; diff --git a/web/src/pages/Agents/AgentBasicInfo.tsx b/web/src/pages/Agents/AgentBasicInfo.tsx index 0ccd70f..2a38216 100644 --- a/web/src/pages/Agents/AgentBasicInfo.tsx +++ b/web/src/pages/Agents/AgentBasicInfo.tsx @@ -105,8 +105,6 @@ const AgentBasicInfo = ({agentId}: AgentBasicInfoProps) => { {agent.visibility === 'public' ? '匿名可见' : '登录可见'} - {agent.weight || 0} - {agent.tags && agent.tags.length > 0 ? (
diff --git a/web/src/pages/Agents/AgentEditModal.tsx b/web/src/pages/Agents/AgentEditModal.tsx index 6abbc00..7e7938e 100644 --- a/web/src/pages/Agents/AgentEditModal.tsx +++ b/web/src/pages/Agents/AgentEditModal.tsx @@ -1,5 +1,5 @@ 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 dayjs from 'dayjs'; import {getAgentForAdmin, updateAgentInfo} from '@/api/agent.ts'; @@ -55,7 +55,6 @@ const AgentEditModal = ({open, agentId, existingTags, onCancel, onSuccess}: Agen tags: agent.tags || [], expireTime: agent.expireTime ? dayjs(agent.expireTime) : null, visibility: agent.visibility || 'public', - weight: agent.weight ?? 0, remark: agent.remark ?? '', }); }, [open, isEditMode, agent, form]); @@ -84,7 +83,6 @@ const AgentEditModal = ({open, agentId, existingTags, onCancel, onSuccess}: Agen name: values.name, visibility: values.visibility || 'public', tags: values.tags || [], - weight: values.weight || 0, remark: values.remark || '', }; @@ -156,19 +154,6 @@ const AgentEditModal = ({open, agentId, existingTags, onCancel, onSuccess}: Agen ]} /> - - - ; + +interface SortableRowContextValue { + listeners?: SortableRowHook['listeners']; + setActivatorNodeRef?: SortableRowHook['setActivatorNodeRef']; +} + +interface SortableRowProps extends HTMLAttributes { + 'data-row-key': string; +} + +const SortableRowContext = createContext({}); +const SortingDisabledContext = createContext(false); + +const DragHandle = () => { + const disabled = useContext(SortingDisabledContext); + const {listeners, setActivatorNodeRef} = useContext(SortableRowContext); + + return ( +
)} - - columns={columns} - dataSource={filteredAgents} - loading={isLoading || isFetching} - rowKey="id" - scroll={{x: 2600}} - tableLayout="fixed" - rowSelection={{ - selectedRowKeys, - onChange: (keys) => setSelectedRowKeys(keys), - preserveSelectedRowKeys: true, - }} - pagination={false} - /> + + + agent.id)} + strategy={verticalListSortingStrategy} + > + + 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} + /> + + +