Files
OpenFlare/frontend/app/(main)/admin/settings/components/uptimekuma-site-modal.tsx
T
ryan 5a8722ff07 feat(frontend): 控制台 next-intl 中英双语
接入无 URL 前缀的 zh-CN/en,顶栏与外观设置可切换语言;选择写入 cookie 后刷新生效。
2026-08-16 16:49:16 +08:00

216 lines
6.7 KiB
TypeScript

'use client';
import { useTranslations } from 'next-intl';
import { useEffect, useMemo, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { ProxyRouteService } from '@/lib/services/openflare';
type UptimeKumaSiteSelectModalProps = {
open: boolean;
selectedSites: string[];
onOpenChange: (open: boolean) => void;
onSave: (sites: string[]) => void;
};
export function UptimeKumaSiteSelectModal({
open,
selectedSites,
onOpenChange,
onSave,
}: UptimeKumaSiteSelectModalProps) {
const t = useTranslations('openflareOps.siteModal');
const tCommon = useTranslations('common');
const [searchTerm, setSearchTerm] = useState('');
const [tempSelected, setTempSelected] = useState<Set<string>>(new Set());
const routesQuery = useQuery({
queryKey: ['openflare', 'proxy-routes'],
queryFn: () => ProxyRouteService.list(),
enabled: open,
});
useEffect(() => {
if (!open) return;
setTempSelected(
new Set(selectedSites.map((site) => site.trim()).filter(Boolean)),
);
setSearchTerm('');
}, [open, selectedSites]);
const filteredRoutes = useMemo(() => {
const routes = routesQuery.data ?? [];
const keyword = searchTerm.trim().toLowerCase();
if (!keyword) return routes;
return routes.filter((route) => {
const domains = (route.zone_domains ?? [])
.map((item) => item.domain)
.join(' ');
return (
route.site_name.toLowerCase().includes(keyword) ||
domains.toLowerCase().includes(keyword)
);
});
}, [routesQuery.data, searchTerm]);
const toggleSite = (siteName: string) => {
setTempSelected((previous) => {
const next = new Set(previous);
if (next.has(siteName)) next.delete(siteName);
else next.add(siteName);
return next;
});
};
const handleSelectAll = () => {
setTempSelected((previous) => {
const next = new Set(previous);
filteredRoutes.forEach((route) => next.add(route.site_name));
return next;
});
};
const handleDeselectAll = () => {
setTempSelected((previous) => {
const next = new Set(previous);
filteredRoutes.forEach((route) => next.delete(route.site_name));
return next;
});
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className='max-w-2xl'>
<DialogHeader>
<DialogTitle>{t('title')}</DialogTitle>
<DialogDescription>{t('description')}</DialogDescription>
</DialogHeader>
<div className='space-y-4'>
<div className='space-y-1.5'>
<Label>{t('search')}</Label>
<Input
value={searchTerm}
onChange={(event) => setSearchTerm(event.target.value)}
placeholder={t('searchPlaceholder')}
/>
</div>
<div className='flex flex-wrap gap-2'>
<Button
type='button'
variant='outline'
size='sm'
onClick={handleSelectAll}
>
{t('selectFiltered')}
</Button>
<Button
type='button'
variant='outline'
size='sm'
onClick={handleDeselectAll}
>
{t('clearFiltered')}
</Button>
</div>
<div className='max-h-72 overflow-y-auto rounded-lg border'>
{routesQuery.isLoading ? (
<p className='p-4 text-sm text-muted-foreground'>
{t('loading')}
</p>
) : routesQuery.isError ? (
<p className='p-4 text-sm text-destructive'>
{routesQuery.error instanceof Error
? routesQuery.error.message
: t('loadFailed')}
</p>
) : filteredRoutes.length === 0 ? (
<p className='p-4 text-sm text-muted-foreground'>
{t('noMatch')}
</p>
) : (
<table className='w-full text-left text-sm'>
<thead className='sticky top-0 bg-muted/60 text-xs uppercase text-muted-foreground'>
<tr>
<th className='w-12 px-3 py-2'>{t('select')}</th>
<th className='px-3 py-2'>{t('siteName')}</th>
<th className='px-3 py-2'>{t('primaryDomain')}</th>
</tr>
</thead>
<tbody className='divide-y'>
{filteredRoutes.map((route) => {
const checked = tempSelected.has(route.site_name);
return (
<tr
key={route.id}
className='cursor-pointer hover:bg-muted/30'
onClick={() => toggleSite(route.site_name)}
>
<td
className='px-3 py-2'
onClick={(event) => event.stopPropagation()}
>
<input
type='checkbox'
checked={checked}
onChange={() => toggleSite(route.site_name)}
className='size-4 rounded border-input'
/>
</td>
<td className='px-3 py-2 font-medium'>
{route.site_name}
</td>
<td className='px-3 py-2 text-muted-foreground'>
{(route.zone_domains ?? [])
.map((item) => item.domain)
.join(', ') || '—'}
</td>
</tr>
);
})}
</tbody>
</table>
)}
</div>
<p className='text-right text-xs text-muted-foreground'>
{t('selectedCount', { count: tempSelected.size })}
</p>
</div>
<DialogFooter>
<Button
type='button'
variant='outline'
onClick={() => onOpenChange(false)}
>
{tCommon('cancel')}
</Button>
<Button
type='button'
onClick={() => {
onSave(Array.from(tempSelected));
onOpenChange(false);
}}
>
{t('save')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}