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 { filterBalanceLog } from '@/services/admin/log';
import { formatDate } from '@workspace/ui/utils'; import { formatDate } from '@workspace/ui/utils';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { useSearchParams } from 'next/navigation';
export default function BalanceLogPage() { export default function BalanceLogPage() {
const t = useTranslations('log'); 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 ( return (
<ProTable<API.BalanceLog, { search?: string }> <ProTable<API.BalanceLog, { search?: string }>
header={{ title: t('title.balance') }} header={{ title: t('title.balance') }}
initialFilters={initialFilters}
columns={[ columns={[
{ {
accessorKey: 'user', accessorKey: 'user',

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -2,6 +2,7 @@
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { useSearchParams } from 'next/navigation';
import { useRef } from 'react'; import { useRef } from 'react';
import { Display } from '@/components/display'; import { Display } from '@/components/display';
@ -17,8 +18,9 @@ import { cn } from '@workspace/ui/lib/utils';
import { formatDate } from '@workspace/ui/utils'; import { formatDate } from '@workspace/ui/utils';
import { UserDetail } from '../user/user-detail'; import { UserDetail } from '../user/user-detail';
export default function Page(props: any) { export default function Page() {
const t = useTranslations('order'); const t = useTranslations('order');
const sp = useSearchParams();
const statusOptions = [ const statusOptions = [
{ value: 1, label: t('status.1'), className: 'bg-orange-500' }, { 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 ( return (
<ProTable<API.Order, any> <ProTable<API.Order, any>
action={ref} action={ref}
initialFilters={initialFilters}
columns={[ columns={[
{ {
accessorKey: 'order_no', accessorKey: 'order_no',
@ -146,7 +156,7 @@ export default function Page(props: any) {
if ([1, 3, 4].includes(row.getValue('status'))) { if ([1, 3, 4].includes(row.getValue('status'))) {
return ( return (
<Combobox<number, false> <Combobox<number, false>
placeholder='状态' placeholder={t('status.0')}
value={row.original.status} value={row.original.status}
onChange={async (value) => { onChange={async (value) => {
await updateOrderStatus({ await updateOrderStatus({
@ -182,19 +192,14 @@ export default function Page(props: any) {
})), })),
}, },
{ key: 'search' }, { 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) => { request={async (pagination, filter) => {
const { data } = await getOrderList({ ...pagination, ...filter, user_id: props.userId }); const { data } = await getOrderList({ ...pagination, ...filter });
return { return {
list: data.data?.list || [], list: data.data?.list || [],
total: data.data?.total || 0, 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 { cn } from '@workspace/ui/lib/utils';
import { formatDate } from '@workspace/ui/utils'; import { formatDate } from '@workspace/ui/utils';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import NextImage from 'next/legacy/image';
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { UserDetail } from '../user/user-detail'; import { UserDetail } from '../user/user-detail';
@ -130,7 +129,7 @@ export default function Page() {
render(row) { render(row) {
if (row.status !== 4) { if (row.status !== 4) {
return [ return [
<Button key='reply' size='sm' onClick={() => setTicketId(row.id)}> <Button key='reply' onClick={() => setTicketId(row.id)}>
{t('reply')} {t('reply')}
</Button>, </Button>,
<ConfirmButton <ConfirmButton
@ -166,7 +165,7 @@ export default function Page() {
if (!open) setTicketId(null); 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'> <DrawerHeader className='border-b text-left'>
<DrawerTitle>{ticket?.title}</DrawerTitle> <DrawerTitle>{ticket?.title}</DrawerTitle>
<DrawerDescription>{ticket?.description}</DrawerDescription> <DrawerDescription>{ticket?.description}</DrawerDescription>
@ -193,7 +192,8 @@ export default function Page() {
> >
{item.type === 1 && item.content} {item.type === 1 && item.content}
{item.type === 2 && ( {item.type === 2 && (
<NextImage // eslint-disable-next-line @next/next/no-img-element
<img
src={item.content!} src={item.content!}
width={300} width={300}
height={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 { Display } from '@/components/display';
import { ProTable, ProTableActions } from '@/components/pro-table'; import { ProTable, ProTableActions } from '@/components/pro-table';
import { getSubscribeList } from '@/services/admin/subscribe'; 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 { useQuery } from '@tanstack/react-query';
import { Badge } from '@workspace/ui/components/badge'; import { Badge } from '@workspace/ui/components/badge';
import { Button } from '@workspace/ui/components/button'; 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 { 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 { ConfirmButton } from '@workspace/ui/custom-components/confirm-button';
import { formatDate } from '@workspace/ui/utils'; import { formatDate } from '@workspace/ui/utils';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import Link from 'next/link'; import Link from 'next/link';
import { useSearchParams } from 'next/navigation';
import { useRef, useState } from 'react'; import { useRef, useState } from 'react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { UserDetail } from './user-detail'; import { UserDetail } from './user-detail';
import UserForm from './user-form'; 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() { export default function Page() {
const t = useTranslations('user'); const t = useTranslations('user');
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const ref = useRef<ProTableActions>(null); const ref = useRef<ProTableActions>(null);
const sp = useSearchParams();
const { data: subscribeList } = useQuery({ const { data: subscribeList } = useQuery({
queryKey: ['getSubscribeList', 'all'], 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 ( return (
<ProTable<API.User, API.GetUserListParams> <ProTable<API.User, API.GetUserListParams>
action={ref} action={ref}
initialFilters={initialFilters}
header={{ header={{
title: t('userList'), title: t('userList'),
toolbar: ( toolbar: (
@ -180,9 +215,8 @@ export default function Page() {
actions={{ actions={{
render: (row) => { render: (row) => {
return [ return [
<Button key='detail' asChild> <ProfileSheet key='profile' userId={row.id} />,
<Link href={`/dashboard/user/${row.id}`}>{t('edit')}</Link> <SubscriptionSheet key='subscription' userId={row.id} />,
</Button>,
<ConfirmButton <ConfirmButton
key='edit' key='edit'
trigger={<Button variant='destructive'>{t('delete')}</Button>} trigger={<Button variant='destructive'>{t('delete')}</Button>}
@ -196,9 +230,91 @@ export default function Page() {
cancelText={t('cancel')} cancelText={t('cancel')}
confirmText={t('confirm')} 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> <div>
<h3 className='mb-2 text-sm font-medium'> <h3 className='mb-2 text-sm font-medium'>
{t('userInfo')} {t('userInfo')}
{data?.user_id && ( {/* Removed link to legacy user detail page */}
<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>
)}
</h3> </h3>
<ul className='grid gap-3'> <ul className='grid gap-3'>
<li className='flex items-center justify-between font-semibold'> <li className='flex items-center justify-between font-semibold'>
@ -133,7 +124,7 @@ export function UserDetail({ id }: { id: number }) {
<HoverCard> <HoverCard>
<HoverCardTrigger asChild> <HoverCardTrigger asChild>
<Button variant='link' className='p-0' 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')} {data?.auth_methods[0]?.auth_identifier || t('loading')}
</Link> </Link>
</Button> </Button>

View File

@ -9,9 +9,16 @@ import {
updateUserSubscribe, updateUserSubscribe,
} from '@/services/admin/user'; } from '@/services/admin/user';
import { Button } from '@workspace/ui/components/button'; 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 { ConfirmButton } from '@workspace/ui/custom-components/confirm-button';
import { formatDate } from '@workspace/ui/utils'; import { formatDate } from '@workspace/ui/utils';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import Link from 'next/link';
import { useRef, useState } from 'react'; import { useRef, useState } from 'react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { SubscriptionDetail } from './subscription-detail'; import { SubscriptionDetail } from './subscription-detail';
@ -139,12 +146,6 @@ export default function UserSubscription({ userId }: { userId: number }) {
return true; return true;
}} }}
/>, />,
<SubscriptionDetail
key='detail'
trigger={<Button variant='secondary'>{t('detail')}</Button>}
userId={userId}
subscriptionId={row.id}
/>,
<ConfirmButton <ConfirmButton
key='delete' key='delete'
trigger={<Button variant='destructive'>{t('delete')}</Button>} trigger={<Button variant='destructive'>{t('delete')}</Button>}
@ -158,9 +159,64 @@ export default function UserSubscription({ userId }: { userId: number }) {
cancelText={t('cancel')} cancelText={t('cancel')}
confirmText={t('confirm')} 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 pathname = usePathname();
const { state, isMobile } = useSidebar(); 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 [openGroups, setOpenGroups] = useState<Record<string, boolean>>(() => {
const groups: Record<string, boolean> = {}; const groups: Record<string, boolean> = {};
(navs as typeof navs).forEach((nav) => { (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; return groups;
}); });
const handleToggleGroup = (title: string) => { const handleToggleGroup = (title: string) => {
setOpenGroups((prev) => { setOpenGroups((prev) => ({ ...prev, [title]: !prev[title] }));
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;
});
}; };
const normalize = (p: string) => (p.endsWith('/') && p !== '/' ? p.replace(/\/+$/, '') : p); 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))) || (hasChildren(nav) && nav.items.some((i: any) => isActiveUrl(i.url))) ||
('url' in nav && nav.url ? isActiveUrl(nav.url as string) : false); ('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(() => { React.useEffect(() => {
setOpenGroups((prev) => { setOpenGroups((prev) => {
const next: Record<string, boolean> = {}; const next: Record<string, boolean> = { ...prev };
(navs as typeof navs).forEach((nav) => { (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; return next;
}); });
}, [pathname]); }, [pathname]);
@ -199,14 +189,17 @@ export function SidebarLeft({ ...props }: React.ComponentProps<typeof Sidebar>)
<SidebarGroup key={nav.title} className={cn('py-1')}> <SidebarGroup key={nav.title} className={cn('py-1')}>
<SidebarMenuButton <SidebarMenuButton
size='sm' size='sm'
className={cn('mb-2 flex h-8 w-full items-center justify-between', { // className={cn('mb-2 flex h-8 w-full items-center justify-between', {
'bg-accent text-accent-foreground': isOpen || groupActive, // 'bg-accent text-accent-foreground': isOpen || groupActive,
'hover:bg-accent/60': !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)} onClick={() => handleToggleGroup(nav.title)}
tabIndex={0} tabIndex={0}
style={{ fontWeight: 500 }} style={{ fontWeight: 500 }}
isActive={groupActive} isActive={false}
> >
<span className='flex min-w-0 items-center gap-2'> <span className='flex min-w-0 items-center gap-2'>
{'icon' in nav && (nav as any).icon ? ( {'icon' in nav && (nav as any).icon ? (

View File

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

View File

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

View File

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

View File

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