'use client'; import { zodResolver } from '@hookform/resolvers/zod'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMemo, useState, type FormEvent } from 'react'; import { useForm, useWatch } from 'react-hook-form'; import { z } from 'zod'; import { EmptyState } from '@/components/feedback/empty-state'; import { ErrorState } from '@/components/feedback/error-state'; import { InlineMessage } from '@/components/feedback/inline-message'; import { LoadingState } from '@/components/feedback/loading-state'; import { PageHeader } from '@/components/layout/page-header'; import { AppCard } from '@/components/ui/app-card'; import { AppModal } from '@/components/ui/app-modal'; import { StatusBadge } from '@/components/ui/status-badge'; import { createManagedDomain, deleteManagedDomain, getManagedDomains, updateManagedDomain, } from '@/features/managed-domains/api/managed-domains'; import type { ManagedDomainItem, ManagedDomainMutationPayload, } from '@/features/managed-domains/types'; import { DangerButton, PrimaryButton, ResourceField, ResourceInput, ResourceSelect, ResourceTextarea, SecondaryButton, ToggleField, } from '@/features/shared/components/resource-primitives'; import { createTlsCertificate, deleteTlsCertificate, getTlsCertificates, importTlsCertificateFiles, } from '@/features/tls-certificates/api/tls-certificates'; import type { TlsCertificateFileImportPayload, TlsCertificateItem, TlsCertificateMutationPayload, } from '@/features/tls-certificates/types'; import { formatDateTime } from '@/lib/utils/date'; const managedDomainsQueryKey = ['managed-domains'] as const; const managedDomainCertificatesQueryKey = [ 'managed-domains', 'certificates', ] as const; const tlsCertificatesQueryKey = ['tls-certificates', 'list'] as const; const managedDomainSchema = z.object({ domain: z .string() .trim() .min(1, '请输入域名') .max(255, '域名不能超过 255 个字符') .refine( (value) => !value.includes('://') && !value.includes('/'), '域名格式不合法', ) .refine( (value) => /^(?:\*\.)?(?=.{1,253}$)(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,63}$/.test( value, ), '域名格式不合法', ) .refine( (value) => !value.includes('*') || (value.startsWith('*.') && value.indexOf('*', 1) === -1), '通配符域名仅支持 *.example.com 格式', ), cert_id: z.string(), enabled: z.boolean(), remark: z.string().max(255, '备注不能超过 255 个字符'), }); 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 个字符'), }); type ManagedDomainFormValues = z.infer; type ManualImportFormValues = z.infer; type FileImportFormValues = { name: string; remark: string; }; type FeedbackState = { tone: 'info' | 'success' | 'danger'; message: string; }; const defaultDomainValues: ManagedDomainFormValues = { domain: '', cert_id: '', enabled: true, remark: '', }; const defaultManualValues: ManualImportFormValues = { name: '', cert_pem: '', key_pem: '', remark: '', }; const defaultFileValues: FileImportFormValues = { name: '', remark: '', }; function getErrorMessage(error: unknown) { return error instanceof Error ? error.message : '请求失败,请稍后重试。'; } function getMatchTypeMeta(domain: string) { return domain.startsWith('*.') ? { label: '通配符', variant: 'warning' as const } : { label: '精确匹配', variant: 'info' as const }; } function getCertificateStatus(certificate: TlsCertificateItem) { const expiresAt = new Date(certificate.not_after).getTime(); const diffMs = expiresAt - Date.now(); const days = Math.ceil(diffMs / (1000 * 60 * 60 * 24)); if (Number.isNaN(expiresAt)) { return { label: '有效期未知', variant: 'warning' as const }; } if (days < 0) { return { label: '已过期', variant: 'danger' as const }; } if (days <= 30) { return { label: `${days} 天内到期`, variant: 'warning' as const }; } return { label: '有效', variant: 'success' as const }; } function buildCertificateLabel(certificate: TlsCertificateItem) { return certificate.not_after ? `${certificate.name}(到期:${formatDateTime(certificate.not_after)})` : certificate.name; } function toManagedDomainPayload( values: ManagedDomainFormValues, ): ManagedDomainMutationPayload { return { domain: values.domain.trim().toLowerCase(), cert_id: values.cert_id ? Number(values.cert_id) : null, enabled: values.enabled, remark: values.remark.trim(), }; } function toManagedDomainFormValues( domain: ManagedDomainItem, ): ManagedDomainFormValues { return { domain: domain.domain, cert_id: domain.cert_id ? String(domain.cert_id) : '', enabled: domain.enabled, remark: domain.remark || '', }; } function toManualPayload( values: ManualImportFormValues, ): TlsCertificateMutationPayload { return { name: values.name.trim(), cert_pem: values.cert_pem.trim(), key_pem: values.key_pem.trim(), remark: values.remark.trim(), }; } function toFilePayload( values: FileImportFormValues, certFile: File | null, keyFile: File | null, ): TlsCertificateFileImportPayload { if (!certFile || !keyFile) { throw new Error('请选择证书文件和私钥文件。'); } return { name: values.name.trim(), remark: values.remark.trim(), certFile, keyFile, }; } export function WebsitesPage() { const queryClient = useQueryClient(); const [editingDomainId, setEditingDomainId] = useState(null); const [feedback, setFeedback] = useState(null); const [isImportModalOpen, setIsImportModalOpen] = useState(false); const [importMode, setImportMode] = useState<'manual' | 'file'>('manual'); const [fileForm, setFileForm] = useState(defaultFileValues); const [certFile, setCertFile] = useState(null); const [keyFile, setKeyFile] = useState(null); const [fileInputNonce, setFileInputNonce] = useState(0); const domainForm = useForm({ resolver: zodResolver(managedDomainSchema), defaultValues: defaultDomainValues, }); const manualForm = useForm({ resolver: zodResolver(manualImportSchema), defaultValues: defaultManualValues, }); const watchedDomain = useWatch({ control: domainForm.control, name: 'domain', }); const watchedCertId = useWatch({ control: domainForm.control, name: 'cert_id', }); const watchedEnabled = useWatch({ control: domainForm.control, name: 'enabled', }); const managedDomainsQuery = useQuery({ queryKey: managedDomainsQueryKey, queryFn: getManagedDomains, }); const certificatesQuery = useQuery({ queryKey: managedDomainCertificatesQueryKey, queryFn: getTlsCertificates, }); const saveDomainMutation = useMutation({ mutationFn: async (values: ManagedDomainFormValues) => { const payload = toManagedDomainPayload(values); return editingDomainId ? updateManagedDomain(editingDomainId, payload) : createManagedDomain(payload); }, onSuccess: async () => { setFeedback({ tone: 'success', message: editingDomainId ? '网站已更新。' : '网站已创建。', }); setEditingDomainId(null); domainForm.reset(defaultDomainValues); await queryClient.invalidateQueries({ queryKey: managedDomainsQueryKey }); }, onError: (error) => { setFeedback({ tone: 'danger', message: getErrorMessage(error) }); }, }); const deleteDomainMutation = useMutation({ mutationFn: deleteManagedDomain, onSuccess: async () => { setFeedback({ tone: 'success', message: '网站已删除。' }); await queryClient.invalidateQueries({ queryKey: managedDomainsQueryKey }); }, onError: (error) => { setFeedback({ tone: 'danger', message: getErrorMessage(error) }); }, }); const manualImportMutation = useMutation({ mutationFn: async (values: ManualImportFormValues) => createTlsCertificate(toManualPayload(values)), onSuccess: async () => { setFeedback({ tone: 'success', message: '证书已导入。' }); setImportMode('manual'); setIsImportModalOpen(false); manualForm.reset(defaultManualValues); await Promise.all([ queryClient.invalidateQueries({ queryKey: managedDomainCertificatesQueryKey, }), queryClient.invalidateQueries({ queryKey: tlsCertificatesQueryKey, }), ]); }, onError: (error) => { setFeedback({ tone: 'danger', message: getErrorMessage(error) }); }, }); const fileImportMutation = useMutation({ mutationFn: async (values: FileImportFormValues) => importTlsCertificateFiles(toFilePayload(values, certFile, keyFile)), onSuccess: async () => { setFeedback({ tone: 'success', message: '证书文件已导入。' }); setImportMode('manual'); setIsImportModalOpen(false); setFileForm(defaultFileValues); setCertFile(null); setKeyFile(null); setFileInputNonce((value) => value + 1); await Promise.all([ queryClient.invalidateQueries({ queryKey: managedDomainCertificatesQueryKey, }), queryClient.invalidateQueries({ queryKey: tlsCertificatesQueryKey, }), ]); }, onError: (error) => { setFeedback({ tone: 'danger', message: getErrorMessage(error) }); }, }); const deleteCertificateMutation = useMutation({ mutationFn: deleteTlsCertificate, onSuccess: async () => { setFeedback({ tone: 'success', message: '证书已删除。' }); await Promise.all([ queryClient.invalidateQueries({ queryKey: managedDomainCertificatesQueryKey, }), queryClient.invalidateQueries({ queryKey: tlsCertificatesQueryKey, }), queryClient.invalidateQueries({ queryKey: managedDomainsQueryKey }), ]); }, onError: (error) => { setFeedback({ tone: 'danger', message: getErrorMessage(error) }); }, }); const domains = useMemo( () => managedDomainsQuery.data ?? [], [managedDomainsQuery.data], ); const certificates = useMemo( () => certificatesQuery.data ?? [], [certificatesQuery.data], ); const certificateMap = useMemo( () => new Map(certificates.map((item) => [item.id, item])), [certificates], ); const currentCertificate = watchedCertId ? certificateMap.get(Number(watchedCertId)) : null; const handleDomainSubmit = domainForm.handleSubmit((values) => { setFeedback(null); saveDomainMutation.mutate(values); }); const handleDomainReset = () => { setEditingDomainId(null); domainForm.reset(defaultDomainValues); }; const handleEditDomain = (domain: ManagedDomainItem) => { setEditingDomainId(domain.id); setFeedback(null); domainForm.reset(toManagedDomainFormValues(domain)); window.scrollTo({ top: 0, behavior: 'smooth' }); }; const handleDeleteDomain = (domain: ManagedDomainItem) => { if (!window.confirm(`确认删除网站 ${domain.domain} 吗?`)) { return; } setFeedback(null); deleteDomainMutation.mutate(domain.id); }; const handleManualSubmit = manualForm.handleSubmit((values) => { setFeedback(null); manualImportMutation.mutate(values); }); const handleFileSubmit = (event: FormEvent) => { event.preventDefault(); setFeedback(null); fileImportMutation.mutate(fileForm); }; const handleDeleteCertificate = (certificate: TlsCertificateItem) => { if (!window.confirm(`确认删除证书 ${certificate.name} 吗?`)) { return; } setFeedback(null); deleteCertificateMutation.mutate(certificate.id); }; const handleCloseImportModal = () => { setImportMode('manual'); setIsImportModalOpen(false); }; return ( <>
{feedback ? ( ) : null}
{editingDomainId ? ( 取消编辑 ) : null} void handleDomainSubmit()} disabled={saveDomainMutation.isPending} > {saveDomainMutation.isPending ? '保存中...' : editingDomainId ? '保存网站' : '创建网站'}
} >
domainForm.setValue('cert_id', event.target.value, { shouldDirty: true, shouldValidate: true, }) } > {certificates.map((certificate) => ( ))}
domainForm.setValue('enabled', checked, { shouldDirty: true, shouldValidate: true, }) } /> void queryClient.invalidateQueries({ queryKey: managedDomainCertificatesQueryKey, }) } > 刷新证书 { setImportMode('manual'); setIsImportModalOpen(true); }} > 导入证书
} >

