feat: Added localized support for user subscription and deletion status, and optimized the subscription form and user interface.

This commit is contained in:
web@ppanel 2025-12-29 07:02:31 +00:00
parent 3751f64f73
commit 9f95cec876
8 changed files with 520 additions and 444 deletions

View File

@ -25,9 +25,11 @@
"createSuccess": "Created successfully", "createSuccess": "Created successfully",
"createUser": "Create User", "createUser": "Create User",
"delete": "Delete", "delete": "Delete",
"deleted": "Deleted",
"deleteDescription": "This action cannot be undone.", "deleteDescription": "This action cannot be undone.",
"deleteSubscriptionDescription": "This action cannot be undone.", "deleteSubscriptionDescription": "This action cannot be undone.",
"deleteSuccess": "Deleted successfully", "deleteSuccess": "Deleted successfully",
"isDeleted": "Status",
"deviceLimit": "Device Limit", "deviceLimit": "Device Limit",
"download": "Download", "download": "Download",
"downloadTraffic": "Download Traffic", "downloadTraffic": "Download Traffic",
@ -51,6 +53,7 @@
"loginStatus": "Login Status", "loginStatus": "Login Status",
"manager": "Administrator", "manager": "Administrator",
"more": "More", "more": "More",
"normal": "Normal",
"notifySettingsTitle": "Notify Settings", "notifySettingsTitle": "Notify Settings",
"offline": "Offline", "offline": "Offline",
"online": "Online", "online": "Online",

View File

@ -25,9 +25,11 @@
"createSuccess": "创建成功", "createSuccess": "创建成功",
"createUser": "创建用户", "createUser": "创建用户",
"delete": "删除", "delete": "删除",
"deleted": "已删除",
"deleteDescription": "此操作无法撤销。", "deleteDescription": "此操作无法撤销。",
"deleteSubscriptionDescription": "此操作无法撤销。", "deleteSubscriptionDescription": "此操作无法撤销。",
"deleteSuccess": "删除成功", "deleteSuccess": "删除成功",
"isDeleted": "状态",
"deviceLimit": "IP限制", "deviceLimit": "IP限制",
"download": "下载", "download": "下载",
"downloadTraffic": "下载流量", "downloadTraffic": "下载流量",
@ -51,6 +53,7 @@
"loginStatus": "登录状态", "loginStatus": "登录状态",
"manager": "管理员", "manager": "管理员",
"more": "更多", "more": "更多",
"normal": "正常",
"notifySettingsTitle": "通知设置", "notifySettingsTitle": "通知设置",
"offline": "离线", "offline": "离线",
"online": "在线", "online": "在线",

View File

