merge: 同步 upstream/main 新功能到定制版本

- feat: Add slider verification code (bd67997)
- fix bug: Inventory cannot be zero (1f7a6ee)
- fix: resolve merge conflicts and lint errors
This commit is contained in:
2026-03-23 21:50:10 -07:00
parent 3806264343
commit d6616c5859
63 changed files with 3111 additions and 1130 deletions
+381 -215
View File
@@ -1,6 +1,7 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { useQuery } from "@tanstack/react-query";
import {
Accordion,
AccordionContent,
@@ -41,12 +42,14 @@ 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";
import {
getGroupConfig,
getNodeGroupList,
} from "@workspace/ui/services/admin/group";
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";
@@ -263,8 +266,13 @@ export default function SubscribeForm<T extends Record<string, any>>({
}
// 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));
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);
@@ -290,7 +298,13 @@ export default function SubscribeForm<T extends Record<string, any>>({
if (bool) setOpen(false);
}
const { getAllAvailableTags, getNodesByTag, getNodesWithoutTags, getNodesWithoutGroups, nodes } = useNode();
const {
getAllAvailableTags,
getNodesByTag,
getNodesWithoutTags,
getNodesWithoutGroups,
nodes,
} = useNode();
const tagGroups = getAllAvailableTags();
@@ -314,7 +328,7 @@ export default function SubscribeForm<T extends Record<string, any>>({
},
});
const isGroupEnabled = groupConfigData?.enabled || false;
const isGroupEnabled = groupConfigData?.enabled;
const unit_time = form.watch("unit_time");
const node_group_id = form.watch("node_group_id");
@@ -332,7 +346,11 @@ export default function SubscribeForm<T extends Record<string, any>>({
// 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) {
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]);
@@ -1030,11 +1048,16 @@ export default function SubscribeForm<T extends Record<string, any>>({
const nodesWithTag = getNodesByTag(tag);
return (
<AccordionItem key={tag} value={String(tag)}>
<AccordionItem
key={tag}
value={String(tag)}
>
<AccordionTrigger>
<div className="flex items-center gap-2">
<Checkbox
checked={value.includes(tagId as any)}
checked={value.includes(
tagId as any
)}
onCheckedChange={(checked) =>
checked
? form.setValue(field.name, [
@@ -1098,7 +1121,10 @@ export default function SubscribeForm<T extends Record<string, any>>({
<div className="flex flex-col gap-2">
{/* 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) => {
{(isGroupEnabled
? getNodesWithoutGroups()
: getNodesWithoutTags()
).map((item: API.Node) => {
const value = field.value || [];
return (
@@ -1141,9 +1167,14 @@ export default function SubscribeForm<T extends Record<string, any>>({
</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")
}
? 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>
@@ -1154,70 +1185,325 @@ export default function SubscribeForm<T extends Record<string, any>>({
{isGroupEnabled && (
<>
{/* When no default node group is set, show simple node group selection */}
{!node_group_id ? (
{node_group_id ? (
<>
{/* 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
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),
})) || []),
]}
placeholder={t(
"form.selectDefaultNodeGroup",
"Select a default node group..."
)}
value={field.value}
/>
</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="mt-3 mb-2 text-muted-foreground text-xs">
{t(
"form.nodesInGroup",
"Nodes in this group:"
)}
</div>
<div className="grid grid-cols-1 gap-2">
{nodesInGroup.map((node) => (
<div
className="flex items-center justify-between rounded border bg-muted/30 p-2 text-sm"
key={node.id}
>
<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
className="rounded-lg border p-4"
key={g.id}
>
<div className="mb-3 flex items-center space-x-2">
<Checkbox
checked={field.value?.includes(
String(g.id)
)}
id={`subscribe-backup-node-group-${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
className="cursor-pointer font-medium"
htmlFor={`subscribe-backup-node-group-${g.id}`}
>
{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="mt-3 ml-6">
<div className="mb-2 text-muted-foreground text-xs">
{t(
"form.nodesInGroup",
"Nodes in this group:"
)}
</div>
<div className="grid grid-cols-1 gap-2">
{nodesInGroup.map(
(node) => (
<div
className="flex items-center justify-between rounded border bg-muted/30 p-2 text-sm"
key={node.id}
>
<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>
)}
/>
</>
) : (
<FormField
control={form.control}
name="node_group_ids"
render={({ field }) => (
<FormItem>
<FormLabel>{t("form.nodeGroups", "Node Groups")}</FormLabel>
<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);
});
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">
<div
className="rounded-lg border p-4"
key={g.id}
>
<div className="mb-3 flex items-center space-x-2">
<Checkbox
checked={field.value?.includes(
String(g.id)
)}
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");
const currentValue =
field.value || [];
const currentDefaultGroupId =
form.getValues(
"node_group_id"
);
if (checked) {
const newValue = [...currentValue, String(g.id)];
form.setValue(field.name, newValue);
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));
form.setValue(
"node_group_id",
String(g.id)
);
}
} else {
form.setValue(
field.name,
currentValue.filter((v: string) => v !== String(g.id))
currentValue.filter(
(v: string) =>
v !== String(g.id)
)
);
}
}}
/>
<Label
htmlFor={`subscribe-node-group-${g.id}`}
className="cursor-pointer font-medium"
htmlFor={`subscribe-node-group-${g.id}`}
>
{g.name}
<span className="ml-2 text-muted-foreground text-sm">
({nodesInGroup.length} {t("form.nodes", "nodes")})
({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 className="mt-3 ml-6">
<div className="mb-2 text-muted-foreground text-xs">
{t(
"form.nodesInGroup",
"Nodes in this group:"
)}
</div>
<div className="grid grid-cols-1 gap-2">
{nodesInGroup.map((node) => (
<div
className="flex items-center justify-between rounded border bg-muted/30 p-2 text-sm"
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}
@@ -1248,169 +1534,6 @@ export default function SubscribeForm<T extends Record<string, any>>({
</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>
)}
/>
</>
)}
</>
)}
@@ -1425,68 +1548,111 @@ export default function SubscribeForm<T extends Record<string, any>>({
name="traffic_limit"
render={({ field }) => (
<FormItem>
<FormLabel>{t("form.trafficLimitRules", "Traffic Limit Rules")}</FormLabel>
<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"),
placeholder: t(
"form.statType",
"Statistics Type"
),
value: "day",
options: [
{ label: t("form.statTypeHour", "Hour"), value: "hour" },
{ label: t("form.statTypeDay", "Day"), value: "day" },
{
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"),
placeholder: t(
"form.statValue",
"Time Value"
),
min: 1,
onKeyDown: (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === '.' || e.key === ',') {
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);
return String(
Number.isNaN(num) ? 0 : Math.floor(num)
);
},
},
{
name: "traffic_usage",
type: "number",
placeholder: t("form.trafficUsage", "Traffic Usage (GB)"),
placeholder: t(
"form.trafficUsage",
"Traffic Usage (GB)"
),
min: 0,
onKeyDown: (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === '.' || e.key === ',') {
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);
return String(
Number.isNaN(num) ? 0 : Math.floor(num)
);
},
},
{
name: "speed_limit",
type: "number",
placeholder: t("form.speedLimitKb", "Speed Limit (kb)"),
placeholder: t(
"form.speedLimitKb",
"Speed Limit (kb)"
),
min: 0,
onKeyDown: (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === '.' || e.key === ',') {
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);
return String(
Number.isNaN(num) ? 0 : Math.floor(num)
);
},
},
]}
onChange={field.onChange}
value={
field.value && field.value.length > 0
? field.value
: [
{
stat_type: "day",
stat_value: 1,
traffic_usage: 0,
speed_limit: 0,
},
]
}
/>
</FormControl>
<FormDescription>