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

210 lines
6.7 KiB
TypeScript

'use client';
import { useMemo, useState } from 'react';
import Link from 'next/link';
import { ExternalLink, Plus } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Input } from '@/components/ui/input';
import { cn } from '@/lib/utils';
import type { ZoneDomainItem, ZoneItem } from '@/lib/services/openflare';
import { useTranslations } from 'next-intl';
import { QuickCreateZoneDomainDialog } from '../../websites/components/quick-create-zone-domain-dialog';
export interface ZoneDomainSelectorProps {
value: number[];
onChange: (ids: number[]) => void;
domains: ZoneDomainItem[];
zones?: ZoneItem[];
/** Current route ID: domains bound to this route remain selectable. */
currentRouteId?: number | null;
disabled?: boolean;
className?: string;
/** Called after a domain is created so parent can refresh the catalog. */
onDomainCreated?: (domain: ZoneDomainItem) => void | Promise<void>;
}
export function ZoneDomainSelector({
value,
onChange,
domains,
zones = [],
currentRouteId = null,
disabled = false,
className,
onDomainCreated,
}: ZoneDomainSelectorProps) {
const t = useTranslations('proxyRoutes');
const [keyword, setKeyword] = useState('');
const [createOpen, setCreateOpen] = useState(false);
const selected = useMemo(() => new Set(value), [value]);
const zoneMap = useMemo(
() => new Map(zones.map((zone) => [zone.id, zone.domain])),
[zones],
);
/** Hide domains already bound to another route; keep unbound + current route. */
const availableDomains = useMemo(
() =>
domains.filter(
(domain) =>
domain.proxy_route_id == null ||
domain.proxy_route_id === currentRouteId,
),
[currentRouteId, domains],
);
const filtered = useMemo(() => {
const normalized = keyword.trim().toLowerCase();
const list = [...availableDomains].sort((a, b) =>
a.domain.localeCompare(b.domain),
);
if (!normalized) {
return list;
}
return list.filter((domain) => {
const zoneRoot = zoneMap.get(domain.zone_id) ?? '';
return (
domain.domain.toLowerCase().includes(normalized) ||
zoneRoot.toLowerCase().includes(normalized) ||
String(domain.id).includes(normalized)
);
});
}, [availableDomains, keyword, zoneMap]);
const toggle = (domain: ZoneDomainItem) => {
if (disabled) {
return;
}
if (
domain.proxy_route_id != null &&
domain.proxy_route_id !== currentRouteId
) {
return;
}
if (selected.has(domain.id)) {
onChange(value.filter((id) => id !== domain.id));
return;
}
onChange([...value, domain.id].sort((a, b) => a - b));
};
return (
<div className={cn('space-y-3', className)}>
<div className='flex flex-col gap-2 sm:flex-row sm:items-center'>
<Input
value={keyword}
onChange={(event) => setKeyword(event.target.value)}
placeholder={t('searchDomainOrZone')}
disabled={disabled}
className='sm:flex-1'
/>
<Button
type='button'
variant='secondary'
size='sm'
className='h-8 shrink-0 text-xs'
disabled={disabled}
onClick={() => setCreateOpen(true)}
>
<Plus className='mr-1 size-3.5' />
{t('quickAddDomain')}
</Button>
</div>
{filtered.length === 0 ? (
<div className='rounded-lg border border-dashed px-4 py-8 text-center text-sm text-muted-foreground'>
{domains.length === 0 ? (
t.rich('noZoneDomains', {
websites: (chunks) => (
<Link
href='/websites'
className='text-primary underline-offset-4 hover:underline'
>
{chunks}
</Link>
),
})
) : availableDomains.length === 0 ? (
t('noBindableDomains')
) : (
t('noMatchingDomains')
)}
</div>
) : (
<div className='max-h-72 space-y-1 overflow-y-auto rounded-lg border p-2'>
{filtered.map((domain) => {
const checked = selected.has(domain.id);
const zoneRoot = zoneMap.get(domain.zone_id);
return (
<label
key={domain.id}
className={cn(
'flex cursor-pointer items-start gap-3 rounded-md px-2 py-2 transition-colors hover:bg-muted/60',
checked && 'bg-muted/40',
)}
>
<Checkbox
checked={checked}
disabled={disabled}
onCheckedChange={() => toggle(domain)}
className='mt-0.5'
/>
<span className='min-w-0 flex-1 space-y-1'>
<span className='flex flex-wrap items-center gap-2'>
<span className='truncate text-sm font-medium'>
{domain.domain}
</span>
{domain.cert_id ? (
<Badge variant='secondary' className='text-[10px]'>
{t('certNumber', { id: domain.cert_id })}
</Badge>
) : (
<Badge variant='outline' className='text-[10px]'>
{t('noCert')}
</Badge>
)}
</span>
<span className='flex items-center gap-1 text-xs text-muted-foreground'>
{zoneRoot ? (
<Link
href={`/websites/${domain.zone_id}`}
className='inline-flex items-center gap-0.5 hover:text-foreground'
onClick={(event) => event.stopPropagation()}
>
{t('zoneName', { name: zoneRoot })}
<ExternalLink className='size-3' />
</Link>
) : (
<span>{t('zoneId', { id: domain.zone_id })}</span>
)}
</span>
</span>
</label>
);
})}
</div>
)}
<QuickCreateZoneDomainDialog
open={createOpen}
onOpenChange={setCreateOpen}
zones={zones}
onCreated={async (domain) => {
// Auto-select the new domain for this route binding.
if (!value.includes(domain.id)) {
onChange([...value, domain.id].sort((a, b) => a - b));
}
await onDomainCreated?.(domain);
}}
/>
</div>
);
}