✨ feat(admin): Add application and rule management entries to localization files
This commit is contained in:
@@ -0,0 +1,215 @@
|
||||
'use client';
|
||||
|
||||
import { createRuleGroup } from '@/services/admin/server';
|
||||
import { Button } from '@workspace/ui/components/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@workspace/ui/components/dialog';
|
||||
import { Label } from '@workspace/ui/components/label';
|
||||
import { Progress } from '@workspace/ui/components/progress';
|
||||
import { Textarea } from '@workspace/ui/components/textarea';
|
||||
import { Icon } from '@workspace/ui/custom-components/icon';
|
||||
import yaml from 'js-yaml';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useRef, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface ImportYamlRulesProps {
|
||||
onImportSuccess?: () => void;
|
||||
}
|
||||
|
||||
interface RuleGroup {
|
||||
name: string;
|
||||
rules: string[];
|
||||
}
|
||||
|
||||
export default function ImportYamlRules({ onImportSuccess }: ImportYamlRulesProps) {
|
||||
const t = useTranslations('rules');
|
||||
const [open, setOpen] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [yamlContent, setYamlContent] = useState('');
|
||||
const [importProgress, setImportProgress] = useState(0);
|
||||
const [importTotal, setImportTotal] = useState(0);
|
||||
const [analyzing, setAnalyzing] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = (event) => {
|
||||
const content = event.target?.result as string;
|
||||
setYamlContent(content);
|
||||
setOpen(true);
|
||||
};
|
||||
reader.readAsText(file);
|
||||
|
||||
e.target.value = '';
|
||||
};
|
||||
|
||||
const processRule = (rule: string): { policyGroup: string; cleanRule: string } | null => {
|
||||
const parts = rule.split(',');
|
||||
|
||||
if (parts.length === 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let policyGroup = 'default';
|
||||
let cleanRule = rule;
|
||||
|
||||
if (parts.length >= 3) {
|
||||
const thirdPart = parts[2]?.trim();
|
||||
if (thirdPart) {
|
||||
policyGroup = thirdPart;
|
||||
}
|
||||
}
|
||||
|
||||
cleanRule = parts.slice(0, 2).join(',');
|
||||
|
||||
return { policyGroup, cleanRule };
|
||||
};
|
||||
|
||||
const parseRulesIntoGroups = (rules: string[]): Record<string, string[]> => {
|
||||
const groups: Record<string, string[]> = {};
|
||||
|
||||
for (const rule of rules) {
|
||||
if (!rule.trim()) continue;
|
||||
|
||||
const result = processRule(rule);
|
||||
if (result === null) continue;
|
||||
|
||||
const { policyGroup, cleanRule } = result;
|
||||
if (!groups[policyGroup]) {
|
||||
groups[policyGroup] = [];
|
||||
}
|
||||
groups[policyGroup].push(cleanRule);
|
||||
}
|
||||
|
||||
return groups;
|
||||
};
|
||||
|
||||
const handleImport = async () => {
|
||||
if (!yamlContent) {
|
||||
toast.error(t('pleaseUploadFile'));
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setAnalyzing(true);
|
||||
try {
|
||||
const parsedYaml = yaml.load(yamlContent) as any;
|
||||
|
||||
if (!parsedYaml || !parsedYaml.rules) {
|
||||
throw new Error(t('invalidYamlFormat'));
|
||||
}
|
||||
|
||||
let allRules: string[] = [];
|
||||
if (Array.isArray(parsedYaml.rules)) {
|
||||
allRules = parsedYaml.rules.filter((rule: string) => rule.trim());
|
||||
}
|
||||
|
||||
if (allRules.length === 0) {
|
||||
throw new Error(t('noValidRules'));
|
||||
}
|
||||
|
||||
const ruleGroups = parseRulesIntoGroups(allRules);
|
||||
const groups = Object.entries(ruleGroups).map(([name, rules]) => ({
|
||||
name,
|
||||
rules,
|
||||
}));
|
||||
|
||||
setImportTotal(groups.length);
|
||||
setAnalyzing(false);
|
||||
|
||||
for (let i = 0; i < groups.length; i++) {
|
||||
const group = groups[i];
|
||||
if (!group?.name || !group?.rules.length) continue;
|
||||
await createRuleGroup({
|
||||
name: group.name,
|
||||
rules: group?.rules.join('\n'),
|
||||
enable: false,
|
||||
tags: [],
|
||||
icon: '',
|
||||
});
|
||||
setImportProgress(i + 1);
|
||||
}
|
||||
|
||||
toast.success(t('importSuccess'));
|
||||
setOpen(false);
|
||||
setYamlContent('');
|
||||
setImportProgress(0);
|
||||
setImportTotal(0);
|
||||
onImportSuccess?.();
|
||||
} catch (error) {
|
||||
console.error('Import error:', error);
|
||||
toast.error(error instanceof Error ? error.message : t('importFailed'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setAnalyzing(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type='file'
|
||||
accept='.yml,.yaml'
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleFileUpload}
|
||||
/>
|
||||
<Button variant='default' onClick={() => fileInputRef.current?.click()}>
|
||||
{t('import')}
|
||||
</Button>
|
||||
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className='sm:max-w-[500px]'>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('importYamlRules')}</DialogTitle>
|
||||
<DialogDescription>{t('importYamlDescription')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className='grid gap-4 py-4'>
|
||||
{yamlContent && (
|
||||
<div className='grid gap-2'>
|
||||
<Label htmlFor='preview'>{t('preview')}</Label>
|
||||
<Textarea
|
||||
id='preview'
|
||||
value={yamlContent}
|
||||
readOnly
|
||||
rows={10}
|
||||
className='font-mono text-xs'
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{importTotal > 0 && (
|
||||
<div className='grid gap-2'>
|
||||
<div className='flex justify-between text-sm'>
|
||||
<span>{analyzing ? t('analyzing') : t('importing')}</span>
|
||||
<span>
|
||||
{importProgress} / {importTotal}
|
||||
</span>
|
||||
</div>
|
||||
<Progress value={(importProgress / importTotal) * 100} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant='outline' onClick={() => setOpen(false)} disabled={loading}>
|
||||
{t('cancel')}
|
||||
</Button>
|
||||
<Button onClick={handleImport} disabled={loading || !yamlContent}>
|
||||
{loading && <Icon icon='mdi:loading' className='mr-2 animate-spin' />}
|
||||
{t('import')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
'use client';
|
||||
|
||||
import { ProTable, ProTableActions } from '@/components/pro-table';
|
||||
import {
|
||||
createRuleGroup,
|
||||
deleteRuleGroup,
|
||||
getRuleGroupList,
|
||||
updateRuleGroup,
|
||||
} from '@/services/admin/server';
|
||||
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 { formatDate } from '@workspace/ui/utils';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import Image from 'next/legacy/image';
|
||||
import Link from 'next/link';
|
||||
import { useRef, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import ImportYamlRules from './import-yaml-rules';
|
||||
import RuleForm from './rule-form';
|
||||
|
||||
export default function Page() {
|
||||
const t = useTranslations('rules');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const ref = useRef<ProTableActions>(null);
|
||||
|
||||
return (
|
||||
<ProTable<API.ServerRuleGroup, { query: string }>
|
||||
action={ref}
|
||||
header={{
|
||||
toolbar: (
|
||||
<div className='flex gap-2'>
|
||||
<Button variant='default' asChild>
|
||||
<Link href='/template/rules.yml' target='_blank' download>
|
||||
{t('downloadTemplate')}
|
||||
</Link>
|
||||
</Button>
|
||||
<ImportYamlRules onImportSuccess={() => ref.current?.refresh()} />
|
||||
<RuleForm<API.CreateRuleGroupRequest>
|
||||
trigger={t('create')}
|
||||
title={t('createRule')}
|
||||
loading={loading}
|
||||
onSubmit={async (values) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await createRuleGroup({
|
||||
name: values.name,
|
||||
rules: values.rules || '',
|
||||
enable: false,
|
||||
tags: values.tags || [],
|
||||
icon: values.icon || '',
|
||||
});
|
||||
toast.success(t('createSuccess'));
|
||||
ref.current?.refresh();
|
||||
setLoading(false);
|
||||
return true;
|
||||
} catch (error) {
|
||||
setLoading(false);
|
||||
return false;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
}}
|
||||
params={[
|
||||
{
|
||||
key: 'search',
|
||||
placeholder: t('searchRule'),
|
||||
},
|
||||
]}
|
||||
request={async (pagination, filters) => {
|
||||
const { data } = await getRuleGroupList({
|
||||
...pagination,
|
||||
...filters,
|
||||
});
|
||||
return {
|
||||
list: data.data?.list || [],
|
||||
total: data.data?.total || 0,
|
||||
};
|
||||
}}
|
||||
columns={[
|
||||
{
|
||||
accessorKey: 'enable',
|
||||
header: t('enable'),
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<Switch
|
||||
defaultChecked={row.getValue('enable')}
|
||||
onCheckedChange={async (checked) => {
|
||||
await updateRuleGroup({
|
||||
...row.original,
|
||||
enable: checked,
|
||||
} as API.UpdateRuleGroupRequest);
|
||||
ref.current?.refresh();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'icon',
|
||||
header: t('appIcon'),
|
||||
cell: ({ row }) =>
|
||||
row.getValue('icon') ? (
|
||||
<Image
|
||||
src={row.getValue('icon')}
|
||||
alt={row.getValue('name')}
|
||||
className='h-8 w-8 rounded-md'
|
||||
width={32}
|
||||
height={32}
|
||||
/>
|
||||
) : (
|
||||
<div className='bg-muted flex h-8 w-8 items-center justify-center rounded-md'>
|
||||
{row.original.name?.slice(0, 2)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
|
||||
{
|
||||
accessorKey: 'name',
|
||||
header: t('name'),
|
||||
},
|
||||
{
|
||||
accessorKey: 'tags',
|
||||
header: t('tags'),
|
||||
cell: ({ row }) => {
|
||||
const tags = row.original.tags.filter((item) => item) || [];
|
||||
if (!tags.length) return '--';
|
||||
return (
|
||||
<>
|
||||
{tags.map((tag) => (
|
||||
<Badge key={tag} variant='outline' className='mr-1'>
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'created_at',
|
||||
header: t('createdAt'),
|
||||
cell: ({ row }) => formatDate(row.original.created_at),
|
||||
},
|
||||
]}
|
||||
actions={{
|
||||
render: (row) => [
|
||||
<RuleForm<API.UpdateRuleGroupRequest>
|
||||
key='edit'
|
||||
trigger={t('edit')}
|
||||
title={t('editRule')}
|
||||
loading={loading}
|
||||
initialValues={row}
|
||||
onSubmit={async (values) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await updateRuleGroup({
|
||||
id: row.id,
|
||||
name: values.name,
|
||||
tags: values.tags,
|
||||
rules: values.rules,
|
||||
enable: row.enable,
|
||||
icon: values.icon,
|
||||
});
|
||||
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 deleteRuleGroup({ id: row.id });
|
||||
toast.success(t('deleteSuccess'));
|
||||
ref.current?.refresh();
|
||||
}}
|
||||
cancelText={t('cancel')}
|
||||
confirmText={t('confirm')}
|
||||
/>,
|
||||
],
|
||||
batchRender: (rows) => [
|
||||
<ConfirmButton
|
||||
key='delete'
|
||||
trigger={<Button variant='destructive'>{t('delete')}</Button>}
|
||||
title={t('confirmDelete')}
|
||||
description={t('deleteWarning')}
|
||||
onConfirm={async () => {
|
||||
for (const row of rows) {
|
||||
await deleteRuleGroup({ id: row.id });
|
||||
}
|
||||
toast.success(t('deleteSuccess'));
|
||||
ref.current?.reset();
|
||||
ref.current?.refresh();
|
||||
}}
|
||||
cancelText={t('cancel')}
|
||||
confirmText={t('confirm')}
|
||||
/>,
|
||||
],
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
'use client';
|
||||
|
||||
import { getNodeTagList } from '@/services/admin/server';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
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 { Textarea } from '@workspace/ui/components/textarea';
|
||||
import { Combobox } from '@workspace/ui/custom-components/combobox';
|
||||
import { EnhancedInput } from '@workspace/ui/custom-components/enhanced-input';
|
||||
import { Icon } from '@workspace/ui/custom-components/icon';
|
||||
import { UploadImage } from '@workspace/ui/custom-components/upload-image';
|
||||
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().min(1, { message: '请输入规则名称' }),
|
||||
tags: z.array(z.number()).default([]),
|
||||
rules: z.string().default(''),
|
||||
icon: z.string().default(''),
|
||||
});
|
||||
|
||||
interface RuleFormProps<T> {
|
||||
onSubmit: (data: T) => Promise<boolean> | boolean;
|
||||
initialValues?: T;
|
||||
loading?: boolean;
|
||||
trigger: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export default function RuleForm<T extends Record<string, any>>({
|
||||
onSubmit,
|
||||
initialValues,
|
||||
loading,
|
||||
trigger,
|
||||
title,
|
||||
}: RuleFormProps<T>) {
|
||||
const t = useTranslations('rules');
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const form = useForm({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
...initialValues,
|
||||
} as any,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (initialValues) {
|
||||
form.reset(initialValues);
|
||||
}
|
||||
}, [form, initialValues]);
|
||||
|
||||
async function handleSubmit(data: { [x: string]: any }) {
|
||||
const bool = await onSubmit(data as T);
|
||||
if (bool) setOpen(false);
|
||||
}
|
||||
|
||||
const { data: tags } = useQuery({
|
||||
queryKey: ['getNodeTagList'],
|
||||
queryFn: async () => {
|
||||
const { data } = await getNodeTagList();
|
||||
return data.data?.tags || [];
|
||||
},
|
||||
});
|
||||
|
||||
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(100vh-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='icon'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('appIcon')}</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
placeholder={t('enterIconUrl')}
|
||||
value={field.value}
|
||||
suffix={
|
||||
<UploadImage
|
||||
className='bg-muted h-9 rounded-none border-none px-2'
|
||||
onChange={(value) => {
|
||||
form.setValue(field.name, value as string);
|
||||
}}
|
||||
/>
|
||||
}
|
||||
onValueChange={(value) => {
|
||||
form.setValue(field.name, value);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='name'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('name')}</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
placeholder={t('enterRuleName')}
|
||||
value={field.value}
|
||||
onValueChange={(value) => {
|
||||
form.setValue(field.name, value);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='tags'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('tagsLabel')}</FormLabel>
|
||||
<FormControl>
|
||||
<Combobox<string, true>
|
||||
multiple
|
||||
placeholder={t('selectTags')}
|
||||
value={field.value}
|
||||
onChange={(value) => {
|
||||
form.setValue(field.name, value);
|
||||
}}
|
||||
options={tags?.map((item: string) => ({
|
||||
value: item,
|
||||
label: item,
|
||||
}))}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='rules'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('rulesLabel')}</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder={t('enterRules')}
|
||||
value={field.value}
|
||||
rows={10}
|
||||
onChange={(e) => {
|
||||
form.setValue(field.name, e.target.value);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<div className='text-muted-foreground mt-1 text-xs'>
|
||||
<pre>{t('rulesFormat')}</pre>
|
||||
<div className='border-muted mt-2 space-y-1 border-l-2 pl-2'>
|
||||
<p className='font-mono'>DOMAIN,example.com</p>
|
||||
<p className='font-mono'>DOMAIN-SUFFIX,google.com,DIRECT</p>
|
||||
<p className='font-mono'>DOMAIN-KEYWORD,amazon,REJECT</p>
|
||||
<p className='font-mono'>IP-CIDR,192.168.0.0/16</p>
|
||||
<p className='font-mono'>IP-CIDR6,2001:db8::/32,REJECT</p>
|
||||
<p className='font-mono'>SRC-IP-CIDR,192.168.1.201/32</p>
|
||||
<p className='font-mono'>GEOIP,CN,DIRECT</p>
|
||||
<p className='font-mono'>GEOIP,US</p>
|
||||
<p className='font-mono'>DST-PORT,80,DIRECT</p>
|
||||
<p className='font-mono'>SRC-PORT,7777,REJECT</p>
|
||||
<p className='font-mono'>PROCESS-NAME,telegram</p>
|
||||
<p className='font-mono'>RULE-SET,netflix</p>
|
||||
</div>
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
</ScrollArea>
|
||||
<SheetFooter className='flex-row justify-end gap-2 pt-3'>
|
||||
<Button
|
||||
variant='outline'
|
||||
disabled={loading}
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
{t('cancel')}
|
||||
</Button>
|
||||
<Button disabled={loading} onClick={form.handleSubmit(handleSubmit)}>
|
||||
{loading && <Icon icon='mdi:loading' className='mr-2 animate-spin' />}
|
||||
{t('confirm')}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,6 @@ import { getTranslations } from 'next-intl/server';
|
||||
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@workspace/ui/components/tabs';
|
||||
|
||||
import SubscribeApp from './app/table';
|
||||
import GroupTable from './group/table';
|
||||
import SubscribeConfig from './subscribe-config';
|
||||
import SubscribeTable from './subscribe-table';
|
||||
@@ -16,7 +15,6 @@ export default async function Page() {
|
||||
<TabsTrigger value='subscribe'>{t('tabs.subscribe')}</TabsTrigger>
|
||||
<TabsTrigger value='group'>{t('tabs.subscribeGroup')}</TabsTrigger>
|
||||
<TabsTrigger value='config'>{t('tabs.subscribeConfig')}</TabsTrigger>
|
||||
<TabsTrigger value='app'>{t('tabs.subscribeApp')}</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value='subscribe'>
|
||||
<SubscribeTable />
|
||||
@@ -27,9 +25,6 @@ export default async function Page() {
|
||||
<TabsContent value='config'>
|
||||
<SubscribeConfig />
|
||||
</TabsContent>
|
||||
<TabsContent value='app'>
|
||||
<SubscribeApp />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user