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
@@ -28,6 +28,8 @@ import type {
} from '@/lib/services/openflare';
import { formatDateTime } from '@/lib/utils';
import { useTranslations } from 'next-intl';
import { getCertificateStatus } from '../../components/website-utils';
export function ZoneCertificatesPanel({
@@ -37,6 +39,8 @@ export function ZoneCertificatesPanel({
domains: ZoneDomainItem[];
certificates: TlsCertificateItem[];
}) {
const t = useTranslations('websites');
const tc = useTranslations('certificates');
const certificateMap = useMemo(
() =>
new Map(certificates.map((certificate) => [certificate.id, certificate])),
@@ -79,14 +83,12 @@ export function ZoneCertificatesPanel({
<Card className='shadow-none'>
<CardHeader className='flex flex-row items-start justify-between gap-3 space-y-0'>
<div>
<CardTitle className='text-base'>本 Zone 使用的证书</CardTitle>
<CardDescription>
证书在全局证书库管理;此处仅展示已绑定到本 Zone 域名的证书。
</CardDescription>
<CardTitle className='text-base'>{t('zoneCertsTitle')}</CardTitle>
<CardDescription>{t('zoneCertsDesc')}</CardDescription>
</div>
<Button variant='outline' size='sm' className='h-7 text-xs' asChild>
<Link href='/certificates'>
证书库
{t('certLibrary')}
<ExternalLink className='ml-1 size-3.5' />
</Link>
</Button>
@@ -94,37 +96,37 @@ export function ZoneCertificatesPanel({
<CardContent className='space-y-3'>
{unboundCount > 0 ? (
<p className='text-xs text-muted-foreground'>
还有 {unboundCount} 个域名未绑定证书,可在「域名」Tab 中选择证书。
{t('unboundCertHint', { count: unboundCount })}
</p>
) : null}
{boundCertificates.length === 0 ? (
<EmptyStateWithBorder
icon={FileKey}
description='暂无绑定证书。请先在域名行选择证书,或前往证书库导入/申请。'
description={t('emptyBoundCerts')}
/>
) : (
<div className='rounded-lg border'>
<Table>
<TableHeader>
<TableRow>
<TableHead>证书</TableHead>
<TableHead>状态</TableHead>
<TableHead>覆盖域名</TableHead>
<TableHead>到期时间</TableHead>
<TableHead>{t('certificate')}</TableHead>
<TableHead>{t('status')}</TableHead>
<TableHead>{t('coverage')}</TableHead>
<TableHead>{t('expiresAt')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{boundCertificates.map(
({ certificate, domains: boundDomains }) => {
const status = getCertificateStatus(certificate);
const status = getCertificateStatus(certificate, tc);
return (
<TableRow key={certificate.id}>
<TableCell className='font-medium'>
{certificate.name}
<p className='mt-0.5 text-xs text-muted-foreground'>
{certificate.primary_domain ||
`证书 #${certificate.id}`}
t('certNumber', { id: certificate.id })}
</p>
</TableCell>
<TableCell>
@@ -39,6 +39,8 @@ import {
type ZoneDomainItem,
} from '@/lib/services/openflare';
import { useTranslations } from 'next-intl';
import { getUpstreamLabels } from '../../../proxy-routes/components/helpers';
import { ZoneDomainDialog } from './zone-domain-dialog';
@@ -59,18 +61,21 @@ export function ZoneDomainsTable({
routesLoading?: boolean;
onChanged(): Promise<unknown> | void;
}) {
const t = useTranslations('websites');
const tc = useTranslations('common');
const tp = useTranslations('proxyRoutes');
const [createOpen, setCreateOpen] = useState(false);
const [deleting, setDeleting] = useState<ZoneDomainItem | null>(null);
const remove = useMutation({
mutationFn: (id: number) => ZoneDomainService.deleteById(zoneId, id),
onSuccess: async () => {
toast.success('域名已删除');
toast.success(t('domainDeleted'));
setDeleting(null);
await onChanged();
},
onError: (error) =>
toast.error(error instanceof Error ? error.message : '删除失败'),
toast.error(error instanceof Error ? error.message : t('deleteFailed')),
});
const certificateMap = useMemo(
@@ -93,12 +98,12 @@ export function ZoneDomainsTable({
onClick={() => setCreateOpen(true)}
>
<Plus className='mr-1 size-3.5' />
添加域名
{t('addDomain')}
</Button>
</div>
{domains.length === 0 ? (
<EmptyStateWithBorder description='暂无已添加域名' />
<EmptyStateWithBorder description={t('emptyDomains')} />
) : (
<div className='overflow-hidden rounded-lg border border-dashed shadow-none'>
<TooltipProvider delayDuration={0}>
@@ -109,13 +114,13 @@ export function ZoneDomainsTable({
FQDN
</TableHead>
<TableHead className='h-8 whitespace-nowrap py-2 min-w-[120px]'>
证书
{t('certificate')}
</TableHead>
<TableHead className='h-8 whitespace-nowrap py-2 min-w-[200px]'>
上游
{t('upstream')}
</TableHead>
<TableHead className='sticky right-0 z-10 h-8 w-[90px] bg-background py-2 text-center'>
操作
{t('actions')}
</TableHead>
</TableRow>
</TableHeader>
@@ -125,11 +130,11 @@ export function ZoneDomainsTable({
domain.proxy_route_id != null
? routeMap.get(domain.proxy_route_id)
: undefined;
const upstreams = route ? getUpstreamLabels(route) : [];
const upstreams = route ? getUpstreamLabels(route, tp) : [];
const certLabel = domain.cert_id
? (certificateMap.get(domain.cert_id)?.name ??
`证书 #${domain.cert_id}`)
: '未绑定';
t('certNumber', { id: domain.cert_id }))
: t('unbound');
return (
<TableRow
@@ -150,15 +155,15 @@ export function ZoneDomainsTable({
<TableCell className='max-w-[280px] py-1'>
{!domain.proxy_route_id ? (
<span className='font-mono text-[10px] text-muted-foreground'>
未关联路由
{t('unlinkedRoute')}
</span>
) : routesLoading && !route ? (
<span className='font-mono text-[10px] text-muted-foreground'>
加载中…
{t('loadingEllipsis')}
</span>
) : upstreams.length === 0 ? (
<span className='font-mono text-[10px] text-muted-foreground'>
未配置上游
{t('upstreamUnset')}
</span>
) : (
<div className='flex flex-col gap-0'>
@@ -196,7 +201,7 @@ export function ZoneDomainsTable({
</Button>
</TooltipTrigger>
<TooltipContent side='top' className='text-xs'>
查看路由详情
{t('viewRoute')}
</TooltipContent>
</Tooltip>
) : null}
@@ -213,7 +218,7 @@ export function ZoneDomainsTable({
</Button>
</TooltipTrigger>
<TooltipContent side='top' className='text-xs'>
删除域名
{t('deleteDomain')}
</TooltipContent>
</Tooltip>
</div>
@@ -245,14 +250,14 @@ export function ZoneDomainsTable({
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>确认删除域名</AlertDialogTitle>
<AlertDialogTitle>{t('deleteDomainTitle')}</AlertDialogTitle>
<AlertDialogDescription>
确认删除 {deleting?.domain} 吗?此操作不可撤销。
{t('deleteDomainDesc', { domain: deleting?.domain ?? '' })}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={remove.isPending}>
取消
{tc('cancel')}
</AlertDialogCancel>
<AlertDialogAction
className='bg-destructive text-white hover:bg-destructive/90'
@@ -264,7 +269,7 @@ export function ZoneDomainsTable({
}
}}
>
{remove.isPending ? '删除中…' : '确认删除'}
{remove.isPending ? t('deleting') : t('confirmDelete')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
@@ -23,18 +23,9 @@ import {
zoneQueryKey,
type ZoneItem,
} from '@/lib/services/openflare';
import { useTranslations } from 'next-intl';
const schema = z.object({
domain: z
.string()
.trim()
.min(1, '请输入 Zone 根域')
.refine(
(value) => !/[*/?#@]|:\/\//.test(value),
'请输入不含协议或通配符的根域',
),
});
type Values = z.infer<typeof schema>;
type Values = { domain: string };
export function ZoneEditorDialog({
open,
@@ -45,7 +36,19 @@ export function ZoneEditorDialog({
onOpenChange(open: boolean): void;
zone?: ZoneItem | null;
}) {
const t = useTranslations('websites');
const tc = useTranslations('common');
const queryClient = useQueryClient();
const schema = z.object({
domain: z
.string()
.trim()
.min(1, t('rootRequired'))
.refine(
(value) => !/[*/?#@]|:\/\//.test(value),
t('rootInvalid'),
),
});
const form = useForm<Values>({
resolver: zodResolver(schema),
defaultValues: { domain: '' },
@@ -59,21 +62,19 @@ export function ZoneEditorDialog({
? ZoneService.update(zone.id, { domain: values.domain.toLowerCase() })
: ZoneService.create({ domain: values.domain.toLowerCase() }),
onSuccess: async () => {
toast.success(zone ? 'Zone 已更新' : 'Zone 已创建');
toast.success(zone ? t('zoneUpdated') : t('zoneCreated'));
await queryClient.invalidateQueries({ queryKey: zoneQueryKey });
onOpenChange(false);
},
onError: (error) =>
toast.error(error instanceof Error ? error.message : '保存失败'),
toast.error(error instanceof Error ? error.message : t('saveFailed')),
});
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>{zone ? '编辑 Zone' : '新增 Zone'}</DialogTitle>
<DialogDescription>
Zone 仅接受可注册根域,例如 example.com。
</DialogDescription>
<DialogTitle>{zone ? t('editZone') : t('createZone')}</DialogTitle>
<DialogDescription>{t('editorDesc')}</DialogDescription>
</DialogHeader>
<form
id='zone-editor'
@@ -83,7 +84,7 @@ export function ZoneEditorDialog({
)}
>
<div className='space-y-1.5'>
<Label htmlFor='zone-domain'>根域</Label>
<Label htmlFor='zone-domain'>{t('rootDomain')}</Label>
<Input
id='zone-domain'
placeholder='example.com'
@@ -98,7 +99,7 @@ export function ZoneEditorDialog({
</form>
<DialogFooter>
<Button variant='outline' onClick={() => onOpenChange(false)}>
取消
{tc('cancel')}
</Button>
<Button
form='zone-editor'
@@ -108,7 +109,7 @@ export function ZoneEditorDialog({
{mutation.isPending && (
<Loader2 className='mr-1 size-4 animate-spin' />
)}
{zone ? '保存修改' : '新增 Zone'}
{zone ? t('saveChanges') : t('createZone')}
</Button>
</DialogFooter>
</DialogContent>
@@ -23,24 +23,7 @@ import {
import { formatDateTime } from '@/lib/utils';
import { formatBytes, formatCompactNumber } from '@/lib/utils/metrics';
import { cn } from '@/lib/utils';
const rangeOptions: Array<{ value: ZoneStatsRange; label: string }> = [
{ value: '24h', label: '24 小时' },
{ value: '7d', label: '7 天' },
{ value: '30d', label: '30 天' },
];
const visitorsChartConfig = {
value: { label: '分桶 UV', color: 'hsl(217 91% 60%)' },
} satisfies ChartConfig;
const requestsChartConfig = {
value: { label: '请求总数', color: 'hsl(217 91% 60%)' },
} satisfies ChartConfig;
const bytesChartConfig = {
value: { label: '已提供数据', color: 'hsl(217 91% 60%)' },
} satisfies ChartConfig;
import { useTranslations } from 'next-intl';
function formatAxisLabel(iso: string, range: ZoneStatsRange) {
const date = new Date(iso);
@@ -83,7 +66,23 @@ export function ZoneOverviewPanel({
overview: ZoneOverview;
zoneId: number;
}) {
const t = useTranslations('websites');
const tc = useTranslations('common');
const [range, setRange] = useState<ZoneStatsRange>('24h');
const rangeOptions: Array<{ value: ZoneStatsRange; label: string }> = [
{ value: '24h', label: t('range24h') },
{ value: '7d', label: t('range7d') },
{ value: '30d', label: t('range30d') },
];
const visitorsChartConfig = {
value: { label: t('chartUv'), color: 'hsl(217 91% 60%)' },
} satisfies ChartConfig;
const requestsChartConfig = {
value: { label: t('totalRequests'), color: 'hsl(217 91% 60%)' },
} satisfies ChartConfig;
const bytesChartConfig = {
value: { label: t('chartBytes'), color: 'hsl(217 91% 60%)' },
} satisfies ChartConfig;
const statsQuery = useQuery({
queryKey: [...zoneQueryKey, zoneId, 'stats', range],
@@ -143,7 +142,7 @@ export function ZoneOverviewPanel({
</div>
) : statsQuery.isError ? (
<div className='rounded-xl border border-dashed p-6 text-center text-sm text-muted-foreground'>
加载流量统计失败
{t('statsFailed')}
<div className='mt-3'>
<Button
size='sm'
@@ -151,7 +150,7 @@ export function ZoneOverviewPanel({
className='h-7 text-xs'
onClick={() => void statsQuery.refetch()}
>
重试
{tc('retry')}
</Button>
</div>
</div>
@@ -159,14 +158,14 @@ export function ZoneOverviewPanel({
<div className='space-y-3'>
{!stats?.available ? (
<p className='text-xs text-muted-foreground'>
分析存储暂不可用,图表可能为空。请确认 ClickHouse 已启用。
{t('analyticsUnavailable')}
</p>
) : null}
<MetricTrendCard
icon={Users}
label='查询窗口独立访客'
description='顶部合计为整窗去重;曲线为各时间桶内 UV,桶间不可相加'
label={t('uniqueVisitors')}
description={t('uniqueVisitorsDesc')}
value={formatCompactNumber(stats?.unique_visitors ?? 0)}
data={chartData}
dataKey='visitors'
@@ -175,7 +174,7 @@ export function ZoneOverviewPanel({
/>
<MetricTrendCard
icon={Activity}
label='请求总数'
label={t('totalRequests')}
value={formatCompactNumber(stats?.request_count ?? 0)}
data={chartData}
dataKey='requests'
@@ -184,7 +183,7 @@ export function ZoneOverviewPanel({
/>
<MetricTrendCard
icon={HardDrive}
label='已提供的数据总计'
label={t('totalBytes')}
value={formatBytes(stats?.bytes_sent ?? 0, { zeroText: '0 B' })}
data={chartData}
dataKey='bytes'
@@ -201,15 +200,15 @@ export function ZoneOverviewPanel({
<div className='grid gap-4 md:grid-cols-3'>
<Card className='border-dashed shadow-none md:col-span-3'>
<CardHeader className='pb-2'>
<CardTitle className='text-sm'>Zone 信息</CardTitle>
<CardTitle className='text-sm'>{t('zoneInfo')}</CardTitle>
</CardHeader>
<CardContent className='grid gap-3 text-sm sm:grid-cols-2'>
<div>
<p className='text-xs text-muted-foreground'>根域</p>
<p className='text-xs text-muted-foreground'>{t('rootDomain')}</p>
<p className='mt-1 font-medium'>{overview.zone.domain}</p>
</div>
<div>
<p className='text-xs text-muted-foreground'>创建时间</p>
<p className='text-xs text-muted-foreground'>{t('createdAt')}</p>
<p className='mt-1'>{formatDateTime(overview.zone.created_at)}</p>
</div>
</CardContent>
@@ -26,6 +26,7 @@ import {
ZoneService,
zoneQueryKey,
} from '@/lib/services/openflare';
import { useTranslations } from 'next-intl';
import { getErrorMessage } from '../components/website-utils';
import { ZoneCertificatesPanel } from './components/zone-certificates';
@@ -48,6 +49,8 @@ function getZoneIdFromPathname(pathname: string | null): number {
}
export function ZonePageClient() {
const t = useTranslations('websites');
const tc = useTranslations('common');
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
@@ -103,11 +106,12 @@ export function ZonePageClient() {
const deleteZone = useMutation({
mutationFn: () => ZoneService.deleteById(zoneId),
onSuccess: async () => {
toast.success('Zone 已删除');
toast.success(t('zoneDeleted'));
await queryClient.invalidateQueries({ queryKey: zoneQueryKey });
window.location.assign('/websites');
},
onError: (error) => toast.error(getErrorMessage(error)),
onError: (error) =>
toast.error(getErrorMessage(error, t('requestFailed'))),
});
if (!mounted) {
@@ -115,7 +119,7 @@ export function ZonePageClient() {
<div className='py-6 px-1'>
<LoadingStateWithBorder
icon={Globe}
description='加载 Zone 详情中...'
description={t('loadingDetail')}
/>
</div>
);
@@ -126,7 +130,7 @@ export function ZonePageClient() {
<div className='py-6 px-1'>
<EmptyStateWithBorder
icon={Globe}
description='无效的网站 ID,请从网站列表进入详情页。'
description={t('invalidId')}
/>
</div>
);
@@ -137,7 +141,7 @@ export function ZonePageClient() {
<div className='py-6 px-1'>
<LoadingStateWithBorder
icon={Globe}
description='加载 Zone 详情中...'
description={t('loadingDetail')}
/>
</div>
);
@@ -153,11 +157,11 @@ export function ZonePageClient() {
onClick={() => router.back()}
>
<ArrowLeft className='size-3.5' />
返回
{t('back')}
</Button>
<EmptyStateWithBorder
icon={Globe}
description='网站不存在,可能已被删除或 ID 无效。'
description={t('notFound')}
/>
</div>
);
@@ -182,7 +186,7 @@ export function ZonePageClient() {
onClick={() => router.back()}
>
<ArrowLeft className='size-3.5' />
返回
{t('back')}
</Button>
<div className='flex items-center gap-2'>
@@ -191,7 +195,7 @@ export function ZonePageClient() {
<h1 className='text-2xl font-semibold tracking-tight'>
{overview.zone.domain}
</h1>
<p className='text-sm text-muted-foreground'>Zone 网站管理</p>
<p className='text-sm text-muted-foreground'>{t('detailSubtitle')}</p>
</div>
</div>
</div>
@@ -202,25 +206,25 @@ export function ZonePageClient() {
value='overview'
className='px-0 pb-2 text-xs font-semibold'
>
概览
{t('tabOverview')}
</TabsTrigger>
<TabsTrigger
value='domains'
className='px-0 pb-2 text-xs font-semibold'
>
域名 ({overview.domains.length})
{t('tabDomains', { count: overview.domains.length })}
</TabsTrigger>
<TabsTrigger
value='certificates'
className='px-0 pb-2 text-xs font-semibold'
>
证书 ({boundCertCount})
{t('tabCertificates', { count: boundCertCount })}
</TabsTrigger>
<TabsTrigger
value='settings'
className='px-0 pb-2 text-xs font-semibold'
>
设置
{tc('settings')}
</TabsTrigger>
</TabsList>
@@ -247,19 +251,19 @@ export function ZonePageClient() {
<TabsContent value='settings'>
<div className='space-y-4'>
<div className='rounded-lg border p-4'>
<p className='text-sm font-semibold'>基本信息</p>
<p className='text-sm font-semibold'>{t('basicInfo')}</p>
<p className='mt-1 text-sm text-muted-foreground'>
维护 Zone 的可注册根域。根域变更后,请确认其下域名仍归属该根域。
{t('basicInfoDesc')}
</p>
<div className='mt-3 grid gap-2 text-sm sm:grid-cols-2'>
<div>
<p className='text-xs text-muted-foreground'>当前根域</p>
<p className='text-xs text-muted-foreground'>{t('currentRoot')}</p>
<p className='mt-1 font-mono text-[13px] font-medium'>
{overview.zone.domain}
</p>
</div>
<div>
<p className='text-xs text-muted-foreground'>域名数量</p>
<p className='text-xs text-muted-foreground'>{t('domainCount')}</p>
<p className='mt-1 font-mono text-[13px] font-medium'>
{overview.domains.length}
</p>
@@ -272,14 +276,14 @@ export function ZonePageClient() {
onClick={() => setEditZone(true)}
>
<Pencil className='mr-1 size-3.5' />
编辑根域
{t('editRoot')}
</Button>
</div>
<div className='rounded-lg border border-destructive/30 p-4'>
<p className='text-sm font-semibold text-destructive'>危险操作</p>
<p className='text-sm font-semibold text-destructive'>{t('dangerZone')}</p>
<p className='mt-1 text-sm text-muted-foreground'>
删除 Zone 前须清空其下全部域名;删除后不可恢复。
{t('dangerZoneDesc')}
</p>
<Button
variant='destructive'
@@ -288,7 +292,7 @@ export function ZonePageClient() {
onClick={() => setConfirmDelete(true)}
>
<Trash2 className='mr-1 size-3.5' />
删除 Zone
{t('deleteZone')}
</Button>
</div>
</div>
@@ -304,15 +308,14 @@ export function ZonePageClient() {
<AlertDialog open={confirmDelete} onOpenChange={setConfirmDelete}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>确认删除 Zone</AlertDialogTitle>
<AlertDialogTitle>{t('deleteZoneTitle')}</AlertDialogTitle>
<AlertDialogDescription>
确认删除 {overview.zone.domain}{' '}
吗?其下域名须先解绑路由后才能删除,此操作不可撤销。
{t('deleteZoneDesc', { domain: overview.zone.domain })}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleteZone.isPending}>
取消
{tc('cancel')}
</AlertDialogCancel>
<AlertDialogAction
className='bg-destructive text-white hover:bg-destructive/90'
@@ -322,7 +325,7 @@ export function ZonePageClient() {
deleteZone.mutate();
}}
>
{deleteZone.isPending ? '删除中…' : '确认删除'}
{deleteZone.isPending ? t('deleting') : t('confirmDelete')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
@@ -1,5 +1,6 @@
import { Suspense } from 'react';
import { Globe } from 'lucide-react';
import { getTranslations } from 'next-intl/server';
import { LoadingStateWithBorder } from '@/components/layout/loading';
import { ZonePageClient } from './page-client';
@@ -8,13 +9,14 @@ export async function generateStaticParams() {
}
export default async function ZonePage() {
const t = await getTranslations('websites');
return (
<Suspense
fallback={
<div className='py-6 px-1'>
<LoadingStateWithBorder
icon={Globe}
description='加载 Zone 详情中...'
description={t('loadingDetail')}
/>
</div>
}
@@ -31,9 +31,11 @@ import {
TlsCertificateService,
} from '@/lib/services/openflare';
import { useTranslations } from 'next-intl';
import {
type AcmeApplyFormValues,
acmeApplySchema,
createAcmeApplySchema,
defaultAcmeApplyValues,
} from './schemas';
import { getErrorMessage } from './website-utils';
@@ -57,6 +59,7 @@ export function CertificateApplyDialog({
mode = 'create',
certificate,
}: CertificateApplyDialogProps) {
const t = useTranslations('certificates');
const queryClient = useQueryClient();
const [error, setError] = useState('');
const [showAdvanced, setShowAdvanced] = useState(false);
@@ -74,7 +77,7 @@ export function CertificateApplyDialog({
});
const form = useForm<AcmeApplyFormValues>({
resolver: zodResolver(acmeApplySchema),
resolver: zodResolver(createAcmeApplySchema(t)),
defaultValues: defaultAcmeApplyValues,
});
@@ -141,22 +144,22 @@ export function CertificateApplyDialog({
onApplied?.(result);
onOpenChange(false);
},
onError: (err) => setError(getErrorMessage(err)),
onError: (err) => setError(getErrorMessage(err, t('requestFailed'))),
});
const title =
mode === 'edit-acme'
? '编辑并重新申请证书'
? t('applyEditTitle')
: mode === 'convert-upload'
? '转换为申请证书'
: '申请证书';
? t('applyConvertTitle')
: t('applyTitle');
const description =
mode === 'edit-acme'
? '修改 ACME 证书配置。保存后将使用新配置重新申请证书。'
? t('applyEditDesc')
: mode === 'convert-upload'
? '填写 ACME 申请资料。申请成功后,当前手动证书会原地转换为可自动续签的申请证书。'
: "使用 ACME (Let's Encrypt 等) 自动申请和续期证书,支持通配符域名。";
? t('applyConvertDesc')
: t('applyDesc');
return (
<Dialog open={open} onOpenChange={onOpenChange}>
@@ -177,33 +180,33 @@ export function CertificateApplyDialog({
<div className='grid gap-4 md:grid-cols-2'>
<div className='space-y-2'>
<Label>证书名称</Label>
<Input placeholder='例如:主站证书' {...form.register('name')} />
<Label>{t('name')}</Label>
<Input placeholder={t('namePlaceholder')} {...form.register('name')} />
</div>
<div className='space-y-2'>
<Label>主域名</Label>
<Label>{t('primaryDomain')}</Label>
<Input
placeholder='example.com 或 *.example.com'
placeholder={t('primaryDomainPlaceholder')}
{...form.register('primary_domain')}
/>
</div>
</div>
<div className='space-y-2'>
<Label>其他域名</Label>
<Label>{t('otherDomains')}</Label>
<Textarea
rows={3}
placeholder='example.net'
{...form.register('other_domains')}
/>
<p className='text-xs text-muted-foreground'>
每行一个域名。如申请通配符证书,请填写对应的根域名以便一并签发。
{t('otherDomainsHint')}
</p>
</div>
<div className='grid gap-4 md:grid-cols-2'>
<div className='space-y-2'>
<Label>DNS 服务商账号</Label>
<Label>{t('dnsAccount')}</Label>
<Select
value={String(form.watch('dns_account_id') || 0)}
onValueChange={(value) =>
@@ -213,10 +216,10 @@ export function CertificateApplyDialog({
}
>
<SelectTrigger>
<SelectValue placeholder='请选择 DNS 账号' />
<SelectValue placeholder={t('selectDnsAccount')} />
</SelectTrigger>
<SelectContent>
<SelectItem value='0'>请选择 DNS 账号</SelectItem>
<SelectItem value='0'>{t('selectDnsAccount')}</SelectItem>
{dnsAccountsQuery.data?.map((account) => (
<SelectItem key={account.id} value={String(account.id)}>
{account.name} ({account.type})
@@ -227,7 +230,7 @@ export function CertificateApplyDialog({
</div>
<div className='space-y-2'>
<Label>密钥算法</Label>
<Label>{t('keyAlgorithm')}</Label>
<Select
value={form.watch('key_algorithm')}
onValueChange={(value) => form.setValue('key_algorithm', value)}
@@ -246,18 +249,18 @@ export function CertificateApplyDialog({
</div>
<div className='space-y-2'>
<Label>备注</Label>
<Label>{t('remark')}</Label>
<Input
placeholder='可选,用于记录证书用途。'
placeholder={t('remarkPlaceholder')}
{...form.register('remark')}
/>
</div>
<div className='flex items-center justify-between rounded-lg border px-3 py-2'>
<div>
<p className='text-sm font-medium'>开启自动续签</p>
<p className='text-sm font-medium'>{t('autoRenew')}</p>
<p className='text-xs text-muted-foreground'>
开启后,将在证书过期前 7 天自动续期。
{t('autoRenewDesc')}
</p>
</div>
<Switch
@@ -275,7 +278,7 @@ export function CertificateApplyDialog({
className='w-full justify-between rounded-none'
onClick={() => setShowAdvanced((current) => !current)}
>
高级选项
{t('advanced')}
<ChevronDown
className={`size-4 transition-transform ${showAdvanced ? 'rotate-180' : ''}`}
/>
@@ -284,16 +287,16 @@ export function CertificateApplyDialog({
<div className='space-y-4 border-t p-3'>
<div className='grid gap-4 md:grid-cols-2'>
<div className='space-y-2'>
<Label>DNS 验证服务器 1</Label>
<Label>{t('dnsServer1')}</Label>
<Input
placeholder='为空则使用默认权威 DNS'
placeholder={t('dnsServerPlaceholder')}
{...form.register('dns1')}
/>
</div>
<div className='space-y-2'>
<Label>DNS 验证服务器 2</Label>
<Label>{t('dnsServer2')}</Label>
<Input
placeholder='为空则使用默认权威 DNS'
placeholder={t('dnsServerPlaceholder')}
{...form.register('dns2')}
/>
</div>
@@ -301,9 +304,9 @@ export function CertificateApplyDialog({
<div className='grid gap-4 md:grid-cols-2'>
<div className='flex items-center justify-between rounded-lg border px-3 py-2'>
<div>
<p className='text-sm font-medium'>跳过 CNAME 检查</p>
<p className='text-sm font-medium'>{t('skipCname')}</p>
<p className='text-xs text-muted-foreground'>
在执行 DNS-01 验证时不追踪 CNAME 记录。
{t('skipCnameDesc')}
</p>
</div>
<Switch
@@ -315,9 +318,9 @@ export function CertificateApplyDialog({
</div>
<div className='flex items-center justify-between rounded-lg border px-3 py-2'>
<div>
<p className='text-sm font-medium'>跳过 DNS 前置检查</p>
<p className='text-sm font-medium'>{t('skipDns')}</p>
<p className='text-xs text-muted-foreground'>
直接请求 Let&apos;s Encrypt 验证而不做本地校验。
{t('skipDnsDesc')}
</p>
</div>
<Switch
@@ -336,14 +339,14 @@ export function CertificateApplyDialog({
{applyMutation.isPending ? (
<>
<Loader2 className='mr-1 size-3.5 animate-spin' />
提交中...
{t('submitting')}
</>
) : mode === 'edit-acme' ? (
'保存并申请'
t('saveAndApply')
) : mode === 'convert-upload' ? (
'开始转换'
t('startConvert')
) : (
'开始申请'
t('startApply')
)}
</Button>
</form>
@@ -19,6 +19,8 @@ import { LoadingStateWithBorder } from '@/components/layout/loading';
import { TlsCertificateService } from '@/lib/services/openflare';
import { formatDateTime } from '@/lib/utils';
import { useTranslations } from 'next-intl';
import { WebsiteStatusBadge } from './status-badge';
import { getCertificateStatus, getErrorMessage } from './website-utils';
@@ -39,6 +41,7 @@ export function CertificateDetailDialog({
onDelete,
deleting = false,
}: CertificateDetailDialogProps) {
const t = useTranslations('certificates');
const certificateQuery = useQuery({
queryKey: ['openflare', 'tls-certificates', 'detail', certificateId],
queryFn: () => TlsCertificateService.getById(certificateId as number),
@@ -53,7 +56,7 @@ export function CertificateDetailDialog({
const certificate = certificateQuery.data;
const content = contentQuery.data;
const status = certificate ? getCertificateStatus(certificate) : null;
const status = certificate ? getCertificateStatus(certificate, t) : null;
const loading = certificateQuery.isLoading || contentQuery.isLoading;
const hasError = certificateQuery.isError || contentQuery.isError;
@@ -62,7 +65,7 @@ export function CertificateDetailDialog({
await navigator.clipboard.writeText(value);
toast.success(message);
} catch (error) {
toast.error(getErrorMessage(error));
toast.error(getErrorMessage(error, t('requestFailed')));
}
};
@@ -70,35 +73,34 @@ export function CertificateDetailDialog({
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className='max-w-3xl max-h-[90vh] overflow-y-auto'>
<DialogHeader>
<DialogTitle>证书详情</DialogTitle>
<DialogDescription>
查看证书元信息、备注以及当前保存的 PEM 内容。
</DialogDescription>
<DialogTitle>{t('detailTitle')}</DialogTitle>
<DialogDescription>{t('detailDesc')}</DialogDescription>
</DialogHeader>
{loading ? (
<LoadingStateWithBorder description='加载证书详情中...' />
<LoadingStateWithBorder description={t('loadingDetail')} />
) : hasError ? (
<ErrorInline
message={getErrorMessage(
certificateQuery.error ?? contentQuery.error,
t('requestFailed'),
)}
className='justify-center'
/>
) : !certificate || !content ? (
<EmptyStateWithBorder description='证书不存在,可能已被删除。' />
<EmptyStateWithBorder description={t('notFound')} />
) : (
<div className='space-y-4'>
<div className='grid gap-3 md:grid-cols-2'>
<div className='rounded-lg border p-3'>
<p className='text-[10px] uppercase tracking-wider text-muted-foreground'>
证书名称
{t('name')}
</p>
<p className='mt-1 text-sm'>{certificate.name}</p>
</div>
<div className='rounded-lg border p-3'>
<p className='text-[10px] uppercase tracking-wider text-muted-foreground'>
状态
{t('status')}
</p>
<div className='mt-1'>
{status ? (
@@ -111,7 +113,7 @@ export function CertificateDetailDialog({
</div>
<div className='rounded-lg border p-3'>
<p className='text-[10px] uppercase tracking-wider text-muted-foreground'>
生效时间
{t('notBefore')}
</p>
<p className='mt-1 text-sm'>
{formatDateTime(certificate.not_before)}
@@ -119,7 +121,7 @@ export function CertificateDetailDialog({
</div>
<div className='rounded-lg border p-3'>
<p className='text-[10px] uppercase tracking-wider text-muted-foreground'>
到期时间
{t('notAfter')}
</p>
<p className='mt-1 text-sm'>
{formatDateTime(certificate.not_after)}
@@ -129,26 +131,28 @@ export function CertificateDetailDialog({
<div className='rounded-lg border p-3'>
<p className='text-[10px] uppercase tracking-wider text-muted-foreground'>
备注
{t('remark')}
</p>
<p className='mt-1 text-sm'>
{certificate.remark || t('noRemark')}
</p>
<p className='mt-1 text-sm'>{certificate.remark || '暂无备注'}</p>
</div>
<div className='space-y-3'>
<div>
<div className='mb-2 flex items-center justify-between'>
<p className='text-sm font-medium'>证书 PEM</p>
<p className='text-sm font-medium'>{t('certPem')}</p>
<Button
type='button'
variant='outline'
size='sm'
className='h-7 text-xs'
onClick={() =>
void handleCopy(content.cert_pem, '证书 PEM 已复制')
void handleCopy(content.cert_pem, t('certPemCopied'))
}
>
<Copy className='mr-1 size-3' />
复制
{t('copy')}
</Button>
</div>
<pre className='max-h-48 overflow-auto rounded-lg border bg-muted/40 p-3 text-xs break-all whitespace-pre-wrap'>
@@ -157,18 +161,18 @@ export function CertificateDetailDialog({
</div>
<div>
<div className='mb-2 flex items-center justify-between'>
<p className='text-sm font-medium'>私钥 PEM</p>
<p className='text-sm font-medium'>{t('keyPem')}</p>
<Button
type='button'
variant='outline'
size='sm'
className='h-7 text-xs'
onClick={() =>
void handleCopy(content.key_pem, '私钥 PEM 已复制')
void handleCopy(content.key_pem, t('keyPemCopied'))
}
>
<Copy className='mr-1 size-3' />
复制
{t('copy')}
</Button>
</div>
<pre className='max-h-48 overflow-auto rounded-lg border bg-muted/40 p-3 text-xs break-all whitespace-pre-wrap'>
@@ -185,10 +189,10 @@ export function CertificateDetailDialog({
variant='outline'
onClick={() => onOpenChange(false)}
>
关闭
{t('close')}
</Button>
<Button type='button' onClick={onEdit} disabled={!certificate}>
编辑证书
{t('editCert')}
</Button>
<Button
type='button'
@@ -199,10 +203,10 @@ export function CertificateDetailDialog({
{deleting ? (
<>
<Loader2 className='mr-1 size-3.5 animate-spin' />
删除中...
{t('deleting')}
</>
) : (
'删除证书'
t('deleteCert')
)}
</Button>
</DialogFooter>
@@ -24,10 +24,12 @@ import { Textarea } from '@/components/ui/textarea';
import type { TlsCertificateItem } from '@/lib/services/openflare';
import { TlsCertificateService } from '@/lib/services/openflare';
import { useTranslations } from 'next-intl';
import {
createManualImportSchema,
defaultManualImportValues,
type ManualImportFormValues,
manualImportSchema,
} from './schemas';
import { getErrorMessage, toManualPayload } from './website-utils';
@@ -48,9 +50,11 @@ export function CertificateEditorDialog({
onSaved,
onConvert,
}: CertificateEditorDialogProps) {
const t = useTranslations('certificates');
const tc = useTranslations('common');
const queryClient = useQueryClient();
const form = useForm<ManualImportFormValues>({
resolver: zodResolver(manualImportSchema),
resolver: zodResolver(createManualImportSchema(t)),
defaultValues: defaultManualImportValues,
});
@@ -103,21 +107,19 @@ export function CertificateEditorDialog({
<Dialog open={open} onOpenChange={(next) => !next && handleClose()}>
<DialogContent className='max-w-2xl max-h-[90vh] overflow-y-auto'>
<DialogHeader>
<DialogTitle>编辑证书</DialogTitle>
<DialogDescription>
可以修改证书名称、备注,以及重新上传 PEM 证书和私钥内容。
</DialogDescription>
<DialogTitle>{t('editTitle')}</DialogTitle>
<DialogDescription>{t('editDesc')}</DialogDescription>
</DialogHeader>
{certificateQuery.isLoading ? (
<LoadingStateWithBorder description='加载证书内容中...' />
<LoadingStateWithBorder description={t('loadingContent')} />
) : certificateQuery.isError ? (
<ErrorInline
message={getErrorMessage(certificateQuery.error)}
message={getErrorMessage(certificateQuery.error, t('requestFailed'))}
className='justify-center'
/>
) : !certificateQuery.data ? (
<EmptyStateWithBorder description='证书不存在,可能已被删除。' />
<EmptyStateWithBorder description={t('notFound')} />
) : (
<form
id='certificate-editor-form'
@@ -126,23 +128,23 @@ export function CertificateEditorDialog({
>
{updateMutation.isError ? (
<p className='text-sm text-destructive'>
{getErrorMessage(updateMutation.error)}
{getErrorMessage(updateMutation.error, t('requestFailed'))}
</p>
) : null}
<div className='grid gap-4 md:grid-cols-2'>
<div className='space-y-2'>
<Label>证书名称</Label>
<Label>{t('name')}</Label>
<Input {...form.register('name')} />
</div>
<div className='space-y-2'>
<Label>备注</Label>
<Label>{t('remark')}</Label>
<Input {...form.register('remark')} />
</div>
</div>
<div className='space-y-2'>
<Label>证书 PEM</Label>
<Label>{t('certPem')}</Label>
<Textarea
className='min-h-32 font-mono text-xs'
{...form.register('cert_pem')}
@@ -150,7 +152,7 @@ export function CertificateEditorDialog({
</div>
<div className='space-y-2'>
<Label>私钥 PEM</Label>
<Label>{t('keyPem')}</Label>
<Textarea
className='min-h-32 font-mono text-xs'
{...form.register('key_pem')}
@@ -170,22 +172,22 @@ export function CertificateEditorDialog({
}}
disabled={updateMutation.isPending}
>
转换来源
{t('convertSource')}
</Button>
) : null}
</div>
<div className='flex gap-2'>
<Button type='button' variant='outline' onClick={handleClose}>
取消
{tc('cancel')}
</Button>
<Button type='submit' disabled={updateMutation.isPending}>
{updateMutation.isPending ? (
<>
<Loader2 className='mr-1 size-3.5 animate-spin' />
保存中...
{t('saving')}
</>
) : (
'保存证书'
t('saveCert')
)}
</Button>
</div>
@@ -21,12 +21,14 @@ import { Textarea } from '@/components/ui/textarea';
import type { TlsCertificateItem } from '@/lib/services/openflare';
import { TlsCertificateService } from '@/lib/services/openflare';
import { useTranslations } from 'next-intl';
import {
createManualImportSchema,
defaultFileImportValues,
defaultManualImportValues,
type FileImportFormValues,
type ManualImportFormValues,
manualImportSchema,
} from './schemas';
import {
getErrorMessage,
@@ -47,6 +49,7 @@ export function CertificateImportDialog({
onOpenChange,
onImported,
}: CertificateImportDialogProps) {
const t = useTranslations('certificates');
const queryClient = useQueryClient();
const [importMode, setImportMode] = useState<'manual' | 'file'>('manual');
const [error, setError] = useState('');
@@ -58,7 +61,7 @@ export function CertificateImportDialog({
const [fileInputNonce, setFileInputNonce] = useState(0);
const manualForm = useForm<ManualImportFormValues>({
resolver: zodResolver(manualImportSchema),
resolver: zodResolver(createManualImportSchema(t)),
defaultValues: defaultManualImportValues,
});
@@ -100,20 +103,20 @@ export function CertificateImportDialog({
onImported?.(certificate);
handleClose();
},
onError: (err) => setError(getErrorMessage(err)),
onError: (err) => setError(getErrorMessage(err, t('requestFailed'))),
});
const fileImportMutation = useMutation({
mutationFn: (values: FileImportFormValues) =>
TlsCertificateService.importFile(
toFilePayload(values, certFile, keyFile),
toFilePayload(values, certFile, keyFile, t('selectFiles')),
),
onSuccess: async (certificate) => {
await invalidateQueries();
onImported?.(certificate);
handleClose();
},
onError: (err) => setError(getErrorMessage(err)),
onError: (err) => setError(getErrorMessage(err, t('requestFailed'))),
});
const handleManualSubmit = manualForm.handleSubmit((values) => {
@@ -134,10 +137,8 @@ export function CertificateImportDialog({
<Dialog open={open} onOpenChange={(next) => !next && handleClose()}>
<DialogContent className='max-w-2xl'>
<DialogHeader>
<DialogTitle>添加证书</DialogTitle>
<DialogDescription>
支持手动粘贴 PEM 或上传证书文件。导入成功后可立即在网站表单里选择。
</DialogDescription>
<DialogTitle>{t('importTitle')}</DialogTitle>
<DialogDescription>{t('importDesc')}</DialogDescription>
</DialogHeader>
{error ? <p className='text-sm text-destructive'>{error}</p> : null}
@@ -147,15 +148,15 @@ export function CertificateImportDialog({
onValueChange={(value) => setImportMode(value as 'manual' | 'file')}
>
<TabsList className='grid w-full grid-cols-2'>
<TabsTrigger value='manual'>手动导入</TabsTrigger>
<TabsTrigger value='file'>文件导入</TabsTrigger>
<TabsTrigger value='manual'>{t('manualImport')}</TabsTrigger>
<TabsTrigger value='file'>{t('fileImport')}</TabsTrigger>
</TabsList>
<TabsContent value='manual' className='space-y-4'>
<form className='space-y-4' onSubmit={handleManualSubmit}>
<div className='grid gap-4 md:grid-cols-2'>
<div className='space-y-2'>
<Label>证书名称</Label>
<Label>{t('name')}</Label>
<Input
placeholder='example-com'
{...manualForm.register('name')}
@@ -167,16 +168,16 @@ export function CertificateImportDialog({
) : null}
</div>
<div className='space-y-2'>
<Label>备注</Label>
<Label>{t('remark')}</Label>
<Input
placeholder='例如:主站生产证书'
placeholder={t('remarkProdPlaceholder')}
{...manualForm.register('remark')}
/>
</div>
</div>
<div className='space-y-2'>
<Label>证书 PEM</Label>
<Label>{t('certPem')}</Label>
<Textarea
className='min-h-32 font-mono text-xs'
placeholder='-----BEGIN CERTIFICATE-----'
@@ -185,7 +186,7 @@ export function CertificateImportDialog({
</div>
<div className='space-y-2'>
<Label>私钥 PEM</Label>
<Label>{t('keyPem')}</Label>
<Textarea
className='min-h-32 font-mono text-xs'
placeholder='-----BEGIN PRIVATE KEY-----'
@@ -197,10 +198,10 @@ export function CertificateImportDialog({
{pending ? (
<>
<Loader2 className='mr-1 size-3.5 animate-spin' />
导入中...
{t('importing')}
</>
) : (
'导入证书'
t('importCert')
)}
</Button>
</form>
@@ -210,7 +211,7 @@ export function CertificateImportDialog({
<form className='space-y-4' onSubmit={handleFileSubmit}>
<div className='grid gap-4 md:grid-cols-2'>
<div className='space-y-2'>
<Label>证书名称</Label>
<Label>{t('name')}</Label>
<Input
value={fileForm.name}
onChange={(event) =>
@@ -223,7 +224,7 @@ export function CertificateImportDialog({
/>
</div>
<div className='space-y-2'>
<Label>备注</Label>
<Label>{t('remark')}</Label>
<Input
value={fileForm.remark}
onChange={(event) =>
@@ -232,14 +233,14 @@ export function CertificateImportDialog({
remark: event.target.value,
}))
}
placeholder='例如:泛域名生产证书'
placeholder={t('remarkWildcardPlaceholder')}
/>
</div>
</div>
<div className='grid gap-4 md:grid-cols-2'>
<div className='space-y-2'>
<Label>证书文件</Label>
<Label>{t('certFile')}</Label>
<Input
key={`cert-${fileInputNonce}`}
type='file'
@@ -250,12 +251,12 @@ export function CertificateImportDialog({
/>
<p className='text-xs text-muted-foreground'>
{certFile
? `已选择:${certFile.name}`
: '请选择 PEM/CRT 文件'}
? t('fileSelected', { name: certFile.name })
: t('selectCertFile')}
</p>
</div>
<div className='space-y-2'>
<Label>私钥文件</Label>
<Label>{t('keyFile')}</Label>
<Input
key={`key-${fileInputNonce}`}
type='file'
@@ -266,8 +267,8 @@ export function CertificateImportDialog({
/>
<p className='text-xs text-muted-foreground'>
{keyFile
? `已选择:${keyFile.name}`
: '请选择 KEY/PEM 文件'}
? t('fileSelected', { name: keyFile.name })
: t('selectKeyFile')}
</p>
</div>
</div>
@@ -277,10 +278,10 @@ export function CertificateImportDialog({
{pending ? (
<>
<Loader2 className='mr-1 size-3.5 animate-spin' />
上传中...
{t('uploading')}
</>
) : (
'上传文件'
t('uploadFiles')
)}
</Button>
<Button
@@ -289,7 +290,7 @@ export function CertificateImportDialog({
disabled={pending}
onClick={resetFileForm}
>
清空文件
{t('clearFiles')}
</Button>
</div>
</form>
@@ -28,17 +28,17 @@ import {
import type { DnsAccountMutationPayload } from '@/lib/services/openflare';
import { DnsAccountService } from '@/lib/services/openflare';
import { useTranslations } from 'next-intl';
import { getErrorMessage } from './website-utils';
const dnsAccountsQueryKey = ['openflare', 'dns-accounts'];
const dnsAccountSchema = z.object({
name: z.string().trim().min(1, '请输入名称').max(255),
type: z.string().min(1),
authorization: z.string().trim().min(1, '请输入 Token'),
});
type DnsAccountFormValues = z.infer<typeof dnsAccountSchema>;
type DnsAccountFormValues = {
name: string;
type: string;
authorization: string;
};
interface DnsAccountCreateDialogProps {
open: boolean;
@@ -51,8 +51,15 @@ export function DnsAccountCreateDialog({
onOpenChange,
onCreated,
}: DnsAccountCreateDialogProps) {
const t = useTranslations('dnsAccounts');
const tc = useTranslations('common');
const queryClient = useQueryClient();
const [error, setError] = useState('');
const dnsAccountSchema = z.object({
name: z.string().trim().min(1, t('nameRequired')).max(255),
type: z.string().min(1),
authorization: z.string().trim().min(1, t('tokenRequired')),
});
const form = useForm<DnsAccountFormValues>({
resolver: zodResolver(dnsAccountSchema),
defaultValues: { name: '', type: 'cloudflare', authorization: '' },
@@ -68,7 +75,7 @@ export function DnsAccountCreateDialog({
onCreated?.();
onOpenChange(false);
},
onError: (err) => setError(getErrorMessage(err)),
onError: (err) => setError(getErrorMessage(err, t('requestFailed'))),
});
const onSubmit = form.handleSubmit((values) => {
@@ -90,19 +97,17 @@ export function DnsAccountCreateDialog({
<Dialog open={open} onOpenChange={(next) => !next && handleClose()}>
<DialogContent>
<DialogHeader>
<DialogTitle>添加 DNS 账号</DialogTitle>
<DialogDescription>
统一管理 DNS 服务商账号,用于 ACME 证书的 DNS 验证申请。
</DialogDescription>
<DialogTitle>{t('createTitle')}</DialogTitle>
<DialogDescription>{t('createDesc')}</DialogDescription>
</DialogHeader>
<form className='space-y-4' onSubmit={onSubmit}>
{error ? <p className='text-sm text-destructive'>{error}</p> : null}
<div className='space-y-2'>
<Label>账号名称</Label>
<Label>{t('accountName')}</Label>
<Input
placeholder='Cloudflare 邮箱账号'
placeholder={t('accountNamePlaceholder')}
{...form.register('name')}
/>
{form.formState.errors.name ? (
@@ -113,7 +118,7 @@ export function DnsAccountCreateDialog({
</div>
<div className='space-y-2'>
<Label>DNS 服务商</Label>
<Label>{t('provider')}</Label>
<Select
value={form.watch('type')}
onValueChange={(value) => form.setValue('type', value)}
@@ -131,7 +136,7 @@ export function DnsAccountCreateDialog({
<Label>API Token</Label>
<Input
{...form.register('authorization')}
placeholder='请勿使用 Global API Key'
placeholder={t('tokenPlaceholder')}
/>
{form.formState.errors.authorization ? (
<p className='text-xs text-destructive'>
@@ -142,16 +147,16 @@ export function DnsAccountCreateDialog({
<DialogFooter>
<Button type='button' variant='outline' onClick={handleClose}>
取消
{tc('cancel')}
</Button>
<Button type='submit' disabled={createMutation.isPending}>
{createMutation.isPending ? (
<>
<Loader2 className='mr-1 size-3.5 animate-spin' />
提交中...
{t('submitting')}
</>
) : (
'提交'
t('submit')
)}
</Button>
</DialogFooter>
@@ -35,18 +35,19 @@ import {
type ZoneItem,
} from '@/lib/services/openflare';
import { useTranslations } from 'next-intl';
import {
previewZoneDomainInput,
resolveZoneDomainInput,
type ZoneDomainInputError,
} from './resolve-zone-domain-input';
const schema = z.object({
zone_id: z.string().min(1, '请选择 Zone'),
domain_input: z.string().trim().min(1, '请输入域名'),
cert_id: z.string(),
});
type Values = z.infer<typeof schema>;
type Values = {
zone_id: string;
domain_input: string;
cert_id: string;
};
export function QuickCreateZoneDomainDialog({
open,
@@ -64,7 +65,19 @@ export function QuickCreateZoneDomainDialog({
zones?: ZoneItem[];
onCreated(domain: ZoneDomainItem): void | Promise<void>;
}) {
const t = useTranslations('websites');
const tc = useTranslations('common');
const queryClient = useQueryClient();
const domainErrorMessage = (
error: ZoneDomainInputError | undefined,
root?: string,
) => {
if (!error) return t('invalidFormat');
if (error === 'mustBelongToZone') {
return t('mustBelongToZone', { root: root ?? '' });
}
return t(error);
};
const zonesQuery = useQuery({
queryKey: zoneQueryKey,
queryFn: () => ZoneService.list(),
@@ -97,6 +110,11 @@ export function QuickCreateZoneDomainDialog({
);
}, [fixedZoneId, fixedZoneRoot, zones]);
const schema = z.object({
zone_id: z.string().min(1, t('selectZone')),
domain_input: z.string().trim().min(1, t('enterDomain')),
cert_id: z.string(),
});
const form = useForm<Values>({
resolver: zodResolver(schema),
defaultValues: {
@@ -136,11 +154,11 @@ export function QuickCreateZoneDomainDialog({
const zoneId = Number(values.zone_id);
const zone = fixedZone ?? zones.find((item) => item.id === zoneId);
if (!zone) {
throw new Error('请选择 Zone');
throw new Error(t('selectZone'));
}
const resolved = resolveZoneDomainInput(values.domain_input, zone.domain);
if (resolved.error || !resolved.domain) {
throw new Error(resolved.error || '域名格式不合法');
throw new Error(domainErrorMessage(resolved.error, zone.domain));
}
return ZoneDomainService.create(zone.id, {
domain: resolved.domain,
@@ -148,7 +166,7 @@ export function QuickCreateZoneDomainDialog({
});
},
onSuccess: async (domain) => {
toast.success('域名已添加', { description: domain.domain });
toast.success(t('domainAdded'), { description: domain.domain });
await Promise.all([
onCreated(domain),
queryClient.invalidateQueries({ queryKey: zoneQueryKey }),
@@ -159,17 +177,15 @@ export function QuickCreateZoneDomainDialog({
onOpenChange(false);
},
onError: (error) =>
toast.error(error instanceof Error ? error.message : '添加失败'),
toast.error(error instanceof Error ? error.message : t('addFailed')),
});
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>快捷新增域名</DialogTitle>
<DialogDescription>
选择 Zone 后输入:子域名称(如 api)、@(根域)或完整 FQDN。
</DialogDescription>
<DialogTitle>{t('quickCreateTitle')}</DialogTitle>
<DialogDescription>{t('quickCreateDesc')}</DialogDescription>
</DialogHeader>
<form
@@ -177,7 +193,7 @@ export function QuickCreateZoneDomainDialog({
className='space-y-4'
onSubmit={form.handleSubmit((values) => {
if (!selectedZone) {
form.setError('zone_id', { message: '请选择 Zone' });
form.setError('zone_id', { message: t('selectZone') });
return;
}
const resolved = resolveZoneDomainInput(
@@ -185,7 +201,12 @@ export function QuickCreateZoneDomainDialog({
selectedZone.domain,
);
if (resolved.error) {
form.setError('domain_input', { message: resolved.error });
form.setError('domain_input', {
message: domainErrorMessage(
resolved.error,
selectedZone.domain,
),
});
return;
}
mutation.mutate(values);
@@ -199,7 +220,7 @@ export function QuickCreateZoneDomainDialog({
onValueChange={(value) => form.setValue('zone_id', value)}
>
<SelectTrigger>
<SelectValue placeholder='选择注册根域' />
<SelectValue placeholder={t('selectRegisteredRoot')} />
</SelectTrigger>
<SelectContent>
{zones.map((zone) => (
@@ -217,7 +238,7 @@ export function QuickCreateZoneDomainDialog({
</div>
) : (
<div className='rounded-md border bg-muted/30 px-3 py-2 text-sm'>
Zone:
{t('zoneLabel')}
<span className='ml-1 font-medium'>
{fixedZoneRoot || fixedZone?.domain || `#${fixedZoneId}`}
</span>
@@ -225,28 +246,38 @@ export function QuickCreateZoneDomainDialog({
)}
<div className='space-y-1.5'>
<Label htmlFor='domain-input'>域名</Label>
<Label htmlFor='domain-input'>{t('domain')}</Label>
<Input
id='domain-input'
placeholder={
selectedZone
? `api 或 @ 或 api.${selectedZone.domain}`
: 'api / @ / 完整域名'
? t('domainPlaceholderWithZone', {
domain: selectedZone.domain,
})
: t('domainPlaceholder')
}
{...form.register('domain_input')}
/>
{preview ? (
<p className='text-xs text-muted-foreground'>
将创建:
{t('willCreate')}
<code className='ml-1 rounded bg-muted px-1 py-0.5 font-mono text-[11px]'>
{preview}
</code>
</p>
) : (
<p className='text-xs text-muted-foreground'>
示例:输入 <code className='font-mono'>api</code> →{' '}
<code className='font-mono'>api.zone.com</code>;
<code className='font-mono'>@</code> → 根域本身
{t.rich('domainExample', {
api: (chunks) => (
<code className='font-mono'>{chunks}</code>
),
fqdn: (chunks) => (
<code className='font-mono'>{chunks}</code>
),
apex: (chunks) => (
<code className='font-mono'>{chunks}</code>
),
})}
</p>
)}
{form.formState.errors.domain_input ? (
@@ -257,7 +288,7 @@ export function QuickCreateZoneDomainDialog({
</div>
<div className='space-y-1.5'>
<Label>证书(可选)</Label>
<Label>{t('certOptional')}</Label>
<Select
value={form.watch('cert_id') || '__none'}
onValueChange={(value) =>
@@ -265,10 +296,10 @@ export function QuickCreateZoneDomainDialog({
}
>
<SelectTrigger>
<SelectValue placeholder='不绑定证书' />
<SelectValue placeholder={t('noCert')} />
</SelectTrigger>
<SelectContent>
<SelectItem value='__none'>不绑定证书</SelectItem>
<SelectItem value='__none'>{t('noCert')}</SelectItem>
{(certificatesQuery.data ?? []).map((certificate) => (
<SelectItem
key={certificate.id}
@@ -284,7 +315,7 @@ export function QuickCreateZoneDomainDialog({
<DialogFooter>
<Button variant='outline' onClick={() => onOpenChange(false)}>
取消
{tc('cancel')}
</Button>
<Button
type='submit'
@@ -294,7 +325,7 @@ export function QuickCreateZoneDomainDialog({
{mutation.isPending ? (
<Loader2 className='mr-1 size-4 animate-spin' />
) : null}
添加域名
{t('addDomain')}
</Button>
</DialogFooter>
</DialogContent>
@@ -5,21 +5,29 @@
* - single label `name` → `name.example.com`
* - full FQDN must equal the root or end with `.root`
*/
export type ZoneDomainInputError =
| 'selectZone'
| 'enterDomain'
| 'noWildcard'
| 'invalidFormat'
| 'invalidSubdomain'
| 'mustBelongToZone';
export function resolveZoneDomainInput(
rawInput: string,
zoneRoot: string,
): { domain: string; error?: string } {
): { domain: string; error?: ZoneDomainInputError } {
const input = rawInput.trim().toLowerCase();
const root = zoneRoot.trim().toLowerCase();
if (!root) {
return { domain: '', error: '请先选择 Zone' };
return { domain: '', error: 'selectZone' };
}
if (!input) {
return { domain: '', error: '请输入域名' };
return { domain: '', error: 'enterDomain' };
}
if (input.includes('*')) {
return { domain: '', error: 'Zone 域名不支持通配符' };
return { domain: '', error: 'noWildcard' };
}
if (
input.includes('://') ||
@@ -27,7 +35,7 @@ export function resolveZoneDomainInput(
input.includes('?') ||
input.includes('#')
) {
return { domain: '', error: '域名格式不合法' };
return { domain: '', error: 'invalidFormat' };
}
if (input === '@') {
@@ -37,7 +45,7 @@ export function resolveZoneDomainInput(
// Short label: no dots → prefix under zone root
if (!input.includes('.')) {
if (!/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/i.test(input)) {
return { domain: '', error: '子域名称格式不合法' };
return { domain: '', error: 'invalidSubdomain' };
}
return { domain: `${input}.${root}` };
}
@@ -47,7 +55,7 @@ export function resolveZoneDomainInput(
return { domain: input };
}
return { domain: '', error: `域名必须属于 Zone ${root}` };
return { domain: '', error: 'mustBelongToZone' };
}
/** Live preview string for the input helper text. */
@@ -1,17 +1,23 @@
import { z } from 'zod';
export const manualImportSchema = z.object({
name: z
.string()
.trim()
.min(1, '请输入证书名称')
.max(255, '证书名称不能超过 255 个字符'),
cert_pem: z.string().trim().min(1, '请输入证书 PEM 内容'),
key_pem: z.string().trim().min(1, '请输入私钥 PEM 内容'),
remark: z.string().max(255, '备注不能超过 255 个字符'),
});
import type { TranslateFn } from './website-utils';
export type ManualImportFormValues = z.infer<typeof manualImportSchema>;
export function createManualImportSchema(t: TranslateFn) {
return z.object({
name: z
.string()
.trim()
.min(1, t('validation.nameRequired'))
.max(255, t('validation.nameTooLong')),
cert_pem: z.string().trim().min(1, t('validation.certPemRequired')),
key_pem: z.string().trim().min(1, t('validation.keyPemRequired')),
remark: z.string().max(255, t('validation.remarkTooLong')),
});
}
export type ManualImportFormValues = z.infer<
ReturnType<typeof createManualImportSchema>
>;
export type FileImportFormValues = {
name: string;
@@ -30,22 +36,26 @@ export const defaultFileImportValues: FileImportFormValues = {
remark: '',
};
export const acmeApplySchema = z.object({
name: z.string().trim().min(1, '请输入证书名称').max(255),
primary_domain: z.string().trim().min(1, '请输入主域名'),
other_domains: z.string(),
dns_account_id: z.number().min(1, '请选择 DNS 账号'),
acme_account_id: z.number(),
key_algorithm: z.string(),
auto_renew: z.boolean(),
disable_cname: z.boolean(),
skip_dns: z.boolean(),
dns1: z.string(),
dns2: z.string(),
remark: z.string().max(255),
});
export function createAcmeApplySchema(t: TranslateFn) {
return z.object({
name: z.string().trim().min(1, t('validation.nameRequired')).max(255),
primary_domain: z.string().trim().min(1, t('validation.primaryDomainRequired')),
other_domains: z.string(),
dns_account_id: z.number().min(1, t('validation.dnsAccountRequired')),
acme_account_id: z.number(),
key_algorithm: z.string(),
auto_renew: z.boolean(),
disable_cname: z.boolean(),
skip_dns: z.boolean(),
dns1: z.string(),
dns2: z.string(),
remark: z.string().max(255),
});
}
export type AcmeApplyFormValues = z.infer<typeof acmeApplySchema>;
export type AcmeApplyFormValues = z.infer<
ReturnType<typeof createAcmeApplySchema>
>;
export const defaultAcmeApplyValues: AcmeApplyFormValues = {
name: '',
@@ -9,8 +9,15 @@ import type { FileImportFormValues, ManualImportFormValues } from './schemas';
export type StatusTone = 'success' | 'warning' | 'danger' | 'info';
export function getErrorMessage(error: unknown) {
return error instanceof Error ? error.message : '请求失败,请稍后重试。';
export type TranslateFn = (
key: string,
values?: Record<string, string | number>,
) => string;
export function getErrorMessage(error: unknown, fallback?: string) {
return error instanceof Error
? error.message
: (fallback ?? '请求失败,请稍后重试。');
}
export function getMatchTypeMeta(domain: string): {
@@ -22,7 +29,10 @@ export function getMatchTypeMeta(domain: string): {
: { label: '精确匹配', tone: 'info' };
}
export function getCertificateStatus(certificate: TlsCertificateItem): {
export function getCertificateStatus(
certificate: TlsCertificateItem,
t: TranslateFn,
): {
label: string;
tone: StatusTone;
} {
@@ -31,18 +41,18 @@ export function getCertificateStatus(certificate: TlsCertificateItem): {
const days = Math.ceil(diffMs / (1000 * 60 * 60 * 24));
if (Number.isNaN(expiresAt)) {
return { label: '有效期未知', tone: 'warning' };
return { label: t('certStatus.unknownExpiry'), tone: 'warning' };
}
if (days < 0) {
return { label: '已过期', tone: 'danger' };
return { label: t('certStatus.expired'), tone: 'danger' };
}
if (days <= 30) {
return { label: `${days} 天内到期`, tone: 'warning' };
return { label: t('certStatus.expiresInDays', { days }), tone: 'warning' };
}
return { label: '有效', tone: 'success' };
return { label: t('certStatus.valid'), tone: 'success' };
}
export function buildCertificateLabel(certificate: TlsCertificateItem) {
@@ -66,9 +76,10 @@ export function toFilePayload(
values: FileImportFormValues,
certFile: File | null,
keyFile: File | null,
missingFilesMessage: string,
): TlsCertificateFileImportPayload {
if (!certFile || !keyFile) {
throw new Error('请选择证书文件和私钥文件。');
throw new Error(missingFilesMessage);
}
return {
+16 -16
View File
@@ -27,11 +27,13 @@ import {
} from '@/components/ui/tooltip';
import { ZoneService, zoneQueryKey } from '@/lib/services/openflare';
import { formatDateTime } from '@/lib/utils';
import { useTranslations } from 'next-intl';
import { ZoneEditorDialog } from './[zoneId]/components/zone-editor-dialog';
import { getErrorMessage } from './components/website-utils';
export default function WebsitesPage() {
const t = useTranslations('websites');
const router = useRouter();
const [search, setSearch] = useState('');
const [editorOpen, setEditorOpen] = useState(false);
@@ -54,7 +56,7 @@ export default function WebsitesPage() {
<div className='flex items-center justify-between gap-3 pb-2'>
<div className='flex items-center gap-2'>
<Globe className='size-5 text-primary' />
<h1 className='text-2xl font-semibold tracking-tight'>网站</h1>
<h1 className='text-2xl font-semibold tracking-tight'>{t('title')}</h1>
</div>
<Button
variant='secondary'
@@ -63,15 +65,15 @@ export default function WebsitesPage() {
onClick={() => setEditorOpen(true)}
>
<Plus className='mr-1 size-3.5' />
新增 Zone
{t('createZone')}
</Button>
</div>
<div className='relative w-full sm:w-64'>
<Search className='pointer-events-none absolute left-2.5 top-2.5 size-3.5 text-muted-foreground' />
<Input
aria-label='搜索 Zone 根域'
placeholder='搜索 Zone 根域'
aria-label={t('searchRoot')}
placeholder={t('searchRoot')}
value={search}
onChange={(event) => setSearch(event.target.value)}
className='h-8 pl-8 text-xs'
@@ -81,13 +83,13 @@ export default function WebsitesPage() {
{zonesQuery.isLoading ? (
<LoadingStateWithBorder
icon={Globe}
description='加载 Zone 列表中...'
description={t('loadingList')}
/>
) : zonesQuery.isError ? (
<div className='rounded-lg border border-dashed p-8'>
<ErrorInline
className='justify-center'
message={getErrorMessage(zonesQuery.error)}
message={getErrorMessage(zonesQuery.error, t('requestFailed'))}
onRetry={() => void zonesQuery.refetch()}
/>
</div>
@@ -95,9 +97,7 @@ export default function WebsitesPage() {
<EmptyStateWithBorder
icon={Globe}
description={
search
? '未找到匹配的 Zone。'
: '暂无 Zone,点击右上角「新增 Zone」开始录入。'
search ? t('emptySearch') : t('emptyList')
}
/>
) : (
@@ -110,19 +110,19 @@ export default function WebsitesPage() {
ID
</TableHead>
<TableHead className='h-8 min-w-[180px] whitespace-nowrap py-2'>
根域
{t('rootDomain')}
</TableHead>
<TableHead className='h-8 min-w-[100px] whitespace-nowrap py-2'>
域名数
{t('domainCount')}
</TableHead>
<TableHead className='h-8 min-w-[140px] whitespace-nowrap py-2'>
创建时间
{t('createdAt')}
</TableHead>
<TableHead className='h-8 min-w-[140px] whitespace-nowrap py-2'>
更新时间
{t('updatedAt')}
</TableHead>
<TableHead className='sticky right-0 z-10 h-8 w-[90px] bg-background py-2 text-center'>
操作
{t('actions')}
</TableHead>
</TableRow>
</TableHeader>
@@ -163,12 +163,12 @@ export default function WebsitesPage() {
>
<Link href={`/websites/${zone.id}`}>
<Eye className='size-3' />
<span className='sr-only'>管理</span>
<span className='sr-only'>{t('manage')}</span>
</Link>
</Button>
</TooltipTrigger>
<TooltipContent side='top' className='text-xs'>
管理 Zone
{t('manageZone')}
</TooltipContent>
</Tooltip>
</div>