- 新增家庭共享订阅管理 - 新增用户邀请统计 - 新增签名和订阅模式设置表单 - 更新 API 服务层和国际化文件 - UI 组件优化(enhanced-input、pro-table)
This commit is contained in:
@@ -0,0 +1,228 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user