diff --git a/atsf_server/web/app/(dashboard)/apply-logs/page.tsx b/atsf_server/web/app/(dashboard)/apply-logs/page.tsx index 9998e3e0..85200955 100644 --- a/atsf_server/web/app/(dashboard)/apply-logs/page.tsx +++ b/atsf_server/web/app/(dashboard)/apply-logs/page.tsx @@ -1,10 +1,5 @@ -import { ModulePageSkeleton } from '@/features/shared/components/module-page-skeleton'; - -export default function ApplyLogsPage() { - return ( - - ); -} +import { ApplyLogsPage as ApplyLogsFeaturePage } from '@/features/apply-logs/components/apply-logs-page'; + +export default function ApplyLogsPage() { + return ; +} diff --git a/atsf_server/web/app/(dashboard)/managed-domains/page.tsx b/atsf_server/web/app/(dashboard)/managed-domains/page.tsx index c3b83cd6..4e4977fd 100644 --- a/atsf_server/web/app/(dashboard)/managed-domains/page.tsx +++ b/atsf_server/web/app/(dashboard)/managed-domains/page.tsx @@ -1,10 +1,5 @@ -import { ModulePageSkeleton } from '@/features/shared/components/module-page-skeleton'; - -export default function ManagedDomainsPage() { - return ( - - ); -} +import { ManagedDomainsPage as ManagedDomainsFeaturePage } from '@/features/managed-domains/components/managed-domains-page'; + +export default function ManagedDomainsPage() { + return ; +} diff --git a/atsf_server/web/app/(dashboard)/tls-certificates/page.tsx b/atsf_server/web/app/(dashboard)/tls-certificates/page.tsx index af42612d..e862f06d 100644 --- a/atsf_server/web/app/(dashboard)/tls-certificates/page.tsx +++ b/atsf_server/web/app/(dashboard)/tls-certificates/page.tsx @@ -1,10 +1,5 @@ -import { ModulePageSkeleton } from '@/features/shared/components/module-page-skeleton'; - -export default function TlsCertificatesPage() { - return ( - - ); -} +import { TlsCertificatesPage as TlsCertificatesFeaturePage } from '@/features/tls-certificates/components/tls-certificates-page'; + +export default function TlsCertificatesPage() { + return ; +} diff --git a/atsf_server/web/app/apply-log/page.tsx b/atsf_server/web/app/apply-log/page.tsx new file mode 100644 index 00000000..a8ff62d4 --- /dev/null +++ b/atsf_server/web/app/apply-log/page.tsx @@ -0,0 +1,5 @@ +import { LegacyRouteRedirect } from '@/features/shared/components/legacy-route-redirect'; + +export default function LegacyApplyLogRoute() { + return ; +} diff --git a/atsf_server/web/app/managed-domain/page.tsx b/atsf_server/web/app/managed-domain/page.tsx new file mode 100644 index 00000000..1263d90e --- /dev/null +++ b/atsf_server/web/app/managed-domain/page.tsx @@ -0,0 +1,5 @@ +import { LegacyRouteRedirect } from '@/features/shared/components/legacy-route-redirect'; + +export default function LegacyManagedDomainRoute() { + return ; +} diff --git a/atsf_server/web/app/tls-certificate/page.tsx b/atsf_server/web/app/tls-certificate/page.tsx new file mode 100644 index 00000000..d0395a7a --- /dev/null +++ b/atsf_server/web/app/tls-certificate/page.tsx @@ -0,0 +1,5 @@ +import { LegacyRouteRedirect } from '@/features/shared/components/legacy-route-redirect'; + +export default function LegacyTlsCertificateRoute() { + return ; +} diff --git a/atsf_server/web/features/apply-logs/api/apply-logs.ts b/atsf_server/web/features/apply-logs/api/apply-logs.ts new file mode 100644 index 00000000..ec259436 --- /dev/null +++ b/atsf_server/web/features/apply-logs/api/apply-logs.ts @@ -0,0 +1,9 @@ +import { apiRequest } from '@/lib/api/client'; + +import type { ApplyLogItem } from '@/features/apply-logs/types'; + +export function getApplyLogs(nodeId?: string) { + const normalizedNodeId = nodeId?.trim(); + const query = normalizedNodeId ? `?node_id=${encodeURIComponent(normalizedNodeId)}` : ''; + return apiRequest(`/apply-logs/${query}`); +} diff --git a/atsf_server/web/features/apply-logs/components/apply-logs-page.tsx b/atsf_server/web/features/apply-logs/components/apply-logs-page.tsx new file mode 100644 index 00000000..fa613cb1 --- /dev/null +++ b/atsf_server/web/features/apply-logs/components/apply-logs-page.tsx @@ -0,0 +1,234 @@ +'use client'; + +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { useEffect, 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 { getApplyLogs } from '@/features/apply-logs/api/apply-logs'; +import type { ApplyLogItem } from '@/features/apply-logs/types'; +import { + PrimaryButton, + ResourceInput, + SecondaryButton, +} from '@/features/shared/components/resource-primitives'; +import { formatDateTime, formatRelativeTime } from '@/lib/utils/date'; + +const applyLogsQueryKey = (nodeId: string) => ['apply-logs', nodeId] as const; + +function getErrorMessage(error: unknown) { + return error instanceof Error ? error.message : '请求失败,请稍后重试。'; +} + +function getResultMeta(result: string) { + if (result === 'success') { + return { label: '成功', variant: 'success' as const }; + } + + return { label: '失败', variant: 'danger' as const }; +} + +function buildSummary(logs: ApplyLogItem[]) { + const nodeIds = new Set(logs.map((item) => item.node_id)); + + return [ + { label: '记录总数', value: logs.length }, + { label: '成功', value: logs.filter((item) => item.result === 'success').length }, + { label: '失败', value: logs.filter((item) => item.result !== 'success').length }, + { label: '节点数', value: nodeIds.size }, + ]; +} + +export function ApplyLogsPage() { + const queryClient = useQueryClient(); + const [nodeFilterInput, setNodeFilterInput] = useState(''); + const [nodeFilter, setNodeFilter] = useState(''); + const [selectedLogId, setSelectedLogId] = useState(null); + const [feedback, setFeedback] = useState(null); + + const logsQuery = useQuery({ + queryKey: applyLogsQueryKey(nodeFilter), + queryFn: () => getApplyLogs(nodeFilter), + }); + + const logs = useMemo(() => logsQuery.data ?? [], [logsQuery.data]); + const summary = useMemo(() => buildSummary(logs), [logs]); + + useEffect(() => { + if (logs.length === 0) { + setSelectedLogId(null); + return; + } + + if (!logs.some((item) => item.id === selectedLogId)) { + setSelectedLogId(logs[0].id); + } + }, [logs, selectedLogId]); + + const selectedLog = logs.find((item) => item.id === selectedLogId) ?? null; + + const handleSearch = () => { + setFeedback(null); + setNodeFilter(nodeFilterInput.trim()); + }; + + const handleReset = () => { + setFeedback(null); + setNodeFilter(''); + setNodeFilterInput(''); + }; + + return ( +
+ + + {feedback ? : null} + + +
+ {summary.map((item) => ( +
+

{item.label}

+

{item.value}

+
+ ))} +
+
+ +
+ void queryClient.invalidateQueries({ queryKey: applyLogsQueryKey(nodeFilter) })} + > + 刷新 + + } + > +
+
+ setNodeFilterInput(event.target.value)} + placeholder='输入 node_id 过滤应用记录' + className='lg:max-w-md' + /> +
+ + 筛选 + + + 清空 + +
+
+ + {logsQuery.isLoading ? ( + + ) : logsQuery.isError ? ( + + ) : logs.length === 0 ? ( + + ) : ( +
+ + + + + + + + + + + + {logs.map((log) => { + const resultMeta = getResultMeta(log.result); + return ( + + + + + + + + ); + })} + +
Node ID版本结果时间详情
{log.node_id}{log.version} + + +
+

{formatDateTime(log.created_at)}

+

{formatRelativeTime(log.created_at)}

+
+
+ { + setSelectedLogId(log.id); + setFeedback(`已选中节点 ${log.node_id} 的版本 ${log.version} 记录。`); + }} + className='px-3 py-2 text-xs' + > + 查看详情 + +
+
+ )} +
+
+ + + {selectedLog ? ( +
+
+ + + +
+ +
+
+

创建时间

+

+ {formatDateTime(selectedLog.created_at)} +

+
+
+

相对时间

+

+ {formatRelativeTime(selectedLog.created_at)} +

+
+
+ +
+

应用信息

+

+ {selectedLog.message || '无附加信息'} +

+
+
+ ) : ( + + )} +
+
+
+ ); +} diff --git a/atsf_server/web/features/apply-logs/types.ts b/atsf_server/web/features/apply-logs/types.ts new file mode 100644 index 00000000..cbadaf02 --- /dev/null +++ b/atsf_server/web/features/apply-logs/types.ts @@ -0,0 +1,8 @@ +export interface ApplyLogItem { + id: number; + node_id: string; + version: string; + result: 'success' | 'failed' | string; + message: string; + created_at: string; +} diff --git a/atsf_server/web/features/managed-domains/api/managed-domains.ts b/atsf_server/web/features/managed-domains/api/managed-domains.ts new file mode 100644 index 00000000..ae60769c --- /dev/null +++ b/atsf_server/web/features/managed-domains/api/managed-domains.ts @@ -0,0 +1,35 @@ +import { apiRequest } from '@/lib/api/client'; + +import type { + ManagedDomainCertificateOption, + ManagedDomainItem, + ManagedDomainMutationPayload, +} from '@/features/managed-domains/types'; + +export function getManagedDomains() { + return apiRequest('/managed-domains/'); +} + +export function createManagedDomain(payload: ManagedDomainMutationPayload) { + return apiRequest('/managed-domains/', { + method: 'POST', + body: JSON.stringify(payload), + }); +} + +export function updateManagedDomain(id: number, payload: ManagedDomainMutationPayload) { + return apiRequest(`/managed-domains/${id}`, { + method: 'PUT', + body: JSON.stringify(payload), + }); +} + +export function deleteManagedDomain(id: number) { + return apiRequest(`/managed-domains/${id}`, { + method: 'DELETE', + }); +} + +export function getManagedDomainCertificates() { + return apiRequest('/tls-certificates/'); +} diff --git a/atsf_server/web/features/managed-domains/components/managed-domains-page.tsx b/atsf_server/web/features/managed-domains/components/managed-domains-page.tsx new file mode 100644 index 00000000..24d5e3a9 --- /dev/null +++ b/atsf_server/web/features/managed-domains/components/managed-domains-page.tsx @@ -0,0 +1,421 @@ +'use client'; + +import { zodResolver } from '@hookform/resolvers/zod'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { useMemo, useState } 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 { StatusBadge } from '@/components/ui/status-badge'; +import { + createManagedDomain, + deleteManagedDomain, + getManagedDomainCertificates, + getManagedDomains, + updateManagedDomain, +} from '@/features/managed-domains/api/managed-domains'; +import type { + ManagedDomainCertificateOption, + ManagedDomainItem, + ManagedDomainMutationPayload, +} from '@/features/managed-domains/types'; +import { + DangerButton, + PrimaryButton, + ResourceField, + ResourceInput, + ResourceSelect, + SecondaryButton, + ToggleField, +} from '@/features/shared/components/resource-primitives'; +import { formatDateTime } from '@/lib/utils/date'; + +const managedDomainsQueryKey = ['managed-domains'] as const; +const managedDomainCertificatesQueryKey = ['managed-domains', 'certificates'] 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 个字符'), +}); + +type ManagedDomainFormValues = z.infer; + +type FeedbackState = { + tone: 'info' | 'success' | 'danger'; + message: string; +}; + +const defaultValues: ManagedDomainFormValues = { + domain: '', + cert_id: '', + enabled: true, + 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 buildCertificateLabel(certificate: ManagedDomainCertificateOption) { + return certificate.not_after + ? `${certificate.name}(到期:${formatDateTime(certificate.not_after)})` + : certificate.name; +} + +function toPayload(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 toFormValues(domain: ManagedDomainItem): ManagedDomainFormValues { + return { + domain: domain.domain, + cert_id: domain.cert_id ? String(domain.cert_id) : '', + enabled: domain.enabled, + remark: domain.remark || '', + }; +} + +export function ManagedDomainsPage() { + const queryClient = useQueryClient(); + const [editingDomainId, setEditingDomainId] = useState(null); + const [feedback, setFeedback] = useState(null); + + const form = useForm({ + resolver: zodResolver(managedDomainSchema), + defaultValues, + }); + + 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 managedDomainsQuery = useQuery({ + queryKey: managedDomainsQueryKey, + queryFn: getManagedDomains, + }); + + const certificatesQuery = useQuery({ + queryKey: managedDomainCertificatesQueryKey, + queryFn: getManagedDomainCertificates, + }); + + const saveMutation = useMutation({ + mutationFn: async (values: ManagedDomainFormValues) => { + const payload = toPayload(values); + return editingDomainId + ? updateManagedDomain(editingDomainId, payload) + : createManagedDomain(payload); + }, + onSuccess: async () => { + setFeedback({ + tone: 'success', + message: editingDomainId ? '域名规则已更新。' : '域名规则已创建。', + }); + setEditingDomainId(null); + form.reset(defaultValues); + await queryClient.invalidateQueries({ queryKey: managedDomainsQueryKey }); + }, + onError: (error) => { + setFeedback({ tone: 'danger', message: getErrorMessage(error) }); + }, + }); + + const deleteMutation = useMutation({ + mutationFn: deleteManagedDomain, + onSuccess: async () => { + setFeedback({ tone: 'success', message: '域名规则已删除。' }); + await queryClient.invalidateQueries({ queryKey: managedDomainsQueryKey }); + }, + onError: (error) => { + setFeedback({ tone: 'danger', message: getErrorMessage(error) }); + }, + }); + + const certificates = useMemo(() => certificatesQuery.data ?? [], [certificatesQuery.data]); + const domains = useMemo(() => managedDomainsQuery.data ?? [], [managedDomainsQuery.data]); + + const certificateMap = useMemo( + () => new Map(certificates.map((item) => [item.id, item])), + [certificates], + ); + + const summary = useMemo( + () => [ + { label: '域名规则', value: domains.length }, + { label: '已启用', value: domains.filter((item) => item.enabled).length }, + { label: '通配符规则', value: domains.filter((item) => item.domain.startsWith('*.')).length }, + { label: '已绑证书', value: domains.filter((item) => item.cert_id).length }, + ], + [domains], + ); + + const currentCertificate = watchedCertId ? certificateMap.get(Number(watchedCertId)) : null; + + const handleReset = () => { + setEditingDomainId(null); + setFeedback(null); + form.reset(defaultValues); + }; + + const handleEdit = (domain: ManagedDomainItem) => { + setEditingDomainId(domain.id); + setFeedback(null); + form.reset(toFormValues(domain)); + }; + + const handleDelete = (domain: ManagedDomainItem) => { + if (!window.confirm(`确认删除域名规则 ${domain.domain} 吗?`)) { + return; + } + + setFeedback(null); + deleteMutation.mutate(domain.id); + }; + + const handleSubmit = form.handleSubmit((values) => { + setFeedback(null); + saveMutation.mutate(values); + }); + + return ( +
+ + + {feedback ? : null} + +
+ +
+
+ + + + + + form.setValue('cert_id', event.target.value, { + shouldDirty: true, + shouldValidate: true, + }) + } + > + + {certificates.map((certificate) => ( + + ))} + + +
+ + + form.setValue('enabled', checked, { + shouldDirty: true, + shouldValidate: true, + }) + } + /> + + + + + +
+ + {saveMutation.isPending ? '保存中...' : editingDomainId ? '保存修改' : '新增域名'} + + + {editingDomainId ? '取消编辑' : '重置表单'} + +
+ +
+ +
+ +
+ {summary.map((item) => ( +
+

+ {item.label} +

+

{item.value}

+
+ ))} +
+
+ + +
+

