♻️ refactor: Refactoring and adding multiple features

This commit is contained in:
web
2025-08-05 06:42:39 -07:00
committed by speakeloudest
parent 5a2fa2e937
commit 47d19d1b44
106 changed files with 6893 additions and 4604 deletions
@@ -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);
@@ -0,0 +1,480 @@
'use client';
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 { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { toast } from 'sonner';
import { z } from 'zod';
const emailBroadcastSchema = z.object({
subject: z.string().min(1, 'Email subject cannot be empty'),
content: z.string().min(1, 'Email content cannot be empty'),
// Send settings
additional_emails: 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: 'Please enter valid email addresses, one per line',
},
),
// Send time settings
scheduled_time: z.string().optional(),
user_filters: z.object({
subscription_status: z.string().optional(),
registration_date_from: z.string().optional(),
registration_date_to: z.string().optional(),
user_groups: z.array(z.string()).default([]),
}),
rate_limit: z.object({
email_interval_seconds: z
.number()
.min(1, 'Email interval (seconds) cannot be less than 1')
.default(1),
daily_limit: z.number().min(1, 'Daily limit must be at least 1').default(1000),
}),
});
type EmailBroadcastFormData = z.infer<typeof emailBroadcastSchema>;
export default function EmailBroadcastForm() {
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: '',
additional_emails: '',
scheduled_time: '',
user_filters: {
subscription_status: 'all',
registration_date_from: '',
registration_date_to: '',
user_groups: [],
},
rate_limit: {
email_interval_seconds: 1,
daily_limit: 1000,
},
},
});
// Calculate recipient count
const calculateRecipients = () => {
const formData = form.getValues();
// Simulate user data statistics (should call API in real implementation)
let userCount = 0;
const sendingScope = formData.user_filters.subscription_status;
if (sendingScope === 'skip') {
// Send only to additional emails
userCount = 0;
} else {
let baseCount = 1500;
if (sendingScope === 'active') {
baseCount = Math.floor(baseCount * 0.3); // 30% active subscription users
} else if (sendingScope === 'expired') {
baseCount = Math.floor(baseCount * 0.2); // 20% expired subscription users
} else if (sendingScope === 'none') {
baseCount = Math.floor(baseCount * 0.5); // 50% no subscription users
}
// If 'all' or empty, keep baseCount unchanged (all platform users)
// Date filter impact (simplified calculation)
if (
formData.user_filters.registration_date_from ||
formData.user_filters.registration_date_to
) {
baseCount = Math.floor(baseCount * 0.7); // Estimate about 70% after date filtering
}
userCount = baseCount;
}
// Calculate additional email count
const additionalEmails = formData.additional_emails || '';
const additionalCount = additionalEmails
.split('\n')
.filter((email: string) => email.trim() !== '').length;
const total = userCount + additionalCount;
setEstimatedRecipients({
users: userCount,
additional: additionalCount,
total,
});
};
// Listen to form changes
const watchedValues = form.watch();
// Use useEffect to respond to form changes
useEffect(() => {
calculateRecipients();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
watchedValues.user_filters?.subscription_status,
watchedValues.user_filters?.registration_date_from,
watchedValues.user_filters?.registration_date_to,
watchedValues.additional_emails,
]);
const onSubmit = async (data: EmailBroadcastFormData) => {
setLoading(true);
try {
// Validate scheduled send time
if (data.scheduled_time && data.scheduled_time.trim() !== '') {
const scheduledDate = new Date(data.scheduled_time);
const now = new Date();
if (scheduledDate <= now) {
toast.error('Scheduled send time must be later than current time');
return;
}
}
// Simulate API call
await new Promise((resolve) => setTimeout(resolve, 1000));
console.log('Email broadcast data:', data);
if (!data.scheduled_time || data.scheduled_time.trim() === '') {
toast.success('Email sent successfully');
} else {
toast.success('Email added to scheduled send queue');
}
form.reset();
setOpen(false);
} catch (error) {
toast.error('Send failed, please try again');
} 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'>Email Broadcast</p>
<p className='text-muted-foreground text-sm'>Create new email broadcast campaign</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>Create Email Broadcast</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'>Email Content</TabsTrigger>
<TabsTrigger value='settings'>Send Settings</TabsTrigger>
</TabsList>
{/* Email Content Tab */}
<TabsContent value='content' className='space-y-2'>
<FormField
control={form.control}
name='subject'
render={({ field }) => (
<FormItem>
<FormLabel>Email Subject</FormLabel>
<FormControl>
<Input placeholder='Please enter email subject' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='content'
render={({ field }) => (
<FormItem>
<FormLabel>Email Content</FormLabel>
<FormControl>
<MarkdownEditor
value={field.value}
onChange={(value) => {
form.setValue(field.name, value || '');
}}
/>
</FormControl>
<FormDescription>
Use Markdown editor to write email content with preview functionality
</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='user_filters.subscription_status'
render={({ field }) => (
<FormItem>
<FormLabel>Send Scope</FormLabel>
<Select onValueChange={field.onChange} value={field.value || 'all'}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder='Select send scope' />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value='all'>All platform users</SelectItem>
<SelectItem value='active'>Active subscription users only</SelectItem>
<SelectItem value='expired'>
Expired subscription users only
</SelectItem>
<SelectItem value='none'>No subscription users only</SelectItem>
<SelectItem value='skip'>
Additional emails only (skip platform users)
</SelectItem>
</SelectContent>
</Select>
<FormDescription>
Choose the user scope for email sending. Select &ldquo;Additional emails
only&rdquo; to send only to the email addresses filled below
</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'>Estimated recipients: </span>
<span className='text-primary text-lg font-medium'>
{estimatedRecipients.total}
</span>
<span className='text-muted-foreground ml-2 text-xs'>
(users: {estimatedRecipients.users}, additional:{' '}
{estimatedRecipients.additional})
</span>
</div>
</div>
</div>
<div className='grid grid-cols-2 gap-4'>
<FormField
control={form.control}
name='user_filters.registration_date_from'
render={({ field }) => (
<FormItem>
<FormLabel>Registration Start Date</FormLabel>
<FormControl>
<EnhancedInput
type='datetime-local'
disabled={form.watch('user_filters.subscription_status') === 'skip'}
value={field.value}
onValueChange={field.onChange}
/>
</FormControl>
<FormDescription>
Include users registered on or after this date
</FormDescription>
</FormItem>
)}
/>
<FormField
control={form.control}
name='user_filters.registration_date_to'
render={({ field }) => (
<FormItem>
<FormLabel>Registration End Date</FormLabel>
<FormControl>
<EnhancedInput
type='datetime-local'
disabled={form.watch('user_filters.subscription_status') === 'skip'}
value={field.value}
onValueChange={field.onChange}
/>
</FormControl>
<FormDescription>
Include users registered on or before this date
</FormDescription>
</FormItem>
)}
/>
</div>
{/* Additional recipients */}
<FormField
control={form.control}
name='additional_emails'
render={({ field }) => (
<FormItem>
<FormLabel>Additional Recipient Emails</FormLabel>
<FormControl>
<Textarea
placeholder={`Please enter additional recipient emails, one per line, for example:\nexample1@domain.com\nexample2@domain.com\nexample3@domain.com`}
className='min-h-[120px] font-mono text-sm'
{...field}
/>
</FormControl>
<FormDescription>
These emails will receive the email additionally, not affected by the user
filter conditions above
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{/* Send time settings */}
<FormField
control={form.control}
name='scheduled_time'
render={({ field }) => (
<FormItem>
<FormLabel>Scheduled Send</FormLabel>
<FormControl>
<EnhancedInput
type='datetime-local'
placeholder='Leave empty for immediate send'
value={field.value}
onValueChange={field.onChange}
/>
</FormControl>
<FormDescription>
Select send time, leave empty for immediate send
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{/* Send rate control */}
<div className='grid grid-cols-2 gap-4'>
<FormField
control={form.control}
name='rate_limit.email_interval_seconds'
render={({ field }) => (
<FormItem>
<FormLabel>Email Interval (seconds)</FormLabel>
<FormControl>
<Input
type='number'
min={0}
step={0.1}
placeholder='1'
{...field}
onChange={(e) => field.onChange(parseFloat(e.target.value) || 1)}
/>
</FormControl>
<FormDescription>Interval time between each email</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='rate_limit.daily_limit'
render={({ field }) => (
<FormItem>
<FormLabel>Daily Send Limit</FormLabel>
<FormControl>
<Input
type='number'
min={1}
placeholder='1000'
{...field}
onChange={(e) => field.onChange(parseInt(e.target.value) || 1000)}
/>
</FormControl>
<FormDescription>
Maximum number of emails to send per day
</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)}>
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
? 'Processing...'
: !form.watch('scheduled_time') || form.watch('scheduled_time')?.trim() === ''
? 'Send Now'
: 'Schedule Send'}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
);
}
@@ -0,0 +1,225 @@
'use client';
import { ProTable, ProTableActions } from '@/components/pro-table';
import { Badge } from '@workspace/ui/components/badge';
import { Button } from '@workspace/ui/components/button';
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 { useRef, useState } from 'react';
interface EmailLog extends Record<string, unknown> {
id: number;
subject: string;
recipient_email: string;
status: 'pending' | 'sent' | 'failed';
sent_at?: string;
error_message?: string;
broadcast_id: number;
}
interface GetEmailLogsParams extends Record<string, unknown> {
page: number;
size: number;
status?: string;
search?: string;
}
// Mock API function
const getEmailLogs = async (params: GetEmailLogsParams) => {
// Simulate API call
await new Promise((resolve) => setTimeout(resolve, 500));
// Mock data
const mockData: EmailLog[] = [
{
id: 1,
subject: 'New Feature Release Notification',
recipient_email: 'user1@example.com',
status: 'sent',
sent_at: '2024-01-15T10:05:00Z',
broadcast_id: 1,
},
{
id: 2,
subject: 'New Feature Release Notification',
recipient_email: 'user2@example.com',
status: 'sent',
sent_at: '2024-01-15T10:05:30Z',
broadcast_id: 1,
},
{
id: 3,
subject: 'New Feature Release Notification',
recipient_email: 'user3@example.com',
status: 'failed',
error_message: 'Invalid email address',
broadcast_id: 1,
},
{
id: 4,
subject: 'System Maintenance Notice',
recipient_email: 'user4@example.com',
status: 'sent',
sent_at: '2024-01-14T15:35:00Z',
broadcast_id: 2,
},
{
id: 5,
subject: 'System Maintenance Notice',
recipient_email: 'user5@example.com',
status: 'pending',
broadcast_id: 2,
},
];
return {
data: {
data: {
list: mockData,
total: mockData.length,
},
},
};
};
export default function BroadcastLogsTable() {
const t = useTranslations('marketing');
const [open, setOpen] = useState(false);
const ref = useRef<ProTableActions>(null);
const getStatusBadge = (status: string) => {
const statusMap = {
pending: { variant: 'secondary' as const, label: 'Pending' },
sent: { variant: 'default' as const, label: 'Sent' },
failed: { variant: 'destructive' as const, label: 'Failed' },
};
const statusInfo = statusMap[status as keyof typeof statusMap] || statusMap.pending;
return <Badge variant={statusInfo.variant}>{statusInfo.label}</Badge>;
};
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'>Broadcast Logs</p>
<p className='text-muted-foreground text-sm'>
View email send records and detailed status
</p>
</div>
</div>
<Icon icon='mdi:chevron-right' className='size-6' />
</div>
</SheetTrigger>
<SheetContent className='w-[90vw] max-w-full md:max-w-screen-xl'>
<SheetHeader>
<SheetTitle>Broadcast Logs</SheetTitle>
</SheetHeader>
<ScrollArea className='-mx-6 h-[calc(100dvh-48px-36px-env(safe-area-inset-top))] px-6'>
<div className='pt-4'>
<ProTable<EmailLog, GetEmailLogsParams>
action={ref}
columns={[
{
accessorKey: 'id',
header: 'ID',
size: 80,
},
{
accessorKey: 'subject',
header: 'Subject',
size: 200,
cell: ({ row }) => (
<div
className='max-w-[200px] truncate'
title={row.getValue('subject') as string}
>
{row.getValue('subject') as string}
</div>
),
},
{
accessorKey: 'recipient_email',
header: 'Recipient Email',
size: 200,
},
{
accessorKey: 'status',
header: 'Status',
size: 100,
cell: ({ row }) => getStatusBadge(row.getValue('status') as string),
},
{
accessorKey: 'sent_at',
header: 'Sent At',
size: 150,
cell: ({ row }) => {
const sentAt = row.getValue('sent_at') as string;
return sentAt ? formatDate(new Date(sentAt)) : '--';
},
},
{
accessorKey: 'error_message',
header: 'Error Message',
size: 200,
cell: ({ row }) => {
const error = row.getValue('error_message') as string;
return error ? <span className='text-sm text-red-600'>{error}</span> : '--';
},
},
]}
request={async (pagination, filter) => {
const { data } = await getEmailLogs({
...pagination,
...filter,
});
return {
list: data.data?.list || [],
total: data.data?.total || 0,
};
}}
params={[
{
key: 'status',
placeholder: 'Status',
options: [
{ label: 'Pending', value: 'pending' },
{ label: 'Sent', value: 'sent' },
{ label: 'Failed', value: 'failed' },
],
},
{
key: 'search',
placeholder: 'Recipient Email',
},
]}
actions={{
render: (row) => {
return [
<Button key='view' variant='outline' size='sm'>
View
</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 BroadcastLogsTable from './email/logs-table';
export default function MarketingPage() {
const t = useTranslations('marketing');
const formSections = [
{
title: 'Email Marketing',
forms: [{ component: EmailBroadcastForm }, { component: BroadcastLogsTable }],
},
];
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>
);
}
@@ -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'],
@@ -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>
);
}
@@ -0,0 +1,250 @@
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { useQuery } from '@tanstack/react-query';
import { useTranslations } from 'next-intl';
import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { toast } from 'sonner';
import { z } from 'zod';
import { getSubscribeConfig, updateSubscribeConfig } from '@/services/admin/system';
import { Button } from '@workspace/ui/components/button';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@workspace/ui/components/form';
import { ScrollArea } from '@workspace/ui/components/scroll-area';
import {
Sheet,
SheetContent,
SheetFooter,
SheetHeader,
SheetTitle,
SheetTrigger,
} from '@workspace/ui/components/sheet';
import { Switch } from '@workspace/ui/components/switch';
import { Textarea } from '@workspace/ui/components/textarea';
import { EnhancedInput } from '@workspace/ui/custom-components/enhanced-input';
import { Icon } from '@workspace/ui/custom-components/icon';
const subscribeConfigSchema = z.object({
single_model: z.boolean().optional(),
pan_domain: z.boolean().optional(),
subscribe_path: z.string().optional(),
subscribe_domain: z.string().optional(),
restrict_user_agent: z.boolean().optional(),
user_agent_whitelist: z.string().optional(),
});
type SubscribeConfigFormData = z.infer<typeof subscribeConfigSchema>;
export default function ConfigForm() {
const t = useTranslations('subscribe');
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
const { data, refetch } = useQuery({
queryKey: ['getSubscribeConfig'],
queryFn: async () => {
const { data } = await getSubscribeConfig();
return data.data;
},
enabled: open,
});
const form = useForm<SubscribeConfigFormData>({
resolver: zodResolver(subscribeConfigSchema),
defaultValues: {
single_model: false,
pan_domain: false,
subscribe_path: '',
subscribe_domain: '',
restrict_user_agent: false,
user_agent_whitelist: '',
},
});
useEffect(() => {
if (data) {
form.reset(data);
}
}, [data, form]);
async function onSubmit(values: SubscribeConfigFormData) {
setLoading(true);
try {
await updateSubscribeConfig(values as API.SubscribeConfig);
toast.success(t('config.updateSuccess'));
refetch();
setOpen(false);
} catch (error) {
toast.error(t('config.updateError'));
} finally {
setLoading(false);
}
}
return (
<Sheet open={open} onOpenChange={setOpen}>
<SheetTrigger asChild>
<div className='flex cursor-pointer items-center justify-between transition-colors'>
<div className='flex items-center gap-3'>
<div className='bg-primary/10 flex h-10 w-10 items-center justify-center rounded-lg'>
<Icon icon='mdi:cog' className='text-primary h-5 w-5' />
</div>
<div className='flex-1'>
<p className='font-medium'>{t('config.title')}</p>
<p className='text-muted-foreground text-sm'>{t('config.description')}</p>
</div>
</div>
<Icon icon='mdi:chevron-right' className='size-6' />
</div>
</SheetTrigger>
<SheetContent className='w-[600px] max-w-full md:max-w-screen-md'>
<SheetHeader>
<SheetTitle>{t('config.title')}</SheetTitle>
</SheetHeader>
<ScrollArea className='-mx-6 h-[calc(100dvh-48px-36px-36px-env(safe-area-inset-top))] px-6'>
<Form {...form}>
<form
id='subscribe-config-form'
onSubmit={form.handleSubmit(onSubmit)}
className='space-y-2 pt-4'
>
<FormField
control={form.control}
name='single_model'
render={({ field }) => (
<FormItem>
<FormLabel>{t('config.singleSubscriptionMode')}</FormLabel>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
className='float-end !mt-0'
/>
</FormControl>
<FormDescription>
{t('config.singleSubscriptionModeDescription')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='pan_domain'
render={({ field }) => (
<FormItem>
<FormLabel>{t('config.wildcardResolution')}</FormLabel>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
className='float-end !mt-0'
/>
</FormControl>
<FormDescription>{t('config.wildcardResolutionDescription')}</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='subscribe_path'
render={({ field }) => (
<FormItem>
<FormLabel>{t('config.subscriptionPath')}</FormLabel>
<FormControl>
<EnhancedInput
placeholder={t('config.subscriptionPathPlaceholder')}
value={field.value}
onValueBlur={field.onChange}
/>
</FormControl>
<FormDescription>{t('config.subscriptionPathDescription')}</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='subscribe_domain'
render={({ field }) => (
<FormItem>
<FormLabel>{t('config.subscriptionDomain')}</FormLabel>
<FormControl>
<Textarea
className='h-32'
placeholder={`${t('config.subscriptionDomainPlaceholder')}\nexample.com\nwww.example.com`}
{...field}
/>
</FormControl>
<FormDescription>{t('config.subscriptionDomainDescription')}</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='restrict_user_agent'
render={({ field }) => (
<FormItem>
<FormLabel>{t('config.restrictUserAgent')}</FormLabel>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
className='float-end !mt-0'
/>
</FormControl>
<FormDescription>{t('config.restrictUserAgentDescription')}</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='user_agent_whitelist'
render={({ field }) => (
<FormItem>
<FormLabel>{t('config.userAgentWhitelist')}</FormLabel>
<FormControl>
<Textarea
className='h-32'
placeholder={`${t('config.userAgentWhitelistPlaceholder')}\nClashX\nClashForAndroid\nClash-verge`}
{...field}
/>
</FormControl>
<FormDescription>{t('config.userAgentWhitelistDescription')}</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</ScrollArea>
<SheetFooter className='flex-row justify-end gap-2 pt-3'>
<Button variant='outline' disabled={loading} onClick={() => setOpen(false)}>
{t('actions.cancel')}
</Button>
<Button disabled={loading} type='submit' form='subscribe-config-form'>
{loading && <Icon icon='mdi:loading' className='mr-2 animate-spin' />}
{t('actions.save')}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
);
}
+18 -24
View File
@@ -1,30 +1,24 @@
import { getTranslations } from 'next-intl/server';
'use client';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@workspace/ui/components/tabs';
import { Card, CardContent } from '@workspace/ui/components/card';
import { useTranslations } from 'next-intl';
import ConfigForm from './config-form';
import { ProtocolForm } from './protocol-form';
import GroupTable from './group/table';
import SubscribeConfig from './subscribe-config';
import SubscribeTable from './subscribe-table';
export default async function Page() {
const t = await getTranslations('subscribe');
export default function SubscribePage() {
const t = useTranslations('subscribe');
return (
<Tabs defaultValue='subscribe'>
<TabsList>
<TabsTrigger value='subscribe'>{t('tabs.subscribe')}</TabsTrigger>
<TabsTrigger value='group'>{t('tabs.subscribeGroup')}</TabsTrigger>
<TabsTrigger value='config'>{t('tabs.subscribeConfig')}</TabsTrigger>
</TabsList>
<TabsContent value='subscribe'>
<SubscribeTable />
</TabsContent>
<TabsContent value='group'>
<GroupTable />
</TabsContent>
<TabsContent value='config'>
<SubscribeConfig />
</TabsContent>
</Tabs>
<div className='space-y-4'>
<h2 className='text-lg font-semibold'>{t('config.title')}</h2>
<Card>
<CardContent className='p-4'>
<ConfigForm />
</CardContent>
</Card>
<h2 className='text-lg font-semibold'>{t('protocol.title')}</h2>
<ProtocolForm />
</div>
);
}
@@ -0,0 +1,240 @@
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { Badge } from '@workspace/ui/components/badge';
import { Button } from '@workspace/ui/components/button';
import { Card, CardContent } from '@workspace/ui/components/card';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@workspace/ui/components/form';
import { ScrollArea } from '@workspace/ui/components/scroll-area';
import {
Sheet,
SheetContent,
SheetFooter,
SheetHeader,
SheetTitle,
} from '@workspace/ui/components/sheet';
import { Textarea } from '@workspace/ui/components/textarea';
import { Icon } from '@workspace/ui/custom-components/icon';
import { useTranslations } from 'next-intl';
import Image from 'next/image';
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
interface Client {
name: string;
platforms: string[];
}
const clientFormSchema = z.object({
template: z.string().optional(),
});
const clientsConfig = [
{
name: 'Hiddify',
platforms: ['Windows', 'macOS', 'Linux', 'iOS', 'Android'],
},
{
name: 'SingBox',
platforms: ['Windows', 'macOS', 'Linux', 'iOS', 'Android'],
},
{
name: 'Clash',
platforms: ['Windows', 'macOS', 'Linux', 'Android'],
},
{
name: 'V2rayN',
platforms: ['Windows', 'macOS', 'Linux'],
},
{
name: 'Stash',
platforms: ['macOS', 'iOS'],
},
{
name: 'Surge',
platforms: ['macOS', 'iOS'],
},
{
name: 'V2Box',
platforms: ['macOS', 'iOS'],
},
{
name: 'Shadowrocket',
platforms: ['iOS'],
},
{
name: 'Quantumult',
platforms: ['iOS'],
},
{
name: 'Loon',
platforms: ['iOS'],
},
{
name: 'Egern',
platforms: ['iOS'],
},
{
name: 'V2rayNG',
platforms: ['Android'],
},
{
name: 'Surfboard',
platforms: ['Android'],
},
{
name: 'Netch',
platforms: ['Windows'],
},
];
type ClientFormData = z.infer<typeof clientFormSchema>;
export function ProtocolForm() {
const t = useTranslations('subscribe');
const [selectedClient, setSelectedClient] = useState<Client | null>(null);
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
const form = useForm<ClientFormData>({
resolver: zodResolver(clientFormSchema),
defaultValues: {
template: '',
},
});
const clients: Client[] = clientsConfig;
const handleClientClick = (client: Client) => {
setSelectedClient(client);
// 请求当前客户端的配置模板
// 这里可以替换为实际的API调用
// 模拟获取模板数据
const mockTemplate = `# ${client.name} 配置模板`;
form.reset({
template: mockTemplate,
});
setOpen(true);
};
const onSubmit = async (data: ClientFormData) => {
setLoading(true);
try {
// TODO: 实现保存逻辑
console.log('Save client template:', {
name: selectedClient?.name,
template: data.template,
});
// 模拟API调用
await new Promise((resolve) => setTimeout(resolve, 1000));
setOpen(false);
} catch (error) {
console.error('Failed to save client template:', error);
} finally {
setLoading(false);
}
};
return (
<div className='space-y-4'>
<div className='grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4'>
{clients.map((client) => (
<Card
key={client.name}
className='hover:bg-muted/50 cursor-pointer transition-colors'
onClick={() => handleClientClick(client)}
>
<CardContent className='p-4'>
<div className='space-y-3'>
<div className='flex items-center gap-2'>
<div className='relative h-6 w-6 flex-shrink-0'>
<Image
src={`/images/protocols/${client.name}.webp`}
alt={client.name}
width={24}
height={24}
className='object-contain'
onError={() => {
console.log(`Failed to load image for ${client.name}`);
}}
/>
</div>
<h3 className='text-base font-semibold'>{client.name}</h3>
<Icon icon='mdi:chevron-right' className='ml-auto flex-shrink-0' />
</div>
<div className='flex flex-wrap gap-1'>
{client.platforms.map((platform) => (
<Badge key={platform} variant='secondary' className='text-xs'>
{platform}
</Badge>
))}
</div>
<p className='text-muted-foreground text-sm leading-relaxed'>
{t(`protocol.clients.${client.name.toLowerCase().replace(/\s+/g, '')}.features`)}
</p>
</div>
</CardContent>
</Card>
))}
</div>
<Sheet open={open} onOpenChange={setOpen}>
<SheetContent className='w-[600px] max-w-full md:max-w-screen-md'>
<SheetHeader>
<SheetTitle>
{t('protocol.subscribeTemplate')} - {selectedClient?.name}
</SheetTitle>
</SheetHeader>
<ScrollArea className='-mx-6 h-[calc(100dvh-48px-36px-36px-env(safe-area-inset-top))] px-6'>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className='space-y-4 pt-4'
id='client-template-form'
>
<FormField
control={form.control}
name='template'
render={({ field }) => (
<FormItem>
<FormLabel>{t('protocol.templateContent')}</FormLabel>
<FormControl>
<Textarea
placeholder={t('protocol.templatePlaceholder')}
value={field.value}
onChange={field.onChange}
rows={20}
className='font-mono text-sm'
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</ScrollArea>
<SheetFooter className='flex-row justify-end gap-2 pt-3'>
<Button variant='outline' disabled={loading} onClick={() => setOpen(false)}>
{t('actions.cancel')}
</Button>
<Button disabled={loading} type='submit' form='client-template-form'>
{loading && <Icon icon='mdi:loading' className='mr-2 animate-spin' />}
{t('actions.saveTemplate')}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
</div>
);
}
@@ -1,106 +0,0 @@
'use client';
import { useQuery } from '@tanstack/react-query';
import { useTranslations } from 'next-intl';
import { toast } from 'sonner';
import { getSubscribeConfig, updateSubscribeConfig } from '@/services/admin/system';
import { Label } from '@workspace/ui/components/label';
import { Switch } from '@workspace/ui/components/switch';
import { Table, TableBody, TableCell, TableRow } from '@workspace/ui/components/table';
import { Textarea } from '@workspace/ui/components/textarea';
import { EnhancedInput } from '@workspace/ui/custom-components/enhanced-input';
export default function SubscribeConfig() {
const t = useTranslations('subscribe.config');
const { data, refetch } = useQuery({
queryKey: ['getSubscribeConfig'],
queryFn: async () => {
const { data } = await getSubscribeConfig();
return data.data;
},
});
async function updateConfig(key: string, value: unknown) {
if (data?.[key] === value) return;
try {
await updateSubscribeConfig({
...data,
[key]: value,
} as API.SubscribeConfig);
toast.success(t('updateSuccess'));
refetch();
} catch (error) {
/* empty */
}
}
return (
<Table>
<TableBody>
<TableRow>
<TableCell>
<Label>{t('singleSubscriptionMode')}</Label>
<p className='text-muted-foreground text-xs'>
{t('singleSubscriptionModeDescription')}
</p>
</TableCell>
<TableCell className='text-right'>
<Switch
checked={data?.single_model}
onCheckedChange={(checked) => {
updateConfig('single_model', checked);
}}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Label>{t('wildcardResolution')}</Label>
<p className='text-muted-foreground text-xs'>{t('wildcardResolutionDescription')}</p>
</TableCell>
<TableCell className='text-right'>
<Switch
checked={data?.pan_domain}
onCheckedChange={(checked) => {
updateConfig('pan_domain', checked);
}}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Label>{t('subscriptionPath')}</Label>
<p className='text-muted-foreground text-xs'>{t('subscriptionPathDescription')}</p>
</TableCell>
<TableCell className='flex items-center gap-2 text-right'>
<EnhancedInput
placeholder={t('subscriptionPathPlaceholder')}
value={data?.subscribe_path}
onValueBlur={(value) => updateConfig('subscribe_path', value)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell className='align-top'>
<Label>{t('subscriptionDomain')}</Label>
<p className='text-muted-foreground text-xs'>{t('subscriptionDomainDescription')}</p>
</TableCell>
<TableCell className='text-right'>
<Textarea
className='h-52'
placeholder={`${t('subscriptionDomainPlaceholder')}\nexample.com\nwww.example.com`}
defaultValue={data?.subscribe_domain}
onBlur={(e) => {
updateConfig('subscribe_domain', e.target.value);
}}
/>
</TableCell>
</TableRow>
</TableBody>
</Table>
);
}