mirror of
https://github.com/Sagit-chu/flvx.git
synced 2026-10-09 11:16:37 +08:00
feat(nodes): add node metadata and expiry reminders (#246)
Entire-Checkpoint: d8b429492cbe
This commit is contained in:
@@ -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 |
|
| `websocket_reporter` | Func | `go-gost/x/socket/websocket_reporter.go` | Panel Telemetry |
|
||||||
|
|
||||||
## CONVENTIONS
|
## 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/`.
|
- **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.
|
- **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.
|
- **Encryption**: Agent-to-panel communication uses AES encryption with node `secret` as PSK.
|
||||||
|
|||||||
@@ -261,6 +261,9 @@ func (h *Handler) nodeCreate(w http.ResponseWriter, r *http.Request) {
|
|||||||
defaultString(asString(req["port"]), "1000-65535"),
|
defaultString(asString(req["port"]), "1000-65535"),
|
||||||
nullableText(asString(req["interfaceName"])),
|
nullableText(asString(req["interfaceName"])),
|
||||||
nullableText(""),
|
nullableText(""),
|
||||||
|
nullableText(strings.TrimSpace(asString(req["remark"]))),
|
||||||
|
nullableText(strings.TrimSpace(asString(req["tags"]))),
|
||||||
|
nullableUnixMilli(asInt64(req["expiryTime"], 0)),
|
||||||
asInt(req["http"], 0),
|
asInt(req["http"], 0),
|
||||||
asInt(req["tls"], 0),
|
asInt(req["tls"], 0),
|
||||||
asInt(req["socks"], 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"),
|
defaultString(asString(req["port"]), "1000-65535"),
|
||||||
nullableText(asString(req["interfaceName"])),
|
nullableText(asString(req["interfaceName"])),
|
||||||
nullableText(asString(req["extraIPs"])),
|
nullableText(asString(req["extraIPs"])),
|
||||||
|
nullableText(strings.TrimSpace(asString(req["remark"]))),
|
||||||
|
nullableText(strings.TrimSpace(asString(req["tags"]))),
|
||||||
|
nullableUnixMilli(asInt64(req["expiryTime"], 0)),
|
||||||
newHTTP,
|
newHTTP,
|
||||||
newTLS,
|
newTLS,
|
||||||
newSocks,
|
newSocks,
|
||||||
@@ -3534,6 +3540,13 @@ func nullableText(s string) interface{} {
|
|||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func nullableUnixMilli(v int64) interface{} {
|
||||||
|
if v <= 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
func nullableInt(v *int64) interface{} {
|
func nullableInt(v *int64) interface{} {
|
||||||
if v == nil {
|
if v == nil {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -60,6 +60,9 @@ func (ForwardPort) TableName() string { return "forward_port" }
|
|||||||
type Node struct {
|
type Node struct {
|
||||||
ID int64 `gorm:"primaryKey;autoIncrement"`
|
ID int64 `gorm:"primaryKey;autoIncrement"`
|
||||||
Name string `gorm:"type:varchar(100);not null"`
|
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"`
|
Secret string `gorm:"type:varchar(100);not null"`
|
||||||
ServerIP string `gorm:"column:server_ip;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)"`
|
ServerIPV4 sql.NullString `gorm:"column:server_ip_v4;type:varchar(100)"`
|
||||||
@@ -336,6 +339,9 @@ type UserBackup struct {
|
|||||||
type NodeBackup struct {
|
type NodeBackup struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
|
Remark string `json:"remark,omitempty"`
|
||||||
|
Tags string `json:"tags,omitempty"`
|
||||||
|
ExpiryTime int64 `json:"expiryTime,omitempty"`
|
||||||
Secret string `json:"secret"`
|
Secret string `json:"secret"`
|
||||||
ServerIP string `json:"serverIp"`
|
ServerIP string `json:"serverIp"`
|
||||||
ServerIPv4 string `json:"serverIpV4,omitempty"`
|
ServerIPv4 string `json:"serverIpV4,omitempty"`
|
||||||
|
|||||||
@@ -634,7 +634,10 @@ func (r *Repository) ListNodes() ([]map[string]interface{}, error) {
|
|||||||
for _, n := range nodes {
|
for _, n := range nodes {
|
||||||
items = append(items, map[string]interface{}{
|
items = append(items, map[string]interface{}{
|
||||||
"id": n.ID, "inx": n.Inx, "name": n.Name,
|
"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),
|
"serverIpV4": nullableString(n.ServerIPV4),
|
||||||
"serverIpV6": nullableString(n.ServerIPV6),
|
"serverIpV6": nullableString(n.ServerIPV6),
|
||||||
"extraIPs": nullableString(n.ExtraIPs),
|
"extraIPs": nullableString(n.ExtraIPs),
|
||||||
@@ -1814,11 +1817,15 @@ func (r *Repository) exportNodes() ([]model.NodeBackup, error) {
|
|||||||
for _, n := range nodes {
|
for _, n := range nodes {
|
||||||
b := model.NodeBackup{
|
b := model.NodeBackup{
|
||||||
ID: n.ID, Name: n.Name, Secret: n.Secret, ServerIP: n.ServerIP,
|
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,
|
Port: n.Port, HTTP: n.HTTP, TLS: n.TLS, Socks: n.Socks,
|
||||||
CreatedTime: n.CreatedTime, Status: n.Status,
|
CreatedTime: n.CreatedTime, Status: n.Status,
|
||||||
TCPListenAddr: n.TCPListenAddr, UDPListenAddr: n.UDPListenAddr,
|
TCPListenAddr: n.TCPListenAddr, UDPListenAddr: n.UDPListenAddr,
|
||||||
Inx: n.Inx, IsRemote: n.IsRemote,
|
Inx: n.Inx, IsRemote: n.IsRemote,
|
||||||
}
|
}
|
||||||
|
if n.ExpiryTime.Valid {
|
||||||
|
b.ExpiryTime = n.ExpiryTime.Int64
|
||||||
|
}
|
||||||
if n.UpdatedTime.Valid {
|
if n.UpdatedTime.Valid {
|
||||||
b.UpdatedTime = n.UpdatedTime.Int64
|
b.UpdatedTime = n.UpdatedTime.Int64
|
||||||
}
|
}
|
||||||
@@ -2168,6 +2175,9 @@ func importNodes(tx *gorm.DB, nodes []model.NodeBackup, now int64) (int, error)
|
|||||||
item := model.Node{
|
item := model.Node{
|
||||||
ID: n.ID,
|
ID: n.ID,
|
||||||
Name: n.Name,
|
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,
|
Secret: n.Secret,
|
||||||
ServerIP: n.ServerIP,
|
ServerIP: n.ServerIP,
|
||||||
ServerIPV4: sql.NullString{String: n.ServerIPv4, Valid: true},
|
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{
|
err := tx.Clauses(clause.OnConflict{
|
||||||
Columns: []clause.Column{{Name: "id"}},
|
Columns: []clause.Column{{Name: "id"}},
|
||||||
DoUpdates: clause.AssignmentColumns([]string{
|
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",
|
"http", "tls", "socks", "updated_time", "status", "tcp_listen_addr", "udp_listen_addr",
|
||||||
"inx", "is_remote", "remote_url", "remote_token", "remote_config",
|
"inx", "is_remote", "remote_url", "remote_token", "remote_config",
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -196,12 +196,15 @@ func (r *Repository) GetUserDefaultsForTunnel(userID int64) (flow int64, num int
|
|||||||
return user.Flow, user.Num, user.ExpTime, user.FlowResetTime, nil
|
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 {
|
if r == nil || r.db == nil {
|
||||||
return errors.New("repository not initialized")
|
return errors.New("repository not initialized")
|
||||||
}
|
}
|
||||||
node := model.Node{
|
node := model.Node{
|
||||||
Name: name,
|
Name: name,
|
||||||
|
Remark: nullStringFromInterface(remark),
|
||||||
|
Tags: nullStringFromInterface(tags),
|
||||||
|
ExpiryTime: nullInt64FromInterface(expiryTime),
|
||||||
Secret: secret,
|
Secret: secret,
|
||||||
ServerIP: serverIP,
|
ServerIP: serverIP,
|
||||||
ServerIPV4: nullStringFromInterface(serverIPV4),
|
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
|
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 {
|
if r == nil || r.db == nil {
|
||||||
return errors.New("repository not initialized")
|
return errors.New("repository not initialized")
|
||||||
}
|
}
|
||||||
@@ -247,6 +250,9 @@ func (r *Repository) UpdateNode(id int64, name, serverIP string, serverIPV4, ser
|
|||||||
Where("id = ?", id).
|
Where("id = ?", id).
|
||||||
Updates(map[string]interface{}{
|
Updates(map[string]interface{}{
|
||||||
"name": name,
|
"name": name,
|
||||||
|
"remark": nullStringFromInterface(remark),
|
||||||
|
"tags": nullStringFromInterface(tags),
|
||||||
|
"expiry_time": nullInt64FromInterface(expiryTime),
|
||||||
"server_ip": serverIP,
|
"server_ip": serverIP,
|
||||||
"server_ip_v4": nullStringFromInterface(serverIPV4),
|
"server_ip_v4": nullStringFromInterface(serverIPV4),
|
||||||
"server_ip_v6": nullStringFromInterface(serverIPV6),
|
"server_ip_v6": nullStringFromInterface(serverIPV6),
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -65,6 +65,8 @@ export const getUserPackageInfo = () =>
|
|||||||
export const createNode = (data: NodeMutationPayload) =>
|
export const createNode = (data: NodeMutationPayload) =>
|
||||||
Network.post("/node/create", data);
|
Network.post("/node/create", data);
|
||||||
export const getNodeList = () => Network.post<NodeApiItem[]>("/node/list");
|
export const getNodeList = () => Network.post<NodeApiItem[]>("/node/list");
|
||||||
|
export const getDashboardNodeExpiryList = () =>
|
||||||
|
Network.post<NodeApiItem[]>("/node/list", {});
|
||||||
export const updateNode = (data: NodeMutationPayload) =>
|
export const updateNode = (data: NodeMutationPayload) =>
|
||||||
Network.post("/node/update", data);
|
Network.post("/node/update", data);
|
||||||
export const deleteNode = (id: number) => Network.post("/node/delete", { id });
|
export const deleteNode = (id: number) => Network.post("/node/delete", { id });
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ export interface NodeApiItem {
|
|||||||
name: string;
|
name: string;
|
||||||
status: number;
|
status: number;
|
||||||
inx?: number;
|
inx?: number;
|
||||||
|
remark?: string;
|
||||||
|
tags?: string;
|
||||||
|
expiryTime?: number;
|
||||||
syncError?: string;
|
syncError?: string;
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
}
|
}
|
||||||
@@ -214,6 +217,9 @@ export interface NodeMutationPayload {
|
|||||||
name?: string;
|
name?: string;
|
||||||
status?: number;
|
status?: number;
|
||||||
inx?: number;
|
inx?: number;
|
||||||
|
remark?: string;
|
||||||
|
tags?: string;
|
||||||
|
expiryTime?: number;
|
||||||
serverIp?: string;
|
serverIp?: string;
|
||||||
serverIpV4?: string;
|
serverIpV4?: string;
|
||||||
serverIpV6?: string;
|
serverIpV6?: string;
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { MetricCard } from "@/pages/dashboard/components/metric-card";
|
|||||||
import {
|
import {
|
||||||
useDashboardData,
|
useDashboardData,
|
||||||
type DashboardForward as Forward,
|
type DashboardForward as Forward,
|
||||||
|
type DashboardNodeExpiryItem,
|
||||||
type DashboardUserTunnel as UserTunnel,
|
type DashboardUserTunnel as UserTunnel,
|
||||||
} from "@/pages/dashboard/use-dashboard-data";
|
} from "@/pages/dashboard/use-dashboard-data";
|
||||||
|
|
||||||
@@ -34,6 +35,7 @@ export default function DashboardPage() {
|
|||||||
userTunnels,
|
userTunnels,
|
||||||
forwardList,
|
forwardList,
|
||||||
statisticsFlows,
|
statisticsFlows,
|
||||||
|
nodeExpiryReminders,
|
||||||
isAdmin,
|
isAdmin,
|
||||||
announcement,
|
announcement,
|
||||||
} = useDashboardData();
|
} = useDashboardData();
|
||||||
@@ -70,6 +72,103 @@ export default function DashboardPage() {
|
|||||||
return value.toString();
|
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 (
|
||||||
|
<div
|
||||||
|
key={node.id}
|
||||||
|
className="rounded-xl border border-amber-200/80 bg-gradient-to-br from-amber-50 via-white to-orange-50 p-4 shadow-sm dark:border-amber-500/20 dark:from-amber-950/20 dark:via-background dark:to-orange-950/10"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="text-sm font-semibold text-foreground truncate">
|
||||||
|
{node.name}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-xs text-default-500">节点 ID: {node.id}</div>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className={`shrink-0 rounded-full px-2.5 py-1 text-[11px] font-medium ${expiryStatus.badgeClassName}`}
|
||||||
|
>
|
||||||
|
{expiryStatus.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3 text-sm text-default-700 dark:text-default-300">
|
||||||
|
{node.expiryTime
|
||||||
|
? new Date(node.expiryTime).toLocaleString()
|
||||||
|
: "未设置到期时间"}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{node.remark?.trim() && (
|
||||||
|
<p className="mt-3 line-clamp-2 text-xs leading-5 text-default-600 dark:text-default-400">
|
||||||
|
{node.remark.trim()}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tags.length > 0 && (
|
||||||
|
<div className="mt-3 flex flex-wrap gap-2">
|
||||||
|
{tags.map((tag) => (
|
||||||
|
<span
|
||||||
|
key={`${node.id}-${tag}`}
|
||||||
|
className="rounded-full bg-white/80 px-2 py-1 text-[11px] text-default-600 ring-1 ring-amber-200/80 dark:bg-white/5 dark:text-default-300 dark:ring-amber-500/20"
|
||||||
|
>
|
||||||
|
#{tag}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
// 处理24小时流量统计数据
|
// 处理24小时流量统计数据
|
||||||
const processFlowChartData = () => {
|
const processFlowChartData = () => {
|
||||||
// 生成最近24小时的时间数组(从当前小时往前推24小时)
|
// 生成最近24小时的时间数组(从当前小时往前推24小时)
|
||||||
@@ -661,6 +760,52 @@ export default function DashboardPage() {
|
|||||||
statisticsFlowsCount={statisticsFlows.length}
|
statisticsFlowsCount={statisticsFlows.length}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{isAdmin && nodeExpiryReminders.length > 0 && (
|
||||||
|
<Card className="mb-6 lg:mb-8 border border-amber-200/80 bg-gradient-to-br from-amber-50/90 via-background to-orange-50/70 shadow-md dark:border-amber-500/20 dark:from-amber-950/10 dark:to-orange-950/10">
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between w-full">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-amber-100 text-amber-700 dark:bg-amber-500/20 dark:text-amber-300">
|
||||||
|
<svg
|
||||||
|
aria-hidden="true"
|
||||||
|
className="h-5 w-5"
|
||||||
|
fill="currentColor"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
clipRule="evenodd"
|
||||||
|
d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.981-1.742 2.981H4.42c-1.53 0-2.492-1.647-1.743-2.98l5.58-9.92zM11 13a1 1 0 10-2 0 1 1 0 002 0zm-1-7a1 1 0 00-1 1v3a1 1 0 102 0V7a1 1 0 00-1-1z"
|
||||||
|
fillRule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg lg:text-xl font-semibold text-foreground">
|
||||||
|
节点到期提醒
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-default-500">
|
||||||
|
展示 7 天内到期或已过期的节点,便于提前续费或清理
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className="inline-flex w-fit items-center rounded-full bg-white/80 px-3 py-1 text-xs font-medium text-amber-700 ring-1 ring-amber-200/80 dark:bg-white/5 dark:text-amber-300 dark:ring-amber-500/20">
|
||||||
|
{nodeExpiryReminders.length} 个提醒
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardBody className="pt-0">
|
||||||
|
<div className="grid grid-cols-1 gap-3 xl:grid-cols-2">
|
||||||
|
{nodeExpiryReminders.slice(0, 6).map(renderNodeExpiryCard)}
|
||||||
|
</div>
|
||||||
|
{nodeExpiryReminders.length > 6 && (
|
||||||
|
<p className="mt-4 text-xs text-default-500">
|
||||||
|
还有 {nodeExpiryReminders.length - 6} 个节点未展开显示,可前往节点页面继续处理。
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 隧道权限 - 管理员不显示 */}
|
{/* 隧道权限 - 管理员不显示 */}
|
||||||
{!isAdmin && (
|
{!isAdmin && (
|
||||||
<Card className="mb-6 lg:mb-8 border border-gray-200 dark:border-default-200 shadow-md">
|
<Card className="mb-6 lg:mb-8 border border-gray-200 dark:border-default-200 shadow-md">
|
||||||
|
|||||||
@@ -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 { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import toast from "react-hot-toast";
|
import toast from "react-hot-toast";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
getAnnouncement,
|
getAnnouncement,
|
||||||
|
getDashboardNodeExpiryList,
|
||||||
getUserPackageInfo,
|
getUserPackageInfo,
|
||||||
type AnnouncementData,
|
type AnnouncementData,
|
||||||
} from "@/api";
|
} from "@/api";
|
||||||
@@ -52,6 +53,14 @@ export interface DashboardStatisticsFlow {
|
|||||||
time: string;
|
time: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DashboardNodeExpiryItem {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
remark?: string;
|
||||||
|
tags?: string;
|
||||||
|
expiryTime?: number;
|
||||||
|
}
|
||||||
|
|
||||||
const DASHBOARD_POLL_INTERVAL_MS = 5000;
|
const DASHBOARD_POLL_INTERVAL_MS = 5000;
|
||||||
const EXPIRATION_NOTIFICATION_STORAGE_KEY =
|
const EXPIRATION_NOTIFICATION_STORAGE_KEY =
|
||||||
"dashboard:last-expiration-notification";
|
"dashboard:last-expiration-notification";
|
||||||
@@ -75,6 +84,7 @@ interface DashboardDataState {
|
|||||||
userTunnels: DashboardUserTunnel[];
|
userTunnels: DashboardUserTunnel[];
|
||||||
forwardList: DashboardForward[];
|
forwardList: DashboardForward[];
|
||||||
statisticsFlows: DashboardStatisticsFlow[];
|
statisticsFlows: DashboardStatisticsFlow[];
|
||||||
|
nodeExpiryReminders: DashboardNodeExpiryItem[];
|
||||||
isAdmin: boolean;
|
isAdmin: boolean;
|
||||||
announcement: AnnouncementData | null;
|
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 => {
|
export const useDashboardData = (): DashboardDataState => {
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [userInfo, setUserInfo] = useState<DashboardUserInfo>(
|
const [userInfo, setUserInfo] = useState<DashboardUserInfo>(
|
||||||
@@ -203,12 +235,16 @@ export const useDashboardData = (): DashboardDataState => {
|
|||||||
const [statisticsFlows, setStatisticsFlows] = useState<
|
const [statisticsFlows, setStatisticsFlows] = useState<
|
||||||
DashboardStatisticsFlow[]
|
DashboardStatisticsFlow[]
|
||||||
>([]);
|
>([]);
|
||||||
|
const [nodeExpiryReminders, setNodeExpiryReminders] = useState<
|
||||||
|
DashboardNodeExpiryItem[]
|
||||||
|
>([]);
|
||||||
const [isAdmin, setIsAdmin] = useState(false);
|
const [isAdmin, setIsAdmin] = useState(false);
|
||||||
const [announcement, setAnnouncement] = useState<AnnouncementData | null>(
|
const [announcement, setAnnouncement] = useState<AnnouncementData | null>(
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
const isMountedRef = useRef(true);
|
const isMountedRef = useRef(true);
|
||||||
const packageRequestInFlightRef = useRef(false);
|
const packageRequestInFlightRef = useRef(false);
|
||||||
|
const nodeExpiryRequestInFlightRef = useRef(false);
|
||||||
|
|
||||||
const applyPackageData = useCallback((data: {
|
const applyPackageData = useCallback((data: {
|
||||||
userInfo?: DashboardUserInfo;
|
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(() => {
|
useEffect(() => {
|
||||||
isMountedRef.current = true;
|
isMountedRef.current = true;
|
||||||
setIsAdmin(getAdminFlag());
|
const adminFlag = getAdminFlag();
|
||||||
|
|
||||||
|
setIsAdmin(adminFlag);
|
||||||
|
|
||||||
void loadPackageData({ notifyOnError: true });
|
void loadPackageData({ notifyOnError: true });
|
||||||
void loadAnnouncement();
|
void loadAnnouncement();
|
||||||
|
if (adminFlag) {
|
||||||
|
void loadNodeExpiryData();
|
||||||
|
}
|
||||||
localStorage.setItem("e", "/dashboard");
|
localStorage.setItem("e", "/dashboard");
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
isMountedRef.current = false;
|
isMountedRef.current = false;
|
||||||
};
|
};
|
||||||
}, [loadAnnouncement, loadPackageData]);
|
}, [loadAnnouncement, loadNodeExpiryData, loadPackageData]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof document === "undefined") {
|
if (typeof document === "undefined") {
|
||||||
@@ -312,6 +376,9 @@ export const useDashboardData = (): DashboardDataState => {
|
|||||||
const handleVisibilityChange = () => {
|
const handleVisibilityChange = () => {
|
||||||
if (document.visibilityState === "visible") {
|
if (document.visibilityState === "visible") {
|
||||||
void loadPackageData({ silent: true });
|
void loadPackageData({ silent: true });
|
||||||
|
if (isAdmin) {
|
||||||
|
void loadNodeExpiryData();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -321,6 +388,9 @@ export const useDashboardData = (): DashboardDataState => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void loadPackageData({ silent: true });
|
void loadPackageData({ silent: true });
|
||||||
|
if (isAdmin) {
|
||||||
|
void loadNodeExpiryData();
|
||||||
|
}
|
||||||
}, DASHBOARD_POLL_INTERVAL_MS);
|
}, DASHBOARD_POLL_INTERVAL_MS);
|
||||||
|
|
||||||
document.addEventListener("visibilitychange", handleVisibilityChange);
|
document.addEventListener("visibilitychange", handleVisibilityChange);
|
||||||
@@ -329,7 +399,7 @@ export const useDashboardData = (): DashboardDataState => {
|
|||||||
window.clearInterval(interval);
|
window.clearInterval(interval);
|
||||||
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||||
};
|
};
|
||||||
}, [loadPackageData]);
|
}, [isAdmin, loadNodeExpiryData, loadPackageData]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
loading,
|
loading,
|
||||||
@@ -337,6 +407,7 @@ export const useDashboardData = (): DashboardDataState => {
|
|||||||
userTunnels,
|
userTunnels,
|
||||||
forwardList,
|
forwardList,
|
||||||
statisticsFlows,
|
statisticsFlows,
|
||||||
|
nodeExpiryReminders,
|
||||||
isAdmin,
|
isAdmin,
|
||||||
announcement,
|
announcement,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -71,6 +71,9 @@ interface Node {
|
|||||||
id: number;
|
id: number;
|
||||||
inx?: number;
|
inx?: number;
|
||||||
name: string;
|
name: string;
|
||||||
|
remark?: string;
|
||||||
|
tags?: string;
|
||||||
|
expiryTime?: number;
|
||||||
ip: string;
|
ip: string;
|
||||||
serverIp: string;
|
serverIp: string;
|
||||||
serverIpV4?: string;
|
serverIpV4?: string;
|
||||||
@@ -105,6 +108,9 @@ interface Node {
|
|||||||
interface NodeForm {
|
interface NodeForm {
|
||||||
id: number | null;
|
id: number | null;
|
||||||
name: string;
|
name: string;
|
||||||
|
remark: string;
|
||||||
|
tags: string;
|
||||||
|
expiryTime: number;
|
||||||
serverHost: string;
|
serverHost: string;
|
||||||
serverIpV4: string;
|
serverIpV4: string;
|
||||||
serverIpV6: string;
|
serverIpV6: string;
|
||||||
@@ -118,6 +124,80 @@ interface NodeForm {
|
|||||||
socks: number; // 0 关 1 开
|
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 = (
|
const mergeNodeRealtimeState = (
|
||||||
incomingNode: Node,
|
incomingNode: Node,
|
||||||
existingNode?: Node,
|
existingNode?: Node,
|
||||||
@@ -182,6 +262,10 @@ export default function NodePage() {
|
|||||||
"node-search-keyword",
|
"node-search-keyword",
|
||||||
"",
|
"",
|
||||||
);
|
);
|
||||||
|
const [nodeFilterMode, setNodeFilterMode] = useLocalStorageState<NodeFilterMode>(
|
||||||
|
"node-expiry-filter-mode",
|
||||||
|
"all",
|
||||||
|
);
|
||||||
const [isSearchVisible, setIsSearchVisible] = useState(false);
|
const [isSearchVisible, setIsSearchVisible] = useState(false);
|
||||||
const [dialogVisible, setDialogVisible] = useState(false);
|
const [dialogVisible, setDialogVisible] = useState(false);
|
||||||
const [dialogTitle, setDialogTitle] = useState("");
|
const [dialogTitle, setDialogTitle] = useState("");
|
||||||
@@ -195,6 +279,9 @@ export default function NodePage() {
|
|||||||
const [form, setForm] = useState<NodeForm>({
|
const [form, setForm] = useState<NodeForm>({
|
||||||
id: null,
|
id: null,
|
||||||
name: "",
|
name: "",
|
||||||
|
remark: "",
|
||||||
|
tags: "",
|
||||||
|
expiryTime: 0,
|
||||||
serverHost: "",
|
serverHost: "",
|
||||||
serverIpV4: "",
|
serverIpV4: "",
|
||||||
serverIpV6: "",
|
serverIpV6: "",
|
||||||
@@ -636,6 +723,9 @@ export default function NodePage() {
|
|||||||
setForm({
|
setForm({
|
||||||
id: node.id,
|
id: node.id,
|
||||||
name: node.name,
|
name: node.name,
|
||||||
|
remark: node.remark || "",
|
||||||
|
tags: node.tags || "",
|
||||||
|
expiryTime: node.expiryTime || 0,
|
||||||
serverHost: normalizedHost,
|
serverHost: normalizedHost,
|
||||||
serverIpV4: normalizedV4,
|
serverIpV4: normalizedV4,
|
||||||
serverIpV6: normalizedV6,
|
serverIpV6: normalizedV6,
|
||||||
@@ -871,6 +961,9 @@ export default function NodePage() {
|
|||||||
const { serverHost, ...rest } = form;
|
const { serverHost, ...rest } = form;
|
||||||
const data = {
|
const data = {
|
||||||
...rest,
|
...rest,
|
||||||
|
remark: form.remark.trim(),
|
||||||
|
tags: form.tags.trim(),
|
||||||
|
expiryTime: form.expiryTime,
|
||||||
extraIPs: form.extraIPs,
|
extraIPs: form.extraIPs,
|
||||||
serverIp:
|
serverIp:
|
||||||
form.serverIpV4?.trim() ||
|
form.serverIpV4?.trim() ||
|
||||||
@@ -892,6 +985,9 @@ export default function NodePage() {
|
|||||||
? {
|
? {
|
||||||
...n,
|
...n,
|
||||||
name: form.name,
|
name: form.name,
|
||||||
|
remark: form.remark.trim(),
|
||||||
|
tags: form.tags.trim(),
|
||||||
|
expiryTime: form.expiryTime,
|
||||||
serverIp:
|
serverIp:
|
||||||
form.serverIpV4?.trim() ||
|
form.serverIpV4?.trim() ||
|
||||||
form.serverIpV6?.trim() ||
|
form.serverIpV6?.trim() ||
|
||||||
@@ -928,6 +1024,9 @@ export default function NodePage() {
|
|||||||
setForm({
|
setForm({
|
||||||
id: null,
|
id: null,
|
||||||
name: "",
|
name: "",
|
||||||
|
remark: "",
|
||||||
|
tags: "",
|
||||||
|
expiryTime: 0,
|
||||||
serverHost: "",
|
serverHost: "",
|
||||||
serverIpV4: "",
|
serverIpV4: "",
|
||||||
serverIpV6: "",
|
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[] => {
|
const sortedNodes = useMemo((): Node[] => {
|
||||||
if (!nodeList || nodeList.length === 0) return [];
|
if (!nodeList || nodeList.length === 0) return [];
|
||||||
@@ -1072,13 +1185,40 @@ export default function NodePage() {
|
|||||||
filteredNodes = filteredNodes.filter(
|
filteredNodes = filteredNodes.filter(
|
||||||
(n) =>
|
(n) =>
|
||||||
(n.name && n.name.toLowerCase().includes(lowerKeyword)) ||
|
(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.serverIp && n.serverIp.toLowerCase().includes(lowerKeyword)) ||
|
||||||
(n.serverIpV4 && n.serverIpV4.toLowerCase().includes(lowerKeyword)) ||
|
(n.serverIpV4 && n.serverIpV4.toLowerCase().includes(lowerKeyword)) ||
|
||||||
(n.serverIpV6 && n.serverIpV6.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 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 aInx = a.inx ?? 0;
|
||||||
const bInx = b.inx ?? 0;
|
const bInx = b.inx ?? 0;
|
||||||
|
|
||||||
@@ -1110,7 +1250,7 @@ export default function NodePage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return sortedByDb;
|
return sortedByDb;
|
||||||
}, [nodeList, nodeOrder, searchKeyword]);
|
}, [nodeFilterMode, nodeList, nodeOrder, searchKeyword]);
|
||||||
|
|
||||||
const sortableNodeIds = useMemo(
|
const sortableNodeIds = useMemo(
|
||||||
() => sortedNodes.map((n) => n.id),
|
() => sortedNodes.map((n) => n.id),
|
||||||
@@ -1120,15 +1260,41 @@ export default function NodePage() {
|
|||||||
return (
|
return (
|
||||||
<AnimatedPage className="px-3 lg:px-6 py-8">
|
<AnimatedPage className="px-3 lg:px-6 py-8">
|
||||||
<div className="flex flex-col sm:flex-row items-stretch sm:items-center justify-between mb-6 gap-3">
|
<div className="flex flex-col sm:flex-row items-stretch sm:items-center justify-between mb-6 gap-3">
|
||||||
<div className="flex-1 max-w-sm flex items-center gap-2">
|
<div className="flex flex-1 flex-col gap-3 lg:flex-row lg:items-center">
|
||||||
<SearchBar
|
<SearchBar
|
||||||
isVisible={isSearchVisible}
|
isVisible={isSearchVisible}
|
||||||
placeholder="搜索节点名称或IP"
|
placeholder="搜索名称、IP、备注或标签"
|
||||||
value={searchKeyword}
|
value={searchKeyword}
|
||||||
onChange={setSearchKeyword}
|
onChange={setSearchKeyword}
|
||||||
onClose={() => setIsSearchVisible(false)}
|
onClose={() => setIsSearchVisible(false)}
|
||||||
onOpen={() => setIsSearchVisible(true)}
|
onOpen={() => setIsSearchVisible(true)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<Select
|
||||||
|
className="w-full max-w-sm lg:w-56"
|
||||||
|
label="到期筛选"
|
||||||
|
selectedKeys={[nodeFilterMode]}
|
||||||
|
size="sm"
|
||||||
|
variant="bordered"
|
||||||
|
onSelectionChange={(keys) => {
|
||||||
|
const selected = Array.from(keys)[0] as NodeFilterMode | undefined;
|
||||||
|
|
||||||
|
setNodeFilterMode(selected || "all");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectItem key="all" textValue="全部节点">
|
||||||
|
全部节点
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem key="expiringSoon" textValue="7天内到期">
|
||||||
|
7天内到期 ({nodeExpiryStats.expiringSoon})
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem key="expired" textValue="已过期">
|
||||||
|
已过期 ({nodeExpiryStats.expired})
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem key="withExpiry" textValue="已设置到期时间">
|
||||||
|
已设置到期时间 ({nodeExpiryStats.withExpiry})
|
||||||
|
</SelectItem>
|
||||||
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="min-h-9 min-w-0 max-w-full overflow-x-auto touch-pan-x">
|
<div className="min-h-9 min-w-0 max-w-full overflow-x-auto touch-pan-x">
|
||||||
@@ -1239,13 +1405,14 @@ export default function NodePage() {
|
|||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4">
|
||||||
{sortedNodes.map((node) => {
|
{sortedNodes.map((node) => {
|
||||||
const isRemoteNode = node.isRemote === 1;
|
const isRemoteNode = node.isRemote === 1;
|
||||||
|
const expiryMeta = getNodeExpiryMeta(node.expiryTime);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SortableItem key={node.id} id={node.id}>
|
<SortableItem key={node.id} id={node.id}>
|
||||||
{(listeners) => (
|
{(listeners) => (
|
||||||
<Card
|
<Card
|
||||||
key={node.id}
|
key={node.id}
|
||||||
className="group shadow-sm border border-divider hover:shadow-md transition-shadow duration-200 overflow-hidden"
|
className={`group shadow-sm border border-divider hover:shadow-md transition-shadow duration-200 overflow-hidden ${expiryMeta.accentClassName}`}
|
||||||
>
|
>
|
||||||
<CardHeader className="pb-2 md:pb-2">
|
<CardHeader className="pb-2 md:pb-2">
|
||||||
<div className="flex justify-between items-start w-full">
|
<div className="flex justify-between items-start w-full">
|
||||||
@@ -1313,8 +1480,60 @@ export default function NodePage() {
|
|||||||
{getRemoteSyncErrorMessage(node.syncError)}
|
{getRemoteSyncErrorMessage(node.syncError)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{expiryMeta.isHighlighted && (
|
||||||
|
<div
|
||||||
|
className={`mb-3 flex items-center justify-between gap-3 rounded-md px-2 py-1.5 text-xs font-medium ${expiryMeta.bannerClassName}`}
|
||||||
|
>
|
||||||
|
<span>节点到期提醒</span>
|
||||||
|
<span>{expiryMeta.label}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{/* 基础信息 */}
|
{/* 基础信息 */}
|
||||||
<div className="space-y-2 mb-4">
|
<div className="space-y-2 mb-4">
|
||||||
|
{(node.remark?.trim() || node.tags?.trim()) && (
|
||||||
|
<div className="rounded-lg border border-divider/80 bg-default-50/80 px-3 py-2">
|
||||||
|
{node.remark?.trim() && (
|
||||||
|
<div className="text-xs leading-5 text-default-700 whitespace-pre-wrap break-words">
|
||||||
|
{node.remark.trim()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{normalizeNodeTags(node.tags).length > 0 && (
|
||||||
|
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||||
|
{normalizeNodeTags(node.tags).map((tag) => (
|
||||||
|
<Chip
|
||||||
|
key={`${node.id}-${tag}`}
|
||||||
|
className="text-[11px]"
|
||||||
|
color="secondary"
|
||||||
|
size="sm"
|
||||||
|
variant="flat"
|
||||||
|
>
|
||||||
|
#{tag}
|
||||||
|
</Chip>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{node.expiryTime && node.expiryTime > 0 && (
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-default-600">到期时间</span>
|
||||||
|
<div className="text-right ml-2">
|
||||||
|
<div className="text-xs text-warning-700 dark:text-warning-400">
|
||||||
|
{formatNodeExpiry(node.expiryTime)}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1">
|
||||||
|
<Chip
|
||||||
|
className="text-[11px]"
|
||||||
|
color={expiryMeta.tone}
|
||||||
|
size="sm"
|
||||||
|
variant="flat"
|
||||||
|
>
|
||||||
|
{expiryMeta.label}
|
||||||
|
</Chip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="flex justify-between items-center text-sm min-w-0">
|
<div className="flex justify-between items-center text-sm min-w-0">
|
||||||
<span className="text-default-600 flex-shrink-0">
|
<span className="text-default-600 flex-shrink-0">
|
||||||
IP
|
IP
|
||||||
@@ -1616,6 +1835,52 @@ export default function NodePage() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<Textarea
|
||||||
|
description="可记录供应商、用途、续费说明等补充信息"
|
||||||
|
label="备注"
|
||||||
|
maxRows={4}
|
||||||
|
minRows={3}
|
||||||
|
placeholder="例如: 搬瓦工年付,2026-12 续费,日本中转"
|
||||||
|
value={form.remark}
|
||||||
|
variant="bordered"
|
||||||
|
onChange={(e) =>
|
||||||
|
setForm((prev) => ({ ...prev, remark: e.target.value }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<Input
|
||||||
|
description="多个标签用逗号分隔,可用于搜索过滤"
|
||||||
|
label="标签"
|
||||||
|
placeholder="例如: 年付,日本中转,测试机"
|
||||||
|
value={form.tags}
|
||||||
|
variant="bordered"
|
||||||
|
onChange={(e) =>
|
||||||
|
setForm((prev) => ({ ...prev, tags: e.target.value }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
description="留空表示永久有效,可用于记录节点到期时间"
|
||||||
|
label="到期时间"
|
||||||
|
type="datetime-local"
|
||||||
|
value={
|
||||||
|
form.expiryTime > 0
|
||||||
|
? new Date(form.expiryTime).toISOString().slice(0, 16)
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
variant="bordered"
|
||||||
|
onChange={(e) =>
|
||||||
|
setForm((prev) => ({
|
||||||
|
...prev,
|
||||||
|
expiryTime: e.target.value
|
||||||
|
? new Date(e.target.value).getTime()
|
||||||
|
: 0,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Input
|
<Input
|
||||||
description="可选:不带协议、不带端口。建议在 IPv4 和 IPv6 都未填写时使用。至少填写一个 IPv4/IPv6/域名"
|
description="可选:不带协议、不带端口。建议在 IPv4 和 IPv6 都未填写时使用。至少填写一个 IPv4/IPv6/域名"
|
||||||
errorMessage={errors.serverHost}
|
errorMessage={errors.serverHost}
|
||||||
|
|||||||
Reference in New Issue
Block a user