diff --git a/atsf_server/web/app/(dashboard)/website/detail/page.tsx b/atsf_server/web/app/(dashboard)/website/detail/page.tsx new file mode 100644 index 00000000..621bc0d7 --- /dev/null +++ b/atsf_server/web/app/(dashboard)/website/detail/page.tsx @@ -0,0 +1,11 @@ +'use client'; + +import { useSearchParams } from 'next/navigation'; + +import { WebsiteDetailPage } from '@/features/websites/components/website-detail-page'; + +export default function WebsiteDetailRoute() { + const searchParams = useSearchParams(); + + return ; +} diff --git a/atsf_server/web/features/websites/components/certificate-import-modal.tsx b/atsf_server/web/features/websites/components/certificate-import-modal.tsx new file mode 100644 index 00000000..1617b82e --- /dev/null +++ b/atsf_server/web/features/websites/components/certificate-import-modal.tsx @@ -0,0 +1,320 @@ +'use client'; + +import { zodResolver } from '@hookform/resolvers/zod'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { useEffect, useState, type FormEvent } from 'react'; +import { useForm } from 'react-hook-form'; + +import { InlineMessage } from '@/components/feedback/inline-message'; +import { AppCard } from '@/components/ui/app-card'; +import { AppModal } from '@/components/ui/app-modal'; +import { + createTlsCertificate, + importTlsCertificateFiles, +} from '@/features/tls-certificates/api/tls-certificates'; +import type { TlsCertificateItem } from '@/features/tls-certificates/types'; +import { + defaultFileImportValues, + defaultManualImportValues, + type FileImportFormValues, + manualImportSchema, + type ManualImportFormValues, +} from '@/features/websites/schemas'; +import { + getErrorMessage, + toFilePayload, + toManualPayload, +} from '@/features/websites/utils'; +import { + PrimaryButton, + ResourceField, + ResourceInput, + ResourceTextarea, + SecondaryButton, +} from '@/features/shared/components/resource-primitives'; + +type FeedbackState = { + tone: 'success' | 'danger'; + message: string; +}; + +interface CertificateImportModalProps { + isOpen: boolean; + onClose: () => void; + onImported?: (certificate: TlsCertificateItem) => void; +} + +export function CertificateImportModal({ + isOpen, + onClose, + onImported, +}: CertificateImportModalProps) { + const queryClient = useQueryClient(); + const [importMode, setImportMode] = useState<'manual' | 'file'>('manual'); + const [feedback, setFeedback] = useState(null); + const [fileForm, setFileForm] = + useState(defaultFileImportValues); + const [certFile, setCertFile] = useState(null); + const [keyFile, setKeyFile] = useState(null); + const [fileInputNonce, setFileInputNonce] = useState(0); + + const manualForm = useForm({ + resolver: zodResolver(manualImportSchema), + defaultValues: defaultManualImportValues, + }); + + useEffect(() => { + if (!isOpen) { + return; + } + + setFeedback(null); + }, [isOpen]); + + const resetFileForm = () => { + setFileForm(defaultFileImportValues); + setCertFile(null); + setKeyFile(null); + setFileInputNonce((value) => value + 1); + }; + + const resetAll = () => { + setImportMode('manual'); + setFeedback(null); + manualForm.reset(defaultManualImportValues); + resetFileForm(); + }; + + const invalidateCertificateQueries = async () => { + await Promise.all([ + queryClient.invalidateQueries({ queryKey: ['tls-certificates'] }), + queryClient.invalidateQueries({ queryKey: ['managed-domains'] }), + ]); + }; + + const manualImportMutation = useMutation({ + mutationFn: async (values: ManualImportFormValues) => + createTlsCertificate(toManualPayload(values)), + onSuccess: async (certificate) => { + await invalidateCertificateQueries(); + onImported?.(certificate); + resetAll(); + onClose(); + }, + onError: (error) => { + setFeedback({ tone: 'danger', message: getErrorMessage(error) }); + }, + }); + + const fileImportMutation = useMutation({ + mutationFn: async (values: FileImportFormValues) => + importTlsCertificateFiles(toFilePayload(values, certFile, keyFile)), + onSuccess: async (certificate) => { + await invalidateCertificateQueries(); + onImported?.(certificate); + resetAll(); + onClose(); + }, + onError: (error) => { + setFeedback({ tone: 'danger', message: getErrorMessage(error) }); + }, + }); + + const handleManualSubmit = manualForm.handleSubmit((values) => { + setFeedback(null); + manualImportMutation.mutate(values); + }); + + const handleFileSubmit = (event: FormEvent) => { + event.preventDefault(); + setFeedback(null); + fileImportMutation.mutate(fileForm); + }; + + const handleClose = () => { + resetAll(); + onClose(); + }; + + return ( + +
+ {feedback ? ( + + ) : null} + +
+ + +
+ + {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 ? '上传中...' : '上传文件'} + + + 清空文件 + +
+
+
+ )} +
+
+ ); +} diff --git a/atsf_server/web/features/websites/components/website-detail-page.tsx b/atsf_server/web/features/websites/components/website-detail-page.tsx new file mode 100644 index 00000000..bf4b2e15 --- /dev/null +++ b/atsf_server/web/features/websites/components/website-detail-page.tsx @@ -0,0 +1,475 @@ +'use client'; + +import Link from 'next/link'; +import { useRouter } from 'next/navigation'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { useMemo, useState } from 'react'; + +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 { StatusBadge } from '@/components/ui/status-badge'; +import { + deleteManagedDomain, + getManagedDomains, +} from '@/features/managed-domains/api/managed-domains'; +import { + getProxyRoutes, +} from '@/features/proxy-routes/api/proxy-routes'; +import { + deleteTlsCertificate, + getTlsCertificates, +} from '@/features/tls-certificates/api/tls-certificates'; +import { CertificateImportModal } from '@/features/websites/components/certificate-import-modal'; +import { WebsiteEditorModal } from '@/features/websites/components/website-editor-modal'; +import { + buildCertificateLabel, + getCertificateStatus, + getErrorMessage, + getMatchTypeMeta, + isRouteRelatedToManagedDomain, +} from '@/features/websites/utils'; +import { + DangerButton, + PrimaryButton, + SecondaryButton, +} from '@/features/shared/components/resource-primitives'; +import { formatDateTime } from '@/lib/utils/date'; + +type FeedbackState = { + tone: 'success' | 'danger'; + message: string; +}; + +export function WebsiteDetailPage({ websiteId }: { websiteId: string }) { + const router = useRouter(); + const queryClient = useQueryClient(); + const [feedback, setFeedback] = useState(null); + const [isEditorOpen, setIsEditorOpen] = useState(false); + const [isCertificateImportOpen, setIsCertificateImportOpen] = useState(false); + const [preferredCertificateId, setPreferredCertificateId] = useState< + number | null + >(null); + + const managedDomainsQuery = useQuery({ + queryKey: ['managed-domains'], + queryFn: getManagedDomains, + }); + const certificatesQuery = useQuery({ + queryKey: ['tls-certificates', 'list'], + queryFn: getTlsCertificates, + }); + const proxyRoutesQuery = useQuery({ + queryKey: ['proxy-routes'], + queryFn: getProxyRoutes, + }); + + const deleteDomainMutation = useMutation({ + mutationFn: deleteManagedDomain, + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: ['managed-domains'] }); + router.push('/website'); + }, + 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: ['tls-certificates'] }), + queryClient.invalidateQueries({ queryKey: ['managed-domains'] }), + ]); + }, + onError: (error) => { + setFeedback({ tone: 'danger', message: getErrorMessage(error) }); + }, + }); + + const website = useMemo(() => { + return ( + (managedDomainsQuery.data ?? []).find( + (item) => String(item.id) === websiteId, + ) ?? null + ); + }, [managedDomainsQuery.data, websiteId]); + + const certificates = useMemo( + () => certificatesQuery.data ?? [], + [certificatesQuery.data], + ); + const certificateMap = useMemo( + () => new Map(certificates.map((item) => [item.id, item])), + [certificates], + ); + const relatedRoutes = useMemo(() => { + if (!website) { + return []; + } + + return (proxyRoutesQuery.data ?? []).filter((route) => + isRouteRelatedToManagedDomain(website.domain, route.domain), + ); + }, [proxyRoutesQuery.data, website]); + + const certificate = website?.cert_id + ? certificateMap.get(website.cert_id) ?? null + : null; + const enabledRoutesCount = relatedRoutes.filter((route) => route.enabled).length; + + const handleDeleteWebsite = () => { + if (!website) { + return; + } + + if (!window.confirm(`确认删除网站 ${website.domain} 吗?`)) { + return; + } + + setFeedback(null); + deleteDomainMutation.mutate(website.id); + }; + + const handleDeleteCertificate = () => { + if (!certificate) { + return; + } + + if (!window.confirm(`确认删除证书 ${certificate.name} 吗?`)) { + return; + } + + setFeedback(null); + deleteCertificateMutation.mutate(certificate.id); + }; + + if ( + managedDomainsQuery.isLoading || + certificatesQuery.isLoading || + proxyRoutesQuery.isLoading + ) { + return ; + } + + if (managedDomainsQuery.isError) { + return ( + + ); + } + + if (certificatesQuery.isError) { + return ( + + ); + } + + if (proxyRoutesQuery.isError) { + return ( + + ); + } + + if (!website) { + return ( + + ); + } + + const matchType = getMatchTypeMeta(website.domain); + const certificateStatus = certificate ? getCertificateStatus(certificate) : null; + + return ( + <> +
+ + + 返回 + + setIsEditorOpen(true)} + > + 编辑网站 + + setIsCertificateImportOpen(true)} + > + 添加证书 + + + 删除网站 + + + } + /> + + {feedback ? ( + + ) : null} + +
+ +
+ +

