'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 } 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 { AppModal } from '@/components/ui/app-modal'; import { AppCard } from '@/components/ui/app-card'; import { StatusBadge } from '@/components/ui/status-badge'; import { createTlsCertificate, deleteTlsCertificate, getTlsCertificates, importTlsCertificateFiles, } from '@/features/tls-certificates/api/tls-certificates'; import type { TlsCertificateFileImportPayload, TlsCertificateItem, TlsCertificateMutationPayload, } from '@/features/tls-certificates/types'; import { DangerButton, PrimaryButton, ResourceField, ResourceInput, ResourceTextarea, SecondaryButton, } from '@/features/shared/components/resource-primitives'; import { formatDateTime } from '@/lib/utils/date'; const tlsCertificatesQueryKey = ['tls-certificates', 'list'] as const; 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 ManualImportFormValues = z.infer; type FileImportFormValues = { name: string; remark: string; }; type FeedbackState = { tone: 'info' | 'success' | 'danger'; message: string; }; 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 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 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 TlsCertificatesPage() { const queryClient = useQueryClient(); 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 manualForm = useForm({ resolver: zodResolver(manualImportSchema), defaultValues: defaultManualValues, }); const certificatesQuery = useQuery({ queryKey: tlsCertificatesQueryKey, queryFn: getTlsCertificates, }); const manualImportMutation = useMutation({ mutationFn: async (values: ManualImportFormValues) => createTlsCertificate(toManualPayload(values)), onSuccess: async () => { setFeedback({ tone: 'success', message: '证书已导入。' }); setImportMode('manual'); setIsImportModalOpen(false); manualForm.reset(defaultManualValues); await 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 queryClient.invalidateQueries({ queryKey: tlsCertificatesQueryKey, }); }, onError: (error) => { setFeedback({ tone: 'danger', message: getErrorMessage(error) }); }, }); const deleteMutation = useMutation({ mutationFn: deleteTlsCertificate, onSuccess: async () => { setFeedback({ tone: 'success', message: '证书已删除。' }); await queryClient.invalidateQueries({ queryKey: tlsCertificatesQueryKey, }); }, onError: (error) => { setFeedback({ tone: 'danger', message: getErrorMessage(error) }); }, }); const certificates = useMemo( () => certificatesQuery.data ?? [], [certificatesQuery.data], ); const handleManualSubmit = manualForm.handleSubmit((values) => { setFeedback(null); manualImportMutation.mutate(values); }); const handleFileSubmit = (event: FormEvent) => { event.preventDefault(); setFeedback(null); fileImportMutation.mutate(fileForm); }; const handleDelete = (certificate: TlsCertificateItem) => { if (!window.confirm(`确认删除证书 ${certificate.name} 吗?`)) { return; } setFeedback(null); deleteMutation.mutate(certificate.id); }; const handleCloseImportModal = () => { setImportMode('manual'); setIsImportModalOpen(false); }; return ( <>
{ setImportMode('manual'); setIsImportModalOpen(true); }} > 导入证书 } /> {feedback ? ( ) : null} void queryClient.invalidateQueries({ queryKey: tlsCertificatesQueryKey, }) } > 刷新 } > {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 || '—'} {formatDateTime(certificate.updated_at)} handleDelete(certificate)} disabled={deleteMutation.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} > 清空文件
)}
); }