mirror of
https://github.com/Sagit-chu/flvx.git
synced 2026-09-28 15:46:38 +08:00
Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b93255df3d | |||
| e1aef8700e | |||
| d5b3a39774 | |||
| 022e9e3807 | |||
| d333d463f6 | |||
| abc9f21ab9 | |||
| d216567c02 | |||
| 45bfd35a20 | |||
| 5a1b72387d | |||
| 66de566a00 |
@@ -360,6 +360,8 @@ func (h *Handler) diagnoseForwardRuntime(forward *forwardRecord) (map[string]int
|
||||
return nil, errors.New("隧道配置不完整")
|
||||
}
|
||||
|
||||
ipPreference := h.repo.GetTunnelIPPreference(forward.TunnelID)
|
||||
|
||||
inNodes, chainHops, outNodes := splitChainNodeGroups(chainRows)
|
||||
results := make([]map[string]interface{}, 0, len(chainRows)*2+len(targets))
|
||||
nodeCache := map[int64]*nodeRecord{}
|
||||
@@ -383,7 +385,7 @@ func (h *Handler) diagnoseForwardRuntime(forward *forwardRecord) (map[string]int
|
||||
"fromChainType": 1,
|
||||
"toChainType": 2,
|
||||
"toInx": firstNode.Inx,
|
||||
}, "")
|
||||
}, ipPreference)
|
||||
}
|
||||
} else {
|
||||
for _, outNode := range outNodes {
|
||||
@@ -391,7 +393,7 @@ func (h *Handler) diagnoseForwardRuntime(forward *forwardRecord) (map[string]int
|
||||
h.appendChainHopDiagnosis(&results, nodeCache, inNode.NodeID, outNode, description, map[string]interface{}{
|
||||
"fromChainType": 1,
|
||||
"toChainType": 3,
|
||||
}, "")
|
||||
}, ipPreference)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -406,7 +408,7 @@ func (h *Handler) diagnoseForwardRuntime(forward *forwardRecord) (map[string]int
|
||||
"fromInx": currentNode.Inx,
|
||||
"toChainType": 2,
|
||||
"toInx": nextNode.Inx,
|
||||
}, "")
|
||||
}, ipPreference)
|
||||
}
|
||||
} else {
|
||||
for _, outNode := range outNodes {
|
||||
@@ -415,7 +417,7 @@ func (h *Handler) diagnoseForwardRuntime(forward *forwardRecord) (map[string]int
|
||||
"fromChainType": 2,
|
||||
"fromInx": currentNode.Inx,
|
||||
"toChainType": 3,
|
||||
}, "")
|
||||
}, ipPreference)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -479,7 +481,7 @@ func (h *Handler) diagnoseTunnelRuntime(tunnelID int64) (map[string]interface{},
|
||||
case 1:
|
||||
for _, inNode := range inNodes {
|
||||
description := fmt.Sprintf("入口(%s)->外网", inNode.NodeName)
|
||||
h.appendPathDiagnosis(&results, nodeCache, inNode.NodeID, "www.google.com", 443, description, map[string]interface{}{
|
||||
h.appendPathDiagnosis(&results, nodeCache, inNode.NodeID, "www.bing.com", 443, description, map[string]interface{}{
|
||||
"fromChainType": 1,
|
||||
})
|
||||
}
|
||||
@@ -532,14 +534,14 @@ func (h *Handler) diagnoseTunnelRuntime(tunnelID int64) (map[string]interface{},
|
||||
|
||||
for _, outNode := range outNodes {
|
||||
description := fmt.Sprintf("出口(%s)->外网", outNode.NodeName)
|
||||
h.appendPathDiagnosis(&results, nodeCache, outNode.NodeID, "www.google.com", 443, description, map[string]interface{}{
|
||||
h.appendPathDiagnosis(&results, nodeCache, outNode.NodeID, "www.bing.com", 443, description, map[string]interface{}{
|
||||
"fromChainType": 3,
|
||||
})
|
||||
}
|
||||
default:
|
||||
for _, inNode := range inNodes {
|
||||
description := fmt.Sprintf("入口(%s)->外网", inNode.NodeName)
|
||||
h.appendPathDiagnosis(&results, nodeCache, inNode.NodeID, "www.google.com", 443, description, map[string]interface{}{
|
||||
h.appendPathDiagnosis(&results, nodeCache, inNode.NodeID, "www.bing.com", 443, description, map[string]interface{}{
|
||||
"fromChainType": 1,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -193,6 +193,126 @@ func TestDiagnosisChainCoverageContracts(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestForwardDiagnosisRespectsTunnelIPPreferenceContract(t *testing.T) {
|
||||
secret := "contract-jwt-secret"
|
||||
router, r := setupDiagnosisContractRouter(t, secret)
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO user(id, user, pwd, role_id, exp_time, flow, in_flow, out_flow, flow_reset_time, num, created_time, updated_time, status)
|
||||
VALUES(2, 'normal_user', '3c85cdebade1c51cf64ca9f3c09d182d', 1, 2727251700000, 99999, 0, 0, 1, 99999, ?, ?, 1)
|
||||
`, now, now).Error; err != nil {
|
||||
t.Fatalf("insert user: %v", err)
|
||||
}
|
||||
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx, ip_preference)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, "diagnose-ip-pref-forward", 1.0, 2, "tls", 99999, now, now, 1, nil, 0, "v6").Error; err != nil {
|
||||
t.Fatalf("insert tunnel: %v", err)
|
||||
}
|
||||
tunnelID := mustLastInsertID(t, r, "diagnose-ip-pref-forward")
|
||||
|
||||
insertNode := func(name, v4, v6 string) int64 {
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, name, name+"-secret", v4, v4, v6, "30000-30010", "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0).Error; err != nil {
|
||||
t.Fatalf("insert node %s: %v", name, err)
|
||||
}
|
||||
return mustLastInsertID(t, r, name)
|
||||
}
|
||||
|
||||
entryNodeID := insertNode("entry-node-v6", "10.10.1.10", "2001:db8:10::10")
|
||||
chainNodeID := insertNode("chain-node-v6", "10.10.1.20", "2001:db8:10::20")
|
||||
exitNodeID := insertNode("exit-node-v6", "10.10.1.30", "2001:db8:10::30")
|
||||
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
|
||||
VALUES(?, 1, ?, 30001, 'round', 1, 'tls')
|
||||
`, tunnelID, entryNodeID).Error; err != nil {
|
||||
t.Fatalf("insert entry chain: %v", err)
|
||||
}
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
|
||||
VALUES(?, 2, ?, 30002, 'round', 1, 'tls')
|
||||
`, tunnelID, chainNodeID).Error; err != nil {
|
||||
t.Fatalf("insert middle chain: %v", err)
|
||||
}
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
|
||||
VALUES(?, 3, ?, 30003, 'round', 1, 'tls')
|
||||
`, tunnelID, exitNodeID).Error; err != nil {
|
||||
t.Fatalf("insert exit chain: %v", err)
|
||||
}
|
||||
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO forward(user_id, user_name, name, tunnel_id, remote_addr, strategy, in_flow, out_flow, created_time, updated_time, status, inx)
|
||||
VALUES(?, ?, ?, ?, ?, ?, 0, 0, ?, ?, 1, ?)
|
||||
`, 2, "normal_user", "ip-pref-forward", tunnelID, "8.8.8.8:53", "fifo", now, now, 0).Error; err != nil {
|
||||
t.Fatalf("insert forward: %v", err)
|
||||
}
|
||||
forwardID := mustLastInsertID(t, r, "ip-pref-forward")
|
||||
|
||||
userToken, err := auth.GenerateToken(2, "normal_user", 1, secret)
|
||||
if err != nil {
|
||||
t.Fatalf("generate user token: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/forward/diagnose", bytes.NewBufferString(`{"forwardId":`+strconv.FormatInt(forwardID, 10)+`}`))
|
||||
req.Header.Set("Authorization", userToken)
|
||||
res := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(res, req)
|
||||
|
||||
var out response.R
|
||||
if err := json.NewDecoder(res.Body).Decode(&out); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if out.Code != 0 {
|
||||
t.Fatalf("expected code 0, got %d (%s)", out.Code, out.Msg)
|
||||
}
|
||||
|
||||
payload, ok := out.Data.(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("expected object payload, got %T", out.Data)
|
||||
}
|
||||
results, ok := payload["results"].([]interface{})
|
||||
if !ok || len(results) == 0 {
|
||||
t.Fatalf("expected non-empty results, got %v", payload["results"])
|
||||
}
|
||||
|
||||
hasEntryToChain := false
|
||||
hasChainToExit := false
|
||||
for _, raw := range results {
|
||||
item, ok := raw.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
from := valueAsInt(item["fromChainType"])
|
||||
to := valueAsInt(item["toChainType"])
|
||||
targetIP := strings.TrimSpace(valueAsString(item["targetIp"]))
|
||||
|
||||
if from == 1 && to == 2 {
|
||||
hasEntryToChain = true
|
||||
if targetIP != "2001:db8:10::20" {
|
||||
t.Fatalf("expected entry->chain diagnosis target to use IPv6, got %q", targetIP)
|
||||
}
|
||||
}
|
||||
|
||||
if from == 2 && to == 3 {
|
||||
hasChainToExit = true
|
||||
if targetIP != "2001:db8:10::30" {
|
||||
t.Fatalf("expected chain->exit diagnosis target to use IPv6, got %q", targetIP)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !hasEntryToChain || !hasChainToExit {
|
||||
t.Fatalf("expected entry->chain and chain->exit steps, got entry=%v chain=%v", hasEntryToChain, hasChainToExit)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiagnosisUsesFederationRuntimeForRemoteNodes(t *testing.T) {
|
||||
secret := "contract-jwt-secret"
|
||||
router, r := setupDiagnosisContractRouter(t, secret)
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { Link } from "@/shadcn-bridge/heroui/link";
|
||||
import {
|
||||
Navbar as HeroUINavbar,
|
||||
NavbarBrand,
|
||||
NavbarContent,
|
||||
} from "@/shadcn-bridge/heroui/navbar";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { Logo } from "@/components/icons";
|
||||
import { siteConfig, getCachedConfig } from "@/config/site";
|
||||
import { useWebViewMode } from "@/hooks/useWebViewMode";
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useEffect } from "react";
|
||||
|
||||
import { useTheme } from "@/shadcn-bridge/heroui/use-theme";
|
||||
|
||||
interface ThemeProviderProps {
|
||||
|
||||
@@ -9,9 +9,12 @@ const alertVariants = cva(
|
||||
variants: {
|
||||
variant: {
|
||||
default: "border-default-200 bg-default-50/70 text-foreground",
|
||||
destructive: "border-danger-200 bg-danger-50 text-danger-700 dark:text-danger-300",
|
||||
success: "border-success-200 bg-success-50 text-success-700 dark:text-success-300",
|
||||
warning: "border-warning-200 bg-warning-50 text-warning-700 dark:text-warning-300",
|
||||
destructive:
|
||||
"border-danger-200 bg-danger-50 text-danger-700 dark:text-danger-300",
|
||||
success:
|
||||
"border-success-200 bg-success-50 text-success-700 dark:text-success-300",
|
||||
warning:
|
||||
"border-warning-200 bg-warning-50 text-warning-700 dark:text-warning-300",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
@@ -45,9 +48,16 @@ function AlertTitle({ className, ...props }: React.ComponentProps<"h5">) {
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||
function AlertDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div className={cn("text-sm opacity-90", className)} data-slot="alert-description" {...props} />
|
||||
<div
|
||||
className={cn("text-sm opacity-90", className)}
|
||||
data-slot="alert-description"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -11,11 +11,15 @@ const buttonVariants = cva(
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
destructive: "bg-danger text-white hover:bg-danger/90",
|
||||
outline: "border border-input bg-background hover:bg-default-100 hover:text-foreground dark:hover:bg-default-200/40",
|
||||
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost: "hover:bg-default-100 hover:text-foreground dark:hover:bg-default-200/40",
|
||||
outline:
|
||||
"border border-input bg-background hover:bg-default-100 hover:text-foreground dark:hover:bg-default-200/40",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost:
|
||||
"hover:bg-default-100 hover:text-foreground dark:hover:bg-default-200/40",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
light: "bg-transparent hover:bg-default-100 dark:hover:bg-default-200/40",
|
||||
light:
|
||||
"bg-transparent hover:bg-default-100 dark:hover:bg-default-200/40",
|
||||
flat: "bg-default-100 text-foreground hover:bg-default-200 dark:bg-default-100/40",
|
||||
},
|
||||
size: {
|
||||
@@ -43,9 +47,9 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
|
||||
return (
|
||||
<Comp
|
||||
ref={ref}
|
||||
className={cn(buttonVariants({ className, size, variant }))}
|
||||
data-slot="button"
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -28,7 +28,10 @@ function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<"h3">) {
|
||||
return (
|
||||
<h3
|
||||
className={cn("text-lg font-semibold leading-none tracking-tight", className)}
|
||||
className={cn(
|
||||
"text-lg font-semibold leading-none tracking-tight",
|
||||
className,
|
||||
)}
|
||||
data-slot="card-title"
|
||||
{...props}
|
||||
/>
|
||||
@@ -47,7 +50,11 @@ function CardDescription({ className, ...props }: React.ComponentProps<"p">) {
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div className={cn("p-6 pt-0", className)} data-slot="card-content" {...props} />
|
||||
<div
|
||||
className={cn("p-6 pt-0", className)}
|
||||
data-slot="card-content"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -61,4 +68,11 @@ function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
);
|
||||
}
|
||||
|
||||
export { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle };
|
||||
export {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
};
|
||||
|
||||
@@ -4,7 +4,10 @@ import { CheckIcon } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Checkbox({ className, ...props }: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
|
||||
function Checkbox({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
|
||||
return (
|
||||
<CheckboxPrimitive.Root
|
||||
className={cn(
|
||||
|
||||
@@ -4,23 +4,34 @@ import { XIcon } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Dialog({ ...props }: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||
function Dialog({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
|
||||
}
|
||||
|
||||
function DialogTrigger({ ...props }: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||
function DialogTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
|
||||
}
|
||||
|
||||
function DialogPortal({ ...props }: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||
function DialogPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
|
||||
}
|
||||
|
||||
function DialogClose({ ...props }: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||
function DialogClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
|
||||
}
|
||||
|
||||
function DialogOverlay({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
||||
function DialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
||||
return (
|
||||
<DialogPrimitive.Overlay
|
||||
className={cn(
|
||||
@@ -54,9 +65,7 @@ function DialogContent({
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close
|
||||
className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none"
|
||||
>
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none">
|
||||
<XIcon className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
@@ -69,7 +78,10 @@ function DialogContent({
|
||||
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
className={cn("flex flex-col space-y-1.5 text-center sm:text-left", className)}
|
||||
className={cn(
|
||||
"flex flex-col space-y-1.5 text-center sm:text-left",
|
||||
className,
|
||||
)}
|
||||
data-slot="dialog-header"
|
||||
{...props}
|
||||
/>
|
||||
@@ -79,17 +91,26 @@ function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)}
|
||||
className={cn(
|
||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||
className,
|
||||
)}
|
||||
data-slot="dialog-footer"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogTitle({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||
function DialogTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||
return (
|
||||
<DialogPrimitive.Title
|
||||
className={cn("text-lg font-semibold leading-none tracking-tight", className)}
|
||||
className={cn(
|
||||
"text-lg font-semibold leading-none tracking-tight",
|
||||
className,
|
||||
)}
|
||||
data-slot="dialog-title"
|
||||
{...props}
|
||||
/>
|
||||
|
||||
@@ -4,32 +4,54 @@ import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function DropdownMenu({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
||||
function DropdownMenu({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
||||
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
|
||||
}
|
||||
|
||||
function DropdownMenuTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
|
||||
return <DropdownMenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />;
|
||||
return (
|
||||
<DropdownMenuPrimitive.Trigger
|
||||
data-slot="dropdown-menu-trigger"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuGroup({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
|
||||
return <DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />;
|
||||
function DropdownMenuGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuPortal({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
|
||||
return <DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />;
|
||||
function DropdownMenuPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuSub({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
|
||||
function DropdownMenuSub({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
|
||||
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />;
|
||||
}
|
||||
|
||||
function DropdownMenuRadioGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
|
||||
return <DropdownMenuPrimitive.RadioGroup data-slot="dropdown-menu-radio-group" {...props} />;
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioGroup
|
||||
data-slot="dropdown-menu-radio-group"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuSubTrigger({
|
||||
@@ -171,7 +193,11 @@ function DropdownMenuLabel({
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Label
|
||||
className={cn("px-2 py-1.5 text-sm font-semibold", inset && "pl-8", className)}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-semibold",
|
||||
inset && "pl-8",
|
||||
className,
|
||||
)}
|
||||
data-slot="dropdown-menu-label"
|
||||
{...props}
|
||||
/>
|
||||
@@ -191,10 +217,16 @@ function DropdownMenuSeparator({
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuShortcut({ className, ...props }: React.ComponentProps<"span">) {
|
||||
function DropdownMenuShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
className={cn("ml-auto text-xs tracking-widest text-default-500", className)}
|
||||
className={cn(
|
||||
"ml-auto text-xs tracking-widest text-default-500",
|
||||
className,
|
||||
)}
|
||||
data-slot="dropdown-menu-shortcut"
|
||||
{...props}
|
||||
/>
|
||||
|
||||
@@ -3,7 +3,10 @@ import * as LabelPrimitive from "@radix-ui/react-label";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Label({ className, ...props }: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||
function Label({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||
return (
|
||||
<LabelPrimitive.Root
|
||||
className={cn(
|
||||
|
||||
@@ -13,13 +13,19 @@ function Progress({
|
||||
}) {
|
||||
return (
|
||||
<ProgressPrimitive.Root
|
||||
className={cn("relative h-2 w-full overflow-hidden rounded-full bg-default-200", className)}
|
||||
className={cn(
|
||||
"relative h-2 w-full overflow-hidden rounded-full bg-default-200",
|
||||
className,
|
||||
)}
|
||||
data-slot="progress"
|
||||
value={value}
|
||||
{...props}
|
||||
>
|
||||
<ProgressPrimitive.Indicator
|
||||
className={cn("h-full w-full flex-1 bg-primary transition-all", indicatorClassName)}
|
||||
className={cn(
|
||||
"h-full w-full flex-1 bg-primary transition-all",
|
||||
indicatorClassName,
|
||||
)}
|
||||
data-slot="progress-indicator"
|
||||
style={{ transform: `translateX(-${100 - (value ?? 0)}%)` }}
|
||||
/>
|
||||
|
||||
@@ -4,7 +4,10 @@ import { CircleIcon } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function RadioGroup({ className, ...props }: React.ComponentProps<typeof RadioGroupPrimitive.Root>) {
|
||||
function RadioGroup({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof RadioGroupPrimitive.Root>) {
|
||||
return (
|
||||
<RadioGroupPrimitive.Root
|
||||
className={cn("grid gap-2", className)}
|
||||
@@ -27,7 +30,10 @@ function RadioGroupItem({
|
||||
data-slot="radio-group-item"
|
||||
{...props}
|
||||
>
|
||||
<RadioGroupPrimitive.Indicator className="flex items-center justify-center" data-slot="radio-group-indicator">
|
||||
<RadioGroupPrimitive.Indicator
|
||||
className="flex items-center justify-center"
|
||||
data-slot="radio-group-indicator"
|
||||
>
|
||||
<CircleIcon className="h-2.5 w-2.5 fill-current text-current" />
|
||||
</RadioGroupPrimitive.Indicator>
|
||||
</RadioGroupPrimitive.Item>
|
||||
|
||||
@@ -4,15 +4,21 @@ import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Select({ ...props }: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
||||
function Select({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
||||
return <SelectPrimitive.Root data-slot="select" {...props} />;
|
||||
}
|
||||
|
||||
function SelectGroup({ ...props }: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
||||
function SelectGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
||||
return <SelectPrimitive.Group data-slot="select-group" {...props} />;
|
||||
}
|
||||
|
||||
function SelectValue({ ...props }: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
||||
function SelectValue({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
||||
return <SelectPrimitive.Value data-slot="select-value" {...props} />;
|
||||
}
|
||||
|
||||
@@ -44,7 +50,10 @@ function SelectScrollUpButton({
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
className={cn("flex cursor-default items-center justify-center py-1", className)}
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className,
|
||||
)}
|
||||
data-slot="select-scroll-up-button"
|
||||
{...props}
|
||||
>
|
||||
@@ -59,7 +68,10 @@ function SelectScrollDownButton({
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
className={cn("flex cursor-default items-center justify-center py-1", className)}
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className,
|
||||
)}
|
||||
data-slot="select-scroll-down-button"
|
||||
{...props}
|
||||
>
|
||||
@@ -103,7 +115,10 @@ function SelectContent({
|
||||
);
|
||||
}
|
||||
|
||||
function SelectLabel({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
||||
function SelectLabel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
||||
return (
|
||||
<SelectPrimitive.Label
|
||||
className={cn("px-2 py-1.5 text-sm font-semibold", className)}
|
||||
|
||||
@@ -3,7 +3,10 @@ import * as SwitchPrimitive from "@radix-ui/react-switch";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Switch({ className, ...props }: React.ComponentProps<typeof SwitchPrimitive.Root>) {
|
||||
function Switch({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SwitchPrimitive.Root>) {
|
||||
return (
|
||||
<SwitchPrimitive.Root
|
||||
className={cn(
|
||||
|
||||
@@ -5,7 +5,11 @@ import { cn } from "@/lib/utils";
|
||||
function Table({ className, ...props }: React.ComponentProps<"table">) {
|
||||
return (
|
||||
<div className="relative w-full overflow-auto" data-slot="table-wrapper">
|
||||
<table className={cn("w-full caption-bottom text-sm", className)} data-slot="table" {...props} />
|
||||
<table
|
||||
className={cn("w-full caption-bottom text-sm", className)}
|
||||
data-slot="table"
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -33,7 +37,10 @@ function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
|
||||
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
|
||||
return (
|
||||
<tfoot
|
||||
className={cn("border-t bg-default-50/70 font-medium [&>tr]:last:border-b-0", className)}
|
||||
className={cn(
|
||||
"border-t bg-default-50/70 font-medium [&>tr]:last:border-b-0",
|
||||
className,
|
||||
)}
|
||||
data-slot="table-footer"
|
||||
{...props}
|
||||
/>
|
||||
@@ -43,7 +50,10 @@ function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
|
||||
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
|
||||
return (
|
||||
<tr
|
||||
className={cn("border-b transition-colors hover:bg-default-50/50", className)}
|
||||
className={cn(
|
||||
"border-b transition-colors hover:bg-default-50/50",
|
||||
className,
|
||||
)}
|
||||
data-slot="table-row"
|
||||
{...props}
|
||||
/>
|
||||
@@ -66,16 +76,26 @@ function TableHead({ className, ...props }: React.ComponentProps<"th">) {
|
||||
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
|
||||
return (
|
||||
<td
|
||||
className={cn("p-2 align-middle [&:has([role=checkbox])]:pr-0", className)}
|
||||
className={cn(
|
||||
"p-2 align-middle [&:has([role=checkbox])]:pr-0",
|
||||
className,
|
||||
)}
|
||||
data-slot="table-cell"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TableCaption({ className, ...props }: React.ComponentProps<"caption">) {
|
||||
function TableCaption({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"caption">) {
|
||||
return (
|
||||
<caption className={cn("mt-4 text-sm text-default-500", className)} data-slot="table-caption" {...props} />
|
||||
<caption
|
||||
className={cn("mt-4 text-sm text-default-500", className)}
|
||||
data-slot="table-caption"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,13 +3,23 @@ import * as TabsPrimitive from "@radix-ui/react-tabs";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Tabs({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Root>) {
|
||||
function Tabs({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
|
||||
return (
|
||||
<TabsPrimitive.Root className={cn("flex flex-col gap-2", className)} data-slot="tabs" {...props} />
|
||||
<TabsPrimitive.Root
|
||||
className={cn("flex flex-col gap-2", className)}
|
||||
data-slot="tabs"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TabsList({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.List>) {
|
||||
function TabsList({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.List>) {
|
||||
return (
|
||||
<TabsPrimitive.List
|
||||
className={cn(
|
||||
@@ -22,7 +32,10 @@ function TabsList({ className, ...props }: React.ComponentProps<typeof TabsPrimi
|
||||
);
|
||||
}
|
||||
|
||||
function TabsTrigger({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
|
||||
function TabsTrigger({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
|
||||
return (
|
||||
<TabsPrimitive.Trigger
|
||||
className={cn(
|
||||
@@ -35,7 +48,10 @@ function TabsTrigger({ className, ...props }: React.ComponentProps<typeof TabsPr
|
||||
);
|
||||
}
|
||||
|
||||
function TabsContent({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Content>) {
|
||||
function TabsContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
|
||||
return (
|
||||
<TabsPrimitive.Content
|
||||
className={cn("outline-none", className)}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { useNavigate, useLocation } from "react-router-dom";
|
||||
import { toast } from "react-hot-toast";
|
||||
|
||||
import { Button } from "@/shadcn-bridge/heroui/button";
|
||||
import {
|
||||
Dropdown,
|
||||
@@ -16,8 +18,6 @@ import {
|
||||
useDisclosure,
|
||||
} from "@/shadcn-bridge/heroui/modal";
|
||||
import { Input } from "@/shadcn-bridge/heroui/input";
|
||||
import { toast } from "react-hot-toast";
|
||||
|
||||
import { Logo } from "@/components/icons";
|
||||
import { updatePassword } from "@/api";
|
||||
import { safeLogout } from "@/utils/logout";
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Button } from "@/shadcn-bridge/heroui/button";
|
||||
|
||||
import { Button } from "@/shadcn-bridge/heroui/button";
|
||||
import { Logo } from "@/components/icons";
|
||||
import { siteConfig } from "@/config/site";
|
||||
import { useScrollTopOnPathChange } from "@/hooks/useScrollTopOnPathChange";
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { Button } from "@/shadcn-bridge/heroui/button";
|
||||
import { Input } from "@/shadcn-bridge/heroui/input";
|
||||
import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card";
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import { Button } from "@/shadcn-bridge/heroui/button";
|
||||
import { Input } from "@/shadcn-bridge/heroui/input";
|
||||
import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card";
|
||||
import { title } from "@/components/primitives";
|
||||
import { updatePassword } from "@/api";
|
||||
import DefaultLayout from "@/layouts/default";
|
||||
|
||||
@@ -149,7 +149,7 @@ const getInitialConfigs = (): Record<string, string> => {
|
||||
initialConfigs[key] = cachedValue;
|
||||
}
|
||||
});
|
||||
} catch {}
|
||||
} catch { }
|
||||
|
||||
return initialConfigs;
|
||||
};
|
||||
@@ -528,11 +528,10 @@ export default function ConfigPage() {
|
||||
<button
|
||||
key={option.value}
|
||||
aria-pressed={isSelected}
|
||||
className={`w-full px-4 py-3 rounded-lg border transition-all duration-200 cursor-pointer text-left ${
|
||||
isSelected
|
||||
? "bg-primary-50 dark:bg-primary-900/20 border-primary-300 dark:border-primary-500/50 shadow-sm"
|
||||
: "bg-white dark:bg-default-50 border-default-200 dark:border-default-100/30 hover:border-primary-200 dark:hover:border-primary-500/30 hover:shadow-sm"
|
||||
}`}
|
||||
className={`w-full px-4 py-3 rounded-lg border transition-all duration-200 cursor-pointer text-left ${isSelected
|
||||
? "bg-primary-50 dark:bg-primary-900/20 border-primary-300 dark:border-primary-500/50 shadow-sm"
|
||||
: "bg-white dark:bg-default-50 border-default-200 dark:border-default-100/30 hover:border-primary-200 dark:hover:border-primary-500/30 hover:shadow-sm"
|
||||
}`}
|
||||
type="button"
|
||||
onClick={() => toggleTypeSelection(option.value, setTypes)}
|
||||
>
|
||||
@@ -546,11 +545,10 @@ export default function ConfigPage() {
|
||||
size="md"
|
||||
/>
|
||||
<span
|
||||
className={`font-medium ${
|
||||
isSelected
|
||||
? "text-default-900 dark:text-default-100"
|
||||
: "text-default-700 dark:text-default-500"
|
||||
}`}
|
||||
className={`font-medium ${isSelected
|
||||
? "text-default-900 dark:text-default-100"
|
||||
: "text-default-700 dark:text-default-500"
|
||||
}`}
|
||||
>
|
||||
{option.label}
|
||||
</span>
|
||||
@@ -585,7 +583,7 @@ export default function ConfigPage() {
|
||||
</div>
|
||||
|
||||
<Card className="shadow-md">
|
||||
<CardHeader className="pb-4">
|
||||
<CardHeader className="pb-6">
|
||||
<div className="flex justify-between items-center w-full">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold">基本设置</h2>
|
||||
@@ -609,7 +607,7 @@ export default function ConfigPage() {
|
||||
|
||||
<Divider />
|
||||
|
||||
<CardBody className="space-y-6 pt-6">
|
||||
<CardBody className="space-y-6 pt-8 md:pt-8">
|
||||
{CONFIG_ITEMS.map((item, index) => {
|
||||
// 检查配置项是否应该显示
|
||||
if (!shouldShowItem(item)) {
|
||||
@@ -649,9 +647,9 @@ export default function ConfigPage() {
|
||||
{hasChanges && (
|
||||
<Card className="mt-4 bg-warning-50 dark:bg-warning-900/20 border-warning-200 dark:border-warning-800">
|
||||
<CardBody className="py-3">
|
||||
<div className="flex items-center gap-2 text-warning-700 dark:text-warning-300">
|
||||
<div className="w-full flex items-center justify-center gap-2 text-warning-700 dark:text-warning-300">
|
||||
<div className="w-2 h-2 bg-warning-500 rounded-full animate-pulse" />
|
||||
<span className="text-sm">
|
||||
<span className="text-sm font-medium">
|
||||
检测到配置变更,请记得保存您的修改
|
||||
</span>
|
||||
</div>
|
||||
@@ -660,7 +658,7 @@ export default function ConfigPage() {
|
||||
)}
|
||||
|
||||
<Card className="mt-6 shadow-md">
|
||||
<CardHeader className="pb-4">
|
||||
<CardHeader className="pb-6">
|
||||
<div className="flex justify-between items-center w-full">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold">公告管理</h2>
|
||||
@@ -673,7 +671,7 @@ export default function ConfigPage() {
|
||||
|
||||
<Divider />
|
||||
|
||||
<CardBody className="space-y-4 pt-6">
|
||||
<CardBody className="space-y-4 pt-8 md:pt-8">
|
||||
{announcementLoading ? (
|
||||
<div className="flex justify-center py-8">
|
||||
<Spinner size="lg" />
|
||||
@@ -727,7 +725,7 @@ export default function ConfigPage() {
|
||||
|
||||
{/* 备份与恢复 */}
|
||||
<Card className="mt-6 shadow-md">
|
||||
<CardHeader className="pb-4">
|
||||
<CardHeader className="pb-6">
|
||||
<div className="flex justify-between items-center w-full">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold">数据备份与恢复</h2>
|
||||
@@ -740,7 +738,7 @@ export default function ConfigPage() {
|
||||
|
||||
<Divider />
|
||||
|
||||
<CardBody className="space-y-6 pt-6">
|
||||
<CardBody className="space-y-6 pt-8 md:pt-8">
|
||||
{/* 导出部分 */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-medium">导出数据</h3>
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card";
|
||||
import { Button } from "@/shadcn-bridge/heroui/button";
|
||||
import { Modal, ModalContent, ModalHeader, ModalBody } from "@/shadcn-bridge/heroui/modal";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card";
|
||||
import { Button } from "@/shadcn-bridge/heroui/button";
|
||||
import {
|
||||
Modal,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalBody,
|
||||
} from "@/shadcn-bridge/heroui/modal";
|
||||
import { PageEmptyState, PageLoadingState } from "@/components/page-state";
|
||||
import { AnnouncementBanner } from "@/pages/dashboard/components/announcement-banner";
|
||||
import { FlowChartCard } from "@/pages/dashboard/components/flow-chart-card";
|
||||
|
||||
@@ -16,8 +16,8 @@ export const AnnouncementBanner = ({
|
||||
return (
|
||||
<Card className="mb-4 lg:mb-6 border border-blue-200 dark:border-blue-500/30 bg-gradient-to-r from-blue-50 to-purple-50 dark:from-blue-500/10 dark:to-purple-500/10">
|
||||
<CardBody className="p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="p-2 bg-blue-100 dark:bg-blue-500/20 rounded-lg flex-shrink-0">
|
||||
<div className="flex items-start justify-start gap-3.5">
|
||||
<div className="w-10 h-10 bg-blue-100 dark:bg-blue-500/20 rounded-lg flex-shrink-0 flex items-center justify-center mt-0.5">
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="w-5 h-5 text-blue-600 dark:text-blue-400"
|
||||
@@ -31,11 +31,11 @@ export const AnnouncementBanner = ({
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-sm lg:text-base font-semibold text-blue-900 dark:text-blue-100 mb-1">
|
||||
<div className="flex-1 min-w-0 pt-0.5">
|
||||
<h3 className="text-base font-semibold leading-none text-blue-900 dark:text-blue-100 mb-1.5">
|
||||
公告
|
||||
</h3>
|
||||
<p className="text-xs lg:text-sm text-blue-800 dark:text-blue-200 whitespace-pre-wrap break-words">
|
||||
<p className="text-sm text-blue-800 dark:text-blue-200 whitespace-pre-wrap break-words leading-relaxed">
|
||||
{announcement.content}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card";
|
||||
import {
|
||||
CartesianGrid,
|
||||
Line,
|
||||
@@ -9,6 +8,7 @@ import {
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
|
||||
import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card";
|
||||
import { PageEmptyState } from "@/components/page-state";
|
||||
|
||||
interface FlowChartPoint {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { ForwardApiItem } from "@/api/types";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
@@ -6,7 +8,6 @@ import {
|
||||
getUserPackageInfo,
|
||||
type AnnouncementData,
|
||||
} from "@/api";
|
||||
import type { ForwardApiItem } from "@/api/types";
|
||||
import { getAdminFlag } from "@/utils/session";
|
||||
|
||||
export interface DashboardUserInfo {
|
||||
@@ -180,9 +181,13 @@ export const useDashboardData = (): DashboardDataState => {
|
||||
);
|
||||
const [userTunnels, setUserTunnels] = useState<DashboardUserTunnel[]>([]);
|
||||
const [forwardList, setForwardList] = useState<DashboardForward[]>([]);
|
||||
const [statisticsFlows, setStatisticsFlows] = useState<DashboardStatisticsFlow[]>([]);
|
||||
const [statisticsFlows, setStatisticsFlows] = useState<
|
||||
DashboardStatisticsFlow[]
|
||||
>([]);
|
||||
const [isAdmin, setIsAdmin] = useState(false);
|
||||
const [announcement, setAnnouncement] = useState<AnnouncementData | null>(null);
|
||||
const [announcement, setAnnouncement] = useState<AnnouncementData | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const loadAnnouncement = async () => {
|
||||
@@ -214,7 +219,10 @@ export const useDashboardData = (): DashboardDataState => {
|
||||
setForwardList(normalizedForwards);
|
||||
setStatisticsFlows(data.statisticsFlows || []);
|
||||
|
||||
checkExpirationNotifications(data.userInfo, normalizedTunnelPermissions);
|
||||
checkExpirationNotifications(
|
||||
data.userInfo,
|
||||
normalizedTunnelPermissions,
|
||||
);
|
||||
} else {
|
||||
toast.error(res.msg || "获取套餐信息失败");
|
||||
}
|
||||
|
||||
+466
-246
@@ -1,22 +1,4 @@
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card";
|
||||
import { Button } from "@/shadcn-bridge/heroui/button";
|
||||
import { Input } from "@/shadcn-bridge/heroui/input";
|
||||
import { Textarea } from "@/shadcn-bridge/heroui/input";
|
||||
import { Select, SelectItem } from "@/shadcn-bridge/heroui/select";
|
||||
import {
|
||||
Modal,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalBody,
|
||||
ModalFooter,
|
||||
} from "@/shadcn-bridge/heroui/modal";
|
||||
import { Chip } from "@/shadcn-bridge/heroui/chip";
|
||||
import { Spinner } from "@/shadcn-bridge/heroui/spinner";
|
||||
import { Switch } from "@/shadcn-bridge/heroui/switch";
|
||||
import { Alert } from "@/shadcn-bridge/heroui/alert";
|
||||
import { Accordion, AccordionItem } from "@/shadcn-bridge/heroui/accordion";
|
||||
import { Checkbox } from "@/shadcn-bridge/heroui/checkbox";
|
||||
import toast from "react-hot-toast";
|
||||
import {
|
||||
DndContext,
|
||||
@@ -33,10 +15,36 @@ import {
|
||||
SortableContext,
|
||||
sortableKeyboardCoordinates,
|
||||
rectSortingStrategy,
|
||||
verticalListSortingStrategy,
|
||||
} from "@dnd-kit/sortable";
|
||||
import { useSortable } from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
|
||||
import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card";
|
||||
import { Button } from "@/shadcn-bridge/heroui/button";
|
||||
import { Input } from "@/shadcn-bridge/heroui/input";
|
||||
import { Textarea } from "@/shadcn-bridge/heroui/input";
|
||||
import { Select, SelectItem } from "@/shadcn-bridge/heroui/select";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableColumn,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/shadcn-bridge/heroui/table";
|
||||
import {
|
||||
Modal,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalBody,
|
||||
ModalFooter,
|
||||
} from "@/shadcn-bridge/heroui/modal";
|
||||
import { Chip } from "@/shadcn-bridge/heroui/chip";
|
||||
import { Spinner } from "@/shadcn-bridge/heroui/spinner";
|
||||
import { Switch } from "@/shadcn-bridge/heroui/switch";
|
||||
import { Alert } from "@/shadcn-bridge/heroui/alert";
|
||||
import { Checkbox } from "@/shadcn-bridge/heroui/checkbox";
|
||||
import {
|
||||
createForward,
|
||||
getForwardList,
|
||||
@@ -136,16 +144,21 @@ export default function ForwardPage() {
|
||||
const savedMode = localStorage.getItem("forward-view-mode");
|
||||
|
||||
return (savedMode as "grouped" | "direct") || "direct";
|
||||
} catch {
|
||||
} catch (e) {
|
||||
return "direct";
|
||||
}
|
||||
});
|
||||
|
||||
// 筛选状态
|
||||
const [filterUserId, setFilterUserId] = useState<string>("all");
|
||||
const [filterTunnelId, setFilterTunnelId] = useState<string>("all");
|
||||
|
||||
// 拖拽排序相关状态
|
||||
const [forwardOrder, setForwardOrder] = useState<number[]>([]);
|
||||
|
||||
// 模态框状态
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [isFilterModalOpen, setIsFilterModalOpen] = useState(false);
|
||||
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
|
||||
const [addressModalOpen, setAddressModalOpen] = useState(false);
|
||||
const [diagnosisModalOpen, setDiagnosisModalOpen] = useState(false);
|
||||
@@ -235,7 +248,7 @@ export default function ForwardPage() {
|
||||
saveOrder(FORWARD_ORDER_KEY, order);
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
} catch { }
|
||||
};
|
||||
|
||||
// 加载所有数据
|
||||
@@ -1177,6 +1190,15 @@ export default function ForwardPage() {
|
||||
(forward) => forward.userId === tokenUserId,
|
||||
);
|
||||
}
|
||||
} else if (viewMode === "grouped") {
|
||||
if (filterUserId !== "all") {
|
||||
const targetUserId = parseInt(filterUserId);
|
||||
filteredForwards = filteredForwards.filter(f => f.userId === targetUserId || (targetUserId === 0 && !f.userId));
|
||||
}
|
||||
if (filterTunnelId !== "all") {
|
||||
const targetTunnelId = parseInt(filterTunnelId);
|
||||
filteredForwards = filteredForwards.filter(f => f.tunnelId === targetTunnelId);
|
||||
}
|
||||
}
|
||||
|
||||
// 确保过滤后的转发列表有效
|
||||
@@ -1220,7 +1242,7 @@ export default function ForwardPage() {
|
||||
}
|
||||
|
||||
return sortedByDb;
|
||||
}, [forwards, forwardOrder, viewMode, tokenUserId]);
|
||||
}, [forwards, forwardOrder, viewMode, tokenUserId, filterUserId, filterTunnelId]);
|
||||
|
||||
const sortableForwardIds = useMemo(
|
||||
() => sortedForwards.map((f) => f.id).filter((id) => id > 0),
|
||||
@@ -1252,6 +1274,170 @@ export default function ForwardPage() {
|
||||
);
|
||||
};
|
||||
|
||||
// 生成用作筛选项的用户和隧道列表
|
||||
const uniqueUsers = useMemo(() => {
|
||||
const userMap = new Map<number, { id: number; name: string }>();
|
||||
forwards.forEach((f) => {
|
||||
const uId = f.userId || 0;
|
||||
if (!userMap.has(uId)) {
|
||||
userMap.set(uId, { id: uId, name: f.userName || "未知用户" });
|
||||
}
|
||||
});
|
||||
|
||||
return Array.from(userMap.values());
|
||||
}, [forwards]);
|
||||
|
||||
// 可拖拽的表格行组件
|
||||
const SortableTableRow = ({ forward, selectMode, selectedIds, toggleSelect, getStrategyDisplay, formatInAddress, formatRemoteAddress, handleServiceToggle, handleEdit, handleDelete, handleDiagnose, showAddressModal, hasMultipleAddresses }: any) => {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id: forward.id });
|
||||
|
||||
const style = {
|
||||
transform: transform ? CSS.Transform.toString(transform) : undefined,
|
||||
transition: isDragging ? undefined : transition || undefined,
|
||||
opacity: isDragging ? 0.5 : 1,
|
||||
backgroundColor: isDragging ? "var(--nextui-default-100)" : undefined,
|
||||
};
|
||||
|
||||
const strategyDisplay = getStrategyDisplay(forward.strategy);
|
||||
|
||||
return (
|
||||
<TableRow ref={setNodeRef} style={style} key={forward.id}>
|
||||
{selectMode && (
|
||||
<TableCell>
|
||||
<Checkbox
|
||||
isSelected={selectedIds.has(forward.id)}
|
||||
onValueChange={() => toggleSelect(forward.id)}
|
||||
/>
|
||||
</TableCell>
|
||||
)}
|
||||
<TableCell>
|
||||
<div
|
||||
className="cursor-grab active:cursor-grabbing p-1 text-default-400 hover:text-default-600 transition-colors"
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
title="拖拽排序"
|
||||
>
|
||||
<svg aria-hidden="true" className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M7 2a2 2 0 1 1 .001 4.001A2 2 0 0 1 7 2zm0 6a2 2 0 1 1 .001 4.001A2 2 0 0 1 7 8zm0 6a2 2 0 1 1 .001 4.001A2 2 0 0 1 7 14zm6-8a2 2 0 1 1-.001-4.001A2 2 0 0 1 13 6zm0 2a2 2 0 1 1 .001 4.001A2 2 0 0 1 13 8zm0 6a2 2 0 1 1 .001 4.001A2 2 0 0 1 13 14z" />
|
||||
</svg>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="whitespace-nowrap">
|
||||
<span className="text-sm font-medium text-default-700">
|
||||
{forward.userName || "未知用户"}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="whitespace-nowrap">
|
||||
<Chip
|
||||
size="sm"
|
||||
color="secondary"
|
||||
className="border-none bg-secondary/10 px-2"
|
||||
>
|
||||
<span className="font-medium text-secondary-700">{forward.tunnelName}</span>
|
||||
</Chip>
|
||||
</TableCell>
|
||||
<TableCell className="whitespace-nowrap font-semibold text-foreground">
|
||||
{forward.name}
|
||||
</TableCell>
|
||||
<TableCell className="max-w-[220px]">
|
||||
<button
|
||||
className={`w-full truncate rounded-md bg-default-100/50 px-2.5 py-1.5 text-left font-mono text-xs font-medium text-default-700 transition-all ${hasMultipleAddresses(forward.inIp)
|
||||
? "hover:bg-default-200 hover:shadow-sm"
|
||||
: ""
|
||||
}`}
|
||||
title={formatInAddress(forward.inIp, forward.inPort)}
|
||||
type="button"
|
||||
onClick={() =>
|
||||
showAddressModal(forward.inIp, forward.inPort, "入口端口")
|
||||
}
|
||||
>
|
||||
{formatInAddress(forward.inIp, forward.inPort)}
|
||||
</button>
|
||||
</TableCell>
|
||||
<TableCell className="max-w-[240px]">
|
||||
<button
|
||||
className={`w-full truncate rounded-md bg-default-100/50 px-2.5 py-1.5 text-left font-mono text-xs font-medium text-default-700 transition-all ${hasMultipleAddresses(forward.remoteAddr)
|
||||
? "hover:bg-default-200 hover:shadow-sm"
|
||||
: ""
|
||||
}`}
|
||||
title={formatRemoteAddress(forward.remoteAddr)}
|
||||
type="button"
|
||||
onClick={() =>
|
||||
showAddressModal(forward.remoteAddr, null, "目标地址")
|
||||
}
|
||||
>
|
||||
{formatRemoteAddress(forward.remoteAddr)}
|
||||
</button>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Chip
|
||||
className="text-xs font-medium"
|
||||
color={strategyDisplay.color as any}
|
||||
size="sm"
|
||||
variant="flat"
|
||||
>
|
||||
{strategyDisplay.text}
|
||||
</Chip>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2.5 whitespace-nowrap">
|
||||
<Switch
|
||||
isDisabled={forward.status !== 1 && forward.status !== 0}
|
||||
isSelected={forward.serviceRunning}
|
||||
size="sm"
|
||||
color="success"
|
||||
onValueChange={() => handleServiceToggle(forward)}
|
||||
/>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
isIconOnly
|
||||
className="bg-primary/10 text-primary hover:bg-primary/20"
|
||||
size="sm"
|
||||
title="编辑"
|
||||
onPress={() => handleEdit(forward)}
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
</svg>
|
||||
</Button>
|
||||
<Button
|
||||
isIconOnly
|
||||
className="bg-warning/10 text-warning hover:bg-warning/20"
|
||||
size="sm"
|
||||
title="诊断"
|
||||
onPress={() => handleDiagnose(forward)}
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4" />
|
||||
</svg>
|
||||
</Button>
|
||||
<Button
|
||||
isIconOnly
|
||||
className="bg-danger/10 text-danger hover:bg-danger/20"
|
||||
size="sm"
|
||||
title="删除"
|
||||
onPress={() => handleDelete(forward)}
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
};
|
||||
|
||||
// 渲染转发卡片
|
||||
const renderForwardCard = (forward: Forward, listeners?: any) => {
|
||||
const statusDisplay = getStatusDisplay(forward.status);
|
||||
@@ -1282,11 +1468,10 @@ export default function ForwardPage() {
|
||||
<div className="flex items-center gap-1.5 ml-2">
|
||||
{viewMode === "direct" && (
|
||||
<div
|
||||
className={`cursor-grab active:cursor-grabbing p-2 text-default-400 hover:text-default-600 transition-colors touch-manipulation ${
|
||||
isMobile
|
||||
? "opacity-100" // 移动端始终显示
|
||||
: "opacity-0 group-hover:opacity-100 sm:opacity-0 sm:group-hover:opacity-100"
|
||||
}`}
|
||||
className={`cursor-grab active:cursor-grabbing p-2 text-default-400 hover:text-default-600 transition-colors touch-manipulation ${isMobile
|
||||
? "opacity-100" // 移动端始终显示
|
||||
: "opacity-0 group-hover:opacity-100 sm:opacity-0 sm:group-hover:opacity-100"
|
||||
}`}
|
||||
{...listeners}
|
||||
style={{ touchAction: "none" }}
|
||||
title={isMobile ? "长按拖拽排序" : "拖拽排序"}
|
||||
@@ -1324,11 +1509,10 @@ export default function ForwardPage() {
|
||||
{/* 地址信息 */}
|
||||
<div className="space-y-1">
|
||||
<button
|
||||
className={`cursor-pointer px-2 py-1 bg-default-50 dark:bg-default-100/50 rounded border border-default-200 dark:border-default-300 transition-colors duration-200 ${
|
||||
hasMultipleAddresses(forward.inIp)
|
||||
? "hover:bg-default-100 dark:hover:bg-default-200/50"
|
||||
: ""
|
||||
}`}
|
||||
className={`cursor-pointer px-2 py-1 bg-default-50 dark:bg-default-100/50 rounded border border-default-200 dark:border-default-300 transition-colors duration-200 ${hasMultipleAddresses(forward.inIp)
|
||||
? "hover:bg-default-100 dark:hover:bg-default-200/50"
|
||||
: ""
|
||||
}`}
|
||||
title={formatInAddress(forward.inIp, forward.inPort)}
|
||||
type="button"
|
||||
onClick={() =>
|
||||
@@ -1364,11 +1548,10 @@ export default function ForwardPage() {
|
||||
</button>
|
||||
|
||||
<button
|
||||
className={`cursor-pointer px-2 py-1 bg-default-50 dark:bg-default-100/50 rounded border border-default-200 dark:border-default-300 transition-colors duration-200 ${
|
||||
hasMultipleAddresses(forward.remoteAddr)
|
||||
? "hover:bg-default-100 dark:hover:bg-default-200/50"
|
||||
: ""
|
||||
}`}
|
||||
className={`cursor-pointer px-2 py-1 bg-default-50 dark:bg-default-100/50 rounded border border-default-200 dark:border-default-300 transition-colors duration-200 ${hasMultipleAddresses(forward.remoteAddr)
|
||||
? "hover:bg-default-100 dark:hover:bg-default-200/50"
|
||||
: ""
|
||||
}`}
|
||||
title={formatRemoteAddress(forward.remoteAddr)}
|
||||
type="button"
|
||||
onClick={() =>
|
||||
@@ -1516,14 +1699,43 @@ export default function ForwardPage() {
|
||||
return <PageLoadingState message="正在加载..." />;
|
||||
}
|
||||
|
||||
const userGroups = groupForwardsByUserAndTunnel();
|
||||
|
||||
return (
|
||||
<div className="px-3 lg:px-6 py-8">
|
||||
{/* 页面头部 */}
|
||||
<div className="flex items-center justify-between mb-6 gap-2">
|
||||
<div className="flex-1" />
|
||||
<div className="flex-1"></div>
|
||||
<div className="flex flex-wrap items-center justify-end gap-2 sm:gap-3">
|
||||
{/* 筛选按钮 (仅在分组视图显示) */}
|
||||
{viewMode === "grouped" && (
|
||||
<Button
|
||||
isIconOnly
|
||||
aria-label="筛选条件"
|
||||
className={filterUserId !== "all" || filterTunnelId !== "all" ? "bg-primary/20 text-primary relative" : "text-default-600 relative"}
|
||||
color={filterUserId !== "all" || filterTunnelId !== "all" ? "primary" : "default"}
|
||||
size="sm"
|
||||
title="筛选条件"
|
||||
variant="flat"
|
||||
onPress={() => setIsFilterModalOpen(true)}
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z"
|
||||
/>
|
||||
</svg>
|
||||
{(filterUserId !== "all" || filterTunnelId !== "all") && (
|
||||
<span className="absolute top-1.5 right-1.5 flex h-1.5 w-1.5 rounded-full bg-primary" />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
{/* 显示模式切换按钮 */}
|
||||
<Button
|
||||
isIconOnly
|
||||
@@ -1584,7 +1796,7 @@ export default function ForwardPage() {
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
color={selectMode ? "warning" : "primary"}
|
||||
color={selectMode ? "secondary" : "default"}
|
||||
size="sm"
|
||||
variant={selectMode ? "solid" : "flat"}
|
||||
onPress={toggleSelectMode}
|
||||
@@ -1604,10 +1816,20 @@ export default function ForwardPage() {
|
||||
<span className="text-sm text-default-600 shrink-0">
|
||||
已选择 {selectedIds.size} 项
|
||||
</span>
|
||||
<Button color="primary" size="sm" variant="flat" onPress={selectAll}>
|
||||
<Button
|
||||
color="primary"
|
||||
size="sm"
|
||||
variant="flat"
|
||||
onPress={selectAll}
|
||||
>
|
||||
全选
|
||||
</Button>
|
||||
<Button color="secondary" size="sm" variant="flat" onPress={deselectAll}>
|
||||
<Button
|
||||
color="secondary"
|
||||
size="sm"
|
||||
variant="flat"
|
||||
onPress={deselectAll}
|
||||
>
|
||||
清空
|
||||
</Button>
|
||||
<Button
|
||||
@@ -1659,116 +1881,56 @@ export default function ForwardPage() {
|
||||
|
||||
{/* 根据显示模式渲染不同内容 */}
|
||||
{viewMode === "grouped" ? (
|
||||
/* 按用户和隧道分组的转发列表 */
|
||||
userGroups.length > 0 ? (
|
||||
<div className="space-y-6">
|
||||
{userGroups.map((userGroup) => (
|
||||
<Card
|
||||
key={userGroup.userId || "unknown"}
|
||||
className="shadow-sm border border-divider w-full overflow-hidden"
|
||||
sortedForwards.length > 0 ? (
|
||||
<div className="overflow-hidden rounded-xl border border-divider bg-content1 shadow-md">
|
||||
<DndContext
|
||||
collisionDetection={closestCenter}
|
||||
sensors={sensors}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<Table
|
||||
aria-label="全部转发列表"
|
||||
classNames={{
|
||||
th: "bg-default-100/50 text-default-600 font-semibold text-sm border-b border-divider py-3 uppercase tracking-wider",
|
||||
td: "py-3 border-b border-divider/50 group-data-[last=true]:border-b-0",
|
||||
tr: "hover:bg-default-50/50 transition-colors",
|
||||
}}
|
||||
>
|
||||
<CardHeader className="pb-2 md:pb-2">
|
||||
<div className="flex items-center justify-between w-full min-w-0">
|
||||
<div className="flex items-center gap-3 min-w-0 flex-1">
|
||||
<div className="w-10 h-10 bg-primary-100 dark:bg-primary-900/30 rounded-full flex items-center justify-center flex-shrink-0">
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="w-5 h-5 text-primary"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
clipRule="evenodd"
|
||||
d="M10 9a3 3 0 100-6 3 3 0 000 6zm-7 9a7 7 0 1114 0H3z"
|
||||
fillRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h2 className="text-base font-medium text-foreground truncate max-w-[150px] sm:max-w-[250px] md:max-w-[350px] lg:max-w-[450px]">
|
||||
{userGroup.userName}
|
||||
</h2>
|
||||
<p className="text-xs text-default-500 truncate max-w-[150px] sm:max-w-[250px] md:max-w-[350px] lg:max-w-[450px]">
|
||||
{userGroup.tunnelGroups.length} 个隧道,
|
||||
{userGroup.tunnelGroups.reduce(
|
||||
(total, tg) => total + tg.forwards.length,
|
||||
0,
|
||||
)}{" "}
|
||||
个转发
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Chip
|
||||
className="text-xs flex-shrink-0 ml-2"
|
||||
color="primary"
|
||||
size="sm"
|
||||
variant="flat"
|
||||
>
|
||||
用户
|
||||
</Chip>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardBody className="pt-0 md:pt-0">
|
||||
<Accordion className="px-0" variant="splitted">
|
||||
{userGroup.tunnelGroups.map((tunnelGroup) => (
|
||||
<AccordionItem
|
||||
key={tunnelGroup.tunnelId}
|
||||
aria-label={tunnelGroup.tunnelName}
|
||||
className="shadow-none border border-divider"
|
||||
title={
|
||||
<div className="flex items-center justify-between w-full min-w-0 pr-4">
|
||||
<div className="flex items-center gap-3 min-w-0 flex-1">
|
||||
<div className="w-8 h-8 bg-success-100 dark:bg-success-900/30 rounded-lg flex items-center justify-center flex-shrink-0">
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="w-4 h-4 text-success"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
d="M13 10V3L4 14h7v7l9-11h-7z"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="text-sm font-medium text-foreground truncate max-w-[120px] sm:max-w-[200px] md:max-w-[300px] lg:max-w-[400px]">
|
||||
{tunnelGroup.tunnelName}
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-shrink-0 ml-2">
|
||||
<Chip
|
||||
className="text-xs"
|
||||
size="sm"
|
||||
variant="flat"
|
||||
>
|
||||
{
|
||||
tunnelGroup.forwards.filter(
|
||||
(f) => f.serviceRunning,
|
||||
).length
|
||||
}
|
||||
/{tunnelGroup.forwards.length}
|
||||
</Chip>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4 p-4">
|
||||
{tunnelGroup.forwards.map((forward) =>
|
||||
renderForwardCard(forward, undefined),
|
||||
)}
|
||||
</div>
|
||||
</AccordionItem>
|
||||
))}
|
||||
</Accordion>
|
||||
</CardBody>
|
||||
</Card>
|
||||
))}
|
||||
<TableHeader>
|
||||
{selectMode && <TableColumn className="w-14">选择</TableColumn>}
|
||||
<TableColumn className="w-10 pl-4"></TableColumn>
|
||||
<TableColumn>用户</TableColumn>
|
||||
<TableColumn>隧道</TableColumn>
|
||||
<TableColumn>名称</TableColumn>
|
||||
<TableColumn>入口</TableColumn>
|
||||
<TableColumn>目标</TableColumn>
|
||||
<TableColumn>策略</TableColumn>
|
||||
<TableColumn>状态</TableColumn>
|
||||
<TableColumn className="text-right">操作</TableColumn>
|
||||
</TableHeader>
|
||||
<TableBody emptyContent="暂无转发配置" items={sortedForwards}>
|
||||
{(forward) => (
|
||||
<SortableContext items={sortableForwardIds} strategy={verticalListSortingStrategy} key={forward.id}>
|
||||
<SortableTableRow
|
||||
forward={forward}
|
||||
selectMode={selectMode}
|
||||
selectedIds={selectedIds}
|
||||
toggleSelect={toggleSelect}
|
||||
getStrategyDisplay={getStrategyDisplay}
|
||||
formatInAddress={formatInAddress}
|
||||
formatRemoteAddress={formatRemoteAddress}
|
||||
handleServiceToggle={handleServiceToggle}
|
||||
handleEdit={handleEdit}
|
||||
handleDelete={handleDelete}
|
||||
handleDiagnose={handleDiagnose}
|
||||
showAddressModal={showAddressModal}
|
||||
hasMultipleAddresses={hasMultipleAddresses}
|
||||
/>
|
||||
</SortableContext>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</DndContext>
|
||||
</div>
|
||||
) : (
|
||||
/* 空状态 */
|
||||
@@ -1804,59 +1966,59 @@ export default function ForwardPage() {
|
||||
</Card>
|
||||
)
|
||||
) : /* 直接显示模式 */
|
||||
forwards.length > 0 ? (
|
||||
<DndContext
|
||||
collisionDetection={closestCenter}
|
||||
sensors={sensors}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragStart={() => {}} // 添加空的 onDragStart 处理器
|
||||
>
|
||||
<SortableContext
|
||||
items={sortableForwardIds}
|
||||
strategy={rectSortingStrategy}
|
||||
forwards.length > 0 ? (
|
||||
<DndContext
|
||||
collisionDetection={closestCenter}
|
||||
sensors={sensors}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragStart={() => { }} // 添加空的 onDragStart 处理器
|
||||
>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4">
|
||||
{sortedForwards.map((forward) =>
|
||||
forward && forward.id ? (
|
||||
<SortableForwardCard key={forward.id} forward={forward} />
|
||||
) : null,
|
||||
)}
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
) : (
|
||||
/* 空状态 */
|
||||
<Card className="shadow-sm border border-gray-200 dark:border-gray-700">
|
||||
<CardBody className="text-center py-16">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div className="w-16 h-16 bg-default-100 rounded-full flex items-center justify-center">
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="w-8 h-8 text-default-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
d="M8 9l4-4 4 4m0 6l-4 4-4-4"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
</svg>
|
||||
<SortableContext
|
||||
items={sortableForwardIds}
|
||||
strategy={rectSortingStrategy}
|
||||
>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4">
|
||||
{sortedForwards.map((forward) =>
|
||||
forward && forward.id ? (
|
||||
<SortableForwardCard key={forward.id} forward={forward} />
|
||||
) : null,
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-foreground">
|
||||
暂无转发配置
|
||||
</h3>
|
||||
<p className="text-default-500 text-sm mt-1">
|
||||
还没有创建任何转发配置,点击上方按钮开始创建
|
||||
</p>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
) : (
|
||||
/* 空状态 */
|
||||
<Card className="shadow-sm border border-gray-200 dark:border-gray-700">
|
||||
<CardBody className="text-center py-16">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div className="w-16 h-16 bg-default-100 rounded-full flex items-center justify-center">
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="w-8 h-8 text-default-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
d="M8 9l4-4 4 4m0 6l-4 4-4-4"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-foreground">
|
||||
暂无转发配置
|
||||
</h3>
|
||||
<p className="text-default-500 text-sm mt-1">
|
||||
还没有创建任何转发配置,点击上方按钮开始创建
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 新增/编辑模态框 */}
|
||||
<Modal
|
||||
@@ -2325,11 +2487,10 @@ export default function ForwardPage() {
|
||||
{importResults.map((result, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`p-2 rounded border ${
|
||||
result.success
|
||||
? "bg-success-50 dark:bg-success-100/10 border-success-200 dark:border-success-300/20"
|
||||
: "bg-danger-50 dark:bg-danger-100/10 border-danger-200 dark:border-danger-300/20"
|
||||
}`}
|
||||
className={`p-2 rounded border ${result.success
|
||||
? "bg-success-50 dark:bg-success-100/10 border-success-200 dark:border-success-300/20"
|
||||
: "bg-danger-50 dark:bg-danger-100/10 border-danger-200 dark:border-danger-300/20"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{result.success ? (
|
||||
@@ -2362,11 +2523,10 @@ export default function ForwardPage() {
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-0.5">
|
||||
<span
|
||||
className={`text-xs font-medium ${
|
||||
result.success
|
||||
? "text-success-700 dark:text-success-300"
|
||||
: "text-danger-700 dark:text-danger-300"
|
||||
}`}
|
||||
className={`text-xs font-medium ${result.success
|
||||
? "text-success-700 dark:text-success-300"
|
||||
: "text-danger-700 dark:text-danger-300"
|
||||
}`}
|
||||
>
|
||||
{result.success ? "成功" : "失败"}
|
||||
</span>
|
||||
@@ -2378,11 +2538,10 @@ export default function ForwardPage() {
|
||||
</code>
|
||||
</div>
|
||||
<div
|
||||
className={`text-xs ${
|
||||
result.success
|
||||
? "text-success-600 dark:text-success-400"
|
||||
: "text-danger-600 dark:text-danger-400"
|
||||
}`}
|
||||
className={`text-xs ${result.success
|
||||
? "text-success-600 dark:text-success-400"
|
||||
: "text-danger-600 dark:text-danger-400"
|
||||
}`}
|
||||
>
|
||||
{result.message}
|
||||
</div>
|
||||
@@ -2565,20 +2724,18 @@ export default function ForwardPage() {
|
||||
return (
|
||||
<tr
|
||||
key={index}
|
||||
className={`hover:bg-default-50 dark:hover:bg-gray-700/50 ${
|
||||
result.success
|
||||
? "bg-white dark:bg-gray-800"
|
||||
: "bg-danger-50 dark:bg-danger-900/30"
|
||||
}`}
|
||||
className={`hover:bg-default-50 dark:hover:bg-gray-700/50 ${result.success
|
||||
? "bg-white dark:bg-gray-800"
|
||||
: "bg-danger-50 dark:bg-danger-900/30"
|
||||
}`}
|
||||
>
|
||||
<td className="px-3 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={`w-5 h-5 rounded-full flex items-center justify-center text-xs ${
|
||||
result.success
|
||||
? "bg-success text-white"
|
||||
: "bg-danger text-white"
|
||||
}`}
|
||||
className={`w-5 h-5 rounded-full flex items-center justify-center text-xs ${result.success
|
||||
? "bg-success text-white"
|
||||
: "bg-danger text-white"
|
||||
}`}
|
||||
>
|
||||
{result.success ? "✓" : "✗"}
|
||||
</span>
|
||||
@@ -2620,11 +2777,10 @@ export default function ForwardPage() {
|
||||
<td className="px-3 py-2 text-center">
|
||||
{result.success ? (
|
||||
<span
|
||||
className={`font-semibold ${
|
||||
(result.packetLoss || 0) > 0
|
||||
? "text-warning"
|
||||
: "text-success"
|
||||
}`}
|
||||
className={`font-semibold ${(result.packetLoss || 0) > 0
|
||||
? "text-warning"
|
||||
: "text-success"
|
||||
}`}
|
||||
>
|
||||
{result.packetLoss?.toFixed(1)}%
|
||||
</span>
|
||||
@@ -2738,19 +2894,17 @@ export default function ForwardPage() {
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={`border rounded-lg p-3 ${
|
||||
result.success
|
||||
? "border-divider bg-white dark:bg-gray-800"
|
||||
: "border-danger-200 dark:border-danger-300/30 bg-danger-50 dark:bg-danger-900/30"
|
||||
}`}
|
||||
className={`border rounded-lg p-3 ${result.success
|
||||
? "border-divider bg-white dark:bg-gray-800"
|
||||
: "border-danger-200 dark:border-danger-300/30 bg-danger-50 dark:bg-danger-900/30"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-2 mb-2">
|
||||
<span
|
||||
className={`w-6 h-6 rounded-full flex items-center justify-center text-xs flex-shrink-0 ${
|
||||
result.success
|
||||
? "bg-success text-white"
|
||||
: "bg-danger text-white"
|
||||
}`}
|
||||
className={`w-6 h-6 rounded-full flex items-center justify-center text-xs flex-shrink-0 ${result.success
|
||||
? "bg-success text-white"
|
||||
: "bg-danger text-white"
|
||||
}`}
|
||||
>
|
||||
{result.success ? "✓" : "✗"}
|
||||
</span>
|
||||
@@ -2786,11 +2940,10 @@ export default function ForwardPage() {
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div
|
||||
className={`text-lg font-bold ${
|
||||
(result.packetLoss || 0) > 0
|
||||
? "text-warning"
|
||||
: "text-success"
|
||||
}`}
|
||||
className={`text-lg font-bold ${(result.packetLoss || 0) > 0
|
||||
? "text-warning"
|
||||
: "text-success"
|
||||
}`}
|
||||
>
|
||||
{result.packetLoss?.toFixed(1)}%
|
||||
</div>
|
||||
@@ -3005,6 +3158,73 @@ export default function ForwardPage() {
|
||||
)}
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
|
||||
{/* 筛选模态框 */}
|
||||
<Modal
|
||||
isOpen={isFilterModalOpen}
|
||||
onOpenChange={setIsFilterModalOpen}
|
||||
placement="center"
|
||||
size="md"
|
||||
>
|
||||
<ModalContent>
|
||||
{(onClose) => (
|
||||
<>
|
||||
<ModalHeader className="flex flex-col gap-1">筛选条件</ModalHeader>
|
||||
<ModalBody>
|
||||
<div className="flex flex-col gap-4 py-2">
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-medium">按用户筛选</label>
|
||||
<Select
|
||||
aria-label="筛选用户"
|
||||
className="w-full"
|
||||
variant="bordered"
|
||||
selectedKeys={[filterUserId]}
|
||||
onSelectionChange={(keys) => {
|
||||
const key = Array.from(keys)[0] as string;
|
||||
setFilterUserId(key || "all");
|
||||
}}
|
||||
>
|
||||
<SelectItem key="all">全部用户</SelectItem>
|
||||
{uniqueUsers.map(user => (
|
||||
<SelectItem key={user.id.toString()}>{user.name}</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-medium">按隧道筛选</label>
|
||||
<Select
|
||||
aria-label="筛选隧道"
|
||||
className="w-full"
|
||||
variant="bordered"
|
||||
selectedKeys={[filterTunnelId]}
|
||||
onSelectionChange={(keys) => {
|
||||
const key = Array.from(keys)[0] as string;
|
||||
setFilterTunnelId(key || "all");
|
||||
}}
|
||||
>
|
||||
<SelectItem key="all">全部隧道</SelectItem>
|
||||
{tunnels.map(tunnel => (
|
||||
<SelectItem key={tunnel.id.toString()}>{tunnel.name}</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button color="default" variant="flat" onPress={() => {
|
||||
setFilterUserId("all");
|
||||
setFilterTunnelId("all");
|
||||
}}>
|
||||
重置
|
||||
</Button>
|
||||
<Button color="primary" onPress={onClose}>
|
||||
完成
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</>
|
||||
)}
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card";
|
||||
import { Button } from "@/shadcn-bridge/heroui/button";
|
||||
import { Input } from "@/shadcn-bridge/heroui/input";
|
||||
@@ -21,8 +23,6 @@ import {
|
||||
} from "@/shadcn-bridge/heroui/table";
|
||||
import { Chip } from "@/shadcn-bridge/heroui/chip";
|
||||
import { Spinner } from "@/shadcn-bridge/heroui/spinner";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import {
|
||||
assignGroupPermission,
|
||||
assignTunnelsToGroup,
|
||||
@@ -173,6 +173,22 @@ export default function GroupPage() {
|
||||
return map;
|
||||
}, [users]);
|
||||
|
||||
const selectedTunnelSummary = useMemo(() => {
|
||||
const value = Array.from(selectedTunnelKeys)
|
||||
.map((id) => tunnelNameMap.get(Number(id)) || id)
|
||||
.join("、");
|
||||
|
||||
return value || "无";
|
||||
}, [selectedTunnelKeys, tunnelNameMap]);
|
||||
|
||||
const selectedUserSummary = useMemo(() => {
|
||||
const value = Array.from(selectedUserKeys)
|
||||
.map((id) => userNameMap.get(Number(id)) || id)
|
||||
.join("、");
|
||||
|
||||
return value || "无";
|
||||
}, [selectedUserKeys, userNameMap]);
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
@@ -629,9 +645,10 @@ export default function GroupPage() {
|
||||
{(item) => <SelectItem key={item.id}>{item.name}</SelectItem>}
|
||||
</Select>
|
||||
<Button
|
||||
className="md:self-end"
|
||||
className="md:self-end md:justify-self-start whitespace-nowrap px-4"
|
||||
color="primary"
|
||||
isLoading={savingPermission}
|
||||
size="sm"
|
||||
onPress={handleAssignPermission}
|
||||
>
|
||||
分配权限
|
||||
@@ -765,8 +782,10 @@ export default function GroupPage() {
|
||||
>
|
||||
<ModalContent>
|
||||
<ModalHeader>分配隧道 - {assignTunnelGroup?.name}</ModalHeader>
|
||||
<ModalBody>
|
||||
<ModalBody className="min-w-0">
|
||||
<Select
|
||||
className="min-w-0"
|
||||
classNames={{ trigger: "max-w-full" }}
|
||||
items={tunnels}
|
||||
label="选择隧道"
|
||||
selectedKeys={selectedTunnelKeys}
|
||||
@@ -779,11 +798,11 @@ export default function GroupPage() {
|
||||
>
|
||||
{(item) => <SelectItem key={item.id}>{item.name}</SelectItem>}
|
||||
</Select>
|
||||
<p className="text-xs text-default-500 break-all whitespace-normal">
|
||||
当前已选:
|
||||
{Array.from(selectedTunnelKeys)
|
||||
.map((id) => tunnelNameMap.get(Number(id)) || id)
|
||||
.join("、") || "无"}
|
||||
<p
|
||||
className="w-full min-w-0 max-w-full text-xs text-default-500 truncate"
|
||||
title={`当前已选:${selectedTunnelSummary}`}
|
||||
>
|
||||
当前已选:{selectedTunnelSummary}
|
||||
</p>
|
||||
<p className="text-xs text-default-500">
|
||||
不选择任何隧道并保存将清空该分组成员。
|
||||
@@ -810,8 +829,10 @@ export default function GroupPage() {
|
||||
>
|
||||
<ModalContent>
|
||||
<ModalHeader>分配用户 - {assignUserGroup?.name}</ModalHeader>
|
||||
<ModalBody>
|
||||
<ModalBody className="min-w-0">
|
||||
<Select
|
||||
className="min-w-0"
|
||||
classNames={{ trigger: "max-w-full" }}
|
||||
items={users}
|
||||
label="选择用户"
|
||||
selectedKeys={selectedUserKeys}
|
||||
@@ -824,11 +845,11 @@ export default function GroupPage() {
|
||||
>
|
||||
{(item) => <SelectItem key={item.id}>{item.user}</SelectItem>}
|
||||
</Select>
|
||||
<p className="text-xs text-default-500 break-all whitespace-normal">
|
||||
当前已选:
|
||||
{Array.from(selectedUserKeys)
|
||||
.map((id) => userNameMap.get(Number(id)) || id)
|
||||
.join("、") || "无"}
|
||||
<p
|
||||
className="w-full min-w-0 max-w-full text-xs text-default-500 truncate"
|
||||
title={`当前已选:${selectedUserSummary}`}
|
||||
>
|
||||
当前已选:{selectedUserSummary}
|
||||
</p>
|
||||
<p className="text-xs text-default-500">
|
||||
不选择任何用户并保存将清空该分组成员。
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { Button } from "@/shadcn-bridge/heroui/button";
|
||||
import { Input } from "@/shadcn-bridge/heroui/input";
|
||||
import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card";
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import toast from "react-hot-toast";
|
||||
import { Turnstile } from "@marsidev/react-turnstile";
|
||||
|
||||
import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card";
|
||||
import { Input } from "@/shadcn-bridge/heroui/input";
|
||||
import { Button } from "@/shadcn-bridge/heroui/button";
|
||||
import { siteConfig } from "@/config/site";
|
||||
import { title } from "@/components/primitives";
|
||||
import DefaultLayout from "@/layouts/default";
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card";
|
||||
import { Button } from "@/shadcn-bridge/heroui/button";
|
||||
import { Input } from "@/shadcn-bridge/heroui/input";
|
||||
@@ -11,8 +13,6 @@ import {
|
||||
ModalFooter,
|
||||
} from "@/shadcn-bridge/heroui/modal";
|
||||
import { Chip } from "@/shadcn-bridge/heroui/chip";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import {
|
||||
createSpeedLimit,
|
||||
getSpeedLimitList,
|
||||
|
||||
@@ -1,23 +1,4 @@
|
||||
import { useState, useEffect, useMemo, useCallback } from "react";
|
||||
import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card";
|
||||
import { Button } from "@/shadcn-bridge/heroui/button";
|
||||
import { Input } from "@/shadcn-bridge/heroui/input";
|
||||
import { Textarea } from "@/shadcn-bridge/heroui/input";
|
||||
import {
|
||||
Modal,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalBody,
|
||||
ModalFooter,
|
||||
} from "@/shadcn-bridge/heroui/modal";
|
||||
import { Chip } from "@/shadcn-bridge/heroui/chip";
|
||||
import { Switch } from "@/shadcn-bridge/heroui/switch";
|
||||
import { Spinner } from "@/shadcn-bridge/heroui/spinner";
|
||||
import { Alert } from "@/shadcn-bridge/heroui/alert";
|
||||
import { Progress } from "@/shadcn-bridge/heroui/progress";
|
||||
import { Accordion, AccordionItem } from "@/shadcn-bridge/heroui/accordion";
|
||||
import { Select, SelectItem } from "@/shadcn-bridge/heroui/select";
|
||||
import { Checkbox } from "@/shadcn-bridge/heroui/checkbox";
|
||||
import toast from "react-hot-toast";
|
||||
import {
|
||||
DndContext,
|
||||
@@ -37,6 +18,25 @@ import {
|
||||
} from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
|
||||
import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card";
|
||||
import { Button } from "@/shadcn-bridge/heroui/button";
|
||||
import { Input } from "@/shadcn-bridge/heroui/input";
|
||||
import { Textarea } from "@/shadcn-bridge/heroui/input";
|
||||
import {
|
||||
Modal,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalBody,
|
||||
ModalFooter,
|
||||
} from "@/shadcn-bridge/heroui/modal";
|
||||
import { Chip } from "@/shadcn-bridge/heroui/chip";
|
||||
import { Switch } from "@/shadcn-bridge/heroui/switch";
|
||||
import { Spinner } from "@/shadcn-bridge/heroui/spinner";
|
||||
import { Alert } from "@/shadcn-bridge/heroui/alert";
|
||||
import { Progress } from "@/shadcn-bridge/heroui/progress";
|
||||
import { Accordion, AccordionItem } from "@/shadcn-bridge/heroui/accordion";
|
||||
import { Select, SelectItem } from "@/shadcn-bridge/heroui/select";
|
||||
import { Checkbox } from "@/shadcn-bridge/heroui/checkbox";
|
||||
import {
|
||||
createNode,
|
||||
getNodeList,
|
||||
@@ -1000,7 +1000,7 @@ export default function NodePage() {
|
||||
|
||||
<div className="flex gap-2 items-center">
|
||||
<Button
|
||||
color={selectMode ? "warning" : "primary"}
|
||||
color={selectMode ? "secondary" : "default"}
|
||||
size="sm"
|
||||
variant={selectMode ? "solid" : "flat"}
|
||||
onPress={toggleSelectMode}
|
||||
@@ -1020,10 +1020,20 @@ export default function NodePage() {
|
||||
<span className="text-sm font-medium shrink-0">
|
||||
已选 {selectedIds.size} 项
|
||||
</span>
|
||||
<Button color="primary" size="sm" variant="flat" onPress={selectAll}>
|
||||
<Button
|
||||
color="primary"
|
||||
size="sm"
|
||||
variant="flat"
|
||||
onPress={selectAll}
|
||||
>
|
||||
全选
|
||||
</Button>
|
||||
<Button color="secondary" size="sm" variant="flat" onPress={deselectAll}>
|
||||
<Button
|
||||
color="secondary"
|
||||
size="sm"
|
||||
variant="flat"
|
||||
onPress={deselectAll}
|
||||
>
|
||||
清空
|
||||
</Button>
|
||||
<Button
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
|
||||
import { Button } from "@/shadcn-bridge/heroui/button";
|
||||
import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card";
|
||||
import { Tabs, Tab } from "@/shadcn-bridge/heroui/tabs";
|
||||
@@ -11,8 +13,6 @@ import {
|
||||
ModalFooter,
|
||||
} from "@/shadcn-bridge/heroui/modal";
|
||||
import { Select, SelectItem } from "@/shadcn-bridge/heroui/select";
|
||||
import { toast } from "react-hot-toast";
|
||||
|
||||
import {
|
||||
getNodeList,
|
||||
createPeerShare,
|
||||
@@ -397,11 +397,30 @@ export default function PanelSharingPage() {
|
||||
selectedKey={selectedTab}
|
||||
onSelectionChange={(k) => setSelectedTab(k as string)}
|
||||
>
|
||||
<Tab key="my-shares" title="我分享的 (Provider)">
|
||||
<Tab
|
||||
key="my-shares"
|
||||
title={
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-semibold text-foreground">
|
||||
Provider
|
||||
</span>
|
||||
<span className="text-xs text-default-500">我分享的</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Card>
|
||||
<CardBody>
|
||||
<div className="mb-4">
|
||||
<CardBody className="space-y-5">
|
||||
<div className="flex flex-col gap-4 rounded-lg border border-divider bg-default-50/60 dark:bg-default-100/20 p-4 md:flex-row md:items-center md:justify-between">
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-lg font-semibold text-foreground">
|
||||
Provider 共享
|
||||
</h2>
|
||||
<p className="text-sm text-default-500">
|
||||
将本地节点分享给其他面板,统一管理 Token、端口范围和到期策略。
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
className="self-start md:self-auto"
|
||||
color="primary"
|
||||
onPress={() => setCreateShareOpen(true)}
|
||||
>
|
||||
@@ -410,9 +429,25 @@ export default function PanelSharingPage() {
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="text-center py-10 text-gray-500">加载中...</div>
|
||||
<div className="text-center py-12 text-default-500">加载中...</div>
|
||||
) : shares.length === 0 ? (
|
||||
<div className="text-center py-10 text-gray-500">暂无分享</div>
|
||||
<div className="rounded-lg border border-dashed border-divider bg-default-50/60 dark:bg-default-100/20 px-6 py-10 text-center">
|
||||
<p className="text-base font-semibold text-foreground">
|
||||
暂无分享
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-default-500">
|
||||
先创建一个分享,把本地节点开放给其他面板使用。
|
||||
</p>
|
||||
<div className="mt-5 flex justify-center">
|
||||
<Button
|
||||
color="primary"
|
||||
variant="flat"
|
||||
onPress={() => setCreateShareOpen(true)}
|
||||
>
|
||||
创建第一个分享
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{shares.map((share) => (
|
||||
@@ -507,11 +542,30 @@ export default function PanelSharingPage() {
|
||||
</CardBody>
|
||||
</Card>
|
||||
</Tab>
|
||||
<Tab key="remote-nodes" title="远程节点 (Consumer)">
|
||||
<Tab
|
||||
key="remote-nodes"
|
||||
title={
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-semibold text-foreground">
|
||||
Consumer
|
||||
</span>
|
||||
<span className="text-xs text-default-500">远程节点</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Card>
|
||||
<CardBody>
|
||||
<div className="mb-4">
|
||||
<CardBody className="space-y-5">
|
||||
<div className="flex flex-col gap-4 rounded-lg border border-divider bg-default-50/60 dark:bg-default-100/20 p-4 md:flex-row md:items-center md:justify-between">
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-lg font-semibold text-foreground">
|
||||
Consumer 接入
|
||||
</h2>
|
||||
<p className="text-sm text-default-500">
|
||||
导入远程节点后,可在这里查看端口占用和同步状态。
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
className="self-start md:self-auto"
|
||||
color="secondary"
|
||||
onPress={() => setImportNodeOpen(true)}
|
||||
>
|
||||
@@ -520,13 +574,24 @@ export default function PanelSharingPage() {
|
||||
</div>
|
||||
|
||||
{remoteUsageLoading ? (
|
||||
<div className="text-center py-10 text-gray-500">加载中...</div>
|
||||
<div className="text-center py-12 text-default-500">加载中...</div>
|
||||
) : remoteUsageNodes.length === 0 ? (
|
||||
<div className="text-center py-10 text-gray-500">
|
||||
<p>暂无远程节点占用记录。</p>
|
||||
<p className="mt-2">
|
||||
<div className="rounded-lg border border-dashed border-divider bg-default-50/60 dark:bg-default-100/20 px-6 py-10 text-center">
|
||||
<p className="text-base font-semibold text-foreground">
|
||||
暂无远程节点占用记录
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-default-500">
|
||||
导入远程节点并创建隧道后,这里会显示远端端口占用情况。
|
||||
</p>
|
||||
<div className="mt-5 flex justify-center">
|
||||
<Button
|
||||
color="secondary"
|
||||
variant="flat"
|
||||
onPress={() => setImportNodeOpen(true)}
|
||||
>
|
||||
去导入远程节点
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { Card, CardBody } from "@/shadcn-bridge/heroui/card";
|
||||
import { Button } from "@/shadcn-bridge/heroui/button";
|
||||
import {
|
||||
@@ -10,9 +13,6 @@ import {
|
||||
useDisclosure,
|
||||
} from "@/shadcn-bridge/heroui/modal";
|
||||
import { Input } from "@/shadcn-bridge/heroui/input";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { isWebViewFunc } from "@/utils/panel";
|
||||
import { siteConfig } from "@/config/site";
|
||||
import { updatePassword } from "@/api";
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Input } from "@/shadcn-bridge/heroui/input";
|
||||
import { Button } from "@/shadcn-bridge/heroui/button";
|
||||
import { Card, CardBody } from "@/shadcn-bridge/heroui/card";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import { Input } from "@/shadcn-bridge/heroui/input";
|
||||
import { Button } from "@/shadcn-bridge/heroui/button";
|
||||
import { Card, CardBody } from "@/shadcn-bridge/heroui/card";
|
||||
import { reinitializeBaseURL } from "@/api/network";
|
||||
import {
|
||||
getPanelAddresses,
|
||||
|
||||
@@ -1,20 +1,4 @@
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card";
|
||||
import { Button } from "@/shadcn-bridge/heroui/button";
|
||||
import { Input, Textarea } from "@/shadcn-bridge/heroui/input";
|
||||
import { Select, SelectItem } from "@/shadcn-bridge/heroui/select";
|
||||
import {
|
||||
Modal,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalBody,
|
||||
ModalFooter,
|
||||
} from "@/shadcn-bridge/heroui/modal";
|
||||
import { Chip } from "@/shadcn-bridge/heroui/chip";
|
||||
import { Spinner } from "@/shadcn-bridge/heroui/spinner";
|
||||
import { Divider } from "@/shadcn-bridge/heroui/divider";
|
||||
import { Alert } from "@/shadcn-bridge/heroui/alert";
|
||||
import { Checkbox } from "@/shadcn-bridge/heroui/checkbox";
|
||||
import toast from "react-hot-toast";
|
||||
import {
|
||||
DndContext,
|
||||
@@ -34,6 +18,22 @@ import {
|
||||
} from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
|
||||
import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card";
|
||||
import { Button } from "@/shadcn-bridge/heroui/button";
|
||||
import { Input, Textarea } from "@/shadcn-bridge/heroui/input";
|
||||
import { Select, SelectItem } from "@/shadcn-bridge/heroui/select";
|
||||
import {
|
||||
Modal,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalBody,
|
||||
ModalFooter,
|
||||
} from "@/shadcn-bridge/heroui/modal";
|
||||
import { Chip } from "@/shadcn-bridge/heroui/chip";
|
||||
import { Spinner } from "@/shadcn-bridge/heroui/spinner";
|
||||
import { Divider } from "@/shadcn-bridge/heroui/divider";
|
||||
import { Alert } from "@/shadcn-bridge/heroui/alert";
|
||||
import { Checkbox } from "@/shadcn-bridge/heroui/checkbox";
|
||||
import {
|
||||
createTunnel,
|
||||
getTunnelList,
|
||||
@@ -226,9 +226,9 @@ export default function TunnelPage() {
|
||||
trafficRatio: tunnel.trafficRatio,
|
||||
inIp: tunnel.inIp
|
||||
? tunnel.inIp
|
||||
.split(",")
|
||||
.map((ip: string) => ip.trim())
|
||||
.join("\n")
|
||||
.split(",")
|
||||
.map((ip: string) => ip.trim())
|
||||
.join("\n")
|
||||
: "",
|
||||
ipPreference: tunnel.ipPreference || "",
|
||||
status: tunnel.status,
|
||||
@@ -678,7 +678,7 @@ export default function TunnelPage() {
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
color={selectMode ? "warning" : "primary"}
|
||||
color={selectMode ? "secondary" : "default"}
|
||||
size="sm"
|
||||
variant={selectMode ? "solid" : "flat"}
|
||||
onPress={toggleSelectMode}
|
||||
@@ -697,10 +697,20 @@ export default function TunnelPage() {
|
||||
<span className="text-sm text-default-600 shrink-0">
|
||||
已选择 {selectedIds.size} 项
|
||||
</span>
|
||||
<Button color="primary" size="sm" variant="flat" onPress={selectAll}>
|
||||
<Button
|
||||
color="primary"
|
||||
size="sm"
|
||||
variant="flat"
|
||||
onPress={selectAll}
|
||||
>
|
||||
全选
|
||||
</Button>
|
||||
<Button color="secondary" size="sm" variant="flat" onPress={deselectAll}>
|
||||
<Button
|
||||
color="secondary"
|
||||
size="sm"
|
||||
variant="flat"
|
||||
onPress={deselectAll}
|
||||
>
|
||||
清空
|
||||
</Button>
|
||||
<Button
|
||||
@@ -1117,11 +1127,6 @@ export default function TunnelPage() {
|
||||
</Select>
|
||||
|
||||
<Input
|
||||
endContent={
|
||||
<div className="pointer-events-none flex items-center">
|
||||
<span className="text-default-400 text-small">x</span>
|
||||
</div>
|
||||
}
|
||||
errorMessage={errors.trafficRatio}
|
||||
isInvalid={!!errors.trafficRatio}
|
||||
label="流量倍率"
|
||||
@@ -1376,6 +1381,7 @@ export default function TunnelPage() {
|
||||
]}
|
||||
label="节点"
|
||||
placeholder="选择节点(可多选)"
|
||||
dropdownPlacement="top"
|
||||
selectedKeys={groupNodes
|
||||
.filter((ct) => ct.nodeId !== -1)
|
||||
.map((ct) => ct.nodeId.toString())}
|
||||
@@ -1442,14 +1448,14 @@ export default function TunnelPage() {
|
||||
{form.inNodeId.some(
|
||||
(ct) => ct.nodeId === node.id,
|
||||
) && (
|
||||
<Chip
|
||||
color="warning"
|
||||
size="sm"
|
||||
variant="flat"
|
||||
>
|
||||
已选为入口
|
||||
</Chip>
|
||||
)}
|
||||
<Chip
|
||||
color="warning"
|
||||
size="sm"
|
||||
variant="flat"
|
||||
>
|
||||
已选为入口
|
||||
</Chip>
|
||||
)}
|
||||
{form.outNodeId &&
|
||||
form.outNodeId.some(
|
||||
(ct) => ct.nodeId === node.id,
|
||||
@@ -1472,14 +1478,14 @@ export default function TunnelPage() {
|
||||
ct.nodeId !== -1,
|
||||
),
|
||||
) && (
|
||||
<Chip
|
||||
color="primary"
|
||||
size="sm"
|
||||
variant="flat"
|
||||
>
|
||||
已选为其他跳
|
||||
</Chip>
|
||||
)}
|
||||
<Chip
|
||||
color="primary"
|
||||
size="sm"
|
||||
variant="flat"
|
||||
>
|
||||
已选为其他跳
|
||||
</Chip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</SelectItem>
|
||||
@@ -1593,11 +1599,12 @@ export default function TunnelPage() {
|
||||
isInvalid={!!errors.outNodeId}
|
||||
label="节点"
|
||||
placeholder="请选择出口节点(可多选)"
|
||||
dropdownPlacement="top"
|
||||
selectedKeys={
|
||||
form.outNodeId
|
||||
? form.outNodeId
|
||||
.filter((ct) => ct.nodeId !== -1)
|
||||
.map((ct) => ct.nodeId.toString())
|
||||
.filter((ct) => ct.nodeId !== -1)
|
||||
.map((ct) => ct.nodeId.toString())
|
||||
: []
|
||||
}
|
||||
selectionMode="multiple"
|
||||
@@ -1664,25 +1671,25 @@ export default function TunnelPage() {
|
||||
{form.inNodeId.some(
|
||||
(ct) => ct.nodeId === node.id,
|
||||
) && (
|
||||
<Chip
|
||||
color="warning"
|
||||
size="sm"
|
||||
variant="flat"
|
||||
>
|
||||
已选为入口
|
||||
</Chip>
|
||||
)}
|
||||
<Chip
|
||||
color="warning"
|
||||
size="sm"
|
||||
variant="flat"
|
||||
>
|
||||
已选为入口
|
||||
</Chip>
|
||||
)}
|
||||
{getSelectedChainNodeIds().includes(
|
||||
node.id,
|
||||
) && (
|
||||
<Chip
|
||||
color="primary"
|
||||
size="sm"
|
||||
variant="flat"
|
||||
>
|
||||
已选为转发链
|
||||
</Chip>
|
||||
)}
|
||||
<Chip
|
||||
color="primary"
|
||||
size="sm"
|
||||
variant="flat"
|
||||
>
|
||||
已选为转发链
|
||||
</Chip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</SelectItem>
|
||||
@@ -2045,20 +2052,18 @@ export default function TunnelPage() {
|
||||
return (
|
||||
<tr
|
||||
key={index}
|
||||
className={`hover:bg-default-50 dark:hover:bg-gray-700/50 ${
|
||||
result.success
|
||||
? "bg-white dark:bg-gray-800"
|
||||
: "bg-danger-50 dark:bg-danger-900/30"
|
||||
}`}
|
||||
className={`hover:bg-default-50 dark:hover:bg-gray-700/50 ${result.success
|
||||
? "bg-white dark:bg-gray-800"
|
||||
: "bg-danger-50 dark:bg-danger-900/30"
|
||||
}`}
|
||||
>
|
||||
<td className="px-3 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={`w-5 h-5 rounded-full flex items-center justify-center text-xs ${
|
||||
result.success
|
||||
? "bg-success text-white"
|
||||
: "bg-danger text-white"
|
||||
}`}
|
||||
className={`w-5 h-5 rounded-full flex items-center justify-center text-xs ${result.success
|
||||
? "bg-success text-white"
|
||||
: "bg-danger text-white"
|
||||
}`}
|
||||
>
|
||||
{result.success ? "✓" : "✗"}
|
||||
</span>
|
||||
@@ -2100,11 +2105,10 @@ export default function TunnelPage() {
|
||||
<td className="px-3 py-2 text-center">
|
||||
{result.success ? (
|
||||
<span
|
||||
className={`font-semibold ${
|
||||
(result.packetLoss || 0) > 0
|
||||
? "text-warning"
|
||||
: "text-success"
|
||||
}`}
|
||||
className={`font-semibold ${(result.packetLoss || 0) > 0
|
||||
? "text-warning"
|
||||
: "text-success"
|
||||
}`}
|
||||
>
|
||||
{result.packetLoss?.toFixed(1)}%
|
||||
</span>
|
||||
@@ -2217,19 +2221,17 @@ export default function TunnelPage() {
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={`border rounded-lg p-3 ${
|
||||
result.success
|
||||
? "border-divider bg-white dark:bg-gray-800"
|
||||
: "border-danger-200 dark:border-danger-300/30 bg-danger-50 dark:bg-danger-900/30"
|
||||
}`}
|
||||
className={`border rounded-lg p-3 ${result.success
|
||||
? "border-divider bg-white dark:bg-gray-800"
|
||||
: "border-danger-200 dark:border-danger-300/30 bg-danger-50 dark:bg-danger-900/30"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-2 mb-2">
|
||||
<span
|
||||
className={`w-6 h-6 rounded-full flex items-center justify-center text-xs flex-shrink-0 ${
|
||||
result.success
|
||||
? "bg-success text-white"
|
||||
: "bg-danger text-white"
|
||||
}`}
|
||||
className={`w-6 h-6 rounded-full flex items-center justify-center text-xs flex-shrink-0 ${result.success
|
||||
? "bg-success text-white"
|
||||
: "bg-danger text-white"
|
||||
}`}
|
||||
>
|
||||
{result.success ? "✓" : "✗"}
|
||||
</span>
|
||||
@@ -2265,11 +2267,10 @@ export default function TunnelPage() {
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div
|
||||
className={`text-lg font-bold ${
|
||||
(result.packetLoss || 0) > 0
|
||||
? "text-warning"
|
||||
: "text-success"
|
||||
}`}
|
||||
className={`text-lg font-bold ${(result.packetLoss || 0) > 0
|
||||
? "text-warning"
|
||||
: "text-success"
|
||||
}`}
|
||||
>
|
||||
{result.packetLoss?.toFixed(1)}%
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { parseDate } from "@internationalized/date";
|
||||
|
||||
import { Button } from "@/shadcn-bridge/heroui/button";
|
||||
import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card";
|
||||
import { Input } from "@/shadcn-bridge/heroui/input";
|
||||
@@ -25,9 +28,6 @@ import { Checkbox } from "@/shadcn-bridge/heroui/checkbox";
|
||||
import { DatePicker } from "@/shadcn-bridge/heroui/date-picker";
|
||||
import { Spinner } from "@/shadcn-bridge/heroui/spinner";
|
||||
import { Progress } from "@/shadcn-bridge/heroui/progress";
|
||||
import toast from "react-hot-toast";
|
||||
import { parseDate } from "@internationalized/date";
|
||||
|
||||
import {
|
||||
User,
|
||||
UserForm,
|
||||
@@ -943,7 +943,6 @@ export default function UserPage() {
|
||||
<DatePicker
|
||||
isRequired
|
||||
showMonthAndYearPickers
|
||||
className="cursor-pointer"
|
||||
label="过期时间"
|
||||
value={
|
||||
userForm.expTime
|
||||
@@ -1167,17 +1166,22 @@ export default function UserPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
className="w-full sm:w-auto"
|
||||
color="primary"
|
||||
isDisabled={batchTunnelSelections.size === 0}
|
||||
isLoading={assignLoading}
|
||||
onPress={handleBatchAssignTunnel}
|
||||
>
|
||||
分配权限{" "}
|
||||
{batchTunnelSelections.size > 0 &&
|
||||
`(${batchTunnelSelections.size}个隧道)`}
|
||||
</Button>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
className="w-full sm:w-auto"
|
||||
color="primary"
|
||||
isDisabled={batchTunnelSelections.size === 0}
|
||||
isLoading={assignLoading}
|
||||
onPress={handleBatchAssignTunnel}
|
||||
>
|
||||
分配权限
|
||||
</Button>
|
||||
{batchTunnelSelections.size > 0 && (
|
||||
<Chip color="primary" size="sm" variant="flat">
|
||||
已选 {batchTunnelSelections.size} 个隧道
|
||||
</Chip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1434,7 +1438,6 @@ export default function UserPage() {
|
||||
<DatePicker
|
||||
isRequired
|
||||
showMonthAndYearPickers
|
||||
className="cursor-pointer"
|
||||
label="到期时间"
|
||||
value={
|
||||
editTunnelForm.expTime
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import * as React from "react";
|
||||
import { HeroUIProvider } from "@/shadcn-bridge/heroui/system";
|
||||
import { useHref, useNavigate } from "react-router-dom";
|
||||
import { Toaster } from "react-hot-toast";
|
||||
import { I18nProvider } from "@react-aria/i18n";
|
||||
|
||||
import { HeroUIProvider } from "@/shadcn-bridge/heroui/system";
|
||||
import { ThemeProvider } from "@/components/theme-provider";
|
||||
|
||||
export interface ProvidersProps {
|
||||
|
||||
@@ -9,7 +9,8 @@ import {
|
||||
type AlertColor = "default" | "success" | "warning" | "danger" | "primary";
|
||||
type AlertVariant = "solid" | "flat" | "faded" | "bordered";
|
||||
|
||||
interface AlertProps extends Omit<React.ComponentProps<"div">, "color" | "title"> {
|
||||
interface AlertProps
|
||||
extends Omit<React.ComponentProps<"div">, "color" | "title"> {
|
||||
color?: AlertColor;
|
||||
description?: React.ReactNode;
|
||||
title?: React.ReactNode;
|
||||
|
||||
@@ -4,8 +4,20 @@ import { Loader2Icon } from "lucide-react";
|
||||
import { Button as BaseButton } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type HeroButtonColor = "default" | "primary" | "secondary" | "success" | "warning" | "danger";
|
||||
type HeroButtonVariant = "solid" | "light" | "flat" | "ghost" | "bordered" | "shadow";
|
||||
type HeroButtonColor =
|
||||
| "default"
|
||||
| "primary"
|
||||
| "secondary"
|
||||
| "success"
|
||||
| "warning"
|
||||
| "danger";
|
||||
type HeroButtonVariant =
|
||||
| "solid"
|
||||
| "light"
|
||||
| "flat"
|
||||
| "ghost"
|
||||
| "bordered"
|
||||
| "shadow";
|
||||
type HeroButtonSize = "sm" | "md" | "lg";
|
||||
|
||||
export interface ButtonProps
|
||||
@@ -20,7 +32,17 @@ export interface ButtonProps
|
||||
variant?: HeroButtonVariant;
|
||||
}
|
||||
|
||||
function mapVariant(color: HeroButtonColor, variant: HeroButtonVariant): "default" | "destructive" | "secondary" | "outline" | "ghost" | "light" | "flat" {
|
||||
function mapVariant(
|
||||
color: HeroButtonColor,
|
||||
variant: HeroButtonVariant,
|
||||
):
|
||||
| "default"
|
||||
| "destructive"
|
||||
| "secondary"
|
||||
| "outline"
|
||||
| "ghost"
|
||||
| "light"
|
||||
| "flat" {
|
||||
if (variant === "bordered") {
|
||||
return "outline";
|
||||
}
|
||||
@@ -43,7 +65,10 @@ function mapVariant(color: HeroButtonColor, variant: HeroButtonVariant): "defaul
|
||||
return "default";
|
||||
}
|
||||
|
||||
function mapSize(size: HeroButtonSize, isIconOnly: boolean): "default" | "sm" | "lg" | "icon" {
|
||||
function mapSize(
|
||||
size: HeroButtonSize,
|
||||
isIconOnly: boolean,
|
||||
): "default" | "sm" | "lg" | "icon" {
|
||||
if (isIconOnly) {
|
||||
return "icon";
|
||||
}
|
||||
@@ -67,33 +92,46 @@ const solidColorClassMap: Partial<Record<HeroButtonColor, string>> = {
|
||||
const borderedColorClassMap: Record<HeroButtonColor, string> = {
|
||||
default:
|
||||
"border-default-300 text-default-700 hover:bg-default-100 dark:border-default-300 dark:text-default-600 dark:hover:bg-default-200/40",
|
||||
primary: "border-primary text-primary hover:bg-primary-50 dark:border-primary-500/60 dark:text-primary-300 dark:hover:bg-primary-900/20",
|
||||
primary:
|
||||
"border-primary text-primary hover:bg-primary-50 dark:border-primary-500/60 dark:text-primary-300 dark:hover:bg-primary-900/20",
|
||||
secondary:
|
||||
"border-secondary text-secondary hover:bg-secondary-50 dark:border-secondary-500/60 dark:text-secondary-300 dark:hover:bg-secondary-900/20",
|
||||
success: "border-success text-success hover:bg-success-50 dark:border-success-500/60 dark:text-success-300 dark:hover:bg-success-900/20",
|
||||
success:
|
||||
"border-success text-success hover:bg-success-50 dark:border-success-500/60 dark:text-success-300 dark:hover:bg-success-900/20",
|
||||
warning:
|
||||
"border-warning text-warning-700 hover:bg-warning-50 dark:border-warning-500/60 dark:text-warning-300 dark:hover:bg-warning-900/20",
|
||||
danger: "border-danger text-danger hover:bg-danger-50 dark:border-danger-500/60 dark:text-danger-300 dark:hover:bg-danger-900/20",
|
||||
danger:
|
||||
"border-danger text-danger hover:bg-danger-50 dark:border-danger-500/60 dark:text-danger-300 dark:hover:bg-danger-900/20",
|
||||
};
|
||||
|
||||
const lightColorClassMap: Record<HeroButtonColor, string> = {
|
||||
default: "text-default-700 hover:bg-default-100 dark:text-default-600 dark:hover:bg-default-200/40",
|
||||
primary: "text-primary hover:bg-primary-100/70 dark:text-primary-300 dark:hover:bg-primary-900/30",
|
||||
secondary: "text-secondary hover:bg-secondary-100/70 dark:text-secondary-300 dark:hover:bg-secondary-900/30",
|
||||
success: "text-success hover:bg-success-100/70 dark:text-success-300 dark:hover:bg-success-900/30",
|
||||
warning: "text-warning-700 hover:bg-warning-100/70 dark:text-warning-300 dark:hover:bg-warning-900/30",
|
||||
danger: "text-danger hover:bg-danger-100/70 dark:text-danger-300 dark:hover:bg-danger-900/30",
|
||||
default:
|
||||
"text-default-700 hover:bg-default-100 dark:text-default-600 dark:hover:bg-default-200/40",
|
||||
primary:
|
||||
"text-primary hover:bg-primary-100/70 dark:text-primary-300 dark:hover:bg-primary-900/30",
|
||||
secondary:
|
||||
"text-secondary hover:bg-secondary-100/70 dark:text-secondary-300 dark:hover:bg-secondary-900/30",
|
||||
success:
|
||||
"text-success hover:bg-success-100/70 dark:text-success-300 dark:hover:bg-success-900/30",
|
||||
warning:
|
||||
"text-warning-700 hover:bg-warning-100/70 dark:text-warning-300 dark:hover:bg-warning-900/30",
|
||||
danger:
|
||||
"text-danger hover:bg-danger-100/70 dark:text-danger-300 dark:hover:bg-danger-900/30",
|
||||
};
|
||||
|
||||
const flatColorClassMap: Record<HeroButtonColor, string> = {
|
||||
default:
|
||||
"bg-default-100 text-default-700 hover:bg-default-200 dark:bg-default-100/45 dark:text-default-700 dark:hover:bg-default-200/60",
|
||||
primary: "bg-primary-100 text-primary-700 hover:bg-primary-200 dark:bg-primary-900/30 dark:text-primary-300 dark:hover:bg-primary-900/45",
|
||||
primary:
|
||||
"bg-primary-100 text-primary-700 hover:bg-primary-200 dark:bg-primary-900/30 dark:text-primary-300 dark:hover:bg-primary-900/45",
|
||||
secondary:
|
||||
"bg-secondary-100 text-secondary-700 hover:bg-secondary-200 dark:bg-secondary-900/30 dark:text-secondary-300 dark:hover:bg-secondary-900/45",
|
||||
success: "bg-success-100 text-success-700 hover:bg-success-200 dark:bg-success-900/30 dark:text-success-300 dark:hover:bg-success-900/45",
|
||||
warning: "bg-warning-100 text-warning-700 hover:bg-warning-200 dark:bg-warning-900/30 dark:text-warning-300 dark:hover:bg-warning-900/45",
|
||||
danger: "bg-danger-100 text-danger-700 hover:bg-danger-200 dark:bg-danger-900/30 dark:text-danger-300 dark:hover:bg-danger-900/45",
|
||||
success:
|
||||
"bg-success-100 text-success-700 hover:bg-success-200 dark:bg-success-900/30 dark:text-success-300 dark:hover:bg-success-900/45",
|
||||
warning:
|
||||
"bg-warning-100 text-warning-700 hover:bg-warning-200 dark:bg-warning-900/30 dark:text-warning-300 dark:hover:bg-warning-900/45",
|
||||
danger:
|
||||
"bg-danger-100 text-danger-700 hover:bg-danger-200 dark:bg-danger-900/30 dark:text-danger-300 dark:hover:bg-danger-900/45",
|
||||
};
|
||||
|
||||
const shadowColorClassMap: Record<HeroButtonColor, string> = {
|
||||
@@ -105,7 +143,10 @@ const shadowColorClassMap: Record<HeroButtonColor, string> = {
|
||||
danger: "shadow-md shadow-danger-500/35",
|
||||
};
|
||||
|
||||
function mapColorClass(color: HeroButtonColor, variant: HeroButtonVariant): string {
|
||||
function mapColorClass(
|
||||
color: HeroButtonColor,
|
||||
variant: HeroButtonVariant,
|
||||
): string {
|
||||
if (variant === "bordered") {
|
||||
return borderedColorClassMap[color];
|
||||
}
|
||||
@@ -122,7 +163,10 @@ function mapColorClass(color: HeroButtonColor, variant: HeroButtonVariant): stri
|
||||
return "";
|
||||
}
|
||||
|
||||
function mapShadowClass(color: HeroButtonColor, variant: HeroButtonVariant): string {
|
||||
function mapShadowClass(
|
||||
color: HeroButtonColor,
|
||||
variant: HeroButtonVariant,
|
||||
): string {
|
||||
if (variant !== "shadow") {
|
||||
return "";
|
||||
}
|
||||
@@ -169,16 +213,23 @@ export const Button = React.forwardRef<
|
||||
|
||||
return (
|
||||
<BaseButton
|
||||
className={cn(isIconOnly ? "p-0" : "", resolvedColorClass, resolvedShadowClass, className)}
|
||||
disabled={resolvedDisabled}
|
||||
ref={ref}
|
||||
className={cn(
|
||||
isIconOnly ? "p-0" : "",
|
||||
resolvedColorClass,
|
||||
resolvedShadowClass,
|
||||
className,
|
||||
)}
|
||||
disabled={resolvedDisabled}
|
||||
size={resolvedSize}
|
||||
type={type}
|
||||
variant={resolvedVariant}
|
||||
onClick={handleClick}
|
||||
{...props}
|
||||
>
|
||||
{isLoading ? <Loader2Icon className="mr-2 h-4 w-4 animate-spin" /> : null}
|
||||
{isLoading ? (
|
||||
<Loader2Icon className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : null}
|
||||
{startContent}
|
||||
{isIconOnly ? null : children}
|
||||
{isIconOnly ? children : null}
|
||||
|
||||
@@ -11,10 +11,18 @@ export function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return <BaseCard className={className} {...props} />;
|
||||
}
|
||||
|
||||
export function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
export function CardHeader({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return <BaseCardHeader className={cn("p-4 md:p-6", className)} {...props} />;
|
||||
}
|
||||
|
||||
export function CardBody({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return <CardContent className={cn("p-4 pt-0 md:p-6 md:pt-0", className)} {...props} />;
|
||||
return (
|
||||
<CardContent
|
||||
className={cn("p-4 pt-0 md:p-6 md:pt-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,10 @@ import { Checkbox as BaseCheckbox } from "@/components/ui/checkbox";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface CheckboxProps
|
||||
extends Omit<React.ComponentProps<typeof BaseCheckbox>, "checked" | "onCheckedChange"> {
|
||||
extends Omit<
|
||||
React.ComponentProps<typeof BaseCheckbox>,
|
||||
"checked" | "onCheckedChange"
|
||||
> {
|
||||
classNames?: Record<string, string>;
|
||||
color?: string;
|
||||
isDisabled?: boolean;
|
||||
@@ -30,7 +33,13 @@ export function Checkbox({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn("inline-flex items-center gap-2", isDisabled ? "opacity-50" : "", className)}>
|
||||
<div
|
||||
className={cn(
|
||||
"inline-flex items-center gap-2",
|
||||
isDisabled ? "opacity-50" : "",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<BaseCheckbox
|
||||
checked={Boolean(isSelected)}
|
||||
disabled={isDisabled}
|
||||
|
||||
@@ -3,7 +3,13 @@ import * as React from "react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type ChipColor = "default" | "primary" | "secondary" | "success" | "warning" | "danger";
|
||||
type ChipColor =
|
||||
| "default"
|
||||
| "primary"
|
||||
| "secondary"
|
||||
| "success"
|
||||
| "warning"
|
||||
| "danger";
|
||||
type ChipVariant = "solid" | "flat" | "light" | "bordered";
|
||||
type ChipSize = "sm" | "md" | "lg";
|
||||
|
||||
|
||||
@@ -1,16 +1,60 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { FieldContainer, type FieldMetaProps } from "./shared";
|
||||
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { FieldContainer, type FieldMetaProps } from "./shared";
|
||||
|
||||
interface CalendarDateLike {
|
||||
day: number;
|
||||
month: number;
|
||||
year: number;
|
||||
}
|
||||
|
||||
function isValidCalendarDate(year: number, month: number, day: number) {
|
||||
if (
|
||||
!Number.isInteger(year) ||
|
||||
!Number.isInteger(month) ||
|
||||
!Number.isInteger(day)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (month < 1 || month > 12 || day < 1 || day > 31) {
|
||||
return false;
|
||||
}
|
||||
const candidate = new Date(year, month - 1, day);
|
||||
|
||||
return (
|
||||
candidate.getFullYear() === year &&
|
||||
candidate.getMonth() === month - 1 &&
|
||||
candidate.getDate() === day
|
||||
);
|
||||
}
|
||||
|
||||
function parseDateText(value: string) {
|
||||
const trimmed = value.trim();
|
||||
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const matched = trimmed.match(/^(\d{4})[-/.](\d{1,2})[-/.](\d{1,2})$/);
|
||||
|
||||
if (!matched) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const year = Number(matched[1]);
|
||||
const month = Number(matched[2]);
|
||||
const day = Number(matched[3]);
|
||||
|
||||
if (!isValidCalendarDate(year, month, day)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { day, month, year };
|
||||
}
|
||||
|
||||
export interface DatePickerProps extends FieldMetaProps {
|
||||
className?: string;
|
||||
isDisabled?: boolean;
|
||||
@@ -39,9 +83,80 @@ export function DatePicker({
|
||||
isRequired,
|
||||
label,
|
||||
onChange,
|
||||
showMonthAndYearPickers,
|
||||
value,
|
||||
}: DatePickerProps) {
|
||||
const id = React.useId();
|
||||
const formattedValue = React.useMemo(() => formatDateValue(value), [value]);
|
||||
const [textValue, setTextValue] = React.useState(formattedValue);
|
||||
|
||||
React.useEffect(() => {
|
||||
setTextValue(formattedValue);
|
||||
}, [formattedValue]);
|
||||
|
||||
const shouldUseTextInput = Boolean(showMonthAndYearPickers);
|
||||
|
||||
const notifyNativeDateChange = (rawValue: string) => {
|
||||
if (!onChange) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!rawValue) {
|
||||
onChange(null);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const [yearText, monthText, dayText] = rawValue.split("-");
|
||||
const year = Number(yearText);
|
||||
const month = Number(monthText);
|
||||
const day = Number(dayText);
|
||||
|
||||
if (!isValidCalendarDate(year, month, day)) {
|
||||
onChange(null);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
onChange({ day, month, year });
|
||||
};
|
||||
|
||||
const notifyTextDateChange = (rawValue: string) => {
|
||||
if (!onChange) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!rawValue.trim()) {
|
||||
onChange(null);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const parsed = parseDateText(rawValue);
|
||||
|
||||
if (parsed) {
|
||||
onChange(parsed);
|
||||
}
|
||||
};
|
||||
|
||||
const commitTextInput = () => {
|
||||
const parsed = parseDateText(textValue);
|
||||
|
||||
if (parsed) {
|
||||
setTextValue(formatDateValue(parsed));
|
||||
onChange?.(parsed);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!textValue.trim()) {
|
||||
onChange?.(null);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
setTextValue(formattedValue);
|
||||
};
|
||||
|
||||
return (
|
||||
<FieldContainer
|
||||
@@ -57,33 +172,34 @@ export function DatePicker({
|
||||
className={cn(className)}
|
||||
disabled={isDisabled}
|
||||
id={id}
|
||||
inputMode={shouldUseTextInput ? "numeric" : undefined}
|
||||
placeholder={shouldUseTextInput ? "YYYY-MM-DD" : undefined}
|
||||
required={isRequired}
|
||||
type="date"
|
||||
value={formatDateValue(value)}
|
||||
type={shouldUseTextInput ? "text" : "date"}
|
||||
value={shouldUseTextInput ? textValue : formattedValue}
|
||||
onChange={(event) => {
|
||||
if (!onChange) {
|
||||
return;
|
||||
}
|
||||
const nextValue = event.target.value;
|
||||
|
||||
if (!event.target.value) {
|
||||
onChange(null);
|
||||
if (shouldUseTextInput) {
|
||||
setTextValue(nextValue);
|
||||
notifyTextDateChange(nextValue);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const [yearText, monthText, dayText] = event.target.value.split("-");
|
||||
const year = Number(yearText);
|
||||
const month = Number(monthText);
|
||||
const day = Number(dayText);
|
||||
|
||||
if (!Number.isFinite(year) || !Number.isFinite(month) || !Number.isFinite(day)) {
|
||||
onChange(null);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
onChange({ day, month, year });
|
||||
notifyNativeDateChange(nextValue);
|
||||
}}
|
||||
onBlur={shouldUseTextInput ? commitTextInput : undefined}
|
||||
onKeyDown={
|
||||
shouldUseTextInput
|
||||
? (event) => {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
commitTextInput();
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</FieldContainer>
|
||||
);
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger as BaseDropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type Placement = "bottom-start" | "bottom-end" | "top-start" | "top-end";
|
||||
@@ -17,14 +16,19 @@ interface DropdownContextValue {
|
||||
align: "start" | "end";
|
||||
}
|
||||
|
||||
const DropdownContext = React.createContext<DropdownContextValue>({ align: "start" });
|
||||
const DropdownContext = React.createContext<DropdownContextValue>({
|
||||
align: "start",
|
||||
});
|
||||
|
||||
export interface DropdownProps {
|
||||
children: React.ReactNode;
|
||||
placement?: Placement;
|
||||
}
|
||||
|
||||
export function Dropdown({ children, placement = "bottom-start" }: DropdownProps) {
|
||||
export function Dropdown({
|
||||
children,
|
||||
placement = "bottom-start",
|
||||
}: DropdownProps) {
|
||||
const align = placement.endsWith("end") ? "end" : "start";
|
||||
|
||||
return (
|
||||
@@ -66,7 +70,10 @@ export function DropdownItem({
|
||||
}: DropdownItemProps) {
|
||||
return (
|
||||
<BaseDropdownMenuItem
|
||||
className={cn(color === "danger" ? "text-danger focus:text-danger" : "", className)}
|
||||
className={cn(
|
||||
color === "danger" ? "text-danger focus:text-danger" : "",
|
||||
className,
|
||||
)}
|
||||
onSelect={(event) => {
|
||||
event.preventDefault();
|
||||
onPress?.();
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { FieldContainer, type FieldMetaProps } from "./shared";
|
||||
|
||||
import { Input as BaseInput } from "@/components/ui/input";
|
||||
import { Textarea as BaseTextarea } from "@/components/ui/textarea";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { FieldContainer, type FieldMetaProps } from "./shared";
|
||||
|
||||
type ClassNameMap = {
|
||||
base?: string;
|
||||
description?: string;
|
||||
@@ -84,7 +84,9 @@ export function Input({
|
||||
classNames?.inputWrapper,
|
||||
)}
|
||||
>
|
||||
{startContent ? <div className="pl-3 text-default-500">{startContent}</div> : null}
|
||||
{startContent ? (
|
||||
<div className="pl-3 text-default-500">{startContent}</div>
|
||||
) : null}
|
||||
<BaseInput
|
||||
aria-invalid={isInvalid}
|
||||
className={cn(
|
||||
@@ -100,7 +102,9 @@ export function Input({
|
||||
required={isRequired}
|
||||
{...props}
|
||||
/>
|
||||
{endContent ? <div className="pr-3 text-default-500">{endContent}</div> : null}
|
||||
{endContent ? (
|
||||
<div className="pr-3 text-default-500">{endContent}</div>
|
||||
) : null}
|
||||
</div>
|
||||
</FieldContainer>
|
||||
);
|
||||
|
||||
@@ -20,5 +20,10 @@ function mapColor(color: LinkColor) {
|
||||
}
|
||||
|
||||
export function Link({ className, color = "default", ...props }: LinkProps) {
|
||||
return <a className={cn("transition-colors", mapColor(color), className)} {...props} />;
|
||||
return (
|
||||
<a
|
||||
className={cn("transition-colors", mapColor(color), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -52,7 +52,12 @@ export function Navbar({
|
||||
style={height ? { minHeight: height } : undefined}
|
||||
{...props}
|
||||
>
|
||||
<div className={cn("mx-auto flex h-full w-full items-center justify-between px-4", maxWidthClass(maxWidth))}>
|
||||
<div
|
||||
className={cn(
|
||||
"mx-auto flex h-full w-full items-center justify-between px-4",
|
||||
maxWidthClass(maxWidth),
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</nav>
|
||||
@@ -85,6 +90,9 @@ export function NavbarContent({
|
||||
);
|
||||
}
|
||||
|
||||
export function NavbarBrand({ className, ...props }: React.ComponentProps<"div">) {
|
||||
export function NavbarBrand({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return <div className={cn("flex items-center", className)} {...props} />;
|
||||
}
|
||||
|
||||
@@ -3,7 +3,13 @@ import * as React from "react";
|
||||
import { Progress as BaseProgress } from "@/components/ui/progress";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type ProgressColor = "default" | "primary" | "secondary" | "success" | "warning" | "danger";
|
||||
type ProgressColor =
|
||||
| "default"
|
||||
| "primary"
|
||||
| "secondary"
|
||||
| "success"
|
||||
| "warning"
|
||||
| "danger";
|
||||
|
||||
export interface ProgressProps {
|
||||
"aria-label"?: string;
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { RadioGroup as BaseRadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import {
|
||||
RadioGroup as BaseRadioGroup,
|
||||
RadioGroupItem,
|
||||
} from "@/components/ui/radio-group";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
@@ -8,7 +11,9 @@ interface RadioContextValue {
|
||||
name: string;
|
||||
}
|
||||
|
||||
const RadioContext = React.createContext<RadioContextValue>({ name: "radio-group" });
|
||||
const RadioContext = React.createContext<RadioContextValue>({
|
||||
name: "radio-group",
|
||||
});
|
||||
|
||||
export interface RadioGroupProps {
|
||||
children: React.ReactNode;
|
||||
@@ -33,7 +38,9 @@ export function RadioGroup({
|
||||
<RadioContext.Provider value={{ name: generatedName }}>
|
||||
<BaseRadioGroup
|
||||
className={cn(
|
||||
orientation === "horizontal" ? "flex flex-wrap items-center gap-4" : "grid gap-3",
|
||||
orientation === "horizontal"
|
||||
? "flex flex-wrap items-center gap-4"
|
||||
: "grid gap-3",
|
||||
)}
|
||||
value={value}
|
||||
onValueChange={onValueChange}
|
||||
|
||||
@@ -37,6 +37,7 @@ export interface SelectProps<T = unknown> extends FieldMetaProps {
|
||||
selectionMode?: SelectionMode;
|
||||
size?: "sm" | "md" | "lg";
|
||||
variant?: string;
|
||||
dropdownPlacement?: "bottom" | "top";
|
||||
}
|
||||
|
||||
export interface SelectItemProps {
|
||||
@@ -163,25 +164,78 @@ export function Select<T>({
|
||||
selectedKeys,
|
||||
selectionMode = "single",
|
||||
size,
|
||||
dropdownPlacement = "bottom",
|
||||
}: SelectProps<T>) {
|
||||
const generatedId = React.useId();
|
||||
const options = React.useMemo(
|
||||
() => getOptions(children, items),
|
||||
[children, items],
|
||||
);
|
||||
const containerRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const listboxRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const [isExpanded, setIsExpanded] = React.useState(false);
|
||||
const selected = React.useMemo(() => toSet(selectedKeys), [selectedKeys]);
|
||||
const disabled = React.useMemo(() => toSet(disabledKeys), [disabledKeys]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isExpanded) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handlePointerDown = (event: MouseEvent | TouchEvent) => {
|
||||
const container = containerRef.current;
|
||||
const listbox = listboxRef.current;
|
||||
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
|
||||
const target = event.target;
|
||||
|
||||
if (!(target instanceof Node)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (container.contains(target) || listbox?.contains(target)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsExpanded(false);
|
||||
};
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
setIsExpanded(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("mousedown", handlePointerDown);
|
||||
document.addEventListener("touchstart", handlePointerDown);
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handlePointerDown);
|
||||
document.removeEventListener("touchstart", handlePointerDown);
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [isExpanded]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isDisabled) {
|
||||
setIsExpanded(false);
|
||||
}
|
||||
}, [isDisabled]);
|
||||
|
||||
const selectedArray = Array.from(selected);
|
||||
const singleValue = selectedArray[0] ?? "";
|
||||
const selectedLabels = options
|
||||
.filter((option) => selected.has(option.key))
|
||||
.map((option) => option.label);
|
||||
const resolvedSelectedValues =
|
||||
selectedLabels.length > 0 ? selectedLabels : selectedArray;
|
||||
const selectedFullText = resolvedSelectedValues.join("、");
|
||||
const selectedText =
|
||||
selectedArray.length > 0
|
||||
? options
|
||||
.filter((option) => selected.has(option.key))
|
||||
.map((option) => option.label)
|
||||
.join("、")
|
||||
: (placeholder ?? "请选择");
|
||||
selectedArray.length > 0 ? selectedFullText : (placeholder ?? "请选择");
|
||||
|
||||
const updateMultipleSelection = (key: string, checked?: boolean) => {
|
||||
if (isDisabled || disabled.has(key)) {
|
||||
@@ -227,6 +281,77 @@ export function Select<T>({
|
||||
onSelectionChange(new Set([event.target.value]));
|
||||
};
|
||||
|
||||
const renderMultipleListbox = () => {
|
||||
if (!isExpanded) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const placementClasses = dropdownPlacement === "top"
|
||||
? "bottom-full mb-1"
|
||||
: "top-full mt-1";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute left-0 z-50 w-full space-y-1 overflow-y-auto rounded-md border border-divider bg-background p-2 shadow-md max-h-56",
|
||||
placementClasses
|
||||
)}
|
||||
id={`${generatedId}-listbox`}
|
||||
ref={listboxRef}
|
||||
role="listbox"
|
||||
>
|
||||
{options.length === 0 ? (
|
||||
<div
|
||||
className={cn(
|
||||
"px-2 py-1 text-default-500",
|
||||
textSizeClass(size),
|
||||
)}
|
||||
>
|
||||
暂无可选项
|
||||
</div>
|
||||
) : (
|
||||
options.map((option) => {
|
||||
const optionDisabled = isDisabled || disabled.has(option.key);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={option.key}
|
||||
className={cn(
|
||||
"flex items-center gap-2 rounded-md px-2 py-1.5",
|
||||
optionDisabled
|
||||
? "cursor-not-allowed opacity-60"
|
||||
: "hover:bg-default-100",
|
||||
)}
|
||||
>
|
||||
<BaseCheckbox
|
||||
checked={selected.has(option.key)}
|
||||
disabled={optionDisabled}
|
||||
onCheckedChange={(value) =>
|
||||
updateMultipleSelection(option.key, value === true)
|
||||
}
|
||||
/>
|
||||
<button
|
||||
className={cn(
|
||||
"min-w-0 flex-1 truncate text-left text-foreground",
|
||||
textSizeClass(size),
|
||||
optionDisabled
|
||||
? "cursor-not-allowed"
|
||||
: "cursor-pointer",
|
||||
)}
|
||||
disabled={optionDisabled}
|
||||
type="button"
|
||||
onClick={() => updateMultipleSelection(option.key)}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<FieldContainer
|
||||
className={classNames?.base}
|
||||
@@ -239,21 +364,23 @@ export function Select<T>({
|
||||
>
|
||||
{selectionMode === "multiple" ? (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={cn(
|
||||
"w-full overflow-hidden rounded-md border border-input bg-background shadow-sm",
|
||||
isDisabled ? "cursor-not-allowed opacity-60" : "",
|
||||
"relative w-full",
|
||||
className,
|
||||
)}
|
||||
id={generatedId}
|
||||
>
|
||||
<button
|
||||
aria-controls={`${generatedId}-listbox`}
|
||||
aria-expanded={isExpanded}
|
||||
aria-haspopup="listbox"
|
||||
className={cn(
|
||||
"flex w-full min-w-0 items-center gap-2 overflow-hidden px-3 py-2 text-left",
|
||||
"flex w-full min-w-0 items-center gap-2 overflow-hidden rounded-md border border-input bg-background px-3 py-2 text-left shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||
isDisabled ? "cursor-not-allowed opacity-60" : "",
|
||||
classNames?.trigger,
|
||||
)}
|
||||
disabled={isDisabled}
|
||||
id={generatedId}
|
||||
type="button"
|
||||
onClick={() => setIsExpanded((prev) => !prev)}
|
||||
>
|
||||
@@ -261,9 +388,11 @@ export function Select<T>({
|
||||
className={cn(
|
||||
"block min-w-0 flex-1 truncate",
|
||||
textSizeClass(size),
|
||||
selectedArray.length > 0 ? "text-foreground" : "text-default-500",
|
||||
selectedArray.length > 0
|
||||
? "text-foreground"
|
||||
: "text-default-500",
|
||||
)}
|
||||
title={selectedText}
|
||||
title={selectedArray.length > 0 ? selectedFullText : undefined}
|
||||
>
|
||||
{selectedText}
|
||||
</span>
|
||||
@@ -274,62 +403,7 @@ export function Select<T>({
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
{isExpanded ? (
|
||||
<div
|
||||
className="max-h-56 space-y-1 overflow-y-auto border-t border-divider p-2"
|
||||
id={`${generatedId}-listbox`}
|
||||
role="listbox"
|
||||
>
|
||||
{options.length === 0 ? (
|
||||
<div
|
||||
className={cn(
|
||||
"px-2 py-1 text-default-500",
|
||||
textSizeClass(size),
|
||||
)}
|
||||
>
|
||||
暂无可选项
|
||||
</div>
|
||||
) : (
|
||||
options.map((option) => {
|
||||
const optionDisabled = isDisabled || disabled.has(option.key);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={option.key}
|
||||
className={cn(
|
||||
"flex items-center gap-2 rounded-md px-2 py-1.5",
|
||||
optionDisabled
|
||||
? "cursor-not-allowed opacity-60"
|
||||
: "hover:bg-default-100",
|
||||
)}
|
||||
>
|
||||
<BaseCheckbox
|
||||
checked={selected.has(option.key)}
|
||||
disabled={optionDisabled}
|
||||
onCheckedChange={(value) =>
|
||||
updateMultipleSelection(option.key, value === true)
|
||||
}
|
||||
/>
|
||||
<button
|
||||
className={cn(
|
||||
"min-w-0 flex-1 truncate text-left text-foreground",
|
||||
textSizeClass(size),
|
||||
optionDisabled
|
||||
? "cursor-not-allowed"
|
||||
: "cursor-pointer",
|
||||
)}
|
||||
disabled={optionDisabled}
|
||||
type="button"
|
||||
onClick={() => updateMultipleSelection(option.key)}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
{renderMultipleListbox()}
|
||||
</div>
|
||||
) : (
|
||||
<select
|
||||
|
||||
@@ -36,7 +36,9 @@ export function FieldContainer({
|
||||
</Label>
|
||||
) : null}
|
||||
{children}
|
||||
{description ? <p className="text-xs text-default-500">{description}</p> : null}
|
||||
{description ? (
|
||||
<p className="text-xs text-default-500">{description}</p>
|
||||
) : null}
|
||||
{isInvalid && errorMessage ? (
|
||||
<p className="text-xs text-danger">{errorMessage}</p>
|
||||
) : null}
|
||||
@@ -45,14 +47,21 @@ export function FieldContainer({
|
||||
}
|
||||
|
||||
export function extractText(content: React.ReactNode): string {
|
||||
if (content === null || content === undefined || typeof content === "boolean") {
|
||||
if (
|
||||
content === null ||
|
||||
content === undefined ||
|
||||
typeof content === "boolean"
|
||||
) {
|
||||
return "";
|
||||
}
|
||||
if (typeof content === "string" || typeof content === "number") {
|
||||
return String(content);
|
||||
}
|
||||
if (Array.isArray(content)) {
|
||||
return content.map((item) => extractText(item)).join("").trim();
|
||||
return content
|
||||
.map((item) => extractText(item))
|
||||
.join("")
|
||||
.trim();
|
||||
}
|
||||
if (React.isValidElement(content)) {
|
||||
return extractText(content.props.children);
|
||||
|
||||
@@ -21,10 +21,17 @@ function iconSize(size: SpinnerSize | undefined) {
|
||||
return "h-5 w-5";
|
||||
}
|
||||
|
||||
export function Spinner({ className, label, size = "md", ...props }: SpinnerProps) {
|
||||
export function Spinner({
|
||||
className,
|
||||
label,
|
||||
size = "md",
|
||||
...props
|
||||
}: SpinnerProps) {
|
||||
return (
|
||||
<div className={cn("inline-flex items-center gap-2", className)} {...props}>
|
||||
<Loader2Icon className={cn("animate-spin text-default-500", iconSize(size))} />
|
||||
<Loader2Icon
|
||||
className={cn("animate-spin text-default-500", iconSize(size))}
|
||||
/>
|
||||
{label ? <span className="text-sm text-default-500">{label}</span> : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -3,7 +3,11 @@ import * as React from "react";
|
||||
import { Switch as BaseSwitch } from "@/components/ui/switch";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface SwitchProps extends Omit<React.ComponentProps<typeof BaseSwitch>, "checked" | "onCheckedChange"> {
|
||||
export interface SwitchProps
|
||||
extends Omit<
|
||||
React.ComponentProps<typeof BaseSwitch>,
|
||||
"checked" | "onCheckedChange"
|
||||
> {
|
||||
classNames?: Record<string, string>;
|
||||
color?: string;
|
||||
isDisabled?: boolean;
|
||||
|
||||
@@ -4,11 +4,15 @@ import { cn } from "@/lib/utils";
|
||||
|
||||
interface TableClassNames {
|
||||
th?: string;
|
||||
td?: string;
|
||||
tr?: string;
|
||||
wrapper?: string;
|
||||
}
|
||||
|
||||
interface TableStyleContextValue {
|
||||
thClassName?: string;
|
||||
tdClassName?: string;
|
||||
trClassName?: string;
|
||||
}
|
||||
|
||||
const TableStyleContext = React.createContext<TableStyleContextValue>({});
|
||||
@@ -17,10 +21,23 @@ export interface TableProps extends React.ComponentProps<"table"> {
|
||||
classNames?: TableClassNames;
|
||||
}
|
||||
|
||||
export function Table({ children, className, classNames, ...props }: TableProps) {
|
||||
export function Table({
|
||||
children,
|
||||
className,
|
||||
classNames,
|
||||
...props
|
||||
}: TableProps) {
|
||||
return (
|
||||
<TableStyleContext.Provider value={{ thClassName: classNames?.th }}>
|
||||
<div className={cn("w-full overflow-auto rounded-md", classNames?.wrapper)}>
|
||||
<TableStyleContext.Provider
|
||||
value={{
|
||||
thClassName: classNames?.th,
|
||||
tdClassName: classNames?.td,
|
||||
trClassName: classNames?.tr,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={cn("w-full overflow-auto rounded-md", classNames?.wrapper)}
|
||||
>
|
||||
<table className={cn("w-full text-sm", className)} {...props}>
|
||||
{children}
|
||||
</table>
|
||||
@@ -29,7 +46,11 @@ export function Table({ children, className, classNames, ...props }: TableProps)
|
||||
);
|
||||
}
|
||||
|
||||
export function TableHeader({ children, className, ...props }: React.ComponentProps<"thead">) {
|
||||
export function TableHeader({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"thead">) {
|
||||
const childArray = React.Children.toArray(children);
|
||||
const hasRow = childArray.some(
|
||||
(child) => React.isValidElement(child) && child.type === TableRow,
|
||||
@@ -42,7 +63,8 @@ export function TableHeader({ children, className, ...props }: React.ComponentPr
|
||||
);
|
||||
}
|
||||
|
||||
interface TableBodyProps<T> extends Omit<React.ComponentProps<"tbody">, "children"> {
|
||||
interface TableBodyProps<T>
|
||||
extends Omit<React.ComponentProps<"tbody">, "children"> {
|
||||
children?: React.ReactNode | ((item: T) => React.ReactNode);
|
||||
emptyContent?: React.ReactNode;
|
||||
isLoading?: boolean;
|
||||
@@ -50,18 +72,24 @@ interface TableBodyProps<T> extends Omit<React.ComponentProps<"tbody">, "childre
|
||||
loadingContent?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function TableBody<T>({
|
||||
children,
|
||||
className,
|
||||
emptyContent,
|
||||
isLoading,
|
||||
items,
|
||||
loadingContent,
|
||||
...props
|
||||
}: TableBodyProps<T>) {
|
||||
export const TableBody = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
TableBodyProps<any>
|
||||
>(function TableBody<T>(
|
||||
{
|
||||
children,
|
||||
className,
|
||||
emptyContent,
|
||||
isLoading,
|
||||
items,
|
||||
loadingContent,
|
||||
...props
|
||||
}: TableBodyProps<T>,
|
||||
ref: React.Ref<HTMLTableSectionElement>,
|
||||
) {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<tbody className={className} {...props}>
|
||||
<tbody ref={ref} className={className} {...props}>
|
||||
<tr>
|
||||
<td className="p-4 text-center text-default-500" colSpan={999}>
|
||||
{loadingContent ?? "加载中..."}
|
||||
@@ -74,7 +102,7 @@ export function TableBody<T>({
|
||||
if (items && typeof children === "function") {
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<tbody className={className} {...props}>
|
||||
<tbody ref={ref} className={className} {...props}>
|
||||
<tr>
|
||||
<td className="p-4 text-center text-default-500" colSpan={999}>
|
||||
{emptyContent ?? "暂无数据"}
|
||||
@@ -85,7 +113,7 @@ export function TableBody<T>({
|
||||
}
|
||||
|
||||
return (
|
||||
<tbody className={className} {...props}>
|
||||
<tbody ref={ref} className={className} {...props}>
|
||||
{items.map((item) => {
|
||||
const key =
|
||||
typeof item === "object" && item !== null && "id" in item
|
||||
@@ -103,7 +131,7 @@ export function TableBody<T>({
|
||||
|
||||
if (staticChildren.length === 0) {
|
||||
return (
|
||||
<tbody className={className} {...props}>
|
||||
<tbody ref={ref} className={className} {...props}>
|
||||
<tr>
|
||||
<td className="p-4 text-center text-default-500" colSpan={999}>
|
||||
{emptyContent ?? "暂无数据"}
|
||||
@@ -114,27 +142,53 @@ export function TableBody<T>({
|
||||
}
|
||||
|
||||
return (
|
||||
<tbody className={className} {...props}>
|
||||
<tbody ref={ref} className={className} {...props}>
|
||||
{typeof children === "function" ? null : children}
|
||||
</tbody>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
export function TableColumn({ className, ...props }: React.ComponentProps<"th">) {
|
||||
export function TableColumn({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"th">) {
|
||||
const { thClassName } = React.useContext(TableStyleContext);
|
||||
|
||||
return (
|
||||
<th
|
||||
className={cn("px-3 py-2 text-left font-medium text-default-600", thClassName, className)}
|
||||
className={cn(
|
||||
"px-3 py-2 text-left font-medium text-default-600",
|
||||
thClassName,
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
|
||||
return <tr className={cn("border-b last:border-b-0", className)} {...props} />;
|
||||
}
|
||||
export const TableRow = React.forwardRef<
|
||||
HTMLTableRowElement,
|
||||
React.ComponentProps<"tr">
|
||||
>(({ className, ...props }, ref) => {
|
||||
const { trClassName } = React.useContext(TableStyleContext);
|
||||
|
||||
return (
|
||||
<tr
|
||||
ref={ref}
|
||||
className={cn("border-b last:border-b-0", trClassName, className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
TableRow.displayName = "TableRow";
|
||||
|
||||
export function TableCell({ className, ...props }: React.ComponentProps<"td">) {
|
||||
return <td className={cn("px-3 py-2 align-middle", className)} {...props} />;
|
||||
const { tdClassName } = React.useContext(TableStyleContext);
|
||||
|
||||
return (
|
||||
<td
|
||||
className={cn("px-3 py-2 align-middle", tdClassName, className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,11 +10,14 @@ function resolveInitialTheme(): ThemeMode {
|
||||
}
|
||||
|
||||
const fromStorage = window.localStorage.getItem(STORAGE_KEY);
|
||||
|
||||
if (fromStorage === "dark" || fromStorage === "light") {
|
||||
return fromStorage;
|
||||
}
|
||||
|
||||
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
||||
return window.matchMedia("(prefers-color-scheme: dark)").matches
|
||||
? "dark"
|
||||
: "light";
|
||||
}
|
||||
|
||||
let currentTheme: ThemeMode = resolveInitialTheme();
|
||||
|
||||
Reference in New Issue
Block a user