mirror of
https://github.com/Sagit-chu/flvx.git
synced 2026-10-05 17:46:37 +08:00
style(frontend): sync remaining glass UI theme files and cyberpunk theme
This commit is contained in:
@@ -1,4 +1,10 @@
|
|||||||
import { Route, Routes, useLocation, useNavigate, Navigate } from "react-router-dom";
|
import {
|
||||||
|
Route,
|
||||||
|
Routes,
|
||||||
|
useLocation,
|
||||||
|
useNavigate,
|
||||||
|
Navigate,
|
||||||
|
} from "react-router-dom";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { AnimatePresence } from "framer-motion";
|
import { AnimatePresence } from "framer-motion";
|
||||||
|
|
||||||
@@ -85,21 +91,39 @@ function App() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const updateBg = () => {
|
const updateBg = () => {
|
||||||
const customBg = siteConfig.app_bg_image;
|
const customBg = siteConfig.app_bg_image;
|
||||||
|
|
||||||
if (customBg) {
|
if (customBg) {
|
||||||
if (customBg === "theme") {
|
if (customBg === "theme") {
|
||||||
document.documentElement.style.removeProperty("--custom-bg-image");
|
document.documentElement.style.removeProperty("--custom-bg-image");
|
||||||
document.documentElement.style.removeProperty("--custom-bg-color");
|
document.documentElement.style.removeProperty("--custom-bg-color");
|
||||||
document.documentElement.classList.add("has-theme-bg");
|
document.documentElement.classList.add("has-theme-bg");
|
||||||
document.documentElement.classList.remove("has-custom-bg");
|
document.documentElement.classList.remove("has-custom-bg");
|
||||||
} else if (customBg.startsWith("http") || customBg.startsWith("data:") || customBg.startsWith("/") || customBg.startsWith("blob:")) {
|
} else if (
|
||||||
document.documentElement.style.setProperty("--custom-bg-image", `url(${customBg})`);
|
customBg.startsWith("http") ||
|
||||||
document.documentElement.style.setProperty("--custom-bg-color", "transparent");
|
customBg.startsWith("data:") ||
|
||||||
|
customBg.startsWith("/") ||
|
||||||
|
customBg.startsWith("blob:")
|
||||||
|
) {
|
||||||
|
document.documentElement.style.setProperty(
|
||||||
|
"--custom-bg-image",
|
||||||
|
`url(${customBg})`,
|
||||||
|
);
|
||||||
|
document.documentElement.style.setProperty(
|
||||||
|
"--custom-bg-color",
|
||||||
|
"transparent",
|
||||||
|
);
|
||||||
document.documentElement.classList.add("has-custom-bg");
|
document.documentElement.classList.add("has-custom-bg");
|
||||||
document.documentElement.classList.remove("has-theme-bg");
|
document.documentElement.classList.remove("has-theme-bg");
|
||||||
} else {
|
} else {
|
||||||
// Assume solid color like "#ffffff", "white", etc.
|
// Assume solid color like "#ffffff", "white", etc.
|
||||||
document.documentElement.style.setProperty("--custom-bg-image", "none");
|
document.documentElement.style.setProperty(
|
||||||
document.documentElement.style.setProperty("--custom-bg-color", customBg);
|
"--custom-bg-image",
|
||||||
|
"none",
|
||||||
|
);
|
||||||
|
document.documentElement.style.setProperty(
|
||||||
|
"--custom-bg-color",
|
||||||
|
customBg,
|
||||||
|
);
|
||||||
document.documentElement.classList.add("has-custom-bg");
|
document.documentElement.classList.add("has-custom-bg");
|
||||||
document.documentElement.classList.remove("has-theme-bg");
|
document.documentElement.classList.remove("has-theme-bg");
|
||||||
}
|
}
|
||||||
@@ -110,8 +134,10 @@ function App() {
|
|||||||
document.documentElement.classList.remove("has-theme-bg");
|
document.documentElement.classList.remove("has-theme-bg");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
updateBg();
|
updateBg();
|
||||||
window.addEventListener("site-config-updated", updateBg);
|
window.addEventListener("site-config-updated", updateBg);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener("site-config-updated", updateBg);
|
window.removeEventListener("site-config-updated", updateBg);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -149,9 +149,12 @@ export const deleteTunnelWithForwards = (data: {
|
|||||||
data,
|
data,
|
||||||
);
|
);
|
||||||
export const previewBatchTunnelDelete = (ids: number[]) =>
|
export const previewBatchTunnelDelete = (ids: number[]) =>
|
||||||
Network.post<TunnelBatchDeletePreviewApiData>("/tunnel/batch-delete-preview", {
|
Network.post<TunnelBatchDeletePreviewApiData>(
|
||||||
ids,
|
"/tunnel/batch-delete-preview",
|
||||||
});
|
{
|
||||||
|
ids,
|
||||||
|
},
|
||||||
|
);
|
||||||
export const batchDeleteTunnelsWithForwards = (data: {
|
export const batchDeleteTunnelsWithForwards = (data: {
|
||||||
ids: number[];
|
ids: number[];
|
||||||
action: "replace" | "delete_forwards";
|
action: "replace" | "delete_forwards";
|
||||||
@@ -496,12 +499,14 @@ export const getServiceMonitorResults = (
|
|||||||
options?: { limit?: number; start?: number; end?: number },
|
options?: { limit?: number; start?: number; end?: number },
|
||||||
) => {
|
) => {
|
||||||
const params: Record<string, string> = {};
|
const params: Record<string, string> = {};
|
||||||
|
|
||||||
if (options?.start != null && options?.end != null) {
|
if (options?.start != null && options?.end != null) {
|
||||||
params.start = String(options.start);
|
params.start = String(options.start);
|
||||||
params.end = String(options.end);
|
params.end = String(options.end);
|
||||||
} else if (options?.limit != null) {
|
} else if (options?.limit != null) {
|
||||||
params.limit = String(options.limit);
|
params.limit = String(options.limit);
|
||||||
}
|
}
|
||||||
|
|
||||||
return Network.get<ServiceMonitorResultApiItem[]>(
|
return Network.get<ServiceMonitorResultApiItem[]>(
|
||||||
`/monitor/services/${monitorId}/results`,
|
`/monitor/services/${monitorId}/results`,
|
||||||
params,
|
params,
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -7,13 +7,14 @@
|
|||||||
* • "Reset to default" option
|
* • "Reset to default" option
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import type { ThemeMode } from "@/themes/registry";
|
||||||
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import toast from "react-hot-toast";
|
import toast from "react-hot-toast";
|
||||||
|
|
||||||
import { Card, CardBody } from "@/shadcn-bridge/heroui/card";
|
import { Card, CardBody } from "@/shadcn-bridge/heroui/card";
|
||||||
import { Button } from "@/shadcn-bridge/heroui/button";
|
import { Button } from "@/shadcn-bridge/heroui/button";
|
||||||
import { useThemeContext } from "@/themes/context";
|
import { useThemeContext } from "@/themes/context";
|
||||||
import type { ThemeMode } from "@/themes/registry";
|
|
||||||
|
|
||||||
// ─── Constants ──────────────────────────────────────────────────────────────
|
// ─── Constants ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -39,12 +40,14 @@ export const ThemeSettings: React.FC = () => {
|
|||||||
const handleModeChange = (m: ThemeMode) => {
|
const handleModeChange = (m: ThemeMode) => {
|
||||||
setMode(m);
|
setMode(m);
|
||||||
const label = m === "light" ? "亮色" : m === "dark" ? "暗色" : "跟随系统";
|
const label = m === "light" ? "亮色" : m === "dark" ? "暗色" : "跟随系统";
|
||||||
|
|
||||||
toast.success(`已切换为${label}模式`);
|
toast.success(`已切换为${label}模式`);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleThemeSelect = (id: string) => {
|
const handleThemeSelect = (id: string) => {
|
||||||
switchTheme(id);
|
switchTheme(id);
|
||||||
const theme = themes.find((t) => t.id === id);
|
const theme = themes.find((t) => t.id === id);
|
||||||
|
|
||||||
toast.success(`已切换主题「${theme?.name ?? id}」`);
|
toast.success(`已切换主题「${theme?.name ?? id}」`);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -125,7 +128,13 @@ export const ThemeSettings: React.FC = () => {
|
|||||||
<div className="flex-1" style={{ background: secondary }} />
|
<div className="flex-1" style={{ background: secondary }} />
|
||||||
<div className="flex-1" style={{ background: success }} />
|
<div className="flex-1" style={{ background: success }} />
|
||||||
<div className="flex-1" style={{ background: danger }} />
|
<div className="flex-1" style={{ background: danger }} />
|
||||||
<div className="flex-1" style={{ background: bg, borderLeft: "1px solid rgba(0,0,0,0.06)" }} />
|
<div
|
||||||
|
className="flex-1"
|
||||||
|
style={{
|
||||||
|
background: bg,
|
||||||
|
borderLeft: "1px solid rgba(0,0,0,0.06)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Info */}
|
{/* Info */}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
function Card({ className, style, ...props }: React.ComponentProps<"div">) {
|
function Card({ className, style, ...props }: React.ComponentProps<"div">) {
|
||||||
@@ -7,16 +8,18 @@ function Card({ className, style, ...props }: React.ComponentProps<"div">) {
|
|||||||
className={cn(
|
className={cn(
|
||||||
"relative flex flex-col transition-all duration-300",
|
"relative flex flex-col transition-all duration-300",
|
||||||
"rounded-[24px] text-card-foreground shadow-[0_12px_40px_rgba(0,0,0,0.12)] dark:shadow-[0_12px_40px_rgba(0,0,0,0.4)]",
|
"rounded-[24px] text-card-foreground shadow-[0_12px_40px_rgba(0,0,0,0.12)] dark:shadow-[0_12px_40px_rgba(0,0,0,0.4)]",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
|
data-slot="card"
|
||||||
style={{
|
style={{
|
||||||
...style,
|
...style,
|
||||||
background: "linear-gradient(135deg, rgba(255,255,255,0.4) 0%, rgba(255,255,255,0.1) 40%, rgba(255,255,255,0.05) 60%, rgba(255,255,255,0.2) 100%)",
|
background:
|
||||||
boxShadow: "inset 0 1px 1px rgba(255,255,255,0.8), inset 0 0 0 1px rgba(255,255,255,0.3), inset 0 -1px 1px rgba(0,0,0,0.1), 0 12px 40px rgba(0,0,0,0.12)",
|
"linear-gradient(135deg, rgba(255,255,255,0.4) 0%, rgba(255,255,255,0.1) 40%, rgba(255,255,255,0.05) 60%, rgba(255,255,255,0.2) 100%)",
|
||||||
|
boxShadow:
|
||||||
|
"inset 0 1px 1px rgba(255,255,255,0.8), inset 0 0 0 1px rgba(255,255,255,0.3), inset 0 -1px 1px rgba(0,0,0,0.1), 0 12px 40px rgba(0,0,0,0.12)",
|
||||||
backdropFilter: "blur(24px) saturate(180%)",
|
backdropFilter: "blur(24px) saturate(180%)",
|
||||||
WebkitBackdropFilter: "blur(24px) saturate(180%)",
|
WebkitBackdropFilter: "blur(24px) saturate(180%)",
|
||||||
}}
|
}}
|
||||||
data-slot="card"
|
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -63,11 +66,7 @@ function CardDescription({ className, ...props }: React.ComponentProps<"p">) {
|
|||||||
|
|
||||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div className={cn("p-6", className)} data-slot="card-content" {...props} />
|
||||||
className={cn("p-6", className)}
|
|
||||||
data-slot="card-content"
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -62,13 +62,15 @@ function DialogContent({
|
|||||||
"rounded-[24px] text-foreground shadow-[0_20px_60px_rgba(0,0,0,0.2)] dark:shadow-[0_20px_60px_rgba(0,0,0,0.5)] border border-white/80 dark:border-white/10",
|
"rounded-[24px] text-foreground shadow-[0_20px_60px_rgba(0,0,0,0.2)] dark:shadow-[0_20px_60px_rgba(0,0,0,0.5)] border border-white/80 dark:border-white/10",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
|
data-slot="dialog-content"
|
||||||
style={{
|
style={{
|
||||||
background: "linear-gradient(135deg, rgba(255,255,255,0.4) 0%, rgba(255,255,255,0.1) 40%, rgba(255,255,255,0.05) 60%, rgba(255,255,255,0.2) 100%)",
|
background:
|
||||||
boxShadow: "inset 0 1px 1px rgba(255,255,255,0.8), inset 0 0 0 1px rgba(255,255,255,0.3), inset 0 -1px 1px rgba(0,0,0,0.1), 0 20px 60px rgba(0,0,0,0.2)",
|
"linear-gradient(135deg, rgba(255,255,255,0.4) 0%, rgba(255,255,255,0.1) 40%, rgba(255,255,255,0.05) 60%, rgba(255,255,255,0.2) 100%)",
|
||||||
|
boxShadow:
|
||||||
|
"inset 0 1px 1px rgba(255,255,255,0.8), inset 0 0 0 1px rgba(255,255,255,0.3), inset 0 -1px 1px rgba(0,0,0,0.1), 0 20px 60px rgba(0,0,0,0.2)",
|
||||||
backdropFilter: "blur(24px) saturate(180%)",
|
backdropFilter: "blur(24px) saturate(180%)",
|
||||||
WebkitBackdropFilter: "blur(24px) saturate(180%)",
|
WebkitBackdropFilter: "blur(24px) saturate(180%)",
|
||||||
}}
|
}}
|
||||||
data-slot="dialog-content"
|
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<div className="flex flex-col flex-1 min-h-0 gap-4 p-6 rounded-[inherit] overflow-hidden bg-white/40 dark:bg-zinc-900/40 w-full h-full relative z-10 pointer-events-auto">
|
<div className="flex flex-col flex-1 min-h-0 gap-4 p-6 rounded-[inherit] overflow-hidden bg-white/40 dark:bg-zinc-900/40 w-full h-full relative z-10 pointer-events-auto">
|
||||||
|
|||||||
@@ -32,8 +32,10 @@ const getInitialConfig = () => {
|
|||||||
localStorage.getItem(CACHE_PREFIX + "app_favicon") || "";
|
localStorage.getItem(CACHE_PREFIX + "app_favicon") || "";
|
||||||
const cachedAppBgImage =
|
const cachedAppBgImage =
|
||||||
localStorage.getItem(CACHE_PREFIX + "app_bg_image") || "";
|
localStorage.getItem(CACHE_PREFIX + "app_bg_image") || "";
|
||||||
const isCommercial = localStorage.getItem(CACHE_PREFIX + "is_commercial") === "true";
|
const isCommercial =
|
||||||
const hideFooterBrand = localStorage.getItem(CACHE_PREFIX + "hide_footer_brand") === "true";
|
localStorage.getItem(CACHE_PREFIX + "is_commercial") === "true";
|
||||||
|
const hideFooterBrand =
|
||||||
|
localStorage.getItem(CACHE_PREFIX + "hide_footer_brand") === "true";
|
||||||
|
|
||||||
if (cachedAppName) {
|
if (cachedAppName) {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -198,19 +198,23 @@ export default function AdminLayout({
|
|||||||
if (adminFlag) {
|
if (adminFlag) {
|
||||||
setMonitorAllowed(true);
|
setMonitorAllowed(true);
|
||||||
setMonitorAccessReason(null);
|
setMonitorAccessReason(null);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const res = await getMonitorAccess();
|
const res = await getMonitorAccess();
|
||||||
|
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
if (res.code === 0 && res.data) {
|
if (res.code === 0 && res.data) {
|
||||||
setMonitorAllowed(Boolean(res.data.allowed));
|
setMonitorAllowed(Boolean(res.data.allowed));
|
||||||
setMonitorAccessReason(
|
setMonitorAccessReason(
|
||||||
res.data.allowed ? null : (res.data.reason || null),
|
res.data.allowed ? null : res.data.reason || null,
|
||||||
);
|
);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Fail open to preserve legacy navigation behavior.
|
// Fail open to preserve legacy navigation behavior.
|
||||||
@@ -408,6 +412,7 @@ export default function AdminLayout({
|
|||||||
return (
|
return (
|
||||||
<li key={item.path}>
|
<li key={item.path}>
|
||||||
<motion.button
|
<motion.button
|
||||||
|
aria-disabled={isMonitorBlocked}
|
||||||
className={`
|
className={`
|
||||||
w-full flex items-center p-3 rounded-2xl text-left
|
w-full flex items-center p-3 rounded-2xl text-left
|
||||||
relative min-h-[48px] overflow-hidden transition-colors
|
relative min-h-[48px] overflow-hidden transition-colors
|
||||||
@@ -415,12 +420,11 @@ export default function AdminLayout({
|
|||||||
${
|
${
|
||||||
isActive
|
isActive
|
||||||
? "text-primary dark:text-primary-400 font-semibold"
|
? "text-primary dark:text-primary-400 font-semibold"
|
||||||
: isMonitorBlocked
|
: isMonitorBlocked
|
||||||
? "text-gray-500 dark:text-gray-400 font-medium"
|
? "text-gray-500 dark:text-gray-400 font-medium"
|
||||||
: "text-gray-600 dark:text-gray-300 font-medium"
|
: "text-gray-600 dark:text-gray-300 font-medium"
|
||||||
}
|
}
|
||||||
`}
|
`}
|
||||||
aria-disabled={isMonitorBlocked}
|
|
||||||
title={
|
title={
|
||||||
isCollapsed
|
isCollapsed
|
||||||
? isMonitorBlocked
|
? isMonitorBlocked
|
||||||
|
|||||||
@@ -114,15 +114,18 @@ export default function H5Layout({ children }: { children: React.ReactNode }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const res = await getMonitorAccess();
|
const res = await getMonitorAccess();
|
||||||
|
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
if (res.code === 0 && res.data) {
|
if (res.code === 0 && res.data) {
|
||||||
setMonitorAllowed(Boolean(res.data.allowed));
|
setMonitorAllowed(Boolean(res.data.allowed));
|
||||||
setMonitorAccessReason(
|
setMonitorAccessReason(
|
||||||
res.data.allowed ? null : (res.data.reason || null),
|
res.data.allowed ? null : res.data.reason || null,
|
||||||
);
|
);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setMonitorAllowed(true);
|
setMonitorAllowed(true);
|
||||||
|
|||||||
@@ -13,7 +13,9 @@ const updateSW = registerSW({
|
|||||||
toast(
|
toast(
|
||||||
(t) => (
|
(t) => (
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
<span className="text-sm font-medium text-foreground">发现新版本,是否立即刷新以应用更新?</span>
|
<span className="text-sm font-medium text-foreground">
|
||||||
|
发现新版本,是否立即刷新以应用更新?
|
||||||
|
</span>
|
||||||
<div className="flex gap-2 justify-end">
|
<div className="flex gap-2 justify-end">
|
||||||
<button
|
<button
|
||||||
className="px-3 py-1.5 text-xs font-medium bg-primary text-primary-foreground hover:bg-primary/90 rounded-md transition-colors"
|
className="px-3 py-1.5 text-xs font-medium bg-primary text-primary-foreground hover:bg-primary/90 rounded-md transition-colors"
|
||||||
@@ -33,7 +35,7 @@ const updateSW = registerSW({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
{ duration: Infinity, position: "bottom-right" }
|
{ duration: Infinity, position: "bottom-right" },
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -89,7 +89,8 @@ const CONFIG_ITEMS: ConfigItem[] = [
|
|||||||
{
|
{
|
||||||
key: "app_bg_image",
|
key: "app_bg_image",
|
||||||
label: "自定义背景",
|
label: "自定义背景",
|
||||||
description: "上传自定义背景图片(建议使用深色/浅色均可看清的图片,或使用半透明模糊效果)",
|
description:
|
||||||
|
"上传自定义背景图片(建议使用深色/浅色均可看清的图片,或使用半透明模糊效果)",
|
||||||
type: "bg_image",
|
type: "bg_image",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -141,7 +142,8 @@ const CONFIG_ITEMS: ConfigItem[] = [
|
|||||||
{
|
{
|
||||||
key: "monitor_tunnel_quality_enabled",
|
key: "monitor_tunnel_quality_enabled",
|
||||||
label: "实时隧道质量检测",
|
label: "实时隧道质量检测",
|
||||||
description: "关闭后,前端停止自动刷新,后端停止实时隧道质量探测(全局配置)",
|
description:
|
||||||
|
"关闭后,前端停止自动刷新,后端停止实时隧道质量探测(全局配置)",
|
||||||
type: "switch",
|
type: "switch",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -388,11 +390,13 @@ export default function ConfigPage() {
|
|||||||
const handleActivateLicense = async () => {
|
const handleActivateLicense = async () => {
|
||||||
if (!licenseKeyInput.trim()) {
|
if (!licenseKeyInput.trim()) {
|
||||||
toast.error("请输入有效的商业授权码");
|
toast.error("请输入有效的商业授权码");
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setActivatingLicense(true);
|
setActivatingLicense(true);
|
||||||
try {
|
try {
|
||||||
const res = await activateLicense(licenseKeyInput.trim());
|
const res = await activateLicense(licenseKeyInput.trim());
|
||||||
|
|
||||||
if (res.code === 0) {
|
if (res.code === 0) {
|
||||||
toast.success("商业版授权激活成功!");
|
toast.success("商业版授权激活成功!");
|
||||||
setLicenseKeyInput("");
|
setLicenseKeyInput("");
|
||||||
@@ -479,7 +483,9 @@ export default function ConfigPage() {
|
|||||||
if (changedKeys.includes("monitor_tunnel_quality_enabled")) {
|
if (changedKeys.includes("monitor_tunnel_quality_enabled")) {
|
||||||
window.dispatchEvent(
|
window.dispatchEvent(
|
||||||
new CustomEvent("monitorTunnelQualityEnabledChanged", {
|
new CustomEvent("monitorTunnelQualityEnabledChanged", {
|
||||||
detail: { enabled: configs["monitor_tunnel_quality_enabled"] === "true" },
|
detail: {
|
||||||
|
enabled: configs["monitor_tunnel_quality_enabled"] === "true",
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -496,7 +502,11 @@ export default function ConfigPage() {
|
|||||||
// 检查配置项是否应该显示(依赖检查)
|
// 检查配置项是否应该显示(依赖检查)
|
||||||
const shouldShowItem = (item: ConfigItem): boolean => {
|
const shouldShowItem = (item: ConfigItem): boolean => {
|
||||||
// 隐藏商业版专属的设置项,它们会在专门的卡片中展示
|
// 隐藏商业版专属的设置项,它们会在专门的卡片中展示
|
||||||
if (["app_name", "app_logo", "app_favicon", "hide_footer_brand"].includes(item.key)) {
|
if (
|
||||||
|
["app_name", "app_logo", "app_favicon", "hide_footer_brand"].includes(
|
||||||
|
item.key,
|
||||||
|
)
|
||||||
|
) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -555,12 +565,16 @@ export default function ConfigPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleBgImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleBgImageUpload = async (
|
||||||
|
e: React.ChangeEvent<HTMLInputElement>,
|
||||||
|
) => {
|
||||||
const file = e.target.files?.[0];
|
const file = e.target.files?.[0];
|
||||||
|
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
|
|
||||||
if (!file.type.startsWith("image/")) {
|
if (!file.type.startsWith("image/")) {
|
||||||
toast.error("只能上传图片文件");
|
toast.error("只能上传图片文件");
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -568,8 +582,10 @@ export default function ConfigPage() {
|
|||||||
try {
|
try {
|
||||||
const compressedImage = await new Promise<string>((resolve, reject) => {
|
const compressedImage = await new Promise<string>((resolve, reject) => {
|
||||||
const reader = new FileReader();
|
const reader = new FileReader();
|
||||||
|
|
||||||
reader.onload = (event) => {
|
reader.onload = (event) => {
|
||||||
const img = new Image();
|
const img = new Image();
|
||||||
|
|
||||||
img.onload = () => {
|
img.onload = () => {
|
||||||
const canvas = document.createElement("canvas");
|
const canvas = document.createElement("canvas");
|
||||||
let width = img.width;
|
let width = img.width;
|
||||||
@@ -592,14 +608,17 @@ export default function ConfigPage() {
|
|||||||
canvas.width = width;
|
canvas.width = width;
|
||||||
canvas.height = height;
|
canvas.height = height;
|
||||||
const ctx = canvas.getContext("2d");
|
const ctx = canvas.getContext("2d");
|
||||||
|
|
||||||
if (!ctx) {
|
if (!ctx) {
|
||||||
reject(new Error("Canvas context is null"));
|
reject(new Error("Canvas context is null"));
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
ctx.drawImage(img, 0, 0, width, height);
|
ctx.drawImage(img, 0, 0, width, height);
|
||||||
|
|
||||||
// Output as webp for better compression
|
// Output as webp for better compression
|
||||||
const dataUrl = canvas.toDataURL("image/webp", 0.8);
|
const dataUrl = canvas.toDataURL("image/webp", 0.8);
|
||||||
|
|
||||||
resolve(dataUrl);
|
resolve(dataUrl);
|
||||||
};
|
};
|
||||||
img.onerror = () => reject(new Error("图片加载失败"));
|
img.onerror = () => reject(new Error("图片加载失败"));
|
||||||
@@ -621,7 +640,11 @@ export default function ConfigPage() {
|
|||||||
|
|
||||||
const renderBgImageUploader = () => {
|
const renderBgImageUploader = () => {
|
||||||
const bgImage = configs["app_bg_image"] || "";
|
const bgImage = configs["app_bg_image"] || "";
|
||||||
const isImage = bgImage.startsWith("http") || bgImage.startsWith("data:") || bgImage.startsWith("/") || bgImage.startsWith("blob:");
|
const isImage =
|
||||||
|
bgImage.startsWith("http") ||
|
||||||
|
bgImage.startsWith("data:") ||
|
||||||
|
bgImage.startsWith("/") ||
|
||||||
|
bgImage.startsWith("blob:");
|
||||||
const isTheme = bgImage === "theme";
|
const isTheme = bgImage === "theme";
|
||||||
const isSolidColor = bgImage && !isImage && !isTheme;
|
const isSolidColor = bgImage && !isImage && !isTheme;
|
||||||
|
|
||||||
@@ -629,42 +652,42 @@ export default function ConfigPage() {
|
|||||||
<div className="flex flex-col gap-4 w-full">
|
<div className="flex flex-col gap-4 w-full">
|
||||||
<div className="flex flex-wrap items-center gap-4">
|
<div className="flex flex-wrap items-center gap-4">
|
||||||
<input
|
<input
|
||||||
type="file"
|
|
||||||
accept="image/*"
|
|
||||||
ref={bgImageFileInputRef}
|
ref={bgImageFileInputRef}
|
||||||
|
accept="image/*"
|
||||||
className="hidden"
|
className="hidden"
|
||||||
|
type="file"
|
||||||
onChange={handleBgImageUpload}
|
onChange={handleBgImageUpload}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
color="primary"
|
color="primary"
|
||||||
|
isLoading={bgImageUploading}
|
||||||
variant="flat"
|
variant="flat"
|
||||||
onPress={() => bgImageFileInputRef.current?.click()}
|
onPress={() => bgImageFileInputRef.current?.click()}
|
||||||
isLoading={bgImageUploading}
|
|
||||||
>
|
>
|
||||||
上传图片
|
上传图片
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
color="secondary"
|
color="secondary"
|
||||||
|
isDisabled={bgImageUploading || isTheme}
|
||||||
variant="flat"
|
variant="flat"
|
||||||
onPress={() => handleConfigChange("app_bg_image", "theme")}
|
onPress={() => handleConfigChange("app_bg_image", "theme")}
|
||||||
isDisabled={bgImageUploading || isTheme}
|
|
||||||
>
|
>
|
||||||
自适应纯色 (跟随深色模式)
|
自适应纯色 (跟随深色模式)
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
color="default"
|
color="default"
|
||||||
|
isDisabled={bgImageUploading || bgImage === "#ffffff"}
|
||||||
variant="flat"
|
variant="flat"
|
||||||
onPress={() => handleConfigChange("app_bg_image", "#ffffff")}
|
onPress={() => handleConfigChange("app_bg_image", "#ffffff")}
|
||||||
isDisabled={bgImageUploading || bgImage === "#ffffff"}
|
|
||||||
>
|
>
|
||||||
白色纯色
|
白色纯色
|
||||||
</Button>
|
</Button>
|
||||||
{bgImage && (
|
{bgImage && (
|
||||||
<Button
|
<Button
|
||||||
color="danger"
|
color="danger"
|
||||||
|
isDisabled={bgImageUploading}
|
||||||
variant="flat"
|
variant="flat"
|
||||||
onPress={() => handleConfigChange("app_bg_image", "")}
|
onPress={() => handleConfigChange("app_bg_image", "")}
|
||||||
isDisabled={bgImageUploading}
|
|
||||||
>
|
>
|
||||||
恢复默认
|
恢复默认
|
||||||
</Button>
|
</Button>
|
||||||
@@ -674,9 +697,9 @@ export default function ConfigPage() {
|
|||||||
{bgImage && isImage && (
|
{bgImage && isImage && (
|
||||||
<div className="relative rounded-xl overflow-hidden border border-divider">
|
<div className="relative rounded-xl overflow-hidden border border-divider">
|
||||||
<img
|
<img
|
||||||
src={bgImage}
|
|
||||||
alt="背景预览"
|
alt="背景预览"
|
||||||
className="w-full max-h-48 object-cover opacity-80"
|
className="w-full max-h-48 object-cover opacity-80"
|
||||||
|
src={bgImage}
|
||||||
/>
|
/>
|
||||||
<div className="absolute inset-0 bg-gradient-to-t from-black/50 to-transparent flex items-end p-4">
|
<div className="absolute inset-0 bg-gradient-to-t from-black/50 to-transparent flex items-end p-4">
|
||||||
<span className="text-white text-sm font-medium">预览效果</span>
|
<span className="text-white text-sm font-medium">预览效果</span>
|
||||||
@@ -686,7 +709,9 @@ export default function ConfigPage() {
|
|||||||
|
|
||||||
{bgImage && isTheme && (
|
{bgImage && isTheme && (
|
||||||
<div className="relative rounded-xl border border-divider bg-background h-32 flex items-center justify-center">
|
<div className="relative rounded-xl border border-divider bg-background h-32 flex items-center justify-center">
|
||||||
<span className="text-foreground text-sm font-medium">当前为自适应纯色背景</span>
|
<span className="text-foreground text-sm font-medium">
|
||||||
|
当前为自适应纯色背景
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -811,8 +836,8 @@ export default function ConfigPage() {
|
|||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<Button
|
<Button
|
||||||
color="primary"
|
color="primary"
|
||||||
isLoading={uploading}
|
|
||||||
isDisabled={isCommercialDisabled}
|
isDisabled={isCommercialDisabled}
|
||||||
|
isLoading={uploading}
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="flat"
|
variant="flat"
|
||||||
onPress={() => triggerBrandFilePicker(key)}
|
onPress={() => triggerBrandFilePicker(key)}
|
||||||
@@ -853,7 +878,10 @@ export default function ConfigPage() {
|
|||||||
const renderConfigItem = (item: ConfigItem) => {
|
const renderConfigItem = (item: ConfigItem) => {
|
||||||
const isChanged =
|
const isChanged =
|
||||||
hasChanges && configs[item.key] !== originalConfigs[item.key];
|
hasChanges && configs[item.key] !== originalConfigs[item.key];
|
||||||
const isCommercialDisabled = ["app_name", "app_logo", "app_favicon", "hide_footer_brand"].includes(item.key) && configs.is_commercial !== "true";
|
const isCommercialDisabled =
|
||||||
|
["app_name", "app_logo", "app_favicon", "hide_footer_brand"].includes(
|
||||||
|
item.key,
|
||||||
|
) && configs.is_commercial !== "true";
|
||||||
|
|
||||||
switch (item.type) {
|
switch (item.type) {
|
||||||
case "bg_image":
|
case "bg_image":
|
||||||
@@ -872,13 +900,15 @@ export default function ConfigPage() {
|
|||||||
? "border-warning-300 data-[hover=true]:border-warning-400"
|
? "border-warning-300 data-[hover=true]:border-warning-400"
|
||||||
: "",
|
: "",
|
||||||
}}
|
}}
|
||||||
|
description={
|
||||||
|
isCommercialDisabled ? "需商业版授权才能修改此项" : undefined
|
||||||
|
}
|
||||||
|
isDisabled={isCommercialDisabled}
|
||||||
placeholder={item.placeholder}
|
placeholder={item.placeholder}
|
||||||
size="md"
|
size="md"
|
||||||
value={configs[item.key] || ""}
|
value={configs[item.key] || ""}
|
||||||
variant="bordered"
|
variant="bordered"
|
||||||
onChange={(e) => handleConfigChange(item.key, e.target.value)}
|
onChange={(e) => handleConfigChange(item.key, e.target.value)}
|
||||||
isDisabled={isCommercialDisabled}
|
|
||||||
description={isCommercialDisabled ? "需商业版授权才能修改此项" : undefined}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -888,12 +918,12 @@ export default function ConfigPage() {
|
|||||||
classNames={{
|
classNames={{
|
||||||
wrapper: isChanged ? "border-warning-300" : "",
|
wrapper: isChanged ? "border-warning-300" : "",
|
||||||
}}
|
}}
|
||||||
|
isDisabled={isCommercialDisabled}
|
||||||
isSelected={configs[item.key] === "true"}
|
isSelected={configs[item.key] === "true"}
|
||||||
size="sm"
|
size="sm"
|
||||||
onValueChange={(checked) =>
|
onValueChange={(checked) =>
|
||||||
handleConfigChange(item.key, checked ? "true" : "false")
|
handleConfigChange(item.key, checked ? "true" : "false")
|
||||||
}
|
}
|
||||||
isDisabled={isCommercialDisabled}
|
|
||||||
>
|
>
|
||||||
<span className="text-sm text-gray-700 dark:text-gray-300">
|
<span className="text-sm text-gray-700 dark:text-gray-300">
|
||||||
{configs[item.key] === "true" ? "已启用" : "已禁用"}
|
{configs[item.key] === "true" ? "已启用" : "已禁用"}
|
||||||
@@ -1126,7 +1156,8 @@ export default function ConfigPage() {
|
|||||||
<div>
|
<div>
|
||||||
<h2 className="text-xl font-semibold">商业版授权</h2>
|
<h2 className="text-xl font-semibold">商业版授权</h2>
|
||||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
激活商业版授权以解锁自定义品牌功能(替换 Logo、应用名称,移除底部版权信息等)
|
激活商业版授权以解锁自定义品牌功能(替换
|
||||||
|
Logo、应用名称,移除底部版权信息等)
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1135,18 +1166,26 @@ export default function ConfigPage() {
|
|||||||
<CardBody className="pt-8">
|
<CardBody className="pt-8">
|
||||||
<div className="flex items-end gap-3 max-w-lg mb-6">
|
<div className="flex items-end gap-3 max-w-lg mb-6">
|
||||||
<Input
|
<Input
|
||||||
|
description={
|
||||||
|
configs.is_commercial === "true"
|
||||||
|
? configs.license_expiry && configs.license_expiry !== "never"
|
||||||
|
? `已激活商业版授权 (有效期至: ${new Date(configs.license_expiry).toLocaleDateString()})`
|
||||||
|
: "已激活商业版授权 (永久有效)"
|
||||||
|
: "需商业授权才能修改站名、图标并隐藏页脚品牌"
|
||||||
|
}
|
||||||
|
isDisabled={configs.is_commercial === "true"}
|
||||||
label="授权激活码"
|
label="授权激活码"
|
||||||
placeholder="请输入 FLVX- 开头的商业授权码"
|
placeholder="请输入 FLVX- 开头的商业授权码"
|
||||||
value={licenseKeyInput}
|
value={licenseKeyInput}
|
||||||
variant="bordered"
|
variant="bordered"
|
||||||
onChange={(e) => setLicenseKeyInput(e.target.value)}
|
onChange={(e) => setLicenseKeyInput(e.target.value)}
|
||||||
isDisabled={configs.is_commercial === "true"}
|
|
||||||
description={configs.is_commercial === "true" ? (configs.license_expiry && configs.license_expiry !== "never" ? `已激活商业版授权 (有效期至: ${new Date(configs.license_expiry).toLocaleDateString()})` : "已激活商业版授权 (永久有效)") : "需商业授权才能修改站名、图标并隐藏页脚品牌"}
|
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
color="primary"
|
|
||||||
className="mb-6"
|
className="mb-6"
|
||||||
isDisabled={configs.is_commercial === "true" || !licenseKeyInput.trim()}
|
color="primary"
|
||||||
|
isDisabled={
|
||||||
|
configs.is_commercial === "true" || !licenseKeyInput.trim()
|
||||||
|
}
|
||||||
isLoading={activatingLicense}
|
isLoading={activatingLicense}
|
||||||
onPress={handleActivateLicense}
|
onPress={handleActivateLicense}
|
||||||
>
|
>
|
||||||
@@ -1157,8 +1196,17 @@ export default function ConfigPage() {
|
|||||||
{configs.is_commercial === "true" && (
|
{configs.is_commercial === "true" && (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<Divider className="my-2" />
|
<Divider className="my-2" />
|
||||||
<h3 className="text-md font-medium text-default-700">白标与品牌设置</h3>
|
<h3 className="text-md font-medium text-default-700">
|
||||||
{CONFIG_ITEMS.filter(item => ["app_name", "app_logo", "app_favicon", "hide_footer_brand"].includes(item.key)).map((item, index) => (
|
白标与品牌设置
|
||||||
|
</h3>
|
||||||
|
{CONFIG_ITEMS.filter((item) =>
|
||||||
|
[
|
||||||
|
"app_name",
|
||||||
|
"app_logo",
|
||||||
|
"app_favicon",
|
||||||
|
"hide_footer_brand",
|
||||||
|
].includes(item.key),
|
||||||
|
).map((item, index) => (
|
||||||
<div key={item.key}>
|
<div key={item.key}>
|
||||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-[1fr_2fr]">
|
<div className="grid grid-cols-1 gap-6 md:grid-cols-[1fr_2fr]">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
@@ -1511,18 +1559,18 @@ export default function ConfigPage() {
|
|||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{hasChanges && (
|
{hasChanges && (
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ y: 100, opacity: 0 }}
|
|
||||||
animate={{ y: 0, opacity: 1 }}
|
animate={{ y: 0, opacity: 1 }}
|
||||||
exit={{ y: 100, opacity: 0 }}
|
|
||||||
transition={{ type: "spring", damping: 20, stiffness: 300 }}
|
|
||||||
className="fixed bottom-6 right-6 z-50"
|
className="fixed bottom-6 right-6 z-50"
|
||||||
|
exit={{ y: 100, opacity: 0 }}
|
||||||
|
initial={{ y: 100, opacity: 0 }}
|
||||||
|
transition={{ type: "spring", damping: 20, stiffness: 300 }}
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
isIconOnly
|
isIconOnly
|
||||||
color="primary"
|
|
||||||
size="lg"
|
|
||||||
className="w-12 h-12 rounded-full shadow-lg"
|
className="w-12 h-12 rounded-full shadow-lg"
|
||||||
|
color="primary"
|
||||||
isLoading={saving}
|
isLoading={saving}
|
||||||
|
size="lg"
|
||||||
onPress={handleSave}
|
onPress={handleSave}
|
||||||
>
|
>
|
||||||
{!saving && <SaveIcon className="w-5 h-5" />}
|
{!saving && <SaveIcon className="w-5 h-5" />}
|
||||||
|
|||||||
@@ -651,25 +651,44 @@ export default function DashboardPage() {
|
|||||||
{/* 顶部个人状态和下拉菜单 */}
|
{/* 顶部个人状态和下拉菜单 */}
|
||||||
<div className="flex justify-between items-center mb-6">
|
<div className="flex justify-between items-center mb-6">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl lg:text-3xl font-bold text-foreground">Good morning, {username}</h1>
|
<h1 className="text-2xl lg:text-3xl font-bold text-foreground">
|
||||||
<p className="text-sm text-default-500 mt-1">Here's what's happening with your network today.</p>
|
Good morning, {username}
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-default-500 mt-1">
|
||||||
|
Here's what's happening with your network today.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Dropdown placement="bottom-end">
|
<Dropdown placement="bottom-end">
|
||||||
<DropdownTrigger>
|
<DropdownTrigger>
|
||||||
<Button
|
<Button
|
||||||
className="rounded-full w-10 h-10 min-w-0 bg-primary text-white font-bold text-sm shadow-[0_4px_12px_rgba(0,122,255,0.3)]"
|
|
||||||
isIconOnly
|
isIconOnly
|
||||||
|
className="rounded-full w-10 h-10 min-w-0 bg-primary text-white font-bold text-sm shadow-[0_4px_12px_rgba(0,122,255,0.3)]"
|
||||||
variant="solid"
|
variant="solid"
|
||||||
>
|
>
|
||||||
{username.slice(0, 2).toUpperCase()}
|
{username.slice(0, 2).toUpperCase()}
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownTrigger>
|
</DropdownTrigger>
|
||||||
<DropdownMenu aria-label="用户菜单" className="bg-white/80 dark:bg-zinc-900/80 backdrop-blur-xl border border-white/50 dark:border-white/10 rounded-2xl shadow-[0_10px_30px_rgba(0,0,0,0.1)]">
|
<DropdownMenu
|
||||||
|
aria-label="用户菜单"
|
||||||
|
className="bg-white/80 dark:bg-zinc-900/80 backdrop-blur-xl border border-white/50 dark:border-white/10 rounded-2xl shadow-[0_10px_30px_rgba(0,0,0,0.1)]"
|
||||||
|
>
|
||||||
<DropdownItem
|
<DropdownItem
|
||||||
key="profile"
|
key="profile"
|
||||||
startContent={
|
startContent={
|
||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" /></svg>
|
<svg
|
||||||
|
className="w-4 h-4"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
}
|
}
|
||||||
onPress={() => navigate("/profile")}
|
onPress={() => navigate("/profile")}
|
||||||
>
|
>
|
||||||
@@ -680,7 +699,17 @@ export default function DashboardPage() {
|
|||||||
className="text-danger"
|
className="text-danger"
|
||||||
color="danger"
|
color="danger"
|
||||||
startContent={
|
startContent={
|
||||||
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20"><path fillRule="evenodd" d="M3 3a1 1 0 00-1 1v12a1 1 0 102 0V4a1 1 0 00-1-1zm10.293 9.293a1 1 0 001.414 1.414l3-3a1 1 0 000-1.414l-3-3a1 1 0 10-1.414 1.414L14.586 9H7a1 1 0 100 2h7.586l-1.293 1.293z" clipRule="evenodd" /></svg>
|
<svg
|
||||||
|
className="w-4 h-4"
|
||||||
|
fill="currentColor"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
clipRule="evenodd"
|
||||||
|
d="M3 3a1 1 0 00-1 1v12a1 1 0 102 0V4a1 1 0 00-1-1zm10.293 9.293a1 1 0 001.414 1.414l3-3a1 1 0 000-1.414l-3-3a1 1 0 10-1.414 1.414L14.586 9H7a1 1 0 100 2h7.586l-1.293 1.293z"
|
||||||
|
fillRule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
}
|
}
|
||||||
onPress={handleLogout}
|
onPress={handleLogout}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
import type { AnnouncementData } from "@/api";
|
import type { AnnouncementData } from "@/api";
|
||||||
|
|
||||||
|
import ReactMarkdown from "react-markdown";
|
||||||
|
import remarkGfm from "remark-gfm";
|
||||||
|
|
||||||
import { Button } from "@/shadcn-bridge/heroui/button";
|
import { Button } from "@/shadcn-bridge/heroui/button";
|
||||||
import {
|
import {
|
||||||
Modal,
|
Modal,
|
||||||
@@ -7,8 +11,6 @@ import {
|
|||||||
ModalFooter,
|
ModalFooter,
|
||||||
ModalHeader,
|
ModalHeader,
|
||||||
} from "@/shadcn-bridge/heroui/modal";
|
} from "@/shadcn-bridge/heroui/modal";
|
||||||
import ReactMarkdown from "react-markdown";
|
|
||||||
import remarkGfm from "remark-gfm";
|
|
||||||
|
|
||||||
interface AnnouncementModalProps {
|
interface AnnouncementModalProps {
|
||||||
announcement: AnnouncementData;
|
announcement: AnnouncementData;
|
||||||
@@ -24,7 +26,11 @@ export const AnnouncementModal = ({
|
|||||||
onDontShowAgain,
|
onDontShowAgain,
|
||||||
}: AnnouncementModalProps) => {
|
}: AnnouncementModalProps) => {
|
||||||
return (
|
return (
|
||||||
<Modal isOpen={isOpen} onOpenChange={(open) => !open && onClose()} size="2xl">
|
<Modal
|
||||||
|
isOpen={isOpen}
|
||||||
|
size="2xl"
|
||||||
|
onOpenChange={(open) => !open && onClose()}
|
||||||
|
>
|
||||||
<ModalContent>
|
<ModalContent>
|
||||||
<ModalHeader className="flex flex-col gap-1">平台公告</ModalHeader>
|
<ModalHeader className="flex flex-col gap-1">平台公告</ModalHeader>
|
||||||
<ModalBody>
|
<ModalBody>
|
||||||
|
|||||||
@@ -50,7 +50,10 @@ export const FlowChartCard = ({
|
|||||||
) : (
|
) : (
|
||||||
<div className="h-64 lg:h-80 w-full">
|
<div className="h-64 lg:h-80 w-full">
|
||||||
<ResponsiveContainer height="100%" width="100%">
|
<ResponsiveContainer height="100%" width="100%">
|
||||||
<LineChart data={chartData} margin={{ top: 20, right: 0, left: -20, bottom: 0 }}>
|
<LineChart
|
||||||
|
data={chartData}
|
||||||
|
margin={{ top: 20, right: 0, left: -20, bottom: 0 }}
|
||||||
|
>
|
||||||
<XAxis
|
<XAxis
|
||||||
axisLine={false}
|
axisLine={false}
|
||||||
dataKey="time"
|
dataKey="time"
|
||||||
@@ -106,11 +109,7 @@ export const FlowChartCard = ({
|
|||||||
}}
|
}}
|
||||||
cursor={{ fill: "rgba(0, 122, 255, 0.05)" }}
|
cursor={{ fill: "rgba(0, 122, 255, 0.05)" }}
|
||||||
/>
|
/>
|
||||||
<Line
|
<Line dataKey="flow" stroke="#007aff" type="monotone" />
|
||||||
dataKey="flow"
|
|
||||||
stroke="#007aff"
|
|
||||||
type="monotone"
|
|
||||||
/>
|
|
||||||
</LineChart>
|
</LineChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -25,9 +25,7 @@ export const MetricCard = ({
|
|||||||
<p className="text-sm font-medium text-default-600 truncate">
|
<p className="text-sm font-medium text-default-600 truncate">
|
||||||
{title}
|
{title}
|
||||||
</p>
|
</p>
|
||||||
<div
|
<div className={`p-2 rounded-xl flex-shrink-0 ${iconClassName}`}>
|
||||||
className={`p-2 rounded-xl flex-shrink-0 ${iconClassName}`}
|
|
||||||
>
|
|
||||||
{icon}
|
{icon}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -35,9 +33,7 @@ export const MetricCard = ({
|
|||||||
{value}
|
{value}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-auto pt-4">
|
<div className="mt-auto pt-4">{bottomContent}</div>
|
||||||
{bottomContent}
|
|
||||||
</div>
|
|
||||||
</CardBody>
|
</CardBody>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -351,7 +351,9 @@ export const useDashboardData = (): DashboardDataState => {
|
|||||||
setAnnouncement(res.data);
|
setAnnouncement(res.data);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const storedTimeStr = localStorage.getItem("flvx_announcement_seen_time");
|
const storedTimeStr = localStorage.getItem(
|
||||||
|
"flvx_announcement_seen_time",
|
||||||
|
);
|
||||||
const storedTime = storedTimeStr ? parseInt(storedTimeStr, 10) : 0;
|
const storedTime = storedTimeStr ? parseInt(storedTimeStr, 10) : 0;
|
||||||
const updateTime = res.data.update_time || 0;
|
const updateTime = res.data.update_time || 0;
|
||||||
|
|
||||||
@@ -359,7 +361,10 @@ export const useDashboardData = (): DashboardDataState => {
|
|||||||
setIsAnnouncementModalOpen(true);
|
setIsAnnouncementModalOpen(true);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn("Failed to read localStorage for announcement state", err);
|
console.warn(
|
||||||
|
"Failed to read localStorage for announcement state",
|
||||||
|
err,
|
||||||
|
);
|
||||||
setIsAnnouncementModalOpen(true);
|
setIsAnnouncementModalOpen(true);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -376,7 +381,10 @@ export const useDashboardData = (): DashboardDataState => {
|
|||||||
setIsAnnouncementModalOpen(false);
|
setIsAnnouncementModalOpen(false);
|
||||||
if (announcement && announcement.update_time) {
|
if (announcement && announcement.update_time) {
|
||||||
try {
|
try {
|
||||||
localStorage.setItem("flvx_announcement_seen_time", announcement.update_time.toString());
|
localStorage.setItem(
|
||||||
|
"flvx_announcement_seen_time",
|
||||||
|
announcement.update_time.toString(),
|
||||||
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn("Failed to set localStorage for announcement state", err);
|
console.warn("Failed to set localStorage for announcement state", err);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4189,7 +4189,8 @@ export default function ForwardPage() {
|
|||||||
<Table
|
<Table
|
||||||
aria-label="全部规则列表"
|
aria-label="全部规则列表"
|
||||||
classNames={{
|
classNames={{
|
||||||
wrapper: "bg-transparent p-0 shadow-none border-none overflow-hidden rounded-[24px]",
|
wrapper:
|
||||||
|
"bg-transparent p-0 shadow-none border-none overflow-hidden rounded-[24px]",
|
||||||
th: "bg-default-100/50 text-default-600 font-semibold text-sm border-b border-divider py-3 uppercase tracking-wider first:rounded-tl-[24px] last:rounded-tr-[24px]",
|
th: "bg-default-100/50 text-default-600 font-semibold text-sm border-b border-divider py-3 uppercase tracking-wider first:rounded-tl-[24px] last:rounded-tr-[24px]",
|
||||||
td: "py-3 border-b border-divider/50 group-data-[last=true]:border-b-0",
|
td: "py-3 border-b border-divider/50 group-data-[last=true]:border-b-0",
|
||||||
tr: "hover:bg-white/10 dark:hover:bg-white/5 transition-colors group",
|
tr: "hover:bg-white/10 dark:hover:bg-white/5 transition-colors group",
|
||||||
@@ -4439,7 +4440,8 @@ export default function ForwardPage() {
|
|||||||
aria-label={`${group.userName}-${tunnel.tunnelName}规则列表`}
|
aria-label={`${group.userName}-${tunnel.tunnelName}规则列表`}
|
||||||
className={`table-fixed ${FORWARD_GROUPED_TABLE_MIN_WIDTH_CLASS}`}
|
className={`table-fixed ${FORWARD_GROUPED_TABLE_MIN_WIDTH_CLASS}`}
|
||||||
classNames={{
|
classNames={{
|
||||||
wrapper: "bg-transparent p-0 shadow-none border-none overflow-hidden rounded-2xl",
|
wrapper:
|
||||||
|
"bg-transparent p-0 shadow-none border-none overflow-hidden rounded-2xl",
|
||||||
th: "bg-transparent text-default-600 font-semibold text-sm border-b border-white/20 dark:border-white/10 py-3 uppercase tracking-wider first:rounded-tl-[24px] last:rounded-tr-[24px]",
|
th: "bg-transparent text-default-600 font-semibold text-sm border-b border-white/20 dark:border-white/10 py-3 uppercase tracking-wider first:rounded-tl-[24px] last:rounded-tr-[24px]",
|
||||||
td: "py-3 border-b border-divider/50 group-data-[last=true]:border-b-0",
|
td: "py-3 border-b border-divider/50 group-data-[last=true]:border-b-0",
|
||||||
tr: "hover:bg-white/40 dark:hover:bg-white/10 transition-colors",
|
tr: "hover:bg-white/40 dark:hover:bg-white/10 transition-colors",
|
||||||
@@ -5447,7 +5449,7 @@ export default function ForwardPage() {
|
|||||||
|
|
||||||
{/* 统计摘要 */}
|
{/* 统计摘要 */}
|
||||||
<div className="grid grid-cols-3 gap-3">
|
<div className="grid grid-cols-3 gap-3">
|
||||||
<div className="text-center p-3 bg-default-100 dark:bg-gray-800 rounded-lg border border-divider">
|
<div className="text-center p-3 bg-default-100 rounded-lg border border-divider">
|
||||||
<div className="text-2xl font-bold text-foreground">
|
<div className="text-2xl font-bold text-foreground">
|
||||||
{diagnosisProgress.total > 0
|
{diagnosisProgress.total > 0
|
||||||
? diagnosisProgress.total
|
? diagnosisProgress.total
|
||||||
@@ -5519,7 +5521,7 @@ export default function ForwardPage() {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={title}
|
key={title}
|
||||||
className="border border-divider rounded-lg overflow-hidden bg-white dark:bg-gray-800"
|
className="border border-divider rounded-lg overflow-hidden"
|
||||||
>
|
>
|
||||||
<div className="bg-primary/10 dark:bg-primary/20 px-3 py-2 border-b border-divider">
|
<div className="bg-primary/10 dark:bg-primary/20 px-3 py-2 border-b border-divider">
|
||||||
<h3 className="text-sm font-semibold text-primary">
|
<h3 className="text-sm font-semibold text-primary">
|
||||||
@@ -5527,7 +5529,7 @@ export default function ForwardPage() {
|
|||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead className="bg-default-100 dark:bg-gray-700">
|
<thead className="bg-default-100">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-3 py-2 text-left font-semibold text-xs">
|
<th className="px-3 py-2 text-left font-semibold text-xs">
|
||||||
路径
|
路径
|
||||||
@@ -5546,7 +5548,7 @@ export default function ForwardPage() {
|
|||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-divider bg-white dark:bg-gray-800">
|
<tbody className="divide-y divide-divider bg-content1">
|
||||||
{results.map((result, index) => {
|
{results.map((result, index) => {
|
||||||
const isDiagnosing = Boolean(
|
const isDiagnosing = Boolean(
|
||||||
result.diagnosing,
|
result.diagnosing,
|
||||||
@@ -5565,7 +5567,7 @@ export default function ForwardPage() {
|
|||||||
isDiagnosing
|
isDiagnosing
|
||||||
? "bg-warning-50 dark:bg-warning-900/20"
|
? "bg-warning-50 dark:bg-warning-900/20"
|
||||||
: isSuccess
|
: isSuccess
|
||||||
? "bg-white dark:bg-gray-800"
|
? "bg-content1"
|
||||||
: "bg-danger-50 dark:bg-danger-900/30"
|
: "bg-danger-50 dark:bg-danger-900/30"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@@ -5752,7 +5754,7 @@ export default function ForwardPage() {
|
|||||||
isDiagnosing
|
isDiagnosing
|
||||||
? "border-warning-200 dark:border-warning-300/30 bg-warning-50 dark:bg-warning-900/20"
|
? "border-warning-200 dark:border-warning-300/30 bg-warning-50 dark:bg-warning-900/20"
|
||||||
: isSuccess
|
: isSuccess
|
||||||
? "border-divider bg-white dark:bg-gray-800"
|
? "border-divider bg-content1"
|
||||||
: "border-danger-200 dark:border-danger-300/30 bg-danger-50 dark:bg-danger-900/30"
|
: "border-danger-200 dark:border-danger-300/30 bg-danger-50 dark:bg-danger-900/30"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@@ -5940,7 +5942,7 @@ export default function ForwardPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</ModalBody>
|
</ModalBody>
|
||||||
<ModalFooter className="bg-transparent border-t border-white/20 dark:border-white/10">
|
<ModalFooter className="bg-transparent border-t border-divider">
|
||||||
<Button variant="light" onPress={onClose}>
|
<Button variant="light" onPress={onClose}>
|
||||||
关闭
|
关闭
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
+435
-433
@@ -24,7 +24,6 @@ import {
|
|||||||
TableRow,
|
TableRow,
|
||||||
} from "@/shadcn-bridge/heroui/table";
|
} from "@/shadcn-bridge/heroui/table";
|
||||||
import { Chip } from "@/shadcn-bridge/heroui/chip";
|
import { Chip } from "@/shadcn-bridge/heroui/chip";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
assignGroupPermission,
|
assignGroupPermission,
|
||||||
assignTunnelsToGroup,
|
assignTunnelsToGroup,
|
||||||
@@ -477,451 +476,454 @@ export default function GroupPage() {
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="flex flex-row items-center gap-3 pb-2">
|
<CardHeader className="flex flex-row items-center gap-3 pb-2">
|
||||||
<h3 className="text-lg font-semibold">隧道分组</h3>
|
<h3 className="text-lg font-semibold">隧道分组</h3>
|
||||||
<Button
|
<Button
|
||||||
className="h-7 px-3 text-xs font-medium min-w-0 shadow-sm"
|
className="h-7 px-3 text-xs font-medium min-w-0 shadow-sm"
|
||||||
color="primary"
|
color="primary"
|
||||||
size="sm"
|
size="sm"
|
||||||
onPress={openCreateTunnelGroup}
|
onPress={openCreateTunnelGroup}
|
||||||
>
|
>
|
||||||
新建
|
新建
|
||||||
</Button>
|
</Button>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardBody>
|
<CardBody>
|
||||||
<Table
|
<Table
|
||||||
aria-label="隧道分组列表"
|
aria-label="隧道分组列表"
|
||||||
|
classNames={{
|
||||||
|
wrapper:
|
||||||
|
"bg-transparent p-0 shadow-none border-none overflow-hidden rounded-2xl",
|
||||||
|
th: "bg-transparent text-default-600 font-semibold text-sm border-b border-white/20 dark:border-white/10 py-3 uppercase tracking-wider first:rounded-tl-[24px] last:rounded-tr-[24px]",
|
||||||
|
td: "py-3 border-b border-divider/50 group-data-[last=true]:border-b-0",
|
||||||
|
tr: "hover:bg-white/40 dark:hover:bg-white/10 transition-colors",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TableHeader>
|
||||||
|
<TableColumn>名称</TableColumn>
|
||||||
|
<TableColumn>隧道</TableColumn>
|
||||||
|
<TableColumn>状态</TableColumn>
|
||||||
|
<TableColumn>创建时间</TableColumn>
|
||||||
|
<TableColumn>操作</TableColumn>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody emptyContent="暂无隧道分组" items={tunnelGroups}>
|
||||||
|
{(item) => (
|
||||||
|
<TableRow key={item.id}>
|
||||||
|
<TableCell>{item.name}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{item.tunnelNames.length > 0
|
||||||
|
? item.tunnelNames.join("、")
|
||||||
|
: "-"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Chip
|
||||||
|
color={item.status === 1 ? "success" : "danger"}
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
{item.status === 1 ? "启用" : "停用"}
|
||||||
|
</Chip>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{formatDate(item.createdTime)}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="flat"
|
||||||
|
onPress={() => openAssignTunnels(item)}
|
||||||
|
>
|
||||||
|
分配隧道
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="light"
|
||||||
|
onPress={() => openEditTunnelGroup(item)}
|
||||||
|
>
|
||||||
|
编辑
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
color="danger"
|
||||||
|
size="sm"
|
||||||
|
variant="light"
|
||||||
|
onPress={() => handleDeleteTunnelGroup(item.id)}
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center gap-3 pb-2">
|
||||||
|
<h3 className="text-lg font-semibold">用户分组</h3>
|
||||||
|
<Button
|
||||||
|
className="h-7 px-3 text-xs font-medium min-w-0 shadow-sm"
|
||||||
|
color="primary"
|
||||||
|
size="sm"
|
||||||
|
onPress={openCreateUserGroup}
|
||||||
|
>
|
||||||
|
新建
|
||||||
|
</Button>
|
||||||
|
</CardHeader>
|
||||||
|
<CardBody>
|
||||||
|
<Table
|
||||||
|
aria-label="用户分组列表"
|
||||||
|
classNames={{
|
||||||
|
wrapper:
|
||||||
|
"bg-transparent p-0 shadow-none border-none overflow-hidden rounded-2xl",
|
||||||
|
th: "bg-transparent text-default-600 font-semibold text-sm border-b border-white/20 dark:border-white/10 py-3 uppercase tracking-wider first:rounded-tl-[24px] last:rounded-tr-[24px]",
|
||||||
|
td: "py-3 border-b border-divider/50 group-data-[last=true]:border-b-0",
|
||||||
|
tr: "hover:bg-white/40 dark:hover:bg-white/10 transition-colors",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TableHeader>
|
||||||
|
<TableColumn>名称</TableColumn>
|
||||||
|
<TableColumn>用户</TableColumn>
|
||||||
|
<TableColumn>状态</TableColumn>
|
||||||
|
<TableColumn>创建时间</TableColumn>
|
||||||
|
<TableColumn>操作</TableColumn>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody emptyContent="暂无用户分组" items={userGroups}>
|
||||||
|
{(item) => (
|
||||||
|
<TableRow key={item.id}>
|
||||||
|
<TableCell>{item.name}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{item.userNames.length > 0
|
||||||
|
? item.userNames.join("、")
|
||||||
|
: "-"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Chip
|
||||||
|
color={item.status === 1 ? "success" : "danger"}
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
{item.status === 1 ? "启用" : "停用"}
|
||||||
|
</Chip>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{formatDate(item.createdTime)}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="flat"
|
||||||
|
onPress={() => openAssignUsers(item)}
|
||||||
|
>
|
||||||
|
分配用户
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="light"
|
||||||
|
onPress={() => openEditUserGroup(item)}
|
||||||
|
>
|
||||||
|
编辑
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
color="danger"
|
||||||
|
size="sm"
|
||||||
|
variant="light"
|
||||||
|
onPress={() => handleDeleteUserGroup(item.id)}
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<h3 className="text-lg font-semibold">权限分配</h3>
|
||||||
|
</CardHeader>
|
||||||
|
<CardBody className="space-y-4">
|
||||||
|
<div className="grid grid-cols-1 gap-3 md:grid-cols-3 md:items-end">
|
||||||
|
<Select
|
||||||
|
items={userGroups}
|
||||||
|
label="用户分组"
|
||||||
|
selectedKeys={
|
||||||
|
selectedUserGroupId ? [String(selectedUserGroupId)] : []
|
||||||
|
}
|
||||||
|
onSelectionChange={(keys) => {
|
||||||
|
const key = Array.from(keys as Set<React.Key>)[0];
|
||||||
|
|
||||||
|
setSelectedUserGroupId(key ? Number(key) : null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{(item) => <SelectItem key={item.id}>{item.name}</SelectItem>}
|
||||||
|
</Select>
|
||||||
|
<Select
|
||||||
|
items={tunnelGroups}
|
||||||
|
label="隧道分组"
|
||||||
|
selectedKeys={
|
||||||
|
selectedTunnelGroupId ? [String(selectedTunnelGroupId)] : []
|
||||||
|
}
|
||||||
|
onSelectionChange={(keys) => {
|
||||||
|
const key = Array.from(keys as Set<React.Key>)[0];
|
||||||
|
|
||||||
|
setSelectedTunnelGroupId(key ? Number(key) : null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{(item) => <SelectItem key={item.id}>{item.name}</SelectItem>}
|
||||||
|
</Select>
|
||||||
|
<Button
|
||||||
|
className="md:self-end md:justify-self-start whitespace-nowrap px-4"
|
||||||
|
color="primary"
|
||||||
|
isLoading={savingPermission}
|
||||||
|
size="sm"
|
||||||
|
onPress={handleAssignPermission}
|
||||||
|
>
|
||||||
|
分配
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Table
|
||||||
|
aria-label="分组权限列表"
|
||||||
|
classNames={{
|
||||||
|
wrapper:
|
||||||
|
"bg-transparent p-0 shadow-none border-none overflow-hidden rounded-2xl",
|
||||||
|
th: "bg-transparent text-default-600 font-semibold text-sm border-b border-white/20 dark:border-white/10 py-3 uppercase tracking-wider first:rounded-tl-[24px] last:rounded-tr-[24px]",
|
||||||
|
td: "py-3 border-b border-divider/50 group-data-[last=true]:border-b-0",
|
||||||
|
tr: "hover:bg-white/40 dark:hover:bg-white/10 transition-colors",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TableHeader>
|
||||||
|
<TableColumn>ID</TableColumn>
|
||||||
|
<TableColumn>用户分组</TableColumn>
|
||||||
|
<TableColumn>隧道分组</TableColumn>
|
||||||
|
<TableColumn>创建时间</TableColumn>
|
||||||
|
<TableColumn>操作</TableColumn>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody emptyContent="暂无权限分配记录" items={permissions}>
|
||||||
|
{(item) => (
|
||||||
|
<TableRow key={item.id}>
|
||||||
|
<TableCell>{item.id}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{item.userGroupName || item.userGroupId}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{item.tunnelGroupName || item.tunnelGroupId}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{formatDate(item.createdTime)}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Button
|
||||||
|
color="danger"
|
||||||
|
size="sm"
|
||||||
|
variant="light"
|
||||||
|
onPress={() => handleRemovePermission(item.id)}
|
||||||
|
>
|
||||||
|
回收
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
backdrop="blur"
|
||||||
classNames={{
|
classNames={{
|
||||||
wrapper: "bg-transparent p-0 shadow-none border-none overflow-hidden rounded-2xl",
|
base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl overflow-hidden",
|
||||||
th: "bg-transparent text-default-600 font-semibold text-sm border-b border-white/20 dark:border-white/10 py-3 uppercase tracking-wider first:rounded-tl-[24px] last:rounded-tr-[24px]",
|
|
||||||
td: "py-3 border-b border-divider/50 group-data-[last=true]:border-b-0",
|
|
||||||
tr: "hover:bg-white/40 dark:hover:bg-white/10 transition-colors",
|
|
||||||
}}
|
}}
|
||||||
|
isOpen={tunnelGroupModalOpen}
|
||||||
|
onOpenChange={onTunnelGroupModalChange}
|
||||||
>
|
>
|
||||||
<TableHeader>
|
<ModalContent>
|
||||||
<TableColumn>名称</TableColumn>
|
<ModalHeader>
|
||||||
<TableColumn>隧道</TableColumn>
|
{editingTunnelGroup ? "编辑隧道分组" : "新建隧道分组"}
|
||||||
<TableColumn>状态</TableColumn>
|
</ModalHeader>
|
||||||
<TableColumn>创建时间</TableColumn>
|
<ModalBody className="space-y-3">
|
||||||
<TableColumn>操作</TableColumn>
|
<Input
|
||||||
</TableHeader>
|
label="分组名称"
|
||||||
<TableBody emptyContent="暂无隧道分组" items={tunnelGroups}>
|
value={groupName}
|
||||||
{(item) => (
|
onChange={(e) => setGroupName(e.target.value)}
|
||||||
<TableRow key={item.id}>
|
/>
|
||||||
<TableCell>{item.name}</TableCell>
|
<Select
|
||||||
<TableCell>
|
label="状态"
|
||||||
{item.tunnelNames.length > 0
|
selectedKeys={[groupStatus]}
|
||||||
? item.tunnelNames.join("、")
|
onSelectionChange={(keys) => {
|
||||||
: "-"}
|
const key = Array.from(keys as Set<React.Key>)[0];
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Chip
|
|
||||||
color={item.status === 1 ? "success" : "danger"}
|
|
||||||
size="sm"
|
|
||||||
>
|
|
||||||
{item.status === 1 ? "启用" : "停用"}
|
|
||||||
</Chip>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>{formatDate(item.createdTime)}</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="flat"
|
|
||||||
onPress={() => openAssignTunnels(item)}
|
|
||||||
>
|
|
||||||
分配隧道
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="light"
|
|
||||||
onPress={() => openEditTunnelGroup(item)}
|
|
||||||
>
|
|
||||||
编辑
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
color="danger"
|
|
||||||
size="sm"
|
|
||||||
variant="light"
|
|
||||||
onPress={() => handleDeleteTunnelGroup(item.id)}
|
|
||||||
>
|
|
||||||
删除
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
)}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</CardBody>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
if (key) {
|
||||||
<CardHeader className="flex flex-row items-center gap-3 pb-2">
|
setGroupStatus(String(key));
|
||||||
<h3 className="text-lg font-semibold">用户分组</h3>
|
}
|
||||||
<Button
|
}}
|
||||||
className="h-7 px-3 text-xs font-medium min-w-0 shadow-sm"
|
>
|
||||||
color="primary"
|
<SelectItem key="1">启用</SelectItem>
|
||||||
size="sm"
|
<SelectItem key="0">停用</SelectItem>
|
||||||
onPress={openCreateUserGroup}
|
</Select>
|
||||||
>
|
</ModalBody>
|
||||||
新建
|
<ModalFooter>
|
||||||
</Button>
|
<Button variant="light" onPress={onTunnelGroupModalClose}>
|
||||||
</CardHeader>
|
取消
|
||||||
<CardBody>
|
</Button>
|
||||||
<Table
|
<Button
|
||||||
aria-label="用户分组列表"
|
color="primary"
|
||||||
|
isLoading={savingGroup}
|
||||||
|
onPress={saveTunnelGroup}
|
||||||
|
>
|
||||||
|
保存
|
||||||
|
</Button>
|
||||||
|
</ModalFooter>
|
||||||
|
</ModalContent>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
backdrop="blur"
|
||||||
classNames={{
|
classNames={{
|
||||||
wrapper: "bg-transparent p-0 shadow-none border-none overflow-hidden rounded-2xl",
|
base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl overflow-hidden",
|
||||||
th: "bg-transparent text-default-600 font-semibold text-sm border-b border-white/20 dark:border-white/10 py-3 uppercase tracking-wider first:rounded-tl-[24px] last:rounded-tr-[24px]",
|
|
||||||
td: "py-3 border-b border-divider/50 group-data-[last=true]:border-b-0",
|
|
||||||
tr: "hover:bg-white/40 dark:hover:bg-white/10 transition-colors",
|
|
||||||
}}
|
}}
|
||||||
|
isOpen={userGroupModalOpen}
|
||||||
|
onOpenChange={onUserGroupModalChange}
|
||||||
>
|
>
|
||||||
<TableHeader>
|
<ModalContent>
|
||||||
<TableColumn>名称</TableColumn>
|
<ModalHeader>
|
||||||
<TableColumn>用户</TableColumn>
|
{editingUserGroup ? "编辑用户分组" : "新建用户分组"}
|
||||||
<TableColumn>状态</TableColumn>
|
</ModalHeader>
|
||||||
<TableColumn>创建时间</TableColumn>
|
<ModalBody className="space-y-3">
|
||||||
<TableColumn>操作</TableColumn>
|
<Input
|
||||||
</TableHeader>
|
label="分组名称"
|
||||||
<TableBody emptyContent="暂无用户分组" items={userGroups}>
|
value={groupName}
|
||||||
{(item) => (
|
onChange={(e) => setGroupName(e.target.value)}
|
||||||
<TableRow key={item.id}>
|
/>
|
||||||
<TableCell>{item.name}</TableCell>
|
<Select
|
||||||
<TableCell>
|
label="状态"
|
||||||
{item.userNames.length > 0
|
selectedKeys={[groupStatus]}
|
||||||
? item.userNames.join("、")
|
onSelectionChange={(keys) => {
|
||||||
: "-"}
|
const key = Array.from(keys as Set<React.Key>)[0];
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Chip
|
|
||||||
color={item.status === 1 ? "success" : "danger"}
|
|
||||||
size="sm"
|
|
||||||
>
|
|
||||||
{item.status === 1 ? "启用" : "停用"}
|
|
||||||
</Chip>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>{formatDate(item.createdTime)}</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="flat"
|
|
||||||
onPress={() => openAssignUsers(item)}
|
|
||||||
>
|
|
||||||
分配用户
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="light"
|
|
||||||
onPress={() => openEditUserGroup(item)}
|
|
||||||
>
|
|
||||||
编辑
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
color="danger"
|
|
||||||
size="sm"
|
|
||||||
variant="light"
|
|
||||||
onPress={() => handleDeleteUserGroup(item.id)}
|
|
||||||
>
|
|
||||||
删除
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
)}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</CardBody>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
if (key) {
|
||||||
<CardHeader>
|
setGroupStatus(String(key));
|
||||||
<h3 className="text-lg font-semibold">权限分配</h3>
|
}
|
||||||
</CardHeader>
|
}}
|
||||||
<CardBody className="space-y-4">
|
>
|
||||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-3 md:items-end">
|
<SelectItem key="1">启用</SelectItem>
|
||||||
<Select
|
<SelectItem key="0">停用</SelectItem>
|
||||||
items={userGroups}
|
</Select>
|
||||||
label="用户分组"
|
</ModalBody>
|
||||||
selectedKeys={
|
<ModalFooter>
|
||||||
selectedUserGroupId ? [String(selectedUserGroupId)] : []
|
<Button variant="light" onPress={onUserGroupModalClose}>
|
||||||
}
|
取消
|
||||||
onSelectionChange={(keys) => {
|
</Button>
|
||||||
const key = Array.from(keys as Set<React.Key>)[0];
|
<Button
|
||||||
|
color="primary"
|
||||||
|
isLoading={savingGroup}
|
||||||
|
onPress={saveUserGroup}
|
||||||
|
>
|
||||||
|
保存
|
||||||
|
</Button>
|
||||||
|
</ModalFooter>
|
||||||
|
</ModalContent>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
setSelectedUserGroupId(key ? Number(key) : null);
|
<Modal
|
||||||
}}
|
backdrop="blur"
|
||||||
>
|
|
||||||
{(item) => <SelectItem key={item.id}>{item.name}</SelectItem>}
|
|
||||||
</Select>
|
|
||||||
<Select
|
|
||||||
items={tunnelGroups}
|
|
||||||
label="隧道分组"
|
|
||||||
selectedKeys={
|
|
||||||
selectedTunnelGroupId ? [String(selectedTunnelGroupId)] : []
|
|
||||||
}
|
|
||||||
onSelectionChange={(keys) => {
|
|
||||||
const key = Array.from(keys as Set<React.Key>)[0];
|
|
||||||
|
|
||||||
setSelectedTunnelGroupId(key ? Number(key) : null);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{(item) => <SelectItem key={item.id}>{item.name}</SelectItem>}
|
|
||||||
</Select>
|
|
||||||
<Button
|
|
||||||
className="md:self-end md:justify-self-start whitespace-nowrap px-4"
|
|
||||||
color="primary"
|
|
||||||
isLoading={savingPermission}
|
|
||||||
size="sm"
|
|
||||||
onPress={handleAssignPermission}
|
|
||||||
>
|
|
||||||
分配
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Table
|
|
||||||
aria-label="分组权限列表"
|
|
||||||
classNames={{
|
classNames={{
|
||||||
wrapper: "bg-transparent p-0 shadow-none border-none overflow-hidden rounded-2xl",
|
base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl overflow-hidden",
|
||||||
th: "bg-transparent text-default-600 font-semibold text-sm border-b border-white/20 dark:border-white/10 py-3 uppercase tracking-wider first:rounded-tl-[24px] last:rounded-tr-[24px]",
|
|
||||||
td: "py-3 border-b border-divider/50 group-data-[last=true]:border-b-0",
|
|
||||||
tr: "hover:bg-white/40 dark:hover:bg-white/10 transition-colors",
|
|
||||||
}}
|
}}
|
||||||
|
isOpen={tunnelAssignModalOpen}
|
||||||
|
onOpenChange={onTunnelAssignModalChange}
|
||||||
>
|
>
|
||||||
<TableHeader>
|
<ModalContent>
|
||||||
<TableColumn>ID</TableColumn>
|
<ModalHeader>分配隧道 - {assignTunnelGroup?.name}</ModalHeader>
|
||||||
<TableColumn>用户分组</TableColumn>
|
<ModalBody className="min-w-0">
|
||||||
<TableColumn>隧道分组</TableColumn>
|
<Select
|
||||||
<TableColumn>创建时间</TableColumn>
|
className="min-w-0"
|
||||||
<TableColumn>操作</TableColumn>
|
classNames={{ trigger: "max-w-full" }}
|
||||||
</TableHeader>
|
items={tunnels}
|
||||||
<TableBody emptyContent="暂无权限分配记录" items={permissions}>
|
label="选择隧道"
|
||||||
{(item) => (
|
selectedKeys={selectedTunnelKeys}
|
||||||
<TableRow key={item.id}>
|
selectionMode="multiple"
|
||||||
<TableCell>{item.id}</TableCell>
|
onSelectionChange={(keys) => {
|
||||||
<TableCell>
|
setSelectedTunnelKeys(
|
||||||
{item.userGroupName || item.userGroupId}
|
new Set(Array.from(keys as Set<React.Key>).map(String)),
|
||||||
</TableCell>
|
);
|
||||||
<TableCell>
|
}}
|
||||||
{item.tunnelGroupName || item.tunnelGroupId}
|
>
|
||||||
</TableCell>
|
{(item) => <SelectItem key={item.id}>{item.name}</SelectItem>}
|
||||||
<TableCell>{formatDate(item.createdTime)}</TableCell>
|
</Select>
|
||||||
<TableCell>
|
<p
|
||||||
<Button
|
className="w-full min-w-0 max-w-full text-xs text-default-500 truncate"
|
||||||
color="danger"
|
title={`当前已选:${selectedTunnelSummary}`}
|
||||||
size="sm"
|
>
|
||||||
variant="light"
|
当前已选:{selectedTunnelSummary}
|
||||||
onPress={() => handleRemovePermission(item.id)}
|
</p>
|
||||||
>
|
<p className="text-xs text-default-500">
|
||||||
回收
|
不选择任何隧道并保存将清空该分组成员。
|
||||||
</Button>
|
</p>
|
||||||
</TableCell>
|
</ModalBody>
|
||||||
</TableRow>
|
<ModalFooter>
|
||||||
)}
|
<Button variant="light" onPress={onTunnelAssignModalClose}>
|
||||||
</TableBody>
|
取消
|
||||||
</Table>
|
</Button>
|
||||||
</CardBody>
|
<Button
|
||||||
</Card>
|
color="primary"
|
||||||
|
isLoading={savingAssign}
|
||||||
|
onPress={saveAssignTunnels}
|
||||||
|
>
|
||||||
|
保存
|
||||||
|
</Button>
|
||||||
|
</ModalFooter>
|
||||||
|
</ModalContent>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
backdrop="blur"
|
backdrop="blur"
|
||||||
classNames={{
|
classNames={{
|
||||||
base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl overflow-hidden",
|
base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl overflow-hidden",
|
||||||
}}
|
}}
|
||||||
isOpen={tunnelGroupModalOpen}
|
isOpen={userAssignModalOpen}
|
||||||
onOpenChange={onTunnelGroupModalChange}
|
onOpenChange={onUserAssignModalChange}
|
||||||
>
|
>
|
||||||
<ModalContent>
|
<ModalContent>
|
||||||
<ModalHeader>
|
<ModalHeader>分配用户 - {assignUserGroup?.name}</ModalHeader>
|
||||||
{editingTunnelGroup ? "编辑隧道分组" : "新建隧道分组"}
|
<ModalBody className="min-w-0">
|
||||||
</ModalHeader>
|
<Select
|
||||||
<ModalBody className="space-y-3">
|
className="min-w-0"
|
||||||
<Input
|
classNames={{ trigger: "max-w-full" }}
|
||||||
label="分组名称"
|
items={users}
|
||||||
value={groupName}
|
label="选择用户"
|
||||||
onChange={(e) => setGroupName(e.target.value)}
|
selectedKeys={selectedUserKeys}
|
||||||
/>
|
selectionMode="multiple"
|
||||||
<Select
|
onSelectionChange={(keys) => {
|
||||||
label="状态"
|
setSelectedUserKeys(
|
||||||
selectedKeys={[groupStatus]}
|
new Set(Array.from(keys as Set<React.Key>).map(String)),
|
||||||
onSelectionChange={(keys) => {
|
);
|
||||||
const key = Array.from(keys as Set<React.Key>)[0];
|
}}
|
||||||
|
>
|
||||||
if (key) {
|
{(item) => <SelectItem key={item.id}>{item.user}</SelectItem>}
|
||||||
setGroupStatus(String(key));
|
</Select>
|
||||||
}
|
<p
|
||||||
}}
|
className="w-full min-w-0 max-w-full text-xs text-default-500 truncate"
|
||||||
>
|
title={`当前已选:${selectedUserSummary}`}
|
||||||
<SelectItem key="1">启用</SelectItem>
|
>
|
||||||
<SelectItem key="0">停用</SelectItem>
|
当前已选:{selectedUserSummary}
|
||||||
</Select>
|
</p>
|
||||||
</ModalBody>
|
<p className="text-xs text-default-500">
|
||||||
<ModalFooter>
|
不选择任何用户并保存将清空该分组成员。
|
||||||
<Button variant="light" onPress={onTunnelGroupModalClose}>
|
</p>
|
||||||
取消
|
</ModalBody>
|
||||||
</Button>
|
<ModalFooter>
|
||||||
<Button
|
<Button variant="light" onPress={onUserAssignModalClose}>
|
||||||
color="primary"
|
取消
|
||||||
isLoading={savingGroup}
|
</Button>
|
||||||
onPress={saveTunnelGroup}
|
<Button
|
||||||
>
|
color="primary"
|
||||||
保存
|
isLoading={savingAssign}
|
||||||
</Button>
|
onPress={saveAssignUsers}
|
||||||
</ModalFooter>
|
>
|
||||||
</ModalContent>
|
保存
|
||||||
</Modal>
|
</Button>
|
||||||
|
</ModalFooter>
|
||||||
<Modal
|
</ModalContent>
|
||||||
backdrop="blur"
|
</Modal>
|
||||||
classNames={{
|
|
||||||
base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl overflow-hidden",
|
|
||||||
}}
|
|
||||||
isOpen={userGroupModalOpen}
|
|
||||||
onOpenChange={onUserGroupModalChange}
|
|
||||||
>
|
|
||||||
<ModalContent>
|
|
||||||
<ModalHeader>
|
|
||||||
{editingUserGroup ? "编辑用户分组" : "新建用户分组"}
|
|
||||||
</ModalHeader>
|
|
||||||
<ModalBody className="space-y-3">
|
|
||||||
<Input
|
|
||||||
label="分组名称"
|
|
||||||
value={groupName}
|
|
||||||
onChange={(e) => setGroupName(e.target.value)}
|
|
||||||
/>
|
|
||||||
<Select
|
|
||||||
label="状态"
|
|
||||||
selectedKeys={[groupStatus]}
|
|
||||||
onSelectionChange={(keys) => {
|
|
||||||
const key = Array.from(keys as Set<React.Key>)[0];
|
|
||||||
|
|
||||||
if (key) {
|
|
||||||
setGroupStatus(String(key));
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<SelectItem key="1">启用</SelectItem>
|
|
||||||
<SelectItem key="0">停用</SelectItem>
|
|
||||||
</Select>
|
|
||||||
</ModalBody>
|
|
||||||
<ModalFooter>
|
|
||||||
<Button variant="light" onPress={onUserGroupModalClose}>
|
|
||||||
取消
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
color="primary"
|
|
||||||
isLoading={savingGroup}
|
|
||||||
onPress={saveUserGroup}
|
|
||||||
>
|
|
||||||
保存
|
|
||||||
</Button>
|
|
||||||
</ModalFooter>
|
|
||||||
</ModalContent>
|
|
||||||
</Modal>
|
|
||||||
|
|
||||||
<Modal
|
|
||||||
backdrop="blur"
|
|
||||||
classNames={{
|
|
||||||
base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl overflow-hidden",
|
|
||||||
}}
|
|
||||||
isOpen={tunnelAssignModalOpen}
|
|
||||||
onOpenChange={onTunnelAssignModalChange}
|
|
||||||
>
|
|
||||||
<ModalContent>
|
|
||||||
<ModalHeader>分配隧道 - {assignTunnelGroup?.name}</ModalHeader>
|
|
||||||
<ModalBody className="min-w-0">
|
|
||||||
<Select
|
|
||||||
className="min-w-0"
|
|
||||||
classNames={{ trigger: "max-w-full" }}
|
|
||||||
items={tunnels}
|
|
||||||
label="选择隧道"
|
|
||||||
selectedKeys={selectedTunnelKeys}
|
|
||||||
selectionMode="multiple"
|
|
||||||
onSelectionChange={(keys) => {
|
|
||||||
setSelectedTunnelKeys(
|
|
||||||
new Set(Array.from(keys as Set<React.Key>).map(String)),
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{(item) => <SelectItem key={item.id}>{item.name}</SelectItem>}
|
|
||||||
</Select>
|
|
||||||
<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">
|
|
||||||
不选择任何隧道并保存将清空该分组成员。
|
|
||||||
</p>
|
|
||||||
</ModalBody>
|
|
||||||
<ModalFooter>
|
|
||||||
<Button variant="light" onPress={onTunnelAssignModalClose}>
|
|
||||||
取消
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
color="primary"
|
|
||||||
isLoading={savingAssign}
|
|
||||||
onPress={saveAssignTunnels}
|
|
||||||
>
|
|
||||||
保存
|
|
||||||
</Button>
|
|
||||||
</ModalFooter>
|
|
||||||
</ModalContent>
|
|
||||||
</Modal>
|
|
||||||
|
|
||||||
<Modal
|
|
||||||
backdrop="blur"
|
|
||||||
classNames={{
|
|
||||||
base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl overflow-hidden",
|
|
||||||
}}
|
|
||||||
isOpen={userAssignModalOpen}
|
|
||||||
onOpenChange={onUserAssignModalChange}
|
|
||||||
>
|
|
||||||
<ModalContent>
|
|
||||||
<ModalHeader>分配用户 - {assignUserGroup?.name}</ModalHeader>
|
|
||||||
<ModalBody className="min-w-0">
|
|
||||||
<Select
|
|
||||||
className="min-w-0"
|
|
||||||
classNames={{ trigger: "max-w-full" }}
|
|
||||||
items={users}
|
|
||||||
label="选择用户"
|
|
||||||
selectedKeys={selectedUserKeys}
|
|
||||||
selectionMode="multiple"
|
|
||||||
onSelectionChange={(keys) => {
|
|
||||||
setSelectedUserKeys(
|
|
||||||
new Set(Array.from(keys as Set<React.Key>).map(String)),
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{(item) => <SelectItem key={item.id}>{item.user}</SelectItem>}
|
|
||||||
</Select>
|
|
||||||
<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">
|
|
||||||
不选择任何用户并保存将清空该分组成员。
|
|
||||||
</p>
|
|
||||||
</ModalBody>
|
|
||||||
<ModalFooter>
|
|
||||||
<Button variant="light" onPress={onUserAssignModalClose}>
|
|
||||||
取消
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
color="primary"
|
|
||||||
isLoading={savingAssign}
|
|
||||||
onPress={saveAssignUsers}
|
|
||||||
>
|
|
||||||
保存
|
|
||||||
</Button>
|
|
||||||
</ModalFooter>
|
|
||||||
</ModalContent>
|
|
||||||
</Modal>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</AnimatedPage>
|
</AnimatedPage>
|
||||||
|
|||||||
@@ -162,7 +162,9 @@ export default function IndexPage() {
|
|||||||
<Card className="w-full bg-white/20 dark:bg-zinc-900/20 backdrop-blur-3xl shadow-[0_20px_40px_rgba(0,0,0,0.15)] border-white/80 dark:border-white/10 rounded-[32px] p-2 sm:p-4">
|
<Card className="w-full bg-white/20 dark:bg-zinc-900/20 backdrop-blur-3xl shadow-[0_20px_40px_rgba(0,0,0,0.15)] border-white/80 dark:border-white/10 rounded-[32px] p-2 sm:p-4">
|
||||||
<CardHeader className="pb-0 pt-6 px-6 flex-col items-center">
|
<CardHeader className="pb-0 pt-6 px-6 flex-col items-center">
|
||||||
<BrandLogo className="w-14 h-14 rounded-2xl mb-4" size={56} />
|
<BrandLogo className="w-14 h-14 rounded-2xl mb-4" size={56} />
|
||||||
<h1 className="text-2xl font-bold tracking-tight text-foreground">{siteConfig.name}</h1>
|
<h1 className="text-2xl font-bold tracking-tight text-foreground">
|
||||||
|
{siteConfig.name}
|
||||||
|
</h1>
|
||||||
<p className="text-sm text-default-500 mt-2 font-medium">
|
<p className="text-sm text-default-500 mt-2 font-medium">
|
||||||
Sign in to manage your networks
|
Sign in to manage your networks
|
||||||
</p>
|
</p>
|
||||||
@@ -171,7 +173,8 @@ export default function IndexPage() {
|
|||||||
<div className="flex flex-col gap-5">
|
<div className="flex flex-col gap-5">
|
||||||
<Input
|
<Input
|
||||||
classNames={{
|
classNames={{
|
||||||
inputWrapper: "bg-white/50 dark:bg-black/50 backdrop-blur-md border border-white/60 dark:border-white/10 h-12 shadow-sm rounded-xl",
|
inputWrapper:
|
||||||
|
"bg-white/50 dark:bg-black/50 backdrop-blur-md border border-white/60 dark:border-white/10 h-12 shadow-sm rounded-xl",
|
||||||
input: "text-base font-medium",
|
input: "text-base font-medium",
|
||||||
}}
|
}}
|
||||||
errorMessage={errors.username}
|
errorMessage={errors.username}
|
||||||
@@ -189,7 +192,8 @@ export default function IndexPage() {
|
|||||||
|
|
||||||
<Input
|
<Input
|
||||||
classNames={{
|
classNames={{
|
||||||
inputWrapper: "bg-white/50 dark:bg-black/50 backdrop-blur-md border border-white/60 dark:border-white/10 h-12 shadow-sm rounded-xl",
|
inputWrapper:
|
||||||
|
"bg-white/50 dark:bg-black/50 backdrop-blur-md border border-white/60 dark:border-white/10 h-12 shadow-sm rounded-xl",
|
||||||
input: "text-base font-medium tracking-wider",
|
input: "text-base font-medium tracking-wider",
|
||||||
}}
|
}}
|
||||||
isDisabled={loading}
|
isDisabled={loading}
|
||||||
@@ -211,7 +215,11 @@ export default function IndexPage() {
|
|||||||
isLoading={loading}
|
isLoading={loading}
|
||||||
onPress={handleLogin}
|
onPress={handleLogin}
|
||||||
>
|
>
|
||||||
{loading ? (showCaptcha ? "Verifying..." : "Signing in...") : "Sign In"}
|
{loading
|
||||||
|
? showCaptcha
|
||||||
|
? "Verifying..."
|
||||||
|
: "Signing in..."
|
||||||
|
: "Sign In"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</CardBody>
|
</CardBody>
|
||||||
|
|||||||
+140
-135
@@ -1,5 +1,6 @@
|
|||||||
import { useState, useEffect, useMemo } from "react";
|
import { useState, useEffect, useMemo } from "react";
|
||||||
import toast from "react-hot-toast";
|
import toast from "react-hot-toast";
|
||||||
|
import { LayoutGrid, List } from "lucide-react";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
AnimatedPage,
|
AnimatedPage,
|
||||||
@@ -18,7 +19,6 @@ import {
|
|||||||
ModalFooter,
|
ModalFooter,
|
||||||
} from "@/shadcn-bridge/heroui/modal";
|
} from "@/shadcn-bridge/heroui/modal";
|
||||||
import { Chip } from "@/shadcn-bridge/heroui/chip";
|
import { Chip } from "@/shadcn-bridge/heroui/chip";
|
||||||
import { LayoutGrid, List } from "lucide-react";
|
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableHeader,
|
TableHeader,
|
||||||
@@ -251,7 +251,11 @@ export default function LimitPage() {
|
|||||||
variant="flat"
|
variant="flat"
|
||||||
onPress={() => setViewMode(viewMode === "list" ? "grid" : "list")}
|
onPress={() => setViewMode(viewMode === "list" ? "grid" : "list")}
|
||||||
>
|
>
|
||||||
{viewMode === "list" ? <LayoutGrid className="w-4 h-4" /> : <List className="w-4 h-4" />}
|
{viewMode === "list" ? (
|
||||||
|
<LayoutGrid className="w-4 h-4" />
|
||||||
|
) : (
|
||||||
|
<List className="w-4 h-4" />
|
||||||
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
<Button color="primary" size="sm" variant="flat" onPress={handleAdd}>
|
<Button color="primary" size="sm" variant="flat" onPress={handleAdd}>
|
||||||
新增
|
新增
|
||||||
@@ -263,153 +267,154 @@ export default function LimitPage() {
|
|||||||
{filteredRules.length > 0 ? (
|
{filteredRules.length > 0 ? (
|
||||||
viewMode === "list" ? (
|
viewMode === "list" ? (
|
||||||
<Card>
|
<Card>
|
||||||
<Table
|
<Table
|
||||||
aria-label="限速规则列表"
|
aria-label="限速规则列表"
|
||||||
className="overflow-x-auto min-w-full"
|
className="overflow-x-auto min-w-full"
|
||||||
classNames={{
|
classNames={{
|
||||||
wrapper: "bg-transparent p-0 shadow-none border-none overflow-hidden rounded-[24px]",
|
wrapper:
|
||||||
th: "bg-transparent text-default-600 font-semibold text-sm border-b border-white/20 dark:border-white/10 py-3 uppercase tracking-wider first:rounded-tl-[24px] last:rounded-tr-[24px]",
|
"bg-transparent p-0 shadow-none border-none overflow-hidden rounded-[24px]",
|
||||||
td: "py-3 border-b border-divider/50 group-data-[last=true]:border-b-0",
|
th: "bg-transparent text-default-600 font-semibold text-sm border-b border-white/20 dark:border-white/10 py-3 uppercase tracking-wider first:rounded-tl-[24px] last:rounded-tr-[24px]",
|
||||||
tr: "hover:bg-white/40 dark:hover:bg-white/10 transition-colors",
|
td: "py-3 border-b border-divider/50 group-data-[last=true]:border-b-0",
|
||||||
}}
|
tr: "hover:bg-white/40 dark:hover:bg-white/10 transition-colors",
|
||||||
>
|
}}
|
||||||
<TableHeader>
|
>
|
||||||
<TableColumn>规则名称</TableColumn>
|
<TableHeader>
|
||||||
<TableColumn>速度限制</TableColumn>
|
<TableColumn>规则名称</TableColumn>
|
||||||
<TableColumn>操作</TableColumn>
|
<TableColumn>速度限制</TableColumn>
|
||||||
</TableHeader>
|
<TableColumn>操作</TableColumn>
|
||||||
<TableBody items={filteredRules}>
|
</TableHeader>
|
||||||
{(rule) => (
|
<TableBody items={filteredRules}>
|
||||||
<TableRow key={rule.id}>
|
{(rule) => (
|
||||||
<TableCell>
|
<TableRow key={rule.id}>
|
||||||
<div className="flex items-center gap-2">
|
<TableCell>
|
||||||
<div
|
<div className="flex items-center gap-2">
|
||||||
className={`shrink-0 w-2 h-2 rounded-full ${
|
<div
|
||||||
rule.status === 1 ? "bg-success" : "bg-danger"
|
className={`shrink-0 w-2 h-2 rounded-full ${
|
||||||
}`}
|
rule.status === 1 ? "bg-success" : "bg-danger"
|
||||||
/>
|
}`}
|
||||||
<span className="font-medium text-foreground text-sm">
|
/>
|
||||||
{rule.name}
|
<span className="font-medium text-foreground text-sm">
|
||||||
</span>
|
{rule.name}
|
||||||
</div>
|
</span>
|
||||||
</TableCell>
|
</div>
|
||||||
<TableCell>
|
</TableCell>
|
||||||
<span className="text-sm font-mono text-default-600">
|
<TableCell>
|
||||||
{rule.speed} Mbps
|
<span className="text-sm font-mono text-default-600">
|
||||||
</span>
|
{rule.speed} Mbps
|
||||||
</TableCell>
|
</span>
|
||||||
<TableCell>
|
</TableCell>
|
||||||
<div className="flex flex-wrap items-center gap-1.5 min-w-max">
|
<TableCell>
|
||||||
<Button
|
<div className="flex flex-wrap items-center gap-1.5 min-w-max">
|
||||||
className="h-6 px-2 min-w-0 text-xs bg-indigo-50 text-indigo-600 hover:bg-indigo-100 dark:bg-indigo-950/30 dark:text-indigo-400"
|
<Button
|
||||||
|
className="h-6 px-2 min-w-0 text-xs bg-indigo-50 text-indigo-600 hover:bg-indigo-100 dark:bg-indigo-950/30 dark:text-indigo-400"
|
||||||
|
size="sm"
|
||||||
|
variant="flat"
|
||||||
|
onPress={() => handleEdit(rule)}
|
||||||
|
>
|
||||||
|
编辑
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
className="h-6 px-2 min-w-0 text-xs bg-rose-50 text-rose-600 hover:bg-rose-100 dark:bg-rose-950/30 dark:text-rose-400"
|
||||||
|
size="sm"
|
||||||
|
variant="flat"
|
||||||
|
onPress={() => handleDelete(rule)}
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<StaggerList className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4">
|
||||||
|
{filteredRules.map((rule) => (
|
||||||
|
<StaggerItem key={rule.id}>
|
||||||
|
<Card className="shadow-sm overflow-hidden h-full">
|
||||||
|
<CardHeader className="pb-2 md:pb-2">
|
||||||
|
<div className="flex justify-between items-start w-full">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-foreground">
|
||||||
|
{rule.name}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<Chip
|
||||||
|
color={rule.status === 1 ? "success" : "danger"}
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="flat"
|
variant="flat"
|
||||||
|
>
|
||||||
|
{rule.status === 1 ? "运行" : "异常"}
|
||||||
|
</Chip>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardBody className="pt-0 pb-3 md:pt-0 md:pb-3">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<span className="text-small text-default-600">
|
||||||
|
速度限制
|
||||||
|
</span>
|
||||||
|
<Chip color="secondary" size="sm" variant="flat">
|
||||||
|
{rule.speed} Mbps
|
||||||
|
</Chip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2 mt-4">
|
||||||
|
<Button
|
||||||
|
className="flex-1"
|
||||||
|
color="primary"
|
||||||
|
size="sm"
|
||||||
|
startContent={
|
||||||
|
<svg
|
||||||
|
aria-hidden="true"
|
||||||
|
className="w-4 h-4"
|
||||||
|
fill="currentColor"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
>
|
||||||
|
<path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z" />
|
||||||
|
</svg>
|
||||||
|
}
|
||||||
|
variant="flat"
|
||||||
onPress={() => handleEdit(rule)}
|
onPress={() => handleEdit(rule)}
|
||||||
>
|
>
|
||||||
编辑
|
编辑
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
className="h-6 px-2 min-w-0 text-xs bg-rose-50 text-rose-600 hover:bg-rose-100 dark:bg-rose-950/30 dark:text-rose-400"
|
className="flex-1"
|
||||||
|
color="danger"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
startContent={
|
||||||
|
<svg
|
||||||
|
aria-hidden="true"
|
||||||
|
className="w-4 h-4"
|
||||||
|
fill="currentColor"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
clipRule="evenodd"
|
||||||
|
d="M9 2a1 1 0 000 2h2a1 1 0 100-2H9z"
|
||||||
|
fillRule="evenodd"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
clipRule="evenodd"
|
||||||
|
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8 7a1 1 0 012 0v4a1 1 0 11-2 0V7zM12 7a1 1 0 012 0v4a1 1 0 11-2 0V7z"
|
||||||
|
fillRule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
}
|
||||||
variant="flat"
|
variant="flat"
|
||||||
onPress={() => handleDelete(rule)}
|
onPress={() => handleDelete(rule)}
|
||||||
>
|
>
|
||||||
删除
|
删除
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</CardBody>
|
||||||
</TableRow>
|
</Card>
|
||||||
)}
|
</StaggerItem>
|
||||||
</TableBody>
|
))}
|
||||||
</Table>
|
</StaggerList>
|
||||||
</Card>
|
|
||||||
) : (
|
|
||||||
<StaggerList className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4">
|
|
||||||
{filteredRules.map((rule) => (
|
|
||||||
<StaggerItem key={rule.id}>
|
|
||||||
<Card className="shadow-sm overflow-hidden h-full">
|
|
||||||
<CardHeader className="pb-2 md:pb-2">
|
|
||||||
<div className="flex justify-between items-start w-full">
|
|
||||||
<div>
|
|
||||||
<h3 className="font-semibold text-foreground">
|
|
||||||
{rule.name}
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
<Chip
|
|
||||||
color={rule.status === 1 ? "success" : "danger"}
|
|
||||||
size="sm"
|
|
||||||
variant="flat"
|
|
||||||
>
|
|
||||||
{rule.status === 1 ? "运行" : "异常"}
|
|
||||||
</Chip>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardBody className="pt-0 pb-3 md:pt-0 md:pb-3">
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="flex justify-between items-center">
|
|
||||||
<span className="text-small text-default-600">
|
|
||||||
速度限制
|
|
||||||
</span>
|
|
||||||
<Chip color="secondary" size="sm" variant="flat">
|
|
||||||
{rule.speed} Mbps
|
|
||||||
</Chip>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex gap-2 mt-4">
|
|
||||||
<Button
|
|
||||||
className="flex-1"
|
|
||||||
color="primary"
|
|
||||||
size="sm"
|
|
||||||
startContent={
|
|
||||||
<svg
|
|
||||||
aria-hidden="true"
|
|
||||||
className="w-4 h-4"
|
|
||||||
fill="currentColor"
|
|
||||||
viewBox="0 0 20 20"
|
|
||||||
>
|
|
||||||
<path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z" />
|
|
||||||
</svg>
|
|
||||||
}
|
|
||||||
variant="flat"
|
|
||||||
onPress={() => handleEdit(rule)}
|
|
||||||
>
|
|
||||||
编辑
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
className="flex-1"
|
|
||||||
color="danger"
|
|
||||||
size="sm"
|
|
||||||
startContent={
|
|
||||||
<svg
|
|
||||||
aria-hidden="true"
|
|
||||||
className="w-4 h-4"
|
|
||||||
fill="currentColor"
|
|
||||||
viewBox="0 0 20 20"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
clipRule="evenodd"
|
|
||||||
d="M9 2a1 1 0 000 2h2a1 1 0 100-2H9z"
|
|
||||||
fillRule="evenodd"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
clipRule="evenodd"
|
|
||||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8 7a1 1 0 012 0v4a1 1 0 11-2 0V7zM12 7a1 1 0 012 0v4a1 1 0 11-2 0V7z"
|
|
||||||
fillRule="evenodd"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
}
|
|
||||||
variant="flat"
|
|
||||||
onPress={() => handleDelete(rule)}
|
|
||||||
>
|
|
||||||
删除
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</CardBody>
|
|
||||||
</Card>
|
|
||||||
</StaggerItem>
|
|
||||||
))}
|
|
||||||
</StaggerList>
|
|
||||||
)
|
)
|
||||||
) : (
|
) : (
|
||||||
/* 空状态 */
|
/* 空状态 */
|
||||||
|
|||||||
@@ -2,7 +2,13 @@ import type { MonitorNodeApiItem } from "@/api/types";
|
|||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import toast from "react-hot-toast";
|
import toast from "react-hot-toast";
|
||||||
import { RefreshCw, LayoutGrid, List, Server, ArrowRightLeft } from "lucide-react";
|
import {
|
||||||
|
RefreshCw,
|
||||||
|
LayoutGrid,
|
||||||
|
List,
|
||||||
|
Server,
|
||||||
|
ArrowRightLeft,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
import { AnimatedPage } from "@/components/animated-page";
|
import { AnimatedPage } from "@/components/animated-page";
|
||||||
import { Button } from "@/shadcn-bridge/heroui/button";
|
import { Button } from "@/shadcn-bridge/heroui/button";
|
||||||
@@ -38,11 +44,16 @@ export default function MonitorPage() {
|
|||||||
const [viewMode, setViewMode] = useState<"list" | "grid">("list");
|
const [viewMode, setViewMode] = useState<"list" | "grid">("list");
|
||||||
const [activeTab, setActiveTab] = useState<MonitorTab>("nodes");
|
const [activeTab, setActiveTab] = useState<MonitorTab>("nodes");
|
||||||
|
|
||||||
const [realtimeNodeStatus, setRealtimeNodeStatus] = useState<Record<number, "online" | "offline">>({});
|
const [realtimeNodeStatus, setRealtimeNodeStatus] = useState<
|
||||||
const [realtimeNodeMetrics, setRealtimeNodeMetrics] = useState<Record<number, any>>({});
|
Record<number, "online" | "offline">
|
||||||
|
>({});
|
||||||
|
const [realtimeNodeMetrics, setRealtimeNodeMetrics] = useState<
|
||||||
|
Record<number, any>
|
||||||
|
>({});
|
||||||
|
|
||||||
const handleRealtimeMessage = useCallback((message: any) => {
|
const handleRealtimeMessage = useCallback((message: any) => {
|
||||||
const nodeId = Number(message?.id ?? 0);
|
const nodeId = Number(message?.id ?? 0);
|
||||||
|
|
||||||
if (!nodeId || Number.isNaN(nodeId)) return;
|
if (!nodeId || Number.isNaN(nodeId)) return;
|
||||||
|
|
||||||
const type = String(message?.type ?? "");
|
const type = String(message?.type ?? "");
|
||||||
@@ -50,15 +61,18 @@ export default function MonitorPage() {
|
|||||||
|
|
||||||
if (type === "status") {
|
if (type === "status") {
|
||||||
const status = Number(payload);
|
const status = Number(payload);
|
||||||
|
|
||||||
setRealtimeNodeStatus((prev) => ({
|
setRealtimeNodeStatus((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
[nodeId]: status === 1 ? "online" : "offline",
|
[nodeId]: status === 1 ? "online" : "offline",
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (type === "metric") {
|
if (type === "metric") {
|
||||||
let raw = payload;
|
let raw = payload;
|
||||||
|
|
||||||
if (typeof raw === "string") {
|
if (typeof raw === "string") {
|
||||||
try {
|
try {
|
||||||
raw = JSON.parse(raw);
|
raw = JSON.parse(raw);
|
||||||
@@ -77,7 +91,7 @@ export default function MonitorPage() {
|
|||||||
netOutSpeed: Number(metric.netOutSpeed ?? metric.net_out_speed ?? 0),
|
netOutSpeed: Number(metric.netOutSpeed ?? metric.net_out_speed ?? 0),
|
||||||
tcpConns: Number(metric.tcpConns ?? metric.tcp_conns ?? 0),
|
tcpConns: Number(metric.tcpConns ?? metric.tcp_conns ?? 0),
|
||||||
udpConns: Number(metric.udpConns ?? metric.udp_conns ?? 0),
|
udpConns: Number(metric.udpConns ?? metric.udp_conns ?? 0),
|
||||||
}
|
},
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
@@ -89,6 +103,7 @@ export default function MonitorPage() {
|
|||||||
|
|
||||||
const loadNodes = useCallback(async (options?: { silent?: boolean }) => {
|
const loadNodes = useCallback(async (options?: { silent?: boolean }) => {
|
||||||
const silent = options?.silent ?? false;
|
const silent = options?.silent ?? false;
|
||||||
|
|
||||||
if (!silent) setNodesLoading(true);
|
if (!silent) setNodesLoading(true);
|
||||||
try {
|
try {
|
||||||
const response = await getMonitorNodes();
|
const response = await getMonitorNodes();
|
||||||
@@ -133,7 +148,9 @@ export default function MonitorPage() {
|
|||||||
.map((n) => ({
|
.map((n) => ({
|
||||||
id: Number(n.id),
|
id: Number(n.id),
|
||||||
name: String(n.name ?? ""),
|
name: String(n.name ?? ""),
|
||||||
connectionStatus: realtimeNodeStatus[Number(n.id)] || (n.status === 1 ? "online" : "offline"),
|
connectionStatus:
|
||||||
|
realtimeNodeStatus[Number(n.id)] ||
|
||||||
|
(n.status === 1 ? "online" : "offline"),
|
||||||
version: n.version,
|
version: n.version,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -151,6 +168,7 @@ export default function MonitorPage() {
|
|||||||
if (node.connectionStatus === "online") {
|
if (node.connectionStatus === "online") {
|
||||||
onlineCount++;
|
onlineCount++;
|
||||||
const metric = realtimeNodeMetrics[node.id];
|
const metric = realtimeNodeMetrics[node.id];
|
||||||
|
|
||||||
if (metric) {
|
if (metric) {
|
||||||
totalCpu += metric.cpuUsage;
|
totalCpu += metric.cpuUsage;
|
||||||
totalConns += metric.tcpConns + metric.udpConns;
|
totalConns += metric.tcpConns + metric.udpConns;
|
||||||
@@ -161,6 +179,7 @@ export default function MonitorPage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const avgCpu = onlineCount > 0 ? totalCpu / onlineCount : 0;
|
const avgCpu = onlineCount > 0 ? totalCpu / onlineCount : 0;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
avgCpu,
|
avgCpu,
|
||||||
totalConns,
|
totalConns,
|
||||||
@@ -174,10 +193,14 @@ export default function MonitorPage() {
|
|||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-8">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-8">
|
||||||
<div className="rounded-3xl border border-white/80 dark:border-white/10 bg-white/20 dark:bg-zinc-900/20 backdrop-blur-3xl shadow-[0_10px_30px_rgba(0,0,0,0.1)] p-6 relative overflow-hidden flex flex-col justify-between h-40">
|
<div className="rounded-3xl border border-white/80 dark:border-white/10 bg-white/20 dark:bg-zinc-900/20 backdrop-blur-3xl shadow-[0_10px_30px_rgba(0,0,0,0.1)] p-6 relative overflow-hidden flex flex-col justify-between h-40">
|
||||||
<div className="flex justify-between items-center z-10 relative">
|
<div className="flex justify-between items-center z-10 relative">
|
||||||
<span className="text-default-600 font-medium text-sm">System Load</span>
|
<span className="text-default-600 font-medium text-sm">
|
||||||
|
System Load
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="z-10 relative">
|
<div className="z-10 relative">
|
||||||
<span className="text-4xl font-bold text-foreground">{aggregateMetrics.avgCpu.toFixed(1)}%</span>
|
<span className="text-4xl font-bold text-foreground">
|
||||||
|
{aggregateMetrics.avgCpu.toFixed(1)}%
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="absolute bottom-0 left-0 right-0 h-12 flex items-end gap-1 px-6 pb-4 opacity-50 z-0">
|
<div className="absolute bottom-0 left-0 right-0 h-12 flex items-end gap-1 px-6 pb-4 opacity-50 z-0">
|
||||||
<div className="w-full bg-primary/40 h-2 rounded-t-sm" />
|
<div className="w-full bg-primary/40 h-2 rounded-t-sm" />
|
||||||
@@ -191,10 +214,14 @@ export default function MonitorPage() {
|
|||||||
|
|
||||||
<div className="rounded-3xl border border-white/80 dark:border-white/10 bg-white/20 dark:bg-zinc-900/20 backdrop-blur-3xl shadow-[0_10px_30px_rgba(0,0,0,0.1)] p-6 relative overflow-hidden flex flex-col justify-between h-40">
|
<div className="rounded-3xl border border-white/80 dark:border-white/10 bg-white/20 dark:bg-zinc-900/20 backdrop-blur-3xl shadow-[0_10px_30px_rgba(0,0,0,0.1)] p-6 relative overflow-hidden flex flex-col justify-between h-40">
|
||||||
<div className="flex justify-between items-center z-10 relative">
|
<div className="flex justify-between items-center z-10 relative">
|
||||||
<span className="text-default-600 font-medium text-sm">Active Connections</span>
|
<span className="text-default-600 font-medium text-sm">
|
||||||
|
Active Connections
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="z-10 relative">
|
<div className="z-10 relative">
|
||||||
<span className="text-4xl font-bold text-foreground">{aggregateMetrics.totalConns}</span>
|
<span className="text-4xl font-bold text-foreground">
|
||||||
|
{aggregateMetrics.totalConns}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="absolute bottom-0 left-0 right-0 h-12 flex items-end gap-1 px-6 pb-4 opacity-50 z-0">
|
<div className="absolute bottom-0 left-0 right-0 h-12 flex items-end gap-1 px-6 pb-4 opacity-50 z-0">
|
||||||
<div className="w-full bg-success/40 h-3.5 rounded-t-sm" />
|
<div className="w-full bg-success/40 h-3.5 rounded-t-sm" />
|
||||||
@@ -208,10 +235,14 @@ export default function MonitorPage() {
|
|||||||
|
|
||||||
<div className="rounded-3xl border border-white/80 dark:border-white/10 bg-white/20 dark:bg-zinc-900/20 backdrop-blur-3xl shadow-[0_10px_30px_rgba(0,0,0,0.1)] p-6 relative overflow-hidden flex flex-col justify-between h-40">
|
<div className="rounded-3xl border border-white/80 dark:border-white/10 bg-white/20 dark:bg-zinc-900/20 backdrop-blur-3xl shadow-[0_10px_30px_rgba(0,0,0,0.1)] p-6 relative overflow-hidden flex flex-col justify-between h-40">
|
||||||
<div className="flex justify-between items-center z-10 relative">
|
<div className="flex justify-between items-center z-10 relative">
|
||||||
<span className="text-default-600 font-medium text-sm">Bandwidth</span>
|
<span className="text-default-600 font-medium text-sm">
|
||||||
|
Bandwidth
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="z-10 relative">
|
<div className="z-10 relative">
|
||||||
<span className="text-4xl font-bold text-foreground">{formatBytesPerSecond(aggregateMetrics.totalBandwidth)}</span>
|
<span className="text-4xl font-bold text-foreground">
|
||||||
|
{formatBytesPerSecond(aggregateMetrics.totalBandwidth)}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="absolute bottom-0 left-0 right-0 h-12 flex items-end gap-1 px-6 pb-4 opacity-50 z-0">
|
<div className="absolute bottom-0 left-0 right-0 h-12 flex items-end gap-1 px-6 pb-4 opacity-50 z-0">
|
||||||
<div className="w-full bg-secondary/40 h-2 rounded-t-sm" />
|
<div className="w-full bg-secondary/40 h-2 rounded-t-sm" />
|
||||||
@@ -236,7 +267,11 @@ export default function MonitorPage() {
|
|||||||
variant="flat"
|
variant="flat"
|
||||||
onPress={() => setViewMode(viewMode === "list" ? "grid" : "list")}
|
onPress={() => setViewMode(viewMode === "list" ? "grid" : "list")}
|
||||||
>
|
>
|
||||||
{viewMode === "list" ? <LayoutGrid className="w-4 h-4" /> : <List className="w-4 h-4" />}
|
{viewMode === "list" ? (
|
||||||
|
<LayoutGrid className="w-4 h-4" />
|
||||||
|
) : (
|
||||||
|
<List className="w-4 h-4" />
|
||||||
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
{activeTab === "nodes" && (
|
{activeTab === "nodes" && (
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -17,10 +17,10 @@ import {
|
|||||||
useSortable,
|
useSortable,
|
||||||
} from "@dnd-kit/sortable";
|
} from "@dnd-kit/sortable";
|
||||||
import { CSS } from "@dnd-kit/utilities";
|
import { CSS } from "@dnd-kit/utilities";
|
||||||
|
import { LayoutGrid, List } from "lucide-react";
|
||||||
|
|
||||||
import { SearchBar } from "@/components/search-bar";
|
import { SearchBar } from "@/components/search-bar";
|
||||||
import { AnimatedPage } from "@/components/animated-page";
|
import { AnimatedPage } from "@/components/animated-page";
|
||||||
import { LayoutGrid, List } from "lucide-react";
|
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableHeader,
|
TableHeader,
|
||||||
@@ -699,7 +699,6 @@ export default function NodePage() {
|
|||||||
|
|
||||||
// 格式化流量
|
// 格式化流量
|
||||||
|
|
||||||
|
|
||||||
const formatFlow = (bytes: number): string => {
|
const formatFlow = (bytes: number): string => {
|
||||||
if (!Number.isFinite(bytes) || bytes <= 0) {
|
if (!Number.isFinite(bytes) || bytes <= 0) {
|
||||||
return "0 B";
|
return "0 B";
|
||||||
@@ -726,7 +725,6 @@ export default function NodePage() {
|
|||||||
return "未知链路";
|
return "未知链路";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
// IPv4/IPv6 格式验证(仅用于判定地址族)
|
// IPv4/IPv6 格式验证(仅用于判定地址族)
|
||||||
const ipv4Regex =
|
const ipv4Regex =
|
||||||
/^(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/;
|
/^(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/;
|
||||||
@@ -1650,10 +1648,12 @@ export default function NodePage() {
|
|||||||
{/* 视图切换按钮 */}
|
{/* 视图切换按钮 */}
|
||||||
<Button
|
<Button
|
||||||
isIconOnly
|
isIconOnly
|
||||||
|
className="text-default-600 hidden sm:flex"
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="flat"
|
variant="flat"
|
||||||
className="text-default-600 hidden sm:flex"
|
onPress={() =>
|
||||||
onPress={() => setViewMode(viewMode === "list" ? "grid" : "list")}
|
setViewMode(viewMode === "list" ? "grid" : "list")
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{viewMode === "list" ? (
|
{viewMode === "list" ? (
|
||||||
<LayoutGrid className="w-4 h-4" />
|
<LayoutGrid className="w-4 h-4" />
|
||||||
@@ -1767,7 +1767,8 @@ export default function NodePage() {
|
|||||||
aria-label="节点列表"
|
aria-label="节点列表"
|
||||||
className="overflow-x-auto min-w-full"
|
className="overflow-x-auto min-w-full"
|
||||||
classNames={{
|
classNames={{
|
||||||
wrapper: "bg-transparent p-0 shadow-none border-none overflow-hidden rounded-2xl",
|
wrapper:
|
||||||
|
"bg-transparent p-0 shadow-none border-none overflow-hidden rounded-2xl",
|
||||||
th: "bg-transparent text-default-600 font-semibold text-sm border-b border-white/20 dark:border-white/10 py-3 uppercase tracking-wider first:rounded-tl-[24px] last:rounded-tr-[24px]",
|
th: "bg-transparent text-default-600 font-semibold text-sm border-b border-white/20 dark:border-white/10 py-3 uppercase tracking-wider first:rounded-tl-[24px] last:rounded-tr-[24px]",
|
||||||
td: "py-3 border-b border-divider/50 group-data-[last=true]:border-b-0",
|
td: "py-3 border-b border-divider/50 group-data-[last=true]:border-b-0",
|
||||||
tr: "hover:bg-white/10 dark:hover:bg-white/5 transition-colors",
|
tr: "hover:bg-white/10 dark:hover:bg-white/5 transition-colors",
|
||||||
@@ -1776,7 +1777,11 @@ export default function NodePage() {
|
|||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableColumn className="w-12 px-4 whitespace-nowrap overflow-hidden">
|
<TableColumn className="w-12 px-4 whitespace-nowrap overflow-hidden">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
isSelected={selectMode && selectedIds.size === displayNodes.length && displayNodes.length > 0}
|
isSelected={
|
||||||
|
selectMode &&
|
||||||
|
selectedIds.size === displayNodes.length &&
|
||||||
|
displayNodes.length > 0
|
||||||
|
}
|
||||||
onValueChange={(checked) => {
|
onValueChange={(checked) => {
|
||||||
if (checked) {
|
if (checked) {
|
||||||
selectAll();
|
selectAll();
|
||||||
@@ -1806,12 +1811,16 @@ export default function NodePage() {
|
|||||||
onValueChange={(checked) => {
|
onValueChange={(checked) => {
|
||||||
if (checked) {
|
if (checked) {
|
||||||
setSelectMode(true);
|
setSelectMode(true);
|
||||||
setSelectedIds((prev) => new Set([...prev, node.id]));
|
setSelectedIds(
|
||||||
|
(prev) => new Set([...prev, node.id]),
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
setSelectedIds((prev) => {
|
setSelectedIds((prev) => {
|
||||||
const next = new Set(prev);
|
const next = new Set(prev);
|
||||||
|
|
||||||
next.delete(node.id);
|
next.delete(node.id);
|
||||||
if (next.size === 0) setSelectMode(false);
|
if (next.size === 0) setSelectMode(false);
|
||||||
|
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1833,7 +1842,11 @@ export default function NodePage() {
|
|||||||
{node.name}
|
{node.name}
|
||||||
</span>
|
</span>
|
||||||
{hasRemark && (
|
{hasRemark && (
|
||||||
<Chip size="sm" variant="flat" className="h-4 px-1 text-[10px]">
|
<Chip
|
||||||
|
className="h-4 px-1 text-[10px]"
|
||||||
|
size="sm"
|
||||||
|
variant="flat"
|
||||||
|
>
|
||||||
{node.remark}
|
{node.remark}
|
||||||
</Chip>
|
</Chip>
|
||||||
)}
|
)}
|
||||||
@@ -1841,7 +1854,9 @@ export default function NodePage() {
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<span className="text-sm font-mono text-default-600">
|
<span className="text-sm font-mono text-default-600">
|
||||||
{isRemoteNode ? new URL(node.remoteUrl || "").hostname : node.serverIp}
|
{isRemoteNode
|
||||||
|
? new URL(node.remoteUrl || "").hostname
|
||||||
|
: node.serverIp}
|
||||||
</span>
|
</span>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
@@ -1853,10 +1868,10 @@ export default function NodePage() {
|
|||||||
<div className="flex flex-wrap items-center gap-1.5 min-w-max">
|
<div className="flex flex-wrap items-center gap-1.5 min-w-max">
|
||||||
{!isRemoteNode && (
|
{!isRemoteNode && (
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
|
||||||
variant="flat"
|
|
||||||
className="h-6 px-2 min-w-0 text-xs bg-emerald-50 text-emerald-600 hover:bg-emerald-100 dark:bg-emerald-950/30 dark:text-emerald-400"
|
className="h-6 px-2 min-w-0 text-xs bg-emerald-50 text-emerald-600 hover:bg-emerald-100 dark:bg-emerald-950/30 dark:text-emerald-400"
|
||||||
isLoading={node.copyLoading}
|
isLoading={node.copyLoading}
|
||||||
|
size="sm"
|
||||||
|
variant="flat"
|
||||||
onPress={() => openInstallSelector(node)}
|
onPress={() => openInstallSelector(node)}
|
||||||
>
|
>
|
||||||
安装
|
安装
|
||||||
@@ -1864,11 +1879,11 @@ export default function NodePage() {
|
|||||||
)}
|
)}
|
||||||
{!isRemoteNode && (
|
{!isRemoteNode && (
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
|
||||||
variant="flat"
|
|
||||||
className="h-6 px-2 min-w-0 text-xs bg-amber-50 text-amber-600 hover:bg-amber-100 dark:bg-amber-950/30 dark:text-amber-400"
|
className="h-6 px-2 min-w-0 text-xs bg-amber-50 text-amber-600 hover:bg-amber-100 dark:bg-amber-950/30 dark:text-amber-400"
|
||||||
isDisabled={node.connectionStatus !== "online"}
|
isDisabled={node.connectionStatus !== "online"}
|
||||||
isLoading={node.upgradeLoading}
|
isLoading={node.upgradeLoading}
|
||||||
|
size="sm"
|
||||||
|
variant="flat"
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
setUpgradeTargetNodeId(node.id);
|
setUpgradeTargetNodeId(node.id);
|
||||||
openUpgradeModal("single");
|
openUpgradeModal("single");
|
||||||
@@ -1879,11 +1894,11 @@ export default function NodePage() {
|
|||||||
)}
|
)}
|
||||||
{!isRemoteNode && (
|
{!isRemoteNode && (
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
|
||||||
variant="flat"
|
|
||||||
className="h-6 px-2 min-w-0 text-xs bg-blue-50 text-blue-600 hover:bg-blue-100 dark:bg-blue-950/30 dark:text-blue-400"
|
className="h-6 px-2 min-w-0 text-xs bg-blue-50 text-blue-600 hover:bg-blue-100 dark:bg-blue-950/30 dark:text-blue-400"
|
||||||
isDisabled={node.connectionStatus !== "online"}
|
isDisabled={node.connectionStatus !== "online"}
|
||||||
isLoading={node.rollbackLoading}
|
isLoading={node.rollbackLoading}
|
||||||
|
size="sm"
|
||||||
|
variant="flat"
|
||||||
onPress={() => handleRollbackNode(node)}
|
onPress={() => handleRollbackNode(node)}
|
||||||
>
|
>
|
||||||
回退
|
回退
|
||||||
@@ -1891,18 +1906,18 @@ export default function NodePage() {
|
|||||||
)}
|
)}
|
||||||
{!isRemoteNode && (
|
{!isRemoteNode && (
|
||||||
<Button
|
<Button
|
||||||
|
className="h-6 px-2 min-w-0 text-xs bg-indigo-50 text-indigo-600 hover:bg-indigo-100 dark:bg-indigo-950/30 dark:text-indigo-400"
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="flat"
|
variant="flat"
|
||||||
className="h-6 px-2 min-w-0 text-xs bg-indigo-50 text-indigo-600 hover:bg-indigo-100 dark:bg-indigo-950/30 dark:text-indigo-400"
|
|
||||||
onPress={() => handleEdit(node)}
|
onPress={() => handleEdit(node)}
|
||||||
>
|
>
|
||||||
编辑
|
编辑
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Button
|
<Button
|
||||||
|
className="h-6 px-2 min-w-0 text-xs bg-rose-50 text-rose-600 hover:bg-rose-100 dark:bg-rose-950/30 dark:text-rose-400"
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="flat"
|
variant="flat"
|
||||||
className="h-6 px-2 min-w-0 text-xs bg-rose-50 text-rose-600 hover:bg-rose-100 dark:bg-rose-950/30 dark:text-rose-400"
|
|
||||||
onPress={() => handleDelete(node)}
|
onPress={() => handleDelete(node)}
|
||||||
>
|
>
|
||||||
删除
|
删除
|
||||||
@@ -2054,7 +2069,9 @@ export default function NodePage() {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
handleDismissExpiryReminder(node.id);
|
handleDismissExpiryReminder(
|
||||||
|
node.id,
|
||||||
|
);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
关闭提醒
|
关闭提醒
|
||||||
@@ -2327,8 +2344,6 @@ export default function NodePage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<div className="mt-auto space-y-3">
|
<div className="mt-auto space-y-3">
|
||||||
{/* 操作按钮 */}
|
{/* 操作按钮 */}
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -5,7 +5,13 @@ import type {
|
|||||||
TunnelQualityHopApiItem,
|
TunnelQualityHopApiItem,
|
||||||
} from "@/api/types";
|
} from "@/api/types";
|
||||||
|
|
||||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import React, {
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
import {
|
import {
|
||||||
LineChart,
|
LineChart,
|
||||||
Line,
|
Line,
|
||||||
@@ -35,7 +41,6 @@ import {
|
|||||||
getMonitorTunnelQualityHistory,
|
getMonitorTunnelQualityHistory,
|
||||||
getConfigByName,
|
getConfigByName,
|
||||||
} from "@/api";
|
} from "@/api";
|
||||||
|
|
||||||
import { Button } from "@/shadcn-bridge/heroui/button";
|
import { Button } from "@/shadcn-bridge/heroui/button";
|
||||||
import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card";
|
import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card";
|
||||||
import { Chip } from "@/shadcn-bridge/heroui/chip";
|
import { Chip } from "@/shadcn-bridge/heroui/chip";
|
||||||
@@ -54,7 +59,8 @@ interface TunnelMonitorViewProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const QUALITY_POLL_INTERVAL = 1_000; // 1 second
|
const QUALITY_POLL_INTERVAL = 1_000; // 1 second
|
||||||
const MONITOR_TUNNEL_QUALITY_ENABLED_CONFIG_KEY = "monitor_tunnel_quality_enabled";
|
const MONITOR_TUNNEL_QUALITY_ENABLED_CONFIG_KEY =
|
||||||
|
"monitor_tunnel_quality_enabled";
|
||||||
const MONITOR_TUNNEL_QUALITY_ENABLED_EVENT =
|
const MONITOR_TUNNEL_QUALITY_ENABLED_EVENT =
|
||||||
"monitorTunnelQualityEnabledChanged";
|
"monitorTunnelQualityEnabledChanged";
|
||||||
|
|
||||||
@@ -78,7 +84,13 @@ const formatTimestamp = (ts: number, rangeMs?: number): string => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
function LatencyDisplay({ value, loading }: { value?: number; loading?: boolean }) {
|
function LatencyDisplay({
|
||||||
|
value,
|
||||||
|
loading,
|
||||||
|
}: {
|
||||||
|
value?: number;
|
||||||
|
loading?: boolean;
|
||||||
|
}) {
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <RefreshCw className="w-3 h-3 animate-spin inline text-primary" />;
|
return <RefreshCw className="w-3 h-3 animate-spin inline text-primary" />;
|
||||||
}
|
}
|
||||||
@@ -87,11 +99,16 @@ function LatencyDisplay({ value, loading }: { value?: number; loading?: boolean
|
|||||||
}
|
}
|
||||||
const ms = value.toFixed(0);
|
const ms = value.toFixed(0);
|
||||||
let colorClass = "text-success";
|
let colorClass = "text-success";
|
||||||
|
|
||||||
if (value > 200) colorClass = "text-danger";
|
if (value > 200) colorClass = "text-danger";
|
||||||
else if (value > 100) colorClass = "text-warning";
|
else if (value > 100) colorClass = "text-warning";
|
||||||
else if (value > 50) colorClass = "text-primary";
|
else if (value > 50) colorClass = "text-primary";
|
||||||
|
|
||||||
return <span className={`font-mono text-xs font-semibold ${colorClass}`}>{ms}ms</span>;
|
return (
|
||||||
|
<span className={`font-mono text-xs font-semibold ${colorClass}`}>
|
||||||
|
{ms}ms
|
||||||
|
</span>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function LiveDot() {
|
function LiveDot() {
|
||||||
@@ -116,9 +133,11 @@ function UptimeHistoryBar({
|
|||||||
}) {
|
}) {
|
||||||
const bars = [...Array(UPTIME_KUMA_BARS_COUNT)].map((_, i) => {
|
const bars = [...Array(UPTIME_KUMA_BARS_COUNT)].map((_, i) => {
|
||||||
const historyIndex = history.length - UPTIME_KUMA_BARS_COUNT + i;
|
const historyIndex = history.length - UPTIME_KUMA_BARS_COUNT + i;
|
||||||
|
|
||||||
if (historyIndex >= 0 && historyIndex < history.length) {
|
if (historyIndex >= 0 && historyIndex < history.length) {
|
||||||
return history[historyIndex];
|
return history[historyIndex];
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -183,13 +202,20 @@ const TIME_RANGE_OPTIONS = [
|
|||||||
{ key: String(24 * 60 * 60 * 1000), label: "24小时" },
|
{ key: String(24 * 60 * 60 * 1000), label: "24小时" },
|
||||||
];
|
];
|
||||||
|
|
||||||
function TimeRangeSelect({ value, onChange }: { value: number; onChange: (v: number) => void }) {
|
function TimeRangeSelect({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
value: number;
|
||||||
|
onChange: (v: number) => void;
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<Select
|
<Select
|
||||||
className="w-36"
|
className="w-36"
|
||||||
selectedKeys={[String(value)]}
|
selectedKeys={[String(value)]}
|
||||||
onSelectionChange={(keys) => {
|
onSelectionChange={(keys) => {
|
||||||
const v = Number(Array.from(keys)[0]);
|
const v = Number(Array.from(keys)[0]);
|
||||||
|
|
||||||
if (v > 0) onChange(v);
|
if (v > 0) onChange(v);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -205,13 +231,23 @@ interface QualityChartCardProps {
|
|||||||
onRangeChange: (v: number) => void;
|
onRangeChange: (v: number) => void;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
data: Array<{ time: string; entryToExit: number | null; exitToBing: number | null }>;
|
data: Array<{
|
||||||
|
time: string;
|
||||||
|
entryToExit: number | null;
|
||||||
|
exitToBing: number | null;
|
||||||
|
}>;
|
||||||
tunnelId: number;
|
tunnelId: number;
|
||||||
onRefresh: (id: number) => void;
|
onRefresh: (id: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const QualityChartCard = React.memo(function QualityChartCard({
|
const QualityChartCard = React.memo(function QualityChartCard({
|
||||||
rangeMs, onRangeChange, loading, error, data, tunnelId, onRefresh,
|
rangeMs,
|
||||||
|
onRangeChange,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
data,
|
||||||
|
tunnelId,
|
||||||
|
onRefresh,
|
||||||
}: QualityChartCardProps) {
|
}: QualityChartCardProps) {
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
@@ -219,7 +255,12 @@ const QualityChartCard = React.memo(function QualityChartCard({
|
|||||||
<h3 className="text-lg font-semibold">质量趋势</h3>
|
<h3 className="text-lg font-semibold">质量趋势</h3>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<TimeRangeSelect value={rangeMs} onChange={onRangeChange} />
|
<TimeRangeSelect value={rangeMs} onChange={onRangeChange} />
|
||||||
<Button isLoading={loading} size="sm" variant="flat" onPress={() => onRefresh(tunnelId)}>
|
<Button
|
||||||
|
isLoading={loading}
|
||||||
|
size="sm"
|
||||||
|
variant="flat"
|
||||||
|
onPress={() => onRefresh(tunnelId)}
|
||||||
|
>
|
||||||
<RefreshCw className="w-4 h-4 mr-1" />
|
<RefreshCw className="w-4 h-4 mr-1" />
|
||||||
刷新
|
刷新
|
||||||
</Button>
|
</Button>
|
||||||
@@ -227,38 +268,75 @@ const QualityChartCard = React.memo(function QualityChartCard({
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardBody>
|
<CardBody>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="flex justify-center py-8"><RefreshCw className="w-6 h-6 animate-spin" /></div>
|
<div className="flex justify-center py-8">
|
||||||
|
<RefreshCw className="w-6 h-6 animate-spin" />
|
||||||
|
</div>
|
||||||
) : error ? (
|
) : error ? (
|
||||||
<div className="text-center py-8 text-danger text-sm">{error}</div>
|
<div className="text-center py-8 text-danger text-sm">{error}</div>
|
||||||
) : data.length > 0 ? (
|
) : data.length > 0 ? (
|
||||||
<div className="h-64">
|
<div className="h-64">
|
||||||
<ResponsiveContainer height="100%" width="100%">
|
<ResponsiveContainer height="100%" width="100%">
|
||||||
<LineChart data={data}>
|
<LineChart data={data}>
|
||||||
<CartesianGrid strokeDasharray="3 3" opacity={0.3} />
|
<CartesianGrid opacity={0.3} strokeDasharray="3 3" />
|
||||||
<XAxis dataKey="time" fontSize={11} tick={{ fill: "#888" }} />
|
<XAxis dataKey="time" fontSize={11} tick={{ fill: "#888" }} />
|
||||||
<YAxis
|
<YAxis
|
||||||
fontSize={11}
|
fontSize={11}
|
||||||
|
label={{
|
||||||
|
value: "延迟 (ms)",
|
||||||
|
angle: -90,
|
||||||
|
position: "insideLeft",
|
||||||
|
style: { fontSize: 11, fill: "#888" },
|
||||||
|
}}
|
||||||
tick={{ fill: "#888" }}
|
tick={{ fill: "#888" }}
|
||||||
tickFormatter={(v: any) => `${Number(v).toFixed(0)}ms`}
|
tickFormatter={(v: any) => `${Number(v).toFixed(0)}ms`}
|
||||||
label={{ value: "延迟 (ms)", angle: -90, position: "insideLeft", style: { fontSize: 11, fill: "#888" } }}
|
|
||||||
/>
|
/>
|
||||||
<Tooltip
|
<Tooltip
|
||||||
contentStyle={{ backgroundColor: "rgba(0,0,0,0.85)", border: "none", borderRadius: "8px", fontSize: 12 }}
|
contentStyle={{
|
||||||
labelStyle={{ color: "#fff" }}
|
backgroundColor: "rgba(0,0,0,0.85)",
|
||||||
|
border: "none",
|
||||||
|
borderRadius: "8px",
|
||||||
|
fontSize: 12,
|
||||||
|
}}
|
||||||
formatter={(value: unknown, name: string) => {
|
formatter={(value: unknown, name: string) => {
|
||||||
const n = Number(value);
|
const n = Number(value);
|
||||||
|
|
||||||
if (!Number.isFinite(n)) return "-";
|
if (!Number.isFinite(n)) return "-";
|
||||||
const label = name === "entryToExit" ? "入口→出口" : name === "exitToBing" ? "出口→Bing" : name;
|
const label =
|
||||||
|
name === "entryToExit"
|
||||||
|
? "入口→出口"
|
||||||
|
: name === "exitToBing"
|
||||||
|
? "出口→Bing"
|
||||||
|
: name;
|
||||||
|
|
||||||
return [`${n.toFixed(1)}ms`, label];
|
return [`${n.toFixed(1)}ms`, label];
|
||||||
}}
|
}}
|
||||||
|
labelStyle={{ color: "#fff" }}
|
||||||
|
/>
|
||||||
|
<Line
|
||||||
|
connectNulls
|
||||||
|
dataKey="entryToExit"
|
||||||
|
dot={false}
|
||||||
|
name="entryToExit"
|
||||||
|
stroke="#10b981"
|
||||||
|
strokeWidth={2}
|
||||||
|
type="monotone"
|
||||||
|
/>
|
||||||
|
<Line
|
||||||
|
connectNulls
|
||||||
|
dataKey="exitToBing"
|
||||||
|
dot={false}
|
||||||
|
name="exitToBing"
|
||||||
|
stroke="#3b82f6"
|
||||||
|
strokeWidth={2}
|
||||||
|
type="monotone"
|
||||||
/>
|
/>
|
||||||
<Line connectNulls dataKey="entryToExit" dot={false} name="entryToExit" stroke="#10b981" strokeWidth={2} type="monotone" />
|
|
||||||
<Line connectNulls dataKey="exitToBing" dot={false} name="exitToBing" stroke="#3b82f6" strokeWidth={2} type="monotone" />
|
|
||||||
</LineChart>
|
</LineChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-center py-8 text-default-500">暂无质量历史数据</div>
|
<div className="text-center py-8 text-default-500">
|
||||||
|
暂无质量历史数据
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardBody>
|
</CardBody>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -270,20 +348,33 @@ interface TrafficChartCardProps {
|
|||||||
onRangeChange: (v: number) => void;
|
onRangeChange: (v: number) => void;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
data: Array<{ time: string; bytesIn: number; bytesOut: number; connections: number }>;
|
data: Array<{
|
||||||
|
time: string;
|
||||||
|
bytesIn: number;
|
||||||
|
bytesOut: number;
|
||||||
|
connections: number;
|
||||||
|
}>;
|
||||||
tunnelId: number;
|
tunnelId: number;
|
||||||
onRefresh: (id: number) => void;
|
onRefresh: (id: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TrafficChartCard = React.memo(function TrafficChartCard({
|
const TrafficChartCard = React.memo(function TrafficChartCard({
|
||||||
rangeMs, onRangeChange, loading, error, data, tunnelId, onRefresh,
|
rangeMs,
|
||||||
|
onRangeChange,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
data,
|
||||||
|
tunnelId,
|
||||||
|
onRefresh,
|
||||||
}: TrafficChartCardProps) {
|
}: TrafficChartCardProps) {
|
||||||
const yFormatter = (value: unknown) => {
|
const yFormatter = (value: unknown) => {
|
||||||
const n = Number(value);
|
const n = Number(value);
|
||||||
|
|
||||||
if (!Number.isFinite(n) || n <= 0) return "0 B";
|
if (!Number.isFinite(n) || n <= 0) return "0 B";
|
||||||
const k = 1024;
|
const k = 1024;
|
||||||
const sizes = ["B", "KB", "MB", "GB", "TB"];
|
const sizes = ["B", "KB", "MB", "GB", "TB"];
|
||||||
const i = Math.floor(Math.log(n) / Math.log(k));
|
const i = Math.floor(Math.log(n) / Math.log(k));
|
||||||
|
|
||||||
return `${parseFloat((n / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
|
return `${parseFloat((n / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -293,7 +384,12 @@ const TrafficChartCard = React.memo(function TrafficChartCard({
|
|||||||
<h3 className="text-lg font-semibold">流量趋势</h3>
|
<h3 className="text-lg font-semibold">流量趋势</h3>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<TimeRangeSelect value={rangeMs} onChange={onRangeChange} />
|
<TimeRangeSelect value={rangeMs} onChange={onRangeChange} />
|
||||||
<Button isLoading={loading} size="sm" variant="flat" onPress={() => onRefresh(tunnelId)}>
|
<Button
|
||||||
|
isLoading={loading}
|
||||||
|
size="sm"
|
||||||
|
variant="flat"
|
||||||
|
onPress={() => onRefresh(tunnelId)}
|
||||||
|
>
|
||||||
<RefreshCw className="w-4 h-4 mr-1" />
|
<RefreshCw className="w-4 h-4 mr-1" />
|
||||||
刷新
|
刷新
|
||||||
</Button>
|
</Button>
|
||||||
@@ -301,23 +397,48 @@ const TrafficChartCard = React.memo(function TrafficChartCard({
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardBody>
|
<CardBody>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="flex justify-center py-8"><RefreshCw className="w-6 h-6 animate-spin" /></div>
|
<div className="flex justify-center py-8">
|
||||||
|
<RefreshCw className="w-6 h-6 animate-spin" />
|
||||||
|
</div>
|
||||||
) : error ? (
|
) : error ? (
|
||||||
<div className="text-center py-8 text-danger text-sm">{error}</div>
|
<div className="text-center py-8 text-danger text-sm">{error}</div>
|
||||||
) : data.length > 0 ? (
|
) : data.length > 0 ? (
|
||||||
<div className="h-64">
|
<div className="h-64">
|
||||||
<ResponsiveContainer height="100%" width="100%">
|
<ResponsiveContainer height="100%" width="100%">
|
||||||
<LineChart data={data}>
|
<LineChart data={data}>
|
||||||
<CartesianGrid strokeDasharray="3 3" opacity={0.3} />
|
<CartesianGrid opacity={0.3} strokeDasharray="3 3" />
|
||||||
<XAxis dataKey="time" fontSize={11} tick={{ fill: "#888" }} />
|
<XAxis dataKey="time" fontSize={11} tick={{ fill: "#888" }} />
|
||||||
<YAxis fontSize={11} tick={{ fill: "#888" }} tickFormatter={yFormatter} />
|
<YAxis
|
||||||
<Tooltip
|
fontSize={11}
|
||||||
contentStyle={{ backgroundColor: "rgba(0,0,0,0.85)", border: "none", borderRadius: "8px", fontSize: 12 }}
|
tick={{ fill: "#888" }}
|
||||||
labelStyle={{ color: "#fff" }}
|
tickFormatter={yFormatter}
|
||||||
formatter={yFormatter}
|
/>
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{
|
||||||
|
backgroundColor: "rgba(0,0,0,0.85)",
|
||||||
|
border: "none",
|
||||||
|
borderRadius: "8px",
|
||||||
|
fontSize: 12,
|
||||||
|
}}
|
||||||
|
formatter={yFormatter}
|
||||||
|
labelStyle={{ color: "#fff" }}
|
||||||
|
/>
|
||||||
|
<Line
|
||||||
|
dataKey="bytesIn"
|
||||||
|
dot={false}
|
||||||
|
name="入站流量"
|
||||||
|
stroke="#10b981"
|
||||||
|
strokeWidth={2}
|
||||||
|
type="monotone"
|
||||||
|
/>
|
||||||
|
<Line
|
||||||
|
dataKey="bytesOut"
|
||||||
|
dot={false}
|
||||||
|
name="出站流量"
|
||||||
|
stroke="#ef4444"
|
||||||
|
strokeWidth={2}
|
||||||
|
type="monotone"
|
||||||
/>
|
/>
|
||||||
<Line dataKey="bytesIn" dot={false} name="入站流量" stroke="#10b981" strokeWidth={2} type="monotone" />
|
|
||||||
<Line dataKey="bytesOut" dot={false} name="出站流量" stroke="#ef4444" strokeWidth={2} type="monotone" />
|
|
||||||
</LineChart>
|
</LineChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</div>
|
</div>
|
||||||
@@ -333,6 +454,7 @@ function ForwardingChainTopology({ hopsStr }: { hopsStr?: string }) {
|
|||||||
if (!hopsStr) return null;
|
if (!hopsStr) return null;
|
||||||
|
|
||||||
let hops: TunnelQualityHopApiItem[] = [];
|
let hops: TunnelQualityHopApiItem[] = [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
hops = JSON.parse(hopsStr);
|
hops = JSON.parse(hopsStr);
|
||||||
} catch {
|
} catch {
|
||||||
@@ -353,28 +475,49 @@ function ForwardingChainTopology({ hopsStr }: { hopsStr?: string }) {
|
|||||||
<div className="flex items-center overflow-x-auto pb-2 py-2">
|
<div className="flex items-center overflow-x-auto pb-2 py-2">
|
||||||
{hops.map((hop, index) => {
|
{hops.map((hop, index) => {
|
||||||
const hasError = hop.latency < 0 || hop.loss > 0;
|
const hasError = hop.latency < 0 || hop.loss > 0;
|
||||||
const colorClass = hop.latency < 0 ? "text-danger" : (hop.loss > 0 ? "text-warning" : "text-success");
|
const colorClass =
|
||||||
|
hop.latency < 0
|
||||||
|
? "text-danger"
|
||||||
|
: hop.loss > 0
|
||||||
|
? "text-warning"
|
||||||
|
: "text-success";
|
||||||
const borderColor = hasError ? "border-danger" : "";
|
const borderColor = hasError ? "border-danger" : "";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<React.Fragment key={index}>
|
<React.Fragment key={index}>
|
||||||
{index === 0 && (
|
{index === 0 && (
|
||||||
<Chip size="sm" variant="flat" className="shrink-0 font-mono shadow-sm">
|
<Chip
|
||||||
|
className="shrink-0 font-mono shadow-sm"
|
||||||
|
size="sm"
|
||||||
|
variant="flat"
|
||||||
|
>
|
||||||
{hop.fromNodeName}
|
{hop.fromNodeName}
|
||||||
</Chip>
|
</Chip>
|
||||||
)}
|
)}
|
||||||
<div className="flex flex-col items-center justify-center min-w-[70px] mx-1 shrink-0 relative">
|
<div className="flex flex-col items-center justify-center min-w-[70px] mx-1 shrink-0 relative">
|
||||||
<span className={`text-[10px] font-mono leading-none mb-1 ${colorClass}`}>
|
<span
|
||||||
|
className={`text-[10px] font-mono leading-none mb-1 ${colorClass}`}
|
||||||
|
>
|
||||||
{hop.latency >= 0 ? `${hop.latency.toFixed(0)}ms` : "超时"}
|
{hop.latency >= 0 ? `${hop.latency.toFixed(0)}ms` : "超时"}
|
||||||
</span>
|
</span>
|
||||||
<div className={`h-[2px] w-full relative flex items-center justify-end bg-default-200 ${hop.latency < 0 ? "!bg-danger" : ""}`}>
|
<div
|
||||||
<ArrowRight className={`w-3.5 h-3.5 absolute -right-2 ${colorClass} bg-background rounded-full p-[1px] z-10`} />
|
className={`h-[2px] w-full relative flex items-center justify-end bg-default-200 ${hop.latency < 0 ? "!bg-danger" : ""}`}
|
||||||
|
>
|
||||||
|
<ArrowRight
|
||||||
|
className={`w-3.5 h-3.5 absolute -right-2 ${colorClass} bg-background rounded-full p-[1px] z-10`}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<span className={`text-[10px] font-mono leading-none mt-1.5 ${hop.loss > 0 ? "text-warning" : "text-default-400"}`}>
|
<span
|
||||||
|
className={`text-[10px] font-mono leading-none mt-1.5 ${hop.loss > 0 ? "text-warning" : "text-default-400"}`}
|
||||||
|
>
|
||||||
{hop.loss.toFixed(0)}% 丢包
|
{hop.loss.toFixed(0)}% 丢包
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<Chip size="sm" variant="flat" className={`shrink-0 font-mono shadow-sm ${borderColor}`}>
|
<Chip
|
||||||
|
className={`shrink-0 font-mono shadow-sm ${borderColor}`}
|
||||||
|
size="sm"
|
||||||
|
variant="flat"
|
||||||
|
>
|
||||||
{hop.toNodeName}
|
{hop.toNodeName}
|
||||||
</Chip>
|
</Chip>
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
@@ -386,15 +529,21 @@ function ForwardingChainTopology({ hopsStr }: { hopsStr?: string }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps) {
|
export function TunnelMonitorView({
|
||||||
|
viewMode = "grid",
|
||||||
|
}: TunnelMonitorViewProps) {
|
||||||
const [tunnels, setTunnels] = useState<MonitorTunnelApiItem[]>([]);
|
const [tunnels, setTunnels] = useState<MonitorTunnelApiItem[]>([]);
|
||||||
const [tunnelsLoading, setTunnelsLoading] = useState(false);
|
const [tunnelsLoading, setTunnelsLoading] = useState(false);
|
||||||
const [tunnelsError, setTunnelsError] = useState<string | null>(null);
|
const [tunnelsError, setTunnelsError] = useState<string | null>(null);
|
||||||
const [accessDenied, setAccessDenied] = useState<string | null>(null);
|
const [accessDenied, setAccessDenied] = useState<string | null>(null);
|
||||||
|
|
||||||
// Quality data from backend periodic probing (latest per tunnel)
|
// Quality data from backend periodic probing (latest per tunnel)
|
||||||
const [qualityMap, setQualityMap] = useState<Record<number, TunnelQualityApiItem>>({});
|
const [qualityMap, setQualityMap] = useState<
|
||||||
const [qualityHistoryMap, setQualityHistoryMap] = useState<Record<number, TunnelQualityApiItem[]>>({});
|
Record<number, TunnelQualityApiItem>
|
||||||
|
>({});
|
||||||
|
const [qualityHistoryMap, setQualityHistoryMap] = useState<
|
||||||
|
Record<number, TunnelQualityApiItem[]>
|
||||||
|
>({});
|
||||||
const initialHistoryFetched = useRef(false);
|
const initialHistoryFetched = useRef(false);
|
||||||
const [qualityLoading, setQualityLoading] = useState(false);
|
const [qualityLoading, setQualityLoading] = useState(false);
|
||||||
const qualityTimerRef = useRef<number | null>(null);
|
const qualityTimerRef = useRef<number | null>(null);
|
||||||
@@ -405,20 +554,27 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
|
|||||||
const [detailTunnelId, setDetailTunnelId] = useState<number | null>(null);
|
const [detailTunnelId, setDetailTunnelId] = useState<number | null>(null);
|
||||||
|
|
||||||
// Quality history for chart (mirrors service monitor results)
|
// Quality history for chart (mirrors service monitor results)
|
||||||
const [qualityHistory, setQualityHistory] = useState<TunnelQualityApiItem[]>([]);
|
const [qualityHistory, setQualityHistory] = useState<TunnelQualityApiItem[]>(
|
||||||
|
[],
|
||||||
|
);
|
||||||
const [qualityHistoryLoading, setQualityHistoryLoading] = useState(false);
|
const [qualityHistoryLoading, setQualityHistoryLoading] = useState(false);
|
||||||
const [qualityHistoryError, setQualityHistoryError] = useState<string | null>(null);
|
const [qualityHistoryError, setQualityHistoryError] = useState<string | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
const [qualityRangeMs, setQualityRangeMs] = useState(60 * 60 * 1000);
|
const [qualityRangeMs, setQualityRangeMs] = useState(60 * 60 * 1000);
|
||||||
|
|
||||||
// Tunnel traffic metrics for chart
|
// Tunnel traffic metrics for chart
|
||||||
const [tunnelMetrics, setTunnelMetrics] = useState<TunnelMetricApiItem[]>([]);
|
const [tunnelMetrics, setTunnelMetrics] = useState<TunnelMetricApiItem[]>([]);
|
||||||
const [tunnelMetricsLoading, setTunnelMetricsLoading] = useState(false);
|
const [tunnelMetricsLoading, setTunnelMetricsLoading] = useState(false);
|
||||||
const [tunnelMetricsError, setTunnelMetricsError] = useState<string | null>(null);
|
const [tunnelMetricsError, setTunnelMetricsError] = useState<string | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
const [tunnelRangeMs, setTunnelRangeMs] = useState(60 * 60 * 1000);
|
const [tunnelRangeMs, setTunnelRangeMs] = useState(60 * 60 * 1000);
|
||||||
|
|
||||||
// --- Load tunnel list ---
|
// --- Load tunnel list ---
|
||||||
const loadTunnels = useCallback(async (options?: { silent?: boolean }) => {
|
const loadTunnels = useCallback(async (options?: { silent?: boolean }) => {
|
||||||
const silent = options?.silent ?? false;
|
const silent = options?.silent ?? false;
|
||||||
|
|
||||||
if (!silent) setTunnelsLoading(true);
|
if (!silent) setTunnelsLoading(true);
|
||||||
try {
|
try {
|
||||||
const response = await getMonitorTunnels();
|
const response = await getMonitorTunnels();
|
||||||
@@ -427,12 +583,14 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
|
|||||||
setAccessDenied(null);
|
setAccessDenied(null);
|
||||||
setTunnelsError(null);
|
setTunnelsError(null);
|
||||||
setTunnels(response.data);
|
setTunnels(response.data);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (response.code === 403) {
|
if (response.code === 403) {
|
||||||
setAccessDenied(response.msg || "暂无监控权限,请联系管理员授权");
|
setAccessDenied(response.msg || "暂无监控权限,请联系管理员授权");
|
||||||
setTunnelsError(null);
|
setTunnelsError(null);
|
||||||
setTunnels([]);
|
setTunnels([]);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setTunnelsError(response.msg || "加载隧道列表失败");
|
setTunnelsError(response.msg || "加载隧道列表失败");
|
||||||
@@ -452,6 +610,7 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
|
|||||||
const response = await getConfigByName(
|
const response = await getConfigByName(
|
||||||
MONITOR_TUNNEL_QUALITY_ENABLED_CONFIG_KEY,
|
MONITOR_TUNNEL_QUALITY_ENABLED_CONFIG_KEY,
|
||||||
);
|
);
|
||||||
|
|
||||||
setMonitorTunnelQualityEnabled(
|
setMonitorTunnelQualityEnabled(
|
||||||
typeof response.data?.value === "string"
|
typeof response.data?.value === "string"
|
||||||
? response.data.value === "true"
|
? response.data.value === "true"
|
||||||
@@ -477,7 +636,9 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleMonitorTunnelQualityEnabledChanged = (event: Event) => {
|
const handleMonitorTunnelQualityEnabledChanged = (event: Event) => {
|
||||||
const enabled = (event as CustomEvent<{ enabled?: boolean }>).detail?.enabled;
|
const enabled = (event as CustomEvent<{ enabled?: boolean }>).detail
|
||||||
|
?.enabled;
|
||||||
|
|
||||||
if (typeof enabled === "boolean") {
|
if (typeof enabled === "boolean") {
|
||||||
setMonitorTunnelQualityEnabled(enabled);
|
setMonitorTunnelQualityEnabled(enabled);
|
||||||
if (!enabled) {
|
if (!enabled) {
|
||||||
@@ -510,31 +671,44 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
|
|||||||
const start = end - 5 * 60 * 1000;
|
const start = end - 5 * 60 * 1000;
|
||||||
|
|
||||||
const promises = tunnels.map((t) =>
|
const promises = tunnels.map((t) =>
|
||||||
getMonitorTunnelQualityHistory(t.id, start, end).then(res => {
|
getMonitorTunnelQualityHistory(t.id, start, end)
|
||||||
if (res.code === 0 && res.data) {
|
.then((res) => {
|
||||||
return { id: t.id, data: res.data };
|
if (res.code === 0 && res.data) {
|
||||||
}
|
return { id: t.id, data: res.data };
|
||||||
return { id: t.id, data: [] };
|
}
|
||||||
}).catch(() => ({ id: t.id, data: [] }))
|
|
||||||
|
return { id: t.id, data: [] };
|
||||||
|
})
|
||||||
|
.catch(() => ({ id: t.id, data: [] })),
|
||||||
);
|
);
|
||||||
|
|
||||||
const results = await Promise.all(promises);
|
const results = await Promise.all(promises);
|
||||||
setQualityHistoryMap(prev => {
|
|
||||||
|
setQualityHistoryMap((prev) => {
|
||||||
const nextMap = { ...prev };
|
const nextMap = { ...prev };
|
||||||
results.forEach(r => {
|
|
||||||
|
results.forEach((r) => {
|
||||||
const existing = nextMap[r.id] || [];
|
const existing = nextMap[r.id] || [];
|
||||||
const merged = [...r.data, ...existing].sort((a,b) => a.timestamp - b.timestamp);
|
const merged = [...r.data, ...existing].sort(
|
||||||
|
(a, b) => a.timestamp - b.timestamp,
|
||||||
|
);
|
||||||
const unique: TunnelQualityApiItem[] = [];
|
const unique: TunnelQualityApiItem[] = [];
|
||||||
|
|
||||||
for (const item of merged) {
|
for (const item of merged) {
|
||||||
if (unique.length === 0 || unique[unique.length-1].timestamp !== item.timestamp) {
|
if (
|
||||||
unique.push(item);
|
unique.length === 0 ||
|
||||||
}
|
unique[unique.length - 1].timestamp !== item.timestamp
|
||||||
|
) {
|
||||||
|
unique.push(item);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
nextMap[r.id] = unique.slice(-UPTIME_KUMA_BARS_COUNT);
|
nextMap[r.id] = unique.slice(-UPTIME_KUMA_BARS_COUNT);
|
||||||
});
|
});
|
||||||
|
|
||||||
return nextMap;
|
return nextMap;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
void fetchHistory();
|
void fetchHistory();
|
||||||
}
|
}
|
||||||
}, [tunnels]);
|
}, [tunnels]);
|
||||||
@@ -542,27 +716,36 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
|
|||||||
// --- Load quality snapshots (auto-polling every 10s) ---
|
// --- Load quality snapshots (auto-polling every 10s) ---
|
||||||
const loadQuality = useCallback(async (options?: { silent?: boolean }) => {
|
const loadQuality = useCallback(async (options?: { silent?: boolean }) => {
|
||||||
const silent = options?.silent ?? false;
|
const silent = options?.silent ?? false;
|
||||||
|
|
||||||
if (!silent) setQualityLoading(true);
|
if (!silent) setQualityLoading(true);
|
||||||
try {
|
try {
|
||||||
const response = await getMonitorTunnelQuality();
|
const response = await getMonitorTunnelQuality();
|
||||||
|
|
||||||
if (response.code === 0 && Array.isArray(response.data)) {
|
if (response.code === 0 && Array.isArray(response.data)) {
|
||||||
const map: Record<number, TunnelQualityApiItem> = {};
|
const map: Record<number, TunnelQualityApiItem> = {};
|
||||||
|
|
||||||
for (const q of response.data) {
|
for (const q of response.data) {
|
||||||
map[q.tunnelId] = q;
|
map[q.tunnelId] = q;
|
||||||
}
|
}
|
||||||
setQualityMap(map);
|
setQualityMap(map);
|
||||||
|
|
||||||
// Accumulate locally
|
// Accumulate locally
|
||||||
setQualityHistoryMap(prev => {
|
setQualityHistoryMap((prev) => {
|
||||||
const nextMap = { ...prev };
|
const nextMap = { ...prev };
|
||||||
for (const q of response.data) {
|
|
||||||
const currentArr = nextMap[q.tunnelId] || [];
|
for (const q of response.data) {
|
||||||
const lastItem = currentArr.length > 0 ? currentArr[currentArr.length - 1] : null;
|
const currentArr = nextMap[q.tunnelId] || [];
|
||||||
if (!lastItem || lastItem.timestamp !== q.timestamp) {
|
const lastItem =
|
||||||
nextMap[q.tunnelId] = [...currentArr, q].slice(-UPTIME_KUMA_BARS_COUNT);
|
currentArr.length > 0 ? currentArr[currentArr.length - 1] : null;
|
||||||
}
|
|
||||||
}
|
if (!lastItem || lastItem.timestamp !== q.timestamp) {
|
||||||
return nextMap;
|
nextMap[q.tunnelId] = [...currentArr, q].slice(
|
||||||
|
-UPTIME_KUMA_BARS_COUNT,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nextMap;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
@@ -586,6 +769,7 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
|
|||||||
window.clearInterval(qualityTimerRef.current);
|
window.clearInterval(qualityTimerRef.current);
|
||||||
qualityTimerRef.current = null;
|
qualityTimerRef.current = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -605,19 +789,26 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
|
|||||||
const loadQualityHistory = useCallback(
|
const loadQualityHistory = useCallback(
|
||||||
async (tunnelId: number, options?: { silent?: boolean }) => {
|
async (tunnelId: number, options?: { silent?: boolean }) => {
|
||||||
const silent = options?.silent ?? false;
|
const silent = options?.silent ?? false;
|
||||||
|
|
||||||
if (!silent) setQualityHistoryLoading(true);
|
if (!silent) setQualityHistoryLoading(true);
|
||||||
try {
|
try {
|
||||||
const end = Date.now();
|
const end = Date.now();
|
||||||
const start = end - qualityRangeMs;
|
const start = end - qualityRangeMs;
|
||||||
const response = await getMonitorTunnelQualityHistory(tunnelId, start, end);
|
const response = await getMonitorTunnelQualityHistory(
|
||||||
|
tunnelId,
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
);
|
||||||
|
|
||||||
if (response.code === 0 && Array.isArray(response.data)) {
|
if (response.code === 0 && Array.isArray(response.data)) {
|
||||||
setQualityHistoryError(null);
|
setQualityHistoryError(null);
|
||||||
setQualityHistory(response.data);
|
setQualityHistory(response.data);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (response.code === 403) {
|
if (response.code === 403) {
|
||||||
setAccessDenied(response.msg || "暂无监控权限");
|
setAccessDenied(response.msg || "暂无监控权限");
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setQualityHistoryError(response.msg || "加载质量历史失败");
|
setQualityHistoryError(response.msg || "加载质量历史失败");
|
||||||
@@ -637,6 +828,7 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
|
|||||||
const loadTunnelMetrics = useCallback(
|
const loadTunnelMetrics = useCallback(
|
||||||
async (tunnelId: number, options?: { silent?: boolean }) => {
|
async (tunnelId: number, options?: { silent?: boolean }) => {
|
||||||
const silent = options?.silent ?? false;
|
const silent = options?.silent ?? false;
|
||||||
|
|
||||||
if (!silent) setTunnelMetricsLoading(true);
|
if (!silent) setTunnelMetricsLoading(true);
|
||||||
try {
|
try {
|
||||||
const end = Date.now();
|
const end = Date.now();
|
||||||
@@ -648,7 +840,9 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
|
|||||||
const ordered = [...response.data].sort(
|
const ordered = [...response.data].sort(
|
||||||
(a, b) => a.timestamp - b.timestamp,
|
(a, b) => a.timestamp - b.timestamp,
|
||||||
);
|
);
|
||||||
|
|
||||||
setTunnelMetrics(ordered);
|
setTunnelMetrics(ordered);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setTunnelMetricsError(response.msg || "加载流量数据失败");
|
setTunnelMetricsError(response.msg || "加载流量数据失败");
|
||||||
@@ -695,29 +889,32 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
|
|||||||
|
|
||||||
// Memoize chart data so React.memo sub-components see stable references
|
// Memoize chart data so React.memo sub-components see stable references
|
||||||
const qualityChartData = useMemo(
|
const qualityChartData = useMemo(
|
||||||
() => qualityHistory.map((q) => ({
|
() =>
|
||||||
time: formatTimestamp(q.timestamp, qualityRangeMs),
|
qualityHistory.map((q) => ({
|
||||||
entryToExit: q.entryToExitLatency >= 0 ? q.entryToExitLatency : null,
|
time: formatTimestamp(q.timestamp, qualityRangeMs),
|
||||||
exitToBing: q.exitToBingLatency >= 0 ? q.exitToBingLatency : null,
|
entryToExit: q.entryToExitLatency >= 0 ? q.entryToExitLatency : null,
|
||||||
entryToExitLoss: q.entryToExitLoss,
|
exitToBing: q.exitToBingLatency >= 0 ? q.exitToBingLatency : null,
|
||||||
exitToBingLoss: q.exitToBingLoss,
|
entryToExitLoss: q.entryToExitLoss,
|
||||||
})),
|
exitToBingLoss: q.exitToBingLoss,
|
||||||
|
})),
|
||||||
[qualityHistory, qualityRangeMs],
|
[qualityHistory, qualityRangeMs],
|
||||||
);
|
);
|
||||||
|
|
||||||
const tunnelChartData = useMemo(
|
const tunnelChartData = useMemo(
|
||||||
() => tunnelMetrics.map((m) => ({
|
() =>
|
||||||
time: formatTimestamp(m.timestamp, tunnelRangeMs),
|
tunnelMetrics.map((m) => ({
|
||||||
bytesIn: m.bytesIn,
|
time: formatTimestamp(m.timestamp, tunnelRangeMs),
|
||||||
bytesOut: m.bytesOut,
|
bytesIn: m.bytesIn,
|
||||||
connections: m.connections,
|
bytesOut: m.bytesOut,
|
||||||
})),
|
connections: m.connections,
|
||||||
|
})),
|
||||||
[tunnelMetrics, tunnelRangeMs],
|
[tunnelMetrics, tunnelRangeMs],
|
||||||
);
|
);
|
||||||
|
|
||||||
const detailTunnel = detailTunnelId != null
|
const detailTunnel =
|
||||||
? tunnels.find((t) => t.id === detailTunnelId)
|
detailTunnelId != null
|
||||||
: null;
|
? tunnels.find((t) => t.id === detailTunnelId)
|
||||||
|
: null;
|
||||||
|
|
||||||
// Aggregate stats
|
// Aggregate stats
|
||||||
const tunnelStats = useMemo(() => {
|
const tunnelStats = useMemo(() => {
|
||||||
@@ -729,9 +926,11 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
|
|||||||
// Last quality update timestamp
|
// Last quality update timestamp
|
||||||
const lastQualityUpdate = useMemo(() => {
|
const lastQualityUpdate = useMemo(() => {
|
||||||
let latest = 0;
|
let latest = 0;
|
||||||
|
|
||||||
for (const q of Object.values(qualityMap)) {
|
for (const q of Object.values(qualityMap)) {
|
||||||
if (q.timestamp > latest) latest = q.timestamp;
|
if (q.timestamp > latest) latest = q.timestamp;
|
||||||
}
|
}
|
||||||
|
|
||||||
return latest > 0 ? new Date(latest).toLocaleTimeString("zh-CN") : null;
|
return latest > 0 ? new Date(latest).toLocaleTimeString("zh-CN") : null;
|
||||||
}, [qualityMap]);
|
}, [qualityMap]);
|
||||||
|
|
||||||
@@ -759,18 +958,28 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
|
|||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center gap-3 flex-wrap">
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
<Button size="sm" variant="flat" onPress={() => {
|
<Button
|
||||||
setDetailTunnelId(null);
|
size="sm"
|
||||||
setQualityHistory([]);
|
variant="flat"
|
||||||
setTunnelMetrics([]);
|
onPress={() => {
|
||||||
}}>
|
setDetailTunnelId(null);
|
||||||
|
setQualityHistory([]);
|
||||||
|
setTunnelMetrics([]);
|
||||||
|
}}
|
||||||
|
>
|
||||||
<ArrowLeft className="w-4 h-4 mr-1" />
|
<ArrowLeft className="w-4 h-4 mr-1" />
|
||||||
返回隧道列表
|
返回隧道列表
|
||||||
</Button>
|
</Button>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<ArrowRightLeft className={`w-5 h-5 ${detailTunnel.status === 1 ? "text-success" : "text-default-400"}`} />
|
<ArrowRightLeft
|
||||||
|
className={`w-5 h-5 ${detailTunnel.status === 1 ? "text-success" : "text-default-400"}`}
|
||||||
|
/>
|
||||||
<h3 className="text-lg font-semibold">{detailTunnel.name}</h3>
|
<h3 className="text-lg font-semibold">{detailTunnel.name}</h3>
|
||||||
<Chip size="sm" color={detailTunnel.status === 1 ? "success" : "danger"} variant="flat">
|
<Chip
|
||||||
|
color={detailTunnel.status === 1 ? "success" : "danger"}
|
||||||
|
size="sm"
|
||||||
|
variant="flat"
|
||||||
|
>
|
||||||
{detailTunnel.status === 1 ? "启用" : "禁用"}
|
{detailTunnel.status === 1 ? "启用" : "禁用"}
|
||||||
</Chip>
|
</Chip>
|
||||||
</div>
|
</div>
|
||||||
@@ -784,7 +993,10 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
|
|||||||
<Zap className="w-3 h-3" />
|
<Zap className="w-3 h-3" />
|
||||||
入口 → 出口 延迟
|
入口 → 出口 延迟
|
||||||
</span>
|
</span>
|
||||||
<LatencyDisplay value={quality?.entryToExitLatency} loading={qualityLoading} />
|
<LatencyDisplay
|
||||||
|
loading={qualityLoading}
|
||||||
|
value={quality?.entryToExitLatency}
|
||||||
|
/>
|
||||||
</CardBody>
|
</CardBody>
|
||||||
</Card>
|
</Card>
|
||||||
<Card className="border border-divider/60 shadow-sm hover:shadow-md transition-shadow bg-gradient-to-br from-background to-default-50/50">
|
<Card className="border border-divider/60 shadow-sm hover:shadow-md transition-shadow bg-gradient-to-br from-background to-default-50/50">
|
||||||
@@ -793,22 +1005,37 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
|
|||||||
<Globe className="w-3 h-3" />
|
<Globe className="w-3 h-3" />
|
||||||
出口 → Bing 延迟
|
出口 → Bing 延迟
|
||||||
</span>
|
</span>
|
||||||
<LatencyDisplay value={quality?.exitToBingLatency} loading={qualityLoading} />
|
<LatencyDisplay
|
||||||
|
loading={qualityLoading}
|
||||||
|
value={quality?.exitToBingLatency}
|
||||||
|
/>
|
||||||
</CardBody>
|
</CardBody>
|
||||||
</Card>
|
</Card>
|
||||||
<Card className="border border-divider/60 shadow-sm hover:shadow-md transition-shadow bg-gradient-to-br from-background to-default-50/50">
|
<Card className="border border-divider/60 shadow-sm hover:shadow-md transition-shadow bg-gradient-to-br from-background to-default-50/50">
|
||||||
<CardBody className="py-3 px-4 flex flex-col items-center justify-center min-h-[5rem]">
|
<CardBody className="py-3 px-4 flex flex-col items-center justify-center min-h-[5rem]">
|
||||||
<span className="text-[11px] text-default-500 mb-1.5">入口 → 出口 丢包</span>
|
<span className="text-[11px] text-default-500 mb-1.5">
|
||||||
<span className={`text-sm font-semibold font-mono ${(quality?.entryToExitLoss ?? 0) > 0 ? "text-warning" : ""}`}>
|
入口 → 出口 丢包
|
||||||
{quality?.entryToExitLoss !== undefined ? `${quality.entryToExitLoss.toFixed(1)}%` : "-"}
|
</span>
|
||||||
|
<span
|
||||||
|
className={`text-sm font-semibold font-mono ${(quality?.entryToExitLoss ?? 0) > 0 ? "text-warning" : ""}`}
|
||||||
|
>
|
||||||
|
{quality?.entryToExitLoss !== undefined
|
||||||
|
? `${quality.entryToExitLoss.toFixed(1)}%`
|
||||||
|
: "-"}
|
||||||
</span>
|
</span>
|
||||||
</CardBody>
|
</CardBody>
|
||||||
</Card>
|
</Card>
|
||||||
<Card className="border border-divider/60 shadow-sm hover:shadow-md transition-shadow bg-gradient-to-br from-background to-default-50/50">
|
<Card className="border border-divider/60 shadow-sm hover:shadow-md transition-shadow bg-gradient-to-br from-background to-default-50/50">
|
||||||
<CardBody className="py-3 px-4 flex flex-col items-center justify-center min-h-[5rem]">
|
<CardBody className="py-3 px-4 flex flex-col items-center justify-center min-h-[5rem]">
|
||||||
<span className="text-[11px] text-default-500 mb-1.5">出口 → Bing 丢包</span>
|
<span className="text-[11px] text-default-500 mb-1.5">
|
||||||
<span className={`text-sm font-semibold font-mono ${(quality?.exitToBingLoss ?? 0) > 0 ? "text-warning" : ""}`}>
|
出口 → Bing 丢包
|
||||||
{quality?.exitToBingLoss !== undefined ? `${quality.exitToBingLoss.toFixed(1)}%` : "-"}
|
</span>
|
||||||
|
<span
|
||||||
|
className={`text-sm font-semibold font-mono ${(quality?.exitToBingLoss ?? 0) > 0 ? "text-warning" : ""}`}
|
||||||
|
>
|
||||||
|
{quality?.exitToBingLoss !== undefined
|
||||||
|
? `${quality.exitToBingLoss.toFixed(1)}%`
|
||||||
|
: "-"}
|
||||||
</span>
|
</span>
|
||||||
</CardBody>
|
</CardBody>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -829,7 +1056,8 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
|
|||||||
)}
|
)}
|
||||||
{quality?.timestamp && (
|
{quality?.timestamp && (
|
||||||
<span className="text-default-400">
|
<span className="text-default-400">
|
||||||
· 最近更新: {new Date(quality.timestamp).toLocaleTimeString("zh-CN")}
|
· 最近更新:{" "}
|
||||||
|
{new Date(quality.timestamp).toLocaleTimeString("zh-CN")}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{quality?.errorMessage && (
|
{quality?.errorMessage && (
|
||||||
@@ -844,23 +1072,23 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
|
|||||||
|
|
||||||
{/* ====== Quality History Chart — isolated with React.memo ====== */}
|
{/* ====== Quality History Chart — isolated with React.memo ====== */}
|
||||||
<QualityChartCard
|
<QualityChartCard
|
||||||
rangeMs={qualityRangeMs}
|
|
||||||
onRangeChange={setQualityRangeMs}
|
|
||||||
loading={qualityHistoryLoading}
|
|
||||||
error={qualityHistoryError}
|
|
||||||
data={qualityChartData}
|
data={qualityChartData}
|
||||||
|
error={qualityHistoryError}
|
||||||
|
loading={qualityHistoryLoading}
|
||||||
|
rangeMs={qualityRangeMs}
|
||||||
tunnelId={detailTunnelId}
|
tunnelId={detailTunnelId}
|
||||||
|
onRangeChange={setQualityRangeMs}
|
||||||
onRefresh={loadQualityHistory}
|
onRefresh={loadQualityHistory}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* ====== Traffic Chart — isolated with React.memo ====== */}
|
{/* ====== Traffic Chart — isolated with React.memo ====== */}
|
||||||
<TrafficChartCard
|
<TrafficChartCard
|
||||||
rangeMs={tunnelRangeMs}
|
|
||||||
onRangeChange={setTunnelRangeMs}
|
|
||||||
loading={tunnelMetricsLoading}
|
|
||||||
error={tunnelMetricsError}
|
|
||||||
data={tunnelChartData}
|
data={tunnelChartData}
|
||||||
|
error={tunnelMetricsError}
|
||||||
|
loading={tunnelMetricsLoading}
|
||||||
|
rangeMs={tunnelRangeMs}
|
||||||
tunnelId={detailTunnelId}
|
tunnelId={detailTunnelId}
|
||||||
|
onRangeChange={setTunnelRangeMs}
|
||||||
onRefresh={loadTunnelMetrics}
|
onRefresh={loadTunnelMetrics}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -871,7 +1099,9 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex flex-wrap items-center gap-3 mb-1">
|
<div className="flex flex-wrap items-center gap-3 mb-1">
|
||||||
<Chip color="primary" size="sm" variant="flat">隧道 {tunnelStats.enabled}/{tunnelStats.total}</Chip>
|
<Chip color="primary" size="sm" variant="flat">
|
||||||
|
隧道 {tunnelStats.enabled}/{tunnelStats.total}
|
||||||
|
</Chip>
|
||||||
{lastQualityUpdate ? (
|
{lastQualityUpdate ? (
|
||||||
<div className="flex items-center gap-1.5 text-xs text-default-500">
|
<div className="flex items-center gap-1.5 text-xs text-default-500">
|
||||||
{monitorTunnelQualityEnabled ? (
|
{monitorTunnelQualityEnabled ? (
|
||||||
@@ -893,7 +1123,12 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<div className="ml-auto">
|
<div className="ml-auto">
|
||||||
<Button isLoading={tunnelsLoading} size="sm" variant="flat" onPress={() => loadTunnels()}>
|
<Button
|
||||||
|
isLoading={tunnelsLoading}
|
||||||
|
size="sm"
|
||||||
|
variant="flat"
|
||||||
|
onPress={() => loadTunnels()}
|
||||||
|
>
|
||||||
<RefreshCw className="w-4 h-4 mr-1" />
|
<RefreshCw className="w-4 h-4 mr-1" />
|
||||||
刷新
|
刷新
|
||||||
</Button>
|
</Button>
|
||||||
@@ -920,21 +1155,33 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
|
|||||||
className="group relative overflow-hidden shadow-sm border border-divider dark:border-default-100 hover:-translate-y-1 hover:shadow-lg transition-all duration-300 h-full flex flex-col cursor-pointer bg-background"
|
className="group relative overflow-hidden shadow-sm border border-divider dark:border-default-100 hover:-translate-y-1 hover:shadow-lg transition-all duration-300 h-full flex flex-col cursor-pointer bg-background"
|
||||||
onClick={() => setDetailTunnelId(tunnel.id)}
|
onClick={() => setDetailTunnelId(tunnel.id)}
|
||||||
>
|
>
|
||||||
<div className={`absolute top-0 left-0 right-0 h-1 ${isEnabled ? "bg-success" : "bg-danger"}`} />
|
<div
|
||||||
<div className={`absolute -right-8 -top-8 w-24 h-24 rounded-full blur-2xl opacity-10 transition-opacity group-hover:opacity-20 ${isEnabled ? "bg-success" : "bg-danger"}`} />
|
className={`absolute top-0 left-0 right-0 h-1 ${isEnabled ? "bg-success" : "bg-danger"}`}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className={`absolute -right-8 -top-8 w-24 h-24 rounded-full blur-2xl opacity-10 transition-opacity group-hover:opacity-20 ${isEnabled ? "bg-success" : "bg-danger"}`}
|
||||||
|
/>
|
||||||
|
|
||||||
<CardHeader className="pb-2 pt-5 px-5 flex flex-row justify-between items-start gap-4">
|
<CardHeader className="pb-2 pt-5 px-5 flex flex-row justify-between items-start gap-4">
|
||||||
<div className="flex items-center gap-3 min-w-0">
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
<div className="relative flex-shrink-0">
|
<div className="relative flex-shrink-0">
|
||||||
<div className="w-10 h-10 rounded-xl bg-default-100 dark:bg-default-50/10 flex items-center justify-center border border-divider">
|
<div className="w-10 h-10 rounded-xl bg-default-100 dark:bg-default-50/10 flex items-center justify-center border border-divider">
|
||||||
<ArrowRightLeft className={`w-5 h-5 ${isEnabled ? "text-success" : "text-danger"}`} />
|
<ArrowRightLeft
|
||||||
|
className={`w-5 h-5 ${isEnabled ? "text-success" : "text-danger"}`}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<span className={`absolute -bottom-0.5 -right-0.5 w-3 h-3 rounded-full border-2 border-background ${isEnabled ? "bg-success" : "bg-danger"}`} />
|
<span
|
||||||
|
className={`absolute -bottom-0.5 -right-0.5 w-3 h-3 rounded-full border-2 border-background ${isEnabled ? "bg-success" : "bg-danger"}`}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col min-w-0">
|
<div className="flex flex-col min-w-0">
|
||||||
<h3 className="font-semibold text-foreground text-sm truncate">{tunnel.name}</h3>
|
<h3 className="font-semibold text-foreground text-sm truncate">
|
||||||
|
{tunnel.name}
|
||||||
|
</h3>
|
||||||
<div className="flex items-center gap-1.5 text-[11px] text-default-500 mt-0.5">
|
<div className="flex items-center gap-1.5 text-[11px] text-default-500 mt-0.5">
|
||||||
<span className="font-mono">{isEnabled ? "启用" : "禁用"}</span>
|
<span className="font-mono">
|
||||||
|
{isEnabled ? "启用" : "禁用"}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -947,20 +1194,30 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
|
|||||||
<Zap className="w-3 h-3" />
|
<Zap className="w-3 h-3" />
|
||||||
入口→出口
|
入口→出口
|
||||||
</div>
|
</div>
|
||||||
<UptimeHistoryBar history={qualityHistoryMap[tunnel.id]} type="entryToExit" latestValue={quality?.entryToExitLatency} />
|
<UptimeHistoryBar
|
||||||
|
history={qualityHistoryMap[tunnel.id]}
|
||||||
|
latestValue={quality?.entryToExitLatency}
|
||||||
|
type="entryToExit"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<div className="text-[10px] text-default-500 flex items-center gap-1">
|
<div className="text-[10px] text-default-500 flex items-center gap-1">
|
||||||
<Globe className="w-3 h-3" />
|
<Globe className="w-3 h-3" />
|
||||||
出口→Bing
|
出口→Bing
|
||||||
</div>
|
</div>
|
||||||
<UptimeHistoryBar history={qualityHistoryMap[tunnel.id]} type="exitToBing" latestValue={quality?.exitToBingLatency} />
|
<UptimeHistoryBar
|
||||||
|
history={qualityHistoryMap[tunnel.id]}
|
||||||
|
latestValue={quality?.exitToBingLatency}
|
||||||
|
type="exitToBing"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-between items-center pt-2 border-t border-divider/50">
|
<div className="flex justify-between items-center pt-2 border-t border-divider/50">
|
||||||
{quality?.errorMessage ? (
|
{quality?.errorMessage ? (
|
||||||
<span className="text-[11px] text-danger truncate">{quality.errorMessage}</span>
|
<span className="text-[11px] text-danger truncate">
|
||||||
|
{quality.errorMessage}
|
||||||
|
</span>
|
||||||
) : quality?.timestamp ? (
|
) : quality?.timestamp ? (
|
||||||
<span className="text-[11px] text-default-500 flex items-center gap-1">
|
<span className="text-[11px] text-default-500 flex items-center gap-1">
|
||||||
{monitorTunnelQualityEnabled ? (
|
{monitorTunnelQualityEnabled ? (
|
||||||
@@ -968,11 +1225,15 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
|
|||||||
) : (
|
) : (
|
||||||
<WifiOff className="w-3 h-3 text-warning" />
|
<WifiOff className="w-3 h-3 text-warning" />
|
||||||
)}
|
)}
|
||||||
{new Date(quality.timestamp).toLocaleTimeString("zh-CN")}
|
{new Date(quality.timestamp).toLocaleTimeString(
|
||||||
|
"zh-CN",
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-[11px] text-default-400">
|
<span className="text-[11px] text-default-400">
|
||||||
{monitorTunnelQualityEnabled ? "等待探测..." : "实时检测已关闭"}
|
{monitorTunnelQualityEnabled
|
||||||
|
? "等待探测..."
|
||||||
|
: "实时检测已关闭"}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -987,7 +1248,8 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
|
|||||||
aria-label="隧道列表"
|
aria-label="隧道列表"
|
||||||
className="overflow-x-auto min-w-full"
|
className="overflow-x-auto min-w-full"
|
||||||
classNames={{
|
classNames={{
|
||||||
wrapper: "bg-transparent p-0 shadow-none border-none overflow-hidden rounded-2xl",
|
wrapper:
|
||||||
|
"bg-transparent p-0 shadow-none border-none overflow-hidden rounded-2xl",
|
||||||
th: "bg-transparent text-default-600 font-semibold text-sm border-b border-white/20 dark:border-white/10 py-3 uppercase tracking-wider first:rounded-tl-[24px] last:rounded-tr-[24px]",
|
th: "bg-transparent text-default-600 font-semibold text-sm border-b border-white/20 dark:border-white/10 py-3 uppercase tracking-wider first:rounded-tl-[24px] last:rounded-tr-[24px]",
|
||||||
td: "py-3 border-b border-divider/50 group-data-[last=true]:border-b-0",
|
td: "py-3 border-b border-divider/50 group-data-[last=true]:border-b-0",
|
||||||
tr: "hover:bg-white/40 dark:hover:bg-white/10 transition-colors",
|
tr: "hover:bg-white/40 dark:hover:bg-white/10 transition-colors",
|
||||||
@@ -1006,7 +1268,11 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
|
|||||||
const isEnabled = tunnel.status === 1;
|
const isEnabled = tunnel.status === 1;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TableRow key={tunnel.id} className="cursor-pointer" onClick={() => setDetailTunnelId(tunnel.id)}>
|
<TableRow
|
||||||
|
key={tunnel.id}
|
||||||
|
className="cursor-pointer"
|
||||||
|
onClick={() => setDetailTunnelId(tunnel.id)}
|
||||||
|
>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
{isEnabled ? (
|
{isEnabled ? (
|
||||||
@@ -1017,13 +1283,23 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
|
|||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<span className="font-semibold text-sm whitespace-nowrap">{tunnel.name}</span>
|
<span className="font-semibold text-sm whitespace-nowrap">
|
||||||
|
{tunnel.name}
|
||||||
|
</span>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<UptimeHistoryBar history={qualityHistoryMap[tunnel.id]} type="entryToExit" latestValue={quality?.entryToExitLatency} />
|
<UptimeHistoryBar
|
||||||
|
history={qualityHistoryMap[tunnel.id]}
|
||||||
|
latestValue={quality?.entryToExitLatency}
|
||||||
|
type="entryToExit"
|
||||||
|
/>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<UptimeHistoryBar history={qualityHistoryMap[tunnel.id]} type="exitToBing" latestValue={quality?.exitToBingLatency} />
|
<UptimeHistoryBar
|
||||||
|
history={qualityHistoryMap[tunnel.id]}
|
||||||
|
latestValue={quality?.exitToBingLatency}
|
||||||
|
type="exitToBing"
|
||||||
|
/>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
{quality?.timestamp ? (
|
{quality?.timestamp ? (
|
||||||
@@ -1033,7 +1309,9 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
|
|||||||
) : (
|
) : (
|
||||||
<WifiOff className="w-3.5 h-3.5 text-warning" />
|
<WifiOff className="w-3.5 h-3.5 text-warning" />
|
||||||
)}
|
)}
|
||||||
{new Date(quality.timestamp).toLocaleTimeString("zh-CN")}
|
{new Date(quality.timestamp).toLocaleTimeString(
|
||||||
|
"zh-CN",
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-xs text-default-400">
|
<span className="text-xs text-default-400">
|
||||||
|
|||||||
@@ -457,10 +457,7 @@ export default function PanelSharingPage() {
|
|||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
{shares.map((share) => (
|
{shares.map((share) => (
|
||||||
<Card
|
<Card key={share.id} className="overflow-hidden">
|
||||||
key={share.id}
|
|
||||||
className="overflow-hidden"
|
|
||||||
>
|
|
||||||
<CardHeader className="flex justify-between pb-2 md:pb-2">
|
<CardHeader className="flex justify-between pb-2 md:pb-2">
|
||||||
<h3 className="font-bold">{share.name}</h3>
|
<h3 className="font-bold">{share.name}</h3>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
@@ -604,10 +601,7 @@ export default function PanelSharingPage() {
|
|||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
{remoteUsageNodes.map((node) => (
|
{remoteUsageNodes.map((node) => (
|
||||||
<Card
|
<Card key={node.nodeId} className="overflow-hidden">
|
||||||
key={node.nodeId}
|
|
||||||
className="overflow-hidden"
|
|
||||||
>
|
|
||||||
<CardHeader className="flex justify-between pb-2 md:pb-2">
|
<CardHeader className="flex justify-between pb-2 md:pb-2">
|
||||||
<h3 className="font-bold">{node.nodeName}</h3>
|
<h3 className="font-bold">{node.nodeName}</h3>
|
||||||
<span className="text-xs text-default-500">
|
<span className="text-xs text-default-500">
|
||||||
|
|||||||
+491
-421
File diff suppressed because it is too large
Load Diff
@@ -1080,7 +1080,8 @@ export default function UserPage() {
|
|||||||
aria-label="用户列表"
|
aria-label="用户列表"
|
||||||
className="overflow-x-auto min-w-full"
|
className="overflow-x-auto min-w-full"
|
||||||
classNames={{
|
classNames={{
|
||||||
wrapper: "bg-transparent p-0 shadow-none border-none overflow-hidden rounded-2xl",
|
wrapper:
|
||||||
|
"bg-transparent p-0 shadow-none border-none overflow-hidden rounded-2xl",
|
||||||
th: "bg-transparent text-default-600 font-semibold text-sm border-b border-white/20 dark:border-white/10 py-3 uppercase tracking-wider first:rounded-tl-[24px] last:rounded-tr-[24px]",
|
th: "bg-transparent text-default-600 font-semibold text-sm border-b border-white/20 dark:border-white/10 py-3 uppercase tracking-wider first:rounded-tl-[24px] last:rounded-tr-[24px]",
|
||||||
td: "py-3 border-b border-divider/50 group-data-[last=true]:border-b-0",
|
td: "py-3 border-b border-divider/50 group-data-[last=true]:border-b-0",
|
||||||
tr: "hover:bg-white/40 dark:hover:bg-white/10 transition-colors",
|
tr: "hover:bg-white/40 dark:hover:bg-white/10 transition-colors",
|
||||||
@@ -1108,7 +1109,9 @@ export default function UserPage() {
|
|||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="flex items-center justify-center shrink-0 w-10 h-10 rounded-full bg-primary text-white font-bold text-sm relative">
|
<div className="flex items-center justify-center shrink-0 w-10 h-10 rounded-full bg-primary text-white font-bold text-sm relative">
|
||||||
{(user.name || user.user).slice(0, 2).toUpperCase()}
|
{(user.name || user.user).slice(0, 2).toUpperCase()}
|
||||||
<span className={`absolute bottom-0 right-0 w-3 h-3 border-2 border-white dark:border-zinc-900 rounded-full ${userStatus.color === "success" ? "bg-success" : "bg-danger"}`} />
|
<span
|
||||||
|
className={`absolute bottom-0 right-0 w-3 h-3 border-2 border-white dark:border-zinc-900 rounded-full ${userStatus.color === "success" ? "bg-success" : "bg-danger"}`}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<span className="font-medium text-foreground text-sm">
|
<span className="font-medium text-foreground text-sm">
|
||||||
@@ -1119,7 +1122,12 @@ export default function UserPage() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{user.disabledByQuota && (
|
{user.disabledByQuota && (
|
||||||
<Chip className="text-[10px] h-4 px-1 ml-1" color="danger" size="sm" variant="flat">
|
<Chip
|
||||||
|
className="text-[10px] h-4 px-1 ml-1"
|
||||||
|
color="danger"
|
||||||
|
size="sm"
|
||||||
|
variant="flat"
|
||||||
|
>
|
||||||
超额
|
超额
|
||||||
</Chip>
|
</Chip>
|
||||||
)}
|
)}
|
||||||
@@ -1143,17 +1151,24 @@ export default function UserPage() {
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div className="flex flex-col gap-0.5 whitespace-nowrap text-[11px]">
|
<div className="flex flex-col gap-0.5 whitespace-nowrap text-[11px]">
|
||||||
{((user.dailyQuotaGB ?? 0) > 0 || (user.monthlyQuotaGB ?? 0) > 0) ? (
|
{(user.dailyQuotaGB ?? 0) > 0 ||
|
||||||
<>
|
(user.monthlyQuotaGB ?? 0) > 0 ? (
|
||||||
<div className="flex gap-1 justify-between">
|
<>
|
||||||
<span className="text-default-500">日配额:</span>
|
<div className="flex gap-1 justify-between">
|
||||||
<span>{formatFlow(user.dailyUsedBytes ?? 0)} / {formatQuotaLimit(user.dailyQuotaGB)}</span>
|
<span className="text-default-500">日配额:</span>
|
||||||
</div>
|
<span>
|
||||||
<div className="flex gap-1 justify-between">
|
{formatFlow(user.dailyUsedBytes ?? 0)} /{" "}
|
||||||
<span className="text-default-500">月配额:</span>
|
{formatQuotaLimit(user.dailyQuotaGB)}
|
||||||
<span>{formatFlow(user.monthlyUsedBytes ?? 0)} / {formatQuotaLimit(user.monthlyQuotaGB)}</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</>
|
<div className="flex gap-1 justify-between">
|
||||||
|
<span className="text-default-500">月配额:</span>
|
||||||
|
<span>
|
||||||
|
{formatFlow(user.monthlyUsedBytes ?? 0)} /{" "}
|
||||||
|
{formatQuotaLimit(user.monthlyQuotaGB)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-default-400">无配额</span>
|
<span className="text-default-400">无配额</span>
|
||||||
)}
|
)}
|
||||||
@@ -1171,51 +1186,53 @@ export default function UserPage() {
|
|||||||
</span>
|
</span>
|
||||||
{expStatus && (
|
{expStatus && (
|
||||||
<Chip
|
<Chip
|
||||||
|
className="h-5 px-1 text-[10px]"
|
||||||
color={expStatus.color}
|
color={expStatus.color}
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="flat"
|
variant="flat"
|
||||||
className="h-5 px-1 text-[10px]"
|
|
||||||
>
|
>
|
||||||
{expStatus.text}
|
{expStatus.text}
|
||||||
</Chip>
|
</Chip>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-default-400 text-sm">无限期</span>
|
<span className="text-default-400 text-sm">
|
||||||
|
无限期
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div className="flex flex-wrap items-center gap-1.5 min-w-max">
|
<div className="flex flex-wrap items-center gap-1.5 min-w-max">
|
||||||
<Button
|
<Button
|
||||||
|
className="h-6 px-2 min-w-0 text-xs bg-emerald-50 text-emerald-600 hover:bg-emerald-100 dark:bg-emerald-950/30 dark:text-emerald-400"
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="flat"
|
variant="flat"
|
||||||
className="h-6 px-2 min-w-0 text-xs bg-emerald-50 text-emerald-600 hover:bg-emerald-100 dark:bg-emerald-950/30 dark:text-emerald-400"
|
|
||||||
onPress={() => handleManageTunnels(user)}
|
onPress={() => handleManageTunnels(user)}
|
||||||
>
|
>
|
||||||
权限
|
权限
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
|
||||||
variant="flat"
|
|
||||||
className="h-6 px-2 min-w-0 text-xs bg-amber-50 text-amber-600 hover:bg-amber-100 dark:bg-amber-950/30 dark:text-amber-400"
|
className="h-6 px-2 min-w-0 text-xs bg-amber-50 text-amber-600 hover:bg-amber-100 dark:bg-amber-950/30 dark:text-amber-400"
|
||||||
|
size="sm"
|
||||||
title="重置流量"
|
title="重置流量"
|
||||||
|
variant="flat"
|
||||||
onPress={() => handleResetFlow(user)}
|
onPress={() => handleResetFlow(user)}
|
||||||
>
|
>
|
||||||
重置
|
重置
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
|
className="h-6 px-2 min-w-0 text-xs bg-indigo-50 text-indigo-600 hover:bg-indigo-100 dark:bg-indigo-950/30 dark:text-indigo-400"
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="flat"
|
variant="flat"
|
||||||
className="h-6 px-2 min-w-0 text-xs bg-indigo-50 text-indigo-600 hover:bg-indigo-100 dark:bg-indigo-950/30 dark:text-indigo-400"
|
|
||||||
onPress={() => handleEdit(user)}
|
onPress={() => handleEdit(user)}
|
||||||
>
|
>
|
||||||
编辑
|
编辑
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
|
className="h-6 px-2 min-w-0 text-xs bg-rose-50 text-rose-600 hover:bg-rose-100 dark:bg-rose-950/30 dark:text-rose-400"
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="flat"
|
variant="flat"
|
||||||
className="h-6 px-2 min-w-0 text-xs bg-rose-50 text-rose-600 hover:bg-rose-100 dark:bg-rose-950/30 dark:text-rose-400"
|
|
||||||
onPress={() => handleDelete(user)}
|
onPress={() => handleDelete(user)}
|
||||||
>
|
>
|
||||||
删除
|
删除
|
||||||
@@ -1252,7 +1269,9 @@ export default function UserPage() {
|
|||||||
<div className="flex items-center gap-3 flex-1 min-w-0">
|
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||||
<div className="flex items-center justify-center shrink-0 w-10 h-10 rounded-full bg-primary text-white font-bold text-sm relative">
|
<div className="flex items-center justify-center shrink-0 w-10 h-10 rounded-full bg-primary text-white font-bold text-sm relative">
|
||||||
{(user.name || user.user).slice(0, 2).toUpperCase()}
|
{(user.name || user.user).slice(0, 2).toUpperCase()}
|
||||||
<span className={`absolute bottom-0 right-0 w-3 h-3 border-2 border-white dark:border-zinc-900 rounded-full ${userStatus.color === "success" ? "bg-success" : "bg-danger"}`} />
|
<span
|
||||||
|
className={`absolute bottom-0 right-0 w-3 h-3 border-2 border-white dark:border-zinc-900 rounded-full ${userStatus.color === "success" ? "bg-success" : "bg-danger"}`}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col min-w-0">
|
<div className="flex flex-col min-w-0">
|
||||||
<h3 className="font-semibold text-foreground truncate text-sm">
|
<h3 className="font-semibold text-foreground truncate text-sm">
|
||||||
|
|||||||
@@ -19,10 +19,5 @@ export function CardHeader({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function CardBody({ className, ...props }: React.ComponentProps<"div">) {
|
export function CardBody({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
return (
|
return <CardContent className={cn("p-4 md:p-6", className)} {...props} />;
|
||||||
<CardContent
|
|
||||||
className={cn("p-4 md:p-6", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,7 +51,11 @@ export interface DropdownMenuProps {
|
|||||||
export function DropdownMenu({ children, className }: DropdownMenuProps) {
|
export function DropdownMenu({ children, className }: DropdownMenuProps) {
|
||||||
const { align } = React.useContext(DropdownContext);
|
const { align } = React.useContext(DropdownContext);
|
||||||
|
|
||||||
return <DropdownMenuContent align={align} className={className}>{children}</DropdownMenuContent>;
|
return (
|
||||||
|
<DropdownMenuContent align={align} className={className}>
|
||||||
|
{children}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DropdownItemProps {
|
export interface DropdownItemProps {
|
||||||
|
|||||||
@@ -414,8 +414,8 @@ export function Select<T>({
|
|||||||
<option value="">{placeholder ?? "请选择"}</option>
|
<option value="">{placeholder ?? "请选择"}</option>
|
||||||
{options.map((option) => (
|
{options.map((option) => (
|
||||||
<option
|
<option
|
||||||
className="bg-background text-foreground"
|
|
||||||
key={option.key}
|
key={option.key}
|
||||||
|
className="bg-background text-foreground"
|
||||||
disabled={disabled.has(option.key)}
|
disabled={disabled.has(option.key)}
|
||||||
value={option.key}
|
value={option.key}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
* re-renders when the active theme changes.
|
* re-renders when the active theme changes.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import type { ThemePackage, ComponentKey, LayoutKey, PageKey } from "./types";
|
||||||
|
|
||||||
import React, {
|
import React, {
|
||||||
createContext,
|
createContext,
|
||||||
useContext,
|
useContext,
|
||||||
@@ -14,7 +16,6 @@ import React, {
|
|||||||
useMemo,
|
useMemo,
|
||||||
} from "react";
|
} from "react";
|
||||||
|
|
||||||
import type { ThemePackage, ComponentKey, LayoutKey, PageKey } from "./types";
|
|
||||||
import {
|
import {
|
||||||
subscribe,
|
subscribe,
|
||||||
getActiveTheme,
|
getActiveTheme,
|
||||||
@@ -59,11 +60,20 @@ interface ThemeContextValue {
|
|||||||
unregister: (id: string) => void;
|
unregister: (id: string) => void;
|
||||||
|
|
||||||
/** Resolve a component (returns themed override or fallback). */
|
/** Resolve a component (returns themed override or fallback). */
|
||||||
component: <P = any>(key: ComponentKey, fallback: React.ComponentType<P>) => React.ComponentType<P>;
|
component: <P = any>(
|
||||||
|
key: ComponentKey,
|
||||||
|
fallback: React.ComponentType<P>,
|
||||||
|
) => React.ComponentType<P>;
|
||||||
/** Resolve a layout. */
|
/** Resolve a layout. */
|
||||||
layout: (key: LayoutKey, fallback: React.ComponentType<{ children: React.ReactNode }>) => React.ComponentType<{ children: React.ReactNode }>;
|
layout: (
|
||||||
|
key: LayoutKey,
|
||||||
|
fallback: React.ComponentType<{ children: React.ReactNode }>,
|
||||||
|
) => React.ComponentType<{ children: React.ReactNode }>;
|
||||||
/** Resolve a page. */
|
/** Resolve a page. */
|
||||||
page: <P = any>(key: PageKey, fallback: React.ComponentType<P>) => React.ComponentType<P>;
|
page: <P = any>(
|
||||||
|
key: PageKey,
|
||||||
|
fallback: React.ComponentType<P>,
|
||||||
|
) => React.ComponentType<P>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ThemeContext = createContext<ThemeContextValue | null>(null);
|
const ThemeContext = createContext<ThemeContextValue | null>(null);
|
||||||
@@ -73,6 +83,7 @@ const ThemeContext = createContext<ThemeContextValue | null>(null);
|
|||||||
// We use a monotonic counter to create new snapshot references when the
|
// We use a monotonic counter to create new snapshot references when the
|
||||||
// registry notifies.
|
// registry notifies.
|
||||||
let snapshotCounter = 0;
|
let snapshotCounter = 0;
|
||||||
|
|
||||||
function getSnapshot() {
|
function getSnapshot() {
|
||||||
return snapshotCounter;
|
return snapshotCounter;
|
||||||
}
|
}
|
||||||
@@ -81,6 +92,7 @@ const originalSubscribe = (onStoreChange: () => void) => {
|
|||||||
snapshotCounter++;
|
snapshotCounter++;
|
||||||
onStoreChange();
|
onStoreChange();
|
||||||
});
|
});
|
||||||
|
|
||||||
return unsub;
|
return unsub;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -98,7 +110,9 @@ export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children }) => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const mq = window.matchMedia("(prefers-color-scheme: dark)");
|
const mq = window.matchMedia("(prefers-color-scheme: dark)");
|
||||||
const handler = () => reapplyActiveTheme();
|
const handler = () => reapplyActiveTheme();
|
||||||
|
|
||||||
mq.addEventListener("change", handler);
|
mq.addEventListener("change", handler);
|
||||||
|
|
||||||
return () => mq.removeEventListener("change", handler);
|
return () => mq.removeEventListener("change", handler);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -115,27 +129,27 @@ export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children }) => {
|
|||||||
const register = useCallback((pkg: ThemePackage) => registerTheme(pkg), []);
|
const register = useCallback((pkg: ThemePackage) => registerTheme(pkg), []);
|
||||||
const unregister = useCallback((id: string) => unregisterTheme(id), []);
|
const unregister = useCallback((id: string) => unregisterTheme(id), []);
|
||||||
|
|
||||||
const value = useMemo<ThemeContextValue>(() => ({
|
const value = useMemo<ThemeContextValue>(
|
||||||
activeTheme: getActiveTheme(),
|
() => ({
|
||||||
activeThemeId: getActiveThemeId(),
|
activeTheme: getActiveTheme(),
|
||||||
themes: getRegisteredThemes(),
|
activeThemeId: getActiveThemeId(),
|
||||||
mode: getSavedMode(),
|
themes: getRegisteredThemes(),
|
||||||
effectiveMode: getEffectiveMode(),
|
mode: getSavedMode(),
|
||||||
switchTheme,
|
effectiveMode: getEffectiveMode(),
|
||||||
resetTheme,
|
switchTheme,
|
||||||
setMode,
|
resetTheme,
|
||||||
register,
|
setMode,
|
||||||
unregister,
|
register,
|
||||||
component: resolveComponent,
|
unregister,
|
||||||
layout: resolveLayout,
|
component: resolveComponent,
|
||||||
page: resolvePage,
|
layout: resolveLayout,
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
page: resolvePage,
|
||||||
}), [snapshotCounter, switchTheme, resetTheme, setMode, register, unregister]);
|
}),
|
||||||
|
[snapshotCounter, switchTheme, resetTheme, setMode, register, unregister],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ThemeContext.Provider value={value}>
|
<ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>
|
||||||
{children}
|
|
||||||
</ThemeContext.Provider>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -144,7 +158,10 @@ export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children }) => {
|
|||||||
/** Access the full theme context. */
|
/** Access the full theme context. */
|
||||||
export function useThemeContext(): ThemeContextValue {
|
export function useThemeContext(): ThemeContextValue {
|
||||||
const ctx = useContext(ThemeContext);
|
const ctx = useContext(ThemeContext);
|
||||||
if (!ctx) throw new Error("useThemeContext must be used within <ThemeProvider>");
|
|
||||||
|
if (!ctx)
|
||||||
|
throw new Error("useThemeContext must be used within <ThemeProvider>");
|
||||||
|
|
||||||
return ctx;
|
return ctx;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -166,6 +183,7 @@ export function useThemedComponent<P = any>(
|
|||||||
fallback: React.ComponentType<P>,
|
fallback: React.ComponentType<P>,
|
||||||
): React.ComponentType<P> {
|
): React.ComponentType<P> {
|
||||||
const ctx = useThemeContext();
|
const ctx = useThemeContext();
|
||||||
|
|
||||||
return ctx.component(key, fallback);
|
return ctx.component(key, fallback);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -175,6 +193,7 @@ export function useThemedLayout(
|
|||||||
fallback: React.ComponentType<{ children: React.ReactNode }>,
|
fallback: React.ComponentType<{ children: React.ReactNode }>,
|
||||||
): React.ComponentType<{ children: React.ReactNode }> {
|
): React.ComponentType<{ children: React.ReactNode }> {
|
||||||
const ctx = useThemeContext();
|
const ctx = useThemeContext();
|
||||||
|
|
||||||
return ctx.layout(key, fallback);
|
return ctx.layout(key, fallback);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -184,5 +203,6 @@ export function useThemedPage<P = any>(
|
|||||||
fallback: React.ComponentType<P>,
|
fallback: React.ComponentType<P>,
|
||||||
): React.ComponentType<P> {
|
): React.ComponentType<P> {
|
||||||
const ctx = useThemeContext();
|
const ctx = useThemeContext();
|
||||||
|
|
||||||
return ctx.page(key, fallback);
|
return ctx.page(key, fallback);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,10 +10,11 @@
|
|||||||
* or you can build a completely new component from scratch.
|
* or you can build a completely new component from scratch.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import type { ButtonProps } from "@/shadcn-bridge/heroui/button";
|
||||||
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
|
||||||
// Import the original Button's props interface for compatibility
|
// Import the original Button's props interface for compatibility
|
||||||
import type { ButtonProps } from "@/shadcn-bridge/heroui/button";
|
|
||||||
// Optionally import the original to wrap it
|
// Optionally import the original to wrap it
|
||||||
import { Button as OriginalButton } from "@/shadcn-bridge/heroui/button";
|
import { Button as OriginalButton } from "@/shadcn-bridge/heroui/button";
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { ThemePackage } from "../types";
|
import type { ThemePackage } from "../types";
|
||||||
|
|
||||||
import { CyberpunkButton } from "./components/button";
|
import { CyberpunkButton } from "./components/button";
|
||||||
|
|
||||||
const cyberpunkTheme: ThemePackage = {
|
const cyberpunkTheme: ThemePackage = {
|
||||||
|
|||||||
@@ -9,7 +9,13 @@
|
|||||||
* integration lives in `./context.tsx`.
|
* integration lives in `./context.tsx`.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { ThemePackage, ThemeTokens, ComponentKey, LayoutKey, PageKey } from "./types";
|
import type {
|
||||||
|
ThemePackage,
|
||||||
|
ThemeTokens,
|
||||||
|
ComponentKey,
|
||||||
|
LayoutKey,
|
||||||
|
PageKey,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
// ─── internal state ──────────────────────────────────────────────────────────
|
// ─── internal state ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -62,7 +68,7 @@ export function getActiveThemeId(): string | null {
|
|||||||
|
|
||||||
/** Get the currently active ThemePackage (or null). */
|
/** Get the currently active ThemePackage (or null). */
|
||||||
export function getActiveTheme(): ThemePackage | null {
|
export function getActiveTheme(): ThemePackage | null {
|
||||||
return activeId ? installed.get(activeId) ?? null : null;
|
return activeId ? (installed.get(activeId) ?? null) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── theme mode ──────────────────────────────────────────────────────────────
|
// ─── theme mode ──────────────────────────────────────────────────────────────
|
||||||
@@ -71,13 +77,18 @@ export type ThemeMode = "light" | "dark" | "system";
|
|||||||
|
|
||||||
export function resolveSystemMode(): "light" | "dark" {
|
export function resolveSystemMode(): "light" | "dark" {
|
||||||
if (typeof window === "undefined") return "light";
|
if (typeof window === "undefined") return "light";
|
||||||
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
|
||||||
|
return window.matchMedia("(prefers-color-scheme: dark)").matches
|
||||||
|
? "dark"
|
||||||
|
: "light";
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getSavedMode(): ThemeMode {
|
export function getSavedMode(): ThemeMode {
|
||||||
if (typeof window === "undefined") return "system";
|
if (typeof window === "undefined") return "system";
|
||||||
const raw = localStorage.getItem(MODE_KEY);
|
const raw = localStorage.getItem(MODE_KEY);
|
||||||
|
|
||||||
if (raw === "light" || raw === "dark" || raw === "system") return raw;
|
if (raw === "light" || raw === "dark" || raw === "system") return raw;
|
||||||
|
|
||||||
return "system";
|
return "system";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,6 +99,7 @@ export function saveMode(mode: ThemeMode): void {
|
|||||||
|
|
||||||
export function getEffectiveMode(): "light" | "dark" {
|
export function getEffectiveMode(): "light" | "dark" {
|
||||||
const mode = getSavedMode();
|
const mode = getSavedMode();
|
||||||
|
|
||||||
return mode === "system" ? resolveSystemMode() : mode;
|
return mode === "system" ? resolveSystemMode() : mode;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,8 +115,10 @@ export function getEffectiveMode(): "light" | "dark" {
|
|||||||
*/
|
*/
|
||||||
export function activateTheme(id: string): void {
|
export function activateTheme(id: string): void {
|
||||||
const pkg = installed.get(id);
|
const pkg = installed.get(id);
|
||||||
|
|
||||||
if (!pkg) {
|
if (!pkg) {
|
||||||
console.warn(`[FLVX themes] Theme "${id}" is not registered.`);
|
console.warn(`[FLVX themes] Theme "${id}" is not registered.`);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,6 +130,7 @@ export function activateTheme(id: string): void {
|
|||||||
// Inject tokens
|
// Inject tokens
|
||||||
const mode = getEffectiveMode();
|
const mode = getEffectiveMode();
|
||||||
const tokens = mode === "dark" ? pkg.tokens?.dark : pkg.tokens?.light;
|
const tokens = mode === "dark" ? pkg.tokens?.dark : pkg.tokens?.light;
|
||||||
|
|
||||||
if (tokens) injectTokens(tokens);
|
if (tokens) injectTokens(tokens);
|
||||||
|
|
||||||
// Inject custom CSS
|
// Inject custom CSS
|
||||||
@@ -128,6 +143,7 @@ export function activateTheme(id: string): void {
|
|||||||
|
|
||||||
// Update dark class
|
// Update dark class
|
||||||
const root = document.documentElement;
|
const root = document.documentElement;
|
||||||
|
|
||||||
root.classList.toggle("dark", mode === "dark");
|
root.classList.toggle("dark", mode === "dark");
|
||||||
root.style.colorScheme = mode;
|
root.style.colorScheme = mode;
|
||||||
|
|
||||||
@@ -143,6 +159,7 @@ export function activateTheme(id: string): void {
|
|||||||
/** Deactivate the current theme, reverting all overrides. */
|
/** Deactivate the current theme, reverting all overrides. */
|
||||||
export function deactivateTheme(): void {
|
export function deactivateTheme(): void {
|
||||||
const prev = activeId ? installed.get(activeId) : null;
|
const prev = activeId ? installed.get(activeId) : null;
|
||||||
|
|
||||||
prev?.onDeactivate?.();
|
prev?.onDeactivate?.();
|
||||||
|
|
||||||
// Remove injected tokens
|
// Remove injected tokens
|
||||||
@@ -163,20 +180,24 @@ export function reapplyActiveTheme(): void {
|
|||||||
const id = activeId;
|
const id = activeId;
|
||||||
// quick re-inject without full lifecycle
|
// quick re-inject without full lifecycle
|
||||||
const pkg = installed.get(id);
|
const pkg = installed.get(id);
|
||||||
|
|
||||||
if (!pkg) return;
|
if (!pkg) return;
|
||||||
|
|
||||||
clearInjectedTokens();
|
clearInjectedTokens();
|
||||||
const mode = getEffectiveMode();
|
const mode = getEffectiveMode();
|
||||||
const tokens = mode === "dark" ? pkg.tokens?.dark : pkg.tokens?.light;
|
const tokens = mode === "dark" ? pkg.tokens?.dark : pkg.tokens?.light;
|
||||||
|
|
||||||
if (tokens) injectTokens(tokens);
|
if (tokens) injectTokens(tokens);
|
||||||
|
|
||||||
const root = document.documentElement;
|
const root = document.documentElement;
|
||||||
|
|
||||||
root.classList.toggle("dark", mode === "dark");
|
root.classList.toggle("dark", mode === "dark");
|
||||||
root.style.colorScheme = mode;
|
root.style.colorScheme = mode;
|
||||||
} else {
|
} else {
|
||||||
// No theme active — just set dark class based on mode
|
// No theme active — just set dark class based on mode
|
||||||
const mode = getEffectiveMode();
|
const mode = getEffectiveMode();
|
||||||
const root = document.documentElement;
|
const root = document.documentElement;
|
||||||
|
|
||||||
root.classList.toggle("dark", mode === "dark");
|
root.classList.toggle("dark", mode === "dark");
|
||||||
root.style.colorScheme = mode;
|
root.style.colorScheme = mode;
|
||||||
}
|
}
|
||||||
@@ -195,6 +216,7 @@ export function resolveComponent<P = any>(
|
|||||||
): React.ComponentType<P> {
|
): React.ComponentType<P> {
|
||||||
const pkg = activeId ? installed.get(activeId) : null;
|
const pkg = activeId ? installed.get(activeId) : null;
|
||||||
const override = pkg?.components?.[key];
|
const override = pkg?.components?.[key];
|
||||||
|
|
||||||
return (override as React.ComponentType<P>) ?? fallback;
|
return (override as React.ComponentType<P>) ?? fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -204,6 +226,7 @@ export function resolveLayout(
|
|||||||
fallback: React.ComponentType<{ children: React.ReactNode }>,
|
fallback: React.ComponentType<{ children: React.ReactNode }>,
|
||||||
): React.ComponentType<{ children: React.ReactNode }> {
|
): React.ComponentType<{ children: React.ReactNode }> {
|
||||||
const pkg = activeId ? installed.get(activeId) : null;
|
const pkg = activeId ? installed.get(activeId) : null;
|
||||||
|
|
||||||
return pkg?.layouts?.[key] ?? fallback;
|
return pkg?.layouts?.[key] ?? fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -214,6 +237,7 @@ export function resolvePage<P = any>(
|
|||||||
): React.ComponentType<P> {
|
): React.ComponentType<P> {
|
||||||
const pkg = activeId ? installed.get(activeId) : null;
|
const pkg = activeId ? installed.get(activeId) : null;
|
||||||
const override = pkg?.pages?.[key];
|
const override = pkg?.pages?.[key];
|
||||||
|
|
||||||
return (override as React.ComponentType<P>) ?? fallback;
|
return (override as React.ComponentType<P>) ?? fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -221,6 +245,7 @@ export function resolvePage<P = any>(
|
|||||||
|
|
||||||
export function subscribe(listener: ChangeListener): () => void {
|
export function subscribe(listener: ChangeListener): () => void {
|
||||||
changeListeners.add(listener);
|
changeListeners.add(listener);
|
||||||
|
|
||||||
return () => changeListeners.delete(listener);
|
return () => changeListeners.delete(listener);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -232,6 +257,7 @@ export function subscribe(listener: ChangeListener): () => void {
|
|||||||
*/
|
*/
|
||||||
export function initThemeSystem(): void {
|
export function initThemeSystem(): void {
|
||||||
const savedId = localStorage.getItem(STORAGE_KEY);
|
const savedId = localStorage.getItem(STORAGE_KEY);
|
||||||
|
|
||||||
if (savedId && installed.has(savedId)) {
|
if (savedId && installed.has(savedId)) {
|
||||||
activateTheme(savedId);
|
activateTheme(savedId);
|
||||||
} else {
|
} else {
|
||||||
@@ -246,6 +272,7 @@ const injectedVars: string[] = [];
|
|||||||
|
|
||||||
function injectTokens(tokens: ThemeTokens): void {
|
function injectTokens(tokens: ThemeTokens): void {
|
||||||
const root = document.documentElement;
|
const root = document.documentElement;
|
||||||
|
|
||||||
for (const [varName, value] of Object.entries(tokens)) {
|
for (const [varName, value] of Object.entries(tokens)) {
|
||||||
if (value !== undefined) {
|
if (value !== undefined) {
|
||||||
root.style.setProperty(varName, value);
|
root.style.setProperty(varName, value);
|
||||||
@@ -256,6 +283,7 @@ function injectTokens(tokens: ThemeTokens): void {
|
|||||||
|
|
||||||
function clearInjectedTokens(): void {
|
function clearInjectedTokens(): void {
|
||||||
const root = document.documentElement;
|
const root = document.documentElement;
|
||||||
|
|
||||||
for (const varName of injectedVars) {
|
for (const varName of injectedVars) {
|
||||||
root.style.removeProperty(varName);
|
root.style.removeProperty(varName);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -182,7 +182,11 @@ export type ComponentKey =
|
|||||||
* All overridable layout keys.
|
* All overridable layout keys.
|
||||||
* Layouts receive `{ children: React.ReactNode }` as props.
|
* Layouts receive `{ children: React.ReactNode }` as props.
|
||||||
*/
|
*/
|
||||||
export type LayoutKey = "AdminLayout" | "H5Layout" | "H5SimpleLayout" | "DefaultLayout";
|
export type LayoutKey =
|
||||||
|
| "AdminLayout"
|
||||||
|
| "H5Layout"
|
||||||
|
| "H5SimpleLayout"
|
||||||
|
| "DefaultLayout";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* All overridable page keys.
|
* All overridable page keys.
|
||||||
@@ -283,7 +287,9 @@ export interface ThemePackage {
|
|||||||
/**
|
/**
|
||||||
* Map of layout overrides. Each layout receives `{ children }`.
|
* Map of layout overrides. Each layout receives `{ children }`.
|
||||||
*/
|
*/
|
||||||
layouts?: Partial<Record<LayoutKey, ComponentType<{ children: React.ReactNode }>>>;
|
layouts?: Partial<
|
||||||
|
Record<LayoutKey, ComponentType<{ children: React.ReactNode }>>
|
||||||
|
>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Map of page overrides. Each page is a full route-level component.
|
* Map of page overrides. Each page is a full route-level component.
|
||||||
|
|||||||
Reference in New Issue
Block a user