支持 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` 放在这些原生元素上。
|
||||
- 需要调整 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 或过时写法。
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"` // 创建时间(时间戳毫秒)
|
||||
|
||||
@@ -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)
|
||||
|
||||
Generated
+72
-3
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
+11
-1
@@ -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<UpdateAgentOrderResponse>('/admin/agents/order', {agentIds});
|
||||
};
|
||||
|
||||
// 获取探针统计数据
|
||||
export interface AgentStatistics {
|
||||
total: number;
|
||||
|
||||
@@ -105,8 +105,6 @@ const AgentBasicInfo = ({agentId}: AgentBasicInfoProps) => {
|
||||
{agent.visibility === 'public' ? '匿名可见' : '登录可见'}
|
||||
</Tag>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="排序权重">{agent.weight || 0}</Descriptions.Item>
|
||||
|
||||
<Descriptions.Item label="标签" span={{xs: 1, sm: 2, lg: 3}}>
|
||||
{agent.tags && agent.tags.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
|
||||
@@ -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
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="权重排序"
|
||||
name="weight"
|
||||
extra="数字越大排序越靠前,默认为0"
|
||||
>
|
||||
<InputNumber
|
||||
min={0}
|
||||
step={1}
|
||||
precision={0}
|
||||
placeholder="请输入权重"
|
||||
style={{width: '100%'}}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="备注"
|
||||
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 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 {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 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 {deleteAgent, getTags, listAgentsByAdmin} from '@/api/agent.ts';
|
||||
import {deleteAgent, getTags, listAgentsByAdmin, updateAgentOrder} from '@/api/agent.ts';
|
||||
import type {Agent} from '@/types';
|
||||
import {getErrorMessage} from '@/lib/utils';
|
||||
import {PageHeader} from '@/components/PageHeader';
|
||||
@@ -17,6 +29,68 @@ import BatchTamperProtectionModal from './BatchTamperProtectionModal';
|
||||
import BatchSSHLoginConfigModal from './BatchSSHLoginConfigModal';
|
||||
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 navigate = useNavigate();
|
||||
const {message: messageApi, modal} = App.useApp();
|
||||
@@ -35,6 +109,10 @@ const AgentList = () => {
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [status, setStatus] = useState<string | undefined>(undefined);
|
||||
const [selectedTags, setSelectedTags] = useState<string[]>([]);
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {activationConstraint: {distance: 4}}),
|
||||
useSensor(KeyboardSensor, {coordinateGetter: sortableKeyboardCoordinates}),
|
||||
);
|
||||
|
||||
const {data: tags = []} = useQuery({
|
||||
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 values = searchForm.getFieldsValue();
|
||||
setKeyword(values.keyword?.trim() || '');
|
||||
@@ -177,7 +285,40 @@ const AgentList = () => {
|
||||
return result;
|
||||
}, [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> = [
|
||||
{
|
||||
title: '排序',
|
||||
key: 'sort',
|
||||
fixed: 'left',
|
||||
align: 'center',
|
||||
width: 56,
|
||||
render: () => <DragHandle/>,
|
||||
},
|
||||
{
|
||||
title: '名称',
|
||||
dataIndex: 'name',
|
||||
@@ -356,12 +497,6 @@ const AgentList = () => {
|
||||
return <Tag color="green" variant={'filled'}>已启用</Tag>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '排序权重',
|
||||
dataIndex: 'weight',
|
||||
key: 'weight',
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
title: '备注',
|
||||
dataIndex: 'remark',
|
||||
@@ -549,20 +684,36 @@ const AgentList = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AdminDataTable<Agent>
|
||||
columns={columns}
|
||||
dataSource={filteredAgents}
|
||||
loading={isLoading || isFetching}
|
||||
rowKey="id"
|
||||
scroll={{x: 2600}}
|
||||
tableLayout="fixed"
|
||||
rowSelection={{
|
||||
selectedRowKeys,
|
||||
onChange: (keys) => setSelectedRowKeys(keys),
|
||||
preserveSelectedRowKeys: true,
|
||||
}}
|
||||
pagination={false}
|
||||
/>
|
||||
<SortingDisabledContext.Provider
|
||||
value={isLoading || isFetching || orderMutation.isPending || filteredAgents.length < 2}
|
||||
>
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
modifiers={[restrictToVerticalAxis]}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext
|
||||
items={filteredAgents.map(agent => agent.id)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
<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
|
||||
open={editModalVisible}
|
||||
|
||||
@@ -28,7 +28,7 @@ export interface Agent {
|
||||
expireTime?: number; // 到期时间(时间戳毫秒)
|
||||
status: number;
|
||||
visibility?: string; // 可见性: public-匿名可见, private-登录可见
|
||||
weight?: number; // 权重排序(数字越大越靠前)
|
||||
weight?: number; // 展示顺序(由拖动排序维护)
|
||||
remark?: string; // 备注信息
|
||||
lastSeenAt: string | number; // 支持字符串或时间戳
|
||||
createdAt?: string;
|
||||
|
||||
Reference in new issue
Block a user