feat: 限速状态可视化 - 订阅详情 Sheet 顶部展示实时限速状态

- typings.d.ts 新增 UserSubscribeDetail.effective_speed/is_throttled/throttle_rule 字段
- SubscriptionDetail Sheet 打开时拉取订阅详情,顶部展示 SpeedLimitCard
- 被降速时显示警告样式 + 实际速率 + 划掉原始速率 + 触发规则说明

Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
shanshanzhong 2026-03-28 08:32:05 -07:00
parent 785c832e02
commit e6c2370d02
7 changed files with 97 additions and 22 deletions

1
.gitignore vendored
View File

@ -34,3 +34,4 @@ npm-debug.log*
# Misc # Misc
.DS_Store .DS_Store
*.pem *.pem
.mcp.json

Binary file not shown.

View File

@ -24,7 +24,7 @@ const notifySettingsSchema = z.object({
enable_balance_notify: z.boolean(), enable_balance_notify: z.boolean(),
enable_login_notify: z.boolean(), enable_login_notify: z.boolean(),
enable_subscribe_notify: z.boolean(), enable_subscribe_notify: z.boolean(),
enable_trade_notify: z.boolean(), // enable_trade_notify: z.boolean(),
}); });
type NotifySettingsValues = z.infer<typeof notifySettingsSchema>; type NotifySettingsValues = z.infer<typeof notifySettingsSchema>;
@ -44,7 +44,7 @@ export function NotifySettingsForm({
enable_balance_notify: user.enable_balance_notify, enable_balance_notify: user.enable_balance_notify,
enable_login_notify: user.enable_login_notify, enable_login_notify: user.enable_login_notify,
enable_subscribe_notify: user.enable_subscribe_notify, enable_subscribe_notify: user.enable_subscribe_notify,
enable_trade_notify: user.enable_trade_notify, // enable_trade_notify: user.enable_trade_notify,
}, },
}); });
@ -126,7 +126,7 @@ export function NotifySettingsForm({
)} )}
/> />
<FormField {/* <FormField
control={form.control} control={form.control}
name="enable_trade_notify" name="enable_trade_notify"
render={({ field }) => ( render={({ field }) => (
@ -142,7 +142,7 @@ export function NotifySettingsForm({
</FormControl> </FormControl>
</FormItem> </FormItem>
)} )}
/> /> */}
</div> </div>
</CardContent> </CardContent>
</Card> </Card>

View File

@ -451,7 +451,7 @@ function RowReadOnlyActions({
"This action cannot be undone." "This action cannot be undone."
)} )}
onConfirm={async () => { onConfirm={async () => {
await deleteUserSubscribe({ user_subscribe_id: String(row.id) }); await deleteUserSubscribe({ user_subscribe_id: row.id });
toast.success(t("deleteSuccess", "Deleted successfully")); toast.success(t("deleteSuccess", "Deleted successfully"));
refresh?.(); refresh?.();
}} }}
@ -636,7 +636,7 @@ function RowMoreActions({
"This action cannot be undone." "This action cannot be undone."
)} )}
onConfirm={async () => { onConfirm={async () => {
await deleteUserSubscribe({ user_subscribe_id: String(row.id) }); await deleteUserSubscribe({ user_subscribe_id: row.id });
toast.success(t("deleteSuccess", "Deleted successfully")); toast.success(t("deleteSuccess", "Deleted successfully"));
refresh(); refresh();
}} }}

View File

