feat(frontend): 控制台 next-intl 中英双语

接入无 URL 前缀的 zh-CN/en,顶栏与外观设置可切换语言;选择写入 cookie 后刷新生效。
This commit is contained in:
ryan
2026-08-16 16:49:16 +08:00
parent 64fbaa7ef1
commit 5a8722ff07
219 changed files with 21437 additions and 5212 deletions
@@ -7,42 +7,54 @@ import type {
} from '@/lib/services/openflare';
import { ZoneService } from '@/lib/services/openflare';
export const proxyRouteConfigSections = [
{
key: 'domains' as const,
label: '域名设置',
description: '维护站点标识,并从 Zone 中选择绑定域名。',
},
{
key: 'limits' as const,
label: '流量限制',
description: '设置连接数和限速(可继承全局默认)。',
},
{
key: 'proxy' as const,
label: '反向代理',
description: '配置主回源和上游地址。',
},
{
key: 'cache' as const,
label: '缓存',
description: '配置站点缓存策略。',
},
{
key: 'waf' as const,
label: 'WAF',
description: '绑定 WAF 规则组,并查看当前站点生效策略。',
},
{
key: 'auth' as const,
label: '认证配置',
description: '配置基础鉴权访问,需要输入账号密码才能访问网站。',
},
] satisfies ReadonlyArray<{
key: ProxyRouteConfigSection;
label: string;
description: string;
}>;
export type TranslateFn = (
key: string,
values?: Record<string, string | number>,
) => string;
const proxyRouteConfigSectionKeys = [
'domains',
'limits',
'proxy',
'cache',
'waf',
'auth',
] as const satisfies ReadonlyArray<ProxyRouteConfigSection>;
export function getProxyRouteConfigSections(t: TranslateFn) {
return [
{
key: 'domains' as const,
label: t('sections.domains'),
description: t('sections.domainsDesc'),
},
{
key: 'limits' as const,
label: t('sections.limits'),
description: t('sections.limitsDesc'),
},
{
key: 'proxy' as const,
label: t('sections.proxy'),
description: t('sections.proxyDesc'),
},
{
key: 'cache' as const,
label: t('sections.cache'),
description: t('sections.cacheDesc'),
},
{
key: 'waf' as const,
label: t('sections.waf'),
description: t('sections.wafDesc'),
},
{
key: 'auth' as const,
label: t('sections.auth'),
description: t('sections.authDesc'),
},
];
}
const domainPattern =
/^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/i;
@@ -50,18 +62,21 @@ const domainPattern =
export function getProxyRouteConfigSection(
value: string | null | undefined,
): ProxyRouteConfigSection {
return proxyRouteConfigSections.some((section) => section.key === value)
return proxyRouteConfigSectionKeys.some((key) => key === value)
? (value as ProxyRouteConfigSection)
: 'domains';
}
export function validateDomain(domain: string): string | null {
export function validateDomain(
domain: string,
t: TranslateFn,
): string | null {
const normalized = domain.trim().toLowerCase();
if (!normalized) {
return '请输入域名';
return t('validation.enterDomain');
}
if (!domainPattern.test(normalized)) {
return '域名格式不合法';
return t('validation.invalidDomain');
}
return null;
}
@@ -88,23 +103,29 @@ export function getRoutePrimaryDomain(route: ProxyRouteItem): string {
return getRouteDomainNames(route)[0] ?? '';
}
export function getRouteDomainsLabel(route: ProxyRouteItem): string {
export function getRouteDomainsLabel(
route: ProxyRouteItem,
t: TranslateFn,
): string {
const names = getRouteDomainNames(route);
return names.length > 0 ? names.join(', ') : '未绑定域名';
return names.length > 0 ? names.join(', ') : t('unboundDomain');
}
/** Upstream address labels for display (supports multi-upstream). */
export function getUpstreamLabels(route: ProxyRouteItem): string[] {
export function getUpstreamLabels(
route: ProxyRouteItem,
t: TranslateFn,
): string[] {
if (route.upstream_type === 'pages') {
return [
route.pages_project_id
? `Pages 项目 #${route.pages_project_id}`
: 'Pages 项目未绑定',
? t('pagesProjectId', { id: route.pages_project_id })
: t('pagesUnbound'),
];
}
if (route.upstream_type === 'tunnel') {
const protocol = route.tunnel_target_protocol || 'http';
const target = route.tunnel_target_addr || '未配置目标';
const target = route.tunnel_target_addr || t('tunnelTargetUnset');
return [`Tunnel → ${protocol}://${target}`];
}
const list = (route.upstream_list ?? []).filter(Boolean);
@@ -114,10 +135,13 @@ export function getUpstreamLabels(route: ProxyRouteItem): string[] {
return route.origin_url ? [route.origin_url] : [];
}
export function getUpstreamSummary(route: ProxyRouteItem): string {
const labels = getUpstreamLabels(route);
export function getUpstreamSummary(
route: ProxyRouteItem,
t: TranslateFn,
): string {
const labels = getUpstreamLabels(route, t);
if (labels.length === 0) {
return '未配置上游';
return t('upstreamUnset');
}
if (labels.length === 1) {
return labels[0];
@@ -130,8 +154,10 @@ const originHostPattern =
const headerKeyPattern = /^[A-Za-z0-9_-]+$/;
const limitRatePattern = /^\d+(?:[kKmM])?$/;
export function getErrorMessage(error: unknown) {
return error instanceof Error ? error.message : '请求失败,请稍后重试。';
export function getErrorMessage(error: unknown, fallback?: string) {
return error instanceof Error
? error.message
: (fallback ?? '请求失败,请稍后重试。');
}
export function linesFromTextarea(value: string) {
@@ -141,23 +167,23 @@ export function linesFromTextarea(value: string) {
.filter(Boolean);
}
export function validateDomains(domains: string[]) {
export function validateDomains(domains: string[], t: TranslateFn) {
if (domains.length === 0) {
return '请至少填写一个域名';
return t('validation.enterAtLeastOneDomain');
}
const seen = new Set<string>();
for (const domain of domains) {
const normalized = domain.trim().toLowerCase();
const error = validateDomain(normalized);
const error = validateDomain(normalized, t);
if (error && normalized) {
return `域名格式不合法:${domain}`;
return t('validation.invalidDomainWithValue', { domain });
}
if (!normalized) {
continue;
}
if (seen.has(normalized)) {
return `域名重复:${domain}`;
return t('validation.duplicateDomain', { domain });
}
seen.add(normalized);
}
@@ -165,10 +191,10 @@ export function validateDomains(domains: string[]) {
return null;
}
export function parseOriginUrls(value: string) {
export function parseOriginUrls(value: string, t: TranslateFn) {
const urls = linesFromTextarea(value);
if (urls.length === 0) {
return { urls: [], error: '请至少填写一个上游地址' };
return { urls: [], error: t('validation.enterAtLeastOneUpstream') };
}
let sharedScheme = '';
@@ -177,25 +203,31 @@ export function parseOriginUrls(value: string) {
try {
parsed = new URL(originUrl);
} catch {
return { urls: [], error: `上游地址格式不合法:${originUrl}` };
return {
urls: [],
error: t('validation.invalidUpstream', { url: originUrl }),
};
}
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
return {
urls: [],
error: `上游地址必须以 http:// 或 https:// 开头:${originUrl}`,
error: t('validation.upstreamMustBeHttp', { url: originUrl }),
};
}
if (!parsed.hostname) {
return { urls: [], error: `上游地址缺少主机名:${originUrl}` };
return {
urls: [],
error: t('validation.upstreamMissingHost', { url: originUrl }),
};
}
if (urls.length > 1) {
if ((parsed.pathname && parsed.pathname !== '/') || parsed.search) {
return {
urls: [],
error: '多上游模式暂不支持带路径或查询参数的地址',
error: t('validation.multiUpstreamNoPath'),
};
}
@@ -204,7 +236,7 @@ export function parseOriginUrls(value: string) {
} else if (sharedScheme !== parsed.protocol) {
return {
urls: [],
error: '同一站点的多个上游必须使用相同协议',
error: t('validation.multiUpstreamSameProtocol'),
};
}
}
@@ -213,7 +245,7 @@ export function parseOriginUrls(value: string) {
return { urls, error: null };
}
export function validateOriginHost(value: string) {
export function validateOriginHost(value: string, t: TranslateFn) {
const normalized = value.trim();
if (!normalized) {
return null;
@@ -223,12 +255,12 @@ export function validateOriginHost(value: string) {
/[/\\\s]/.test(normalized) ||
!originHostPattern.test(normalized)
) {
return '回源 Host 格式不合法';
return t('validation.invalidOriginHost');
}
return null;
}
export function parseCustomHeadersText(value: string) {
export function parseCustomHeadersText(value: string, t: TranslateFn) {
const lines = linesFromTextarea(value);
const headers: ProxyRouteCustomHeader[] = [];
@@ -237,7 +269,7 @@ export function parseCustomHeadersText(value: string) {
if (separatorIndex <= 0) {
return {
headers: [],
error: `自定义请求头格式不合法:${line}`,
error: t('validation.invalidHeader', { line }),
};
}
@@ -247,7 +279,7 @@ export function parseCustomHeadersText(value: string) {
if (!headerKeyPattern.test(key)) {
return {
headers: [],
error: `自定义请求头名称不合法:${key}`,
error: t('validation.invalidHeaderName', { key }),
};
}
@@ -261,13 +293,13 @@ export function customHeadersToText(headers: ProxyRouteCustomHeader[]) {
return headers.map((header) => `${header.key}: ${header.value}`).join('\n');
}
export function validateLimitRate(value: string) {
export function validateLimitRate(value: string, t: TranslateFn) {
const normalized = value.trim();
if (!normalized || normalized === '0' || normalized === '-1') {
return null;
}
if (!limitRatePattern.test(normalized)) {
return '限速格式不合法,请使用 512k、1m、纯数字,或 -1 关闭';
return t('validation.invalidLimitRate');
}
return null;
}
@@ -280,13 +312,13 @@ export function normalizeLimitRate(value: string) {
const limitReqPattern = /^\d+r\/[sm]$/i;
export function validateLimitReqPerIP(value: string) {
export function validateLimitReqPerIP(value: string, t: TranslateFn) {
const normalized = value.trim();
if (!normalized || normalized === '0' || normalized === '-1') {
return null;
}
if (!limitReqPattern.test(normalized)) {
return '请求频率格式不合法,请使用 10r/s、100r/m,或 -1 关闭';
return t('validation.invalidLimitReq');
}
return null;
}
@@ -300,20 +332,21 @@ export function normalizeLimitReqPerIP(value: string) {
export function validateCacheRules(
policy: 'static' | 'all' | 'url' | 'suffix' | 'path_prefix' | 'path_exact',
rules: string[],
t: TranslateFn,
) {
if (policy === 'static' || policy === 'all' || policy === 'url') {
return null;
}
if (rules.length === 0) {
return '当前缓存策略至少需要一条规则';
return t('validation.cacheRuleRequired');
}
if (policy === 'suffix') {
for (const rule of rules) {
const normalized = rule.replace(/^\./, '');
if (!normalized || /[/\\\s]/.test(normalized)) {
return `缓存后缀格式不合法:${rule}`;
return t('validation.invalidCacheSuffix', { rule });
}
}
return null;
@@ -321,7 +354,7 @@ export function validateCacheRules(
for (const rule of rules) {
if (!rule.startsWith('/') || rule.includes('://') || /[\s]/.test(rule)) {
return `缓存路径规则格式不合法:${rule}`;
return t('validation.invalidCachePath', { rule });
}
}
@@ -44,70 +44,22 @@ import {
zoneQueryKey,
} from '@/lib/services/openflare';
import { useTranslations } from 'next-intl';
import { listAllZoneDomains, parseOriginUrl, parseOriginUrls } from './helpers';
import { ZoneDomainSelector } from './zone-domain-selector';
const createProxyRouteSchema = z
.object({
site_name: z.string().trim().max(255, '站点标识不能超过 255 个字符'),
zone_domain_ids: z
.array(z.number().int().positive())
.min(1, '请至少选择一个域名'),
upstream_type: z.enum(['direct', 'tunnel', 'pages']),
origin_urls_text: z.string().trim(),
tunnel_id: z.string().optional(),
tunnel_target_addr: z.string().trim().optional(),
tunnel_target_protocol: z.enum(['http', 'https']).optional(),
pages_project_id: z.string().optional(),
enabled: z.boolean(),
})
.superRefine((value, context) => {
if (value.upstream_type === 'direct') {
if (!value.origin_urls_text.trim()) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ['origin_urls_text'],
message: '请至少填写一个上游地址',
});
} else {
const { error } = parseOriginUrls(value.origin_urls_text);
if (error) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ['origin_urls_text'],
message: error,
});
}
}
return;
}
if (value.upstream_type === 'tunnel') {
if (!value.tunnel_id) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ['tunnel_id'],
message: '请选择内网穿透隧道',
});
}
if (!value.tunnel_target_addr?.trim()) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ['tunnel_target_addr'],
message: '请填写内网服务地址 (如 127.0.0.1:8080)',
});
}
return;
}
if (!value.pages_project_id) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ['pages_project_id'],
message: '请选择 Pages 项目',
});
}
});
type CreateProxyRouteFormValues = z.infer<typeof createProxyRouteSchema>;
type CreateProxyRouteFormValues = {
site_name: string;
zone_domain_ids: number[];
upstream_type: 'direct' | 'tunnel' | 'pages';
origin_urls_text: string;
tunnel_id?: string;
tunnel_target_addr?: string;
tunnel_target_protocol?: 'http' | 'https';
pages_project_id?: string;
enabled: boolean;
};
const defaultValues: CreateProxyRouteFormValues = {
site_name: '',
@@ -132,6 +84,67 @@ export function ProxyRouteCreateSheet({
onOpenChange,
onCreated,
}: ProxyRouteCreateSheetProps) {
const t = useTranslations('proxyRoutes');
const tc = useTranslations('common');
const createProxyRouteSchema = z
.object({
site_name: z.string().trim().max(255, t('validation.siteNameTooLong')),
zone_domain_ids: z
.array(z.number().int().positive())
.min(1, t('validation.selectAtLeastOneDomain')),
upstream_type: z.enum(['direct', 'tunnel', 'pages']),
origin_urls_text: z.string().trim(),
tunnel_id: z.string().optional(),
tunnel_target_addr: z.string().trim().optional(),
tunnel_target_protocol: z.enum(['http', 'https']).optional(),
pages_project_id: z.string().optional(),
enabled: z.boolean(),
})
.superRefine((value, context) => {
if (value.upstream_type === 'direct') {
if (!value.origin_urls_text.trim()) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ['origin_urls_text'],
message: t('validation.enterAtLeastOneUpstream'),
});
} else {
const { error } = parseOriginUrls(value.origin_urls_text, t);
if (error) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ['origin_urls_text'],
message: error,
});
}
}
return;
}
if (value.upstream_type === 'tunnel') {
if (!value.tunnel_id) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ['tunnel_id'],
message: t('validation.selectTunnel'),
});
}
if (!value.tunnel_target_addr?.trim()) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ['tunnel_target_addr'],
message: t('validation.enterTunnelTarget'),
});
}
return;
}
if (!value.pages_project_id) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ['pages_project_id'],
message: t('validation.selectPagesProject'),
});
}
});
const form = useForm<CreateProxyRouteFormValues>({
resolver: zodResolver(createProxyRouteSchema),
defaultValues,
@@ -185,7 +198,7 @@ export function ProxyRouteCreateSheet({
let upstreams: string[] = [];
if (values.upstream_type === 'direct') {
const { urls } = parseOriginUrls(values.origin_urls_text);
const { urls } = parseOriginUrls(values.origin_urls_text, t);
const primaryOrigin = parseOriginUrl(urls[0]);
originUrl = urls[0];
originScheme = primaryOrigin.scheme;
@@ -257,7 +270,7 @@ export function ProxyRouteCreateSheet({
} catch (error) {
form.setError('root', {
message:
error instanceof Error ? error.message : '创建失败,请稍后重试',
error instanceof Error ? error.message : t('createFailed'),
});
}
});
@@ -266,11 +279,8 @@ export function ProxyRouteCreateSheet({
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent side='right' className='w-full sm:max-w-lg overflow-y-auto'>
<SheetHeader>
<SheetTitle>新建规则</SheetTitle>
<SheetDescription>
从已注册的 Zone
域名中选择绑定关系,创建后可继续配置缓存和限流等高级选项。
</SheetDescription>
<SheetTitle>{t('createRule')}</SheetTitle>
<SheetDescription>{t('createDesc')}</SheetDescription>
</SheetHeader>
<Form {...form}>
@@ -280,13 +290,11 @@ export function ProxyRouteCreateSheet({
name='site_name'
render={({ field }) => (
<FormItem>
<FormLabel>站点标识</FormLabel>
<FormLabel>{t('siteId')}</FormLabel>
<FormControl>
<Input placeholder='marketing-site' {...field} />
</FormControl>
<FormDescription>
可选,留空时会自动使用首个域名。
</FormDescription>
<FormDescription>{t('siteIdOptionalDesc')}</FormDescription>
<FormMessage />
</FormItem>
)}
@@ -297,7 +305,7 @@ export function ProxyRouteCreateSheet({
name='zone_domain_ids'
render={({ field }) => (
<FormItem>
<FormLabel>绑定域名</FormLabel>
<FormLabel>{t('bindDomains')}</FormLabel>
<FormControl>
<ZoneDomainSelector
value={field.value}
@@ -320,13 +328,13 @@ export function ProxyRouteCreateSheet({
name='upstream_type'
render={({ field }) => (
<FormItem className='space-y-3'>
<FormLabel>回源方式</FormLabel>
<FormLabel>{t('upstreamType')}</FormLabel>
<div className='flex flex-wrap gap-4'>
{(
[
['direct', '直连上游'],
['tunnel', '内网穿透 (Tunnel)'],
['pages', 'Pages 静态站点'],
['direct', t('upstreamDirect')],
['tunnel', t('upstreamTunnel')],
['pages', t('upstreamPages')],
] as const
).map(([value, label]) => (
<label
@@ -355,7 +363,7 @@ export function ProxyRouteCreateSheet({
name='origin_urls_text'
render={({ field }) => (
<FormItem>
<FormLabel>上游地址</FormLabel>
<FormLabel>{t('upstreamAddresses')}</FormLabel>
<FormControl>
<Textarea
className='min-h-32 font-mono text-xs'
@@ -365,11 +373,7 @@ export function ProxyRouteCreateSheet({
{...field}
/>
</FormControl>
<FormDescription>
每行一个完整
URL。第一行作为主回源,多上游模式请保持相同协议且不要包含
path 或 query。
</FormDescription>
<FormDescription>{t('upstreamUrlsHint')}</FormDescription>
<FormMessage />
</FormItem>
)}
@@ -383,7 +387,7 @@ export function ProxyRouteCreateSheet({
name='tunnel_id'
render={({ field }) => (
<FormItem>
<FormLabel>选择内网穿透隧道</FormLabel>
<FormLabel>{t('selectTunnel')}</FormLabel>
<Select
value={field.value || 'none'}
onValueChange={(value) =>
@@ -392,25 +396,26 @@ export function ProxyRouteCreateSheet({
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder='请选择...' />
<SelectValue placeholder={t('pleaseSelect')} />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value='none'>请选择...</SelectItem>
<SelectItem value='none'>{t('pleaseSelect')}</SelectItem>
{tunnelClients.map((tunnel) => (
<SelectItem
key={tunnel.id}
value={String(tunnel.id)}
>
{tunnel.name} (
{tunnel.status === 'online' ? '在线' : '离线'})
{tunnel.status === 'online'
? t('online')
: t('offline')}
)
</SelectItem>
))}
</SelectContent>
</Select>
<FormDescription>
将请求转发到该隧道连接的客户端节点。
</FormDescription>
<FormDescription>{t('tunnelForwardHint')}</FormDescription>
<FormMessage />
</FormItem>
)}
@@ -421,7 +426,7 @@ export function ProxyRouteCreateSheet({
name='tunnel_target_protocol'
render={({ field }) => (
<FormItem>
<FormLabel>内网服务协议</FormLabel>
<FormLabel>{t('tunnelProtocol')}</FormLabel>
<Select
value={field.value}
onValueChange={field.onChange}
@@ -446,13 +451,11 @@ export function ProxyRouteCreateSheet({
name='tunnel_target_addr'
render={({ field }) => (
<FormItem>
<FormLabel>内网服务地址</FormLabel>
<FormLabel>{t('tunnelAddress')}</FormLabel>
<FormControl>
<Input placeholder='127.0.0.1:8080' {...field} />
</FormControl>
<FormDescription>
例如: 127.0.0.1:8080 或 192.168.1.10:80
</FormDescription>
<FormDescription>{t('tunnelAddressHint')}</FormDescription>
<FormMessage />
</FormItem>
)}
@@ -467,7 +470,7 @@ export function ProxyRouteCreateSheet({
name='pages_project_id'
render={({ field }) => (
<FormItem>
<FormLabel>选择 Pages 项目</FormLabel>
<FormLabel>{t('selectPagesProject')}</FormLabel>
<Select
value={field.value || 'none'}
onValueChange={(value) =>
@@ -476,11 +479,11 @@ export function ProxyRouteCreateSheet({
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder='请选择...' />
<SelectValue placeholder={t('pleaseSelect')} />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value='none'>请选择...</SelectItem>
<SelectItem value='none'>{t('pleaseSelect')}</SelectItem>
{pagesProjects.map((project) => (
<SelectItem
key={project.id}
@@ -504,10 +507,8 @@ export function ProxyRouteCreateSheet({
render={({ field }) => (
<FormItem className='flex items-center justify-between rounded-lg border p-3'>
<div className='space-y-0.5'>
<FormLabel>启用站点</FormLabel>
<FormDescription>
关闭后会保留配置,但不会参与发布。
</FormDescription>
<FormLabel>{t('enableSite')}</FormLabel>
<FormDescription>{t('enableSiteDesc')}</FormDescription>
</div>
<FormControl>
<Switch
@@ -531,10 +532,10 @@ export function ProxyRouteCreateSheet({
variant='outline'
onClick={() => onOpenChange(false)}
>
取消
{tc('cancel')}
</Button>
<Button type='submit' disabled={form.formState.isSubmitting}>
{form.formState.isSubmitting ? '创建中…' : '创建'}
{form.formState.isSubmitting ? t('creating') : t('create')}
</Button>
</SheetFooter>
</form>
@@ -11,6 +11,8 @@ 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 {
@@ -36,6 +38,7 @@ export function ZoneDomainSelector({
className,
onDomainCreated,
}: ZoneDomainSelectorProps) {
const t = useTranslations('proxyRoutes');
const [keyword, setKeyword] = useState('');
const [createOpen, setCreateOpen] = useState(false);
const selected = useMemo(() => new Set(value), [value]);
@@ -97,7 +100,7 @@ export function ZoneDomainSelector({
<Input
value={keyword}
onChange={(event) => setKeyword(event.target.value)}
placeholder='搜索域名或 Zone…'
placeholder={t('searchDomainOrZone')}
disabled={disabled}
className='sm:flex-1'
/>
@@ -110,27 +113,27 @@ export function ZoneDomainSelector({
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 ? (
<>
暂无可用 Zone 域名。请先在{' '}
<Link
href='/websites'
className='text-primary underline-offset-4 hover:underline'
>
网站管理
</Link>{' '}
中添加 FQDN,或使用「快捷新增域名」。
</>
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>
) : (
@@ -160,11 +163,11 @@ export function ZoneDomainSelector({
</span>
{domain.cert_id ? (
<Badge variant='secondary' className='text-[10px]'>
证书 #{domain.cert_id}
{t('certNumber', { id: domain.cert_id })}
</Badge>
) : (
<Badge variant='outline' className='text-[10px]'>
无证书
{t('noCert')}
</Badge>
)}
</span>
@@ -175,11 +178,11 @@ export function ZoneDomainSelector({
className='inline-flex items-center gap-0.5 hover:text-foreground'
onClick={(event) => event.stopPropagation()}
>
Zone {zoneRoot}
{t('zoneName', { name: zoneRoot })}
<ExternalLink className='size-3' />
</Link>
) : (
<span>Zone #{domain.zone_id}</span>
<span>{t('zoneId', { id: domain.zone_id })}</span>
)}
</span>
</span>