♻️ refactor(view): System and Auth Control

This commit is contained in:
web
2025-08-04 08:58:57 -07:00
committed by speakeloudest
parent 9ba3ec40d6
commit 97aa24ba8e
333 changed files with 13905 additions and 9399 deletions
@@ -90,7 +90,7 @@ export default function GroupForm<T extends Record<string, any>>({
name='name'
render={({ field }) => (
<FormItem>
<FormLabel>{t('group.form.name')}</FormLabel>
<FormLabel>{t('groupForm.name')}</FormLabel>
<FormControl>
<EnhancedInput
{...field}
@@ -108,7 +108,7 @@ export default function GroupForm<T extends Record<string, any>>({
name='description'
render={({ field }) => (
<FormItem>
<FormLabel>{t('group.form.description')}</FormLabel>
<FormLabel>{t('groupForm.description')}</FormLabel>
<FormControl>
<EnhancedInput
{...field}
@@ -132,11 +132,11 @@ export default function GroupForm<T extends Record<string, any>>({
setOpen(false);
}}
>
{t('group.form.cancel')}
{t('groupForm.cancel')}
</Button>
<Button disabled={loading} onClick={form.handleSubmit(handleSubmit)}>
{loading && <Icon icon='mdi:loading' className='mr-2 animate-spin' />}{' '}
{t('group.form.confirm')}
{t('groupForm.confirm')}
</Button>
</SheetFooter>
</SheetContent>
@@ -0,0 +1,251 @@
'use client';
import { UserDetail } from '@/app/dashboard/user/user-detail';
import { ProTable } from '@/components/pro-table';
import { getUserSubscribeById } 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 { Progress } from '@workspace/ui/components/progress';
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetTrigger,
} from '@workspace/ui/components/sheet';
import { formatBytes, formatDate } from '@workspace/ui/utils';
import { useTranslations } from 'next-intl';
import { useState } from 'react';
interface NodeDetailDialogProps {
node: API.Server;
children?: React.ReactNode;
trigger?: React.ReactNode;
}
// 统一的用户订阅信息组件
function UserSubscribeInfo({
userId,
type,
}: {
userId: number;
type: 'account' | 'subscribeName' | 'subscribeId' | 'trafficUsage' | 'expireTime';
}) {
const { data } = useQuery({
enabled: userId !== 0,
queryKey: ['getUserSubscribeById', userId],
queryFn: async () => {
const { data } = await getUserSubscribeById({ id: userId });
return data.data;
},
});
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'>
</Badge>
)}
</div>
);
}
default:
return <span className='text-muted-foreground'>--</span>;
}
}
export function NodeDetailDialog({ node, children, trigger }: NodeDetailDialogProps) {
const t = useTranslations('server.node');
const [open, setOpen] = useState(false);
const { status } = node;
const { online, cpu, mem, disk, updated_at } = status || {
online: {},
cpu: 0,
mem: 0,
disk: 0,
updated_at: 0,
};
const isOnline = updated_at > 0;
const onlineCount = (online && Object.keys(online).length) || 0;
// 转换在线用户数据为ProTable需要的格式
const onlineUsersData = Object.entries(online || {}).map(([uid, ips]) => ({
uid,
ips: ips as string[],
primaryIp: ips[0] || '',
allIps: (ips as string[]).join(', '),
}));
return (
<Sheet open={open} onOpenChange={setOpen}>
<SheetTrigger asChild>
{trigger || (
<Button variant='outline' size='sm'>
{t('detail')}
</Button>
)}
</SheetTrigger>
<SheetContent className='w-full max-w-full sm:w-[600px] sm:max-w-screen-md'>
<SheetHeader>
<SheetTitle>
{t('nodeDetail')} - {node.name}
</SheetTitle>
</SheetHeader>
<div className='-mx-6 h-[calc(100dvh-48px-16px-env(safe-area-inset-top))] space-y-2 overflow-y-auto px-6 py-4'>
<h3 className='text-base font-medium'>{t('nodeStatus')}</h3>
<div className='space-y-3'>
<div className='flex w-full flex-col gap-2 text-sm sm:flex-row sm:items-center sm:gap-3'>
<Badge variant={isOnline ? 'default' : 'destructive'} className='w-fit text-xs'>
{isOnline ? t('normal') : t('abnormal')}
</Badge>
<span className='text-muted-foreground'>
{t('onlineCount')}: {onlineCount}
</span>
{isOnline && (
<span className='text-muted-foreground text-xs sm:text-sm'>
{t('lastUpdated')}: {formatDate(updated_at)}
</span>
)}
</div>
{isOnline && (
<div className='grid grid-cols-1 gap-3 sm:grid-cols-3'>
<div className='space-y-1'>
<div className='flex justify-between text-xs'>
<span>CPU</span>
<span>{cpu?.toFixed(1)}%</span>
</div>
<Progress value={cpu ?? 0} className='h-1.5' max={100} />
</div>
<div className='space-y-1'>
<div className='flex justify-between text-xs'>
<span>{t('memory')}</span>
<span>{mem?.toFixed(1)}%</span>
</div>
<Progress value={mem ?? 0} className='h-1.5' max={100} />
</div>
<div className='space-y-1'>
<div className='flex justify-between text-xs'>
<span>{t('disk')}</span>
<span>{disk?.toFixed(1)}%</span>
</div>
<Progress value={disk ?? 0} className='h-1.5' max={100} />
</div>
</div>
)}
</div>
{isOnline && onlineCount > 0 && (
<div>
<h3 className='mb-3 text-lg font-medium'>{t('onlineUsers')}</h3>
<div className='overflow-x-auto'>
<ProTable
header={{
hidden: true,
}}
columns={[
{
accessorKey: 'allIps',
header: t('ipAddresses'),
cell: ({ row }) => {
const ips = row.original.ips;
return (
<div className='flex min-w-0 flex-col gap-1'>
{ips.map((ip: string, index: number) => (
<div key={ip} className='whitespace-nowrap text-sm'>
{index === 0 ? (
<span className='font-medium'>{ip}</span>
) : (
<span className='text-muted-foreground'>{ip}</span>
)}
</div>
))}
</div>
);
},
},
{
accessorKey: 'user',
header: t('user'),
cell: ({ row }) => (
<UserSubscribeInfo userId={Number(row.original.uid)} type='account' />
),
},
{
accessorKey: 'subscribeName',
header: t('subscribeName'),
cell: ({ row }) => (
<UserSubscribeInfo userId={Number(row.original.uid)} type='subscribeName' />
),
},
{
accessorKey: 'subscribeId',
header: t('subscribeId'),
cell: ({ row }) => (
<UserSubscribeInfo userId={Number(row.original.uid)} type='subscribeId' />
),
},
{
accessorKey: 'trafficUsage',
header: t('trafficUsage'),
cell: ({ row }) => (
<UserSubscribeInfo userId={Number(row.original.uid)} type='trafficUsage' />
),
},
{
accessorKey: 'expireTime',
header: t('expireTime'),
cell: ({ row }) => (
<UserSubscribeInfo userId={Number(row.original.uid)} type='expireTime' />
),
},
]}
request={async () => ({
list: onlineUsersData,
total: onlineUsersData.length,
})}
/>
</div>
</div>
)}
</div>
</SheetContent>
</Sheet>
);
}
+57 -58
View File
@@ -58,7 +58,10 @@ export default function NodeForm<T extends { [x: string]: any }>({
trigger,
title,
}: Readonly<NodeFormProps<T>>) {
const t = useTranslations('server.node');
const t = useTranslations('server');
const tf = useTranslations('server.nodeForm');
const trs = useTranslations('server.relayModeOptions');
const tsc = useTranslations('server.securityConfig');
const [open, setOpen] = useState(false);
const form = useForm({
@@ -123,7 +126,7 @@ export default function NodeForm<T extends { [x: string]: any }>({
name='name'
render={({ field }) => (
<FormItem>
<FormLabel>{t('form.name')}</FormLabel>
<FormLabel>{t('nodeForm.name')}</FormLabel>
<FormControl>
<EnhancedInput
{...field}
@@ -141,10 +144,10 @@ export default function NodeForm<T extends { [x: string]: any }>({
name='group_id'
render={({ field }) => (
<FormItem>
<FormLabel>{t('form.groupId')}</FormLabel>
<FormLabel>{t('nodeForm.groupId')}</FormLabel>
<FormControl>
<Combobox<number, false>
placeholder={t('form.selectNodeGroup')}
placeholder={t('nodeForm.selectNodeGroup')}
{...field}
options={groups?.map((item) => ({
value: item.id,
@@ -166,10 +169,10 @@ export default function NodeForm<T extends { [x: string]: any }>({
name='tags'
render={({ field }) => (
<FormItem className='col-span-3'>
<FormLabel>{t('form.tags')}</FormLabel>
<FormLabel>{t('nodeForm.tags')}</FormLabel>
<FormControl>
<TagInput
placeholder={t('form.tagsPlaceholder')}
placeholder={t('nodeForm.tagsPlaceholder')}
value={field.value || []}
onChange={(value) => form.setValue(field.name, value)}
/>
@@ -183,7 +186,7 @@ export default function NodeForm<T extends { [x: string]: any }>({
name='country'
render={({ field }) => (
<FormItem>
<FormLabel>{t('form.country')}</FormLabel>
<FormLabel>{t('nodeForm.country')}</FormLabel>
<FormControl>
<EnhancedInput
{...field}
@@ -201,7 +204,7 @@ export default function NodeForm<T extends { [x: string]: any }>({
name='city'
render={({ field }) => (
<FormItem>
<FormLabel>{t('form.city')}</FormLabel>
<FormLabel>{t('nodeForm.city')}</FormLabel>
<FormControl>
<EnhancedInput
{...field}
@@ -221,7 +224,7 @@ export default function NodeForm<T extends { [x: string]: any }>({
name='server_addr'
render={({ field }) => (
<FormItem>
<FormLabel>{t('form.serverAddr')}</FormLabel>
<FormLabel>{t('nodeForm.serverAddr')}</FormLabel>
<FormControl>
<EnhancedInput
{...field}
@@ -239,12 +242,12 @@ export default function NodeForm<T extends { [x: string]: any }>({
name='speed_limit'
render={({ field }) => (
<FormItem>
<FormLabel>{t('form.speedLimit')}</FormLabel>
<FormLabel>{t('nodeForm.speedLimit')}</FormLabel>
<FormControl>
<EnhancedInput
type='number'
{...field}
placeholder={t('form.speedLimitPlaceholder')}
placeholder={t('nodeForm.speedLimitPlaceholder')}
formatInput={(value) => unitConversion('bitsToMb', value)}
formatOutput={(value) => unitConversion('mbToBits', value)}
onValueChange={(value) => {
@@ -262,7 +265,7 @@ export default function NodeForm<T extends { [x: string]: any }>({
name='traffic_ratio'
render={({ field }) => (
<FormItem>
<FormLabel>{t('form.trafficRatio')}</FormLabel>
<FormLabel>{t('nodeForm.trafficRatio')}</FormLabel>
<FormControl>
<EnhancedInput
{...field}
@@ -284,7 +287,7 @@ export default function NodeForm<T extends { [x: string]: any }>({
name='protocol'
render={({ field }) => (
<FormItem>
<FormLabel>{t('form.protocol')}</FormLabel>
<FormLabel>{t('nodeForm.protocol')}</FormLabel>
<FormControl>
<Tabs
value={field.value}
@@ -316,7 +319,7 @@ export default function NodeForm<T extends { [x: string]: any }>({
name='config.method'
render={({ field }) => (
<FormItem>
<FormLabel>{t('form.encryptionMethod')}</FormLabel>
<FormLabel>{t('nodeForm.encryptionMethod')}</FormLabel>
<FormControl>
<Select
onValueChange={(value) => {
@@ -326,7 +329,7 @@ export default function NodeForm<T extends { [x: string]: any }>({
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder={t('form.selectEncryptionMethod')} />
<SelectValue placeholder={t('nodeForm.selectEncryptionMethod')} />
</SelectTrigger>
</FormControl>
<SelectContent>
@@ -357,7 +360,7 @@ export default function NodeForm<T extends { [x: string]: any }>({
name='config.port'
render={({ field }) => (
<FormItem>
<FormLabel>{t('form.port')}</FormLabel>
<FormLabel>{t('nodeForm.port')}</FormLabel>
<FormControl>
<EnhancedInput
{...field}
@@ -385,7 +388,7 @@ export default function NodeForm<T extends { [x: string]: any }>({
name='config.server_key'
render={({ field }) => (
<FormItem>
<FormLabel>{t('form.serverKey')}</FormLabel>
<FormLabel>{t('nodeForm.serverKey')}</FormLabel>
<FormControl>
<EnhancedInput
{...field}
@@ -414,7 +417,7 @@ export default function NodeForm<T extends { [x: string]: any }>({
name='config.port'
render={({ field }) => (
<FormItem>
<FormLabel>{t('form.port')}</FormLabel>
<FormLabel>{t('nodeForm.port')}</FormLabel>
<FormControl>
<EnhancedInput
{...field}
@@ -438,7 +441,7 @@ export default function NodeForm<T extends { [x: string]: any }>({
name='config.flow'
render={({ field }) => (
<FormItem>
<FormLabel>{t('form.flow')}</FormLabel>
<FormLabel>{t('nodeForm.flow')}</FormLabel>
<FormControl>
<Select
value={field.value}
@@ -448,7 +451,7 @@ export default function NodeForm<T extends { [x: string]: any }>({
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder={t('form.pleaseSelect')} />
<SelectValue placeholder={t('nodeForm.pleaseSelect')} />
</SelectTrigger>
</FormControl>
<SelectContent>
@@ -469,11 +472,11 @@ export default function NodeForm<T extends { [x: string]: any }>({
name='config.obfs_password'
render={({ field }) => (
<FormItem>
<FormLabel>{t('form.obfsPassword')}</FormLabel>
<FormLabel>{t('nodeForm.obfsPassword')}</FormLabel>
<FormControl>
<EnhancedInput
{...field}
placeholder={t('form.obfsPasswordPlaceholder')}
placeholder={t('nodeForm.obfsPasswordPlaceholder')}
onValueChange={(value) => {
form.setValue(field.name, value);
}}
@@ -488,10 +491,10 @@ export default function NodeForm<T extends { [x: string]: any }>({
name='config.hop_ports'
render={({ field }) => (
<FormItem>
<FormLabel>{t('form.hopPorts')}</FormLabel>
<FormLabel>{t('nodeForm.hopPorts')}</FormLabel>
<FormControl>
<EnhancedInput
placeholder={t('form.hopPortsPlaceholder')}
placeholder={t('nodeForm.hopPortsPlaceholder')}
{...field}
onValueChange={(value) => {
form.setValue(field.name, value);
@@ -507,7 +510,7 @@ export default function NodeForm<T extends { [x: string]: any }>({
name='config.hop_interval'
render={({ field }) => (
<FormItem>
<FormLabel>{t('form.hopInterval')}</FormLabel>
<FormLabel>{t('nodeForm.hopInterval')}</FormLabel>
<FormControl>
<EnhancedInput
{...field}
@@ -531,7 +534,7 @@ export default function NodeForm<T extends { [x: string]: any }>({
name='config.udp_relay_mode'
render={({ field }) => (
<FormItem>
<FormLabel>{t('form.udpRelayMode')}</FormLabel>
<FormLabel>{t('nodeForm.udpRelayMode')}</FormLabel>
<FormControl>
<Select
value={field.value}
@@ -541,7 +544,7 @@ export default function NodeForm<T extends { [x: string]: any }>({
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder={t('form.pleaseSelect')} />
<SelectValue placeholder={t('nodeForm.pleaseSelect')} />
</SelectTrigger>
</FormControl>
<SelectContent>
@@ -559,7 +562,7 @@ export default function NodeForm<T extends { [x: string]: any }>({
name='config.congestion_controller'
render={({ field }) => (
<FormItem>
<FormLabel>{t('form.congestionController')}</FormLabel>
<FormLabel>{t('nodeForm.congestionController')}</FormLabel>
<FormControl>
<Select
value={field.value}
@@ -569,7 +572,7 @@ export default function NodeForm<T extends { [x: string]: any }>({
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder={t('form.pleaseSelect')} />
<SelectValue placeholder={t('nodeForm.pleaseSelect')} />
</SelectTrigger>
</FormControl>
<SelectContent>
@@ -589,7 +592,7 @@ export default function NodeForm<T extends { [x: string]: any }>({
name='config.disable_sni'
render={({ field }) => (
<FormItem>
<FormLabel>{t('form.disableSni')}</FormLabel>
<FormLabel>{t('nodeForm.disableSni')}</FormLabel>
<FormControl>
<div className='pt-2'>
<Switch
@@ -609,7 +612,7 @@ export default function NodeForm<T extends { [x: string]: any }>({
name='config.reduce_rtt'
render={({ field }) => (
<FormItem>
<FormLabel>{t('form.reduceRtt')}</FormLabel>
<FormLabel>{t('nodeForm.reduceRtt')}</FormLabel>
<FormControl>
<div className='pt-2'>
<Switch
@@ -631,7 +634,7 @@ export default function NodeForm<T extends { [x: string]: any }>({
{['vmess', 'vless', 'trojan'].includes(protocol) && (
<Card>
<CardHeader className='flex flex-row items-center justify-between p-3'>
<CardTitle>{t('form.transportConfig')}</CardTitle>
<CardTitle>{t('nodeForm.transportConfig')}</CardTitle>
<FormField
control={form.control}
name='config.transport'
@@ -646,7 +649,7 @@ export default function NodeForm<T extends { [x: string]: any }>({
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder={t('form.pleaseSelect')} />
<SelectValue placeholder={t('nodeForm.pleaseSelect')} />
</SelectTrigger>
</FormControl>
<SelectContent>
@@ -737,7 +740,7 @@ export default function NodeForm<T extends { [x: string]: any }>({
['anytls', 'tuic', 'hysteria2'].includes(protocol)) && (
<Card>
<CardHeader className='flex flex-row items-center justify-between p-3'>
<CardTitle>{t('form.securityConfig')}</CardTitle>
<CardTitle>{t('nodeForm.securityConfig')}</CardTitle>
{['vmess', 'vless', 'trojan'].includes(protocol) && (
<FormField
control={form.control}
@@ -752,7 +755,7 @@ export default function NodeForm<T extends { [x: string]: any }>({
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder={t('form.pleaseSelect')} />
<SelectValue placeholder={t('nodeForm.pleaseSelect')} />
</SelectTrigger>
</FormControl>
<SelectContent>
@@ -802,7 +805,7 @@ export default function NodeForm<T extends { [x: string]: any }>({
name='config.security_config.reality_server_addr'
render={({ field }) => (
<FormItem>
<FormLabel>{t('form.security_config.serverAddress')}</FormLabel>
<FormLabel>{t('securityConfig.serverAddress')}</FormLabel>
<FormControl>
<EnhancedInput
{...field}
@@ -823,7 +826,7 @@ export default function NodeForm<T extends { [x: string]: any }>({
name='config.security_config.reality_server_port'
render={({ field }) => (
<FormItem>
<FormLabel>{t('form.security_config.serverPort')}</FormLabel>
<FormLabel>{t('securityConfig.serverPort')}</FormLabel>
<FormControl>
<EnhancedInput
{...field}
@@ -847,7 +850,7 @@ export default function NodeForm<T extends { [x: string]: any }>({
name='config.security_config.reality_private_key'
render={({ field }) => (
<FormItem>
<FormLabel>{t('form.security_config.privateKey')}</FormLabel>
<FormLabel>{t('securityConfig.privateKey')}</FormLabel>
<FormControl>
<EnhancedInput
{...field}
@@ -868,11 +871,11 @@ export default function NodeForm<T extends { [x: string]: any }>({
name='config.security_config.reality_public_key'
render={({ field }) => (
<FormItem>
<FormLabel>{t('form.security_config.publicKey')}</FormLabel>
<FormLabel>{t('securityConfig.publicKey')}</FormLabel>
<FormControl>
<EnhancedInput
{...field}
placeholder={t('form.security_config.publicKeyPlaceholder')}
placeholder={t('securityConfig.publicKeyPlaceholder')}
onValueChange={(value) => {
form.setValue(field.name, value);
}}
@@ -887,11 +890,11 @@ export default function NodeForm<T extends { [x: string]: any }>({
name='config.security_config.reality_short_id'
render={({ field }) => (
<FormItem>
<FormLabel>{t('form.security_config.shortId')}</FormLabel>
<FormLabel>{t('securityConfig.shortId')}</FormLabel>
<FormControl>
<EnhancedInput
{...field}
placeholder={t('form.security_config.shortIdPlaceholder')}
placeholder={t('securityConfig.shortIdPlaceholder')}
onValueChange={(value) => {
form.setValue(field.name, value);
}}
@@ -910,7 +913,7 @@ export default function NodeForm<T extends { [x: string]: any }>({
name='config.security_config.fingerprint'
render={({ field }) => (
<FormItem>
<FormLabel>{t('form.security_config.fingerprint')}</FormLabel>
<FormLabel>{t('securityConfig.fingerprint')}</FormLabel>
<Select
value={field.value}
onValueChange={(value) => {
@@ -919,7 +922,7 @@ export default function NodeForm<T extends { [x: string]: any }>({
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder={t('form.pleaseSelect')} />
<SelectValue placeholder={t('nodeForm.pleaseSelect')} />
</SelectTrigger>
</FormControl>
<SelectContent>
@@ -968,7 +971,7 @@ export default function NodeForm<T extends { [x: string]: any }>({
<Card>
<CardHeader className='flex flex-row items-center justify-between p-3'>
<CardTitle>{t('form.relayMode')}</CardTitle>
<CardTitle>{t('nodeForm.relayMode')}</CardTitle>
<FormField
control={form.control}
name='relay_mode'
@@ -983,17 +986,13 @@ export default function NodeForm<T extends { [x: string]: any }>({
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder={t('form.selectRelayMode')} />
<SelectValue placeholder={t('nodeForm.selectRelayMode')} />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value='none'>
{t('form.relayModeOptions.none')}
</SelectItem>
<SelectItem value='all'>{t('form.relayModeOptions.all')}</SelectItem>
<SelectItem value='random'>
{t('form.relayModeOptions.random')}
</SelectItem>
<SelectItem value='none'>{t('relayModeOptions.none')}</SelectItem>
<SelectItem value='all'>{t('relayModeOptions.all')}</SelectItem>
<SelectItem value='random'>{t('relayModeOptions.random')}</SelectItem>
</SelectContent>
</Select>
</FormControl>
@@ -1015,7 +1014,7 @@ export default function NodeForm<T extends { [x: string]: any }>({
{
name: 'host',
type: 'text',
placeholder: t('form.relayHost'),
placeholder: t('nodeForm.relayHost'),
},
{
name: 'port',
@@ -1023,12 +1022,12 @@ export default function NodeForm<T extends { [x: string]: any }>({
step: 1,
min: 1,
max: 65535,
placeholder: t('form.relayPort'),
placeholder: t('nodeForm.relayPort'),
},
{
name: 'prefix',
type: 'text',
placeholder: t('form.relayPrefix'),
placeholder: t('nodeForm.relayPrefix'),
},
]}
value={field.value}
@@ -1055,7 +1054,7 @@ export default function NodeForm<T extends { [x: string]: any }>({
setOpen(false);
}}
>
{t('form.cancel')}
{t('node.cancel')}
</Button>
<Button
disabled={loading}
@@ -1069,7 +1068,7 @@ export default function NodeForm<T extends { [x: string]: any }>({
})}
>
{loading && <Icon icon='mdi:loading' className='mr-2 animate-spin' />}{' '}
{t('form.confirm')}
{t('node.confirm')}
</Button>
</SheetFooter>
</SheetContent>
+234 -80
View File
@@ -1,32 +1,94 @@
'use client';
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from '@workspace/ui/components/accordion';
import { UserDetail } from '@/app/dashboard/user/user-detail';
import { IpLink } from '@/components/ip-link';
import { ProTable } from '@/components/pro-table';
import { getUserSubscribeById } from '@/services/admin/user';
import { useQuery } from '@tanstack/react-query';
import { Badge } from '@workspace/ui/components/badge';
import { Progress } from '@workspace/ui/components/progress';
import { ScrollArea } from '@workspace/ui/components/scroll-area';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@workspace/ui/components/tooltip';
import { formatDate } from '@workspace/ui/utils';
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetTrigger,
} from '@workspace/ui/components/sheet';
import { formatBytes, formatDate } from '@workspace/ui/utils';
import { useTranslations } from 'next-intl';
import { useState } from 'react';
import { UserSubscribeDetail } from '../user/user-detail';
export function formatPercentage(value: number): string {
return `${value.toFixed(1)}%`;
}
export function NodeStatusCell({ status }: { status: API.NodeStatus }) {
// 统一的用户订阅信息组件
function UserSubscribeInfo({
userId,
type,
}: {
userId: number;
type: 'account' | 'subscribeName' | 'subscribeId' | 'trafficUsage' | 'expireTime';
}) {
const { data } = useQuery({
enabled: userId !== 0,
queryKey: ['getUserSubscribeById', userId],
queryFn: async () => {
const { data } = await getUserSubscribeById({ id: userId });
return data.data;
},
});
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'>
</Badge>
)}
</div>
);
}
default:
return <span className='text-muted-foreground'>--</span>;
}
}
export function NodeStatusCell({ status, node }: { status: API.NodeStatus; node?: API.Server }) {
const t = useTranslations('server.node');
const [openItem, setOpenItem] = useState<string | null>(null);
const [open, setOpen] = useState(false);
const { online, cpu, mem, disk, updated_at } = status || {
online: {},
@@ -35,78 +97,170 @@ export function NodeStatusCell({ status }: { status: API.NodeStatus }) {
disk: 0,
updated_at: 0,
};
const isOnline = updated_at > 0;
const badgeVariant = isOnline ? 'default' : 'destructive';
const badgeText = isOnline ? t('normal') : t('abnormal');
const onlineCount = (online && Object.keys(online).length) || 0;
// 转换在线用户数据为ProTable需要的格式
const onlineUsersData = Object.entries(online || {}).map(([uid, ips]) => ({
uid,
ips: ips as string[],
primaryIp: ips[0] || '',
allIps: (ips as string[]).join(', '),
}));
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className='flex items-center gap-2 text-xs *:flex-1'>
<div className='flex items-center space-x-1'>
<Badge variant={badgeVariant}>{badgeText}</Badge>
<span className='font-medium'>
{t('onlineCount')}: {onlineCount}
</span>
</div>
<div className='flex flex-col space-y-1'>
<div className='flex justify-between'>
<span>CPU</span>
<span>{formatPercentage(cpu ?? 0)}</span>
<>
<Sheet open={open} onOpenChange={setOpen}>
<SheetTrigger asChild>
<button className='hover:text-foreground flex cursor-pointer items-center gap-2 border-none bg-transparent p-0 text-left text-sm transition-colors'>
<Badge variant={badgeVariant}>{badgeText}</Badge>
<span className='text-muted-foreground'>
{t('onlineCount')}: {onlineCount}
</span>
</button>
</SheetTrigger>
{node && (
<SheetContent className='h-screen w-screen max-w-none sm:h-auto sm:w-[800px] sm:max-w-[90vw]'>
<SheetHeader>
<SheetTitle>
{t('nodeDetail')} - {node.name}
</SheetTitle>
</SheetHeader>
<div className='-mx-6 h-[calc(100vh-48px-16px)] space-y-2 overflow-y-auto px-6 py-4 sm:h-[calc(100dvh-48px-16px-env(safe-area-inset-top))]'>
<h3 className='text-base font-medium'>{t('nodeStatus')}</h3>
<div className='space-y-3'>
<div className='flex w-full flex-col gap-2 text-sm sm:flex-row sm:items-center sm:gap-3'>
<Badge variant={isOnline ? 'default' : 'destructive'} className='w-fit text-xs'>
{isOnline ? t('normal') : t('abnormal')}
</Badge>
<span className='text-muted-foreground'>
{t('onlineCount')}: {onlineCount}
</span>
{isOnline && (
<span className='text-muted-foreground text-xs sm:text-sm'>
{t('lastUpdated')}: {formatDate(updated_at)}
</span>
)}
</div>
{isOnline && (
<div className='grid grid-cols-1 gap-3 sm:grid-cols-3'>
<div className='space-y-1'>
<div className='flex justify-between text-xs'>
<span>CPU</span>
<span>{cpu?.toFixed(1)}%</span>
</div>
<Progress value={cpu ?? 0} className='h-1.5' max={100} />
</div>
<div className='space-y-1'>
<div className='flex justify-between text-xs'>
<span>{t('memory')}</span>
<span>{mem?.toFixed(1)}%</span>
</div>
<Progress value={mem ?? 0} className='h-1.5' max={100} />
</div>
<div className='space-y-1'>
<div className='flex justify-between text-xs'>
<span>{t('disk')}</span>
<span>{disk?.toFixed(1)}%</span>
</div>
<Progress value={disk ?? 0} className='h-1.5' max={100} />
</div>
</div>
)}
</div>
<Progress value={cpu ?? 0} className='h-2' max={100} />
{isOnline && onlineCount > 0 && (
<div>
<h3 className='mb-3 text-lg font-medium'>{t('onlineUsers')}</h3>
<div className='overflow-x-auto'>
<ProTable
header={{
hidden: true,
}}
columns={[
{
accessorKey: 'allIps',
header: t('ipAddresses'),
cell: ({ row }) => {
const ips = row.original.ips;
return (
<div className='flex min-w-0 flex-col gap-1'>
{ips.map((ip: string, index: number) => (
<div key={ip} className='whitespace-nowrap text-sm'>
{index === 0 ? (
<IpLink ip={ip} className='font-medium' />
) : (
<IpLink ip={ip} className='text-muted-foreground' />
)}
</div>
))}
</div>
);
},
},
{
accessorKey: 'user',
header: t('user'),
cell: ({ row }) => (
<UserSubscribeInfo userId={Number(row.original.uid)} type='account' />
),
},
{
accessorKey: 'subscribeName',
header: t('subscribeName'),
cell: ({ row }) => (
<UserSubscribeInfo
userId={Number(row.original.uid)}
type='subscribeName'
/>
),
},
{
accessorKey: 'subscribeId',
header: t('subscribeId'),
cell: ({ row }) => (
<UserSubscribeInfo
userId={Number(row.original.uid)}
type='subscribeId'
/>
),
},
{
accessorKey: 'trafficUsage',
header: t('trafficUsage'),
cell: ({ row }) => (
<UserSubscribeInfo
userId={Number(row.original.uid)}
type='trafficUsage'
/>
),
},
{
accessorKey: 'expireTime',
header: t('expireTime'),
cell: ({ row }) => (
<UserSubscribeInfo
userId={Number(row.original.uid)}
type='expireTime'
/>
),
},
]}
request={async () => ({
list: onlineUsersData,
total: onlineUsersData.length,
})}
/>
</div>
</div>
)}
</div>
<div className='flex flex-col space-y-1'>
<div className='flex justify-between'>
<span>{t('memory')}</span>
<span>{formatPercentage(mem ?? 0)}</span>
</div>
<Progress value={mem ?? 0} className='h-2' max={100} />
</div>
<div className='flex flex-col space-y-1'>
<div className='flex justify-between'>
<span>{t('disk')}</span>
<span>{formatPercentage(disk ?? 0)}</span>
</div>
<Progress value={disk ?? 0} className='h-2' max={100} />
</div>
{isOnline && (
<div>
{t('lastUpdated')}: {formatDate(updated_at ?? 0)}
</div>
)}
</div>
</TooltipTrigger>
{isOnline && onlineCount > 0 && (
<TooltipContent className='bg-card text-foreground w-96'>
<ScrollArea className='h-[540px] rounded-md border px-4 py-2'>
<h4 className='py-1 text-sm font-semibold'>{t('onlineUsers')}</h4>
<Accordion
type='single'
collapsible
className='w-full'
onValueChange={(value) => setOpenItem(value)}
>
{Object.entries(online).map(([uid, ips]) => (
<AccordionItem key={uid} value={uid}>
<AccordionTrigger>{`[UID: ${uid}] - ${ips[0]}`}</AccordionTrigger>
<AccordionContent>
<ul>
{ips.map((ip: string) => (
<li key={ip}>{ip}</li>
))}
</ul>
<UserSubscribeDetail id={Number(uid)} enabled={openItem === uid} />
</AccordionContent>
</AccordionItem>
))}
</Accordion>
</ScrollArea>
</TooltipContent>
</SheetContent>
)}
</Tooltip>
</TooltipProvider>
</Sheet>
</>
);
}
@@ -139,7 +139,7 @@ export default function NodeTable() {
accessorKey: 'status',
header: t('status'),
cell: ({ row }) => {
return <NodeStatusCell status={row.original?.status} />;
return <NodeStatusCell status={row.original?.status} node={row.original} />;
},
},
{
@@ -183,9 +183,6 @@ export default function NodeTable() {
},
]}
params={[
{
key: 'search',
},
{
key: 'group_id',
placeholder: t('nodeGroup'),
@@ -194,6 +191,9 @@ export default function NodeTable() {
value: String(item.id),
})),
},
{
key: 'search',
},
]}
request={async (pagination, filter) => {
const { data } = await getNodeList({