+ {website.domain.startsWith('*.') + ? '当前网站会覆盖该后缀下的子域名规则。' + : '当前网站只匹配同名精确域名。'} +

+
+
+ + +
+ +

+ 更新时间:{formatDateTime(website.updated_at)} +

+
+
+ + +
+

规则总数:{relatedRoutes.length}

+

已启用规则:{enabledRoutesCount}

+
+
+ + +
+ +

+ {certificateStatus + ? `证书状态:${certificateStatus.label}` + : '当前网站未设置默认证书。'} +

+
+
+
+ +
+ +
+
+

+ 域名 +

+

+ {website.domain} +

+
+
+

+ 创建时间 +

+

+ {formatDateTime(website.created_at)} +

+
+
+

+ 备注 +

+

+ {website.remark || '暂无备注'} +

+
+
+
+ + setIsCertificateImportOpen(true)} + > + 添加证书 + + } + > + {certificate ? ( +
+
+ +
+
+

证书名称:{buildCertificateLabel(certificate)}

+

生效时间:{formatDateTime(certificate.not_before)}

+

到期时间:{formatDateTime(certificate.not_after)}

+

备注:{certificate.remark || '暂无备注'}

+
+ + 删除证书 + +
+ ) : ( + + )} +
+
+ + + {relatedRoutes.length === 0 ? ( + + ) : ( +
+ + + + + + + + + + + + + {relatedRoutes.map((route) => { + const routeCertificate = route.cert_id + ? certificateMap.get(route.cert_id) ?? null + : null; + + return ( + + + + + + + + + ); + })} + +
规则域名源站HTTPS证书状态备注
+
+

{route.domain}

+ +
+
+

{route.origin_url}

+
+
+ + {route.redirect_http ? ( + + ) : null} +
+
+ {routeCertificate + ? routeCertificate.name + : '未绑定证书'} + + + + {route.remark || '暂无备注'} +
+
+ )} +
+
+ + { + setIsEditorOpen(false); + setPreferredCertificateId(null); + }} + initialDomain={website} + certificates={certificates} + certificatesLoading={certificatesQuery.isLoading} + preferredCertificateId={preferredCertificateId} + onRequestImportCertificate={() => setIsCertificateImportOpen(true)} + onSaved={() => { + setPreferredCertificateId(null); + setFeedback({ tone: 'success', message: '网站已更新。' }); + }} + /> + + setIsCertificateImportOpen(false)} + onImported={(importedCertificate) => { + setPreferredCertificateId(importedCertificate.id); + setFeedback({ + tone: 'success', + message: `证书 ${importedCertificate.name} 已导入,可在编辑网站时直接应用。`, + }); + setIsEditorOpen(true); + }} + /> + + ); +} diff --git a/atsf_server/web/features/websites/components/website-editor-modal.tsx b/atsf_server/web/features/websites/components/website-editor-modal.tsx new file mode 100644 index 00000000..7fd70a60 --- /dev/null +++ b/atsf_server/web/features/websites/components/website-editor-modal.tsx @@ -0,0 +1,276 @@ +'use client'; + +import { zodResolver } from '@hookform/resolvers/zod'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { useEffect } from 'react'; +import { useForm, useWatch } from 'react-hook-form'; + +import { InlineMessage } from '@/components/feedback/inline-message'; +import { AppModal } from '@/components/ui/app-modal'; +import { StatusBadge } from '@/components/ui/status-badge'; +import { + createManagedDomain, + updateManagedDomain, +} from '@/features/managed-domains/api/managed-domains'; +import type { ManagedDomainItem } from '@/features/managed-domains/types'; +import type { TlsCertificateItem } from '@/features/tls-certificates/types'; +import { + defaultManagedDomainValues, + managedDomainSchema, + type ManagedDomainFormValues, +} from '@/features/websites/schemas'; +import { + buildCertificateLabel, + getErrorMessage, + getMatchTypeMeta, + toManagedDomainFormValues, + toManagedDomainPayload, +} from '@/features/websites/utils'; +import { + PrimaryButton, + ResourceField, + ResourceInput, + ResourceSelect, + SecondaryButton, + ToggleField, +} from '@/features/shared/components/resource-primitives'; + +interface WebsiteEditorModalProps { + isOpen: boolean; + onClose: () => void; + onSaved?: (domain: ManagedDomainItem, mode: 'create' | 'update') => void; + onRequestImportCertificate: () => void; + certificates: TlsCertificateItem[]; + certificatesLoading?: boolean; + initialDomain?: ManagedDomainItem | null; + preferredCertificateId?: number | null; +} + +export function WebsiteEditorModal({ + isOpen, + onClose, + onSaved, + onRequestImportCertificate, + certificates, + certificatesLoading = false, + initialDomain = null, + preferredCertificateId = null, +}: WebsiteEditorModalProps) { + const queryClient = useQueryClient(); + const form = useForm({ + resolver: zodResolver(managedDomainSchema), + defaultValues: defaultManagedDomainValues, + }); + + const watchedDomain = useWatch({ + control: form.control, + name: 'domain', + }); + const watchedCertId = useWatch({ + control: form.control, + name: 'cert_id', + }); + const watchedEnabled = useWatch({ + control: form.control, + name: 'enabled', + }); + + const saveMutation = useMutation({ + mutationFn: async (values: ManagedDomainFormValues) => { + const payload = toManagedDomainPayload(values); + return initialDomain + ? updateManagedDomain(initialDomain.id, payload) + : createManagedDomain(payload); + }, + onSuccess: async (domain) => { + await queryClient.invalidateQueries({ queryKey: ['managed-domains'] }); + onSaved?.(domain, initialDomain ? 'update' : 'create'); + handleClose(); + }, + }); + + const currentCertificate = watchedCertId + ? certificates.find((item) => item.id === Number(watchedCertId)) ?? null + : null; + + const handleSubmit = form.handleSubmit((values) => { + saveMutation.mutate(values); + }); + + const handleClose = () => { + saveMutation.reset(); + form.reset(defaultManagedDomainValues); + onClose(); + }; + + useEffect(() => { + if (!isOpen) { + return; + } + + saveMutation.reset(); + form.reset( + initialDomain + ? toManagedDomainFormValues(initialDomain) + : defaultManagedDomainValues, + ); + }, [form, initialDomain, isOpen]); + + useEffect(() => { + if (!isOpen || !preferredCertificateId) { + return; + } + + form.setValue('cert_id', String(preferredCertificateId), { + shouldDirty: true, + shouldValidate: true, + }); + }, [form, isOpen, preferredCertificateId]); + + return ( + + + 取消 + + + {saveMutation.isPending + ? '保存中...' + : initialDomain + ? '保存网站' + : '创建网站'} + + + } + > +
+ {saveMutation.isError ? ( + + ) : null} + +
+ + + + + +
+ + form.setValue('cert_id', event.target.value, { + shouldDirty: true, + shouldValidate: true, + }) + } + > + + {certificates.map((certificate) => ( + + ))} + + + 添加证书 + +
+
+
+ +
+
+