+ 当前输入: + + {watchedDomain.trim() || '未填写域名'} + +

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

+ 建议只为真正需要默认推荐的域名绑定证书;如果同一 hostname 同时命中精确和通配符规则, + 系统会优先使用精确匹配。 +

+
+
+
+
+ + void queryClient.invalidateQueries({ queryKey: managedDomainsQueryKey })} + > + 刷新 + + } + > + {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} + + + {certificate ? certificate.name : '未绑定'} + + + {domain.remark || '—'} + {formatDateTime(domain.updated_at)} + +
+ handleEdit(domain)} + className='px-3 py-2 text-xs' + > + 编辑 + + handleDelete(domain)} + disabled={deleteMutation.isPending} + className='px-3 py-2 text-xs' + > + 删除 + +
+
+
+ )} +
+
+ ); +} diff --git a/atsf_server/web/features/managed-domains/types.ts b/atsf_server/web/features/managed-domains/types.ts new file mode 100644 index 00000000..9006d478 --- /dev/null +++ b/atsf_server/web/features/managed-domains/types.ts @@ -0,0 +1,22 @@ +export interface ManagedDomainItem { + id: number; + domain: string; + cert_id: number | null; + enabled: boolean; + remark: string; + created_at: string; + updated_at: string; +} + +export interface ManagedDomainMutationPayload { + domain: string; + cert_id: number | null; + enabled: boolean; + remark: string; +} + +export interface ManagedDomainCertificateOption { + id: number; + name: string; + not_after: string | null; +} diff --git a/atsf_server/web/features/tls-certificates/api/tls-certificates.ts b/atsf_server/web/features/tls-certificates/api/tls-certificates.ts new file mode 100644 index 00000000..f76c488f --- /dev/null +++ b/atsf_server/web/features/tls-certificates/api/tls-certificates.ts @@ -0,0 +1,37 @@ +import { apiRequest } from '@/lib/api/client'; + +import type { + TlsCertificateFileImportPayload, + TlsCertificateItem, + TlsCertificateMutationPayload, +} from '@/features/tls-certificates/types'; + +export function getTlsCertificates() { + return apiRequest('/tls-certificates/'); +} + +export function createTlsCertificate(payload: TlsCertificateMutationPayload) { + return apiRequest('/tls-certificates/', { + method: 'POST', + body: JSON.stringify(payload), + }); +} + +export function importTlsCertificateFiles(payload: TlsCertificateFileImportPayload) { + const formData = new FormData(); + formData.append('name', payload.name); + formData.append('remark', payload.remark); + formData.append('cert_file', payload.certFile); + formData.append('key_file', payload.keyFile); + + return apiRequest('/tls-certificates/import-file', { + method: 'POST', + body: formData, + }); +} + +export function deleteTlsCertificate(id: number) { + return apiRequest(`/tls-certificates/${id}`, { + method: 'DELETE', + }); +} diff --git a/atsf_server/web/features/tls-certificates/components/tls-certificates-page.tsx b/atsf_server/web/features/tls-certificates/components/tls-certificates-page.tsx new file mode 100644 index 00000000..dadfc876 --- /dev/null +++ b/atsf_server/web/features/tls-certificates/components/tls-certificates-page.tsx @@ -0,0 +1,426 @@ +'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 { 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 [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: '证书已导入。' }); + 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: '证书文件已导入。' }); + 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 summary = useMemo( + () => [ + { label: '证书总数', value: certificates.length }, + { + label: '30 天内到期', + value: certificates.filter((item) => { + const expiresAt = new Date(item.not_after).getTime(); + if (Number.isNaN(expiresAt)) { + return false; + } + + const diffMs = expiresAt - Date.now(); + return diffMs >= 0 && diffMs <= 30 * 24 * 60 * 60 * 1000; + }).length, + }, + { + label: '已过期', + value: certificates.filter((item) => { + const expiresAt = new Date(item.not_after).getTime(); + return !Number.isNaN(expiresAt) && expiresAt < Date.now(); + }).length, + }, + { label: '最近更新', value: certificates[0] ? formatDateTime(certificates[0].updated_at) : '—' }, + ], + [certificates], + ); + + 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); + }; + + return ( +
+ + + {feedback ? : null} + + +
+ {summary.map((item) => ( +
+

{item.label}

+

{item.value}

+
+ ))} +
+
+ +
+ +
+
+ + + + + + +
+ + + + + + + + + + + {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} + > + 清空文件 + +
+
+
+
+ + 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' + > + 删除 + +
+
+ )} +
+
+ ); +} diff --git a/atsf_server/web/features/tls-certificates/types.ts b/atsf_server/web/features/tls-certificates/types.ts new file mode 100644 index 00000000..3e0b5853 --- /dev/null +++ b/atsf_server/web/features/tls-certificates/types.ts @@ -0,0 +1,23 @@ +export interface TlsCertificateItem { + id: number; + name: string; + not_before: string; + not_after: string; + remark: string; + created_at: string; + updated_at: string; +} + +export interface TlsCertificateMutationPayload { + name: string; + cert_pem: string; + key_pem: string; + remark: string; +} + +export interface TlsCertificateFileImportPayload { + name: string; + remark: string; + certFile: File; + keyFile: File; +} diff --git a/atsf_server/web/lib/constants/navigation.ts b/atsf_server/web/lib/constants/navigation.ts index c728799a..aa20fc9f 100644 --- a/atsf_server/web/lib/constants/navigation.ts +++ b/atsf_server/web/lib/constants/navigation.ts @@ -25,24 +25,24 @@ export const dashboardNavigation: NavigationItem[] = [ shortLabel: '节点', description: '已接入状态标签、部署命令、心跳与更新动作。', }, - { - href: '/apply-logs', - label: '应用记录', - shortLabel: '记录', - description: '阶段 3 接入筛选、分页与详情展示。', - }, - { - href: '/managed-domains', - label: '域名管理', - shortLabel: '域名', - description: '阶段 3 接入证书绑定与启用状态切换。', - }, - { - href: '/tls-certificates', - label: 'TLS 证书', - shortLabel: '证书', - description: '阶段 3 接入导入、上传与有效期展示。', - }, + { + href: '/apply-logs', + label: '应用记录', + shortLabel: '记录', + description: '已接入 node_id 筛选、结果状态展示与单条详情查看。', + }, + { + href: '/managed-domains', + label: '域名管理', + shortLabel: '域名', + description: '已接入列表、证书绑定、启停控制与编辑删除。', + }, + { + href: '/tls-certificates', + label: 'TLS 证书', + shortLabel: '证书', + description: '已接入 PEM 导入、文件上传、到期展示与删除。', + }, { href: '/files', label: '文件管理',