♻️ refactor: Refactoring and adding multiple features
This commit is contained in:
@@ -0,0 +1,250 @@
|
||||
'use client';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { toast } from 'sonner';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { getSubscribeConfig, updateSubscribeConfig } from '@/services/admin/system';
|
||||
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 { Textarea } from '@workspace/ui/components/textarea';
|
||||
import { EnhancedInput } from '@workspace/ui/custom-components/enhanced-input';
|
||||
import { Icon } from '@workspace/ui/custom-components/icon';
|
||||
|
||||
const subscribeConfigSchema = z.object({
|
||||
single_model: z.boolean().optional(),
|
||||
pan_domain: z.boolean().optional(),
|
||||
subscribe_path: z.string().optional(),
|
||||
subscribe_domain: z.string().optional(),
|
||||
restrict_user_agent: z.boolean().optional(),
|
||||
user_agent_whitelist: z.string().optional(),
|
||||
});
|
||||
|
||||
type SubscribeConfigFormData = z.infer<typeof subscribeConfigSchema>;
|
||||
|
||||
export default function ConfigForm() {
|
||||
const t = useTranslations('subscribe');
|
||||
const [open, setOpen] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const { data, refetch } = useQuery({
|
||||
queryKey: ['getSubscribeConfig'],
|
||||
queryFn: async () => {
|
||||
const { data } = await getSubscribeConfig();
|
||||
return data.data;
|
||||
},
|
||||
enabled: open,
|
||||
});
|
||||
|
||||
const form = useForm<SubscribeConfigFormData>({
|
||||
resolver: zodResolver(subscribeConfigSchema),
|
||||
defaultValues: {
|
||||
single_model: false,
|
||||
pan_domain: false,
|
||||
subscribe_path: '',
|
||||
subscribe_domain: '',
|
||||
restrict_user_agent: false,
|
||||
user_agent_whitelist: '',
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
form.reset(data);
|
||||
}
|
||||
}, [data, form]);
|
||||
|
||||
async function onSubmit(values: SubscribeConfigFormData) {
|
||||
setLoading(true);
|
||||
try {
|
||||
await updateSubscribeConfig(values as API.SubscribeConfig);
|
||||
toast.success(t('config.updateSuccess'));
|
||||
refetch();
|
||||
setOpen(false);
|
||||
} catch (error) {
|
||||
toast.error(t('config.updateError'));
|
||||
} 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:cog' className='text-primary h-5 w-5' />
|
||||
</div>
|
||||
<div className='flex-1'>
|
||||
<p className='font-medium'>{t('config.title')}</p>
|
||||
<p className='text-muted-foreground text-sm'>{t('config.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('config.title')}</SheetTitle>
|
||||
</SheetHeader>
|
||||
<ScrollArea className='-mx-6 h-[calc(100dvh-48px-36px-36px-env(safe-area-inset-top))] px-6'>
|
||||
<Form {...form}>
|
||||
<form
|
||||
id='subscribe-config-form'
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className='space-y-2 pt-4'
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='single_model'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('config.singleSubscriptionMode')}</FormLabel>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
className='float-end !mt-0'
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t('config.singleSubscriptionModeDescription')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='pan_domain'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('config.wildcardResolution')}</FormLabel>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
className='float-end !mt-0'
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>{t('config.wildcardResolutionDescription')}</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='subscribe_path'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('config.subscriptionPath')}</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
placeholder={t('config.subscriptionPathPlaceholder')}
|
||||
value={field.value}
|
||||
onValueBlur={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>{t('config.subscriptionPathDescription')}</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='subscribe_domain'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('config.subscriptionDomain')}</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
className='h-32'
|
||||
placeholder={`${t('config.subscriptionDomainPlaceholder')}\nexample.com\nwww.example.com`}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>{t('config.subscriptionDomainDescription')}</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='restrict_user_agent'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('config.restrictUserAgent')}</FormLabel>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
className='float-end !mt-0'
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>{t('config.restrictUserAgentDescription')}</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='user_agent_whitelist'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('config.userAgentWhitelist')}</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
className='h-32'
|
||||
placeholder={`${t('config.userAgentWhitelistPlaceholder')}\nClashX\nClashForAndroid\nClash-verge`}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>{t('config.userAgentWhitelistDescription')}</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('actions.cancel')}
|
||||
</Button>
|
||||
<Button disabled={loading} type='submit' form='subscribe-config-form'>
|
||||
{loading && <Icon icon='mdi:loading' className='mr-2 animate-spin' />}
|
||||
{t('actions.save')}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -1,143 +0,0 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Button } from '@workspace/ui/components/button';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
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 { 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 { z } from 'zod';
|
||||
|
||||
const formSchema = z.object({
|
||||
name: z.string(),
|
||||
description: z.string().optional(),
|
||||
});
|
||||
|
||||
interface GroupFormProps<T> {
|
||||
onSubmit: (data: T) => Promise<boolean> | boolean;
|
||||
initialValues?: T;
|
||||
loading?: boolean;
|
||||
trigger: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export default function GroupForm<T extends Record<string, any>>({
|
||||
onSubmit,
|
||||
initialValues,
|
||||
loading,
|
||||
trigger,
|
||||
title,
|
||||
}: GroupFormProps<T>) {
|
||||
const t = useTranslations('subscribe');
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const form = useForm({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
...initialValues,
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
form?.reset(initialValues);
|
||||
}, [form, initialValues]);
|
||||
|
||||
async function handleSubmit(data: { [x: string]: any }) {
|
||||
const bool = await onSubmit(data as T);
|
||||
|
||||
if (bool) setOpen(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={setOpen}>
|
||||
<SheetTrigger asChild>
|
||||
<Button
|
||||
onClick={() => {
|
||||
form.reset();
|
||||
setOpen(true);
|
||||
}}
|
||||
>
|
||||
{trigger}
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent className='w-[500px] max-w-full md:max-w-screen-md'>
|
||||
<SheetHeader>
|
||||
<SheetTitle>{title}</SheetTitle>
|
||||
</SheetHeader>
|
||||
<ScrollArea className='-mx-6 h-[calc(100dvh-48px-36px-36px-env(safe-area-inset-top))]'>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(handleSubmit)} className='space-y-4 px-6 pt-4'>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='name'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('group.form.name')}</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
{...field}
|
||||
onValueChange={(value) => {
|
||||
form.setValue(field.name, value);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='description'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('group.form.description')}</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
{...field}
|
||||
onValueChange={(value) => {
|
||||
form.setValue(field.name, value);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
</ScrollArea>
|
||||
<SheetFooter className='flex-row justify-end gap-2 pt-3'>
|
||||
<Button
|
||||
variant='outline'
|
||||
disabled={loading}
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
{t('group.form.cancel')}
|
||||
</Button>
|
||||
<Button disabled={loading} onClick={form.handleSubmit(handleSubmit)}>
|
||||
{loading && <Icon icon='mdi:loading' className='mr-2 animate-spin' />}{' '}
|
||||
{t('group.form.confirm')}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -1,141 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { ProTable, ProTableActions } from '@/components/pro-table';
|
||||
import {
|
||||
batchDeleteSubscribeGroup,
|
||||
createSubscribeGroup,
|
||||
deleteSubscribeGroup,
|
||||
getSubscribeGroupList,
|
||||
updateSubscribeGroup,
|
||||
} from '@/services/admin/subscribe';
|
||||
import { Button } from '@workspace/ui/components/button';
|
||||
import { ConfirmButton } from '@workspace/ui/custom-components/confirm-button';
|
||||
import { formatDate } from '@workspace/ui/utils';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useRef, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import GroupForm from './form';
|
||||
|
||||
const GroupTable = () => {
|
||||
const t = useTranslations('subscribe');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const ref = useRef<ProTableActions>(null);
|
||||
|
||||
return (
|
||||
<ProTable<API.SubscribeGroup, any>
|
||||
action={ref}
|
||||
header={{
|
||||
title: t('group.title'),
|
||||
toolbar: (
|
||||
<GroupForm<API.CreateSubscribeGroupRequest>
|
||||
trigger={t('group.create')}
|
||||
title={t('group.createSubscribeGroup')}
|
||||
loading={loading}
|
||||
onSubmit={async (values) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await createSubscribeGroup(values);
|
||||
toast.success(t('group.createSuccess'));
|
||||
ref.current?.refresh();
|
||||
setLoading(false);
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
setLoading(false);
|
||||
|
||||
return false;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
columns={[
|
||||
{
|
||||
accessorKey: 'name',
|
||||
header: t('group.name'),
|
||||
},
|
||||
{
|
||||
accessorKey: 'description',
|
||||
header: t('group.description'),
|
||||
},
|
||||
{
|
||||
accessorKey: 'updated_at',
|
||||
header: t('group.updatedAt'),
|
||||
cell: ({ row }) => formatDate(row.getValue('updated_at')),
|
||||
},
|
||||
]}
|
||||
request={async () => {
|
||||
const { data } = await getSubscribeGroupList();
|
||||
return {
|
||||
list: data.data?.list || [],
|
||||
total: data.data?.total || 0,
|
||||
};
|
||||
}}
|
||||
actions={{
|
||||
render: (row) => [
|
||||
<GroupForm<API.SubscribeGroup>
|
||||
key='edit'
|
||||
trigger={t('group.edit')}
|
||||
title={t('group.editSubscribeGroup')}
|
||||
loading={loading}
|
||||
initialValues={row}
|
||||
onSubmit={async (values) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await updateSubscribeGroup({
|
||||
...row,
|
||||
...values,
|
||||
});
|
||||
toast.success(t('group.updateSuccess'));
|
||||
ref.current?.refresh();
|
||||
setLoading(false);
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
setLoading(false);
|
||||
|
||||
return false;
|
||||
}
|
||||
}}
|
||||
/>,
|
||||
<ConfirmButton
|
||||
key='delete'
|
||||
trigger={<Button variant='destructive'>{t('group.delete')}</Button>}
|
||||
title={t('group.confirmDelete')}
|
||||
description={t('group.deleteWarning')}
|
||||
onConfirm={async () => {
|
||||
await deleteSubscribeGroup({
|
||||
id: row.id,
|
||||
});
|
||||
toast.success(t('group.deleteSuccess'));
|
||||
ref.current?.refresh();
|
||||
}}
|
||||
cancelText={t('group.cancel')}
|
||||
confirmText={t('group.confirm')}
|
||||
/>,
|
||||
],
|
||||
batchRender(rows) {
|
||||
return [
|
||||
<ConfirmButton
|
||||
key='delete'
|
||||
trigger={<Button variant='destructive'>{t('group.delete')}</Button>}
|
||||
title={t('group.confirmDelete')}
|
||||
description={t('group.deleteWarning')}
|
||||
onConfirm={async () => {
|
||||
await batchDeleteSubscribeGroup({
|
||||
ids: rows.map((item) => item.id),
|
||||
});
|
||||
toast.success(t('group.deleteSuccess'));
|
||||
ref.current?.refresh();
|
||||
}}
|
||||
cancelText={t('group.cancel')}
|
||||
confirmText={t('group.confirm')}
|
||||
/>,
|
||||
];
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default GroupTable;
|
||||
@@ -1,30 +1,24 @@
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
'use client';
|
||||
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@workspace/ui/components/tabs';
|
||||
import { Card, CardContent } from '@workspace/ui/components/card';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import ConfigForm from './config-form';
|
||||
import { ProtocolForm } from './protocol-form';
|
||||
|
||||
import GroupTable from './group/table';
|
||||
import SubscribeConfig from './subscribe-config';
|
||||
import SubscribeTable from './subscribe-table';
|
||||
|
||||
export default async function Page() {
|
||||
const t = await getTranslations('subscribe');
|
||||
export default function SubscribePage() {
|
||||
const t = useTranslations('subscribe');
|
||||
|
||||
return (
|
||||
<Tabs defaultValue='subscribe'>
|
||||
<TabsList>
|
||||
<TabsTrigger value='subscribe'>{t('tabs.subscribe')}</TabsTrigger>
|
||||
<TabsTrigger value='group'>{t('tabs.subscribeGroup')}</TabsTrigger>
|
||||
<TabsTrigger value='config'>{t('tabs.subscribeConfig')}</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value='subscribe'>
|
||||
<SubscribeTable />
|
||||
</TabsContent>
|
||||
<TabsContent value='group'>
|
||||
<GroupTable />
|
||||
</TabsContent>
|
||||
<TabsContent value='config'>
|
||||
<SubscribeConfig />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
<div className='space-y-4'>
|
||||
<h2 className='text-lg font-semibold'>{t('config.title')}</h2>
|
||||
<Card>
|
||||
<CardContent className='p-4'>
|
||||
<ConfigForm />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<h2 className='text-lg font-semibold'>{t('protocol.title')}</h2>
|
||||
<ProtocolForm />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
'use client';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Badge } from '@workspace/ui/components/badge';
|
||||
import { Button } from '@workspace/ui/components/button';
|
||||
import { Card, CardContent } from '@workspace/ui/components/card';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@workspace/ui/components/form';
|
||||
import { ScrollArea } from '@workspace/ui/components/scroll-area';
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from '@workspace/ui/components/sheet';
|
||||
import { Textarea } from '@workspace/ui/components/textarea';
|
||||
import { Icon } from '@workspace/ui/custom-components/icon';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import Image from 'next/image';
|
||||
import { useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
interface Client {
|
||||
name: string;
|
||||
platforms: string[];
|
||||
}
|
||||
|
||||
const clientFormSchema = z.object({
|
||||
template: z.string().optional(),
|
||||
});
|
||||
|
||||
const clientsConfig = [
|
||||
{
|
||||
name: 'Hiddify',
|
||||
platforms: ['Windows', 'macOS', 'Linux', 'iOS', 'Android'],
|
||||
},
|
||||
{
|
||||
name: 'SingBox',
|
||||
platforms: ['Windows', 'macOS', 'Linux', 'iOS', 'Android'],
|
||||
},
|
||||
{
|
||||
name: 'Clash',
|
||||
platforms: ['Windows', 'macOS', 'Linux', 'Android'],
|
||||
},
|
||||
{
|
||||
name: 'V2rayN',
|
||||
platforms: ['Windows', 'macOS', 'Linux'],
|
||||
},
|
||||
{
|
||||
name: 'Stash',
|
||||
platforms: ['macOS', 'iOS'],
|
||||
},
|
||||
{
|
||||
name: 'Surge',
|
||||
platforms: ['macOS', 'iOS'],
|
||||
},
|
||||
{
|
||||
name: 'V2Box',
|
||||
platforms: ['macOS', 'iOS'],
|
||||
},
|
||||
{
|
||||
name: 'Shadowrocket',
|
||||
platforms: ['iOS'],
|
||||
},
|
||||
{
|
||||
name: 'Quantumult',
|
||||
platforms: ['iOS'],
|
||||
},
|
||||
{
|
||||
name: 'Loon',
|
||||
platforms: ['iOS'],
|
||||
},
|
||||
{
|
||||
name: 'Egern',
|
||||
platforms: ['iOS'],
|
||||
},
|
||||
{
|
||||
name: 'V2rayNG',
|
||||
platforms: ['Android'],
|
||||
},
|
||||
{
|
||||
name: 'Surfboard',
|
||||
platforms: ['Android'],
|
||||
},
|
||||
{
|
||||
name: 'Netch',
|
||||
platforms: ['Windows'],
|
||||
},
|
||||
];
|
||||
|
||||
type ClientFormData = z.infer<typeof clientFormSchema>;
|
||||
|
||||
export function ProtocolForm() {
|
||||
const t = useTranslations('subscribe');
|
||||
const [selectedClient, setSelectedClient] = useState<Client | null>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const form = useForm<ClientFormData>({
|
||||
resolver: zodResolver(clientFormSchema),
|
||||
defaultValues: {
|
||||
template: '',
|
||||
},
|
||||
});
|
||||
|
||||
const clients: Client[] = clientsConfig;
|
||||
|
||||
const handleClientClick = (client: Client) => {
|
||||
setSelectedClient(client);
|
||||
// 请求当前客户端的配置模板
|
||||
// 这里可以替换为实际的API调用
|
||||
// 模拟获取模板数据
|
||||
const mockTemplate = `# ${client.name} 配置模板`;
|
||||
form.reset({
|
||||
template: mockTemplate,
|
||||
});
|
||||
setOpen(true);
|
||||
};
|
||||
|
||||
const onSubmit = async (data: ClientFormData) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
// TODO: 实现保存逻辑
|
||||
console.log('Save client template:', {
|
||||
name: selectedClient?.name,
|
||||
template: data.template,
|
||||
});
|
||||
// 模拟API调用
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
setOpen(false);
|
||||
} catch (error) {
|
||||
console.error('Failed to save client template:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='space-y-4'>
|
||||
<div className='grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4'>
|
||||
{clients.map((client) => (
|
||||
<Card
|
||||
key={client.name}
|
||||
className='hover:bg-muted/50 cursor-pointer transition-colors'
|
||||
onClick={() => handleClientClick(client)}
|
||||
>
|
||||
<CardContent className='p-4'>
|
||||
<div className='space-y-3'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<div className='relative h-6 w-6 flex-shrink-0'>
|
||||
<Image
|
||||
src={`/images/protocols/${client.name}.webp`}
|
||||
alt={client.name}
|
||||
width={24}
|
||||
height={24}
|
||||
className='object-contain'
|
||||
onError={() => {
|
||||
console.log(`Failed to load image for ${client.name}`);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<h3 className='text-base font-semibold'>{client.name}</h3>
|
||||
<Icon icon='mdi:chevron-right' className='ml-auto flex-shrink-0' />
|
||||
</div>
|
||||
|
||||
<div className='flex flex-wrap gap-1'>
|
||||
{client.platforms.map((platform) => (
|
||||
<Badge key={platform} variant='secondary' className='text-xs'>
|
||||
{platform}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<p className='text-muted-foreground text-sm leading-relaxed'>
|
||||
{t(`protocol.clients.${client.name.toLowerCase().replace(/\s+/g, '')}.features`)}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Sheet open={open} onOpenChange={setOpen}>
|
||||
<SheetContent className='w-[600px] max-w-full md:max-w-screen-md'>
|
||||
<SheetHeader>
|
||||
<SheetTitle>
|
||||
{t('protocol.subscribeTemplate')} - {selectedClient?.name}
|
||||
</SheetTitle>
|
||||
</SheetHeader>
|
||||
<ScrollArea className='-mx-6 h-[calc(100dvh-48px-36px-36px-env(safe-area-inset-top))] px-6'>
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className='space-y-4 pt-4'
|
||||
id='client-template-form'
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='template'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('protocol.templateContent')}</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder={t('protocol.templatePlaceholder')}
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
rows={20}
|
||||
className='font-mono text-sm'
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
</ScrollArea>
|
||||
<SheetFooter className='flex-row justify-end gap-2 pt-3'>
|
||||
<Button variant='outline' disabled={loading} onClick={() => setOpen(false)}>
|
||||
{t('actions.cancel')}
|
||||
</Button>
|
||||
<Button disabled={loading} type='submit' form='client-template-form'>
|
||||
{loading && <Icon icon='mdi:loading' className='mr-2 animate-spin' />}
|
||||
{t('actions.saveTemplate')}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { getSubscribeConfig, updateSubscribeConfig } from '@/services/admin/system';
|
||||
import { Label } from '@workspace/ui/components/label';
|
||||
import { Switch } from '@workspace/ui/components/switch';
|
||||
import { Table, TableBody, TableCell, TableRow } from '@workspace/ui/components/table';
|
||||
import { Textarea } from '@workspace/ui/components/textarea';
|
||||
import { EnhancedInput } from '@workspace/ui/custom-components/enhanced-input';
|
||||
|
||||
export default function SubscribeConfig() {
|
||||
const t = useTranslations('subscribe.config');
|
||||
|
||||
const { data, refetch } = useQuery({
|
||||
queryKey: ['getSubscribeConfig'],
|
||||
queryFn: async () => {
|
||||
const { data } = await getSubscribeConfig();
|
||||
|
||||
return data.data;
|
||||
},
|
||||
});
|
||||
|
||||
async function updateConfig(key: string, value: unknown) {
|
||||
if (data?.[key] === value) return;
|
||||
try {
|
||||
await updateSubscribeConfig({
|
||||
...data,
|
||||
[key]: value,
|
||||
} as API.SubscribeConfig);
|
||||
toast.success(t('updateSuccess'));
|
||||
refetch();
|
||||
} catch (error) {
|
||||
/* empty */
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Table>
|
||||
<TableBody>
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<Label>{t('singleSubscriptionMode')}</Label>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
{t('singleSubscriptionModeDescription')}
|
||||
</p>
|
||||
</TableCell>
|
||||
<TableCell className='text-right'>
|
||||
<Switch
|
||||
checked={data?.single_model}
|
||||
onCheckedChange={(checked) => {
|
||||
updateConfig('single_model', checked);
|
||||
}}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<Label>{t('wildcardResolution')}</Label>
|
||||
<p className='text-muted-foreground text-xs'>{t('wildcardResolutionDescription')}</p>
|
||||
</TableCell>
|
||||
<TableCell className='text-right'>
|
||||
<Switch
|
||||
checked={data?.pan_domain}
|
||||
onCheckedChange={(checked) => {
|
||||
updateConfig('pan_domain', checked);
|
||||
}}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<Label>{t('subscriptionPath')}</Label>
|
||||
<p className='text-muted-foreground text-xs'>{t('subscriptionPathDescription')}</p>
|
||||
</TableCell>
|
||||
<TableCell className='flex items-center gap-2 text-right'>
|
||||
<EnhancedInput
|
||||
placeholder={t('subscriptionPathPlaceholder')}
|
||||
value={data?.subscribe_path}
|
||||
onValueBlur={(value) => updateConfig('subscribe_path', value)}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell className='align-top'>
|
||||
<Label>{t('subscriptionDomain')}</Label>
|
||||
<p className='text-muted-foreground text-xs'>{t('subscriptionDomainDescription')}</p>
|
||||
</TableCell>
|
||||
<TableCell className='text-right'>
|
||||
<Textarea
|
||||
className='h-52'
|
||||
placeholder={`${t('subscriptionDomainPlaceholder')}\nexample.com\nwww.example.com`}
|
||||
defaultValue={data?.subscribe_domain}
|
||||
onBlur={(e) => {
|
||||
updateConfig('subscribe_domain', e.target.value);
|
||||
}}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
);
|
||||
}
|
||||
@@ -1,929 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { getNodeGroupList, getNodeList } from '@/services/admin/server';
|
||||
import { getSubscribeGroupList } from '@/services/admin/subscribe';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from '@workspace/ui/components/accordion';
|
||||
import { Button } from '@workspace/ui/components/button';
|
||||
import { Checkbox } from '@workspace/ui/components/checkbox';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@workspace/ui/components/form';
|
||||
import { Label } from '@workspace/ui/components/label';
|
||||
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 { Tabs, TabsContent, TabsList, TabsTrigger } from '@workspace/ui/components/tabs';
|
||||
import { Combobox } from '@workspace/ui/custom-components/combobox';
|
||||
import { ArrayInput } from '@workspace/ui/custom-components/dynamic-Inputs';
|
||||
import { JSONEditor } from '@workspace/ui/custom-components/editor';
|
||||
import { EnhancedInput } from '@workspace/ui/custom-components/enhanced-input';
|
||||
import { Icon } from '@workspace/ui/custom-components/icon';
|
||||
import { evaluateWithPrecision, unitConversion } from '@workspace/ui/utils';
|
||||
import { CreditCard, Server, Settings } from 'lucide-react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { assign, shake } from 'radash';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { toast } from 'sonner';
|
||||
import { z } from 'zod';
|
||||
|
||||
interface SubscribeFormProps<T> {
|
||||
onSubmit: (data: T) => Promise<boolean> | boolean;
|
||||
initialValues?: T;
|
||||
loading?: boolean;
|
||||
trigger: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
const defaultValues = {
|
||||
inventory: 0,
|
||||
speed_limit: 0,
|
||||
device_limit: 0,
|
||||
traffic: 0,
|
||||
quota: 0,
|
||||
discount: [],
|
||||
server_group: [],
|
||||
server: [],
|
||||
unit_time: 'Month',
|
||||
deduction_ratio: 0,
|
||||
purchase_with_discount: false,
|
||||
reset_cycle: 0,
|
||||
renewal_reset: false,
|
||||
deduction_mode: 'auto',
|
||||
};
|
||||
|
||||
export default function SubscribeForm<T extends Record<string, any>>({
|
||||
onSubmit,
|
||||
initialValues,
|
||||
loading,
|
||||
trigger,
|
||||
title,
|
||||
}: Readonly<SubscribeFormProps<T>>) {
|
||||
const t = useTranslations('subscribe');
|
||||
const [open, setOpen] = useState(false);
|
||||
const updateTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
const formSchema = z.object({
|
||||
name: z.string(),
|
||||
description: z.string().optional(),
|
||||
unit_price: z.number(),
|
||||
unit_time: z.string().default('Month'),
|
||||
replacement: z.number().optional(),
|
||||
discount: z
|
||||
.array(
|
||||
z.object({
|
||||
quantity: z.number(),
|
||||
discount: z.number(),
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
inventory: z.number().optional().default(-1),
|
||||
speed_limit: z.number().optional().default(0),
|
||||
device_limit: z.number().optional().default(0),
|
||||
traffic: z.number().optional().default(0),
|
||||
quota: z.number().optional().default(0),
|
||||
group_id: z.number().optional().nullish(),
|
||||
server_group: z.array(z.number()).optional().default([]),
|
||||
server: z.array(z.number()).optional().default([]),
|
||||
deduction_ratio: z.number().optional().default(0),
|
||||
allow_deduction: z.boolean().optional().default(false),
|
||||
reset_cycle: z.number().optional().default(0),
|
||||
renewal_reset: z.boolean().optional().default(false),
|
||||
});
|
||||
|
||||
const form = useForm({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: assign(
|
||||
defaultValues,
|
||||
shake(initialValues, (value) => value === null) as Record<string, any>,
|
||||
),
|
||||
});
|
||||
|
||||
const debouncedCalculateDiscount = useCallback(
|
||||
(values: any[], fieldName: string, lastChangedField?: string, changedIndex?: number) => {
|
||||
if (updateTimeoutRef.current) {
|
||||
clearTimeout(updateTimeoutRef.current);
|
||||
}
|
||||
|
||||
updateTimeoutRef.current = setTimeout(() => {
|
||||
const { unit_price } = form.getValues();
|
||||
if (!unit_price || !values?.length) return;
|
||||
|
||||
let hasChanges = false;
|
||||
const calculatedValues = values.map((item: any, index: number) => {
|
||||
const result = { ...item };
|
||||
|
||||
if (changedIndex !== undefined && index !== changedIndex) {
|
||||
return result;
|
||||
}
|
||||
|
||||
const quantity = Number(item.quantity) || 0;
|
||||
const discount = Number(item.discount) || 0;
|
||||
const price = Number(item.price) || 0;
|
||||
|
||||
switch (lastChangedField) {
|
||||
case 'quantity':
|
||||
case 'discount':
|
||||
if (quantity > 0 && discount > 0) {
|
||||
const newPrice = evaluateWithPrecision(
|
||||
`${unit_price} * ${quantity} * ${discount} / 100`,
|
||||
);
|
||||
if (Math.abs(newPrice - price) > 0.01) {
|
||||
result.price = newPrice;
|
||||
hasChanges = true;
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'price':
|
||||
if (quantity > 0 && price > 0) {
|
||||
const newDiscount = evaluateWithPrecision(
|
||||
`${price} / ${quantity} / ${unit_price} * 100`,
|
||||
);
|
||||
if (Math.abs(newDiscount - discount) > 0.01) {
|
||||
result.discount = Math.min(100, Math.max(0, newDiscount));
|
||||
hasChanges = true;
|
||||
}
|
||||
} else if (discount > 0 && price > 0) {
|
||||
const newQuantity = evaluateWithPrecision(
|
||||
`${price} / ${unit_price} / ${discount} * 100`,
|
||||
);
|
||||
if (Math.abs(newQuantity - quantity) > 0.01 && newQuantity > 0) {
|
||||
result.quantity = Math.max(1, Math.round(newQuantity));
|
||||
hasChanges = true;
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
if (quantity > 0 && discount > 0 && price === 0) {
|
||||
result.price = evaluateWithPrecision(
|
||||
`${unit_price} * ${quantity} * ${discount} / 100`,
|
||||
);
|
||||
hasChanges = true;
|
||||
} else if (quantity > 0 && price > 0 && discount === 0) {
|
||||
const newDiscount = evaluateWithPrecision(
|
||||
`${price} / ${quantity} / ${unit_price} * 100`,
|
||||
);
|
||||
result.discount = Math.min(100, Math.max(0, newDiscount));
|
||||
hasChanges = true;
|
||||
} else if (discount > 0 && price > 0 && quantity === 0) {
|
||||
const newQuantity = evaluateWithPrecision(
|
||||
`${price} / ${unit_price} / ${discount} * 100`,
|
||||
);
|
||||
if (newQuantity > 0) {
|
||||
result.quantity = Math.max(1, Math.round(newQuantity));
|
||||
hasChanges = true;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
return result;
|
||||
});
|
||||
|
||||
if (hasChanges) {
|
||||
form.setValue(fieldName, calculatedValues, { shouldDirty: true });
|
||||
}
|
||||
}, 300);
|
||||
},
|
||||
[form],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
form?.reset(
|
||||
assign(defaultValues, shake(initialValues, (value) => value === null) as Record<string, any>),
|
||||
);
|
||||
}, [form, initialValues]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (updateTimeoutRef.current) {
|
||||
clearTimeout(updateTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
async function handleSubmit(data: { [x: string]: any }) {
|
||||
const bool = await onSubmit(data as T);
|
||||
if (bool) setOpen(false);
|
||||
}
|
||||
|
||||
const { data: group } = useQuery({
|
||||
queryKey: ['getSubscribeGroupList'],
|
||||
queryFn: async () => {
|
||||
const { data } = await getSubscribeGroupList();
|
||||
return data.data?.list as API.SubscribeGroup[];
|
||||
},
|
||||
});
|
||||
|
||||
const { data: server } = useQuery({
|
||||
queryKey: ['getNodeList', 'all'],
|
||||
queryFn: async () => {
|
||||
const { data } = await getNodeList({
|
||||
page: 1,
|
||||
size: 9999,
|
||||
});
|
||||
return data.data?.list;
|
||||
},
|
||||
});
|
||||
|
||||
const { data: server_groups } = useQuery({
|
||||
queryKey: ['getNodeGroupList'],
|
||||
queryFn: async () => {
|
||||
const { data } = await getNodeGroupList();
|
||||
return (data.data?.list || []) as API.ServerGroup[];
|
||||
},
|
||||
});
|
||||
|
||||
const unit_time = form.watch('unit_time');
|
||||
const unit_price = form.watch('unit_price');
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={setOpen}>
|
||||
<SheetTrigger asChild>
|
||||
<Button
|
||||
onClick={() => {
|
||||
form.reset();
|
||||
setOpen(true);
|
||||
}}
|
||||
>
|
||||
{trigger}
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent className='w-[800px] max-w-full md:max-w-screen-md'>
|
||||
<SheetHeader>
|
||||
<SheetTitle>{title}</SheetTitle>
|
||||
</SheetHeader>
|
||||
<ScrollArea className='h-[calc(100dvh-48px-36px-36px-env(safe-area-inset-top))]'>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(handleSubmit)} className='pt-4'>
|
||||
<Tabs defaultValue='basic' className='w-full'>
|
||||
<TabsList className='mb-6 grid w-full grid-cols-3'>
|
||||
<TabsTrigger value='basic' className='flex items-center gap-2'>
|
||||
<Settings className='h-4 w-4' />
|
||||
{t('form.basic')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value='pricing' className='flex items-center gap-2'>
|
||||
<CreditCard className='h-4 w-4' />
|
||||
{t('form.pricing')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value='servers' className='flex items-center gap-2'>
|
||||
<Server className='h-4 w-4' />
|
||||
{t('form.servers')}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value='basic' className='space-y-4'>
|
||||
<div className='grid gap-6'>
|
||||
<div className='grid grid-cols-2 gap-4'>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='name'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('form.name')}</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
{...field}
|
||||
onValueChange={(value) => {
|
||||
form.setValue(field.name, value);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='group_id'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('form.groupId')}</FormLabel>
|
||||
<FormControl>
|
||||
<Combobox<number, false>
|
||||
placeholder={t('form.selectSubscribeGroup')}
|
||||
{...field}
|
||||
onChange={(value) => {
|
||||
form.setValue(field.name, value || 0);
|
||||
}}
|
||||
options={group?.map((item) => ({
|
||||
label: item.name,
|
||||
value: item.id,
|
||||
}))}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='grid grid-cols-3 gap-4'>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='traffic'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('form.traffic')}</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
placeholder={t('form.noLimit')}
|
||||
type='number'
|
||||
{...field}
|
||||
formatInput={(value) => unitConversion('bytesToGb', value)}
|
||||
formatOutput={(value) => unitConversion('gbToBytes', value)}
|
||||
suffix='GB'
|
||||
onValueChange={(value) => {
|
||||
form.setValue(field.name, value);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='speed_limit'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('form.speedLimit')}</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
placeholder={t('form.noLimit')}
|
||||
type='number'
|
||||
{...field}
|
||||
formatInput={(value) => unitConversion('bitsToMb', value)}
|
||||
formatOutput={(value) => unitConversion('mbToBits', value)}
|
||||
suffix='Mbps'
|
||||
onValueChange={(value) => {
|
||||
form.setValue(field.name, value);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='device_limit'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('form.deviceLimit')}</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
placeholder={t('form.noLimit')}
|
||||
type='number'
|
||||
step={1}
|
||||
{...field}
|
||||
onValueChange={(value) => {
|
||||
form.setValue(field.name, value);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='grid grid-cols-3 gap-4'>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='inventory'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('form.inventory')}</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
placeholder={t('form.noLimit')}
|
||||
type='number'
|
||||
step={1}
|
||||
value={field.value}
|
||||
min={0}
|
||||
onValueChange={(value) => {
|
||||
form.setValue(field.name, value);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='quota'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('form.quota')}</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
placeholder={t('form.noLimit')}
|
||||
type='number'
|
||||
step={1}
|
||||
{...field}
|
||||
onValueChange={(value) => {
|
||||
form.setValue(field.name, value);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='description'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<JSONEditor
|
||||
title={t('form.description')}
|
||||
value={field.value && JSON.parse(field.value)}
|
||||
onChange={(value) => {
|
||||
form.setValue(field.name, JSON.stringify(value));
|
||||
}}
|
||||
placeholder={{
|
||||
description: 'description',
|
||||
features: [
|
||||
{
|
||||
type: 'default',
|
||||
icon: '',
|
||||
label: 'label',
|
||||
},
|
||||
],
|
||||
}}
|
||||
schema={{
|
||||
type: 'object',
|
||||
properties: {
|
||||
description: {
|
||||
type: 'string',
|
||||
description: 'A brief description of the item.',
|
||||
},
|
||||
features: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
icon: {
|
||||
type: 'string',
|
||||
description:
|
||||
"Enter an Iconify icon identifier (e.g., 'mdi:account').",
|
||||
pattern: '^[a-z0-9]+:[a-z0-9-]+$',
|
||||
examples: [
|
||||
'uil:shield-check',
|
||||
'uil:shield-exclamation',
|
||||
'uil:database',
|
||||
'uil:server',
|
||||
],
|
||||
},
|
||||
label: {
|
||||
type: 'string',
|
||||
description: 'The label describing the feature.',
|
||||
},
|
||||
type: {
|
||||
type: 'string',
|
||||
enum: ['default', 'success', 'destructive'],
|
||||
description:
|
||||
'The type of feature, limited to specific values.',
|
||||
},
|
||||
},
|
||||
},
|
||||
description: 'A list of feature objects.',
|
||||
},
|
||||
},
|
||||
required: ['description', 'features'],
|
||||
additionalProperties: false,
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value='pricing' className='space-y-4'>
|
||||
<div className='grid gap-6'>
|
||||
<div className='grid grid-cols-4 gap-4'>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='unit_price'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('form.unitPrice')}</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
type='number'
|
||||
{...field}
|
||||
min={0}
|
||||
formatInput={(value) => unitConversion('centsToDollars', value)}
|
||||
formatOutput={(value) => unitConversion('dollarsToCents', value)}
|
||||
onValueChange={(value) => {
|
||||
form.setValue(field.name, value);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='unit_time'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('form.unitTime')}</FormLabel>
|
||||
<FormControl>
|
||||
<Combobox
|
||||
placeholder={t('form.selectUnitTime')}
|
||||
{...field}
|
||||
onChange={(value) => {
|
||||
if (value) {
|
||||
form.setValue(field.name, value);
|
||||
}
|
||||
}}
|
||||
options={[
|
||||
{ label: t('form.NoLimit'), value: 'NoLimit' },
|
||||
{ label: t('form.Year'), value: 'Year' },
|
||||
{ label: t('form.Month'), value: 'Month' },
|
||||
{ label: t('form.Day'), value: 'Day' },
|
||||
{ label: t('form.Hour'), value: 'Hour' },
|
||||
{ label: t('form.Minute'), value: 'Minute' },
|
||||
]}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='replacement'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('form.replacement')}</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
type='number'
|
||||
{...field}
|
||||
min={0}
|
||||
formatInput={(value) => unitConversion('centsToDollars', value)}
|
||||
formatOutput={(value) => unitConversion('dollarsToCents', value)}
|
||||
onValueChange={(value) => {
|
||||
form.setValue(field.name, value);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='reset_cycle'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('form.resetCycle')}</FormLabel>
|
||||
<FormControl>
|
||||
<Combobox<number, false>
|
||||
placeholder={t('form.selectResetCycle')}
|
||||
{...field}
|
||||
onChange={(value) => {
|
||||
if (typeof value === 'number') {
|
||||
form.setValue(field.name, value);
|
||||
}
|
||||
}}
|
||||
options={[
|
||||
{ label: t('form.noReset'), value: 0 },
|
||||
{ label: t('form.resetOn1st'), value: 1 },
|
||||
{ label: t('form.monthlyReset'), value: 2 },
|
||||
{ label: t('form.annualReset'), value: 3 },
|
||||
]}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='discount'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('form.discount')}</FormLabel>
|
||||
<FormControl>
|
||||
<ArrayInput<API.SubscribeDiscount & { price?: number }>
|
||||
fields={[
|
||||
{
|
||||
name: 'quantity',
|
||||
type: 'number',
|
||||
step: 1,
|
||||
min: 1,
|
||||
suffix: unit_time && t(`form.${unit_time}`),
|
||||
},
|
||||
{
|
||||
name: 'discount',
|
||||
type: 'number',
|
||||
max: 100,
|
||||
placeholder: t('form.discountPercent'),
|
||||
suffix: '%',
|
||||
},
|
||||
{
|
||||
name: 'price',
|
||||
placeholder: t('form.discount_price'),
|
||||
type: 'number',
|
||||
min: 0,
|
||||
step: 0.01,
|
||||
formatInput: (value) => unitConversion('centsToDollars', value),
|
||||
formatOutput: (value) => unitConversion('dollarsToCents', value),
|
||||
},
|
||||
]}
|
||||
value={field.value}
|
||||
onChange={(
|
||||
newValues: (API.SubscribeDiscount & { price?: number })[],
|
||||
) => {
|
||||
const oldValues = field.value || [];
|
||||
let lastChangedField: string | undefined;
|
||||
let changedIndex: number | undefined;
|
||||
|
||||
for (
|
||||
let i = 0;
|
||||
i < Math.max(newValues.length, oldValues.length);
|
||||
i++
|
||||
) {
|
||||
const newItem = newValues[i] || {};
|
||||
const oldItem = oldValues[i] || {};
|
||||
|
||||
if ((newItem as any).quantity !== (oldItem as any).quantity) {
|
||||
lastChangedField = 'quantity';
|
||||
changedIndex = i;
|
||||
break;
|
||||
}
|
||||
if ((newItem as any).discount !== (oldItem as any).discount) {
|
||||
lastChangedField = 'discount';
|
||||
changedIndex = i;
|
||||
break;
|
||||
}
|
||||
if ((newItem as any).price !== (oldItem as any).price) {
|
||||
lastChangedField = 'price';
|
||||
changedIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
form.setValue(field.name, newValues, { shouldDirty: true });
|
||||
if (newValues?.length > 0) {
|
||||
debouncedCalculateDiscount(
|
||||
newValues,
|
||||
field.name,
|
||||
lastChangedField,
|
||||
changedIndex,
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>{t('form.discountDescription')}</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='deduction_ratio'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('form.deductionRatio')}</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
type='number'
|
||||
{...field}
|
||||
min={0}
|
||||
max={100}
|
||||
placeholder='Auto'
|
||||
suffix='%'
|
||||
onValueChange={(value) => {
|
||||
form.setValue(field.name, value);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
<FormDescription>{t('form.deductionRatioDescription')}</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='renewal_reset'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='space-y-0.5'>
|
||||
<FormLabel>{t('form.renewalReset')}</FormLabel>
|
||||
<FormDescription>{t('form.renewalResetDescription')}</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch checked={field.value} onCheckedChange={field.onChange} />
|
||||
</FormControl>
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='allow_deduction'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='space-y-0.5'>
|
||||
<FormLabel>{t('form.purchaseWithDiscount')}</FormLabel>
|
||||
<FormDescription>
|
||||
{t('form.purchaseWithDiscountDescription')}
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={!!field.value}
|
||||
onCheckedChange={(value) => {
|
||||
form.setValue(field.name, value);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value='servers' className='space-y-4'>
|
||||
<div className='space-y-6'>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='server_group'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('form.serverGroup')}</FormLabel>
|
||||
<FormControl>
|
||||
<Accordion type='single' collapsible className='w-full'>
|
||||
{server_groups?.map((group: API.ServerGroup) => {
|
||||
const value = field.value || [];
|
||||
|
||||
return (
|
||||
<AccordionItem key={group.id} value={String(group.id)}>
|
||||
<AccordionTrigger>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Checkbox
|
||||
checked={value.includes(group.id!)}
|
||||
onCheckedChange={(checked) => {
|
||||
return checked
|
||||
? form.setValue(field.name, [...value, group.id])
|
||||
: form.setValue(
|
||||
field.name,
|
||||
value.filter(
|
||||
(value: number) => value !== group.id,
|
||||
),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Label>{group.name}</Label>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<ul className='list-disc [&>li]:mt-2'>
|
||||
{server
|
||||
?.filter(
|
||||
(server: API.Server) => server.group_id === group.id,
|
||||
)
|
||||
?.map((node: API.Server) => {
|
||||
return (
|
||||
<li
|
||||
key={node.id}
|
||||
className='flex items-center justify-between *:flex-1'
|
||||
>
|
||||
<span>{node.name}</span>
|
||||
<span>{node.server_addr}</span>
|
||||
<span className='text-right'>{node.protocol}</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
);
|
||||
})}
|
||||
</Accordion>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='server'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('form.server')}</FormLabel>
|
||||
<FormControl>
|
||||
<div className='flex flex-col gap-2'>
|
||||
{server
|
||||
?.filter((item: API.Server) => !item.group_id)
|
||||
?.map((item: API.Server) => {
|
||||
const value = field.value || [];
|
||||
|
||||
return (
|
||||
<div className='flex items-center gap-2' key={item.id}>
|
||||
<Checkbox
|
||||
checked={value.includes(item.id!)}
|
||||
onCheckedChange={(checked) => {
|
||||
return checked
|
||||
? form.setValue(field.name, [...value, item.id])
|
||||
: form.setValue(
|
||||
field.name,
|
||||
value.filter((value: number) => value !== item.id),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Label className='flex w-full items-center justify-between *:flex-1'>
|
||||
<span>{item.name}</span>
|
||||
<span>{item.server_addr}</span>
|
||||
<span className='text-right'>{item.protocol}</span>
|
||||
</Label>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</form>
|
||||
</Form>
|
||||
</ScrollArea>
|
||||
<SheetFooter className='flex-row justify-end gap-2 pt-3'>
|
||||
<Button
|
||||
variant='outline'
|
||||
disabled={loading}
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
{t('form.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
disabled={loading}
|
||||
onClick={form.handleSubmit(handleSubmit, (errors) => {
|
||||
const keys = Object.keys(errors);
|
||||
for (const key of keys) {
|
||||
const formattedKey = key.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase());
|
||||
toast.error(`${t(`form.${formattedKey}`)} is ${errors[key]?.message}`);
|
||||
return false;
|
||||
}
|
||||
})}
|
||||
>
|
||||
{loading && <Icon icon='mdi:loading' className='mr-2 animate-spin' />}
|
||||
{t('form.confirm')}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -1,306 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { Display } from '@/components/display';
|
||||
import { ProTable, ProTableActions } from '@/components/pro-table';
|
||||
import {
|
||||
batchDeleteSubscribe,
|
||||
createSubscribe,
|
||||
deleteSubscribe,
|
||||
getSubscribeGroupList,
|
||||
getSubscribeList,
|
||||
subscribeSort,
|
||||
updateSubscribe,
|
||||
} from '@/services/admin/subscribe';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Badge } from '@workspace/ui/components/badge';
|
||||
import { Button } from '@workspace/ui/components/button';
|
||||
import { Switch } from '@workspace/ui/components/switch';
|
||||
import { ConfirmButton } from '@workspace/ui/custom-components/confirm-button';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useRef, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import SubscribeForm from './subscribe-form';
|
||||
|
||||
export default function SubscribeTable() {
|
||||
const t = useTranslations('subscribe');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { data: groups } = useQuery({
|
||||
queryKey: ['getSubscribeGroupList', 'all'],
|
||||
queryFn: async () => {
|
||||
const { data } = await getSubscribeGroupList({
|
||||
page: 1,
|
||||
size: 9999,
|
||||
});
|
||||
return data.data?.list as API.SubscribeGroup[];
|
||||
},
|
||||
});
|
||||
const ref = useRef<ProTableActions>(null);
|
||||
return (
|
||||
<ProTable<API.SubscribeItem, { group_id: number; query: string }>
|
||||
action={ref}
|
||||
header={{
|
||||
toolbar: (
|
||||
<SubscribeForm<API.CreateSubscribeRequest>
|
||||
trigger={t('create')}
|
||||
title={t('createSubscribe')}
|
||||
loading={loading}
|
||||
onSubmit={async (values) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await createSubscribe({
|
||||
...values,
|
||||
show: false,
|
||||
sell: false,
|
||||
});
|
||||
toast.success(t('createSuccess'));
|
||||
ref.current?.refresh();
|
||||
setLoading(false);
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
setLoading(false);
|
||||
|
||||
return false;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
params={[
|
||||
{
|
||||
key: 'group_id',
|
||||
placeholder: t('subscribeGroup'),
|
||||
options: groups?.map((item) => ({
|
||||
label: item.name,
|
||||
value: String(item.id),
|
||||
})),
|
||||
},
|
||||
{
|
||||
key: 'search',
|
||||
},
|
||||
]}
|
||||
request={async (pagination, filters) => {
|
||||
const { data } = await getSubscribeList({
|
||||
...pagination,
|
||||
...filters,
|
||||
});
|
||||
return {
|
||||
list: data.data?.list || [],
|
||||
total: data.data?.total || 0,
|
||||
};
|
||||
}}
|
||||
columns={[
|
||||
{
|
||||
accessorKey: 'show',
|
||||
header: t('show'),
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<Switch
|
||||
defaultChecked={row.getValue('show')}
|
||||
onCheckedChange={async (checked) => {
|
||||
await updateSubscribe({
|
||||
...row.original,
|
||||
show: checked,
|
||||
} as API.UpdateSubscribeRequest);
|
||||
ref.current?.refresh();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'sell',
|
||||
header: t('sell'),
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<Switch
|
||||
defaultChecked={row.getValue('sell')}
|
||||
onCheckedChange={async (checked) => {
|
||||
await updateSubscribe({
|
||||
...row.original,
|
||||
sell: checked,
|
||||
} as API.UpdateSubscribeRequest);
|
||||
ref.current?.refresh();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'name',
|
||||
header: t('name'),
|
||||
},
|
||||
{
|
||||
accessorKey: 'unit_price',
|
||||
header: t('unitPrice'),
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<>
|
||||
<Display type='currency' value={row.getValue('unit_price')} />/
|
||||
{t(row.original.unit_time ? `form.${row.original.unit_time}` : 'form.Month')}
|
||||
</>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'replacement',
|
||||
header: t('replacement'),
|
||||
cell: ({ row }) => <Display type='currency' value={row.getValue('replacement')} />,
|
||||
},
|
||||
{
|
||||
accessorKey: 'traffic',
|
||||
header: t('traffic'),
|
||||
cell: ({ row }) => <Display type='traffic' value={row.getValue('traffic')} unlimited />,
|
||||
},
|
||||
{
|
||||
accessorKey: 'device_limit',
|
||||
header: t('deviceLimit'),
|
||||
cell: ({ row }) => (
|
||||
<Display type='number' value={row.getValue('device_limit')} unlimited />
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'inventory',
|
||||
header: t('inventory'),
|
||||
cell: ({ row }) => (
|
||||
<Display
|
||||
type='number'
|
||||
value={row.getValue('inventory') === -1 ? 0 : row.getValue('inventory')}
|
||||
unlimited
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'quota',
|
||||
header: t('quota'),
|
||||
cell: ({ row }) => <Display type='number' value={row.getValue('quota')} unlimited />,
|
||||
},
|
||||
{
|
||||
accessorKey: 'group_id',
|
||||
header: t('subscribeGroup'),
|
||||
cell: ({ row }) => {
|
||||
const name = groups?.find((group) => group.id === row.getValue('group_id'))?.name;
|
||||
return name ? <Badge variant='outline'>{name}</Badge> : '--';
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'sold',
|
||||
header: t('sold'),
|
||||
cell: ({ row }) => <Badge variant='outline'>{row.getValue('sold')}</Badge>,
|
||||
},
|
||||
]}
|
||||
actions={{
|
||||
render: (row) => [
|
||||
<SubscribeForm<API.SubscribeItem>
|
||||
key='edit'
|
||||
trigger={t('edit')}
|
||||
title={t('editSubscribe')}
|
||||
loading={loading}
|
||||
initialValues={row}
|
||||
onSubmit={async (values) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await updateSubscribe({
|
||||
...row,
|
||||
...values,
|
||||
} as API.UpdateSubscribeRequest);
|
||||
toast.success(t('updateSuccess'));
|
||||
ref.current?.refresh();
|
||||
setLoading(false);
|
||||
return true;
|
||||
} catch (error) {
|
||||
setLoading(false);
|
||||
|
||||
return false;
|
||||
}
|
||||
}}
|
||||
/>,
|
||||
<ConfirmButton
|
||||
key='delete'
|
||||
trigger={<Button variant='destructive'>{t('delete')}</Button>}
|
||||
title={t('confirmDelete')}
|
||||
description={t('deleteWarning')}
|
||||
onConfirm={async () => {
|
||||
await deleteSubscribe({
|
||||
id: row.id!,
|
||||
});
|
||||
toast.success(t('deleteSuccess'));
|
||||
ref.current?.refresh();
|
||||
}}
|
||||
cancelText={t('cancel')}
|
||||
confirmText={t('confirm')}
|
||||
/>,
|
||||
<Button
|
||||
key='copy'
|
||||
variant='secondary'
|
||||
onClick={async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const { id, sort, sell, updated_at, created_at, ...params } = row;
|
||||
await createSubscribe({
|
||||
...params,
|
||||
show: false,
|
||||
sell: false,
|
||||
} as API.CreateSubscribeRequest);
|
||||
toast.success(t('copySuccess'));
|
||||
ref.current?.refresh();
|
||||
setLoading(false);
|
||||
return true;
|
||||
} catch (error) {
|
||||
setLoading(false);
|
||||
return false;
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t('copy')}
|
||||
</Button>,
|
||||
],
|
||||
batchRender: (rows) => [
|
||||
<ConfirmButton
|
||||
key='delete'
|
||||
trigger={<Button variant='destructive'>{t('delete')}</Button>}
|
||||
title={t('confirmDelete')}
|
||||
description={t('deleteWarning')}
|
||||
onConfirm={async () => {
|
||||
await batchDeleteSubscribe({
|
||||
ids: rows.map((item) => item.id) as number[],
|
||||
});
|
||||
|
||||
toast.success(t('deleteSuccess'));
|
||||
ref.current?.reset();
|
||||
}}
|
||||
cancelText={t('cancel')}
|
||||
confirmText={t('confirm')}
|
||||
/>,
|
||||
],
|
||||
}}
|
||||
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) {
|
||||
subscribeSort({
|
||||
sort: changedItems.map((item) => ({ id: item.id, sort: item.sort })) as API.SortItem[],
|
||||
});
|
||||
}
|
||||
|
||||
return updatedItems;
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user