@ -277,7 +277,7 @@ export default function SubscribeForm<T extends Record<string, any>>({
{trigger} {trigger}
</Button> </Button>
</SheetTrigger> </SheetTrigger>
<SheetContent className="w-[800px] max-w-full gap-0 md:max-w-screen-md"> <SheetContent className="w-[800px] max-w-full gap-0 md:max-w-3xl">
<SheetHeader> <SheetHeader>
<SheetTitle>{title}</SheetTitle> <SheetTitle>{title}</SheetTitle>
</SheetHeader> </SheetHeader>

View File

@ -172,6 +172,18 @@ export default function User() {
accessorKey: "id", accessorKey: "id",
header: "ID", header: "ID",
}, },
{
accessorKey: "deleted_at",
header: t("isDeleted", "Deleted"),
cell: ({ row }) => {
const deletedAt = row.getValue("deleted_at") as number | undefined;
return deletedAt ? (
<Badge variant="destructive">{t("deleted", "Deleted")}</Badge>
) : (
<Badge variant="outline">{t("normal", "Normal")}</Badge>
);
},
},
{ {
accessorKey: "auth_methods", accessorKey: "auth_methods",
header: t("userName", "Username"), header: t("userName", "Username"),
@ -355,16 +367,13 @@ function SubscriptionSheet({ userId }: { userId: number }) {
<SheetTrigger asChild> <SheetTrigger asChild>
<Button variant="secondary">{t("subscription", "Subscription")}</Button> <Button variant="secondary">{t("subscription", "Subscription")}</Button>
</SheetTrigger> </SheetTrigger>
<SheetContent <SheetContent className="w-[1000px] max-w-full md:max-w-7xl" side="right">
className="w-[1000px] max-w-full md:max-w-screen-xl"
side="right"
>
<SheetHeader> <SheetHeader>
<SheetTitle> <SheetTitle>
{t("subscriptionList", "Subscription List")} · ID: {userId} {t("subscriptionList", "Subscription List")} · ID: {userId}
</SheetTitle> </SheetTitle>
</SheetHeader> </SheetHeader>
<div className="mt-2"> <div className="mt-2 px-4">
<UserSubscription userId={userId} /> <UserSubscription userId={userId} />
</div> </div>
</SheetContent> </SheetContent>

View File

@ -33,7 +33,6 @@ export default function UserSubscription({ userId }: { userId: number }) {
const { t } = useTranslation("user"); const { t } = useTranslation("user");
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const ref = useRef<ProTableActions>(null); const ref = useRef<ProTableActions>(null);
const { getUserSubscribe: getUserSubscribeUrls } = useGlobalStore();
return ( return (
<ProTable<API.UserSubscribe, Record<string, unknown>> <ProTable<API.UserSubscribe, Record<string, unknown>>
@ -59,105 +58,13 @@ export default function UserSubscription({ userId }: { userId: number }) {
title={t("editSubscription", "Edit Subscription")} title={t("editSubscription", "Edit Subscription")}
trigger={t("edit", "Edit")} trigger={t("edit", "Edit")}
/>, />,
<Button <RowMoreActions
key="copy" key="more"
onClick={async () => { refresh={() => ref.current?.refresh()}
await navigator.clipboard.writeText( row={row}
getUserSubscribeUrls(row.token)[0] || "" token={row.token}
); userId={userId}
toast.success(t("copySuccess", "Copied successfully"));
}}
variant="secondary"
>
{t("copySubscription", "Copy Subscription")}
</Button>,
<ConfirmButton
cancelText={t("cancel", "Cancel")}
confirmText={t("confirm", "Confirm")}
description={t(
"resetTokenDescription",
"This will reset the subscription address and regenerate a new token."
)}
key="resetToken"
onConfirm={async () => {
await resetUserSubscribeToken({ user_subscribe_id: row.id });
toast.success(
t(
"resetTokenSuccess",
"Subscription address reset successfully"
)
);
ref.current?.refresh();
}}
title={t("confirmResetToken", "Confirm Reset Subscription Address")}
trigger={
<Button variant="outline">
{t("resetToken", "Reset Subscription Address")}
</Button>
}
/>, />,
<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."
)
}
key="toggle"
onConfirm={async () => {
await toggleUserSubscribeStatus({ user_subscribe_id: row.id });
toast.success(
row.status === 5
? t(
"resumeSubscribeSuccess",
"Subscription resumed successfully"
)
: t(
"stopSubscribeSuccess",
"Subscription stopped successfully"
)
);
ref.current?.refresh();
}}
title={
row.status === 5
? t("confirmResumeSubscribe", "Confirm Resume Subscription")
: t("confirmStopSubscribe", "Confirm Stop Subscription")
}
trigger={
<Button variant="secondary">
{row.status === 5
? t("resumeSubscribe", "Resume Subscription")
: t("stopSubscribe", "Stop Subscription")}
</Button>
}
/>,
<ConfirmButton
cancelText={t("cancel", "Cancel")}
confirmText={t("confirm", "Confirm")}
description={t(
"deleteSubscriptionDescription",
"This action cannot be undone."
)}
key="delete"
onConfirm={async () => {
await deleteUserSubscribe({ user_subscribe_id: row.id });
toast.success(t("deleteSuccess", "Deleted successfully"));
ref.current?.refresh();
}}
title={t("confirmDelete", "Confirm Delete")}
trigger={
<Button variant="destructive">{t("delete", "Delete")}</Button>
}
/>,
<RowMoreActions key="more" subId={row.id} userId={userId} />,
], ],
}} }}
columns={[ columns={[
@ -175,6 +82,11 @@ export default function UserSubscription({ userId }: { userId: number }) {
header: t("status", "Status"), header: t("status", "Status"),
cell: ({ row }) => { cell: ({ row }) => {
const status = row.getValue("status") as number; 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< const statusMap: Record<
number, number,
{ {
@ -201,7 +113,7 @@ export default function UserSubscription({ userId }: { userId: number }) {
variant: "destructive", variant: "destructive",
}, },
}; };
const statusInfo = statusMap[status] || { const statusInfo = statusMap[displayStatus] || {
label: "Unknown", label: "Unknown",
variant: "outline", variant: "outline",
}; };
@ -261,10 +173,12 @@ export default function UserSubscription({ userId }: { userId: number }) {
{ {
accessorKey: "expire_time", accessorKey: "expire_time",
header: t("expireTime", "Expire Time"), header: t("expireTime", "Expire Time"),
cell: ({ row }) => cell: ({ row }) => {
row.getValue("expire_time") const expireTime = row.getValue("expire_time") as number;
? formatDate(row.getValue("expire_time")) return expireTime && expireTime !== 0
: t("permanent", "Permanent"), ? formatDate(expireTime)
: t("permanent", "Permanent");
},
}, },
{ {
accessorKey: "created_at", accessorKey: "created_at",
@ -308,9 +222,24 @@ export default function UserSubscription({ userId }: { userId: number }) {
); );
} }
function RowMoreActions({ userId, subId }: { userId: number; subId: number }) { function RowMoreActions({
userId,
row,
token,
refresh,
}: {
userId: number;
row: API.UserSubscribe;
token: string;
refresh: () => void;
}) {
const triggerRef = useRef<HTMLButtonElement>(null); 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 { t } = useTranslation("user");
const { getUserSubscribe: getUserSubscribeUrls } = useGlobalStore();
return ( return (
<div className="inline-flex"> <div className="inline-flex">
<DropdownMenu> <DropdownMenu>
@ -318,9 +247,47 @@ function RowMoreActions({ userId, subId }: { userId: number; subId: number }) {
<Button variant="outline">{t("more", "More")}</Button> <Button variant="outline">{t("more", "More")}</Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end"> <DropdownMenuContent align="end">
<DropdownMenuItem
onSelect={async (e) => {
e.preventDefault();
await navigator.clipboard.writeText(
getUserSubscribeUrls(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> <DropdownMenuItem asChild>
<Link <Link
search={{ user_id: userId, user_subscribe_id: subId }} search={{ user_id: userId, user_subscribe_id: row.id }}
to="/dashboard/log/subscribe" to="/dashboard/log/subscribe"
> >
{t("subscriptionLogs", "Subscription Logs")} {t("subscriptionLogs", "Subscription Logs")}
@ -328,7 +295,7 @@ function RowMoreActions({ userId, subId }: { userId: number; subId: number }) {
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem asChild> <DropdownMenuItem asChild>
<Link <Link
search={{ user_id: userId, user_subscribe_id: subId }} search={{ user_id: userId, user_subscribe_id: row.id }}
to="/dashboard/log/reset-subscribe" to="/dashboard/log/reset-subscribe"
> >
{t("resetLogs", "Reset Logs")} {t("resetLogs", "Reset Logs")}
@ -336,7 +303,7 @@ function RowMoreActions({ userId, subId }: { userId: number; subId: number }) {
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem asChild> <DropdownMenuItem asChild>
<Link <Link
search={{ user_id: userId, user_subscribe_id: subId }} search={{ user_id: userId, user_subscribe_id: row.id }}
to="/dashboard/log/subscribe-traffic" to="/dashboard/log/subscribe-traffic"
> >
{t("trafficStats", "Traffic Stats")} {t("trafficStats", "Traffic Stats")}
@ -344,7 +311,7 @@ function RowMoreActions({ userId, subId }: { userId: number; subId: number }) {
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem asChild> <DropdownMenuItem asChild>
<Link <Link
search={{ user_id: userId, subscribe_id: subId }} search={{ user_id: userId, subscribe_id: row.id }}
to="/dashboard/log/traffic-details" to="/dashboard/log/traffic-details"
> >
{t("trafficDetails", "Traffic Details")} {t("trafficDetails", "Traffic Details")}
@ -361,8 +328,78 @@ function RowMoreActions({ userId, subId }: { userId: number; subId: number }) {
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </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.subscribe_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.subscribe_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 <SubscriptionDetail
subscriptionId={subId} subscriptionId={row.id}
trigger={<Button className="hidden" ref={triggerRef} />} trigger={<Button className="hidden" ref={triggerRef} />}
userId={userId} userId={userId}
/> />

View File

@ -95,7 +95,7 @@ export function SubscriptionForm({
<SheetHeader> <SheetHeader>
<SheetTitle>{title}</SheetTitle> <SheetTitle>{title}</SheetTitle>
</SheetHeader> </SheetHeader>
<ScrollArea className="h-[calc(100dvh-48px-36px-36px-env(safe-area-inset-top))]"> <ScrollArea className="h-[calc(100dvh-48px-36px-36px-env(safe-area-inset-top))] px-4">
<div className="pr-4"> <div className="pr-4">
<Form {...form}> <Form {...form}>
<form <form

View File

@ -218,337 +218,354 @@ export default function Content() {
</Tabs> </Tabs>
)} )}
</div> </div>
{userSubscribe.map((item) => ( {userSubscribe.map((item) => {
<Card // 如果过期时间为0说明是永久订阅不应该显示过期状态
className={cn("relative", { const isActuallyExpired =
"relative opacity-80 grayscale": item.status === 3, item.status === 3 && item.expire_time !== 0;
"relative hidden opacity-60 blur-[0.3px] grayscale": const shouldShowWatermark =
item.status === 4, item.status === 2 || item.status === 4 || isActuallyExpired;
})}
key={item.id}
>
{item.status >= 2 && (
<div
className={cn(
"pointer-events-none absolute top-0 left-0 z-10 h-full w-full overflow-hidden mix-blend-difference brightness-150 contrast-200 invert-[0.2]",
{
"text-destructive": item.status === 2,
"text-white": item.status === 3 || item.status === 4,
}
)}
>
<div className="absolute inset-0">
{Array.from({ length: 16 }).map((_, i) => {
const row = Math.floor(i / 4);
const col = i % 4;
const top = 10 + row * 25 + (col % 2 === 0 ? 5 : -5);
const left = 5 + col * 30 + (row % 2 === 0 ? 0 : 10);
return ( return (
<span <Card
className="absolute rotate-[-30deg] whitespace-nowrap font-black text-lg opacity-40 shadow-[0px_0px_1px_rgba(255,255,255,0.5)]" className={cn("relative", {
key={i} "relative opacity-80 grayscale": isActuallyExpired,
style={{ "relative hidden opacity-60 blur-[0.3px] grayscale":
top: `${top}%`, item.status === 4,
left: `${left}%`, })}
}} key={item.id}
> >
{ {shouldShowWatermark && (
statusWatermarks[ <div
item.status as keyof typeof statusWatermarks className={cn(
] "pointer-events-none absolute top-0 left-0 z-10 h-full w-full overflow-hidden mix-blend-difference brightness-150 contrast-200 invert-[0.2]",
} {
</span> "text-destructive": item.status === 2,
); "text-white": isActuallyExpired || item.status === 4,
})} }
</div> )}
</div> >
)} <div className="absolute inset-0">
<CardHeader className="flex flex-row flex-wrap items-center justify-between gap-2 space-y-0"> {Array.from({ length: 16 }).map((_, i) => {
<CardTitle className="font-medium"> const row = Math.floor(i / 4);
{item.subscribe.name} const col = i % 4;
<p className="mt-1 text-foreground/50 text-sm"> const top = 10 + row * 25 + (col % 2 === 0 ? 5 : -5);
{formatDate(item.start_time)} const left = 5 + col * 30 + (row % 2 === 0 ? 0 : 10);
</p>
</CardTitle> return (
{item.status !== 4 && ( <span
<div className="flex flex-wrap gap-2"> className="absolute rotate-[-30deg] whitespace-nowrap font-black text-lg opacity-40 shadow-[0px_0px_1px_rgba(255,255,255,0.5)]"
<AlertDialog> key={i}
<AlertDialogTrigger asChild> style={{
<Button size="sm" variant="destructive"> top: `${top}%`,
{t("resetSubscription", "Reset Subscription")} left: `${left}%`,
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("prompt", "Prompt")}
</AlertDialogTitle>
<AlertDialogDescription>
{t(
"confirmResetSubscription",
"Are you sure you want to reset your subscription?"
)}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
{t("cancel", "Cancel")}
</AlertDialogCancel>
<AlertDialogAction
onClick={async () => {
await resetUserSubscribeToken({
user_subscribe_id: item.id,
});
await refetch();
toast.success(t("resetSuccess", "Reset Success"));
}} }}
> >
{t("confirm", "Confirm")} {
</AlertDialogAction> statusWatermarks[
</AlertDialogFooter> item.status as keyof typeof statusWatermarks
</AlertDialogContent> ]
</AlertDialog> }
<ResetTraffic </span>
id={item.id} );
replacement={item.subscribe.replacement} })}
/> </div>
{item.expire_time !== 0 && (
<Renewal id={item.id} subscribe={item.subscribe} />
)}
<Unsubscribe
allowDeduction={item.subscribe.allow_deduction}
id={item.id}
onSuccess={refetch}
/>
</div> </div>
)} )}
</CardHeader> <CardHeader className="flex flex-row flex-wrap items-center justify-between gap-2 space-y-0">
<CardContent> <CardTitle className="font-medium">
<ul className="grid grid-cols-2 gap-3 *:flex *:flex-col *:justify-between lg:grid-cols-4"> {item.subscribe.name}
<li> <p className="mt-1 text-foreground/50 text-sm">
<span className="text-muted-foreground"> {formatDate(item.start_time)}
{t("used", "Used")} </p>
</span> </CardTitle>
<span className="font-bold text-2xl"> {item.status !== 4 && (
<Display <div className="flex flex-wrap gap-2">
type="traffic" <AlertDialog>
unlimited={!item.traffic} <AlertDialogTrigger asChild>
value={item.upload + item.download} <Button size="sm" variant="destructive">
/> {t("resetSubscription", "Reset Subscription")}
</span> </Button>
</li> </AlertDialogTrigger>
<li> <AlertDialogContent>
<span className="text-muted-foreground"> <AlertDialogHeader>
{t("totalTraffic", "Total Traffic")} <AlertDialogTitle>
</span> {t("prompt", "Prompt")}
<span className="font-bold text-2xl"> </AlertDialogTitle>
<Display <AlertDialogDescription>
type="traffic" {t(
unlimited={!item.traffic} "confirmResetSubscription",
value={item.traffic} "Are you sure you want to reset your subscription?"
/> )}
</span> </AlertDialogDescription>
</li> </AlertDialogHeader>
<li> <AlertDialogFooter>
<span className="text-muted-foreground"> <AlertDialogCancel>
{t("nextResetDays", "Next Reset Days")} {t("cancel", "Cancel")}
</span> </AlertDialogCancel>
<span className="font-semibold text-2xl"> <AlertDialogAction
{item.reset_time onClick={async () => {
? differenceInDays( await resetUserSubscribeToken({
new Date(item.reset_time), user_subscribe_id: item.id,
new Date() });
) await refetch();
: t("noReset", "No Reset")} toast.success(
</span> t("resetSuccess", "Reset Success")
</li> );
<li> }}
<span className="text-muted-foreground">
{t("expirationDays", "Expiration Days")}
</span>
<span className="font-semibold text-2xl">
{}
{item.expire_time
? differenceInDays(
new Date(item.expire_time),
new Date()
) || t("unknown", "Unknown")
: t("noLimit", "No Limit")}
</span>
</li>
</ul>
<Separator className="mt-4" />
<Accordion
className="w-full"
collapsible
defaultValue="0"
type="single"
>
{getUserSubscribe(item.token, protocol)?.map((url, index) => (
<AccordionItem key={url} value={String(index)}>
<AccordionTrigger className="hover:no-underline">
<div className="flex w-full flex-row items-center justify-between">
<CardTitle className="font-medium text-sm">
{t("subscriptionUrl", "Subscription URL")}{" "}
{index + 1}
</CardTitle>
<CopyToClipboard
onCopy={(_, result) => {
if (result) {
toast.success(t("copySuccess", "Copy Success"));
}
}}
text={url}
>
<span
className="mr-4 flex cursor-pointer rounded p-2 text-primary text-sm hover:bg-accent"
onClick={(e) => e.stopPropagation()}
> >
<Icon className="mr-2 size-5" icon="uil:copy" /> {t("confirm", "Confirm")}
{t("copy", "Copy")} </AlertDialogAction>
</span> </AlertDialogFooter>
</CopyToClipboard> </AlertDialogContent>
</div> </AlertDialog>
</AccordionTrigger> <ResetTraffic
<AccordionContent> id={item.id}
<div className="grid grid-cols-2 gap-4 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-6"> replacement={item.subscribe.replacement}
{applications />
?.filter( {item.expire_time !== 0 && (
(application) => <Renewal id={item.id} subscribe={item.subscribe} />
!!( )}
application.download_link?.[platform] && <Unsubscribe
application.scheme allowDeduction={item.subscribe.allow_deduction}
) id={item.id}
onSuccess={refetch}
/>
</div>
)}
</CardHeader>
<CardContent>
<ul className="grid grid-cols-2 gap-3 *:flex *:flex-col *:justify-between lg:grid-cols-4">
<li>
<span className="text-muted-foreground">
{t("used", "Used")}
</span>
<span className="font-bold text-2xl">
<Display
type="traffic"
unlimited={!item.traffic}
value={item.upload + item.download}
/>
</span>
</li>
<li>
<span className="text-muted-foreground">
{t("totalTraffic", "Total Traffic")}
</span>
<span className="font-bold text-2xl">
<Display
type="traffic"
unlimited={!item.traffic}
value={item.traffic}
/>
</span>
</li>
<li>
<span className="text-muted-foreground">
{t("nextResetDays", "Next Reset Days")}
</span>
<span className="font-semibold text-2xl">
{item.reset_time
? differenceInDays(
new Date(item.reset_time),
new Date()
) )
.map((application) => { : t("noReset", "No Reset")}
const downloadUrl = </span>
application.download_link?.[platform]; </li>
<li>
<span className="text-muted-foreground">
{t("expirationDays", "Expiration Days")}
</span>
<span className="font-semibold text-2xl">
{}
{item.expire_time
? differenceInDays(
new Date(item.expire_time),
new Date()
) || t("unknown", "Unknown")
: t("noLimit", "No Limit")}
</span>
</li>
</ul>
<Separator className="mt-4" />
<Accordion
className="w-full"
collapsible
defaultValue="0"
type="single"
>
{getUserSubscribe(item.token, protocol)?.map(
(url, index) => (
<AccordionItem key={url} value={String(index)}>
<AccordionTrigger className="hover:no-underline">
<div className="flex w-full flex-row items-center justify-between">
<CardTitle className="font-medium text-sm">
{t("subscriptionUrl", "Subscription URL")}{" "}
{index + 1}
</CardTitle>
const handleCopy = ( <CopyToClipboard
_: string, onCopy={(_, result) => {
result: boolean if (result) {
) => {
if (result) {
const href = getAppSubLink(
url,
application.scheme
);
const showSuccessMessage = () => {
toast.success( toast.success(
<> t("copySuccess", "Copy Success")
<p>
{t("copySuccess", "Copy Success")}
</p>
<br />
<p>
{t(
"manualImportMessage",
"Please import manually"
)}
</p>
</>
); );
}
}}
text={url}
>
<span
className="mr-4 flex cursor-pointer rounded p-2 text-primary text-sm hover:bg-accent"
onClick={(e) => e.stopPropagation()}
>
<Icon
className="mr-2 size-5"
icon="uil:copy"
/>
{t("copy", "Copy")}
</span>
</CopyToClipboard>
</div>
</AccordionTrigger>
<AccordionContent>
<div className="grid grid-cols-2 gap-4 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-6">
{applications
?.filter(
(application) =>
!!(
application.download_link?.[platform] &&
application.scheme
)
)
.map((application) => {
const downloadUrl =
application.download_link?.[platform];
const handleCopy = (
_: string,
result: boolean
) => {
if (result) {
const href = getAppSubLink(
url,
application.scheme
);
const showSuccessMessage = () => {
toast.success(
<>
<p>
{t("copySuccess", "Copy Success")}
</p>
<br />
<p>
{t(
"manualImportMessage",
"Please import manually"
)}
</p>
</>
);
};
if (isBrowser() && href) {
window.location.href = href;
const checkRedirect = setTimeout(() => {
if (window.location.href !== href) {
showSuccessMessage();
}
clearTimeout(checkRedirect);
}, 1000);
return;
}
showSuccessMessage();
}
}; };
if (isBrowser() && href) { return (
window.location.href = href; <div
const checkRedirect = setTimeout(() => { className="flex size-full flex-col items-center justify-between gap-2 text-muted-foreground text-xs"
if (window.location.href !== href) { key={application.name}
showSuccessMessage(); >
} <span>{application.name}</span>
clearTimeout(checkRedirect);
}, 1000);
return;
}
showSuccessMessage(); {application.icon && (
} <img
}; alt={application.name}
className="p-1"
return ( height={64}
<div src={application.icon}
className="flex size-full flex-col items-center justify-between gap-2 text-muted-foreground text-xs" width={64}
key={application.name} />
> )}
<span>{application.name}</span> <div className="flex">
{downloadUrl && (
{application.icon && ( <Button
<img asChild
alt={application.name} className={
className="p-1" application.scheme
height={64} ? "rounded-r-none px-1.5"
src={application.icon} : "px-1.5"
width={64} }
/> size="sm"
)} variant="secondary"
<div className="flex"> >
{downloadUrl && ( <a
<Button href={downloadUrl}
asChild rel="noopener noreferrer"
className={ target="_blank"
application.scheme >
? "rounded-r-none px-1.5" {t("download", "Download")}
: "px-1.5" </a>
} </Button>
size="sm"
variant="secondary"
>
<a
href={downloadUrl}
rel="noopener noreferrer"
target="_blank"
>
{t("download", "Download")}
</a>
</Button>
)}
{application.scheme && (
<CopyToClipboard
onCopy={handleCopy}
text={getAppSubLink(
url,
application.scheme
)} )}
>
<Button {application.scheme && (
className={ <CopyToClipboard
downloadUrl onCopy={handleCopy}
? "rounded-l-none p-2" text={getAppSubLink(
: "p-2" url,
} application.scheme
size="sm" )}
> >
{t("import", "Import")} <Button
</Button> className={
</CopyToClipboard> downloadUrl
)} ? "rounded-l-none p-2"
</div> : "p-2"
</div> }
); size="sm"
})} >
<div className="hidden size-full flex-col items-center justify-between gap-2 text-muted-foreground text-sm lg:flex"> {t("import", "Import")}
<span>{t("qrCode", "QR Code")}</span> </Button>
<QRCodeCanvas </CopyToClipboard>
bgColor="transparent" )}
fgColor="rgb(59, 130, 246)" </div>
size={80} </div>
value={url} );
/> })}
<span className="text-center"> <div className="hidden size-full flex-col items-center justify-between gap-2 text-muted-foreground text-sm lg:flex">
{t("scanToSubscribe", "Scan to Subscribe")} <span>{t("qrCode", "QR Code")}</span>
</span> <QRCodeCanvas
</div> bgColor="transparent"
</div> fgColor="rgb(59, 130, 246)"
</AccordionContent> size={80}
</AccordionItem> value={url}
))} />
</Accordion> <span className="text-center">
</CardContent> {t("scanToSubscribe", "Scan to Subscribe")}
</Card> </span>
))} </div>
</div>
</AccordionContent>
</AccordionItem>
)
)}
</Accordion>
</CardContent>
</Card>
);
})}
</> </>
) : ( ) : (
<> <>

View File

@ -34,6 +34,15 @@ export function DatePicker({
} }
}; };
const handleClear = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
setDate(undefined);
if (onChange) {
onChange(0);
}
};
return ( return (
<Popover> <Popover>
<PopoverTrigger asChild> <PopoverTrigger asChild>
@ -47,16 +56,14 @@ export function DatePicker({
{value ? intlFormat(value) : <span>{placeholder}</span>} {value ? intlFormat(value) : <span>{placeholder}</span>}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{value && ( {value && (
<X <button
className="size-4 opacity-50 hover:opacity-100" className="flex items-center"
onClick={(e) => { onClick={handleClear}
e.stopPropagation(); onMouseDown={handleClear}
setDate(undefined); type="button"
if (onChange) { >
onChange(0); <X className="size-4 opacity-50 hover:opacity-100" />
} </button>
}}
/>
)} )}
<CalendarIcon className="size-4" /> <CalendarIcon className="size-4" />
</div> </div>