diff --git a/AGENTS.md b/AGENTS.md index d88d47d..6131811 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -53,6 +53,7 @@ FLVX (formerly Flux Panel) is a traffic forwarding management system built on a | `websocket_reporter` | Func | `go-gost/x/socket/websocket_reporter.go` | Panel Telemetry | ## CONVENTIONS +- **Skills & MCP**: Always prefer using available skills (via `skill` tool) and MCP tools when applicable. Check for relevant skills before implementing from scratch. - **Auth**: `Authorization` header carries the raw JWT token (no `Bearer` prefix) between `vite-frontend/` and `go-backend/`. - **Module Fork**: `go-gost/` uses `replace github.com/go-gost/x => ./x` and `go-gost/x/` is also its own Go module. - **Encryption**: Agent-to-panel communication uses AES encryption with node `secret` as PSK. diff --git a/go-backend/internal/http/handler/mutations.go b/go-backend/internal/http/handler/mutations.go index 9b4447c..1a6eeb7 100644 --- a/go-backend/internal/http/handler/mutations.go +++ b/go-backend/internal/http/handler/mutations.go @@ -261,6 +261,9 @@ func (h *Handler) nodeCreate(w http.ResponseWriter, r *http.Request) { defaultString(asString(req["port"]), "1000-65535"), nullableText(asString(req["interfaceName"])), nullableText(""), + nullableText(strings.TrimSpace(asString(req["remark"]))), + nullableText(strings.TrimSpace(asString(req["tags"]))), + nullableUnixMilli(asInt64(req["expiryTime"], 0)), asInt(req["http"], 0), asInt(req["tls"], 0), asInt(req["socks"], 0), @@ -326,6 +329,9 @@ func (h *Handler) nodeUpdate(w http.ResponseWriter, r *http.Request) { defaultString(asString(req["port"]), "1000-65535"), nullableText(asString(req["interfaceName"])), nullableText(asString(req["extraIPs"])), + nullableText(strings.TrimSpace(asString(req["remark"]))), + nullableText(strings.TrimSpace(asString(req["tags"]))), + nullableUnixMilli(asInt64(req["expiryTime"], 0)), newHTTP, newTLS, newSocks, @@ -3534,6 +3540,13 @@ func nullableText(s string) interface{} { return s } +func nullableUnixMilli(v int64) interface{} { + if v <= 0 { + return nil + } + return v +} + func nullableInt(v *int64) interface{} { if v == nil { return nil diff --git a/go-backend/internal/store/model/model.go b/go-backend/internal/store/model/model.go index 8eb51a6..d69a6e1 100644 --- a/go-backend/internal/store/model/model.go +++ b/go-backend/internal/store/model/model.go @@ -60,6 +60,9 @@ func (ForwardPort) TableName() string { return "forward_port" } type Node struct { ID int64 `gorm:"primaryKey;autoIncrement"` Name string `gorm:"type:varchar(100);not null"` + Remark sql.NullString `gorm:"column:remark;type:text"` + Tags sql.NullString `gorm:"column:tags;type:text"` + ExpiryTime sql.NullInt64 `gorm:"column:expiry_time"` Secret string `gorm:"type:varchar(100);not null"` ServerIP string `gorm:"column:server_ip;type:varchar(100);not null"` ServerIPV4 sql.NullString `gorm:"column:server_ip_v4;type:varchar(100)"` @@ -336,6 +339,9 @@ type UserBackup struct { type NodeBackup struct { ID int64 `json:"id"` Name string `json:"name"` + Remark string `json:"remark,omitempty"` + Tags string `json:"tags,omitempty"` + ExpiryTime int64 `json:"expiryTime,omitempty"` Secret string `json:"secret"` ServerIP string `json:"serverIp"` ServerIPv4 string `json:"serverIpV4,omitempty"` diff --git a/go-backend/internal/store/repo/repository.go b/go-backend/internal/store/repo/repository.go index c2bff03..45cca03 100644 --- a/go-backend/internal/store/repo/repository.go +++ b/go-backend/internal/store/repo/repository.go @@ -634,7 +634,10 @@ func (r *Repository) ListNodes() ([]map[string]interface{}, error) { for _, n := range nodes { items = append(items, map[string]interface{}{ "id": n.ID, "inx": n.Inx, "name": n.Name, - "ip": n.ServerIP, "serverIp": n.ServerIP, + "remark": nullableString(n.Remark), + "tags": nullableString(n.Tags), + "expiryTime": nullableInt64(n.ExpiryTime), + "ip": n.ServerIP, "serverIp": n.ServerIP, "serverIpV4": nullableString(n.ServerIPV4), "serverIpV6": nullableString(n.ServerIPV6), "extraIPs": nullableString(n.ExtraIPs), @@ -1814,11 +1817,15 @@ func (r *Repository) exportNodes() ([]model.NodeBackup, error) { for _, n := range nodes { b := model.NodeBackup{ ID: n.ID, Name: n.Name, Secret: n.Secret, ServerIP: n.ServerIP, + Remark: n.Remark.String, Tags: n.Tags.String, Port: n.Port, HTTP: n.HTTP, TLS: n.TLS, Socks: n.Socks, CreatedTime: n.CreatedTime, Status: n.Status, TCPListenAddr: n.TCPListenAddr, UDPListenAddr: n.UDPListenAddr, Inx: n.Inx, IsRemote: n.IsRemote, } + if n.ExpiryTime.Valid { + b.ExpiryTime = n.ExpiryTime.Int64 + } if n.UpdatedTime.Valid { b.UpdatedTime = n.UpdatedTime.Int64 } @@ -2168,6 +2175,9 @@ func importNodes(tx *gorm.DB, nodes []model.NodeBackup, now int64) (int, error) item := model.Node{ ID: n.ID, Name: n.Name, + Remark: sql.NullString{String: n.Remark, Valid: n.Remark != ""}, + Tags: sql.NullString{String: n.Tags, Valid: n.Tags != ""}, + ExpiryTime: sql.NullInt64{Int64: n.ExpiryTime, Valid: n.ExpiryTime > 0}, Secret: n.Secret, ServerIP: n.ServerIP, ServerIPV4: sql.NullString{String: n.ServerIPv4, Valid: true}, @@ -2192,7 +2202,7 @@ func importNodes(tx *gorm.DB, nodes []model.NodeBackup, now int64) (int, error) err := tx.Clauses(clause.OnConflict{ Columns: []clause.Column{{Name: "id"}}, DoUpdates: clause.AssignmentColumns([]string{ - "name", "secret", "server_ip", "server_ip_v4", "server_ip_v6", "port", "interface_name", "version", + "name", "remark", "tags", "expiry_time", "secret", "server_ip", "server_ip_v4", "server_ip_v6", "port", "interface_name", "version", "http", "tls", "socks", "updated_time", "status", "tcp_listen_addr", "udp_listen_addr", "inx", "is_remote", "remote_url", "remote_token", "remote_config", }), diff --git a/go-backend/internal/store/repo/repository_mutations.go b/go-backend/internal/store/repo/repository_mutations.go index ad29f59..985b08f 100644 --- a/go-backend/internal/store/repo/repository_mutations.go +++ b/go-backend/internal/store/repo/repository_mutations.go @@ -196,12 +196,15 @@ func (r *Repository) GetUserDefaultsForTunnel(userID int64) (flow int64, num int return user.Flow, user.Num, user.ExpTime, user.FlowResetTime, nil } -func (r *Repository) CreateNode(name, secret, serverIP string, serverIPV4, serverIPV6, port, interfaceName, version interface{}, httpFlag, tlsFlag, socksFlag int, now int64, status int, tcpAddr, udpAddr string, inx, isRemote int, remoteURL, remoteToken, remoteConfig, extraIPs interface{}) error { +func (r *Repository) CreateNode(name, secret, serverIP string, serverIPV4, serverIPV6, port, interfaceName, version, remark, tags, expiryTime interface{}, httpFlag, tlsFlag, socksFlag int, now int64, status int, tcpAddr, udpAddr string, inx, isRemote int, remoteURL, remoteToken, remoteConfig, extraIPs interface{}) error { if r == nil || r.db == nil { return errors.New("repository not initialized") } node := model.Node{ Name: name, + Remark: nullStringFromInterface(remark), + Tags: nullStringFromInterface(tags), + ExpiryTime: nullInt64FromInterface(expiryTime), Secret: secret, ServerIP: serverIP, ServerIPV4: nullStringFromInterface(serverIPV4), @@ -239,7 +242,7 @@ func (r *Repository) GetNodeStatusFields(nodeID int64) (status, httpFlag, tlsFla return node.Status, node.HTTP, node.TLS, node.Socks, nil } -func (r *Repository) UpdateNode(id int64, name, serverIP string, serverIPV4, serverIPV6, port, interfaceName, extraIPs interface{}, httpFlag, tlsFlag, socksFlag int, tcpAddr, udpAddr string, now int64) error { +func (r *Repository) UpdateNode(id int64, name, serverIP string, serverIPV4, serverIPV6, port, interfaceName, extraIPs, remark, tags, expiryTime interface{}, httpFlag, tlsFlag, socksFlag int, tcpAddr, udpAddr string, now int64) error { if r == nil || r.db == nil { return errors.New("repository not initialized") } @@ -247,6 +250,9 @@ func (r *Repository) UpdateNode(id int64, name, serverIP string, serverIPV4, ser Where("id = ?", id). Updates(map[string]interface{}{ "name": name, + "remark": nullStringFromInterface(remark), + "tags": nullStringFromInterface(tags), + "expiry_time": nullInt64FromInterface(expiryTime), "server_ip": serverIP, "server_ip_v4": nullStringFromInterface(serverIPV4), "server_ip_v6": nullStringFromInterface(serverIPV6), diff --git a/plans/021-node-remarks-tags-expiry.md b/plans/021-node-remarks-tags-expiry.md new file mode 100644 index 0000000..af0b3a6 --- /dev/null +++ b/plans/021-node-remarks-tags-expiry.md @@ -0,0 +1,6 @@ +# Node Remarks, Tags, and Expiry Plan + +- [x] Review issue #246 and inspect current node backend/frontend flow +- [x] Extend node persistence and API payloads with remark, tags, and expiry fields +- [x] Update node management UI to edit, display, and search the new metadata +- [x] Verify the backend and frontend still build successfully diff --git a/plans/022-node-expiry-highlights-dashboard-reminders.md b/plans/022-node-expiry-highlights-dashboard-reminders.md new file mode 100644 index 0000000..81b9a15 --- /dev/null +++ b/plans/022-node-expiry-highlights-dashboard-reminders.md @@ -0,0 +1,6 @@ +# Node Expiry Highlights And Dashboard Reminders Plan + +- [x] Review current node page and dashboard data flow for expiry-related hooks +- [x] Add node expiry status helpers plus expiring-soon filter/highlight in node management +- [x] Load node expiry data on the dashboard for admins and render reminder card +- [x] Verify frontend build and mark the plan complete diff --git a/vite-frontend/src/api/index.ts b/vite-frontend/src/api/index.ts index 305d549..acddbec 100644 --- a/vite-frontend/src/api/index.ts +++ b/vite-frontend/src/api/index.ts @@ -65,6 +65,8 @@ export const getUserPackageInfo = () => export const createNode = (data: NodeMutationPayload) => Network.post("/node/create", data); export const getNodeList = () => Network.post("/node/list"); +export const getDashboardNodeExpiryList = () => + Network.post("/node/list", {}); export const updateNode = (data: NodeMutationPayload) => Network.post("/node/update", data); export const deleteNode = (id: number) => Network.post("/node/delete", { id }); diff --git a/vite-frontend/src/api/types.ts b/vite-frontend/src/api/types.ts index 94f817c..a15911f 100644 --- a/vite-frontend/src/api/types.ts +++ b/vite-frontend/src/api/types.ts @@ -3,6 +3,9 @@ export interface NodeApiItem { name: string; status: number; inx?: number; + remark?: string; + tags?: string; + expiryTime?: number; syncError?: string; [key: string]: unknown; } @@ -214,6 +217,9 @@ export interface NodeMutationPayload { name?: string; status?: number; inx?: number; + remark?: string; + tags?: string; + expiryTime?: number; serverIp?: string; serverIpV4?: string; serverIpV6?: string; diff --git a/vite-frontend/src/pages/dashboard.tsx b/vite-frontend/src/pages/dashboard.tsx index 889a481..f6f15e3 100644 --- a/vite-frontend/src/pages/dashboard.tsx +++ b/vite-frontend/src/pages/dashboard.tsx @@ -17,6 +17,7 @@ import { MetricCard } from "@/pages/dashboard/components/metric-card"; import { useDashboardData, type DashboardForward as Forward, + type DashboardNodeExpiryItem, type DashboardUserTunnel as UserTunnel, } from "@/pages/dashboard/use-dashboard-data"; @@ -34,6 +35,7 @@ export default function DashboardPage() { userTunnels, forwardList, statisticsFlows, + nodeExpiryReminders, isAdmin, announcement, } = useDashboardData(); @@ -70,6 +72,103 @@ export default function DashboardPage() { return value.toString(); }; + const getNodeExpiryStatus = (expiryTime?: number) => { + if (!expiryTime) { + return { + label: "永久有效", + badgeClassName: + "bg-default-100 text-default-700 dark:bg-default-50 dark:text-default-300", + }; + } + + const diffMs = expiryTime - Date.now(); + const diffDays = Math.ceil(diffMs / (1000 * 60 * 60 * 24)); + + if (diffDays <= 0) { + return { + label: "已过期", + badgeClassName: + "bg-red-100 text-red-700 dark:bg-red-500/20 dark:text-red-300", + }; + } + + if (diffDays === 1) { + return { + label: "明天到期", + badgeClassName: + "bg-amber-100 text-amber-700 dark:bg-amber-500/20 dark:text-amber-300", + }; + } + + return { + label: `${diffDays}天后到期`, + badgeClassName: + diffDays <= 7 + ? "bg-amber-100 text-amber-700 dark:bg-amber-500/20 dark:text-amber-300" + : "bg-emerald-100 text-emerald-700 dark:bg-emerald-500/20 dark:text-emerald-300", + }; + }; + + const parseNodeTags = (tags?: string) => { + if (!tags) return []; + return tags + .split(",") + .map((tag) => tag.trim()) + .filter(Boolean) + .slice(0, 3); + }; + + const renderNodeExpiryCard = (node: DashboardNodeExpiryItem) => { + const expiryStatus = getNodeExpiryStatus(node.expiryTime); + const tags = parseNodeTags(node.tags); + + return ( +
+
+
+
+ {node.name} +
+
节点 ID: {node.id}
+
+ + {expiryStatus.label} + +
+ +
+ {node.expiryTime + ? new Date(node.expiryTime).toLocaleString() + : "未设置到期时间"} +
+ + {node.remark?.trim() && ( +

+ {node.remark.trim()} +

+ )} + + {tags.length > 0 && ( +
+ {tags.map((tag) => ( + + #{tag} + + ))} +
+ )} +
+ ); + }; + // 处理24小时流量统计数据 const processFlowChartData = () => { // 生成最近24小时的时间数组(从当前小时往前推24小时) @@ -661,6 +760,52 @@ export default function DashboardPage() { statisticsFlowsCount={statisticsFlows.length} /> + {isAdmin && nodeExpiryReminders.length > 0 && ( + + +
+
+
+ +
+
+

+ 节点到期提醒 +

+

+ 展示 7 天内到期或已过期的节点,便于提前续费或清理 +

+
+
+ + {nodeExpiryReminders.length} 个提醒 + +
+
+ +
+ {nodeExpiryReminders.slice(0, 6).map(renderNodeExpiryCard)} +
+ {nodeExpiryReminders.length > 6 && ( +

+ 还有 {nodeExpiryReminders.length - 6} 个节点未展开显示,可前往节点页面继续处理。 +

+ )} +
+
+ )} + {/* 隧道权限 - 管理员不显示 */} {!isAdmin && ( diff --git a/vite-frontend/src/pages/dashboard/use-dashboard-data.ts b/vite-frontend/src/pages/dashboard/use-dashboard-data.ts index 052f7fe..b5d644b 100644 --- a/vite-frontend/src/pages/dashboard/use-dashboard-data.ts +++ b/vite-frontend/src/pages/dashboard/use-dashboard-data.ts @@ -1,10 +1,11 @@ -import type { ForwardApiItem } from "@/api/types"; +import type { ForwardApiItem, NodeApiItem } from "@/api/types"; import { useCallback, useEffect, useRef, useState } from "react"; import toast from "react-hot-toast"; import { getAnnouncement, + getDashboardNodeExpiryList, getUserPackageInfo, type AnnouncementData, } from "@/api"; @@ -52,6 +53,14 @@ export interface DashboardStatisticsFlow { time: string; } +export interface DashboardNodeExpiryItem { + id: number; + name: string; + remark?: string; + tags?: string; + expiryTime?: number; +} + const DASHBOARD_POLL_INTERVAL_MS = 5000; const EXPIRATION_NOTIFICATION_STORAGE_KEY = "dashboard:last-expiration-notification"; @@ -75,6 +84,7 @@ interface DashboardDataState { userTunnels: DashboardUserTunnel[]; forwardList: DashboardForward[]; statisticsFlows: DashboardStatisticsFlow[]; + nodeExpiryReminders: DashboardNodeExpiryItem[]; isAdmin: boolean; announcement: AnnouncementData | null; } @@ -193,6 +203,28 @@ const normalizeTunnelPermissions = (items: DashboardUserTunnel[]) => { })); }; +const normalizeNodeExpiryReminders = (items: NodeApiItem[]) => { + const now = Date.now(); + const warningWindowMs = 7 * 24 * 60 * 60 * 1000; + + return (items || []) + .map((item) => ({ + id: item.id, + name: item.name || "", + remark: typeof item.remark === "string" ? item.remark : "", + tags: typeof item.tags === "string" ? item.tags : "", + expiryTime: + typeof item.expiryTime === "number" && item.expiryTime > 0 + ? item.expiryTime + : undefined, + })) + .filter((item) => { + if (!item.expiryTime) return false; + return item.expiryTime <= now + warningWindowMs; + }) + .sort((a, b) => (a.expiryTime || 0) - (b.expiryTime || 0)); +}; + export const useDashboardData = (): DashboardDataState => { const [loading, setLoading] = useState(true); const [userInfo, setUserInfo] = useState( @@ -203,12 +235,16 @@ export const useDashboardData = (): DashboardDataState => { const [statisticsFlows, setStatisticsFlows] = useState< DashboardStatisticsFlow[] >([]); + const [nodeExpiryReminders, setNodeExpiryReminders] = useState< + DashboardNodeExpiryItem[] + >([]); const [isAdmin, setIsAdmin] = useState(false); const [announcement, setAnnouncement] = useState( null, ); const isMountedRef = useRef(true); const packageRequestInFlightRef = useRef(false); + const nodeExpiryRequestInFlightRef = useRef(false); const applyPackageData = useCallback((data: { userInfo?: DashboardUserInfo; @@ -291,18 +327,46 @@ export const useDashboardData = (): DashboardDataState => { } }, []); + const loadNodeExpiryData = useCallback(async () => { + if (nodeExpiryRequestInFlightRef.current) { + return; + } + + nodeExpiryRequestInFlightRef.current = true; + + try { + const res = await getDashboardNodeExpiryList(); + + if (!isMountedRef.current) { + return; + } + + if (res.code === 0 && Array.isArray(res.data)) { + setNodeExpiryReminders(normalizeNodeExpiryReminders(res.data)); + } + } catch { + } finally { + nodeExpiryRequestInFlightRef.current = false; + } + }, []); + useEffect(() => { isMountedRef.current = true; - setIsAdmin(getAdminFlag()); + const adminFlag = getAdminFlag(); + + setIsAdmin(adminFlag); void loadPackageData({ notifyOnError: true }); void loadAnnouncement(); + if (adminFlag) { + void loadNodeExpiryData(); + } localStorage.setItem("e", "/dashboard"); return () => { isMountedRef.current = false; }; - }, [loadAnnouncement, loadPackageData]); + }, [loadAnnouncement, loadNodeExpiryData, loadPackageData]); useEffect(() => { if (typeof document === "undefined") { @@ -312,6 +376,9 @@ export const useDashboardData = (): DashboardDataState => { const handleVisibilityChange = () => { if (document.visibilityState === "visible") { void loadPackageData({ silent: true }); + if (isAdmin) { + void loadNodeExpiryData(); + } } }; @@ -321,6 +388,9 @@ export const useDashboardData = (): DashboardDataState => { } void loadPackageData({ silent: true }); + if (isAdmin) { + void loadNodeExpiryData(); + } }, DASHBOARD_POLL_INTERVAL_MS); document.addEventListener("visibilitychange", handleVisibilityChange); @@ -329,7 +399,7 @@ export const useDashboardData = (): DashboardDataState => { window.clearInterval(interval); document.removeEventListener("visibilitychange", handleVisibilityChange); }; - }, [loadPackageData]); + }, [isAdmin, loadNodeExpiryData, loadPackageData]); return { loading, @@ -337,6 +407,7 @@ export const useDashboardData = (): DashboardDataState => { userTunnels, forwardList, statisticsFlows, + nodeExpiryReminders, isAdmin, announcement, }; diff --git a/vite-frontend/src/pages/node.tsx b/vite-frontend/src/pages/node.tsx index 096ab2c..d18be2d 100644 --- a/vite-frontend/src/pages/node.tsx +++ b/vite-frontend/src/pages/node.tsx @@ -71,6 +71,9 @@ interface Node { id: number; inx?: number; name: string; + remark?: string; + tags?: string; + expiryTime?: number; ip: string; serverIp: string; serverIpV4?: string; @@ -105,6 +108,9 @@ interface Node { interface NodeForm { id: number | null; name: string; + remark: string; + tags: string; + expiryTime: number; serverHost: string; serverIpV4: string; serverIpV6: string; @@ -118,6 +124,80 @@ interface NodeForm { socks: number; // 0 关 1 开 } +const formatNodeExpiry = (timestamp?: number): string => { + if (!timestamp || timestamp <= 0) return "永久有效"; + return new Date(timestamp).toLocaleString(); +}; + +const EXPIRING_SOON_DAYS = 7; + +type NodeExpiryState = "permanent" | "healthy" | "expiringSoon" | "expired"; + +type NodeFilterMode = "all" | "expiringSoon" | "expired" | "withExpiry"; + +const getNodeExpiryMeta = (timestamp?: number) => { + if (!timestamp || timestamp <= 0) { + return { + state: "permanent" as NodeExpiryState, + label: "永久有效", + tone: "default" as const, + accentClassName: "", + bannerClassName: "", + isHighlighted: false, + sortWeight: 3, + }; + } + + const diffMs = timestamp - Date.now(); + const diffDays = Math.ceil(diffMs / (1000 * 60 * 60 * 24)); + + if (diffDays <= 0) { + return { + state: "expired" as NodeExpiryState, + label: "已过期", + tone: "danger" as const, + accentClassName: + "border-red-300/80 bg-red-50/70 shadow-red-100 dark:border-red-500/40 dark:bg-red-950/20", + bannerClassName: + "bg-red-50 text-red-700 dark:bg-red-950/30 dark:text-red-300", + isHighlighted: true, + sortWeight: 0, + }; + } + + if (diffDays <= EXPIRING_SOON_DAYS) { + return { + state: "expiringSoon" as NodeExpiryState, + label: diffDays === 1 ? "明天到期" : `${diffDays}天后到期`, + tone: "warning" as const, + accentClassName: + "border-amber-300/80 bg-amber-50/80 shadow-amber-100 dark:border-amber-500/40 dark:bg-amber-950/20", + bannerClassName: + "bg-amber-50 text-amber-700 dark:bg-amber-950/30 dark:text-amber-300", + isHighlighted: true, + sortWeight: 1, + }; + } + + return { + state: "healthy" as NodeExpiryState, + label: `${diffDays}天后到期`, + tone: "success" as const, + accentClassName: "", + bannerClassName: "", + isHighlighted: false, + sortWeight: 2, + }; +}; + +const normalizeNodeTags = (tags?: string): string[] => { + if (!tags) return []; + return tags + .split(",") + .map((tag) => tag.trim()) + .filter(Boolean); +}; + const mergeNodeRealtimeState = ( incomingNode: Node, existingNode?: Node, @@ -182,6 +262,10 @@ export default function NodePage() { "node-search-keyword", "", ); + const [nodeFilterMode, setNodeFilterMode] = useLocalStorageState( + "node-expiry-filter-mode", + "all", + ); const [isSearchVisible, setIsSearchVisible] = useState(false); const [dialogVisible, setDialogVisible] = useState(false); const [dialogTitle, setDialogTitle] = useState(""); @@ -195,6 +279,9 @@ export default function NodePage() { const [form, setForm] = useState({ id: null, name: "", + remark: "", + tags: "", + expiryTime: 0, serverHost: "", serverIpV4: "", serverIpV6: "", @@ -636,6 +723,9 @@ export default function NodePage() { setForm({ id: node.id, name: node.name, + remark: node.remark || "", + tags: node.tags || "", + expiryTime: node.expiryTime || 0, serverHost: normalizedHost, serverIpV4: normalizedV4, serverIpV6: normalizedV6, @@ -871,6 +961,9 @@ export default function NodePage() { const { serverHost, ...rest } = form; const data = { ...rest, + remark: form.remark.trim(), + tags: form.tags.trim(), + expiryTime: form.expiryTime, extraIPs: form.extraIPs, serverIp: form.serverIpV4?.trim() || @@ -892,6 +985,9 @@ export default function NodePage() { ? { ...n, name: form.name, + remark: form.remark.trim(), + tags: form.tags.trim(), + expiryTime: form.expiryTime, serverIp: form.serverIpV4?.trim() || form.serverIpV6?.trim() || @@ -928,6 +1024,9 @@ export default function NodePage() { setForm({ id: null, name: "", + remark: "", + tags: "", + expiryTime: 0, serverHost: "", serverIpV4: "", serverIpV6: "", @@ -1060,6 +1159,20 @@ export default function NodePage() { }), ); + const nodeExpiryStats = useMemo(() => { + return nodeList.reduce( + (acc, node) => { + const meta = getNodeExpiryMeta(node.expiryTime); + + if (meta.state === "expired") acc.expired += 1; + if (meta.state === "expiringSoon") acc.expiringSoon += 1; + if (node.expiryTime && node.expiryTime > 0) acc.withExpiry += 1; + return acc; + }, + { expired: 0, expiringSoon: 0, withExpiry: 0 }, + ); + }, [nodeList]); + // 根据排序顺序获取节点列表 const sortedNodes = useMemo((): Node[] => { if (!nodeList || nodeList.length === 0) return []; @@ -1072,13 +1185,40 @@ export default function NodePage() { filteredNodes = filteredNodes.filter( (n) => (n.name && n.name.toLowerCase().includes(lowerKeyword)) || + (n.remark && n.remark.toLowerCase().includes(lowerKeyword)) || + (n.tags && n.tags.toLowerCase().includes(lowerKeyword)) || (n.serverIp && n.serverIp.toLowerCase().includes(lowerKeyword)) || (n.serverIpV4 && n.serverIpV4.toLowerCase().includes(lowerKeyword)) || (n.serverIpV6 && n.serverIpV6.toLowerCase().includes(lowerKeyword)), ); } + if (nodeFilterMode !== "all") { + filteredNodes = filteredNodes.filter((node) => { + const expiryMeta = getNodeExpiryMeta(node.expiryTime); + + switch (nodeFilterMode) { + case "expiringSoon": + return expiryMeta.state === "expiringSoon"; + case "expired": + return expiryMeta.state === "expired"; + case "withExpiry": + return !!node.expiryTime && node.expiryTime > 0; + default: + return true; + } + }); + } + const sortedByDb = [...filteredNodes].sort((a, b) => { + const expiryDiff = + getNodeExpiryMeta(a.expiryTime).sortWeight - + getNodeExpiryMeta(b.expiryTime).sortWeight; + + if (expiryDiff !== 0) { + return expiryDiff; + } + const aInx = a.inx ?? 0; const bInx = b.inx ?? 0; @@ -1110,7 +1250,7 @@ export default function NodePage() { } return sortedByDb; - }, [nodeList, nodeOrder, searchKeyword]); + }, [nodeFilterMode, nodeList, nodeOrder, searchKeyword]); const sortableNodeIds = useMemo( () => sortedNodes.map((n) => n.id), @@ -1120,15 +1260,41 @@ export default function NodePage() { return (
-
+
setIsSearchVisible(false)} onOpen={() => setIsSearchVisible(true)} /> + +
@@ -1239,13 +1405,14 @@ export default function NodePage() {
{sortedNodes.map((node) => { const isRemoteNode = node.isRemote === 1; + const expiryMeta = getNodeExpiryMeta(node.expiryTime); return ( {(listeners) => (
@@ -1313,8 +1480,60 @@ export default function NodePage() { {getRemoteSyncErrorMessage(node.syncError)}
)} + {expiryMeta.isHighlighted && ( +
+ 节点到期提醒 + {expiryMeta.label} +
+ )} {/* 基础信息 */}
+ {(node.remark?.trim() || node.tags?.trim()) && ( +
+ {node.remark?.trim() && ( +
+ {node.remark.trim()} +
+ )} + {normalizeNodeTags(node.tags).length > 0 && ( +
+ {normalizeNodeTags(node.tags).map((tag) => ( + + #{tag} + + ))} +
+ )} +
+ )} + {node.expiryTime && node.expiryTime > 0 && ( +
+ 到期时间 +
+
+ {formatNodeExpiry(node.expiryTime)} +
+
+ + {expiryMeta.label} + +
+
+
+ )}
IP @@ -1616,6 +1835,52 @@ export default function NodePage() { } /> +