Files
hi-frontend/apps/admin/src/sections/user/user-subscription/index.tsx
T
shanshanzhong147 2a3d5a3234
PR Check / Lint, Check, Test and Build (push) Has been cancelled
Build and Release / Build (push) Has been cancelled
修复(#117): 补齐用户订阅限速配置 UI
2026-05-29 00:31:56 -07:00

701 lines
23 KiB
TypeScript

import { Link, useNavigate } from "@tanstack/react-router";
import { Alert, AlertDescription } from "@workspace/ui/components/alert";
import { Badge } from "@workspace/ui/components/badge";
import { Button } from "@workspace/ui/components/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@workspace/ui/components/dropdown-menu";
import { ConfirmButton } from "@workspace/ui/composed/confirm-button";
import {
ProTable,
type ProTableActions,
} from "@workspace/ui/composed/pro-table/pro-table";
import {
createUserSubscribe,
deleteUserSubscribe,
getFamilyList,
getUserSubscribe,
resetUserSubscribeToken,
toggleUserSubscribeStatus,
updateUserSubscribe,
} from "@workspace/ui/services/admin/user";
import { Info } from "lucide-react";
import { useCallback, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { Display } from "@/components/display";
import { useGlobalStore } from "@/stores/global";
import { formatDate } from "@/utils/common";
import { SubscriptionDetail } from "./subscription-detail";
import {
SubscriptionForm,
type SubscriptionFormValues,
} from "./subscription-form";
interface SharedInfo {
ownerUserId: number;
familyId: number;
}
function toUserSubscribePayload(values: SubscriptionFormValues) {
return {
...values,
expired_at: values.expired_at || 0,
subscribe_id: values.subscribe_id || 0,
traffic: values.traffic || 0,
upload: values.upload || 0,
download: values.download || 0,
speed_limit: values.speed_limit || 0,
device_limit: values.device_limit || 0,
traffic_limit: values.traffic_limit || [],
};
}
export default function UserSubscription({ userId }: { userId: number }) {
const { t } = useTranslation("user");
const [loading, setLoading] = useState(false);
const ref = useRef<ProTableActions>(null);
const [sharedInfo, setSharedInfo] = useState<SharedInfo | null>(null);
const navigate = useNavigate();
const request = useCallback(
async (pagination: { page: number; size: number }) => {
// 1. Fetch user's own subscriptions
const { data } = await getUserSubscribe({
user_id: userId,
...pagination,
});
const list = data.data?.list || [];
const total = data.data?.total || 0;
// 2. If user has own subscriptions, show them directly
if (list.length > 0) {
setSharedInfo(null);
return { list, total };
}
// 3. Check if user belongs to a device group
try {
const { data: familyData } = await getFamilyList({
user_id: userId,
page: 1,
size: 1,
});
const familyList = familyData.data?.list || [];
const family = familyList.find(
(f) => f.owner_user_id !== userId && f.status === "active"
);
if (family) {
// 4. Fetch owner's subscriptions
const { data: ownerData } = await getUserSubscribe({
user_id: family.owner_user_id,
...pagination,
});
const ownerList = ownerData.data?.list || [];
const ownerTotal = ownerData.data?.total || 0;
if (ownerList.length > 0) {
setSharedInfo({
ownerUserId: family.owner_user_id,
familyId: family.family_id,
});
return { list: ownerList, total: ownerTotal };
}
}
} catch {
// Silently fall through to show empty list
}
setSharedInfo(null);
return { list: [], total: 0 };
},
[userId]
);
const isSharedView = !!sharedInfo;
return (
<div className="space-y-3">
{isSharedView && (
<Alert>
<Info className="h-4 w-4" />
<AlertDescription className="flex items-center justify-between">
<span>
{t("sharedSubscriptionInfo", {
defaultValue:
"This user is a device group member. Showing shared subscriptions from owner (ID: {{ownerId}})",
ownerId: sharedInfo.ownerUserId,
})}
</span>
<span className="flex gap-2">
<Button asChild size="sm" variant="outline">
<Link
search={{ user_id: sharedInfo.ownerUserId }}
to="/dashboard/family"
>
{t("viewDeviceGroup", "View Device Group")}
</Link>
</Button>
<Button
onClick={() => {
navigate({
to: "/dashboard/user",
search: { user_id: sharedInfo.ownerUserId },
});
}}
size="sm"
variant="outline"
>
{t("viewOwner", "View Owner")}
</Button>
</span>
</AlertDescription>
</Alert>
)}
<ProTable<API.UserSubscribe, Record<string, unknown>>
action={ref}
actions={{
render: (row) =>
isSharedView
? [
<Badge key="shared" variant="secondary">
{t("sharedSubscription", "Shared")}
</Badge>,
<RowReadOnlyActions
key="more"
refresh={() => ref.current?.refresh()}
row={row}
token={row.token}
userId={sharedInfo!.ownerUserId}
/>,
]
: [
<SubscriptionForm
initialData={row}
key="edit"
loading={loading}
onSubmit={async (values) => {
setLoading(true);
await updateUserSubscribe({
user_id: Number(userId),
user_subscribe_id: row.id,
...toUserSubscribePayload(values),
});
toast.success(t("updateSuccess", "Updated successfully"));
ref.current?.refresh();
setLoading(false);
return true;
}}
title={t("editSubscription", "Edit Subscription")}
trigger={t("edit", "Edit")}
/>,
<RowMoreActions
key="more"
refresh={() => ref.current?.refresh()}
row={row}
token={row.token}
userId={userId}
/>,
],
}}
columns={[
{
accessorKey: "id",
header: "ID",
},
{
accessorKey: "name",
header: t("subscriptionName", "Subscription Name"),
cell: ({ row }) => (
<span className="flex items-center gap-2">
{row.original.subscribe.name}
{isSharedView && (
<Badge variant="secondary">
{t("sharedSubscription", "Shared")}
</Badge>
)}
</span>
),
},
{
accessorKey: "status",
header: t("status", "Status"),
cell: ({ row }) => {
const status = row.getValue("status") as number;
const expireTime = row.original.expire_time;
// 如果过期时间为0,说明是永久订阅,应该显示为激活状态
const displayStatus =
status === 3 && expireTime === 0 ? 1 : status;
const statusMap: Record<
number,
{
label: string;
variant: "default" | "secondary" | "destructive" | "outline";
}
> = {
0: {
label: t("statusPending", "Pending"),
variant: "outline",
},
1: { label: t("statusActive", "Active"), variant: "default" },
2: {
label: t("statusFinished", "Finished"),
variant: "secondary",
},
3: {
label: t("statusExpired", "Expired"),
variant: "destructive",
},
4: {
label: t("statusDeducted", "Deducted"),
variant: "secondary",
},
5: {
label: t("statusStopped", "Stopped"),
variant: "destructive",
},
};
const statusInfo = statusMap[displayStatus] || {
label: "Unknown",
variant: "outline",
};
return (
<Badge variant={statusInfo.variant}>{statusInfo.label}</Badge>
);
},
},
{
accessorKey: "upload",
header: t("upload", "Upload"),
cell: ({ row }) => (
<Display type="traffic" value={row.getValue("upload")} />
),
},
{
accessorKey: "download",
header: t("download", "Download"),
cell: ({ row }) => (
<Display type="traffic" value={row.getValue("download")} />
),
},
{
accessorKey: "traffic",
header: t("totalTraffic", "Total Traffic"),
cell: ({ row }) => (
<Display
type="traffic"
unlimited
value={row.getValue("traffic")}
/>
),
},
{
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"),
cell: ({ row }) => {
const userSpeed = row.original.speed_limit ?? 0;
const effectiveSpeed = row.original.effective_speed ?? 0;
const planSpeed = row.original.subscribe?.speed_limit ?? 0;
const speed = effectiveSpeed || userSpeed || planSpeed;
const source =
userSpeed > 0
? t("userOverride", "User Override")
: t("subscriptionDefault", "Subscription Default");
return (
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2">
<Display type="trafficSpeed" value={speed} />
{row.original.is_throttled && (
<Badge variant="destructive">
{t("throttled", "Throttled")}
</Badge>
)}
</div>
<span className="text-muted-foreground text-xs">
{source}
</span>
</div>
);
},
},
{
accessorKey: "device_limit",
header: t("deviceLimit", "Device Limit"),
cell: ({ row }) => {
const limit =
row.original.device_limit ||
row.original?.subscribe?.device_limit;
return <Display type="number" unlimited value={limit} />;
},
},
{
accessorKey: "reset_time",
header: t("resetTime", "Reset Time"),
cell: ({ row }) => (
<Display
type="number"
unlimited
value={row.getValue("reset_time")}
/>
),
},
{
accessorKey: "expire_time",
header: t("expireTime", "Expire Time"),
cell: ({ row }) => {
const expireTime = row.getValue("expire_time") as number;
return expireTime && expireTime !== 0
? formatDate(expireTime)
: t("permanent", "Permanent");
},
},
{
accessorKey: "node_group_name",
header: t("nodeGroup", "Node Group"),
cell: ({ row }) => row.original.node_group_name || "-",
},
{
accessorKey: "created_at",
header: t("createdAt", "Created At"),
cell: ({ row }) => formatDate(row.getValue("created_at")),
},
]}
header={{
title: isSharedView
? t("sharedSubscriptionList", "Shared Subscription List")
: t("subscriptionList", "Subscription List"),
toolbar: isSharedView ? undefined : (
<SubscriptionForm
key="create"
loading={loading}
onSubmit={async (values) => {
setLoading(true);
await createUserSubscribe({
user_id: Number(userId),
...toUserSubscribePayload(values),
});
toast.success(t("createSuccess", "Created successfully"));
ref.current?.refresh();
setLoading(false);
return true;
}}
title={t("createSubscription", "Create Subscription")}
trigger={t("add", "Add")}
/>
),
}}
request={request}
/>
</div>
);
}
function RowReadOnlyActions({
userId,
row,
token,
refresh,
}: {
userId: number;
row: API.UserSubscribe;
token: string;
refresh?: () => void;
}) {
const { t } = useTranslation("user");
const triggerRef = useRef<HTMLButtonElement>(null);
const deleteRef = useRef<HTMLButtonElement>(null);
const { getUserSubscribe: getUserSubscribeUrls } = useGlobalStore();
return (
<div className="inline-flex">
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button variant="outline">{t("more", "More")}</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onSelect={async (e) => {
e.preventDefault();
await navigator.clipboard.writeText(
getUserSubscribeUrls(row.short, token)[0] || ""
);
toast.success(t("copySuccess", "Copied successfully"));
}}
>
{t("copySubscription", "Copy Subscription")}
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link
search={{ user_id: userId, user_subscribe_id: row.id }}
to="/dashboard/log/subscribe"
>
{t("subscriptionLogs", "Subscription Logs")}
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link
search={{ user_id: userId, user_subscribe_id: row.id }}
to="/dashboard/log/subscribe-traffic"
>
{t("trafficStats", "Traffic Stats")}
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link
search={{ user_id: userId, subscribe_id: row.id }}
to="/dashboard/log/traffic-details"
>
{t("trafficDetails", "Traffic Details")}
</Link>
</DropdownMenuItem>
<DropdownMenuItem
onSelect={(e) => {
e.preventDefault();
triggerRef.current?.click();
}}
>
{t("onlineDevices", "Online Devices")}
</DropdownMenuItem>
<DropdownMenuItem
className="text-destructive"
onSelect={(e) => {
e.preventDefault();
deleteRef.current?.click();
}}
>
{t("delete", "Delete")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<ConfirmButton
cancelText={t("cancel", "Cancel")}
confirmText={t("confirm", "Confirm")}
description={t(
"deleteSubscriptionDescription",
"This action cannot be undone."
)}
onConfirm={async () => {
await deleteUserSubscribe({ user_subscribe_id: row.id });
toast.success(t("deleteSuccess", "Deleted successfully"));
refresh?.();
}}
title={t("confirmDelete", "Confirm Delete")}
trigger={<Button className="hidden" ref={deleteRef} />}
/>
<SubscriptionDetail
subscriptionId={row.id}
trigger={<Button className="hidden" ref={triggerRef} />}
userId={userId}
/>
</div>
);
}
function RowMoreActions({
userId,
row,
token,
refresh,
}: {
userId: number;
row: API.UserSubscribe;
token: string;
refresh: () => void;
}) {
const triggerRef = useRef<HTMLButtonElement>(null);
const resetTokenRef = useRef<HTMLButtonElement>(null);
const toggleStatusRef = useRef<HTMLButtonElement>(null);
const deleteRef = useRef<HTMLButtonElement>(null);
const { t } = useTranslation("user");
const { getUserSubscribe: getUserSubscribeUrls } = useGlobalStore();
return (
<div className="inline-flex">
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button variant="outline">{t("more", "More")}</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onSelect={async (e) => {
e.preventDefault();
await navigator.clipboard.writeText(
getUserSubscribeUrls(row.short, token)[0] || ""
);
toast.success(t("copySuccess", "Copied successfully"));
}}
>
{t("copySubscription", "Copy Subscription")}
</DropdownMenuItem>
<DropdownMenuItem
onSelect={(e) => {
e.preventDefault();
resetTokenRef.current?.click();
}}
>
{t("resetToken", "Reset Subscription Address")}
</DropdownMenuItem>
<DropdownMenuItem
onSelect={(e) => {
e.preventDefault();
toggleStatusRef.current?.click();
}}
>
{row.status === 5
? t("resumeSubscribe", "Resume Subscription")
: t("stopSubscribe", "Stop Subscription")}
</DropdownMenuItem>
<DropdownMenuItem
className="text-destructive"
onSelect={(e) => {
e.preventDefault();
deleteRef.current?.click();
}}
>
{t("delete", "Delete")}
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link
search={{ user_id: userId, user_subscribe_id: row.id }}
to="/dashboard/log/subscribe"
>
{t("subscriptionLogs", "Subscription Logs")}
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link
search={{ user_id: userId, user_subscribe_id: row.id }}
to="/dashboard/log/reset-subscribe"
>
{t("resetLogs", "Reset Logs")}
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link
search={{ user_id: userId, user_subscribe_id: row.id }}
to="/dashboard/log/subscribe-traffic"
>
{t("trafficStats", "Traffic Stats")}
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link
search={{ user_id: userId, subscribe_id: row.id }}
to="/dashboard/log/traffic-details"
>
{t("trafficDetails", "Traffic Details")}
</Link>
</DropdownMenuItem>
<DropdownMenuItem
onSelect={(e) => {
e.preventDefault();
triggerRef.current?.click();
}}
>
{t("onlineDevices", "Online Devices")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{/* Hidden triggers for confirm dialogs */}
<ConfirmButton
cancelText={t("cancel", "Cancel")}
confirmText={t("confirm", "Confirm")}
description={t(
"resetTokenDescription",
"This will reset the subscription address and regenerate a new token."
)}
onConfirm={async () => {
await resetUserSubscribeToken({
user_subscribe_id: row.id,
});
toast.success(
t("resetTokenSuccess", "Subscription address reset successfully")
);
refresh();
}}
title={t("confirmResetToken", "Confirm Reset Subscription Address")}
trigger={<Button className="hidden" ref={resetTokenRef} />}
/>
<ConfirmButton
cancelText={t("cancel", "Cancel")}
confirmText={t("confirm", "Confirm")}
description={
row.status === 5
? t(
"resumeSubscribeDescription",
"This will resume the subscription and allow the user to use it."
)
: t(
"stopSubscribeDescription",
"This will stop the subscription temporarily. User will not be able to use it."
)
}
onConfirm={async () => {
await toggleUserSubscribeStatus({
user_subscribe_id: row.id,
});
toast.success(
row.status === 5
? t("resumeSubscribeSuccess", "Subscription resumed successfully")
: t("stopSubscribeSuccess", "Subscription stopped successfully")
);
refresh();
}}
title={
row.status === 5
? t("confirmResumeSubscribe", "Confirm Resume Subscription")
: t("confirmStopSubscribe", "Confirm Stop Subscription")
}
trigger={<Button className="hidden" ref={toggleStatusRef} />}
/>
<ConfirmButton
cancelText={t("cancel", "Cancel")}
confirmText={t("confirm", "Confirm")}
description={t(
"deleteSubscriptionDescription",
"This action cannot be undone."
)}
onConfirm={async () => {
await deleteUserSubscribe({ user_subscribe_id: row.id });
toast.success(t("deleteSuccess", "Deleted successfully"));
refresh();
}}
title={t("confirmDelete", "Confirm Delete")}
trigger={<Button className="hidden" ref={deleteRef} />}
/>
<SubscriptionDetail
subscriptionId={row.id}
trigger={<Button className="hidden" ref={triggerRef} />}
userId={userId}
/>
</div>
);
}