mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-13 18:36:37 +08:00
feat(frontend): 控制台 next-intl 中英双语
接入无 URL 前缀的 zh-CN/en,顶栏与外观设置可切换语言;选择写入 cookie 后刷新生效。
This commit is contained in:
@@ -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'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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user