Compare commits

...

26 Commits

Author SHA1 Message Date
semantic-release-bot a7936de6dc 🔖 chore(release): v1.3.0 [skip ci]
# [1.3.0](https://github.com/perfect-panel/ppanel-web/compare/v1.2.0...v1.3.0) (2025-08-15)

### ♻ Code Refactoring

* Refactoring and adding multiple features ([65c9b9f](https://github.com/perfect-panel/ppanel-web/commit/65c9b9f))

###  Features

* **api**: Add getClient API endpoint to retrieve subscription applications ([7a279e6](https://github.com/perfect-panel/ppanel-web/commit/7a279e6))
* **marketing**: Add marketing management features and localization updates ([ea08de0](https://github.com/perfect-panel/ppanel-web/commit/ea08de0))
* **protocol**: Add template preview functionality with localization support ([0448d21](https://github.com/perfect-panel/ppanel-web/commit/0448d21))
* **subscribe**: Update subscription management localization and add new fields ([1d9b0a4](https://github.com/perfect-panel/ppanel-web/commit/1d9b0a4))

### 🐛 Bug Fixes

* **bun**: Update bun.lockb to reflect dependency changes ([bbcd018](https://github.com/perfect-panel/ppanel-web/commit/bbcd018))
* **editor**: Add Go template editor component and update related schemas ([9d9c3cd](https://github.com/perfect-panel/ppanel-web/commit/9d9c3cd))
* **editor**: Enhance Go Template Editor to support trimmed template tags and improve range/end matching ([641ed5e](https://github.com/perfect-panel/ppanel-web/commit/641ed5e))
* **editor**: Enhance Go Template Editor with schema support and improved completion ([5b21d8a](https://github.com/perfect-panel/ppanel-web/commit/5b21d8a))
* **enhaced-input**: Disable autocomplete for EnhancedInput component ([f190c68](https://github.com/perfect-panel/ppanel-web/commit/f190c68))
* **global**: Add user agent limit settings to subscription configuration ([822416d](https://github.com/perfect-panel/ppanel-web/commit/822416d))
* **locales**: Update 'conf' output format to use uppercase 'CONF' ([fce9119](https://github.com/perfect-panel/ppanel-web/commit/fce9119))
* **locales**: Update "userAccount" label to "user" in multiple localization files ([48415e9](https://github.com/perfect-panel/ppanel-web/commit/48415e9))
* **protocol-form**: Swap 'description' and 'user_agent' columns for improved clarity in the table ([72a4106](https://github.com/perfect-panel/ppanel-web/commit/72a4106))
* **protocol-form**: Update protocol options descriptions for clarity and add new security and transport options ([e5d4deb](https://github.com/perfect-panel/ppanel-web/commit/e5d4deb))
* **protocol**: Add 'conf' output format option and update translations ([292efdf](https://github.com/perfect-panel/ppanel-web/commit/292efdf))
* **protpcp-form**: Rename 'schema' to 'scheme' for consistency across the application ([6ab2ba9](https://github.com/perfect-panel/ppanel-web/commit/6ab2ba9))
* **register**: Update localization files to include trial subscription settings and descriptions ([33daa1f](https://github.com/perfect-panel/ppanel-web/commit/33daa1f))
* **system**: Add time unit translations for user registration settings in multiple languages ([296a6c1](https://github.com/perfect-panel/ppanel-web/commit/296a6c1))
* Update privacy policy and terms of service schemas to use correct field names ([0e6ba5b](https://github.com/perfect-panel/ppanel-web/commit/0e6ba5b))
2025-08-15 16:47:17 +00:00
web 72a4106022 🐛 fix(protocol-form): Swap 'description' and 'user_agent' columns for improved clarity in the table 2025-08-15 09:43:02 -07:00
web 6ab2ba92a0 🐛 fix(protpcp-form): Rename 'schema' to 'scheme' for consistency across the application 2025-08-15 04:30:20 -07:00
web e5d4deb346 🐛 fix(protocol-form): Update protocol options descriptions for clarity and add new security and transport options 2025-08-14 10:13:12 -07:00
web 0448d213a3 feat(protocol): Add template preview functionality with localization support 2025-08-14 09:55:13 -07:00
web f190c68eb0 🐛 fix(enhaced-input): Disable autocomplete for EnhancedInput component 2025-08-13 07:39:28 -07:00
web 7a279e6e30 feat(api): Add getClient API endpoint to retrieve subscription applications 2025-08-13 07:35:58 -07:00
web 641ed5ec36 🐛 fix(editor): Enhance Go Template Editor to support trimmed template tags and improve range/end matching 2025-08-12 12:44:49 -07:00
web 5b21d8a7bc 🐛 fix(editor): Enhance Go Template Editor with schema support and improved completion 2025-08-12 12:32:00 -07:00
web fce9119567 🐛 fix(locales): Update 'conf' output format to use uppercase 'CONF' 2025-08-12 06:04:00 -07:00
web 292efdf1d8 🐛 fix(protocol): Add 'conf' output format option and update translations 2025-08-12 05:58:25 -07:00
web 9d9c3cd7b3 🐛 fix(editor): Add Go template editor component and update related schemas 2025-08-12 05:47:58 -07:00
web 296a6c10a3 🐛 fix(system): Add time unit translations for user registration settings in multiple languages 2025-08-11 04:06:39 -07:00
web 822416d22d 🐛 fix(global): Add user agent limit settings to subscription configuration 2025-08-11 03:34:14 -07:00
web bbcd018aeb 🐛 fix(bun): Update bun.lockb to reflect dependency changes 2025-08-11 03:28:14 -07:00
web 0e6ba5b081 🐛 fix: Update privacy policy and terms of service schemas to use correct field names 2025-08-10 08:10:49 -07:00
web 33daa1fb73 🐛 fix(register): Update localization files to include trial subscription settings and descriptions 2025-08-09 19:04:59 -07:00
web 1d9b0a4e06 feat(subscribe): Update subscription management localization and add new fields 2025-08-08 11:22:31 -07:00
web ea08de0aa2 feat(marketing): Add marketing management features and localization updates 2025-08-08 08:59:19 -07:00
web 65c9b9f64f ♻️ refactor: Refactoring and adding multiple features 2025-08-05 06:42:39 -07:00
web 48415e9a30 🐛 fix(locales): Update "userAccount" label to "user" in multiple localization files 2025-08-04 09:17:04 -07:00
semantic-release-bot 42671a5974 🔖 chore(release): v1.2.0 [skip ci]
# [1.2.0](https://github.com/perfect-panel/ppanel-web/compare/v1.1.5...v1.2.0) (2025-08-04)

### ♻ Code Refactoring

* **view**: System and Auth Control ([b2b4a95](https://github.com/perfect-panel/ppanel-web/commit/b2b4a95))

###  Features

* **netlify**: Add Netlify configuration for admin and user apps with Next.js plugin ([b4d4f59](https://github.com/perfect-panel/ppanel-web/commit/b4d4f59))
2025-08-04 16:02:48 +00:00
web b2b4a95250 ♻️ refactor(view): System and Auth Control 2025-08-04 08:58:57 -07:00
web b4d4f59c71 feat(netlify): Add Netlify configuration for admin and user apps with Next.js plugin 2025-07-29 05:38:09 -07:00
semantic-release-bot 7f3cac3c1c 🔖 chore(release): v1.1.5 [skip ci]
## [1.1.5](https://github.com/perfect-panel/ppanel-web/compare/v1.1.4...v1.1.5) (2025-07-26)

### 🐛 Bug Fixes

* **subscribe**: Filter out items that are not marked as visible in subscription list ([32253e3](https://github.com/perfect-panel/ppanel-web/commit/32253e3))
2025-07-26 10:55:38 +00:00
web 32253e3717 🐛 fix(subscribe): Filter out items that are not marked as visible in subscription list 2025-07-26 03:49:53 -07:00
483 changed files with 26556 additions and 13972 deletions
+65 -10
View File
@@ -1,30 +1,85 @@
<a name="readme-top"></a>
# Changelog
## [1.1.4](https://github.com/perfect-panel/ppanel-web/compare/v1.1.3...v1.1.4) (2025-07-25)
# [1.3.0](https://github.com/perfect-panel/ppanel-web/compare/v1.2.0...v1.3.0) (2025-08-15)
### ♻ Code Refactoring
* Refactoring and adding multiple features ([65c9b9f](https://github.com/perfect-panel/ppanel-web/commit/65c9b9f))
### ✨ Features
* **api**: Add getClient API endpoint to retrieve subscription applications ([7a279e6](https://github.com/perfect-panel/ppanel-web/commit/7a279e6))
* **marketing**: Add marketing management features and localization updates ([ea08de0](https://github.com/perfect-panel/ppanel-web/commit/ea08de0))
* **protocol**: Add template preview functionality with localization support ([0448d21](https://github.com/perfect-panel/ppanel-web/commit/0448d21))
* **subscribe**: Update subscription management localization and add new fields ([1d9b0a4](https://github.com/perfect-panel/ppanel-web/commit/1d9b0a4))
### 🐛 Bug Fixes
* **locales**: Simplify "show" label in subscription localization files ([d53a006](https://github.com/perfect-panel/ppanel-web/commit/d53a006))
* **order**: Preserve last successful order on error during order creation ([2fb98be](https://github.com/perfect-panel/ppanel-web/commit/2fb98be))
* **subscribe**: Filter out hidden items in subscription list display ([634be37](https://github.com/perfect-panel/ppanel-web/commit/634be37))
* **bun**: Update bun.lockb to reflect dependency changes ([bbcd018](https://github.com/perfect-panel/ppanel-web/commit/bbcd018))
* **editor**: Add Go template editor component and update related schemas ([9d9c3cd](https://github.com/perfect-panel/ppanel-web/commit/9d9c3cd))
* **editor**: Enhance Go Template Editor to support trimmed template tags and improve range/end matching ([641ed5e](https://github.com/perfect-panel/ppanel-web/commit/641ed5e))
* **editor**: Enhance Go Template Editor with schema support and improved completion ([5b21d8a](https://github.com/perfect-panel/ppanel-web/commit/5b21d8a))
* **enhaced-input**: Disable autocomplete for EnhancedInput component ([f190c68](https://github.com/perfect-panel/ppanel-web/commit/f190c68))
* **global**: Add user agent limit settings to subscription configuration ([822416d](https://github.com/perfect-panel/ppanel-web/commit/822416d))
* **locales**: Update 'conf' output format to use uppercase 'CONF' ([fce9119](https://github.com/perfect-panel/ppanel-web/commit/fce9119))
* **locales**: Update "userAccount" label to "user" in multiple localization files ([48415e9](https://github.com/perfect-panel/ppanel-web/commit/48415e9))
* **protocol-form**: Swap 'description' and 'user_agent' columns for improved clarity in the table ([72a4106](https://github.com/perfect-panel/ppanel-web/commit/72a4106))
* **protocol-form**: Update protocol options descriptions for clarity and add new security and transport options ([e5d4deb](https://github.com/perfect-panel/ppanel-web/commit/e5d4deb))
* **protocol**: Add 'conf' output format option and update translations ([292efdf](https://github.com/perfect-panel/ppanel-web/commit/292efdf))
* **protpcp-form**: Rename 'schema' to 'scheme' for consistency across the application ([6ab2ba9](https://github.com/perfect-panel/ppanel-web/commit/6ab2ba9))
* **register**: Update localization files to include trial subscription settings and descriptions ([33daa1f](https://github.com/perfect-panel/ppanel-web/commit/33daa1f))
* **system**: Add time unit translations for user registration settings in multiple languages ([296a6c1](https://github.com/perfect-panel/ppanel-web/commit/296a6c1))
* Update privacy policy and terms of service schemas to use correct field names ([0e6ba5b](https://github.com/perfect-panel/ppanel-web/commit/0e6ba5b))
<a name="readme-top"></a>
# Changelog
# [1.2.0](https://github.com/perfect-panel/ppanel-web/compare/v1.1.5...v1.2.0) (2025-08-04)
### ♻ Code Refactoring
- **view**: System and Auth Control ([b2b4a95](https://github.com/perfect-panel/ppanel-web/commit/b2b4a95))
### ✨ Features
- **netlify**: Add Netlify configuration for admin and user apps with Next.js plugin ([b4d4f59](https://github.com/perfect-panel/ppanel-web/commit/b4d4f59))
<a name="readme-top"></a>
# Changelog
## [1.1.5](https://github.com/perfect-panel/ppanel-web/compare/v1.1.4...v1.1.5) (2025-07-26)
### 🐛 Bug Fixes
- **subscribe**: Filter out items that are not marked as visible in subscription list ([32253e3](https://github.com/perfect-panel/ppanel-web/commit/32253e3))
## [1.1.4](https://github.com/perfect-panel/ppanel-web/compare/v1.1.3...v1.1.4) (2025-07-25)
### 🐛 Bug Fixes
- **locales**: Simplify "show" label in subscription localization files ([d53a006](https://github.com/perfect-panel/ppanel-web/commit/d53a006))
- **order**: Preserve last successful order on error during order creation ([2fb98be](https://github.com/perfect-panel/ppanel-web/commit/2fb98be))
- **subscribe**: Filter out hidden items in subscription list display ([634be37](https://github.com/perfect-panel/ppanel-web/commit/634be37))
## [1.1.3](https://github.com/perfect-panel/ppanel-web/compare/v1.1.2...v1.1.3) (2025-07-24)
### 🐛 Bug Fixes
* **auth**: Implement user redirection to dashboard upon authentication ([f84f98c](https://github.com/perfect-panel/ppanel-web/commit/f84f98c))
- **auth**: Implement user redirection to dashboard upon authentication ([f84f98c](https://github.com/perfect-panel/ppanel-web/commit/f84f98c))
## [1.1.2](https://github.com/perfect-panel/ppanel-web/compare/v1.1.1...v1.1.2) (2025-07-24)
### 🐛 Bug Fixes
* **billing**: Add display for gift amount in subscription billing ([04af2f9](https://github.com/perfect-panel/ppanel-web/commit/04af2f9))
* **order**: Update subscription cell to display name and quantity ([96eba17](https://github.com/perfect-panel/ppanel-web/commit/96eba17))
* **tool**: Added API for obtaining version, updated version information display logic ([2675034](https://github.com/perfect-panel/ppanel-web/commit/2675034))
- **billing**: Add display for gift amount in subscription billing ([04af2f9](https://github.com/perfect-panel/ppanel-web/commit/04af2f9))
- **order**: Update subscription cell to display name and quantity ([96eba17](https://github.com/perfect-panel/ppanel-web/commit/96eba17))
- **tool**: Added API for obtaining version, updated version information display logic ([2675034](https://github.com/perfect-panel/ppanel-web/commit/2675034))
<a name="readme-top"></a>
+3 -3
View File
@@ -46,9 +46,6 @@ export default function Page() {
),
}}
params={[
{
key: 'search',
},
{
key: 'status',
placeholder: t('status'),
@@ -57,6 +54,9 @@ export default function Page() {
{ label: t('disabled'), value: '0' },
],
},
{
key: 'search',
},
]}
request={async (pagination, filters) => {
const { data } = await getAdsList({
@@ -81,9 +81,13 @@ export default function AnnouncementForm<T extends Record<string, any>>({
<SheetHeader>
<SheetTitle>{title}</SheetTitle>
</SheetHeader>
<ScrollArea className='-mx-6 h-[calc(100vh-48px-36px-36px-env(safe-area-inset-top))]'>
<ScrollArea className='-mx-6 h-[calc(100vh-48px-36px-36px-env(safe-area-inset-top))] px-6'>
<Form {...form}>
<form onSubmit={form.handleSubmit(handleSubmit)} className='space-y-4 px-6 pt-4'>
<form
id='notice-form'
onSubmit={form.handleSubmit(handleSubmit)}
className='space-y-4 pt-4'
>
<FormField
control={form.control}
name='title'
@@ -51,7 +51,7 @@ const formSchema = z.object({
type FormSchema = z.infer<typeof formSchema>;
export default function ConfigForm() {
const t = useTranslations('subscribe.app');
const t = useTranslations('product.app');
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
@@ -80,7 +80,7 @@ interface FormProps<T> {
export default function SubscribeAppForm<
T extends API.CreateApplicationRequest | API.UpdateApplicationRequest,
>({ trigger, title, loading, initialValues, onSubmit }: FormProps<T>) {
const t = useTranslations('subscribe.app');
const t = useTranslations('product.app');
const [open, setOpen] = useState(false);
type FormSchema = z.infer<typeof formSchema>;
@@ -17,7 +17,7 @@ import ConfigForm from './config';
import SubscribeAppForm from './form';
export default function SubscribeApp() {
const t = useTranslations('subscribe.app');
const t = useTranslations('product.app');
const [loading, setLoading] = useState(false);
const ref = useRef<ProTableActions>(null);
@@ -1,148 +0,0 @@
'use client';
import { getAuthMethodConfig, updateAuthMethodConfig } from '@/services/admin/authMethod';
import { useQuery } from '@tanstack/react-query';
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';
import { useTranslations } from 'next-intl';
import { toast } from 'sonner';
export default function Page() {
const t = useTranslations('apple');
const { data, refetch } = useQuery({
queryKey: ['getAuthMethodConfig', 'apple'],
queryFn: async () => {
const { data } = await getAuthMethodConfig({
method: 'apple',
});
return data.data;
},
});
async function updateConfig(key: keyof API.UpdateAuthMethodConfigRequest, value: unknown) {
try {
await updateAuthMethodConfig({
...data,
[key]: value,
} as API.UpdateAuthMethodConfigRequest);
toast.success(t('saveSuccess'));
refetch();
} catch (error) {
toast.error(t('saveFailed'));
}
}
return (
<Table>
<TableBody>
<TableRow>
<TableCell>
<Label>{t('enable')}</Label>
<p className='text-muted-foreground text-xs'>{t('enableDescription')}</p>
</TableCell>
<TableCell className='text-right'>
<Switch
checked={data?.enabled}
onCheckedChange={(checked) => updateConfig('enabled', checked)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Label>{t('teamId')}</Label>
<p className='text-muted-foreground text-xs'>{t('teamIdDescription')}</p>
</TableCell>
<TableCell className='text-right'>
<EnhancedInput
placeholder='ABCDE1FGHI'
value={data?.config?.team_id}
onValueBlur={(value) => {
updateConfig('config', {
...data?.config,
team_id: value,
});
}}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Label>{t('keyId')}</Label>
<p className='text-muted-foreground text-xs'>{t('keyIdDescription')}</p>
</TableCell>
<TableCell className='text-right'>
<EnhancedInput
placeholder='ABC1234567'
value={data?.config?.key_id}
onValueBlur={(value) => {
updateConfig('config', {
...data?.config,
key_id: value,
});
}}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Label>{t('clientId')}</Label>
<p className='text-muted-foreground text-xs'>{t('clientIdDescription')}</p>
</TableCell>
<TableCell className='text-right'>
<EnhancedInput
placeholder='com.your.app.service'
value={data?.config?.client_id}
onValueBlur={(value) => {
updateConfig('config', {
...data?.config,
client_id: value,
});
}}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell className='align-top'>
<Label>{t('clientSecret')}</Label>
<p className='text-muted-foreground text-xs'>{t('clientSecretDescription')}</p>
</TableCell>
<TableCell className='text-right'>
<Textarea
className='h-20'
placeholder={`-----BEGIN PRIVATE KEY-----\nMIGTAgEA...\n-----END PRIVATE KEY-----`}
defaultValue={data?.config?.client_secret}
onBlur={(e) => {
updateConfig('config', {
...data?.config,
client_secret: e.target.value,
});
}}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Label>{t('redirectUri')}</Label>
<p className='text-muted-foreground text-xs'>{t('redirectUriDescription')}</p>
</TableCell>
<TableCell className='text-right'>
<EnhancedInput
placeholder='https://your-domain.com'
value={data?.config.redirect_url}
onValueBlur={(value) =>
updateConfig('config', {
...data?.config,
redirect_url: value,
})
}
/>
</TableCell>
</TableRow>
</TableBody>
</Table>
);
}
@@ -1,141 +0,0 @@
'use client';
import { getAuthMethodConfig, updateAuthMethodConfig } from '@/services/admin/authMethod';
import { useQuery } from '@tanstack/react-query';
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 { EnhancedInput } from '@workspace/ui/custom-components/enhanced-input';
import { DicesIcon } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { uid } from 'radash';
import { toast } from 'sonner';
export default function Page() {
const t = useTranslations('device');
const { data, refetch } = useQuery({
queryKey: ['getAuthMethodConfig', 'device'],
queryFn: async () => {
const { data } = await getAuthMethodConfig({
method: 'device',
});
return data.data;
},
});
async function updateConfig(key: keyof API.UpdateAuthMethodConfigRequest, value: unknown) {
try {
await updateAuthMethodConfig({
...data,
[key]: value,
} as API.UpdateAuthMethodConfigRequest);
toast.success(t('saveSuccess'));
refetch();
} catch (error) {
toast.error(t('saveFailed'));
}
}
return (
<Table>
<TableBody>
<TableRow>
<TableCell>
<Label>{t('enable')}</Label>
<p className='text-muted-foreground text-xs'>{t('enableDescription')}</p>
</TableCell>
<TableCell className='text-right'>
<Switch
checked={data?.enabled}
onCheckedChange={(checked) => updateConfig('enabled', checked)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Label>{t('showAds')}</Label>
<p className='text-muted-foreground text-xs'>{t('showAdsDescription')}</p>
</TableCell>
<TableCell className='text-right'>
<Switch
checked={data?.config?.show_ads}
onCheckedChange={(checked) => {
updateConfig('config', {
...data?.config,
show_ads: checked,
});
}}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Label>{t('blockVirtualMachine')}</Label>
<p className='text-muted-foreground text-xs'>{t('blockVirtualMachineDescription')}</p>
</TableCell>
<TableCell className='text-right'>
<Switch
checked={data?.config?.only_real_device}
onCheckedChange={(checked) => {
updateConfig('config', {
...data?.config,
only_real_device: checked,
});
}}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Label>{t('enableSecurity')}</Label>
<p className='text-muted-foreground text-xs'>{t('enableSecurityDescription')}</p>
</TableCell>
<TableCell className='text-right'>
<Switch
checked={data?.config?.enable_security}
onCheckedChange={(checked) => {
updateConfig('config', {
...data?.config,
enable_security: checked,
});
}}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Label>{t('communicationKey')}</Label>
<p className='text-muted-foreground text-xs'>{t('communicationKeyDescription')}</p>
</TableCell>
<TableCell className='text-right'>
<EnhancedInput
value={data?.config?.security_secret}
onValueBlur={(value) => {
updateConfig('config', {
...data?.config,
security_secret: value,
});
}}
suffix={
<div className='bg-muted flex h-9 items-center text-nowrap px-3'>
<DicesIcon
onClick={() => {
const id = uid(32).toLowerCase();
const formatted = `${id.slice(0, 8)}-${id.slice(8, 12)}-${id.slice(12, 16)}-${id.slice(16, 20)}-${id.slice(20)}`;
updateConfig('config', {
...data?.config,
security_secret: formatted,
});
}}
className='cursor-pointer'
/>
</div>
}
/>
</TableCell>
</TableRow>
</TableBody>
</Table>
);
}
@@ -1,325 +0,0 @@
'use client';
import {
getAuthMethodConfig,
testEmailSend,
updateAuthMethodConfig,
} from '@/services/admin/authMethod';
import { useQuery } from '@tanstack/react-query';
import { Button } from '@workspace/ui/components/button';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@workspace/ui/components/card';
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 { Tabs, TabsContent, TabsList, TabsTrigger } from '@workspace/ui/components/tabs';
import { Textarea } from '@workspace/ui/components/textarea';
import { HTMLEditor } from '@workspace/ui/custom-components/editor';
import { EnhancedInput } from '@workspace/ui/custom-components/enhanced-input';
import { useTranslations } from 'next-intl';
import { useRef, useState } from 'react';
import { toast } from 'sonner';
import { LogsTable } from '../log';
export default function Page() {
const t = useTranslations('email');
const ref = useRef<Partial<API.AuthMethodConfig>>({});
const [email, setEmail] = useState<string>();
const { data, refetch, isFetching } = useQuery({
queryKey: ['getAuthMethodConfig', 'email'],
queryFn: async () => {
const { data } = await getAuthMethodConfig({
method: 'email',
});
ref.current = data.data as API.AuthMethodConfig;
return data.data;
},
});
async function updateConfig(key: string, value: unknown) {
if (data?.[key] === value) return;
try {
await updateAuthMethodConfig({
...ref.current,
[key]: value,
} as API.UpdateAuthMethodConfigRequest);
toast.success(t('saveSuccess'));
refetch();
} catch (error) {
toast.error(t('saveFailed'));
}
}
return (
<Tabs defaultValue='settings'>
<TabsList className='h-full flex-wrap'>
<TabsTrigger value='settings'>{t('settings')}</TabsTrigger>
<TabsTrigger value='template'>{t('template')}</TabsTrigger>
<TabsTrigger value='logs'>{t('logs')}</TabsTrigger>
</TabsList>
<TabsContent value='settings'>
<Table>
<TableBody>
<TableRow>
<TableCell>
<Label>{t('enable')}</Label>
<p className='text-muted-foreground text-xs'>{t('enableDescription')}</p>
</TableCell>
<TableCell className='text-right'>
<Switch
checked={data?.enabled}
onCheckedChange={(checked) => updateConfig('enabled', checked)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Label>{t('emailVerification')}</Label>
<p className='text-muted-foreground text-xs'>{t('emailVerificationDescription')}</p>
</TableCell>
<TableCell className='text-right'>
<Switch
checked={data?.config?.enable_verify}
onCheckedChange={(checked) =>
updateConfig('config', { ...data?.config, enable_verify: checked })
}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Label>{t('emailSuffixWhitelist')}</Label>
<p className='text-muted-foreground text-xs'>
{t('emailSuffixWhitelistDescription')}
</p>
</TableCell>
<TableCell className='text-right'>
<Switch
checked={data?.config?.enable_domain_suffix}
onCheckedChange={(checked) =>
updateConfig('config', { ...data?.config, enable_domain_suffix: checked })
}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell className='align-top'>
<Label>{t('whitelistSuffixes')}</Label>
<p className='text-muted-foreground text-xs'>{t('whitelistSuffixesDescription')}</p>
</TableCell>
<TableCell className='text-right'>
<Textarea
className='h-32'
placeholder={t('whitelistSuffixesPlaceholder')}
defaultValue={data?.config?.domain_suffix_list}
onBlur={(e) =>
updateConfig('config', { ...data?.config, domain_suffix_list: e.target.value })
}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Label>{t('smtpServerAddress')}</Label>
<p className='text-muted-foreground text-xs'>{t('smtpServerAddressDescription')}</p>
</TableCell>
<TableCell className='text-right'>
<EnhancedInput
placeholder={t('inputPlaceholder')}
value={data?.config?.platform_config?.host}
onValueBlur={(value) =>
updateConfig('config', {
...data?.config,
platform_config: {
...data?.platform_config,
host: value,
},
})
}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Label>{t('smtpServerPort')}</Label>
<p className='text-muted-foreground text-xs'>{t('smtpServerPortDescription')}</p>
</TableCell>
<TableCell className='text-right'>
<EnhancedInput
placeholder={t('inputPlaceholder')}
value={data?.config?.platform_config?.port}
type='number'
onValueBlur={(value) =>
updateConfig('config', {
...data?.config,
platform_config: {
...ref.current?.config?.platform_config,
port: value,
},
})
}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Label>{t('smtpEncryptionMethod')}</Label>
<p className='text-muted-foreground text-xs'>
{t('smtpEncryptionMethodDescription')}
</p>
</TableCell>
<TableCell className='text-right'>
<Switch
checked={data?.config?.platform_config?.ssl}
onCheckedChange={(checked) =>
updateConfig('config', {
...data?.config,
platform_config: {
...ref.current?.config?.platform_config,
ssl: checked,
},
})
}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Label>{t('smtpAccount')}</Label>
<p className='text-muted-foreground text-xs'>{t('smtpAccountDescription')}</p>
</TableCell>
<TableCell className='text-right'>
<EnhancedInput
placeholder={t('inputPlaceholder')}
value={data?.config?.platform_config?.user}
onValueBlur={(value) =>
updateConfig('config', {
...data?.config,
platform_config: {
...ref.current?.config?.platform_config,
user: value,
},
})
}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Label>{t('smtpPassword')}</Label>
<p className='text-muted-foreground text-xs'>{t('smtpPasswordDescription')}</p>
</TableCell>
<TableCell className='text-right'>
<EnhancedInput
placeholder={t('inputPlaceholder')}
value={data?.config?.platform_config?.pass}
type='password'
onValueBlur={(value) =>
updateConfig('config', {
...data?.config,
platform_config: {
...ref.current?.config?.platform_config,
pass: value,
},
})
}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Label>{t('senderAddress')}</Label>
<p className='text-muted-foreground text-xs'>{t('senderAddressDescription')}</p>
</TableCell>
<TableCell className='text-right'>
<EnhancedInput
placeholder={t('inputPlaceholder')}
value={data?.config?.platform_config?.from}
onValueBlur={(value) =>
updateConfig('config', {
...data?.config,
platform_config: {
...ref.current?.config?.platform_config,
from: value,
},
})
}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Label>{t('sendTestEmail')}</Label>
<p className='text-muted-foreground text-xs'>{t('sendTestEmailDescription')}</p>
</TableCell>
<TableCell className='flex items-center gap-2 text-right'>
<EnhancedInput
placeholder='test@example.com'
value={email}
type='email'
onValueChange={(value) => setEmail(value as string)}
/>
<Button
disabled={!email || isFetching}
onClick={async () => {
if (!email) return;
try {
await testEmailSend({ email });
toast.success(t('sendSuccess'));
} catch {
toast.error(t('sendFailure'));
}
}}
>
{t('sendTestEmail')}
</Button>
</TableCell>
</TableRow>
</TableBody>
</Table>
</TabsContent>
<TabsContent value='template'>
<div className='grid grid-cols-1 gap-4 md:grid-cols-2'>
{[
'verify_email_template',
'expiration_email_template',
'maintenance_email_template',
'traffic_exceed_email_template',
].map((templateKey) => (
<Card key={templateKey}>
<CardHeader>
<CardTitle>{t(`${templateKey}`)}</CardTitle>
<CardDescription>
{t(`${templateKey}Description`, { after: '{{', before: '}}' })}
</CardDescription>
</CardHeader>
<CardContent>
<HTMLEditor
placeholder={t('inputPlaceholder')}
value={data?.config?.[templateKey] as string}
onBlur={(value) =>
updateConfig('config', {
...data?.config,
[templateKey]: value,
})
}
/>
</CardContent>
</Card>
))}
</div>
</TabsContent>
<TabsContent value='logs'>
<LogsTable type='email' />
</TabsContent>
</Tabs>
);
}
@@ -1,92 +0,0 @@
'use client';
import { getAuthMethodConfig, updateAuthMethodConfig } from '@/services/admin/authMethod';
import { useQuery } from '@tanstack/react-query';
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 { EnhancedInput } from '@workspace/ui/custom-components/enhanced-input';
import { useTranslations } from 'next-intl';
import { toast } from 'sonner';
export default function Page() {
const t = useTranslations('facebook');
const { data, refetch } = useQuery({
queryKey: ['getAuthMethodConfig', 'facebook'],
queryFn: async () => {
const { data } = await getAuthMethodConfig({
method: 'facebook',
});
return data.data;
},
});
async function updateConfig(key: keyof API.UpdateAuthMethodConfigRequest, value: unknown) {
try {
await updateAuthMethodConfig({
...data,
[key]: value,
} as API.UpdateAuthMethodConfigRequest);
toast.success(t('saveSuccess'));
refetch();
} catch (error) {
toast.error(t('saveFailed'));
}
}
return (
<Table>
<TableBody>
<TableRow>
<TableCell>
<Label>{t('enable')}</Label>
<p className='text-muted-foreground text-xs'>{t('enableDescription')}</p>
</TableCell>
<TableCell className='text-right'>
<Switch
checked={data?.enabled}
onCheckedChange={(checked) => updateConfig('enabled', checked)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Label>{t('clientId')}</Label>
<p className='text-muted-foreground text-xs'>{t('clientIdDescription')}</p>
</TableCell>
<TableCell className='text-right'>
<EnhancedInput
placeholder='1234567890123456'
value={data?.config?.client_id}
onValueBlur={(value) => {
updateConfig('config', {
...data?.config,
client_id: value,
});
}}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell className='align-top'>
<Label>{t('clientSecret')}</Label>
<p className='text-muted-foreground text-xs'>{t('clientSecretDescription')}</p>
</TableCell>
<TableCell className='text-right'>
<EnhancedInput
placeholder='1234567890abcdef1234567890abcdef'
value={data?.config?.client_secret}
onValueBlur={(value) => {
updateConfig('config', {
...data?.config,
client_secret: value,
});
}}
/>
</TableCell>
</TableRow>
</TableBody>
</Table>
);
}
@@ -0,0 +1,268 @@
'use client';
import { getAuthMethodConfig, updateAuthMethodConfig } from '@/services/admin/authMethod';
import { zodResolver } from '@hookform/resolvers/zod';
import { useQuery } from '@tanstack/react-query';
import { Button } from '@workspace/ui/components/button';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@workspace/ui/components/form';
import { ScrollArea } from '@workspace/ui/components/scroll-area';
import {
Sheet,
SheetContent,
SheetFooter,
SheetHeader,
SheetTitle,
SheetTrigger,
} from '@workspace/ui/components/sheet';
import { Switch } from '@workspace/ui/components/switch';
import { Textarea } from '@workspace/ui/components/textarea';
import { EnhancedInput } from '@workspace/ui/custom-components/enhanced-input';
import { Icon } from '@workspace/ui/custom-components/icon';
import { useTranslations } from 'next-intl';
import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { toast } from 'sonner';
import { z } from 'zod';
const appleSchema = z.object({
enabled: z.boolean(),
config: z
.object({
team_id: z.string().optional(),
key_id: z.string().optional(),
client_id: z.string().optional(),
client_secret: z.string().optional(),
redirect_url: z.string().optional(),
})
.optional(),
});
type AppleFormData = z.infer<typeof appleSchema>;
export default function AppleForm() {
const t = useTranslations('auth-control');
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
const { data, refetch } = useQuery({
queryKey: ['getAuthMethodConfig', 'apple'],
queryFn: async () => {
const { data } = await getAuthMethodConfig({
method: 'apple',
});
return data.data;
},
});
const form = useForm<AppleFormData>({
resolver: zodResolver(appleSchema),
defaultValues: {
enabled: false,
config: {
team_id: '',
key_id: '',
client_id: '',
client_secret: '',
redirect_url: '',
},
},
});
useEffect(() => {
if (data) {
form.reset({
enabled: data.enabled || false,
config: {
team_id: data.config?.team_id || '',
key_id: data.config?.key_id || '',
client_id: data.config?.client_id || '',
client_secret: data.config?.client_secret || '',
redirect_url: data.config?.redirect_url || '',
},
});
}
}, [data, form]);
async function onSubmit(values: AppleFormData) {
setLoading(true);
try {
await updateAuthMethodConfig({
...data,
enabled: values.enabled,
config: {
...data?.config,
...values.config,
},
} as API.UpdateAuthMethodConfigRequest);
toast.success(t('common.saveSuccess'));
refetch();
setOpen(false);
} catch (error) {
toast.error(t('common.saveFailed'));
} finally {
setLoading(false);
}
}
return (
<Sheet open={open} onOpenChange={setOpen}>
<SheetTrigger asChild>
<div className='flex cursor-pointer items-center justify-between transition-colors'>
<div className='flex items-center gap-3'>
<div className='bg-primary/10 flex h-10 w-10 items-center justify-center rounded-lg'>
<Icon icon='mdi:apple' className='text-primary h-5 w-5' />
</div>
<div className='flex-1'>
<p className='font-medium'>{t('apple.title')}</p>
<p className='text-muted-foreground text-sm'>{t('apple.description')}</p>
</div>
</div>
<Icon icon='mdi:chevron-right' className='size-6' />
</div>
</SheetTrigger>
<SheetContent className='w-[500px] max-w-full md:max-w-screen-md'>
<SheetHeader>
<SheetTitle>{t('apple.title')}</SheetTitle>
</SheetHeader>
<ScrollArea className='-mx-6 h-[calc(100dvh-48px-36px-36px-env(safe-area-inset-top))] px-6'>
<Form {...form}>
<form id='apple-form' onSubmit={form.handleSubmit(onSubmit)} className='space-y-2 pt-4'>
<FormField
control={form.control}
name='enabled'
render={({ field }) => (
<FormItem>
<FormLabel>{t('apple.enable')}</FormLabel>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
className='float-end !mt-0'
/>
</FormControl>
<FormDescription>{t('apple.enableDescription')}</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='config.team_id'
render={({ field }) => (
<FormItem>
<FormLabel>{t('apple.teamId')}</FormLabel>
<FormControl>
<EnhancedInput
placeholder='ABCDE1FGHI'
value={field.value}
onValueChange={field.onChange}
/>
</FormControl>
<FormDescription>{t('apple.teamIdDescription')}</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='config.key_id'
render={({ field }) => (
<FormItem>
<FormLabel>{t('apple.keyId')}</FormLabel>
<FormControl>
<EnhancedInput
placeholder='ABC1234567'
value={field.value}
onValueChange={field.onChange}
/>
</FormControl>
<FormDescription>{t('apple.keyIdDescription')}</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='config.client_id'
render={({ field }) => (
<FormItem>
<FormLabel>{t('apple.clientId')}</FormLabel>
<FormControl>
<EnhancedInput
placeholder='com.your.app.service'
value={field.value}
onValueChange={field.onChange}
/>
</FormControl>
<FormDescription>{t('apple.clientIdDescription')}</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='config.client_secret'
render={({ field }) => (
<FormItem>
<FormLabel>{t('apple.clientSecret')}</FormLabel>
<FormControl>
<Textarea
className='h-20'
placeholder={`-----BEGIN PRIVATE KEY-----\nMIGTAgEA...\n-----END PRIVATE KEY-----`}
value={field.value}
onChange={field.onChange}
/>
</FormControl>
<FormDescription>{t('apple.clientSecretDescription')}</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='config.redirect_url'
render={({ field }) => (
<FormItem>
<FormLabel>{t('apple.redirectUri')}</FormLabel>
<FormControl>
<EnhancedInput
placeholder='https://your-domain.com'
value={field.value}
onValueChange={field.onChange}
/>
</FormControl>
<FormDescription>{t('apple.redirectUriDescription')}</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</ScrollArea>
<SheetFooter className='flex-row justify-end gap-2 pt-3'>
<Button variant='outline' disabled={loading} onClick={() => setOpen(false)}>
{t('common.cancel')}
</Button>
<Button disabled={loading} type='submit' form='apple-form'>
{loading && <Icon icon='mdi:loading' className='mr-2 animate-spin' />}
{t('common.save')}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
);
}
@@ -0,0 +1,253 @@
'use client';
import { getAuthMethodConfig, updateAuthMethodConfig } from '@/services/admin/authMethod';
import { zodResolver } from '@hookform/resolvers/zod';
import { useQuery } from '@tanstack/react-query';
import { Button } from '@workspace/ui/components/button';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@workspace/ui/components/form';
import { ScrollArea } from '@workspace/ui/components/scroll-area';
import {
Sheet,
SheetContent,
SheetFooter,
SheetHeader,
SheetTitle,
SheetTrigger,
} from '@workspace/ui/components/sheet';
import { Switch } from '@workspace/ui/components/switch';
import { EnhancedInput } from '@workspace/ui/custom-components/enhanced-input';
import { Icon } from '@workspace/ui/custom-components/icon';
import { useTranslations } from 'next-intl';
import { uid } from 'radash';
import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { toast } from 'sonner';
import { z } from 'zod';
const deviceSchema = z.object({
id: z.number(),
method: z.string().default('device'),
enabled: z.boolean().default(false),
config: z
.object({
show_ads: z.boolean().optional(),
only_real_device: z.boolean().optional(),
enable_security: z.boolean().optional(),
security_secret: z.string().optional(),
})
.optional(),
});
type DeviceFormData = z.infer<typeof deviceSchema>;
export default function DeviceForm() {
const t = useTranslations('auth-control');
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
const { data, refetch } = useQuery({
queryKey: ['getAuthMethodConfig', 'device'],
queryFn: async () => {
const { data } = await getAuthMethodConfig({
method: 'device',
});
return data.data;
},
enabled: open,
});
const form = useForm<DeviceFormData>({
resolver: zodResolver(deviceSchema),
defaultValues: {
id: 0,
method: 'device',
enabled: false,
config: {
show_ads: false,
only_real_device: false,
enable_security: false,
security_secret: '',
},
},
});
useEffect(() => {
if (data) {
form.reset(data);
}
}, [data, form]);
async function onSubmit(values: DeviceFormData) {
setLoading(true);
try {
await updateAuthMethodConfig(values as API.UpdateAuthMethodConfigRequest);
toast.success(t('common.saveSuccess'));
refetch();
setOpen(false);
} catch (error) {
toast.error(t('common.saveFailed'));
} finally {
setLoading(false);
}
}
function generateSecurityKey() {
const id = uid(32).toLowerCase();
const formatted = `${id.slice(0, 8)}-${id.slice(8, 12)}-${id.slice(12, 16)}-${id.slice(16, 20)}-${id.slice(20)}`;
form.setValue('config.security_secret', formatted);
}
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:devices' className='text-primary h-5 w-5' />
</div>
<div className='flex-1'>
<p className='font-medium'>{t('device.title')}</p>
<p className='text-muted-foreground text-sm'>{t('device.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('device.title')}</SheetTitle>
</SheetHeader>
<ScrollArea className='-mx-6 h-[calc(100dvh-48px-36px-36px-env(safe-area-inset-top))] px-6'>
<Form {...form}>
<form
id='device-form'
onSubmit={form.handleSubmit(onSubmit)}
className='space-y-2 pt-4'
>
<FormField
control={form.control}
name='enabled'
render={({ field }) => (
<FormItem>
<FormLabel>{t('device.enable')}</FormLabel>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
className='float-end !mt-0'
/>
</FormControl>
<FormDescription>{t('device.enableDescription')}</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='config.show_ads'
render={({ field }) => (
<FormItem>
<FormLabel>{t('device.showAds')}</FormLabel>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
className='float-end !mt-0'
/>
</FormControl>
<FormDescription>{t('device.showAdsDescription')}</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='config.only_real_device'
render={({ field }) => (
<FormItem>
<FormLabel>{t('device.blockVirtualMachine')}</FormLabel>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
className='float-end !mt-0'
/>
</FormControl>
<FormDescription>{t('device.blockVirtualMachineDescription')}</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='config.enable_security'
render={({ field }) => (
<FormItem>
<FormLabel>{t('device.enableSecurity')}</FormLabel>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
className='float-end !mt-0'
/>
</FormControl>
<FormDescription>{t('device.enableSecurityDescription')}</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='config.security_secret'
render={({ field }) => (
<FormItem>
<FormLabel>{t('device.communicationKey')}</FormLabel>
<FormControl>
<EnhancedInput
placeholder='e.g., 12345678-1234-1234-1234-123456789abc'
value={field.value}
onValueChange={field.onChange}
suffix={
<div className='bg-muted flex h-9 items-center text-nowrap px-3'>
<Icon
icon='mdi:dice-multiple'
onClick={generateSecurityKey}
className='size-4 cursor-pointer'
/>
</div>
}
/>
</FormControl>
<FormDescription>{t('device.communicationKeyDescription')}</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</ScrollArea>
<SheetFooter className='flex-row justify-end gap-2 pt-3'>
<Button variant='outline' disabled={loading} onClick={() => setOpen(false)}>
{t('common.cancel')}
</Button>
<Button disabled={loading} type='submit' form='device-form'>
{loading && <Icon icon='mdi:loading' className='mr-2 animate-spin' />}
{t('common.save')}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
);
}
@@ -0,0 +1,48 @@
'use client';
import { ScrollArea } from '@workspace/ui/components/scroll-area';
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetTrigger,
} from '@workspace/ui/components/sheet';
import { Icon } from '@workspace/ui/custom-components/icon';
import { useTranslations } from 'next-intl';
import { useState } from 'react';
import { LogsTable } from '../log';
export default function EmailLogsTable() {
const t = useTranslations('auth-control');
const [open, setOpen] = useState(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:email-newsletter' className='text-primary h-5 w-5' />
</div>
<div className='flex-1'>
<p className='font-medium'>{t('email.logs')}</p>
<p className='text-muted-foreground text-sm'>{t('email.logsDescription')}</p>
</div>
</div>
<Icon icon='mdi:chevron-right' className='size-6' />
</div>
</SheetTrigger>
<SheetContent className='w-[800px] max-w-full md:max-w-screen-lg'>
<SheetHeader>
<SheetTitle>{t('email.logs')}</SheetTitle>
</SheetHeader>
<ScrollArea className='-mx-6 h-[calc(100dvh-48px-36px-36px-env(safe-area-inset-top))]'>
<div className='px-6 pt-4'>
<LogsTable type='email' />
</div>
</ScrollArea>
</SheetContent>
</Sheet>
);
}
@@ -0,0 +1,627 @@
'use client';
import {
getAuthMethodConfig,
testEmailSend,
updateAuthMethodConfig,
} from '@/services/admin/authMethod';
import { zodResolver } from '@hookform/resolvers/zod';
import { useQuery } from '@tanstack/react-query';
import { Button } from '@workspace/ui/components/button';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@workspace/ui/components/form';
import { ScrollArea } from '@workspace/ui/components/scroll-area';
import {
Sheet,
SheetContent,
SheetFooter,
SheetHeader,
SheetTitle,
SheetTrigger,
} from '@workspace/ui/components/sheet';
import { Switch } from '@workspace/ui/components/switch';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@workspace/ui/components/tabs';
import { Textarea } from '@workspace/ui/components/textarea';
import { HTMLEditor } from '@workspace/ui/custom-components/editor';
import { EnhancedInput } from '@workspace/ui/custom-components/enhanced-input';
import { Icon } from '@workspace/ui/custom-components/icon';
import { useTranslations } from 'next-intl';
import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { toast } from 'sonner';
import { z } from 'zod';
const emailSettingsSchema = z.object({
id: z.number(),
method: z.string().default('email'),
enabled: z.boolean().default(false),
config: z
.object({
enable_verify: z.boolean().default(false),
enable_domain_suffix: z.boolean().default(false),
domain_suffix_list: z.string().optional(),
verify_email_template: z.string().optional(),
expiration_email_template: z.string().optional(),
maintenance_email_template: z.string().optional(),
traffic_exceed_email_template: z.string().optional(),
platform: z.string().default('smtp'),
platform_config: z
.object({
host: z.string().optional(),
port: z.coerce.number().optional(),
ssl: z.boolean().default(false),
user: z.string().optional(),
pass: z.string().optional(),
from: z.string().optional(),
})
.optional(),
})
.optional(),
});
type EmailSettingsFormData = z.infer<typeof emailSettingsSchema>;
export default function EmailSettingsForm() {
const t = useTranslations('auth-control');
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
const [testEmail, setTestEmail] = useState<string>();
const { data, refetch, isFetching } = useQuery({
queryKey: ['getAuthMethodConfig', 'email'],
queryFn: async () => {
const { data } = await getAuthMethodConfig({
method: 'email',
});
return data.data;
},
enabled: open,
});
const form = useForm<EmailSettingsFormData>({
resolver: zodResolver(emailSettingsSchema),
defaultValues: {
id: 0,
method: 'email',
enabled: false,
config: {
enable_verify: false,
enable_domain_suffix: false,
domain_suffix_list: '',
verify_email_template: '',
expiration_email_template: '',
maintenance_email_template: '',
traffic_exceed_email_template: '',
platform: 'smtp',
platform_config: {
host: '',
port: 587,
ssl: false,
user: '',
pass: '',
from: '',
},
},
},
});
useEffect(() => {
if (data) {
form.reset(data);
}
}, [data, form]);
async function onSubmit(values: EmailSettingsFormData) {
setLoading(true);
try {
await updateAuthMethodConfig({
...values,
config: {
...values.config,
platform: 'smtp',
},
} as API.UpdateAuthMethodConfigRequest);
toast.success(t('common.saveSuccess'));
refetch();
setOpen(false);
} catch (error) {
toast.error(t('common.saveFailed'));
} finally {
setLoading(false);
}
}
return (
<Sheet open={open} onOpenChange={setOpen}>
<SheetTrigger asChild>
<div className='flex cursor-pointer items-center justify-between transition-colors'>
<div className='flex items-center gap-3'>
<div className='bg-primary/10 flex h-10 w-10 items-center justify-center rounded-lg'>
<Icon icon='mdi:email-outline' className='text-primary h-5 w-5' />
</div>
<div className='flex-1'>
<p className='font-medium'>{t('email.title')}</p>
<p className='text-muted-foreground text-sm'>{t('email.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('email.title')}</SheetTitle>
</SheetHeader>
<ScrollArea className='-mx-6 h-[calc(100dvh-48px-36px-36px-env(safe-area-inset-top))] px-6'>
<Form {...form}>
<form
id='email-settings-form'
onSubmit={form.handleSubmit(onSubmit)}
className='space-y-2 pt-4'
>
<Tabs defaultValue='basic' className='space-y-2'>
<TabsList className='flex h-full w-full flex-wrap *:flex-auto md:flex-nowrap'>
<TabsTrigger value='basic'>{t('email.basicSettings')}</TabsTrigger>
<TabsTrigger value='smtp'>{t('email.smtpSettings')}</TabsTrigger>
<TabsTrigger value='verify'>{t('email.verifyTemplate')}</TabsTrigger>
<TabsTrigger value='expiration'>{t('email.expirationTemplate')}</TabsTrigger>
<TabsTrigger value='maintenance'>{t('email.maintenanceTemplate')}</TabsTrigger>
<TabsTrigger value='traffic'>{t('email.trafficTemplate')}</TabsTrigger>
</TabsList>
<TabsContent value='basic' className='space-y-2'>
<FormField
control={form.control}
name='enabled'
render={({ field }) => (
<FormItem>
<FormLabel>{t('email.enable')}</FormLabel>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
className='float-end !mt-0'
/>
</FormControl>
<FormDescription>{t('email.enableDescription')}</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='config.enable_verify'
render={({ field }) => (
<FormItem>
<FormLabel>{t('email.emailVerification')}</FormLabel>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
className='float-end !mt-0'
/>
</FormControl>
<FormDescription>{t('email.emailVerificationDescription')}</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='config.enable_domain_suffix'
render={({ field }) => (
<FormItem>
<FormLabel>{t('email.emailSuffixWhitelist')}</FormLabel>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
className='float-end !mt-0'
/>
</FormControl>
<FormDescription>
{t('email.emailSuffixWhitelistDescription')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='config.domain_suffix_list'
render={({ field }) => (
<FormItem>
<FormLabel>{t('email.whitelistSuffixes')}</FormLabel>
<FormControl>
<Textarea
className='h-32'
placeholder={t('email.whitelistSuffixesPlaceholder')}
value={field.value}
onChange={field.onChange}
/>
</FormControl>
<FormDescription>{t('email.whitelistSuffixesDescription')}</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</TabsContent>
<TabsContent value='smtp' className='space-y-2'>
<FormField
control={form.control}
name='config.platform_config.host'
render={({ field }) => (
<FormItem>
<FormLabel>{t('email.smtpServerAddress')}</FormLabel>
<FormControl>
<EnhancedInput
placeholder={t('email.inputPlaceholder')}
value={field.value}
onValueChange={field.onChange}
/>
</FormControl>
<FormDescription>{t('email.smtpServerAddressDescription')}</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='config.platform_config.port'
render={({ field }) => (
<FormItem>
<FormLabel>{t('email.smtpServerPort')}</FormLabel>
<FormControl>
<EnhancedInput
type='number'
placeholder='587'
value={field.value?.toString()}
onValueChange={(value) => field.onChange(Number(value))}
/>
</FormControl>
<FormDescription>{t('email.smtpServerPortDescription')}</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='config.platform_config.ssl'
render={({ field }) => (
<FormItem>
<FormLabel>{t('email.smtpEncryptionMethod')}</FormLabel>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
className='float-end !mt-0'
/>
</FormControl>
<FormDescription>
{t('email.smtpEncryptionMethodDescription')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='config.platform_config.user'
render={({ field }) => (
<FormItem>
<FormLabel>{t('email.smtpAccount')}</FormLabel>
<FormControl>
<EnhancedInput
placeholder={t('email.inputPlaceholder')}
value={field.value}
onValueChange={field.onChange}
/>
</FormControl>
<FormDescription>{t('email.smtpAccountDescription')}</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='config.platform_config.pass'
render={({ field }) => (
<FormItem>
<FormLabel>{t('email.smtpPassword')}</FormLabel>
<FormControl>
<EnhancedInput
type='password'
placeholder={t('email.inputPlaceholder')}
value={field.value}
onValueChange={field.onChange}
/>
</FormControl>
<FormDescription>{t('email.smtpPasswordDescription')}</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='config.platform_config.from'
render={({ field }) => (
<FormItem>
<FormLabel>{t('email.senderAddress')}</FormLabel>
<FormControl>
<EnhancedInput
placeholder={t('email.inputPlaceholder')}
value={field.value}
onValueChange={field.onChange}
/>
</FormControl>
<FormDescription>{t('email.senderAddressDescription')}</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className='space-y-2 border-t pt-4'>
<FormLabel>{t('email.sendTestEmail')}</FormLabel>
<div className='flex items-center gap-2'>
<EnhancedInput
placeholder='test@example.com'
type='email'
value={testEmail}
onValueChange={(value) => setTestEmail(value as string)}
/>
<Button
type='button'
disabled={!testEmail || isFetching}
onClick={async () => {
if (!testEmail) return;
try {
await testEmailSend({ email: testEmail });
toast.success(t('email.sendSuccess'));
} catch {
toast.error(t('email.sendFailure'));
}
}}
>
{t('email.sendTestEmail')}
</Button>
</div>
<p className='text-muted-foreground text-xs'>
{t('email.sendTestEmailDescription')}
</p>
</div>
</TabsContent>
<TabsContent value='verify' className='space-y-2'>
<FormField
control={form.control}
name='config.verify_email_template'
render={({ field }) => (
<FormItem>
<FormLabel>{t('email.verifyEmailTemplate')}</FormLabel>
<FormControl>
<HTMLEditor
placeholder={t('email.inputPlaceholder')}
value={field.value}
onBlur={field.onChange}
/>
</FormControl>
<div className='mt-4 space-y-2 border-t pt-4'>
<p className='text-muted-foreground text-sm font-medium'>
{t('email.templateVariables.title')}
</p>
<div className='text-muted-foreground space-y-2 text-xs'>
<div className='flex items-center gap-2'>
<code className='bg-muted text-foreground rounded px-1.5 py-0.5 font-mono'>
{'{{.Type}}'}
</code>
<span>{t('email.templateVariables.type.description')}</span>
</div>
<div className='pl-6 text-orange-600 dark:text-orange-400'>
💡 {t('email.templateVariables.type.conditionalSyntax')}
<br />
<code className='rounded bg-orange-50 px-1 text-xs dark:bg-orange-900/20'>
{'{{if eq .Type 1}}...{{else}}...{{end}}'}
</code>
</div>
<div className='flex items-center gap-2'>
<code className='bg-muted text-foreground rounded px-1.5 py-0.5 font-mono'>
{'{{.SiteLogo}}'}
</code>
<span>{t('email.templateVariables.siteLogo.description')}</span>
</div>
<div className='flex items-center gap-2'>
<code className='bg-muted text-foreground rounded px-1.5 py-0.5 font-mono'>
{'{{.SiteName}}'}
</code>
<span>{t('email.templateVariables.siteName.description')}</span>
</div>
<div className='flex items-center gap-2'>
<code className='bg-muted text-foreground rounded px-1.5 py-0.5 font-mono'>
{'{{.Expire}}'}
</code>
<span>{t('email.templateVariables.expire.description')}</span>
</div>
<div className='flex items-center gap-2'>
<code className='bg-muted text-foreground rounded px-1.5 py-0.5 font-mono'>
{'{{.Code}}'}
</code>
<span>{t('email.templateVariables.code.description')}</span>
</div>
</div>
</div>
<FormMessage />
</FormItem>
)}
/>
</TabsContent>
<TabsContent value='expiration' className='space-y-2'>
<FormField
control={form.control}
name='config.expiration_email_template'
render={({ field }) => (
<FormItem>
<FormLabel>{t('email.expirationEmailTemplate')}</FormLabel>
<FormControl>
<HTMLEditor
placeholder={t('email.inputPlaceholder')}
value={field.value}
onBlur={field.onChange}
/>
</FormControl>
<div className='mt-4 space-y-2 border-t pt-4'>
<p className='text-muted-foreground text-sm font-medium'>
{t('email.templateVariables.title')}
</p>
<div className='text-muted-foreground space-y-2 text-xs'>
<div className='flex items-center gap-2'>
<code className='bg-muted text-foreground rounded px-1.5 py-0.5 font-mono'>
{'{{.SiteLogo}}'}
</code>
<span>{t('email.templateVariables.siteLogo.description')}</span>
</div>
<div className='flex items-center gap-2'>
<code className='bg-muted text-foreground rounded px-1.5 py-0.5 font-mono'>
{'{{.SiteName}}'}
</code>
<span>{t('email.templateVariables.siteName.description')}</span>
</div>
<div className='flex items-center gap-2'>
<code className='bg-muted text-foreground rounded px-1.5 py-0.5 font-mono'>
{'{{.ExpireDate}}'}
</code>
<span>{t('email.templateVariables.expireDate.description')}</span>
</div>
</div>
</div>
<FormMessage />
</FormItem>
)}
/>
</TabsContent>
<TabsContent value='maintenance' className='space-y-2'>
<FormField
control={form.control}
name='config.maintenance_email_template'
render={({ field }) => (
<FormItem>
<FormLabel>{t('email.maintenanceEmailTemplate')}</FormLabel>
<FormControl>
<HTMLEditor
placeholder={t('email.inputPlaceholder')}
value={field.value}
onBlur={field.onChange}
/>
</FormControl>
<div className='mt-4 space-y-2 border-t pt-4'>
<p className='text-muted-foreground text-sm font-medium'>
{t('email.templateVariables.title')}
</p>
<div className='text-muted-foreground space-y-2 text-xs'>
<div className='flex items-center gap-2'>
<code className='bg-muted text-foreground rounded px-1.5 py-0.5 font-mono'>
{'{{.SiteLogo}}'}
</code>
<span>{t('email.templateVariables.siteLogo.description')}</span>
</div>
<div className='flex items-center gap-2'>
<code className='bg-muted text-foreground rounded px-1.5 py-0.5 font-mono'>
{'{{.SiteName}}'}
</code>
<span>{t('email.templateVariables.siteName.description')}</span>
</div>
<div className='flex items-center gap-2'>
<code className='bg-muted text-foreground rounded px-1.5 py-0.5 font-mono'>
{'{{.MaintenanceDate}}'}
</code>
<span>
{t('email.templateVariables.maintenanceDate.description')}
</span>
</div>
<div className='flex items-center gap-2'>
<code className='bg-muted text-foreground rounded px-1.5 py-0.5 font-mono'>
{'{{.MaintenanceTime}}'}
</code>
<span>
{t('email.templateVariables.maintenanceTime.description')}
</span>
</div>
</div>
</div>
<FormMessage />
</FormItem>
)}
/>
</TabsContent>
<TabsContent value='traffic' className='space-y-2'>
<FormField
control={form.control}
name='config.traffic_exceed_email_template'
render={({ field }) => (
<FormItem>
<FormLabel>{t('email.trafficExceedEmailTemplate')}</FormLabel>
<FormControl>
<HTMLEditor
placeholder={t('email.inputPlaceholder')}
value={field.value}
onBlur={field.onChange}
/>
</FormControl>
<div className='mt-4 space-y-2 border-t pt-4'>
<p className='text-muted-foreground text-sm font-medium'>
{t('email.templateVariables.title')}
</p>
<div className='text-muted-foreground space-y-2 text-xs'>
<div className='flex items-center gap-2'>
<code className='bg-muted text-foreground rounded px-1.5 py-0.5 font-mono'>
{'{{.SiteLogo}}'}
</code>
<span>{t('email.templateVariables.siteLogo.description')}</span>
</div>
<div className='flex items-center gap-2'>
<code className='bg-muted text-foreground rounded px-1.5 py-0.5 font-mono'>
{'{{.SiteName}}'}
</code>
<span>{t('email.templateVariables.siteName.description')}</span>
</div>
</div>
</div>
<FormMessage />
</FormItem>
)}
/>
</TabsContent>
</Tabs>
</form>
</Form>
</ScrollArea>
<SheetFooter className='flex-row justify-end gap-2 pt-3'>
<Button variant='outline' disabled={loading} onClick={() => setOpen(false)}>
{t('common.cancel')}
</Button>
<Button disabled={loading} type='submit' form='email-settings-form'>
{loading && <Icon icon='mdi:loading' className='mr-2 animate-spin' />}
{t('common.save')}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
);
}
@@ -0,0 +1,198 @@
'use client';
import { getAuthMethodConfig, updateAuthMethodConfig } from '@/services/admin/authMethod';
import { zodResolver } from '@hookform/resolvers/zod';
import { useQuery } from '@tanstack/react-query';
import { Button } from '@workspace/ui/components/button';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@workspace/ui/components/form';
import { ScrollArea } from '@workspace/ui/components/scroll-area';
import {
Sheet,
SheetContent,
SheetFooter,
SheetHeader,
SheetTitle,
SheetTrigger,
} from '@workspace/ui/components/sheet';
import { Switch } from '@workspace/ui/components/switch';
import { EnhancedInput } from '@workspace/ui/custom-components/enhanced-input';
import { Icon } from '@workspace/ui/custom-components/icon';
import { useTranslations } from 'next-intl';
import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { toast } from 'sonner';
import { z } from 'zod';
const facebookSchema = z.object({
enabled: z.boolean(),
client_id: z.string().optional(),
client_secret: z.string().optional(),
});
type FacebookFormData = z.infer<typeof facebookSchema>;
export default function FacebookForm() {
const t = useTranslations('auth-control');
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
const { data, refetch, isFetching } = useQuery({
queryKey: ['getAuthMethodConfig', 'facebook'],
queryFn: async () => {
const { data } = await getAuthMethodConfig({
method: 'facebook',
});
return data.data;
},
// 移除 enabled: open,现在默认加载数据
});
const form = useForm<FacebookFormData>({
resolver: zodResolver(facebookSchema),
defaultValues: {
enabled: false,
client_id: '',
client_secret: '',
},
});
useEffect(() => {
if (data) {
form.reset({
enabled: data.enabled || false,
client_id: data.config?.client_id || '',
client_secret: data.config?.client_secret || '',
});
}
}, [data, form]);
async function onSubmit(values: FacebookFormData) {
setLoading(true);
try {
await updateAuthMethodConfig({
...data,
enabled: values.enabled,
config: {
...data?.config,
client_id: values.client_id,
client_secret: values.client_secret,
},
} as API.UpdateAuthMethodConfigRequest);
toast.success(t('common.saveSuccess'));
refetch();
setOpen(false);
} catch (error) {
toast.error(t('common.saveFailed'));
} finally {
setLoading(false);
}
}
return (
<Sheet open={open} onOpenChange={setOpen}>
<SheetTrigger asChild>
<div className='flex cursor-pointer items-center justify-between transition-colors'>
<div className='flex items-center gap-3'>
<div className='bg-primary/10 flex h-10 w-10 items-center justify-center rounded-lg'>
<Icon icon='mdi:facebook' className='text-primary h-5 w-5' />
</div>
<div className='flex-1'>
<p className='font-medium'>{t('facebook.title')}</p>
<p className='text-muted-foreground text-sm'>{t('facebook.description')}</p>
</div>
</div>
<Icon icon='mdi:chevron-right' className='size-6' />
</div>
</SheetTrigger>
<SheetContent className='w-[500px] max-w-full md:max-w-screen-md'>
<SheetHeader>
<SheetTitle>{t('facebook.title')}</SheetTitle>
</SheetHeader>
<ScrollArea className='-mx-6 h-[calc(100dvh-48px-36px-36px-env(safe-area-inset-top))] px-6'>
<Form {...form}>
<form
id='facebook-form'
onSubmit={form.handleSubmit(onSubmit)}
className='space-y-2 pt-4'
>
<FormField
control={form.control}
name='enabled'
render={({ field }) => (
<FormItem>
<FormLabel>{t('facebook.enable')}</FormLabel>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
className='float-end !mt-0'
/>
</FormControl>
<FormDescription>{t('facebook.enableDescription')}</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='client_id'
render={({ field }) => (
<FormItem>
<FormLabel>{t('facebook.clientId')}</FormLabel>
<FormControl>
<EnhancedInput
placeholder='1234567890123456'
value={field.value}
onValueChange={field.onChange}
/>
</FormControl>
<FormDescription>{t('facebook.clientIdDescription')}</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='client_secret'
render={({ field }) => (
<FormItem>
<FormLabel>{t('facebook.clientSecret')}</FormLabel>
<FormControl>
<EnhancedInput
placeholder='1234567890abcdef1234567890abcdef'
value={field.value}
onValueChange={field.onChange}
type='password'
/>
</FormControl>
<FormDescription>{t('facebook.clientSecretDescription')}</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</ScrollArea>
<SheetFooter className='flex-row justify-end gap-2 pt-3'>
<Button variant='outline' disabled={loading} onClick={() => setOpen(false)}>
{t('common.cancel')}
</Button>
<Button disabled={loading} type='submit' form='facebook-form'>
{loading && <Icon icon='mdi:loading' className='mr-2 animate-spin' />}
{t('common.save')}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
);
}
@@ -0,0 +1,198 @@
'use client';
import { getAuthMethodConfig, updateAuthMethodConfig } from '@/services/admin/authMethod';
import { zodResolver } from '@hookform/resolvers/zod';
import { useQuery } from '@tanstack/react-query';
import { Button } from '@workspace/ui/components/button';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@workspace/ui/components/form';
import { ScrollArea } from '@workspace/ui/components/scroll-area';
import {
Sheet,
SheetContent,
SheetFooter,
SheetHeader,
SheetTitle,
SheetTrigger,
} from '@workspace/ui/components/sheet';
import { Switch } from '@workspace/ui/components/switch';
import { EnhancedInput } from '@workspace/ui/custom-components/enhanced-input';
import { Icon } from '@workspace/ui/custom-components/icon';
import { useTranslations } from 'next-intl';
import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { toast } from 'sonner';
import { z } from 'zod';
const githubSchema = z.object({
enabled: z.boolean(),
client_id: z.string().optional(),
client_secret: z.string().optional(),
});
type GithubFormData = z.infer<typeof githubSchema>;
export default function GithubForm() {
const t = useTranslations('auth-control');
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
const { data, refetch } = useQuery({
queryKey: ['getAuthMethodConfig', 'github'],
queryFn: async () => {
const { data } = await getAuthMethodConfig({
method: 'github',
});
return data.data;
},
});
const form = useForm<GithubFormData>({
resolver: zodResolver(githubSchema),
defaultValues: {
enabled: false,
client_id: '',
client_secret: '',
},
});
useEffect(() => {
if (data) {
form.reset({
enabled: data.enabled || false,
client_id: data.config?.client_id || '',
client_secret: data.config?.client_secret || '',
});
}
}, [data, form]);
async function onSubmit(values: GithubFormData) {
setLoading(true);
try {
await updateAuthMethodConfig({
...data,
enabled: values.enabled,
config: {
...data?.config,
client_id: values.client_id,
client_secret: values.client_secret,
},
} as API.UpdateAuthMethodConfigRequest);
toast.success(t('common.saveSuccess'));
refetch();
setOpen(false);
} catch (error) {
toast.error(t('common.saveFailed'));
} finally {
setLoading(false);
}
}
return (
<Sheet open={open} onOpenChange={setOpen}>
<SheetTrigger asChild>
<div className='flex cursor-pointer items-center justify-between transition-colors'>
<div className='flex items-center gap-3'>
<div className='bg-primary/10 flex h-10 w-10 items-center justify-center rounded-lg'>
<Icon icon='mdi:github' className='text-primary h-5 w-5' />
</div>
<div className='flex-1'>
<p className='font-medium'>{t('github.title')}</p>
<p className='text-muted-foreground text-sm'>{t('github.description')}</p>
</div>
</div>
<Icon icon='mdi:chevron-right' className='size-6' />
</div>
</SheetTrigger>
<SheetContent className='w-[500px] max-w-full md:max-w-screen-md'>
<SheetHeader>
<SheetTitle>{t('github.title')}</SheetTitle>
</SheetHeader>
<ScrollArea className='-mx-6 h-[calc(100dvh-48px-36px-36px-env(safe-area-inset-top))] px-6'>
<Form {...form}>
<form
id='github-form'
onSubmit={form.handleSubmit(onSubmit)}
className='space-y-2 pt-4'
>
<FormField
control={form.control}
name='enabled'
render={({ field }) => (
<FormItem>
<FormLabel>{t('github.enable')}</FormLabel>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
className='float-end !mt-0'
/>
</FormControl>
<FormDescription>{t('github.enableDescription')}</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='client_id'
render={({ field }) => (
<FormItem>
<FormLabel>{t('github.clientId')}</FormLabel>
<FormControl>
<EnhancedInput
placeholder='e.g., Iv1.1234567890abcdef'
value={field.value}
onValueChange={field.onChange}
/>
</FormControl>
<FormDescription>{t('github.clientIdDescription')}</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='client_secret'
render={({ field }) => (
<FormItem>
<FormLabel>{t('github.clientSecret')}</FormLabel>
<FormControl>
<EnhancedInput
placeholder='e.g., 1234567890abcdef1234567890abcdef12345678'
value={field.value}
onValueChange={field.onChange}
type='password'
/>
</FormControl>
<FormDescription>{t('github.clientSecretDescription')}</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</ScrollArea>
<SheetFooter className='flex-row justify-end gap-2 pt-3'>
<Button variant='outline' disabled={loading} onClick={() => setOpen(false)}>
{t('common.cancel')}
</Button>
<Button disabled={loading} type='submit' form='github-form'>
{loading && <Icon icon='mdi:loading' className='mr-2 animate-spin' />}
{t('common.save')}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
);
}
@@ -0,0 +1,196 @@
'use client';
import { getAuthMethodConfig, updateAuthMethodConfig } from '@/services/admin/authMethod';
import { zodResolver } from '@hookform/resolvers/zod';
import { useQuery } from '@tanstack/react-query';
import { Button } from '@workspace/ui/components/button';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@workspace/ui/components/form';
import { ScrollArea } from '@workspace/ui/components/scroll-area';
import {
Sheet,
SheetContent,
SheetFooter,
SheetHeader,
SheetTitle,
SheetTrigger,
} from '@workspace/ui/components/sheet';
import { Switch } from '@workspace/ui/components/switch';
import { EnhancedInput } from '@workspace/ui/custom-components/enhanced-input';
import { Icon } from '@workspace/ui/custom-components/icon';
import { useTranslations } from 'next-intl';
import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { toast } from 'sonner';
import { z } from 'zod';
const googleSchema = z.object({
id: z.number(),
method: z.string().default('google'),
enabled: z.boolean().default(false),
config: z
.object({
client_id: z.string().optional(),
client_secret: z.string().optional(),
})
.optional(),
});
type GoogleFormData = z.infer<typeof googleSchema>;
export default function GoogleForm() {
const t = useTranslations('auth-control');
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
const { data, refetch, isFetching } = useQuery({
queryKey: ['getAuthMethodConfig', 'google'],
queryFn: async () => {
const { data } = await getAuthMethodConfig({
method: 'google',
});
return data.data;
},
enabled: open,
});
const form = useForm<GoogleFormData>({
resolver: zodResolver(googleSchema),
defaultValues: {
id: 0,
method: 'google',
enabled: false,
config: {
client_id: '',
client_secret: '',
},
},
});
useEffect(() => {
if (data) {
form.reset(data);
}
}, [data, form]);
async function onSubmit(values: GoogleFormData) {
setLoading(true);
try {
await updateAuthMethodConfig(values as API.UpdateAuthMethodConfigRequest);
toast.success(t('common.saveSuccess'));
refetch();
setOpen(false);
} catch (error) {
toast.error(t('common.saveFailed'));
} finally {
setLoading(false);
}
}
return (
<Sheet open={open} onOpenChange={setOpen}>
<SheetTrigger asChild>
<div className='flex cursor-pointer items-center justify-between transition-colors'>
<div className='flex items-center gap-3'>
<div className='bg-primary/10 flex h-10 w-10 items-center justify-center rounded-lg'>
<Icon icon='mdi:google' className='text-primary h-5 w-5' />
</div>
<div className='flex-1'>
<p className='font-medium'>{t('google.title')}</p>
<p className='text-muted-foreground text-sm'>{t('google.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('google.title')}</SheetTitle>
</SheetHeader>
<ScrollArea className='-mx-6 h-[calc(100dvh-48px-36px-36px-env(safe-area-inset-top))] px-6'>
<Form {...form}>
<form
id='google-form'
onSubmit={form.handleSubmit(onSubmit)}
className='space-y-2 pt-4'
>
<FormField
control={form.control}
name='enabled'
render={({ field }) => (
<FormItem>
<FormLabel>{t('google.enable')}</FormLabel>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
className='float-end !mt-0'
/>
</FormControl>
<FormDescription>{t('google.enableDescription')}</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='config.client_id'
render={({ field }) => (
<FormItem>
<FormLabel>{t('google.clientId')}</FormLabel>
<FormControl>
<EnhancedInput
placeholder='123456789-abc123def456.apps.googleusercontent.com'
value={field.value}
onValueChange={field.onChange}
/>
</FormControl>
<FormDescription>{t('google.clientIdDescription')}</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='config.client_secret'
render={({ field }) => (
<FormItem>
<FormLabel>{t('google.clientSecret')}</FormLabel>
<FormControl>
<EnhancedInput
placeholder='GOCSPX-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'
value={field.value}
onValueChange={field.onChange}
type='password'
/>
</FormControl>
<FormDescription>{t('google.clientSecretDescription')}</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</ScrollArea>
<SheetFooter className='flex-row justify-end gap-2 pt-3'>
<Button variant='outline' disabled={loading} onClick={() => setOpen(false)}>
{t('common.cancel')}
</Button>
<Button disabled={loading} type='submit' form='google-form'>
{loading && <Icon icon='mdi:loading' className='mr-2 animate-spin' />}
{t('common.save')}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
);
}
@@ -0,0 +1,48 @@
'use client';
import { ScrollArea } from '@workspace/ui/components/scroll-area';
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetTrigger,
} from '@workspace/ui/components/sheet';
import { Icon } from '@workspace/ui/custom-components/icon';
import { useTranslations } from 'next-intl';
import { useState } from 'react';
import { LogsTable } from '../log';
export default function PhoneLogsTable() {
const t = useTranslations('auth-control');
const [open, setOpen] = useState(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:phone-log' className='text-primary h-5 w-5' />
</div>
<div className='flex-1'>
<p className='font-medium'>{t('phone.logs')}</p>
<p className='text-muted-foreground text-sm'>{t('phone.logsDescription')}</p>
</div>
</div>
<Icon icon='mdi:chevron-right' className='size-6' />
</div>
</SheetTrigger>
<SheetContent className='w-[800px] max-w-full md:max-w-screen-lg'>
<SheetHeader>
<SheetTitle>{t('phone.logs')}</SheetTitle>
</SheetHeader>
<ScrollArea className='-mx-6 h-[calc(100dvh-48px-36px-36px-env(safe-area-inset-top))]'>
<div className='px-6 pt-4'>
<LogsTable type='mobile' />
</div>
</ScrollArea>
</SheetContent>
</Sheet>
);
}
@@ -0,0 +1,505 @@
'use client';
import {
getAuthMethodConfig,
getSmsPlatform,
testSmsSend,
updateAuthMethodConfig,
} from '@/services/admin/authMethod';
import { zodResolver } from '@hookform/resolvers/zod';
import { useQuery } from '@tanstack/react-query';
import { Button } from '@workspace/ui/components/button';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@workspace/ui/components/form';
import { ScrollArea } from '@workspace/ui/components/scroll-area';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@workspace/ui/components/select';
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 { AreaCodeSelect } from '@workspace/ui/custom-components/area-code-select';
import { EnhancedInput } from '@workspace/ui/custom-components/enhanced-input';
import { Icon } from '@workspace/ui/custom-components/icon';
import TagInput from '@workspace/ui/custom-components/tag-input';
import { useTranslations } from 'next-intl';
import Link from 'next/link';
import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { toast } from 'sonner';
import { z } from 'zod';
const phoneSettingsSchema = z.object({
id: z.number(),
method: z.string().default('mobile'),
enabled: z.boolean().default(false),
config: z
.object({
enable_whitelist: z.boolean().optional(),
whitelist: z.array(z.string()).optional(),
platform: z.string().optional(),
platform_config: z
.object({
access: z.string().optional(),
endpoint: z.string().optional(),
secret: z.string().optional(),
template_code: z.string().optional(),
sign_name: z.string().optional(),
phone_number: z.string().optional(),
template: z.string().optional(),
})
.optional(),
})
.optional(),
});
type PhoneSettingsFormData = z.infer<typeof phoneSettingsSchema>;
export default function PhoneSettingsForm() {
const t = useTranslations('auth-control');
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
const [testParams, setTestParams] = useState<API.TestSmsSendRequest>({
telephone: '',
area_code: '1',
});
const { data, refetch, isFetching } = useQuery({
queryKey: ['getAuthMethodConfig', 'mobile'],
queryFn: async () => {
const { data } = await getAuthMethodConfig({
method: 'mobile',
});
return data.data;
},
enabled: open,
});
const { data: platforms } = useQuery({
queryKey: ['getSmsPlatform'],
queryFn: async () => {
const { data } = await getSmsPlatform();
return data.data?.list;
},
enabled: open,
});
const form = useForm<PhoneSettingsFormData>({
resolver: zodResolver(phoneSettingsSchema),
defaultValues: {
id: 0,
method: 'mobile',
enabled: false,
config: {
enable_whitelist: false,
whitelist: [],
platform: '',
platform_config: {
access: '',
endpoint: '',
secret: '',
template_code: 'code',
sign_name: '',
phone_number: '',
template: '',
},
},
},
});
const selectedPlatform = platforms?.find(
(platform) => platform.platform === form.watch('config.platform'),
);
const { platform_url, platform_field_description: platformConfig } = selectedPlatform ?? {};
useEffect(() => {
if (data) {
form.reset(data);
}
}, [data, form]);
async function onSubmit(values: PhoneSettingsFormData) {
setLoading(true);
try {
await updateAuthMethodConfig(values as API.UpdateAuthMethodConfigRequest);
toast.success(t('common.saveSuccess'));
refetch();
setOpen(false);
} catch (error) {
toast.error(t('common.saveFailed'));
} finally {
setLoading(false);
}
}
return (
<Sheet open={open} onOpenChange={setOpen}>
<SheetTrigger asChild>
<div className='flex cursor-pointer items-center justify-between transition-colors'>
<div className='flex items-center gap-3'>
<div className='bg-primary/10 flex h-10 w-10 items-center justify-center rounded-lg'>
<Icon icon='mdi:phone-settings' className='text-primary h-5 w-5' />
</div>
<div className='flex-1'>
<p className='font-medium'>{t('phone.title')}</p>
<p className='text-muted-foreground text-sm'>{t('phone.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('phone.title')}</SheetTitle>
</SheetHeader>
<ScrollArea className='-mx-6 h-[calc(100dvh-48px-36px-36px-env(safe-area-inset-top))] px-6'>
<Form {...form}>
<form
id='phone-settings-form'
onSubmit={form.handleSubmit(onSubmit)}
className='space-y-2 pt-4'
>
<FormField
control={form.control}
name='enabled'
render={({ field }) => (
<FormItem>
<FormLabel>{t('phone.enable')}</FormLabel>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
disabled={isFetching}
className='float-end !mt-0'
/>
</FormControl>
<FormDescription>{t('phone.enableTip')}</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='config.enable_whitelist'
render={({ field }) => (
<FormItem>
<FormLabel>{t('phone.whitelistValidation')}</FormLabel>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
className='float-end !mt-0'
/>
</FormControl>
<FormDescription>{t('phone.whitelistValidationTip')}</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='config.whitelist'
render={({ field }) => (
<FormItem>
<FormLabel>{t('phone.whitelistAreaCode')}</FormLabel>
<FormControl>
<TagInput
placeholder='1, 852, 886, 888'
value={field.value}
onChange={field.onChange}
/>
</FormControl>
<FormDescription>{t('phone.whitelistAreaCodeTip')}</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='config.platform'
render={({ field }) => (
<FormItem>
<FormLabel>{t('phone.platform')}</FormLabel>
<div className='flex items-center gap-1'>
<FormControl>
<Select
value={field.value}
onValueChange={field.onChange}
disabled={isFetching}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{platforms?.map((item) => (
<SelectItem key={item.platform} value={item.platform}>
{item.platform}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
{platform_url && (
<Button size='sm' asChild>
<Link href={platform_url} target='_blank'>
{t('phone.applyPlatform')}
</Link>
</Button>
)}
</div>
<FormDescription>{t('phone.platformTip')}</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='config.platform_config.access'
render={({ field }) => (
<FormItem>
<FormLabel>{t('phone.accessLabel')}</FormLabel>
<FormControl>
<EnhancedInput
value={field.value}
onValueChange={field.onChange}
disabled={isFetching}
placeholder={t('phone.platformConfigTip', { key: platformConfig?.access })}
/>
</FormControl>
<FormDescription>
{t('phone.platformConfigTip', { key: platformConfig?.access })}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{platformConfig?.endpoint && (
<FormField
control={form.control}
name='config.platform_config.endpoint'
render={({ field }) => (
<FormItem>
<FormLabel>{t('phone.endpointLabel')}</FormLabel>
<FormControl>
<EnhancedInput
value={field.value}
onValueChange={field.onChange}
disabled={isFetching}
placeholder={t('phone.platformConfigTip', {
key: platformConfig?.endpoint,
})}
/>
</FormControl>
<FormDescription>
{t('phone.platformConfigTip', { key: platformConfig?.endpoint })}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
)}
<FormField
control={form.control}
name='config.platform_config.secret'
render={({ field }) => (
<FormItem>
<FormLabel>{t('phone.secretLabel')}</FormLabel>
<FormControl>
<EnhancedInput
type='password'
value={field.value}
onValueChange={field.onChange}
disabled={isFetching}
placeholder={t('phone.platformConfigTip', { key: platformConfig?.secret })}
/>
</FormControl>
<FormDescription>
{t('phone.platformConfigTip', { key: platformConfig?.secret })}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{platformConfig?.template_code && (
<FormField
control={form.control}
name='config.platform_config.template_code'
render={({ field }) => (
<FormItem>
<FormLabel>{t('phone.templateCodeLabel')}</FormLabel>
<FormControl>
<EnhancedInput
value={field.value}
onValueChange={field.onChange}
disabled={isFetching}
placeholder={t('phone.platformConfigTip', {
key: platformConfig?.template_code,
})}
/>
</FormControl>
<FormDescription>
{t('phone.platformConfigTip', { key: platformConfig?.template_code })}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
)}
{platformConfig?.sign_name && (
<FormField
control={form.control}
name='config.platform_config.sign_name'
render={({ field }) => (
<FormItem>
<FormLabel>{t('phone.signNameLabel')}</FormLabel>
<FormControl>
<EnhancedInput
value={field.value}
onValueChange={field.onChange}
disabled={isFetching}
placeholder={t('phone.platformConfigTip', {
key: platformConfig?.sign_name,
})}
/>
</FormControl>
<FormDescription>
{t('phone.platformConfigTip', { key: platformConfig?.sign_name })}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
)}
{platformConfig?.phone_number && (
<FormField
control={form.control}
name='config.platform_config.phone_number'
render={({ field }) => (
<FormItem>
<FormLabel>{t('phone.phoneNumberLabel')}</FormLabel>
<FormControl>
<EnhancedInput
value={field.value}
onValueChange={field.onChange}
disabled={isFetching}
placeholder={t('phone.platformConfigTip', {
key: platformConfig?.phone_number,
})}
/>
</FormControl>
<FormDescription>
{t('phone.platformConfigTip', { key: platformConfig?.phone_number })}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
)}
{platformConfig?.code_variable && (
<FormField
control={form.control}
name='config.platform_config.template'
render={({ field }) => (
<FormItem>
<FormLabel>{t('phone.template')}</FormLabel>
<FormControl>
<Textarea
value={field.value}
onChange={field.onChange}
disabled={isFetching}
placeholder={t('phone.placeholders.template', {
code: platformConfig?.code_variable,
})}
/>
</FormControl>
<FormDescription>
{t('phone.templateTip', { code: platformConfig?.code_variable })}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
)}
<div className='space-y-4 border-t pt-4'>
<div>
<FormLabel>{t('phone.testSms')}</FormLabel>
<p className='text-muted-foreground mb-3 text-sm'>{t('phone.testSmsTip')}</p>
<div className='flex items-center gap-2'>
<AreaCodeSelect
value={testParams.area_code}
onChange={(value) => {
if (value.phone) {
setTestParams((prev) => ({ ...prev, area_code: value.phone! }));
}
}}
/>
<EnhancedInput
placeholder={t('phone.testSmsPhone')}
value={testParams.telephone}
onValueChange={(value) => {
setTestParams((prev) => ({ ...prev, telephone: value as string }));
}}
/>
<Button
type='button'
disabled={!testParams.telephone || !testParams.area_code || isFetching}
onClick={async () => {
if (isFetching || !testParams.telephone || !testParams.area_code) return;
try {
await testSmsSend(testParams);
toast.success(t('phone.sendSuccess'));
} catch {
toast.error(t('phone.sendFailed'));
}
}}
>
{t('phone.testSms')}
</Button>
</div>
</div>
</div>
</form>
</Form>
</ScrollArea>
<SheetFooter className='flex-row justify-end gap-2 pt-3'>
<Button variant='outline' disabled={loading} onClick={() => setOpen(false)}>
{t('common.cancel')}
</Button>
<Button disabled={loading} type='submit' form='phone-settings-form'>
{loading && <Icon icon='mdi:loading' className='mr-2 animate-spin' />}
{t('common.save')}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
);
}
@@ -0,0 +1,198 @@
'use client';
import { getAuthMethodConfig, updateAuthMethodConfig } from '@/services/admin/authMethod';
import { zodResolver } from '@hookform/resolvers/zod';
import { useQuery } from '@tanstack/react-query';
import { Button } from '@workspace/ui/components/button';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@workspace/ui/components/form';
import { ScrollArea } from '@workspace/ui/components/scroll-area';
import {
Sheet,
SheetContent,
SheetFooter,
SheetHeader,
SheetTitle,
SheetTrigger,
} from '@workspace/ui/components/sheet';
import { Switch } from '@workspace/ui/components/switch';
import { EnhancedInput } from '@workspace/ui/custom-components/enhanced-input';
import { Icon } from '@workspace/ui/custom-components/icon';
import { useTranslations } from 'next-intl';
import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { toast } from 'sonner';
import { z } from 'zod';
const telegramSchema = z.object({
enabled: z.boolean(),
bot: z.string().optional(),
bot_token: z.string().optional(),
});
type TelegramFormData = z.infer<typeof telegramSchema>;
export default function TelegramForm() {
const t = useTranslations('auth-control');
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
const { data, refetch } = useQuery({
queryKey: ['getAuthMethodConfig', 'telegram'],
queryFn: async () => {
const { data } = await getAuthMethodConfig({
method: 'telegram',
});
return data.data;
},
});
const form = useForm<TelegramFormData>({
resolver: zodResolver(telegramSchema),
defaultValues: {
enabled: false,
bot: '',
bot_token: '',
},
});
useEffect(() => {
if (data) {
form.reset({
enabled: data.enabled || false,
bot: data.config?.bot || '',
bot_token: data.config?.bot_token || '',
});
}
}, [data, form]);
async function onSubmit(values: TelegramFormData) {
setLoading(true);
try {
await updateAuthMethodConfig({
...data,
enabled: values.enabled,
config: {
...data?.config,
bot: values.bot,
bot_token: values.bot_token,
},
} as API.UpdateAuthMethodConfigRequest);
toast.success(t('common.saveSuccess'));
refetch();
setOpen(false);
} catch (error) {
toast.error(t('common.saveFailed'));
} finally {
setLoading(false);
}
}
return (
<Sheet open={open} onOpenChange={setOpen}>
<SheetTrigger asChild>
<div className='flex cursor-pointer items-center justify-between transition-colors'>
<div className='flex items-center gap-3'>
<div className='bg-primary/10 flex h-10 w-10 items-center justify-center rounded-lg'>
<Icon icon='mdi:telegram' className='text-primary h-5 w-5' />
</div>
<div className='flex-1'>
<p className='font-medium'>{t('telegram.title')}</p>
<p className='text-muted-foreground text-sm'>{t('telegram.description')}</p>
</div>
</div>
<Icon icon='mdi:chevron-right' className='size-6' />
</div>
</SheetTrigger>
<SheetContent className='w-[500px] max-w-full md:max-w-screen-md'>
<SheetHeader>
<SheetTitle>{t('telegram.title')}</SheetTitle>
</SheetHeader>
<ScrollArea className='-mx-6 h-[calc(100dvh-48px-36px-36px-env(safe-area-inset-top))] px-6'>
<Form {...form}>
<form
id='telegram-form'
onSubmit={form.handleSubmit(onSubmit)}
className='space-y-2 pt-4'
>
<FormField
control={form.control}
name='enabled'
render={({ field }) => (
<FormItem>
<FormLabel>{t('telegram.enable')}</FormLabel>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
className='float-end !mt-0'
/>
</FormControl>
<FormDescription>{t('telegram.enableDescription')}</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='bot'
render={({ field }) => (
<FormItem>
<FormLabel>{t('telegram.clientId')}</FormLabel>
<FormControl>
<EnhancedInput
placeholder='6123456789'
value={field.value}
onValueChange={field.onChange}
/>
</FormControl>
<FormDescription>{t('telegram.clientIdDescription')}</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='bot_token'
render={({ field }) => (
<FormItem>
<FormLabel>{t('telegram.clientSecret')}</FormLabel>
<FormControl>
<EnhancedInput
placeholder='6123456789:AAHn_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'
value={field.value}
onValueChange={field.onChange}
type='password'
/>
</FormControl>
<FormDescription>{t('telegram.clientSecretDescription')}</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</ScrollArea>
<SheetFooter className='flex-row justify-end gap-2 pt-3'>
<Button variant='outline' disabled={loading} onClick={() => setOpen(false)}>
{t('common.cancel')}
</Button>
<Button disabled={loading} type='submit' form='telegram-form'>
{loading && <Icon icon='mdi:loading' className='mr-2 animate-spin' />}
{t('common.save')}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
);
}
@@ -1,103 +0,0 @@
'use client';
import { getInviteConfig, updateInviteConfig } from '@/services/admin/system';
import { useQuery } from '@tanstack/react-query';
import { Card, CardContent, CardHeader, CardTitle } from '@workspace/ui/components/card';
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 { EnhancedInput } from '@workspace/ui/custom-components/enhanced-input';
import { useTranslations } from 'next-intl';
import { toast } from 'sonner';
export function Invite() {
const t = useTranslations('auth-control.invite');
const { data, refetch } = useQuery({
queryKey: ['getInviteConfig'],
queryFn: async () => {
const { data } = await getInviteConfig();
return data.data;
},
});
async function updateConfig(key: string, value: unknown) {
if (data?.[key] === value) return;
try {
await updateInviteConfig({
...data,
[key]: value,
} as API.InviteConfig);
toast.success(t('saveSuccess'));
refetch();
} catch (error) {
/* empty */
}
}
return (
<Card>
<CardHeader>
<CardTitle>{t('inviteSettings')}</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableBody>
<TableRow>
<TableCell>
<Label>{t('enableForcedInvite')}</Label>
<p className='text-muted-foreground text-xs'>
{t('enableForcedInviteDescription')}
</p>
</TableCell>
<TableCell className='text-right'>
<Switch
checked={data?.forced_invite}
onCheckedChange={(checked) => {
updateConfig('forced_invite', checked);
}}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Label>{t('inviteCommissionPercentage')}</Label>
<p className='text-muted-foreground text-xs'>
{t('inviteCommissionPercentageDescription')}
</p>
</TableCell>
<TableCell className='text-right'>
<EnhancedInput
placeholder={t('inputPlaceholder')}
value={data?.referral_percentage}
type='number'
min={0}
max={100}
suffix='%'
onValueBlur={(value) => updateConfig('referral_percentage', value)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Label>{t('commissionFirstTimeOnly')}</Label>
<p className='text-muted-foreground text-xs'>
{t('commissionFirstTimeOnlyDescription')}
</p>
</TableCell>
<TableCell className='text-right'>
<Switch
checked={data?.only_first_purchase}
onCheckedChange={(checked) => {
updateConfig('only_first_purchase', checked);
}}
/>
</TableCell>
</TableRow>
</TableBody>
</Table>
</CardContent>
</Card>
);
}
@@ -1,17 +0,0 @@
'use client';
import { Invite } from './invite';
import { Register } from './register';
import { Verify } from './verify';
import { VerifyCode } from './verify-code';
export default function Page() {
return (
<div className='space-y-3'>
<Invite />
<Register />
<VerifyCode />
<Verify />
</div>
);
}
@@ -1,201 +0,0 @@
'use client';
import { getSubscribeList } from '@/services/admin/subscribe';
import { getRegisterConfig, updateRegisterConfig } from '@/services/admin/system';
import { useQuery } from '@tanstack/react-query';
import { Card, CardContent, CardHeader, CardTitle } from '@workspace/ui/components/card';
import { Label } from '@workspace/ui/components/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@workspace/ui/components/select';
import { Switch } from '@workspace/ui/components/switch';
import { Table, TableBody, TableCell, TableRow } from '@workspace/ui/components/table';
import { Combobox } from '@workspace/ui/custom-components/combobox';
import { EnhancedInput } from '@workspace/ui/custom-components/enhanced-input';
import { useTranslations } from 'next-intl';
import { toast } from 'sonner';
export function Register() {
const t = useTranslations('auth-control.register');
const { data, refetch } = useQuery({
queryKey: ['getRegisterConfig'],
queryFn: async () => {
const { data } = await getRegisterConfig();
return data.data;
},
});
async function updateConfig(key: string, value: unknown) {
if (data?.[key] === value) return;
await updateRegisterConfig({
...data,
[key]: value,
} as API.RegisterConfig);
toast.success(t('saveSuccess'));
refetch();
}
const { data: subscribe } = useQuery({
queryKey: ['getSubscribeList', 'all'],
queryFn: async () => {
const { data } = await getSubscribeList({
page: 1,
size: 9999,
});
return data.data?.list as API.Subscribe[];
},
});
return (
<Card>
<CardHeader>
<CardTitle>{t('registerSettings')}</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableBody>
<TableRow>
<TableCell>
<Label>{t('stopNewUserRegistration')}</Label>
<p className='text-muted-foreground text-xs'>
{t('stopNewUserRegistrationDescription')}
</p>
</TableCell>
<TableCell className='text-right'>
<Switch
checked={data?.stop_register}
onCheckedChange={(checked) => {
updateConfig('stop_register', checked);
}}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Label>{t('ipRegistrationLimit')}</Label>
<p className='text-muted-foreground text-xs'>
{t('ipRegistrationLimitDescription')}
</p>
</TableCell>
<TableCell className='text-right'>
<Switch
checked={data?.enable_ip_register_limit}
onCheckedChange={(checked) => {
updateConfig('enable_ip_register_limit', checked);
}}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Label>{t('registrationLimitCount')}</Label>
<p className='text-muted-foreground text-xs'>
{t('registrationLimitCountDescription')}
</p>
</TableCell>
<TableCell className='text-right'>
<EnhancedInput
type='number'
min={0}
value={data?.ip_register_limit}
onValueBlur={(value) => updateConfig('ip_register_limit', value)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Label>{t('penaltyTime')}</Label>
<p className='text-muted-foreground text-xs'>{t('penaltyTimeDescription')}</p>
</TableCell>
<TableCell className='text-right'>
<EnhancedInput
type='number'
min={0}
value={data?.ip_register_limit_duration}
onValueBlur={(value) => updateConfig('ip_register_limit_duration', value)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Label>{t('trialRegistration')}</Label>
<p className='text-muted-foreground text-xs'>{t('trialRegistrationDescription')}</p>
</TableCell>
<TableCell className='text-right'>
<Switch
checked={data?.enable_trial}
onCheckedChange={(checked) => {
updateConfig('enable_trial', checked);
}}
/>
</TableCell>
</TableRow>
<TableRow className='flex flex-col'>
<TableCell className='w-full'>
<Label>{t('trialSubscribePlan')}</Label>
<p className='text-muted-foreground text-xs'>
{t('trialSubscribePlanDescription')}
</p>
</TableCell>
<TableCell className='max-w-96'>
<EnhancedInput
placeholder={t('trialDuration')}
type='number'
min={0}
value={data?.trial_time}
onValueBlur={(value) => updateConfig('trial_time', value)}
prefix={
<Select
value={String(data?.trial_subscribe)}
onValueChange={(value) => updateConfig('trial_subscribe', Number(value))}
>
<SelectTrigger className='bg-secondary rounded-r-none'>
{data?.trial_subscribe ? (
<SelectValue placeholder='Select Subscribe' />
) : (
'Select Subscribe'
)}
</SelectTrigger>
<SelectContent>
{subscribe?.map((item) => (
<SelectItem key={item.id} value={String(item.id)}>
{item.name}
</SelectItem>
))}
</SelectContent>
</Select>
}
suffix={
<Combobox
className='bg-secondary rounded-l-none'
value={data?.trial_time_unit}
onChange={(value) => {
if (value) {
updateConfig('trial_time_unit', value);
}
}}
options={[
{ label: t('noLimit'), value: 'NoLimit' },
{ label: t('year'), value: 'Year' },
{ label: t('month'), value: 'Month' },
{ label: t('day'), value: 'Day' },
{ label: t('hour'), value: 'Hour' },
{ label: t('minute'), value: 'Minute' },
]}
/>
}
/>
</TableCell>
</TableRow>
</TableBody>
</Table>
</CardContent>
</Card>
);
}
@@ -1,95 +0,0 @@
'use client';
import { getVerifyCodeConfig, updateVerifyCodeConfig } from '@/services/admin/system';
import { useQuery } from '@tanstack/react-query';
import { Card, CardContent, CardHeader, CardTitle } from '@workspace/ui/components/card';
import { Label } from '@workspace/ui/components/label';
import { Table, TableBody, TableCell, TableRow } from '@workspace/ui/components/table';
import { EnhancedInput } from '@workspace/ui/custom-components/enhanced-input';
import { useTranslations } from 'next-intl';
import { toast } from 'sonner';
export function VerifyCode() {
const t = useTranslations('auth-control.verify-code');
const { data, refetch } = useQuery({
queryKey: ['getVerifyCodeConfig'],
queryFn: async () => {
const { data } = await getVerifyCodeConfig();
return data.data;
},
});
async function updateConfig(key: string, value: unknown) {
if (data?.[key] === value) return;
try {
await updateVerifyCodeConfig({
...data,
[key]: value,
} as API.VerifyCodeConfig);
toast.success(t('saveSuccess'));
refetch();
} catch (error) {
/* empty */
}
}
return (
<Card className='mb-6'>
<CardHeader>
<CardTitle>{t('verifyCodeSettings')}</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableBody>
<TableRow>
<TableCell>
<Label>{t('expireTime')}</Label>
<p className='text-muted-foreground text-xs'>{t('expireTimeDescription')}</p>
</TableCell>
<TableCell className='text-right'>
<EnhancedInput
type='number'
placeholder='300'
value={data?.verify_code_expire_time}
onValueBlur={(value) => updateConfig('verify_code_expire_time', Number(value))}
suffix={t('second')}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Label>{t('interval')}</Label>
<p className='text-muted-foreground text-xs'>{t('intervalDescription')}</p>
</TableCell>
<TableCell className='text-right'>
<EnhancedInput
type='number'
placeholder='60'
value={data?.verify_code_interval}
onValueBlur={(value) => updateConfig('verify_code_interval', Number(value))}
suffix={t('second')}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Label>{t('dailyLimit')}</Label>
<p className='text-muted-foreground text-xs'>{t('dailyLimitDescription')}</p>
</TableCell>
<TableCell className='text-right'>
<EnhancedInput
type='number'
placeholder='15'
value={data?.verify_code_limit}
onValueBlur={(value) => updateConfig('verify_code_limit', Number(value))}
suffix={t('times')}
/>
</TableCell>
</TableRow>
</TableBody>
</Table>
</CardContent>
</Card>
);
}
@@ -1,125 +0,0 @@
'use client';
import { getVerifyConfig, updateVerifyConfig } from '@/services/admin/system';
import { useQuery } from '@tanstack/react-query';
import { Card, CardContent, CardHeader, CardTitle } from '@workspace/ui/components/card';
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 { EnhancedInput } from '@workspace/ui/custom-components/enhanced-input';
import { useTranslations } from 'next-intl';
import { toast } from 'sonner';
export function Verify() {
const t = useTranslations('auth-control.verify');
const { data, refetch } = useQuery({
queryKey: ['getVerifyConfig'],
queryFn: async () => {
const { data } = await getVerifyConfig();
return data.data;
},
});
async function updateConfig(key: string, value: unknown) {
if (data?.[key] === value) return;
try {
await updateVerifyConfig({
...data,
[key]: value,
} as API.VerifyConfig);
toast.success(t('saveSuccess'));
refetch();
} catch (error) {
/* empty */
}
}
return (
<Card className='mb-6'>
<CardHeader>
<CardTitle>{t('verifySettings')}</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableBody>
<TableRow>
<TableCell>
<Label>Turnstile Site Key</Label>
<p className='text-muted-foreground text-xs'>{t('turnstileSiteKeyDescription')}</p>
</TableCell>
<TableCell className='text-right'>
<EnhancedInput
placeholder={t('inputPlaceholder')}
value={data?.turnstile_site_key}
onValueBlur={(value) => updateConfig('turnstile_site_key', value)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Label>Turnstile Site Secret</Label>
<p className='text-muted-foreground text-xs'>{t('turnstileSecretDescription')}</p>
</TableCell>
<TableCell className='text-right'>
<EnhancedInput
placeholder={t('inputPlaceholder')}
value={data?.turnstile_secret}
onValueBlur={(value) => updateConfig('turnstile_secret', value)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Label>{t('registrationVerificationCode')}</Label>
<p className='text-muted-foreground text-xs'>
{t('registrationVerificationCodeDescription')}
</p>
</TableCell>
<TableCell className='text-right'>
<Switch
checked={data?.enable_register_verify}
onCheckedChange={(checked) => {
updateConfig('enable_register_verify', checked);
}}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Label>{t('loginVerificationCode')}</Label>
<p className='text-muted-foreground text-xs'>
{t('loginVerificationCodeDescription')}
</p>
</TableCell>
<TableCell className='text-right'>
<Switch
checked={data?.enable_login_verify}
onCheckedChange={(checked) => {
updateConfig('enable_login_verify', checked);
}}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Label>{t('resetPasswordVerificationCode')}</Label>
<p className='text-muted-foreground text-xs'>
{t('resetPasswordVerificationCodeDescription')}
</p>
</TableCell>
<TableCell className='text-right'>
<Switch
checked={data?.enable_reset_password_verify}
onCheckedChange={(checked) => {
updateConfig('enable_reset_password_verify', checked);
}}
/>
</TableCell>
</TableRow>
</TableBody>
</Table>
</CardContent>
</Card>
);
}
@@ -1,91 +0,0 @@
'use client';
import { getAuthMethodConfig, updateAuthMethodConfig } from '@/services/admin/authMethod';
import { useQuery } from '@tanstack/react-query';
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 { EnhancedInput } from '@workspace/ui/custom-components/enhanced-input';
import { useTranslations } from 'next-intl';
import { toast } from 'sonner';
export default function Page() {
const t = useTranslations('github');
const { data, refetch } = useQuery({
queryKey: ['getAuthMethodConfig', 'github'],
queryFn: async () => {
const { data } = await getAuthMethodConfig({
method: 'github',
});
return data.data;
},
});
async function updateConfig(key: keyof API.UpdateAuthMethodConfigRequest, value: unknown) {
try {
await updateAuthMethodConfig({
...data,
[key]: value,
} as API.UpdateAuthMethodConfigRequest);
toast.success(t('saveSuccess'));
refetch();
} catch (error) {
toast.error(t('saveFailed'));
}
}
return (
<Table>
<TableBody>
<TableRow>
<TableCell>
<Label>{t('enable')}</Label>
<p className='text-muted-foreground text-xs'>{t('enableDescription')}</p>
</TableCell>
<TableCell className='text-right'>
<Switch
checked={data?.enabled}
onCheckedChange={(checked) => updateConfig('enabled', checked)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Label>{t('clientId')}</Label>
<p className='text-muted-foreground text-xs'>{t('clientIdDescription')}</p>
</TableCell>
<TableCell className='text-right'>
<EnhancedInput
placeholder='e.g., Iv1.1234567890abcdef'
value={data?.config?.client_id}
onValueBlur={(value) =>
updateConfig('config', {
...data?.config,
client_id: value,
})
}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell className='align-top'>
<Label>{t('clientSecret')}</Label>
<p className='text-muted-foreground text-xs'>{t('clientSecretDescription')}</p>
</TableCell>
<TableCell className='text-right'>
<EnhancedInput
placeholder='e.g., 1234567890abcdef1234567890abcdef12345678'
value={data?.config?.client_secret}
onValueBlur={(value) => {
updateConfig('config', {
...data?.config,
client_secret: value,
});
}}
/>
</TableCell>
</TableRow>
</TableBody>
</Table>
);
}
@@ -1,92 +0,0 @@
'use client';
import { getAuthMethodConfig, updateAuthMethodConfig } from '@/services/admin/authMethod';
import { useQuery } from '@tanstack/react-query';
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 { EnhancedInput } from '@workspace/ui/custom-components/enhanced-input';
import { useTranslations } from 'next-intl';
import { toast } from 'sonner';
export default function Page() {
const t = useTranslations('google');
const { data, refetch } = useQuery({
queryKey: ['getAuthMethodConfig', 'google'],
queryFn: async () => {
const { data } = await getAuthMethodConfig({
method: 'google',
});
return data.data;
},
});
async function updateConfig(key: keyof API.UpdateAuthMethodConfigRequest, value: unknown) {
try {
await updateAuthMethodConfig({
...data,
[key]: value,
} as API.UpdateAuthMethodConfigRequest);
toast.success(t('saveSuccess'));
refetch();
} catch (error) {
toast.error(t('saveFailed'));
}
}
return (
<Table>
<TableBody>
<TableRow>
<TableCell>
<Label>{t('enable')}</Label>
<p className='text-muted-foreground text-xs'>{t('enableDescription')}</p>
</TableCell>
<TableCell className='text-right'>
<Switch
checked={data?.enabled}
onCheckedChange={(checked) => updateConfig('enabled', checked)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Label>{t('clientId')}</Label>
<p className='text-muted-foreground text-xs'>{t('clientIdDescription')}</p>
</TableCell>
<TableCell className='text-right'>
<EnhancedInput
placeholder='123456789-abc123def456.apps.googleusercontent.com'
value={data?.config?.client_id}
onValueBlur={(value) => {
updateConfig('config', {
...data?.config,
client_id: value,
});
}}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell className='align-top'>
<Label>{t('clientSecret')}</Label>
<p className='text-muted-foreground text-xs'>{t('clientSecretDescription')}</p>
</TableCell>
<TableCell className='text-right'>
<EnhancedInput
placeholder='GOCSPX-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'
value={data?.config?.client_secret}
onValueBlur={(value) => {
updateConfig('config', {
...data?.config,
client_secret: value,
});
}}
/>
</TableCell>
</TableRow>
</TableBody>
</Table>
);
}
@@ -1,29 +0,0 @@
'use client';
import { AuthControl } from '@/config/navs';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@workspace/ui/components/tabs';
import { useTranslations } from 'next-intl';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
interface AuthControlLayoutProps {
children: React.ReactNode;
}
export default function AuthControlLayout({ children }: Readonly<AuthControlLayoutProps>) {
const pathname = usePathname();
const t = useTranslations('menu');
if (!pathname) return null;
return (
<Tabs value={pathname}>
<TabsList className='h-full flex-wrap'>
{AuthControl.map((item) => (
<TabsTrigger key={item.url} value={item.url} asChild>
<Link href={item.url}>{t(item.title)}</Link>
</TabsTrigger>
))}
</TabsList>
<TabsContent value={pathname}>{children}</TabsContent>
</Tabs>
);
}
@@ -66,10 +66,6 @@ export function LogsTable({ type }: { type: 'email' | 'mobile' }) {
},
]}
params={[
// {
// key: 'platform',
// placeholder: t('platform'),
// },
{
key: 'to',
placeholder: t('to'),
+66 -2
View File
@@ -1,5 +1,69 @@
import { redirect } from 'next/navigation';
'use client';
import { Table, TableBody, TableCell, TableRow } from '@workspace/ui/components/table';
import { useTranslations } from 'next-intl';
import AppleForm from './forms/apple-form';
import DeviceForm from './forms/device-form';
import EmailLogsTable from './forms/email-logs-table';
import EmailSettingsForm from './forms/email-settings-form';
import FacebookForm from './forms/facebook-form';
import GithubForm from './forms/github-form';
import GoogleForm from './forms/google-form';
import PhoneLogsTable from './forms/phone-logs-table';
import PhoneSettingsForm from './forms/phone-settings-form';
import TelegramForm from './forms/telegram-form';
export default function Page() {
return redirect('/dashboard/auth-control/general');
const t = useTranslations('auth-control');
// 定义表单配置
const formSections = [
{
title: t('communicationMethods'),
forms: [
{ component: EmailSettingsForm },
{ component: EmailLogsTable },
{ component: PhoneSettingsForm },
{ component: PhoneLogsTable },
],
},
{
title: t('socialAuthMethods'),
forms: [
{ component: AppleForm },
{ component: GoogleForm },
{ component: FacebookForm },
{ component: GithubForm },
{ component: TelegramForm },
],
},
{
title: t('deviceAuthMethods'),
forms: [{ component: DeviceForm }],
},
];
return (
<div className='space-y-8'>
{formSections.map((section, sectionIndex) => (
<div key={sectionIndex}>
<h2 className='mb-4 text-lg font-semibold'>{section.title}</h2>
<Table>
<TableBody>
{section.forms.map((form, formIndex) => {
const FormComponent = form.component;
return (
<TableRow key={formIndex}>
<TableCell>
<FormComponent />
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
))}
</div>
);
}
@@ -1,395 +0,0 @@
'use client';
import {
getAuthMethodConfig,
getSmsPlatform,
testSmsSend,
updateAuthMethodConfig,
} from '@/services/admin/authMethod';
import { useQuery } from '@tanstack/react-query';
import { Button } from '@workspace/ui/components/button';
import { Label } from '@workspace/ui/components/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@workspace/ui/components/select';
import { Switch } from '@workspace/ui/components/switch';
import { Table, TableBody, TableCell, TableRow } from '@workspace/ui/components/table';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@workspace/ui/components/tabs';
import { Textarea } from '@workspace/ui/components/textarea';
import { AreaCodeSelect } from '@workspace/ui/custom-components/area-code-select';
import { EnhancedInput } from '@workspace/ui/custom-components/enhanced-input';
import TagInput from '@workspace/ui/custom-components/tag-input';
import { useTranslations } from 'next-intl';
import Link from 'next/link';
import { useState } from 'react';
import { toast } from 'sonner';
import { LogsTable } from '../log';
export default function Page() {
const t = useTranslations('phone');
const { data, refetch, isFetching } = useQuery({
queryKey: ['getAuthMethodConfig', 'mobile'],
queryFn: async () => {
const { data } = await getAuthMethodConfig({
method: 'mobile',
});
return data.data;
},
});
const { data: platforms } = useQuery({
queryKey: ['getSmsPlatform'],
queryFn: async () => {
const { data } = await getSmsPlatform();
return data.data?.list;
},
});
const selectedPlatform = platforms?.find(
(platform) => platform.platform === data?.config?.platform,
);
const { platform_url, platform_field_description: platformConfig } = selectedPlatform ?? {};
async function updateConfig(key: string, value: unknown) {
if (data?.[key] === value) return;
try {
await updateAuthMethodConfig({
...data,
[key]: value,
} as API.UpdateAuthMethodConfigRequest);
toast.success(t('updateSuccess'));
refetch();
} catch (error) {
/* empty */
}
}
const [params, setParams] = useState<API.TestSmsSendRequest>({
telephone: '',
area_code: '1',
});
return (
<Tabs defaultValue='settings' className='w-full'>
<TabsList>
<TabsTrigger value='settings'>{t('settings')}</TabsTrigger>
<TabsTrigger value='logs'>{t('logs')}</TabsTrigger>
</TabsList>
<TabsContent value='settings'>
<Table>
<TableBody>
<TableRow>
<TableCell>
<Label>{t('enable')}</Label>
<p className='text-muted-foreground text-xs'>{t('enableTip')}</p>
</TableCell>
<TableCell className='text-right'>
<Switch
checked={data?.enabled}
onCheckedChange={(checked) => updateConfig('enabled', checked)}
disabled={isFetching}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Label>{t('whitelistValidation')}</Label>
<p className='text-muted-foreground text-xs'>{t('whitelistValidationTip')}</p>
</TableCell>
<TableCell className='text-right'>
<Switch
defaultValue={data?.config?.enable_whitelist}
onCheckedChange={(checked) =>
updateConfig('config', { ...data?.config, enable_whitelist: checked })
}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Label>{t('whitelistAreaCode')}</Label>
<p className='text-muted-foreground text-xs'>{t('whitelistAreaCodeTip')}</p>
</TableCell>
<TableCell className='w-1/2 text-right'>
<TagInput
placeholder='1, 852, 886, 888'
value={data?.config?.whitelist || []}
onChange={(value) =>
updateConfig('config', { ...data?.config, whitelist: value })
}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Label>{t('platform')}</Label>
<p className='text-muted-foreground text-xs'>{t('platformTip')}</p>
</TableCell>
<TableCell className='flex items-center gap-1 text-right'>
<Select
value={data?.config?.platform}
onValueChange={(value) =>
updateConfig('config', {
...data?.config,
platform: value,
})
}
disabled={isFetching}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{platforms?.map((item) => (
<SelectItem key={item.platform} value={item.platform}>
{item.platform}
</SelectItem>
))}
</SelectContent>
</Select>
{platform_url && (
<Button size='sm' asChild>
<Link href={platform_url} target='_blank'>
{t('applyPlatform')}
</Link>
</Button>
)}
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Label>{t('accessLabel')}</Label>
<p className='text-muted-foreground text-xs'>
{t('platformConfigTip', { key: platformConfig?.access })}
</p>
</TableCell>
<TableCell className='text-right'>
<EnhancedInput
value={data?.config?.platform_config.access ?? ''}
onValueBlur={(value) =>
updateConfig('config', {
...data?.config,
platform_config: {
...data?.config?.platform_config,
access: value,
},
})
}
disabled={isFetching}
placeholder={t('platformConfigTip', { key: platformConfig?.access })}
/>
</TableCell>
</TableRow>
{platformConfig?.endpoint && (
<TableRow>
<TableCell>
<Label>{t('endpointLabel')}</Label>
<p className='text-muted-foreground text-xs'>
{t('platformConfigTip', { key: platformConfig?.endpoint })}
</p>
</TableCell>
<TableCell className='text-right'>
<EnhancedInput
value={data?.config?.platform_config.endpoint ?? ''}
onValueBlur={(value) =>
updateConfig('config', {
...data?.config,
platform_config: {
...data?.config?.platform_config,
endpoint: value,
},
})
}
disabled={isFetching}
placeholder={t('platformConfigTip', { key: platformConfig?.endpoint })}
/>
</TableCell>
</TableRow>
)}
<TableRow>
<TableCell>
<Label>{t('secretLabel')}</Label>
<p className='text-muted-foreground text-xs'>
{t('platformConfigTip', { key: platformConfig?.secret })}
</p>
</TableCell>
<TableCell className='text-right'>
<EnhancedInput
value={data?.config?.platform_config?.secret ?? ''}
onValueBlur={(value) =>
updateConfig('config', {
...data?.config,
platform_config: {
...data?.config?.platform_config,
secret: value,
},
})
}
disabled={isFetching}
type='password'
placeholder={t('platformConfigTip', { key: platformConfig?.secret })}
/>
</TableCell>
</TableRow>
{platformConfig?.template_code && (
<TableRow>
<TableCell>
<Label>{t('templateCodeLabel')}</Label>
<p className='text-muted-foreground text-xs'>
{t('platformConfigTip', { key: platformConfig?.template_code })}
</p>
</TableCell>
<TableCell className='text-right'>
<EnhancedInput
value={data?.config?.platform_config?.template_code ?? 'code'}
onValueBlur={(value) =>
updateConfig('config', {
...data?.config,
platform_config: {
...data?.config?.platform_config,
template_code: value,
},
})
}
disabled={isFetching}
placeholder={t('platformConfigTip', { key: platformConfig?.template_code })}
/>
</TableCell>
</TableRow>
)}
{platformConfig?.sign_name && (
<TableRow>
<TableCell>
<Label>{t('signNameLabel')}</Label>
<p className='text-muted-foreground text-xs'>
{t('platformConfigTip', { key: platformConfig?.sign_name })}
</p>
</TableCell>
<TableCell className='text-right'>
<EnhancedInput
value={data?.config?.platform_config?.sign_name ?? ''}
onValueBlur={(value) =>
updateConfig('config', {
...data?.config,
platform_config: {
...data?.config?.platform_config,
sign_name: value,
},
})
}
disabled={isFetching}
placeholder={t('platformConfigTip', {
key: platformConfig?.sign_name,
})}
/>
</TableCell>
</TableRow>
)}
{platformConfig?.phone_number && (
<TableRow>
<TableCell>
<Label>{t('phoneNumberLabel')}</Label>
<p className='text-muted-foreground text-xs'>
{t('platformConfigTip', { key: platformConfig?.phone_number })}
</p>
</TableCell>
<TableCell className='text-right'>
<EnhancedInput
value={data?.config?.platform_config?.phone_number ?? ''}
onValueBlur={(value) =>
updateConfig('config', {
...data?.config,
platform_config: {
...data?.config?.platform_config,
phone_number: value,
},
})
}
disabled={isFetching}
placeholder={t('platformConfigTip', {
key: platformConfig?.phone_number,
})}
/>
</TableCell>
</TableRow>
)}
{platformConfig?.code_variable && (
<TableRow>
<TableCell>
<Label>{t('template')}</Label>
<p className='text-muted-foreground text-xs'>
{t('templateTip', { code: platformConfig?.code_variable })}
</p>
</TableCell>
<TableCell className='text-right'>
<Textarea
defaultValue={data?.config?.platform_config?.template ?? ''}
onBlur={(e) =>
updateConfig('config', {
...data?.config,
platform_config: {
...data?.config?.platform_config,
template: e.target.value,
},
})
}
disabled={isFetching}
placeholder={t('placeholders.template', {
code: platformConfig?.code_variable,
})}
/>
</TableCell>
</TableRow>
)}
<TableRow>
<TableCell>
<Label>{t('testSms')}</Label>
<p className='text-muted-foreground text-xs'>{t('testSmsTip')}</p>
</TableCell>
<TableCell className='flex items-center gap-2 text-right'>
<AreaCodeSelect
value={params.area_code}
onChange={(value) => {
if (value.phone) {
setParams((prev) => ({ ...prev, area_code: value.phone! }));
}
}}
/>
<EnhancedInput
placeholder={t('testSmsPhone')}
value={params.telephone}
onValueChange={(value) => {
setParams((prev) => ({ ...prev, telephone: value as string }));
}}
/>
<Button
disabled={!params.telephone || !params.area_code}
onClick={async () => {
if (isFetching || !params.telephone || !params.area_code) return;
try {
await testSmsSend(params);
toast.success(t('sendSuccess'));
} catch {
toast.error(t('sendFailed'));
}
}}
>
{t('testSms')}
</Button>
</TableCell>
</TableRow>
</TableBody>
</Table>
</TabsContent>
<TabsContent value='logs'>
<LogsTable type='mobile' />
</TabsContent>
</Tabs>
);
}
@@ -1,92 +0,0 @@
'use client';
import { getAuthMethodConfig, updateAuthMethodConfig } from '@/services/admin/authMethod';
import { useQuery } from '@tanstack/react-query';
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 { EnhancedInput } from '@workspace/ui/custom-components/enhanced-input';
import { useTranslations } from 'next-intl';
import { toast } from 'sonner';
export default function Page() {
const t = useTranslations('telegram');
const { data, refetch } = useQuery({
queryKey: ['getAuthMethodConfig', 'telegram'],
queryFn: async () => {
const { data } = await getAuthMethodConfig({
method: 'telegram',
});
return data.data;
},
});
async function updateConfig(key: keyof API.UpdateAuthMethodConfigRequest, value: unknown) {
try {
await updateAuthMethodConfig({
...data,
[key]: value,
} as API.UpdateAuthMethodConfigRequest);
toast.success(t('saveSuccess'));
refetch();
} catch (error) {
toast.error(t('saveFailed'));
}
}
return (
<Table>
<TableBody>
<TableRow>
<TableCell>
<Label>{t('enable')}</Label>
<p className='text-muted-foreground text-xs'>{t('enableDescription')}</p>
</TableCell>
<TableCell className='text-right'>
<Switch
checked={data?.enabled}
onCheckedChange={(checked) => updateConfig('enabled', checked)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Label>{t('clientId')}</Label>
<p className='text-muted-foreground text-xs'>{t('clientIdDescription')}</p>
</TableCell>
<TableCell className='text-right'>
<EnhancedInput
placeholder='6123456789'
value={data?.config?.bot}
onValueBlur={(value) => {
updateConfig('config', {
...data?.config,
bot: value,
});
}}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Label>{t('clientSecret')}</Label>
<p className='text-muted-foreground text-xs'>{t('clientSecretDescription')}</p>
</TableCell>
<TableCell className='text-right'>
<EnhancedInput
placeholder='6123456789:AAHn_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'
value={data?.config?.bot_token}
onValueBlur={(value) => {
updateConfig('config', {
...data?.config,
bot_token: value,
});
}}
/>
</TableCell>
</TableRow>
</TableBody>
</Table>
);
}
+3 -3
View File
@@ -64,9 +64,6 @@ export default function Page() {
),
}}
params={[
{
key: 'search',
},
{
key: 'subscribe',
placeholder: t('subscribe'),
@@ -75,6 +72,9 @@ export default function Page() {
value: String(item.id),
})),
},
{
key: 'search',
},
]}
request={async (pagination, filters) => {
const { data } = await getCouponList({
@@ -0,0 +1,495 @@
'use client';
import { createBatchSendEmailTask, getPreSendEmailCount } from '@/services/admin/marketing';
import { zodResolver } from '@hookform/resolvers/zod';
import { Button } from '@workspace/ui/components/button';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@workspace/ui/components/form';
import { Input } from '@workspace/ui/components/input';
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 { Tabs, TabsContent, TabsList, TabsTrigger } from '@workspace/ui/components/tabs';
import { Textarea } from '@workspace/ui/components/textarea';
import { MarkdownEditor } from '@workspace/ui/custom-components/editor';
import { EnhancedInput } from '@workspace/ui/custom-components/enhanced-input';
import { Icon } from '@workspace/ui/custom-components/icon';
import { useTranslations } from 'next-intl';
import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { toast } from 'sonner';
import { z } from 'zod';
export default function EmailBroadcastForm() {
const t = useTranslations('marketing');
// Define schema with internationalized error messages
const emailBroadcastSchema = z.object({
subject: z.string().min(1, t('subject') + ' ' + t('cannotBeEmpty')),
content: z.string().min(1, t('content') + ' ' + t('cannotBeEmpty')),
scope: z.string().default('all'),
register_start_time: z.string().optional(),
register_end_time: z.string().optional(),
additional: z
.string()
.optional()
.refine(
(value) => {
if (!value || value.trim() === '') return true;
const emails = value.split('\n').filter((email) => email.trim() !== '');
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emails.every((email) => emailRegex.test(email.trim()));
},
{
message: t('pleaseEnterValidEmailAddresses'),
},
),
scheduled: z.string().optional(),
interval: z.number().min(0.1, t('emailIntervalMinimum')).optional(),
limit: z.number().min(1, t('dailyLimit')).optional(),
});
type EmailBroadcastFormData = z.infer<typeof emailBroadcastSchema>;
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
const [estimatedRecipients, setEstimatedRecipients] = useState<{
users: number;
additional: number;
total: number;
}>({ users: 0, additional: 0, total: 0 });
const form = useForm<EmailBroadcastFormData>({
resolver: zodResolver(emailBroadcastSchema),
defaultValues: {
subject: '',
content: '',
scope: 'all',
register_start_time: '',
register_end_time: '',
additional: '',
scheduled: '',
interval: 1,
limit: 1000,
},
});
// Calculate recipient count
const calculateRecipients = async () => {
const formData = form.getValues();
try {
// Call API to get actual recipient count
const scope = formData.scope || 'all';
// Convert dates to timestamps if they exist
let register_start_time: number = 0;
let register_end_time: number = 0;
if (formData.register_start_time) {
register_start_time = Math.floor(new Date(formData.register_start_time).getTime());
}
if (formData.register_end_time) {
register_end_time = Math.floor(new Date(formData.register_end_time).getTime());
}
const response = await getPreSendEmailCount({
scope,
register_start_time,
register_end_time,
});
const userCount = response.data?.data?.count || 0;
// Calculate additional email count
const additionalEmails = formData.additional || '';
const additionalCount = additionalEmails
.split('\n')
.filter((email: string) => email.trim() !== '').length;
const total = userCount + additionalCount;
setEstimatedRecipients({
users: userCount,
additional: additionalCount,
total,
});
} catch (error) {
console.error('Failed to get recipient count:', error);
// Set to 0 if API fails, don't use fallback simulation
const additionalEmails = formData.additional || '';
const additionalCount = additionalEmails
.split('\n')
.filter((email: string) => email.trim() !== '').length;
setEstimatedRecipients({
users: 0,
additional: additionalCount,
total: additionalCount,
});
}
};
// Listen to form changes
const watchedValues = form.watch();
// Use useEffect to respond to form changes
useEffect(() => {
const debounceTimer = setTimeout(() => {
calculateRecipients();
}, 500); // Add debounce to avoid too frequent API calls
return () => clearTimeout(debounceTimer);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
watchedValues.scope,
watchedValues.register_start_time,
watchedValues.register_end_time,
watchedValues.additional,
]);
const onSubmit = async (data: EmailBroadcastFormData) => {
setLoading(true);
try {
// Validate scheduled send time
let scheduled: number | undefined;
if (data.scheduled && data.scheduled.trim() !== '') {
const scheduledDate = new Date(data.scheduled);
const now = new Date();
if (scheduledDate <= now) {
toast.error(t('scheduledSendTimeMustBeLater'));
return;
}
scheduled = Math.floor(scheduledDate.getTime());
}
let register_start_time: number = 0;
let register_end_time: number = 0;
if (data.register_start_time) {
register_start_time = Math.floor(new Date(data.register_start_time).getTime());
}
if (data.register_end_time) {
register_end_time = Math.floor(new Date(data.register_end_time).getTime());
}
// Prepare API request data
const requestData: API.CreateBatchSendEmailTaskRequest = {
subject: data.subject,
content: data.content,
scope: data.scope,
register_start_time,
register_end_time,
additional: data.additional || undefined,
scheduled,
interval: data.interval ? data.interval * 1000 : undefined, // Convert seconds to milliseconds
limit: data.limit,
};
// Call API to create batch send email task
await createBatchSendEmailTask(requestData);
if (!data.scheduled || data.scheduled.trim() === '') {
toast.success(t('emailBroadcastTaskCreatedSuccessfully'));
} else {
toast.success(t('emailAddedToScheduledQueue'));
}
form.reset();
setOpen(false);
} catch (error) {
console.error('Email broadcast failed:', error);
toast.error(t('sendFailed'));
} 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:email-send' className='text-primary h-5 w-5' />
</div>
<div className='flex-1'>
<p className='font-medium'>{t('emailBroadcast')}</p>
<p className='text-muted-foreground text-sm'>
{t('createNewEmailBroadcastCampaign')}
</p>
</div>
</div>
<Icon icon='mdi:chevron-right' className='size-6' />
</div>
</SheetTrigger>
<SheetContent className='w-[700px] max-w-full md:max-w-screen-lg'>
<SheetHeader>
<SheetTitle>{t('createBroadcast')}</SheetTitle>
</SheetHeader>
<ScrollArea className='-mx-6 h-[calc(100dvh-48px-36px-36px-env(safe-area-inset-top))] px-6'>
<Form {...form}>
<form
id='broadcast-form'
onSubmit={form.handleSubmit(onSubmit)}
className='space-y-2 pt-4'
>
<Tabs defaultValue='content' className='space-y-2'>
<TabsList className='grid w-full grid-cols-2'>
<TabsTrigger value='content'>{t('content')}</TabsTrigger>
<TabsTrigger value='settings'>{t('sendSettings')}</TabsTrigger>
</TabsList>
{/* Email Content Tab */}
<TabsContent value='content' className='space-y-2'>
<FormField
control={form.control}
name='subject'
render={({ field }) => (
<FormItem>
<FormLabel>{t('subject')}</FormLabel>
<FormControl>
<Input
placeholder={`${t('pleaseEnter')} ${t('subject').toLowerCase()}`}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='content'
render={({ field }) => (
<FormItem>
<FormLabel>{t('content')}</FormLabel>
<FormControl>
<MarkdownEditor
value={field.value}
onChange={(value) => {
form.setValue(field.name, value || '');
}}
/>
</FormControl>
<FormDescription>{t('useMarkdownEditor')}</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</TabsContent>
{/* Send Settings Tab */}
<TabsContent value='settings' className='space-y-2'>
{/* Send scope and estimated recipients */}
<div className='grid grid-cols-2 items-center gap-4'>
<FormField
control={form.control}
name='scope'
render={({ field }) => (
<FormItem>
<FormLabel>{t('sendScope')}</FormLabel>
<Select onValueChange={field.onChange} value={field.value || 'all'}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder={t('selectSendScope')} />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value='all'>{t('allUsers')}</SelectItem>
<SelectItem value='active'>{t('subscribedUsersOnly')}</SelectItem>
<SelectItem value='expired'>
{t('expiredSubscriptionUsersOnly')}
</SelectItem>
<SelectItem value='none'>{t('noSubscriptionUsersOnly')}</SelectItem>
<SelectItem value='skip'>{t('specificUsersOnly')}</SelectItem>
</SelectContent>
</Select>
<FormDescription>{t('sendScopeDescription')}</FormDescription>
</FormItem>
)}
/>
{/* Estimated recipients info */}
<div className='flex justify-end'>
<div className='border-l-primary bg-primary/10 border-l-4 px-4 py-3 text-sm'>
<span className='text-muted-foreground'>{t('estimatedRecipients')}: </span>
<span className='text-primary text-lg font-medium'>
{estimatedRecipients.total}
</span>
<span className='text-muted-foreground ml-2 text-xs'>
({t('users')}: {estimatedRecipients.users}, {t('additional')}:{' '}
{estimatedRecipients.additional})
</span>
</div>
</div>
</div>
<div className='grid grid-cols-2 gap-4'>
<FormField
control={form.control}
name='register_start_time'
render={({ field }) => (
<FormItem>
<FormLabel>{t('registrationStartDate')}</FormLabel>
<FormControl>
<EnhancedInput
type='datetime-local'
disabled={form.watch('scope') === 'skip'}
value={field.value}
onValueChange={field.onChange}
/>
</FormControl>
<FormDescription>{t('includeUsersRegisteredAfter')}</FormDescription>
</FormItem>
)}
/>
<FormField
control={form.control}
name='register_end_time'
render={({ field }) => (
<FormItem>
<FormLabel>{t('registrationEndDate')}</FormLabel>
<FormControl>
<EnhancedInput
type='datetime-local'
disabled={form.watch('scope') === 'skip'}
value={field.value}
onValueChange={field.onChange}
/>
</FormControl>
<FormDescription>{t('includeUsersRegisteredBefore')}</FormDescription>
</FormItem>
)}
/>
</div>
{/* Additional recipients */}
<FormField
control={form.control}
name='additional'
render={({ field }) => (
<FormItem>
<FormLabel>{t('additionalRecipientEmails')}</FormLabel>
<FormControl>
<Textarea
placeholder={`${t('pleaseEnter')}${t('additionalRecipientEmails').toLowerCase()}${t('onePerLine')}for example:\nexample1@domain.com\nexample2@domain.com\nexample3@domain.com`}
className='min-h-[120px] font-mono text-sm'
{...field}
/>
</FormControl>
<FormDescription>{t('additionalRecipientsDescription')}</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{/* Send time settings */}
<FormField
control={form.control}
name='scheduled'
render={({ field }) => (
<FormItem>
<FormLabel>{t('scheduledSend')}</FormLabel>
<FormControl>
<EnhancedInput
type='datetime-local'
placeholder={t('leaveEmptyForImmediateSend')}
value={field.value}
onValueChange={field.onChange}
/>
</FormControl>
<FormDescription>{t('selectSendTime')}</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{/* Send rate control */}
<div className='grid grid-cols-2 gap-4'>
<FormField
control={form.control}
name='interval'
render={({ field }) => (
<FormItem>
<FormLabel>{t('emailInterval')}</FormLabel>
<FormControl>
<Input
type='number'
min={1}
step={0.1}
placeholder='1'
{...field}
onChange={(e) => field.onChange(parseFloat(e.target.value) || 1)}
/>
</FormControl>
<FormDescription>{t('intervalTimeBetweenEmails')}</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='limit'
render={({ field }) => (
<FormItem>
<FormLabel>{t('dailySendLimit')}</FormLabel>
<FormControl>
<Input
type='number'
min={1}
step={1}
placeholder='1000'
{...field}
onChange={(e) => field.onChange(parseInt(e.target.value) || 1000)}
/>
</FormControl>
<FormDescription>{t('maximumNumberPerDay')}</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
</TabsContent>
</Tabs>
</form>
</Form>
</ScrollArea>
<SheetFooter className='flex flex-row items-center justify-end gap-2 pt-3'>
<Button variant='outline' onClick={() => setOpen(false)}>
{t('cancel')}
</Button>
<Button type='submit' form='broadcast-form' disabled={loading}>
{loading && <Icon icon='mdi:loading' className='mr-2 h-4 w-4 animate-spin' />}
{loading
? t('processing')
: !form.watch('scheduled') || form.watch('scheduled')?.trim() === ''
? t('sendNow')
: t('scheduleSend')}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
);
}
@@ -0,0 +1,302 @@
'use client';
import { ProTable } from '@/components/pro-table';
import {
getBatchSendEmailTaskList,
getBatchSendEmailTaskStatus,
stopBatchSendEmailTask,
} from '@/services/admin/marketing';
import { Badge } from '@workspace/ui/components/badge';
import { Button } from '@workspace/ui/components/button';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@workspace/ui/components/dialog';
import { ScrollArea } from '@workspace/ui/components/scroll-area';
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetTrigger,
} from '@workspace/ui/components/sheet';
import { Icon } from '@workspace/ui/custom-components/icon';
import { formatDate } from '@workspace/ui/utils';
import { useTranslations } from 'next-intl';
import { useState } from 'react';
import { toast } from 'sonner';
export default function EmailTaskManager() {
const t = useTranslations('marketing');
const [refreshing, setRefreshing] = useState<Record<number, boolean>>({});
const [selectedTask, setSelectedTask] = useState<API.BatchSendEmailTask | null>(null);
// Get task status
const refreshTaskStatus = async (taskId: number) => {
setRefreshing((prev) => ({ ...prev, [taskId]: true }));
try {
const response = await getBatchSendEmailTaskStatus({
id: taskId,
});
const taskStatus = response.data?.data;
if (taskStatus) {
// Just show success message, ProTable will auto-refresh
toast.success(t('taskStatusRefreshed'));
}
} catch (error) {
console.error('Failed to refresh task status:', error);
toast.error(t('failedToRefreshTaskStatus'));
} finally {
setRefreshing((prev) => ({ ...prev, [taskId]: false }));
}
};
// Stop task
const stopTask = async (taskId: number) => {
try {
await stopBatchSendEmailTask({
id: taskId,
});
toast.success(t('taskStoppedSuccessfully'));
await refreshTaskStatus(taskId);
} catch (error) {
console.error('Failed to stop task:', error);
toast.error(t('failedToStopTask'));
}
};
const getStatusBadge = (status: number) => {
const statusConfig = {
0: { label: t('notStarted'), variant: 'secondary' as const },
1: { label: t('inProgress'), variant: 'default' as const },
2: { label: t('completed'), variant: 'default' as const },
};
const config = statusConfig[status as keyof typeof statusConfig] || {
label: `${t('status')} ${status}`,
variant: 'secondary' as const,
};
return <Badge variant={config.variant}>{config.label}</Badge>;
};
return (
<Sheet>
<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:email-multiple' className='text-primary h-5 w-5' />
</div>
<div className='flex-1'>
<p className='font-medium'>{t('emailTaskManager')}</p>
<p className='text-muted-foreground text-sm'>
{t('viewAndManageEmailBroadcastTasks')}
</p>
</div>
</div>
<Icon icon='mdi:chevron-right' className='size-6' />
</div>
</SheetTrigger>
<SheetContent className='w-[1000px] max-w-full md:max-w-screen-lg'>
<SheetHeader>
<SheetTitle>{t('emailBroadcastTasks')}</SheetTitle>
</SheetHeader>
<ScrollArea className='-mx-6 h-[calc(100dvh-48px-36px-env(safe-area-inset-top))] px-6'>
<div className='mt-4 space-y-4'>
<ProTable<API.BatchSendEmailTask, API.GetBatchSendEmailTaskListParams>
columns={[
{
accessorKey: 'subject',
header: t('subject'),
size: 200,
cell: ({ row }) => (
<div
className='max-w-[200px] truncate font-medium'
title={row.getValue('subject') as string}
>
{row.getValue('subject') as string}
</div>
),
},
{
accessorKey: 'scope',
header: t('recipientType'),
size: 120,
cell: ({ row }) => {
const scope = row.getValue('scope') as string;
const scopeLabels = {
all: t('allUsers'),
active: t('subscribedUsers'),
expired: t('expiredUsers'),
none: t('nonSubscribers'),
skip: t('specificUsers'),
};
return scopeLabels[scope as keyof typeof scopeLabels] || scope;
},
},
{
accessorKey: 'status',
header: t('status'),
size: 100,
cell: ({ row }) => getStatusBadge(row.getValue('status') as number),
},
{
accessorKey: 'progress',
header: t('progress'),
size: 150,
cell: ({ row }) => {
const task = row.original as API.BatchSendEmailTask;
const progress = task.total > 0 ? (task.current / task.total) * 100 : 0;
return (
<div className='space-y-1'>
<div className='flex justify-between text-sm'>
<span>
{task.current} / {task.total}
</span>
<span>{progress.toFixed(1)}%</span>
</div>
<div className='bg-muted h-2 overflow-hidden rounded-full'>
<div
className='bg-primary h-full transition-all duration-300'
style={{ width: `${progress}%` }}
/>
</div>
</div>
);
},
},
{
accessorKey: 'created_at',
header: t('createdAt'),
size: 150,
cell: ({ row }) => {
const createdAt = row.getValue('created_at') as number;
return formatDate(createdAt);
},
},
{
accessorKey: 'scheduled',
header: t('sendTime'),
size: 150,
cell: ({ row }) => {
const scheduled = row.getValue('scheduled') as number;
return scheduled && scheduled > 0 ? formatDate(scheduled) : '--';
},
},
]}
request={async (pagination, filters) => {
const response = await getBatchSendEmailTaskList({
...filters,
page: pagination.page,
size: pagination.size,
});
return {
list: response.data?.data?.list || [],
total: response.data?.data?.total || 0,
};
}}
params={[
{
key: 'status',
placeholder: t('status'),
options: [
{ label: t('notStarted'), value: '0' },
{ label: t('inProgress'), value: '1' },
{ label: t('completed'), value: '2' },
],
},
{
key: 'scope',
placeholder: t('sendScope'),
options: [
{ label: t('allUsers'), value: 'all' },
{ label: t('subscribedUsers'), value: 'active' },
{ label: t('expiredUsers'), value: 'expired' },
{ label: t('nonSubscribers'), value: 'none' },
{ label: t('specificUsers'), value: 'skip' },
],
},
]}
actions={{
render: (row) => {
return [
<Dialog key='view-content'>
<DialogTrigger asChild>
<Button
variant='outline'
size='icon'
onClick={() => setSelectedTask(row as API.BatchSendEmailTask)}
>
<Icon icon='mdi:eye' />
</Button>
</DialogTrigger>
<DialogContent className='max-h-[80vh] max-w-4xl'>
<DialogHeader>
<DialogTitle>{t('emailContent')}</DialogTitle>
</DialogHeader>
<ScrollArea className='h-[60vh] pr-4'>
{selectedTask && (
<div className='space-y-4'>
<div>
<h4 className='text-muted-foreground mb-2 text-sm font-medium'>
{t('subject')}
</h4>
<p className='font-medium'>{selectedTask.subject}</p>
</div>
<div>
<h4 className='text-muted-foreground mb-2 text-sm font-medium'>
{t('content')}
</h4>
<div
className='prose prose-sm max-w-none'
dangerouslySetInnerHTML={{ __html: selectedTask.content }}
/>
</div>
{selectedTask.additional && (
<div>
<h4 className='text-muted-foreground mb-2 text-sm font-medium'>
{t('additionalRecipients')}
</h4>
<p className='text-sm'>{selectedTask.additional}</p>
</div>
)}
</div>
)}
</ScrollArea>
</DialogContent>
</Dialog>,
<Button
key='refresh'
variant='outline'
size='icon'
onClick={() => refreshTaskStatus(row.id)}
disabled={refreshing[row.id]}
>
{refreshing[row.id] && (
<Icon icon='mdi:loading' className='mr-2 h-3 w-3 animate-spin' />
)}
<Icon icon='mdi:refresh' className='h-3 w-3' />
</Button>,
...([0, 1].includes(row.status)
? [
<Button key='stop' variant='destructive' onClick={() => stopTask(row.id)}>
{t('stop')}
</Button>,
]
: []),
];
},
}}
/>
</div>
</ScrollArea>
</SheetContent>
</Sheet>
);
}
@@ -0,0 +1,41 @@
'use client';
import { Table, TableBody, TableCell, TableRow } from '@workspace/ui/components/table';
import { useTranslations } from 'next-intl';
import EmailBroadcastForm from './email/broadcast-form';
import EmailTaskManager from './email/task-manager';
export default function MarketingPage() {
const t = useTranslations('marketing');
const formSections = [
{
title: t('emailMarketing'),
forms: [{ component: EmailBroadcastForm }, { component: EmailTaskManager }],
},
];
return (
<div className='space-y-8'>
{formSections.map((section, sectionIndex) => (
<div key={sectionIndex}>
<h2 className='mb-4 text-lg font-semibold'>{section.title}</h2>
<Table>
<TableBody>
{section.forms.map((form, formIndex) => {
const FormComponent = form.component;
return (
<TableRow key={formIndex}>
<TableCell>
<FormComponent />
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
))}
</div>
);
}
+1 -1
View File
@@ -165,7 +165,6 @@ export default function Page(props: any) {
},
]}
params={[
{ key: 'search' },
{
key: 'status',
placeholder: t('status.0'),
@@ -182,6 +181,7 @@ export default function Page(props: any) {
value: String(item.id),
})),
},
{ key: 'search' },
].concat(
props.userId
? []
@@ -44,7 +44,7 @@ export default function GroupForm<T extends Record<string, any>>({
trigger,
title,
}: GroupFormProps<T>) {
const t = useTranslations('subscribe');
const t = useTranslations('product');
const [open, setOpen] = useState(false);
const form = useForm({
@@ -17,7 +17,7 @@ import { toast } from 'sonner';
import GroupForm from './form';
const GroupTable = () => {
const t = useTranslations('subscribe');
const t = useTranslations('product');
const [loading, setLoading] = useState(false);
const ref = useRef<ProTableActions>(null);
+25
View File
@@ -0,0 +1,25 @@
import { getTranslations } from 'next-intl/server';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@workspace/ui/components/tabs';
import GroupTable from './group/table';
import SubscribeTable from './subscribe-table';
export default async function Page() {
const t = await getTranslations('product');
return (
<Tabs defaultValue='subscribe'>
<TabsList>
<TabsTrigger value='subscribe'>{t('tabs.subscribe')}</TabsTrigger>
<TabsTrigger value='group'>{t('tabs.subscribeGroup')}</TabsTrigger>
</TabsList>
<TabsContent value='subscribe'>
<SubscribeTable />
</TabsContent>
<TabsContent value='group'>
<GroupTable />
</TabsContent>
</Tabs>
);
}
@@ -79,7 +79,7 @@ export default function SubscribeForm<T extends Record<string, any>>({
trigger,
title,
}: Readonly<SubscribeFormProps<T>>) {
const t = useTranslations('subscribe');
const t = useTranslations('product');
const [open, setOpen] = useState(false);
const updateTimeoutRef = useRef<NodeJS.Timeout | null>(null);
@@ -22,7 +22,7 @@ import { toast } from 'sonner';
import SubscribeForm from './subscribe-form';
export default function SubscribeTable() {
const t = useTranslations('subscribe');
const t = useTranslations('product');
const [loading, setLoading] = useState(false);
const { data: groups } = useQuery({
queryKey: ['getSubscribeGroupList', 'all'],
@@ -67,9 +67,6 @@ export default function SubscribeTable() {
),
}}
params={[
{
key: 'search',
},
{
key: 'group_id',
placeholder: t('subscribeGroup'),
@@ -78,6 +75,9 @@ export default function SubscribeTable() {
value: String(item.id),
})),
},
{
key: 'search',
},
]}
request={async (pagination, filters) => {
const { data } = await getSubscribeList({
@@ -1,240 +0,0 @@
'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] = [];
}
// 不插入 MATCH 规则,只用于标识默认规则组
if (!rule.trim().startsWith('MATCH,')) {
groups[policyGroup].push(cleanRule);
}
}
return groups;
};
const checkIfDefaultRule = (originalRules: string[], groupName: string): boolean => {
return originalRules.some((rule) => {
const trimmedRule = rule.trim();
if (!trimmedRule.startsWith('MATCH,')) return false;
// 检查 MATCH 规则是否属于当前组
const parts = trimmedRule.split(',');
if (parts.length >= 3) {
const ruleGroup = parts[2]?.trim();
return ruleGroup === groupName;
}
return groupName === 'default';
});
};
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;
const isDefault = checkIfDefaultRule(allRules, group.name);
await createRuleGroup({
name: group.name,
rules: group?.rules.join('\n'),
enable: false,
tags: [],
icon: '',
type: 'default',
default: isDefault,
});
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>
</>
);
}
-244
View File
@@ -1,244 +0,0 @@
'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 || '',
type: values.type || 'default',
default: false,
});
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: 'default',
header: t('defaultRule'),
cell: ({ row }) => (
<Switch
defaultChecked={row.original.default}
onCheckedChange={async (checked) => {
await updateRuleGroup({
...row.original,
default: checked,
} as API.UpdateRuleGroupRequest);
ref.current?.refresh();
}}
/>
),
},
{
accessorKey: 'type',
header: t('type'),
cell: ({ row }) => {
const type = row.original.type || 'default';
if (type === 'default') {
return <Badge variant='default'>{t('default')}</Badge>;
}
if (type === 'reject') {
return <Badge variant='destructive'>{t('reject')}</Badge>;
}
if (type === 'direct') {
return <Badge variant='secondary'>{t('direct')}</Badge>;
}
return <Badge variant='default'>{t('default')}</Badge>;
},
},
{
accessorKey: 'name',
header: t('name'),
cell: ({ row }) => (
<div className='flex items-center gap-2'>
{row.original.icon && (
<Image
src={row.original.icon}
alt={row.original.name}
className='h-6 w-6 rounded-md'
width={24}
height={24}
/>
)}
<span>{row.original.name}</span>
</div>
),
},
{
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,
type: values.type,
default: row.default,
});
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')}
/>,
],
}}
/>
);
}
@@ -1,266 +0,0 @@
'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 {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@workspace/ui/components/select';
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.string()).default([]),
rules: z.string().default(''),
icon: z.string().default(''),
type: z.string().default('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='type'
render={({ field }) => (
<FormItem>
<FormLabel>{t('type')}</FormLabel>
<FormControl>
<Select
value={field.value}
onValueChange={(value) => {
form.setValue(field.name, value);
}}
>
<SelectTrigger>
<SelectValue placeholder={t('selectType')} />
</SelectTrigger>
<SelectContent>
<SelectItem value='default'>{t('default')}</SelectItem>
<SelectItem value='reject'>{t('reject')}</SelectItem>
<SelectItem value='direct'>{t('direct')}</SelectItem>
</SelectContent>
</Select>
</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>
</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>
);
}
@@ -90,7 +90,7 @@ export default function GroupForm<T extends Record<string, any>>({
name='name'
render={({ field }) => (
<FormItem>
<FormLabel>{t('group.form.name')}</FormLabel>
<FormLabel>{t('groupForm.name')}</FormLabel>
<FormControl>
<EnhancedInput
{...field}
@@ -108,7 +108,7 @@ export default function GroupForm<T extends Record<string, any>>({
name='description'
render={({ field }) => (
<FormItem>
<FormLabel>{t('group.form.description')}</FormLabel>
<FormLabel>{t('groupForm.description')}</FormLabel>
<FormControl>
<EnhancedInput
{...field}
@@ -132,11 +132,11 @@ export default function GroupForm<T extends Record<string, any>>({
setOpen(false);
}}
>
{t('group.form.cancel')}
{t('groupForm.cancel')}
</Button>
<Button disabled={loading} onClick={form.handleSubmit(handleSubmit)}>
{loading && <Icon icon='mdi:loading' className='mr-2 animate-spin' />}{' '}
{t('group.form.confirm')}
{t('groupForm.confirm')}
</Button>
</SheetFooter>
</SheetContent>
@@ -0,0 +1,251 @@
'use client';
import { UserDetail } from '@/app/dashboard/user/user-detail';
import { ProTable } from '@/components/pro-table';
import { getUserSubscribeById } from '@/services/admin/user';
import { useQuery } from '@tanstack/react-query';
import { Badge } from '@workspace/ui/components/badge';
import { Button } from '@workspace/ui/components/button';
import { Progress } from '@workspace/ui/components/progress';
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetTrigger,
} from '@workspace/ui/components/sheet';
import { formatBytes, formatDate } from '@workspace/ui/utils';
import { useTranslations } from 'next-intl';
import { useState } from 'react';
interface NodeDetailDialogProps {
node: API.Server;
children?: React.ReactNode;
trigger?: React.ReactNode;
}
// 统一的用户订阅信息组件
function UserSubscribeInfo({
userId,
type,
}: {
userId: number;
type: 'account' | 'subscribeName' | 'subscribeId' | 'trafficUsage' | 'expireTime';
}) {
const { data } = useQuery({
enabled: userId !== 0,
queryKey: ['getUserSubscribeById', userId],
queryFn: async () => {
const { data } = await getUserSubscribeById({ id: userId });
return data.data;
},
});
if (!data) return <span className='text-muted-foreground'>--</span>;
switch (type) {
case 'account':
if (!data.user_id) return <span className='text-muted-foreground'>--</span>;
return <UserDetail id={data.user_id} />;
case 'subscribeName':
if (!data.subscribe?.name) return <span className='text-muted-foreground'>--</span>;
return <span className='text-sm'>{data.subscribe.name}</span>;
case 'subscribeId':
if (!data.id) return <span className='text-muted-foreground'>--</span>;
return <span className='font-mono text-sm'>{data.id}</span>;
case 'trafficUsage': {
const usedTraffic = data.upload + data.download;
const totalTraffic = data.traffic || 0;
return (
<div className='min-w-0 text-sm'>
<div className='break-words'>
{formatBytes(usedTraffic)} / {totalTraffic > 0 ? formatBytes(totalTraffic) : '无限制'}
</div>
</div>
);
}
case 'expireTime': {
if (!data.expire_time) return <span className='text-muted-foreground'>--</span>;
const isExpired = data.expire_time < Date.now() / 1000;
return (
<div className='flex flex-col gap-1 sm:flex-row sm:items-center sm:gap-2'>
<span className='text-sm'>{formatDate(data.expire_time)}</span>
{isExpired && (
<Badge variant='destructive' className='w-fit px-1 py-0 text-xs'>
</Badge>
)}
</div>
);
}
default:
return <span className='text-muted-foreground'>--</span>;
}
}
export function NodeDetailDialog({ node, children, trigger }: NodeDetailDialogProps) {
const t = useTranslations('server.node');
const [open, setOpen] = useState(false);
const { status } = node;
const { online, cpu, mem, disk, updated_at } = status || {
online: {},
cpu: 0,
mem: 0,
disk: 0,
updated_at: 0,
};
const isOnline = updated_at > 0;
const onlineCount = (online && Object.keys(online).length) || 0;
// 转换在线用户数据为ProTable需要的格式
const onlineUsersData = Object.entries(online || {}).map(([uid, ips]) => ({
uid,
ips: ips as string[],
primaryIp: ips[0] || '',
allIps: (ips as string[]).join(', '),
}));
return (
<Sheet open={open} onOpenChange={setOpen}>
<SheetTrigger asChild>
{trigger || (
<Button variant='outline' size='sm'>
{t('detail')}
</Button>
)}
</SheetTrigger>
<SheetContent className='w-full max-w-full sm:w-[600px] sm:max-w-screen-md'>
<SheetHeader>
<SheetTitle>
{t('nodeDetail')} - {node.name}
</SheetTitle>
</SheetHeader>
<div className='-mx-6 h-[calc(100dvh-48px-16px-env(safe-area-inset-top))] space-y-2 overflow-y-auto px-6 py-4'>
<h3 className='text-base font-medium'>{t('nodeStatus')}</h3>
<div className='space-y-3'>
<div className='flex w-full flex-col gap-2 text-sm sm:flex-row sm:items-center sm:gap-3'>
<Badge variant={isOnline ? 'default' : 'destructive'} className='w-fit text-xs'>
{isOnline ? t('normal') : t('abnormal')}
</Badge>
<span className='text-muted-foreground'>
{t('onlineCount')}: {onlineCount}
</span>
{isOnline && (
<span className='text-muted-foreground text-xs sm:text-sm'>
{t('lastUpdated')}: {formatDate(updated_at)}
</span>
)}
</div>
{isOnline && (
<div className='grid grid-cols-1 gap-3 sm:grid-cols-3'>
<div className='space-y-1'>
<div className='flex justify-between text-xs'>
<span>CPU</span>
<span>{cpu?.toFixed(1)}%</span>
</div>
<Progress value={cpu ?? 0} className='h-1.5' max={100} />
</div>
<div className='space-y-1'>
<div className='flex justify-between text-xs'>
<span>{t('memory')}</span>
<span>{mem?.toFixed(1)}%</span>
</div>
<Progress value={mem ?? 0} className='h-1.5' max={100} />
</div>
<div className='space-y-1'>
<div className='flex justify-between text-xs'>
<span>{t('disk')}</span>
<span>{disk?.toFixed(1)}%</span>
</div>
<Progress value={disk ?? 0} className='h-1.5' max={100} />
</div>
</div>
)}
</div>
{isOnline && onlineCount > 0 && (
<div>
<h3 className='mb-3 text-lg font-medium'>{t('onlineUsers')}</h3>
<div className='overflow-x-auto'>
<ProTable
header={{
hidden: true,
}}
columns={[
{
accessorKey: 'allIps',
header: t('ipAddresses'),
cell: ({ row }) => {
const ips = row.original.ips;
return (
<div className='flex min-w-0 flex-col gap-1'>
{ips.map((ip: string, index: number) => (
<div key={ip} className='whitespace-nowrap text-sm'>
{index === 0 ? (
<span className='font-medium'>{ip}</span>
) : (
<span className='text-muted-foreground'>{ip}</span>
)}
</div>
))}
</div>
);
},
},
{
accessorKey: 'user',
header: t('user'),
cell: ({ row }) => (
<UserSubscribeInfo userId={Number(row.original.uid)} type='account' />
),
},
{
accessorKey: 'subscribeName',
header: t('subscribeName'),
cell: ({ row }) => (
<UserSubscribeInfo userId={Number(row.original.uid)} type='subscribeName' />
),
},
{
accessorKey: 'subscribeId',
header: t('subscribeId'),
cell: ({ row }) => (
<UserSubscribeInfo userId={Number(row.original.uid)} type='subscribeId' />
),
},
{
accessorKey: 'trafficUsage',
header: t('trafficUsage'),
cell: ({ row }) => (
<UserSubscribeInfo userId={Number(row.original.uid)} type='trafficUsage' />
),
},
{
accessorKey: 'expireTime',
header: t('expireTime'),
cell: ({ row }) => (
<UserSubscribeInfo userId={Number(row.original.uid)} type='expireTime' />
),
},
]}
request={async () => ({
list: onlineUsersData,
total: onlineUsersData.length,
})}
/>
</div>
</div>
)}
</div>
</SheetContent>
</Sheet>
);
}
+57 -58
View File
@@ -58,7 +58,10 @@ export default function NodeForm<T extends { [x: string]: any }>({
trigger,
title,
}: Readonly<NodeFormProps<T>>) {
const t = useTranslations('server.node');
const t = useTranslations('server');
const tf = useTranslations('server.nodeForm');
const trs = useTranslations('server.relayModeOptions');
const tsc = useTranslations('server.securityConfig');
const [open, setOpen] = useState(false);
const form = useForm({
@@ -123,7 +126,7 @@ export default function NodeForm<T extends { [x: string]: any }>({
name='name'
render={({ field }) => (
<FormItem>
<FormLabel>{t('form.name')}</FormLabel>
<FormLabel>{t('nodeForm.name')}</FormLabel>
<FormControl>
<EnhancedInput
{...field}
@@ -141,10 +144,10 @@ export default function NodeForm<T extends { [x: string]: any }>({
name='group_id'
render={({ field }) => (
<FormItem>
<FormLabel>{t('form.groupId')}</FormLabel>
<FormLabel>{t('nodeForm.groupId')}</FormLabel>
<FormControl>
<Combobox<number, false>
placeholder={t('form.selectNodeGroup')}
placeholder={t('nodeForm.selectNodeGroup')}
{...field}
options={groups?.map((item) => ({
value: item.id,
@@ -166,10 +169,10 @@ export default function NodeForm<T extends { [x: string]: any }>({
name='tags'
render={({ field }) => (
<FormItem className='col-span-3'>
<FormLabel>{t('form.tags')}</FormLabel>
<FormLabel>{t('nodeForm.tags')}</FormLabel>
<FormControl>
<TagInput
placeholder={t('form.tagsPlaceholder')}
placeholder={t('nodeForm.tagsPlaceholder')}
value={field.value || []}
onChange={(value) => form.setValue(field.name, value)}
/>
@@ -183,7 +186,7 @@ export default function NodeForm<T extends { [x: string]: any }>({
name='country'
render={({ field }) => (
<FormItem>
<FormLabel>{t('form.country')}</FormLabel>
<FormLabel>{t('nodeForm.country')}</FormLabel>
<FormControl>
<EnhancedInput
{...field}
@@ -201,7 +204,7 @@ export default function NodeForm<T extends { [x: string]: any }>({
name='city'
render={({ field }) => (
<FormItem>
<FormLabel>{t('form.city')}</FormLabel>
<FormLabel>{t('nodeForm.city')}</FormLabel>
<FormControl>
<EnhancedInput
{...field}
@@ -221,7 +224,7 @@ export default function NodeForm<T extends { [x: string]: any }>({
name='server_addr'
render={({ field }) => (
<FormItem>
<FormLabel>{t('form.serverAddr')}</FormLabel>
<FormLabel>{t('nodeForm.serverAddr')}</FormLabel>
<FormControl>
<EnhancedInput
{...field}
@@ -239,12 +242,12 @@ export default function NodeForm<T extends { [x: string]: any }>({
name='speed_limit'
render={({ field }) => (
<FormItem>
<FormLabel>{t('form.speedLimit')}</FormLabel>
<FormLabel>{t('nodeForm.speedLimit')}</FormLabel>
<FormControl>
<EnhancedInput
type='number'
{...field}
placeholder={t('form.speedLimitPlaceholder')}
placeholder={t('nodeForm.speedLimitPlaceholder')}
formatInput={(value) => unitConversion('bitsToMb', value)}
formatOutput={(value) => unitConversion('mbToBits', value)}
onValueChange={(value) => {
@@ -262,7 +265,7 @@ export default function NodeForm<T extends { [x: string]: any }>({
name='traffic_ratio'
render={({ field }) => (
<FormItem>
<FormLabel>{t('form.trafficRatio')}</FormLabel>
<FormLabel>{t('nodeForm.trafficRatio')}</FormLabel>
<FormControl>
<EnhancedInput
{...field}
@@ -284,7 +287,7 @@ export default function NodeForm<T extends { [x: string]: any }>({
name='protocol'
render={({ field }) => (
<FormItem>
<FormLabel>{t('form.protocol')}</FormLabel>
<FormLabel>{t('nodeForm.protocol')}</FormLabel>
<FormControl>
<Tabs
value={field.value}
@@ -316,7 +319,7 @@ export default function NodeForm<T extends { [x: string]: any }>({
name='config.method'
render={({ field }) => (
<FormItem>
<FormLabel>{t('form.encryptionMethod')}</FormLabel>
<FormLabel>{t('nodeForm.encryptionMethod')}</FormLabel>
<FormControl>
<Select
onValueChange={(value) => {
@@ -326,7 +329,7 @@ export default function NodeForm<T extends { [x: string]: any }>({
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder={t('form.selectEncryptionMethod')} />
<SelectValue placeholder={t('nodeForm.selectEncryptionMethod')} />
</SelectTrigger>
</FormControl>
<SelectContent>
@@ -357,7 +360,7 @@ export default function NodeForm<T extends { [x: string]: any }>({
name='config.port'
render={({ field }) => (
<FormItem>
<FormLabel>{t('form.port')}</FormLabel>
<FormLabel>{t('nodeForm.port')}</FormLabel>
<FormControl>
<EnhancedInput
{...field}
@@ -385,7 +388,7 @@ export default function NodeForm<T extends { [x: string]: any }>({
name='config.server_key'
render={({ field }) => (
<FormItem>
<FormLabel>{t('form.serverKey')}</FormLabel>
<FormLabel>{t('nodeForm.serverKey')}</FormLabel>
<FormControl>
<EnhancedInput
{...field}
@@ -414,7 +417,7 @@ export default function NodeForm<T extends { [x: string]: any }>({
name='config.port'
render={({ field }) => (
<FormItem>
<FormLabel>{t('form.port')}</FormLabel>
<FormLabel>{t('nodeForm.port')}</FormLabel>
<FormControl>
<EnhancedInput
{...field}
@@ -438,7 +441,7 @@ export default function NodeForm<T extends { [x: string]: any }>({
name='config.flow'
render={({ field }) => (
<FormItem>
<FormLabel>{t('form.flow')}</FormLabel>
<FormLabel>{t('nodeForm.flow')}</FormLabel>
<FormControl>
<Select
value={field.value}
@@ -448,7 +451,7 @@ export default function NodeForm<T extends { [x: string]: any }>({
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder={t('form.pleaseSelect')} />
<SelectValue placeholder={t('nodeForm.pleaseSelect')} />
</SelectTrigger>
</FormControl>
<SelectContent>
@@ -469,11 +472,11 @@ export default function NodeForm<T extends { [x: string]: any }>({
name='config.obfs_password'
render={({ field }) => (
<FormItem>
<FormLabel>{t('form.obfsPassword')}</FormLabel>
<FormLabel>{t('nodeForm.obfsPassword')}</FormLabel>
<FormControl>
<EnhancedInput
{...field}
placeholder={t('form.obfsPasswordPlaceholder')}
placeholder={t('nodeForm.obfsPasswordPlaceholder')}
onValueChange={(value) => {
form.setValue(field.name, value);
}}
@@ -488,10 +491,10 @@ export default function NodeForm<T extends { [x: string]: any }>({
name='config.hop_ports'
render={({ field }) => (
<FormItem>
<FormLabel>{t('form.hopPorts')}</FormLabel>
<FormLabel>{t('nodeForm.hopPorts')}</FormLabel>
<FormControl>
<EnhancedInput
placeholder={t('form.hopPortsPlaceholder')}
placeholder={t('nodeForm.hopPortsPlaceholder')}
{...field}
onValueChange={(value) => {
form.setValue(field.name, value);
@@ -507,7 +510,7 @@ export default function NodeForm<T extends { [x: string]: any }>({
name='config.hop_interval'
render={({ field }) => (
<FormItem>
<FormLabel>{t('form.hopInterval')}</FormLabel>
<FormLabel>{t('nodeForm.hopInterval')}</FormLabel>
<FormControl>
<EnhancedInput
{...field}
@@ -531,7 +534,7 @@ export default function NodeForm<T extends { [x: string]: any }>({
name='config.udp_relay_mode'
render={({ field }) => (
<FormItem>
<FormLabel>{t('form.udpRelayMode')}</FormLabel>
<FormLabel>{t('nodeForm.udpRelayMode')}</FormLabel>
<FormControl>
<Select
value={field.value}
@@ -541,7 +544,7 @@ export default function NodeForm<T extends { [x: string]: any }>({
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder={t('form.pleaseSelect')} />
<SelectValue placeholder={t('nodeForm.pleaseSelect')} />
</SelectTrigger>
</FormControl>
<SelectContent>
@@ -559,7 +562,7 @@ export default function NodeForm<T extends { [x: string]: any }>({
name='config.congestion_controller'
render={({ field }) => (
<FormItem>
<FormLabel>{t('form.congestionController')}</FormLabel>
<FormLabel>{t('nodeForm.congestionController')}</FormLabel>
<FormControl>
<Select
value={field.value}
@@ -569,7 +572,7 @@ export default function NodeForm<T extends { [x: string]: any }>({
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder={t('form.pleaseSelect')} />
<SelectValue placeholder={t('nodeForm.pleaseSelect')} />
</SelectTrigger>
</FormControl>
<SelectContent>
@@ -589,7 +592,7 @@ export default function NodeForm<T extends { [x: string]: any }>({
name='config.disable_sni'
render={({ field }) => (
<FormItem>
<FormLabel>{t('form.disableSni')}</FormLabel>
<FormLabel>{t('nodeForm.disableSni')}</FormLabel>
<FormControl>
<div className='pt-2'>
<Switch
@@ -609,7 +612,7 @@ export default function NodeForm<T extends { [x: string]: any }>({
name='config.reduce_rtt'
render={({ field }) => (
<FormItem>
<FormLabel>{t('form.reduceRtt')}</FormLabel>
<FormLabel>{t('nodeForm.reduceRtt')}</FormLabel>
<FormControl>
<div className='pt-2'>
<Switch
@@ -631,7 +634,7 @@ export default function NodeForm<T extends { [x: string]: any }>({
{['vmess', 'vless', 'trojan'].includes(protocol) && (
<Card>
<CardHeader className='flex flex-row items-center justify-between p-3'>
<CardTitle>{t('form.transportConfig')}</CardTitle>
<CardTitle>{t('nodeForm.transportConfig')}</CardTitle>
<FormField
control={form.control}
name='config.transport'
@@ -646,7 +649,7 @@ export default function NodeForm<T extends { [x: string]: any }>({
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder={t('form.pleaseSelect')} />
<SelectValue placeholder={t('nodeForm.pleaseSelect')} />
</SelectTrigger>
</FormControl>
<SelectContent>
@@ -737,7 +740,7 @@ export default function NodeForm<T extends { [x: string]: any }>({
['anytls', 'tuic', 'hysteria2'].includes(protocol)) && (
<Card>
<CardHeader className='flex flex-row items-center justify-between p-3'>
<CardTitle>{t('form.securityConfig')}</CardTitle>
<CardTitle>{t('nodeForm.securityConfig')}</CardTitle>
{['vmess', 'vless', 'trojan'].includes(protocol) && (
<FormField
control={form.control}
@@ -752,7 +755,7 @@ export default function NodeForm<T extends { [x: string]: any }>({
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder={t('form.pleaseSelect')} />
<SelectValue placeholder={t('nodeForm.pleaseSelect')} />
</SelectTrigger>
</FormControl>
<SelectContent>
@@ -802,7 +805,7 @@ export default function NodeForm<T extends { [x: string]: any }>({
name='config.security_config.reality_server_addr'
render={({ field }) => (
<FormItem>
<FormLabel>{t('form.security_config.serverAddress')}</FormLabel>
<FormLabel>{t('securityConfig.serverAddress')}</FormLabel>
<FormControl>
<EnhancedInput
{...field}
@@ -823,7 +826,7 @@ export default function NodeForm<T extends { [x: string]: any }>({
name='config.security_config.reality_server_port'
render={({ field }) => (
<FormItem>
<FormLabel>{t('form.security_config.serverPort')}</FormLabel>
<FormLabel>{t('securityConfig.serverPort')}</FormLabel>
<FormControl>
<EnhancedInput
{...field}
@@ -847,7 +850,7 @@ export default function NodeForm<T extends { [x: string]: any }>({
name='config.security_config.reality_private_key'
render={({ field }) => (
<FormItem>
<FormLabel>{t('form.security_config.privateKey')}</FormLabel>
<FormLabel>{t('securityConfig.privateKey')}</FormLabel>
<FormControl>
<EnhancedInput
{...field}
@@ -868,11 +871,11 @@ export default function NodeForm<T extends { [x: string]: any }>({
name='config.security_config.reality_public_key'
render={({ field }) => (
<FormItem>
<FormLabel>{t('form.security_config.publicKey')}</FormLabel>
<FormLabel>{t('securityConfig.publicKey')}</FormLabel>
<FormControl>
<EnhancedInput
{...field}
placeholder={t('form.security_config.publicKeyPlaceholder')}
placeholder={t('securityConfig.publicKeyPlaceholder')}
onValueChange={(value) => {
form.setValue(field.name, value);
}}
@@ -887,11 +890,11 @@ export default function NodeForm<T extends { [x: string]: any }>({
name='config.security_config.reality_short_id'
render={({ field }) => (
<FormItem>
<FormLabel>{t('form.security_config.shortId')}</FormLabel>
<FormLabel>{t('securityConfig.shortId')}</FormLabel>
<FormControl>
<EnhancedInput
{...field}
placeholder={t('form.security_config.shortIdPlaceholder')}
placeholder={t('securityConfig.shortIdPlaceholder')}
onValueChange={(value) => {
form.setValue(field.name, value);
}}
@@ -910,7 +913,7 @@ export default function NodeForm<T extends { [x: string]: any }>({
name='config.security_config.fingerprint'
render={({ field }) => (
<FormItem>
<FormLabel>{t('form.security_config.fingerprint')}</FormLabel>
<FormLabel>{t('securityConfig.fingerprint')}</FormLabel>
<Select
value={field.value}
onValueChange={(value) => {
@@ -919,7 +922,7 @@ export default function NodeForm<T extends { [x: string]: any }>({
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder={t('form.pleaseSelect')} />
<SelectValue placeholder={t('nodeForm.pleaseSelect')} />
</SelectTrigger>
</FormControl>
<SelectContent>
@@ -968,7 +971,7 @@ export default function NodeForm<T extends { [x: string]: any }>({
<Card>
<CardHeader className='flex flex-row items-center justify-between p-3'>
<CardTitle>{t('form.relayMode')}</CardTitle>
<CardTitle>{t('nodeForm.relayMode')}</CardTitle>
<FormField
control={form.control}
name='relay_mode'
@@ -983,17 +986,13 @@ export default function NodeForm<T extends { [x: string]: any }>({
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder={t('form.selectRelayMode')} />
<SelectValue placeholder={t('nodeForm.selectRelayMode')} />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value='none'>
{t('form.relayModeOptions.none')}
</SelectItem>
<SelectItem value='all'>{t('form.relayModeOptions.all')}</SelectItem>
<SelectItem value='random'>
{t('form.relayModeOptions.random')}
</SelectItem>
<SelectItem value='none'>{t('relayModeOptions.none')}</SelectItem>
<SelectItem value='all'>{t('relayModeOptions.all')}</SelectItem>
<SelectItem value='random'>{t('relayModeOptions.random')}</SelectItem>
</SelectContent>
</Select>
</FormControl>
@@ -1015,7 +1014,7 @@ export default function NodeForm<T extends { [x: string]: any }>({
{
name: 'host',
type: 'text',
placeholder: t('form.relayHost'),
placeholder: t('nodeForm.relayHost'),
},
{
name: 'port',
@@ -1023,12 +1022,12 @@ export default function NodeForm<T extends { [x: string]: any }>({
step: 1,
min: 1,
max: 65535,
placeholder: t('form.relayPort'),
placeholder: t('nodeForm.relayPort'),
},
{
name: 'prefix',
type: 'text',
placeholder: t('form.relayPrefix'),
placeholder: t('nodeForm.relayPrefix'),
},
]}
value={field.value}
@@ -1055,7 +1054,7 @@ export default function NodeForm<T extends { [x: string]: any }>({
setOpen(false);
}}
>
{t('form.cancel')}
{t('node.cancel')}
</Button>
<Button
disabled={loading}
@@ -1069,7 +1068,7 @@ export default function NodeForm<T extends { [x: string]: any }>({
})}
>
{loading && <Icon icon='mdi:loading' className='mr-2 animate-spin' />}{' '}
{t('form.confirm')}
{t('node.confirm')}
</Button>
</SheetFooter>
</SheetContent>
+234 -80
View File
@@ -1,32 +1,94 @@
'use client';
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from '@workspace/ui/components/accordion';
import { UserDetail } from '@/app/dashboard/user/user-detail';
import { IpLink } from '@/components/ip-link';
import { ProTable } from '@/components/pro-table';
import { getUserSubscribeById } from '@/services/admin/user';
import { useQuery } from '@tanstack/react-query';
import { Badge } from '@workspace/ui/components/badge';
import { Progress } from '@workspace/ui/components/progress';
import { ScrollArea } from '@workspace/ui/components/scroll-area';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@workspace/ui/components/tooltip';
import { formatDate } from '@workspace/ui/utils';
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetTrigger,
} from '@workspace/ui/components/sheet';
import { formatBytes, formatDate } from '@workspace/ui/utils';
import { useTranslations } from 'next-intl';
import { useState } from 'react';
import { UserSubscribeDetail } from '../user/user-detail';
export function formatPercentage(value: number): string {
return `${value.toFixed(1)}%`;
}
export function NodeStatusCell({ status }: { status: API.NodeStatus }) {
// 统一的用户订阅信息组件
function UserSubscribeInfo({
userId,
type,
}: {
userId: number;
type: 'account' | 'subscribeName' | 'subscribeId' | 'trafficUsage' | 'expireTime';
}) {
const { data } = useQuery({
enabled: userId !== 0,
queryKey: ['getUserSubscribeById', userId],
queryFn: async () => {
const { data } = await getUserSubscribeById({ id: userId });
return data.data;
},
});
if (!data) return <span className='text-muted-foreground'>--</span>;
switch (type) {
case 'account':
if (!data.user_id) return <span className='text-muted-foreground'>--</span>;
return <UserDetail id={data.user_id} />;
case 'subscribeName':
if (!data.subscribe?.name) return <span className='text-muted-foreground'>--</span>;
return <span className='text-sm'>{data.subscribe.name}</span>;
case 'subscribeId':
if (!data.id) return <span className='text-muted-foreground'>--</span>;
return <span className='font-mono text-sm'>{data.id}</span>;
case 'trafficUsage': {
const usedTraffic = data.upload + data.download;
const totalTraffic = data.traffic || 0;
return (
<div className='min-w-0 text-sm'>
<div className='break-words'>
{formatBytes(usedTraffic)} / {totalTraffic > 0 ? formatBytes(totalTraffic) : '无限制'}
</div>
</div>
);
}
case 'expireTime': {
if (!data.expire_time) return <span className='text-muted-foreground'>--</span>;
const isExpired = data.expire_time < Date.now() / 1000;
return (
<div className='flex flex-col gap-1 sm:flex-row sm:items-center sm:gap-2'>
<span className='text-sm'>{formatDate(data.expire_time)}</span>
{isExpired && (
<Badge variant='destructive' className='w-fit px-1 py-0 text-xs'>
</Badge>
)}
</div>
);
}
default:
return <span className='text-muted-foreground'>--</span>;
}
}
export function NodeStatusCell({ status, node }: { status: API.NodeStatus; node?: API.Server }) {
const t = useTranslations('server.node');
const [openItem, setOpenItem] = useState<string | null>(null);
const [open, setOpen] = useState(false);
const { online, cpu, mem, disk, updated_at } = status || {
online: {},
@@ -35,78 +97,170 @@ export function NodeStatusCell({ status }: { status: API.NodeStatus }) {
disk: 0,
updated_at: 0,
};
const isOnline = updated_at > 0;
const badgeVariant = isOnline ? 'default' : 'destructive';
const badgeText = isOnline ? t('normal') : t('abnormal');
const onlineCount = (online && Object.keys(online).length) || 0;
// 转换在线用户数据为ProTable需要的格式
const onlineUsersData = Object.entries(online || {}).map(([uid, ips]) => ({
uid,
ips: ips as string[],
primaryIp: ips[0] || '',
allIps: (ips as string[]).join(', '),
}));
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className='flex items-center gap-2 text-xs *:flex-1'>
<div className='flex items-center space-x-1'>
<Badge variant={badgeVariant}>{badgeText}</Badge>
<span className='font-medium'>
{t('onlineCount')}: {onlineCount}
</span>
</div>
<div className='flex flex-col space-y-1'>
<div className='flex justify-between'>
<span>CPU</span>
<span>{formatPercentage(cpu ?? 0)}</span>
<>
<Sheet open={open} onOpenChange={setOpen}>
<SheetTrigger asChild>
<button className='hover:text-foreground flex cursor-pointer items-center gap-2 border-none bg-transparent p-0 text-left text-sm transition-colors'>
<Badge variant={badgeVariant}>{badgeText}</Badge>
<span className='text-muted-foreground'>
{t('onlineCount')}: {onlineCount}
</span>
</button>
</SheetTrigger>
{node && (
<SheetContent className='h-screen w-screen max-w-none sm:h-auto sm:w-[800px] sm:max-w-[90vw]'>
<SheetHeader>
<SheetTitle>
{t('nodeDetail')} - {node.name}
</SheetTitle>
</SheetHeader>
<div className='-mx-6 h-[calc(100vh-48px-16px)] space-y-2 overflow-y-auto px-6 py-4 sm:h-[calc(100dvh-48px-16px-env(safe-area-inset-top))]'>
<h3 className='text-base font-medium'>{t('nodeStatus')}</h3>
<div className='space-y-3'>
<div className='flex w-full flex-col gap-2 text-sm sm:flex-row sm:items-center sm:gap-3'>
<Badge variant={isOnline ? 'default' : 'destructive'} className='w-fit text-xs'>
{isOnline ? t('normal') : t('abnormal')}
</Badge>
<span className='text-muted-foreground'>
{t('onlineCount')}: {onlineCount}
</span>
{isOnline && (
<span className='text-muted-foreground text-xs sm:text-sm'>
{t('lastUpdated')}: {formatDate(updated_at)}
</span>
)}
</div>
{isOnline && (
<div className='grid grid-cols-1 gap-3 sm:grid-cols-3'>
<div className='space-y-1'>
<div className='flex justify-between text-xs'>
<span>CPU</span>
<span>{cpu?.toFixed(1)}%</span>
</div>
<Progress value={cpu ?? 0} className='h-1.5' max={100} />
</div>
<div className='space-y-1'>
<div className='flex justify-between text-xs'>
<span>{t('memory')}</span>
<span>{mem?.toFixed(1)}%</span>
</div>
<Progress value={mem ?? 0} className='h-1.5' max={100} />
</div>
<div className='space-y-1'>
<div className='flex justify-between text-xs'>
<span>{t('disk')}</span>
<span>{disk?.toFixed(1)}%</span>
</div>
<Progress value={disk ?? 0} className='h-1.5' max={100} />
</div>
</div>
)}
</div>
<Progress value={cpu ?? 0} className='h-2' max={100} />
{isOnline && onlineCount > 0 && (
<div>
<h3 className='mb-3 text-lg font-medium'>{t('onlineUsers')}</h3>
<div className='overflow-x-auto'>
<ProTable
header={{
hidden: true,
}}
columns={[
{
accessorKey: 'allIps',
header: t('ipAddresses'),
cell: ({ row }) => {
const ips = row.original.ips;
return (
<div className='flex min-w-0 flex-col gap-1'>
{ips.map((ip: string, index: number) => (
<div key={ip} className='whitespace-nowrap text-sm'>
{index === 0 ? (
<IpLink ip={ip} className='font-medium' />
) : (
<IpLink ip={ip} className='text-muted-foreground' />
)}
</div>
))}
</div>
);
},
},
{
accessorKey: 'user',
header: t('user'),
cell: ({ row }) => (
<UserSubscribeInfo userId={Number(row.original.uid)} type='account' />
),
},
{
accessorKey: 'subscribeName',
header: t('subscribeName'),
cell: ({ row }) => (
<UserSubscribeInfo
userId={Number(row.original.uid)}
type='subscribeName'
/>
),
},
{
accessorKey: 'subscribeId',
header: t('subscribeId'),
cell: ({ row }) => (
<UserSubscribeInfo
userId={Number(row.original.uid)}
type='subscribeId'
/>
),
},
{
accessorKey: 'trafficUsage',
header: t('trafficUsage'),
cell: ({ row }) => (
<UserSubscribeInfo
userId={Number(row.original.uid)}
type='trafficUsage'
/>
),
},
{
accessorKey: 'expireTime',
header: t('expireTime'),
cell: ({ row }) => (
<UserSubscribeInfo
userId={Number(row.original.uid)}
type='expireTime'
/>
),
},
]}
request={async () => ({
list: onlineUsersData,
total: onlineUsersData.length,
})}
/>
</div>
</div>
)}
</div>
<div className='flex flex-col space-y-1'>
<div className='flex justify-between'>
<span>{t('memory')}</span>
<span>{formatPercentage(mem ?? 0)}</span>
</div>
<Progress value={mem ?? 0} className='h-2' max={100} />
</div>
<div className='flex flex-col space-y-1'>
<div className='flex justify-between'>
<span>{t('disk')}</span>
<span>{formatPercentage(disk ?? 0)}</span>
</div>
<Progress value={disk ?? 0} className='h-2' max={100} />
</div>
{isOnline && (
<div>
{t('lastUpdated')}: {formatDate(updated_at ?? 0)}
</div>
)}
</div>
</TooltipTrigger>
{isOnline && onlineCount > 0 && (
<TooltipContent className='bg-card text-foreground w-96'>
<ScrollArea className='h-[540px] rounded-md border px-4 py-2'>
<h4 className='py-1 text-sm font-semibold'>{t('onlineUsers')}</h4>
<Accordion
type='single'
collapsible
className='w-full'
onValueChange={(value) => setOpenItem(value)}
>
{Object.entries(online).map(([uid, ips]) => (
<AccordionItem key={uid} value={uid}>
<AccordionTrigger>{`[UID: ${uid}] - ${ips[0]}`}</AccordionTrigger>
<AccordionContent>
<ul>
{ips.map((ip: string) => (
<li key={ip}>{ip}</li>
))}
</ul>
<UserSubscribeDetail id={Number(uid)} enabled={openItem === uid} />
</AccordionContent>
</AccordionItem>
))}
</Accordion>
</ScrollArea>
</TooltipContent>
</SheetContent>
)}
</Tooltip>
</TooltipProvider>
</Sheet>
</>
);
}
@@ -139,7 +139,7 @@ export default function NodeTable() {
accessorKey: 'status',
header: t('status'),
cell: ({ row }) => {
return <NodeStatusCell status={row.original?.status} />;
return <NodeStatusCell status={row.original?.status} node={row.original} />;
},
},
{
@@ -183,9 +183,6 @@ export default function NodeTable() {
},
]}
params={[
{
key: 'search',
},
{
key: 'group_id',
placeholder: t('nodeGroup'),
@@ -194,6 +191,9 @@ export default function NodeTable() {
value: String(item.id),
})),
},
{
key: 'search',
},
]}
request={async (pagination, filter) => {
const { data } = await getNodeList({
@@ -0,0 +1,258 @@
'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(),
user_agent_limit: z.boolean().optional(),
user_agent_list: 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: '',
user_agent_limit: false,
user_agent_list: '',
},
});
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='user_agent_limit'
render={({ field }) => (
<FormItem>
<FormLabel>{t('config.userAgentLimit', { userAgent: 'User-Agent' })}</FormLabel>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
className='float-end !mt-0'
/>
</FormControl>
<FormDescription>
{t('config.userAgentLimitDescription', { userAgent: 'User-Agent' })}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='user_agent_list'
render={({ field }) => (
<FormItem>
<FormLabel>
{t('config.userAgentList', {
userAgent: 'User-Agent',
})}
</FormLabel>
<FormControl>
<Textarea
className='h-32'
placeholder={`${t('config.userAgentListPlaceholder', { userAgent: 'User-Agent' })}\nClashX\nClashForAndroid\nClash-verge`}
{...field}
/>
</FormControl>
<FormDescription>
{t('config.userAgentListDescription', { userAgent: 'User-Agent' })}
</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>
);
}
+17 -24
View File
@@ -1,30 +1,23 @@
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>
<ProtocolForm />
</div>
);
}
@@ -0,0 +1,841 @@
'use client';
import { ProTable, ProTableActions } from '@/components/pro-table';
import {
createSubscribeApplication,
deleteSubscribeApplication,
getSubscribeApplicationList,
updateSubscribeApplication,
} from '@/services/admin/application';
import { zodResolver } from '@hookform/resolvers/zod';
import { ColumnDef } from '@tanstack/react-table';
import { Badge } from '@workspace/ui/components/badge';
import { Button } from '@workspace/ui/components/button';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@workspace/ui/components/form';
import { Input } from '@workspace/ui/components/input';
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,
} from '@workspace/ui/components/sheet';
import { Switch } from '@workspace/ui/components/switch';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@workspace/ui/components/tabs';
import { Textarea } from '@workspace/ui/components/textarea';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@workspace/ui/components/tooltip';
import { ConfirmButton } from '@workspace/ui/custom-components/confirm-button';
import { GoTemplateEditor } from '@workspace/ui/custom-components/editor';
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 Image from 'next/image';
import { useRef, useState } from 'react';
import { useForm } from 'react-hook-form';
import { toast } from 'sonner';
import { z } from 'zod';
import { TemplatePreview } from './template-preview';
const createClientFormSchema = (t: any) =>
z.object({
name: z.string().min(1, t('form.validation.nameRequired')),
description: z.string().optional(),
icon: z.string().optional(),
user_agent: z.string().min(1, `User-Agent ${t('form.validation.userAgentRequiredSuffix')}`),
scheme: z.string().default(''),
template: z.string().default(''),
output_format: z.string().default(''),
download_link: z.object({
windows: z.string().optional(),
mac: z.string().optional(),
linux: z.string().optional(),
ios: z.string().optional(),
android: z.string().optional(),
harmony: z.string().optional(),
}),
});
type ClientFormData = z.infer<ReturnType<typeof createClientFormSchema>>;
export function ProtocolForm() {
const t = useTranslations('subscribe');
const [loading, setLoading] = useState(false);
const [open, setOpen] = useState(false);
const [previewOpen, setPreviewOpen] = useState(false);
const [previewApplicationId, setPreviewApplicationId] = useState<number | null>(null);
const [editingClient, setEditingClient] = useState<API.SubscribeApplication | null>(null);
const tableRef = useRef<ProTableActions>(null);
const clientFormSchema = createClientFormSchema(t);
const form = useForm<ClientFormData>({
resolver: zodResolver(clientFormSchema),
defaultValues: {
name: '',
description: '',
icon: '',
user_agent: '',
scheme: '',
template: '',
output_format: '',
download_link: {
windows: '',
mac: '',
linux: '',
ios: '',
android: '',
harmony: '',
},
},
});
// API请求函数
const request = async (
pagination: { page: number; size: number },
filter: Record<string, unknown>,
) => {
const { data } = await getSubscribeApplicationList({
page: pagination.page,
size: pagination.size,
});
return {
list: data.data?.list || [],
total: data.data?.total || 0,
};
};
// 表格列定义
const columns: ColumnDef<API.SubscribeApplication, any>[] = [
{
accessorKey: 'is_default',
header: t('table.columns.default'),
cell: ({ row }) => (
<Switch
checked={row.original.is_default || false}
onCheckedChange={async (checked) => {
await updateSubscribeApplication({
...row.original,
proxy_template: '',
is_default: checked,
});
tableRef.current?.refresh();
}}
/>
),
},
{
accessorKey: 'name',
header: t('table.columns.name'),
cell: ({ row }) => (
<div className='flex items-center gap-2'>
{row.original.icon && (
<div className='relative h-6 w-6 flex-shrink-0'>
<Image
src={row.original.icon}
alt={row.original.name}
width={24}
height={24}
className='rounded object-contain'
onError={() => {
console.log(`Failed to load image for ${row.original.name}`);
}}
/>
</div>
)}
<span className='font-medium'>{row.original.name}</span>
</div>
),
},
{
accessorKey: 'user_agent',
header: 'User-Agent',
cell: ({ row }) => (
<div className='text-muted-foreground max-w-[150px] truncate font-mono text-sm'>
{row.original.user_agent}
</div>
),
},
{
accessorKey: 'output_format',
header: t('table.columns.outputFormat'),
cell: ({ row }) => (
<Badge variant='secondary' className='text-xs'>
{t(`outputFormats.${row.original.output_format}`) || row.original.output_format}
</Badge>
),
},
{
accessorKey: 'download_link',
header: t('table.columns.supportedPlatforms'),
cell: ({ row }) => {
return (
<div className='flex flex-wrap gap-1'>
{Object.entries(row.original.download_link || {}).map(([key, value]) => {
if (value) {
return (
<Badge key={key} variant='secondary' className='text-xs'>
{t(`platforms.${key}`)}
</Badge>
);
}
return null;
})}
</div>
);
},
},
{
accessorKey: 'description',
header: t('table.columns.description'),
cell: ({ row }) => (
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<div className='text-muted-foreground max-w-[200px] truncate text-sm'>
{row.original.description}
</div>
</TooltipTrigger>
<TooltipContent className='max-w-xs'>
<p>{row.original.description}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
),
},
];
const handleAdd = () => {
setEditingClient(null);
form.reset({
name: '',
description: '',
icon: '',
user_agent: '',
scheme: '',
template: '',
output_format: '',
download_link: {
windows: '',
mac: '',
linux: '',
ios: '',
android: '',
harmony: '',
},
});
setOpen(true);
};
const handleEdit = (client: API.SubscribeApplication) => {
setEditingClient(client);
form.reset(client);
setOpen(true);
};
const handleDelete = async (client: API.SubscribeApplication) => {
setLoading(true);
try {
await deleteSubscribeApplication({ id: client.id });
tableRef.current?.refresh();
toast.success(t('actions.deleteSuccess'));
} catch (error) {
console.error('Failed to delete client:', error);
toast.error(t('actions.deleteFailed'));
} finally {
setLoading(false);
}
};
const handleBatchDelete = async (clients: API.SubscribeApplication[]) => {
setLoading(true);
try {
await Promise.all(clients.map((client) => deleteSubscribeApplication({ id: client.id })));
tableRef.current?.refresh();
toast.success(t('actions.batchDeleteSuccess', { count: clients.length }));
} catch (error) {
console.error('Failed to batch delete clients:', error);
toast.error(t('actions.deleteFailed'));
} finally {
setLoading(false);
}
};
const onSubmit = async (data: ClientFormData) => {
setLoading(true);
try {
if (editingClient) {
await updateSubscribeApplication({
...data,
proxy_template: '',
is_default: editingClient.is_default,
id: editingClient.id,
});
toast.success(t('actions.updateSuccess'));
} else {
await createSubscribeApplication({
...data,
is_default: false,
});
toast.success(t('actions.createSuccess'));
}
setOpen(false);
tableRef.current?.refresh();
} catch (error) {
console.error('Failed to save client:', error);
toast.error(t('actions.saveFailed'));
} finally {
setLoading(false);
}
};
return (
<>
<ProTable<API.SubscribeApplication, Record<string, unknown>>
action={tableRef}
columns={columns}
request={request}
header={{
title: <h2 className='text-lg font-semibold'>{t('protocol.title')}</h2>,
toolbar: <Button onClick={handleAdd}>{t('actions.add')}</Button>,
}}
actions={{
render: (row) => [
<TemplatePreview
key='preview'
applicationId={row.id}
output_format={row.output_format}
/>,
<Button
key='edit'
onClick={() => handleEdit(row as unknown as API.SubscribeApplication)}
>
{t('actions.edit')}
</Button>,
<ConfirmButton
key='delete'
trigger={
<Button variant='destructive' disabled={loading}>
{t('actions.delete')}
</Button>
}
title={t('actions.confirmDelete')}
description={t('actions.deleteWarning')}
onConfirm={() => handleDelete(row as unknown as API.SubscribeApplication)}
cancelText={t('actions.cancel')}
confirmText={t('actions.confirm')}
/>,
],
batchRender: (rows) => [
<ConfirmButton
key='batchDelete'
trigger={<Button variant='destructive'>{t('actions.batchDelete')}</Button>}
title={t('actions.confirmDelete')}
description={t('actions.batchDeleteWarning', { count: rows.length })}
onConfirm={() => handleBatchDelete(rows as unknown as API.SubscribeApplication[])}
cancelText={t('actions.cancel')}
confirmText={t('actions.confirm')}
/>,
],
}}
/>
<Sheet open={open} onOpenChange={setOpen}>
<SheetContent className='w-[580px] max-w-full md:max-w-screen-md'>
<SheetHeader>
<SheetTitle>{editingClient ? t('form.editTitle') : t('form.addTitle')}</SheetTitle>
</SheetHeader>
<ScrollArea className='h-[calc(100dvh-48px-36px-36px)]'>
<Form {...form}>
<form className='space-y-6 py-4'>
<Tabs defaultValue='basic' className='w-full'>
<TabsList className='grid w-full grid-cols-3'>
<TabsTrigger value='basic'>{t('form.tabs.basic')}</TabsTrigger>
<TabsTrigger value='template'>{t('form.tabs.template')}</TabsTrigger>
<TabsTrigger value='download'>{t('form.tabs.download')}</TabsTrigger>
</TabsList>
<TabsContent value='basic' className='space-y-4'>
<FormField
control={form.control}
name='icon'
render={({ field }) => (
<FormItem>
<FormLabel>{t('form.fields.icon')}</FormLabel>
<FormControl>
<EnhancedInput
type='text'
placeholder='https://example.com/icon.png'
suffix={
<UploadImage
className='bg-muted h-9 rounded-none border-none px-2'
onChange={(value) => {
form.setValue(field.name, value as string);
}}
/>
}
value={field.value}
onValueChange={(value) => {
form.setValue(field.name, value as string);
}}
/>
</FormControl>
<FormDescription>{t('form.descriptions.icon')}</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='name'
render={({ field }) => (
<FormItem>
<FormLabel>{t('form.fields.name')}</FormLabel>
<FormControl>
<Input placeholder='Clash for Windows' {...field} />
</FormControl>
<FormDescription>{t('form.descriptions.name')}</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='user_agent'
render={({ field }) => (
<FormItem>
<FormLabel>User-Agent</FormLabel>
<FormControl>
<Input placeholder='Clash' {...field} />
</FormControl>
<FormDescription>
{t('form.descriptions.userAgentPrefix')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='description'
render={({ field }) => (
<FormItem>
<FormLabel>{t('form.fields.description')}</FormLabel>
<FormControl>
<Textarea placeholder={t('form.descriptions.description')} {...field} />
</FormControl>
<FormDescription>{t('form.descriptions.description')}</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</TabsContent>
<TabsContent value='template' className='space-y-4'>
<FormField
control={form.control}
name='output_format'
render={({ field }) => (
<FormItem>
<FormLabel>{t('form.fields.outputFormat')}</FormLabel>
<FormControl>
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue placeholder='Select ...' />
</SelectTrigger>
<SelectContent>
<SelectItem value='base64'>{t('outputFormats.base64')}</SelectItem>
<SelectItem value='yaml'>{t('outputFormats.yaml')}</SelectItem>
<SelectItem value='json'>{t('outputFormats.json')}</SelectItem>
<SelectItem value='conf'>{t('outputFormats.conf')}</SelectItem>
<SelectItem value='plain'>{t('outputFormats.plain')}</SelectItem>
</SelectContent>
</Select>
</FormControl>
<FormDescription>{t('form.descriptions.outputFormat')}</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='scheme'
render={({ field }) => (
<FormItem>
<FormLabel className='flex items-center gap-2'>
{t('form.fields.scheme')}
<TooltipProvider>
<Tooltip>
<TooltipTrigger
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
}}
>
<Icon
icon='mdi:help-circle-outline'
className='text-muted-foreground h-4 w-4'
/>
</TooltipTrigger>
<TooltipContent
side='right'
className='bg-secondary text-secondary-foreground max-w-md'
>
<div className='space-y-2 text-sm'>
<div className='font-medium'>
{t('form.descriptions.scheme.title')}
</div>
<div>
<div className='font-medium'>
{t('form.descriptions.scheme.variables')}
</div>
<ul className='ml-2 list-disc space-y-1 text-xs'>
<li>
<code className='rounded px-1'>{'${url}'}</code> -{' '}
{t('form.descriptions.scheme.urlVariable')}
</li>
<li>
<code className='rounded px-1'>{'${name}'}</code> -{' '}
{t('form.descriptions.scheme.nameVariable')}
</li>
</ul>
</div>
<div>
<div className='font-medium'>
{t('form.descriptions.scheme.functions')}
</div>
<ul className='ml-2 list-disc space-y-1 text-xs'>
<li>
<code className='rounded px-1'>
{'${encodeURIComponent(...)}'}
</code>{' '}
- {t('form.descriptions.scheme.urlEncoding')}
</li>
<li>
<code className='rounded px-1'>
{'${window.btoa(...)}'}
</code>{' '}
- {t('form.descriptions.scheme.base64Encoding')}
</li>
<li>
<code className='rounded px-1'>
{'${JSON.stringify(...)}'}
</code>{' '}
- {t('form.descriptions.scheme.jsonStringify')}
</li>
</ul>
</div>
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</FormLabel>
<FormControl>
<Input
placeholder='clash://install-config?url=${url}&name=${name}'
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='template'
render={({ field }) => (
<FormItem>
<FormLabel className='flex items-center gap-2'>
{t('form.fields.template')}
<TooltipProvider>
<Tooltip>
<TooltipTrigger
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
}}
>
<Icon
icon='mdi:help-circle-outline'
className='text-muted-foreground h-4 w-4'
/>
</TooltipTrigger>
<TooltipContent
side='right'
className='bg-secondary text-secondary-foreground max-w-md'
>
<div className='space-y-2 text-sm'>
<div className='font-medium'>
{t('form.descriptions.template.title')}
</div>
<div>
<div className='font-medium'>
{t('form.descriptions.template.variables')}
</div>
<ul className='ml-2 list-disc space-y-1 text-xs'>
<li>
<code className='rounded px-1'>.SiteName</code> -{' '}
{t('form.descriptions.template.siteName')}
</li>
<li>
<code className='rounded px-1'>.SubscribeName</code> -{' '}
{t('form.descriptions.template.subscribeName')}
</li>
<li>
<code className='rounded px-1'>.Proxies</code> -{' '}
{t('form.descriptions.template.nodes')}
</li>
<li>
<code className='rounded px-1'>.UserInfo</code> -{' '}
{t('form.descriptions.template.userInfo')}
</li>
</ul>
</div>
<div>
<div className='font-medium'>
{t('form.descriptions.template.functions')}
</div>
<ul className='ml-2 list-disc space-y-1 text-xs'>
<li>
<code className='rounded px-1'>
{'{{range .Proxies}}...{{end}}'}
</code>{' '}
- {t('form.descriptions.template.range')}
</li>
<li>
<code className='rounded px-1'>
{'{{if .condition}}...{{end}}'}
</code>{' '}
- {t('form.descriptions.template.if')}
</li>
<li>
<code className='rounded px-1'>{'{{sprig_func}}'}</code> -{' '}
{t('form.descriptions.template.sprig')}
</li>
</ul>
</div>
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</FormLabel>
<FormControl>
<GoTemplateEditor
showLineNumbers
schema={{
SiteName: { type: 'string', description: 'Site name' },
SubscribeName: { type: 'string', description: 'Subscribe name' },
Proxies: {
type: 'array',
description: 'Array of proxy nodes',
items: {
type: 'object',
properties: {
Name: { type: 'string', description: 'Node name' },
Server: { type: 'string', description: 'Server host' },
Port: { type: 'number', description: 'Server port' },
Type: { type: 'string', description: 'Proxy type' },
Tags: {
type: 'array',
description: 'Node tags',
items: { type: 'string' },
},
// Security Options
Security: {
type: 'string',
description: 'Security protocol',
},
SNI: {
type: 'string',
description: 'Server Name Indication for TLS',
},
AllowInsecure: {
type: 'boolean',
description:
'Allow insecure connections (skip certificate verification)',
},
Fingerprint: {
type: 'string',
description: 'Client fingerprint for TLS connections',
},
RealityServerAddr: {
type: 'string',
description: 'Reality server address',
},
RealityServerPort: {
type: 'number',
description: 'Reality server port',
},
RealityPrivateKey: {
type: 'string',
description: 'Reality private key for authentication',
},
RealityPublicKey: {
type: 'string',
description: 'Reality public key for authentication',
},
RealityShortId: {
type: 'string',
description: 'Reality short ID for authentication',
},
// Transport Options
Transport: {
type: 'string',
description: 'Transport protocol (e.g., ws, http, grpc)',
},
Host: {
type: 'string',
description: 'For WebSocket/HTTP/HTTPS',
},
Path: { type: 'string', description: 'For HTTP/HTTPS' },
ServiceName: {
type: 'string',
description: 'For gRPC',
},
// Shadowsocks Options
Method: { type: 'string', description: 'Encryption method' },
ServerKey: {
type: 'string',
description: 'For Shadowsocks 2022',
},
// Vmess/Vless/Trojan Options
Flow: {
type: 'string',
description: 'Flow for Vmess/Vless/Trojan',
},
// Hysteria2 Options
HopPorts: {
type: 'string',
description: 'Comma-separated list of hop ports',
},
HopInterval: {
type: 'number',
description: 'Interval for hop ports in seconds',
},
ObfsPassword: {
type: 'string',
description: 'Obfuscation password for Hysteria2',
},
// Tuic Options
DisableSNI: {
type: 'boolean',
description: 'Disable SNI',
},
ReduceRtt: {
type: 'boolean',
description: 'Reduce RTT',
},
UDPRelayMode: {
type: 'string',
description: 'UDP relay mode (e.g., "full", "partial")',
},
CongestionController: {
type: 'string',
description: 'Congestion controller (e.g., "cubic", "bbr")',
},
},
},
},
UserInfo: {
type: 'object',
description: 'User information',
properties: {
Password: { type: 'string', description: 'User password' },
ExpiredAt: { type: 'string', description: 'Expiration date' },
Download: { type: 'number', description: 'Downloaded bytes' },
Upload: { type: 'number', description: 'Uploaded bytes' },
Traffic: { type: 'number', description: 'Total traffic bytes' },
SubscribeURL: {
type: 'string',
description: 'Subscription URL',
},
},
},
}}
enableSprig
value={field.value || ''}
onChange={(value) => field.onChange(value)}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</TabsContent>
<TabsContent value='download' className='space-y-4'>
<div className='space-y-4'>
<div className='grid gap-3'>
{['windows', 'mac', 'linux', 'ios', 'android', 'harmony'].map((key) => (
<FormField
key={key}
control={form.control}
name={
`download_link.${key}` as `download_link.${keyof ClientFormData['download_link']}`
}
render={({ field }) => (
<FormItem>
<FormLabel>{t(`platforms.${key}`)}</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
{t(`platforms.${key}`)} {t('form.descriptions.downloadLink')}
</FormDescription>
</FormItem>
)}
/>
))}
</div>
</div>
</TabsContent>
</Tabs>
</form>
</Form>
</ScrollArea>
<SheetFooter className='flex-row justify-end gap-2 pt-3'>
<Button variant='outline' onClick={() => setOpen(false)}>
{t('actions.cancel')}
</Button>
<Button onClick={form.handleSubmit(onSubmit)} disabled={loading}>
{loading && <Icon icon='mdi:loading' className='mr-2 animate-spin' />}
{editingClient ? t('actions.update') : t('actions.add')}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
</>
);
}
@@ -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>
);
}
@@ -0,0 +1,89 @@
'use client';
import { previewSubscribeTemplate } from '@/services/admin/application';
import { useQuery } from '@tanstack/react-query';
import { Button } from '@workspace/ui/components/button';
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@workspace/ui/components/sheet';
import { Icon } from '@workspace/ui/custom-components/icon';
import { Markdown } from '@workspace/ui/custom-components/markdown';
import { useTranslations } from 'next-intl';
import { useState } from 'react';
interface TemplatePreviewProps {
applicationId: number;
output_format?: string;
}
export function TemplatePreview({ applicationId, output_format }: TemplatePreviewProps) {
const t = useTranslations('subscribe.templatePreview');
const [isOpen, setIsOpen] = useState(false);
const { data, isLoading, error } = useQuery({
queryKey: ['previewSubscribeTemplate', applicationId],
queryFn: () => previewSubscribeTemplate({ id: applicationId }, { skipErrorHandler: true }),
enabled: isOpen && !!applicationId,
retry: false,
});
const originalContent = data?.data?.data?.template || '';
const errorMessage = (error as any)?.data?.msg || error?.message || t('failed');
const displayContent = originalContent || (error ? errorMessage : '');
const getDecodedContent = () => {
if (output_format === 'base64' && originalContent) {
try {
return atob(originalContent);
} catch {
return t('base64.decodeError');
}
}
return '';
};
const getDisplayContent = () => {
switch (output_format) {
case 'base64':
return `\`\`\`base64\n# ${t('base64.originalContent')}\n${displayContent}\n\n# ${t('base64.decodedContent')}\n${getDecodedContent()}\n\`\`\``;
case 'yaml':
return `\`\`\`yaml\n${displayContent}\n\`\`\``;
case 'json':
return `\`\`\`json\n${displayContent}\n\`\`\``;
case 'conf':
return `\`\`\`ini\n${displayContent}\n\`\`\``;
case 'plain':
return `\`\`\`text\n${displayContent}\n\`\`\``;
default:
return displayContent;
}
};
const handleOpenChange = (newOpen: boolean) => {
setIsOpen(newOpen);
};
return (
<>
<Button variant='ghost' size='sm' onClick={() => setIsOpen(true)}>
<Icon icon='mdi:eye' className='mr-2 h-4 w-4' />
{t('preview')}
</Button>
<Sheet open={isOpen} onOpenChange={handleOpenChange}>
<SheetContent className='w-[800px] max-w-[90vw] md:max-w-screen-md'>
<SheetHeader>
<SheetTitle>{t('title')}</SheetTitle>
</SheetHeader>
{isLoading ? (
<div className='flex items-center justify-center'>
<Icon icon='mdi:loading' className='h-6 w-6 animate-spin' />
<span className='ml-2'>{t('loading')}</span>
</div>
) : (
<div className='*:text-sm [&_pre>div>div+div]:max-h-[calc(100dvh-48px-36px-36px)] [&_pre>div>div+div]:overflow-auto'>
<Markdown>{getDisplayContent()}</Markdown>
</div>
)}
</SheetContent>
</Sheet>
</>
);
}
@@ -0,0 +1,188 @@
'use client';
import { getCurrencyConfig, updateCurrencyConfig } from '@/services/admin/system';
import { zodResolver } from '@hookform/resolvers/zod';
import { useQuery } from '@tanstack/react-query';
import { Button } from '@workspace/ui/components/button';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@workspace/ui/components/form';
import { ScrollArea } from '@workspace/ui/components/scroll-area';
import {
Sheet,
SheetContent,
SheetFooter,
SheetHeader,
SheetTitle,
SheetTrigger,
} from '@workspace/ui/components/sheet';
import { EnhancedInput } from '@workspace/ui/custom-components/enhanced-input';
import { Icon } from '@workspace/ui/custom-components/icon';
import { useTranslations } from 'next-intl';
import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { toast } from 'sonner';
import { z } from 'zod';
// Constants
const EXCHANGE_RATE_HOST_URL = 'https://exchangerate.host';
const currencySchema = z.object({
access_key: z.string().optional(),
currency_unit: z.string().min(1),
currency_symbol: z.string().min(1),
});
type CurrencyFormData = z.infer<typeof currencySchema>;
export default function CurrencyConfig() {
const t = useTranslations('system');
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
const { data, refetch } = useQuery({
queryKey: ['getCurrencyConfig'],
queryFn: async () => {
const { data } = await getCurrencyConfig();
return data.data;
},
enabled: open, // Only request data when the modal is open
});
const form = useForm<CurrencyFormData>({
resolver: zodResolver(currencySchema),
defaultValues: {
access_key: '',
currency_unit: 'USD',
currency_symbol: '$',
},
});
useEffect(() => {
if (data) {
form.reset(data);
}
}, [data, form]);
async function onSubmit(values: CurrencyFormData) {
setLoading(true);
try {
await updateCurrencyConfig(values as API.CurrencyConfig);
toast.success(t('common.saveSuccess'));
refetch();
setOpen(false);
} catch (error) {
toast.error(t('common.saveFailed'));
} finally {
setLoading(false);
}
}
return (
<Sheet open={open} onOpenChange={setOpen}>
<SheetTrigger asChild>
<div className='flex cursor-pointer items-center justify-between transition-colors'>
<div className='flex items-center gap-3'>
<div className='bg-primary/10 flex h-10 w-10 items-center justify-center rounded-lg'>
<Icon icon='mdi:currency-usd' className='text-primary h-5 w-5' />
</div>
<div className='flex-1'>
<p className='font-medium'>{t('currency.title')}</p>
<p className='text-muted-foreground text-sm'>{t('currency.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('currency.title')}</SheetTitle>
</SheetHeader>
<ScrollArea className='-mx-6 h-[calc(100dvh-48px-36px-36px-env(safe-area-inset-top))] px-6'>
<Form {...form}>
<form
id='currency-form'
onSubmit={form.handleSubmit(onSubmit)}
className='space-y-2 pt-4'
>
<FormField
control={form.control}
name='access_key'
render={({ field }) => (
<FormItem>
<FormLabel>{t('currency.accessKey')}</FormLabel>
<FormControl>
<EnhancedInput
placeholder={t('currency.accessKeyPlaceholder')}
value={field.value}
onValueChange={field.onChange}
/>
</FormControl>
<FormDescription>
{t('currency.accessKeyDescription', { url: EXCHANGE_RATE_HOST_URL })}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='currency_unit'
render={({ field }) => (
<FormItem>
<FormLabel>{t('currency.currencyUnit')}</FormLabel>
<FormControl>
<EnhancedInput
placeholder={t('currency.currencyUnitPlaceholder')}
value={field.value}
onValueChange={field.onChange}
/>
</FormControl>
<FormDescription>{t('currency.currencyUnitDescription')}</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='currency_symbol'
render={({ field }) => (
<FormItem>
<FormLabel>{t('currency.currencySymbol')}</FormLabel>
<FormControl>
<EnhancedInput
placeholder={t('currency.currencySymbolPlaceholder')}
value={field.value}
onValueChange={field.onChange}
/>
</FormControl>
<FormDescription>{t('currency.currencySymbolDescription')}</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</ScrollArea>
<SheetFooter className='flex-row justify-end gap-2 pt-3'>
<Button variant='outline' disabled={loading} onClick={() => setOpen(false)}>
{t('common.cancel')}
</Button>
<Button disabled={loading} type='submit' form='currency-form'>
{loading && <Icon icon='mdi:loading' className='mr-2 animate-spin' />}
{t('common.save')}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
);
}
@@ -0,0 +1,139 @@
'use client';
import { getPrivacyPolicyConfig, updatePrivacyPolicyConfig } from '@/services/admin/system';
import { zodResolver } from '@hookform/resolvers/zod';
import { useQuery } from '@tanstack/react-query';
import { Button } from '@workspace/ui/components/button';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@workspace/ui/components/form';
import { ScrollArea } from '@workspace/ui/components/scroll-area';
import {
Sheet,
SheetContent,
SheetFooter,
SheetHeader,
SheetTitle,
SheetTrigger,
} from '@workspace/ui/components/sheet';
import { MarkdownEditor } from '@workspace/ui/custom-components/editor';
import { Icon } from '@workspace/ui/custom-components/icon';
import { useTranslations } from 'next-intl';
import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { toast } from 'sonner';
import { z } from 'zod';
const privacyPolicySchema = z.object({
privacy_policy: z.string().optional(),
});
type PrivacyPolicyFormData = z.infer<typeof privacyPolicySchema>;
export default function PrivacyPolicyConfig() {
const t = useTranslations('system');
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
const { data, refetch } = useQuery({
queryKey: ['getPrivacyPolicyConfig'],
queryFn: async () => {
const { data } = await getPrivacyPolicyConfig();
return data.data;
},
enabled: open,
});
const form = useForm<PrivacyPolicyFormData>({
resolver: zodResolver(privacyPolicySchema),
defaultValues: {
privacy_policy: '',
},
});
useEffect(() => {
if (data) {
form.reset({
privacy_policy: data.privacy_policy || '',
});
}
}, [data, form]);
async function onSubmit(values: PrivacyPolicyFormData) {
setLoading(true);
try {
await updatePrivacyPolicyConfig(values as API.PrivacyPolicyConfig);
toast.success(t('common.saveSuccess'));
refetch();
setOpen(false);
} catch (error) {
toast.error(t('common.saveFailed'));
} finally {
setLoading(false);
}
}
return (
<Sheet open={open} onOpenChange={setOpen}>
<SheetTrigger asChild>
<div className='flex cursor-pointer items-center justify-between transition-colors'>
<div className='flex items-center gap-3'>
<div className='bg-primary/10 flex h-10 w-10 items-center justify-center rounded-lg'>
<Icon icon='mdi:shield-account-outline' className='text-primary h-5 w-5' />
</div>
<div className='flex-1'>
<p className='font-medium'>{t('privacyPolicy.title')}</p>
<p className='text-muted-foreground text-sm'>{t('privacyPolicy.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('privacyPolicy.title')}</SheetTitle>
</SheetHeader>
<ScrollArea className='-mx-6 h-[calc(100dvh-48px-36px-36px-env(safe-area-inset-top))] px-6'>
<Form {...form}>
<form
id='privacy-policy-form'
onSubmit={form.handleSubmit(onSubmit)}
className='space-y-2 pt-4'
>
<FormField
control={form.control}
name='privacy_policy'
render={({ field }) => (
<FormItem>
<FormLabel>{t('privacyPolicy.title')}</FormLabel>
<FormControl>
<MarkdownEditor value={field.value || ''} onChange={field.onChange} />
</FormControl>
<FormDescription>{t('privacyPolicy.description')}</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</ScrollArea>
<SheetFooter className='flex-row justify-end gap-2 pt-3'>
<Button variant='outline' disabled={loading} onClick={() => setOpen(false)}>
{t('common.cancel')}
</Button>
<Button disabled={loading} type='submit' form='privacy-policy-form'>
{loading && <Icon icon='mdi:loading' className='mr-2 animate-spin' />}
{t('common.save')}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
);
}
@@ -0,0 +1,304 @@
'use client';
import { getSiteConfig, updateSiteConfig } from '@/services/admin/system';
import { zodResolver } from '@hookform/resolvers/zod';
import { useQuery } from '@tanstack/react-query';
import { Button } from '@workspace/ui/components/button';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@workspace/ui/components/form';
import { ScrollArea } from '@workspace/ui/components/scroll-area';
import {
Sheet,
SheetContent,
SheetFooter,
SheetHeader,
SheetTitle,
SheetTrigger,
} from '@workspace/ui/components/sheet';
import { Textarea } from '@workspace/ui/components/textarea';
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 { 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 { toast } from 'sonner';
import { z } from 'zod';
const siteSchema = z.object({
site_logo: z.string().optional(),
site_name: z.string().min(1),
site_desc: z.string().optional(),
keywords: z.string().optional(),
custom_html: z.string().optional(),
host: z.string().optional(),
custom_data: z.any().optional(),
});
type SiteFormData = z.infer<typeof siteSchema>;
export default function SiteConfig() {
const t = useTranslations('system');
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
const { data, refetch } = useQuery({
queryKey: ['getSiteConfig'],
queryFn: async () => {
const { data } = await getSiteConfig();
return data.data;
},
enabled: open,
});
const form = useForm<SiteFormData>({
resolver: zodResolver(siteSchema),
defaultValues: {
site_logo: '',
site_name: '',
site_desc: '',
keywords: '',
custom_html: '',
host: '',
custom_data: {},
},
});
useEffect(() => {
if (data) {
form.reset(data);
}
}, [data, form]);
async function onSubmit(values: SiteFormData) {
setLoading(true);
try {
await updateSiteConfig(values as API.SiteConfig);
toast.success(t('common.saveSuccess'));
refetch();
setOpen(false);
} catch (error) {
toast.error(t('common.saveFailed'));
} finally {
setLoading(false);
}
}
return (
<Sheet open={open} onOpenChange={setOpen}>
<SheetTrigger asChild>
<div className='flex cursor-pointer items-center justify-between transition-colors'>
<div className='flex items-center gap-3'>
<div className='bg-primary/10 flex h-10 w-10 items-center justify-center rounded-lg'>
<Icon icon='mdi:web' className='text-primary h-5 w-5' />
</div>
<div className='flex-1'>
<p className='font-medium'>{t('site.title')}</p>
<p className='text-muted-foreground text-sm'>{t('site.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('site.title')}</SheetTitle>
</SheetHeader>
<ScrollArea className='-mx-6 h-[calc(100dvh-48px-36px-36px-env(safe-area-inset-top))] px-6'>
<Form {...form}>
<form id='site-form' onSubmit={form.handleSubmit(onSubmit)} className='space-y-2 pt-4'>
<FormField
control={form.control}
name='site_logo'
render={({ field }) => (
<FormItem>
<FormLabel>{t('site.logo')}</FormLabel>
<FormControl>
<EnhancedInput
placeholder={t('site.logoPlaceholder')}
value={field.value}
onValueChange={field.onChange}
suffix={
<UploadImage
className='bg-muted h-9 rounded-none border-none px-2'
onChange={(value) => {
field.onChange(value);
}}
/>
}
/>
</FormControl>
<FormDescription>{t('site.logoDescription')}</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='site_name'
render={({ field }) => (
<FormItem>
<FormLabel>{t('site.siteName')}</FormLabel>
<FormControl>
<EnhancedInput
placeholder={t('site.siteNamePlaceholder')}
value={field.value}
onValueChange={field.onChange}
/>
</FormControl>
<FormDescription>{t('site.siteNameDescription')}</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='site_desc'
render={({ field }) => (
<FormItem>
<FormLabel>{t('site.siteDesc')}</FormLabel>
<FormControl>
<EnhancedInput
placeholder={t('site.siteDescPlaceholder')}
value={field.value}
onValueChange={field.onChange}
/>
</FormControl>
<FormDescription>{t('site.siteDescDescription')}</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='keywords'
render={({ field }) => (
<FormItem>
<FormLabel>{t('site.keywords')}</FormLabel>
<FormControl>
<EnhancedInput
placeholder={t('site.keywordsPlaceholder')}
value={field.value}
onValueChange={field.onChange}
/>
</FormControl>
<FormDescription>{t('site.keywordsDescription')}</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='custom_html'
render={({ field }) => (
<FormItem>
<FormLabel>{t('site.customHtml')}</FormLabel>
<FormControl>
<Textarea
className='h-32'
placeholder={t('site.customHtmlDescription')}
{...field}
/>
</FormControl>
<FormDescription>{t('site.customHtmlDescription')}</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='host'
render={({ field }) => (
<FormItem>
<FormLabel>{t('site.siteDomain')}</FormLabel>
<FormControl>
<Textarea
className='h-32'
placeholder={`${t('site.siteDomainPlaceholder')}\nexample.com\nwww.example.com`}
{...field}
/>
</FormControl>
<FormDescription>{t('site.siteDomainDescription')}</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='custom_data'
render={({ field }) => (
<FormItem>
<FormLabel>{t('site.customData')}</FormLabel>
<FormControl>
<JSONEditor
schema={{
type: 'object',
additionalProperties: true,
properties: {
website: { type: 'string', title: 'Website' },
contacts: {
type: 'object',
title: 'Contacts',
additionalProperties: true,
properties: {
email: { type: 'string', title: 'Email' },
telephone: { type: 'string', title: 'Telephone' },
address: { type: 'string', title: 'Address' },
},
},
community: {
type: 'object',
title: 'Community',
additionalProperties: true,
properties: {
telegram: { type: 'string', title: 'Telegram' },
twitter: { type: 'string', title: 'Twitter' },
discord: { type: 'string', title: 'Discord' },
instagram: { type: 'string', title: 'Instagram' },
linkedin: { type: 'string', title: 'Linkedin' },
facebook: { type: 'string', title: 'Facebook' },
github: { type: 'string', title: 'Github' },
},
},
},
}}
value={field.value}
onBlur={(value) => field.onChange(value)}
/>
</FormControl>
<FormDescription>{t('site.customDataDescription')}</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</ScrollArea>
<SheetFooter className='flex-row justify-end gap-2 pt-3'>
<Button variant='outline' disabled={loading} onClick={() => setOpen(false)}>
{t('common.cancel')}
</Button>
<Button disabled={loading} type='submit' form='site-form'>
{loading && <Icon icon='mdi:loading' className='mr-2 animate-spin' />}
{t('common.save')}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
);
}
@@ -0,0 +1,134 @@
'use client';
import { getTosConfig, updateTosConfig } from '@/services/admin/system';
import { zodResolver } from '@hookform/resolvers/zod';
import { useQuery } from '@tanstack/react-query';
import { Button } from '@workspace/ui/components/button';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@workspace/ui/components/form';
import { ScrollArea } from '@workspace/ui/components/scroll-area';
import {
Sheet,
SheetContent,
SheetFooter,
SheetHeader,
SheetTitle,
SheetTrigger,
} from '@workspace/ui/components/sheet';
import { MarkdownEditor } from '@workspace/ui/custom-components/editor';
import { Icon } from '@workspace/ui/custom-components/icon';
import { useTranslations } from 'next-intl';
import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { toast } from 'sonner';
import { z } from 'zod';
const tosSchema = z.object({
tos_content: z.string().optional(),
});
type TosFormData = z.infer<typeof tosSchema>;
export default function TosConfig() {
const t = useTranslations('system');
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
const { data, refetch } = useQuery({
queryKey: ['getTosConfig'],
queryFn: async () => {
const { data } = await getTosConfig();
return data.data;
},
enabled: open,
});
const form = useForm<TosFormData>({
resolver: zodResolver(tosSchema),
defaultValues: {
tos_content: '',
},
});
useEffect(() => {
if (data) {
form.reset({
tos_content: data.tos_content || '',
});
}
}, [data, form]);
async function onSubmit(values: TosFormData) {
setLoading(true);
try {
await updateTosConfig(values as API.TosConfig);
toast.success(t('common.saveSuccess'));
refetch();
setOpen(false);
} catch (error) {
toast.error(t('common.saveFailed'));
} finally {
setLoading(false);
}
}
return (
<Sheet open={open} onOpenChange={setOpen}>
<SheetTrigger asChild>
<div className='flex cursor-pointer items-center justify-between transition-colors'>
<div className='flex items-center gap-3'>
<div className='bg-primary/10 flex h-10 w-10 items-center justify-center rounded-lg'>
<Icon icon='mdi:file-document-outline' className='text-primary h-5 w-5' />
</div>
<div className='flex-1'>
<p className='font-medium'>{t('tos.title')}</p>
<p className='text-muted-foreground text-sm'>{t('tos.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('tos.title')}</SheetTitle>
</SheetHeader>
<ScrollArea className='-mx-6 h-[calc(100dvh-48px-36px-36px-env(safe-area-inset-top))] px-6'>
<Form {...form}>
<form id='tos-form' onSubmit={form.handleSubmit(onSubmit)} className='space-y-2 pt-4'>
<FormField
control={form.control}
name='tos_content'
render={({ field }) => (
<FormItem>
<FormLabel>{t('tos.title')}</FormLabel>
<FormControl>
<MarkdownEditor value={field.value || ''} onChange={field.onChange} />
</FormControl>
<FormDescription>{t('tos.description')}</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</ScrollArea>
<SheetFooter className='flex-row justify-end gap-2 pt-3'>
<Button variant='outline' disabled={loading} onClick={() => setOpen(false)}>
{t('common.cancel')}
</Button>
<Button disabled={loading} type='submit' form='tos-form'>
{loading && <Icon icon='mdi:loading' className='mr-2 animate-spin' />}
{t('common.save')}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
);
}
@@ -1,80 +0,0 @@
'use client';
import { getCurrencyConfig, updateCurrencyConfig } from '@/services/admin/system';
import { useQuery } from '@tanstack/react-query';
import { Label } from '@workspace/ui/components/label';
import { Table, TableBody, TableCell, TableRow } from '@workspace/ui/components/table';
import { EnhancedInput } from '@workspace/ui/custom-components/enhanced-input';
import { useTranslations } from 'next-intl';
import { toast } from 'sonner';
export default function Site() {
const t = useTranslations('system.currency');
const { data, refetch } = useQuery({
queryKey: ['getCurrencyConfig'],
queryFn: async () => {
const { data } = await getCurrencyConfig();
return data.data;
},
});
async function updateConfig(key: string, value: unknown) {
if (data?.[key] === value) return;
try {
await updateCurrencyConfig({
...data,
[key]: value,
} as API.CurrencyConfig);
toast.success(t('saveSuccess'));
refetch();
} catch (error) {
/* empty */
}
}
return (
<Table>
<TableBody>
<TableRow>
<TableCell>
<Label>{t('accessKey')}</Label>
<p className='text-muted-foreground text-xs'>{t('accessKeyDescription')}</p>
</TableCell>
<TableCell className='text-right'>
<EnhancedInput
value={data?.access_key}
onValueBlur={(value) => updateConfig('access_key', value)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Label>{t('currencyUnit')}</Label>
<p className='text-muted-foreground text-xs'>{t('currencyUnitDescription')}</p>
</TableCell>
<TableCell className='text-right'>
<EnhancedInput
placeholder='USD'
value={data?.currency_unit}
onValueBlur={(value) => updateConfig('currency_unit', value)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Label>{t('currencySymbol')}</Label>
<p className='text-muted-foreground text-xs'>{t('currencySymbolDescription')}</p>
</TableCell>
<TableCell className='text-right'>
<EnhancedInput
placeholder='$'
value={data?.currency_symbol}
onValueBlur={(value) => updateConfig('currency_symbol', value)}
/>
</TableCell>
</TableRow>
</TableBody>
</Table>
);
}
+57 -28
View File
@@ -1,33 +1,62 @@
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@workspace/ui/components/tabs';
import { getTranslations } from 'next-intl/server';
import Currency from './currency';
import PrivacyPolicy from './privacy-policy';
import Site from './site';
import Tos from './tos';
'use client';
export default async function Page() {
const t = await getTranslations('system');
import { Table, TableBody, TableCell, TableRow } from '@workspace/ui/components/table';
import { useTranslations } from 'next-intl';
import CurrencyForm from './basic-settings/currency-form';
import PrivacyPolicyForm from './basic-settings/privacy-policy-form';
import SiteForm from './basic-settings/site-form';
import TosForm from './basic-settings/tos-form';
import InviteForm from './user-security/invite-form';
import RegisterForm from './user-security/register-form';
import VerifyCodeForm from './user-security/verify-code-form';
import VerifyForm from './user-security/verify-form';
export default function Page() {
const t = useTranslations('system');
// 定义表单配置
const formSections = [
{
title: t('basicSettings'),
forms: [
{ component: SiteForm },
{ component: CurrencyForm },
{ component: TosForm },
{ component: PrivacyPolicyForm },
],
},
{
title: t('userSecuritySettings'),
forms: [
{ component: RegisterForm },
{ component: InviteForm },
{ component: VerifyForm },
{ component: VerifyCodeForm },
],
},
];
return (
<Tabs defaultValue='site'>
<TabsList className='h-full flex-wrap'>
<TabsTrigger value='site'>{t('tabs.site')}</TabsTrigger>
<TabsTrigger value='currency'>{t('tabs.currency')}</TabsTrigger>
<TabsTrigger value='tos'>{t('tabs.tos')}</TabsTrigger>
<TabsTrigger value='privacy-policy'>{t('privacy-policy.title')}</TabsTrigger>
</TabsList>
<TabsContent value='site'>
<Site />
</TabsContent>
<TabsContent value='currency'>
<Currency />
</TabsContent>
<TabsContent value='tos'>
<Tos />
</TabsContent>
<TabsContent value='privacy-policy'>
<PrivacyPolicy />
</TabsContent>
</Tabs>
<div className='space-y-8'>
{formSections.map((section, sectionIndex) => (
<div key={sectionIndex}>
<h2 className='mb-4 text-lg font-semibold'>{section.title}</h2>
<Table>
<TableBody>
{section.forms.map((form, formIndex) => {
const FormComponent = form.component;
return (
<TableRow key={formIndex}>
<TableCell>
<FormComponent />
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
))}
</div>
);
}
@@ -1,48 +0,0 @@
'use client';
import { getPrivacyPolicyConfig, updatePrivacyPolicyConfig } from '@/services/admin/system';
import { useQuery } from '@tanstack/react-query';
import { MarkdownEditor } from '@workspace/ui/custom-components/editor';
import { useTranslations } from 'next-intl';
import { toast } from 'sonner';
export default function PrivacyPolicy() {
const t = useTranslations('system.privacy-policy');
const { data, refetch, isFetched } = useQuery({
queryKey: ['getPrivacyPolicyConfig'],
queryFn: async () => {
const { data } = await getPrivacyPolicyConfig();
return data.data;
},
});
async function updateConfig(key: string, value: unknown) {
if (data?.[key] === value) return;
try {
await updatePrivacyPolicyConfig({
...data,
[key]: value,
} as API.PrivacyPolicyConfig);
toast.success(t('saveSuccess'));
refetch();
} catch (error) {
/* empty */
}
}
return (
isFetched && (
<div className='h-[calc(100dvh-132px-env(safe-area-inset-top))] overflow-hidden'>
<MarkdownEditor
title={t('title')}
value={data?.privacy_policy}
onBlur={(value) => {
if (data?.privacy_policy !== value) {
updateConfig('privacy_policy', value);
}
}}
/>
</div>
)
);
}
-182
View File
@@ -1,182 +0,0 @@
'use client';
import { getSiteConfig, updateSiteConfig } from '@/services/admin/system';
import { useQuery } from '@tanstack/react-query';
import { Label } from '@workspace/ui/components/label';
import { Table, TableBody, TableCell, TableRow } from '@workspace/ui/components/table';
import { Textarea } from '@workspace/ui/components/textarea';
import { JSONEditor } from '@workspace/ui/custom-components/editor';
import { EnhancedInput } from '@workspace/ui/custom-components/enhanced-input';
import { UploadImage } from '@workspace/ui/custom-components/upload-image';
import { useTranslations } from 'next-intl';
import { useRef } from 'react';
import { toast } from 'sonner';
export default function Site() {
const t = useTranslations('system.site');
const ref = useRef<API.SiteConfig | undefined>(undefined);
const { data, refetch } = useQuery({
queryKey: ['getSiteConfig'],
queryFn: async () => {
const { data } = await getSiteConfig();
ref.current = data.data;
return data.data;
},
});
async function updateConfig(key: string, value: unknown) {
if (data?.[key] === value) return;
try {
await updateSiteConfig({
...ref.current,
[key]: value,
} as API.SiteConfig);
toast.success(t('saveSuccess'));
refetch();
} catch (error) {
/* empty */
}
}
return (
<Table>
<TableBody>
<TableRow>
<TableCell>
<Label>{t('logo')}</Label>
<p className='text-muted-foreground text-xs'>{t('logoDescription')}</p>
</TableCell>
<TableCell className='text-right'>
<EnhancedInput
placeholder={t('logoPlaceholder')}
value={data?.site_logo}
onValueBlur={(value) => updateConfig('site_logo', value)}
suffix={
<UploadImage
className='bg-muted h-9 rounded-none border-none px-2'
onChange={(value) => {
updateConfig('site_logo', value);
}}
/>
}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Label>{t('siteName')}</Label>
<p className='text-muted-foreground text-xs'>{t('siteNameDescription')}</p>
</TableCell>
<TableCell className='text-right'>
<EnhancedInput
placeholder={t('siteNamePlaceholder')}
value={data?.site_name}
onValueBlur={(value) => updateConfig('site_name', value)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Label>{t('siteDesc')}</Label>
<p className='text-muted-foreground text-xs'>{t('siteDescDescription')}</p>
</TableCell>
<TableCell className='text-right'>
<EnhancedInput
placeholder={t('siteDescPlaceholder')}
value={data?.site_desc}
onValueBlur={(value) => updateConfig('site_desc', value)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Label>{t('keywords')}</Label>
<p className='text-muted-foreground text-xs'>{t('keywordsDescription')}</p>
</TableCell>
<TableCell className='text-right'>
<EnhancedInput
placeholder={t('keywordsDescription')}
value={data?.keywords}
onValueBlur={(value) => updateConfig('keywords', value)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell className='align-top'>
<Label>{t('customHtml')}</Label>
<p className='text-muted-foreground text-xs'>{t('customHtmlDescription')}</p>
</TableCell>
<TableCell className='text-right'>
<Textarea
className='h-52'
placeholder={t('customHtmlDescription')}
defaultValue={data?.custom_html}
onBlur={(e) => {
updateConfig('custom_html', e.target.value);
}}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell className='align-top'>
<Label>{t('siteDomain')}</Label>
<p className='text-muted-foreground text-xs'>{t('siteDomainDescription')}</p>
</TableCell>
<TableCell className='text-right'>
<Textarea
className='h-52'
placeholder={`${t('siteDomainPlaceholder')}\nexample.com\nwww.example.com`}
defaultValue={data?.host}
onBlur={(e) => {
updateConfig('host', e.target.value);
}}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Label>{t('customData')}</Label>
<p className='text-muted-foreground text-xs'>{t('customDataDescription')}</p>
</TableCell>
<TableCell className='w-1/2 text-right'>
<JSONEditor
schema={{
type: 'object',
additionalProperties: true,
properties: {
website: { type: 'string', title: 'Website' },
contacts: {
type: 'object',
title: 'Contacts',
additionalProperties: true,
properties: {
email: { type: 'string', title: 'Email' },
telephone: { type: 'string', title: 'Telephone' },
address: { type: 'string', title: 'Address' },
},
},
community: {
type: 'object',
title: 'Community',
additionalProperties: true,
properties: {
telegram: { type: 'string', title: 'Telegram' },
twitter: { type: 'string', title: 'Twitter' },
discord: { type: 'string', title: 'Discord' },
instagram: { type: 'string', title: 'Instagram' },
linkedin: { type: 'string', title: 'Linkedin' },
facebook: { type: 'string', title: 'Facebook' },
github: { type: 'string', title: 'Github' },
},
},
},
}}
value={data?.custom_data}
onBlur={(value) => updateConfig('custom_data', value)}
/>
</TableCell>
</TableRow>
</TableBody>
</Table>
);
}
-49
View File
@@ -1,49 +0,0 @@
'use client';
import { getTosConfig, updateTosConfig } from '@/services/admin/system';
import { useQuery } from '@tanstack/react-query';
import { MarkdownEditor } from '@workspace/ui/custom-components/editor';
import { useTranslations } from 'next-intl';
import { toast } from 'sonner';
export default function Tos() {
const t = useTranslations('system.tos');
const { data, refetch, isFetched } = useQuery({
queryKey: ['getTosConfig'],
queryFn: async () => {
const { data } = await getTosConfig();
return data.data;
},
});
async function updateConfig(key: string, value: unknown) {
if (data?.[key] === value) return;
try {
await updateTosConfig({
...data,
[key]: value,
} as API.TosConfig);
toast.success(t('saveSuccess'));
refetch();
} catch (error) {
/* empty */
}
}
return (
isFetched && (
<div className='h-[calc(100dvh-132px-env(safe-area-inset-top))] overflow-hidden'>
<MarkdownEditor
title={t('title')}
value={data?.tos_content}
onBlur={(value) => {
if (data?.tos_content !== value) {
updateConfig('tos_content', value);
}
}}
/>
</div>
)
);
}
@@ -0,0 +1,188 @@
'use client';
import { getInviteConfig, updateInviteConfig } from '@/services/admin/system';
import { zodResolver } from '@hookform/resolvers/zod';
import { useQuery } from '@tanstack/react-query';
import { Button } from '@workspace/ui/components/button';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@workspace/ui/components/form';
import { ScrollArea } from '@workspace/ui/components/scroll-area';
import {
Sheet,
SheetContent,
SheetFooter,
SheetHeader,
SheetTitle,
SheetTrigger,
} from '@workspace/ui/components/sheet';
import { Switch } from '@workspace/ui/components/switch';
import { EnhancedInput } from '@workspace/ui/custom-components/enhanced-input';
import { Icon } from '@workspace/ui/custom-components/icon';
import { useTranslations } from 'next-intl';
import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { toast } from 'sonner';
import { z } from 'zod';
const inviteSchema = z.object({
forced_invite: z.boolean().optional(),
referral_percentage: z.number().optional(),
only_first_purchase: z.boolean().optional(),
});
type InviteFormData = z.infer<typeof inviteSchema>;
export default function InviteConfig() {
const t = useTranslations('system.invite');
const systemT = useTranslations('system');
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
const { data, refetch } = useQuery({
queryKey: ['getInviteConfig'],
queryFn: async () => {
const { data } = await getInviteConfig();
return data.data;
},
enabled: open,
});
const form = useForm<InviteFormData>({
resolver: zodResolver(inviteSchema),
defaultValues: {
forced_invite: false,
referral_percentage: 0,
only_first_purchase: false,
},
});
useEffect(() => {
if (data) {
form.reset(data);
}
}, [data, form]);
async function onSubmit(values: InviteFormData) {
setLoading(true);
try {
await updateInviteConfig(values as API.InviteConfig);
toast.success(t('saveSuccess'));
refetch();
setOpen(false);
} catch (error) {
toast.error(t('saveFailed'));
} finally {
setLoading(false);
}
}
return (
<Sheet open={open} onOpenChange={setOpen}>
<SheetTrigger asChild>
<div className='flex cursor-pointer items-center justify-between transition-colors'>
<div className='flex items-center gap-3'>
<div className='bg-primary/10 flex h-10 w-10 items-center justify-center rounded-lg'>
<Icon icon='mdi:account-multiple-plus-outline' className='text-primary h-5 w-5' />
</div>
<div className='flex-1'>
<p className='font-medium'>{t('title')}</p>
<p className='text-muted-foreground text-sm'>{t('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('title')}</SheetTitle>
</SheetHeader>
<ScrollArea className='-mx-6 h-[calc(100dvh-48px-36px-36px-env(safe-area-inset-top))] px-6'>
<Form {...form}>
<form
id='invite-form'
onSubmit={form.handleSubmit(onSubmit)}
className='space-y-2 pt-4'
>
<FormField
control={form.control}
name='forced_invite'
render={({ field }) => (
<FormItem>
<FormLabel>{t('forcedInvite')}</FormLabel>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
className='float-end !mt-0'
/>
</FormControl>
<FormDescription>{t('forcedInviteDescription')}</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='referral_percentage'
render={({ field }) => (
<FormItem>
<FormLabel>{t('referralPercentage')}</FormLabel>
<FormControl>
<EnhancedInput
placeholder={t('inputPlaceholder')}
value={field.value}
type='number'
min={0}
max={100}
suffix='%'
onValueBlur={(value) => field.onChange(Number(value))}
/>
</FormControl>
<FormDescription>{t('referralPercentageDescription')}</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='only_first_purchase'
render={({ field }) => (
<FormItem>
<FormLabel>{t('onlyFirstPurchase')}</FormLabel>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
className='float-end !mt-0'
/>
</FormControl>
<FormDescription>{t('onlyFirstPurchaseDescription')}</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</ScrollArea>
<SheetFooter className='flex-row justify-end gap-2 pt-3'>
<Button variant='outline' disabled={loading} onClick={() => setOpen(false)}>
{systemT('common.cancel')}
</Button>
<Button disabled={loading} type='submit' form='invite-form'>
{loading && <Icon icon='mdi:loading' className='mr-2 animate-spin' />}
{systemT('common.save')}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
);
}
@@ -0,0 +1,331 @@
'use client';
import { getSubscribeList } from '@/services/admin/subscribe';
import { getRegisterConfig, updateRegisterConfig } from '@/services/admin/system';
import { zodResolver } from '@hookform/resolvers/zod';
import { useQuery } from '@tanstack/react-query';
import { Button } from '@workspace/ui/components/button';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@workspace/ui/components/form';
import { ScrollArea } from '@workspace/ui/components/scroll-area';
import {
Sheet,
SheetContent,
SheetFooter,
SheetHeader,
SheetTitle,
SheetTrigger,
} from '@workspace/ui/components/sheet';
import { Switch } from '@workspace/ui/components/switch';
import { 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 { useTranslations } from 'next-intl';
import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { toast } from 'sonner';
import { z } from 'zod';
const registerSchema = z.object({
stop_register: z.boolean().optional(),
enable_trial: z.boolean().optional(),
trial_subscribe: z.number().optional(),
trial_time: z.number().optional(),
trial_time_unit: z.string().optional(),
enable_ip_register_limit: z.boolean().optional(),
ip_register_limit: z.number().optional(),
ip_register_limit_duration: z.number().optional(),
});
type RegisterFormData = z.infer<typeof registerSchema>;
export default function RegisterConfig() {
const t = useTranslations('system.register');
const systemT = useTranslations('system');
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
const { data, refetch } = useQuery({
queryKey: ['getRegisterConfig'],
queryFn: async () => {
const { data } = await getRegisterConfig();
return data.data;
},
enabled: open,
});
const { data: subscribe } = useQuery({
queryKey: ['getSubscribeList', 'all'],
queryFn: async () => {
const { data } = await getSubscribeList({
page: 1,
size: 9999,
});
return data.data?.list as API.Subscribe[];
},
enabled: open,
});
const form = useForm<RegisterFormData>({
resolver: zodResolver(registerSchema),
defaultValues: {
stop_register: false,
enable_trial: false,
trial_subscribe: undefined,
trial_time: 0,
trial_time_unit: 'day',
enable_ip_register_limit: false,
ip_register_limit: 1,
ip_register_limit_duration: 1,
},
});
useEffect(() => {
if (data) {
form.reset(data);
}
}, [data, form]);
async function onSubmit(values: RegisterFormData) {
setLoading(true);
try {
await updateRegisterConfig(values as API.RegisterConfig);
toast.success(t('saveSuccess'));
refetch();
setOpen(false);
} catch (error) {
toast.error(t('saveFailed'));
} finally {
setLoading(false);
}
}
return (
<Sheet open={open} onOpenChange={setOpen}>
<SheetTrigger asChild>
<div className='flex cursor-pointer items-center justify-between transition-colors'>
<div className='flex items-center gap-3'>
<div className='bg-primary/10 flex h-10 w-10 items-center justify-center rounded-lg'>
<Icon icon='mdi:account-plus-outline' className='text-primary h-5 w-5' />
</div>
<div className='flex-1'>
<p className='font-medium'>{t('title')}</p>
<p className='text-muted-foreground text-sm'>{t('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('title')}</SheetTitle>
</SheetHeader>
<ScrollArea className='-mx-6 h-[calc(100dvh-48px-36px-36px-env(safe-area-inset-top))] px-6'>
<Form {...form}>
<form
id='register-form'
onSubmit={form.handleSubmit(onSubmit)}
className='space-y-2 pt-4'
>
<FormField
control={form.control}
name='stop_register'
render={({ field }) => (
<FormItem>
<FormLabel>{t('stopNewUserRegistration')}</FormLabel>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
className='float-end !mt-0'
/>
</FormControl>
<FormDescription>{t('stopNewUserRegistrationDescription')}</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='enable_ip_register_limit'
render={({ field }) => (
<FormItem>
<FormLabel>{t('ipRegistrationLimit')}</FormLabel>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
className='float-end !mt-0'
/>
</FormControl>
<FormDescription>{t('ipRegistrationLimitDescription')}</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{form.watch('enable_ip_register_limit') && (
<div className='grid grid-cols-1 gap-4 md:grid-cols-2'>
<FormField
control={form.control}
name='ip_register_limit'
render={({ field }) => (
<FormItem>
<FormLabel>{t('registrationLimitCount')}</FormLabel>
<FormControl>
<EnhancedInput
placeholder={t('inputPlaceholder')}
value={field.value}
type='number'
min={1}
onValueBlur={(value) => field.onChange(Number(value))}
/>
</FormControl>
<FormDescription>{t('registrationLimitCountDescription')}</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='ip_register_limit_duration'
render={({ field }) => (
<FormItem>
<FormLabel>{t('registrationLimitExpire')}</FormLabel>
<FormControl>
<EnhancedInput
placeholder={t('inputPlaceholder')}
value={field.value}
type='number'
min={1}
suffix={t('minute')}
onValueBlur={(value) => field.onChange(Number(value))}
/>
</FormControl>
<FormDescription>{t('registrationLimitExpireDescription')}</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
)}
<FormField
control={form.control}
name='enable_trial'
render={({ field }) => (
<FormItem>
<FormLabel>{t('enableTrial')}</FormLabel>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
className='float-end !mt-0'
/>
</FormControl>
<FormDescription>{t('enableTrialDescription')}</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{form.watch('enable_trial') && (
<>
<FormField
control={form.control}
name='trial_time'
render={({ field }) => (
<FormItem>
<FormLabel>{t('trialConfig')}</FormLabel>
<FormControl>
<div className='flex gap-2'>
<EnhancedInput
placeholder={t('inputPlaceholder')}
value={field.value}
type='number'
min={0}
onValueBlur={(value) => field.onChange(Number(value))}
className='flex-1'
prefix={
<FormField
control={form.control}
name='trial_subscribe'
render={({ field }) => (
<Combobox
placeholder={t('selectPlaceholder')}
value={field.value}
onChange={(value: number) => {
if (value) {
field.onChange(value);
}
}}
options={
subscribe?.map((item) => ({
label: item.name,
value: item.id,
})) || []
}
className='bg-secondary w-32 rounded-r-none'
/>
)}
/>
}
suffix={
<FormField
control={form.control}
name='trial_time_unit'
render={({ field: unitField }) => (
<Combobox
placeholder={t('selectPlaceholder')}
value={unitField.value}
onChange={(value: string) => {
unitField.onChange(value);
}}
options={[
{ label: t('none'), value: 'None' },
{ label: t('year'), value: 'Year' },
{ label: t('month'), value: 'Month' },
{ label: t('day'), value: 'Day' },
{ label: t('hour'), value: 'Hour' },
{ label: t('minute'), value: 'Minute' },
]}
className='bg-secondary w-32 rounded-l-none'
/>
)}
/>
}
/>
</div>
</FormControl>
<FormDescription>{t('trialConfigDescription')}</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</>
)}
</form>
</Form>
</ScrollArea>
<SheetFooter className='flex-row justify-end gap-2 pt-3'>
<Button variant='outline' disabled={loading} onClick={() => setOpen(false)}>
{systemT('common.cancel')}
</Button>
<Button disabled={loading} type='submit' form='register-form'>
{loading && <Icon icon='mdi:loading' className='mr-2 animate-spin' />}
{systemT('common.save')}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
);
}
@@ -0,0 +1,192 @@
'use client';
import { getVerifyCodeConfig, updateVerifyCodeConfig } from '@/services/admin/system';
import { zodResolver } from '@hookform/resolvers/zod';
import { useQuery } from '@tanstack/react-query';
import { Button } from '@workspace/ui/components/button';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@workspace/ui/components/form';
import { ScrollArea } from '@workspace/ui/components/scroll-area';
import {
Sheet,
SheetContent,
SheetFooter,
SheetHeader,
SheetTitle,
SheetTrigger,
} from '@workspace/ui/components/sheet';
import { EnhancedInput } from '@workspace/ui/custom-components/enhanced-input';
import { Icon } from '@workspace/ui/custom-components/icon';
import { useTranslations } from 'next-intl';
import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { toast } from 'sonner';
import { z } from 'zod';
const verifyCodeSchema = z.object({
verify_code_expire_time: z.number().optional(),
verify_code_interval: z.number().optional(),
verify_code_limit: z.number().optional(),
});
type VerifyCodeFormData = z.infer<typeof verifyCodeSchema>;
export default function VerifyCodeConfig() {
const t = useTranslations('system.verifyCode');
const systemT = useTranslations('system');
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
const { data, refetch } = useQuery({
queryKey: ['getVerifyCodeConfig'],
queryFn: async () => {
const { data } = await getVerifyCodeConfig();
return data.data;
},
enabled: open,
});
const form = useForm<VerifyCodeFormData>({
resolver: zodResolver(verifyCodeSchema),
defaultValues: {
verify_code_expire_time: 300,
verify_code_interval: 60,
verify_code_limit: 10,
},
});
useEffect(() => {
if (data) {
form.reset(data);
}
}, [data, form]);
async function onSubmit(values: VerifyCodeFormData) {
setLoading(true);
try {
await updateVerifyCodeConfig(values as API.VerifyCodeConfig);
toast.success(t('saveSuccess'));
refetch();
setOpen(false);
} catch (error) {
toast.error(t('saveFailed'));
} finally {
setLoading(false);
}
}
return (
<Sheet open={open} onOpenChange={setOpen}>
<SheetTrigger asChild>
<div className='flex cursor-pointer items-center justify-between transition-colors'>
<div className='flex items-center gap-3'>
<div className='bg-primary/10 flex h-10 w-10 items-center justify-center rounded-lg'>
<Icon icon='mdi:message-text-clock-outline' className='text-primary h-5 w-5' />
</div>
<div className='flex-1'>
<p className='font-medium'>{t('title')}</p>
<p className='text-muted-foreground text-sm'>{t('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('title')}</SheetTitle>
</SheetHeader>
<ScrollArea className='-mx-6 h-[calc(100dvh-48px-36px-36px-env(safe-area-inset-top))] px-6'>
<Form {...form}>
<form
id='verify-code-form'
onSubmit={form.handleSubmit(onSubmit)}
className='space-y-2 pt-4'
>
<FormField
control={form.control}
name='verify_code_expire_time'
render={({ field }) => (
<FormItem>
<FormLabel>{t('expireTime')}</FormLabel>
<FormControl>
<EnhancedInput
placeholder={t('inputPlaceholder')}
value={field.value}
type='number'
min={60}
suffix={t('seconds')}
onValueBlur={(value) => field.onChange(Number(value))}
/>
</FormControl>
<FormDescription>{t('expireTimeDescription')}</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='verify_code_interval'
render={({ field }) => (
<FormItem>
<FormLabel>{t('interval')}</FormLabel>
<FormControl>
<EnhancedInput
placeholder={t('inputPlaceholder')}
value={field.value}
type='number'
min={30}
suffix={t('seconds')}
onValueBlur={(value) => field.onChange(Number(value))}
/>
</FormControl>
<FormDescription>{t('intervalDescription')}</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='verify_code_limit'
render={({ field }) => (
<FormItem>
<FormLabel>{t('dailyLimit')}</FormLabel>
<FormControl>
<EnhancedInput
placeholder={t('inputPlaceholder')}
value={field.value}
type='number'
min={1}
suffix={t('times')}
onValueBlur={(value) => field.onChange(Number(value))}
/>
</FormControl>
<FormDescription>{t('dailyLimitDescription')}</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</ScrollArea>
<SheetFooter className='flex-row justify-end gap-2 pt-3'>
<Button variant='outline' disabled={loading} onClick={() => setOpen(false)}>
{systemT('common.cancel')}
</Button>
<Button disabled={loading} type='submit' form='verify-code-form'>
{loading && <Icon icon='mdi:loading' className='mr-2 animate-spin' />}
{systemT('common.save')}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
);
}
@@ -0,0 +1,227 @@
'use client';
import { getVerifyConfig, updateVerifyConfig } from '@/services/admin/system';
import { zodResolver } from '@hookform/resolvers/zod';
import { useQuery } from '@tanstack/react-query';
import { Button } from '@workspace/ui/components/button';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@workspace/ui/components/form';
import { ScrollArea } from '@workspace/ui/components/scroll-area';
import {
Sheet,
SheetContent,
SheetFooter,
SheetHeader,
SheetTitle,
SheetTrigger,
} from '@workspace/ui/components/sheet';
import { Switch } from '@workspace/ui/components/switch';
import { EnhancedInput } from '@workspace/ui/custom-components/enhanced-input';
import { Icon } from '@workspace/ui/custom-components/icon';
import { useTranslations } from 'next-intl';
import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { toast } from 'sonner';
import { z } from 'zod';
const verifySchema = z.object({
turnstile_site_key: z.string().optional(),
turnstile_secret: z.string().optional(),
enable_register_verify: z.boolean().optional(),
enable_login_verify: z.boolean().optional(),
enable_password_verify: z.boolean().optional(),
});
type VerifyFormData = z.infer<typeof verifySchema>;
export default function VerifyConfig() {
const t = useTranslations('system.verify');
const systemT = useTranslations('system');
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
const { data, refetch } = useQuery({
queryKey: ['getVerifyConfig'],
queryFn: async () => {
const { data } = await getVerifyConfig();
return data.data;
},
enabled: open,
});
const form = useForm<VerifyFormData>({
resolver: zodResolver(verifySchema),
defaultValues: {
turnstile_site_key: '',
turnstile_secret: '',
enable_register_verify: false,
enable_login_verify: false,
enable_password_verify: false,
},
});
useEffect(() => {
if (data) {
form.reset(data);
}
}, [data, form]);
async function onSubmit(values: VerifyFormData) {
setLoading(true);
try {
await updateVerifyConfig(values as API.VerifyConfig);
toast.success(t('saveSuccess'));
refetch();
setOpen(false);
} catch (error) {
toast.error(t('saveFailed'));
} finally {
setLoading(false);
}
}
return (
<Sheet open={open} onOpenChange={setOpen}>
<SheetTrigger asChild>
<div className='flex cursor-pointer items-center justify-between transition-colors'>
<div className='flex items-center gap-3'>
<div className='bg-primary/10 flex h-10 w-10 items-center justify-center rounded-lg'>
<Icon icon='mdi:shield-check-outline' className='text-primary h-5 w-5' />
</div>
<div className='flex-1'>
<p className='font-medium'>{t('title')}</p>
<p className='text-muted-foreground text-sm'>{t('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('title')}</SheetTitle>
</SheetHeader>
<ScrollArea className='-mx-6 h-[calc(100dvh-48px-36px-36px-env(safe-area-inset-top))] px-6'>
<Form {...form}>
<form
id='verify-form'
onSubmit={form.handleSubmit(onSubmit)}
className='space-y-2 pt-4'
>
<FormField
control={form.control}
name='turnstile_site_key'
render={({ field }) => (
<FormItem>
<FormLabel>{t('turnstileSiteKey')}</FormLabel>
<FormControl>
<EnhancedInput
placeholder={t('turnstileSiteKeyPlaceholder')}
value={field.value}
onValueChange={field.onChange}
/>
</FormControl>
<FormDescription>{t('turnstileSiteKeyDescription')}</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='turnstile_secret'
render={({ field }) => (
<FormItem>
<FormLabel>{t('turnstileSecret')}</FormLabel>
<FormControl>
<EnhancedInput
placeholder={t('turnstileSecretPlaceholder')}
value={field.value}
type='password'
onValueChange={field.onChange}
/>
</FormControl>
<FormDescription>{t('turnstileSecretDescription')}</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='enable_register_verify'
render={({ field }) => (
<FormItem>
<FormLabel>{t('enableRegisterVerify')}</FormLabel>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
className='float-end !mt-0'
/>
</FormControl>
<FormDescription>{t('enableRegisterVerifyDescription')}</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='enable_login_verify'
render={({ field }) => (
<FormItem>
<FormLabel>{t('enableLoginVerify')}</FormLabel>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
className='float-end !mt-0'
/>
</FormControl>
<FormDescription>{t('enableLoginVerifyDescription')}</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='enable_password_verify'
render={({ field }) => (
<FormItem>
<FormLabel>{t('enablePasswordVerify')}</FormLabel>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
className='float-end !mt-0'
/>
</FormControl>
<FormDescription>{t('enablePasswordVerifyDescription')}</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</ScrollArea>
<SheetFooter className='flex-row justify-end gap-2 pt-3'>
<Button variant='outline' disabled={loading} onClick={() => setOpen(false)}>
{systemT('common.cancel')}
</Button>
<Button disabled={loading} type='submit' form='verify-form'>
{loading && <Icon icon='mdi:loading' className='mr-2 animate-spin' />}
{systemT('common.save')}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
);
}
+1 -1
View File
@@ -172,7 +172,7 @@ export default function Page() {
<DrawerDescription>{ticket?.description}</DrawerDescription>
</DrawerHeader>
<ScrollArea className='h-full overflow-hidden' ref={scrollRef}>
<div className='flex h-screen flex-col gap-4 p-4'>
<div className='flex h-full flex-col gap-4 p-4'>
{ticket?.follow?.map((item) => (
<div
key={item.id}
@@ -1,5 +1,6 @@
'use client';
import { IpLink } from '@/components/ip-link';
import { ProTable } from '@/components/pro-table';
import { getUserLoginLogs } from '@/services/admin/user';
import { Badge } from '@workspace/ui/components/badge';
@@ -26,6 +27,7 @@ export default function UserLoginHistory() {
{
accessorKey: 'login_ip',
header: t('loginIp'),
cell: ({ row }) => <IpLink ip={row.getValue('login_ip')} />,
},
{
accessorKey: 'user_agent',
@@ -1,6 +1,7 @@
'use client';
import { Display } from '@/components/display';
import { IpLink } from '@/components/ip-link';
import { ProTable } from '@/components/pro-table';
import {
getUserSubscribeDevices,
@@ -64,6 +65,7 @@ export function SubscriptionDetail({
{
accessorKey: 'ip',
header: 'IP',
cell: ({ row }) => <IpLink ip={row.getValue('ip')} />,
},
{
accessorKey: 'user_agent',
@@ -156,6 +158,7 @@ export function SubscriptionDetail({
{
accessorKey: 'ip',
header: 'IP',
cell: ({ row }) => <IpLink ip={row.getValue('ip')} />,
},
{
accessorKey: 'online',
@@ -8,7 +8,6 @@ import { HoverCard, HoverCardContent, HoverCardTrigger } from '@workspace/ui/com
import { formatBytes, formatDate } from '@workspace/ui/utils';
import { useTranslations } from 'next-intl';
import Link from 'next/link';
import { useState } from 'react';
export function UserSubscribeDetail({ id, enabled }: { id: number; enabled: boolean }) {
const t = useTranslations('user');
@@ -118,10 +117,9 @@ export function UserSubscribeDetail({ id, enabled }: { id: number; enabled: bool
export function UserDetail({ id }: { id: number }) {
const t = useTranslations('user');
const [shouldFetch, setShouldFetch] = useState(false);
const { data } = useQuery({
enabled: id !== 0 && shouldFetch,
enabled: id !== 0,
queryKey: ['getUserDetail', id],
queryFn: async () => {
const { data } = await getUserDetail({ id });
@@ -133,7 +131,7 @@ export function UserDetail({ id }: { id: number }) {
return (
<HoverCard>
<HoverCardTrigger asChild onMouseEnter={() => setShouldFetch(true)}>
<HoverCardTrigger asChild>
<Button variant='link' className='p-0' asChild>
<Link href={`/dashboard/user/${id}`}>
{data?.auth_methods[0]?.auth_identifier || t('loading')}
+27
View File
@@ -0,0 +1,27 @@
'use client';
import { ExternalLink } from 'lucide-react';
import React from 'react';
interface IpLinkProps {
ip: string;
children?: React.ReactNode;
className?: string;
target?: '_blank' | '_self';
}
export function IpLink({ ip, children, className = '', target = '_blank' }: IpLinkProps) {
const url = `https://ip.sb/ip/${ip}`;
return (
<a
href={url}
target={target}
rel={target === '_blank' ? 'noopener noreferrer' : undefined}
className={`text-primary hover:text-primary/80 inline-flex items-center gap-1 font-mono transition-colors hover:underline ${className}`}
>
{children || ip}
<ExternalLink className='h-3 w-3' />
</a>
);
}
+16 -12
View File
@@ -25,42 +25,46 @@ export function SidebarLeft({ ...props }: React.ComponentProps<typeof Sidebar>)
const pathname = usePathname();
return (
<Sidebar className='border-r-0' collapsible='icon' {...props}>
<SidebarHeader>
<SidebarHeader className='p-2'>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton size='lg' asChild>
<SidebarMenuButton size='sm' asChild className='h-10'>
<Link href='/'>
<div className='flex aspect-square size-8 items-center justify-center rounded-lg'>
<div className='flex aspect-square size-6 items-center justify-center rounded-lg'>
<Image
src={site.site_logo || '/favicon.svg'}
alt='logo'
width={48}
height={48}
width={24}
height={24}
className='size-full'
unoptimized
/>
</div>
<div className='grid flex-1 text-left text-sm leading-tight'>
<span className='truncate font-semibold'>{site.site_name}</span>
<span className='truncate text-xs'>{site.site_desc}</span>
<span className='truncate text-xs font-semibold'>{site.site_name}</span>
<span className='truncate text-xs opacity-70'>{site.site_desc}</span>
</div>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarHeader>
<SidebarContent>
<SidebarContent className='py-2'>
<SidebarMenu>
{navs.map((nav) => (
<SidebarGroup key={nav.title}>
{nav.items && <SidebarGroupLabel>{t(nav.title)}</SidebarGroupLabel>}
<SidebarGroup key={nav.title} className='py-1'>
{nav.items && (
<SidebarGroupLabel className='py-1 text-xs'>{t(nav.title)}</SidebarGroupLabel>
)}
<SidebarGroupContent>
<SidebarMenu>
{(nav.items || [nav]).map((item) => (
<SidebarMenuItem key={item.title}>
<SidebarMenuButton
asChild
size='sm'
tooltip={t(item.title)}
className='h-8'
isActive={
item.url === '/dashboard'
? pathname === item.url
@@ -68,8 +72,8 @@ export function SidebarLeft({ ...props }: React.ComponentProps<typeof Sidebar>)
}
>
<Link href={item.url}>
{item.icon && <Icon icon={item.icon} />}
<span>{t(item.title)}</span>
{item.icon && <Icon icon={item.icon} className='size-4' />}
<span className='text-sm'>{t(item.title)}</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
+25 -63
View File
@@ -1,46 +1,3 @@
export const AuthControl = [
{
title: 'General',
url: '/dashboard/auth-control/general',
},
{
title: 'Email',
url: '/dashboard/auth-control/email',
},
{
title: 'Phone Number',
url: '/dashboard/auth-control/phone',
},
{
title: 'Telegram',
url: '/dashboard/auth-control/telegram',
},
{
title: 'Apple',
url: '/dashboard/auth-control/apple',
},
{
title: 'Google',
url: '/dashboard/auth-control/google',
},
{
title: 'Facebook',
url: '/dashboard/auth-control/facebook',
},
// {
// title: 'Twitter',
// url: '/dashboard/auth-control/twitter',
// },
{
title: 'GitHub',
url: '/dashboard/auth-control/github',
},
{
title: 'Device',
url: '/dashboard/auth-control/device',
},
];
export const navs = [
{
title: 'Dashboard',
@@ -48,7 +5,7 @@ export const navs = [
icon: 'flat-color-icons:globe',
},
{
title: 'Settings',
title: 'System Management',
items: [
{
title: 'System Config',
@@ -59,43 +16,48 @@ export const navs = [
title: 'Auth Control',
url: '/dashboard/auth-control',
icon: 'flat-color-icons:lock-portrait',
items: AuthControl,
},
{
title: 'Payment Config',
url: '/dashboard/payment',
icon: 'flat-color-icons:currency-exchange',
},
{
title: 'ADS Config',
url: '/dashboard/ads',
icon: 'flat-color-icons:electrical-sensor',
},
{
title: 'Payment Config',
url: '/dashboard/payment',
icon: 'flat-color-icons:currency-exchange',
title: 'System Tool',
url: '/dashboard/tool',
icon: 'flat-color-icons:info',
},
],
},
{
title: 'Server',
items: [
{
title: 'Subscribe Config',
url: '/dashboard/subscribe',
icon: 'flat-color-icons:ruler',
},
{
title: 'Server Management',
url: '/dashboard/server',
icon: 'flat-color-icons:data-protection',
},
{
title: 'Product Management',
url: '/dashboard/product',
icon: 'flat-color-icons:shop',
},
{
title: 'Application Management',
url: '/dashboard/application',
icon: 'flat-color-icons:touchscreen-smartphone',
},
{
title: 'Rule Management',
url: '/dashboard/rules',
icon: 'flat-color-icons:ruler',
},
{
title: 'Subscribe Management',
url: '/dashboard/subscribe',
icon: 'flat-color-icons:shop',
},
],
},
{
@@ -127,6 +89,11 @@ export const navs = [
},
],
},
{
title: 'Marketing Management',
url: '/dashboard/marketing',
icon: 'flat-color-icons:bullish',
},
{
title: 'Announcement Management',
url: '/dashboard/announcement',
@@ -144,11 +111,6 @@ export const navs = [
},
],
},
{
title: 'System Tool',
url: '/dashboard/tool',
icon: 'flat-color-icons:info',
},
];
export function findNavByUrl(url: string) {
+2
View File
@@ -57,6 +57,8 @@ export const useGlobalStore = create<GlobalStore>((set) => ({
subscribe_path: '',
subscribe_domain: '',
pan_domain: false,
user_agent_limit: false,
user_agent_list: '',
},
verify_code: {
verify_code_expire_time: 5,
-16
View File
@@ -1,16 +0,0 @@
{
"clientId": "ID služby",
"clientIdDescription": "Apple Service ID, který můžete získat z Apple Developer Portálu",
"clientSecret": "Soukromý klíč",
"clientSecretDescription": "Obsah soukromého klíče (.p8 soubor) používaný pro autentizaci s Apple",
"enable": "Povolit",
"enableDescription": "Po povolení se uživatelé mohou přihlásit pomocí svého Apple ID",
"keyId": "ID klíče",
"keyIdDescription": "ID vašeho soukromého klíče z Apple Developer Portálu",
"redirectUri": "Přesměrovací URL",
"redirectUriDescription": "Prosím vyplňte adresu API přesměrované URL po úspěšném ověření pomocí Apple. Nepoužívejte / na konci.",
"saveFailed": "Uložení se nezdařilo",
"saveSuccess": "Uložení bylo úspěšné",
"teamId": "ID týmu",
"teamIdDescription": "ID týmu Apple Developer"
}
+188 -56
View File
@@ -1,14 +1,149 @@
{
"invite": {
"commissionFirstTimeOnly": "Provize pouze za první nákup",
"commissionFirstTimeOnlyDescription": "Pokud je povoleno, provize se generuje pouze při první platbě pozývatele; jednotlivé uživatele můžete konfigurovat v uživatelské správě",
"enableForcedInvite": "Povolit nucené pozvání",
"enableForcedInviteDescription": "Pokud je povoleno, mohou se registrovat pouze pozvaní uživatelé",
"inputPlaceholder": "Zadejte",
"inviteCommissionPercentage": "Procento provize za pozvání",
"inviteCommissionPercentageDescription": "Výchozí globální poměr rozdělení provize; jednotlivé poměry můžete konfigurovat v uživatelské správě",
"inviteSettings": "Nastavení pozvání",
"saveSuccess": "Uložení úspěšné"
"apple": {
"clientId": "ID služby",
"clientIdDescription": "Apple Service ID, který můžete získat z Apple Developer Portálu",
"clientSecret": "Soukromý klíč",
"clientSecretDescription": "Obsah soukromého klíče (.p8 soubor) používaný pro autentizaci s Apple",
"description": "Ověřte uživatele pomocí účtů Apple",
"enable": "Povolit",
"enableDescription": "Po povolení se uživatelé mohou přihlásit pomocí svého Apple ID",
"keyId": "ID klíče",
"keyIdDescription": "ID vašeho soukromého klíče z Apple Developer Portálu",
"redirectUri": "Přesměrovací URL",
"redirectUriDescription": "Prosím vyplňte adresu API přesměrované URL po úspěšném ověření pomocí Apple. Nepoužívejte / na konci.",
"teamId": "ID týmu",
"teamIdDescription": "ID týmu Apple Developer",
"title": "Přihlášení pomocí Apple"
},
"common": {
"cancel": "Zrušit",
"save": "Uložit",
"saveFailed": "Uložení se nezdařilo",
"saveSuccess": "Úspěšně uloženo"
},
"communicationMethods": "Communication Methods",
"device": {
"blockVirtualMachine": "Blokovat virtuální stroj",
"blockVirtualMachineDescription": "Pokud je povoleno, zařízení nebudou moci běžet na virtuálních strojích nebo emulátorech",
"communicationKey": "Komunikační klíč",
"communicationKeyDescription": "Komunikační klíč se používá pro bezpečnou komunikaci mezi zařízeními a servery",
"description": "Ověřte uživatele pomocí identifikátorů zařízení",
"enable": "Povolit",
"enableDescription": "Po povolení jsou podporovány různé identifikátory zařízení, jako jsou IMEI/IDFA/IDFV/AndroidID/Mac adresa pro přihlášení a registraci",
"enableSecurity": "Povolit šifrování komunikace",
"enableSecurityDescription": "Pokud je povoleno, komunikace mezi zařízeními a servery bude šifrována",
"showAds": "Zobrazit reklamy",
"showAdsDescription": "Pokud je povoleno, na zařízeních se budou zobrazovat reklamy",
"title": "Ověření zařízení"
},
"deviceAuthMethods": "Device Authentication Methods",
"email": {
"basicSettings": "Základní nastavení",
"description": "Ověřte uživatele pomocí e-mailových adres",
"emailSuffixWhitelist": "Seznam povolených přípon e-mailů",
"emailSuffixWhitelistDescription": "Když je povoleno, mohou se registrovat pouze e-maily s příponami v seznamu",
"emailVerification": "Ověření e-mailu",
"emailVerificationDescription": "Když je povoleno, uživatelé budou muset ověřit svůj e-mail",
"enable": "Povolit",
"enableDescription": "Po aktivaci budou povoleny funkce registrace e-mailem, přihlášení, propojení a odpojení.",
"expirationEmailTemplate": "Šablona oznámení o vypršení platnosti",
"expirationTemplate": "Oznámení o vypršení platnosti",
"inputPlaceholder": "Zadejte hodnotu...",
"logs": "Protokoly",
"logsDescription": "Zobrazit historii odeslaných e-mailů a jejich stav",
"maintenanceEmailTemplate": "Šablona oznámení o údržbě",
"maintenanceTemplate": "Oznámení o údržbě",
"sendFailure": "Nepodařilo se odeslat testovací e-mail, zkontrolujte prosím konfiguraci.",
"sendSuccess": "Testovací e-mail byl úspěšně odeslán.",
"sendTestEmail": "Odeslat testovací e-mail",
"sendTestEmailDescription": "Odeslat testovací e-mail pro ověření konfigurace.",
"senderAddress": "Adresa odesílatele",
"senderAddressDescription": "Výchozí e-mailová adresa používaná pro odesílání e-mailů.",
"smtpAccount": "SMTP účet",
"smtpAccountDescription": "E-mailový účet používaný pro ověřování.",
"smtpEncryptionMethod": "Metoda šifrování SMTP",
"smtpEncryptionMethodDescription": "Vyberte, zda povolit šifrování SSL/TLS.",
"smtpPassword": "SMTP heslo",
"smtpPasswordDescription": "Heslo pro účet SMTP.",
"smtpServerAddress": "Adresa SMTP serveru",
"smtpServerAddressDescription": "Zadejte adresu serveru používanou pro odesílání e-mailů.",
"smtpServerPort": "Port SMTP serveru",
"smtpServerPortDescription": "Zadejte port používaný k připojení k SMTP serveru.",
"smtpSettings": "Nastavení SMTP",
"templateVariables": {
"code": {
"description": "6místný ověřovací kód",
"title": "Ověřovací kód"
},
"expire": {
"description": "Doba platnosti ověřovacího kódu (minuty)",
"title": "Doba platnosti"
},
"expireDate": {
"description": "Datum vypršení platnosti služby, připomíná uživatelům, kdy služba vyprší",
"title": "Datum vypršení platnosti"
},
"maintenanceDate": {
"description": "Datum údržby systému, zobrazuje datum zahájení údržby",
"title": "Datum údržby"
},
"maintenanceTime": {
"description": "Odhadovaná doba údržby, zobrazuje časové období nebo trvání údržby",
"title": "Doba údržby"
},
"siteLogo": {
"description": "URL obrázku loga webu",
"title": "Logo webu"
},
"siteName": {
"description": "Aktuální název webu",
"title": "Název webu"
},
"title": "Proměnné šablony e-mailu",
"type": {
"conditionalSyntax": "Podporuje podmínkovou syntaxi pro přepínání obsahu na základě typu",
"description": "Identifikátor typu e-mailu, 1 pro ověřovací kód registrace, ostatní pro ověřovací kód pro resetování hesla",
"title": "Typ e-mailu"
}
},
"title": "Ověření e-mailem",
"trafficExceedEmailTemplate": "Šablona oznámení o překročení limitu provozu",
"trafficTemplate": "Limit provozu",
"verifyEmailTemplate": "Šablona ověřovacího e-mailu",
"verifyTemplate": "Ověřovací e-mail",
"whitelistSuffixes": "Povolené přípony",
"whitelistSuffixesDescription": "Používá se pro ověření e-mailu během registrace; jeden na řádek",
"whitelistSuffixesPlaceholder": "Zadejte přípony e-mailů, každou na samostatný řádek"
},
"facebook": {
"clientId": "ID klienta",
"clientIdDescription": "ID aplikace Facebook z konzole pro vývojáře Facebooku",
"clientSecret": "Klientský tajný klíč",
"clientSecretDescription": "Facebook App Secret z Facebook Developers Console",
"description": "Ověřte uživatele pomocí účtů Facebook",
"enable": "Povolit",
"enableDescription": "Po povolení se uživatelé mohou přihlásit pomocí svého účtu na Facebooku",
"title": "Přihlášení pomocí Facebooku"
},
"github": {
"clientId": "ID klienta GitHub",
"clientIdDescription": "ID klienta z nastavení vaší GitHub OAuth aplikace",
"clientSecret": "GitHub klientský tajný klíč",
"clientSecretDescription": "Tajný klíč klienta z nastavení vaší GitHub OAuth aplikace",
"description": "Ověřte uživatele pomocí účtů GitHub",
"enable": "Povolit ověřování GitHub",
"enableDescription": "Povolit uživatelům přihlásit se pomocí jejich účtů na GitHubu",
"title": "Přihlášení pomocí GitHubu"
},
"google": {
"clientId": "ID klienta",
"clientIdDescription": "ID klienta Google OAuth 2.0 z Google Cloud Console",
"clientSecret": "Klientský tajný klíč",
"clientSecretDescription": "Tajný klíč klienta Google OAuth 2.0 z Google Cloud Console",
"description": "Ověřte uživatele pomocí účtů Google",
"enable": "Povolit",
"enableDescription": "Po povolení se uživatelé mohou přihlásit pomocí svého účtu Google",
"title": "Přihlášení pomocí Google"
},
"log": {
"content": "Obsah",
@@ -23,52 +158,49 @@
"to": "Příjemce",
"updatedAt": "Aktualizováno"
},
"register": {
"day": "Den",
"hour": "Hodina",
"ipRegistrationLimit": "Omezení registrace podle IP",
"ipRegistrationLimitDescription": "Pokud je povoleno, IP adresy splňující pravidla budou omezeny v registraci; mějte na paměti, že určení IP může způsobit problémy kvůli CDN nebo proxy serverům",
"minute": "Minuta",
"month": "Měsíc",
"noLimit": "Bez omezení",
"penaltyTime": "Doba trestu (minuty)",
"penaltyTimeDescription": "Uživatelé musí počkat, až doba trestu vyprší, než se znovu zaregistrují",
"registerSettings": "Nastavení registrace",
"registrationLimitCount": "Počet omezení registrace",
"registrationLimitCountDescription": "Povolit trest po dosažení registračního limitu",
"saveSuccess": "Uložení úspěšné",
"stopNewUserRegistration": "Zastavit registraci nových uživatelů",
"stopNewUserRegistrationDescription": "Pokud je povoleno, nikdo se nemůže registrovat",
"trialDuration": "Doba zkušební verze",
"trialRegistration": "Zkušební registrace",
"trialRegistrationDescription": "Povolit zkušební registraci; nejprve upravte zkušební balíček a dobu trvání",
"trialSubscribePlan": "Plán zkušebního předplatného",
"trialSubscribePlanDescription": "Vyberte plán zkušebního předplatného",
"year": "Rok"
"phone": {
"accessLabel": "Přístup",
"applyPlatform": "Použít platformu",
"description": "Ověřte uživatele pomocí telefonních čísel",
"enable": "Povolit",
"enableTip": "Po povolení budou povoleny funkce registrace, přihlášení, připojení a odpojení mobilního telefonu",
"endpointLabel": "Koncový bod",
"logs": "Protokoly",
"logsDescription": "Zobrazit historii odeslaných SMS zpráv a jejich stav",
"phoneNumberLabel": "Telefonní číslo",
"placeholders": {
"template": "Váš ověřovací kód je {code}, platný po dobu 5 minut"
},
"platform": "SMS platforma",
"platformConfigTip": "Vyplňte prosím poskytnutou konfiguraci {key}",
"platformTip": "Vyberte prosím platformu pro SMS",
"secretLabel": "Tajný klíč",
"sendFailed": "Odeslání se nezdařilo",
"sendSuccess": "Úspěšně odesláno",
"settings": "Nastavení",
"signNameLabel": "Název podpisu",
"template": "Šablona SMS",
"templateCodeLabel": "Kód šablony",
"templateTip": "Prosím vyplňte SMS šablonu, ponechte {code} uprostřed, jinak SMS funkce nebude fungovat",
"testSms": "Odeslat testovací SMS",
"testSmsPhone": "Zadejte telefonní číslo",
"testSmsTip": "Odeslat testovací SMS pro ověření vaší konfigurace",
"title": "Ověření telefonem",
"updateSuccess": "Aktualizace úspěšná",
"whitelistAreaCode": "Kód oblasti v bílém seznamu",
"whitelistAreaCodeTip": "Zadejte kódy oblastí v bílém seznamu, např. 1, 852, 886, 888",
"whitelistValidation": "Ověření bílého seznamu",
"whitelistValidationTip": "Pokud je povoleno, mohou SMS odesílat pouze kódy oblastí v bílém seznamu"
},
"verify": {
"inputPlaceholder": "Zadejte",
"loginVerificationCode": "Ověřovací kód pro přihlášení",
"loginVerificationCodeDescription": "Ověření člověka během přihlášení",
"registrationVerificationCode": "Ověřovací kód pro registraci",
"registrationVerificationCodeDescription": "Ověření člověka během registrace",
"resetPasswordVerificationCode": "Ověřovací kód pro resetování hesla",
"resetPasswordVerificationCodeDescription": "Ověření člověka během resetování hesla",
"saveSuccess": "Uložení úspěšné",
"turnstileSecretDescription": "Tajný klíč turniketu poskytovaný Cloudflare",
"turnstileSiteKeyDescription": "Klíč pro web turniketu poskytovaný Cloudflare",
"verifySettings": "Nastavení ověření"
},
"verify-code": {
"dailyLimit": "Denní limit",
"dailyLimitDescription": "Maximální počet ověřovacích kódů, které lze odeslat za den",
"expireTime": "Čas vypršení",
"expireTimeDescription": "Doba platnosti ověřovacího kódu (vteřiny)",
"interval": "Interval odesílání",
"intervalDescription": "Minimální interval mezi odesíláním ověřovacích kódů (sekundy)",
"saveSuccess": "Uložení úspěšné",
"second": "sekundy",
"times": "krát",
"verifyCodeSettings": "Nastavení ověřovacího kódu"
"socialAuthMethods": "Social Authentication Methods",
"telegram": {
"clientId": "ID robota",
"clientIdDescription": "ID Telegram bota, které můžete získat od @BotFather",
"clientSecret": "Token robota",
"clientSecretDescription": "Telegram Bot Token, který můžete získat od @BotFather",
"description": "Ověřte uživatele pomocí účtů Telegram",
"enable": "Povolit",
"enableDescription": "Po povolení budou povoleny funkce registrace, přihlášení, připojení a odpojení mobilního telefonu",
"title": "Přihlášení pomocí Telegramu"
}
}
-14
View File
@@ -1,14 +0,0 @@
{
"blockVirtualMachine": "Blokovat virtuální stroj",
"blockVirtualMachineDescription": "Pokud je povoleno, zařízení nebudou moci běžet na virtuálních strojích nebo emulátorech",
"communicationKey": "Komunikační klíč",
"communicationKeyDescription": "Komunikační klíč se používá pro bezpečnou komunikaci mezi zařízeními a servery",
"enable": "Povolit",
"enableDescription": "Po povolení jsou podporovány různé identifikátory zařízení, jako jsou IMEI/IDFA/IDFV/AndroidID/Mac adresa pro přihlášení a registraci",
"enableSecurity": "Povolit šifrování komunikace",
"enableSecurityDescription": "Pokud je povoleno, komunikace mezi zařízeními a servery bude šifrována",
"saveFailed": "Uložení se nezdařilo",
"saveSuccess": "Uložení bylo úspěšné",
"showAds": "Zobrazit reklamy",
"showAdsDescription": "Pokud je povoleno, na zařízeních se budou zobrazovat reklamy"
}
-49
View File
@@ -1,49 +0,0 @@
{
"emailBasicConfigDescription": "Nakonfigurujte nastavení SMTP serveru a možnosti ověření e-mailu",
"emailLogsDescription": "Zobrazit historii odeslaných e-mailů a jejich stav",
"emailSuffixWhitelist": "Seznam povolených přípon e-mailů",
"emailSuffixWhitelistDescription": "Když je povoleno, mohou se registrovat pouze e-maily s příponami v seznamu",
"emailVerification": "Ověření e-mailu",
"emailVerificationDescription": "Když je povoleno, uživatelé budou muset ověřit svůj e-mail",
"enable": "Povolit",
"enableDescription": "Po aktivaci budou povoleny funkce registrace e-mailem, přihlášení, propojení a odpojení.",
"expiration_email_template": "Šablona oznámení o vypršení platnosti",
"expiration_email_templateDescription": "Zástupné symboly {after}.variable{before} budou nahrazeny skutečnými daty. Ujistěte se, že tyto proměnné zachováte.",
"failed": "Neúspěch",
"inputPlaceholder": "Zadejte hodnotu...",
"logs": "Protokoly",
"maintenance_email_template": "Šablona oznámení o údržbě",
"maintenance_email_templateDescription": "Zástupné symboly {after}.variable{before} budou nahrazeny skutečnými daty. Ujistěte se, že tyto proměnné zachováte.",
"recipient": "Příjemce",
"saveFailed": "Nepodařilo se uložit konfiguraci",
"saveSuccess": "Konfigurace byla úspěšně uložena.",
"sendFailure": "Nepodařilo se odeslat testovací e-mail, zkontrolujte prosím konfiguraci.",
"sendSuccess": "Testovací e-mail byl úspěšně odeslán.",
"sendTestEmail": "Odeslat testovací e-mail",
"sendTestEmailDescription": "Odeslat testovací e-mail pro ověření konfigurace.",
"senderAddress": "Adresa odesílatele",
"senderAddressDescription": "Výchozí e-mailová adresa používaná pro odesílání e-mailů.",
"sent": "Odesláno",
"sentAt": "Odesláno",
"settings": "Nastavení",
"smtpAccount": "SMTP účet",
"smtpAccountDescription": "E-mailový účet používaný pro ověřování.",
"smtpEncryptionMethod": "Metoda šifrování SMTP",
"smtpEncryptionMethodDescription": "Vyberte, zda povolit šifrování SSL/TLS.",
"smtpPassword": "SMTP heslo",
"smtpPasswordDescription": "Heslo pro účet SMTP.",
"smtpServerAddress": "Adresa SMTP serveru",
"smtpServerAddressDescription": "Zadejte adresu serveru používanou pro odesílání e-mailů.",
"smtpServerPort": "Port SMTP serveru",
"smtpServerPortDescription": "Zadejte port používaný k připojení k SMTP serveru.",
"status": "Stav",
"subject": "Předmět",
"template": "Šablona",
"traffic_exceed_email_template": "Šablona oznámení o překročení provozu",
"traffic_exceed_email_templateDescription": "Zástupné symboly {after}.variable{before} budou nahrazeny skutečnými daty. Ujistěte se, že tyto proměnné zachováte.",
"verify_email_template": "Šablona ověřovacího e-mailu",
"verify_email_templateDescription": "Zástupné symboly {after}.variable{before} budou nahrazeny skutečnými daty. Ujistěte se, že tyto proměnné zachováte.",
"whitelistSuffixes": "Povolené přípony",
"whitelistSuffixesDescription": "Používá se pro ověření e-mailu během registrace; jeden na řádek",
"whitelistSuffixesPlaceholder": "Zadejte přípony e-mailů, každou na samostatný řádek"
}
-10
View File
@@ -1,10 +0,0 @@
{
"clientId": "ID klienta",
"clientIdDescription": "ID aplikace Facebook z konzole pro vývojáře Facebooku",
"clientSecret": "Klientský tajný klíč",
"clientSecretDescription": "Facebook App Secret z Facebook Developers Console",
"enable": "Povolit",
"enableDescription": "Po povolení se uživatelé mohou přihlásit pomocí svého účtu na Facebooku",
"saveFailed": "Uložení se nezdařilo",
"saveSuccess": "Uložení bylo úspěšné"
}
-10
View File
@@ -1,10 +0,0 @@
{
"clientId": "ID klienta GitHub",
"clientIdDescription": "ID klienta z nastavení vaší GitHub OAuth aplikace",
"clientSecret": "GitHub klientský tajný klíč",
"clientSecretDescription": "Tajný klíč klienta z nastavení vaší GitHub OAuth aplikace",
"enable": "Povolit ověřování GitHub",
"enableDescription": "Povolit uživatelům přihlásit se pomocí jejich účtů na GitHubu",
"saveFailed": "Nepodařilo se uložit nastavení GitHubu",
"saveSuccess": "Nastavení GitHubu bylo úspěšně uloženo"
}
-10
View File
@@ -1,10 +0,0 @@
{
"clientId": "ID klienta",
"clientIdDescription": "ID klienta Google OAuth 2.0 z Google Cloud Console",
"clientSecret": "Klientský tajný klíč",
"clientSecretDescription": "Tajný klíč klienta Google OAuth 2.0 z Google Cloud Console",
"enable": "Povolit",
"enableDescription": "Po povolení se uživatelé mohou přihlásit pomocí svého účtu Google",
"saveFailed": "Uložení se nezdařilo",
"saveSuccess": "Uložení bylo úspěšné"
}
+97
View File
@@ -0,0 +1,97 @@
{
"additional": "Další",
"additionalRecipientEmails": "Další e-mailové adresy příjemců",
"additionalRecipients": "Další příjemci",
"additionalRecipientsDescription": "Tyto e-maily obdrží vysílání kromě uživatelského filtru výše",
"allUsers": "Všichni uživatelé",
"broadcastList": "Seznam vysílání",
"broadcastLogs": "Záznamy vysílání",
"cancel": "Zrušit",
"cannotBeEmpty": "nemůže být prázdné",
"completed": "Dokončeno",
"confirm": "Potvrdit",
"confirmDelete": "Potvrdit smazání",
"content": "Obsah e-mailu",
"create": "Vytvořit",
"createBroadcast": "Vytvořit vysílání",
"createNewEmailBroadcastCampaign": "Vytvořit novou kampaň e-mailového vysílání",
"createSuccess": "Úspěšně vytvořeno",
"createdAt": "Vytvořeno dne",
"dailyLimit": "Denní limit musí být alespoň 1",
"dailySendLimit": "Denní limit odeslání",
"delete": "Smazat",
"deleteDescription": "Tuto operaci nelze vrátit zpět. Opravdu chcete smazat?",
"deleteSuccess": "Úspěšně smazáno",
"edit": "Upravit",
"emailAddedToScheduledQueue": "E-mail přidán do naplánované fronty odeslání",
"emailBroadcast": "E-mailové vysílání",
"emailBroadcastTaskCreatedSuccessfully": "Úkol e-mailového vysílání byl úspěšně vytvořen",
"emailBroadcastTasks": "Úkoly e-mailového vysílání",
"emailContent": "Obsah e-mailu",
"emailInterval": "Interval e-mailu (sekundy)",
"emailIntervalMinimum": "Interval e-mailu musí být alespoň 0,1 sekundy",
"emailMarketing": "E-mailový marketing",
"emailTaskManager": "Správce úkolů e-mailu",
"errorMessage": "Zpráva o chybě",
"estimatedRecipients": "Odhadovaný počet příjemců",
"expiredSubscriptionUsersOnly": "Pouze uživatelé s vypršeným předplatným",
"expiredUsers": "Uživatelé s vypršeným předplatným",
"failCount": "Počet neúspěchů",
"failed": "Neúspěšně",
"failedToRefreshTaskStatus": "Nepodařilo se obnovit stav úkolu",
"failedToStopTask": "Nepodařilo se zastavit úkol",
"inProgress": "Probíhá",
"includeUsersRegisteredAfter": "Zahrnout uživatele registrované po tomto datu",
"includeUsersRegisteredBefore": "Zahrnout uživatele registrované před tímto datem",
"intervalTimeBetweenEmails": "Časový interval mezi jednotlivými e-maily",
"leaveEmptyForImmediateSend": "Nechte prázdné pro okamžité odeslání",
"logList": "Seznam záznamů",
"marketingManagement": "Správa marketingu",
"maximumNumberPerDay": "Maximální počet e-mailů k odeslání za den",
"noSubscriptionUsersOnly": "Pouze uživatelé bez předplatného",
"nonSubscribers": "Nepředplatitelé",
"notStarted": "Nebylo zahájeno",
"onePerLine": "jeden na řádek",
"only": "pouze",
"pending": "Čeká se",
"pleaseEnter": "Prosím zadejte",
"pleaseEnterValidEmailAddresses": "Prosím zadejte platné e-mailové adresy, jednu na řádek",
"processing": "Zpracovává se...",
"progress": "Pokrok",
"recipient": "Příjemce",
"recipientEmail": "E-mail příjemce",
"recipientType": "Typ příjemce",
"registrationEndDate": "Datum ukončení registrace",
"registrationStartDate": "Datum zahájení registrace",
"scheduleSend": "Naplánovat odeslání",
"scheduledSend": "Naplánované odeslání",
"scheduledSendTimeMustBeLater": "Čas naplánovaného odeslání musí být pozdější než aktuální čas",
"selectSendScope": "Vyberte rozsah odeslání",
"selectSendTime": "Vyberte čas odeslání, nechte prázdné pro okamžité odeslání",
"sendFailed": "Odeslání selhalo, zkuste to prosím znovu",
"sendNow": "Odeslat nyní",
"sendScope": "Rozsah odeslání",
"sendScopeDescription": "Vyberte uživatelský rozsah pro odesílání e-mailů. Vyberte \"Pouze další e-maily\" pro odeslání pouze na e-mailové adresy vyplněné níže",
"sendSettings": "Nastavení odeslání",
"sendTime": "Čas odeslání",
"sending": "Odesílání",
"sentAt": "Odesláno dne",
"specificUsers": "Specifičtí uživatelé",
"specificUsersOnly": "Pouze další e-maily (přeskočit uživatele platformy)",
"status": "Stav",
"stop": "Zastavit",
"stopped": "Zastaveno",
"subject": "Předmět e-mailu",
"subscribedUsers": "Přihlášení uživatelé",
"subscribedUsersOnly": "Pouze přihlášení uživatelé",
"successCount": "Počet úspěchů",
"taskStatusRefreshed": "Stav úkolu obnoven",
"taskStoppedSuccessfully": "Úkol úspěšně zastaven",
"totalSent": "Celkem odesláno",
"updateSuccess": "Úspěšně aktualizováno",
"useMarkdownEditor": "Použijte editor Markdown pro psaní obsahu e-mailu s funkcí náhledu",
"users": "uživatelé",
"view": "Zobrazit",
"viewAndManageEmailBroadcastTasks": "Zobrazit a spravovat úkoly e-mailového vysílání",
"viewContent": "Zobrazit obsah"
}
+5 -11
View File
@@ -1,32 +1,26 @@
{
"ADS Config": "Konfigurace ADS",
"Announcement Management": "Správa oznámení",
"Apple": "Apple ID",
"Application Management": "Správa aplikací",
"Auth Control": "Řízení ověřování",
"Coupon Management": "Správa kupónů",
"Dashboard": "Přístrojová deska",
"Device": "Zařízení",
"Document Management": "Správa dokumentů",
"Email": "E-mail",
"Facebook": "Facebook",
"Finance": "Finance",
"General": "Obecné",
"GitHub": "GitHub",
"Google": "Google",
"Marketing Management": "Řízení marketingu",
"Order Management": "Správa objednávek",
"Payment Config": "Konfigurace platby",
"Phone Number": "Telefonní číslo",
"Product Management": "Správa produktů",
"Protocol Management": "Správa protokolů",
"Rule Management": "Správa pravidel",
"Server": "Server",
"Server Management": "Správa serveru",
"Settings": "Nastavení",
"Subscribe Management": "Správa předplatného",
"Subscribe Config": "Nastavení odběru",
"System Config": "Systémová konfigurace",
"System Management": "Správa systému",
"System Tool": "Systémový nástroj",
"Telegram": "Telegram",
"Ticket Management": "Správa lístků",
"Twitter": "Twitter",
"User": "Uživatel",
"User Detail": "Podrobnosti o uživateli",
"User Management": "Správa uživatelů"
-32
View File
@@ -1,32 +0,0 @@
{
"accessLabel": "Přístup",
"applyPlatform": "Použít platformu",
"enable": "Povolit",
"enableTip": "Po povolení budou povoleny funkce registrace, přihlášení, připojení a odpojení mobilního telefonu",
"endpointLabel": "Koncový bod",
"logs": "Protokoly",
"phoneNumberLabel": "Telefonní číslo",
"placeholders": {
"template": "Váš ověřovací kód je {code}, platný po dobu 5 minut",
"templateCode": "Zadejte kód šablony"
},
"platform": "SMS platforma",
"platformConfigTip": "Vyplňte prosím poskytnutou konfiguraci {key}",
"platformTip": "Vyberte prosím platformu pro SMS",
"secretLabel": "Tajný klíč",
"sendFailed": "Odeslání se nezdařilo",
"sendSuccess": "Úspěšně odesláno",
"settings": "Nastavení",
"signNameLabel": "Název podpisu",
"template": "Šablona SMS",
"templateCodeLabel": "Kód šablony",
"templateTip": "Prosím vyplňte SMS šablonu, ponechte {code} uprostřed, jinak SMS funkce nebude fungovat",
"testSms": "Odeslat testovací SMS",
"testSmsPhone": "Zadejte telefonní číslo",
"testSmsTip": "Odeslat testovací SMS pro ověření vaší konfigurace",
"updateSuccess": "Aktualizace úspěšná",
"whitelistAreaCode": "Kód oblasti v bílém seznamu",
"whitelistAreaCodeTip": "Zadejte kódy oblastí v bílém seznamu, např. 1, 852, 886, 888",
"whitelistValidation": "Ověření bílého seznamu",
"whitelistValidationTip": "Pokud je povoleno, mohou SMS odesílat pouze kódy oblastí v bílém seznamu"
}
+161
View File
@@ -0,0 +1,161 @@
{
"actions": "akce",
"app": {
"appDownloadURL": "URL pro stažení aplikace",
"appIcon": "Ikona aplikace",
"appList": "Seznam aplikací",
"appName": "Název aplikace",
"backupDomains": "Seznam záložních domén",
"backupDomainsDescription": "Seznam záložních domén pro řešení domén, jedna doména na řádek",
"batchDelete": "Hromadné smazání",
"cancel": "Zrušit",
"communicationKey": "Komunikační klíč",
"communicationKeyDescription": "Klíč používaný pro komunikaci klienta",
"config": "Konfigurace",
"configApp": "Konfigurace aplikace",
"confirm": "Potvrdit",
"confirmDelete": "Potvrdit smazání",
"create": "Vytvořit",
"createApp": "Vytvořit aplikaci",
"createSuccess": "Úspěšně vytvořeno",
"defaultVersion": "Výchozí",
"delete": "Smazat",
"deleteWarning": "Tuto akci nelze vrátit zpět",
"describeDescription": "Používá se k popisu aplikace, zobrazuje se v seznamu aplikací",
"description": "Popis",
"downloadLink": "Odkaz ke stažení",
"edit": "Upravit",
"editApp": "Upravit aplikaci",
"encryption": "Metoda šifrování",
"encryptionDescription": "Vyberte metodu šifrování pro komunikaci klienta. Pokud je vybrána, klient použije tuto metodu ke komunikaci se serverem",
"nameDescription": "Název aplikace, zobrazuje se v seznamu aplikací",
"platform": "Platforma",
"selectApp": "Vybrat aplikaci",
"selectAppDescription": "Vyberte aplikaci k nastavení, všechna nastavení se použijí na vybranou aplikaci",
"startupPicture": "Úvodní obrázek",
"startupPictureDescription": "Úvodní obrázek, podporuje síťové a lokální obrázky. Pro síťové obrázky zadejte úplnou URL adresu obrázku",
"startupPicturePreview": "Náhled úvodního obrázku",
"startupPictureSkip": "Čas přeskočení úvodního obrázku",
"startupPictureSkipDescription": "Doba zobrazení úvodního obrázku v sekundách, zadejte 0 pro nezobrazení",
"subscriptionProtocol": "Protokol předplatného",
"updateSuccess": "Úspěšně aktualizováno",
"version": "Verze"
},
"cancel": "Zrušit",
"config": {
"singleSubscriptionMode": "Režim jednorázového předplatného",
"singleSubscriptionModeDescription": "Když je povoleno, všechna uživatelská předplatná budou převedena na zůstatek",
"subscriptionDomain": "Doména předplatného",
"subscriptionDomainDescription": "Používá se pro předplatné; nechte prázdné pro použití domény webu",
"subscriptionDomainPlaceholder": "Zadejte doménu předplatného, jednu na řádek",
"subscriptionPath": "Cesta předplatného",
"subscriptionPathDescription": "Používá se pro předplatné; po úpravě nezapomeňte restartovat systém pro optimální výkon",
"subscriptionPathPlaceholder": "Zadejte",
"updateSuccess": "Úspěšně aktualizováno",
"wildcardResolution": "Řešení zástupných znaků",
"wildcardResolutionDescription": "Používá se pro předplatné"
},
"confirm": "Potvrdit",
"confirmDelete": "Opravdu chcete smazat?",
"copy": "Kopírovat",
"copySuccess": "Úspěšně zkopírováno",
"create": "Vytvořit",
"createSubscribe": "Vytvořit odběr",
"createSuccess": "Vytvoření úspěšné",
"delete": "smazat",
"deleteSuccess": "Úspěšně odstraněno",
"deleteWarning": "Po odstranění nelze data obnovit, prosím, postupujte opatrně.",
"deviceLimit": "Počet zařízení/ks",
"edit": "Upravit",
"editSubscribe": "Upravit odběr",
"form": {
"Day": "Den",
"Hour": "Hodina",
"Minute": "Minuta",
"Month": "Měsíc",
"NoLimit": "Bez omezení",
"Year": "Rok",
"annualReset": "Roční Reset",
"basic": "Základní",
"cancel": "Zrušit",
"confirm": "Potvrdit",
"deductionRatio": "Automatická/Manuální Konfigurace Odpočtu",
"deductionRatioDescription": "Používá se pro odpočet. Systém standardně používá automatický výpočetní algoritmus. Když je poskytnut manuální poměr, systém vypočítá proporce na základě časového a dopravního poměru, přičemž celkový součet činí 100%.",
"description": "Popis",
"deviceLimit": "Omezení zařízení",
"discount": "Sleva",
"discountDescription": "Nastavit slevu na základě jednotkové ceny",
"discountMonths": "Měsíce",
"discountPercent": "Procento slevy",
"discount_price": "Cena po slevě",
"duration": "Doba trvání (měsíce)",
"groupId": "Skupina předplatného",
"inventory": "Limit předplatného",
"monthlyReset": "Měsíční Reset",
"name": "Název",
"noLimit": "Bez omezení",
"noReset": "Žádný Reset",
"pricing": "Cenotvorba",
"purchaseWithDiscount": "Povolit Odpočet",
"purchaseWithDiscountDescription": "Povolit nebo zakázat funkci odhlášení. Po aktivaci systém provede zpracování odpočtu podle nakonfigurovaných pravidel a poměrů a zbývající hodnota bude vrácena na zůstatek.",
"quota": "Limit nákupu",
"renewalReset": "Reset při Obnovení",
"renewalResetDescription": "Resetovací cyklus při obnovení",
"replacement": "Cena za reset (za každý)",
"resetCycle": "Resetovací Cyklus",
"resetOn1st": "Reset 1. dne",
"selectResetCycle": "Vyberte prosím resetovací cyklus",
"selectSubscribeGroup": "Vyberte prosím skupinu předplatného",
"selectUnitTime": "Vyberte jednotku času",
"server": "Server",
"serverGroup": "Skupina serverů",
"servers": "Servery",
"speedLimit": "Omezení rychlosti ",
"traffic": "Přenos dat",
"unitPrice": "Jednotková cena",
"unitTime": "Jednotka času"
},
"group": {
"actions": "Akce",
"cancel": "Zrušit",
"confirm": "Potvrdit",
"confirmDelete": "Opravdu chcete smazat?",
"create": "Vytvořit",
"createSubscribeGroup": "Vytvořit novou skupinu odběrů",
"createSuccess": "Úspěšně vytvořeno",
"delete": "Smazat",
"deleteSuccess": "Úspěšně smazáno",
"deleteWarning": "Po smazání nelze data obnovit, prosím postupujte opatrně.",
"description": "Popis",
"edit": "Upravit",
"editSubscribeGroup": "Upravit skupinu odběrů",
"form": {
"cancel": "Zrušit",
"confirm": "Potvrdit",
"description": "Popis",
"name": "Název"
},
"name": "Název",
"title": "Seznam skupin odběrů",
"updateSuccess": "Úspěšně aktualizováno",
"updatedAt": "Čas aktualizace"
},
"inventory": "Limit předplatného",
"name": "název",
"quota": "Limit na nákup/čas",
"replacement": "Obnovení ceny/krát",
"sell": "Prodej",
"show": "Zobrazit",
"sold": "Počet předplatných",
"subscribe": "Předplatit",
"subscribeGroup": "Přihlásit se ke skupině",
"tabs": {
"subscribe": "Předplatit",
"subscribeApp": "Konfigurace aplikace",
"subscribeConfig": "Konfigurace předplatného",
"subscribeGroup": "Skupina předplatných"
},
"traffic": "provoz",
"unitPrice": "Jednotková cena",
"updateSuccess": "Aktualizace byla úspěšná"
}
+6
View File
@@ -8,9 +8,12 @@
"createRule": "Přidat pravidlo",
"createSuccess": "Pravidlo bylo úspěšně vytvořeno",
"createdAt": "Vytvořeno dne",
"default": "Výchozí",
"defaultRule": "Výchozí pravidlo",
"delete": "Smazat",
"deleteSuccess": "Pravidlo bylo úspěšně smazáno",
"deleteWarning": "Opravdu chcete smazat toto pravidlo? Tato akce není vratná.",
"direct": "Přímý",
"downloadTemplate": "Stáhnout šablonu",
"edit": "Upravit",
"editRule": "Upravit pravidlo",
@@ -29,11 +32,14 @@
"noValidRules": "Nebyly nalezeny žádné platné pravidla",
"pleaseUploadFile": "Prosím, nahrajte soubor YAML",
"preview": "Náhled",
"reject": "Odmítnout",
"rulesFormat": "Formát pravidla: typ pravidla, obsah shody, [politika], kde politika je volitelná.\nPokud politika není specifikována, bude automaticky použito aktuální jméno skupiny pravidel. Příklady:",
"rulesLabel": "Obsah pravidla",
"searchRule": "Hledat název pravidla",
"selectTags": "Vybrat značky uzlu",
"selectType": "Vyberte typ pravidla",
"tags": "Značky uzlu",
"tagsLabel": "Značky uzlu",
"type": "Typ pravidla",
"updateSuccess": "Pravidlo bylo úspěšně aktualizováno"
}
+100 -75
View File
@@ -33,21 +33,22 @@
"description": "Popis",
"edit": "Upravit",
"editNodeGroup": "Upravit skupinu uzlů",
"form": {
"cancel": "Zrušit",
"confirm": "Potvrdit",
"description": "Popis",
"name": "Název"
},
"name": "Název",
"title": "Seznam skupin uzlů",
"updatedAt": "Aktualizováno"
},
"groupForm": {
"cancel": "Zrušit",
"confirm": "Potvrdit",
"description": "Popis",
"name": "Název"
},
"node": {
"abnormal": "Abnormální",
"actions": "Akce",
"address": "Adresa",
"all": "Vše",
"basicInfo": "Základní informace",
"cancel": "Zrušit",
"confirm": "Potvrdit",
"confirmDelete": "Opravdu chcete smazat?",
@@ -59,96 +60,120 @@
"delete": "Smazat",
"deleteSuccess": "Úspěšně smazáno",
"deleteWarning": "Po smazání nebude možné data obnovit. Prosím, buďte opatrní.",
"detail": "Detail",
"disabled": "Zakázáno",
"disk": "Disk",
"edit": "Upravit",
"editNode": "Upravit uzel",
"enable": "Povolit",
"form": {
"allowInsecure": "Povolit nezabezpečené",
"cancel": "Zrušit",
"city": "Město",
"confirm": "Potvrdit",
"country": "Země",
"edit": "Upravit",
"editSecurity": "Upravit nastavení zabezpečení",
"enableTLS": "Povolit TLS",
"encryptionMethod": "Metoda šifrování",
"flow": "Algoritmus řízení toku",
"groupId": "Skupina uzlů",
"hopInterval": "Interval skoků",
"hopPorts": "Porty skoků",
"hopPortsPlaceholder": "Více portů oddělených čárkou",
"name": "Název",
"obfsPassword": "Heslo pro zmatení",
"obfsPasswordPlaceholder": "Nechte prázdné, pokud nechcete zmást",
"path": "Cesta",
"pleaseSelect": "Prosím vyberte",
"port": "Port služby",
"protocol": "Protokol",
"relayHost": "Adresa přenosu",
"relayMode": "Režim relé",
"relayModeOptions": {
"all": "Vše",
"none": "Žádný",
"random": "Náhodný"
},
"relayPort": "Port přenosu",
"relayPrefix": "Předpona relé",
"remarks": "Poznámky",
"security": "Zabezpečení",
"securityConfig": "Nastavení zabezpečení",
"security_config": {
"fingerprint": "FingerPrint",
"privateKey": "Soukromý klíč",
"privateKeyPlaceholder": "Nechte prázdné pro automatické generování",
"publicKey": "Veřejný klíč",
"publicKeyPlaceholder": "Nechte prázdné pro automatické generování",
"serverAddress": "Adresa serveru",
"serverAddressPlaceholder": "Cílová adresa REALITY, výchozí je SNI",
"serverName": "Název serveru (SNI)",
"serverNamePlaceholder": "REALITY je povinné, musí být shodné s backendem",
"serverPort": "Port serveru",
"serverPortPlaceholder": "Cílový port REALITY, výchozí 443",
"shortId": "ShortId",
"shortIdPlaceholder": "Nechte prázdné pro automatické generování",
"sni": "Indikace názvu serveru (SNI)"
},
"selectEncryptionMethod": "Vyberte metodu šifrování",
"selectNodeGroup": "Vyberte skupinu uzlů",
"selectProtocol": "Vyberte protokol",
"selectRelayMode": "Vyberte režim relé",
"serverAddr": "Adresa serveru",
"serverKey": "Klíč serveru",
"serverName": "Název služby",
"speedLimit": "Omezení rychlosti",
"speedLimitPlaceholder": "Bez omezení",
"tags": "Štítky",
"tagsPlaceholder": "Použijte Enter nebo čárku (,) pro zadání více štítků",
"trafficRatio": "Poměr provozu",
"transport": "Přenosový protokol",
"transportConfig": "Nastavení přenosového protokolu",
"transportHost": "Adresa přenosové služby",
"transportPath": "Cesta přenosu",
"transportServerName": "Název přenosové služby"
},
"enabled": "Povoleno",
"expireTime": "Čas vypršení",
"hide": "Skrýt",
"id": "ID",
"ipAddresses": "IP adresy",
"lastUpdated": "Poslední aktualizace",
"location": "Umístění",
"memory": "Paměť",
"name": "Název",
"noData": "--",
"node": "Uzel",
"nodeDetail": "Detail uzlu",
"nodeGroup": "Skupina uzlů",
"nodeStatus": "Stav uzlu",
"normal": "Normální",
"onlineCount": "Počet online",
"onlineUsers": "Online uživatelé",
"protocol": "Protokol",
"rate": "Rychlost",
"relay": "Přenos",
"serverAddr": "Adresa serveru",
"speedLimit": "Omezení rychlosti",
"status": "Stav",
"subscribeId": "ID předplatného",
"subscribeName": "Název předplatného",
"subscription": "Předplatné",
"tags": "Štítky",
"trafficRatio": "Poměr provozu",
"trafficUsage": "Využití provozu",
"type": "Typ",
"updateSuccess": "Úspěšně aktualizováno",
"updatedAt": "Čas aktualizace"
"updatedAt": "Čas aktualizace",
"user": "Uživatel",
"userDetail": "Detail uživatele",
"userId": "ID uživatele"
},
"nodeForm": {
"allowInsecure": "Povolit nezabezpečené",
"cancel": "Zrušit",
"city": "Město",
"confirm": "Potvrdit",
"congestionController": "Ovladač přetížení",
"country": "Země",
"disableSni": "Zakázat SNI",
"edit": "Upravit",
"editSecurity": "Upravit bezpečnostní konfiguraci",
"enableTLS": "Povolit TLS",
"encryptionMethod": "Metoda šifrování",
"fingerprint": "Otisk",
"flow": "Algoritmus řízení toku",
"groupId": "Skupina uzlů",
"hopInterval": "Interval skoků",
"hopPorts": "Porty skoků",
"hopPortsPlaceholder": "Oddělte více portů čárkami",
"name": "Název",
"obfsPassword": "Heslo pro obfuskování",
"obfsPasswordPlaceholder": "Nechte prázdné pro žádné obfuskování",
"path": "Cesta",
"pleaseSelect": "Prosím vyberte",
"port": "Port serveru",
"protocol": "Protokol",
"reduceRtt": "Snížit RTT",
"relayHost": "Hostitel relé",
"relayMode": "Režim relé",
"relayPort": "Port relé",
"relayPrefix": "Předpona relé",
"remarks": "Poznámky",
"security": "Bezpečnost",
"securityConfig": "Bezpečnostní konfigurace",
"selectEncryptionMethod": "Vyberte metodu šifrování",
"selectNodeGroup": "Vyberte skupinu uzlů",
"selectProtocol": "Vyberte protokol",
"selectRelayMode": "Vyberte režim relé",
"serverAddr": "Adresa serveru",
"serverKey": "Klíč serveru",
"serverName": "Název služby",
"speedLimit": "Rychlostní limit",
"speedLimitPlaceholder": "Neomezeno",
"tags": "Štítky",
"tagsPlaceholder": "Použijte Enter nebo čárku (,) pro zadání více štítků",
"trafficRatio": "Poměr provozu",
"transport": "Konfigurace transportního protokolu",
"transportConfig": "Konfigurace transportního protokolu",
"transportHost": "Adresa transportního serveru",
"transportPath": "Transportní cesta",
"transportServerName": "Název transportního serveru",
"udpRelayMode": "Režim UDP relé"
},
"relayModeOptions": {
"all": "Vše",
"none": "Žádné",
"random": "Náhodně"
},
"securityConfig": {
"fingerprint": "Otisk",
"privateKey": "Soukromý klíč",
"privateKeyPlaceholder": "Nechte prázdné pro automatické vygenerování",
"publicKey": "Veřejný klíč",
"publicKeyPlaceholder": "Nechte prázdné pro automatické vygenerování",
"serverAddress": "Adresa serveru",
"serverAddressPlaceholder": "Cílová adresa REALITY, výchozí použití SNI",
"serverName": "Název serveru (SNI)",
"serverNamePlaceholder": "REALITY vyžaduje, konzistentní s backendem",
"serverPort": "Port serveru",
"serverPortPlaceholder": "Cílový port REALITY, výchozí 443",
"shortId": "Krátké ID",
"shortIdPlaceholder": "Nechte prázdné pro automatické vygenerování",
"sni": "Indikace názvu serveru (SNI)"
},
"tabs": {
"node": "Uzlu",
+112 -141
View File
@@ -1,161 +1,132 @@
{
"actions": "akce",
"app": {
"appDownloadURL": "URL pro stažení aplikace",
"appIcon": "Ikona aplikace",
"appList": "Seznam aplikací",
"appName": "Název aplikace",
"backupDomains": "Seznam záložních domén",
"backupDomainsDescription": "Seznam záložních domén pro řešení domén, jedna doména na řádek",
"actions": {
"add": "Přidat",
"batchDelete": "Hromadné smazání",
"batchDeleteSuccess": "Úspěšně smazáno {count} klientů",
"batchDeleteWarning": "Opravdu chcete smazat vybrané {count} klienty?",
"cancel": "Zrušit",
"communicationKey": "Komunikační klíč",
"communicationKeyDescription": "Klíč používaný pro komunikaci klienta",
"config": "Konfigurace",
"configApp": "Konfigurace aplikace",
"confirm": "Potvrdit",
"confirmDelete": "Potvrdit smazání",
"create": "Vytvořit",
"createApp": "Vytvořit aplikaci",
"createSuccess": "Úspěšně vytvořeno",
"defaultVersion": "Výchozí",
"delete": "Smazat",
"deleteWarning": "Tuto akci nelze vrátit zpět",
"describeDescription": "Používá se k popisu aplikace, zobrazuje se v seznamu aplikací",
"description": "Popis",
"downloadLink": "Odkaz ke stažení",
"deleteFailed": "Smazání se nezdařilo",
"deleteSuccess": "Úspěšně smazáno",
"deleteWarning": "Tuto operaci nelze vrátit zpět. Opravdu chcete smazat tohoto klienta?",
"edit": "Upravit",
"editApp": "Upravit aplikaci",
"encryption": "Metoda šifrování",
"encryptionDescription": "Vyberte metodu šifrování pro komunikaci klienta. Pokud je vybrána, klient použije tuto metodu ke komunikaci se serverem",
"nameDescription": "Název aplikace, zobrazuje se v seznamu aplikací",
"platform": "Platforma",
"selectApp": "Vybrat aplikaci",
"selectAppDescription": "Vyberte aplikaci k nastavení, všechna nastavení se použijí na vybranou aplikaci",
"startupPicture": "Úvodní obrázek",
"startupPictureDescription": "Úvodní obrázek, podporuje síťové a lokální obrázky. Pro síťové obrázky zadejte úplnou URL adresu obrázku",
"startupPicturePreview": "Náhled úvodního obrázku",
"startupPictureSkip": "Čas přeskočení úvodního obrázku",
"startupPictureSkipDescription": "Doba zobrazení úvodního obrázku v sekundách, zadejte 0 pro nezobrazení",
"subscriptionProtocol": "Protokol předplatného",
"updateSuccess": "Úspěšně aktualizováno",
"version": "Verze"
"save": "Uložit",
"saveFailed": "Uložení se nezdařilo",
"update": "Aktualizovat",
"updateSuccess": "Úspěšně aktualizováno"
},
"cancel": "Zrušit",
"config": {
"singleSubscriptionMode": "Režim jednorázového předplatného",
"singleSubscriptionModeDescription": "Když je povoleno, všechna uživatelská předplatná budou převedena na zůstatek",
"description": "Spravujte nastavení systému předplatného",
"singleSubscriptionMode": "Režim jednoho předplatného",
"singleSubscriptionModeDescription": "Omezit uživatele na jedno aktivní předplatné. Existující předplatná nejsou ovlivněna",
"subscriptionDomain": "Doména předplatného",
"subscriptionDomainDescription": "Používá se pro předplatné; nechte prázdné pro použití domény webu",
"subscriptionDomainDescription": "Vlastní doména pro odkazy na předplatné",
"subscriptionDomainPlaceholder": "Zadejte doménu předplatného, jednu na řádek",
"subscriptionPath": "Cesta předplatného",
"subscriptionPathDescription": "Používá se pro předplatné; po úpravě nezapomeňte restartovat systém pro optimální výkon",
"subscriptionPathPlaceholder": "Zadejte",
"updateSuccess": "Úspěšně aktualizováno",
"subscriptionPathDescription": "Vlastní cesta pro koncové body předplatného (lepší výkon po restartu systému)",
"subscriptionPathPlaceholder": "Zadejte cestu předplatného",
"title": "Konfigurace předplatného",
"updateError": "Aktualizace se nezdařila",
"updateSuccess": "Nastavení byla úspěšně aktualizována",
"userAgentLimit": "{userAgent} Omezení",
"userAgentLimitDescription": "Povolit omezení přístupu na základě {userAgent}",
"userAgentList": "{userAgent} Bílá listina",
"userAgentListDescription": "Povolené {userAgent} pro přístup k předplatnému, jedna na řádek. Konfigurovaná aplikace {userAgent} bude automaticky zahrnuta",
"userAgentListPlaceholder": "Zadejte povolené {userAgent}, jedna na řádek",
"wildcardResolution": "Řešení zástupných znaků",
"wildcardResolutionDescription": "Používá se pro předplatné"
"wildcardResolutionDescription": "Povolit řešení domény se zástupnými znaky pro předplatná"
},
"confirm": "Potvrdit",
"confirmDelete": "Opravdu chcete smazat?",
"copy": "Kopírovat",
"copySuccess": "Úspěšně zkopírováno",
"create": "Vytvořit",
"createSubscribe": "Vytvořit odběr",
"createSuccess": "Vytvoření úspěšné",
"delete": "smazat",
"deleteSuccess": "Úspěšně odstraněno",
"deleteWarning": "Po odstranění nelze data obnovit, prosím, postupujte opatrně.",
"deviceLimit": "Počet zařízení/ks",
"edit": "Upravit",
"editSubscribe": "Upravit odběr",
"form": {
"Day": "Den",
"Hour": "Hodina",
"Minute": "Minuta",
"Month": "Měsíc",
"NoLimit": "Bez omezení",
"Year": "Rok",
"annualReset": "Roční Reset",
"basic": "Základní",
"cancel": "Zrušit",
"confirm": "Potvrdit",
"deductionRatio": "Automatická/Manuální Konfigurace Odpočtu",
"deductionRatioDescription": "Používá se pro odpočet. Systém standardně používá automatický výpočetní algoritmus. Když je poskytnut manuální poměr, systém vypočítá proporce na základě časového a dopravního poměru, přičemž celkový součet činí 100%.",
"description": "Popis",
"deviceLimit": "Omezení zařízení",
"discount": "Sleva",
"discountDescription": "Nastavit slevu na základě jednotkové ceny",
"discountMonths": "Měsíce",
"discountPercent": "Procento slevy",
"discount_price": "Cena po slevě",
"duration": "Doba trvání (měsíce)",
"groupId": "Skupina předplatného",
"inventory": "Limit předplatného",
"monthlyReset": "Měsíční Reset",
"name": "Název",
"noLimit": "Bez omezení",
"noReset": "Žádný Reset",
"pricing": "Cenotvorba",
"purchaseWithDiscount": "Povolit Odpočet",
"purchaseWithDiscountDescription": "Povolit nebo zakázat funkci odhlášení. Po aktivaci systém provede zpracování odpočtu podle nakonfigurovaných pravidel a poměrů a zbývající hodnota bude vrácena na zůstatek.",
"quota": "Limit nákupu",
"renewalReset": "Reset při Obnovení",
"renewalResetDescription": "Resetovací cyklus při obnovení",
"replacement": "Cena za reset (za každý)",
"resetCycle": "Resetovací Cyklus",
"resetOn1st": "Reset 1. dne",
"selectResetCycle": "Vyberte prosím resetovací cyklus",
"selectSubscribeGroup": "Vyberte prosím skupinu předplatného",
"selectUnitTime": "Vyberte jednotku času",
"server": "Server",
"serverGroup": "Skupina serverů",
"servers": "Servery",
"speedLimit": "Omezení rychlosti ",
"traffic": "Přenos dat",
"unitPrice": "Jednotková cena",
"unitTime": "Jednotka času"
},
"group": {
"actions": "Akce",
"cancel": "Zrušit",
"confirm": "Potvrdit",
"confirmDelete": "Opravdu chcete smazat?",
"create": "Vytvořit",
"createSubscribeGroup": "Vytvořit novou skupinu odběrů",
"createSuccess": "Úspěšně vytvořeno",
"delete": "Smazat",
"deleteSuccess": "Úspěšně smazáno",
"deleteWarning": "Po smazání nelze data obnovit, prosím postupujte opatrně.",
"description": "Popis",
"edit": "Upravit",
"editSubscribeGroup": "Upravit skupinu odběrů",
"form": {
"cancel": "Zrušit",
"confirm": "Potvrdit",
"description": "Popis",
"name": "Název"
"addTitle": "Přidat klienta",
"descriptions": {
"description": "Podrobný popis klienta",
"downloadLink": "odkaz na stažení platformy",
"icon": "URL ikony nebo base64 kódování",
"name": "Název zobrazení klienta",
"outputFormat": "Formát konfiguračního souboru předplatného",
"scheme": {
"base64Encoding": "Kódování Base64",
"functions": "Podporuje funkce:",
"jsonStringify": "JSON objekt na řetězec",
"nameVariable": "název webu",
"title": "Šablona URL schématu",
"urlEncoding": "Kódování URL",
"urlVariable": "URL pro odběr",
"variables": "Podporuje proměnné:"
},
"template": {
"description": "Použijte syntaxi Go šablony k generování konfiguračních souborů předplatného",
"functions": "Funkce šablony:",
"if": "podmínkové příkazy",
"nodes": "seznam proxy uzlů",
"range": "iterovat pole",
"siteName": "název webu",
"sprig": "Knihovna funkcí Sprig (zpracování řetězců, data atd.)",
"subscribeName": "název předplatného",
"title": "Syntaxe Go šablony",
"userInfo": "uživatelské informace (provoz, expirace atd.)",
"variables": "Dostupné proměnné:"
},
"userAgentPrefix": "Identifikátor klienta pro rozlišení různých klientů"
},
"name": "Název",
"title": "Seznam skupin odběrů",
"updateSuccess": "Úspěšně aktualizováno",
"updatedAt": "Čas aktualizace"
"editTitle": "Upravit klienta",
"fields": {
"description": "Popis",
"icon": "Ikona",
"name": "Název",
"outputFormat": "Formát výstupu",
"scheme": "URL schéma",
"template": "Šablona souboru předplatného"
},
"tabs": {
"basic": "Základní informace",
"download": "Stahování",
"template": "Šablony"
},
"validation": {
"nameRequired": "Název klienta je povinný",
"userAgentRequiredSuffix": "je povinný"
}
},
"inventory": "Limit předplatného",
"name": "název",
"quota": "Limit na nákup/čas",
"replacement": "Obnovení ceny/krát",
"sell": "Prodej",
"show": "Zobrazit",
"sold": "Počet předplatných",
"subscribe": "Předplatit",
"subscribeGroup": "Přihlásit se ke skupině",
"tabs": {
"subscribe": "Předplatit",
"subscribeApp": "Konfigurace aplikace",
"subscribeConfig": "Konfigurace předplatného",
"subscribeGroup": "Skupina předplatných"
"outputFormats": {
"base64": "Base64",
"conf": "CONF",
"json": "JSON",
"plain": "Čistý text",
"yaml": "YAML"
},
"traffic": "provoz",
"unitPrice": "Jednotková cena",
"updateSuccess": "Aktualizace byla úspěšná"
"platforms": {
"android": "Android",
"harmony": "HarmonyOS",
"ios": "iOS",
"linux": "Linux",
"mac": "macOS",
"windows": "Windows"
},
"protocol": {
"title": "Správa klientů"
},
"table": {
"columns": {
"default": "Výchozí",
"description": "Popis",
"name": "Název klienta",
"outputFormat": "Formát výstupu",
"supportedPlatforms": "Podporované platformy"
}
},
"templatePreview": {
"base64": {
"decodeError": "Dekódování selhalo: Obsah není platný formát Base64",
"decodedContent": "Dekódovaný obsah",
"originalContent": "Původní obsah (Base64)"
},
"failed": "Náhled selhal",
"loading": "Načítání...",
"preview": "Náhled",
"title": "Náhled šablony"
}
}
+96 -12
View File
@@ -1,28 +1,80 @@
{
"authSettings": "Nastavení ověřování",
"basicSettings": "Základní nastavení",
"common": {
"cancel": "Zrušit",
"save": "Uložit nastavení",
"saveFailed": "Uložení selhalo",
"saveSuccess": "Uložení úspěšné"
},
"currency": {
"accessKey": "Klíč",
"accessKeyDescription": "https://exchangerate.host poskytuje zdarma klíč k API směnných kurzů",
"accessKeyDescription": "{url} poskytuje zdarma klíč k API směnných kurzů",
"accessKeyPlaceholder": "Zadejte API klíč",
"currencySymbol": "Symbol měny",
"currencySymbolDescription": "Pouze pro zobrazení, po změně se změní všechny měnové jednotky v systému",
"currencySymbolPlaceholder": "Kč",
"currencyUnit": "Měnová jednotka",
"currencyUnitDescription": "Pouze pro zobrazení, po změně se změní všechny měnové jednotky v systému",
"saveSuccess": "Úspěšně uloženo"
"currencyUnitPlaceholder": "CZK",
"description": "Nastavte jednotky měny, symboly a nastavení API pro směnné kurzy",
"title": "Nastavení měny"
},
"privacy-policy": {
"saveSuccess": "Uložení bylo úspěšné",
"invite": {
"description": "Nastavte pozvání uživatelů a odměny za doporučení",
"forcedInvite": "Vyžadovat pozvánku k registraci",
"forcedInviteDescription": "Pokud je povoleno, uživatelé se musí registrovat prostřednictvím pozvánkového odkazu",
"inputPlaceholder": "Prosím zadejte",
"onlyFirstPurchase": "Odměna pouze za první nákup",
"onlyFirstPurchaseDescription": "Pokud je povoleno, doporučující obdrží odměny pouze za první nákup doporučených uživatelů",
"referralPercentage": "Procento odměny za doporučení",
"referralPercentageDescription": "Procento odměny udělené doporučujícím",
"saveFailed": "Uložení selhalo",
"saveSuccess": "Uložení úspěšné",
"title": "Nastavení pozvánek"
},
"privacyPolicy": {
"description": "Upravte a spravujte obsah zásad ochrany osobních údajů",
"title": "Zásady ochrany osobních údajů"
},
"register": {
"day": "den(dny)",
"description": "Nastavte související nastavení registrace uživatelů",
"enableTrial": "Povolit zkušební verzi",
"enableTrialDescription": "Pokud je povoleno, noví uživatelé obdrží zkušební předplatné při registraci",
"hour": "Hodina(y)",
"inputPlaceholder": "Prosím zadejte",
"ipRegistrationLimit": "Limit registrace podle IP",
"ipRegistrationLimitDescription": "Omezení počtu registrací z jedné IP adresy",
"minute": "Minuta(y)",
"month": "Měsíc(e)",
"none": "Žádný",
"registrationLimitCount": "Počet registrací v limitu",
"registrationLimitCountDescription": "Počet registrací povolených na IP během limitního období",
"registrationLimitExpire": "Limitní období",
"registrationLimitExpireDescription": "Doba pro limit registrace podle IP",
"saveFailed": "Uložení selhalo",
"saveSuccess": "Uložení úspěšné",
"selectPlaceholder": "Prosím vyberte",
"stopNewUserRegistration": "Zastavit registraci nových uživatelů",
"stopNewUserRegistrationDescription": "Pokud je povoleno, registrace nových uživatelů bude zakázána",
"title": "Nastavení registrace",
"trialConfig": "Nastavení zkušební verze",
"trialConfigDescription": "Nastavte zkušební předplatné, dobu trvání a časovou jednotku pro nové uživatele při registraci",
"year": "Rok(y)"
},
"site": {
"customData": "Vlastní data",
"customDataDescription": "Vlastní data, používaná pro vlastní data webu",
"customHtml": "Vlastní HTML",
"customHtmlDescription": "Vlastní HTML kód, který bude vložen na konec tagu body na stránce.",
"description": "Nastavte základní informace o webu, logo, doménu a další nastavení",
"keywords": "Klíčová slova",
"keywordsDescription": "Používá se pro SEO účely",
"keywordsPlaceholder": "klíčové slovo1, klíčové slovo2, klíčové slovo3",
"logo": "LOGO",
"logoDescription": "Používá se k zobrazení místa, kde je potřeba zobrazit LOGO",
"logoPlaceholder": "Zadejte URL adresu LOGA, nekončete '/'",
"saveSuccess": "Uložení bylo úspěšné",
"siteDesc": "Popis webu",
"siteDescDescription": "Používá se k zobrazení místa, kde je potřeba zobrazit popis webu",
"siteDescPlaceholder": "Zadejte popis webu",
@@ -31,15 +83,47 @@
"siteDomainPlaceholder": "Zadejte adresu domény, pro více domén použijte jeden řádek pro každou",
"siteName": "Název webu",
"siteNameDescription": "Používá se k zobrazení místa, kde je potřeba zobrazit název webu",
"siteNamePlaceholder": "Zadejte název webu"
},
"tabs": {
"currency": "Měna",
"site": "Stránka",
"tos": "Podmínky služby"
"siteNamePlaceholder": "Zadejte název webu",
"title": "Nastavení webu"
},
"siteSettings": "Nastavení webu",
"tos": {
"saveSuccess": "Uložení bylo úspěšné",
"description": "Upravte a spravujte obsah podmínek služby",
"title": "Podmínky služby"
},
"userSecuritySettings": "Uživatel a zabezpečení",
"verify": {
"description": "Nastavte Turnstile CAPTCHA a ověřovací nastavení",
"enableLoginVerify": "Povolit ověření při přihlášení",
"enableLoginVerifyDescription": "Pokud je povoleno, uživatelé musí projít ověřením člověka při přihlášení",
"enablePasswordVerify": "Povolit ověření při resetování hesla",
"enablePasswordVerifyDescription": "Pokud je povoleno, uživatelé musí projít ověřením člověka při resetování hesla",
"enableRegisterVerify": "Povolit ověření při registraci",
"enableRegisterVerifyDescription": "Pokud je povoleno, uživatelé musí projít ověřením člověka při registraci",
"inputPlaceholder": "Prosím zadejte",
"saveFailed": "Uložení selhalo",
"saveSuccess": "Uložení úspěšné",
"title": "Ověření zabezpečení",
"turnstileSecret": "Tajný klíč Turnstile",
"turnstileSecretDescription": "Tajný klíč Cloudflare Turnstile pro ověření na backendu",
"turnstileSecretPlaceholder": "Zadejte tajný klíč Turnstile",
"turnstileSiteKey": "Klíč webu Turnstile",
"turnstileSiteKeyDescription": "Klíč webu Cloudflare Turnstile pro ověření na frontendu",
"turnstileSiteKeyPlaceholder": "Zadejte klíč webu Turnstile"
},
"verifyCode": {
"dailyLimit": "Denní limit odesílání",
"dailyLimitDescription": "Maximální počet ověřovacích kódů, které může každý uživatel odeslat za den",
"description": "Nastavte pravidla a limity pro odesílání ověřovacích kódů e-mailem",
"expireTime": "Platnost ověřovacího kódu",
"expireTimeDescription": "Doba platnosti ověřovacích kódů (vteřiny)",
"inputPlaceholder": "Prosím zadejte",
"interval": "Interval odesílání",
"intervalDescription": "Minimální interval mezi dvěma odesláními ověřovacího kódu (vteřiny)",
"saveFailed": "Uložení selhalo",
"saveSuccess": "Uložení úspěšné",
"seconds": "vteřiny",
"times": "krát",
"title": "Nastavení ověřovacího kódu"
}
}
-10
View File
@@ -1,10 +0,0 @@
{
"clientId": "ID robota",
"clientIdDescription": "ID Telegram bota, které můžete získat od @BotFather",
"clientSecret": "Token robota",
"clientSecretDescription": "Telegram Bot Token, který můžete získat od @BotFather",
"enable": "Povolit",
"enableDescription": "Po povolení budou povoleny funkce registrace, přihlášení, připojení a odpojení mobilního telefonu",
"saveFailed": "Uložení se nezdařilo",
"saveSuccess": "Uložení bylo úspěšné"
}
-16
View File
@@ -1,16 +0,0 @@
{
"clientId": "Dienst-ID",
"clientIdDescription": "Apple-Dienst-ID, die Sie im Apple Developer Portal erhalten können",
"clientSecret": "Privater Schlüssel",
"clientSecretDescription": "Der private Schlüsselinhalt (.p8-Datei), der für die Authentifizierung bei Apple verwendet wird",
"enable": "Aktivieren",
"enableDescription": "Nach der Aktivierung können sich Benutzer mit ihrer Apple-ID anmelden",
"keyId": "Schlüssel-ID",
"keyIdDescription": "Die ID Ihres privaten Schlüssels aus dem Apple Developer Portal",
"redirectUri": "Weiterleitungs-URL",
"redirectUriDescription": "Bitte geben Sie die API-Adresse der umgeleiteten URL ein, nachdem die Apple-Authentifizierung erfolgreich abgeschlossen wurde. Verwenden Sie kein / am Ende.",
"saveFailed": "Speichern fehlgeschlagen",
"saveSuccess": "Erfolgreich gespeichert",
"teamId": "Team-ID",
"teamIdDescription": "Apple Developer-Team-ID"
}
+187 -55
View File
@@ -1,15 +1,150 @@
{
"invite": {
"commissionFirstTimeOnly": "Provision nur beim ersten Kauf",
"commissionFirstTimeOnlyDescription": "Wenn aktiviert, wird die Provision nur bei der ersten Zahlung des Einladers generiert; individuelle Benutzer können in der Benutzerverwaltung konfiguriert werden",
"enableForcedInvite": "Erzwinge Einladung",
"enableForcedInviteDescription": "Wenn aktiviert, können sich nur eingeladene Benutzer registrieren",
"inputPlaceholder": "Eingeben",
"inviteCommissionPercentage": "Einladungsprovisionssatz",
"inviteCommissionPercentageDescription": "Standardmäßiges globales Provisionsverteilungsschema; individuelle Sätze können in der Benutzerverwaltung konfiguriert werden",
"inviteSettings": "Einladungseinstellungen",
"apple": {
"clientId": "Dienst-ID",
"clientIdDescription": "Apple-Dienst-ID, die Sie im Apple Developer Portal erhalten können",
"clientSecret": "Privater Schlüssel",
"clientSecretDescription": "Der private Schlüsselinhalt (.p8-Datei), der für die Authentifizierung bei Apple verwendet wird",
"description": "Benutzer mit Apple-Konten authentifizieren",
"enable": "Aktivieren",
"enableDescription": "Nach der Aktivierung können sich Benutzer mit ihrer Apple-ID anmelden",
"keyId": "Schlüssel-ID",
"keyIdDescription": "Die ID Ihres privaten Schlüssels aus dem Apple Developer Portal",
"redirectUri": "Weiterleitungs-URL",
"redirectUriDescription": "Bitte geben Sie die API-Adresse der umgeleiteten URL ein, nachdem die Apple-Authentifizierung erfolgreich abgeschlossen wurde. Verwenden Sie kein / am Ende.",
"teamId": "Team-ID",
"teamIdDescription": "Apple Developer-Team-ID",
"title": "Apple-Anmeldung"
},
"common": {
"cancel": "Abbrechen",
"save": "Speichern",
"saveFailed": "Speichern fehlgeschlagen",
"saveSuccess": "Erfolgreich gespeichert"
},
"communicationMethods": "Communication Methods",
"device": {
"blockVirtualMachine": "Virtuelle Maschine blockieren",
"blockVirtualMachineDescription": "Wenn aktiviert, wird verhindert, dass Geräte auf virtuellen Maschinen oder Emulatoren ausgeführt werden.",
"communicationKey": "Kommunikationsschlüssel",
"communicationKeyDescription": "Der Kommunikationsschlüssel wird für die sichere Kommunikation zwischen Geräten und Servern verwendet.",
"description": "Benutzer mit Gerätekennungen authentifizieren",
"enable": "Aktivieren",
"enableDescription": "Nach der Aktivierung werden mehrere Geräteidentifikatoren wie IMEI/IDFA/IDFV/AndroidID/Mac-Adresse für die Anmeldung und Registrierung unterstützt.",
"enableSecurity": "Kommunikationsverschlüsselung aktivieren",
"enableSecurityDescription": "Wenn aktiviert, wird die Kommunikation zwischen Geräten und Servern verschlüsselt.",
"showAds": "Werbung anzeigen",
"showAdsDescription": "Wenn aktiviert, werden auf den Geräten Anzeigen angezeigt.",
"title": "Geräteauthentifizierung"
},
"deviceAuthMethods": "Device Authentication Methods",
"email": {
"basicSettings": "Grundeinstellungen",
"description": "Benutzer mit E-Mail-Adressen authentifizieren",
"emailSuffixWhitelist": "E-Mail-Suffix-Whitelist",
"emailSuffixWhitelistDescription": "Wenn aktiviert, können sich nur E-Mails mit Suffixen aus der Liste registrieren",
"emailVerification": "E-Mail-Verifizierung",
"emailVerificationDescription": "Wenn aktiviert, müssen Benutzer ihre E-Mail-Adresse verifizieren",
"enable": "Aktivieren",
"enableDescription": "Nach der Aktivierung werden die Funktionen zur E-Mail-Registrierung, Anmeldung, Bindung und Entbindung aktiviert.",
"expirationEmailTemplate": "Vorlage für Ablaufbenachrichtigung",
"expirationTemplate": "Ablaufbenachrichtigung",
"inputPlaceholder": "Wert eingeben...",
"logs": "Protokolle",
"logsDescription": "Verlauf der gesendeten E-Mails und deren Status anzeigen",
"maintenanceEmailTemplate": "Vorlage für Wartungsbenachrichtigung",
"maintenanceTemplate": "Wartungsbenachrichtigung",
"sendFailure": "Senden der Test-E-Mail fehlgeschlagen, bitte überprüfen Sie die Konfiguration.",
"sendSuccess": "Test-E-Mail erfolgreich gesendet.",
"sendTestEmail": "Test-E-Mail senden",
"sendTestEmailDescription": "Senden Sie eine Test-E-Mail, um die Konfiguration zu überprüfen.",
"senderAddress": "Absenderadresse",
"senderAddressDescription": "Die standardmäßige E-Mail-Adresse, die zum Versenden von E-Mails verwendet wird.",
"smtpAccount": "SMTP-Konto",
"smtpAccountDescription": "Das E-Mail-Konto, das für die Authentifizierung verwendet wird.",
"smtpEncryptionMethod": "SMTP-Verschlüsselungsmethode",
"smtpEncryptionMethodDescription": "Wählen Sie, ob SSL/TLS-Verschlüsselung aktiviert werden soll.",
"smtpPassword": "SMTP-Passwort",
"smtpPasswordDescription": "Das Passwort für das SMTP-Konto.",
"smtpServerAddress": "SMTP-Server-Adresse",
"smtpServerAddressDescription": "Geben Sie die Serveradresse an, die zum Senden von E-Mails verwendet wird.",
"smtpServerPort": "SMTP-Server-Port",
"smtpServerPortDescription": "Geben Sie den Port an, der für die Verbindung zum SMTP-Server verwendet wird.",
"smtpSettings": "SMTP-Einstellungen",
"templateVariables": {
"code": {
"description": "6-stelliger Bestätigungscode",
"title": "Bestätigungscode"
},
"expire": {
"description": "Gültigkeitszeit des Bestätigungscodes (Minuten)",
"title": "Gültigkeitsdauer"
},
"expireDate": {
"description": "Ablaufdatum des Dienstes, erinnert Benutzer daran, wann der Dienst abläuft",
"title": "Ablaufdatum"
},
"maintenanceDate": {
"description": "Datum der Systemwartung, zeigt das Startdatum der Wartung an",
"title": "Wartungsdatum"
},
"maintenanceTime": {
"description": "Geschätzte Wartungszeit, zeigt den Wartungszeitraum oder die Dauer an",
"title": "Wartungsdauer"
},
"siteLogo": {
"description": "URL des Website-Logo-Bildes",
"title": "Website-Logo"
},
"siteName": {
"description": "Aktueller Website-Name",
"title": "Website-Name"
},
"title": "E-Mail-Vorlagenvariablen",
"type": {
"conditionalSyntax": "Unterstützt bedingte Syntax zum Wechseln des Inhalts basierend auf dem Typ",
"description": "E-Mail-Typ-Identifikator, 1 für Registrierungsbestätigungscode, andere für Passwortzurücksetzungsbestätigungscode",
"title": "E-Mail-Typ"
}
},
"title": "E-Mail-Authentifizierung",
"trafficExceedEmailTemplate": "Vorlage für Verkehrsüberschreitungsbenachrichtigung",
"trafficTemplate": "Verkehrsgrenze",
"verifyEmailTemplate": "Vorlage für Bestätigungs-E-Mail",
"verifyTemplate": "Bestätigungs-E-Mail",
"whitelistSuffixes": "Whitelist-Suffixe",
"whitelistSuffixesDescription": "Wird zur E-Mail-Verifizierung während der Registrierung verwendet; eine pro Zeile",
"whitelistSuffixesPlaceholder": "Geben Sie E-Mail-Suffixe ein, eines pro Zeile"
},
"facebook": {
"clientId": "Kunden-ID",
"clientIdDescription": "Facebook-App-ID aus der Facebook-Entwicklerkonsole",
"clientSecret": "Client-Geheimnis",
"clientSecretDescription": "Facebook-App-Geheimnis aus der Facebook-Entwicklerkonsole",
"description": "Benutzer mit Facebook-Konten authentifizieren",
"enable": "Aktivieren",
"enableDescription": "Nach der Aktivierung können sich Benutzer mit ihrem Facebook-Konto anmelden",
"title": "Facebook-Anmeldung"
},
"github": {
"clientId": "GitHub-Client-ID",
"clientIdDescription": "Die Client-ID aus den Einstellungen Ihrer GitHub-OAuth-Anwendung",
"clientSecret": "GitHub-Clientgeheimnis",
"clientSecretDescription": "Das Client-Geheimnis aus den Einstellungen Ihrer GitHub-OAuth-Anwendung",
"description": "Benutzer mit GitHub-Konten authentifizieren",
"enable": "GitHub-Authentifizierung aktivieren",
"enableDescription": "Ermöglichen Sie Benutzern, sich mit ihren GitHub-Konten anzumelden",
"title": "GitHub-Anmeldung"
},
"google": {
"clientId": "Kunden-ID",
"clientIdDescription": "Google OAuth 2.0-Client-ID aus der Google Cloud Console",
"clientSecret": "Client-Geheimnis",
"clientSecretDescription": "Google OAuth 2.0-Client-Geheimnis aus der Google Cloud Console",
"description": "Benutzer mit Google-Konten authentifizieren",
"enable": "Aktivieren",
"enableDescription": "Nach der Aktivierung können sich Benutzer mit ihrem Google-Konto anmelden",
"title": "Google-Anmeldung"
},
"log": {
"content": "Inhalt",
"createdAt": "Erstellt am",
@@ -23,52 +158,49 @@
"to": "Empfänger",
"updatedAt": "Aktualisiert am"
},
"register": {
"day": "Tag",
"hour": "Stunde",
"ipRegistrationLimit": "IP-Registrierungsgrenze",
"ipRegistrationLimitDescription": "Wenn aktiviert, werden IPs, die die Regelanforderungen erfüllen, von der Registrierung ausgeschlossen; beachten Sie, dass die IP-Bestimmung aufgrund von CDNs oder Frontend-Proxys Probleme verursachen kann",
"minute": "Minute",
"month": "Monat",
"noLimit": "Keine Begrenzung",
"penaltyTime": "Strafzeit (Minuten)",
"penaltyTimeDescription": "Benutzer müssen warten, bis die Strafzeit abgelaufen ist, bevor sie sich erneut registrieren",
"registerSettings": "Registrierungseinstellungen",
"registrationLimitCount": "Registrierungsgrenzanzahl",
"registrationLimitCountDescription": "Strafe aktivieren, nachdem die Registrierungsgrenze erreicht wurde",
"saveSuccess": "Erfolgreich gespeichert",
"stopNewUserRegistration": "Neue Benutzerregistrierung stoppen",
"stopNewUserRegistrationDescription": "Wenn aktiviert, kann sich niemand registrieren",
"trialDuration": "Testdauer",
"trialRegistration": "Testregistrierung",
"trialRegistrationDescription": "Testregistrierung aktivieren; Testpaket und -dauer zuerst ändern",
"trialSubscribePlan": "Testabonnement-Plan",
"trialSubscribePlanDescription": "Wählen Sie einen Testabonnement-Plan aus",
"year": "Jahr"
"phone": {
"accessLabel": "Zugriff",
"applyPlatform": "Plattform anwenden",
"description": "Benutzer mit Telefonnummern authentifizieren",
"enable": "Aktivieren",
"enableTip": "Nach der Aktivierung werden die Funktionen zur Registrierung, Anmeldung, Bindung und Entbindung von Mobiltelefonen aktiviert",
"endpointLabel": "Endpunkt",
"logs": "Protokolle",
"logsDescription": "Verlauf der gesendeten SMS-Nachrichten und deren Status anzeigen",
"phoneNumberLabel": "Telefonnummer",
"placeholders": {
"template": "Ihr Bestätigungscode ist {code}, gültig für 5 Minuten"
},
"platform": "SMS-Plattform",
"platformConfigTip": "Bitte füllen Sie die bereitgestellte {key}-Konfiguration aus",
"platformTip": "Bitte wählen Sie die SMS-Plattform aus",
"secretLabel": "Geheimnis",
"sendFailed": "Senden fehlgeschlagen",
"sendSuccess": "Erfolgreich gesendet",
"settings": "Einstellungen",
"signNameLabel": "Signaturname",
"template": "SMS-Vorlage",
"templateCodeLabel": "Vorlagen-Code",
"templateTip": "Bitte füllen Sie die SMS-Vorlage aus, lassen Sie {code} in der Mitte, andernfalls funktioniert die SMS-Funktion nicht.",
"testSms": "Test-SMS senden",
"testSmsPhone": "Telefonnummer eingeben",
"testSmsTip": "Senden Sie eine Test-SMS, um Ihre Konfiguration zu überprüfen",
"title": "Telefon-Authentifizierung",
"updateSuccess": "Aktualisierung erfolgreich",
"whitelistAreaCode": "Whitelist Bereichscode",
"whitelistAreaCodeTip": "Bitte geben Sie die Whitelist Bereichscodes ein, z.B. 1, 852, 886, 888",
"whitelistValidation": "Whitelist Überprüfung",
"whitelistValidationTip": "Wenn aktiviert, können nur Bereichscodes in der Whitelist SMS senden"
},
"verify": {
"inputPlaceholder": "Eingeben",
"loginVerificationCode": "Anmeldebestätigungscode",
"loginVerificationCodeDescription": "Menschliche Überprüfung während der Anmeldung",
"registrationVerificationCode": "Registrierungsbestätigungscode",
"registrationVerificationCodeDescription": "Menschliche Überprüfung während der Registrierung",
"resetPasswordVerificationCode": "Bestätigungscode zum Zurücksetzen des Passworts",
"resetPasswordVerificationCodeDescription": "Menschliche Überprüfung während der Passwortzurücksetzung",
"saveSuccess": "Erfolgreich gespeichert",
"turnstileSecretDescription": "Turnstile-Geheimschlüssel, bereitgestellt von Cloudflare",
"turnstileSiteKeyDescription": "Turnstile-Seitenschlüssel, bereitgestellt von Cloudflare",
"verifySettings": "Überprüfungseinstellungen"
},
"verify-code": {
"dailyLimit": "Tägliches Limit",
"dailyLimitDescription": "Maximale Anzahl von Bestätigungscodes, die pro Tag gesendet werden können",
"expireTime": "Ablaufzeit",
"expireTimeDescription": "Ablaufzeit des Bestätigungscodes (Sekunden)",
"interval": "Sendeintervall",
"intervalDescription": "Minimales Intervall zwischen dem Versenden von Bestätigungscodes (Sekunden)",
"saveSuccess": "Erfolgreich gespeichert",
"second": "Sekunden",
"times": "Mal",
"verifyCodeSettings": "Einstellungen für den Bestätigungscode"
"socialAuthMethods": "Social Authentication Methods",
"telegram": {
"clientId": "Bot-ID",
"clientIdDescription": "Telegram-Bot-ID, die Sie von @BotFather erhalten können",
"clientSecret": "Bot-Token",
"clientSecretDescription": "Telegram-Bot-Token, den Sie von @BotFather erhalten können",
"description": "Benutzer mit Telegram-Konten authentifizieren",
"enable": "Aktivieren",
"enableDescription": "Nach der Aktivierung werden die Funktionen zur Registrierung, Anmeldung, Bindung und Entbindung von Mobiltelefonen aktiviert",
"title": "Telegram-Anmeldung"
}
}
-14
View File
@@ -1,14 +0,0 @@
{
"blockVirtualMachine": "Virtuelle Maschine blockieren",
"blockVirtualMachineDescription": "Wenn aktiviert, wird verhindert, dass Geräte auf virtuellen Maschinen oder Emulatoren ausgeführt werden.",
"communicationKey": "Kommunikationsschlüssel",
"communicationKeyDescription": "Der Kommunikationsschlüssel wird für die sichere Kommunikation zwischen Geräten und Servern verwendet.",
"enable": "Aktivieren",
"enableDescription": "Nach der Aktivierung werden mehrere Geräteidentifikatoren wie IMEI/IDFA/IDFV/AndroidID/Mac-Adresse für die Anmeldung und Registrierung unterstützt.",
"enableSecurity": "Kommunikationsverschlüsselung aktivieren",
"enableSecurityDescription": "Wenn aktiviert, wird die Kommunikation zwischen Geräten und Servern verschlüsselt.",
"saveFailed": "Speichern fehlgeschlagen",
"saveSuccess": "Speichern erfolgreich",
"showAds": "Werbung anzeigen",
"showAdsDescription": "Wenn aktiviert, werden auf den Geräten Anzeigen angezeigt."
}
-49
View File
@@ -1,49 +0,0 @@
{
"emailBasicConfigDescription": "Konfigurieren Sie die SMTP-Servereinstellungen und E-Mail-Verifizierungsoptionen",
"emailLogsDescription": "Sehen Sie sich den Verlauf gesendeter E-Mails und deren Status an",
"emailSuffixWhitelist": "E-Mail-Suffix-Whitelist",
"emailSuffixWhitelistDescription": "Wenn aktiviert, können sich nur E-Mails mit Suffixen aus der Liste registrieren",
"emailVerification": "E-Mail-Verifizierung",
"emailVerificationDescription": "Wenn aktiviert, müssen Benutzer ihre E-Mail-Adresse verifizieren",
"enable": "Aktivieren",
"enableDescription": "Nach der Aktivierung werden die Funktionen zur E-Mail-Registrierung, Anmeldung, Bindung und Entbindung aktiviert.",
"expiration_email_template": "Vorlage für Ablaufbenachrichtigung",
"expiration_email_templateDescription": "Die Platzhalter {after}.variable{before} werden durch tatsächliche Daten ersetzt. Stellen Sie sicher, dass Sie diese Variablen beibehalten.",
"failed": "Fehlgeschlagen",
"inputPlaceholder": "Wert eingeben...",
"logs": "Protokolle",
"maintenance_email_template": "Wartungshinweis Vorlage",
"maintenance_email_templateDescription": "Die Platzhalter {after}.variable{before} werden durch tatsächliche Daten ersetzt. Stellen Sie sicher, dass Sie diese Variablen beibehalten.",
"recipient": "Empfänger",
"saveFailed": "Konfiguration konnte nicht gespeichert werden",
"saveSuccess": "Konfiguration erfolgreich gespeichert.",
"sendFailure": "Senden der Test-E-Mail fehlgeschlagen, bitte überprüfen Sie die Konfiguration.",
"sendSuccess": "Test-E-Mail erfolgreich gesendet.",
"sendTestEmail": "Test-E-Mail senden",
"sendTestEmailDescription": "Senden Sie eine Test-E-Mail, um die Konfiguration zu überprüfen.",
"senderAddress": "Absenderadresse",
"senderAddressDescription": "Die standardmäßige E-Mail-Adresse, die zum Versenden von E-Mails verwendet wird.",
"sent": "Gesendet",
"sentAt": "Gesendet am",
"settings": "Einstellungen",
"smtpAccount": "SMTP-Konto",
"smtpAccountDescription": "Das E-Mail-Konto, das für die Authentifizierung verwendet wird.",
"smtpEncryptionMethod": "SMTP-Verschlüsselungsmethode",
"smtpEncryptionMethodDescription": "Wählen Sie, ob SSL/TLS-Verschlüsselung aktiviert werden soll.",
"smtpPassword": "SMTP-Passwort",
"smtpPasswordDescription": "Das Passwort für das SMTP-Konto.",
"smtpServerAddress": "SMTP-Server-Adresse",
"smtpServerAddressDescription": "Geben Sie die Serveradresse an, die zum Senden von E-Mails verwendet wird.",
"smtpServerPort": "SMTP-Server-Port",
"smtpServerPortDescription": "Geben Sie den Port an, der für die Verbindung zum SMTP-Server verwendet wird.",
"status": "Status",
"subject": "Betreff",
"template": "Vorlage",
"traffic_exceed_email_template": "Benachrichtigung über überschreitenden Verkehr",
"traffic_exceed_email_templateDescription": "Die Platzhalter {after}.variable{before} werden durch tatsächliche Daten ersetzt. Stellen Sie sicher, dass Sie diese Variablen beibehalten.",
"verify_email_template": "E-Mail-Bestätigungsvorlage",
"verify_email_templateDescription": "Die Platzhalter {after}.variable{before} werden durch tatsächliche Daten ersetzt. Stellen Sie sicher, dass diese Variablen beibehalten werden.",
"whitelistSuffixes": "Whitelist-Suffixe",
"whitelistSuffixesDescription": "Wird zur E-Mail-Verifizierung während der Registrierung verwendet; eine pro Zeile",
"whitelistSuffixesPlaceholder": "Geben Sie E-Mail-Suffixe ein, eines pro Zeile"
}
-10
View File
@@ -1,10 +0,0 @@
{
"clientId": "Kunden-ID",
"clientIdDescription": "Facebook-App-ID aus der Facebook-Entwicklerkonsole",
"clientSecret": "Client-Geheimnis",
"clientSecretDescription": "Facebook-App-Geheimnis aus der Facebook-Entwicklerkonsole",
"enable": "Aktivieren",
"enableDescription": "Nach der Aktivierung können sich Benutzer mit ihrem Facebook-Konto anmelden",
"saveFailed": "Speichern fehlgeschlagen",
"saveSuccess": "Erfolgreich gespeichert"
}
-10
View File
@@ -1,10 +0,0 @@
{
"clientId": "GitHub-Client-ID",
"clientIdDescription": "Die Client-ID aus den Einstellungen Ihrer GitHub-OAuth-Anwendung",
"clientSecret": "GitHub-Clientgeheimnis",
"clientSecretDescription": "Das Client-Geheimnis aus den Einstellungen Ihrer GitHub-OAuth-Anwendung",
"enable": "GitHub-Authentifizierung aktivieren",
"enableDescription": "Ermöglichen Sie Benutzern, sich mit ihren GitHub-Konten anzumelden",
"saveFailed": "Speichern der GitHub-Einstellungen fehlgeschlagen",
"saveSuccess": "GitHub-Einstellungen erfolgreich gespeichert"
}
-10
View File
@@ -1,10 +0,0 @@
{
"clientId": "Kunden-ID",
"clientIdDescription": "Google OAuth 2.0-Client-ID aus der Google Cloud Console",
"clientSecret": "Client-Geheimnis",
"clientSecretDescription": "Google OAuth 2.0-Client-Geheimnis aus der Google Cloud Console",
"enable": "Aktivieren",
"enableDescription": "Nach der Aktivierung können sich Benutzer mit ihrem Google-Konto anmelden",
"saveFailed": "Speichern fehlgeschlagen",
"saveSuccess": "Erfolgreich gespeichert"
}
+97
View File
@@ -0,0 +1,97 @@
{
"additional": "Zusätzlich",
"additionalRecipientEmails": "Zusätzliche Empfänger-E-Mails",
"additionalRecipients": "Zusätzliche Empfänger",
"additionalRecipientsDescription": "Diese E-Mails erhalten die Übertragung zusätzlich zu dem oben genannten Benutzerfilter",
"allUsers": "Alle Benutzer",
"broadcastList": "Übertragungsliste",
"broadcastLogs": "Übertragungsprotokolle",
"cancel": "Abbrechen",
"cannotBeEmpty": "darf nicht leer sein",
"completed": "Abgeschlossen",
"confirm": "Bestätigen",
"confirmDelete": "Löschen bestätigen",
"content": "E-Mail-Inhalt",
"create": "Erstellen",
"createBroadcast": "Übertragung erstellen",
"createNewEmailBroadcastCampaign": "Neue E-Mail-Übertragungskampagne erstellen",
"createSuccess": "Erfolgreich erstellt",
"createdAt": "Erstellt am",
"dailyLimit": "Tägliches Limit muss mindestens 1 betragen",
"dailySendLimit": "Tägliches Sendelimit",
"delete": "Löschen",
"deleteDescription": "Dieser Vorgang kann nicht rückgängig gemacht werden. Sind Sie sicher, dass Sie löschen möchten?",
"deleteSuccess": "Erfolgreich gelöscht",
"edit": "Bearbeiten",
"emailAddedToScheduledQueue": "E-Mail zur geplanten Sendewarteschlange hinzugefügt",
"emailBroadcast": "E-Mail-Übertragung",
"emailBroadcastTaskCreatedSuccessfully": "E-Mail-Übertragungsaufgabe erfolgreich erstellt",
"emailBroadcastTasks": "E-Mail-Übertragungsaufgaben",
"emailContent": "E-Mail-Inhalt",
"emailInterval": "E-Mail-Intervall (Sekunden)",
"emailIntervalMinimum": "E-Mail-Intervall muss mindestens 0,1 Sekunden betragen",
"emailMarketing": "E-Mail-Marketing",
"emailTaskManager": "E-Mail-Aufgabenmanager",
"errorMessage": "Fehlermeldung",
"estimatedRecipients": "Geschätzte Empfänger",
"expiredSubscriptionUsersOnly": "Nur abgelaufene Abonnenten",
"expiredUsers": "Abgelaufene Benutzer",
"failCount": "Fehleranzahl",
"failed": "Fehlgeschlagen",
"failedToRefreshTaskStatus": "Aktualisierung des Aufgabenstatus fehlgeschlagen",
"failedToStopTask": "Beenden der Aufgabe fehlgeschlagen",
"inProgress": "In Bearbeitung",
"includeUsersRegisteredAfter": "Benutzer einbeziehen, die nach diesem Datum registriert sind",
"includeUsersRegisteredBefore": "Benutzer einbeziehen, die vor diesem Datum registriert sind",
"intervalTimeBetweenEmails": "Intervallzeit zwischen jeder E-Mail",
"leaveEmptyForImmediateSend": "Leer lassen für sofortige Sendung",
"logList": "Protokollliste",
"marketingManagement": "Marketing-Management",
"maximumNumberPerDay": "Maximale Anzahl von E-Mails, die pro Tag gesendet werden dürfen",
"noSubscriptionUsersOnly": "Nur keine Abonnenten",
"nonSubscribers": "Nicht-Abonnenten",
"notStarted": "Nicht gestartet",
"onePerLine": "eine pro Zeile",
"only": "nur",
"pending": "Ausstehend",
"pleaseEnter": "Bitte eingeben",
"pleaseEnterValidEmailAddresses": "Bitte geben Sie gültige E-Mail-Adressen ein, eine pro Zeile",
"processing": "Verarbeitung...",
"progress": "Fortschritt",
"recipient": "Empfänger",
"recipientEmail": "Empfänger-E-Mail",
"recipientType": "Empfängertyp",
"registrationEndDate": "Registrierungsenddatum",
"registrationStartDate": "Registrierungsstartdatum",
"scheduleSend": "Sendung planen",
"scheduledSend": "Geplante Sendung",
"scheduledSendTimeMustBeLater": "Die geplante Sendezeit muss später als die aktuelle Zeit sein",
"selectSendScope": "Sendeumfang auswählen",
"selectSendTime": "Sendezeit auswählen, leer lassen für sofortige Sendung",
"sendFailed": "Sendung fehlgeschlagen, bitte erneut versuchen",
"sendNow": "Jetzt senden",
"sendScope": "Sendeumfang",
"sendScopeDescription": "Wählen Sie den Benutzerumfang für den E-Mail-Versand. Wählen Sie \"Nur zusätzliche E-Mails\", um nur an die unten ausgefüllten E-Mail-Adressen zu senden",
"sendSettings": "Sendeinstellungen",
"sendTime": "Sendezeit",
"sending": "Senden",
"sentAt": "Gesendet am",
"specificUsers": "Spezifische Benutzer",
"specificUsersOnly": "Nur zusätzliche E-Mails (Plattformbenutzer überspringen)",
"status": "Status",
"stop": "Stoppen",
"stopped": "Gestoppt",
"subject": "E-Mail-Betreff",
"subscribedUsers": "Abonnierte Benutzer",
"subscribedUsersOnly": "Nur abonnierte Benutzer",
"successCount": "Erfolgsanzahl",
"taskStatusRefreshed": "Aufgabenstatus aktualisiert",
"taskStoppedSuccessfully": "Aufgabe erfolgreich gestoppt",
"totalSent": "Insgesamt gesendet",
"updateSuccess": "Erfolgreich aktualisiert",
"useMarkdownEditor": "Markdown-Editor verwenden, um E-Mail-Inhalte mit Vorschaufunktion zu schreiben",
"users": "Benutzer",
"view": "Anzeigen",
"viewAndManageEmailBroadcastTasks": "E-Mail-Übertragungsaufgaben anzeigen und verwalten",
"viewContent": "Inhalt anzeigen"
}

Some files were not shown because too many files have changed in this diff Show More