Some checks failed
Build and Release / Build (push) Has been cancelled
- 去掉所有 Link search 中的 String() 包装(7处),修复 URL 引号问题 - View Owner 改用 useNavigate 强制导航,解决同页面跳转不生效 - 设备组详情成员列表添加 auth_type Badge 和 device_type Badge - 设备组列表 owner 列统一为 [AUTH_TYPE Badge] + identifier 格式 - UserDetail 组件统一设备标识逻辑,去掉 shortenDeviceIdentifier Co-Authored-By: claude-flow <ruv@ruv.net>
655 lines
21 KiB
TypeScript
655 lines
21 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 } from "./subscription-form";
|
||
|
||
interface SharedInfo {
|
||
ownerUserId: number;
|
||
familyId: number;
|
||
}
|
||
|
||
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,
|
||
...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 speed = row.original?.subscribe?.speed_limit;
|
||
return <Display type="trafficSpeed" value={speed} />;
|
||
},
|
||
},
|
||
{
|
||
accessorKey: "device_limit",
|
||
header: t("deviceLimit", "Device Limit"),
|
||
cell: ({ row }) => {
|
||
const 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: "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),
|
||
...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: String(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: String(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>
|
||
);
|
||
}
|