feat: Refactor user detail and subscription management components

This commit is contained in:
web 2025-08-27 06:28:51 -07:00
parent 2f20ac95da
commit 973c06f0fa
31 changed files with 459 additions and 408 deletions

View File

@ -5,12 +5,20 @@ import { ProTable } from '@/components/pro-table';
import { filterBalanceLog } from '@/services/admin/log';
import { formatDate } from '@workspace/ui/utils';
import { useTranslations } from 'next-intl';
import { useSearchParams } from 'next/navigation';
export default function BalanceLogPage() {
const t = useTranslations('log');
const sp = useSearchParams();
const initialFilters = {
search: sp.get('search') || undefined,
date: sp.get('date') || undefined,
user_id: sp.get('user_id') ? Number(sp.get('user_id')) : undefined,
};
return (
<ProTable<API.BalanceLog, { search?: string }>
header={{ title: t('title.balance') }}
initialFilters={initialFilters}
columns={[
{
accessorKey: 'user',

View File

@ -5,12 +5,20 @@ import { ProTable } from '@/components/pro-table';
import { filterCommissionLog } from '@/services/admin/log';
import { formatDate } from '@workspace/ui/utils';
import { useTranslations } from 'next-intl';
import { useSearchParams } from 'next/navigation';
export default function CommissionLogPage() {
const t = useTranslations('log');
const sp = useSearchParams();
const initialFilters = {
search: sp.get('search') || undefined,
date: sp.get('date') || undefined,
user_id: sp.get('user_id') ? Number(sp.get('user_id')) : undefined,
};
return (
<ProTable<API.CommissionLog, { search?: string }>
header={{ title: t('title.commission') }}
initialFilters={initialFilters}
columns={[
{
accessorKey: 'user',

View File

@ -5,12 +5,19 @@ import { filterEmailLog } from '@/services/admin/log';
import { Badge } from '@workspace/ui/components/badge';
import { formatDate } from '@workspace/ui/utils';
import { useTranslations } from 'next-intl';
import { useSearchParams } from 'next/navigation';
export default function EmailLogPage() {
const t = useTranslations('log');
const sp = useSearchParams();
const initialFilters = {
search: sp.get('search') || undefined,
date: sp.get('date') || undefined,
};
return (
<ProTable<API.MessageLog, { search?: string }>
header={{ title: t('title.email') }}
initialFilters={initialFilters}
columns={[
{
accessorKey: 'id',

View File

@ -5,12 +5,20 @@ import { ProTable } from '@/components/pro-table';
import { filterGiftLog } from '@/services/admin/log';
import { formatDate } from '@workspace/ui/utils';
import { useTranslations } from 'next-intl';
import { useSearchParams } from 'next/navigation';
export default function GiftLogPage() {
const t = useTranslations('log');
const sp = useSearchParams();
const initialFilters = {
search: sp.get('search') || undefined,
date: sp.get('date') || undefined,
user_id: sp.get('user_id') ? Number(sp.get('user_id')) : undefined,
};
return (
<ProTable<API.GiftLog, { search?: string }>
header={{ title: t('title.gift') }}
initialFilters={initialFilters}
columns={[
{
accessorKey: 'user',

View File

@ -7,12 +7,20 @@ import { filterLoginLog } from '@/services/admin/log';
import { Badge } from '@workspace/ui/components/badge';
import { formatDate } from '@workspace/ui/utils';
import { useTranslations } from 'next-intl';
import { useSearchParams } from 'next/navigation';
export default function LoginLogPage() {
const t = useTranslations('log');
const sp = useSearchParams();
const initialFilters = {
search: sp.get('search') || undefined,
date: sp.get('date') || undefined,
user_id: sp.get('user_id') ? Number(sp.get('user_id')) : undefined,
};
return (
<ProTable<API.LoginLog, { search?: string }>
header={{ title: t('title.login') }}
initialFilters={initialFilters}
columns={[
{
accessorKey: 'user',

View File

@ -5,11 +5,18 @@ import { filterMobileLog } from '@/services/admin/log';
import { Badge } from '@workspace/ui/components/badge';
import { formatDate } from '@workspace/ui/utils';
import { useTranslations } from 'next-intl';
import { useSearchParams } from 'next/navigation';
export default function MobileLogPage() {
const t = useTranslations('log');
const sp = useSearchParams();
const initialFilters = {
search: sp.get('search') || undefined,
date: sp.get('date') || undefined,
};
return (
<ProTable<API.MessageLog, { search?: string }>
header={{ title: t('title.mobile') }}
initialFilters={initialFilters}
columns={[
{
accessorKey: 'id',

View File

@ -6,12 +6,20 @@ import { ProTable } from '@/components/pro-table';
import { filterRegisterLog } from '@/services/admin/log';
import { formatDate } from '@workspace/ui/utils';
import { useTranslations } from 'next-intl';
import { useSearchParams } from 'next/navigation';
export default function RegisterLogPage() {
const t = useTranslations('log');
const sp = useSearchParams();
const initialFilters = {
search: sp.get('search') || undefined,
date: sp.get('date') || undefined,
user_id: sp.get('user_id') ? Number(sp.get('user_id')) : undefined,
};
return (
<ProTable<API.RegisterLog, { search?: string }>
header={{ title: t('title.register') }}
initialFilters={initialFilters}
columns={[
{
accessorKey: 'user',

View File

@ -5,12 +5,22 @@ import { ProTable } from '@/components/pro-table';
import { filterResetSubscribeLog } from '@/services/admin/log';
import { formatDate } from '@workspace/ui/utils';
import { useTranslations } from 'next-intl';
import { useSearchParams } from 'next/navigation';
export default function ResetSubscribeLogPage() {
const t = useTranslations('log');
const sp = useSearchParams();
const initialFilters = {
search: sp.get('search') || undefined,
date: sp.get('date') || undefined,
user_subscribe_id: sp.get('user_subscribe_id')
? Number(sp.get('user_subscribe_id'))
: undefined,
};
return (
<ProTable<API.ResetSubscribeLog, { search?: string }>
header={{ title: t('title.resetSubscribe') }}
initialFilters={initialFilters}
columns={[
{
accessorKey: 'user',

View File

@ -4,12 +4,20 @@ import { ProTable } from '@/components/pro-table';
import { filterServerTrafficLog } from '@/services/admin/log';
import { formatBytes } from '@workspace/ui/utils';
import { useTranslations } from 'next-intl';
import { useSearchParams } from 'next/navigation';
export default function ServerTrafficLogPage() {
const t = useTranslations('log');
const sp = useSearchParams();
const initialFilters = {
search: sp.get('search') || undefined,
date: sp.get('date') || undefined,
server_id: sp.get('server_id') ? Number(sp.get('server_id')) : undefined,
};
return (
<ProTable<API.ServerTrafficLog, { search?: string }>
header={{ title: t('title.serverTraffic') }}
initialFilters={initialFilters}
columns={[
{ accessorKey: 'server_id', header: t('column.serverId') },
{

View File

@ -5,12 +5,23 @@ import { ProTable } from '@/components/pro-table';
import { filterUserSubscribeTrafficLog } from '@/services/admin/log';
import { formatBytes } from '@workspace/ui/utils';
import { useTranslations } from 'next-intl';
import { useSearchParams } from 'next/navigation';
export default function SubscribeTrafficLogPage() {
const t = useTranslations('log');
const sp = useSearchParams();
const initialFilters = {
search: sp.get('search') || undefined,
date: sp.get('date') || undefined,
user_id: sp.get('user_id') ? Number(sp.get('user_id')) : undefined,
user_subscribe_id: sp.get('user_subscribe_id')
? Number(sp.get('user_subscribe_id'))
: undefined,
};
return (
<ProTable<API.UserSubscribeTrafficLog, { search?: string }>
header={{ title: t('title.subscribeTraffic') }}
initialFilters={initialFilters}
columns={[
{
accessorKey: 'user',

View File

@ -6,12 +6,20 @@ import { ProTable } from '@/components/pro-table';
import { filterSubscribeLog } from '@/services/admin/log';
import { formatDate } from '@workspace/ui/utils';
import { useTranslations } from 'next-intl';
import { useSearchParams } from 'next/navigation';
export default function SubscribeLogPage() {
const t = useTranslations('log');
const sp = useSearchParams();
const initialFilters = {
search: sp.get('search') || undefined,
date: sp.get('date') || undefined,
user_id: sp.get('user_id') ? Number(sp.get('user_id')) : undefined,
};
return (
<ProTable<API.SubscribeLog, { search?: string }>
header={{ title: t('title.subscribe') }}
initialFilters={initialFilters}
columns={[
{
accessorKey: 'user',

View File

@ -4,12 +4,22 @@ import { ProTable } from '@/components/pro-table';
import { filterTrafficLogDetails } from '@/services/admin/log';
import { formatBytes, formatDate } from '@workspace/ui/utils';
import { useTranslations } from 'next-intl';
import { useSearchParams } from 'next/navigation';
export default function TrafficDetailsPage() {
const t = useTranslations('log');
const sp = useSearchParams();
const initialFilters = {
search: sp.get('search') || undefined,
date: sp.get('date') || undefined,
server_id: sp.get('server_id') ? Number(sp.get('server_id')) : undefined,
user_id: sp.get('user_id') ? Number(sp.get('user_id')) : undefined,
subscribe_id: sp.get('subscribe_id') ? Number(sp.get('subscribe_id')) : undefined,
};
return (
<ProTable<API.TrafficLogDetails, { search?: string }>
header={{ title: t('title.trafficDetails') }}
initialFilters={initialFilters}
columns={[
{ accessorKey: 'server_id', header: t('column.serverId') },
{ accessorKey: 'user_id', header: t('column.userId') },

View File

@ -2,6 +2,7 @@
import { useQuery } from '@tanstack/react-query';
import { useTranslations } from 'next-intl';
import { useSearchParams } from 'next/navigation';
import { useRef } from 'react';
import { Display } from '@/components/display';
@ -17,8 +18,9 @@ import { cn } from '@workspace/ui/lib/utils';
import { formatDate } from '@workspace/ui/utils';
import { UserDetail } from '../user/user-detail';
export default function Page(props: any) {
export default function Page() {
const t = useTranslations('order');
const sp = useSearchParams();
const statusOptions = [
{ value: 1, label: t('status.1'), className: 'bg-orange-500' },
@ -41,9 +43,17 @@ export default function Page(props: any) {
},
});
const initialFilters = {
search: sp.get('search') || undefined,
status: sp.get('status') || undefined,
subscribe_id: sp.get('subscribe_id') || undefined,
user_id: sp.get('user_id') || undefined,
};
return (
<ProTable<API.Order, any>
action={ref}
initialFilters={initialFilters}
columns={[
{
accessorKey: 'order_no',
@ -146,7 +156,7 @@ export default function Page(props: any) {
if ([1, 3, 4].includes(row.getValue('status'))) {
return (
<Combobox<number, false>
placeholder='状态'
placeholder={t('status.0')}
value={row.original.status}
onChange={async (value) => {
await updateOrderStatus({
@ -182,19 +192,14 @@ export default function Page(props: any) {
})),
},
{ key: 'search' },
].concat(
props.userId
? []
: [
{
key: 'user_id',
placeholder: `${t('user')} ID`,
options: undefined,
},
],
)}
{
key: 'user_id',
placeholder: `${t('user')} ID`,
options: undefined,
},
]}
request={async (pagination, filter) => {
const { data } = await getOrderList({ ...pagination, ...filter, user_id: props.userId });
const { data } = await getOrderList({ ...pagination, ...filter });
return {
list: data.data?.list || [],
total: data.data?.total || 0,

View File

@ -25,7 +25,6 @@ import { Icon } from '@workspace/ui/custom-components/icon';
import { cn } from '@workspace/ui/lib/utils';
import { formatDate } from '@workspace/ui/utils';
import { useTranslations } from 'next-intl';
import NextImage from 'next/legacy/image';
import { useEffect, useRef, useState } from 'react';
import { toast } from 'sonner';
import { UserDetail } from '../user/user-detail';
@ -130,7 +129,7 @@ export default function Page() {
render(row) {
if (row.status !== 4) {
return [
<Button key='reply' size='sm' onClick={() => setTicketId(row.id)}>
<Button key='reply' onClick={() => setTicketId(row.id)}>
{t('reply')}
</Button>,
<ConfirmButton
@ -166,7 +165,7 @@ export default function Page() {
if (!open) setTicketId(null);
}}
>
<DrawerContent className='container mx-auto h-screen'>
<DrawerContent className='container mx-auto h-screen *:select-text'>
<DrawerHeader className='border-b text-left'>
<DrawerTitle>{ticket?.title}</DrawerTitle>
<DrawerDescription>{ticket?.description}</DrawerDescription>
@ -193,7 +192,8 @@ export default function Page() {
>
{item.type === 1 && item.content}
{item.type === 2 && (
<NextImage
// eslint-disable-next-line @next/next/no-img-element
<img
src={item.content!}
width={300}
height={300}

View File

@ -1,33 +0,0 @@
import UserOrderList from '@/app/dashboard/order/page';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@workspace/ui/components/tabs';
import { getTranslations } from 'next-intl/server';
import UserLoginHistory from './user-login-history';
import { UserProfileForm } from './user-profile';
import UserSubscription from './user-subscription';
export default async function Page({ params }: { params: Promise<{ id: number }> }) {
const t = await getTranslations('user');
const { id } = await params;
return (
<Tabs defaultValue='profile'>
<TabsList>
<TabsTrigger value='profile'>{t('userProfile')}</TabsTrigger>
<TabsTrigger value='subscriptions'>{t('userSubscriptions')}</TabsTrigger>
<TabsTrigger value='orders'>{t('userOrders')}</TabsTrigger>
<TabsTrigger value='logs'>{t('userLogs')}</TabsTrigger>
</TabsList>
<TabsContent value='profile'>
<UserProfileForm />
</TabsContent>
<TabsContent value='subscriptions'>
<UserSubscription userId={id} />
</TabsContent>
<TabsContent value='orders'>
<UserOrderList userId={id} />
</TabsContent>
<TabsContent value='logs'>
<UserLoginHistory />
</TabsContent>
</Tabs>
);
}

View File

@ -1,55 +0,0 @@
'use client';
import { IpLink } from '@/components/ip-link';
import { ProTable } from '@/components/pro-table';
import { getUserLoginLogs } from '@/services/admin/user';
import { Badge } from '@workspace/ui/components/badge';
import { formatDate } from '@workspace/ui/utils';
import { useTranslations } from 'next-intl';
import { useParams } from 'next/navigation';
export default function UserLoginHistory() {
const t = useTranslations('user');
const { id } = useParams<{ id: string }>();
return (
<ProTable<API.UserLoginLog, Record<string, unknown>>
columns={[
{
accessorKey: 'success',
header: t('loginStatus'),
cell: ({ row }) => (
<Badge variant={row.getValue('success') ? 'default' : 'destructive'}>
{row.getValue('success') ? t('success') : t('failed')}
</Badge>
),
},
{
accessorKey: 'login_ip',
header: t('loginIp'),
cell: ({ row }) => <IpLink ip={row.getValue('login_ip')} />,
},
{
accessorKey: 'user_agent',
header: t('userAgent'),
},
{
accessorKey: 'created_at',
header: t('loginTime'),
cell: ({ row }) => formatDate(row.getValue('created_at')),
},
]}
request={async (pagination, filter) => {
const { data } = await getUserLoginLogs({
user_id: Number(id),
...pagination,
...filter,
});
return {
list: data.data?.list || [],
total: data.data?.total || 0,
};
}}
/>
);
}

View File

@ -1,38 +0,0 @@
'use client';
import { getUserDetail } from '@/services/admin/user';
import { useQuery } from '@tanstack/react-query';
import { useParams } from 'next/navigation';
import { AuthMethodsForm } from './auth-methods-form';
import { BasicInfoForm } from './basic-info-form';
import { NotifySettingsForm } from './notify-settings-form';
export function UserProfileForm() {
const { id } = useParams<{ id: string }>();
const { data: user, refetch } = useQuery({
queryKey: ['user', id],
queryFn: async () => {
const { data } = await getUserDetail({
id: Number(id),
});
return data.data;
},
});
if (!user) return null;
return (
<div className='grid gap-4 md:grid-cols-2 xl:grid-cols-3'>
<div className='md:col-span-2 xl:col-span-1'>
<BasicInfoForm user={user} refetch={refetch} />
</div>
<div>
<NotifySettingsForm user={user} refetch={refetch} />
</div>
<div>
<AuthMethodsForm user={user} refetch={refetch} />
</div>
</div>
);
}

View File

@ -1,216 +0,0 @@
'use client';
import { Display } from '@/components/display';
import { IpLink } from '@/components/ip-link';
import { ProTable } from '@/components/pro-table';
import {
getUserSubscribeDevices,
getUserSubscribeLogs,
getUserSubscribeTrafficLogs,
kickOfflineByUserDevice,
} from '@/services/admin/user';
import { Badge } from '@workspace/ui/components/badge';
import { Button } from '@workspace/ui/components/button';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@workspace/ui/components/dialog';
import { Switch } from '@workspace/ui/components/switch';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@workspace/ui/components/tabs';
import { ConfirmButton } from '@workspace/ui/custom-components/confirm-button';
import { formatDate } from '@workspace/ui/utils';
import { useTranslations } from 'next-intl';
import { ReactNode, useState } from 'react';
import { toast } from 'sonner';
export function SubscriptionDetail({
trigger,
userId,
subscriptionId,
}: {
trigger: ReactNode;
userId: number;
subscriptionId: number;
}) {
const t = useTranslations('user');
const [open, setOpen] = useState(false);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>{trigger}</DialogTrigger>
<DialogContent className='max-w-5xl'>
<DialogHeader>
<DialogTitle>{t('subscriptionDetails')}</DialogTitle>
</DialogHeader>
<div className='mt-4'>
<Tabs defaultValue='logs'>
<TabsList className='w-full'>
<TabsTrigger value='logs' className='flex-1'>
{t('subscriptionLogs')}
</TabsTrigger>
<TabsTrigger value='traffic' className='flex-1'>
{t('trafficLogs')}
</TabsTrigger>
<TabsTrigger value='devices' className='flex-1'>
{t('onlineDevices')}
</TabsTrigger>
</TabsList>
<div className='mt-4 max-h-[60dvh] overflow-y-auto'>
<TabsContent value='logs'>
<ProTable<API.UserSubscribeLog, Record<string, unknown>>
columns={[
{
accessorKey: 'ip',
header: 'IP',
cell: ({ row }) => <IpLink ip={row.getValue('ip')} />,
},
{
accessorKey: 'user_agent',
header: t('userAgent'),
},
{
accessorKey: 'token',
header: t('token'),
},
{
accessorKey: 'created_at',
header: t('time'),
cell: ({ row }) => formatDate(row.getValue('created_at')),
},
]}
request={async (pagination) => {
const { data } = await getUserSubscribeLogs({
user_id: userId,
subscribe_id: subscriptionId,
...pagination,
});
return {
list: data.data?.list || [],
total: data.data?.total || 0,
};
}}
/>
</TabsContent>
<TabsContent value='traffic'>
<ProTable<API.TrafficLog, Record<string, unknown>>
columns={[
{
accessorKey: 'download',
header: t('download'),
cell: ({ row }) => (
<Display type='traffic' value={row.getValue('download')} />
),
},
{
accessorKey: 'upload',
header: t('upload'),
cell: ({ row }) => <Display type='traffic' value={row.getValue('upload')} />,
},
{
accessorKey: 'timestamp',
header: t('time'),
cell: ({ row }) => formatDate(row.getValue('timestamp')),
},
]}
request={async (pagination) => {
const { data } = await getUserSubscribeTrafficLogs({
user_id: userId,
subscribe_id: subscriptionId,
...pagination,
} as API.GetUserSubscribeTrafficLogsParams);
return {
list: data.data?.list || [],
total: data.data?.total || 0,
};
}}
/>
</TabsContent>
<TabsContent value='devices'>
<ProTable<API.UserDevice, Record<string, unknown>>
columns={[
{
accessorKey: 'enabled',
header: t('enable'),
cell: ({ row }) => (
<Switch
checked={row.getValue('enabled')}
onChange={(checked) => {
console.log('Switch:', checked);
}}
/>
),
},
{
accessorKey: 'id',
header: 'ID',
},
{
accessorKey: 'identifier',
header: 'IMEI',
},
{
accessorKey: 'user_agent',
header: t('userAgent'),
},
{
accessorKey: 'ip',
header: 'IP',
cell: ({ row }) => <IpLink ip={row.getValue('ip')} />,
},
{
accessorKey: 'online',
header: t('loginStatus'),
cell: ({ row }) => (
<Badge variant={row.getValue('online') ? 'default' : 'destructive'}>
{row.getValue('online') ? t('online') : t('offline')}
</Badge>
),
},
{
accessorKey: 'updated_at',
header: t('lastSeen'),
cell: ({ row }) => formatDate(row.getValue('updated_at')),
},
]}
request={async (pagination) => {
const { data } = await getUserSubscribeDevices({
user_id: userId,
subscribe_id: subscriptionId,
...pagination,
});
return {
list: data.data?.list || [],
total: data.data?.total || 0,
};
}}
actions={{
render: (row) => {
if (!row.identifier) return [];
return [
<ConfirmButton
key='offline'
trigger={<Button variant='destructive'>{t('confirmOffline')}</Button>}
title={t('confirmOffline')}
description={t('kickOfflineConfirm', { ip: row.ip })}
onConfirm={async () => {
await kickOfflineByUserDevice({ id: row.id });
toast.success(t('kickOfflineSuccess'));
}}
cancelText={t('cancel')}
confirmText={t('confirm')}
/>,
];
},
}}
/>
</TabsContent>
</div>
</Tabs>
</div>
</DialogContent>
</Dialog>
);
}

View File

@ -3,24 +3,51 @@
import { Display } from '@/components/display';
import { ProTable, ProTableActions } from '@/components/pro-table';
import { getSubscribeList } from '@/services/admin/subscribe';
import { createUser, deleteUser, getUserList, updateUserBasicInfo } from '@/services/admin/user';
import {
createUser,
deleteUser,
getUserDetail,
getUserList,
updateUserBasicInfo,
} from '@/services/admin/user';
import { useQuery } from '@tanstack/react-query';
import { Badge } from '@workspace/ui/components/badge';
import { Button } from '@workspace/ui/components/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@workspace/ui/components/dropdown-menu';
import { ScrollArea } from '@workspace/ui/components/scroll-area';
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetTrigger,
} from '@workspace/ui/components/sheet';
import { Switch } from '@workspace/ui/components/switch';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@workspace/ui/components/tabs';
import { ConfirmButton } from '@workspace/ui/custom-components/confirm-button';
import { formatDate } from '@workspace/ui/utils';
import { useTranslations } from 'next-intl';
import Link from 'next/link';
import { useSearchParams } from 'next/navigation';
import { useRef, useState } from 'react';
import { toast } from 'sonner';
import { UserDetail } from './user-detail';
import UserForm from './user-form';
import { AuthMethodsForm } from './user-profile/auth-methods-form';
import { BasicInfoForm } from './user-profile/basic-info-form';
import { NotifySettingsForm } from './user-profile/notify-settings-form';
import UserSubscription from './user-subscription';
export default function Page() {
const t = useTranslations('user');
const [loading, setLoading] = useState(false);
const ref = useRef<ProTableActions>(null);
const sp = useSearchParams();
const { data: subscribeList } = useQuery({
queryKey: ['getSubscribeList', 'all'],
@ -33,9 +60,17 @@ export default function Page() {
},
});
const initialFilters = {
search: sp.get('search') || undefined,
user_id: sp.get('user_id') || undefined,
subscribe_id: sp.get('subscribe_id') || undefined,
user_subscribe_id: sp.get('user_subscribe_id') || undefined,
};
return (
<ProTable<API.User, API.GetUserListParams>
action={ref}
initialFilters={initialFilters}
header={{
title: t('userList'),
toolbar: (
@ -180,9 +215,8 @@ export default function Page() {
actions={{
render: (row) => {
return [
<Button key='detail' asChild>
<Link href={`/dashboard/user/${row.id}`}>{t('edit')}</Link>
</Button>,
<ProfileSheet key='profile' userId={row.id} />,
<SubscriptionSheet key='subscription' userId={row.id} />,
<ConfirmButton
key='edit'
trigger={<Button variant='destructive'>{t('delete')}</Button>}
@ -196,9 +230,91 @@ export default function Page() {
cancelText={t('cancel')}
confirmText={t('confirm')}
/>,
<DropdownMenu key='more'>
<DropdownMenuTrigger asChild>
<Button variant='outline'>{t('more')}</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align='end'>
<DropdownMenuItem asChild>
<Link href={`/dashboard/log/login?user_id=${row.id}`}>{t('loginLogs')}</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href={`/dashboard/order?user_id=${row.id}`}>{t('orderList')}</Link>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>,
];
},
}}
/>
);
}
function ProfileSheet({ userId }: { userId: number }) {
const t = useTranslations('user');
const [open, setOpen] = useState(false);
const { data: user, refetch } = useQuery({
enabled: open,
queryKey: ['user', userId],
queryFn: async () => {
const { data } = await getUserDetail({ id: userId });
return data.data as API.User;
},
});
return (
<Sheet open={open} onOpenChange={setOpen}>
<SheetTrigger asChild>
<Button variant='default'>{t('edit')}</Button>
</SheetTrigger>
<SheetContent side='right' className='w-[700px] max-w-full md:max-w-screen-lg'>
<SheetHeader>
<SheetTitle>
{t('userProfile')} · ID: {userId}
</SheetTitle>
</SheetHeader>
{user && (
<ScrollArea className='h-[calc(100dvh-140px)] p-2'>
<Tabs defaultValue='basic'>
<TabsList className='mb-3'>
<TabsTrigger value='basic'>{t('basicInfoTitle')}</TabsTrigger>
<TabsTrigger value='notify'>{t('notifySettingsTitle')}</TabsTrigger>
<TabsTrigger value='auth'>{t('authMethodsTitle')}</TabsTrigger>
</TabsList>
<TabsContent value='basic' className='mt-0'>
<BasicInfoForm user={user} refetch={refetch as any} />
</TabsContent>
<TabsContent value='notify' className='mt-0'>
<NotifySettingsForm user={user} refetch={refetch as any} />
</TabsContent>
<TabsContent value='auth' className='mt-0'>
<AuthMethodsForm user={user} refetch={refetch as any} />
</TabsContent>
</Tabs>
</ScrollArea>
)}
</SheetContent>
</Sheet>
);
}
function SubscriptionSheet({ userId }: { userId: number }) {
const t = useTranslations('user');
const [open, setOpen] = useState(false);
return (
<Sheet open={open} onOpenChange={setOpen}>
<SheetTrigger asChild>
<Button variant='secondary'></Button>
</SheetTrigger>
<SheetContent side='right' className='w-[1000px] max-w-full md:max-w-screen-xl'>
<SheetHeader>
<SheetTitle>
{t('subscriptionList')} · ID: {userId}
</SheetTitle>
</SheetHeader>
<div className='mt-2'>
<UserSubscription userId={userId} />
</div>
</SheetContent>
</Sheet>
);
}

View File

@ -71,16 +71,7 @@ export function UserSubscribeDetail({ id, enabled }: { id: number; enabled: bool
<div>
<h3 className='mb-2 text-sm font-medium'>
{t('userInfo')}
{data?.user_id && (
<Button
variant='link'
size='sm'
className='text-primary ml-2 h-auto p-0 text-xs'
asChild
>
<Link href={`/dashboard/user/${data.user_id}`}>{t('viewDetails')}</Link>
</Button>
)}
{/* Removed link to legacy user detail page */}
</h3>
<ul className='grid gap-3'>
<li className='flex items-center justify-between font-semibold'>
@ -133,7 +124,7 @@ export function UserDetail({ id }: { id: number }) {
<HoverCard>
<HoverCardTrigger asChild>
<Button variant='link' className='p-0' asChild>
<Link href={`/dashboard/user/${id}`}>
<Link href={`/dashboard/user?user_id=${id}`}>
{data?.auth_methods[0]?.auth_identifier || t('loading')}
</Link>
</Button>

View File

@ -9,9 +9,16 @@ import {
updateUserSubscribe,
} from '@/services/admin/user';
import { Button } from '@workspace/ui/components/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@workspace/ui/components/dropdown-menu';
import { ConfirmButton } from '@workspace/ui/custom-components/confirm-button';
import { formatDate } from '@workspace/ui/utils';
import { useTranslations } from 'next-intl';
import Link from 'next/link';
import { useRef, useState } from 'react';
import { toast } from 'sonner';
import { SubscriptionDetail } from './subscription-detail';
@ -139,12 +146,6 @@ export default function UserSubscription({ userId }: { userId: number }) {
return true;
}}
/>,
<SubscriptionDetail
key='detail'
trigger={<Button variant='secondary'>{t('detail')}</Button>}
userId={userId}
subscriptionId={row.id}
/>,
<ConfirmButton
key='delete'
trigger={<Button variant='destructive'>{t('delete')}</Button>}
@ -158,9 +159,64 @@ export default function UserSubscription({ userId }: { userId: number }) {
cancelText={t('cancel')}
confirmText={t('confirm')}
/>,
<RowMoreActions key='more' userId={userId} subId={row.id} />,
];
},
}}
/>
);
}
function RowMoreActions({ userId, subId }: { userId: number; subId: number }) {
const triggerRef = useRef<HTMLButtonElement>(null);
const t = useTranslations('user');
return (
<div className='inline-flex'>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant='outline'>{t('more')}</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align='end'>
<DropdownMenuItem asChild>
<Link href={`/dashboard/log/subscribe?user_id=${userId}&user_subscribe_id=${subId}`}>
{t('subscriptionLogs')}
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link
href={`/dashboard/log/reset-subscribe?user_id=${userId}&user_subscribe_id=${subId}`}
>
{t('resetLogs')}
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link
href={`/dashboard/log/subscribe-traffic?user_id=${userId}&user_subscribe_id=${subId}`}
>
{t('trafficStats')}
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href={`/dashboard/log/traffic-details?user_id=${userId}&subscribe_id=${subId}`}>
{t('trafficDetails')}
</Link>
</DropdownMenuItem>
<DropdownMenuItem
onSelect={(e) => {
e.preventDefault();
triggerRef.current?.click();
}}
>
{t('onlineDevices')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<SubscriptionDetail
trigger={<Button ref={triggerRef} className='hidden' />}
userId={userId}
subscriptionId={subId}
/>
</div>
);
}

View File

@ -0,0 +1,114 @@
'use client';
import { IpLink } from '@/components/ip-link';
import { ProTable } from '@/components/pro-table';
import { getUserSubscribeDevices, kickOfflineByUserDevice } from '@/services/admin/user';
import { Badge } from '@workspace/ui/components/badge';
import { Button } from '@workspace/ui/components/button';
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetTrigger,
} from '@workspace/ui/components/sheet';
import { Switch } from '@workspace/ui/components/switch';
import { ConfirmButton } from '@workspace/ui/custom-components/confirm-button';
import { formatDate } from '@workspace/ui/utils';
import { useTranslations } from 'next-intl';
import { ReactNode, useState } from 'react';
import { toast } from 'sonner';
export function SubscriptionDetail({
trigger,
userId,
subscriptionId,
}: {
trigger: ReactNode;
userId: number;
subscriptionId: number;
}) {
const t = useTranslations('user');
const [open, setOpen] = useState(false);
return (
<Sheet open={open} onOpenChange={setOpen}>
<SheetTrigger asChild>{trigger}</SheetTrigger>
<SheetContent side='right' className='w-[700px] max-w-full md:max-w-screen-md'>
<SheetHeader>
<SheetTitle>{t('onlineDevices')}</SheetTitle>
</SheetHeader>
<div className='mt-4 max-h-[calc(100dvh-120px)] overflow-y-auto'>
<ProTable<API.UserDevice, Record<string, unknown>>
columns={[
{
accessorKey: 'enabled',
header: t('enable'),
cell: ({ row }) => (
<Switch
checked={row.getValue('enabled')}
onChange={(checked) => {
console.log('Switch:', checked);
}}
/>
),
},
{ accessorKey: 'id', header: 'ID' },
{ accessorKey: 'identifier', header: 'IMEI' },
{ accessorKey: 'user_agent', header: t('userAgent') },
{
accessorKey: 'ip',
header: 'IP',
cell: ({ row }) => <IpLink ip={row.getValue('ip')} />,
},
{
accessorKey: 'online',
header: t('loginStatus'),
cell: ({ row }) => (
<Badge variant={row.getValue('online') ? 'default' : 'destructive'}>
{row.getValue('online') ? t('online') : t('offline')}
</Badge>
),
},
{
accessorKey: 'updated_at',
header: t('lastSeen'),
cell: ({ row }) => formatDate(row.getValue('updated_at')),
},
]}
request={async (pagination) => {
const { data } = await getUserSubscribeDevices({
user_id: userId,
subscribe_id: subscriptionId,
...pagination,
});
return {
list: data.data?.list || [],
total: data.data?.total || 0,
};
}}
actions={{
render: (row) => {
if (!row.identifier) return [];
return [
<ConfirmButton
key='offline'
trigger={<Button variant='destructive'>{t('confirmOffline')}</Button>}
title={t('confirmOffline')}
description={t('kickOfflineConfirm', { ip: row.ip })}
onConfirm={async () => {
await kickOfflineByUserDevice({ id: row.id });
toast.success(t('kickOfflineSuccess'));
}}
cancelText={t('cancel')}
confirmText={t('confirm')}
/>,
];
},
}}
/>
</div>
</SheetContent>
</Sheet>
);
}

View File

@ -34,26 +34,22 @@ export function SidebarLeft({ ...props }: React.ComponentProps<typeof Sidebar>)
const pathname = usePathname();
const { state, isMobile } = useSidebar();
const firstGroupTitle = (navs as typeof navs).find((n) => hasChildren(n))?.title ?? '';
const logsGroupTitle = 'Logs & Analytics';
const systemGroupTitle = 'System';
const [openGroups, setOpenGroups] = useState<Record<string, boolean>>(() => {
const groups: Record<string, boolean> = {};
(navs as typeof navs).forEach((nav) => {
if (hasChildren(nav)) groups[nav.title] = nav.title === firstGroupTitle;
if (hasChildren(nav)) {
// Default: open all groups except Logs & Analytics and System
groups[nav.title] = nav.title !== logsGroupTitle && nav.title !== systemGroupTitle;
}
});
return groups;
});
const handleToggleGroup = (title: string) => {
setOpenGroups((prev) => {
const currentlyOpen = !!prev[title];
const next: Record<string, boolean> = {};
(navs as typeof navs).forEach((nav) => {
if (hasChildren(nav)) next[nav.title] = false;
});
next[title] = !currentlyOpen;
return next;
});
setOpenGroups((prev) => ({ ...prev, [title]: !prev[title] }));
};
const normalize = (p: string) => (p.endsWith('/') && p !== '/' ? p.replace(/\/+$/, '') : p);
@ -70,19 +66,13 @@ export function SidebarLeft({ ...props }: React.ComponentProps<typeof Sidebar>)
(hasChildren(nav) && nav.items.some((i: any) => isActiveUrl(i.url))) ||
('url' in nav && nav.url ? isActiveUrl(nav.url as string) : false);
// Auto-open the group containing the active route whenever pathname changes
// Ensure the group containing the active route is open, without closing others
React.useEffect(() => {
setOpenGroups((prev) => {
const next: Record<string, boolean> = {};
const next: Record<string, boolean> = { ...prev };
(navs as typeof navs).forEach((nav) => {
if (hasChildren(nav)) next[nav.title] = isGroupActive(nav);
if (hasChildren(nav) && isGroupActive(nav)) next[nav.title] = true;
});
// If no active group detected, keep previously opened or default to first
if (!Object.values(next).some(Boolean)) {
(navs as typeof navs).forEach((nav) => {
if (hasChildren(nav)) next[nav.title] = prev[nav.title] ?? nav.title === firstGroupTitle;
});
}
return next;
});
}, [pathname]);
@ -199,14 +189,17 @@ export function SidebarLeft({ ...props }: React.ComponentProps<typeof Sidebar>)
<SidebarGroup key={nav.title} className={cn('py-1')}>
<SidebarMenuButton
size='sm'
className={cn('mb-2 flex h-8 w-full items-center justify-between', {
'bg-accent text-accent-foreground': isOpen || groupActive,
'hover:bg-accent/60': !isOpen && !groupActive,
})}
// className={cn('mb-2 flex h-8 w-full items-center justify-between', {
// 'bg-accent text-accent-foreground': isOpen || groupActive,
// 'hover:bg-accent/60': !isOpen && !groupActive,
// })}
className={cn(
'hover:bg-accent/60 mb-2 flex h-8 w-full items-center justify-between',
)}
onClick={() => handleToggleGroup(nav.title)}
tabIndex={0}
style={{ fontWeight: 500 }}
isActive={groupActive}
isActive={false}
>
<span className='flex min-w-0 items-center gap-2'>
{'icon' in nav && (nav as any).icon ? (

View File

@ -47,7 +47,6 @@ export const navs = [
title: 'User Management',
url: '/dashboard/user',
icon: 'flat-color-icons:conference-call',
items: [{ title: 'User Detail', url: '/dashboard/user/:id' }],
},
{
title: 'Ticket Management',

View File

@ -56,12 +56,15 @@
"lastSeen": "Last Seen",
"loading": "Loading...",
"loginIp": "Login IP",
"loginLogs": "Login Logs",
"loginNotifications": "Login Notifications",
"loginStatus": "Login Status",
"loginTime": "Login Time",
"manager": "Manager",
"more": "More",
"notifySettingsTitle": "Notification Settings",
"onlineDevices": "Online Devices",
"orderList": "Order List",
"password": "Password",
"passwordPlaceholder": "Enter new password (optional)",
"permanent": "Permanent",
@ -73,6 +76,7 @@
"referralCode": "Referral Code",
"referrerUserId": "Referrer (User ID)",
"remove": "Remove",
"resetLogs": "Reset Logs",
"resetTime": "Reset Time",
"save": "Save",
"searchIp": "Search IP address",
@ -95,8 +99,10 @@
"token": "Token",
"totalTraffic": "Total Traffic",
"tradeNotifications": "Trade Notifications",
"trafficDetails": "Traffic Details",
"trafficLimit": "Traffic Limit",
"trafficLogs": "Traffic Logs",
"trafficStats": "Traffic Stats",
"trafficUsage": "Traffic Usage",
"unknown": "Unknown",
"unlimited": "Unlimited",

View File

@ -56,12 +56,15 @@
"lastSeen": "最后一次查看",
"loading": "加载中...",
"loginIp": "登录IP",
"loginLogs": "登录日志",
"loginNotifications": "登录通知",
"loginStatus": "登录状态",
"loginTime": "登录时间",
"manager": "管理员",
"more": "更多",
"notifySettingsTitle": "通知设置",
"onlineDevices": "在线设备",
"orderList": "订单列表",
"password": "密码",
"passwordPlaceholder": "输入新密码(选填)",
"permanent": "永久",
@ -73,6 +76,7 @@
"referralCode": "推荐码",
"referrerUserId": "推荐人用户ID",
"remove": "移除",
"resetLogs": "重置日志",
"resetTime": "重置时间",
"save": "保存",
"searchIp": "搜索IP地址",
@ -95,8 +99,10 @@
"token": "令牌",
"totalTraffic": "总流量",
"tradeNotifications": "交易通知",
"trafficDetails": "流量详情",
"trafficLimit": "流量限制",
"trafficLogs": "流量日志",
"trafficStats": "流量统计",
"trafficUsage": "流量使用",
"unknown": "未知",
"unlimited": "无限制",

View File

@ -70,6 +70,7 @@ export interface ProTableProps<TData, TValue> {
targetId: string | number | null,
items: TData[],
) => Promise<TData[]>;
initialFilters?: Record<string, unknown>;
}
export interface ProTableActions {
@ -90,9 +91,14 @@ export function ProTable<
texts,
empty,
onSort,
initialFilters,
}: ProTableProps<TData, TValue>) {
const [sorting, setSorting] = useState<SortingState>([]);
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>(() =>
initialFilters
? (Object.entries(initialFilters).map(([id, value]) => ({ id, value })) as ColumnFiltersState)
: [],
);
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
const [rowSelection, setRowSelection] = useState({});
const [data, setData] = useState<TData[]>([]);