feat(subscribe): Update subscription management localization and add new fields

This commit is contained in:
web
2025-08-08 11:22:31 -07:00
committed by speakeloudest
parent ec3133e0c8
commit 63842e5d99
32 changed files with 2297 additions and 1707 deletions
@@ -38,8 +38,8 @@ const subscribeConfigSchema = z.object({
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(),
user_agent_limit: z.boolean().optional(),
user_agent_list: z.string().optional(),
});
type SubscribeConfigFormData = z.infer<typeof subscribeConfigSchema>;
@@ -65,8 +65,8 @@ export default function ConfigForm() {
pan_domain: false,
subscribe_path: '',
subscribe_domain: '',
restrict_user_agent: false,
user_agent_whitelist: '',
user_agent_limit: false,
user_agent_list: '',
},
});
@@ -197,10 +197,10 @@ export default function ConfigForm() {
<FormField
control={form.control}
name='restrict_user_agent'
name='user_agent_limit'
render={({ field }) => (
<FormItem>
<FormLabel>{t('config.restrictUserAgent')}</FormLabel>
<FormLabel>{t('config.userAgentLimit', { userAgent: 'User-Agent' })}</FormLabel>
<FormControl>
<Switch
checked={field.value}
@@ -208,7 +208,9 @@ export default function ConfigForm() {
className='float-end !mt-0'
/>
</FormControl>
<FormDescription>{t('config.restrictUserAgentDescription')}</FormDescription>
<FormDescription>
{t('config.userAgentLimitDescription', { userAgent: 'User-Agent' })}
</FormDescription>
<FormMessage />
</FormItem>
)}
@@ -216,18 +218,24 @@ export default function ConfigForm() {
<FormField
control={form.control}
name='user_agent_whitelist'
name='user_agent_list'
render={({ field }) => (
<FormItem>
<FormLabel>{t('config.userAgentWhitelist')}</FormLabel>
<FormLabel>
{t('config.userAgentList', {
userAgent: 'User-Agent',
})}
</FormLabel>
<FormControl>
<Textarea
className='h-32'
placeholder={`${t('config.userAgentWhitelistPlaceholder')}\nClashX\nClashForAndroid\nClash-verge`}
placeholder={`${t('config.userAgentListPlaceholder', { userAgent: 'User-Agent' })}\nClashX\nClashForAndroid\nClash-verge`}
{...field}
/>
</FormControl>
<FormDescription>{t('config.userAgentWhitelistDescription')}</FormDescription>
<FormDescription>
{t('config.userAgentListDescription', { userAgent: 'User-Agent' })}
</FormDescription>
<FormMessage />
</FormItem>
)}
@@ -17,7 +17,6 @@ export default function SubscribePage() {
</CardContent>
</Card>
<h2 className='text-lg font-semibold'>{t('protocol.title')}</h2>
<ProtocolForm />
</div>
);
@@ -1,18 +1,34 @@
'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 { Card, CardContent } from '@workspace/ui/components/card';
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,
@@ -20,221 +36,541 @@ import {
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 { 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 { useState } from 'react';
import { useRef, useState } from 'react';
import { useForm } from 'react-hook-form';
import { toast } from 'sonner';
import { z } from 'zod';
interface Client {
name: string;
platforms: string[];
}
// 表单验证规则 - 基于 API.CreateSubscribeApplicationRequest
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')}`),
proxy_template: 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(),
}),
});
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>;
type ClientFormData = z.infer<ReturnType<typeof createClientFormSchema>>;
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 [open, setOpen] = useState(false);
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: '',
proxy_template: '',
template: '',
output_format: '',
download_link: {
windows: '',
mac: '',
linux: '',
ios: '',
android: '',
harmony: '',
},
},
});
const clients: Client[] = clientsConfig;
// API请求函数
const request = async (
pagination: { page: number; size: number },
filter: Record<string, unknown>,
) => {
const { data } = await getSubscribeApplicationList({
page: pagination.page,
size: pagination.size,
});
const handleClientClick = (client: Client) => {
setSelectedClient(client);
// 请求当前客户端的配置模板
// 这里可以替换为实际的API调用
// 模拟获取模板数据
const mockTemplate = `# ${client.name} 配置模板`;
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,
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: '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>
),
},
{
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: '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>
),
},
];
const handleAdd = () => {
setEditingClient(null);
form.reset({
template: mockTemplate,
name: '',
description: '',
icon: '',
user_agent: '',
proxy_template: '',
template: '',
output_format: '',
download_link: {
windows: '',
mac: '',
linux: '',
ios: '',
android: '',
harmony: '',
},
});
setOpen(true);
};
const handleEdit = (client: API.SubscribeApplication) => {
setEditingClient(client);
form.reset({
name: client.name,
description: client.description || '',
icon: client.icon || '',
user_agent: client.user_agent,
proxy_template: client.proxy_template || '',
template: client.template || '',
output_format: client.output_format || '',
download_link: {
windows: '',
mac: '',
linux: '',
ios: '',
android: '',
harmony: '',
},
});
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 {
// TODO: 实现保存逻辑
console.log('Save client template:', {
name: selectedClient?.name,
template: data.template,
});
// 模拟API调用
await new Promise((resolve) => setTimeout(resolve, 1000));
if (editingClient) {
await updateSubscribeApplication({
...data,
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 template:', error);
console.error('Failed to save client:', error);
toast.error(t('actions.saveFailed'));
} 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>
<>
<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) => [
<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-[600px] max-w-full md:max-w-screen-md'>
<SheetContent className='w-[580px] max-w-full md:max-w-screen-md'>
<SheetHeader>
<SheetTitle>
{t('protocol.subscribeTemplate')} - {selectedClient?.name}
</SheetTitle>
<SheetTitle>{editingClient ? t('form.editTitle') : t('form.addTitle')}</SheetTitle>
</SheetHeader>
<ScrollArea className='-mx-6 h-[calc(100dvh-48px-36px-36px-env(safe-area-inset-top))] px-6'>
<ScrollArea className='h-[calc(100dvh-48px-36px-36px)]'>
<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 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='yaml'>{t('outputFormats.yaml')}</SelectItem>
<SelectItem value='json'>{t('outputFormats.json')}</SelectItem>
<SelectItem value='base64'>{t('outputFormats.base64')}</SelectItem>
<SelectItem value='plain'>{t('outputFormats.plain')}</SelectItem>
</SelectContent>
</Select>
</FormControl>
<FormDescription>{t('form.descriptions.outputFormat')}</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='proxy_template'
render={({ field }) => (
<FormItem>
<FormLabel>{t('form.fields.proxyTemplate')}</FormLabel>
<FormControl>
<Input
placeholder='clash://install-config?url={url}&name={name}'
{...field}
/>
</FormControl>
<FormDescription>
{t('form.descriptions.proxyTemplate')}
{`{url} {name}`}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='template'
render={({ field }) => (
<FormItem>
<FormLabel>{t('form.fields.template')}</FormLabel>
<FormControl>
<Textarea
placeholder='proxies:\n - name: {name}\n type: {type}\n server: {server}\n port: {port}'
className='min-h-[120px] font-mono text-sm'
{...field}
/>
</FormControl>
<FormDescription>{t('form.descriptions.template')}</FormDescription>
<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' disabled={loading} onClick={() => setOpen(false)}>
<Button variant='outline' onClick={() => setOpen(false)}>
{t('actions.cancel')}
</Button>
<Button disabled={loading} type='submit' form='client-template-form'>
<Button onClick={form.handleSubmit(onSubmit)} disabled={loading}>
{loading && <Icon icon='mdi:loading' className='mr-2 animate-spin' />}
{t('actions.saveTemplate')}
{editingClient ? t('actions.update') : t('actions.add')}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
</div>
</>
);
}