feat: Add log cleanup settings and update localization files

- Introduced log cleanup settings in the admin panel, allowing configuration of automatic log clearing and retention periods.
- Updated English, Spanish, French, German, and other localization files to include new log cleanup settings.
- Added new fields for referral percentage and first purchase only in user settings.
- Implemented API endpoints for getting and updating log settings.
- Enhanced the admin dashboard with a new log cleanup form component.
This commit is contained in:
web
2025-09-01 10:25:04 -07:00
parent d4b37e4997
commit 6ccf9b8bdc
88 changed files with 827 additions and 125 deletions
+30
View File
@@ -6,6 +6,7 @@ import {
deleteNode,
filterNodeList,
filterServerList,
resetSortWithNode,
toggleNodeStatus,
updateNode,
} from '@/services/admin/server';
@@ -235,6 +236,35 @@ export default function NodesPage() {
];
},
}}
onSort={async (source, target, items) => {
const sourceIndex = items.findIndex((item) => String(item.id) === source);
const targetIndex = items.findIndex((item) => String(item.id) === target);
const originalSorts = items.map((item) => item.sort);
const [movedItem] = items.splice(sourceIndex, 1);
items.splice(targetIndex, 0, movedItem!);
const updatedItems = items.map((item, index) => {
const originalSort = originalSorts[index];
const newSort = originalSort !== undefined ? originalSort : item.sort;
return { ...item, sort: newSort };
});
const changedItems = updatedItems.filter((item, index) => {
return item.sort !== items[index]?.sort;
});
if (changedItems.length > 0) {
resetSortWithNode({
sort: changedItems.map((item) => ({
id: item.id,
sort: item.sort,
})) as API.SortItem[],
});
}
return updatedItems;
}}
/>
);
}
@@ -3,7 +3,7 @@
import { UserDetail } from '@/app/dashboard/user/user-detail';
import { IpLink } from '@/components/ip-link';
import { ProTable } from '@/components/pro-table';
import { filterServerList } from '@/services/admin/server';
import { getUserSubscribeById } from '@/services/admin/user';
import { useQuery } from '@tanstack/react-query';
import { Badge } from '@workspace/ui/components/badge';
import {
@@ -13,55 +13,85 @@ import {
SheetTitle,
SheetTrigger,
} from '@workspace/ui/components/sheet';
import type { useTranslations } from 'next-intl';
import { formatBytes, formatDate } from '@workspace/ui/utils';
import { useTranslations } from 'next-intl';
import { useState } from 'react';
function mapOnlineUsers(online: API.ServerStatus['online'] = []): {
uid: string;
ips: string[];
subscribe?: string;
subscribe_id?: number;
traffic?: number;
expired_at?: number;
}[] {
return (online || []).map((u) => ({
uid: String(u.user_id || ''),
ips: Array.isArray(u.ip) ? u.ip.map(String) : [],
subscribe: (u as any).subscribe,
subscribe_id: (u as any).subscribe_id,
traffic: (u as any).traffic,
expired_at: (u as any).expired_at,
}));
}
export default function OnlineUsersCell({
serverId,
status,
t,
function UserSubscribeInfo({
subscribeId,
open,
type,
}: {
serverId?: number;
status?: API.ServerStatus;
t: ReturnType<typeof useTranslations>;
subscribeId: number;
open: boolean;
type: 'account' | 'subscribeName' | 'subscribeId' | 'trafficUsage' | 'expireTime';
}) {
const [open, setOpen] = useState(false);
const { data: latest } = useQuery({
queryKey: ['serverStatusById', serverId, open],
enabled: !!serverId && open,
const t = useTranslations('servers');
const { data } = useQuery({
enabled: subscribeId !== 0 && open,
queryKey: ['getUserSubscribeById', subscribeId],
queryFn: async () => {
const { data } = await filterServerList({ page: 1, size: 1, search: String(serverId) });
const list = (data?.data?.list || []) as API.Server[];
return list[0]?.status as API.ServerStatus | undefined;
const { data } = await getUserSubscribeById({ id: subscribeId });
return data.data;
},
});
const rows = mapOnlineUsers((latest || status)?.online);
const count = rows.length;
if (!data) return <span className='text-muted-foreground'>--</span>;
switch (type) {
case 'account':
if (!data.user_id) return <span className='text-muted-foreground'>--</span>;
return <UserDetail id={data.user_id} />;
case 'subscribeName':
if (!data.subscribe?.name) return <span className='text-muted-foreground'>--</span>;
return <span className='text-sm'>{data.subscribe.name}</span>;
case 'subscribeId':
if (!data.id) return <span className='text-muted-foreground'>--</span>;
return <span className='font-mono text-sm'>{data.id}</span>;
case 'trafficUsage': {
const usedTraffic = data.upload + data.download;
const totalTraffic = data.traffic || 0;
return (
<div className='min-w-0 text-sm'>
<div className='break-words'>
{formatBytes(usedTraffic)} / {totalTraffic > 0 ? formatBytes(totalTraffic) : '无限制'}
</div>
</div>
);
}
case 'expireTime': {
if (!data.expire_time) return <span className='text-muted-foreground'>--</span>;
const isExpired = data.expire_time < Date.now() / 1000;
return (
<div className='flex flex-col gap-1 sm:flex-row sm:items-center sm:gap-2'>
<span className='text-sm'>{formatDate(data.expire_time)}</span>
{isExpired && (
<Badge variant='destructive' className='w-fit px-1 py-0 text-xs'>
{t('expired')}
</Badge>
)}
</div>
);
}
default:
return <span className='text-muted-foreground'>--</span>;
}
}
export default function OnlineUsersCell({ status }: { status?: API.ServerStatus }) {
const t = useTranslations('servers');
const [open, setOpen] = useState(false);
return (
<Sheet open={open} onOpenChange={setOpen}>
<SheetTrigger asChild>
<button className='hover:text-foreground text-muted-foreground flex items-center gap-2 bg-transparent p-0 text-sm'>
<Badge variant='secondary'>{count}</Badge>
<Badge variant='secondary'>{status?.online.length}</Badge>
<span>{t('onlineUsers')}</span>
</button>
</SheetTrigger>
@@ -70,36 +100,20 @@ export default function OnlineUsersCell({
<SheetTitle>{t('onlineUsers')}</SheetTitle>
</SheetHeader>
<div className='-mx-6 h-[calc(100vh-48px-16px)] overflow-y-auto px-6 py-4 sm:h-[calc(100dvh-48px-16px-env(safe-area-inset-top))]'>
<ProTable<
{
uid: string;
ips: string[];
subscribe?: string;
subscribe_id?: number;
traffic?: number;
expired_at?: number;
},
Record<string, unknown>
>
<ProTable<API.ServerOnlineUser, Record<string, unknown>>
header={{ hidden: true }}
columns={[
{
accessorKey: 'ips',
accessorKey: 'ip',
header: t('ipAddresses'),
cell: ({ row }) => {
const ips = row.original.ips;
const ips = row.original.ip;
return (
<div className='flex min-w-0 flex-col gap-1'>
{ips.map((ip, i) => (
<div
key={`${row.original.uid}-${ip}`}
className='whitespace-nowrap text-sm'
>
{i === 0 ? (
<IpLink ip={ip} className='font-medium' />
) : (
<IpLink ip={ip} className='text-muted-foreground' />
)}
{ips.map((item, i) => (
<div className='whitespace-nowrap text-sm' key={i}>
<Badge>{item.protocol}</Badge>
<IpLink ip={item.ip} className='font-medium' />
</div>
))}
</div>
@@ -109,51 +123,63 @@ export default function OnlineUsersCell({
{
accessorKey: 'user',
header: t('user'),
cell: ({ row }) => <UserDetail id={Number(row.original.uid)} />,
cell: ({ row }) => (
<UserSubscribeInfo
subscribeId={Number(row.original.subscribe_id)}
open={open}
type='account'
/>
),
},
{
accessorKey: 'subscription',
header: t('subscription'),
cell: ({ row }) => (
<span className='text-sm'>{row.original.subscribe || '--'}</span>
<UserSubscribeInfo
subscribeId={Number(row.original.subscribe_id)}
open={open}
type='subscribeName'
/>
),
},
{
accessorKey: 'subscribeId',
header: t('subscribeId'),
cell: ({ row }) => (
<span className='font-mono text-sm'>{row.original.subscribe_id || '--'}</span>
<UserSubscribeInfo
subscribeId={Number(row.original.subscribe_id)}
open={open}
type='subscribeId'
/>
),
},
{
accessorKey: 'traffic',
header: t('traffic'),
cell: ({ row }) => {
const v = Number(row.original.traffic || 0);
return <span className='text-sm'>{(v / 1024 ** 3).toFixed(2)} GB</span>;
},
cell: ({ row }) => (
<UserSubscribeInfo
subscribeId={Number(row.original.subscribe_id)}
open={open}
type='trafficUsage'
/>
),
},
{
accessorKey: 'expireTime',
header: t('expireTime'),
cell: ({ row }) => {
const ts = Number(row.original.expired_at || 0);
if (!ts) return <span className='text-muted-foreground'>--</span>;
const expired = ts < Date.now() / 1000;
return (
<div className='flex items-center gap-2'>
<span className='text-sm'>{new Date(ts * 1000).toLocaleString()}</span>
{expired && (
<Badge variant='destructive' className='w-fit px-1 py-0 text-xs'>
{t('expired')}
</Badge>
)}
</div>
);
},
cell: ({ row }) => (
<UserSubscribeInfo
subscribeId={Number(row.original.subscribe_id)}
open={open}
type='expireTime'
/>
),
},
]}
request={async () => ({ list: rows, total: rows.length })}
request={async () => ({
list: status?.online || [],
total: status?.online?.length || 0,
})}
/>
</div>
</SheetContent>
+31 -7
View File
@@ -5,6 +5,7 @@ import {
createServer,
deleteServer,
filterServerList,
resetSortWithServer,
updateServer,
} from '@/services/admin/server';
import { Badge } from '@workspace/ui/components/badge';
@@ -195,13 +196,7 @@ export default function ServersPage() {
{
id: 'online_users',
header: t('onlineUsers'),
cell: ({ row }) => (
<OnlineUsersCell
serverId={row.original.id}
status={row.original.status as API.ServerStatus}
t={t}
/>
),
cell: ({ row }) => <OnlineUsersCell status={row.original.status as API.ServerStatus} />,
},
{
id: 'traffic_ratio',
@@ -288,6 +283,35 @@ export default function ServersPage() {
</Button>,
],
}}
onSort={async (source, target, items) => {
const sourceIndex = items.findIndex((item) => String(item.id) === source);
const targetIndex = items.findIndex((item) => String(item.id) === target);
const originalSorts = items.map((item) => item.sort);
const [movedItem] = items.splice(sourceIndex, 1);
items.splice(targetIndex, 0, movedItem!);
const updatedItems = items.map((item, index) => {
const originalSort = originalSorts[index];
const newSort = originalSort !== undefined ? originalSort : item.sort;
return { ...item, sort: newSort };
});
const changedItems = updatedItems.filter((item, index) => {
return item.sort !== items[index]?.sort;
});
if (changedItems.length > 0) {
resetSortWithServer({
sort: changedItems.map((item) => ({
id: item.id,
sort: item.sort,
})) as API.SortItem[],
});
}
return updatedItems;
}}
/>
</div>
);
@@ -0,0 +1,164 @@
'use client';
import { getLogSetting, updateLogSetting } from '@/services/admin/log';
import { zodResolver } from '@hookform/resolvers/zod';
import { useQuery } from '@tanstack/react-query';
import { Button } from '@workspace/ui/components/button';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@workspace/ui/components/form';
import { ScrollArea } from '@workspace/ui/components/scroll-area';
import {
Sheet,
SheetContent,
SheetFooter,
SheetHeader,
SheetTitle,
SheetTrigger,
} from '@workspace/ui/components/sheet';
import { Switch } from '@workspace/ui/components/switch';
import { EnhancedInput } from '@workspace/ui/custom-components/enhanced-input';
import { Icon } from '@workspace/ui/custom-components/icon';
import { useTranslations } from 'next-intl';
import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { toast } from 'sonner';
import { z } from 'zod';
const logCleanupSchema = z.object({
auto_clear: z.boolean(),
clear_days: z.number().min(1),
});
type LogCleanupFormData = z.infer<typeof logCleanupSchema>;
export default function LogCleanupForm() {
const t = useTranslations('system');
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
const { data, refetch } = useQuery({
queryKey: ['getLogSetting'],
queryFn: async () => {
const { data } = await getLogSetting();
return data.data;
},
enabled: open,
});
const form = useForm<LogCleanupFormData>({
resolver: zodResolver(logCleanupSchema),
defaultValues: {
auto_clear: false,
clear_days: 30,
},
});
useEffect(() => {
if (data) {
form.reset(data);
}
}, [data, form]);
async function onSubmit(values: LogCleanupFormData) {
setLoading(true);
try {
await updateLogSetting(values as API.LogSetting);
toast.success(t('common.saveSuccess'));
refetch();
setOpen(false);
} catch (error) {
toast.error(t('common.saveFailed'));
} finally {
setLoading(false);
}
}
return (
<Sheet open={open} onOpenChange={setOpen}>
<SheetTrigger asChild>
<div className='flex cursor-pointer items-center justify-between transition-colors'>
<div className='flex items-center gap-3'>
<div className='bg-primary/10 flex h-10 w-10 items-center justify-center rounded-lg'>
<Icon icon='mdi:delete-sweep' className='text-primary h-5 w-5' />
</div>
<div className='flex-1'>
<p className='font-medium'>{t('logCleanup.title')}</p>
<p className='text-muted-foreground text-sm'>{t('logCleanup.description')}</p>
</div>
</div>
<Icon icon='mdi:chevron-right' className='size-6' />
</div>
</SheetTrigger>
<SheetContent className='w-[600px] max-w-full md:max-w-screen-md'>
<SheetHeader>
<SheetTitle>{t('logCleanup.title')}</SheetTitle>
</SheetHeader>
<ScrollArea className='-mx-6 h-[calc(100dvh-48px-36px-36px-env(safe-area-inset-top))] px-6'>
<Form {...form}>
<form
id='log-cleanup-form'
onSubmit={form.handleSubmit(onSubmit)}
className='space-y-2 pt-4'
>
<FormField
control={form.control}
name='auto_clear'
render={({ field }) => (
<FormItem>
<FormLabel>{t('logCleanup.autoClear')}</FormLabel>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
className='float-end !mt-0'
/>
</FormControl>
<FormDescription>{t('logCleanup.autoClearDescription')}</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='clear_days'
render={({ field }) => (
<FormItem>
<FormLabel>{t('logCleanup.clearDays')}</FormLabel>
<FormControl>
<EnhancedInput
type='number'
placeholder={t('logCleanup.clearDaysPlaceholder')}
value={field.value?.toString()}
onValueChange={(value) => field.onChange(Number(value))}
disabled={!form.watch('auto_clear')}
/>
</FormControl>
<FormDescription>{t('logCleanup.clearDaysDescription')}</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</ScrollArea>
<SheetFooter className='flex-row justify-end gap-2 pt-3'>
<Button variant='outline' disabled={loading} onClick={() => setOpen(false)}>
{t('common.cancel')}
</Button>
<Button disabled={loading} type='submit' form='log-cleanup-form'>
{loading && <Icon icon='mdi:loading' className='mr-2 animate-spin' />}
{t('common.save')}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
);
}
+5 -1
View File
@@ -6,6 +6,7 @@ import CurrencyForm from './basic-settings/currency-form';
import PrivacyPolicyForm from './basic-settings/privacy-policy-form';
import SiteForm from './basic-settings/site-form';
import TosForm from './basic-settings/tos-form';
import LogCleanupForm from './log-cleanup/log-cleanup-form';
import InviteForm from './user-security/invite-form';
import RegisterForm from './user-security/register-form';
import VerifyCodeForm from './user-security/verify-code-form';
@@ -14,7 +15,6 @@ import VerifyForm from './user-security/verify-form';
export default function Page() {
const t = useTranslations('system');
// 定义表单配置
const formSections = [
{
title: t('basicSettings'),
@@ -34,6 +34,10 @@ export default function Page() {
{ component: VerifyCodeForm },
],
},
{
title: t('logSettings'),
forms: [{ component: LogCleanupForm }],
},
];
return (
@@ -58,6 +58,8 @@ export default function UserForm<T extends Record<string, any>>({
password: z.string().optional(),
referer_id: z.number().optional(),
refer_code: z.string().optional(),
referral_percentage: z.number().optional(),
only_first_purchase: z.boolean().optional(),
is_admin: z.boolean().optional(),
balance: z.number().optional(),
gift_amount: z.number().optional(),
@@ -218,6 +220,41 @@ export default function UserForm<T extends Record<string, any>>({
</FormItem>
)}
/>
<FormField
control={form.control}
name='referral_percentage'
render={({ field }) => (
<FormItem>
<FormLabel>{t('referralPercentage')}</FormLabel>
<FormControl>
<EnhancedInput
type='number'
min={0}
max={100}
placeholder={t('referralPercentagePlaceholder')}
{...field}
suffix='%'
onValueChange={(value) => {
form.setValue(field.name, Number(value));
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='only_first_purchase'
render={({ field }) => (
<FormItem className='flex items-center justify-between space-x-2'>
<FormLabel>{t('onlyFirstPurchase')}</FormLabel>
<FormControl>
<Switch checked={field.value} onCheckedChange={field.onChange} />
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name='balance'
@@ -30,6 +30,8 @@ const basicInfoSchema = z.object({
gift_amount: z.number().optional(),
refer_code: z.string().optional(),
referer_id: z.number().optional(),
referral_percentage: z.number().optional(),
only_first_purchase: z.boolean().optional(),
is_admin: z.boolean().optional(),
password: z.string().optional(),
enable: z.boolean(),
@@ -52,6 +54,8 @@ export function BasicInfoForm({ user, refetch }: { user: API.User; refetch: () =
gift_amount: user.gift_amount,
refer_code: user.refer_code,
referer_id: user.referer_id,
referral_percentage: user.referral_percentage,
only_first_purchase: user.only_first_purchase,
is_admin: user.is_admin,
enable: user.enable,
},
@@ -216,6 +220,41 @@ export function BasicInfoForm({ user, refetch }: { user: API.User; refetch: () =
)}
/>
</div>
<FormField
control={form.control}
name='referral_percentage'
render={({ field }) => (
<FormItem>
<FormLabel>{t('referralPercentage')}</FormLabel>
<FormControl>
<EnhancedInput
type='number'
min={0}
max={100}
value={field.value}
suffix='%'
onValueChange={(value) => {
form.setValue(field.name, value as number);
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='only_first_purchase'
render={({ field }) => (
<FormItem className='flex items-center justify-between space-x-2'>
<FormLabel>{t('onlyFirstPurchase')}</FormLabel>
<FormControl>
<Switch checked={field.value} onCheckedChange={field.onChange} />
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name='avatar'