diff --git a/apps/admin/app/dashboard/subscribe/page.tsx b/apps/admin/app/dashboard/subscribe/page.tsx index 989125d..aa37b0e 100644 --- a/apps/admin/app/dashboard/subscribe/page.tsx +++ b/apps/admin/app/dashboard/subscribe/page.tsx @@ -3,6 +3,8 @@ import { getTranslations } from 'next-intl/server'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@workspace/ui/components/tabs'; import GroupTable from './group-table'; +import SubscribeApp from './subscribe-app'; +import SubscribeConfig from './subscribe-config'; import SubscribeTable from './subscribe-table'; export default async function Page() { @@ -13,6 +15,8 @@ export default async function Page() { {t('tabs.subscribe')} {t('tabs.subscribeGroup')} + {t('tabs.subscribeConfig')} + {t('tabs.subscribeApp')} @@ -20,6 +24,12 @@ export default async function Page() { + + + + + + ); } diff --git a/apps/admin/app/dashboard/subscribe/subscribe-app.tsx b/apps/admin/app/dashboard/subscribe/subscribe-app.tsx new file mode 100644 index 0000000..eda695a --- /dev/null +++ b/apps/admin/app/dashboard/subscribe/subscribe-app.tsx @@ -0,0 +1,386 @@ +'use client'; + +import { ProTable, ProTableActions } from '@/components/pro-table'; +import { + createApplication, + deleteApplication, + getApplication, + getSubscribeType, + updateApplication, +} from '@/services/admin/system'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { Icon } from '@iconify/react'; +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 { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@workspace/ui/components/select'; +import { + Sheet, + SheetContent, + SheetFooter, + SheetHeader, + SheetTitle, + SheetTrigger, +} from '@workspace/ui/components/sheet'; +import { ConfirmButton } from '@workspace/ui/custom-components/confirm-button'; +import { EnhancedInput } from '@workspace/ui/custom-components/enhanced-input'; +import { useTranslations } from 'next-intl'; +import Image from 'next/legacy/image'; +import { assign, shake } from 'radash'; +import { useEffect, useRef, useState } from 'react'; +import { useForm } from 'react-hook-form'; +import { toast } from 'sonner'; +import { z } from 'zod'; + +const defaultValues = { + platform: 'windows', + subscribe_type: 'Clash', + name: '', + icon: '', + url: '', +}; + +interface FormProps { + trigger: React.ReactNode | string; + title: string; + initialValues?: Partial; + onSubmit: (values: T) => Promise; + loading?: boolean; +} + +function SubscribeAppForm({ + trigger, + title, + loading, + initialValues, + onSubmit, +}: FormProps) { + const t = useTranslations('subscribe.app'); + const [open, setOpen] = useState(false); + + const formSchema = z.object({ + platform: z.enum(['windows', 'macos', 'linux', 'android', 'ios']), + name: z.string(), + subscribe_type: z.string(), + icon: z.string(), + url: z.string(), + }); + + type FormSchema = z.infer; + + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: assign( + defaultValues, + shake(initialValues, (value) => value === null), + ), + }); + + useEffect(() => { + form.reset( + assign( + defaultValues, + shake(initialValues, (value) => value === null), + ), + ); + }, [form, initialValues]); + + const { data: subscribe_types } = useQuery({ + queryKey: ['getSubscribeType'], + queryFn: async () => { + const { data } = await getSubscribeType(); + return data.data?.subscribe_types || []; + }, + }); + + return ( + + + {typeof trigger === 'string' ? : trigger} + + + + {title} + + +
+ + ( + + {t('platform')} + + + + + + )} + /> + + ( + + {t('subscriptionProtocol')} + + + + + + )} + /> + + ( + + {t('appName')} + + + + + + )} + /> + + ( + + {t('appIcon')} + + + + + + )} + /> + + ( + + {t('appDownloadURL')} + + + + + + )} + /> + + +
+ + + + +
+
+ ); +} + +export default function SubscribeApp() { + const t = useTranslations('subscribe.app'); + const [loading, setLoading] = useState(false); + const ref = useRef(null); + + return ( + + action={ref} + header={{ + toolbar: ( + + trigger={t('add')} + title={t('createApp')} + loading={loading} + onSubmit={async (values) => { + setLoading(true); + try { + await createApplication(values); + toast.success(t('createSuccess')); + ref.current?.refresh(); + setLoading(false); + return true; + } catch (error) { + setLoading(false); + return false; + } + }} + /> + ), + }} + params={[ + { + key: 'platform', + placeholder: t('platform'), + options: [ + { label: 'Windows', value: 'windows' }, + { label: 'MacOS', value: 'mac' }, + { label: 'Linux', value: 'linux' }, + { label: 'Android', value: 'android' }, + { label: 'iOS', value: 'ios' }, + ], + }, + ]} + request={async (_pagination, filters) => { + const { data } = await getApplication(); + const flatApps = Object.entries(data.data || {}).flatMap(([platform, apps]) => + (apps as API.Application[]).map((app) => ({ + ...app, + platform, + })), + ); + return { + list: filters.platform + ? flatApps.filter((app) => app.platform === filters.platform) + : flatApps, + total: 0, + }; + }} + columns={[ + { + accessorKey: 'platform', + header: t('platform'), + cell: ({ row }) => row.getValue('platform'), + }, + { + accessorKey: 'subscribe_type', + header: t('subscriptionProtocol'), + cell: ({ row }) => row.getValue('subscribe_type'), + }, + { + accessorKey: 'name', + header: t('appName'), + }, + { + accessorKey: 'icon', + header: t('appIcon'), + cell: ({ row }) => ( + {row.getValue('name')} + ), + }, + { + accessorKey: 'url', + header: t('appDownloadURL'), + }, + ]} + actions={{ + render: (row) => [ + + key='edit' + trigger={} + title={t('editApp')} + loading={loading} + initialValues={{ + ...row, + }} + onSubmit={async (values) => { + setLoading(true); + try { + await updateApplication({ + ...values, + id: row.id, + }); + toast.success(t('updateSuccess')); + ref.current?.refresh(); + setLoading(false); + return true; + } catch (error) { + setLoading(false); + return false; + } + }} + />, + {t('delete')}} + title={t('confirmDelete')} + description={t('deleteWarning')} + onConfirm={async () => { + await deleteApplication({ id: row.id! }); + toast.success(t('deleteSuccess')); + ref.current?.refresh(); + }} + cancelText={t('cancel')} + confirmText={t('confirm')} + />, + ], + batchRender: (rows) => [ + {t('batchDelete')}} + title={t('confirmDelete')} + description={t('deleteWarning')} + onConfirm={async () => { + await Promise.all(rows.map((row) => deleteApplication({ id: row.id! }))); + toast.success(t('deleteSuccess')); + ref.current?.reset(); + }} + cancelText={t('cancel')} + confirmText={t('confirm')} + />, + ], + }} + /> + ); +} diff --git a/apps/admin/app/dashboard/subscribe/subscribe-config.tsx b/apps/admin/app/dashboard/subscribe/subscribe-config.tsx new file mode 100644 index 0000000..433ef14 --- /dev/null +++ b/apps/admin/app/dashboard/subscribe/subscribe-config.tsx @@ -0,0 +1,106 @@ +'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('saveSuccess')); + refetch(); + } catch (error) { + /* empty */ + } + } + + return ( + + + + + +

+ {t('singleSubscriptionModeDescription')} +

+
+ + { + updateConfig('single_model', checked); + }} + /> + +
+ + + + +

{t('wildcardResolutionDescription')}

+
+ + { + updateConfig('pan_domain', checked); + }} + /> + +
+ + + +

{t('subscriptionPathDescription')}

+
+ + updateConfig('subscribe_path', value)} + /> + +
+ + + +

{t('subscriptionDomainDescription')}

+
+ +