@ -11,16 +11,86 @@ import { Switch } from "@workspace/ui/components/switch";
import { ConfirmButton } from "@workspace/ui/composed/confirm-button"; import { ConfirmButton } from "@workspace/ui/composed/confirm-button";
import { ProTable } from "@workspace/ui/composed/pro-table/pro-table"; import { ProTable } from "@workspace/ui/composed/pro-table/pro-table";
import { import {
getUserSubscribeById,
getUserSubscribeDevices, getUserSubscribeDevices,
kickOfflineByUserDevice, kickOfflineByUserDevice,
} from "@workspace/ui/services/admin/user"; } from "@workspace/ui/services/admin/user";
import { deviceIdToHash } from "@workspace/ui/utils/device"; import { deviceIdToHash } from "@workspace/ui/utils/device";
import { type ReactNode, useState } from "react"; import { AlertTriangle, Gauge } from "lucide-react";
import { type ReactNode, useEffect, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { toast } from "sonner"; import { toast } from "sonner";
import { IpLink } from "@/components/ip-link"; import { IpLink } from "@/components/ip-link";
import { formatDate } from "@/utils/common"; import { formatDate } from "@/utils/common";
function SpeedLimitCard({ subscriptionId }: { subscriptionId: number }) {
const { t } = useTranslation("user");
const [detail, setDetail] = useState<API.UserSubscribeDetail | null>(null);
useEffect(() => {
getUserSubscribeById({ id: subscriptionId }).then(({ data }) => {
if (data.data) setDetail(data.data);
});
}, [subscriptionId]);
if (!detail || detail.status !== 1) return null;
const baseSpeed = detail.subscribe?.speed_limit ?? 0;
const effectiveSpeed = detail.effective_speed ?? 0;
const isThrottled = detail.is_throttled;
if (baseSpeed === 0 && !isThrottled) return null;
return (
<div
className={`mb-4 flex items-start gap-3 rounded-lg border p-3 text-sm ${
isThrottled
? "border-destructive/40 bg-destructive/5 text-destructive"
: "border-border bg-muted/40 text-foreground"
}`}
>
{isThrottled ? (
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
) : (
<Gauge className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
)}
<div className="space-y-1">
<div className="flex flex-wrap items-center gap-2 font-medium">
{isThrottled ? (
<>
<span>{t("throttled", "Speed Throttled")}</span>
<Badge className="text-xs" variant="destructive">
{effectiveSpeed} Mbps
</Badge>
{baseSpeed > 0 && (
<span className="text-muted-foreground text-xs line-through">
{baseSpeed} Mbps
</span>
)}
</>
) : (
<>
<span className="text-muted-foreground">
{t("speedLimit", "Speed Limit")}
</span>
<Badge className="text-xs" variant="secondary">
{effectiveSpeed > 0
? `${effectiveSpeed} Mbps`
: t("unlimited", "Unlimited")}
</Badge>
</>
)}
</div>
{isThrottled && detail.throttle_rule && (
<p className="text-muted-foreground text-xs">
{detail.throttle_rule}
</p>
)}
</div>
</div>
);
}
export function SubscriptionDetail({ export function SubscriptionDetail({
trigger, trigger,
userId, userId,
@ -44,6 +114,7 @@ export function SubscriptionDetail({
<SheetTitle>{t("onlineDevices", "Online Devices")}</SheetTitle> <SheetTitle>{t("onlineDevices", "Online Devices")}</SheetTitle>
</SheetHeader> </SheetHeader>
<div className="mt-4 max-h-[calc(100dvh-120px)] overflow-y-auto"> <div className="mt-4 max-h-[calc(100dvh-120px)] overflow-y-auto">
{open && <SpeedLimitCard subscriptionId={subscriptionId} />}
<ProTable<API.UserDevice, Record<string, unknown>> <ProTable<API.UserDevice, Record<string, unknown>>
actions={{ actions={{
render: (row) => { render: (row) => {

View File

@ -27,7 +27,7 @@ const FormSchema = z.object({
enable_balance_notify: z.boolean(), enable_balance_notify: z.boolean(),
enable_login_notify: z.boolean(), enable_login_notify: z.boolean(),
enable_subscribe_notify: z.boolean(), enable_subscribe_notify: z.boolean(),
enable_trade_notify: z.boolean(), // enable_trade_notify: z.boolean(),
}); });
export default function NotifySettings() { export default function NotifySettings() {
@ -39,7 +39,7 @@ export default function NotifySettings() {
enable_balance_notify: user?.enable_balance_notify ?? false, enable_balance_notify: user?.enable_balance_notify ?? false,
enable_login_notify: user?.enable_login_notify ?? false, enable_login_notify: user?.enable_login_notify ?? false,
enable_subscribe_notify: user?.enable_subscribe_notify ?? false, enable_subscribe_notify: user?.enable_subscribe_notify ?? false,
enable_trade_notify: user?.enable_trade_notify ?? false, // enable_trade_notify: user?.enable_trade_notify ?? false,
}, },
}); });
@ -80,10 +80,10 @@ export default function NotifySettings() {
name: "enable_subscribe_notify", name: "enable_subscribe_notify",
label: t("notify.subscribe", "Subscribe"), label: t("notify.subscribe", "Subscribe"),
}, },
{ // {
name: "enable_trade_notify", // name: "enable_trade_notify",
label: t("notify.finance", "Finance"), // label: t("notify.finance", "Finance"),
}, // },
].map(({ name, label }) => ( ].map(({ name, label }) => (
<FormField <FormField
control={form.control} control={form.control}

View File

@ -456,7 +456,7 @@ declare namespace API {
}; };
type DeleteUserSubscribeRequest = { type DeleteUserSubscribeRequest = {
user_subscribe_id: string; user_subscribe_id: number;
}; };
type DeviceAuthticateConfig = { type DeviceAuthticateConfig = {
@ -1850,11 +1850,11 @@ declare namespace API {
}; };
type ResetUserSubscribeTokenRequest = { type ResetUserSubscribeTokenRequest = {
user_subscribe_id: any; user_subscribe_id: number;
}; };
type ResetUserSubscribeTrafficRequest = { type ResetUserSubscribeTrafficRequest = {
user_subscribe_id: string; user_subscribe_id: number;
}; };
type Response = { type Response = {
@ -2177,7 +2177,7 @@ declare namespace API {
}; };
type ToggleUserSubscribeStatusRequest = { type ToggleUserSubscribeStatusRequest = {
user_subscribe_id: any; user_subscribe_id: number;
}; };
type TosConfig = { type TosConfig = {
@ -2406,7 +2406,7 @@ declare namespace API {
}; };
type UpdateUserSubscribeRequest = { type UpdateUserSubscribeRequest = {
user_subscribe_id: string; user_subscribe_id: number;
subscribe_id: number; subscribe_id: number;
traffic: number; traffic: number;
expired_at: number; expired_at: number;
@ -2431,7 +2431,7 @@ declare namespace API {
enable_login_notify: boolean; enable_login_notify: boolean;
enable_subscribe_notify: boolean; enable_subscribe_notify: boolean;
enable_trade_notify: boolean; enable_trade_notify: boolean;
user_group_id: string; user_group_id: number;
group_locked: boolean; group_locked: boolean;
auth_methods: UserAuthMethod[]; auth_methods: UserAuthMethod[];
user_devices: UserDevice[]; user_devices: UserDevice[];
@ -2523,6 +2523,9 @@ declare namespace API {
upload: number; upload: number;
token: string; token: string;
status: number; status: number;
effective_speed: number;
is_throttled: boolean;
throttle_rule?: string;
created_at: number; created_at: number;
updated_at: number; updated_at: number;
}; };
@ -2824,7 +2827,7 @@ declare namespace API {
type SubscribeGroupMapping = { type SubscribeGroupMapping = {
subscribe_id: number; subscribe_id: number;
user_group_id: string; user_group_id: number;
created_at: number; created_at: number;
updated_at: number; updated_at: number;
}; };
@ -2856,8 +2859,8 @@ declare namespace API {
type GroupHistoryDetailItem = { type GroupHistoryDetailItem = {
id: number; id: number;
history_id: number; history_id: number;
user_group_id: string; user_group_id: number;
node_group_id: string; node_group_id: number;
user_count: number; user_count: number;
node_count: number; node_count: number;
user_data?: string; user_data?: string;