From f8fbf5529f9aa5a71451c8c4cae2367046cc3377 Mon Sep 17 00:00:00 2001 From: EUForest Date: Sat, 14 Mar 2026 17:59:27 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat:=20add=20traffic=20limit=20rul?= =?UTF-8?q?es=20and=20user=20traffic=20statistics?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../public/assets/locales/en-US/group.json | 16 +- .../public/assets/locales/en-US/product.json | 11 + .../public/assets/locales/zh-CN/group.json | 16 +- .../public/assets/locales/zh-CN/product.json | 11 + .../src/sections/group/node-group-form.tsx | 243 +++++++++++++++--- apps/admin/src/sections/group/node-groups.tsx | 14 +- .../src/sections/product/subscribe-form.tsx | 111 +++++++- .../src/sections/product/subscribe-table.tsx | 6 +- .../assets/locales/en-US/components.json | 1 + .../public/assets/locales/en-US/traffic.json | 16 ++ .../assets/locales/zh-CN/components.json | 1 + .../public/assets/locales/zh-CN/traffic.json | 16 ++ apps/user/src/layout/navs.ts | 5 + apps/user/src/routeTree.gen.ts | 27 ++ .../src/routes/(main)/(user)/traffic.lazy.tsx | 6 + .../user/traffic-statistics/index.tsx | 132 ++++++++++ .../traffic-ratio-chart.tsx | 55 ++++ .../traffic-stats-cards.tsx | 52 ++++ .../traffic-trend-chart.tsx | 81 ++++++ packages/ui/src/services/admin/typings.d.ts | 13 + packages/ui/src/services/user/index.ts | 2 + packages/ui/src/services/user/traffic.ts | 37 +++ packages/ui/src/services/user/typings.d.ts | 1 + 23 files changed, 823 insertions(+), 50 deletions(-) create mode 100644 apps/user/public/assets/locales/en-US/traffic.json create mode 100644 apps/user/public/assets/locales/zh-CN/traffic.json create mode 100644 apps/user/src/routes/(main)/(user)/traffic.lazy.tsx create mode 100644 apps/user/src/sections/user/traffic-statistics/index.tsx create mode 100644 apps/user/src/sections/user/traffic-statistics/traffic-ratio-chart.tsx create mode 100644 apps/user/src/sections/user/traffic-statistics/traffic-stats-cards.tsx create mode 100644 apps/user/src/sections/user/traffic-statistics/traffic-trend-chart.tsx create mode 100644 packages/ui/src/services/user/traffic.ts diff --git a/apps/admin/public/assets/locales/en-US/group.json b/apps/admin/public/assets/locales/en-US/group.json index 2c4dd85..cab6184 100644 --- a/apps/admin/public/assets/locales/en-US/group.json +++ b/apps/admin/public/assets/locales/en-US/group.json @@ -182,5 +182,19 @@ "validationFailed": "Validation failed", "totalNodeGroups": "Total Node Groups", "invalidRange": "Minimum traffic must be less than maximum traffic", - "rangeConflict": "Traffic range conflicts with node group \"{{name}}\" (range: {{min}} - {{max}} GB)" + "rangeConflict": "Traffic range conflicts with node group \"{{name}}\" (range: {{min}} - {{max}} GB)", + "isExpiredGroup": "Expired Node Group", + "isExpiredGroupDescription": "Allow expired users to use limited nodes", + "expiredDaysLimit": "Expired Days Limit", + "expiredDaysLimitDescription": "Number of days after expiration that users can still access nodes", + "maxTrafficGBExpired": "Max Traffic for Expired Users (GB)", + "maxTrafficGBExpiredDescription": "Maximum traffic allowed for expired users (0 = unlimited)", + "speedLimit": "Speed Limit (KB/s)", + "speedLimitDescription": "Speed limit for users in this node group (0 = unlimited)", + "expiredGroup": "Expired Only", + "expiredSettings": "Expired Settings", + "days": "days", + "expiredGroupExists": "System already has an expired node group: {{name}}", + "nodeGroupUsedBySubscribe": "This node group is used as default node group in subscription products, cannot set as expired group", + "expiredGroupForCalculationDescription": "Expired-only node groups cannot participate in group calculation" } diff --git a/apps/admin/public/assets/locales/en-US/product.json b/apps/admin/public/assets/locales/en-US/product.json index 5c19842..71765be 100644 --- a/apps/admin/public/assets/locales/en-US/product.json +++ b/apps/admin/public/assets/locales/en-US/product.json @@ -74,6 +74,17 @@ "showOriginalPriceDescription": "When enabled, the subscription card will display both the original price and the discounted price to help users understand the discount amount", "speedLimit": "Speed Limit ", "traffic": "Traffic", + "trafficLimit": "Traffic Limit", + "trafficLimitRules": "Traffic Limit Rules", + "trafficLimitDescription": "Configure traffic-based speed limit rules. When traffic usage reaches the specified amount, the speed will be limited.", + "addTrafficLimitRule": "Add Traffic Limit Rule", + "statType": "Statistics Type", + "selectStatType": "Select type...", + "statTypeHour": "Hour", + "statTypeDay": "Day", + "statValue": "Time Value", + "trafficUsage": "Traffic Usage (GB)", + "speedLimitKb": "Speed Limit (kb)", "unitPrice": "Unit Price", "unitTime": "Unit Time", "unlimitedInventory": "Unlimited (enter -1)", diff --git a/apps/admin/public/assets/locales/zh-CN/group.json b/apps/admin/public/assets/locales/zh-CN/group.json index 396197f..ad5c6fb 100644 --- a/apps/admin/public/assets/locales/zh-CN/group.json +++ b/apps/admin/public/assets/locales/zh-CN/group.json @@ -182,6 +182,20 @@ "validationFailed": "验证失败", "totalNodeGroups": "总节点组数", "invalidRange": "最小流量必须小于最大流量", - "rangeConflict": "流量区间与节点组 \"{{name}}\" 冲突(区间:{{min}} - {{max}} GB)" + "rangeConflict": "流量区间与节点组 \"{{name}}\" 冲突(区间:{{min}} - {{max}} GB)", + "isExpiredGroup": "过期节点组", + "isExpiredGroupDescription": "允许过期用户使用受限节点", + "expiredDaysLimit": "过期天数限制", + "expiredDaysLimitDescription": "用户订阅过期后仍可访问节点的天数", + "maxTrafficGBExpired": "过期用户最大流量 (GB)", + "maxTrafficGBExpiredDescription": "过期用户允许使用的最大流量(0 = 不限制)", + "speedLimit": "限速 (KB/s)", + "speedLimitDescription": "该节点组用户的速度限制(0 = 不限制)", + "expiredGroup": "过期专用", + "expiredSettings": "过期设置", + "days": "天", + "expiredGroupExists": "系统中已存在过期节点组:{{name}}", + "nodeGroupUsedBySubscribe": "该节点组已被订阅商品设置为默认节点组,不能设为过期节点组", + "expiredGroupForCalculationDescription": "过期专用节点组不能参与分组计算" } diff --git a/apps/admin/public/assets/locales/zh-CN/product.json b/apps/admin/public/assets/locales/zh-CN/product.json index d5705b6..6ff9d46 100644 --- a/apps/admin/public/assets/locales/zh-CN/product.json +++ b/apps/admin/public/assets/locales/zh-CN/product.json @@ -74,6 +74,17 @@ "showOriginalPriceDescription": "开启后,在订阅卡片上将会显示原价和折后价,帮助用户了解优惠幅度", "speedLimit": "速度限制", "traffic": "流量", + "trafficLimit": "按量限速", + "trafficLimitRules": "按量限速规则", + "trafficLimitDescription": "配置基于流量的限速规则。当流量使用达到指定量时,将进行限速。", + "addTrafficLimitRule": "添加限速规则", + "statType": "统计类型", + "selectStatType": "选择类型...", + "statTypeHour": "小时", + "statTypeDay": "天", + "statValue": "时间值", + "trafficUsage": "使用流量(GB)", + "speedLimitKb": "限速(kb)", "unitPrice": "单价", "unitTime": "时间单位", "unlimitedInventory": "无限制(输入 -1)", diff --git a/apps/admin/src/sections/group/node-group-form.tsx b/apps/admin/src/sections/group/node-group-form.tsx index 7fd553f..ad97df2 100644 --- a/apps/admin/src/sections/group/node-group-form.tsx +++ b/apps/admin/src/sections/group/node-group-form.tsx @@ -40,6 +40,10 @@ const NodeGroupForm = forwardRef< 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, }); @@ -53,6 +57,10 @@ const NodeGroupForm = forwardRef< 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, }); @@ -62,6 +70,10 @@ const NodeGroupForm = forwardRef< 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, }); @@ -118,16 +130,69 @@ const NodeGroupForm = forwardRef< return ""; }; + // 检测过期节点组冲突 + const checkExpiredGroupConflict = async (isExpiredGroup: boolean): Promise => { + 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 conflict = checkTrafficRangeConflict(values.min_traffic_gb, values.max_traffic_gb); - if (conflict) { - setConflictError(conflict); + // 检测过期节点组冲突 + 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); @@ -139,6 +204,10 @@ const NodeGroupForm = forwardRef< 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, }); @@ -207,69 +276,159 @@ const NodeGroupForm = forwardRef< {t("forCalculation", "For Calculation")}

