- 新增家庭共享订阅管理 - 新增用户邀请统计 - 新增签名和订阅模式设置表单 - 更新 API 服务层和国际化文件 - UI 组件优化(enhanced-input、pro-table)
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { Link } 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 {
|
||||
@@ -15,12 +16,14 @@ import {
|
||||
import {
|
||||
createUserSubscribe,
|
||||
deleteUserSubscribe,
|
||||
getFamilyList,
|
||||
getUserSubscribe,
|
||||
resetUserSubscribeToken,
|
||||
toggleUserSubscribeStatus,
|
||||
updateUserSubscribe,
|
||||
} from "@workspace/ui/services/admin/user";
|
||||
import { useRef, useState } from "react";
|
||||
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";
|
||||
@@ -29,196 +32,420 @@ 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 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 (
|
||||
<ProTable<API.UserSubscribe, Record<string, unknown>>
|
||||
action={ref}
|
||||
actions={{
|
||||
render: (row) => [
|
||||
<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 }) => row.original.subscribe.name,
|
||||
},
|
||||
{
|
||||
accessorKey: "status",
|
||||
header: t("status", "Status"),
|
||||
cell: ({ row }) => {
|
||||
const status = row.getValue("status") as number;
|
||||
const expireTime = row.original.expire_time;
|
||||
<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: String(sharedInfo.ownerUserId) }}
|
||||
to="/dashboard/family"
|
||||
>
|
||||
{t("viewDeviceGroup", "View Device Group")}
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild size="sm" variant="outline">
|
||||
<Link
|
||||
search={{ user_id: String(sharedInfo.ownerUserId) }}
|
||||
to="/dashboard/user"
|
||||
>
|
||||
{t("viewOwner", "View Owner")}
|
||||
</Link>
|
||||
</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;
|
||||
// 如果过期时间为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>
|
||||
);
|
||||
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")} />
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "speed_limit",
|
||||
header: t("speedLimit", "Speed Limit"),
|
||||
cell: ({ row }) => {
|
||||
const speed = row.original?.subscribe?.speed_limit;
|
||||
return <Display type="trafficSpeed" value={speed} />;
|
||||
{
|
||||
accessorKey: "upload",
|
||||
header: t("upload", "Upload"),
|
||||
cell: ({ row }) => (
|
||||
<Display type="traffic" value={row.getValue("upload")} />
|
||||
),
|
||||
},
|
||||
},
|
||||
{
|
||||
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: "download",
|
||||
header: t("download", "Download"),
|
||||
cell: ({ row }) => (
|
||||
<Display type="traffic" value={row.getValue("download")} />
|
||||
),
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "reset_time",
|
||||
header: t("resetTime", "Reset Time"),
|
||||
cell: ({ row }) => (
|
||||
<Display
|
||||
type="number"
|
||||
unlimited
|
||||
value={row.getValue("reset_time")}
|
||||
{
|
||||
accessorKey: "traffic",
|
||||
header: t("totalTraffic", "Total Traffic"),
|
||||
cell: ({ row }) => (
|
||||
<Display
|
||||
type="traffic"
|
||||
unlimited
|
||||
value={row.getValue("traffic")}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
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")}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
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: t("subscriptionList", "Subscription List"),
|
||||
toolbar: (
|
||||
<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;
|
||||
}}
|
||||
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"));
|
||||
}}
|
||||
title={t("createSubscription", "Create Subscription")}
|
||||
trigger={t("add", "Add")}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
request={async (pagination) => {
|
||||
const { data } = await getUserSubscribe({
|
||||
user_id: userId,
|
||||
...pagination,
|
||||
});
|
||||
return {
|
||||
list: data.data?.list || [],
|
||||
total: data.data?.total || 0,
|
||||
};
|
||||
}}
|
||||
/>
|
||||
>
|
||||
{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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
getUserSubscribeDevices,
|
||||
kickOfflineByUserDevice,
|
||||
} from "@workspace/ui/services/admin/user";
|
||||
import { deviceIdToHash } from "@workspace/ui/utils/device";
|
||||
import { type ReactNode, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
@@ -86,7 +87,18 @@ export function SubscriptionDetail({
|
||||
),
|
||||
},
|
||||
{ accessorKey: "id", header: "ID" },
|
||||
{ accessorKey: "identifier", header: "IMEI" },
|
||||
{
|
||||
accessorKey: "identifier",
|
||||
header: t("deviceNo", "Device No."),
|
||||
cell: ({ row }) => {
|
||||
const id = row.original.id;
|
||||
return (
|
||||
<span className="font-mono" title={row.original.identifier}>
|
||||
{deviceIdToHash(id)}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "user_agent",
|
||||
header: t("userAgent", "User Agent"),
|
||||
|
||||
Reference in New Issue
Block a user