mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-03 15:06:36 +08:00
411 lines
14 KiB
TypeScript
411 lines
14 KiB
TypeScript
'use client';
|
||
|
||
import Link from 'next/link';
|
||
import { useRouter } from 'next/navigation';
|
||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||
import { useMemo, useState } from 'react';
|
||
|
||
import { EmptyState } from '@/components/feedback/empty-state';
|
||
import { ErrorState } from '@/components/feedback/error-state';
|
||
import { InlineMessage } from '@/components/feedback/inline-message';
|
||
import { LoadingState } from '@/components/feedback/loading-state';
|
||
import { PageHeader } from '@/components/layout/page-header';
|
||
import { AppCard } from '@/components/ui/app-card';
|
||
import { StatusBadge } from '@/components/ui/status-badge';
|
||
import {
|
||
getConfigVersionDiff,
|
||
publishConfigVersion,
|
||
} from '@/features/config-versions/api/config-versions';
|
||
import { ProxyRouteCreateDrawer } from '@/features/proxy-routes/components/proxy-route-create-drawer';
|
||
import {
|
||
getErrorMessage,
|
||
getUpstreamSummary,
|
||
getWebsiteStatusBadges,
|
||
} from '@/features/proxy-routes/helpers';
|
||
import {
|
||
deleteProxyRoute,
|
||
getProxyRoutes,
|
||
} from '@/features/proxy-routes/api/proxy-routes';
|
||
import type { ProxyRouteItem } from '@/features/proxy-routes/types';
|
||
import {
|
||
DangerButton,
|
||
PrimaryButton,
|
||
ResourceInput,
|
||
SecondaryButton,
|
||
} from '@/features/shared/components/resource-primitives';
|
||
|
||
type FeedbackState = {
|
||
tone: 'info' | 'success' | 'danger';
|
||
message: string;
|
||
};
|
||
|
||
function hasConfigChanges(diff: {
|
||
active_version?: string;
|
||
added_sites: string[];
|
||
removed_sites: string[];
|
||
modified_sites: string[];
|
||
added_domains: string[];
|
||
removed_domains: string[];
|
||
modified_domains: string[];
|
||
main_config_changed: boolean;
|
||
waf_config_changed?: boolean;
|
||
changed_option_keys: string[];
|
||
}) {
|
||
return (
|
||
diff.added_sites.length > 0 ||
|
||
diff.removed_sites.length > 0 ||
|
||
diff.modified_sites.length > 0 ||
|
||
diff.added_domains.length > 0 ||
|
||
diff.removed_domains.length > 0 ||
|
||
diff.modified_domains.length > 0 ||
|
||
diff.main_config_changed ||
|
||
Boolean(diff.waf_config_changed) ||
|
||
diff.changed_option_keys.length > 0 ||
|
||
!diff.active_version
|
||
);
|
||
}
|
||
|
||
export function ProxyRoutesPage() {
|
||
const router = useRouter();
|
||
const queryClient = useQueryClient();
|
||
const [feedback, setFeedback] = useState<FeedbackState | null>(null);
|
||
const [keyword, setKeyword] = useState('');
|
||
const [isCreateOpen, setIsCreateOpen] = useState(false);
|
||
|
||
const routesQuery = useQuery({
|
||
queryKey: ['proxy-routes'],
|
||
queryFn: getProxyRoutes,
|
||
});
|
||
const diffQuery = useQuery({
|
||
queryKey: ['config-versions', 'diff'],
|
||
queryFn: getConfigVersionDiff,
|
||
});
|
||
|
||
const deleteMutation = useMutation({
|
||
mutationFn: deleteProxyRoute,
|
||
onSuccess: async () => {
|
||
setFeedback({ tone: 'success', message: '网站已删除。' });
|
||
await Promise.all([
|
||
queryClient.invalidateQueries({ queryKey: ['proxy-routes'] }),
|
||
queryClient.invalidateQueries({
|
||
queryKey: ['config-versions', 'diff'],
|
||
}),
|
||
]);
|
||
},
|
||
onError: (error) => {
|
||
setFeedback({ tone: 'danger', message: getErrorMessage(error) });
|
||
},
|
||
});
|
||
|
||
const publishMutation = useMutation({
|
||
mutationFn: publishConfigVersion,
|
||
onSuccess: async (version) => {
|
||
setFeedback({
|
||
tone: 'success',
|
||
message: `配置已发布,版本号 ${version.version}。`,
|
||
});
|
||
await Promise.all([
|
||
queryClient.invalidateQueries({ queryKey: ['config-versions'] }),
|
||
queryClient.invalidateQueries({
|
||
queryKey: ['config-versions', 'diff'],
|
||
}),
|
||
]);
|
||
},
|
||
onError: (error) => {
|
||
setFeedback({ tone: 'danger', message: getErrorMessage(error) });
|
||
},
|
||
});
|
||
|
||
const routes = useMemo(() => routesQuery.data ?? [], [routesQuery.data]);
|
||
const filteredRoutes = useMemo(() => {
|
||
const normalizedKeyword = keyword.trim().toLowerCase();
|
||
if (!normalizedKeyword) {
|
||
return routes;
|
||
}
|
||
|
||
return routes.filter((route) => {
|
||
const haystack = [
|
||
route.site_name,
|
||
route.primary_domain,
|
||
...route.domains,
|
||
...route.upstream_list,
|
||
route.remark,
|
||
]
|
||
.join(' ')
|
||
.toLowerCase();
|
||
|
||
return haystack.includes(normalizedKeyword);
|
||
});
|
||
}, [keyword, routes]);
|
||
|
||
const diff = diffQuery.data;
|
||
const totalDomains = routes.reduce(
|
||
(sum, route) => sum + route.domain_count,
|
||
0,
|
||
);
|
||
const enabledCount = routes.filter((route) => route.enabled).length;
|
||
|
||
const handleDelete = (route: ProxyRouteItem) => {
|
||
if (
|
||
!window.confirm(
|
||
`确认删除网站 ${route.site_name} 吗?\n这会删除该站点下的全部域名与配置。`,
|
||
)
|
||
) {
|
||
return;
|
||
}
|
||
|
||
setFeedback(null);
|
||
deleteMutation.mutate(route.id);
|
||
};
|
||
|
||
const handlePublish = () => {
|
||
if (!diff || !hasConfigChanges(diff)) {
|
||
setFeedback({ tone: 'info', message: '当前草稿没有可发布的变更。' });
|
||
return;
|
||
}
|
||
|
||
const summary = [
|
||
`新增网站 ${diff.added_sites.length} 个`,
|
||
`删除网站 ${diff.removed_sites.length} 个`,
|
||
`修改网站 ${diff.modified_sites.length} 个`,
|
||
`域名变更 ${diff.added_domains.length + diff.removed_domains.length + diff.modified_domains.length} 项`,
|
||
].join(',');
|
||
|
||
if (!window.confirm(`确认发布当前配置吗?\n${summary}`)) {
|
||
return;
|
||
}
|
||
|
||
setFeedback(null);
|
||
publishMutation.mutate(false);
|
||
};
|
||
|
||
if (routesQuery.isLoading) {
|
||
return <LoadingState />;
|
||
}
|
||
|
||
if (routesQuery.isError) {
|
||
return (
|
||
<ErrorState
|
||
title="规则列表加载失败"
|
||
description={getErrorMessage(routesQuery.error)}
|
||
/>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<>
|
||
<div className="space-y-6">
|
||
<PageHeader
|
||
title="规则配置"
|
||
action={
|
||
<div className="flex flex-wrap gap-3">
|
||
<SecondaryButton
|
||
type="button"
|
||
onClick={handlePublish}
|
||
disabled={publishMutation.isPending || diffQuery.isLoading}
|
||
>
|
||
{publishMutation.isPending ? '发布中...' : '发布配置'}
|
||
</SecondaryButton>
|
||
<PrimaryButton
|
||
type="button"
|
||
onClick={() => {
|
||
setFeedback(null);
|
||
setIsCreateOpen(true);
|
||
}}
|
||
>
|
||
新建规则
|
||
</PrimaryButton>
|
||
</div>
|
||
}
|
||
/>
|
||
|
||
{feedback ? (
|
||
<InlineMessage tone={feedback.tone} message={feedback.message} />
|
||
) : null}
|
||
|
||
<div className="grid gap-4 xl:grid-cols-4">
|
||
<AppCard title="网站数">
|
||
<div className="space-y-2">
|
||
<p className="text-3xl font-semibold text-[var(--foreground-primary)]">
|
||
{routes.length}
|
||
</p>
|
||
<p className="text-sm text-[var(--foreground-secondary)]">
|
||
已启用 {enabledCount} 个
|
||
</p>
|
||
</div>
|
||
</AppCard>
|
||
|
||
<AppCard title="域名数">
|
||
<div className="space-y-2">
|
||
<p className="text-3xl font-semibold text-[var(--foreground-primary)]">
|
||
{totalDomains}
|
||
</p>
|
||
<p className="text-sm text-[var(--foreground-secondary)]">
|
||
每个网站第一行域名为主域名
|
||
</p>
|
||
</div>
|
||
</AppCard>
|
||
|
||
<AppCard title="草稿状态">
|
||
<div className="space-y-3">
|
||
<StatusBadge
|
||
label={
|
||
diff && hasConfigChanges(diff)
|
||
? '有待发布变更'
|
||
: '已与线上一致'
|
||
}
|
||
variant={diff && hasConfigChanges(diff) ? 'warning' : 'success'}
|
||
/>
|
||
<p className="text-sm text-[var(--foreground-secondary)]">
|
||
{diff
|
||
? `网站变更 ${diff.added_sites.length + diff.removed_sites.length + diff.modified_sites.length} 项`
|
||
: '正在读取 diff...'}
|
||
</p>
|
||
</div>
|
||
</AppCard>
|
||
|
||
<AppCard title="发布摘要">
|
||
<div className="space-y-2 text-sm text-[var(--foreground-secondary)]">
|
||
<p>新增网站:{diff?.added_sites.length ?? 0}</p>
|
||
<p>删除网站:{diff?.removed_sites.length ?? 0}</p>
|
||
<p>修改网站:{diff?.modified_sites.length ?? 0}</p>
|
||
</div>
|
||
</AppCard>
|
||
</div>
|
||
|
||
<AppCard title="规则列表">
|
||
<div className="space-y-4">
|
||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||
<div className="max-w-xl">
|
||
<ResourceInput
|
||
value={keyword}
|
||
onChange={(event) => setKeyword(event.target.value)}
|
||
placeholder="搜索站点"
|
||
/>
|
||
</div>
|
||
{diff && hasConfigChanges(diff) ? (
|
||
<div className="flex flex-wrap gap-2">
|
||
{diff.modified_sites.length > 0 ? (
|
||
<StatusBadge
|
||
label={`修改网站 ${diff.modified_sites.length}`}
|
||
variant="warning"
|
||
/>
|
||
) : null}
|
||
{diff.added_sites.length > 0 ? (
|
||
<StatusBadge
|
||
label={`新增网站 ${diff.added_sites.length}`}
|
||
variant="success"
|
||
/>
|
||
) : null}
|
||
{diff.removed_sites.length > 0 ? (
|
||
<StatusBadge
|
||
label={`删除网站 ${diff.removed_sites.length}`}
|
||
variant="danger"
|
||
/>
|
||
) : null}
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
|
||
{filteredRoutes.length === 0 ? (
|
||
<EmptyState
|
||
title={routes.length === 0 ? '暂无网站' : '没有匹配结果'}
|
||
description={
|
||
routes.length === 0
|
||
? '先创建一个网站,再进入配置子页面继续补齐 HTTPS、缓存和限流。'
|
||
: '试试调整搜索词,或者清空筛选条件。'
|
||
}
|
||
/>
|
||
) : (
|
||
<div className="grid gap-4 xl:grid-cols-2">
|
||
{filteredRoutes.map((route) => (
|
||
<article
|
||
key={route.id}
|
||
className="rounded-[28px] border border-[var(--border-default)] bg-[var(--surface-elevated)] p-5"
|
||
>
|
||
<div className="flex flex-col gap-5">
|
||
<div className="flex items-start justify-between gap-4">
|
||
<div className="space-y-3">
|
||
<div className="space-y-2">
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<h2 className="text-lg font-semibold text-[var(--foreground-primary)]">
|
||
{route.site_name}
|
||
</h2>
|
||
{getWebsiteStatusBadges(route).map((badge) => (
|
||
<StatusBadge
|
||
key={`${route.id}-${badge.label}`}
|
||
label={badge.label}
|
||
variant={badge.variant}
|
||
/>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex flex-wrap gap-2">
|
||
<Link
|
||
href={`/proxy-route/detail?id=${route.id}§ion=domains`}
|
||
className="inline-flex items-center justify-center rounded-2xl border border-[var(--border-default)] bg-[var(--control-background)] px-4 py-3 text-sm font-medium text-[var(--foreground-primary)] transition hover:bg-[var(--control-background-hover)]"
|
||
>
|
||
配置
|
||
</Link>
|
||
<DangerButton
|
||
type="button"
|
||
onClick={() => handleDelete(route)}
|
||
disabled={deleteMutation.isPending}
|
||
>
|
||
删除
|
||
</DangerButton>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="grid gap-3 md:grid-cols-2">
|
||
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-panel)] px-4 py-3">
|
||
<p className="text-xs tracking-[0.18em] text-[var(--foreground-muted)] uppercase">
|
||
域名列表
|
||
</p>
|
||
<p className="mt-2 text-sm text-[var(--foreground-primary)]">
|
||
{route.domains.join(' / ')}
|
||
</p>
|
||
</div>
|
||
|
||
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-panel)] px-4 py-3">
|
||
<p className="text-xs tracking-[0.18em] text-[var(--foreground-muted)] uppercase">
|
||
上游摘要
|
||
</p>
|
||
<p className="mt-2 text-sm text-[var(--foreground-primary)]">
|
||
{getUpstreamSummary(route)}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
<p className="text-sm text-[var(--foreground-secondary)]">
|
||
{route.remark || null}
|
||
</p>
|
||
</div>
|
||
</article>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</AppCard>
|
||
</div>
|
||
|
||
<ProxyRouteCreateDrawer
|
||
open={isCreateOpen}
|
||
onOpenChange={setIsCreateOpen}
|
||
domainSuggestionSources={routes.flatMap((route) => route.domains)}
|
||
onCreated={async (route) => {
|
||
await Promise.all([
|
||
queryClient.invalidateQueries({ queryKey: ['proxy-routes'] }),
|
||
queryClient.invalidateQueries({
|
||
queryKey: ['config-versions', 'diff'],
|
||
}),
|
||
]);
|
||
router.push(`/proxy-route/detail?id=${route.id}§ion=domains`);
|
||
}}
|
||
/>
|
||
</>
|
||
);
|
||
}
|