6b92979c7c
Build and Release / Build (push) Has been cancelled
- 新增家庭共享订阅管理 - 新增用户邀请统计 - 新增签名和订阅模式设置表单 - 更新 API 服务层和国际化文件 - UI 组件优化(enhanced-input、pro-table)
229 lines
7.3 KiB
TypeScript
229 lines
7.3 KiB
TypeScript
import { useQuery } from "@tanstack/react-query";
|
|
import {
|
|
Avatar,
|
|
AvatarFallback,
|
|
AvatarImage,
|
|
} from "@workspace/ui/components/avatar";
|
|
import { Badge } from "@workspace/ui/components/badge";
|
|
import {
|
|
Sheet,
|
|
SheetContent,
|
|
SheetHeader,
|
|
SheetTitle,
|
|
} from "@workspace/ui/components/sheet";
|
|
import { Skeleton } from "@workspace/ui/components/skeleton";
|
|
import {
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableHead,
|
|
TableHeader,
|
|
TableRow,
|
|
} from "@workspace/ui/components/table";
|
|
import {
|
|
getAdminUserInviteList,
|
|
getAdminUserInviteStats,
|
|
} from "@workspace/ui/services/admin/user";
|
|
import { useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { Display } from "@/components/display";
|
|
import { formatDate } from "@/utils/common";
|
|
|
|
interface UserInviteStatsSheetProps {
|
|
userId: number;
|
|
open: boolean;
|
|
onOpenChange: (open: boolean) => void;
|
|
}
|
|
|
|
export function UserInviteStatsSheet({
|
|
userId,
|
|
open,
|
|
onOpenChange,
|
|
}: UserInviteStatsSheetProps) {
|
|
const { t } = useTranslation("user");
|
|
const [page, setPage] = useState(1);
|
|
const pageSize = 200;
|
|
|
|
const { data: stats, isLoading: statsLoading } = useQuery({
|
|
queryKey: ["adminUserInviteStats", userId],
|
|
queryFn: async () => {
|
|
const { data } = await getAdminUserInviteStats({ user_id: userId });
|
|
return data.data;
|
|
},
|
|
enabled: open && !!userId,
|
|
});
|
|
|
|
const { data: listResult, isLoading: listLoading } = useQuery({
|
|
queryKey: ["adminUserInviteList", userId, page],
|
|
queryFn: async () => {
|
|
const { data } = await getAdminUserInviteList({
|
|
user_id: userId,
|
|
page,
|
|
size: pageSize,
|
|
});
|
|
return data.data;
|
|
},
|
|
enabled: open && !!userId,
|
|
});
|
|
|
|
const inviteList = listResult?.list ?? [];
|
|
const total = listResult?.total ?? 0;
|
|
const totalPages = Math.ceil(total / pageSize);
|
|
|
|
return (
|
|
<Sheet onOpenChange={onOpenChange} open={open}>
|
|
<SheetContent className="w-[560px] overflow-y-auto sm:max-w-[560px]">
|
|
<SheetHeader>
|
|
<SheetTitle>{t("inviteStats", "Invite Statistics")}</SheetTitle>
|
|
</SheetHeader>
|
|
|
|
{/* 概览卡片 */}
|
|
<div className="mt-4 grid grid-cols-2 gap-3">
|
|
<StatCard
|
|
label={t("inviteCount", "Invited Users")}
|
|
loading={statsLoading}
|
|
>
|
|
<span className="font-semibold text-2xl">
|
|
{stats?.invite_count ?? 0}
|
|
</span>
|
|
</StatCard>
|
|
<StatCard
|
|
label={t("totalCommission", "Total Commission")}
|
|
loading={statsLoading}
|
|
>
|
|
<Display type="currency" value={stats?.total_commission} />
|
|
</StatCard>
|
|
<StatCard
|
|
label={t("currentCommission", "Current Commission")}
|
|
loading={statsLoading}
|
|
>
|
|
<Display type="currency" value={stats?.current_commission} />
|
|
</StatCard>
|
|
<StatCard
|
|
label={t("referralPercentage", "Referral %")}
|
|
loading={statsLoading}
|
|
>
|
|
<span className="font-semibold text-2xl">
|
|
{stats?.referral_percentage
|
|
? `${stats.referral_percentage}%`
|
|
: t("globalDefault", "Global Default")}
|
|
</span>
|
|
{stats?.only_first_purchase && (
|
|
<span className="ml-1 text-muted-foreground text-xs">
|
|
({t("firstPurchaseOnly", "First purchase only")})
|
|
</span>
|
|
)}
|
|
</StatCard>
|
|
</div>
|
|
|
|
{/* 邀请用户列表 */}
|
|
<div className="mt-6">
|
|
<h3 className="mb-3 font-medium text-sm">
|
|
{t("invitedUsers", "Invited Users")} ({total})
|
|
</h3>
|
|
{listLoading ? (
|
|
<div className="space-y-2">
|
|
{Array.from({ length: 5 }).map((_, i) => (
|
|
<Skeleton className="h-10 w-full" key={i} />
|
|
))}
|
|
</div>
|
|
) : inviteList.length === 0 ? (
|
|
<p className="py-8 text-center text-muted-foreground text-sm">
|
|
{t("noInvitedUsers", "No invited users yet")}
|
|
</p>
|
|
) : (
|
|
<>
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead>{t("user", "User")}</TableHead>
|
|
<TableHead>{t("status", "Status")}</TableHead>
|
|
<TableHead>{t("registeredAt", "Registered At")}</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{inviteList.map((user) => (
|
|
<TableRow key={user.id}>
|
|
<TableCell>
|
|
<div className="flex items-center gap-2">
|
|
<Avatar className="h-7 w-7">
|
|
<AvatarImage src={user.avatar} />
|
|
<AvatarFallback className="text-xs">
|
|
{user.identifier?.charAt(0)?.toUpperCase() ?? "U"}
|
|
</AvatarFallback>
|
|
</Avatar>
|
|
<span className="max-w-[160px] truncate text-sm">
|
|
{user.identifier || `#${user.id}`}
|
|
</span>
|
|
</div>
|
|
</TableCell>
|
|
<TableCell>
|
|
<Badge variant={user.enable ? "default" : "secondary"}>
|
|
{user.enable
|
|
? t("enabled", "Enabled")
|
|
: t("disabled", "Disabled")}
|
|
</Badge>
|
|
</TableCell>
|
|
<TableCell className="text-muted-foreground text-sm">
|
|
{formatDate(user.created_at)}
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
|
|
{/* 分页 */}
|
|
{totalPages > 1 && (
|
|
<div className="mt-4 flex justify-center gap-2">
|
|
<button
|
|
className="rounded border px-3 py-1 text-sm disabled:opacity-40"
|
|
disabled={page <= 1}
|
|
onClick={() => setPage((p) => p - 1)}
|
|
type="button"
|
|
>
|
|
{t("prev", "Prev")}
|
|
</button>
|
|
<span className="px-3 py-1 text-sm">
|
|
{page} / {totalPages}
|
|
</span>
|
|
<button
|
|
className="rounded border px-3 py-1 text-sm disabled:opacity-40"
|
|
disabled={page >= totalPages}
|
|
onClick={() => setPage((p) => p + 1)}
|
|
type="button"
|
|
>
|
|
{t("next", "Next")}
|
|
</button>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
</SheetContent>
|
|
</Sheet>
|
|
);
|
|
}
|
|
|
|
function StatCard({
|
|
label,
|
|
loading,
|
|
children,
|
|
}: {
|
|
label: string;
|
|
loading: boolean;
|
|
children: React.ReactNode;
|
|
}) {
|
|
return (
|
|
<div className="space-y-1 rounded-lg border p-4">
|
|
<p className="text-muted-foreground text-xs">{label}</p>
|
|
{loading ? (
|
|
<Skeleton className="h-8 w-24" />
|
|
) : (
|
|
<div className="flex items-baseline gap-1 font-semibold text-2xl">
|
|
{children}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|