- 新增家庭共享订阅管理 - 新增用户邀请统计 - 新增签名和订阅模式设置表单 - 更新 API 服务层和国际化文件 - UI 组件优化(enhanced-input、pro-table)
This commit is contained in:
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
type TranslateFn = (...args: any[]) => any;
|
||||
|
||||
function normalizeEnumValue(value?: string) {
|
||||
return (value || "").trim().toLowerCase();
|
||||
}
|
||||
|
||||
export function isFamilyStatusActive(status?: string) {
|
||||
return normalizeEnumValue(status) === "active";
|
||||
}
|
||||
|
||||
export function isFamilyRoleOwner(role?: string) {
|
||||
return normalizeEnumValue(role) === "owner";
|
||||
}
|
||||
|
||||
export function isFamilyMemberStatusActive(status?: string) {
|
||||
return normalizeEnumValue(status) === "active";
|
||||
}
|
||||
|
||||
export function getFamilyStatusLabel(t: TranslateFn, status?: string) {
|
||||
const normalized = normalizeEnumValue(status);
|
||||
if (!normalized) return "--";
|
||||
|
||||
switch (normalized) {
|
||||
case "active":
|
||||
return t("statusActive", "Active");
|
||||
case "disabled":
|
||||
return t("familyDisabled", "Disabled");
|
||||
default:
|
||||
return status || "--";
|
||||
}
|
||||
}
|
||||
|
||||
export function getFamilyRoleLabel(t: TranslateFn, role?: string) {
|
||||
const normalized = normalizeEnumValue(role);
|
||||
if (!normalized) return "--";
|
||||
|
||||
switch (normalized) {
|
||||
case "owner":
|
||||
return t("owner", "Owner");
|
||||
case "member":
|
||||
return t("familyMember", "Member");
|
||||
default:
|
||||
return role || "--";
|
||||
}
|
||||
}
|
||||
|
||||
export function getFamilyMemberStatusLabel(t: TranslateFn, status?: string) {
|
||||
const normalized = normalizeEnumValue(status);
|
||||
if (!normalized) return "--";
|
||||
|
||||
switch (normalized) {
|
||||
case "active":
|
||||
return t("statusActive", "Active");
|
||||
case "left":
|
||||
return t("familyMemberLeft", "Left");
|
||||
case "removed":
|
||||
return t("familyMemberRemoved", "Removed");
|
||||
default:
|
||||
return status || "--";
|
||||
}
|
||||
}
|
||||
|
||||
export function getFamilyJoinSourceLabel(t: TranslateFn, joinSource?: string) {
|
||||
const normalized = normalizeEnumValue(joinSource);
|
||||
if (!normalized) return "--";
|
||||
|
||||
switch (normalized) {
|
||||
case "owner_init":
|
||||
return t("familyJoinSourceOwnerInit", "Owner Initialization");
|
||||
case "bind_email_with_verification":
|
||||
return t(
|
||||
"familyJoinSourceBindEmailWithVerification",
|
||||
"Bind Email With Verification"
|
||||
);
|
||||
default:
|
||||
return joinSource || "--";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Badge } from "@workspace/ui/components/badge";
|
||||
import { Button } from "@workspace/ui/components/button";
|
||||
import { ScrollArea } from "@workspace/ui/components/scroll-area";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
} from "@workspace/ui/components/sheet";
|
||||
import { ConfirmButton } from "@workspace/ui/composed/confirm-button";
|
||||
import { EnhancedInput } from "@workspace/ui/composed/enhanced-input";
|
||||
import {
|
||||
dissolveFamily,
|
||||
getFamilyDetail,
|
||||
removeFamilyMember,
|
||||
updateFamilyMaxMembers,
|
||||
} from "@workspace/ui/services/admin/user";
|
||||
import type { ReactNode } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { formatDate } from "@/utils/common";
|
||||
import {
|
||||
getFamilyJoinSourceLabel,
|
||||
getFamilyMemberStatusLabel,
|
||||
getFamilyRoleLabel,
|
||||
getFamilyStatusLabel,
|
||||
isFamilyMemberStatusActive,
|
||||
isFamilyRoleOwner,
|
||||
isFamilyStatusActive,
|
||||
} from "./enums";
|
||||
|
||||
interface FamilyDetailSheetProps {
|
||||
familyId?: number;
|
||||
trigger: ReactNode;
|
||||
onChanged?: () => void;
|
||||
}
|
||||
|
||||
export function FamilyDetailSheet({
|
||||
familyId,
|
||||
trigger,
|
||||
onChanged,
|
||||
}: Readonly<FamilyDetailSheetProps>) {
|
||||
const { t } = useTranslation("user");
|
||||
const [open, setOpen] = useState(false);
|
||||
const [maxMembersInput, setMaxMembersInput] = useState("");
|
||||
const queryClient = useQueryClient();
|
||||
const validFamilyId = Number(familyId || 0);
|
||||
|
||||
const { data, isLoading, refetch } = useQuery({
|
||||
enabled: open && validFamilyId > 0,
|
||||
queryKey: ["familyDetail", validFamilyId],
|
||||
queryFn: async () => {
|
||||
const { data } = await getFamilyDetail({ id: validFamilyId });
|
||||
return data.data;
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!(open && data?.summary)) return;
|
||||
setMaxMembersInput(String(data.summary.max_members));
|
||||
}, [data?.summary, open]);
|
||||
|
||||
const invalidateAll = async () => {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ["familyList"] }),
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["familyDetail", validFamilyId],
|
||||
}),
|
||||
queryClient.invalidateQueries({ queryKey: ["getUserList"] }),
|
||||
]);
|
||||
onChanged?.();
|
||||
};
|
||||
|
||||
const updateMaxMutation = useMutation({
|
||||
mutationFn: async (maxMembers: number) =>
|
||||
updateFamilyMaxMembers({
|
||||
family_id: validFamilyId,
|
||||
max_members: maxMembers,
|
||||
}),
|
||||
onSuccess: async () => {
|
||||
toast.success(t("updateSuccess", "Updated successfully"));
|
||||
await invalidateAll();
|
||||
await refetch();
|
||||
},
|
||||
});
|
||||
|
||||
const removeMemberMutation = useMutation({
|
||||
mutationFn: async (userId: number) =>
|
||||
removeFamilyMember({ family_id: validFamilyId, user_id: userId }),
|
||||
onSuccess: async () => {
|
||||
toast.success(t("removeSuccess", "Removed successfully"));
|
||||
await invalidateAll();
|
||||
await refetch();
|
||||
},
|
||||
});
|
||||
|
||||
const dissolveMutation = useMutation({
|
||||
mutationFn: async () => dissolveFamily({ family_id: validFamilyId }),
|
||||
onSuccess: async () => {
|
||||
toast.success(t("familyDissolved", "Family dissolved"));
|
||||
await invalidateAll();
|
||||
await refetch();
|
||||
},
|
||||
});
|
||||
|
||||
const canDissolve = isFamilyStatusActive(data?.summary.status);
|
||||
|
||||
const summaryItems = useMemo(() => {
|
||||
if (!data?.summary) return [];
|
||||
return [
|
||||
{ label: t("familyId", "Family ID"), value: data.summary.family_id },
|
||||
{ label: t("owner", "Owner"), value: data.summary.owner_identifier },
|
||||
{ label: t("userId", "User ID"), value: data.summary.owner_user_id },
|
||||
{
|
||||
label: t("familyStatus", "Family Status"),
|
||||
value: getFamilyStatusLabel(t, data.summary.status),
|
||||
},
|
||||
{
|
||||
label: t("memberCount", "Member Count"),
|
||||
value: `${data.summary.active_member_count}/${data.summary.max_members}`,
|
||||
},
|
||||
{
|
||||
label: t("createdAt", "Created At"),
|
||||
value: formatDate(data.summary.created_at),
|
||||
},
|
||||
{
|
||||
label: t("updatedAt", "Updated At"),
|
||||
value: formatDate(data.summary.updated_at),
|
||||
},
|
||||
];
|
||||
}, [data?.summary, t]);
|
||||
|
||||
return (
|
||||
<Sheet onOpenChange={setOpen} open={open}>
|
||||
<SheetTrigger asChild>{trigger}</SheetTrigger>
|
||||
<SheetContent className="w-[920px] max-w-full md:max-w-6xl" side="right">
|
||||
<SheetHeader>
|
||||
<SheetTitle>
|
||||
{t("familyDetail", "Family Detail")}
|
||||
{validFamilyId ? ` · ID: ${validFamilyId}` : ""}
|
||||
</SheetTitle>
|
||||
</SheetHeader>
|
||||
<ScrollArea className="h-[calc(100dvh-120px)] pr-2">
|
||||
{isLoading ? (
|
||||
<div className="p-4">{t("loading", "Loading...")}</div>
|
||||
) : data ? (
|
||||
<div className="space-y-4 p-2">
|
||||
<div className="rounded-md border p-4">
|
||||
<h3 className="mb-3 font-medium text-sm">
|
||||
{t("familySummary", "Family Summary")}
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{summaryItems.map((item) => (
|
||||
<div
|
||||
className="flex items-center justify-between rounded bg-muted/40 px-3 py-2"
|
||||
key={item.label}
|
||||
>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{item.label}
|
||||
</span>
|
||||
<span className="font-medium text-sm">{item.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border p-4">
|
||||
<h3 className="mb-3 font-medium text-sm">
|
||||
{t("familyActions", "Family Actions")}
|
||||
</h3>
|
||||
<div className="flex flex-wrap items-end gap-2">
|
||||
<div className="w-40">
|
||||
<EnhancedInput
|
||||
onValueChange={(value) => {
|
||||
setMaxMembersInput(value);
|
||||
}}
|
||||
type="number"
|
||||
value={maxMembersInput}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
disabled={updateMaxMutation.isPending}
|
||||
onClick={() => {
|
||||
const maxMembers = Number(maxMembersInput);
|
||||
if (
|
||||
!maxMembers ||
|
||||
Number.isNaN(maxMembers) ||
|
||||
maxMembers <= 0
|
||||
) {
|
||||
toast.error(
|
||||
t("familyInvalidMaxMembers", "Invalid max members")
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
data.summary.active_member_count &&
|
||||
maxMembers < data.summary.active_member_count
|
||||
) {
|
||||
toast.error(
|
||||
t(
|
||||
"familyMaxMembersTooSmall",
|
||||
"Max members cannot be lower than active member count"
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
updateMaxMutation.mutate(maxMembers);
|
||||
}}
|
||||
variant="secondary"
|
||||
>
|
||||
{t("familyUpdateMaxMembers", "Update Max Members")}
|
||||
</Button>
|
||||
|
||||
<ConfirmButton
|
||||
cancelText={t("cancel", "Cancel")}
|
||||
confirmText={t("confirm", "Confirm")}
|
||||
description={t(
|
||||
"familyDissolveDescription",
|
||||
"This will dissolve the family and remove all active members."
|
||||
)}
|
||||
onConfirm={async () => {
|
||||
await dissolveMutation.mutateAsync();
|
||||
}}
|
||||
title={t(
|
||||
"familyConfirmDissolve",
|
||||
"Confirm Dissolve Family"
|
||||
)}
|
||||
trigger={
|
||||
<Button
|
||||
disabled={!canDissolve || dissolveMutation.isPending}
|
||||
variant="destructive"
|
||||
>
|
||||
{t("familyDissolve", "Dissolve Family")}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border p-4">
|
||||
<h3 className="mb-3 font-medium text-sm">
|
||||
{t("familyMembers", "Family Members")}
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{data.members?.length ? (
|
||||
data.members.map((member) => {
|
||||
const canRemove =
|
||||
isFamilyStatusActive(data.summary.status) &&
|
||||
!isFamilyRoleOwner(member.role_name) &&
|
||||
isFamilyMemberStatusActive(member.status_name);
|
||||
return (
|
||||
<div
|
||||
className="flex items-center justify-between rounded border px-3 py-2"
|
||||
key={`${member.user_id}-${member.joined_at}`}
|
||||
>
|
||||
<div className="space-y-1 text-xs">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline">
|
||||
ID: {member.user_id}
|
||||
</Badge>
|
||||
{member.device_no ? (
|
||||
<Badge variant="outline">
|
||||
<span className="font-mono">
|
||||
{member.device_no}
|
||||
</span>
|
||||
</Badge>
|
||||
) : null}
|
||||
<span>{member.identifier}</span>
|
||||
<Badge>
|
||||
{getFamilyRoleLabel(t, member.role_name)}
|
||||
</Badge>
|
||||
<Badge
|
||||
variant={
|
||||
isFamilyMemberStatusActive(member.status_name)
|
||||
? "default"
|
||||
: "destructive"
|
||||
}
|
||||
>
|
||||
{getFamilyMemberStatusLabel(
|
||||
t,
|
||||
member.status_name
|
||||
)}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="text-muted-foreground">
|
||||
{t("familyJoinSource", "Join Source")}:{" "}
|
||||
{getFamilyJoinSourceLabel(t, member.join_source)}{" "}
|
||||
· {t("familyJoinedAt", "Joined At")}:{" "}
|
||||
{member.joined_at
|
||||
? formatDate(member.joined_at)
|
||||
: "--"}{" "}
|
||||
· {t("familyLeftAt", "Left At")}:{" "}
|
||||
{member.left_at
|
||||
? formatDate(member.left_at)
|
||||
: "--"}
|
||||
</div>
|
||||
</div>
|
||||
{canRemove ? (
|
||||
<ConfirmButton
|
||||
cancelText={t("cancel", "Cancel")}
|
||||
confirmText={t("confirm", "Confirm")}
|
||||
description={t(
|
||||
"familyRemoveMemberDescription",
|
||||
"This will remove the member from the active family."
|
||||
)}
|
||||
onConfirm={async () => {
|
||||
await removeMemberMutation.mutateAsync(
|
||||
member.user_id
|
||||
);
|
||||
}}
|
||||
title={t(
|
||||
"familyConfirmRemoveMember",
|
||||
"Confirm Remove Member"
|
||||
)}
|
||||
trigger={
|
||||
<Button
|
||||
disabled={removeMemberMutation.isPending}
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
>
|
||||
{t("remove", "Remove")}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<div className="text-muted-foreground text-sm">
|
||||
{t("familyNoMembers", "No members")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-4 text-muted-foreground">
|
||||
{t("familyNoData", "No family data")}
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import { Badge } from "@workspace/ui/components/badge";
|
||||
import { Button } from "@workspace/ui/components/button";
|
||||
import {
|
||||
ProTable,
|
||||
type ProTableActions,
|
||||
} from "@workspace/ui/composed/pro-table/pro-table";
|
||||
import { getFamilyList } from "@workspace/ui/services/admin/user";
|
||||
import { useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { formatDate } from "@/utils/common";
|
||||
import { getFamilyStatusLabel, isFamilyStatusActive } from "./enums";
|
||||
import { FamilyDetailSheet } from "./family-detail-sheet";
|
||||
|
||||
interface FamilyManagementProps {
|
||||
initialFamilyId?: number;
|
||||
initialUserId?: number;
|
||||
onChanged?: () => void;
|
||||
}
|
||||
|
||||
export default function FamilyManagement({
|
||||
initialFamilyId,
|
||||
initialUserId,
|
||||
onChanged,
|
||||
}: Readonly<FamilyManagementProps>) {
|
||||
const { t } = useTranslation("user");
|
||||
const ref = useRef<ProTableActions>(null);
|
||||
|
||||
const initialFilters = {
|
||||
family_id: initialFamilyId || undefined,
|
||||
user_id: initialUserId || undefined,
|
||||
};
|
||||
|
||||
return (
|
||||
<ProTable<API.FamilySummary, API.GetFamilyListParams>
|
||||
action={ref}
|
||||
actions={{
|
||||
render: (row) => [
|
||||
<FamilyDetailSheet
|
||||
familyId={row.family_id}
|
||||
key={`detail-${row.family_id}`}
|
||||
onChanged={() => {
|
||||
ref.current?.refresh();
|
||||
onChanged?.();
|
||||
}}
|
||||
trigger={<Button>{t("familyDetail", "Family Detail")}</Button>}
|
||||
/>,
|
||||
],
|
||||
}}
|
||||
columns={[
|
||||
{
|
||||
accessorKey: "family_id",
|
||||
header: t("familyId", "Family ID"),
|
||||
},
|
||||
{
|
||||
accessorKey: "owner_identifier",
|
||||
header: t("owner", "Owner"),
|
||||
cell: ({ row }) =>
|
||||
`${row.original.owner_identifier} (ID: ${row.original.owner_user_id})`,
|
||||
},
|
||||
{
|
||||
accessorKey: "status",
|
||||
header: t("status", "Status"),
|
||||
cell: ({ row }) => {
|
||||
const status = row.getValue("status") as string;
|
||||
return isFamilyStatusActive(status) ? (
|
||||
<Badge>{t("statusActive", "Active")}</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary">
|
||||
{getFamilyStatusLabel(t, status)}
|
||||
</Badge>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "active_member_count",
|
||||
header: t("memberCount", "Member Count"),
|
||||
cell: ({ row }) =>
|
||||
`${row.original.active_member_count}/${row.original.max_members}`,
|
||||
},
|
||||
{
|
||||
accessorKey: "max_members",
|
||||
header: t("familyMaxMembers", "Max Members"),
|
||||
},
|
||||
{
|
||||
accessorKey: "created_at",
|
||||
header: t("createdAt", "Created At"),
|
||||
cell: ({ row }) => formatDate(row.getValue("created_at")),
|
||||
},
|
||||
{
|
||||
accessorKey: "updated_at",
|
||||
header: t("updatedAt", "Updated At"),
|
||||
cell: ({ row }) => formatDate(row.getValue("updated_at")),
|
||||
},
|
||||
]}
|
||||
header={{
|
||||
title: t("familyManagement", "Family Group Management"),
|
||||
}}
|
||||
initialFilters={initialFilters}
|
||||
key={String(initialFamilyId || initialUserId || "all")}
|
||||
params={[
|
||||
{
|
||||
key: "keyword",
|
||||
placeholder: t("search", "Search"),
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
placeholder: t("status", "Status"),
|
||||
options: [
|
||||
{ label: getFamilyStatusLabel(t, "active"), value: "active" },
|
||||
{ label: getFamilyStatusLabel(t, "disabled"), value: "disabled" },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "owner_user_id",
|
||||
placeholder: t("familyOwnerUserId", "Owner User ID"),
|
||||
},
|
||||
{
|
||||
key: "family_id",
|
||||
placeholder: t("familyId", "Family ID"),
|
||||
},
|
||||
{
|
||||
key: "user_id",
|
||||
placeholder: t("userId", "User ID"),
|
||||
},
|
||||
]}
|
||||
request={async (pagination, filter) => {
|
||||
const { data } = await getFamilyList({
|
||||
...pagination,
|
||||
...filter,
|
||||
});
|
||||
return {
|
||||
list: data.data?.list || [],
|
||||
total: data.data?.total || 0,
|
||||
};
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -8,7 +8,15 @@ import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@workspace/ui/components/dropdown-menu";
|
||||
import { Input } from "@workspace/ui/components/input";
|
||||
import { ScrollArea } from "@workspace/ui/components/scroll-area";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@workspace/ui/components/select";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
@@ -23,6 +31,7 @@ import {
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from "@workspace/ui/components/tabs";
|
||||
import { Combobox } from "@workspace/ui/composed/combobox";
|
||||
import { ConfirmButton } from "@workspace/ui/composed/confirm-button";
|
||||
import {
|
||||
ProTable,
|
||||
@@ -35,14 +44,16 @@ import {
|
||||
getUserList,
|
||||
updateUserBasicInfo,
|
||||
} from "@workspace/ui/services/admin/user";
|
||||
import { useRef, useState } from "react";
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { Display } from "@/components/display";
|
||||
import { useSubscribe } from "@/stores/subscribe";
|
||||
import { formatDate } from "@/utils/common";
|
||||
import FamilyManagement from "./family";
|
||||
import { UserDetail } from "./user-detail";
|
||||
import UserForm from "./user-form";
|
||||
import { UserInviteStatsSheet } from "./user-invite-stats-sheet";
|
||||
import { AuthMethodsForm } from "./user-profile/auth-methods-form";
|
||||
import { BasicInfoForm } from "./user-profile/basic-info-form";
|
||||
import { NotifySettingsForm } from "./user-profile/notify-settings-form";
|
||||
@@ -56,13 +67,15 @@ export default function User() {
|
||||
|
||||
const { subscribes } = useSubscribe();
|
||||
|
||||
const initialFilters = {
|
||||
search: sp.search || undefined,
|
||||
user_id: sp.user_id || undefined,
|
||||
subscribe_id: sp.subscribe_id || undefined,
|
||||
user_subscribe_id: sp.user_subscribe_id || undefined,
|
||||
short_code: sp.short_code || undefined,
|
||||
};
|
||||
const searchRef = useRef({
|
||||
type: sp.user_id ? "user_id" : "email",
|
||||
value: sp.search || sp.user_id || "",
|
||||
});
|
||||
|
||||
const handleSearch = useCallback((type: string, value: string) => {
|
||||
searchRef.current = { type, value };
|
||||
ref.current?.refresh();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ProTable<API.User, API.GetUserListParams>
|
||||
@@ -75,6 +88,11 @@ export default function User() {
|
||||
userId={row.id}
|
||||
/>,
|
||||
<SubscriptionSheet key="subscription" userId={row.id} />,
|
||||
<DeviceGroupSheet
|
||||
key="device-group"
|
||||
onChanged={() => ref.current?.refresh()}
|
||||
userId={row.id}
|
||||
/>,
|
||||
<ConfirmButton
|
||||
cancelText={t("cancel", "Cancel")}
|
||||
confirmText={t("confirm", "Confirm")}
|
||||
@@ -98,6 +116,7 @@ export default function User() {
|
||||
<Button variant="outline">{t("more", "More")}</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<InviteStatsMenuItem userId={row.id} />
|
||||
<DropdownMenuItem asChild>
|
||||
<Link
|
||||
search={{ user_id: String(row.id) }}
|
||||
@@ -194,6 +213,10 @@ export default function User() {
|
||||
header: t("userName", "Username"),
|
||||
cell: ({ row }) => {
|
||||
const method = row.original.auth_methods?.[0];
|
||||
const identifier = method?.auth_identifier || "";
|
||||
const isDevice = method?.auth_type === "device";
|
||||
const deviceNo = (row.original.user_devices?.[0] as any)?.device_no;
|
||||
const display = isDevice ? deviceNo || identifier : identifier;
|
||||
return (
|
||||
<div>
|
||||
<Badge
|
||||
@@ -202,7 +225,7 @@ export default function User() {
|
||||
>
|
||||
{method?.auth_type}
|
||||
</Badge>
|
||||
{method?.auth_identifier}
|
||||
<span title={isDevice ? display : undefined}>{display}</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
@@ -245,7 +268,14 @@ export default function User() {
|
||||
},
|
||||
]}
|
||||
header={{
|
||||
title: t("userList", "User List"),
|
||||
title: (
|
||||
<UserSearchBar
|
||||
initialType={searchRef.current.type}
|
||||
initialValue={searchRef.current.value}
|
||||
onSearch={handleSearch}
|
||||
subscribes={subscribes}
|
||||
/>
|
||||
),
|
||||
toolbar: (
|
||||
<UserForm<API.CreateUserRequest>
|
||||
key="create"
|
||||
@@ -270,39 +300,19 @@ export default function User() {
|
||||
/>
|
||||
),
|
||||
}}
|
||||
initialFilters={initialFilters}
|
||||
key={initialFilters.user_id}
|
||||
params={[
|
||||
{
|
||||
key: "subscribe_id",
|
||||
placeholder: t("subscription", "Subscription"),
|
||||
options: subscribes?.map((item) => ({
|
||||
label: item.name!,
|
||||
value: String(item.id!),
|
||||
})),
|
||||
},
|
||||
{
|
||||
key: "search",
|
||||
placeholder: "Search",
|
||||
},
|
||||
{
|
||||
key: "user_id",
|
||||
placeholder: t("userId", "User ID"),
|
||||
},
|
||||
{
|
||||
key: "user_subscribe_id",
|
||||
placeholder: t("subscriptionId", "Subscription ID"),
|
||||
},
|
||||
{
|
||||
key: "short_code",
|
||||
placeholder: t("shortCode", "Short Code"),
|
||||
},
|
||||
]}
|
||||
request={async (pagination, filter) => {
|
||||
const { data } = await getUserList({
|
||||
...pagination,
|
||||
...filter,
|
||||
});
|
||||
request={async (pagination) => {
|
||||
const { type, value } = searchRef.current;
|
||||
const params: Record<string, unknown> = { ...pagination };
|
||||
if (value) {
|
||||
if (type === "user_id") {
|
||||
params.user_id = value;
|
||||
} else if (type === "subscribe_id") {
|
||||
params.subscribe_id = value;
|
||||
} else {
|
||||
params.search = value;
|
||||
}
|
||||
}
|
||||
const { data } = await getUserList(params as API.GetUserListParams);
|
||||
return {
|
||||
list: data.data?.list || [],
|
||||
total: data.data?.total || 0,
|
||||
@@ -401,3 +411,137 @@ function SubscriptionSheet({ userId }: { userId: number }) {
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
function InviteStatsMenuItem({ userId }: { userId: number }) {
|
||||
const { t } = useTranslation("user");
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<>
|
||||
<DropdownMenuItem
|
||||
onSelect={(e) => {
|
||||
e.preventDefault();
|
||||
setOpen(true);
|
||||
}}
|
||||
>
|
||||
{t("inviteStats", "Invite Statistics")}
|
||||
</DropdownMenuItem>
|
||||
<UserInviteStatsSheet
|
||||
onOpenChange={setOpen}
|
||||
open={open}
|
||||
userId={userId}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function DeviceGroupSheet({
|
||||
userId,
|
||||
onChanged,
|
||||
}: {
|
||||
userId: number;
|
||||
onChanged?: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation("user");
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<Sheet onOpenChange={setOpen} open={open}>
|
||||
<SheetTrigger asChild>
|
||||
<Button variant="outline">{t("deviceGroup", "Device Group")}</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent className="w-[1000px] max-w-full md:max-w-7xl" side="right">
|
||||
<SheetHeader>
|
||||
<SheetTitle>
|
||||
{t("deviceGroup", "Device Group")} · ID: {userId}
|
||||
</SheetTitle>
|
||||
</SheetHeader>
|
||||
<div className="mt-2 px-4">
|
||||
<FamilyManagement initialUserId={userId} onChanged={onChanged} />
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
function UserSearchBar({
|
||||
initialType,
|
||||
initialValue,
|
||||
onSearch,
|
||||
subscribes,
|
||||
}: {
|
||||
initialType: string;
|
||||
initialValue: string;
|
||||
onSearch: (type: string, value: string) => void;
|
||||
subscribes?: API.SubscribeItem[];
|
||||
}) {
|
||||
const { t } = useTranslation("user");
|
||||
const [searchType, setSearchType] = useState(initialType);
|
||||
const [searchValue, setSearchValue] = useState(initialValue);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Select
|
||||
onValueChange={(v) => {
|
||||
setSearchType(v);
|
||||
setSearchValue("");
|
||||
}}
|
||||
value={searchType}
|
||||
>
|
||||
<SelectTrigger className="w-24">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="email">{t("email", "Email")}</SelectItem>
|
||||
<SelectItem value="device">{t("deviceSearch", "Device")}</SelectItem>
|
||||
<SelectItem value="user_id">{t("userId", "User ID")}</SelectItem>
|
||||
<SelectItem value="subscribe_id">
|
||||
{t("subscription", "Subscription")}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{searchType === "subscribe_id" ? (
|
||||
<Combobox
|
||||
className="w-48"
|
||||
onChange={(value) => {
|
||||
setSearchValue(value);
|
||||
onSearch("subscribe_id", value);
|
||||
}}
|
||||
options={subscribes?.map((item) => ({
|
||||
label: item.name!,
|
||||
value: String(item.id!),
|
||||
}))}
|
||||
placeholder={t("subscription", "Subscription")}
|
||||
value={searchValue}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<Input
|
||||
className="w-48"
|
||||
onChange={(e) => setSearchValue(e.target.value)}
|
||||
onKeyDown={(e) =>
|
||||
e.key === "Enter" && onSearch(searchType, searchValue)
|
||||
}
|
||||
placeholder={t("searchInputPlaceholder", "Enter search term")}
|
||||
value={searchValue}
|
||||
/>
|
||||
<Button
|
||||
onClick={() => onSearch(searchType, searchValue)}
|
||||
variant="default"
|
||||
>
|
||||
{t("search", "Search")}
|
||||
</Button>
|
||||
{searchValue && (
|
||||
<Button
|
||||
onClick={() => {
|
||||
setSearchValue("");
|
||||
onSearch(searchType, "");
|
||||
}}
|
||||
variant="outline"
|
||||
>
|
||||
{t("resetSearch", "Reset")}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
getUserDetail,
|
||||
getUserSubscribeById,
|
||||
} from "@workspace/ui/services/admin/user";
|
||||
import { shortenDeviceIdentifier } from "@workspace/ui/utils/device";
|
||||
import { formatBytes } from "@workspace/ui/utils/formatting";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Display } from "@/components/display";
|
||||
@@ -163,9 +164,14 @@ export function UserDetail({ id }: { id: number }) {
|
||||
|
||||
if (!id) return "--";
|
||||
|
||||
const identifier =
|
||||
data?.auth_methods.find((m) => m.auth_type === "email")?.auth_identifier ||
|
||||
data?.auth_methods[0]?.auth_identifier;
|
||||
const emailMethod = data?.auth_methods.find((m) => m.auth_type === "email");
|
||||
const firstMethod = data?.auth_methods[0];
|
||||
const rawIdentifier =
|
||||
emailMethod?.auth_identifier || firstMethod?.auth_identifier || "";
|
||||
const isDevice = !emailMethod && firstMethod?.auth_type === "device";
|
||||
const identifier = isDevice
|
||||
? shortenDeviceIdentifier(rawIdentifier)
|
||||
: rawIdentifier;
|
||||
|
||||
return (
|
||||
<HoverCard>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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