+ 当前域名 +

+

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

+
+
+

+ 匹配类型 +

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

+ 当前证书 +

+
+ +
+
+
+ + + form.setValue('enabled', checked, { + shouldDirty: true, + shouldValidate: true, + }) + } + /> + + + + + +
+ ); +} diff --git a/atsf_server/web/features/websites/components/websites-page.tsx b/atsf_server/web/features/websites/components/websites-page.tsx index 68196856..70c80367 100644 --- a/atsf_server/web/features/websites/components/websites-page.tsx +++ b/atsf_server/web/features/websites/components/websites-page.tsx @@ -1,10 +1,8 @@ 'use client'; -import { zodResolver } from '@hookform/resolvers/zod'; +import Link from 'next/link'; 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 { useMemo, useState } from 'react'; import { EmptyState } from '@/components/feedback/empty-state'; import { ErrorState } from '@/components/feedback/error-state'; @@ -15,318 +13,59 @@ 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 type { ManagedDomainItem } from '@/features/managed-domains/types'; +import { + deleteTlsCertificate, + getTlsCertificates, +} from '@/features/tls-certificates/api/tls-certificates'; +import type { TlsCertificateItem } from '@/features/tls-certificates/types'; +import { CertificateImportModal } from '@/features/websites/components/certificate-import-modal'; +import { WebsiteEditorModal } from '@/features/websites/components/website-editor-modal'; +import { + buildCertificateLabel, + getCertificateStatus, + getErrorMessage, + getMatchTypeMeta, +} from '@/features/websites/utils'; 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 [isWebsiteModalOpen, setIsWebsiteModalOpen] = useState(false); + const [isCertificateImportOpen, setIsCertificateImportOpen] = useState(false); + const [isCertificateListOpen, setIsCertificateListOpen] = useState(false); + const [preferredCertificateId, setPreferredCertificateId] = useState< + number | null + >(null); const managedDomainsQuery = useQuery({ - queryKey: managedDomainsQueryKey, + queryKey: ['managed-domains'], queryFn: getManagedDomains, }); const certificatesQuery = useQuery({ - queryKey: managedDomainCertificatesQueryKey, + queryKey: ['tls-certificates', 'list'], 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, - }), - ]); + await queryClient.invalidateQueries({ queryKey: ['managed-domains'] }); }, onError: (error) => { setFeedback({ tone: 'danger', message: getErrorMessage(error) }); @@ -338,13 +77,8 @@ export function WebsitesPage() { onSuccess: async () => { setFeedback({ tone: 'success', message: '证书已删除。' }); await Promise.all([ - queryClient.invalidateQueries({ - queryKey: managedDomainCertificatesQueryKey, - }), - queryClient.invalidateQueries({ - queryKey: tlsCertificatesQueryKey, - }), - queryClient.invalidateQueries({ queryKey: managedDomainsQueryKey }), + queryClient.invalidateQueries({ queryKey: ['tls-certificates'] }), + queryClient.invalidateQueries({ queryKey: ['managed-domains'] }), ]); }, onError: (error) => { @@ -364,566 +98,263 @@ export function WebsitesPage() { () => new Map(certificates.map((item) => [item.id, item])), [certificates], ); - const currentCertificate = watchedCertId - ? certificateMap.get(Number(watchedCertId)) - : null; - const handleDomainSubmit = domainForm.handleSubmit((values) => { + const handleOpenWebsiteModal = () => { + setPreferredCertificateId(null); 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' }); + setIsWebsiteModalOpen(true); }; 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 ( <>
+ setIsCertificateListOpen(true)} + > + 证书列表 + + + 新增网站 + + + } /> {feedback ? ( ) : null} -
- - {editingDomainId ? ( - - 取消编辑 - - ) : null} - void handleDomainSubmit()} - disabled={saveDomainMutation.isPending} - > - {saveDomainMutation.isPending - ? '保存中...' - : editingDomainId - ? '保存网站' - : '创建网站'} - -
- } - > -
-
- - - - - - domainForm.setValue('cert_id', event.target.value, { - shouldDirty: true, - shouldValidate: true, - }) - } + + {managedDomainsQuery.isLoading ? ( + + ) : managedDomainsQuery.isError ? ( + + ) : domains.length === 0 ? ( + + ) : ( +
+ {domains.map((domain) => { + const certificate = domain.cert_id + ? certificateMap.get(domain.cert_id) ?? null + : null; + const matchType = getMatchTypeMeta(domain.domain); + + return ( +
- - {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 || '—'} -

-
-
+
+
+
+
+

+ {domain.domain} +

-
- {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)} +

+ {domain.remark || '暂无备注'} +

+
+ +
+
+

+ 绑定证书 +

+

+ {certificate + ? buildCertificateLabel(certificate) + : '未绑定证书'} +

+
+
+

+ 更新时间 +

+

+ {formatDateTime(domain.updated_at)}

-

到期:{formatDateTime(certificate.not_after)}

-

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

+
+ +
+ + 详情 + handleDeleteCertificate(certificate)} - disabled={deleteCertificateMutation.isPending} - className="px-3 py-2 text-xs" + onClick={() => handleDeleteDomain(domain)} + disabled={deleteDomainMutation.isPending} > 删除
- ); - })} -
- )} - -
+ + ); + })} +
+ )} + + { + setIsWebsiteModalOpen(false); + setPreferredCertificateId(null); + }} + certificates={certificates} + certificatesLoading={certificatesQuery.isLoading} + preferredCertificateId={preferredCertificateId} + onRequestImportCertificate={() => setIsCertificateImportOpen(true)} + onSaved={(_, mode) => { + setPreferredCertificateId(null); + setFeedback({ + tone: 'success', + message: mode === 'create' ? '网站已创建。' : '网站已更新。', + }); + }} + /> + + setIsCertificateImportOpen(false)} + onImported={(certificate) => { + setPreferredCertificateId(certificate.id); + setFeedback({ + tone: 'success', + message: `证书 ${certificate.name} 已导入,可直接用于当前网站。`, + }); + }} + /> + setIsCertificateListOpen(false)} + title="证书列表" + description="回到主界面后可在这里统一查看已添加的证书,并继续新增或删除证书。" size="xl" > -
-
- - + 添加证书 +
- {importMode === 'manual' ? ( - -
-
- - - - - - -
- - - - - - - - - - - {manualImportMutation.isPending ? '导入中...' : '导入证书'} - -
-
+ {certificatesQuery.isLoading ? ( + + ) : certificatesQuery.isError ? ( + + ) : certificates.length === 0 ? ( + ) : ( - -
-
- - - setFileForm((current) => ({ - ...current, - name: event.target.value, - })) - } - placeholder="wildcard-example" - /> - - - - setFileForm((current) => ({ - ...current, - remark: event.target.value, - })) - } - placeholder="例如:泛域名生产证书" - /> - -
+
+ {certificates.map((certificate) => { + const status = getCertificateStatus(certificate); -
- - - setCertFile(event.target.files?.[0] ?? null) - } - /> - - - - setKeyFile(event.target.files?.[0] ?? null) - } - /> - -
+
+
+
+

+ {certificate.name} +

+ +
+
+

生效:{formatDateTime(certificate.not_before)}

+

到期:{formatDateTime(certificate.not_after)}

+

备注:{certificate.remark || '暂无备注'}

+
+
-
- - {fileImportMutation.isPending ? '上传中...' : '上传文件'} - - { - setFileForm(defaultFileValues); - setCertFile(null); - setKeyFile(null); - setFileInputNonce((value) => value + 1); - }} - disabled={fileImportMutation.isPending} - > - 清空文件 - -
- - + handleDeleteCertificate(certificate)} + disabled={deleteCertificateMutation.isPending} + className="px-3 py-2 text-xs" + > + 删除 + +
+
+ ); + })} +
)}
diff --git a/atsf_server/web/features/websites/schemas.ts b/atsf_server/web/features/websites/schemas.ts new file mode 100644 index 00000000..c91029eb --- /dev/null +++ b/atsf_server/web/features/websites/schemas.ts @@ -0,0 +1,67 @@ +import { z } from 'zod'; + +export 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 个字符'), +}); + +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 个字符'), +}); + +export type ManagedDomainFormValues = z.infer; +export type ManualImportFormValues = z.infer; + +export type FileImportFormValues = { + name: string; + remark: string; +}; + +export const defaultManagedDomainValues: ManagedDomainFormValues = { + domain: '', + cert_id: '', + enabled: true, + remark: '', +}; + +export const defaultManualImportValues: ManualImportFormValues = { + name: '', + cert_pem: '', + key_pem: '', + remark: '', +}; + +export const defaultFileImportValues: FileImportFormValues = { + name: '', + remark: '', +}; diff --git a/atsf_server/web/features/websites/utils.ts b/atsf_server/web/features/websites/utils.ts new file mode 100644 index 00000000..e1586898 --- /dev/null +++ b/atsf_server/web/features/websites/utils.ts @@ -0,0 +1,119 @@ +import type { + ManagedDomainItem, + ManagedDomainMutationPayload, +} from '@/features/managed-domains/types'; +import type { ProxyRouteItem } from '@/features/proxy-routes/types'; +import type { + TlsCertificateFileImportPayload, + TlsCertificateItem, + TlsCertificateMutationPayload, +} from '@/features/tls-certificates/types'; + +import type { + FileImportFormValues, + ManagedDomainFormValues, + ManualImportFormValues, +} from '@/features/websites/schemas'; +import { formatDateTime } from '@/lib/utils/date'; + +export function getErrorMessage(error: unknown) { + return error instanceof Error ? error.message : '请求失败,请稍后重试。'; +} + +export function getMatchTypeMeta(domain: string) { + return domain.startsWith('*.') + ? { label: '通配符', variant: 'warning' as const } + : { label: '精确匹配', variant: 'info' as const }; +} + +export 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 }; +} + +export function buildCertificateLabel(certificate: TlsCertificateItem) { + return certificate.not_after + ? `${certificate.name}(到期:${formatDateTime(certificate.not_after)})` + : certificate.name; +} + +export 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(), + }; +} + +export function toManagedDomainFormValues( + domain: ManagedDomainItem, +): ManagedDomainFormValues { + return { + domain: domain.domain, + cert_id: domain.cert_id ? String(domain.cert_id) : '', + enabled: domain.enabled, + remark: domain.remark || '', + }; +} + +export 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(), + }; +} + +export 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 isRouteRelatedToManagedDomain( + managedDomain: string, + routeDomain: string, +) { + if (managedDomain === routeDomain) { + return true; + } + + if (!managedDomain.startsWith('*.')) { + return false; + } + + const suffix = managedDomain.slice(2); + return routeDomain.endsWith(`.${suffix}`); +}