- {t("forCalculationDescription", "Whether this node group participates in grouping calculation")} + {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")}

setValues({ ...values, for_calculation: checked }) } /> -
+ {/* 仅在没有其他过期节点组或当前就是过期节点组时显示 */} + {(!hasOtherExpiredGroup || isCurrentExpiredGroup) && (
- +
+ +

+ {t("isExpiredGroupDescription", "Allow expired users to use limited nodes")} +

+
+ { + 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); + }} + />
-

- {t("trafficRangeDescription", "Users with traffic >= Min and < Max will be assigned to this node group")} -

-
+ )} + + {values.is_expired_group && ( + <>
- + +

+ {t("expiredDaysLimitDescription", "Number of days after expiration that users can still access nodes")} +

{ - const newValue = parseFloat(e.target.value) || 0; - setValues({ ...values, min_traffic_gb: newValue }); - // 实时检测冲突 - const conflict = checkTrafficRangeConflict(newValue, values.max_traffic_gb); - setConflictError(conflict); - }} + min={1} + value={values.expired_days_limit} + onChange={(e) => + setValues({ ...values, expired_days_limit: parseInt(e.target.value) || 7 }) + } />
+
- + +

+ {t("maxTrafficGBExpiredDescription", "Maximum traffic allowed for expired users (0 = unlimited)")} +

{ - const newValue = parseFloat(e.target.value) || 0; - setValues({ ...values, max_traffic_gb: newValue }); - // 实时检测冲突 - const conflict = checkTrafficRangeConflict(values.min_traffic_gb, newValue); - setConflictError(conflict); - }} + value={values.max_traffic_gb_expired} + onChange={(e) => + setValues({ ...values, max_traffic_gb_expired: parseInt(e.target.value) || 0 }) + } />
-
- {/* 显示冲突错误 */} - {conflictError && ( -
- - {conflictError} + +
+ + + setValues({ ...values, speed_limit: parseInt(e.target.value) || 0 }) + } + />
- )} -
+ + )} + + {!values.is_expired_group && ( +
+
+ +
+

+ {t("trafficRangeDescription", "Users with traffic >= Min and < Max will be assigned to this node group")} +

+
+
+ + { + const newValue = parseFloat(e.target.value) || 0; + setValues({ ...values, min_traffic_gb: newValue }); + // 实时检测冲突 + const conflict = checkTrafficRangeConflict(newValue, values.max_traffic_gb); + setConflictError(conflict); + }} + /> +
+
+ + { + const newValue = parseFloat(e.target.value) || 0; + setValues({ ...values, max_traffic_gb: newValue }); + // 实时检测冲突 + const conflict = checkTrafficRangeConflict(values.min_traffic_gb, newValue); + setConflictError(conflict); + }} + /> +
+
+ {/* 显示冲突错误 */} + {conflictError && ( +
+ + {conflictError} +
+ )} +
+ )}
+ + {/* Traffic Limit Tab */} + +
+ ( + + {t("form.trafficLimitRules", "Traffic Limit Rules")} + + 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) => { + 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) => { + 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) => { + if (e.key === '.' || e.key === ',') { + e.preventDefault(); + } + }, + formatOutput: (value: string | number) => { + const num = Number(value); + return isNaN(num) ? 0 : Math.floor(num); + }, + }, + ]} + /> + + + {t( + "form.trafficLimitDescription", + "Configure traffic-based speed limit rules. When traffic usage reaches the specified amount, the speed will be limited." + )} + + + + )} + /> +
+
diff --git a/apps/admin/src/sections/product/subscribe-table.tsx b/apps/admin/src/sections/product/subscribe-table.tsx index a3f1a96..a5f0730 100644 --- a/apps/admin/src/sections/product/subscribe-table.tsx +++ b/apps/admin/src/sections/product/subscribe-table.tsx @@ -31,12 +31,14 @@ export default function SubscribeTable() { const ref = useRef(null); const { fetchSubscribes } = useSubscribe(); - // Fetch node groups for filtering + // Fetch node groups for filtering (exclude expired groups) const { data: nodeGroupsData } = useQuery({ queryKey: ["nodeGroups"], queryFn: async () => { const { data } = await getNodeGroupList({ page: 1, size: 1000 }); - return data.data?.list || []; + const allGroups = data.data?.list || []; + // Filter out expired node groups + return allGroups.filter((group) => !group.is_expired_group); }, }); diff --git a/apps/user/public/assets/locales/en-US/components.json b/apps/user/public/assets/locales/en-US/components.json index 7afc77c..9840237 100644 --- a/apps/user/public/assets/locales/en-US/components.json +++ b/apps/user/public/assets/locales/en-US/components.json @@ -76,6 +76,7 @@ "server": "Server Management", "subscribe": "Subscribe", "ticket": "Ticket Management", + "traffic": "Traffic Statistics", "wallet": "Balance" }, "pagination": { diff --git a/apps/user/public/assets/locales/en-US/traffic.json b/apps/user/public/assets/locales/en-US/traffic.json new file mode 100644 index 0000000..ba04ede --- /dev/null +++ b/apps/user/public/assets/locales/en-US/traffic.json @@ -0,0 +1,16 @@ +{ + "title": "Traffic Statistics", + "selectSubscription": "Select Subscription", + "days7": "7 Days", + "days30": "30 Days", + "totalTraffic": "Total Traffic", + "uploadTraffic": "Upload Traffic", + "downloadTraffic": "Download Traffic", + "trafficTrend": "Traffic Trend", + "trafficRatio": "Upload/Download Ratio", + "upload": "Upload", + "download": "Download", + "noData": "No traffic data available", + "date": "Date", + "traffic": "Traffic (MB)" +} diff --git a/apps/user/public/assets/locales/zh-CN/components.json b/apps/user/public/assets/locales/zh-CN/components.json index 41a7969..58eb9f5 100644 --- a/apps/user/public/assets/locales/zh-CN/components.json +++ b/apps/user/public/assets/locales/zh-CN/components.json @@ -76,6 +76,7 @@ "server": "服务器管理", "subscribe": "订阅管理", "ticket": "工单管理", + "traffic": "流量统计", "wallet": "余额管理" }, "pagination": { diff --git a/apps/user/public/assets/locales/zh-CN/traffic.json b/apps/user/public/assets/locales/zh-CN/traffic.json new file mode 100644 index 0000000..88409de --- /dev/null +++ b/apps/user/public/assets/locales/zh-CN/traffic.json @@ -0,0 +1,16 @@ +{ + "title": "流量统计", + "selectSubscription": "选择订阅", + "days7": "7 天", + "days30": "30 天", + "totalTraffic": "总流量", + "uploadTraffic": "上传流量", + "downloadTraffic": "下载流量", + "trafficTrend": "流量趋势", + "trafficRatio": "上传/下载占比", + "upload": "上传", + "download": "下载", + "noData": "暂无流量数据", + "date": "日期", + "traffic": "流量 (MB)" +} diff --git a/apps/user/src/layout/navs.ts b/apps/user/src/layout/navs.ts index 2e3878d..a17fa54 100644 --- a/apps/user/src/layout/navs.ts +++ b/apps/user/src/layout/navs.ts @@ -40,6 +40,11 @@ export function useNavs() { icon: "uil:shop", title: t("menu.subscribe", "Subscribe"), }, + { + url: "/traffic", + icon: "uil:chart-line", + title: t("menu.traffic", "Traffic Statistics"), + }, ], }, { diff --git a/apps/user/src/routeTree.gen.ts b/apps/user/src/routeTree.gen.ts index b6160cc..e51077a 100644 --- a/apps/user/src/routeTree.gen.ts +++ b/apps/user/src/routeTree.gen.ts @@ -28,6 +28,9 @@ const mainPurchasingIndexLazyRouteImport = createFileRoute( '/(main)/purchasing/', )() const mainuserWalletLazyRouteImport = createFileRoute('/(main)/(user)/wallet')() +const mainuserTrafficLazyRouteImport = createFileRoute( + '/(main)/(user)/traffic', +)() const mainuserTicketLazyRouteImport = createFileRoute('/(main)/(user)/ticket')() const mainuserSubscribeLazyRouteImport = createFileRoute( '/(main)/(user)/subscribe', @@ -126,6 +129,15 @@ const mainuserWalletLazyRoute = mainuserWalletLazyRouteImport getParentRoute: () => mainuserRouteLazyRoute, } as any) .lazy(() => import('./routes/(main)/(user)/wallet.lazy').then((d) => d.Route)) +const mainuserTrafficLazyRoute = mainuserTrafficLazyRouteImport + .update({ + id: '/traffic', + path: '/traffic', + getParentRoute: () => mainuserRouteLazyRoute, + } as any) + .lazy(() => + import('./routes/(main)/(user)/traffic.lazy').then((d) => d.Route), + ) const mainuserTicketLazyRoute = mainuserTicketLazyRouteImport .update({ id: '/ticket', @@ -220,6 +232,7 @@ export interface FileRoutesByFullPath { '/profile': typeof mainuserProfileLazyRoute '/subscribe': typeof mainuserSubscribeLazyRoute '/ticket': typeof mainuserTicketLazyRoute + '/traffic': typeof mainuserTrafficLazyRoute '/wallet': typeof mainuserWalletLazyRoute '/purchasing': typeof mainPurchasingIndexLazyRoute '/purchasing/order': typeof mainPurchasingOrderIndexRoute @@ -240,6 +253,7 @@ export interface FileRoutesByTo { '/profile': typeof mainuserProfileLazyRoute '/subscribe': typeof mainuserSubscribeLazyRoute '/ticket': typeof mainuserTicketLazyRoute + '/traffic': typeof mainuserTrafficLazyRoute '/wallet': typeof mainuserWalletLazyRoute '/purchasing': typeof mainPurchasingIndexLazyRoute '/purchasing/order': typeof mainPurchasingOrderIndexRoute @@ -263,6 +277,7 @@ export interface FileRoutesById { '/(main)/(user)/profile': typeof mainuserProfileLazyRoute '/(main)/(user)/subscribe': typeof mainuserSubscribeLazyRoute '/(main)/(user)/ticket': typeof mainuserTicketLazyRoute + '/(main)/(user)/traffic': typeof mainuserTrafficLazyRoute '/(main)/(user)/wallet': typeof mainuserWalletLazyRoute '/(main)/purchasing/': typeof mainPurchasingIndexLazyRoute '/(main)/purchasing/order/': typeof mainPurchasingOrderIndexRoute @@ -285,6 +300,7 @@ export interface FileRouteTypes { | '/profile' | '/subscribe' | '/ticket' + | '/traffic' | '/wallet' | '/purchasing' | '/purchasing/order' @@ -305,6 +321,7 @@ export interface FileRouteTypes { | '/profile' | '/subscribe' | '/ticket' + | '/traffic' | '/wallet' | '/purchasing' | '/purchasing/order' @@ -327,6 +344,7 @@ export interface FileRouteTypes { | '/(main)/(user)/profile' | '/(main)/(user)/subscribe' | '/(main)/(user)/ticket' + | '/(main)/(user)/traffic' | '/(main)/(user)/wallet' | '/(main)/purchasing/' | '/(main)/purchasing/order/' @@ -418,6 +436,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof mainuserWalletLazyRouteImport parentRoute: typeof mainuserRouteLazyRoute } + '/(main)/(user)/traffic': { + id: '/(main)/(user)/traffic' + path: '/traffic' + fullPath: '/traffic' + preLoaderRoute: typeof mainuserTrafficLazyRouteImport + parentRoute: typeof mainuserRouteLazyRoute + } '/(main)/(user)/ticket': { id: '/(main)/(user)/ticket' path: '/ticket' @@ -493,6 +518,7 @@ interface mainuserRouteLazyRouteChildren { mainuserProfileLazyRoute: typeof mainuserProfileLazyRoute mainuserSubscribeLazyRoute: typeof mainuserSubscribeLazyRoute mainuserTicketLazyRoute: typeof mainuserTicketLazyRoute + mainuserTrafficLazyRoute: typeof mainuserTrafficLazyRoute mainuserWalletLazyRoute: typeof mainuserWalletLazyRoute } @@ -505,6 +531,7 @@ const mainuserRouteLazyRouteChildren: mainuserRouteLazyRouteChildren = { mainuserProfileLazyRoute: mainuserProfileLazyRoute, mainuserSubscribeLazyRoute: mainuserSubscribeLazyRoute, mainuserTicketLazyRoute: mainuserTicketLazyRoute, + mainuserTrafficLazyRoute: mainuserTrafficLazyRoute, mainuserWalletLazyRoute: mainuserWalletLazyRoute, } diff --git a/apps/user/src/routes/(main)/(user)/traffic.lazy.tsx b/apps/user/src/routes/(main)/(user)/traffic.lazy.tsx new file mode 100644 index 0000000..a906fcd --- /dev/null +++ b/apps/user/src/routes/(main)/(user)/traffic.lazy.tsx @@ -0,0 +1,6 @@ +import { createLazyFileRoute } from "@tanstack/react-router"; +import TrafficStatistics from "@/sections/user/traffic-statistics"; + +export const Route = createLazyFileRoute("/(main)/(user)/traffic")({ + component: TrafficStatistics, +}); diff --git a/apps/user/src/sections/user/traffic-statistics/index.tsx b/apps/user/src/sections/user/traffic-statistics/index.tsx new file mode 100644 index 0000000..2a21588 --- /dev/null +++ b/apps/user/src/sections/user/traffic-statistics/index.tsx @@ -0,0 +1,132 @@ +import { useQuery } from "@tanstack/react-query"; +import { Card, CardContent, CardHeader, CardTitle } from "@workspace/ui/components/card"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@workspace/ui/components/select"; +import { Tabs, TabsList, TabsTrigger } from "@workspace/ui/components/tabs"; +import { Icon } from "@workspace/ui/composed/icon"; +import { queryUserSubscribe } from "@workspace/ui/services/user/user"; +import { getUserTrafficStats } from "@workspace/ui/services/user/traffic"; +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import TrafficTrendChart from "./traffic-trend-chart"; +import TrafficRatioChart from "./traffic-ratio-chart"; +import TrafficStatsCards from "./traffic-stats-cards"; + +export default function TrafficStatistics() { + const { t } = useTranslation("traffic"); + const [days, setDays] = useState<7 | 30>(7); + const [selectedSubscribeId, setSelectedSubscribeId] = useState(null); + + // 查询用户订阅列表 + const { data: userSubscribe = [] } = useQuery({ + queryKey: ["queryUserSubscribe"], + queryFn: async () => { + const { data } = await queryUserSubscribe(); + return data.data?.list || []; + }, + }); + + // 使用 id_str 字段,避免 JavaScript 精度丢失 + const activeSubscribeId = selectedSubscribeId || (userSubscribe[0]?.id_str || null); + + // 查询流量统计数据 + const { data: trafficStats, isLoading } = useQuery({ + queryKey: ["getUserTrafficStats", activeSubscribeId, days], + queryFn: async () => { + if (!activeSubscribeId) return null; + const { data } = await getUserTrafficStats({ + user_subscribe_id: activeSubscribeId, + days, + }); + return data.data; + }, + enabled: !!activeSubscribeId, + }); + + return ( +
+ {/* 标题和控制栏 */} +
+

+ + {t("title", "Traffic Statistics")} +

+
+ {/* 订阅选择 */} + {userSubscribe.length > 1 && ( + + )} + {/* 时间范围切换 */} + setDays(Number(value) as 7 | 30)}> + + {t("days7", "7 Days")} + {t("days30", "30 Days")} + + +
+
+ + {/* 统计卡片 */} + {trafficStats && } + + {/* 图表区域 */} +
+ {/* 流量趋势图 */} + + + {t("trafficTrend", "Traffic Trend")} + + + {isLoading ? ( +
+ +
+ ) : trafficStats && trafficStats.list.length > 0 ? ( + + ) : ( +
+ {t("noData", "No traffic data available")} +
+ )} +
+
+ + {/* 流量占比图 */} + + + {t("trafficRatio", "Upload/Download Ratio")} + + + {isLoading ? ( +
+ +
+ ) : trafficStats && (trafficStats.total_upload > 0 || trafficStats.total_download > 0) ? ( + + ) : ( +
+ {t("noData", "No traffic data available")} +
+ )} +
+
+
+
+ ); +} \ No newline at end of file diff --git a/apps/user/src/sections/user/traffic-statistics/traffic-ratio-chart.tsx b/apps/user/src/sections/user/traffic-statistics/traffic-ratio-chart.tsx new file mode 100644 index 0000000..b729b2d --- /dev/null +++ b/apps/user/src/sections/user/traffic-statistics/traffic-ratio-chart.tsx @@ -0,0 +1,55 @@ +import { useTranslation } from "react-i18next"; +import { PieChart, Pie, Cell, ResponsiveContainer, Legend, Tooltip } from "recharts"; + +interface TrafficRatioChartProps { + upload: number; + download: number; +} + +export default function TrafficRatioChart({ upload, download }: TrafficRatioChartProps) { + const { t } = useTranslation("traffic"); + + const data = [ + { name: t("upload", "Upload"), value: upload }, + { name: t("download", "Download"), value: download }, + ]; + + const COLORS = ["#10b981", "#3b82f6"]; + + // 格式化流量显示 + const formatTraffic = (value: number) => { + if (value >= 1024 * 1024 * 1024) { + return `${(value / (1024 * 1024 * 1024)).toFixed(2)} GB`; + } + if (value >= 1024 * 1024) { + return `${(value / (1024 * 1024)).toFixed(2)} MB`; + } + if (value >= 1024) { + return `${(value / 1024).toFixed(2)} KB`; + } + return `${value} B`; + }; + + return ( + + + `${name}: ${(percent * 100).toFixed(1)}%`} + outerRadius={80} + fill="#8884d8" + dataKey="value" + > + {data.map((_, index) => ( + + ))} + + formatTraffic(value)} /> + + + + ); +} diff --git a/apps/user/src/sections/user/traffic-statistics/traffic-stats-cards.tsx b/apps/user/src/sections/user/traffic-statistics/traffic-stats-cards.tsx new file mode 100644 index 0000000..672978a --- /dev/null +++ b/apps/user/src/sections/user/traffic-statistics/traffic-stats-cards.tsx @@ -0,0 +1,52 @@ +import { Card, CardContent } from "@workspace/ui/components/card"; +import { Icon } from "@workspace/ui/composed/icon"; +import type { GetUserTrafficStatsResponse } from "@workspace/ui/services/user/traffic"; +import { useTranslation } from "react-i18next"; +import { Display } from "@/components/display"; + +interface TrafficStatsCardsProps { + stats: GetUserTrafficStatsResponse; +} + +export default function TrafficStatsCards({ stats }: TrafficStatsCardsProps) { + const { t } = useTranslation("traffic"); + + const cards = [ + { + title: t("totalTraffic", "Total Traffic"), + value: stats.total_traffic, + icon: "uil:chart-line", + color: "text-blue-500", + }, + { + title: t("uploadTraffic", "Upload Traffic"), + value: stats.total_upload, + icon: "uil:upload", + color: "text-green-500", + }, + { + title: t("downloadTraffic", "Download Traffic"), + value: stats.total_download, + icon: "uil:download", + color: "text-purple-500", + }, + ]; + + return ( +
+ {cards.map((card) => ( + + +
+ {card.title} + + + +
+ +
+
+ ))} +
+ ); +} diff --git a/apps/user/src/sections/user/traffic-statistics/traffic-trend-chart.tsx b/apps/user/src/sections/user/traffic-statistics/traffic-trend-chart.tsx new file mode 100644 index 0000000..ff56e42 --- /dev/null +++ b/apps/user/src/sections/user/traffic-statistics/traffic-trend-chart.tsx @@ -0,0 +1,81 @@ +import type { GetUserTrafficStatsResponse } from "@workspace/ui/services/user/traffic"; +import { format } from "date-fns"; +import { useTranslation } from "react-i18next"; +import { + LineChart, + Line, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + Legend, + ResponsiveContainer, +} from "recharts"; + +interface TrafficTrendChartProps { + data: GetUserTrafficStatsResponse["list"]; +} + +export default function TrafficTrendChart({ data }: TrafficTrendChartProps) { + const { t } = useTranslation("traffic"); + + // 转换数据格式,将字节转换为 MB(保持为数字) + const chartData = data.map((item) => ({ + date: format(new Date(item.date), "MM-dd"), + upload: Number((item.upload / (1024 * 1024)).toFixed(2)), + download: Number((item.download / (1024 * 1024)).toFixed(2)), + })); + + // 格式化流量显示 + const formatTraffic = (value: number | string) => { + const numValue = typeof value === "string" ? parseFloat(value) : value; + if (numValue >= 1024) { + return `${(numValue / 1024).toFixed(2)} GB`; + } + return `${numValue.toFixed(2)} MB`; + }; + + return ( + + + + + + formatTraffic(value)} + labelStyle={{ color: "#000" }} + /> + + + + + + ); +} diff --git a/packages/ui/src/services/admin/typings.d.ts b/packages/ui/src/services/admin/typings.d.ts index d835996..6a0a4fd 100644 --- a/packages/ui/src/services/admin/typings.d.ts +++ b/packages/ui/src/services/admin/typings.d.ts @@ -2693,8 +2693,13 @@ declare namespace API { description: string; sort: number; for_calculation: boolean; + is_expired_group: boolean; + expired_days_limit: number; + max_traffic_gb_expired?: number; + speed_limit: number; min_traffic_gb?: number; max_traffic_gb?: number; + node_count?: number; created_at: number; updated_at: number; }; @@ -2832,6 +2837,10 @@ declare namespace API { description?: string; sort?: number; for_calculation?: boolean; + is_expired_group?: boolean; + expired_days_limit?: number; + max_traffic_gb_expired?: number; + speed_limit?: number; min_traffic_gb?: number; max_traffic_gb?: number; }; @@ -2842,6 +2851,10 @@ declare namespace API { description?: string; sort?: number; for_calculation?: boolean; + is_expired_group?: boolean; + expired_days_limit?: number; + max_traffic_gb_expired?: number; + speed_limit?: number; min_traffic_gb?: number; max_traffic_gb?: number; }; diff --git a/packages/ui/src/services/user/index.ts b/packages/ui/src/services/user/index.ts index e67bdfa..08837ce 100644 --- a/packages/ui/src/services/user/index.ts +++ b/packages/ui/src/services/user/index.ts @@ -9,6 +9,7 @@ import * as payment from "./payment"; import * as portal from "./portal"; import * as subscribe from "./subscribe"; import * as ticket from "./ticket"; +import * as traffic from "./traffic"; import * as user from "./user"; export default { announcement, @@ -18,5 +19,6 @@ export default { portal, subscribe, ticket, + traffic, user, }; diff --git a/packages/ui/src/services/user/traffic.ts b/packages/ui/src/services/user/traffic.ts new file mode 100644 index 0000000..ffdcf8d --- /dev/null +++ b/packages/ui/src/services/user/traffic.ts @@ -0,0 +1,37 @@ +import request from "@workspace/ui/lib/request"; + +export interface GetUserTrafficStatsRequest { + user_subscribe_id: string; // 保持字符串,避免精度问题 + days: 7 | 30; +} + +export interface DailyTrafficStats { + date: string; + upload: number; + download: number; + total: number; +} + +export interface GetUserTrafficStatsResponse { + list: DailyTrafficStats[]; + total_upload: number; + total_download: number; + total_traffic: number; +} + +/** Get User Traffic Statistics GET /v1/public/user/traffic_stats */ +export async function getUserTrafficStats( + params: GetUserTrafficStatsRequest, + options?: { [key: string]: any } +) { + return request( + `${import.meta.env.VITE_API_PREFIX || ""}/v1/public/user/traffic_stats`, + { + method: "GET", + params: { + ...params, + }, + ...(options || {}), + } + ); +} diff --git a/packages/ui/src/services/user/typings.d.ts b/packages/ui/src/services/user/typings.d.ts index b37e50d..c377f8a 100644 --- a/packages/ui/src/services/user/typings.d.ts +++ b/packages/ui/src/services/user/typings.d.ts @@ -1184,6 +1184,7 @@ declare namespace API { type UserSubscribe = { id: number; + id_str: string; user_id: number; order_id: number; subscribe_id: number;