diff --git a/apps/admin/public/assets/locales/en-US/user.json b/apps/admin/public/assets/locales/en-US/user.json index 4ba0f85..338417b 100644 --- a/apps/admin/public/assets/locales/en-US/user.json +++ b/apps/admin/public/assets/locales/en-US/user.json @@ -28,6 +28,7 @@ "createSubscription": "Create Subscription", "createSuccess": "Created successfully", "createUser": "Create User", + "current": "Current", "currentCommission": "Current Commission", "delete": "Delete", "deleted": "Deleted", @@ -117,6 +118,8 @@ "permanent": "Permanent", "prev": "Prev", "pleaseEnterEmail": "Enter email", + "hour": "Hour", + "day": "Day", "referer": "Referer", "refererId": "Referer ID", "refererIdPlaceholder": "Enter referer ID", @@ -149,7 +152,12 @@ "save": "Save", "shortCode": "Short Code", "speedLimit": "Speed Limit", + "speedLimitMbps": "Target Speed Limit (Mbps)", "startTime": "startTime", + "statType": "Statistics Type", + "statTypeDay": "Day", + "statTypeHour": "Hour", + "statValue": "Time Value", "status": "Status", "statusActive": "Active", "statusDeducted": "Deducted", @@ -171,6 +179,7 @@ "subscriptionInfo": "subscriptionInfo", "subscriptionList": "Subscription List", "subscriptionLogs": "Subscription Logs", + "subscriptionDefault": "Subscription Default", "subscriptionName": "subscriptionName", "subscriptionNotifications": "Subscription Notifications", "telephone": "Phone", @@ -181,17 +190,25 @@ "tradeNotifications": "Trade Notifications", "trafficDetails": "Traffic Details", "trafficLimit": "Traffic Limit", + "trafficLimitRule": "Rule", + "trafficLimitRules": "Traffic Limit Rules", + "trafficLimitRuleSummary": "{{traffic}} GB in {{value}} {{type}} -> {{speed}} Mbps", "trafficStats": "Traffic Stats", - "trafficUsage": "Traffic Usage", + "trafficUsage": "Traffic Threshold (GB)", + "throttled": "Throttled", "remainingTraffic": "Remaining Traffic", "unlimited": "unlimited", "unverified": "Unverified", "update": "Update", "updateSuccess": "Updated successfully", + "userOverride": "User Override", + "effectiveSpeed": "Effective Speed", "groupUpdated": "Group updated successfully", "upload": "Upload", "uploadTraffic": "Upload Traffic", "userStatus": "User Status", + "subscribed": "Subscribed", + "unsubscribed": "No active subscription", "userAgent": "User Agent", "userEmail": "Email", "userEmailPlaceholder": "Enter email", diff --git a/apps/admin/public/assets/locales/zh-CN/user.json b/apps/admin/public/assets/locales/zh-CN/user.json index 612e003..5216a02 100644 --- a/apps/admin/public/assets/locales/zh-CN/user.json +++ b/apps/admin/public/assets/locales/zh-CN/user.json @@ -28,6 +28,7 @@ "createSubscription": "创建订阅", "createSuccess": "创建成功", "createUser": "创建用户", + "current": "当前", "currentCommission": "当前佣金", "delete": "删除", "deleted": "已删除", @@ -118,6 +119,8 @@ "permanent": "永久", "prev": "上一页", "pleaseEnterEmail": "输入邮箱", + "hour": "小时", + "day": "天", "referer": "推荐人", "refererId": "推荐人 ID", "refererIdPlaceholder": "输入推荐人 ID", @@ -150,7 +153,12 @@ "save": "保存", "shortCode": "短码", "speedLimit": "速度限制", + "speedLimitMbps": "目标限速 (Mbps)", "startTime": "开始时间", + "statType": "统计周期", + "statTypeDay": "天", + "statTypeHour": "小时", + "statValue": "周期值", "status": "状态", "statusActive": "活跃", "statusDeducted": "已扣除", @@ -172,6 +180,7 @@ "subscriptionInfo": "订阅信息", "subscriptionList": "订阅列表", "subscriptionLogs": "订阅日志", + "subscriptionDefault": "套餐默认", "subscriptionName": "订阅名称", "subscriptionNotifications": "订阅通知", "telephone": "电话", @@ -182,17 +191,25 @@ "tradeNotifications": "交易通知", "trafficDetails": "流量详情", "trafficLimit": "流量限制", + "trafficLimitRule": "规则", + "trafficLimitRules": "流量限速规则", + "trafficLimitRuleSummary": "{{value}}{{type}}内达到 {{traffic}} GB 后限速到 {{speed}} Mbps", "trafficStats": "流量统计", - "trafficUsage": "流量使用", + "trafficUsage": "触发流量 (GB)", + "throttled": "已触发限速", "remainingTraffic": "剩余流量", "unlimited": "无限制", "unverified": "未验证", "update": "更新", "updateSuccess": "更新成功", + "userOverride": "用户覆盖", + "effectiveSpeed": "当前生效", "groupUpdated": "分组更新成功", "upload": "上传", "uploadTraffic": "上传流量", "userStatus": "用户状态", + "subscribed": "有有效订阅", + "unsubscribed": "无有效订阅", "userAgent": "用户代理", "userEmail": "邮箱", "userEmailPlaceholder": "输入邮箱", diff --git a/apps/admin/src/sections/dashboard/components/statistics.tsx b/apps/admin/src/sections/dashboard/components/statistics.tsx index 1009c41..17dee26 100644 --- a/apps/admin/src/sections/dashboard/components/statistics.tsx +++ b/apps/admin/src/sections/dashboard/components/statistics.tsx @@ -18,10 +18,8 @@ import { Separator } from "@workspace/ui/components/separator"; import { Tabs, TabsList, TabsTrigger } from "@workspace/ui/components/tabs"; import Empty from "@workspace/ui/composed/empty"; import { Icon } from "@workspace/ui/composed/icon"; -import { - queryServerTotalData, - queryTicketWaitReply, -} from "@workspace/ui/services/admin/console"; +import { queryServerTotalData } from "@workspace/ui/services/admin/console"; +import { getWithdrawalList } from "@workspace/ui/services/admin/withdrawal"; import { formatBytes } from "@workspace/ui/utils/formatting"; import { useState } from "react"; import { useTranslation } from "react-i18next"; @@ -41,11 +39,15 @@ import { UserStatisticsCard } from "./user-statistics-card"; export default function Statistics() { const { t } = useTranslation("dashboard"); - const { data: TicketTotal } = useQuery({ - queryKey: ["queryTicketWaitReply"], + const { data: PendingWithdrawalTotal } = useQuery({ + queryKey: ["queryPendingWithdrawalTotal"], queryFn: async () => { - const { data } = await queryTicketWaitReply(); - return data.data?.count; + const { data } = await getWithdrawalList({ + page: 1, + size: 1, + status: 0, + }); + return data.data?.total || 0; }, }); const { data: ServerTotal } = useQuery({ @@ -242,7 +244,7 @@ export default function Statistics() { }, { title: t("withdrawalManagement", "提现管理"), - value: TicketTotal || 0, + value: PendingWithdrawalTotal || 0, subtitle: t("pending", "Pending"), icon: "uil:clipboard-notes", href: "/dashboard/withdrawal", diff --git a/apps/admin/src/sections/promo/rule-form.tsx b/apps/admin/src/sections/promo/rule-form.tsx index 23404d1..a8808bf 100644 --- a/apps/admin/src/sections/promo/rule-form.tsx +++ b/apps/admin/src/sections/promo/rule-form.tsx @@ -93,8 +93,8 @@ function toFormValues(rule?: API.PromoRule): RuleFormValues { window_hours: rule?.params?.window_hours, priority: rule?.priority ?? 0, enabled: rule?.enabled ?? false, - start_time: rule?.start_time, - end_time: rule?.end_time, + start_time: rule?.start_time ? rule.start_time * 1000 : undefined, + end_time: rule?.end_time ? rule.end_time * 1000 : undefined, }; } @@ -112,8 +112,8 @@ function toRequest(values: RuleFormValues): API.CreatePromoRuleRequest { params, priority: values.priority, enabled: values.enabled, - start_time: values.start_time || undefined, - end_time: values.end_time || undefined, + start_time: values.start_time ? Math.floor(values.start_time / 1000) : undefined, + end_time: values.end_time ? Math.floor(values.end_time / 1000) : undefined, }; } diff --git a/apps/admin/src/sections/user/index.tsx b/apps/admin/src/sections/user/index.tsx index 2117b19..81465e0 100644 --- a/apps/admin/src/sections/user/index.tsx +++ b/apps/admin/src/sections/user/index.tsx @@ -66,7 +66,6 @@ import { formatDate } from "@/utils/common"; import FamilyManagement from "./family"; import { RemarkForm } from "./remark-form"; import { buildUserBasicInfoPayload } from "./user-basic-info-payload"; -import { UserDetail } from "./user-detail"; import UserForm from "./user-form"; import { UserIdentifier } from "./user-identifier"; import { UserInviteStatsSheet } from "./user-invite-stats-sheet"; @@ -336,23 +335,31 @@ export default function User() { id: "referer_id", accessorKey: "referer_id", header: t("referer", "Referer"), - cell: ({ row }) => , + cell: ({ row }) => ( + ref.current?.refresh()} + userId={row.original.referer_id} + /> + ), }, { - id: "last_login_time", - accessorKey: "last_login_time", + id: "member_status", + accessorKey: "member_status", header: t("userStatus", "用户状态"), cell: ({ row }) => { - const lastLoginTime = ( + // member_status 由后端基于当前有效订阅计算:有有效订阅时为套餐名,否则为空 + const memberStatus = ( row.original as API.User & { - last_login_time?: number; + member_status?: string; } - ).last_login_time; - const isOnline = Boolean(lastLoginTime); - const dotClassName = isOnline ? "bg-green-500" : "bg-gray-400"; - const statusLabel = isOnline - ? t("active", "Active") - : t("inactive", "Inactive"); + ).member_status; + const hasActiveSubscription = Boolean(memberStatus); + const dotClassName = hasActiveSubscription + ? "bg-green-500" + : "bg-gray-400"; + const tooltipLabel = hasActiveSubscription + ? memberStatus + : t("unsubscribed", "No active subscription"); return ( @@ -363,18 +370,10 @@ export default function User() { aria-hidden="true" className={`h-2.5 w-2.5 rounded-full ${dotClassName}`} /> - - {isOnline - ? `${statusLabel}: ${formatDate(lastLoginTime)}` - : statusLabel} - + {tooltipLabel} - - {isOnline - ? formatDate(lastLoginTime) - : t("neverLoggedIn", "Never logged in")} - + {tooltipLabel} ); @@ -439,9 +438,11 @@ export default function User() { function ProfileSheet({ userId, onUpdated, + trigger, }: { userId: number; onUpdated?: () => void; + trigger?: React.ReactNode; }) { const { t } = useTranslation("user"); const [open, setOpen] = useState(false); @@ -462,7 +463,7 @@ function ProfileSheet({ return ( - + {trigger || } void; +}) { + const { t } = useTranslation("user"); + const { data: user } = useQuery({ + enabled: userId > 0, + queryKey: ["refererUserDetail", userId], + queryFn: async () => { + const { data } = await getUserDetail({ id: userId }); + return data.data as API.User; + }, + }); + + if (!userId) return "--"; + + return ( + + {user ? ( + + ) : ( + t("loading", "Loading...") + )} + + } + userId={userId} + /> + ); +} + function InviteStatsMenuItem({ userId }: { userId: number }) { const { t } = useTranslation("user"); const [open, setOpen] = useState(false); diff --git a/apps/admin/src/sections/user/user-subscription/index.tsx b/apps/admin/src/sections/user/user-subscription/index.tsx index 7b56804..47bb185 100644 --- a/apps/admin/src/sections/user/user-subscription/index.tsx +++ b/apps/admin/src/sections/user/user-subscription/index.tsx @@ -316,19 +316,22 @@ export default function UserSubscription({ userId }: { userId: number }) { { accessorKey: "speed_limit", header: t("speedLimit", "Speed Limit"), + meta: { + className: "min-w-[220px] whitespace-normal", + }, cell: ({ row }) => { const userSpeed = row.original.speed_limit ?? 0; const effectiveSpeed = row.original.effective_speed ?? 0; const planSpeed = resolvePlanSpeed(row.original); const speed = effectiveSpeed || userSpeed || planSpeed; const source = row.original.is_throttled - ? t("effectiveSpeed", "Effective Speed") + ? t("effectiveSpeed") : userSpeed > 0 - ? t("userOverride", "User Override") - : t("subscriptionDefault", "Subscription Default"); + ? t("userOverride") + : t("subscriptionDefault"); return ( -
+
{row.original.is_throttled && ( @@ -337,19 +340,18 @@ export default function UserSubscription({ userId }: { userId: number }) { )}
- - {source} - - - {t("userOverride", "User Override")}:{" "} - +
+ {t("current")} + {source} + {t("userOverride")} + - {" "} - / {t("subscriptionDefault", "Subscription Default")}:{" "} - + + {t("subscriptionDefault")} + - +
); }, diff --git a/apps/admin/src/sections/user/user-subscription/subscription-detail.tsx b/apps/admin/src/sections/user/user-subscription/subscription-detail.tsx index 48c8d15..c280a09 100644 --- a/apps/admin/src/sections/user/user-subscription/subscription-detail.tsx +++ b/apps/admin/src/sections/user/user-subscription/subscription-detail.tsx @@ -121,13 +121,13 @@ function SpeedLimitCard({ subscriptionId }: { subscriptionId: number }) {
{isThrottled ? ( <> - {t("throttled", "Speed Throttled")} + {t("throttled")} {effectiveSpeed} Mbps {baseSpeed > 0 && ( - {t("subscriptionDefault", "Subscription Default")}:{" "} + {t("subscriptionDefault")}:{" "} {baseSpeed} Mbps )} @@ -138,24 +138,22 @@ function SpeedLimitCard({ subscriptionId }: { subscriptionId: number }) { {t("speedLimit", "Speed Limit")} - {effectiveSpeed > 0 - ? `${effectiveSpeed} Mbps` - : t("unlimited", "Unlimited")} + {effectiveSpeed > 0 ? `${effectiveSpeed} Mbps` : t("unlimited")} )}
@@ -173,7 +171,7 @@ function SpeedLimitCard({ subscriptionId }: { subscriptionId: number }) { {rules.length > 0 && (

- {t("trafficLimitRules", "Traffic Limit Rules")} + {t("trafficLimitRules")}

{rules.map((rule, index) => ( @@ -188,8 +186,8 @@ function SpeedLimitCard({ subscriptionId }: { subscriptionId: number }) { value: rule.stat_value, type: rule.stat_type === "hour" - ? t("hour", "Hour") - : t("day", "Day"), + ? t("hour") + : t("day"), speed: rule.speed_limit, })}
@@ -209,7 +207,7 @@ function SpeedMetric({ label, value }: { label: string; value: number }) {
{label}
- {value > 0 ? `${value} Mbps` : t("unlimited", "Unlimited")} + {value > 0 ? `${value} Mbps` : t("unlimited")}
); diff --git a/apps/admin/src/sections/user/user-subscription/subscription-form.tsx b/apps/admin/src/sections/user/user-subscription/subscription-form.tsx index 9b81260..9bcaf44 100644 --- a/apps/admin/src/sections/user/user-subscription/subscription-form.tsx +++ b/apps/admin/src/sections/user/user-subscription/subscription-form.tsx @@ -1,5 +1,11 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { Button } from "@workspace/ui/components/button"; +import { + Card, + CardContent, + CardHeader, + CardTitle, +} from "@workspace/ui/components/card"; import { Form, FormControl, @@ -20,10 +26,11 @@ import { } from "@workspace/ui/components/sheet"; import { Combobox } from "@workspace/ui/composed/combobox"; import { DatePicker } from "@workspace/ui/composed/date-picker"; -import { ArrayInput } from "@workspace/ui/composed/dynamic-inputs"; import { EnhancedInput } from "@workspace/ui/composed/enhanced-input"; import { Icon } from "@workspace/ui/composed/icon"; +import { cn } from "@workspace/ui/lib/utils"; import { unitConversion } from "@workspace/ui/utils/unit-conversions"; +import { CircleMinusIcon, CirclePlusIcon } from "lucide-react"; import { type KeyboardEvent, type ReactNode, useState } from "react"; import { useForm } from "react-hook-form"; import { useTranslation } from "react-i18next"; @@ -87,6 +94,199 @@ const normalizeLimitRule = ( speed_limit: Math.max(0, Number(item.speed_limit) || 0), }); +function TrafficLimitRuleEditor({ + value, + onChange, +}: { + value: SubscriptionTrafficLimitRule[]; + onChange: (value: SubscriptionTrafficLimitRule[]) => void; +}) { + const { t } = useTranslation("user"); + const rules = value.length > 0 ? value : [defaultLimitRule]; + + const updateRule = ( + index: number, + patch: Partial + ) => { + const next = rules.map((rule, i) => + i === index ? normalizeLimitRule({ ...rule, ...patch }) : rule + ); + onChange(next); + }; + + const addRule = () => { + onChange([...rules, defaultLimitRule]); + }; + + const removeRule = (index: number) => { + const next = rules.filter((_, i) => i !== index); + onChange(next.length > 0 ? next : []); + }; + + return ( +
+ {rules.map((rule, index) => ( + + +
+ + {t("trafficLimitRule")} {index + 1} + +
+ {rules.length > 1 && ( + + )} + {index === rules.length - 1 && ( + + )} +
+
+
+ +
+
+ + {t("statType")} + + + onChange={(nextValue) => { + updateRule(index, { + stat_type: nextValue || "day", + }); + }} + options={[ + { + label: t("statTypeHour"), + value: "hour", + }, + { + label: t("statTypeDay"), + value: "day", + }, + ]} + placeholder={t("statType")} + value={rule.stat_type} + /> +
+ +
+ + {t("statValue")} + + ) => { + if (e.key === "." || e.key === ",") { + e.preventDefault(); + } + }} + onValueChange={(nextValue) => { + updateRule(index, { + stat_value: Math.max( + 1, + Math.floor(Number(nextValue) || 1) + ), + }); + }} + placeholder={t("statValue")} + type="number" + value={rule.stat_value} + /> +
+ +
+ + {t("trafficUsage")} + + ) => { + if (e.key === "." || e.key === ",") { + e.preventDefault(); + } + }} + onValueChange={(nextValue) => { + updateRule(index, { + traffic_usage: Math.max( + 0, + Math.floor(Number(nextValue) || 0) + ), + }); + }} + placeholder={t("trafficUsage")} + suffix="GB" + type="number" + value={rule.traffic_usage} + /> +
+ +
+ + {t("speedLimitMbps")} + + ) => { + if (e.key === "." || e.key === ",") { + e.preventDefault(); + } + }} + onValueChange={(nextValue) => { + updateRule(index, { + speed_limit: Math.max( + 0, + Math.floor(Number(nextValue) || 0) + ), + }); + }} + placeholder={t("speedLimitMbps")} + suffix="Mbps" + type="number" + value={rule.speed_limit} + /> +
+
+ +
+ {t("trafficLimitRuleSummary", { + defaultValue: + "{{traffic}} GB in {{value}} {{type}} -> {{speed}} Mbps", + traffic: rule.traffic_usage, + value: rule.stat_value, + type: + rule.stat_type === "hour" + ? t("hour") + : t("day"), + speed: rule.speed_limit, + })} +
+
+
+ ))} +
+ ); +} + export function SubscriptionForm({ trigger, title, @@ -317,98 +517,11 @@ export function SubscriptionForm({ {t("trafficLimitRules", "Traffic Limit Rules")} - - className="grid grid-cols-2 gap-3 lg:grid-cols-4" - fields={[ - { - name: "stat_type", - type: "select", - placeholder: t("statType", "Statistics Type"), - value: "day", - options: [ - { - label: t("statTypeHour", "Hour"), - value: "hour", - }, - { - label: t("statTypeDay", "Day"), - value: "day", - }, - ], - }, - { - name: "stat_value", - type: "number", - placeholder: t("statValue", "Time Value"), - min: 1, - step: 1, - onKeyDown: ( - e: KeyboardEvent - ) => { - if (e.key === "." || e.key === ",") { - e.preventDefault(); - } - }, - formatOutput: (value: string | number) => { - const num = Number(value); - return String( - Number.isNaN(num) ? 1 : Math.floor(num) - ); - }, - }, - { - name: "traffic_usage", - type: "number", - placeholder: t( - "trafficUsage", - "Traffic Usage (GB)" - ), - min: 0, - onKeyDown: ( - e: KeyboardEvent - ) => { - if (e.key === "." || e.key === ",") { - e.preventDefault(); - } - }, - formatOutput: (value: string | number) => { - const num = Number(value); - return String( - Number.isNaN(num) ? 0 : Math.floor(num) - ); - }, - }, - { - name: "speed_limit", - type: "number", - placeholder: t( - "speedLimitMbps", - "Speed Limit (Mbps)" - ), - min: 0, - onKeyDown: ( - e: KeyboardEvent - ) => { - if (e.key === "." || e.key === ",") { - e.preventDefault(); - } - }, - formatOutput: (value: string | number) => { - const num = Number(value); - return String( - Number.isNaN(num) ? 0 : Math.floor(num) - ); - }, - }, - ]} + { field.onChange(items.map(normalizeLimitRule)); }} - value={ - field.value && field.value.length > 0 - ? field.value - : [defaultLimitRule] - } + value={field.value || []} /> diff --git a/packages/ui/src/composed/pro-table/pro-table.tsx b/packages/ui/src/composed/pro-table/pro-table.tsx index a111b09..d5b1d40 100644 --- a/packages/ui/src/composed/pro-table/pro-table.tsx +++ b/packages/ui/src/composed/pro-table/pro-table.tsx @@ -313,7 +313,8 @@ export function ProTable< @@ -362,7 +363,10 @@ export function ProTable< .filter((cell) => cell.column.id !== "sortable") .map((cell) => ( {flexRender( @@ -381,7 +385,10 @@ export function ProTable< > {row.getVisibleCells().map((cell) => ( {flexRender( @@ -476,3 +483,17 @@ function getTableCellClass(columnId: string) { } return "truncate"; } + +function getColumnClassName( + columnLike: { + column: { + columnDef: { + meta?: { + className?: string; + }; + }; + }; + } +) { + return columnLike.column.columnDef.meta?.className || ""; +} diff --git a/packages/ui/src/services/admin/typings.d.ts b/packages/ui/src/services/admin/typings.d.ts index 0280f6e..eb9650a 100644 --- a/packages/ui/src/services/admin/typings.d.ts +++ b/packages/ui/src/services/admin/typings.d.ts @@ -2598,6 +2598,9 @@ declare namespace API { user_devices: UserDevice[]; remark: string; rules: string[]; + /** 当前有效订阅的套餐名(由后端计算),无有效订阅时为空 */ + member_status?: string; + last_login_time?: number; created_at: number; updated_at: number; deleted_at?: number; diff --git a/packages/ui/src/services/user/user.ts b/packages/ui/src/services/user/user.ts index 16a6950..f48f01a 100644 --- a/packages/ui/src/services/user/user.ts +++ b/packages/ui/src/services/user/user.ts @@ -1,4 +1,3 @@ -// @ts-expect-error /* eslint-disable */ import request from "@workspace/ui/lib/request";