支持 agent 拖动排序

This commit is contained in:
dushixiang committed 2026-08-26 20:06:20 +08:00
1 parent df5685535e
commit a4ec00b47e
12 files changed
+346 -59

No files matched your search

+1
View File
@@ -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 或过时写法。
+1
View File
@@ -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)
+31 -11
View File
@@ -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()
+1 -1
View File
@@ -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"` // 创建时间(时间戳毫秒)
+48
View File
@@ -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)
+72 -3
View File
@@ -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",
+4
View File
@@ -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
View File
@@ -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;
-2
View File
@@ -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 -16
View File
@@ -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"
+175 -24
View File
@@ -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}
+1 -1
View File
@@ -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;