当前网站输入

{watchedDomain.trim() || '未填写域名'}

匹配类型

{watchedDomain.trim() ? ( ) : ( )}

当前证书

建议流程:先导入证书,再在左侧创建网站并直接绑定。若同一 hostname 同时命中精确和通配符, 系统会优先使用精确匹配。

{managedDomainsQuery.isLoading ? ( ) : managedDomainsQuery.isError ? ( ) : domains.length === 0 ? ( ) : (
{domains.map((domain) => { const certificate = domain.cert_id ? certificateMap.get(domain.cert_id) : null; const matchType = getMatchTypeMeta(domain.domain); return ( ); })}
域名 类型 绑定证书 状态 更新时间 操作

{domain.domain}

{domain.remark || '—'}

{certificate ? certificate.name : '未绑定'} {formatDateTime(domain.updated_at)}
handleEditDomain(domain)} className="px-3 py-2 text-xs" > 编辑 handleDeleteDomain(domain)} disabled={deleteDomainMutation.isPending} className="px-3 py-2 text-xs" > 删除
)}
{certificatesQuery.isLoading ? ( ) : certificatesQuery.isError ? ( ) : certificates.length === 0 ? ( ) : (
{certificates.map((certificate) => { const status = getCertificateStatus(certificate); return (

{certificate.name}

生效:{formatDateTime(certificate.not_before)}

到期:{formatDateTime(certificate.not_after)}

备注:{certificate.remark || '—'}

handleDeleteCertificate(certificate)} disabled={deleteCertificateMutation.isPending} className="px-3 py-2 text-xs" > 删除
); })}
)}
{importMode === 'manual' ? (
{manualImportMutation.isPending ? '导入中...' : '导入证书'}
) : (
setFileForm((current) => ({ ...current, name: event.target.value, })) } placeholder="wildcard-example" /> setFileForm((current) => ({ ...current, remark: event.target.value, })) } placeholder="例如:泛域名生产证书" />
setCertFile(event.target.files?.[0] ?? null) } /> setKeyFile(event.target.files?.[0] ?? null) } />
{fileImportMutation.isPending ? '上传中...' : '上传文件'} { setFileForm(defaultFileValues); setCertFile(null); setKeyFile(null); setFileInputNonce((value) => value + 1); }} disabled={fileImportMutation.isPending} > 清空文件
)}
); }