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 ? '保存修改' : '新增域名'}
-
-
- }
- >
-
-
- >
- );
-}
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' ? (
-
-
-
- ) : (
-
-
-
- )}
-
-
- >
- );
-}
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
+ ? '保存网站'
+ : '创建网站'}
+
+
+ }
+ >
+
+
+
+
+
+ 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' ? (
+
+
+
+ ) : (
+
+
+
+ )}
+
+
+ >
+ );
+}
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 证书
+* 网站
* 用户管理
* 设置
* 性能