merge: 同步 upstream/main 新功能到定制版本
Build and Release / Build (push) Has been cancelled
Issue Close Require / issue-close-require (push) Has been cancelled

- 分流规则 + 用户流量统计
- 验证码功能
- 节点分组管理 UI
- 重构用户分组组件
This commit is contained in:
2026-03-19 03:16:59 -07:00
89 changed files with 7036 additions and 383 deletions
+5
View File
@@ -34,6 +34,11 @@ export function useNavs() {
url: "/dashboard/nodes",
icon: "flat-color-icons:mind-map",
},
{
title: t("Group Management", "Group Management"),
url: "/dashboard/group",
icon: "flat-color-icons:department",
},
{
title: t("Subscribe Config", "Subscribe Config"),
url: "/dashboard/subscribe",
+24
View File
@@ -39,6 +39,8 @@ const DashboardOrderIndexLazyRouteImport =
const DashboardMarketingIndexLazyRouteImport = createFileRoute(
'/dashboard/marketing/',
)()
const DashboardGroupIndexLazyRouteImport =
createFileRoute('/dashboard/group/')()
const DashboardFamilyIndexLazyRouteImport =
createFileRoute('/dashboard/family/')()
const DashboardDocumentIndexLazyRouteImport = createFileRoute(
@@ -191,6 +193,13 @@ const DashboardMarketingIndexLazyRoute =
} as any).lazy(() =>
import('./routes/dashboard/marketing/index.lazy').then((d) => d.Route),
)
const DashboardGroupIndexLazyRoute = DashboardGroupIndexLazyRouteImport.update({
id: '/group/',
path: '/group/',
getParentRoute: () => DashboardRouteLazyRoute,
} as any).lazy(() =>
import('./routes/dashboard/group/index.lazy').then((d) => d.Route),
)
const DashboardFamilyIndexLazyRoute =
DashboardFamilyIndexLazyRouteImport.update({
id: '/family/',
@@ -356,6 +365,7 @@ export interface FileRoutesByFullPath {
'/dashboard/coupon': typeof DashboardCouponIndexLazyRoute
'/dashboard/document': typeof DashboardDocumentIndexLazyRoute
'/dashboard/family': typeof DashboardFamilyIndexLazyRoute
'/dashboard/group': typeof DashboardGroupIndexLazyRoute
'/dashboard/marketing': typeof DashboardMarketingIndexLazyRoute
'/dashboard/order': typeof DashboardOrderIndexLazyRoute
'/dashboard/payment': typeof DashboardPaymentIndexLazyRoute
@@ -389,6 +399,7 @@ export interface FileRoutesByTo {
'/dashboard/coupon': typeof DashboardCouponIndexLazyRoute
'/dashboard/document': typeof DashboardDocumentIndexLazyRoute
'/dashboard/family': typeof DashboardFamilyIndexLazyRoute
'/dashboard/group': typeof DashboardGroupIndexLazyRoute
'/dashboard/marketing': typeof DashboardMarketingIndexLazyRoute
'/dashboard/order': typeof DashboardOrderIndexLazyRoute
'/dashboard/payment': typeof DashboardPaymentIndexLazyRoute
@@ -424,6 +435,7 @@ export interface FileRoutesById {
'/dashboard/coupon/': typeof DashboardCouponIndexLazyRoute
'/dashboard/document/': typeof DashboardDocumentIndexLazyRoute
'/dashboard/family/': typeof DashboardFamilyIndexLazyRoute
'/dashboard/group/': typeof DashboardGroupIndexLazyRoute
'/dashboard/marketing/': typeof DashboardMarketingIndexLazyRoute
'/dashboard/order/': typeof DashboardOrderIndexLazyRoute
'/dashboard/payment/': typeof DashboardPaymentIndexLazyRoute
@@ -460,6 +472,7 @@ export interface FileRouteTypes {
| '/dashboard/coupon'
| '/dashboard/document'
| '/dashboard/family'
| '/dashboard/group'
| '/dashboard/marketing'
| '/dashboard/order'
| '/dashboard/payment'
@@ -493,6 +506,7 @@ export interface FileRouteTypes {
| '/dashboard/coupon'
| '/dashboard/document'
| '/dashboard/family'
| '/dashboard/group'
| '/dashboard/marketing'
| '/dashboard/order'
| '/dashboard/payment'
@@ -527,6 +541,7 @@ export interface FileRouteTypes {
| '/dashboard/coupon/'
| '/dashboard/document/'
| '/dashboard/family/'
| '/dashboard/group/'
| '/dashboard/marketing/'
| '/dashboard/order/'
| '/dashboard/payment/'
@@ -643,6 +658,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof DashboardMarketingIndexLazyRouteImport
parentRoute: typeof DashboardRouteLazyRoute
}
'/dashboard/group/': {
id: '/dashboard/group/'
path: '/group'
fullPath: '/dashboard/group'
preLoaderRoute: typeof DashboardGroupIndexLazyRouteImport
parentRoute: typeof DashboardRouteLazyRoute
}
'/dashboard/family/': {
id: '/dashboard/family/'
path: '/family'
@@ -794,6 +816,7 @@ interface DashboardRouteLazyRouteChildren {
DashboardCouponIndexLazyRoute: typeof DashboardCouponIndexLazyRoute
DashboardDocumentIndexLazyRoute: typeof DashboardDocumentIndexLazyRoute
DashboardFamilyIndexLazyRoute: typeof DashboardFamilyIndexLazyRoute
DashboardGroupIndexLazyRoute: typeof DashboardGroupIndexLazyRoute
DashboardMarketingIndexLazyRoute: typeof DashboardMarketingIndexLazyRoute
DashboardOrderIndexLazyRoute: typeof DashboardOrderIndexLazyRoute
DashboardPaymentIndexLazyRoute: typeof DashboardPaymentIndexLazyRoute
@@ -827,6 +850,7 @@ const DashboardRouteLazyRouteChildren: DashboardRouteLazyRouteChildren = {
DashboardCouponIndexLazyRoute: DashboardCouponIndexLazyRoute,
DashboardDocumentIndexLazyRoute: DashboardDocumentIndexLazyRoute,
DashboardFamilyIndexLazyRoute: DashboardFamilyIndexLazyRoute,
DashboardGroupIndexLazyRoute: DashboardGroupIndexLazyRoute,
DashboardMarketingIndexLazyRoute: DashboardMarketingIndexLazyRoute,
DashboardOrderIndexLazyRoute: DashboardOrderIndexLazyRoute,
DashboardPaymentIndexLazyRoute: DashboardPaymentIndexLazyRoute,
@@ -0,0 +1,6 @@
import { createLazyFileRoute } from "@tanstack/react-router";
import Group from "@/sections/group";
export const Route = createLazyFileRoute("/dashboard/group/")({
component: Group,
});
@@ -10,12 +10,13 @@ import {
import { Input } from "@workspace/ui/components/input";
import { Icon } from "@workspace/ui/composed/icon";
import type { Dispatch, SetStateAction } from "react";
import { useRef } from "react";
import { useRef, useState } from "react";
import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { z } from "zod";
import { useGlobalStore } from "@/stores/global";
import CloudFlareTurnstile, { type TurnstileRef } from "../turnstile";
import LocalCaptcha, { type LocalCaptchaRef } from "../local-captcha";
export default function LoginForm({
loading,
@@ -33,14 +34,25 @@ export default function LoginForm({
const { t } = useTranslation("auth");
const { common } = useGlobalStore();
const { verify } = common;
const [captchaId, setCaptchaId] = useState("");
const isTurnstile = verify.captcha_type === "turnstile";
const isLocal = verify.captcha_type === "local";
const captchaEnabled = verify.enable_admin_login_captcha;
const formSchema = z.object({
email: z.email(t("login.email", "Email")),
email: z
.string()
.email(t("login.email", "Please enter a valid email address")),
password: z.string(),
cf_token:
verify.enable_login_verify && verify.turnstile_site_key
captchaEnabled && isTurnstile && verify.turnstile_site_key
? z.string()
: z.string().optional(),
captcha_code:
captchaEnabled && isLocal
? z.string().min(1, t("captcha.required", "Please enter captcha code"))
: z.string().optional(),
});
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
@@ -48,11 +60,17 @@ export default function LoginForm({
});
const turnstile = useRef<TurnstileRef>(null);
const localCaptcha = useRef<LocalCaptchaRef>(null);
const handleSubmit = form.handleSubmit((data) => {
try {
// Add captcha_id for local captcha
if (isLocal && captchaEnabled) {
(data as any).captcha_id = captchaId;
}
onSubmit(data);
} catch (_error) {
turnstile.current?.reset();
localCaptcha.current?.reset();
}
});
@@ -98,7 +116,7 @@ export default function LoginForm({
</FormItem>
)}
/>
{verify.enable_login_verify && (
{captchaEnabled && isTurnstile && (
<FormField
control={form.control}
name="cf_token"
@@ -116,6 +134,24 @@ export default function LoginForm({
)}
/>
)}
{captchaEnabled && isLocal && (
<FormField
control={form.control}
name="captcha_code"
render={({ field }) => (
<FormItem>
<FormControl>
<LocalCaptcha
{...field}
ref={localCaptcha}
onCaptchaIdChange={setCaptchaId}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<Button disabled={loading} type="submit">
{loading && <Icon className="animate-spin" icon="mdi:loading" />}
{t("login.title", "Login")}
@@ -10,13 +10,14 @@ import {
import { Input } from "@workspace/ui/components/input";
import { Icon } from "@workspace/ui/composed/icon";
import type { Dispatch, SetStateAction } from "react";
import { useRef } from "react";
import { useRef, useState } from "react";
import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { z } from "zod";
import { useGlobalStore } from "@/stores/global";
import SendCode from "../send-code";
import CloudFlareTurnstile, { type TurnstileRef } from "../turnstile";
import LocalCaptcha, { type LocalCaptchaRef } from "../local-captcha";
export default function ResetForm({
loading,
@@ -35,15 +36,26 @@ export default function ResetForm({
const { common } = useGlobalStore();
const { verify, auth } = common;
const [captchaId, setCaptchaId] = useState("");
const isTurnstile = verify.captcha_type === "turnstile";
const isLocal = verify.captcha_type === "local";
const captchaEnabled = verify.enable_user_reset_password_captcha;
const formSchema = z.object({
email: z.email(t("reset.email", "Email")),
email: z
.string()
.email(t("reset.email", "Please enter a valid email address")),
password: z.string(),
code: auth?.email?.enable_verify ? z.string() : z.string().nullish(),
cf_token:
verify.enable_register_verify && verify.turnstile_site_key
captchaEnabled && isTurnstile && verify.turnstile_site_key
? z.string()
: z.string().nullish(),
captcha_code:
captchaEnabled && isLocal
? z.string().min(1, t("captcha.required", "Please enter captcha code"))
: z.string().nullish(),
});
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
@@ -51,11 +63,17 @@ export default function ResetForm({
});
const turnstile = useRef<TurnstileRef>(null);
const localCaptcha = useRef<LocalCaptchaRef>(null);
const handleSubmit = form.handleSubmit((data) => {
try {
// Add captcha_id for local captcha
if (isLocal && captchaEnabled) {
(data as any).captcha_id = captchaId;
}
onSubmit(data);
} catch (_error) {
turnstile.current?.reset();
localCaptcha.current?.reset();
}
});
@@ -128,7 +146,7 @@ export default function ResetForm({
</FormItem>
)}
/>
{verify.enable_reset_password_verify && (
{captchaEnabled && isTurnstile && (
<FormField
control={form.control}
name="cf_token"
@@ -146,6 +164,24 @@ export default function ResetForm({
)}
/>
)}
{captchaEnabled && isLocal && (
<FormField
control={form.control}
name="captcha_code"
render={({ field }) => (
<FormItem>
<FormControl>
<LocalCaptcha
{...field}
ref={localCaptcha}
onCaptchaIdChange={setCaptchaId}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<Button disabled={loading} type="submit">
{loading && <Icon className="animate-spin" icon="mdi:loading" />}
{t("reset.title", "Reset Password")}
@@ -0,0 +1,95 @@
import { Button } from "@workspace/ui/components/button";
import { Input } from "@workspace/ui/components/input";
import { Icon } from "@workspace/ui/composed/icon";
import { adminGenerateCaptcha } from "@workspace/ui/services/admin/auth";
import { forwardRef, useEffect, useImperativeHandle, useState } from "react";
import { useTranslation } from "react-i18next";
export interface LocalCaptchaRef {
reset: () => void;
}
interface LocalCaptchaProps {
value?: string | null;
onChange?: (value: string) => void;
onCaptchaIdChange?: (id: string) => void;
}
const LocalCaptcha = forwardRef<LocalCaptchaRef, LocalCaptchaProps>(
({ value, onChange, onCaptchaIdChange }, ref) => {
const { t } = useTranslation("auth");
const [captchaImage, setCaptchaImage] = useState("");
const [loading, setLoading] = useState(false);
const fetchCaptcha = async () => {
setLoading(true);
try {
const res = await adminGenerateCaptcha();
const captchaData = res.data?.data;
if (captchaData) {
setCaptchaImage(captchaData.image);
onCaptchaIdChange?.(captchaData.id);
}
} catch (error) {
console.error("Failed to generate captcha:", error);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchCaptcha();
}, []);
useImperativeHandle(ref, () => ({
reset: () => {
onChange?.("");
fetchCaptcha();
},
}));
return (
<div className="flex gap-2">
<Input
placeholder={t("captcha.placeholder", "Enter captcha code...")}
value={value || ""}
onChange={(e) => onChange?.(e.target.value)}
className="flex-1"
/>
<div className="relative h-10 w-32 flex-shrink-0">
{loading ? (
<div className="flex h-full items-center justify-center bg-muted">
<Icon className="animate-spin" icon="mdi:loading" />
</div>
) : captchaImage ? (
<img
src={captchaImage}
alt="captcha"
className="h-full w-full cursor-pointer object-contain"
onClick={fetchCaptcha}
title={t("captcha.clickToRefresh", "Click to refresh")}
/>
) : (
<div className="flex h-full items-center justify-center bg-muted text-xs text-muted-foreground">
{t("captcha.noImage", "No Image")}
</div>
)}
</div>
<Button
type="button"
variant="outline"
size="icon"
onClick={fetchCaptcha}
disabled={loading}
title={t("captcha.refresh", "Refresh captcha")}
>
<Icon icon="mdi:refresh" />
</Button>
</div>
);
}
);
LocalCaptcha.displayName = "LocalCaptcha";
export default LocalCaptcha;
@@ -0,0 +1,264 @@
"use client";
import { Badge } from "@workspace/ui/components/badge";
import { Button } from "@workspace/ui/components/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@workspace/ui/components/card";
import { Input } from "@workspace/ui/components/input";
import { Label } from "@workspace/ui/components/label";
import { Loader2 } from "lucide-react";
import { useEffect, useState, useRef } from "react";
import { useTranslation } from "react-i18next";
import { useQuery } from "@tanstack/react-query";
import { toast } from "sonner";
import {
getGroupConfig,
getNodeGroupList,
getRecalculationStatus,
recalculateGroup,
} from "@workspace/ui/services/admin/group";
export default function AverageModeTab() {
const { t } = useTranslation("group");
const [recalculating, setRecalculating] = useState(false);
const [loadingStatus, setLoadingStatus] = useState(false);
const [averageConfig, setAverageConfig] = useState({
node_group_count: 0,
});
const [status, setStatus] = useState<{
state: string;
progress: number;
total: number;
} | null>(null);
const hasLoadedConfig = useRef(true);
const { data: nodeGroupsData } = useQuery({
queryKey: ["nodeGroups"],
queryFn: async () => {
const { data } = await getNodeGroupList({ page: 1, size: 1000 });
return data.data?.list || [];
},
});
const loadConfig = async () => {
try {
const { data } = await getGroupConfig();
if (data.data?.config?.average_config) {
setAverageConfig(data.data.config.average_config as any);
}
hasLoadedConfig.current = true;
} catch (error) {
console.error("Failed to load group config:", error);
toast.error(t("loadFailed", "Failed to load configuration"));
}
};
const loadStatus = async () => {
setLoadingStatus(true);
try {
const { data } = await getRecalculationStatus();
if (data.data) {
setStatus(data.data);
}
} catch (error) {
console.error("Failed to load recalculation status:", error);
} finally {
setLoadingStatus(false);
}
};
useEffect(() => {
loadConfig();
loadStatus();
}, []);
useEffect(() => {
if (nodeGroupsData) {
const nodeGroupCount = nodeGroupsData?.length || 0;
if (averageConfig.node_group_count !== nodeGroupCount) {
setAverageConfig({
...averageConfig,
node_group_count: nodeGroupCount,
});
}
}
}, [nodeGroupsData]);
useEffect(() => {
const interval = setInterval(() => {
if (status?.state === "running") {
loadStatus();
}
}, 2000);
return () => clearInterval(interval);
}, [status?.state]);
const handleRecalculate = async () => {
setRecalculating(true);
try {
await recalculateGroup({ mode: "average" });
toast.success(t("recalculationStarted", "Recalculation started"));
loadStatus();
} catch (error) {
console.error("Failed to start recalculation:", error);
toast.error(t("recalculationFailed", "Failed to start recalculation"));
} finally {
setRecalculating(false);
}
};
const getStateLabel = (state: string) => {
switch (state) {
case "running":
return t("running", "Running");
case "completed":
return t("completed", "Completed");
case "failed":
return t("failed", "Failed");
default:
return t("idle", "Idle");
}
};
const getStateVariant = (state: string) => {
switch (state) {
case "running":
return "default";
case "completed":
return "secondary";
case "failed":
return "destructive";
default:
return "outline";
}
};
return (
<div className="space-y-4">
{/* Configuration Card */}
<Card>
<CardHeader>
<CardTitle>{t("averageModeConfig", "Average Mode Configuration")}</CardTitle>
<CardDescription>
{t(
"averageModeDescription",
"Randomly assign node groups to user subscriptions based on subscribe configuration"
)}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 gap-4">
<div className="space-y-2">
<Label htmlFor="node_group_count">
{t("availableNodeGroups", "Available Node Groups")}
</Label>
<Input
id="node_group_count"
type="number"
min={1}
value={averageConfig.node_group_count}
readOnly
className="bg-muted"
/>
<p className="text-xs text-muted-foreground">
{t("nodeGroupCountAutoCalculated", "Auto-calculated from actual node groups")}
</p>
</div>
</div>
</CardContent>
</Card>
{/* Recalculation Card */}
<Card>
<CardHeader>
<CardTitle>{t("groupRecalculation", "Group Recalculation")}</CardTitle>
<CardDescription>
{t(
"groupRecalculationDescription",
"Manually trigger a full recalculation of all user groups based on current configuration"
)}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* Current Status */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">
{t("currentStatus", "Current Status")}
</span>
{loadingStatus ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : status ? (
<Badge variant={getStateVariant(status.state) as any}>
{getStateLabel(status.state)}
</Badge>
) : null}
</div>
{status?.state === "running" && (
<div className="space-y-2">
<div className="flex items-center justify-between text-sm">
<span>{t("progress", "Progress")}</span>
<span>
{status.progress} / {status.total || 0}
</span>
</div>
<div className="h-2 overflow-hidden rounded-full bg-muted">
<div
className="h-full bg-primary transition-all"
style={{
width: `${status.total > 0 ? (status.progress / status.total) * 100 : 0}%`,
}}
/>
</div>
</div>
)}
{status?.state === "completed" && (
<div className="text-sm text-muted-foreground">
{t("recalculationCompleted", "Recalculation completed successfully")}
</div>
)}
{status?.state === "failed" && (
<div className="text-sm text-destructive">
{t("recalculationFailed", "Recalculation failed. Please try again.")}
</div>
)}
</div>
{/* Recalculate Button */}
<div className="flex justify-end">
<Button
onClick={handleRecalculate}
disabled={recalculating || status?.state === "running"}
>
{recalculating && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
{t("recalculateAll", "Recalculate All Users")}
</Button>
</div>
{/* Warning */}
<div className="rounded-md bg-yellow-50 p-4 text-sm text-yellow-800 dark:bg-yellow-900/20 dark:text-yellow-400">
<strong>{t("warning", "Warning")}:</strong>{" "}
{t(
"recalculationWarning",
"Recalculation will reassign all users to new groups based on current configuration. This operation cannot be undone."
)}
</div>
</CardContent>
</Card>
</div>
);
}
@@ -0,0 +1,174 @@
"use client";
import { Button } from "@workspace/ui/components/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@workspace/ui/components/dialog";
import { Label } from "@workspace/ui/components/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@workspace/ui/components/select";
import { Loader2 } from "lucide-react";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useQuery } from "@tanstack/react-query";
import { toast } from "sonner";
import { getNodeGroupList, bindNodeGroups } from "@workspace/ui/services/admin/group";
interface BindNodeGroupsDialogProps {
userGroupIds: number[];
userGroupNames: string[];
onOpenChange?: (open: boolean) => void;
onSuccess?: () => void;
}
export default function BindNodeGroupsDialog({
userGroupIds,
userGroupNames,
onOpenChange,
onSuccess,
}: BindNodeGroupsDialogProps) {
const { t } = useTranslation("group");
const [open, setOpen] = useState(false);
const [saving, setSaving] = useState(false);
const [selectedNodeGroupId, setSelectedNodeGroupId] = useState<number | undefined>();
const { data: nodeGroupsData, isLoading } = useQuery({
queryKey: ["nodeGroups"],
queryFn: async () => {
const { data } = await getNodeGroupList({ page: 1, size: 1000 });
return data.data?.list || [];
},
});
useEffect(() => {
if (open && nodeGroupsData) {
// Load current binding when dialog opens
loadCurrentBinding();
}
}, [open]);
const loadCurrentBinding = () => {
// Get first user group's current node group binding
// For batch binding, we'll default to unbound
setSelectedNodeGroupId(undefined);
};
const handleBind = async () => {
if (selectedNodeGroupId === undefined) {
toast.error(t("selectNodeGroupRequired", "Please select a node group"));
return;
}
setSaving(true);
try {
await bindNodeGroups({
user_group_ids: userGroupIds,
node_group_id: selectedNodeGroupId === 0 ? null : selectedNodeGroupId,
} as API.BindNodeGroupsRequest);
toast.success(
t("bindSuccess", "Successfully bound {{userGroupCount}} user groups to node group").replace(
/{{userGroupCount}}/g,
String(userGroupIds.length)
)
);
setOpen(false);
onOpenChange?.(false);
onSuccess?.();
} catch (error) {
console.error("Failed to bind node group:", error);
toast.error(t("bindFailed", "Failed to bind node group"));
} finally {
setSaving(false);
}
};
const displayNames =
userGroupNames.length > 2
? `${userGroupNames.slice(0, 2).join(", ")}... (${userGroupIds.length})`
: userGroupNames.join(", ");
return (
<Dialog open={open} onOpenChange={(newOpen) => {
setOpen(newOpen);
onOpenChange?.(newOpen);
}}>
<DialogTrigger asChild>
<Button variant="outline" size="sm">
{t("bindNodeGroup", "Bind Node Group")}
</Button>
</DialogTrigger>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{t("bindNodeGroup", "Bind Node Group")}</DialogTitle>
<DialogDescription>
{t(
"bindNodeGroupDescription",
"Select a node group to bind to user groups: {{userGroups}}",
{ userGroups: displayNames }
).replace(/{{userGroups}}/g, displayNames)}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
{isLoading ? (
<div className="flex justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin" />
</div>
) : (
<div className="space-y-2">
<Label htmlFor="node-group">{t("selectNodeGroup", "Select Node Group")}</Label>
<Select
value={selectedNodeGroupId?.toString() || ""}
onValueChange={(val) => setSelectedNodeGroupId(parseInt(val) || undefined)}
>
<SelectTrigger id="node-group" className="w-full">
<SelectValue placeholder={t("selectNodeGroupPlaceholder", "Select a node group...")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="0">
{t("unbound", "Unbound")}
</SelectItem>
{nodeGroupsData?.map((nodeGroup) => (
<SelectItem key={nodeGroup.id} value={String(nodeGroup.id)}>
{nodeGroup.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => {
setOpen(false);
onOpenChange?.(false);
}}
disabled={saving}
>
{t("cancel", "Cancel")}
</Button>
<Button onClick={handleBind} disabled={saving || selectedNodeGroupId === undefined}>
{saving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{t("confirm", "Confirm")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,375 @@
"use client";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@workspace/ui/components/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@workspace/ui/components/dialog";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@workspace/ui/components/table";
import { Loader2 } from "lucide-react";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useQuery } from "@tanstack/react-query";
import { getGroupHistory, getGroupHistoryDetail, getNodeGroupList } from "@workspace/ui/services/admin/group";
export default function CurrentGroupResults() {
const { t } = useTranslation("group");
const [loading, setLoading] = useState(true);
const [latestResult, setLatestResult] = useState<any>(null);
const [latestDetails, setLatestDetails] = useState<any[]>([]);
const [detailsLoading, setDetailsLoading] = useState(false);
// User list dialog state
const [userListOpen, setUserListOpen] = useState(false);
const [selectedNodeGroupName, setSelectedNodeGroupName] = useState<string>("");
const [userList, setUserList] = useState<any[]>([]);
const [userListLoading, setUserListLoading] = useState(false);
const [userListTotal, setUserListTotal] = useState(0);
// Fetch node groups
const { data: nodeGroups } = useQuery({
queryKey: ["nodeGroups"],
queryFn: async () => {
const { data } = await getNodeGroupList({ page: 1, size: 1000 });
return data.data?.list || [];
},
});
useEffect(() => {
const loadData = async () => {
try {
// Load latest result
const { data: historyData } = await getGroupHistory({
page: 1,
size: 1,
});
if (historyData.data?.list && historyData.data.list.length > 0) {
const latest = historyData.data.list[0];
if (!latest) return;
setLatestResult(latest);
// Fetch details
setDetailsLoading(true);
try {
const { data: detailData } = await getGroupHistoryDetail({
id: latest.id,
});
if (detailData.data?.config_snapshot?.group_details) {
setLatestDetails(detailData.data.config_snapshot.group_details);
} else {
setLatestDetails([]);
}
} catch (error) {
console.error("Failed to fetch latest result details:", error);
setLatestDetails([]);
} finally {
setDetailsLoading(false);
}
}
} catch (error) {
console.error("Failed to load data:", error);
} finally {
setLoading(false);
}
};
loadData();
}, []);
const handleShowUserList = async (nodeGroupId: number, nodeGroupName: string) => {
setSelectedNodeGroupName(nodeGroupName);
setUserListOpen(true);
setUserListLoading(true);
// 从历史详情记录中获取用户数据
const detail = latestDetails.find((d: any) => {
const detailNodeGroupId = d.NodeGroupId || d.node_group_id;
return detailNodeGroupId === nodeGroupId;
});
if (detail) {
const userDataJSON = detail.UserData || detail.user_data;
if (userDataJSON) {
try {
const userData = JSON.parse(userDataJSON);
setUserList(userData);
setUserListTotal(userData.length);
} catch (error) {
console.error("Failed to parse user data:", error);
setUserList([]);
setUserListTotal(0);
}
} else {
setUserList([]);
setUserListTotal(0);
}
} else {
setUserList([]);
setUserListTotal(0);
}
setUserListLoading(false);
};
if (loading) {
return (
<Card>
<CardHeader>
<CardTitle>{t("currentGroupingResult", "Current Grouping Result")}</CardTitle>
<CardDescription>
{t("loading", "Loading...")}
</CardDescription>
</CardHeader>
</Card>
);
}
return (
<div className="space-y-4">
{/* Latest Result Card */}
{!latestResult ? (
<Card>
<CardHeader>
<CardTitle>{t("currentGroupingResult", "Current Grouping Result")}</CardTitle>
</CardHeader>
<CardContent>
<div className="text-center py-8 text-sm text-muted-foreground">
{t("noDetails", "No details available")}
</div>
</CardContent>
</Card>
) : (
<Card>
<CardHeader>
<CardTitle>{t("currentGroupingResult", "Current Grouping Result")}</CardTitle>
<CardDescription>
{t("latestGroupingCalculation", "Latest grouping calculation details")}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* Calculation Info */}
<div className="space-y-2">
<h3 className="text-sm font-medium">{t("calculationInfo", "Calculation Information")}</h3>
<div className="grid grid-cols-2 gap-4 rounded-lg bg-muted/50 p-4">
<div>
<div className="text-xs text-muted-foreground">{t("groupMode", "Group Mode")}</div>
<div className="font-medium">
{(latestResult.GroupMode || latestResult.group_mode) === "average"
? t("averageMode", "Average Mode")
: (latestResult.GroupMode || latestResult.group_mode) === "subscribe"
? t("subscribeMode", "Subscribe Mode")
: t("trafficMode", "Traffic Mode")}
</div>
</div>
<div>
<div className="text-xs text-muted-foreground">{t("state", "State")}</div>
<div className="font-medium">
{(latestResult.State || latestResult.state) === "completed"
? t("completed", "Completed")
: (latestResult.State || latestResult.state) === "running"
? t("running", "Running")
: (latestResult.State || latestResult.state) === "failed"
? t("failed", "Failed")
: t("idle", "Idle")}
</div>
</div>
<div>
<div className="text-xs text-muted-foreground">{t("triggerType", "Trigger Type")}</div>
<div className="font-medium">
{(latestResult.TriggerType || latestResult.trigger_type) === "manual"
? t("manualTrigger", "Manual")
: (latestResult.TriggerType || latestResult.trigger_type) === "auto"
? t("autoTrigger", "Auto")
: t("scheduleTrigger", "Schedule")}
</div>
</div>
<div>
<div className="text-xs text-muted-foreground">{t("successFailedCount", "Success/Failed")}</div>
<div className="font-medium">
{latestResult.SuccessCount || latestResult.success_count || 0} / {latestResult.FailedCount || latestResult.failed_count || 0}
</div>
</div>
<div>
<div className="text-xs text-muted-foreground">{t("startTime", "Start Time")}</div>
<div className="font-medium">
{latestResult.StartTime || latestResult.start_time
? new Date((latestResult.StartTime || latestResult.start_time) * 1000).toLocaleString()
: "-"}
</div>
</div>
<div>
<div className="text-xs text-muted-foreground">{t("endTime", "End Time")}</div>
<div className="font-medium">
{latestResult.EndTime || latestResult.end_time
? new Date((latestResult.EndTime || latestResult.end_time) * 1000).toLocaleString()
: "-"}
</div>
</div>
</div>
</div>
{/* Grouping Details */}
<div className="space-y-2">
<h3 className="text-sm font-medium">{t("groupingDetailsStatistics", "Grouping Details Statistics")}</h3>
<div className="grid grid-cols-3 gap-4 rounded-lg bg-muted/50 p-4">
<div className="text-center">
<div className="text-2xl font-bold">
{latestDetails.reduce((sum: number, d: any) => sum + (d.UserCount || d.user_count || 0), 0)}
</div>
<div className="text-xs text-muted-foreground">
{t("totalUsers", "Total Users")}
</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold">
{latestDetails.reduce((sum: number, d: any) => sum + (d.NodeCount || d.node_count || 0), 0)}
</div>
<div className="text-xs text-muted-foreground">
{t("totalNodes", "Total Nodes")}
</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold">{latestDetails.length}</div>
<div className="text-xs text-muted-foreground">
{t("totalNodeGroups", "Total Node Groups")}
</div>
</div>
</div>
</div>
{detailsLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
<span className="ml-2 text-sm text-muted-foreground">
{t("loading", "Loading...")}
</span>
</div>
) : latestDetails.length > 0 ? (
<>
{/* Details Table */}
<div className="rounded-md border">
<table className="w-full text-sm">
<thead className="bg-muted">
<tr>
<th className="border-b px-4 py-2 text-left">
{t("nodeGroup", "Node Group")}
</th>
<th className="border-b px-4 py-2 text-right">
{t("userCount", "User Count")}
</th>
<th className="border-b px-4 py-2 text-right">
{t("nodeCount", "Node Count")}
</th>
</tr>
</thead>
<tbody>
{latestDetails.map((detail: any, index: number) => {
const nodeGroupId = detail.NodeGroupId || detail.node_group_id;
const nodeGroup = nodeGroups?.find((ng) => ng.id === nodeGroupId);
const nodeGroupName = nodeGroup?.name || `${t("idPrefix", "#")}${nodeGroupId}`;
const userCount = detail.UserCount || detail.user_count || 0;
return (
<tr key={index}>
<td className="border-b px-4 py-2">
<div>
<div className="font-medium">{nodeGroupName}</div>
<div className="text-xs text-muted-foreground">{t("id", "ID")}: {nodeGroupId}</div>
</div>
</td>
<td className="border-b px-4 py-2 text-right">
<button
className={`font-semibold hover:underline ${
userCount === 0 ? 'text-muted-foreground cursor-not-allowed' : 'cursor-pointer'
}`}
onClick={() => handleShowUserList(nodeGroupId, nodeGroupName)}
disabled={userCount === 0}
>
{userCount}
</button>
</td>
<td className="border-b px-4 py-2 text-right">
{detail.NodeCount || detail.node_count || 0}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</>
) : (
<div className="text-center py-8 text-sm text-muted-foreground">
{t("noDetails", "No details available")}
</div>
)}
</CardContent>
</Card>
)}
{/* User List Dialog */}
<Dialog open={userListOpen} onOpenChange={setUserListOpen}>
<DialogContent className="sm:max-w-[700px] max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>
{selectedNodeGroupName} - {t("userList", "User List")}
</DialogTitle>
<DialogDescription>
{t("totalUsers", "Total Users")}: {userListTotal}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{userListLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
<span className="ml-2 text-sm text-muted-foreground">
{t("loading", "Loading...")}
</span>
</div>
) : userList.length > 0 ? (
<Table>
<TableHeader>
<TableRow>
<TableHead>{t("id", "ID")}</TableHead>
<TableHead>{t("email", "Email")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{userList.map((user) => (
<TableRow key={user.id}>
<TableCell className="font-medium">{user.id}</TableCell>
<TableCell>
{user.email || "-"}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
) : (
<div className="text-center py-8 text-sm text-muted-foreground">
{t("noUsers", "No users found")}
</div>
)}
</div>
</DialogContent>
</Dialog>
</div>
);
}
@@ -0,0 +1,264 @@
"use client";
import { Button } from "@workspace/ui/components/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@workspace/ui/components/card";
import { Loader2 } from "lucide-react";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import {
getGroupConfig,
updateGroupConfig,
resetGroups,
} from "@workspace/ui/services/admin/group";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@workspace/ui/components/alert-dialog";
export default function GroupConfig() {
const { t } = useTranslation("group");
const [saving, setSaving] = useState(false);
const [resetting, setResetting] = useState(false);
const [showResetDialog, setShowResetDialog] = useState(false);
const [config, setConfig] = useState<{
enabled: boolean;
mode: "average" | "subscribe" | "traffic";
}>({
enabled: false,
mode: "average",
});
const loadConfig = async () => {
try {
const { data } = await getGroupConfig();
if (data.data) {
setConfig({
enabled: data.data.enabled || false,
mode: (data.data.mode || "average") as "average" | "subscribe" | "traffic",
});
}
} catch (error) {
console.error("Failed to load group config:", error);
toast.error(t("loadFailed", "Failed to load configuration"));
}
};
useEffect(() => {
loadConfig();
}, []);
const handleUpdateEnabled = async (enabled: boolean) => {
setSaving(true);
try {
const payload: any = {
enabled,
mode: config.mode,
};
await updateGroupConfig(payload);
setConfig({ ...config, enabled });
toast.success(t("saved", "Configuration saved successfully"));
} catch (error) {
console.error("Failed to update group config:", error);
toast.error(t("saveFailed", "Failed to save configuration"));
} finally {
setSaving(false);
}
};
const handleUpdateMode = async (mode: "average" | "subscribe" | "traffic") => {
setSaving(true);
try {
const payload: any = {
enabled: config.enabled,
mode,
};
await updateGroupConfig(payload);
setConfig({ ...config, mode });
toast.success(t("saved", "Configuration saved successfully"));
} catch (error) {
console.error("Failed to update group config:", error);
toast.error(t("saveFailed", "Failed to save configuration"));
} finally {
setSaving(false);
}
};
const handleResetGroups = async () => {
setResetting(true);
try {
await resetGroups({ confirm: true });
toast.success(t("resetSuccess", "All groups have been reset successfully"));
setShowResetDialog(false);
// Reload config after reset
await loadConfig();
} catch (error) {
console.error("Failed to reset groups:", error);
toast.error(t("resetFailed", "Failed to reset groups"));
} finally {
setResetting(false);
}
};
return (
<div className="space-y-4">
<Card>
<CardHeader>
<CardTitle>{t("groupConfig", "Group Configuration")}</CardTitle>
<CardDescription>
{t(
"groupConfigDescription",
"Configure user group and node group settings"
)}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* Enable/Disable */}
<div className="flex items-center justify-between">
<div>
<label htmlFor="enabled" className="font-medium">
{t("enableGrouping", "Enable Grouping")}
</label>
<p className="text-sm text-muted-foreground">
{t(
"enableGroupingDescription",
"When enabled, users will only see nodes from their assigned group"
)}
</p>
</div>
<input
id="enabled"
type="checkbox"
checked={config.enabled}
onChange={(e) => handleUpdateEnabled(e.target.checked)}
disabled={saving}
className="h-4 w-4"
/>
</div>
{/* Mode Selection */}
{config.enabled && (
<div className="space-y-2">
<label className="font-medium">
{t("groupingMode", "Grouping Mode")}
</label>
<div className="grid grid-cols-3 gap-4">
<button
type="button"
onClick={() => handleUpdateMode("average")}
disabled={saving}
className={`rounded-lg border p-4 text-left transition-colors ${
config.mode === "average"
? "border-primary bg-primary/10"
: "border-border hover:bg-muted"
} ${saving ? "opacity-50 cursor-not-allowed" : ""}`}
>
<div className="font-medium">
{t("averageMode", "Average Mode")}
</div>
<div className="text-sm text-muted-foreground">
{t(
"averageModeDescription",
"Distribute users evenly across groups"
)}
</div>
</button>
<button
type="button"
onClick={() => handleUpdateMode("subscribe")}
disabled={saving}
className={`rounded-lg border p-4 text-left transition-colors ${
config.mode === "subscribe"
? "border-primary bg-primary/10"
: "border-border hover:bg-muted"
} ${saving ? "opacity-50 cursor-not-allowed" : ""}`}
>
<div className="font-medium">
{t("subscribeMode", "Subscribe Mode")}
</div>
<div className="text-sm text-muted-foreground">
{t(
"subscribeModeDescription",
"Group users by their subscription plan"
)}
</div>
</button>
<button
type="button"
onClick={() => handleUpdateMode("traffic")}
disabled={saving}
className={`rounded-lg border p-4 text-left transition-colors ${
config.mode === "traffic"
? "border-primary bg-primary/10"
: "border-border hover:bg-muted"
} ${saving ? "opacity-50 cursor-not-allowed" : ""}`}
>
<div className="font-medium">
{t("trafficMode", "Traffic Mode")}
</div>
<div className="text-sm text-muted-foreground">
{t(
"trafficModeDescription",
"Group users by their traffic usage"
)}
</div>
</button>
</div>
</div>
)}
{/* Reset Button */}
<div className="flex justify-end pt-4 border-t">
<AlertDialog open={showResetDialog} onOpenChange={setShowResetDialog}>
<AlertDialogTrigger asChild>
<Button variant="destructive">
{t("resetGroups", "Reset All Groups")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("resetGroupsTitle", "Reset All Groups")}
</AlertDialogTitle>
<AlertDialogDescription>
{t(
"resetGroupsDescription",
"This action will delete all node groups and user groups, reset all users' group ID to 0, clear all products' node group IDs, and clear all nodes' node group IDs. This action cannot be undone."
)}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
{t("cancel", "Cancel")}
</AlertDialogCancel>
<AlertDialogAction
onClick={handleResetGroups}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{resetting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{t("confirm", "Confirm")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</CardContent>
</Card>
</div>
);
}
@@ -0,0 +1,499 @@
"use client";
import { Badge } from "@workspace/ui/components/badge";
import { Button } from "@workspace/ui/components/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@workspace/ui/components/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@workspace/ui/components/dialog";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@workspace/ui/components/table";
import {
ProTable,
type ProTableActions,
} from "@workspace/ui/composed/pro-table/pro-table";
import {
getGroupHistory,
getGroupHistoryDetail,
getNodeGroupList,
} from "@workspace/ui/services/admin/group";
import { useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { formatDate } from "@/utils/common";
import { Loader2 } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
export default function GroupHistory() {
const { t } = useTranslation("group");
const ref = useRef<ProTableActions>(null);
const [detailOpen, setDetailOpen] = useState(false);
const [detailLoading, setDetailLoading] = useState(false);
const [selectedHistory, setSelectedHistory] = useState<API.GroupHistory | null>(null);
const [details, setDetails] = useState<any[]>([]);
const [nodeGroupMap, setNodeGroupMap] = useState<Map<number, string>>(new Map());
// User list dialog state
const [userListOpen, setUserListOpen] = useState(false);
const [selectedNodeGroupName, setSelectedNodeGroupName] = useState<string>("");
const [userList, setUserList] = useState<any[]>([]);
const [userListTotal, setUserListTotal] = useState(0);
// Fetch all node groups
const { data: nodeGroups } = useQuery({
queryKey: ["getNodeGroupListForDetail"],
queryFn: async () => {
const { data } = await getNodeGroupList({
page: 1,
size: 100,
});
return data.data?.list || [];
},
});
// Build ID to name maps when groups are loaded
if (nodeGroups) {
const newNodeGroupMap = new Map<number, string>();
nodeGroups.forEach((ng: API.NodeGroup) => {
newNodeGroupMap.set(ng.id, ng.name);
});
if (newNodeGroupMap.size !== nodeGroupMap.size) {
setNodeGroupMap(newNodeGroupMap);
}
}
const getModeLabel = (mode: string) => {
switch (mode) {
case "average":
return t("averageMode", "Average");
case "subscribe":
return t("subscribeMode", "Subscribe");
case "traffic":
return t("trafficMode", "Traffic");
default:
return mode;
}
};
const getTriggerTypeLabel = (type: string) => {
switch (type) {
case "manual":
return t("manualTrigger", "Manual");
case "auto":
return t("autoTrigger", "Auto");
case "schedule":
return t("scheduleTrigger", "Schedule");
default:
return type;
}
};
const handleViewDetail = async (record: API.GroupHistory) => {
setSelectedHistory(record);
setDetailOpen(true);
setDetailLoading(true);
try {
const { data } = await getGroupHistoryDetail({
id: record.id,
});
console.log("Group history detail response:", data);
// 从返回的数据中获取详情列表
// data.data.config_snapshot.group_details 包含分组详情
if (data.data?.config_snapshot?.group_details) {
setDetails(data.data.config_snapshot.group_details);
} else {
console.warn("No group_details found in response:", data);
setDetails([]);
}
} catch (error) {
console.error("Failed to fetch history details:", error);
setDetails([]);
} finally {
setDetailLoading(false);
}
};
const handleShowUserList = async (nodeGroupId: number, nodeGroupName: string) => {
setSelectedNodeGroupName(nodeGroupName);
setUserListOpen(true);
// 从历史详情记录中获取用户数据
const detail = details.find((d: any) => {
const detailNodeGroupId = d.NodeGroupId || d.node_group_id;
return detailNodeGroupId === nodeGroupId;
});
if (detail) {
const userDataJSON = detail.UserData || detail.user_data;
if (userDataJSON) {
try {
const userData = JSON.parse(userDataJSON);
setUserList(userData);
setUserListTotal(userData.length);
} catch (error) {
console.error("Failed to parse user data:", error);
setUserList([]);
setUserListTotal(0);
}
} else {
setUserList([]);
setUserListTotal(0);
}
} else {
setUserList([]);
setUserListTotal(0);
}
};
return (
<div className="space-y-4">
<Card>
<CardHeader>
<CardTitle>{t("groupHistory", "Group Calculation History")}</CardTitle>
<CardDescription>
{t("groupHistoryDescription", "View group recalculation history and results")}
</CardDescription>
</CardHeader>
<CardContent>
<ProTable<API.GroupHistory, API.GetGroupHistoryRequest>
action={ref}
request={async (params) => {
const { data } = await getGroupHistory({
page: params.page || 1,
size: params.size || 10,
});
return {
list: data.data?.list || [],
total: data.data?.total || 0,
};
}}
columns={[
{
id: "id",
accessorKey: "id",
header: t("id", "ID"),
cell: ({ row }: { row: any }) => (
<span className="text-muted-foreground">
{t("idPrefix", "#")}{row.getValue("id")}
</span>
),
},
{
id: "group_mode",
accessorKey: "group_mode",
header: t("groupMode", "Group Mode"),
cell: ({ row }: { row: any }) => (
<Badge variant="outline">
{getModeLabel(row.getValue("group_mode"))}
</Badge>
),
},
{
id: "trigger_type",
accessorKey: "trigger_type",
header: t("triggerType", "Trigger Type"),
cell: ({ row }: { row: any }) => (
<Badge variant="secondary">
{getTriggerTypeLabel(row.getValue("trigger_type"))}
</Badge>
),
},
{
id: "total_users",
accessorKey: "total_users",
header: t("totalUsers", "Total Users"),
cell: ({ row }: { row: any }) => (
<span className="font-semibold">{row.getValue("total_users")}</span>
),
},
{
id: "result",
accessorKey: "error_log",
header: t("result", "Result"),
cell: ({ row }: { row: any }) => {
const record = row.original;
return (
<div className="space-y-1">
<div className="text-xs text-muted-foreground">
{t("successCount", "Success")}: {record.success_count}
{" "}{t("separator", "/")}{" "}
{t("failedCount", "Failed")}: {record.failed_count}
</div>
{record.error_log && (
<Badge variant="destructive">
{t("failed", "Failed")}
</Badge>
)}
{!record.error_log && record.failed_count === 0 && (
<Badge variant="default">
{t("completed", "Completed")}
</Badge>
)}
</div>
);
},
},
{
id: "created_at",
accessorKey: "created_at",
header: t("createdAt", "Created At"),
cell: ({ row }: { row: any }) => formatDate(row.getValue("created_at")),
},
]}
actions={{
render: (row: any) => [
<Button
key="detail"
variant="outline"
size="sm"
onClick={() => handleViewDetail(row)}
>
{t("viewDetail", "View Detail")}
</Button>,
],
}}
header={{
title: t("groupHistory", "Group Calculation History"),
}}
/>
</CardContent>
</Card>
{/* Detail Dialog */}
<Dialog open={detailOpen} onOpenChange={setDetailOpen}>
<DialogContent className="sm:max-w-[700px] max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>
{t("groupHistoryDetail", "Group Calculation Detail")}
</DialogTitle>
<DialogDescription>
{t("historyId", "History ID")}: {selectedHistory?.id}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{selectedHistory && (
<>
<div className="grid grid-cols-2 gap-4">
<div>
<div className="text-sm text-muted-foreground">
{t("groupMode", "Group Mode")}
</div>
<div className="font-medium">
{getModeLabel(selectedHistory.group_mode)}
</div>
</div>
<div>
<div className="text-sm text-muted-foreground">
{t("triggerType", "Trigger Type")}
</div>
<div className="font-medium">
{getTriggerTypeLabel(selectedHistory.trigger_type)}
</div>
</div>
<div>
<div className="text-sm text-muted-foreground">
{t("totalUsers", "Total Users")}
</div>
<div className="font-medium">{selectedHistory.total_users}</div>
</div>
<div>
<div className="text-sm text-muted-foreground">
{t("result", "Result")}
</div>
<div className="font-medium">
{t("successCount", "Success")}: {selectedHistory.success_count}
{" "}{t("separator", "/")}{" "}
{t("failedCount", "Failed")}: {selectedHistory.failed_count}
</div>
</div>
{selectedHistory.start_time && (
<div>
<div className="text-sm text-muted-foreground">
{t("startTime", "Start Time")}
</div>
<div className="font-medium">
{formatDate(selectedHistory.start_time)}
</div>
</div>
)}
{selectedHistory.end_time && (
<div>
<div className="text-sm text-muted-foreground">
{t("endTime", "End Time")}
</div>
<div className="font-medium">
{formatDate(selectedHistory.end_time)}
</div>
</div>
)}
</div>
{selectedHistory.error_log && (
<div>
<div className="text-sm text-muted-foreground">
{t("errorMessage", "Error Message")}
</div>
<div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive">
{selectedHistory.error_log}
</div>
</div>
)}
</>
)}
<div>
<div className="mb-2 text-sm font-medium">
{t("groupDetails", "Group Details")}
</div>
{detailLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
<span className="ml-2 text-sm text-muted-foreground">
{t("loading", "Loading...")}
</span>
</div>
) : details.length > 0 ? (
<>
{/* 统计信息 */}
<div className="mb-4 grid grid-cols-3 gap-4 rounded-lg bg-muted/50 p-4">
<div className="text-center">
<div className="text-2xl font-bold">
{details.reduce((sum: number, d: any) => sum + (d.UserCount || d.user_count || 0), 0)}
</div>
<div className="text-xs text-muted-foreground">
{t("totalUsers", "Total Users")}
</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold">
{details.reduce((sum: number, d: any) => sum + (d.NodeCount || d.node_count || 0), 0)}
</div>
<div className="text-xs text-muted-foreground">
{t("totalNodes", "Total Nodes")}
</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold">{details.length}</div>
<div className="text-xs text-muted-foreground">
{t("totalNodeGroups", "Total Node Groups")}
</div>
</div>
</div>
{/* 详情表格 */}
<div className="rounded-md border">
<table className="w-full text-sm">
<thead className="bg-muted">
<tr>
<th className="border-b px-4 py-2 text-left">
{t("nodeGroup", "Node Group")}
</th>
<th className="border-b px-4 py-2 text-right">
{t("userCount", "User Count")}
</th>
<th className="border-b px-4 py-2 text-right">
{t("nodeCount", "Node Count")}
</th>
</tr>
</thead>
<tbody>
{details.map((detail: any, index: number) => {
const nodeGroupId = detail.NodeGroupId || detail.node_group_id;
const nodeGroupName = nodeGroupMap.get(nodeGroupId) || `${t("idPrefix", "#")}${nodeGroupId}`;
return (
<tr key={index}>
<td className="border-b px-4 py-2">
<div>
<div className="font-medium">{nodeGroupName}</div>
<div className="text-xs text-muted-foreground">{t("id", "ID")}: {nodeGroupId}</div>
</div>
</td>
<td className="border-b px-4 py-2 text-right">
<button
className="font-semibold hover:underline cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
onClick={() => handleShowUserList(nodeGroupId, nodeGroupName)}
disabled={(detail.UserCount || detail.user_count || 0) === 0}
>
{detail.UserCount || detail.user_count || 0}
</button>
</td>
<td className="border-b px-4 py-2 text-right">
{detail.NodeCount || detail.node_count || 0}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</>
) : (
<div className="text-center py-8 text-sm text-muted-foreground">
{t("noDetails", "No details available")}
</div>
)}
</div>
</div>
</DialogContent>
</Dialog>
{/* User List Dialog */}
<Dialog open={userListOpen} onOpenChange={setUserListOpen}>
<DialogContent className="sm:max-w-[700px] max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>
{selectedNodeGroupName} - {t("userList", "User List")}
</DialogTitle>
<DialogDescription>
{t("totalUsers", "Total Users")}: {userListTotal}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{userList.length > 0 ? (
<Table>
<TableHeader>
<TableRow>
<TableHead>{t("id", "ID")}</TableHead>
<TableHead>{t("email", "Email")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{userList.map((user) => (
<TableRow key={user.id}>
<TableCell className="font-medium">{user.id}</TableCell>
<TableCell>
{user.email || "-"}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
) : (
<div className="text-center py-8 text-sm text-muted-foreground">
{t("noUsers", "No users found")}
</div>
)}
</div>
</DialogContent>
</Dialog>
</div>
);
}
@@ -0,0 +1,228 @@
"use client";
import { Badge } from "@workspace/ui/components/badge";
import { Button } from "@workspace/ui/components/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@workspace/ui/components/card";
import { Loader2 } from "lucide-react";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import {
getRecalculationStatus,
recalculateGroup,
} from "@workspace/ui/services/admin/group";
export default function GroupRecalculate() {
const { t } = useTranslation("group");
const [recalculating, setRecalculating] = useState<string | null>(null);
const [loadingStatus, setLoadingStatus] = useState(false);
const [status, setStatus] = useState<{
state: string;
progress: number;
total: number;
} | null>(null);
const loadStatus = async () => {
setLoadingStatus(true);
try {
const { data } = await getRecalculationStatus();
if (data.data) {
setStatus(data.data);
}
} catch (error) {
console.error("Failed to load recalculation status:", error);
} finally {
setLoadingStatus(false);
}
};
useEffect(() => {
loadStatus();
// Poll status every 2 seconds when recalculating
const interval = setInterval(() => {
if (status?.state === "running") {
loadStatus();
}
}, 2000);
return () => clearInterval(interval);
}, [status?.state]);
const handleRecalculate = async (mode: "average" | "subscribe" | "traffic") => {
setRecalculating(mode);
try {
await recalculateGroup({ mode });
toast.success(t("recalculationStarted", "Recalculation started"));
loadStatus();
} catch (error) {
console.error("Failed to start recalculation:", error);
toast.error(t("recalculationFailed", "Failed to start recalculation"));
} finally {
setRecalculating(null);
}
};
const getStateLabel = (state: string) => {
switch (state) {
case "running":
return t("running", "Running");
case "completed":
return t("completed", "Completed");
case "failed":
return t("failed", "Failed");
default:
return t("idle", "Idle");
}
};
const getStateVariant = (state: string) => {
switch (state) {
case "running":
return "default";
case "completed":
return "secondary";
case "failed":
return "destructive";
default:
return "outline";
}
};
return (
<div className="space-y-4">
<Card>
<CardHeader>
<CardTitle>{t("groupRecalculation", "Group Recalculation")}</CardTitle>
<CardDescription>
{t(
"groupRecalculationDescription",
"Manually trigger a full recalculation of all user groups based on current configuration"
)}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* Current Status */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">
{t("currentStatus", "Current Status")}
</span>
{loadingStatus ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : status ? (
<Badge variant={getStateVariant(status.state) as any}>
{getStateLabel(status.state)}
</Badge>
) : null}
</div>
{status?.state === "running" && (
<div className="space-y-2">
<div className="flex items-center justify-between text-sm">
<span>{t("progress", "Progress")}</span>
<span>
{status.progress} / {status.total || 0}
</span>
</div>
<div className="h-2 overflow-hidden rounded-full bg-muted">
<div
className="h-full bg-primary transition-all"
style={{
width: `${status.total > 0 ? (status.progress / status.total) * 100 : 0}%`,
}}
/>
</div>
</div>
)}
{status?.state === "completed" && (
<div className="text-sm text-muted-foreground">
{t("recalculationCompleted", "Recalculation completed successfully")}
</div>
)}
{status?.state === "failed" && (
<div className="text-sm text-destructive">
{t("recalculationFailed", "Recalculation failed. Please try again.")}
</div>
)}
</div>
{/* Recalculate Buttons */}
<div className="space-y-4">
<div className="grid grid-cols-3 gap-4">
{/* Average Mode Recalculate */}
<div className="space-y-2">
<div className="font-medium">
{t("averageMode", "Average Mode")}
</div>
<Button
onClick={() => handleRecalculate("average")}
disabled={recalculating === "average" || status?.state === "running"}
className="w-full"
variant="outline"
>
{recalculating === "average" && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
{t("recalculate", "Recalculate")}
</Button>
</div>
{/* Subscribe Mode Recalculate */}
<div className="space-y-2">
<div className="font-medium">
{t("subscribeMode", "Subscribe Mode")}
</div>
<Button
onClick={() => handleRecalculate("subscribe")}
disabled={recalculating === "subscribe" || status?.state === "running"}
className="w-full"
variant="outline"
>
{recalculating === "subscribe" && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
{t("recalculate", "Recalculate")}
</Button>
</div>
{/* Traffic Mode Recalculate */}
<div className="space-y-2">
<div className="font-medium">
{t("trafficMode", "Traffic Mode")}
</div>
<Button
onClick={() => handleRecalculate("traffic")}
disabled={recalculating === "traffic" || status?.state === "running"}
className="w-full"
variant="outline"
>
{recalculating === "traffic" && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
{t("recalculate", "Recalculate")}
</Button>
</div>
</div>
</div>
{/* Warning */}
<div className="rounded-md bg-yellow-50 p-4 text-sm text-yellow-800 dark:bg-yellow-900/20 dark:text-yellow-400">
<strong>{t("warning", "Warning")}:</strong>{" "}
{t(
"recalculationWarning",
"Recalculation will reassign all users to new groups based on current configuration. This operation cannot be undone."
)}
</div>
</CardContent>
</Card>
</div>
);
}
+85
View File
@@ -0,0 +1,85 @@
"use client";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@workspace/ui/components/tabs";
import { useTranslation } from "react-i18next";
// import UserGroups from "./user-groups";
import NodeGroups from "./node-groups";
import GroupHistory from "./group-history";
import GroupConfig from "./group-config";
import AverageModeTab from "./average-mode-tab";
import SubscribeModeTab from "./subscribe-mode-tab";
import TrafficModeTab from "./traffic-mode-tab";
import CurrentGroupResults from "./current-group-results";
export default function Group() {
const { t } = useTranslation("group");
return (
<div className="space-y-4">
<h2 className="font-semibold text-lg">
{t("title", "Group Management")}
</h2>
<Tabs defaultValue="config">
<TabsList className="flex flex-wrap gap-2">
<TabsTrigger value="config">
{t("config", "Config")}
</TabsTrigger>
{/* <TabsTrigger value="user">
{t("userGroups", "User Groups")}
</TabsTrigger> */}
<TabsTrigger value="node">
{t("nodeGroups", "Node Groups")}
</TabsTrigger>
<TabsTrigger value="average">
{t("averageMode", "Average Mode")}
</TabsTrigger>
<TabsTrigger value="subscribe">
{t("subscribeMode", "Subscribe Mode")}
</TabsTrigger>
<TabsTrigger value="traffic">
{t("trafficMode", "Traffic Mode")}
</TabsTrigger>
<TabsTrigger value="results">
{t("currentGroupingResult", "Current Grouping Result")}
</TabsTrigger>
<TabsTrigger value="history">
{t("history", "History")}
</TabsTrigger>
</TabsList>
<TabsContent value="config" className="mt-4">
<GroupConfig />
</TabsContent>
{/* <TabsContent value="user" className="mt-4">
<UserGroups />
</TabsContent> */}
<TabsContent value="node" className="mt-4">
<NodeGroups />
</TabsContent>
<TabsContent value="average" className="mt-4">
<AverageModeTab />
</TabsContent>
<TabsContent value="subscribe" className="mt-4">
<SubscribeModeTab />
</TabsContent>
<TabsContent value="traffic" className="mt-4">
<TrafficModeTab />
</TabsContent>
<TabsContent value="results" className="mt-4">
<CurrentGroupResults />
</TabsContent>
<TabsContent value="history" className="mt-4">
<GroupHistory />
</TabsContent>
</Tabs>
</div>
);
}
@@ -0,0 +1,459 @@
"use client";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@workspace/ui/components/dialog";
import { Input } from "@workspace/ui/components/input";
import { Label } from "@workspace/ui/components/label";
import { Textarea } from "@workspace/ui/components/textarea";
import { Switch } from "@workspace/ui/components/switch";
import { AlertCircle, Loader2 } from "lucide-react";
import { forwardRef, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
interface NodeGroupFormProps {
initialValues?: Partial<API.NodeGroup>;
allNodeGroups?: API.NodeGroup[];
currentGroupId?: number;
loading?: boolean;
onSubmit: (values: Record<string, unknown>) => Promise<boolean>;
title: string;
trigger: React.ReactNode;
}
const NodeGroupForm = forwardRef<
HTMLButtonElement,
NodeGroupFormProps
>(({ initialValues, allNodeGroups = [], currentGroupId, loading, onSubmit, title, trigger }, ref) => {
const { t } = useTranslation("group");
const [open, setOpen] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [conflictError, setConflictError] = useState<string>("");
const [values, setValues] = useState({
name: "",
description: "",
sort: 0,
for_calculation: true,
is_expired_group: false,
expired_days_limit: 7,
max_traffic_gb_expired: 0,
speed_limit: 0,
min_traffic_gb: 0,
max_traffic_gb: 0,
});
useEffect(() => {
if (open) {
setConflictError(""); // 重置冲突错误
if (initialValues) {
setValues({
name: initialValues.name || "",
description: initialValues.description || "",
sort: initialValues.sort ?? 0,
for_calculation: initialValues.for_calculation ?? true,
is_expired_group: initialValues.is_expired_group ?? false,
expired_days_limit: initialValues.expired_days_limit ?? 7,
max_traffic_gb_expired: initialValues.max_traffic_gb_expired ?? 0,
speed_limit: initialValues.speed_limit ?? 0,
min_traffic_gb: initialValues.min_traffic_gb ?? 0,
max_traffic_gb: initialValues.max_traffic_gb ?? 0,
});
} else {
setValues({
name: "",
description: "",
sort: 0,
for_calculation: true,
is_expired_group: false,
expired_days_limit: 7,
max_traffic_gb_expired: 0,
speed_limit: 0,
min_traffic_gb: 0,
max_traffic_gb: 0,
});
}
}
}, [initialValues, open]);
// 检测流量区间冲突
const checkTrafficRangeConflict = (minTraffic: number, maxTraffic: number): string => {
// 如果 min=0 且 max=0,表示不参与流量分组,跳过所有验证
if (minTraffic === 0 && maxTraffic === 0) {
return "";
}
// 验证区间有效性:min 必须 < max(除非 max=0 表示无上限)
if (minTraffic > 0 && maxTraffic > 0 && minTraffic >= maxTraffic) {
return t("invalidRange", "Min traffic must be less than max traffic");
}
// 处理 max=0 的情况,表示无上限,使用一个很大的数代替
const actualMax = maxTraffic === 0 ? Number.MAX_VALUE : maxTraffic;
// 检查与其他节点组的冲突
for (const group of allNodeGroups) {
// 跳过当前编辑的节点组
if (currentGroupId && group.id === currentGroupId) {
continue;
}
// 跳过没有设置流量区间的节点组(min=0 且 max=0 表示未配置)
const existingMin = group.min_traffic_gb ?? 0;
const existingMax = group.max_traffic_gb ?? 0;
if (existingMin === 0 && existingMax === 0) {
continue;
}
// 处理现有节点组 max=0 的情况
const actualExistingMax = existingMax === 0 ? Number.MAX_VALUE : existingMax;
// 检测区间重叠
// 两个区间 [min1, max1] 和 [min2, max2] 重叠的条件:
// max1 > min2 && max2 > min1
const hasOverlap = actualMax > existingMin && actualExistingMax > minTraffic;
if (hasOverlap) {
return t("rangeConflict", {
name: group.name,
min: existingMin.toString(),
max: existingMax === 0 ? "∞" : existingMax.toString(),
});
}
}
return "";
};
// 检测过期节点组冲突
const checkExpiredGroupConflict = async (isExpiredGroup: boolean): Promise<string> => {
if (!isExpiredGroup) {
return "";
}
// 检查是否已存在其他过期节点组
const existingExpiredGroup = allNodeGroups.find(
(group) => group.is_expired_group && group.id !== currentGroupId
);
if (existingExpiredGroup) {
return t("expiredGroupExists", `System already has an expired node group: ${existingExpiredGroup.name}`);
}
// 检查当前节点组是否被订阅商品使用
if (currentGroupId) {
try {
const { getSubscribeList } = await import("@workspace/ui/services/admin/subscribe");
const { data } = await getSubscribeList({
page: 1,
size: 1,
node_group_id: currentGroupId
});
if (data.data && data.data.total > 0) {
return t("nodeGroupUsedBySubscribe", "This node group is used as default node group in subscription products, cannot set as expired group");
}
} catch (error) {
console.error("Failed to check subscribe usage:", error);
}
}
return "";
};
// 检查是否存在其他过期节点组(用于隐藏开关)
const hasOtherExpiredGroup = allNodeGroups.some(
(group) => group.is_expired_group && group.id !== currentGroupId
);
// 当前是否是过期节点组(编辑模式下)
const isCurrentExpiredGroup = initialValues?.is_expired_group ?? false;
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
// 检测过期节点组冲突
const expiredGroupConflict = await checkExpiredGroupConflict(values.is_expired_group);
if (expiredGroupConflict) {
setConflictError(expiredGroupConflict);
return;
}
// 仅在非过期节点组时检测流量区间冲突
if (!values.is_expired_group) {
const conflict = checkTrafficRangeConflict(values.min_traffic_gb, values.max_traffic_gb);
if (conflict) {
setConflictError(conflict);
return;
}
}
setSubmitting(true);
const success = await onSubmit(values);
setSubmitting(false);
if (success) {
setOpen(false);
setConflictError("");
setValues({
name: "",
description: "",
sort: 0,
for_calculation: true,
is_expired_group: false,
expired_days_limit: 7,
max_traffic_gb_expired: 0,
speed_limit: 0,
min_traffic_gb: 0,
max_traffic_gb: 0,
});
}
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild ref={ref}>
{trigger}
</DialogTrigger>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>
{t("nodeGroupFormDescription", "Configure node group settings")}
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="name">
{t("name", "Name")} *
</Label>
<Input
id="name"
value={values.name}
onChange={(e) =>
setValues({ ...values, name: e.target.value })
}
placeholder={t("namePlaceholder", "Enter name")}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="description">
{t("description", "Description")}
</Label>
<Textarea
id="description"
value={values.description}
onChange={(e) =>
setValues({ ...values, description: e.target.value })
}
placeholder={t("descriptionPlaceholder", "Enter description")}
rows={3}
/>
</div>
<div className="space-y-2">
<Label htmlFor="sort">{t("sort", "Sort Order")}</Label>
<Input
id="sort"
type="number"
value={values.sort}
onChange={(e) =>
setValues({ ...values, sort: parseInt(e.target.value) || 0 })
}
min={0}
/>
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="for_calculation">
{t("forCalculation", "For Calculation")}
</Label>
<p className="text-sm text-muted-foreground">
{values.is_expired_group
? t("expiredGroupForCalculationDescription", "Expired-only node groups cannot participate in group calculation")
: t("forCalculationDescription", "Whether this node group participates in grouping calculation")}
</p>
</div>
<Switch
id="for_calculation"
checked={values.for_calculation}
disabled={values.is_expired_group}
onCheckedChange={(checked) =>
setValues({ ...values, for_calculation: checked })
}
/>
</div>
{/* 仅在没有其他过期节点组或当前就是过期节点组时显示 */}
{(!hasOtherExpiredGroup || isCurrentExpiredGroup) && (
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="is_expired_group">
{t("isExpiredGroup", "Expired Node Group")}
</Label>
<p className="text-sm text-muted-foreground">
{t("isExpiredGroupDescription", "Allow expired users to use limited nodes")}
</p>
</div>
<Switch
id="is_expired_group"
checked={values.is_expired_group}
onCheckedChange={async (checked) => {
setValues({
...values,
is_expired_group: checked,
for_calculation: checked ? false : values.for_calculation,
min_traffic_gb: checked ? 0 : values.min_traffic_gb,
max_traffic_gb: checked ? 0 : values.max_traffic_gb,
});
// 实时检测过期节点组冲突
const conflict = await checkExpiredGroupConflict(checked);
setConflictError(conflict);
}}
/>
</div>
)}
{values.is_expired_group && (
<>
<div className="space-y-2">
<Label htmlFor="expired_days_limit">
{t("expiredDaysLimit", "Expired Days Limit")}
</Label>
<p className="text-sm text-muted-foreground">
{t("expiredDaysLimitDescription", "Number of days after expiration that users can still access nodes")}
</p>
<Input
id="expired_days_limit"
type="number"
min={1}
value={values.expired_days_limit}
onChange={(e) =>
setValues({ ...values, expired_days_limit: parseInt(e.target.value) || 7 })
}
/>
</div>
<div className="space-y-2">
<Label htmlFor="max_traffic_gb_expired">
{t("maxTrafficGBExpired", "Max Traffic for Expired Users (GB)")}
</Label>
<p className="text-sm text-muted-foreground">
{t("maxTrafficGBExpiredDescription", "Maximum traffic allowed for expired users (0 = unlimited)")}
</p>
<Input
id="max_traffic_gb_expired"
type="number"
min={0}
value={values.max_traffic_gb_expired}
onChange={(e) =>
setValues({ ...values, max_traffic_gb_expired: parseInt(e.target.value) || 0 })
}
/>
</div>
<div className="space-y-2">
<Label htmlFor="speed_limit">
{t("speedLimit", "Speed Limit (KB/s)")}
</Label>
<Input
id="speed_limit"
type="number"
min={0}
value={values.speed_limit}
onChange={(e) =>
setValues({ ...values, speed_limit: parseInt(e.target.value) || 0 })
}
/>
</div>
</>
)}
{!values.is_expired_group && (
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label>{t("trafficRangeGB", "Traffic Range (GB)")}</Label>
</div>
<p className="text-sm text-muted-foreground">
{t("trafficRangeDescription", "Users with traffic >= Min and < Max will be assigned to this node group")}
</p>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="min_traffic_gb">{t("minTrafficGB", "Min Traffic (GB)")}</Label>
<Input
id="min_traffic_gb"
type="number"
min={0}
step={1}
value={values.min_traffic_gb}
onChange={(e) => {
const newValue = parseFloat(e.target.value) || 0;
setValues({ ...values, min_traffic_gb: newValue });
// 实时检测冲突
const conflict = checkTrafficRangeConflict(newValue, values.max_traffic_gb);
setConflictError(conflict);
}}
/>
</div>
<div className="space-y-2">
<Label htmlFor="max_traffic_gb">{t("maxTrafficGB", "Max Traffic (GB)")}</Label>
<Input
id="max_traffic_gb"
type="number"
min={0}
step={1}
value={values.max_traffic_gb}
onChange={(e) => {
const newValue = parseFloat(e.target.value) || 0;
setValues({ ...values, max_traffic_gb: newValue });
// 实时检测冲突
const conflict = checkTrafficRangeConflict(values.min_traffic_gb, newValue);
setConflictError(conflict);
}}
/>
</div>
</div>
{/* 显示冲突错误 */}
{conflictError && (
<div className="flex items-center gap-2 rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive">
<AlertCircle className="h-4 w-4 flex-shrink-0" />
<span>{conflictError}</span>
</div>
)}
</div>
)}
<div className="flex justify-end gap-2">
<button
type="button"
onClick={() => setOpen(false)}
className="rounded-md border px-4 py-2 text-sm"
disabled={submitting || loading}
>
{t("cancel", "Cancel")}
</button>
<button
type="submit"
disabled={submitting || loading || !!conflictError}
className="flex items-center gap-2 rounded-md bg-primary px-4 py-2 text-sm text-primary-foreground disabled:opacity-50"
>
{submitting && <Loader2 className="h-4 w-4 animate-spin" />}
{t("save", "Save")}
</button>
</div>
</form>
</DialogContent>
</Dialog>
);
});
NodeGroupForm.displayName = "NodeGroupForm";
export default NodeGroupForm;
@@ -0,0 +1,236 @@
"use client";
import { Badge } from "@workspace/ui/components/badge";
import { Button } from "@workspace/ui/components/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@workspace/ui/components/card";
import { ConfirmButton } from "@workspace/ui/composed/confirm-button";
import {
ProTable,
type ProTableActions,
} from "@workspace/ui/composed/pro-table/pro-table";
import {
createNodeGroup,
deleteNodeGroup,
getNodeGroupList,
updateNodeGroup,
} from "@workspace/ui/services/admin/group";
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import NodeGroupForm from "./node-group-form";
export default function NodeGroups() {
const { t } = useTranslation("group");
const [loading, setLoading] = useState(false);
const [allNodeGroups, setAllNodeGroups] = useState<API.NodeGroup[]>([]);
const ref = useRef<ProTableActions>(null);
// 获取所有节点组数据(用于冲突检测)
useEffect(() => {
const fetchAllNodeGroups = async () => {
try {
const { data } = await getNodeGroupList({ page: 1, size: 1000 });
setAllNodeGroups(data.data?.list || []);
} catch (error) {
console.error("Failed to fetch node groups:", error);
}
};
fetchAllNodeGroups();
}, []);
return (
<div className="space-y-4">
<Card>
<CardHeader>
<CardTitle>{t("nodeGroups", "Node Groups")}</CardTitle>
<CardDescription>
{t("nodeGroupsDescription", "Manage node groups for user access control")}
</CardDescription>
</CardHeader>
<CardContent>
<ProTable<API.NodeGroup, API.GetNodeGroupListRequest>
action={ref}
request={async (params) => {
const { data } = await getNodeGroupList({
page: params.page || 1,
size: params.size || 10,
});
return {
list: data.data?.list || [],
total: data.data?.total || 0,
};
}}
columns={[
{
id: "id",
accessorKey: "id",
header: t("id", "ID"),
cell: ({ row }: { row: any }) => <span className="text-muted-foreground">#{row.getValue("id")}</span>,
},
{
id: "name",
accessorKey: "name",
header: t("name", "Name"),
cell: ({ row }: { row: any }) => {
const isExpiredGroup = row.original.is_expired_group;
return (
<div className="flex items-center gap-2">
<span>{row.getValue("name")}</span>
{isExpiredGroup && (
<Badge variant="destructive">
{t("expiredGroup", "Expired")}
</Badge>
)}
</div>
);
},
},
{
id: "description",
accessorKey: "description",
header: t("description", "Description"),
cell: ({ row }: { row: any }) => row.getValue("description") || "--",
},
{
id: "for_calculation",
accessorKey: "for_calculation",
header: t("forCalculation", "For Calculation"),
cell: ({ row }: { row: any }) => {
const value = row.getValue("for_calculation");
return value ? (
<Badge variant="default">{t("yes", "Yes")}</Badge>
) : (
<Badge variant="secondary">{t("no", "No")}</Badge>
);
},
},
{
id: "traffic_range",
header: t("trafficRange", "Traffic Range (GB)"),
cell: ({ row }: { row: any }) => {
const min = row.original.min_traffic_gb;
const max = row.original.max_traffic_gb;
if (min !== undefined && max !== undefined) {
return `${min} - ${max}`;
}
if (min !== undefined) {
return `${min}`;
}
if (max !== undefined) {
return `${max}`;
}
return "--";
},
},
{
id: "sort",
accessorKey: "sort",
header: t("sort", "Sort"),
},
]}
actions={{
render: (row: any) => [
<NodeGroupForm
key={`edit-${row.id}`}
initialValues={row}
allNodeGroups={allNodeGroups}
currentGroupId={row.id}
loading={loading}
onSubmit={async (values) => {
setLoading(true);
try {
await updateNodeGroup({
id: row.id,
...values,
} as API.UpdateNodeGroupRequest);
toast.success(t("updated", "Updated successfully"));
// 刷新节点组列表
const { data } = await getNodeGroupList({ page: 1, size: 1000 });
setAllNodeGroups(data.data?.list || []);
ref.current?.refresh();
setLoading(false);
return true;
} catch {
setLoading(false);
return false;
}
}}
title={t("editNodeGroup", "Edit Node Group")}
trigger={
<Button variant="outline" size="sm">
{t("edit", "Edit")}
</Button>
}
/>,
<ConfirmButton
key="delete"
cancelText={t("cancel", "Cancel")}
confirmText={t("confirm", "Confirm")}
description={t(
"deleteNodeGroupConfirm",
"This will delete the node group. Nodes in this group will be reassigned."
)}
onConfirm={async () => {
await deleteNodeGroup({ id: row.id });
toast.success(t("deleted", "Deleted successfully"));
// 刷新节点组列表
const { data } = await getNodeGroupList({ page: 1, size: 1000 });
setAllNodeGroups(data.data?.list || []);
ref.current?.refresh();
setLoading(false);
}}
title={t("confirmDelete", "Confirm Delete")}
trigger={
<Button variant="destructive" size="sm">
{t("delete", "Delete")}
</Button>
}
/>,
],
}}
header={{
title: t("nodeGroups", "Node Groups"),
toolbar: (
<NodeGroupForm
key="create"
initialValues={undefined}
allNodeGroups={allNodeGroups}
currentGroupId={undefined}
loading={loading}
onSubmit={async (values) => {
setLoading(true);
try {
await createNodeGroup(values as API.CreateNodeGroupRequest);
toast.success(t("created", "Created successfully"));
// 刷新节点组列表
const { data } = await getNodeGroupList({ page: 1, size: 1000 });
setAllNodeGroups(data.data?.list || []);
ref.current?.refresh();
setLoading(false);
return true;
} catch {
setLoading(false);
return false;
}
}}
title={t("createNodeGroup", "Create Node Group")}
trigger={
<Button>
{t("create", "Create")}
</Button>
}
/>
),
}}
/>
</CardContent>
</Card>
</div>
);
}
@@ -0,0 +1,260 @@
"use client";
import { Badge } from "@workspace/ui/components/badge";
import { Button } from "@workspace/ui/components/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@workspace/ui/components/card";
import {
Table,
TableBody,
TableCell,
TableRow,
} from "@workspace/ui/components/table";
import { Loader2 } from "lucide-react";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { useQuery } from "@tanstack/react-query";
import {
getRecalculationStatus,
recalculateGroup,
getSubscribeGroupMapping,
} from "@workspace/ui/services/admin/group";
interface SubscribeGroupMapping {
subscribe_name: string;
node_group_name: string;
}
export default function SubscribeModeTab() {
const { t } = useTranslation("group");
const [recalculating, setRecalculating] = useState(false);
const [loadingStatus, setLoadingStatus] = useState(false);
const [status, setStatus] = useState<{
state: string;
progress: number;
total: number;
} | null>(null);
// Fetch subscribe group mapping
const { data: mappingData, isLoading: mappingLoading } = useQuery({
queryKey: ["subscribeGroupMapping"],
queryFn: async () => {
const { data } = await getSubscribeGroupMapping();
return (data.data?.list || []) as SubscribeGroupMapping[];
},
});
const loadStatus = async () => {
setLoadingStatus(true);
try {
const { data } = await getRecalculationStatus();
if (data.data) {
setStatus(data.data);
}
} catch (error) {
console.error("Failed to load recalculation status:", error);
} finally {
setLoadingStatus(false);
}
};
useEffect(() => {
loadStatus();
}, []);
useEffect(() => {
const interval = setInterval(() => {
if (status?.state === "running") {
loadStatus();
}
}, 2000);
return () => clearInterval(interval);
}, [status?.state]);
const handleRecalculate = async () => {
setRecalculating(true);
try {
await recalculateGroup({ mode: "subscribe" });
toast.success(t("recalculationStarted", "Recalculation started"));
loadStatus();
} catch (error) {
console.error("Failed to start recalculation:", error);
toast.error(t("recalculationFailed", "Failed to start recalculation"));
} finally {
setRecalculating(false);
}
};
const getStateLabel = (state: string) => {
switch (state) {
case "running":
return t("running", "Running");
case "completed":
return t("completed", "Completed");
case "failed":
return t("failed", "Failed");
default:
return t("idle", "Idle");
}
};
const getStateVariant = (state: string) => {
switch (state) {
case "running":
return "default";
case "completed":
return "secondary";
case "failed":
return "destructive";
default:
return "outline";
}
};
return (
<div className="space-y-4">
{/* Configuration Card */}
<Card>
<CardHeader>
<CardTitle>{t("subscribeModeConfig", "Subscribe Mode Configuration")}</CardTitle>
<CardDescription>
{t("subscribeModeDescription", "Group users by their purchased subscription plan")}
</CardDescription>
</CardHeader>
</Card>
{/* Subscribe Group Mapping Card */}
<Card>
<CardHeader>
<CardTitle>{t("subscribeGroupMappingTitle", "套餐-节点组对应关系")}</CardTitle>
</CardHeader>
<CardContent>
{mappingLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : (
<Table>
<TableBody>
{mappingData && mappingData.length > 0 ? (
mappingData
.filter(
(item: SubscribeGroupMapping) => item.subscribe_name && item.node_group_name
)
.map((item: SubscribeGroupMapping, index: number) => (
<TableRow key={index}>
<TableCell>
<span className="font-medium">{item.subscribe_name}</span>
<span className="mx-2 text-muted-foreground">
{t("arrow", "→")}
</span>
<Badge variant="outline">{item.node_group_name}</Badge>
</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell className="text-center text-muted-foreground">
{t("noMappingData", "No mapping data available")}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
)}
</CardContent>
</Card>
{/* Recalculation Card */}
<Card>
<CardHeader>
<CardTitle>{t("groupRecalculation", "Group Recalculation")}</CardTitle>
<CardDescription>
{t(
"groupRecalculationDescription",
"Manually trigger a full recalculation of all user groups based on current configuration"
)}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* Current Status */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">
{t("currentStatus", "Current Status")}
</span>
{loadingStatus ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : status ? (
<Badge variant={getStateVariant(status.state) as any}>
{getStateLabel(status.state)}
</Badge>
) : null}
</div>
{status?.state === "running" && (
<div className="space-y-2">
<div className="flex items-center justify-between text-sm">
<span>{t("progress", "Progress")}</span>
<span>
{status.progress} / {status.total || 0}
</span>
</div>
<div className="h-2 overflow-hidden rounded-full bg-muted">
<div
className="h-full bg-primary transition-all"
style={{
width: `${status.total > 0 ? (status.progress / status.total) * 100 : 0}%`,
}}
/>
</div>
</div>
)}
{status?.state === "completed" && (
<div className="text-sm text-muted-foreground">
{t("recalculationCompleted", "Recalculation completed successfully")}
</div>
)}
{status?.state === "failed" && (
<div className="text-sm text-destructive">
{t("recalculationFailed", "Recalculation failed. Please try again.")}
</div>
)}
</div>
{/* Recalculate Button */}
<div className="flex justify-end">
<Button
onClick={handleRecalculate}
disabled={recalculating || status?.state === "running"}
>
{recalculating && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
{t("recalculateAll", "Recalculate All Users")}
</Button>
</div>
{/* Warning */}
<div className="rounded-md bg-yellow-50 p-4 text-sm text-yellow-800 dark:bg-yellow-900/20 dark:text-yellow-400">
<strong>{t("warning", "Warning")}:</strong>{" "}
{t(
"recalculationWarning",
"Recalculation will reassign all users to new groups based on current configuration. This operation cannot be undone."
)}
</div>
</CardContent>
</Card>
</div>
);
}
@@ -0,0 +1,228 @@
"use client";
import { Badge } from "@workspace/ui/components/badge";
import { Button } from "@workspace/ui/components/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@workspace/ui/components/card";
import { Loader2 } from "lucide-react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { useQuery } from "@tanstack/react-query";
import {
getNodeGroupList,
updateNodeGroup,
getRecalculationStatus,
recalculateGroup,
} from "@workspace/ui/services/admin/group";
import TrafficRangeConfig from "./traffic-ranges-config";
export default function TrafficModeTab() {
const { t } = useTranslation("group");
const [recalculating, setRecalculating] = useState(false);
const [loadingStatus, setLoadingStatus] = useState(false);
const [status, setStatus] = useState<{
state: string;
progress: number;
total: number;
} | null>(null);
// Fetch node groups
const { data: nodeGroupsData, isLoading: isLoadingNodeGroups, refetch: refetchNodeGroups } = useQuery({
queryKey: ["nodeGroups"],
queryFn: async () => {
const { data } = await getNodeGroupList({ page: 1, size: 1000 });
return data.data?.list || [];
},
});
const loadStatus = async () => {
setLoadingStatus(true);
try {
const { data } = await getRecalculationStatus();
if (data.data) {
setStatus(data.data);
}
} catch (error) {
console.error("Failed to load recalculation status:", error);
} finally {
setLoadingStatus(false);
}
};
const handleTrafficUpdate = async (nodeGroupId: number, fields: { min_traffic_gb?: number; max_traffic_gb?: number }) => {
try {
await updateNodeGroup({
id: nodeGroupId,
...fields,
});
toast.success(t("configSaved", "Configuration saved successfully"));
// Refetch node groups to get updated data
refetchNodeGroups();
} catch (error) {
console.error("Failed to update node group:", error);
toast.error(t("saveFailed", "Failed to save configuration"));
}
};
const handleRecalculate = async () => {
setRecalculating(true);
try {
await recalculateGroup({ mode: "traffic" });
toast.success(t("recalculationStarted", "Recalculation started"));
loadStatus();
} catch (error) {
console.error("Failed to start recalculation:", error);
toast.error(t("recalculationFailed", "Failed to start recalculation"));
} finally {
setRecalculating(false);
}
};
const getStateLabel = (state: string) => {
switch (state) {
case "running":
return t("running", "Running");
case "completed":
return t("completed", "Completed");
case "failed":
return t("failed", "Failed");
default:
return t("idle", "Idle");
}
};
const getStateVariant = (state: string) => {
switch (state) {
case "running":
return "default";
case "completed":
return "secondary";
case "failed":
return "destructive";
default:
return "outline";
}
};
return (
<div className="space-y-4">
{/* Node Groups Traffic Configuration Card */}
<Card>
<CardHeader>
<CardTitle>{t("trafficModeConfig", "Traffic Mode Configuration")}</CardTitle>
<CardDescription>
{t(
"trafficModeDescription",
"Configure traffic ranges for node groups. Users will be assigned to node groups based on their traffic usage."
)}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{isLoadingNodeGroups ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
<span className="ml-2 text-sm text-muted-foreground">
{t("loading", "Loading...")}
</span>
</div>
) : (
<TrafficRangeConfig
nodeGroups={nodeGroupsData || []}
onTrafficUpdate={handleTrafficUpdate}
/>
)}
</CardContent>
</Card>
{/* Recalculation Card */}
<Card>
<CardHeader>
<CardTitle>{t("groupRecalculation", "Group Recalculation")}</CardTitle>
<CardDescription>
{t(
"groupRecalculationDescription",
"Manually trigger a full recalculation of all user groups based on current configuration"
)}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* Current Status */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">
{t("currentStatus", "Current Status")}
</span>
{loadingStatus ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : status ? (
<Badge variant={getStateVariant(status.state) as any}>
{getStateLabel(status.state)}
</Badge>
) : null}
</div>
{status?.state === "running" && (
<div className="space-y-2">
<div className="flex items-center justify-between text-sm">
<span>{t("progress", "Progress")}</span>
<span>
{status.progress} / {status.total || 0}
</span>
</div>
<div className="h-2 overflow-hidden rounded-full bg-muted">
<div
className="h-full bg-primary transition-all"
style={{
width: `${status.total > 0 ? (status.progress / status.total) * 100 : 0}%`,
}}
/>
</div>
</div>
)}
{status?.state === "completed" && (
<div className="text-sm text-muted-foreground">
{t("recalculationCompleted", "Recalculation completed successfully")}
</div>
)}
{status?.state === "failed" && (
<div className="text-sm text-destructive">
{t("recalculationFailed", "Recalculation failed. Please try again.")}
</div>
)}
</div>
{/* Recalculate Button */}
<div className="flex justify-end">
<Button
onClick={handleRecalculate}
disabled={recalculating || status?.state === "running"}
>
{recalculating && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
{t("recalculateAll", "Recalculate All Users")}
</Button>
</div>
{/* Warning */}
<div className="rounded-md bg-yellow-50 p-4 text-sm text-yellow-800 dark:bg-yellow-900/20 dark:text-yellow-400">
<strong>{t("warning", "Warning")}:</strong>{" "}
{t(
"recalculationWarning",
"Recalculation will reassign all users to new groups based on current configuration. This operation cannot be undone."
)}
</div>
</CardContent>
</Card>
</div>
);
}
@@ -0,0 +1,247 @@
"use client";
import { Input } from "@workspace/ui/components/input";
import { Loader2 } from "lucide-react";
import { useTranslation } from "react-i18next";
import { useState } from "react";
import { toast } from "sonner";
interface NodeGroup {
id: number;
name: string;
min_traffic_gb?: number;
max_traffic_gb?: number;
}
interface TrafficRangeConfigProps {
nodeGroups: NodeGroup[];
onTrafficUpdate: (nodeGroupId: number, fields: { min_traffic_gb?: number; max_traffic_gb?: number }) => Promise<void>;
}
interface UpdatingNode {
nodeGroupId: number;
field: 'min_traffic_gb' | 'max_traffic_gb';
}
interface NodeGroupTempValues {
min_traffic_gb?: number;
max_traffic_gb?: number;
}
export default function TrafficRangeConfig({ nodeGroups, onTrafficUpdate }: TrafficRangeConfigProps) {
const { t } = useTranslation("group");
const [updatingNodes, setUpdatingNodes] = useState<UpdatingNode[]>([]);
// 使用对象存储每个节点组的临时值
const [temporaryValues, setTemporaryValues] = useState<Record<number, NodeGroupTempValues>>({});
// Get the display value (temporary or actual)
const getDisplayValue = (nodeGroupId: number, field: 'min_traffic_gb' | 'max_traffic_gb'): number => {
const temp = temporaryValues[nodeGroupId];
if (temp && temp[field] !== undefined) {
return temp[field]!;
}
const nodeGroup = nodeGroups.find(ng => ng.id === nodeGroupId);
return field === 'min_traffic_gb' ? (nodeGroup?.min_traffic_gb ?? 0) : (nodeGroup?.max_traffic_gb ?? 0);
};
// Validate traffic ranges: no overlaps
const validateTrafficRange = (
nodeGroupId: number,
minTraffic: number,
maxTraffic: number
): { valid: boolean; error?: string } => {
// 如果 min=0 且 max=0,表示不参与流量分组,跳过验证
if (minTraffic === 0 && maxTraffic === 0) {
return { valid: true };
}
// Check if min >= max (both > 0)
if (minTraffic > 0 && maxTraffic > 0 && minTraffic >= maxTraffic) {
return { valid: false, error: t("minCannotExceedMax", "Minimum traffic cannot exceed maximum traffic") };
}
// Check for overlaps with other node groups
const otherGroups = nodeGroups
.filter(ng => ng.id !== nodeGroupId)
.map(ng => {
const temp = temporaryValues[ng.id];
return {
id: ng.id,
name: ng.name,
min: temp?.min_traffic_gb !== undefined ? temp.min_traffic_gb : (ng.min_traffic_gb ?? 0),
max: temp?.max_traffic_gb !== undefined ? temp.max_traffic_gb : (ng.max_traffic_gb ?? 0),
};
})
.filter(ng => !(ng.min === 0 && ng.max === 0)) // 跳过未配置流量区间的组
.sort((a, b) => a.min - b.min);
for (const other of otherGroups) {
// Handle max=0 as no limit (infinity)
const otherMax = other.max === 0 ? Number.MAX_VALUE : other.max;
const currentMax = maxTraffic === 0 ? Number.MAX_VALUE : maxTraffic;
// Check for overlap: two ranges [min1, max1] and [min2, max2] overlap if:
// max1 > min2 && max2 > min1
if (currentMax > other.min && otherMax > minTraffic) {
return {
valid: false,
error: t("rangeOverlap", "Range overlaps with node group \"{{name}}\"", { name: other.name })
};
}
}
return { valid: true };
};
const handleTrafficBlur = async (nodeGroupId: number) => {
const nodeGroup = nodeGroups.find(ng => ng.id === nodeGroupId);
if (!nodeGroup) return;
const tempValues = temporaryValues[nodeGroupId];
if (!tempValues) return;
// 获取当前的临时值或实际值
const currentMin = tempValues.min_traffic_gb !== undefined
? tempValues.min_traffic_gb
: (nodeGroup.min_traffic_gb ?? 0);
const currentMax = tempValues.max_traffic_gb !== undefined
? tempValues.max_traffic_gb
: (nodeGroup.max_traffic_gb ?? 0);
// 只要有一个字段被修改了就保存
const hasMinChange = tempValues.min_traffic_gb !== undefined;
const hasMaxChange = tempValues.max_traffic_gb !== undefined;
if (!hasMinChange && !hasMaxChange) {
return;
}
// 验证
const validation = validateTrafficRange(nodeGroupId, currentMin, currentMax);
if (!validation.valid) {
toast.error(validation.error || t("validationFailed", "Validation failed"));
return;
}
// 检查值是否真的改变了
const originalMin = nodeGroup.min_traffic_gb ?? 0;
const originalMax = nodeGroup.max_traffic_gb ?? 0;
if (currentMin === originalMin && currentMax === originalMax) {
return;
}
// 标记为更新中(只标记被修改的字段)
if (hasMinChange) {
setUpdatingNodes(prev => [...prev, { nodeGroupId, field: 'min_traffic_gb' }]);
}
if (hasMaxChange) {
setUpdatingNodes(prev => [...prev, { nodeGroupId, field: 'max_traffic_gb' }]);
}
try {
// 一次性传递两个字段
const fieldsToUpdate: { min_traffic_gb?: number; max_traffic_gb?: number } = {};
if (currentMin !== originalMin) {
fieldsToUpdate.min_traffic_gb = currentMin;
}
if (currentMax !== originalMax) {
fieldsToUpdate.max_traffic_gb = currentMax;
}
if (Object.keys(fieldsToUpdate).length > 0) {
await onTrafficUpdate(nodeGroupId, fieldsToUpdate);
}
} finally {
// 移除更新状态
setUpdatingNodes(prev => prev.filter(u => !(u.nodeGroupId === nodeGroupId)));
}
};
const isUpdating = (nodeGroupId: number) => {
return updatingNodes.some(u => u.nodeGroupId === nodeGroupId);
};
return (
<>
<div className="space-y-2">
<div className="grid grid-cols-12 gap-2 text-sm font-medium text-muted-foreground">
<div className="col-span-6">{t("nodeGroup", "Node Group")}</div>
<div className="col-span-3">{t("minTrafficGB", "Min (GB)")}</div>
<div className="col-span-3">{t("maxTrafficGB", "Max (GB)")}</div>
</div>
{nodeGroups.map((nodeGroup) => (
<div key={nodeGroup.id} className="grid grid-cols-12 gap-2 items-center">
<div className="col-span-6">
<div className="font-medium">{nodeGroup.name}</div>
<div className="text-xs text-muted-foreground">{t("id", "ID")}: {nodeGroup.id}</div>
</div>
<div className="col-span-3 relative">
<Input
type="number"
min={0}
step={1}
placeholder="0"
value={getDisplayValue(nodeGroup.id, "min_traffic_gb")}
onChange={(e) => {
const newValue = parseFloat(e.target.value) || 0;
// 更新临时状态
setTemporaryValues(prev => ({
...prev,
[nodeGroup.id]: {
...prev[nodeGroup.id],
min_traffic_gb: newValue,
max_traffic_gb: prev[nodeGroup.id]?.max_traffic_gb,
},
}));
}}
onBlur={() => handleTrafficBlur(nodeGroup.id)}
disabled={isUpdating(nodeGroup.id)}
/>
{isUpdating(nodeGroup.id) && (
<div className="absolute right-2 top-1/2 -translate-y-1/2">
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
</div>
)}
</div>
<div className="col-span-3 relative">
<Input
type="number"
min={0}
step={1}
placeholder="0"
value={getDisplayValue(nodeGroup.id, "max_traffic_gb")}
onChange={(e) => {
const newValue = parseFloat(e.target.value) || 0;
// 更新临时状态
setTemporaryValues(prev => ({
...prev,
[nodeGroup.id]: {
...prev[nodeGroup.id],
min_traffic_gb: prev[nodeGroup.id]?.min_traffic_gb,
max_traffic_gb: newValue,
},
}));
}}
onBlur={() => handleTrafficBlur(nodeGroup.id)}
disabled={isUpdating(nodeGroup.id)}
/>
{isUpdating(nodeGroup.id) && (
<div className="absolute right-2 top-1/2 -translate-y-1/2">
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
</div>
)}
</div>
</div>
))}
</div>
<div className="rounded-md bg-muted p-4 text-sm text-muted-foreground">
<strong>{t("note", "Note")}:</strong>{" "}
{t(
"trafficRangesNote",
"Set traffic ranges for each node group. Users will be assigned to node groups based on their traffic usage. Leave both values as 0 to not use this node group for traffic-based assignment."
)}
</div>
</>
);
}
@@ -25,14 +25,12 @@ export default function ResetSubscribeLogPage() {
const initialFilters = {
date: sp.date || today,
user_subscribe_id: sp.user_subscribe_id
? Number(sp.user_subscribe_id)
: undefined,
user_subscribe_id: sp.user_subscribe_id || undefined,
};
return (
<ProTable<
API.ResetSubscribeLog,
{ date?: string; user_subscribe_id?: number }
{ date?: string; user_subscribe_id?: string }
>
columns={[
{
@@ -83,7 +81,7 @@ export default function ResetSubscribeLogPage() {
page: pagination.page,
size: pagination.size,
date: (filter as any)?.date,
user_subscribe_id: (filter as any)?.user_subscribe_id,
user_subscribe_id: (filter as any)?.user_subscribe_id ? Number((filter as any)?.user_subscribe_id) : undefined,
});
const list = (data?.data?.list || []) as any[];
const total = Number(data?.data?.total || list.length);
@@ -17,14 +17,12 @@ export default function SubscribeTrafficLogPage() {
const initialFilters = {
date: sp.date || today,
user_id: sp.user_id ? Number(sp.user_id) : undefined,
user_subscribe_id: sp.user_subscribe_id
? Number(sp.user_subscribe_id)
: undefined,
user_subscribe_id: sp.user_subscribe_id || undefined,
};
return (
<ProTable<
API.UserSubscribeTrafficLog,
{ date?: string; user_id?: number; user_subscribe_id?: number }
{ date?: string; user_id?: number; user_subscribe_id?: string }
>
actions={{
render: (row) => [
@@ -95,7 +93,7 @@ export default function SubscribeTrafficLogPage() {
size: pagination.size,
date: (filter as any)?.date,
user_id: (filter as any)?.user_id,
user_subscribe_id: (filter as any)?.user_subscribe_id,
user_subscribe_id: (filter as any)?.user_subscribe_id ? Number((filter as any)?.user_subscribe_id) : undefined,
});
const list =
((data?.data?.list || []) as API.UserSubscribeTrafficLog[]) || [];
@@ -23,12 +23,10 @@ export default function SubscribeLogPage() {
const initialFilters = {
date: sp.date || today,
user_id: sp.user_id ? Number(sp.user_id) : undefined,
user_subscribe_id: sp.user_subscribe_id
? Number(sp.user_subscribe_id)
: undefined,
user_subscribe_id: sp.user_subscribe_id || undefined,
};
return (
<ProTable<API.SubscribeLog, { date?: string; user_id?: number }>
<ProTable<API.SubscribeLog, { date?: string; user_id?: number; user_subscribe_id?: string }>
columns={[
{
accessorKey: "user",
@@ -96,7 +94,7 @@ export default function SubscribeLogPage() {
size: pagination.size,
date: (filter as any)?.date,
user_id: (filter as any)?.user_id,
user_subscribe_id: (filter as any)?.user_subscribe_id,
user_subscribe_id: (filter as any)?.user_subscribe_id ? Number((filter as any)?.user_subscribe_id) : undefined,
});
const list = (data?.data?.list || []) as any[];
const total = Number(data?.data?.total || list.length);
+156 -64
View File
@@ -16,7 +16,9 @@ import {
toggleNodeStatus,
updateNode,
} from "@workspace/ui/services/admin/server";
import { useRef, useState } from "react";
import { getGroupConfig, getNodeGroupList } from "@workspace/ui/services/admin/group";
import { useQuery } from "@tanstack/react-query";
import { useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { useNode } from "@/stores/node";
@@ -32,13 +34,132 @@ export default function Nodes() {
const { getServerName, getServerAddress, getProtocolPort } = useServer();
const { fetchNodes, fetchTags } = useNode();
// Fetch node groups for display
const { data: nodeGroupsData } = useQuery({
queryKey: ["nodeGroups"],
queryFn: async () => {
const { data } = await getNodeGroupList({ page: 1, size: 1000 });
return data.data?.list || [];
},
});
// Fetch group config to check if group feature is enabled
const { data: groupConfigData } = useQuery({
queryKey: ["groupConfig"],
queryFn: async () => {
const { data } = await getGroupConfig();
return data.data;
},
});
const isGroupEnabled = groupConfigData?.enabled || false;
// Dynamic columns based on group feature status
const columns = useMemo(() => {
const baseColumns = [
{
id: "enabled",
header: t("enabled", "Enabled"),
cell: ({ row }: { row: any }) => (
<Switch
checked={row.original.enabled}
onCheckedChange={async (v) => {
await toggleNodeStatus({ id: row.original.id, enable: v });
toast.success(
v ? t("enabled_on", "Enabled") : t("enabled_off", "Disabled")
);
ref.current?.refresh();
fetchNodes();
fetchTags();
}}
/>
),
},
{
id: "name",
accessorKey: "name",
header: t("name", "Name"),
},
{
id: "address_port",
header: `${t("address", "Address")}:${t("port", "Port")}`,
cell: ({ row }: { row: any }) =>
`${row.original.address || "—"}:${row.original.port || "—"}`,
},
{
id: "server_id",
header: t("server", "Server"),
cell: ({ row }: { row: any }) =>
`${getServerName(row.original.server_id)}:${getServerAddress(row.original.server_id)}`,
},
{
id: "protocol",
header: ` ${t("protocol", "Protocol")}:${t("port", "Port")}`,
cell: ({ row }: { row: any }) =>
`${row.original.protocol}:${getProtocolPort(row.original.server_id, row.original.protocol)}`,
},
{
id: "tags",
header: t("tags", "Tags"),
cell: ({ row }: { row: any }) => (
<div className="flex flex-wrap gap-1">
{(row.original.tags || []).length === 0
? "—"
: row.original.tags.map((tg: string) => (
<Badge key={tg} variant="outline">
{tg}
</Badge>
))}
</div>
),
},
];
// Add Node Groups column when group feature is enabled
if (isGroupEnabled) {
baseColumns.push({
id: "node_group_ids",
header: t("nodeGroups", "Node Groups"),
cell: ({ row }: { row: any }) => {
const groupIds = row.original.node_group_ids as number[] || [];
// Public node indicator (when node_group_ids is empty)
if (groupIds.length === 0) {
return (
<div className="flex items-center gap-2">
<Badge variant="secondary" className="text-xs">
{t("public", "Public")}
</Badge>
</div>
);
}
return (
<div className="flex flex-wrap gap-1">
{groupIds.map((groupId) => {
const group = nodeGroupsData?.find((g) => g.id === groupId);
return (
<Badge key={groupId} variant="outline">
{group?.name || String(groupId)}
</Badge>
);
})}
</div>
);
},
});
}
return baseColumns;
}, [isGroupEnabled, nodeGroupsData, t, getServerName, getServerAddress, getProtocolPort]);
return (
<ProTable<API.Node, { search: string }>
<ProTable<API.Node, { search: string; node_group_id?: number }>
action={ref}
actions={{
render: (row) => [
<NodeForm
initialValues={row}
initialValues={row as any}
key="edit"
loading={loading}
onSubmit={async (values) => {
@@ -47,6 +168,7 @@ export default function Nodes() {
const body: API.UpdateNodeRequest = {
...row,
...values,
node_group_ids: values.node_group_ids?.map((id: string | number) => Number(id)) || [],
} as any;
await updateNode(body);
toast.success(t("updated", "Updated"));
@@ -135,62 +257,7 @@ export default function Nodes() {
];
},
}}
columns={[
{
id: "enabled",
header: t("enabled", "Enabled"),
cell: ({ row }) => (
<Switch
checked={row.original.enabled}
onCheckedChange={async (v) => {
await toggleNodeStatus({ id: row.original.id, enable: v });
toast.success(
v ? t("enabled_on", "Enabled") : t("enabled_off", "Disabled")
);
ref.current?.refresh();
fetchNodes();
fetchTags();
}}
/>
),
},
{ accessorKey: "name", header: t("name", "Name") },
{
id: "address_port",
header: `${t("address", "Address")}:${t("port", "Port")}`,
cell: ({ row }) =>
`${row.original.address || "—"}:${row.original.port || "—"}`,
},
{
id: "server_id",
header: t("server", "Server"),
cell: ({ row }) =>
`${getServerName(row.original.server_id)}:${getServerAddress(row.original.server_id)}`,
},
{
id: "protocol",
header: ` ${t("protocol", "Protocol")}:${t("port", "Port")}`,
cell: ({ row }) =>
`${row.original.protocol}:${getProtocolPort(row.original.server_id, row.original.protocol)}`,
},
{
accessorKey: "tags",
header: t("tags", "Tags"),
cell: ({ row }) => (
<div className="flex flex-wrap gap-1">
{(row.original.tags || []).length === 0
? "—"
: row.original.tags.map((tg) => (
<Badge key={tg} variant="outline">
{tg}
</Badge>
))}
</div>
),
},
]}
columns={columns}
header={{
title: t("pageTitle", "Nodes"),
toolbar: (
@@ -199,15 +266,18 @@ export default function Nodes() {
onSubmit={async (values) => {
setLoading(true);
try {
const body: API.CreateNodeRequest = {
const body: any = {
name: values.name,
server_id: Number(values.server_id!),
protocol: values.protocol,
address: values.address,
port: Number(values.port!),
tags: values.tags || [],
enabled: false,
};
// Add node_group_ids if it exists
if (values.node_group_ids) {
body.node_group_ids = values.node_group_ids.map((id: string | number) => Number(id));
}
await createNode(body);
toast.success(t("created", "Created"));
ref.current?.refresh();
@@ -277,13 +347,35 @@ export default function Nodes() {
return updatedItems;
}}
params={[{ key: "search" }]}
params={[
{
key: "search",
},
...(isGroupEnabled
? [
{
key: "node_group_id",
placeholder: t("nodeGroups", "Node Groups"),
options: [
{ label: t("all", "All"), value: "" },
...(nodeGroupsData?.map((item) => ({
label: item.name,
value: String(item.id),
})) || []),
],
},
]
: []),
]}
request={async (pagination, filter) => {
const { data } = await filterNodeList({
const filters = {
page: pagination.page,
size: pagination.size,
search: filter?.search || undefined,
});
node_group_id: filter?.node_group_id ? Number(filter.node_group_id) : undefined,
};
const { data } = await filterNodeList(filters);
const rawList = (data?.data?.list || []) as API.Node[];
// Backend should ideally return nodes already sorted, but we also sort on the
// frontend to keep the UI stable (and avoid "random" order after refresh).
+115 -8
View File
@@ -2,6 +2,7 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { Button } from "@workspace/ui/components/button";
import { Checkbox } from "@workspace/ui/components/checkbox";
import {
Form,
FormControl,
@@ -11,6 +12,7 @@ import {
FormLabel,
FormMessage,
} from "@workspace/ui/components/form";
import { Label } from "@workspace/ui/components/label";
import { ScrollArea } from "@workspace/ui/components/scroll-area";
import {
Sheet,
@@ -23,6 +25,8 @@ import {
import { Combobox } from "@workspace/ui/composed/combobox";
import { EnhancedInput } from "@workspace/ui/composed/enhanced-input";
import TagInput from "@workspace/ui/composed/tag-input";
import { getGroupConfig, getNodeGroupList } from "@workspace/ui/services/admin/group";
import { useQuery } from "@tanstack/react-query";
import type { TFunction } from "i18next";
import { useEffect, useMemo, useState } from "react";
import { useForm } from "react-hook-form";
@@ -54,7 +58,7 @@ const buildSchema = (t: TFunction) =>
server_id: z
.number({ message: t("errors.serverRequired", "Please select a server") })
.int()
.gt(0, t("errors.serverRequired", "Please select a server"))
.positive(t("errors.serverRequired", "Please select a server"))
.optional(),
protocol: z
.string()
@@ -71,6 +75,7 @@ const buildSchema = (t: TFunction) =>
.min(1, t("errors.portRange", "Port must be between 1 and 65535"))
.max(65_535, t("errors.portRange", "Port must be between 1 and 65535")),
tags: z.array(z.string()),
node_group_ids: z.optional(z.array(z.string()).default([])),
});
export type NodeFormValues = z.infer<ReturnType<typeof buildSchema>>;
@@ -112,8 +117,10 @@ export default function NodeForm(props: {
address: "",
port: 0,
tags: [],
node_group_ids: [],
...initialValues,
},
mode: "onSubmit", // Only validate on form submission
});
const serverId = form.watch("server_id");
@@ -125,17 +132,54 @@ export default function NodeForm(props: {
const availableProtocols = getAvailableProtocols(serverId);
// Fetch node groups
const { data: nodeGroupsData } = useQuery({
queryKey: ["nodeGroups"],
queryFn: async () => {
const { data } = await getNodeGroupList({ page: 1, size: 1000 });
return data.data?.list || [];
},
});
// Fetch group config to check if group feature is enabled
const { data: groupConfigData } = useQuery({
queryKey: ["groupConfig"],
queryFn: async () => {
const { data } = await getGroupConfig();
return data.data;
},
});
const isGroupEnabled = groupConfigData?.enabled || false;
useEffect(() => {
if (initialValues) {
form.reset({
const resetValues: NodeFormValues = {
name: "",
server_id: undefined,
protocol: "",
address: "",
port: 0,
tags: [],
...initialValues,
});
node_group_ids: [],
};
// Copy only the values we need from initialValues
if (initialValues.name) resetValues.name = initialValues.name;
if (initialValues.server_id) resetValues.server_id = initialValues.server_id;
if (initialValues.protocol) resetValues.protocol = initialValues.protocol;
if (initialValues.address) resetValues.address = initialValues.address;
if (initialValues.port) resetValues.port = initialValues.port;
if (initialValues.tags) resetValues.tags = initialValues.tags;
// Convert node_group_ids from number[] to string[], ensure it's always an array
if (initialValues.node_group_ids && Array.isArray(initialValues.node_group_ids)) {
resetValues.node_group_ids = initialValues.node_group_ids.map((id: string | number) => String(id));
} else {
resetValues.node_group_ids = [];
}
form.reset(resetValues);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [initialValues]);
@@ -360,6 +404,7 @@ export default function NodeForm(props: {
</FormItem>
)}
/>
{/* Tags field - always shown */}
<FormField
control={form.control}
name="tags"
@@ -378,15 +423,77 @@ export default function NodeForm(props: {
/>
</FormControl>
<FormDescription>
{t(
"tags_description",
"Permission grouping tag (incl. plan binding and delivery policies)."
)}
{isGroupEnabled
? t(
"tags_groupMode_description",
"Optional tags for display and filtering (node group will be used as tag if empty)."
)
: t(
"tags_description",
"Permission grouping tag (incl. plan binding and delivery policies)."
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{/* Show Node Group field only when group feature is enabled */}
{isGroupEnabled && (
<FormField
control={form.control}
name="node_group_ids"
render={({ field }) => (
<FormItem>
<FormLabel>{t("nodeGroup", "Node Group")}</FormLabel>
<FormControl>
<div className="grid grid-cols-2 gap-2">
{nodeGroupsData?.map((g) => (
<div
key={g.id}
className="flex items-center space-x-2"
>
<Checkbox
id={`node-group-${g.id}`}
checked={field.value?.includes(String(g.id)) || false}
onCheckedChange={(checked) => {
// Ensure field.value is always an array
const currentValue = Array.isArray(field.value) ? field.value : [];
if (checked) {
const newValue = [...currentValue, String(g.id)];
form.setValue(field.name, newValue, {
shouldValidate: true,
shouldDirty: true,
});
} else {
const newValue = currentValue.filter((v: string) => v !== String(g.id));
form.setValue(field.name, newValue, {
shouldValidate: true,
shouldDirty: true,
});
}
}}
/>
<Label
htmlFor={`node-group-${g.id}`}
className="cursor-pointer"
>
{g.name}
</Label>
</div>
))}
</div>
</FormControl>
<FormDescription>
{t(
"nodeGroup_description",
"Assign this node to multiple groups for user access control."
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
)}
</form>
</Form>
</ScrollArea>
@@ -8,6 +8,7 @@ import {
AccordionTrigger,
} from "@workspace/ui/components/accordion";
import { Button } from "@workspace/ui/components/button";
import { Card } from "@workspace/ui/components/card";
import { Checkbox } from "@workspace/ui/components/checkbox";
import {
Form,
@@ -36,7 +37,7 @@ import {
TabsTrigger,
} from "@workspace/ui/components/tabs";
import { Combobox } from "@workspace/ui/composed/combobox";
import { ArrayInput } from "@workspace/ui/composed/dynamic-Inputs";
import { ArrayInput } from "@workspace/ui/composed/dynamic-inputs";
import { JSONEditor } from "@workspace/ui/composed/editor/index";
import { EnhancedInput } from "@workspace/ui/composed/enhanced-input";
import { Icon } from "@workspace/ui/composed/icon";
@@ -44,6 +45,8 @@ import {
evaluateWithPrecision,
unitConversion,
} from "@workspace/ui/utils/unit-conversions";
import { getGroupConfig, getNodeGroupList } from "@workspace/ui/services/admin/group";
import { useQuery } from "@tanstack/react-query";
import { CreditCard, Server, Settings } from "lucide-react";
import { assign, shake } from "radash";
import { useCallback, useEffect, useRef, useState } from "react";
@@ -72,6 +75,8 @@ const defaultValues = {
language: "",
node_tags: [],
nodes: [],
node_group_id: "",
node_group_ids: [],
unit_time: "Month",
deduction_ratio: 0,
purchase_with_discount: false,
@@ -79,6 +84,7 @@ const defaultValues = {
renewal_reset: false,
show_original_price: false,
deduction_mode: "auto",
traffic_limit: [],
};
export default function SubscribeForm<T extends Record<string, any>>({
@@ -117,11 +123,23 @@ export default function SubscribeForm<T extends Record<string, any>>({
language: z.string().optional(),
node_tags: z.array(z.string()).optional(),
nodes: z.array(z.number()).optional(),
node_group_id: z.string().optional(),
node_group_ids: z.optional(z.array(z.string()).default([])),
deduction_ratio: z.number().optional(),
allow_deduction: z.boolean().optional(),
reset_cycle: z.number().optional(),
renewal_reset: z.boolean().optional(),
show_original_price: z.boolean().optional(),
traffic_limit: z
.array(
z.object({
stat_type: z.string(),
stat_value: z.number().int(),
traffic_usage: z.number(),
speed_limit: z.number(),
})
)
.optional(),
});
const form = useForm<z.infer<typeof formSchema>>({
@@ -234,12 +252,22 @@ export default function SubscribeForm<T extends Record<string, any>>({
);
useEffect(() => {
form?.reset(
assign(
defaultValues,
shake(initialValues, (value) => value === null) as Record<string, any>
)
const processedValues = assign(
defaultValues,
shake(initialValues, (value) => value === null) as Record<string, any>
);
// Convert node_group_id from number to string (including 0)
if (initialValues?.node_group_id !== undefined) {
processedValues.node_group_id = String(initialValues.node_group_id);
}
// Convert node_group_ids from number[] to string[]
if (initialValues?.node_group_ids && Array.isArray(initialValues.node_group_ids)) {
processedValues.node_group_ids = (initialValues.node_group_ids as any[]).map((id) => String(id));
}
form?.reset(processedValues);
const discount = form.getValues("discount") || [];
if (discount.length > 0) {
debouncedCalculateDiscount(discount, "discount");
@@ -256,15 +284,58 @@ export default function SubscribeForm<T extends Record<string, any>>({
);
async function handleSubmit(data: { [x: string]: any }) {
// Don't process node_group_id - submit as-is
const bool = await onSubmit(data as T);
if (bool) setOpen(false);
}
const { getAllAvailableTags, getNodesByTag, getNodesWithoutTags } = useNode();
const { getAllAvailableTags, getNodesByTag, getNodesWithoutTags, getNodesWithoutGroups, nodes } = useNode();
const tagGroups = getAllAvailableTags();
// Fetch node groups (exclude expired groups)
const { data: nodeGroupsData } = useQuery({
queryKey: ["nodeGroups"],
queryFn: async () => {
const { data } = await getNodeGroupList({ page: 1, size: 1000 });
const allGroups = data.data?.list || [];
// Filter out expired node groups
return allGroups.filter((group) => !group.is_expired_group);
},
});
// Fetch group config to check if group feature is enabled
const { data: groupConfigData } = useQuery({
queryKey: ["groupConfig"],
queryFn: async () => {
const { data } = await getGroupConfig();
return data.data;
},
});
const isGroupEnabled = groupConfigData?.enabled || false;
const unit_time = form.watch("unit_time");
const node_group_id = form.watch("node_group_id");
const node_group_ids = form.watch("node_group_ids");
// Watch node_group_id and automatically include it in node_group_ids
useEffect(() => {
if (node_group_id) {
const currentGroupIds = form.getValues("node_group_ids") || [];
if (!currentGroupIds.includes(node_group_id)) {
form.setValue("node_group_ids", [...currentGroupIds, node_group_id]);
}
}
}, [node_group_id, form]);
// If node_group_id is empty or 0, automatically set it to the first item in node_group_ids
useEffect(() => {
if ((!node_group_id || node_group_id === "0") && node_group_ids && node_group_ids.length > 0) {
form.setValue("node_group_id", node_group_ids[0]);
}
}, [node_group_ids, node_group_id, form]);
return (
<Sheet onOpenChange={setOpen} open={open}>
@@ -286,7 +357,7 @@ export default function SubscribeForm<T extends Record<string, any>>({
<Form {...form}>
<form className="pt-4" onSubmit={form.handleSubmit(handleSubmit)}>
<Tabs className="w-full" defaultValue="basic">
<TabsList className="mb-6 grid w-full grid-cols-3">
<TabsList className="mb-6 grid w-full grid-cols-4">
<TabsTrigger
className="flex items-center gap-2"
value="basic"
@@ -308,6 +379,13 @@ export default function SubscribeForm<T extends Record<string, any>>({
<Server className="h-4 w-4" />
{t("form.nodes")}
</TabsTrigger>
<TabsTrigger
className="flex items-center gap-2"
value="traffic-limit"
>
<Icon className="h-4 w-4" icon="uil:tachometer-fast" />
{t("form.trafficLimit")}
</TabsTrigger>
</TabsList>
<TabsContent className="space-y-4" value="basic">
@@ -932,80 +1010,83 @@ export default function SubscribeForm<T extends Record<string, any>>({
<TabsContent className="space-y-4" value="servers">
<div className="space-y-6">
<FormField
control={form.control}
name="node_tags"
render={({ field }) => (
<FormItem>
<FormLabel>{t("form.nodeGroup")}</FormLabel>
<FormControl>
<Accordion
className="w-full"
collapsible
type="single"
>
{tagGroups.map((tag) => {
const value = field.value || [];
const tagId = tag;
const nodesWithTag = getNodesByTag(tag);
{/* Show node_tags field only when group feature is disabled */}
{!isGroupEnabled && (
<FormField
control={form.control}
name="node_tags"
render={({ field }) => (
<FormItem>
<FormLabel>{t("form.nodeGroup")}</FormLabel>
<FormControl>
<Accordion
className="w-full"
collapsible
type="single"
>
{tagGroups.map((tag) => {
const value = field.value || [];
const tagId = tag;
const nodesWithTag = getNodesByTag(tag);
return (
<AccordionItem key={tag} value={String(tag)}>
<AccordionTrigger>
<div className="flex items-center gap-2">
<Checkbox
checked={value.includes(tagId as any)}
onCheckedChange={(checked) =>
checked
? form.setValue(field.name, [
...value,
tagId,
] as any)
: form.setValue(
field.name,
value.filter(
(v: any) => v !== tagId
return (
<AccordionItem key={tag} value={String(tag)}>
<AccordionTrigger>
<div className="flex items-center gap-2">
<Checkbox
checked={value.includes(tagId as any)}
onCheckedChange={(checked) =>
checked
? form.setValue(field.name, [
...value,
tagId,
] as any)
: form.setValue(
field.name,
value.filter(
(v: any) => v !== tagId
)
)
)
}
/>
<Label>
{tag}
<span className="ml-2 text-muted-foreground text-xs">
({nodesWithTag.length})
</span>
</Label>
</div>
</AccordionTrigger>
<AccordionContent>
<ul className="space-y-1">
{getNodesByTag(tag).map((node) => (
<li
className="flex items-center justify-between gap-3"
key={node.id}
>
<span className="flex-1">
{node.name}
}
/>
<Label>
{tag}
<span className="ml-2 text-muted-foreground text-xs">
({nodesWithTag.length})
</span>
<span className="flex-1">
{node.address}:{node.port}
</span>
<span className="flex-1 text-right">
{node.protocol}
</span>
</li>
))}
</ul>
</AccordionContent>
</AccordionItem>
);
})}
</Accordion>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</Label>
</div>
</AccordionTrigger>
<AccordionContent>
<ul className="space-y-1">
{getNodesByTag(tag).map((node) => (
<li
className="flex items-center justify-between gap-3"
key={node.id}
>
<span className="flex-1">
{node.name}
</span>
<span className="flex-1">
{node.address}:{node.port}
</span>
<span className="flex-1 text-right">
{node.protocol}
</span>
</li>
))}
</ul>
</AccordionContent>
</AccordionItem>
);
})}
</Accordion>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<FormField
control={form.control}
@@ -1015,7 +1096,9 @@ export default function SubscribeForm<T extends Record<string, any>>({
<FormLabel>{t("form.node")}</FormLabel>
<FormControl>
<div className="flex flex-col gap-2">
{getNodesWithoutTags().map((item) => {
{/* When group feature is enabled, show nodes without groups */}
{/* When group feature is disabled, show nodes without tags */}
{(isGroupEnabled ? getNodesWithoutGroups() : getNodesWithoutTags()).map((item: API.Node) => {
const value = field.value || [];
return (
@@ -1056,6 +1139,362 @@ export default function SubscribeForm<T extends Record<string, any>>({
})}
</div>
</FormControl>
<FormDescription>
{isGroupEnabled
? t("form.nodesWithoutGroupsDescription", "Nodes without group assignment will be shown here (nodes that belong to groups are managed in the Node Groups section above)")
: t("form.nodesDescription", "Select nodes for this subscription")
}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{/* Show node_group_ids field only when group feature is enabled */}
{isGroupEnabled && (
<>
{/* When no default node group is set, show simple node group selection */}
{!node_group_id ? (
<FormField
control={form.control}
name="node_group_ids"
render={({ field }) => (
<FormItem>
<FormLabel>{t("form.nodeGroups", "Node Groups")}</FormLabel>
<FormControl>
<div className="space-y-4">
{nodeGroupsData?.map((g) => {
// Filter nodes that belong to this group
const nodesInGroup = (nodes || []).filter((node) => {
const nodeGroupIds = (node as any).node_group_ids || [];
return nodeGroupIds.includes(g.id);
});
return (
<div key={g.id} className="border rounded-lg p-4">
<div className="flex items-center space-x-2 mb-3">
<Checkbox
id={`subscribe-node-group-${g.id}`}
checked={field.value?.includes(String(g.id))}
onCheckedChange={(checked) => {
const currentValue = field.value || [];
const currentDefaultGroupId = form.getValues("node_group_id");
if (checked) {
const newValue = [...currentValue, String(g.id)];
form.setValue(field.name, newValue);
// If no default node group is set, set this one as default
if (!currentDefaultGroupId) {
form.setValue("node_group_id", String(g.id));
}
} else {
form.setValue(
field.name,
currentValue.filter((v: string) => v !== String(g.id))
);
}
}}
/>
<Label
htmlFor={`subscribe-node-group-${g.id}`}
className="cursor-pointer font-medium"
>
{g.name}
<span className="ml-2 text-muted-foreground text-sm">
({nodesInGroup.length} {t("form.nodes", "nodes")})
</span>
</Label>
</div>
{/* Show nodes in this group */}
{nodesInGroup.length > 0 && (
<div className="ml-6 mt-3">
<div className="text-xs text-muted-foreground mb-2">
{t("form.nodesInGroup", "Nodes in this group:")}
</div>
<div className="grid grid-cols-1 gap-2">
{nodesInGroup.map((node) => (
<div
key={node.id}
className="flex items-center justify-between rounded border p-2 text-sm bg-muted/30"
>
<span className="flex-1 font-medium">
{node.name}
</span>
<span className="flex-1 text-muted-foreground">
{node.address}:{node.port}
</span>
<span className="flex-1 text-right text-muted-foreground">
{node.protocol}
</span>
</div>
))}
</div>
</div>
)}
</div>
);
})}
</div>
</FormControl>
<FormDescription>
{t(
"form.nodeGroupsFirstSelectionDescription",
"Select node groups for this product. The first selected group will be set as the default node group."
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
) : (
<>
{/* Default Node Group Selection - shown when default is set */}
<FormField
control={form.control}
name="node_group_id"
render={({ field }) => {
// Find the selected node group
const selectedNodeGroup = nodeGroupsData?.find((g) => String(g.id) === field.value);
// Filter nodes that belong to this group
const nodesInGroup = selectedNodeGroup ? (nodes || []).filter((node) => {
const nodeGroupIds = (node as any).node_group_ids || [];
return nodeGroupIds.includes(selectedNodeGroup.id);
}) : [];
return (
<FormItem>
<FormLabel>{t("form.defaultNodeGroup", "Default Node Group")}</FormLabel>
<Card className="p-4">
<FormControl>
<Combobox
placeholder={t("form.selectDefaultNodeGroup", "Select a default node group...")}
value={field.value}
onChange={(value) => {
form.setValue(field.name, value || "");
}}
options={[
{ label: t("form.noDefaultNodeGroup", "No Default Node Group"), value: "" },
...(nodeGroupsData?.map((g) => ({
label: g.name,
value: String(g.id),
})) || []),
]}
/>
</FormControl>
<FormDescription className="mt-2">
{t(
"form.defaultNodeGroupDescription",
"The default node group for this product."
)}
</FormDescription>
{/* Show nodes in the selected default node group */}
{nodesInGroup.length > 0 && (
<>
<div className="text-xs text-muted-foreground mb-2 mt-3">
{t("form.nodesInGroup", "Nodes in this group:")}
</div>
<div className="grid grid-cols-1 gap-2">
{nodesInGroup.map((node) => (
<div
key={node.id}
className="flex items-center justify-between rounded border p-2 text-sm bg-muted/30"
>
<span className="flex-1 font-medium">
{node.name}
</span>
<span className="flex-1 text-muted-foreground">
{node.address}:{node.port}
</span>
<span className="flex-1 text-right text-muted-foreground">
{node.protocol}
</span>
</div>
))}
</div>
</>
)}
</Card>
<FormMessage />
</FormItem>
);
}}
/>
{/* Backup Node Groups Selection - filter out default node group */}
<FormField
control={form.control}
name="node_group_ids"
render={({ field }) => (
<FormItem>
<FormLabel>{t("form.backupNodeGroups", "Backup Node Groups")}</FormLabel>
<FormControl>
<div className="space-y-4">
{nodeGroupsData
?.filter((g) => String(g.id) !== node_group_id)
?.map((g) => {
// Filter nodes that belong to this group
const nodesInGroup = (nodes || []).filter((node) => {
const nodeGroupIds = (node as any).node_group_ids || [];
return nodeGroupIds.includes(g.id);
});
return (
<div key={g.id} className="border rounded-lg p-4">
<div className="flex items-center space-x-2 mb-3">
<Checkbox
id={`subscribe-backup-node-group-${g.id}`}
checked={field.value?.includes(String(g.id))}
onCheckedChange={(checked) => {
const currentValue = field.value || [];
if (checked) {
form.setValue(field.name, [...currentValue, String(g.id)]);
} else {
form.setValue(
field.name,
currentValue.filter((v: string) => v !== String(g.id))
);
}
}}
/>
<Label
htmlFor={`subscribe-backup-node-group-${g.id}`}
className="cursor-pointer font-medium"
>
{g.name}
<span className="ml-2 text-muted-foreground text-sm">
({nodesInGroup.length} {t("form.nodes", "nodes")})
</span>
</Label>
</div>
{/* Show nodes in this group */}
{nodesInGroup.length > 0 && (
<div className="ml-6 mt-3">
<div className="text-xs text-muted-foreground mb-2">
{t("form.nodesInGroup", "Nodes in this group:")}
</div>
<div className="grid grid-cols-1 gap-2">
{nodesInGroup.map((node) => (
<div
key={node.id}
className="flex items-center justify-between rounded border p-2 text-sm bg-muted/30"
>
<span className="flex-1 font-medium">
{node.name}
</span>
<span className="flex-1 text-muted-foreground">
{node.address}:{node.port}
</span>
<span className="flex-1 text-right text-muted-foreground">
{node.protocol}
</span>
</div>
))}
</div>
</div>
)}
</div>
);
})}
</div>
</FormControl>
<FormDescription>
{t(
"form.backupNodeGroupsDescription",
"Select additional backup node groups."
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</>
)}
</>
)}
</div>
</TabsContent>
{/* Traffic Limit Tab */}
<TabsContent className="space-y-4" value="traffic-limit">
<div className="space-y-4">
<FormField
control={form.control}
name="traffic_limit"
render={({ field }) => (
<FormItem>
<FormLabel>{t("form.trafficLimitRules", "Traffic Limit Rules")}</FormLabel>
<FormControl>
<ArrayInput
value={field.value && field.value.length > 0 ? field.value : [{ stat_type: "day" }]}
onChange={field.onChange}
fields={[
{
name: "stat_type",
type: "select",
placeholder: t("form.statType", "Statistics Type"),
value: "day",
options: [
{ label: t("form.statTypeHour", "Hour"), value: "hour" },
{ label: t("form.statTypeDay", "Day"), value: "day" },
],
},
{
name: "stat_value",
type: "number",
placeholder: t("form.statValue", "Time Value"),
min: 1,
onKeyDown: (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === '.' || e.key === ',') {
e.preventDefault();
}
},
formatOutput: (value: string | number) => {
const num = Number(value);
return isNaN(num) ? 0 : Math.floor(num);
},
},
{
name: "traffic_usage",
type: "number",
placeholder: t("form.trafficUsage", "Traffic Usage (GB)"),
min: 0,
onKeyDown: (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === '.' || e.key === ',') {
e.preventDefault();
}
},
formatOutput: (value: string | number) => {
const num = Number(value);
return isNaN(num) ? 0 : Math.floor(num);
},
},
{
name: "speed_limit",
type: "number",
placeholder: t("form.speedLimitKb", "Speed Limit (kb)"),
min: 0,
onKeyDown: (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === '.' || e.key === ',') {
e.preventDefault();
}
},
formatOutput: (value: string | number) => {
const num = Number(value);
return isNaN(num) ? 0 : Math.floor(num);
},
},
]}
/>
</FormControl>
<FormDescription>
{t(
"form.trafficLimitDescription",
"Configure traffic-based speed limit rules. When traffic usage reaches the specified amount, the speed will be limited."
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
@@ -16,6 +16,8 @@ import {
subscribeSort,
updateSubscribe,
} from "@workspace/ui/services/admin/subscribe";
import { getNodeGroupList } from "@workspace/ui/services/admin/group";
import { useQuery } from "@tanstack/react-query";
import { useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
@@ -28,8 +30,31 @@ export default function SubscribeTable() {
const [loading, setLoading] = useState(false);
const ref = useRef<ProTableActions>(null);
const { fetchSubscribes } = useSubscribe();
// Fetch node groups for filtering (exclude expired groups)
const { data: nodeGroupsData } = useQuery({
queryKey: ["nodeGroups"],
queryFn: async () => {
const { data } = await getNodeGroupList({ page: 1, size: 1000 });
const allGroups = data.data?.list || [];
// Filter out expired node groups
return allGroups.filter((group) => !group.is_expired_group);
},
});
// Fetch group config to check if group feature is enabled
const { data: groupConfigData } = useQuery({
queryKey: ["groupConfig"],
queryFn: async () => {
const { data } = await (await import("@workspace/ui/services/admin/group")).getGroupConfig();
return data.data;
},
});
const isGroupEnabled = groupConfigData?.enabled || false;
return (
<ProTable<API.SubscribeItem, { group_id: number; query: string }>
<ProTable<API.SubscribeItem, { group_id: number; query: string; node_group_id?: number }>
action={ref}
actions={{
render: (row) => [
@@ -40,10 +65,16 @@ export default function SubscribeTable() {
onSubmit={async (values) => {
setLoading(true);
try {
await updateSubscribe({
const updateBody: any = {
...row,
...values,
} as API.UpdateSubscribeRequest);
};
// Add node_group_ids if it exists in values
const vals = values as any;
if (vals.node_group_ids) {
updateBody.node_group_ids = vals.node_group_ids.map((id: string | number) => Number(id));
}
await updateSubscribe(updateBody as API.UpdateSubscribeRequest);
toast.success(t("updateSuccess"));
ref.current?.refresh();
fetchSubscribes();
@@ -243,6 +274,26 @@ export default function SubscribeTable() {
<Badge variant="outline">{row.getValue("sold")}</Badge>
),
},
...(isGroupEnabled
? [
{
id: "node_group",
header: t("defaultNodeGroup", "Default Node Group"),
cell: ({ row }: { row: any }) => {
const nodeGroupId = row.original.node_group_id;
const nodeGroup = nodeGroupsData?.find((g) => g.id === nodeGroupId);
return (
<div>
{nodeGroup ? (
<Badge variant="outline">{nodeGroup.name}</Badge>
) : null}
</div>
);
},
},
]
: []),
]}
header={{
toolbar: (
@@ -251,11 +302,17 @@ export default function SubscribeTable() {
onSubmit={async (values) => {
setLoading(true);
try {
await createSubscribe({
const createBody: any = {
...values,
show: false,
sell: false,
});
};
// Add node_group_ids if it exists in values
const vals = values as any;
if (vals.node_group_ids) {
createBody.node_group_ids = vals.node_group_ids.map((id: string | number) => Number(id));
}
await createSubscribe(createBody);
toast.success(t("createSuccess"));
ref.current?.refresh();
fetchSubscribes();
@@ -312,12 +369,30 @@ export default function SubscribeTable() {
{
key: "search",
},
...(isGroupEnabled
? [
{
key: "node_group_id",
placeholder: t("nodeGroups", "Node Groups"),
options: [
{ label: t("all", "All"), value: "" },
...(nodeGroupsData?.map((item) => ({
label: item.name,
value: String(item.id),
})) || []),
],
},
]
: []),
]}
request={async (pagination, filters) => {
const { data } = await getSubscribeList({
const params = {
...pagination,
...filters,
});
node_group_id: filters?.node_group_id ? Number(filters.node_group_id) : undefined,
} as any;
const { data } = await getSubscribeList(params);
return {
list: data.data?.list || [],
total: data.data?.total || 0,
@@ -11,6 +11,13 @@ import {
FormMessage,
} from "@workspace/ui/components/form";
import { ScrollArea } from "@workspace/ui/components/scroll-area";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@workspace/ui/components/select";
import {
Sheet,
SheetContent,
@@ -33,11 +40,13 @@ import { toast } from "sonner";
import { z } from "zod";
const verifySchema = z.object({
captcha_type: z.string().optional(),
turnstile_site_key: z.string().optional(),
turnstile_secret: z.string().optional(),
enable_register_verify: z.boolean().optional(),
enable_login_verify: z.boolean().optional(),
enable_reset_password_verify: z.boolean().optional(),
enable_user_login_captcha: z.boolean().optional(),
enable_user_register_captcha: z.boolean().optional(),
enable_admin_login_captcha: z.boolean().optional(),
enable_user_reset_password_captcha: z.boolean().optional(),
});
type VerifyFormData = z.infer<typeof verifySchema>;
@@ -59,11 +68,13 @@ export default function VerifyConfig() {
const form = useForm<VerifyFormData>({
resolver: zodResolver(verifySchema),
defaultValues: {
captcha_type: "local",
turnstile_site_key: "",
turnstile_secret: "",
enable_register_verify: false,
enable_login_verify: false,
enable_reset_password_verify: false,
enable_user_login_captcha: false,
enable_user_register_captcha: false,
enable_admin_login_captcha: false,
enable_user_reset_password_captcha: false,
},
});
@@ -126,26 +137,42 @@ export default function VerifyConfig() {
>
<FormField
control={form.control}
name="turnstile_site_key"
name="captcha_type"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("verify.turnstileSiteKey", "Turnstile Site Key")}
{t("verify.captchaType", "Captcha Type")}
</FormLabel>
<FormControl>
<EnhancedInput
<Select
onValueChange={field.onChange}
placeholder={t(
"verify.turnstileSiteKeyPlaceholder",
"Enter Turnstile site key"
)}
value={field.value}
/>
>
<SelectTrigger>
<SelectValue
placeholder={t(
"verify.captchaTypePlaceholder",
"Select captcha type"
)}
/>
</SelectTrigger>
<SelectContent>
<SelectItem value="local">
{t("verify.captchaTypeLocal", "Local Image Captcha")}
</SelectItem>
<SelectItem value="turnstile">
{t(
"verify.captchaTypeTurnstile",
"Cloudflare Turnstile"
)}
</SelectItem>
</SelectContent>
</Select>
</FormControl>
<FormDescription>
{t(
"verify.turnstileSiteKeyDescription",
"Cloudflare Turnstile site key for frontend verification"
"verify.captchaTypeDescription",
"Choose between local image captcha (offline) or Cloudflare Turnstile"
)}
</FormDescription>
<FormMessage />
@@ -153,45 +180,78 @@ export default function VerifyConfig() {
)}
/>
<FormField
control={form.control}
name="turnstile_secret"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("verify.turnstileSecret", "Turnstile Secret Key")}
</FormLabel>
<FormControl>
<EnhancedInput
onValueChange={field.onChange}
placeholder={t(
"verify.turnstileSecretPlaceholder",
"Enter Turnstile secret key"
)}
type="password"
value={field.value}
/>
</FormControl>
<FormDescription>
{t(
"verify.turnstileSecretDescription",
"Cloudflare Turnstile secret key for backend verification"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{form.watch("captcha_type") === "turnstile" && (
<>
<FormField
control={form.control}
name="turnstile_site_key"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("verify.turnstileSiteKey", "Turnstile Site Key")}
</FormLabel>
<FormControl>
<EnhancedInput
onValueChange={field.onChange}
placeholder={t(
"verify.turnstileSiteKeyPlaceholder",
"Enter Turnstile site key"
)}
value={field.value}
/>
</FormControl>
<FormDescription>
{t(
"verify.turnstileSiteKeyDescription",
"Cloudflare Turnstile site key for frontend verification"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="turnstile_secret"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("verify.turnstileSecret", "Turnstile Secret Key")}
</FormLabel>
<FormControl>
<EnhancedInput
onValueChange={field.onChange}
placeholder={t(
"verify.turnstileSecretPlaceholder",
"Enter Turnstile secret key"
)}
type="password"
value={field.value}
/>
</FormControl>
<FormDescription>
{t(
"verify.turnstileSecretDescription",
"Cloudflare Turnstile secret key for backend verification"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</>
)}
<FormField
control={form.control}
name="enable_register_verify"
name="enable_user_login_captcha"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"verify.enableRegisterVerify",
"Enable Verification on Registration"
"verify.enableUserLoginCaptcha",
"Enable User Login Captcha"
)}
</FormLabel>
<FormControl>
@@ -203,8 +263,8 @@ export default function VerifyConfig() {
</FormControl>
<FormDescription>
{t(
"verify.enableRegisterVerifyDescription",
"When enabled, users must pass human verification during registration"
"verify.enableUserLoginCaptchaDescription",
"When enabled, users must pass captcha verification during login"
)}
</FormDescription>
<FormMessage />
@@ -214,13 +274,13 @@ export default function VerifyConfig() {
<FormField
control={form.control}
name="enable_login_verify"
name="enable_user_register_captcha"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"verify.enableLoginVerify",
"Enable Verification on Login"
"verify.enableUserRegisterCaptcha",
"Enable User Registration Captcha"
)}
</FormLabel>
<FormControl>
@@ -232,8 +292,8 @@ export default function VerifyConfig() {
</FormControl>
<FormDescription>
{t(
"verify.enableLoginVerifyDescription",
"When enabled, users must pass human verification during login"
"verify.enableUserRegisterCaptchaDescription",
"When enabled, users must pass captcha verification during registration"
)}
</FormDescription>
<FormMessage />
@@ -243,13 +303,13 @@ export default function VerifyConfig() {
<FormField
control={form.control}
name="enable_reset_password_verify"
name="enable_user_reset_password_captcha"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"verify.enablePasswordVerify",
"Enable Verification on Password Reset"
"verify.enableUserResetPasswordCaptcha",
"Enable User Password Reset Captcha"
)}
</FormLabel>
<FormControl>
@@ -261,8 +321,37 @@ export default function VerifyConfig() {
</FormControl>
<FormDescription>
{t(
"verify.enablePasswordVerifyDescription",
"When enabled, users must pass human verification during password reset"
"verify.enableUserResetPasswordCaptchaDescription",
"When enabled, users must pass captcha verification during password reset"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="enable_admin_login_captcha"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"verify.enableAdminLoginCaptcha",
"Enable Admin Authentication Captcha"
)}
</FormLabel>
<FormControl>
<Switch
checked={field.value}
className="!mt-0 float-end"
onCheckedChange={field.onChange}
/>
</FormControl>
<FormDescription>
{t(
"verify.enableAdminLoginCaptchaDescription",
"When enabled, administrators must pass captcha verification during login"
)}
</FormDescription>
<FormMessage />
+158 -13
View File
@@ -2,6 +2,13 @@ import { useQuery } from "@tanstack/react-query";
import { Link, useSearch } from "@tanstack/react-router";
import { Badge } from "@workspace/ui/components/badge";
import { Button } from "@workspace/ui/components/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@workspace/ui/components/dialog";
import {
DropdownMenu,
DropdownMenuContent,
@@ -44,6 +51,10 @@ import {
getUserList,
updateUserBasicInfo,
} from "@workspace/ui/services/admin/user";
import {
// getUserGroupList,
previewUserNodes,
} from "@workspace/ui/services/admin/group";
import { useCallback, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
@@ -58,6 +69,7 @@ import { AuthMethodsForm } from "./user-profile/auth-methods-form";
import { BasicInfoForm } from "./user-profile/basic-info-form";
import { NotifySettingsForm } from "./user-profile/notify-settings-form";
import UserSubscription from "./user-subscription";
// import EditUserGroupDialog from "./edit-user-group-dialog";
export default function User() {
const { t } = useTranslation("user");
@@ -77,6 +89,23 @@ export default function User() {
ref.current?.refresh();
}, []);
// const { data: userGroupsData } = useQuery({
// queryKey: ["userGroups"],
// queryFn: async () => {
// const { data } = await getUserGroupList({ page: 1, size: 1000 });
// return data.data?.list || [];
// },
// });
const initialFilters = {
search: sp.search || undefined,
user_id: sp.user_id || undefined,
subscribe_id: sp.subscribe_id || undefined,
user_subscribe_id: sp.user_subscribe_id || undefined,
short_code: sp.short_code || undefined,
// user_group_id: sp.user_group_id || undefined,
};
return (
<ProTable<API.User, API.GetUserListParams>
action={ref}
@@ -93,6 +122,7 @@ export default function User() {
onChanged={() => ref.current?.refresh()}
userId={row.id}
/>,
<PreviewNodesDialog key="preview-nodes" userId={row.id} />,
<ConfirmButton
cancelText={t("cancel", "Cancel")}
confirmText={t("confirm", "Confirm")}
@@ -163,6 +193,7 @@ export default function User() {
}}
columns={[
{
id: "enable",
accessorKey: "enable",
header: t("enable", "Enable"),
cell: ({ row }) => (
@@ -193,10 +224,12 @@ export default function User() {
),
},
{
id: "id",
accessorKey: "id",
header: "ID",
},
{
id: "deleted_at",
accessorKey: "deleted_at",
header: t("isDeleted", "Deleted"),
cell: ({ row }) => {
@@ -209,6 +242,7 @@ export default function User() {
},
},
{
id: "auth_methods",
accessorKey: "auth_methods",
header: t("userName", "Username"),
cell: ({ row }) => {
@@ -231,6 +265,7 @@ export default function User() {
},
},
{
id: "balance",
accessorKey: "balance",
header: t("balance", "Balance"),
cell: ({ row }) => (
@@ -238,6 +273,7 @@ export default function User() {
),
},
{
id: "gift_amount",
accessorKey: "gift_amount",
header: t("giftAmount", "Gift Amount"),
cell: ({ row }) => (
@@ -245,6 +281,7 @@ export default function User() {
),
},
{
id: "commission",
accessorKey: "commission",
header: t("commission", "Commission"),
cell: ({ row }) => (
@@ -252,16 +289,19 @@ export default function User() {
),
},
{
id: "refer_code",
accessorKey: "refer_code",
header: t("inviteCode", "Invite Code"),
cell: ({ row }) => row.getValue("refer_code") || "--",
},
{
id: "referer_id",
accessorKey: "referer_id",
header: t("referer", "Referer"),
cell: ({ row }) => <UserDetail id={row.original.referer_id} />,
},
{
id: "created_at",
accessorKey: "created_at",
header: t("createdAt", "Created At"),
cell: ({ row }) => formatDate(row.getValue("created_at")),
@@ -300,19 +340,42 @@ export default function User() {
/>
),
}}
request={async (pagination) => {
const { type, value } = searchRef.current;
const params: Record<string, unknown> = { ...pagination };
if (value) {
if (type === "user_id") {
params.user_id = value;
} else if (type === "subscribe_id") {
params.subscribe_id = value;
} else {
params.search = value;
}
}
const { data } = await getUserList(params as API.GetUserListParams);
initialFilters={initialFilters}
key={initialFilters.user_id}
params={[
{
key: "subscribe_id",
placeholder: t("subscription", "Subscription"),
options: [
{ label: t("all", "All"), value: "" },
...(subscribes?.map((item) => ({
label: item.name!,
value: String(item.id!),
})) || []),
],
},
{
key: "search",
placeholder: "Search",
},
{
key: "user_id",
placeholder: t("userId", "User ID"),
},
{
key: "user_subscribe_id",
placeholder: t("subscriptionId", "Subscription ID"),
},
{
key: "short_code",
placeholder: t("shortCode", "Short Code"),
},
]}
request={async (pagination, filter) => {
const { data } = await getUserList({
...pagination,
...filter,
});
return {
list: data.data?.list || [],
total: data.data?.total || 0,
@@ -545,3 +608,85 @@ function UserSearchBar({
</div>
);
}
function PreviewNodesDialog({ userId }: { userId: number }) {
const { t } = useTranslation("user");
const [open, setOpen] = useState(false);
const { data: previewData, isLoading } = useQuery({
enabled: open,
queryKey: ["previewUserNodes", userId],
queryFn: async () => {
const { data } = await previewUserNodes({ user_id: userId });
return data.data;
},
});
return (
<Dialog onOpenChange={setOpen} open={open}>
<DialogTrigger asChild>
<Button variant="outline">{t("previewNodes", "Preview Nodes")}</Button>
</DialogTrigger>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>
{t("previewNodes", "Preview Nodes")} · ID: {userId}
</DialogTitle>
</DialogHeader>
{isLoading ? (
<div className="py-8 text-center text-muted-foreground">
{t("loading", "Loading...")}
</div>
) : previewData ? (
<div className="space-y-4">
<div>
<span className="text-sm font-medium text-muted-foreground">
{t("availableNodes", "Available Nodes")}:
</span>{" "}
{previewData.node_groups?.reduce((sum, group) => sum + (group.nodes?.length || 0), 0) || 0}
</div>
{previewData.node_groups && previewData.node_groups.length > 0 ? (
<div className="max-h-[400px] overflow-y-auto space-y-4">
{previewData.node_groups.map((group) => (
<div key={group.id}>
<h4 className="text-sm font-semibold mb-2">
{group.name ||
(group.id === -1
? t("subscriptionNodes", "Subscription Nodes")
: group.id === 0
? t("publicNodes", "Public Nodes")
: `${t("nodeGroup", "Node Group")} ${group.id}`)}
</h4>
{group.nodes && group.nodes.length > 0 ? (
<table className="w-full text-sm">
<thead>
<tr className="border-b">
<th className="p-2 text-left font-medium">ID</th>
<th className="p-2 text-left font-medium">{t("name", "Name")}</th>
<th className="p-2 text-left font-medium">{t("address", "Address")}</th>
</tr>
</thead>
<tbody>
{group.nodes.map((node) => (
<tr key={node.id} className="border-b">
<td className="p-2">{node.id}</td>
<td className="p-2">{node.name}</td>
<td className="p-2">{node.address}:{node.port}</td>
</tr>
))}
</tbody>
</table>
) : null}
</div>
))}
</div>
) : (
<div className="py-4 text-center text-muted-foreground">
{t("noNodesAvailable", "No nodes available")}
</div>
)}
</div>
) : null}
</DialogContent>
</Dialog>
);
}
@@ -17,6 +17,8 @@ import { formatBytes } from "@workspace/ui/utils/formatting";
import { useTranslation } from "react-i18next";
import { Display } from "@/components/display";
import { formatDate } from "@/utils/common";
// import EditUserGroupDialog from "./edit-user-group-dialog";
// import { getUserGroupList } from "@workspace/ui/services/admin/group";
export function UserSubscribeDetail({
id,
@@ -38,10 +40,33 @@ export function UserSubscribeDetail({
},
});
// Fetch user groups for display
// const { data: groupsData } = useQuery({
// enabled: id !== 0 && enabled,
// queryKey: ["getUserGroupList"],
// queryFn: async () => {
// const { data } = await getUserGroupList({
// page: 1,
// size: 100,
// });
// return data.data?.list || [];
// },
// });
if (!id) return "--";
const usedTraffic = data ? data.upload + data.download : 0;
const totalTraffic = data?.traffic || 0;
const remainingTraffic = totalTraffic > 0 ? totalTraffic - usedTraffic : 0;
// Get user group info from data.user
// const userGroupId = typeof data?.user?.user_group_id === 'number' ? data?.user?.user_group_id : 0;
// const groupLocked = data?.user?.group_locked || false;
// const groupIds = userGroupId > 0 ? [userGroupId] : [];
// const groupNames = userGroupId > 0
// ? groupsData?.find((g: API.UserGroup) => g.id === userGroupId)?.name || "--"
// : "--";
const subscribeContent = (
<div className="space-y-4">
@@ -77,6 +102,16 @@ export function UserSubscribeDetail({
: "--"}
</span>
</li>
<li className="flex items-center justify-between">
<span className="text-muted-foreground">{t("remainingTraffic")}</span>
<span>
{data
? totalTraffic === 0
? t("unlimited")
: formatBytes(remainingTraffic)
: "--"}
</span>
</li>
<li className="flex items-center justify-between">
<span className="text-muted-foreground">{t("startTime")}</span>
<span>
@@ -89,6 +124,35 @@ export function UserSubscribeDetail({
{data?.expire_time ? formatDate(data.expire_time) : "--"}
</span>
</li>
{/* <li className="flex items-center justify-between">
<span className="text-muted-foreground">{t("userGroup")}</span>
<div className="flex items-center gap-2">
<span>{groupNames || "--"}</span>
{data?.id && (
<EditUserGroupDialog
userId={data?.user_id || 0}
userSubscribeId={data?.id}
currentGroupIds={groupIds}
currentLocked={groupLocked}
trigger={
<Button variant="ghost" size="sm" className="h-6 px-2">
{t("edit", "Edit")}
</Button>
}
onSubmit={async () => {
window.location.reload();
return true;
}}
/>
)}
</div>
</li>
<li className="flex items-center justify-between">
<span className="text-muted-foreground">{t("groupLocked")}</span>
<span>
{groupLocked ? t("yes", "Yes") : t("no", "No")}
</span>
</li> */}
</ul>
</div>
</div>
@@ -273,6 +273,19 @@ export default function UserSubscription({ userId }: { userId: number }) {
/>
),
},
{
id: "remaining_traffic",
header: t("remainingTraffic", "Remaining Traffic"),
cell: ({ row }) => {
const upload = row.original.upload || 0;
const download = row.original.download || 0;
const totalTraffic = row.original.traffic || 0;
const remainingTraffic = totalTraffic > 0 ? totalTraffic - upload - download : 0;
return (
<Display type="traffic" unlimited value={remainingTraffic} />
);
},
},
{
accessorKey: "speed_limit",
header: t("speedLimit", "Speed Limit"),
@@ -617,7 +630,7 @@ function RowMoreActions({
"This action cannot be undone."
)}
onConfirm={async () => {
await deleteUserSubscribe({ user_subscribe_id: row.id });
await deleteUserSubscribe({ user_subscribe_id: String(row.id) });
toast.success(t("deleteSuccess", "Deleted successfully"));
refresh();
}}
+5
View File
@@ -49,9 +49,14 @@ export const useGlobalStore = create<GlobalStore>((set, get) => ({
},
verify: {
turnstile_site_key: "",
captcha_type: "turnstile",
enable_login_verify: false,
enable_register_verify: false,
enable_reset_password_verify: false,
enable_user_login_captcha: false,
enable_user_register_captcha: false,
enable_user_reset_password_captcha: false,
enable_admin_login_captcha: false,
},
auth: {
mobile: {
+8
View File
@@ -25,6 +25,7 @@ interface NodeState {
isServerReferencedByNodes: (serverId: number) => boolean;
getNodesByTag: (tag: string) => API.Node[];
getNodesWithoutTags: () => API.Node[];
getNodesWithoutGroups: () => API.Node[];
getNodeTags: () => string[];
getAllAvailableTags: () => string[];
}
@@ -92,6 +93,12 @@ export const useNodeStore = create<NodeState>((set, get) => ({
getNodesWithoutTags: () =>
get().nodes.filter((node) => (node.tags || []).length === 0),
getNodesWithoutGroups: () =>
get().nodes.filter((node) => {
const groupIds = (node as any).node_group_ids;
return !groupIds || groupIds.length === 0;
}),
getNodeTags: () =>
Array.from(
new Set(
@@ -135,6 +142,7 @@ export const useNode = () => {
isServerReferencedByNodes: store.isServerReferencedByNodes,
getNodesByTag: store.getNodesByTag,
getNodesWithoutTags: store.getNodesWithoutTags,
getNodesWithoutGroups: store.getNodesWithoutGroups,
getNodeTags: store.getNodeTags,
getAllAvailableTags: store.getAllAvailableTags,
};
+11 -2
View File
@@ -24,9 +24,18 @@ export function differenceInDays(date1: Date, date2: Date): number {
export function formatDate(date?: Date | number, showTime = true) {
if (!date) return;
// Backend returns Unix timestamps in seconds; convert to milliseconds for JS Date
const dateValue =
typeof date === "number" && date < 1e12 ? date * 1000 : date;
// Unix timestamps (seconds): 10 digits e.g. 1771936457
// JavaScript timestamps (milliseconds): 13 digits
let dateValue = date;
if (typeof date === "number") {
if (date < 10000000000) {
dateValue = date * 1000;
}
}
const timeZone = localStorage.getItem("timezone") || "UTC";
return intlFormat(dateValue, {
year: "numeric",