diff --git a/atsf_server/web/app/(dashboard)/managed-domain/page.tsx b/atsf_server/web/app/(dashboard)/managed-domain/page.tsx deleted file mode 100644 index 4e4977fd..00000000 --- a/atsf_server/web/app/(dashboard)/managed-domain/page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -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-certificate/page.tsx b/atsf_server/web/app/(dashboard)/tls-certificate/page.tsx deleted file mode 100644 index e862f06d..00000000 --- a/atsf_server/web/app/(dashboard)/tls-certificate/page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { TlsCertificatesPage as TlsCertificatesFeaturePage } from '@/features/tls-certificates/components/tls-certificates-page'; - -export default function TlsCertificatesPage() { - return ; -} diff --git a/atsf_server/web/app/(dashboard)/website/page.tsx b/atsf_server/web/app/(dashboard)/website/page.tsx new file mode 100644 index 00000000..d766483d --- /dev/null +++ b/atsf_server/web/app/(dashboard)/website/page.tsx @@ -0,0 +1,5 @@ +import { WebsitesPage } from '@/features/websites/components/websites-page'; + +export default function WebsiteRoute() { + return ; +} diff --git a/atsf_server/web/features/dashboard/components/dashboard-overview.tsx b/atsf_server/web/features/dashboard/components/dashboard-overview.tsx index de9f3b35..77a9dcb2 100644 --- a/atsf_server/web/features/dashboard/components/dashboard-overview.tsx +++ b/atsf_server/web/features/dashboard/components/dashboard-overview.tsx @@ -59,7 +59,7 @@ function SummaryCard({ action={ 进入模块 @@ -68,18 +68,23 @@ function SummaryCard({ {status === 'pending' ? ( ) : status === 'error' ? ( - + ) : ( -
+
{items.map((item) => (
-

+

{item.label}

-

{item.value}

+

+ {item.value} +

))}
@@ -99,9 +104,18 @@ export function DashboardOverview() { ] = useQueries({ queries: [ { queryKey: ['dashboard', 'nodes'], queryFn: getNodes }, - { queryKey: ['dashboard', 'config-versions'], queryFn: getConfigVersions }, - { queryKey: ['dashboard', 'managed-domains'], queryFn: getManagedDomains }, - { queryKey: ['dashboard', 'tls-certificates'], queryFn: getTlsCertificates }, + { + queryKey: ['dashboard', 'config-versions'], + queryFn: getConfigVersions, + }, + { + queryKey: ['dashboard', 'managed-domains'], + queryFn: getManagedDomains, + }, + { + queryKey: ['dashboard', 'tls-certificates'], + queryFn: getTlsCertificates, + }, { queryKey: ['dashboard', 'proxy-routes'], queryFn: getProxyRoutes }, { queryKey: ['dashboard', 'users'], @@ -125,7 +139,11 @@ export function DashboardOverview() { const expiringCertificates = certificates.filter((item) => { const expiresAt = new Date(item.not_after).getTime(); - return !Number.isNaN(expiresAt) && expiresAt >= Date.now() && expiresAt - Date.now() <= 30 * 24 * 60 * 60 * 1000; + return ( + !Number.isNaN(expiresAt) && + expiresAt >= Date.now() && + expiresAt - Date.now() <= 30 * 24 * 60 * 60 * 1000 + ); }).length; const expiredCertificates = certificates.filter((item) => { @@ -142,9 +160,18 @@ export function DashboardOverview() { error: nodesQuery.error, items: [ { label: '节点总数', value: nodes.length }, - { label: '在线节点', value: nodes.filter((item) => item.status === 'online').length }, - { label: '待接入节点', value: nodes.filter((item) => item.status === 'pending').length }, - { label: '自动更新', value: nodes.filter((item) => item.auto_update_enabled).length }, + { + label: '在线节点', + value: nodes.filter((item) => item.status === 'online').length, + }, + { + label: '待接入节点', + value: nodes.filter((item) => item.status === 'pending').length, + }, + { + label: '自动更新', + value: nodes.filter((item) => item.auto_update_enabled).length, + }, ], }, { @@ -155,34 +182,56 @@ export function DashboardOverview() { error: versionsQuery.error, items: [ { label: '版本总数', value: versions.length }, - { label: '激活版本', value: versions.filter((item) => item.is_active).length }, - { label: '最近创建', value: versions[0]?.created_at ? formatDateTime(versions[0].created_at) : '—' }, + { + label: '激活版本', + value: versions.filter((item) => item.is_active).length, + }, + { + label: '最近创建', + value: versions[0]?.created_at + ? formatDateTime(versions[0].created_at) + : '—', + }, ], }, { title: '规则摘要', description: '查看域名规则启用状态、通配符覆盖和证书绑定规模。', - href: '/managed-domain', + href: '/website', status: managedDomainsQuery.status, error: managedDomainsQuery.error, items: [ { 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 }, + { + 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, + }, ], }, { title: '证书概览', description: '快速识别证书存量、近 30 天到期风险和过期情况。', - href: '/tls-certificate', + href: '/website', status: certificatesQuery.status, error: certificatesQuery.error, items: [ { label: '证书总数', value: certificates.length }, { label: '30 天内到期', value: expiringCertificates }, { label: '已过期', value: expiredCertificates }, - { label: '最近更新', value: certificates[0]?.updated_at ? formatDateTime(certificates[0].updated_at) : '—' }, + { + label: '最近更新', + value: certificates[0]?.updated_at + ? formatDateTime(certificates[0].updated_at) + : '—', + }, ], }, { @@ -193,11 +242,21 @@ export function DashboardOverview() { error: proxyRoutesQuery.error, items: [ { label: '规则总数', value: routes.length }, - { label: '已启用', value: routes.filter((item) => item.enabled).length }, - { label: 'HTTPS 规则', value: routes.filter((item) => item.enable_https).length }, + { + label: '已启用', + value: routes.filter((item) => item.enabled).length, + }, + { + label: 'HTTPS 规则', + value: routes.filter((item) => item.enable_https).length, + }, { label: '自定义请求头', - value: routes.reduce((count, route) => count + parseCustomHeaders(route.custom_headers).length, 0), + value: routes.reduce( + (count, route) => + count + parseCustomHeaders(route.custom_headers).length, + 0, + ), }, ], }, @@ -209,16 +268,25 @@ export function DashboardOverview() { error: usersQuery.error, items: [ { label: '用户总数', value: users.length }, - { label: '管理员', value: users.filter((item) => item.role >= 10).length }, - { label: '已激活', value: users.filter((item) => item.status === 1).length }, - { label: '已封禁', value: users.filter((item) => item.status !== 1).length }, + { + label: '管理员', + value: users.filter((item) => item.role >= 10).length, + }, + { + label: '已激活', + value: users.filter((item) => item.status === 1).length, + }, + { + label: '已封禁', + value: users.filter((item) => item.status !== 1).length, + }, ], }, ] as const; return ( -
-
+
+
{dashboardCards.map((card) => ( ))} 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 deleted file mode 100644 index f66ebebe..00000000 --- a/atsf_server/web/features/managed-domains/components/managed-domains-page.tsx +++ /dev/null @@ -1,411 +0,0 @@ -'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 { AppModal } from '@/components/ui/app-modal'; -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 [isEditorOpen, setIsEditorOpen] = useState(false); - 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); - setIsEditorOpen(false); - 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 currentCertificate = watchedCertId ? certificateMap.get(Number(watchedCertId)) : null; - - const handleReset = () => { - setEditingDomainId(null); - setIsEditorOpen(false); - setFeedback(null); - form.reset(defaultValues); - }; - - const handleCreate = () => { - setEditingDomainId(null); - setFeedback(null); - form.reset(defaultValues); - setIsEditorOpen(true); - }; - - const handleEdit = (domain: ManagedDomainItem) => { - setEditingDomainId(domain.id); - setFeedback(null); - form.reset(toFormValues(domain)); - setIsEditorOpen(true); - }; - - 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} - - 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' - > - 删除 - -
-
-
- )} -
-
- - - 取消 - - - {saveMutation.isPending ? '保存中...' : editingDomainId ? '保存修改' : '新增域名'} - -
- } - > -
-
- - - - - - form.setValue('cert_id', event.target.value, { - shouldDirty: true, - shouldValidate: true, - }) - } - > - - {certificates.map((certificate) => ( - - ))} - - -
- - - form.setValue('enabled', checked, { - shouldDirty: true, - shouldValidate: true, - }) - } - /> - - -
-

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

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

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

-
-
- - - - - - - - ); -} diff --git a/atsf_server/web/features/performance/components/performance-page.tsx b/atsf_server/web/features/performance/components/performance-page.tsx index d7f35e68..6e4e426f 100644 --- a/atsf_server/web/features/performance/components/performance-page.tsx +++ b/atsf_server/web/features/performance/components/performance-page.tsx @@ -569,7 +569,6 @@ export function PerformancePage() { setPerformanceFields((previous) => ({ 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 deleted file mode 100644 index 84615151..00000000 --- a/atsf_server/web/features/tls-certificates/components/tls-certificates-page.tsx +++ /dev/null @@ -1,525 +0,0 @@ -'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} - > - 清空文件 - -
-
-
- )} -
-
- - ); -} diff --git a/atsf_server/web/features/websites/components/websites-page.tsx b/atsf_server/web/features/websites/components/websites-page.tsx new file mode 100644 index 00000000..711f5c2d --- /dev/null +++ b/atsf_server/web/features/websites/components/websites-page.tsx @@ -0,0 +1,934 @@ +'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} + > + 清空文件 + +
+
+
+ )} +
+
+ + ); +} diff --git a/atsf_server/web/lib/constants/navigation.ts b/atsf_server/web/lib/constants/navigation.ts index bfbc82d0..ff38d53f 100644 --- a/atsf_server/web/lib/constants/navigation.ts +++ b/atsf_server/web/lib/constants/navigation.ts @@ -22,14 +22,9 @@ export const dashboardNavigation: NavigationItem[] = [ icon: 'release', }, { - href: '/managed-domain', - label: '域名', - icon: 'domain', - }, - { - href: '/tls-certificate', - label: '证书', - icon: 'certificate', + href: '/website', + label: '网站', + icon: 'website', }, { href: '/user', diff --git a/atsf_server/web/lib/utils/navigation.ts b/atsf_server/web/lib/utils/navigation.ts index 7d5588ae..6be737a0 100644 --- a/atsf_server/web/lib/utils/navigation.ts +++ b/atsf_server/web/lib/utils/navigation.ts @@ -16,18 +16,34 @@ export function isPathActive(pathname: string, href: string) { return normalizedPathname === '/'; } - return normalizedPathname === href || normalizedPathname.startsWith(`${href}/`); + return ( + normalizedPathname === href || normalizedPathname.startsWith(`${href}/`) + ); } -export function isNavigationItemActive(pathname: string, item: NavigationItem): boolean { - return isPathActive(pathname, item.href) || item.children?.some((child) => isNavigationItemActive(pathname, child)) || false; +export function isNavigationItemActive( + pathname: string, + item: NavigationItem, +): boolean { + return ( + isPathActive(pathname, item.href) || + item.children?.some((child) => isNavigationItemActive(pathname, child)) || + false + ); } -export function flattenNavigationItems(items: NavigationItem[]): NavigationItem[] { - return items.flatMap((item) => [item, ...(item.children ? flattenNavigationItems(item.children) : [])]); +export function flattenNavigationItems( + items: NavigationItem[], +): NavigationItem[] { + return items.flatMap((item) => [ + item, + ...(item.children ? flattenNavigationItems(item.children) : []), + ]); } -export function getCurrentNavigationItem(pathname: string): NavigationItem | undefined { +export function getCurrentNavigationItem( + pathname: string, +): NavigationItem | undefined { const findMatch = (items: NavigationItem[]): NavigationItem | undefined => { for (const item of items) { if (item.children) { diff --git a/atsf_server/web/tests/unit/navigation-utils.test.ts b/atsf_server/web/tests/unit/navigation-utils.test.ts index 160c7463..99af0a2b 100644 --- a/atsf_server/web/tests/unit/navigation-utils.test.ts +++ b/atsf_server/web/tests/unit/navigation-utils.test.ts @@ -10,7 +10,7 @@ describe('navigation utils', () => { it('resolves current navigation item for nested paths', () => { expect(getCurrentNavigationItem('/node/abc')?.label).toBe('节点'); - expect(getCurrentNavigationItem('/managed-domain')?.label).toBe('域名'); + expect(getCurrentNavigationItem('/website')?.label).toBe('网站'); expect(getCurrentNavigationItem('/performance')?.label).toBe('性能'); expect(getCurrentNavigationItem('/setting')?.label).toBe('设置'); }); diff --git a/docs/frontend-revamp-plan.md b/docs/frontend-revamp-plan.md index 025a7696..533148ab 100644 --- a/docs/frontend-revamp-plan.md +++ b/docs/frontend-revamp-plan.md @@ -51,8 +51,7 @@ * 配置版本 * 节点管理 * 应用记录 -* 域名管理 -* TLS 证书 +* 网站 * 用户管理 * 设置 * 性能