shanshanzhong 5a493fce67
Some checks failed
Build and Release / Build (push) Has been cancelled
fix: 修复订阅跳转引号问题 + 设备组显示设备类型 + 统一用户名显示
- 去掉所有 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>
2026-03-26 21:36:12 -07:00

655 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>
);
}