mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-01 06:36:38 +08:00
[优化] go 引用调整
This commit is contained in:
@@ -0,0 +1,134 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { marked } from 'marked';
|
||||
|
||||
import { EmptyState } from '@/components/feedback/empty-state';
|
||||
import { ErrorState } from '@/components/feedback/error-state';
|
||||
import { LoadingState } from '@/components/feedback/loading-state';
|
||||
import { AppCard } from '@/components/ui/app-card';
|
||||
import { StatusBadge } from '@/components/ui/status-badge';
|
||||
import { getPublicStatus } from '@/features/auth/api/public';
|
||||
import { getAboutContent } from '@/features/settings/api/settings';
|
||||
import { formatDateTime } from '@/lib/utils/date';
|
||||
|
||||
function getErrorMessage(error: unknown) {
|
||||
return error instanceof Error ? error.message : '请求失败,请稍后重试。';
|
||||
}
|
||||
|
||||
export function AboutPage() {
|
||||
const aboutQuery = useQuery({
|
||||
queryKey: ['about'],
|
||||
queryFn: getAboutContent,
|
||||
});
|
||||
|
||||
const statusQuery = useQuery({
|
||||
queryKey: ['public-status'],
|
||||
queryFn: getPublicStatus,
|
||||
});
|
||||
|
||||
if (aboutQuery.isLoading || statusQuery.isLoading) {
|
||||
return <LoadingState />;
|
||||
}
|
||||
|
||||
if (aboutQuery.isError) {
|
||||
return (
|
||||
<ErrorState
|
||||
title="关于页加载失败"
|
||||
description={getErrorMessage(aboutQuery.error)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (statusQuery.isError) {
|
||||
return (
|
||||
<ErrorState
|
||||
title="系统状态加载失败"
|
||||
description={getErrorMessage(statusQuery.error)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const aboutContent = aboutQuery.data?.trim() ?? '';
|
||||
const status = statusQuery.data;
|
||||
|
||||
if (!status) {
|
||||
return (
|
||||
<EmptyState
|
||||
title="暂无系统信息"
|
||||
description="未能获取 OpenFlare 的公开状态信息。"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<AppCard
|
||||
title="关于 OpenFlare"
|
||||
description="公开展示当前系统简介、版本信息与项目入口。"
|
||||
action={<StatusBadge label={status.version || 'dev'} variant="info" />}
|
||||
>
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-muted)] px-4 py-4">
|
||||
<p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
|
||||
系统名称
|
||||
</p>
|
||||
<p className="mt-2 text-sm font-medium text-[var(--foreground-primary)]">
|
||||
{status.system_name || 'OpenFlare'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-muted)] px-4 py-4">
|
||||
<p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
|
||||
Server 启动时间
|
||||
</p>
|
||||
<p className="mt-2 text-sm font-medium text-[var(--foreground-primary)]">
|
||||
{formatDateTime(new Date(status.start_time * 1000))}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-muted)] px-4 py-4">
|
||||
<p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
|
||||
项目仓库
|
||||
</p>
|
||||
<a
|
||||
href="https://github.com/Rain-kl/OpenFlare"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="mt-2 block text-sm font-medium text-[var(--brand-primary)] transition hover:opacity-80"
|
||||
>
|
||||
github.com/Rain-kl/OpenFlare
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</AppCard>
|
||||
|
||||
{aboutContent ? (
|
||||
<AppCard
|
||||
title="项目介绍"
|
||||
description="以下内容由系统设置中的“关于内容”维护。"
|
||||
>
|
||||
<div
|
||||
className="prose prose-sm max-w-none text-[var(--foreground-primary)] [&_a]:text-[var(--brand-primary)]"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: marked.parse(aboutContent) as string,
|
||||
}}
|
||||
/>
|
||||
</AppCard>
|
||||
) : (
|
||||
<EmptyState
|
||||
title="尚未配置关于内容"
|
||||
description="可在设置页的“其他设置”标签中编写 Markdown / HTML 内容,这里会自动同步展示。"
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap gap-3 text-sm">
|
||||
<Link
|
||||
href="/login"
|
||||
className="text-[var(--brand-primary)] transition hover:opacity-80"
|
||||
>
|
||||
前往登录
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { apiRequest } from '@/lib/api/client';
|
||||
|
||||
import type {
|
||||
AccessLogCleanupPayload,
|
||||
AccessLogCleanupResult,
|
||||
AccessLogFilters,
|
||||
AccessLogIPSummaryFilters,
|
||||
AccessLogIPSummaryList,
|
||||
AccessLogIPTrend,
|
||||
AccessLogIPTrendFilters,
|
||||
AccessLogList,
|
||||
FoldedAccessLogIPFilters,
|
||||
FoldedAccessLogIPList,
|
||||
FoldedAccessLogFilters,
|
||||
FoldedAccessLogList,
|
||||
} from '@/features/access-logs/types';
|
||||
|
||||
function buildSearchParams(filters: object) {
|
||||
const searchParams = new URLSearchParams();
|
||||
Object.entries(
|
||||
filters as Record<string, string | number | undefined>,
|
||||
).forEach(([key, value]) => {
|
||||
if (value === undefined || value === null || value === '') {
|
||||
return;
|
||||
}
|
||||
searchParams.set(key, String(value));
|
||||
});
|
||||
return searchParams.toString();
|
||||
}
|
||||
|
||||
export function getAccessLogs(filters: AccessLogFilters) {
|
||||
const query = buildSearchParams(filters);
|
||||
return apiRequest<AccessLogList>(`/access-logs/${query ? `?${query}` : ''}`);
|
||||
}
|
||||
|
||||
export function getFoldedAccessLogs(filters: FoldedAccessLogFilters) {
|
||||
const query = buildSearchParams(filters);
|
||||
return apiRequest<FoldedAccessLogList>(
|
||||
`/access-logs/folds${query ? `?${query}` : ''}`,
|
||||
);
|
||||
}
|
||||
|
||||
export function getFoldedAccessLogIPs(filters: FoldedAccessLogIPFilters) {
|
||||
const query = buildSearchParams(filters);
|
||||
return apiRequest<FoldedAccessLogIPList>(
|
||||
`/access-logs/folds/ip-summary${query ? `?${query}` : ''}`,
|
||||
);
|
||||
}
|
||||
|
||||
export function getAccessLogIPSummaries(filters: AccessLogIPSummaryFilters) {
|
||||
const query = buildSearchParams(filters);
|
||||
return apiRequest<AccessLogIPSummaryList>(
|
||||
`/access-logs/ip-summary${query ? `?${query}` : ''}`,
|
||||
);
|
||||
}
|
||||
|
||||
export function getAccessLogIPTrend(filters: AccessLogIPTrendFilters) {
|
||||
const query = buildSearchParams(filters);
|
||||
return apiRequest<AccessLogIPTrend>(
|
||||
`/access-logs/ip-summary/trend${query ? `?${query}` : ''}`,
|
||||
);
|
||||
}
|
||||
|
||||
export function cleanupAccessLogs(payload: AccessLogCleanupPayload) {
|
||||
return apiRequest<AccessLogCleanupResult>('/access-logs/cleanup', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,138 @@
|
||||
export interface AccessLogFilters {
|
||||
node_id?: string;
|
||||
remote_addr?: string;
|
||||
host?: string;
|
||||
path?: string;
|
||||
p?: number;
|
||||
page_size?: number;
|
||||
sort_by?: string;
|
||||
sort_order?: 'asc' | 'desc';
|
||||
}
|
||||
|
||||
export interface AccessLogItem {
|
||||
id: number;
|
||||
node_id: string;
|
||||
node_name: string;
|
||||
logged_at: string;
|
||||
remote_addr: string;
|
||||
region: string;
|
||||
host: string;
|
||||
path: string;
|
||||
status_code: number;
|
||||
}
|
||||
|
||||
export interface AccessLogList {
|
||||
items: AccessLogItem[];
|
||||
page: number;
|
||||
page_size: number;
|
||||
has_more: boolean;
|
||||
total_record: number;
|
||||
total_ip: number;
|
||||
}
|
||||
|
||||
export interface FoldedAccessLogFilters extends AccessLogFilters {
|
||||
fold_minutes: 3 | 5;
|
||||
}
|
||||
|
||||
export interface FoldedAccessLogItem {
|
||||
bucket_started_at: string;
|
||||
request_count: number;
|
||||
unique_ip_count: number;
|
||||
unique_host_count: number;
|
||||
success_count: number;
|
||||
client_error_count: number;
|
||||
server_error_count: number;
|
||||
}
|
||||
|
||||
export interface FoldedAccessLogList {
|
||||
items: FoldedAccessLogItem[];
|
||||
page: number;
|
||||
page_size: number;
|
||||
has_more: boolean;
|
||||
total_bucket: number;
|
||||
total_record: number;
|
||||
total_ip: number;
|
||||
fold_minutes: number;
|
||||
}
|
||||
|
||||
export interface FoldedAccessLogIPFilters extends FoldedAccessLogFilters {
|
||||
bucket_started_at: string;
|
||||
}
|
||||
|
||||
export interface FoldedAccessLogIPItem {
|
||||
remote_addr: string;
|
||||
request_count: number;
|
||||
success_count: number;
|
||||
client_error_count: number;
|
||||
server_error_count: number;
|
||||
last_seen_at: string;
|
||||
}
|
||||
|
||||
export interface FoldedAccessLogIPList {
|
||||
items: FoldedAccessLogIPItem[];
|
||||
page: number;
|
||||
page_size: number;
|
||||
has_more: boolean;
|
||||
total_ip: number;
|
||||
bucket_started_at: string;
|
||||
fold_minutes: number;
|
||||
sort_by: string;
|
||||
sort_order: 'asc' | 'desc';
|
||||
}
|
||||
|
||||
export interface AccessLogIPSummaryFilters {
|
||||
node_id?: string;
|
||||
remote_addr?: string;
|
||||
host?: string;
|
||||
p?: number;
|
||||
page_size?: number;
|
||||
sort_by?: string;
|
||||
sort_order?: 'asc' | 'desc';
|
||||
}
|
||||
|
||||
export interface AccessLogIPSummaryItem {
|
||||
remote_addr: string;
|
||||
total_requests: number;
|
||||
recent_requests: number;
|
||||
last_seen_at: string;
|
||||
}
|
||||
|
||||
export interface AccessLogIPSummaryList {
|
||||
items: AccessLogIPSummaryItem[];
|
||||
page: number;
|
||||
page_size: number;
|
||||
has_more: boolean;
|
||||
total_ip: number;
|
||||
sort_by: string;
|
||||
sort_order: 'asc' | 'desc';
|
||||
}
|
||||
|
||||
export interface AccessLogIPTrendFilters {
|
||||
node_id?: string;
|
||||
remote_addr: string;
|
||||
host?: string;
|
||||
hours?: number;
|
||||
bucket_minutes?: number;
|
||||
}
|
||||
|
||||
export interface AccessLogIPTrendPoint {
|
||||
bucket_started_at: string;
|
||||
request_count: number;
|
||||
}
|
||||
|
||||
export interface AccessLogIPTrend {
|
||||
remote_addr: string;
|
||||
hours: number;
|
||||
bucket_minutes: number;
|
||||
points: AccessLogIPTrendPoint[];
|
||||
}
|
||||
|
||||
export interface AccessLogCleanupPayload {
|
||||
retention_days: number;
|
||||
}
|
||||
|
||||
export interface AccessLogCleanupResult {
|
||||
retention_days: number;
|
||||
deleted_count: number;
|
||||
cutoff: string;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { apiRequest } from '@/lib/api/client';
|
||||
import type { AcmeAccountItem } from '@/features/acme-accounts/types';
|
||||
|
||||
export function getDefaultAcmeAccount() {
|
||||
return apiRequest<AcmeAccountItem>('/acme-accounts/default');
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export interface AcmeAccountItem {
|
||||
id: number;
|
||||
email: string;
|
||||
url: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { apiRequest } from '@/lib/api/client';
|
||||
|
||||
import type {
|
||||
ApplyLogCleanupPayload,
|
||||
ApplyLogCleanupResult,
|
||||
ApplyLogList,
|
||||
ApplyLogListQuery,
|
||||
} from '@/features/apply-logs/types';
|
||||
|
||||
export function getApplyLogs(query: ApplyLogListQuery = {}) {
|
||||
const params = new URLSearchParams();
|
||||
const normalizedNodeId = query.node_id?.trim();
|
||||
if (normalizedNodeId) {
|
||||
params.set('node_id', normalizedNodeId);
|
||||
}
|
||||
if (query.pageNo) {
|
||||
params.set('pageNo', String(query.pageNo));
|
||||
}
|
||||
if (query.pageSize) {
|
||||
params.set('pageSize', String(query.pageSize));
|
||||
}
|
||||
const suffix = params.size > 0 ? `?${params.toString()}` : '';
|
||||
return apiRequest<ApplyLogList>(`/apply-logs/${suffix}`);
|
||||
}
|
||||
|
||||
export function cleanupApplyLogs(payload: ApplyLogCleanupPayload) {
|
||||
return apiRequest<ApplyLogCleanupResult>('/apply-logs/cleanup', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,553 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import { AppModal } from '@/components/ui/app-modal';
|
||||
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 {
|
||||
cleanupApplyLogs,
|
||||
getApplyLogs,
|
||||
} from '@/features/apply-logs/api/apply-logs';
|
||||
import type {
|
||||
ApplyLogCleanupPayload,
|
||||
ApplyLogItem,
|
||||
} from '@/features/apply-logs/types';
|
||||
import {
|
||||
PrimaryButton,
|
||||
ResourceField,
|
||||
ResourceInput,
|
||||
ResourceSelect,
|
||||
SecondaryButton,
|
||||
} from '@/features/shared/components/resource-primitives';
|
||||
import { formatDateTime, formatRelativeTime } from '@/lib/utils/date';
|
||||
|
||||
const applyLogsQueryKey = (nodeId: string, pageNo: number, pageSize: number) =>
|
||||
['apply-logs', nodeId, pageNo, pageSize] as const;
|
||||
|
||||
const pageSizeOptions = [20, 50, 100];
|
||||
const emptyApplyLogRows: ApplyLogItem[] = [];
|
||||
|
||||
type FeedbackState = {
|
||||
tone: 'info' | 'success' | 'danger';
|
||||
message: string;
|
||||
};
|
||||
|
||||
function getErrorMessage(error: unknown) {
|
||||
return error instanceof Error ? error.message : '请求失败,请稍后重试。';
|
||||
}
|
||||
|
||||
function getResultMeta(result: string) {
|
||||
if (result === 'success') {
|
||||
return { label: '成功', variant: 'success' as const };
|
||||
}
|
||||
if (result === 'warning') {
|
||||
return { label: '警告', variant: 'warning' as const };
|
||||
}
|
||||
return { label: '失败', variant: 'danger' as const };
|
||||
}
|
||||
|
||||
function truncateHash(value: string) {
|
||||
if (!value) {
|
||||
return '—';
|
||||
}
|
||||
return value.length > 12 ? `${value.slice(0, 12)}...` : value;
|
||||
}
|
||||
|
||||
function buildSummary(
|
||||
rows: ApplyLogItem[],
|
||||
total: number,
|
||||
current: number,
|
||||
totalPage: number,
|
||||
) {
|
||||
const nodeIds = new Set(rows.map((item) => item.node_id));
|
||||
return [
|
||||
{ label: '总记录数', value: total },
|
||||
{ label: '当前页', value: current },
|
||||
{ label: '总页数', value: totalPage },
|
||||
{ label: '当前页节点数', value: nodeIds.size },
|
||||
];
|
||||
}
|
||||
|
||||
export function ApplyLogsPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const [nodeFilterInput, setNodeFilterInput] = useState('');
|
||||
const [nodeFilter, setNodeFilter] = useState('');
|
||||
const [pageNo, setPageNo] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(20);
|
||||
const [selectedLog, setSelectedLog] = useState<ApplyLogItem | null>(null);
|
||||
const [isCleanupModalOpen, setCleanupModalOpen] = useState(false);
|
||||
const [cleanupMode, setCleanupMode] = useState<'all' | 'custom'>('custom');
|
||||
const [customRetentionDays, setCustomRetentionDays] = useState('30');
|
||||
const [feedback, setFeedback] = useState<FeedbackState | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const nodeID = new URLSearchParams(window.location.search)
|
||||
.get('node_id')
|
||||
?.trim();
|
||||
if (!nodeID) {
|
||||
return;
|
||||
}
|
||||
setNodeFilterInput(nodeID);
|
||||
setNodeFilter(nodeID);
|
||||
setPageNo(1);
|
||||
}, []);
|
||||
|
||||
const logsQuery = useQuery({
|
||||
queryKey: applyLogsQueryKey(nodeFilter, pageNo, pageSize),
|
||||
queryFn: () =>
|
||||
getApplyLogs({
|
||||
node_id: nodeFilter || undefined,
|
||||
pageNo,
|
||||
pageSize,
|
||||
}),
|
||||
placeholderData: (previous) => previous,
|
||||
});
|
||||
|
||||
const cleanupMutation = useMutation({
|
||||
mutationFn: (payload: ApplyLogCleanupPayload) => cleanupApplyLogs(payload),
|
||||
onSuccess: async (result) => {
|
||||
setCleanupModalOpen(false);
|
||||
setPageNo(1);
|
||||
setFeedback({
|
||||
tone: 'success',
|
||||
message: result.delete_all
|
||||
? `已删除全部应用日志,共 ${result.deleted_count} 条。`
|
||||
: `已清理保留期之外的应用日志,共 ${result.deleted_count} 条。`,
|
||||
});
|
||||
await queryClient.invalidateQueries({ queryKey: ['apply-logs'] });
|
||||
},
|
||||
onError: (error) => {
|
||||
setFeedback({ tone: 'danger', message: getErrorMessage(error) });
|
||||
},
|
||||
});
|
||||
|
||||
const rows = logsQuery.data?.rows ?? emptyApplyLogRows;
|
||||
const current = logsQuery.data?.current ?? pageNo;
|
||||
const total = logsQuery.data?.total ?? 0;
|
||||
const totalPage = logsQuery.data?.totalPage ?? 0;
|
||||
const summary = useMemo(
|
||||
() => buildSummary(rows, total, current, totalPage),
|
||||
[rows, total, current, totalPage],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (rows.length === 0 && selectedLog) {
|
||||
setSelectedLog(null);
|
||||
return;
|
||||
}
|
||||
if (selectedLog && !rows.some((item) => item.id === selectedLog.id)) {
|
||||
setSelectedLog(null);
|
||||
}
|
||||
}, [rows, selectedLog]);
|
||||
|
||||
const handleSearch = () => {
|
||||
setFeedback(null);
|
||||
setPageNo(1);
|
||||
setNodeFilter(nodeFilterInput.trim());
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setFeedback(null);
|
||||
setNodeFilter('');
|
||||
setNodeFilterInput('');
|
||||
setPageNo(1);
|
||||
};
|
||||
|
||||
const handleRefresh = async () => {
|
||||
setFeedback(null);
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: applyLogsQueryKey(nodeFilter, pageNo, pageSize),
|
||||
});
|
||||
};
|
||||
|
||||
const handleCleanupConfirm = () => {
|
||||
const payload: ApplyLogCleanupPayload =
|
||||
cleanupMode === 'all'
|
||||
? { delete_all: true }
|
||||
: {
|
||||
retention_days: Number.parseInt(customRetentionDays, 10),
|
||||
};
|
||||
cleanupMutation.mutate(payload);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="应用日志"
|
||||
description="查看节点应用配置的成功、警告和失败记录,支持分页查询、详情弹窗和按保留天数清理。"
|
||||
action={
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<SecondaryButton type="button" onClick={handleRefresh}>
|
||||
刷新
|
||||
</SecondaryButton>
|
||||
<PrimaryButton
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setFeedback(null);
|
||||
setCleanupModalOpen(true);
|
||||
}}
|
||||
>
|
||||
删除日志
|
||||
</PrimaryButton>
|
||||
<Link
|
||||
href="/node"
|
||||
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>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{feedback ? (
|
||||
<InlineMessage
|
||||
tone={feedback.tone}
|
||||
message={feedback.message}
|
||||
onClear={() => setFeedback(null)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<AppCard
|
||||
title="日志摘要"
|
||||
description="后端按分页返回应用日志,页面仅展示当前页数据和总量信息。"
|
||||
>
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
{summary.map((item) => (
|
||||
<div
|
||||
key={item.label}
|
||||
className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4"
|
||||
>
|
||||
<p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
|
||||
{item.label}
|
||||
</p>
|
||||
<p className="mt-2 text-lg font-semibold text-[var(--foreground-primary)]">
|
||||
{item.value}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</AppCard>
|
||||
|
||||
<AppCard
|
||||
title="过滤与列表"
|
||||
description="支持按 node_id 过滤,并按页查看应用结果。默认每页 20 条。"
|
||||
>
|
||||
<div className="space-y-5">
|
||||
<div className="flex flex-col gap-3 xl:flex-row xl:items-end xl:justify-between">
|
||||
<div className="grid flex-1 gap-3 md:grid-cols-[minmax(0,1fr)_180px]">
|
||||
<ResourceField label="Node ID">
|
||||
<ResourceInput
|
||||
value={nodeFilterInput}
|
||||
onChange={(event) => setNodeFilterInput(event.target.value)}
|
||||
placeholder="输入 node_id 过滤应用日志"
|
||||
/>
|
||||
</ResourceField>
|
||||
<ResourceField label="每页条数">
|
||||
<ResourceSelect
|
||||
value={String(pageSize)}
|
||||
onChange={(event) => {
|
||||
setPageSize(Number.parseInt(event.target.value, 10));
|
||||
setPageNo(1);
|
||||
}}
|
||||
>
|
||||
{pageSizeOptions.map((option) => (
|
||||
<option key={option} value={option}>
|
||||
{option} 条
|
||||
</option>
|
||||
))}
|
||||
</ResourceSelect>
|
||||
</ResourceField>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<PrimaryButton type="button" onClick={handleSearch}>
|
||||
筛选
|
||||
</PrimaryButton>
|
||||
<SecondaryButton type="button" onClick={handleReset}>
|
||||
清空
|
||||
</SecondaryButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{logsQuery.isLoading ? (
|
||||
<LoadingState />
|
||||
) : logsQuery.isError ? (
|
||||
<ErrorState
|
||||
title="应用日志加载失败"
|
||||
description={getErrorMessage(logsQuery.error)}
|
||||
/>
|
||||
) : rows.length === 0 ? (
|
||||
<EmptyState
|
||||
title="暂无应用日志"
|
||||
description="当前筛选条件下没有可展示的应用记录。"
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-[var(--border-default)] text-left text-sm">
|
||||
<thead>
|
||||
<tr className="text-[var(--foreground-secondary)]">
|
||||
<th className="px-3 py-3 font-medium">Node ID</th>
|
||||
<th className="px-3 py-3 font-medium">版本</th>
|
||||
<th className="px-3 py-3 font-medium">结果</th>
|
||||
<th className="px-3 py-3 font-medium">Checksum</th>
|
||||
<th className="px-3 py-3 font-medium">时间</th>
|
||||
<th className="px-3 py-3 font-medium">消息</th>
|
||||
<th className="px-3 py-3 font-medium">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-[var(--border-default)]">
|
||||
{rows.map((log) => {
|
||||
const resultMeta = getResultMeta(log.result);
|
||||
return (
|
||||
<tr key={log.id} className="align-top">
|
||||
<td className="px-3 py-4 font-medium text-[var(--foreground-primary)]">
|
||||
{log.node_id}
|
||||
</td>
|
||||
<td className="px-3 py-4 text-[var(--foreground-secondary)]">
|
||||
{log.version}
|
||||
</td>
|
||||
<td className="px-3 py-4">
|
||||
<StatusBadge
|
||||
label={resultMeta.label}
|
||||
variant={resultMeta.variant}
|
||||
/>
|
||||
</td>
|
||||
<td
|
||||
className="px-3 py-4 text-[var(--foreground-secondary)]"
|
||||
title={log.checksum}
|
||||
>
|
||||
{truncateHash(log.checksum)}
|
||||
</td>
|
||||
<td className="px-3 py-4 text-[var(--foreground-secondary)]">
|
||||
<div className="space-y-1">
|
||||
<p>{formatDateTime(log.created_at)}</p>
|
||||
<p className="text-xs">
|
||||
{formatRelativeTime(log.created_at)}
|
||||
</p>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-4 text-[var(--foreground-secondary)]">
|
||||
<div className="max-w-72 break-words whitespace-pre-wrap">
|
||||
{log.message || '—'}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-4">
|
||||
<SecondaryButton
|
||||
type="button"
|
||||
className="px-3 py-2 text-xs"
|
||||
onClick={() => setSelectedLog(log)}
|
||||
>
|
||||
查看详情
|
||||
</SecondaryButton>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 border-t border-[var(--border-default)] pt-4 md:flex-row md:items-center md:justify-between">
|
||||
<p className="text-sm text-[var(--foreground-secondary)]">
|
||||
第 {current} / {Math.max(totalPage, 1)} 页,共 {total}{' '}
|
||||
条记录。
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<SecondaryButton
|
||||
type="button"
|
||||
disabled={current <= 1}
|
||||
onClick={() =>
|
||||
setPageNo((previous) => Math.max(1, previous - 1))
|
||||
}
|
||||
>
|
||||
上一页
|
||||
</SecondaryButton>
|
||||
<SecondaryButton
|
||||
type="button"
|
||||
disabled={totalPage === 0 || current >= totalPage}
|
||||
onClick={() =>
|
||||
setPageNo((previous) =>
|
||||
totalPage > 0
|
||||
? Math.min(totalPage, previous + 1)
|
||||
: previous,
|
||||
)
|
||||
}
|
||||
>
|
||||
下一页
|
||||
</SecondaryButton>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</AppCard>
|
||||
|
||||
<AppModal
|
||||
isOpen={selectedLog !== null}
|
||||
title="应用日志详情"
|
||||
description="查看单条应用日志的完整结果、消息和校验信息。"
|
||||
size="lg"
|
||||
onClose={() => setSelectedLog(null)}
|
||||
>
|
||||
{selectedLog ? (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<StatusBadge {...getResultMeta(selectedLog.result)} />
|
||||
<StatusBadge
|
||||
label={`Node:${selectedLog.node_id}`}
|
||||
variant="info"
|
||||
/>
|
||||
<StatusBadge
|
||||
label={`版本:${selectedLog.version}`}
|
||||
variant="warning"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<MetricCard
|
||||
label="创建时间"
|
||||
value={formatDateTime(selectedLog.created_at)}
|
||||
/>
|
||||
<MetricCard
|
||||
label="相对时间"
|
||||
value={formatRelativeTime(selectedLog.created_at)}
|
||||
/>
|
||||
<MetricCard
|
||||
label="目标 Checksum"
|
||||
value={selectedLog.checksum || '—'}
|
||||
breakAll
|
||||
/>
|
||||
<MetricCard
|
||||
label="支持文件数"
|
||||
value={String(selectedLog.support_file_count)}
|
||||
/>
|
||||
<MetricCard
|
||||
label="主配置摘要"
|
||||
value={selectedLog.main_config_checksum || '—'}
|
||||
breakAll
|
||||
/>
|
||||
<MetricCard
|
||||
label="路由配置摘要"
|
||||
value={selectedLog.route_config_checksum || '—'}
|
||||
breakAll
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
|
||||
<p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
|
||||
消息
|
||||
</p>
|
||||
<pre className="mt-3 text-sm leading-6 break-words whitespace-pre-wrap text-[var(--foreground-primary)]">
|
||||
{selectedLog.message || '—'}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</AppModal>
|
||||
|
||||
<AppModal
|
||||
isOpen={isCleanupModalOpen}
|
||||
title="删除应用日志"
|
||||
description="可以选择删除全部应用日志,或仅保留最近自定义天数内的记录。"
|
||||
onClose={() => setCleanupModalOpen(false)}
|
||||
footer={
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
<SecondaryButton
|
||||
type="button"
|
||||
onClick={() => setCleanupModalOpen(false)}
|
||||
>
|
||||
取消
|
||||
</SecondaryButton>
|
||||
<PrimaryButton
|
||||
type="button"
|
||||
disabled={cleanupMutation.isPending}
|
||||
onClick={handleCleanupConfirm}
|
||||
>
|
||||
{cleanupMutation.isPending ? '处理中...' : '确认删除'}
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="space-y-5">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCleanupMode('all')}
|
||||
className={`rounded-2xl border px-4 py-3 text-sm transition ${
|
||||
cleanupMode === 'all'
|
||||
? 'border-[var(--brand-primary)] bg-[var(--accent-soft)] text-[var(--foreground-primary)]'
|
||||
: 'border-[var(--border-default)] bg-[var(--surface-elevated)] text-[var(--foreground-secondary)]'
|
||||
}`}
|
||||
>
|
||||
全部删除
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCleanupMode('custom')}
|
||||
className={`rounded-2xl border px-4 py-3 text-sm transition ${
|
||||
cleanupMode === 'custom'
|
||||
? 'border-[var(--brand-primary)] bg-[var(--accent-soft)] text-[var(--foreground-primary)]'
|
||||
: 'border-[var(--border-default)] bg-[var(--surface-elevated)] text-[var(--foreground-secondary)]'
|
||||
}`}
|
||||
>
|
||||
保留自定义天数
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{cleanupMode === 'custom' ? (
|
||||
<ResourceField label="保留天数" hint="当前支持 1 到 3650 天。">
|
||||
<ResourceInput
|
||||
value={customRetentionDays}
|
||||
onChange={(event) => setCustomRetentionDays(event.target.value)}
|
||||
type="number"
|
||||
min={1}
|
||||
max={3650}
|
||||
placeholder="输入保留天数"
|
||||
/>
|
||||
</ResourceField>
|
||||
) : null}
|
||||
|
||||
{cleanupMutation.isError ? (
|
||||
<ErrorState
|
||||
title="删除应用日志失败"
|
||||
description={getErrorMessage(cleanupMutation.error)}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</AppModal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MetricCard({
|
||||
label,
|
||||
value,
|
||||
breakAll = false,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
breakAll?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
|
||||
<p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
|
||||
{label}
|
||||
</p>
|
||||
<p
|
||||
className={`mt-2 text-sm text-[var(--foreground-primary)] ${
|
||||
breakAll ? 'break-all' : ''
|
||||
}`}
|
||||
>
|
||||
{value}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
export interface ApplyLogItem {
|
||||
id: number;
|
||||
node_id: string;
|
||||
version: string;
|
||||
result: 'success' | 'failed' | string;
|
||||
message: string;
|
||||
checksum: string;
|
||||
main_config_checksum: string;
|
||||
route_config_checksum: string;
|
||||
support_file_count: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface ApplyLogList {
|
||||
rows: ApplyLogItem[];
|
||||
current: number;
|
||||
total: number;
|
||||
totalPage: number;
|
||||
}
|
||||
|
||||
export interface ApplyLogListQuery {
|
||||
node_id?: string;
|
||||
pageNo?: number;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
export interface ApplyLogCleanupPayload {
|
||||
delete_all?: boolean;
|
||||
retention_days?: number;
|
||||
}
|
||||
|
||||
export interface ApplyLogCleanupResult {
|
||||
delete_all: boolean;
|
||||
retention_days: number;
|
||||
deleted_count: number;
|
||||
cutoff?: string;
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { apiRequest } from '@/lib/api/client';
|
||||
import {
|
||||
clearStoredOpenFlareToken,
|
||||
setStoredOpenFlareToken,
|
||||
} from '@/lib/api/auth-token';
|
||||
import type {
|
||||
AuthUser,
|
||||
LoginPayload,
|
||||
PasswordResetRequestPayload,
|
||||
RegisterPayload,
|
||||
} from '@/types/auth';
|
||||
|
||||
export function getCurrentUser() {
|
||||
return apiRequest<AuthUser>('/user/self');
|
||||
}
|
||||
|
||||
export function login(payload: LoginPayload) {
|
||||
return apiRequest<AuthUser>('/user/login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
}).then((user) => {
|
||||
if (user.token) {
|
||||
setStoredOpenFlareToken(user.token);
|
||||
}
|
||||
return user;
|
||||
});
|
||||
}
|
||||
|
||||
export function logout() {
|
||||
return apiRequest<void>('/user/logout').finally(() => {
|
||||
clearStoredOpenFlareToken();
|
||||
});
|
||||
}
|
||||
|
||||
export function register(payload: RegisterPayload) {
|
||||
return apiRequest<void>('/user/register', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export function sendEmailVerification(email: string) {
|
||||
const searchParams = new URLSearchParams({ email });
|
||||
|
||||
return apiRequest<void>(`/verification?${searchParams.toString()}`);
|
||||
}
|
||||
|
||||
export function sendPasswordResetEmail(email: string) {
|
||||
const searchParams = new URLSearchParams({ email });
|
||||
|
||||
return apiRequest<void>(`/reset_password?${searchParams.toString()}`);
|
||||
}
|
||||
|
||||
export function resetPassword(payload: PasswordResetRequestPayload) {
|
||||
return apiRequest<string>('/user/reset', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export function exchangeGitHubCode(code: string) {
|
||||
return apiRequest<AuthUser>(
|
||||
`/oauth/github?code=${encodeURIComponent(code)}`,
|
||||
).then((user) => {
|
||||
if (user.token) {
|
||||
setStoredOpenFlareToken(user.token);
|
||||
}
|
||||
return user;
|
||||
});
|
||||
}
|
||||
|
||||
export interface OAuthAuthorizeResult {
|
||||
authorize_url: string;
|
||||
}
|
||||
|
||||
export interface OAuthCallbackResult {
|
||||
status: 'logged_in' | 'registered' | 'linked' | 'link_required';
|
||||
user?: AuthUser;
|
||||
}
|
||||
|
||||
export interface LinkExistingOAuthPayload {
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export function getOAuthAuthorizeUrl(source: number | string) {
|
||||
return apiRequest<OAuthAuthorizeResult>(
|
||||
`/oauth/${encodeURIComponent(String(source))}/authorize`,
|
||||
);
|
||||
}
|
||||
|
||||
export function exchangeOAuthCode(
|
||||
source: number | string,
|
||||
code: string,
|
||||
state: string,
|
||||
) {
|
||||
const searchParams = new URLSearchParams({ code, state });
|
||||
return apiRequest<OAuthCallbackResult>(
|
||||
`/oauth/${encodeURIComponent(String(source))}/callback?${searchParams.toString()}`,
|
||||
).then((result) => {
|
||||
if (result.user?.token) {
|
||||
setStoredOpenFlareToken(result.user.token);
|
||||
}
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
export function linkExistingOAuthAccount(payload: LinkExistingOAuthPayload) {
|
||||
return apiRequest<OAuthCallbackResult>('/oauth/link-existing', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
}).then((result) => {
|
||||
if (result.user?.token) {
|
||||
setStoredOpenFlareToken(result.user.token);
|
||||
}
|
||||
return result;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import {apiRequest} from '@/lib/api/client';
|
||||
import type {PublicStatus} from '@/types/public-status';
|
||||
|
||||
export function getPublicStatus() {
|
||||
return apiRequest<PublicStatus>('/status');
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import type {
|
||||
ButtonHTMLAttributes,
|
||||
InputHTMLAttributes,
|
||||
ReactNode,
|
||||
} from 'react';
|
||||
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
|
||||
export function AuthFormField({
|
||||
label,
|
||||
hint,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
hint?: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<label className="block space-y-2">
|
||||
<span className="text-sm font-medium text-[var(--foreground-primary)]">
|
||||
{label}
|
||||
</span>
|
||||
{children}
|
||||
{hint ? (
|
||||
<span className="block text-xs text-[var(--foreground-secondary)]">
|
||||
{hint}
|
||||
</span>
|
||||
) : null}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export function AuthInput(props: InputHTMLAttributes<HTMLInputElement>) {
|
||||
return (
|
||||
<input
|
||||
{...props}
|
||||
className={cn(
|
||||
'w-full rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-3 text-sm text-[var(--foreground-primary)] transition outline-none placeholder:text-[var(--foreground-muted)] focus:border-[var(--border-strong)] focus:ring-2 focus:ring-[var(--accent-soft)]',
|
||||
props.className,
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function AuthButton({
|
||||
className,
|
||||
...props
|
||||
}: ButtonHTMLAttributes<HTMLButtonElement>) {
|
||||
return (
|
||||
<button
|
||||
{...props}
|
||||
className={cn(
|
||||
'inline-flex w-full items-center justify-center rounded-2xl bg-[var(--brand-primary)] px-4 py-3 text-sm font-medium text-[var(--foreground-inverse)] transition hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-60',
|
||||
className,
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function SecondaryButton({
|
||||
className,
|
||||
...props
|
||||
}: ButtonHTMLAttributes<HTMLButtonElement>) {
|
||||
return (
|
||||
<button
|
||||
{...props}
|
||||
className={cn(
|
||||
'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)] disabled:cursor-not-allowed disabled:opacity-60',
|
||||
className,
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
'use client';
|
||||
|
||||
import type { ReactNode } from 'react';
|
||||
import { useEffect } from 'react';
|
||||
import { usePathname, useRouter } from 'next/navigation';
|
||||
|
||||
import { LoadingState } from '@/components/feedback/loading-state';
|
||||
import { useAuth } from '@/components/providers/auth-provider';
|
||||
|
||||
interface DashboardAuthGuardProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function DashboardAuthGuard({ children }: DashboardAuthGuardProps) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const { isAuthenticated, isLoading } = useAuth();
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading && !isAuthenticated) {
|
||||
const redirect =
|
||||
pathname && pathname !== '/'
|
||||
? `?redirect=${encodeURIComponent(pathname)}`
|
||||
: '';
|
||||
router.replace(`/login${redirect}`);
|
||||
}
|
||||
}, [isAuthenticated, isLoading, pathname, router]);
|
||||
|
||||
if (isLoading || !isAuthenticated) {
|
||||
return (
|
||||
<main className="flex min-h-screen items-center justify-center px-4 py-8">
|
||||
<LoadingState className="w-full max-w-md" />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
'use client';
|
||||
|
||||
import {useMutation} from '@tanstack/react-query';
|
||||
import {useRouter, useSearchParams} from 'next/navigation';
|
||||
import {useEffect, useRef, useState} from 'react';
|
||||
|
||||
import {InlineMessage} from '@/components/feedback/inline-message';
|
||||
import {LoadingState} from '@/components/feedback/loading-state';
|
||||
import {useAuth} from '@/components/providers/auth-provider';
|
||||
import {AppCard} from '@/components/ui/app-card';
|
||||
import {exchangeGitHubCode} from '@/features/auth/api/auth';
|
||||
|
||||
export function GitHubOAuthCallback() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const { setUser } = useAuth();
|
||||
const handledCodeRef = useRef<string | null>(null);
|
||||
const [prompt, setPrompt] = useState('正在处理 GitHub 授权结果...');
|
||||
const [message, setMessage] = useState<{
|
||||
tone: 'danger' | 'success';
|
||||
text: string;
|
||||
} | null>(null);
|
||||
const code = searchParams?.get('code')?.trim() ?? '';
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: exchangeGitHubCode,
|
||||
onSuccess: (user) => {
|
||||
setUser(user);
|
||||
setMessage({ tone: 'success', text: '登录成功,正在跳转...' });
|
||||
router.replace('/');
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
setPrompt('授权处理失败');
|
||||
setMessage({
|
||||
tone: 'danger',
|
||||
text: error.message || 'GitHub 授权失败,请稍后重试。',
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!code) {
|
||||
setPrompt('缺少授权 code');
|
||||
setMessage({
|
||||
tone: 'danger',
|
||||
text: '未收到 GitHub 授权参数,请返回登录页重试。',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (handledCodeRef.current === code) {
|
||||
return;
|
||||
}
|
||||
|
||||
handledCodeRef.current = code;
|
||||
mutation.mutate(code);
|
||||
}, [code, mutation]);
|
||||
|
||||
return (
|
||||
<AppCard title="GitHub OAuth 回调" description={prompt}>
|
||||
<div className="space-y-4">
|
||||
{mutation.isPending ? <LoadingState /> : null}
|
||||
{message ? (
|
||||
<InlineMessage tone={message.tone} message={message.text} />
|
||||
) : null}
|
||||
</div>
|
||||
</AppCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
'use client';
|
||||
|
||||
import {zodResolver} from '@hookform/resolvers/zod';
|
||||
import {useMutation, useQuery} from '@tanstack/react-query';
|
||||
import Link from 'next/link';
|
||||
import {useRouter, useSearchParams} from 'next/navigation';
|
||||
import {useState} from 'react';
|
||||
import {useForm} from 'react-hook-form';
|
||||
import {z} from 'zod';
|
||||
|
||||
import {InlineMessage} from '@/components/feedback/inline-message';
|
||||
import {useAuth} from '@/components/providers/auth-provider';
|
||||
import {AppCard} from '@/components/ui/app-card';
|
||||
import {getOAuthAuthorizeUrl, login} from '@/features/auth/api/auth';
|
||||
import {getPublicStatus} from '@/features/auth/api/public';
|
||||
import {AuthButton, AuthFormField, AuthInput, SecondaryButton,} from '@/features/auth/components/auth-form-primitives';
|
||||
import {PublicAuthGuard} from '@/features/auth/components/public-auth-guard';
|
||||
|
||||
const TEXT = {
|
||||
usernameRequired: '\u8bf7\u8f93\u5165\u7528\u6237\u540d',
|
||||
passwordRequired: '\u8bf7\u8f93\u5165\u5bc6\u7801',
|
||||
loginFailed:
|
||||
'\u767b\u5f55\u5931\u8d25\uff0c\u8bf7\u7a0d\u540e\u91cd\u8bd5\u3002',
|
||||
oauthUnavailable: '第三方登录当前不可用。',
|
||||
title: '\u7528\u6237\u767b\u5f55',
|
||||
username: '\u7528\u6237\u540d',
|
||||
password: '\u5bc6\u7801',
|
||||
loginPending: '\u767b\u5f55\u4e2d...',
|
||||
login: '\u767b\u5f55',
|
||||
forgotPassword: '\u5fd8\u8bb0\u5bc6\u7801\uff1f',
|
||||
register: '\u6ce8\u518c',
|
||||
};
|
||||
|
||||
const loginSchema = z.object({
|
||||
username: z.string().min(1, TEXT.usernameRequired),
|
||||
password: z.string().min(1, TEXT.passwordRequired),
|
||||
});
|
||||
|
||||
type LoginFormValues = z.infer<typeof loginSchema>;
|
||||
|
||||
export function LoginForm() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const { setUser } = useAuth();
|
||||
const [errorMessage, setErrorMessage] = useState('');
|
||||
const redirect = searchParams?.get('redirect') || '/';
|
||||
|
||||
const form = useForm<LoginFormValues>({
|
||||
resolver: zodResolver(loginSchema),
|
||||
defaultValues: {
|
||||
username: '',
|
||||
password: '',
|
||||
},
|
||||
});
|
||||
|
||||
const statusQuery = useQuery({
|
||||
queryKey: ['public-status'],
|
||||
queryFn: getPublicStatus,
|
||||
});
|
||||
|
||||
const loginMutation = useMutation({
|
||||
mutationFn: login,
|
||||
onSuccess: (user) => {
|
||||
setUser(user);
|
||||
router.replace(redirect);
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
setErrorMessage(error.message || TEXT.loginFailed);
|
||||
},
|
||||
});
|
||||
|
||||
const oauthMutation = useMutation({
|
||||
mutationFn: getOAuthAuthorizeUrl,
|
||||
onSuccess: (result) => {
|
||||
window.location.href = result.authorize_url;
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
setErrorMessage(error.message || TEXT.oauthUnavailable);
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = form.handleSubmit((values) => {
|
||||
setErrorMessage('');
|
||||
loginMutation.mutate(values);
|
||||
});
|
||||
|
||||
const handleOAuthLogin = (sourceName: string) => {
|
||||
setErrorMessage('');
|
||||
oauthMutation.mutate(sourceName);
|
||||
};
|
||||
|
||||
return (
|
||||
<PublicAuthGuard>
|
||||
<AppCard title={TEXT.title}>
|
||||
<form className="space-y-4" onSubmit={handleSubmit}>
|
||||
<AuthFormField label={TEXT.username}>
|
||||
<AuthInput
|
||||
placeholder={TEXT.username}
|
||||
{...form.register('username')}
|
||||
/>
|
||||
{form.formState.errors.username ? (
|
||||
<span className="text-xs text-[var(--status-danger-foreground)]">
|
||||
{form.formState.errors.username.message}
|
||||
</span>
|
||||
) : null}
|
||||
</AuthFormField>
|
||||
|
||||
<AuthFormField label={TEXT.password}>
|
||||
<AuthInput
|
||||
type="password"
|
||||
placeholder={TEXT.password}
|
||||
{...form.register('password')}
|
||||
/>
|
||||
{form.formState.errors.password ? (
|
||||
<span className="text-xs text-[var(--status-danger-foreground)]">
|
||||
{form.formState.errors.password.message}
|
||||
</span>
|
||||
) : null}
|
||||
</AuthFormField>
|
||||
|
||||
{errorMessage ? (
|
||||
<InlineMessage tone="danger" message={errorMessage} />
|
||||
) : null}
|
||||
|
||||
<div>
|
||||
<AuthButton type="submit" disabled={loginMutation.isPending}>
|
||||
{loginMutation.isPending ? TEXT.loginPending : TEXT.login}
|
||||
</AuthButton>
|
||||
</div>
|
||||
|
||||
{(statusQuery.data?.auth_sources ?? []).length > 0 ? (
|
||||
<div className="flex flex-col items-center gap-3 pt-1">
|
||||
<div className="text-xs text-[var(--foreground-secondary)]">
|
||||
第三方账号登录
|
||||
</div>
|
||||
<div className="flex flex-wrap justify-center gap-3">
|
||||
{(statusQuery.data?.auth_sources ?? []).map((source) => (
|
||||
<SecondaryButton
|
||||
key={source.id}
|
||||
type="button"
|
||||
onClick={() => handleOAuthLogin(source.name)}
|
||||
className="min-w-36"
|
||||
disabled={oauthMutation.isPending}
|
||||
>
|
||||
{source.display_name || source.name} 登录
|
||||
</SecondaryButton>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</form>
|
||||
|
||||
<div className="mt-6 flex flex-wrap gap-3 text-sm text-[var(--foreground-secondary)]">
|
||||
<Link
|
||||
href="/reset"
|
||||
className="text-[var(--brand-primary)] transition hover:opacity-80"
|
||||
>
|
||||
{TEXT.forgotPassword}
|
||||
</Link>
|
||||
</div>
|
||||
</AppCard>
|
||||
</PublicAuthGuard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
'use client';
|
||||
|
||||
import {useMutation} from '@tanstack/react-query';
|
||||
import Link from 'next/link';
|
||||
import {usePathname, useRouter, useSearchParams} from 'next/navigation';
|
||||
import {useEffect, useRef, useState} from 'react';
|
||||
|
||||
import {InlineMessage} from '@/components/feedback/inline-message';
|
||||
import {LoadingState} from '@/components/feedback/loading-state';
|
||||
import {useAuth} from '@/components/providers/auth-provider';
|
||||
import {AppCard} from '@/components/ui/app-card';
|
||||
import {exchangeOAuthCode} from '@/features/auth/api/auth';
|
||||
|
||||
function parseOAuthSource(pathname: string | null, sourceParam: string) {
|
||||
const normalizedParam = sourceParam.trim();
|
||||
if (normalizedParam) {
|
||||
return normalizedParam;
|
||||
}
|
||||
|
||||
const pathMatch = pathname?.match(/^\/oauth\/([^/?#]+)$/);
|
||||
if (!pathMatch) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return decodeURIComponent(pathMatch[1]);
|
||||
}
|
||||
|
||||
export function OAuthCallback({ sourceId }: { sourceId?: number }) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const { setUser } = useAuth();
|
||||
const handledRef = useRef('');
|
||||
const [prompt, setPrompt] = useState('正在处理授权结果...');
|
||||
const [message, setMessage] = useState<{
|
||||
tone: 'danger' | 'success';
|
||||
text: string;
|
||||
} | null>(null);
|
||||
const code = searchParams?.get('code')?.trim() ?? '';
|
||||
const state = searchParams?.get('state')?.trim() ?? '';
|
||||
const oauthError = searchParams?.get('error')?.trim() ?? '';
|
||||
const oauthErrorDescription =
|
||||
searchParams?.get('error_description')?.trim() ?? '';
|
||||
const resolvedSource =
|
||||
sourceId ?? parseOAuthSource(pathname, searchParams?.get('source') ?? '');
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: () => exchangeOAuthCode(resolvedSource, code, state),
|
||||
onSuccess: (result) => {
|
||||
if (result.status === 'link_required') {
|
||||
setMessage({ tone: 'success', text: '请绑定已有账号以完成登录。' });
|
||||
router.replace('/oauth/link');
|
||||
return;
|
||||
}
|
||||
if (result.user) {
|
||||
setUser(result.user);
|
||||
setMessage({ tone: 'success', text: '登录成功,正在跳转...' });
|
||||
router.replace('/');
|
||||
return;
|
||||
}
|
||||
setPrompt('授权处理失败');
|
||||
setMessage({ tone: 'danger', text: '授权结果缺少用户信息。' });
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
setPrompt('授权处理失败');
|
||||
setMessage({
|
||||
tone: 'danger',
|
||||
text: error.message || '授权失败,请稍后重试。',
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (oauthError || oauthErrorDescription) {
|
||||
setPrompt('授权处理失败');
|
||||
setMessage({
|
||||
tone: 'danger',
|
||||
text: oauthErrorDescription || oauthError,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (String(resolvedSource).trim() === '') {
|
||||
setPrompt('缺少认证源参数');
|
||||
setMessage({
|
||||
tone: 'danger',
|
||||
text: '未收到认证源参数,请返回登录页重试。',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!code || !state) {
|
||||
setPrompt('缺少授权参数');
|
||||
setMessage({
|
||||
tone: 'danger',
|
||||
text: '未收到完整授权参数,请返回登录页重试。',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const key = `${resolvedSource}:${code}:${state}`;
|
||||
if (handledRef.current === key) {
|
||||
return;
|
||||
}
|
||||
handledRef.current = key;
|
||||
mutation.mutate();
|
||||
}, [
|
||||
code,
|
||||
mutation,
|
||||
oauthError,
|
||||
oauthErrorDescription,
|
||||
resolvedSource,
|
||||
state,
|
||||
]);
|
||||
|
||||
return (
|
||||
<AppCard title="第三方登录回调" description={prompt}>
|
||||
<div className="space-y-4">
|
||||
{mutation.isPending ? <LoadingState /> : null}
|
||||
{message ? (
|
||||
<InlineMessage tone={message.tone} message={message.text} />
|
||||
) : null}
|
||||
{message?.tone === 'danger' ? (
|
||||
<div className="flex justify-center">
|
||||
<Link
|
||||
href="/login"
|
||||
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>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</AppCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
'use client';
|
||||
|
||||
import {zodResolver} from '@hookform/resolvers/zod';
|
||||
import {useMutation} from '@tanstack/react-query';
|
||||
import Link from 'next/link';
|
||||
import {useRouter} from 'next/navigation';
|
||||
import {useState} from 'react';
|
||||
import {useForm} from 'react-hook-form';
|
||||
import {z} from 'zod';
|
||||
|
||||
import {InlineMessage} from '@/components/feedback/inline-message';
|
||||
import {useAuth} from '@/components/providers/auth-provider';
|
||||
import {AppCard} from '@/components/ui/app-card';
|
||||
import {linkExistingOAuthAccount} from '@/features/auth/api/auth';
|
||||
import {AuthButton, AuthFormField, AuthInput,} from '@/features/auth/components/auth-form-primitives';
|
||||
|
||||
const schema = z.object({
|
||||
username: z.string().min(1, '请输入用户名'),
|
||||
password: z.string().min(1, '请输入密码'),
|
||||
});
|
||||
|
||||
type FormValues = z.infer<typeof schema>;
|
||||
|
||||
export function OAuthLinkExistingForm() {
|
||||
const router = useRouter();
|
||||
const { setUser } = useAuth();
|
||||
const [errorMessage, setErrorMessage] = useState('');
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: {
|
||||
username: '',
|
||||
password: '',
|
||||
},
|
||||
});
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: linkExistingOAuthAccount,
|
||||
onSuccess: (result) => {
|
||||
if (result.user) {
|
||||
setUser(result.user);
|
||||
router.replace('/');
|
||||
return;
|
||||
}
|
||||
setErrorMessage('绑定成功但未返回用户信息,请重新登录。');
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
setErrorMessage(error.message || '绑定失败,请稍后重试。');
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = form.handleSubmit((values) => {
|
||||
setErrorMessage('');
|
||||
mutation.mutate(values);
|
||||
});
|
||||
|
||||
return (
|
||||
<AppCard
|
||||
title="绑定已有账号"
|
||||
description="当前第三方账号尚未绑定本地用户,请使用已有账号完成关联。"
|
||||
>
|
||||
<form className="space-y-4" onSubmit={handleSubmit}>
|
||||
<AuthFormField label="用户名">
|
||||
<AuthInput
|
||||
placeholder="请输入用户名"
|
||||
{...form.register('username')}
|
||||
/>
|
||||
{form.formState.errors.username ? (
|
||||
<span className="text-xs text-[var(--status-danger-foreground)]">
|
||||
{form.formState.errors.username.message}
|
||||
</span>
|
||||
) : null}
|
||||
</AuthFormField>
|
||||
|
||||
<AuthFormField label="密码">
|
||||
<AuthInput
|
||||
type="password"
|
||||
placeholder="请输入密码"
|
||||
{...form.register('password')}
|
||||
/>
|
||||
{form.formState.errors.password ? (
|
||||
<span className="text-xs text-[var(--status-danger-foreground)]">
|
||||
{form.formState.errors.password.message}
|
||||
</span>
|
||||
) : null}
|
||||
</AuthFormField>
|
||||
|
||||
{errorMessage ? (
|
||||
<InlineMessage tone="danger" message={errorMessage} />
|
||||
) : null}
|
||||
|
||||
<AuthButton type="submit" disabled={mutation.isPending}>
|
||||
{mutation.isPending ? '绑定中...' : '绑定并登录'}
|
||||
</AuthButton>
|
||||
</form>
|
||||
|
||||
<div className="mt-6 text-sm text-[var(--foreground-secondary)]">
|
||||
<Link
|
||||
href="/login"
|
||||
className="text-[var(--brand-primary)] transition hover:opacity-80"
|
||||
>
|
||||
返回登录
|
||||
</Link>
|
||||
</div>
|
||||
</AppCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
'use client';
|
||||
|
||||
import {useMutation} from '@tanstack/react-query';
|
||||
import Link from 'next/link';
|
||||
import {useSearchParams} from 'next/navigation';
|
||||
import {useState} from 'react';
|
||||
|
||||
import {InlineMessage} from '@/components/feedback/inline-message';
|
||||
import {AppCard} from '@/components/ui/app-card';
|
||||
import {resetPassword} from '@/features/auth/api/auth';
|
||||
import {AuthButton, AuthFormField, AuthInput, SecondaryButton,} from '@/features/auth/components/auth-form-primitives';
|
||||
|
||||
export function PasswordResetConfirmForm() {
|
||||
const searchParams = useSearchParams();
|
||||
const email = searchParams?.get('email') || '';
|
||||
const token = searchParams?.get('token') || '';
|
||||
const [message, setMessage] = useState<{
|
||||
tone: 'success' | 'danger';
|
||||
text: string;
|
||||
} | null>(null);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: () => resetPassword({ email, token }),
|
||||
onSuccess: async (password) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(password);
|
||||
setMessage({
|
||||
tone: 'success',
|
||||
text: `密码已重置,新密码已复制到剪贴板:${password}`,
|
||||
});
|
||||
} catch {
|
||||
setMessage({ tone: 'success', text: `密码已重置:${password}` });
|
||||
}
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
setMessage({
|
||||
tone: 'danger',
|
||||
text: error.message || '密码重置失败,请重新获取链接。',
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const missingParams = !email || !token;
|
||||
|
||||
return (
|
||||
<AppCard
|
||||
title="密码重置确认"
|
||||
description="确认后,系统会生成新的随机密码。"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<AuthFormField label="邮箱地址">
|
||||
<AuthInput value={email} readOnly />
|
||||
</AuthFormField>
|
||||
|
||||
{missingParams ? (
|
||||
<InlineMessage
|
||||
tone="danger"
|
||||
message="重置链接缺少必要参数,请重新发起密码重置。"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{message ? (
|
||||
<InlineMessage tone={message.tone} message={message.text} />
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-col gap-3 sm:flex-row">
|
||||
<AuthButton
|
||||
type="button"
|
||||
disabled={missingParams || mutation.isPending}
|
||||
onClick={() => mutation.mutate()}
|
||||
>
|
||||
{mutation.isPending ? '处理中...' : '确认重置密码'}
|
||||
</AuthButton>
|
||||
{message?.tone === 'success' ? (
|
||||
<SecondaryButton
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
const password = message.text.split(':').pop() || '';
|
||||
if (password) {
|
||||
await navigator.clipboard.writeText(password);
|
||||
}
|
||||
}}
|
||||
>
|
||||
再次复制密码
|
||||
</SecondaryButton>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="text-sm text-[var(--foreground-secondary)]">
|
||||
处理完成后可返回
|
||||
<Link
|
||||
href="/login"
|
||||
className="ml-2 text-[var(--brand-primary)] transition hover:opacity-80"
|
||||
>
|
||||
登录页
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</AppCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
'use client';
|
||||
|
||||
import {useSearchParams} from 'next/navigation';
|
||||
|
||||
import {PasswordResetConfirmForm} from '@/features/auth/components/password-reset-confirm-form';
|
||||
import {PasswordResetRequestForm} from '@/features/auth/components/password-reset-request-form';
|
||||
|
||||
export function PasswordResetFlow() {
|
||||
const searchParams = useSearchParams();
|
||||
const hasConfirmationParams = Boolean(
|
||||
searchParams?.get('email') && searchParams?.get('token'),
|
||||
);
|
||||
|
||||
return hasConfirmationParams ? (
|
||||
<PasswordResetConfirmForm />
|
||||
) : (
|
||||
<PasswordResetRequestForm />
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
'use client';
|
||||
|
||||
import {zodResolver} from '@hookform/resolvers/zod';
|
||||
import {useMutation} from '@tanstack/react-query';
|
||||
import Link from 'next/link';
|
||||
import {useState} from 'react';
|
||||
import {useForm} from 'react-hook-form';
|
||||
import {z} from 'zod';
|
||||
|
||||
import {InlineMessage} from '@/components/feedback/inline-message';
|
||||
import {AppCard} from '@/components/ui/app-card';
|
||||
import {sendPasswordResetEmail} from '@/features/auth/api/auth';
|
||||
import {AuthButton, AuthFormField, AuthInput,} from '@/features/auth/components/auth-form-primitives';
|
||||
import {PublicAuthGuard} from '@/features/auth/components/public-auth-guard';
|
||||
|
||||
const resetRequestSchema = z.object({
|
||||
email: z.string().email('请输入有效邮箱地址'),
|
||||
});
|
||||
|
||||
type ResetRequestFormValues = z.infer<typeof resetRequestSchema>;
|
||||
|
||||
export function PasswordResetRequestForm() {
|
||||
const [message, setMessage] = useState<{
|
||||
tone: 'success' | 'danger' | 'info';
|
||||
text: string;
|
||||
} | null>(null);
|
||||
|
||||
const form = useForm<ResetRequestFormValues>({
|
||||
resolver: zodResolver(resetRequestSchema),
|
||||
defaultValues: { email: '' },
|
||||
});
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (values: ResetRequestFormValues) =>
|
||||
sendPasswordResetEmail(values.email),
|
||||
onSuccess: () => {
|
||||
setMessage({ tone: 'success', text: '重置邮件发送成功,请检查邮箱。' });
|
||||
form.reset();
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
setMessage({
|
||||
tone: 'danger',
|
||||
text: error.message || '重置邮件发送失败,请稍后重试。',
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = form.handleSubmit((values) => {
|
||||
setMessage(null);
|
||||
mutation.mutate(values);
|
||||
});
|
||||
|
||||
return (
|
||||
<PublicAuthGuard>
|
||||
<AppCard
|
||||
title="密码重置"
|
||||
description="提交后,系统会向你的注册邮箱发送重置链接。"
|
||||
>
|
||||
<form className="space-y-4" onSubmit={handleSubmit}>
|
||||
<AuthFormField label="邮箱地址">
|
||||
<AuthInput
|
||||
type="email"
|
||||
placeholder="请输入邮箱地址"
|
||||
{...form.register('email')}
|
||||
/>
|
||||
{form.formState.errors.email ? (
|
||||
<span className="text-xs text-[var(--status-danger-foreground)]">
|
||||
{form.formState.errors.email.message}
|
||||
</span>
|
||||
) : null}
|
||||
</AuthFormField>
|
||||
|
||||
{message ? (
|
||||
<InlineMessage tone={message.tone} message={message.text} />
|
||||
) : null}
|
||||
|
||||
<AuthButton type="submit" disabled={mutation.isPending}>
|
||||
{mutation.isPending ? '提交中...' : '发送重置邮件'}
|
||||
</AuthButton>
|
||||
</form>
|
||||
|
||||
<div className="mt-6 text-sm text-[var(--foreground-secondary)]">
|
||||
想起密码了?
|
||||
<Link
|
||||
href="/login"
|
||||
className="ml-2 text-[var(--brand-primary)] transition hover:opacity-80"
|
||||
>
|
||||
返回登录
|
||||
</Link>
|
||||
</div>
|
||||
</AppCard>
|
||||
</PublicAuthGuard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
'use client';
|
||||
|
||||
import type {ReactNode} from 'react';
|
||||
import {useEffect} from 'react';
|
||||
import {useRouter, useSearchParams} from 'next/navigation';
|
||||
|
||||
import {LoadingState} from '@/components/feedback/loading-state';
|
||||
import {useAuth} from '@/components/providers/auth-provider';
|
||||
|
||||
interface PublicAuthGuardProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function PublicAuthGuard({ children }: PublicAuthGuardProps) {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const { isAuthenticated, isLoading } = useAuth();
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading && isAuthenticated) {
|
||||
const redirect = searchParams?.get('redirect');
|
||||
router.replace(redirect || '/');
|
||||
}
|
||||
}, [isAuthenticated, isLoading, router, searchParams]);
|
||||
|
||||
if (isLoading) {
|
||||
return <LoadingState />;
|
||||
}
|
||||
|
||||
if (isAuthenticated) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import {FeaturePlaceholder} from '@/components/feedback/feature-placeholder';
|
||||
|
||||
interface PublicRoutePlaceholderProps {
|
||||
title: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export function PublicRoutePlaceholder({
|
||||
title,
|
||||
description,
|
||||
}: PublicRoutePlaceholderProps) {
|
||||
return (
|
||||
<FeaturePlaceholder
|
||||
title={title}
|
||||
description={description}
|
||||
milestones={[
|
||||
'认证页路由入口已建立,便于阶段 2 接入真实表单。',
|
||||
'公共布局已统一,可复用品牌区、说明区与回跳入口。',
|
||||
'后续将在此处补齐表单校验、Session 兼容与 OAuth 回调处理。',
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { apiRequest } from '@/lib/api/client';
|
||||
|
||||
import type {
|
||||
ConfigDiffResult,
|
||||
ConfigPreviewResult,
|
||||
ConfigVersionDetail,
|
||||
ConfigVersionSummary,
|
||||
} from '@/features/config-versions/types';
|
||||
|
||||
export function getConfigVersions() {
|
||||
return apiRequest<ConfigVersionSummary[]>('/config-versions/');
|
||||
}
|
||||
|
||||
export function getConfigVersion(id: number) {
|
||||
return apiRequest<ConfigVersionDetail>(`/config-versions/${id}`);
|
||||
}
|
||||
|
||||
export function getActiveConfigVersion() {
|
||||
return apiRequest<ConfigVersionDetail>('/config-versions/active');
|
||||
}
|
||||
|
||||
export function getConfigVersionPreview() {
|
||||
return apiRequest<ConfigPreviewResult>('/config-versions/preview');
|
||||
}
|
||||
|
||||
export function getConfigVersionDiff() {
|
||||
return apiRequest<ConfigDiffResult>('/config-versions/diff');
|
||||
}
|
||||
|
||||
export function publishConfigVersion(force?: boolean) {
|
||||
const query = force ? '?force=true' : '';
|
||||
return apiRequest<ConfigVersionDetail>(`/config-versions/publish${query}`, {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
export function activateConfigVersion(id: number) {
|
||||
return apiRequest<ConfigVersionDetail>(`/config-versions/${id}/activate`, {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
export function cleanupConfigVersions(payload: { keep_count: number }) {
|
||||
return apiRequest<{ deleted_count: number }>('/config-versions/cleanup', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
+84
@@ -0,0 +1,84 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
import { InlineMessage } from '@/components/feedback/inline-message';
|
||||
import { AppModal } from '@/components/ui/app-modal';
|
||||
import {
|
||||
PrimaryButton,
|
||||
SecondaryButton,
|
||||
} from '@/features/shared/components/resource-primitives';
|
||||
|
||||
export function CleanupConfigVersionsModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
onConfirm,
|
||||
isPending,
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: (keepCount: number) => void;
|
||||
isPending: boolean;
|
||||
}) {
|
||||
const [keepCount, setKeepCount] = useState<number>(10);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (keepCount < 3) {
|
||||
setError('最少保留 3 个历史快照');
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
onConfirm(keepCount);
|
||||
};
|
||||
|
||||
return (
|
||||
<AppModal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title="清理历史快照"
|
||||
description="删除旧的历史快照配置,系统将始终自动保护当前已激活的版本不被删除。"
|
||||
size="md"
|
||||
footer={
|
||||
<div className="flex justify-end gap-3">
|
||||
<SecondaryButton type="button" onClick={onClose} disabled={isPending}>
|
||||
取消
|
||||
</SecondaryButton>
|
||||
<PrimaryButton
|
||||
type="submit"
|
||||
form="cleanup-form"
|
||||
disabled={isPending}
|
||||
className="border-[var(--status-danger-foreground)] bg-[var(--status-danger-foreground)] hover:bg-[var(--status-danger-foreground)] hover:opacity-90"
|
||||
>
|
||||
{isPending ? '清理中...' : '确认清理'}
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<form id="cleanup-form" onSubmit={handleSubmit} className="space-y-4">
|
||||
{error && <InlineMessage tone="danger" message={error} />}
|
||||
<div>
|
||||
<label
|
||||
htmlFor="keepCount"
|
||||
className="mb-1 block text-sm font-medium text-[var(--foreground-primary)]"
|
||||
>
|
||||
保留最近快照个数
|
||||
</label>
|
||||
<input
|
||||
id="keepCount"
|
||||
type="number"
|
||||
min={3}
|
||||
value={keepCount}
|
||||
onChange={(e) => setKeepCount(parseInt(e.target.value, 10) || 3)}
|
||||
className="w-full rounded-lg border border-[var(--border-default)] bg-[var(--surface-default)] px-3 py-2 text-sm text-[var(--foreground-primary)] focus:border-[var(--brand-primary)] focus:ring-1 focus:ring-[var(--brand-primary)] focus:outline-none"
|
||||
disabled={isPending}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-[var(--foreground-secondary)]">
|
||||
默认为 10 个,最少需保留 3 个。
|
||||
</p>
|
||||
</div>
|
||||
</form>
|
||||
</AppModal>
|
||||
);
|
||||
}
|
||||
+119
@@ -0,0 +1,119 @@
|
||||
'use client';
|
||||
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { ErrorState } from '@/components/feedback/error-state';
|
||||
import { LoadingState } from '@/components/feedback/loading-state';
|
||||
import { AppModal } from '@/components/ui/app-modal';
|
||||
import { getConfigVersion } from '@/features/config-versions/api/config-versions';
|
||||
import type { ConfigVersionSummary } from '@/features/config-versions/types';
|
||||
import {
|
||||
CodeBlock,
|
||||
SecondaryButton,
|
||||
} from '@/features/shared/components/resource-primitives';
|
||||
import { formatDateTime } from '@/lib/utils/date';
|
||||
|
||||
function getErrorMessage(error: unknown) {
|
||||
return error instanceof Error ? error.message : '请求失败,请稍后重试。';
|
||||
}
|
||||
|
||||
export function ConfigVersionSnapshotModal({
|
||||
version,
|
||||
onClose,
|
||||
}: {
|
||||
version: ConfigVersionSummary | null;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const versionDetailQuery = useQuery({
|
||||
queryKey: ['config-versions', 'detail', version?.id ?? 0],
|
||||
queryFn: () => {
|
||||
if (!version) {
|
||||
throw new Error('missing config version');
|
||||
}
|
||||
return getConfigVersion(version.id);
|
||||
},
|
||||
enabled: Boolean(version?.id),
|
||||
});
|
||||
const versionDetail = versionDetailQuery.data ?? null;
|
||||
|
||||
return (
|
||||
<AppModal
|
||||
isOpen={Boolean(version)}
|
||||
onClose={onClose}
|
||||
title={version ? `版本 ${version.version}` : '查看快照'}
|
||||
size="xl"
|
||||
footer={
|
||||
<div className="flex justify-end">
|
||||
<SecondaryButton type="button" onClick={onClose}>
|
||||
关闭
|
||||
</SecondaryButton>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{!version ? null : versionDetailQuery.isLoading && !versionDetail ? (
|
||||
<LoadingState />
|
||||
) : versionDetailQuery.isError ? (
|
||||
<ErrorState
|
||||
title="配置版本详情加载失败"
|
||||
description={getErrorMessage(versionDetailQuery.error)}
|
||||
/>
|
||||
) : versionDetail ? (
|
||||
<div className="space-y-5">
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
|
||||
<p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
|
||||
Checksum
|
||||
</p>
|
||||
<p className="mt-2 text-sm break-all text-[var(--foreground-primary)]">
|
||||
{versionDetail.checksum}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
|
||||
<p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
|
||||
创建人
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-[var(--foreground-primary)]">
|
||||
{versionDetail.created_by || '系统'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
|
||||
<p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
|
||||
创建时间
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-[var(--foreground-primary)]">
|
||||
{formatDateTime(versionDetail.created_at)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="mb-2 text-sm font-semibold text-[var(--foreground-primary)]">
|
||||
快照 JSON
|
||||
</p>
|
||||
<CodeBlock className="max-h-96 whitespace-pre-wrap">
|
||||
{versionDetail.snapshot_json}
|
||||
</CodeBlock>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="mb-2 text-sm font-semibold text-[var(--foreground-primary)]">
|
||||
主配置
|
||||
</p>
|
||||
<CodeBlock className="max-h-96 whitespace-pre-wrap">
|
||||
{versionDetail.main_config}
|
||||
</CodeBlock>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="mb-2 text-sm font-semibold text-[var(--foreground-primary)]">
|
||||
路由配置
|
||||
</p>
|
||||
<CodeBlock className="max-h-[32rem] whitespace-pre-wrap">
|
||||
{versionDetail.rendered_config}
|
||||
</CodeBlock>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</AppModal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,693 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
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 {
|
||||
activateConfigVersion,
|
||||
getActiveConfigVersion,
|
||||
getConfigVersionDiff,
|
||||
getConfigVersionPreview,
|
||||
getConfigVersions,
|
||||
publishConfigVersion,
|
||||
cleanupConfigVersions,
|
||||
} from '@/features/config-versions/api/config-versions';
|
||||
import { ConfigVersionSnapshotModal } from '@/features/config-versions/components/config-version-snapshot-modal';
|
||||
import { CleanupConfigVersionsModal } from '@/features/config-versions/components/cleanup-config-versions-modal';
|
||||
import type {
|
||||
ConfigOptionDiffItem,
|
||||
ConfigDiffResult,
|
||||
ConfigPreviewResult,
|
||||
ConfigVersionDetail,
|
||||
ConfigVersionSummary,
|
||||
SupportFile,
|
||||
} from '@/features/config-versions/types';
|
||||
import {
|
||||
CodeBlock,
|
||||
PrimaryButton,
|
||||
SecondaryButton,
|
||||
} from '@/features/shared/components/resource-primitives';
|
||||
import { formatDateTime } from '@/lib/utils/date';
|
||||
|
||||
const versionsQueryKey = ['config-versions'];
|
||||
|
||||
type FeedbackState = {
|
||||
tone: 'info' | 'success' | 'danger';
|
||||
message: string;
|
||||
};
|
||||
|
||||
function getErrorMessage(error: unknown) {
|
||||
return error instanceof Error ? error.message : '请求失败,请稍后重试。';
|
||||
}
|
||||
|
||||
function truncateChecksum(checksum: string) {
|
||||
if (!checksum) {
|
||||
return '—';
|
||||
}
|
||||
|
||||
return checksum.length > 16 ? `${checksum.slice(0, 16)}...` : checksum;
|
||||
}
|
||||
|
||||
function hasConfigDiff(diff: ConfigDiffResult) {
|
||||
return (
|
||||
diff.added_domains.length > 0 ||
|
||||
diff.removed_domains.length > 0 ||
|
||||
diff.modified_domains.length > 0 ||
|
||||
diff.main_config_changed ||
|
||||
diff.changed_option_keys.length > 0 ||
|
||||
!diff.active_version
|
||||
);
|
||||
}
|
||||
|
||||
function DiffList({ title, items }: { title: string; items: string[] }) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="text-sm font-semibold text-[var(--foreground-primary)]">
|
||||
{title}
|
||||
</p>
|
||||
<StatusBadge
|
||||
label={`${items.length} 项`}
|
||||
variant={items.length > 0 ? 'info' : 'warning'}
|
||||
/>
|
||||
</div>
|
||||
{items.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{items.map((item) => (
|
||||
<span
|
||||
key={item}
|
||||
className="rounded-full border border-[var(--border-default)] bg-[var(--surface-elevated)] px-3 py-1 text-xs text-[var(--foreground-secondary)]"
|
||||
>
|
||||
{item}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-[var(--foreground-secondary)]">
|
||||
当前无相关变更。
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SupportFilesList({ files }: { files: SupportFile[] }) {
|
||||
if (files.length === 0) {
|
||||
return (
|
||||
<p className="text-sm text-[var(--foreground-secondary)]">
|
||||
当前发布不需要额外支持文件。
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{files.map((file) => (
|
||||
<details
|
||||
key={file.path}
|
||||
className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-3"
|
||||
>
|
||||
<summary className="cursor-pointer text-sm font-medium text-[var(--foreground-primary)]">
|
||||
{file.path}
|
||||
</summary>
|
||||
<CodeBlock className="mt-3 max-h-72 whitespace-pre-wrap">
|
||||
{file.content}
|
||||
</CodeBlock>
|
||||
</details>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function renderOptionValue(value: string) {
|
||||
return value === '' ? '空' : value;
|
||||
}
|
||||
|
||||
function OptionDiffTable({ items }: { items: ConfigOptionDiffItem[] }) {
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<p className="text-sm text-[var(--foreground-secondary)]">
|
||||
当前无 OpenResty 性能参数变化。
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto rounded-2xl border border-[var(--border-default)]">
|
||||
<table className="min-w-full divide-y divide-[var(--border-default)] text-left text-sm">
|
||||
<thead>
|
||||
<tr className="bg-[var(--surface-elevated)] text-[var(--foreground-secondary)]">
|
||||
<th className="px-3 py-3 font-medium">参数</th>
|
||||
<th className="px-3 py-3 font-medium">当前激活值</th>
|
||||
<th className="px-3 py-3 font-medium">待发布值</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-[var(--border-default)]">
|
||||
{items.map((item) => (
|
||||
<tr key={item.key} className="align-top">
|
||||
<td className="px-3 py-3 font-medium text-[var(--foreground-primary)]">
|
||||
{item.key}
|
||||
</td>
|
||||
<td className="px-3 py-3 text-[var(--foreground-secondary)]">
|
||||
<code>{renderOptionValue(item.previous_value)}</code>
|
||||
</td>
|
||||
<td className="px-3 py-3 text-[var(--foreground-secondary)]">
|
||||
<code>{renderOptionValue(item.current_value)}</code>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PublishPreviewCard({
|
||||
preview,
|
||||
diff,
|
||||
activeVersionMeta,
|
||||
activeVersionDetail,
|
||||
isActiveVersionDetailLoading,
|
||||
activeVersionDetailError,
|
||||
isPublishing,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: {
|
||||
preview: ConfigPreviewResult;
|
||||
diff: ConfigDiffResult;
|
||||
activeVersionMeta: ConfigVersionSummary | null;
|
||||
activeVersionDetail: ConfigVersionDetail | null;
|
||||
isActiveVersionDetailLoading: boolean;
|
||||
activeVersionDetailError: string | null;
|
||||
isPublishing: boolean;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const canPublish = preview.route_count > 0 && hasConfigDiff(diff);
|
||||
|
||||
return (
|
||||
<AppCard
|
||||
title="发布前预览"
|
||||
description="先核对增删改域名、渲染结果与支持文件,再决定是否发布为新激活版本。"
|
||||
action={
|
||||
<StatusBadge
|
||||
label={`启用规则 ${preview.route_count} 条`}
|
||||
variant="info"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div className="space-y-5">
|
||||
<div className="grid gap-4 md:grid-cols-4">
|
||||
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
|
||||
<p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
|
||||
当前激活版本
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-[var(--foreground-primary)]">
|
||||
{diff.active_version || '无'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-[var(--status-success-border)] bg-[var(--status-success-soft)] px-4 py-4">
|
||||
<p className="text-xs tracking-[0.2em] text-[var(--status-success-foreground)] uppercase">
|
||||
新增域名
|
||||
</p>
|
||||
<p className="mt-2 text-lg font-semibold text-[var(--status-success-foreground)]">
|
||||
{diff.added_domains.length}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-[var(--status-warning-border)] bg-[var(--status-warning-soft)] px-4 py-4">
|
||||
<p className="text-xs tracking-[0.2em] text-[var(--status-warning-foreground)] uppercase">
|
||||
删除域名
|
||||
</p>
|
||||
<p className="mt-2 text-lg font-semibold text-[var(--status-warning-foreground)]">
|
||||
{diff.removed_domains.length}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-[var(--status-info-border)] bg-[var(--status-info-soft)] px-4 py-4">
|
||||
<p className="text-xs tracking-[0.2em] text-[var(--status-info-foreground)] uppercase">
|
||||
修改域名
|
||||
</p>
|
||||
<p className="mt-2 text-lg font-semibold text-[var(--status-info-foreground)]">
|
||||
{diff.modified_domains.length}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
|
||||
<p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
|
||||
主配置变化
|
||||
</p>
|
||||
<p className="mt-2 text-lg font-semibold text-[var(--foreground-primary)]">
|
||||
{diff.main_config_changed ? '已变化' : '无变化'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!canPublish ? (
|
||||
<InlineMessage
|
||||
tone="info"
|
||||
message="当前规则与已激活版本一致,已阻止重复发布。"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-5 xl:grid-cols-3">
|
||||
<DiffList title="新增域名" items={diff.added_domains} />
|
||||
<DiffList title="删除域名" items={diff.removed_domains} />
|
||||
<DiffList title="修改域名" items={diff.modified_domains} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="text-sm font-semibold text-[var(--foreground-primary)]">
|
||||
OpenResty 参数变化
|
||||
</p>
|
||||
<StatusBadge
|
||||
label={`${diff.changed_option_keys.length} 项`}
|
||||
variant={diff.changed_option_keys.length > 0 ? 'info' : 'warning'}
|
||||
/>
|
||||
</div>
|
||||
<OptionDiffTable items={diff.changed_option_details} />
|
||||
</div>
|
||||
|
||||
{diff.main_config_changed && activeVersionMeta ? (
|
||||
<div className="grid gap-5 xl:grid-cols-2">
|
||||
<div>
|
||||
<div className="mb-2 flex flex-wrap items-center justify-between gap-3">
|
||||
<p className="text-sm font-semibold text-[var(--foreground-primary)]">
|
||||
Current Active Main Config
|
||||
</p>
|
||||
<StatusBadge label={activeVersionMeta.version} variant="info" />
|
||||
</div>
|
||||
{isActiveVersionDetailLoading ? (
|
||||
<LoadingState />
|
||||
) : activeVersionDetailError ? (
|
||||
<InlineMessage
|
||||
tone="danger"
|
||||
message={activeVersionDetailError}
|
||||
/>
|
||||
) : activeVersionDetail ? (
|
||||
<CodeBlock className="max-h-[32rem] whitespace-pre-wrap">
|
||||
{activeVersionDetail.main_config}
|
||||
</CodeBlock>
|
||||
) : (
|
||||
<InlineMessage
|
||||
tone="info"
|
||||
message="当前激活版本详情暂不可用。"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-2 flex flex-wrap items-center justify-between gap-3">
|
||||
<p className="text-sm font-semibold text-[var(--foreground-primary)]">
|
||||
Pending Main Config
|
||||
</p>
|
||||
</div>
|
||||
<CodeBlock className="max-h-[32rem] whitespace-pre-wrap">
|
||||
{preview.main_config}
|
||||
</CodeBlock>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Legacy duplicated main config preview block kept commented out while
|
||||
we replace it with an explicit active-vs-pending comparison above.
|
||||
{diff.main_config_changed && activeVersion ? (
|
||||
<div className="grid gap-5 xl:grid-cols-2">
|
||||
<div>
|
||||
<div className="mb-2 flex flex-wrap items-center justify-between gap-3">
|
||||
<p className="text-sm font-semibold text-[var(--foreground-primary)]">
|
||||
褰撳墠婵€娲讳富閰嶇疆
|
||||
</p>
|
||||
<StatusBadge label={activeVersion.version} variant="info" />
|
||||
</div>
|
||||
<CodeBlock className="max-h-[32rem] whitespace-pre-wrap">
|
||||
{activeVersion.main_config}
|
||||
</CodeBlock>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-2 flex flex-wrap items-center justify-between gap-3">
|
||||
<p className="text-sm font-semibold text-[var(--foreground-primary)]">
|
||||
寰呭彂甯冧富閰嶇疆
|
||||
</p>
|
||||
<p className="text-xs text-[var(--foreground-secondary)]">
|
||||
Checksum锛歿preview.checksum}
|
||||
</p>
|
||||
</div>
|
||||
<CodeBlock className="max-h-[32rem] whitespace-pre-wrap">
|
||||
{preview.main_config}
|
||||
</CodeBlock>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
*/}
|
||||
|
||||
<div>
|
||||
<div className="mb-2 flex flex-wrap items-center justify-between gap-3">
|
||||
<p className="text-sm font-semibold text-[var(--foreground-primary)]">
|
||||
主配置
|
||||
</p>
|
||||
<p className="text-xs text-[var(--foreground-secondary)]">
|
||||
Checksum:{preview.checksum}
|
||||
</p>
|
||||
</div>
|
||||
<CodeBlock className="max-h-[32rem] whitespace-pre-wrap">
|
||||
{preview.main_config}
|
||||
</CodeBlock>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="mb-2 flex flex-wrap items-center justify-between gap-3">
|
||||
<p className="text-sm font-semibold text-[var(--foreground-primary)]">
|
||||
路由配置
|
||||
</p>
|
||||
</div>
|
||||
<CodeBlock className="max-h-[32rem] whitespace-pre-wrap">
|
||||
{preview.rendered_config}
|
||||
</CodeBlock>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="mb-2 text-sm font-semibold text-[var(--foreground-primary)]">
|
||||
支持文件
|
||||
</p>
|
||||
<SupportFilesList files={preview.support_files} />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<PrimaryButton
|
||||
type="button"
|
||||
onClick={onConfirm}
|
||||
disabled={isPublishing || !canPublish}
|
||||
>
|
||||
{isPublishing ? '发布中...' : '确认发布'}
|
||||
</PrimaryButton>
|
||||
<SecondaryButton
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
disabled={isPublishing}
|
||||
>
|
||||
取消预览
|
||||
</SecondaryButton>
|
||||
</div>
|
||||
</div>
|
||||
</AppCard>
|
||||
);
|
||||
}
|
||||
|
||||
export function ConfigVersionsPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const [feedback, setFeedback] = useState<FeedbackState | null>(null);
|
||||
const [selectedVersionId, setSelectedVersionId] = useState<number | null>(
|
||||
null,
|
||||
);
|
||||
const [publishPreview, setPublishPreview] = useState<{
|
||||
preview: ConfigPreviewResult;
|
||||
diff: ConfigDiffResult;
|
||||
} | null>(null);
|
||||
const [isPreviewLoading, setIsPreviewLoading] = useState(false);
|
||||
const [isCleanupModalOpen, setIsCleanupModalOpen] = useState(false);
|
||||
|
||||
const versionsQuery = useQuery({
|
||||
queryKey: versionsQueryKey,
|
||||
queryFn: getConfigVersions,
|
||||
});
|
||||
|
||||
const versions = useMemo(
|
||||
() => versionsQuery.data ?? [],
|
||||
[versionsQuery.data],
|
||||
);
|
||||
const activeVersion = useMemo(
|
||||
() => versions.find((item) => item.is_active) ?? null,
|
||||
[versions],
|
||||
);
|
||||
const selectedVersion = useMemo(
|
||||
() => versions.find((item) => item.id === selectedVersionId) ?? null,
|
||||
[selectedVersionId, versions],
|
||||
);
|
||||
const shouldLoadActiveVersionDetail = Boolean(
|
||||
activeVersion?.id && publishPreview?.diff.main_config_changed,
|
||||
);
|
||||
const activeVersionDetailQuery = useQuery({
|
||||
queryKey: ['config-versions', 'active-detail', activeVersion?.id ?? 0],
|
||||
queryFn: getActiveConfigVersion,
|
||||
enabled: shouldLoadActiveVersionDetail,
|
||||
});
|
||||
|
||||
const publishMutation = useMutation({
|
||||
mutationFn: (force?: boolean) => publishConfigVersion(force),
|
||||
onSuccess: async (version) => {
|
||||
setFeedback({
|
||||
tone: 'success',
|
||||
message: `发布成功,版本 ${version.version}`,
|
||||
});
|
||||
setPublishPreview(null);
|
||||
await queryClient.invalidateQueries({ queryKey: versionsQueryKey });
|
||||
},
|
||||
onError: (error) => {
|
||||
setFeedback({ tone: 'danger', message: getErrorMessage(error) });
|
||||
},
|
||||
});
|
||||
|
||||
const activateMutation = useMutation({
|
||||
mutationFn: activateConfigVersion,
|
||||
onSuccess: async (version) => {
|
||||
setFeedback({
|
||||
tone: 'success',
|
||||
message: `已激活版本 ${version.version}`,
|
||||
});
|
||||
setSelectedVersionId(version.id);
|
||||
await queryClient.invalidateQueries({ queryKey: versionsQueryKey });
|
||||
},
|
||||
onError: (error) => {
|
||||
setFeedback({ tone: 'danger', message: getErrorMessage(error) });
|
||||
},
|
||||
});
|
||||
|
||||
const cleanupMutation = useMutation({
|
||||
mutationFn: cleanupConfigVersions,
|
||||
onSuccess: async (data) => {
|
||||
setFeedback({
|
||||
tone: 'success',
|
||||
message: `成功清理 ${data.deleted_count} 个历史快照`,
|
||||
});
|
||||
setIsCleanupModalOpen(false);
|
||||
await queryClient.invalidateQueries({ queryKey: versionsQueryKey });
|
||||
},
|
||||
onError: (error) => {
|
||||
setFeedback({ tone: 'danger', message: getErrorMessage(error) });
|
||||
},
|
||||
});
|
||||
|
||||
const handleOpenPublishPreview = async () => {
|
||||
setFeedback(null);
|
||||
setIsPreviewLoading(true);
|
||||
|
||||
try {
|
||||
const [preview, diff] = await Promise.all([
|
||||
getConfigVersionPreview(),
|
||||
getConfigVersionDiff(),
|
||||
]);
|
||||
setPublishPreview({ preview, diff });
|
||||
} catch (error) {
|
||||
setFeedback({ tone: 'danger', message: getErrorMessage(error) });
|
||||
} finally {
|
||||
setIsPreviewLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleActivate = (version: ConfigVersionSummary) => {
|
||||
if (version.is_active) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!window.confirm(`确认激活版本 ${version.version} 吗?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setFeedback(null);
|
||||
activateMutation.mutate(version.id);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="配置版本"
|
||||
description="查看历史快照、预览待发布配置差异,并在需要时重新激活旧版本。"
|
||||
action={
|
||||
<div className="flex gap-2">
|
||||
<SecondaryButton
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (
|
||||
window.confirm(
|
||||
'确认强制重新发布吗?这会忽略配置变化检查并立即生成一个新版本。',
|
||||
)
|
||||
) {
|
||||
publishMutation.mutate(true);
|
||||
}
|
||||
}}
|
||||
disabled={publishMutation.isPending}
|
||||
>
|
||||
重新发布
|
||||
</SecondaryButton>
|
||||
<PrimaryButton
|
||||
type="button"
|
||||
onClick={handleOpenPublishPreview}
|
||||
disabled={isPreviewLoading || publishMutation.isPending}
|
||||
>
|
||||
{isPreviewLoading ? '加载预览中...' : '预览并发布'}
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{feedback ? (
|
||||
<InlineMessage
|
||||
tone={feedback.tone}
|
||||
message={feedback.message}
|
||||
onClear={() => setFeedback(null)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{publishPreview ? (
|
||||
<PublishPreviewCard
|
||||
preview={publishPreview.preview}
|
||||
diff={publishPreview.diff}
|
||||
activeVersionMeta={activeVersion}
|
||||
activeVersionDetail={activeVersionDetailQuery.data ?? null}
|
||||
isActiveVersionDetailLoading={activeVersionDetailQuery.isLoading}
|
||||
activeVersionDetailError={
|
||||
activeVersionDetailQuery.isError
|
||||
? getErrorMessage(activeVersionDetailQuery.error)
|
||||
: null
|
||||
}
|
||||
isPublishing={publishMutation.isPending}
|
||||
onConfirm={() => publishMutation.mutate(false)}
|
||||
onCancel={() => setPublishPreview(null)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<AppCard
|
||||
title="历史版本"
|
||||
description="发布成功后会立即刷新列表,不再需要手动刷新页面。"
|
||||
action={
|
||||
<div className="flex gap-2">
|
||||
<SecondaryButton
|
||||
type="button"
|
||||
onClick={() => setIsCleanupModalOpen(true)}
|
||||
>
|
||||
清理旧版本
|
||||
</SecondaryButton>
|
||||
<SecondaryButton
|
||||
type="button"
|
||||
onClick={() =>
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: versionsQueryKey,
|
||||
})
|
||||
}
|
||||
>
|
||||
刷新列表
|
||||
</SecondaryButton>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{versionsQuery.isLoading ? (
|
||||
<LoadingState />
|
||||
) : versionsQuery.isError ? (
|
||||
<ErrorState
|
||||
title="版本列表加载失败"
|
||||
description={getErrorMessage(versionsQuery.error)}
|
||||
/>
|
||||
) : versions.length === 0 ? (
|
||||
<EmptyState
|
||||
title="暂无历史版本"
|
||||
description="当前还没有可查看的发布记录,请先从反代规则页触发一次发布。"
|
||||
/>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-[var(--border-default)] text-left text-sm">
|
||||
<thead>
|
||||
<tr className="text-[var(--foreground-secondary)]">
|
||||
<th className="px-3 py-3 font-medium">版本号</th>
|
||||
<th className="px-3 py-3 font-medium">状态</th>
|
||||
<th className="px-3 py-3 font-medium">创建人</th>
|
||||
<th className="px-3 py-3 font-medium">Checksum</th>
|
||||
<th className="px-3 py-3 font-medium">创建时间</th>
|
||||
<th className="px-3 py-3 font-medium">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-[var(--border-default)]">
|
||||
{versions.map((version) => (
|
||||
<tr key={version.id} className="align-top">
|
||||
<td className="px-3 py-4 font-medium text-[var(--foreground-primary)]">
|
||||
{version.version}
|
||||
</td>
|
||||
<td className="px-3 py-4">
|
||||
<StatusBadge
|
||||
label={version.is_active ? '当前激活' : '历史版本'}
|
||||
variant={version.is_active ? 'success' : 'info'}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-4 text-[var(--foreground-secondary)]">
|
||||
{version.created_by || '系统'}
|
||||
</td>
|
||||
<td
|
||||
className="px-3 py-4 text-[var(--foreground-secondary)]"
|
||||
title={version.checksum}
|
||||
>
|
||||
{truncateChecksum(version.checksum)}
|
||||
</td>
|
||||
<td className="px-3 py-4 text-[var(--foreground-secondary)]">
|
||||
{formatDateTime(version.created_at)}
|
||||
</td>
|
||||
<td className="px-3 py-4">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<SecondaryButton
|
||||
type="button"
|
||||
onClick={() => setSelectedVersionId(version.id)}
|
||||
className="px-3 py-2 text-xs"
|
||||
>
|
||||
查看快照
|
||||
</SecondaryButton>
|
||||
{!version.is_active ? (
|
||||
<PrimaryButton
|
||||
type="button"
|
||||
onClick={() => handleActivate(version)}
|
||||
disabled={activateMutation.isPending}
|
||||
className="px-3 py-2 text-xs"
|
||||
>
|
||||
重新激活
|
||||
</PrimaryButton>
|
||||
) : null}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</AppCard>
|
||||
</div>
|
||||
|
||||
<ConfigVersionSnapshotModal
|
||||
version={selectedVersion}
|
||||
onClose={() => setSelectedVersionId(null)}
|
||||
/>
|
||||
|
||||
<CleanupConfigVersionsModal
|
||||
isOpen={isCleanupModalOpen}
|
||||
onClose={() => setIsCleanupModalOpen(false)}
|
||||
onConfirm={(keepCount) =>
|
||||
cleanupMutation.mutate({ keep_count: keepCount })
|
||||
}
|
||||
isPending={cleanupMutation.isPending}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
export interface ConfigVersionSummary {
|
||||
id: number;
|
||||
version: string;
|
||||
checksum: string;
|
||||
is_active: boolean;
|
||||
created_by: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface ConfigVersionDetail extends ConfigVersionSummary {
|
||||
snapshot_json: string;
|
||||
main_config: string;
|
||||
rendered_config: string;
|
||||
support_files_json: string;
|
||||
}
|
||||
|
||||
export interface SupportFile {
|
||||
path: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface ConfigPreviewResult {
|
||||
snapshot_json: string;
|
||||
main_config: string;
|
||||
route_config: string;
|
||||
rendered_config: string;
|
||||
support_files: SupportFile[];
|
||||
checksum: string;
|
||||
route_count: number;
|
||||
website_count: number;
|
||||
}
|
||||
|
||||
export interface ConfigDiffResult {
|
||||
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[];
|
||||
changed_option_details: ConfigOptionDiffItem[];
|
||||
current_website_count: number;
|
||||
active_website_count: number;
|
||||
}
|
||||
|
||||
export interface ConfigOptionDiffItem {
|
||||
key: string;
|
||||
previous_value: string;
|
||||
current_value: string;
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
import {apiRequest} from '@/lib/api/client';
|
||||
|
||||
import type {
|
||||
CompactCapacityTrendPoint,
|
||||
CompactDashboardNodeHealth,
|
||||
CompactDiskIOTrendPoint,
|
||||
CompactDistributionItem,
|
||||
CompactNetworkTrendPoint,
|
||||
CompactTrafficTrendPoint,
|
||||
DashboardCapacity,
|
||||
DashboardNodeHealth,
|
||||
DashboardOverview,
|
||||
DashboardOverviewCompact,
|
||||
DashboardSummary,
|
||||
DashboardTraffic,
|
||||
DistributionItem,
|
||||
} from '@/features/dashboard/types';
|
||||
|
||||
function arrayOrEmpty<T>(value: T[] | null | undefined) {
|
||||
return Array.isArray(value) ? value : [];
|
||||
}
|
||||
|
||||
function isCompactDistributionItem(
|
||||
value: DistributionItem | CompactDistributionItem,
|
||||
): value is CompactDistributionItem {
|
||||
return Array.isArray(value);
|
||||
}
|
||||
|
||||
function isCompactTrafficTrendPoint(
|
||||
value:
|
||||
| DashboardOverview['trends']['traffic_24h'][number]
|
||||
| CompactTrafficTrendPoint,
|
||||
): value is CompactTrafficTrendPoint {
|
||||
return Array.isArray(value);
|
||||
}
|
||||
|
||||
function isCompactCapacityTrendPoint(
|
||||
value:
|
||||
| DashboardOverview['trends']['capacity_24h'][number]
|
||||
| CompactCapacityTrendPoint,
|
||||
): value is CompactCapacityTrendPoint {
|
||||
return Array.isArray(value);
|
||||
}
|
||||
|
||||
function isCompactNetworkTrendPoint(
|
||||
value:
|
||||
| DashboardOverview['trends']['network_24h'][number]
|
||||
| CompactNetworkTrendPoint,
|
||||
): value is CompactNetworkTrendPoint {
|
||||
return Array.isArray(value);
|
||||
}
|
||||
|
||||
function isCompactDiskIOTrendPoint(
|
||||
value:
|
||||
| DashboardOverview['trends']['disk_io_24h'][number]
|
||||
| CompactDiskIOTrendPoint,
|
||||
): value is CompactDiskIOTrendPoint {
|
||||
return Array.isArray(value);
|
||||
}
|
||||
|
||||
function isCompactDashboardNode(
|
||||
value: DashboardNodeHealth | CompactDashboardNodeHealth,
|
||||
): value is CompactDashboardNodeHealth {
|
||||
return Array.isArray(value);
|
||||
}
|
||||
|
||||
function normalizeDistributionItems(
|
||||
items: Array<DistributionItem | CompactDistributionItem> | null | undefined,
|
||||
): DistributionItem[] {
|
||||
return arrayOrEmpty(items).map((item) =>
|
||||
isCompactDistributionItem(item)
|
||||
? { key: String(item[0] ?? ''), value: Number(item[1] ?? 0) }
|
||||
: item,
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeTrafficTrendPoints(
|
||||
items:
|
||||
| Array<
|
||||
| DashboardOverview['trends']['traffic_24h'][number]
|
||||
| CompactTrafficTrendPoint
|
||||
>
|
||||
| null
|
||||
| undefined,
|
||||
) {
|
||||
return arrayOrEmpty(items).map((item) =>
|
||||
isCompactTrafficTrendPoint(item)
|
||||
? {
|
||||
bucket_started_at: String(item[0] ?? ''),
|
||||
request_count: Number(item[1] ?? 0),
|
||||
error_count: Number(item[2] ?? 0),
|
||||
unique_visitor_count: Number(item[3] ?? 0),
|
||||
}
|
||||
: item,
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeCapacityTrendPoints(
|
||||
items:
|
||||
| Array<
|
||||
| DashboardOverview['trends']['capacity_24h'][number]
|
||||
| CompactCapacityTrendPoint
|
||||
>
|
||||
| null
|
||||
| undefined,
|
||||
) {
|
||||
return arrayOrEmpty(items).map((item) =>
|
||||
isCompactCapacityTrendPoint(item)
|
||||
? {
|
||||
bucket_started_at: String(item[0] ?? ''),
|
||||
average_cpu_usage_percent: Number(item[1] ?? 0),
|
||||
average_memory_usage_percent: Number(item[2] ?? 0),
|
||||
reported_nodes: Number(item[3] ?? 0),
|
||||
}
|
||||
: item,
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeNetworkTrendPoints(
|
||||
items:
|
||||
| Array<
|
||||
| DashboardOverview['trends']['network_24h'][number]
|
||||
| CompactNetworkTrendPoint
|
||||
>
|
||||
| null
|
||||
| undefined,
|
||||
) {
|
||||
return arrayOrEmpty(items).map((item) =>
|
||||
isCompactNetworkTrendPoint(item)
|
||||
? {
|
||||
bucket_started_at: String(item[0] ?? ''),
|
||||
network_rx_bytes: Number(item[1] ?? 0),
|
||||
network_tx_bytes: Number(item[2] ?? 0),
|
||||
openresty_rx_bytes: Number(item[3] ?? 0),
|
||||
openresty_tx_bytes: Number(item[4] ?? 0),
|
||||
reported_nodes: Number(item[5] ?? 0),
|
||||
}
|
||||
: item,
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeDiskIOTrendPoints(
|
||||
items:
|
||||
| Array<
|
||||
| DashboardOverview['trends']['disk_io_24h'][number]
|
||||
| CompactDiskIOTrendPoint
|
||||
>
|
||||
| null
|
||||
| undefined,
|
||||
) {
|
||||
return arrayOrEmpty(items).map((item) =>
|
||||
isCompactDiskIOTrendPoint(item)
|
||||
? {
|
||||
bucket_started_at: String(item[0] ?? ''),
|
||||
disk_read_bytes: Number(item[1] ?? 0),
|
||||
disk_write_bytes: Number(item[2] ?? 0),
|
||||
reported_nodes: Number(item[3] ?? 0),
|
||||
}
|
||||
: item,
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeDashboardNodes(
|
||||
items:
|
||||
| Array<DashboardNodeHealth | CompactDashboardNodeHealth>
|
||||
| null
|
||||
| undefined,
|
||||
): DashboardNodeHealth[] {
|
||||
return arrayOrEmpty(items).map((item) =>
|
||||
isCompactDashboardNode(item)
|
||||
? {
|
||||
id: Number(item[0] ?? 0),
|
||||
node_id: String(item[1] ?? ''),
|
||||
name: String(item[2] ?? ''),
|
||||
geo_name: String(item[3] ?? ''),
|
||||
geo_latitude:
|
||||
item[4] === null || item[4] === undefined ? null : Number(item[4]),
|
||||
geo_longitude:
|
||||
item[5] === null || item[5] === undefined ? null : Number(item[5]),
|
||||
status: (item[6] ?? 'pending') as DashboardNodeHealth['status'],
|
||||
openresty_status: (item[7] ??
|
||||
'unknown') as DashboardNodeHealth['openresty_status'],
|
||||
current_version: String(item[8] ?? ''),
|
||||
last_seen_at: String(item[9] ?? ''),
|
||||
active_event_count: Number(item[10] ?? 0),
|
||||
cpu_usage_percent: Number(item[11] ?? 0),
|
||||
memory_usage_percent: Number(item[12] ?? 0),
|
||||
storage_usage_percent: Number(item[13] ?? 0),
|
||||
request_count: Number(item[14] ?? 0),
|
||||
error_count: Number(item[15] ?? 0),
|
||||
unique_visitor_count: Number(item[16] ?? 0),
|
||||
}
|
||||
: item,
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeDashboardOverview(
|
||||
overview: DashboardOverview | DashboardOverviewCompact | null | undefined,
|
||||
): DashboardOverview | null {
|
||||
if (!overview) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const summary = (overview.summary ?? {}) as DashboardSummary;
|
||||
const traffic = (overview.traffic ?? {}) as DashboardTraffic;
|
||||
const capacity = (overview.capacity ?? {}) as DashboardCapacity;
|
||||
|
||||
return {
|
||||
generated_at: String(overview.generated_at ?? ''),
|
||||
summary,
|
||||
traffic,
|
||||
capacity,
|
||||
nodes: normalizeDashboardNodes(overview.nodes),
|
||||
distributions: {
|
||||
source_countries: normalizeDistributionItems(
|
||||
overview.distributions?.source_countries,
|
||||
),
|
||||
status_codes: normalizeDistributionItems(
|
||||
overview.distributions?.status_codes,
|
||||
),
|
||||
top_domains: normalizeDistributionItems(
|
||||
overview.distributions?.top_domains,
|
||||
),
|
||||
},
|
||||
trends: {
|
||||
traffic_24h: normalizeTrafficTrendPoints(overview.trends?.traffic_24h),
|
||||
capacity_24h: normalizeCapacityTrendPoints(overview.trends?.capacity_24h),
|
||||
network_24h: normalizeNetworkTrendPoints(overview.trends?.network_24h),
|
||||
disk_io_24h: normalizeDiskIOTrendPoints(overview.trends?.disk_io_24h),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function getDashboardOverview() {
|
||||
const overview = await apiRequest<
|
||||
DashboardOverview | DashboardOverviewCompact
|
||||
>('/dashboard/overview');
|
||||
return normalizeDashboardOverview(overview);
|
||||
}
|
||||
@@ -0,0 +1,415 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { RankChart } from '@/components/data/rank-chart';
|
||||
import { TrendChart } from '@/components/data/trend-chart';
|
||||
import { EmptyState } from '@/components/feedback/empty-state';
|
||||
import { ErrorState } from '@/components/feedback/error-state';
|
||||
import { LoadingState } from '@/components/feedback/loading-state';
|
||||
import { AppCard } from '@/components/ui/app-card';
|
||||
import { StatusBadge } from '@/components/ui/status-badge';
|
||||
import { getDashboardOverview } from '@/features/dashboard/api/overview';
|
||||
import type { DashboardNodeHealth } from '@/features/dashboard/types';
|
||||
import { WorldStage } from '@/features/dashboard/components/world-stage';
|
||||
import {
|
||||
getNodeStatusLabel,
|
||||
getNodeStatusVariant,
|
||||
getOpenrestyStatusLabel,
|
||||
getOpenrestyStatusVariant,
|
||||
isWSConnectedLastSeen,
|
||||
} from '@/features/nodes/utils';
|
||||
import { formatDateTime, formatRelativeTime } from '@/lib/utils/date';
|
||||
import {
|
||||
formatBytes,
|
||||
formatBytesPerSecond,
|
||||
formatPercent,
|
||||
} from '@/lib/utils/metrics';
|
||||
|
||||
function getErrorMessage(error: unknown) {
|
||||
return error instanceof Error ? error.message : '请求失败,请稍后重试。';
|
||||
}
|
||||
|
||||
function formatTrendHour(value: string) {
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return '—';
|
||||
}
|
||||
return `${date.getHours().toString().padStart(2, '0')}:00`;
|
||||
}
|
||||
|
||||
function buildNodeDetailHref(id?: number | null) {
|
||||
if (!id) {
|
||||
return '/node';
|
||||
}
|
||||
return `/node/detail?id=${id}`;
|
||||
}
|
||||
|
||||
function buildNodeRankItems(
|
||||
nodes: DashboardNodeHealth[],
|
||||
selector: (node: DashboardNodeHealth) => number,
|
||||
limit = 5,
|
||||
) {
|
||||
return [...nodes]
|
||||
.sort((left, right) => {
|
||||
const leftValue = selector(left);
|
||||
const rightValue = selector(right);
|
||||
if (leftValue === rightValue) {
|
||||
return left.name.localeCompare(right.name, 'zh-CN');
|
||||
}
|
||||
return rightValue - leftValue;
|
||||
})
|
||||
.slice(0, limit)
|
||||
.filter((node) => selector(node) > 0)
|
||||
.map((node) => ({
|
||||
label: node.name,
|
||||
value: selector(node),
|
||||
}));
|
||||
}
|
||||
|
||||
function NodeHealthRow({ node }: { node: DashboardNodeHealth }) {
|
||||
return (
|
||||
<Link
|
||||
href={buildNodeDetailHref(node.id)}
|
||||
className="block rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4 transition hover:border-[var(--border-strong)] hover:bg-[var(--surface-muted)]"
|
||||
>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-[var(--foreground-primary)]">
|
||||
{node.name}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-[var(--foreground-muted)]">
|
||||
{node.node_id}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<StatusBadge
|
||||
label={getNodeStatusLabel(node.status)}
|
||||
variant={getNodeStatusVariant(node.status)}
|
||||
/>
|
||||
<StatusBadge
|
||||
label={getOpenrestyStatusLabel(node.openresty_status)}
|
||||
variant={getOpenrestyStatusVariant(node.openresty_status)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid gap-3 md:grid-cols-4">
|
||||
<div>
|
||||
<p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
|
||||
CPU
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-[var(--foreground-primary)]">
|
||||
{formatPercent(node.cpu_usage_percent)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
|
||||
内存
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-[var(--foreground-primary)]">
|
||||
{formatPercent(node.memory_usage_percent)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
|
||||
最近窗口请求
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-[var(--foreground-primary)]">
|
||||
{node.request_count.toLocaleString('zh-CN')}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
|
||||
活动异常
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-[var(--foreground-primary)]">
|
||||
{node.active_event_count}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-wrap items-center justify-between gap-3 text-sm text-[var(--foreground-secondary)]">
|
||||
<p>当前版本:{node.current_version || '未应用'}</p>
|
||||
<p>
|
||||
最近心跳:
|
||||
{isWSConnectedLastSeen(node.last_seen_at)
|
||||
? ' WS 已连接'
|
||||
: node.last_seen_at
|
||||
? ` ${formatRelativeTime(node.last_seen_at)} · ${formatDateTime(node.last_seen_at)}`
|
||||
: ' 暂无'}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
export function DashboardOverview() {
|
||||
const overviewQuery = useQuery({
|
||||
queryKey: ['dashboard', 'overview'],
|
||||
queryFn: getDashboardOverview,
|
||||
refetchInterval: 10000,
|
||||
});
|
||||
|
||||
if (overviewQuery.isLoading) {
|
||||
return <LoadingState />;
|
||||
}
|
||||
|
||||
if (overviewQuery.isError) {
|
||||
return (
|
||||
<ErrorState
|
||||
title="总览看板加载失败"
|
||||
description={getErrorMessage(overviewQuery.error)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const overview = overviewQuery.data;
|
||||
if (!overview) {
|
||||
return (
|
||||
<EmptyState
|
||||
title="暂无总览数据"
|
||||
description="系统已经启动,但还没有可展示的总览聚合结果。"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<WorldStage
|
||||
summary={overview.summary}
|
||||
traffic={overview.traffic}
|
||||
capacity={overview.capacity}
|
||||
nodes={overview.nodes}
|
||||
sourceCountries={overview.distributions.source_countries}
|
||||
/>
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-2">
|
||||
<AppCard
|
||||
title="24 小时请求趋势"
|
||||
description="观察整体请求量和错误量是否出现异常抬升。"
|
||||
>
|
||||
<TrendChart
|
||||
labels={overview.trends.traffic_24h.map((point) =>
|
||||
formatTrendHour(point.bucket_started_at),
|
||||
)}
|
||||
series={[
|
||||
{
|
||||
label: '请求量',
|
||||
color: '#f59e0b',
|
||||
fillColor: 'rgba(245, 158, 11, 0.18)',
|
||||
variant: 'area',
|
||||
values: overview.trends.traffic_24h.map(
|
||||
(point) => point.request_count,
|
||||
),
|
||||
},
|
||||
{
|
||||
label: '错误量',
|
||||
color: '#ef4444',
|
||||
values: overview.trends.traffic_24h.map(
|
||||
(point) => point.error_count,
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</AppCard>
|
||||
|
||||
<AppCard
|
||||
title="24 小时容量趋势"
|
||||
description="按小时聚合 CPU 与内存使用率,判断整体容量是否持续紧张。"
|
||||
>
|
||||
<TrendChart
|
||||
labels={overview.trends.capacity_24h.map((point) =>
|
||||
formatTrendHour(point.bucket_started_at),
|
||||
)}
|
||||
yAxisValueFormatter={formatPercent}
|
||||
series={[
|
||||
{
|
||||
label: '平均 CPU',
|
||||
color: '#0f766e',
|
||||
fillColor: 'rgba(15, 118, 110, 0.15)',
|
||||
variant: 'area',
|
||||
values: overview.trends.capacity_24h.map(
|
||||
(point) => point.average_cpu_usage_percent,
|
||||
),
|
||||
valueFormatter: formatPercent,
|
||||
},
|
||||
{
|
||||
label: '平均内存',
|
||||
color: '#2563eb',
|
||||
values: overview.trends.capacity_24h.map(
|
||||
(point) => point.average_memory_usage_percent,
|
||||
),
|
||||
valueFormatter: formatPercent,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</AppCard>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-[1.1fr_0.9fr]">
|
||||
<AppCard title="24 小时网络与磁盘趋势">
|
||||
<div className="space-y-6">
|
||||
<TrendChart
|
||||
labels={overview.trends.network_24h.map((point) =>
|
||||
formatTrendHour(point.bucket_started_at),
|
||||
)}
|
||||
height={180}
|
||||
yAxisValueFormatter={(value) => formatBytesPerSecond(value, 3600)}
|
||||
series={[
|
||||
{
|
||||
label: 'OpenResty 入站',
|
||||
color: '#22c55e',
|
||||
fillColor: 'rgba(34, 197, 94, 0.14)',
|
||||
variant: 'area',
|
||||
values: overview.trends.network_24h.map(
|
||||
(point) => point.openresty_rx_bytes,
|
||||
),
|
||||
valueFormatter: (value) => formatBytesPerSecond(value, 3600),
|
||||
},
|
||||
{
|
||||
label: 'OpenResty 出站',
|
||||
color: '#38bdf8',
|
||||
values: overview.trends.network_24h.map(
|
||||
(point) => point.openresty_tx_bytes,
|
||||
),
|
||||
valueFormatter: (value) => formatBytesPerSecond(value, 3600),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<TrendChart
|
||||
labels={overview.trends.disk_io_24h.map((point) =>
|
||||
formatTrendHour(point.bucket_started_at),
|
||||
)}
|
||||
height={180}
|
||||
yAxisValueFormatter={formatBytes}
|
||||
series={[
|
||||
{
|
||||
label: '磁盘读',
|
||||
color: '#a78bfa',
|
||||
fillColor: 'rgba(167, 139, 250, 0.14)',
|
||||
variant: 'area',
|
||||
values: overview.trends.disk_io_24h.map(
|
||||
(point) => point.disk_read_bytes,
|
||||
),
|
||||
valueFormatter: formatBytes,
|
||||
},
|
||||
{
|
||||
label: '磁盘写',
|
||||
color: '#fb7185',
|
||||
values: overview.trends.disk_io_24h.map(
|
||||
(point) => point.disk_write_bytes,
|
||||
),
|
||||
valueFormatter: formatBytes,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</AppCard>
|
||||
|
||||
<AppCard title="Top 节点榜单">
|
||||
<div className="grid gap-6 xl:grid-cols-1">
|
||||
<div>
|
||||
<p className="mb-3 text-xs tracking-[0.22em] text-[var(--foreground-muted)] uppercase">
|
||||
流量最高节点
|
||||
</p>
|
||||
<RankChart
|
||||
items={buildNodeRankItems(
|
||||
overview.nodes,
|
||||
(node) => node.request_count,
|
||||
)}
|
||||
color="#38bdf8"
|
||||
emptyMessage="暂无流量榜单"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<p className="mb-3 text-xs tracking-[0.22em] text-[var(--foreground-muted)] uppercase">
|
||||
容量压力节点
|
||||
</p>
|
||||
<RankChart
|
||||
items={buildNodeRankItems(overview.nodes, (node) =>
|
||||
Math.round(
|
||||
Math.max(
|
||||
node.cpu_usage_percent,
|
||||
node.memory_usage_percent,
|
||||
node.storage_usage_percent,
|
||||
),
|
||||
),
|
||||
)}
|
||||
color="#ef4444"
|
||||
valueFormatter={(value) => `${value}%`}
|
||||
emptyMessage="暂无容量压力数据"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</AppCard>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-3">
|
||||
<AppCard title="来源分布" description="聚合最近 24 小时主要来源国家。">
|
||||
<RankChart
|
||||
items={overview.distributions.source_countries.map((item) => ({
|
||||
label: item.key,
|
||||
value: item.value,
|
||||
}))}
|
||||
color="#38bdf8"
|
||||
emptyMessage="暂无来源分布数据"
|
||||
/>
|
||||
</AppCard>
|
||||
|
||||
<AppCard
|
||||
title="状态码分布"
|
||||
description="快速判断成功响应是否仍是主流,以及错误码是否有抬升。"
|
||||
>
|
||||
<RankChart
|
||||
items={overview.distributions.status_codes.map((item) => ({
|
||||
label: `HTTP ${item.key}`,
|
||||
value: item.value,
|
||||
}))}
|
||||
color="#f59e0b"
|
||||
emptyMessage="暂无状态码分布"
|
||||
/>
|
||||
</AppCard>
|
||||
|
||||
<AppCard title="Top Domain" description="观察主要流量集中在哪些域名。">
|
||||
<RankChart
|
||||
items={overview.distributions.top_domains.map((item) => ({
|
||||
label: item.key,
|
||||
value: item.value,
|
||||
}))}
|
||||
color="#34d399"
|
||||
emptyMessage="暂无域名分布"
|
||||
/>
|
||||
</AppCard>
|
||||
</div>
|
||||
|
||||
<AppCard
|
||||
title="节点健康列表"
|
||||
description="按异常数量和资源压力排序。"
|
||||
action={
|
||||
<Link
|
||||
href="/node"
|
||||
className="inline-flex items-center rounded-full border border-[var(--border-default)] px-3 py-1.5 text-xs text-[var(--foreground-primary)] transition hover:bg-[var(--control-background-hover)]"
|
||||
>
|
||||
进入节点页
|
||||
</Link>
|
||||
}
|
||||
>
|
||||
{overview.nodes.length === 0 ? (
|
||||
<EmptyState
|
||||
title="暂无节点"
|
||||
description="节点接入后,这里会展示系统健康与容量摘要。"
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{overview.nodes.slice(0, 8).map((node) => (
|
||||
<NodeHealthRow key={node.node_id} node={node} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</AppCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,571 @@
|
||||
'use client';
|
||||
|
||||
import ReactEChartsCore from 'echarts-for-react/lib/core';
|
||||
import type { EChartsCoreOption } from 'echarts/core';
|
||||
import * as echarts from 'echarts/core';
|
||||
import { MapChart, ScatterChart } from 'echarts/charts';
|
||||
import { GeoComponent, TooltipComponent } from 'echarts/components';
|
||||
import { CanvasRenderer } from 'echarts/renderers';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { EmptyState } from '@/components/feedback/empty-state';
|
||||
import worldGeoJson from '@/features/dashboard/data/world-geo.json';
|
||||
import type {
|
||||
DashboardNodeHealth,
|
||||
DistributionItem,
|
||||
} from '@/features/dashboard/types';
|
||||
import {
|
||||
getNodeStatusLabel,
|
||||
getOpenrestyStatusLabel,
|
||||
} from '@/features/nodes/utils';
|
||||
|
||||
echarts.use([
|
||||
GeoComponent,
|
||||
TooltipComponent,
|
||||
MapChart,
|
||||
ScatterChart,
|
||||
CanvasRenderer,
|
||||
]);
|
||||
|
||||
const fallbackCoordinates = [
|
||||
[-122.4194, 37.7749],
|
||||
[-46.6333, -23.5505],
|
||||
[-0.1276, 51.5072],
|
||||
[2.3522, 48.8566],
|
||||
[77.209, 28.6139],
|
||||
[121.4737, 31.2304],
|
||||
[103.8198, 1.3521],
|
||||
[151.2093, -33.8688],
|
||||
[28.0473, -26.2041],
|
||||
[139.6917, 35.6895],
|
||||
] as const;
|
||||
|
||||
type Tone = 'healthy' | 'warning' | 'danger';
|
||||
|
||||
type WorldGeoJson = {
|
||||
type: string;
|
||||
features?: WorldFeature[];
|
||||
};
|
||||
|
||||
type WorldFeature = {
|
||||
properties?: {
|
||||
name?: string;
|
||||
};
|
||||
geometry?: {
|
||||
type?: 'Polygon' | 'MultiPolygon';
|
||||
coordinates?: number[][][] | number[][][][];
|
||||
};
|
||||
};
|
||||
|
||||
type MapNodeDatum = {
|
||||
id: number;
|
||||
name: string;
|
||||
geoName: string;
|
||||
route: string;
|
||||
derivedFromGeo: boolean;
|
||||
requestCount: number;
|
||||
errorCount: number;
|
||||
activeEventCount: number;
|
||||
status: DashboardNodeHealth['status'];
|
||||
openrestyStatus: DashboardNodeHealth['openresty_status'];
|
||||
value: [number, number, number];
|
||||
itemStyle: {
|
||||
color: string;
|
||||
borderColor: string;
|
||||
borderWidth: number;
|
||||
};
|
||||
emphasis: {
|
||||
itemStyle: {
|
||||
borderColor: string;
|
||||
borderWidth: number;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
type CountryRegionDatum = {
|
||||
name: string;
|
||||
value: number;
|
||||
itemStyle: {
|
||||
areaColor: string;
|
||||
borderColor: string;
|
||||
};
|
||||
emphasis: {
|
||||
itemStyle: {
|
||||
areaColor: string;
|
||||
borderColor: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
let worldMapRegistrationAttempted = false;
|
||||
let worldMapRegistrationSucceeded = false;
|
||||
|
||||
const baseWorldMapLayoutSizePercent = 168;
|
||||
const baseWorldMapZoom = 1;
|
||||
|
||||
function buildNodeDetailHref(id?: number | null) {
|
||||
if (!id) {
|
||||
return '/node';
|
||||
}
|
||||
return `/node/detail?id=${id}`;
|
||||
}
|
||||
|
||||
function getNodeTone(node: DashboardNodeHealth): Tone {
|
||||
if (
|
||||
node.status === 'offline' ||
|
||||
node.openresty_status === 'unhealthy' ||
|
||||
node.active_event_count > 0
|
||||
) {
|
||||
return 'danger';
|
||||
}
|
||||
|
||||
if (
|
||||
node.cpu_usage_percent >= 80 ||
|
||||
node.memory_usage_percent >= 85 ||
|
||||
node.storage_usage_percent >= 85
|
||||
) {
|
||||
return 'warning';
|
||||
}
|
||||
|
||||
return 'healthy';
|
||||
}
|
||||
|
||||
function getNodeCoordinates(node: DashboardNodeHealth, index: number) {
|
||||
if (
|
||||
typeof node.geo_latitude === 'number' &&
|
||||
typeof node.geo_longitude === 'number'
|
||||
) {
|
||||
return {
|
||||
coordinates: [node.geo_longitude, node.geo_latitude] as [number, number],
|
||||
derivedFromGeo: true,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
coordinates: [
|
||||
...fallbackCoordinates[index % fallbackCoordinates.length],
|
||||
] as [number, number],
|
||||
derivedFromGeo: false,
|
||||
};
|
||||
}
|
||||
|
||||
function ensureWorldMapRegistered() {
|
||||
if (worldMapRegistrationSucceeded || echarts.getMap('world')) {
|
||||
worldMapRegistrationSucceeded = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (worldMapRegistrationAttempted) {
|
||||
return false;
|
||||
}
|
||||
|
||||
worldMapRegistrationAttempted = true;
|
||||
|
||||
try {
|
||||
const geoJson = worldGeoJson as WorldGeoJson;
|
||||
|
||||
if (
|
||||
geoJson.type !== 'FeatureCollection' ||
|
||||
!Array.isArray(geoJson.features)
|
||||
) {
|
||||
throw new Error('invalid world geojson payload');
|
||||
}
|
||||
|
||||
echarts.registerMap(
|
||||
'world',
|
||||
geoJson as unknown as Parameters<typeof echarts.registerMap>[1],
|
||||
);
|
||||
|
||||
if (!echarts.getMap('world')) {
|
||||
throw new Error('world map registration failed');
|
||||
}
|
||||
|
||||
worldMapRegistrationSucceeded = true;
|
||||
return true;
|
||||
} catch (error) {
|
||||
const registrationError =
|
||||
error instanceof Error
|
||||
? error
|
||||
: new Error('unknown world map registration error');
|
||||
console.error('Failed to register ECharts world map', registrationError);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function WorldStageMap({
|
||||
isDark,
|
||||
nodes,
|
||||
sourceCountries,
|
||||
}: {
|
||||
isDark: boolean;
|
||||
nodes: DashboardNodeHealth[];
|
||||
sourceCountries: DistributionItem[];
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const chartContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
const [mapReady, setMapReady] = useState(false);
|
||||
const [mapFailed, setMapFailed] = useState(false);
|
||||
const [containerSize, setContainerSize] = useState({ width: 0, height: 0 });
|
||||
|
||||
useEffect(() => {
|
||||
const ready = ensureWorldMapRegistered();
|
||||
setMapReady(ready);
|
||||
setMapFailed(!ready);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const container = chartContainerRef.current;
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
|
||||
const updateSize = () => {
|
||||
const nextWidth = container.clientWidth;
|
||||
const nextHeight = container.clientHeight;
|
||||
setContainerSize((previous) =>
|
||||
previous.width === nextWidth && previous.height === nextHeight
|
||||
? previous
|
||||
: { width: nextWidth, height: nextHeight },
|
||||
);
|
||||
};
|
||||
|
||||
updateSize();
|
||||
|
||||
if (typeof ResizeObserver === 'undefined') {
|
||||
window.addEventListener('resize', updateSize);
|
||||
return () => {
|
||||
window.removeEventListener('resize', updateSize);
|
||||
};
|
||||
}
|
||||
|
||||
const observer = new ResizeObserver(() => {
|
||||
updateSize();
|
||||
});
|
||||
observer.observe(container);
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const mapPalette = useMemo(
|
||||
() =>
|
||||
isDark
|
||||
? {
|
||||
areaColor: '#13233b',
|
||||
borderColor: 'rgba(125,211,252,0.14)',
|
||||
labelColor: '#e2e8f0',
|
||||
sourceAreaColor: 'rgba(56, 189, 248, 0.18)',
|
||||
sourceAreaBorder: 'rgba(125, 211, 252, 0.45)',
|
||||
healthyColor: '#34d399',
|
||||
warningColor: '#fbbf24',
|
||||
dangerColor: '#fb7185',
|
||||
healthyBorder: '#bbf7d0',
|
||||
warningBorder: '#fde68a',
|
||||
dangerBorder: '#fecdd3',
|
||||
}
|
||||
: {
|
||||
areaColor: '#eaf2ff',
|
||||
borderColor: 'rgba(71,85,105,0.12)',
|
||||
labelColor: '#0f172a',
|
||||
sourceAreaColor: 'rgba(14, 165, 233, 0.22)',
|
||||
sourceAreaBorder: 'rgba(14, 165, 233, 0.46)',
|
||||
healthyColor: '#10b981',
|
||||
warningColor: '#f59e0b',
|
||||
dangerColor: '#f43f5e',
|
||||
healthyBorder: '#d1fae5',
|
||||
warningBorder: '#fde68a',
|
||||
dangerBorder: '#fecdd3',
|
||||
},
|
||||
[isDark],
|
||||
);
|
||||
|
||||
const mapNodes = useMemo<MapNodeDatum[]>(
|
||||
() =>
|
||||
nodes.map((node, index) => {
|
||||
const { coordinates, derivedFromGeo } = getNodeCoordinates(node, index);
|
||||
const tone = getNodeTone(node);
|
||||
const toneColor =
|
||||
tone === 'healthy'
|
||||
? mapPalette.healthyColor
|
||||
: tone === 'warning'
|
||||
? mapPalette.warningColor
|
||||
: mapPalette.dangerColor;
|
||||
const toneBorder =
|
||||
tone === 'healthy'
|
||||
? mapPalette.healthyBorder
|
||||
: tone === 'warning'
|
||||
? mapPalette.warningBorder
|
||||
: mapPalette.dangerBorder;
|
||||
|
||||
return {
|
||||
id: node.id,
|
||||
name: node.name,
|
||||
geoName: node.geo_name || node.name,
|
||||
route: buildNodeDetailHref(node.id),
|
||||
derivedFromGeo,
|
||||
requestCount: node.request_count,
|
||||
errorCount: node.error_count,
|
||||
activeEventCount: node.active_event_count,
|
||||
status: node.status,
|
||||
openrestyStatus: node.openresty_status,
|
||||
value: [
|
||||
coordinates[0],
|
||||
coordinates[1],
|
||||
Math.max(node.request_count, 1),
|
||||
],
|
||||
itemStyle: {
|
||||
color: toneColor,
|
||||
borderColor: toneBorder,
|
||||
borderWidth: 1.5,
|
||||
},
|
||||
emphasis: {
|
||||
itemStyle: {
|
||||
borderColor: toneBorder,
|
||||
borderWidth: 2,
|
||||
},
|
||||
},
|
||||
};
|
||||
}),
|
||||
[mapPalette, nodes],
|
||||
);
|
||||
|
||||
const countryRegions = useMemo<CountryRegionDatum[]>(() => {
|
||||
const maxRequests = Math.max(
|
||||
1,
|
||||
...sourceCountries.map((item) => item.value || 0),
|
||||
);
|
||||
|
||||
return sourceCountries
|
||||
.filter((item) => item.key && item.value > 0)
|
||||
.map((item) => {
|
||||
const intensity = Math.max(0.18, item.value / maxRequests);
|
||||
const areaOpacity = Number((0.14 + intensity * 0.58).toFixed(3));
|
||||
const borderOpacity = Number((0.22 + intensity * 0.48).toFixed(3));
|
||||
const areaColor = isDark
|
||||
? `rgba(56, 189, 248, ${areaOpacity})`
|
||||
: `rgba(14, 165, 233, ${areaOpacity})`;
|
||||
const borderColor = isDark
|
||||
? `rgba(125, 211, 252, ${borderOpacity})`
|
||||
: `rgba(14, 165, 233, ${borderOpacity})`;
|
||||
|
||||
return {
|
||||
name: item.key,
|
||||
value: item.value,
|
||||
itemStyle: {
|
||||
areaColor,
|
||||
borderColor,
|
||||
},
|
||||
emphasis: {
|
||||
itemStyle: {
|
||||
areaColor,
|
||||
borderColor,
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
}, [isDark, sourceCountries]);
|
||||
|
||||
const responsiveMapScale = useMemo(() => {
|
||||
const { width, height } = containerSize;
|
||||
if (width <= 0 || height <= 0) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
const widthScale = Math.min(Math.max(width / 960, 0.52), 1.08);
|
||||
const heightScale = Math.min(Math.max(height / 520, 0.72), 1.1);
|
||||
const compactViewportScale = width < 640 ? 0.9 : 1;
|
||||
|
||||
return Number(
|
||||
(Math.min(widthScale, heightScale) * compactViewportScale).toFixed(3),
|
||||
);
|
||||
}, [containerSize]);
|
||||
|
||||
const computedLayoutSize = useMemo(
|
||||
() => `${Math.round(baseWorldMapLayoutSizePercent * responsiveMapScale)}%`,
|
||||
[responsiveMapScale],
|
||||
);
|
||||
const computedZoom = useMemo(
|
||||
() => Number((baseWorldMapZoom * responsiveMapScale).toFixed(3)),
|
||||
[responsiveMapScale],
|
||||
);
|
||||
|
||||
const mapOption = useMemo<EChartsCoreOption>(
|
||||
() => ({
|
||||
animation: false,
|
||||
backgroundColor: 'transparent',
|
||||
tooltip: {
|
||||
trigger: 'item',
|
||||
transitionDuration: 0,
|
||||
backgroundColor: isDark
|
||||
? 'rgba(15,23,42,0.96)'
|
||||
: 'rgba(255,255,255,0.96)',
|
||||
borderColor: isDark
|
||||
? 'rgba(148,163,184,0.2)'
|
||||
: 'rgba(148,163,184,0.24)',
|
||||
borderWidth: 1,
|
||||
textStyle: {
|
||||
color: isDark ? '#e2e8f0' : '#0f172a',
|
||||
fontSize: 12,
|
||||
},
|
||||
formatter: (params: unknown) => {
|
||||
const payload = params as {
|
||||
data?: MapNodeDatum | CountryRegionDatum;
|
||||
seriesType?: string;
|
||||
name?: string;
|
||||
};
|
||||
const data = payload.data;
|
||||
if (
|
||||
payload.seriesType !== 'scatter' &&
|
||||
data &&
|
||||
'value' in data &&
|
||||
typeof data.value === 'number'
|
||||
) {
|
||||
return [
|
||||
`<div style="font-weight:600;margin-bottom:6px;">${payload.name ?? data.name}</div>`,
|
||||
`<div>最近 24 小时来源请求 ${data.value.toLocaleString('zh-CN')}</div>`,
|
||||
].join('');
|
||||
}
|
||||
|
||||
if (!data || !('requestCount' in data)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const locationLine = data.derivedFromGeo
|
||||
? data.geoName
|
||||
: `${data.geoName} · 预设落点`;
|
||||
|
||||
return [
|
||||
`<div style="font-weight:600;margin-bottom:6px;">${data.name}</div>`,
|
||||
`<div>${locationLine}</div>`,
|
||||
`<div>请求量 ${data.requestCount.toLocaleString('zh-CN')} · 错误数 ${data.errorCount.toLocaleString('zh-CN')}</div>`,
|
||||
`<div>活动事件 ${data.activeEventCount} · 节点状态 ${getNodeStatusLabel(data.status)}</div>`,
|
||||
`<div>OpenResty 状态 ${getOpenrestyStatusLabel(data.openrestyStatus)}</div>`,
|
||||
].join('');
|
||||
},
|
||||
},
|
||||
geo: {
|
||||
map: 'world',
|
||||
roam: true,
|
||||
silent: true,
|
||||
layoutCenter: ['50%', '50%'],
|
||||
layoutSize: computedLayoutSize,
|
||||
zoom: computedZoom,
|
||||
scaleLimit: {
|
||||
min: Math.max(Number((computedZoom * 0.78).toFixed(3)), 0.35),
|
||||
max: Math.max(Number((computedZoom * 2.2).toFixed(3)), 1.4),
|
||||
},
|
||||
regions: countryRegions,
|
||||
itemStyle: {
|
||||
areaColor: mapPalette.areaColor,
|
||||
borderColor: mapPalette.borderColor,
|
||||
borderWidth: 0.7,
|
||||
},
|
||||
emphasis: {
|
||||
disabled: true,
|
||||
},
|
||||
},
|
||||
series: [
|
||||
{
|
||||
type: 'map',
|
||||
map: 'world',
|
||||
geoIndex: 0,
|
||||
data: countryRegions,
|
||||
silent: true,
|
||||
z: 1,
|
||||
emphasis: {
|
||||
disabled: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'scatter',
|
||||
coordinateSystem: 'geo',
|
||||
data: mapNodes,
|
||||
z: 3,
|
||||
progressive: 64,
|
||||
large: true,
|
||||
largeThreshold: 24,
|
||||
symbolSize: (value: unknown) => {
|
||||
const size =
|
||||
Array.isArray(value) && typeof value[2] === 'number'
|
||||
? value[2]
|
||||
: 1;
|
||||
const responsiveBase = 8 + Math.log10(size + 1) * 3.6;
|
||||
return Math.max(
|
||||
7,
|
||||
Math.min(18, responsiveBase * Math.max(responsiveMapScale, 0.88)),
|
||||
);
|
||||
},
|
||||
label: {
|
||||
show: false,
|
||||
color: mapPalette.labelColor,
|
||||
fontSize: 11,
|
||||
},
|
||||
emphasis: {
|
||||
scale: 1.12,
|
||||
label: {
|
||||
show: mapNodes.length > 0 && mapNodes.length <= 5,
|
||||
position: 'right',
|
||||
distance: 8,
|
||||
formatter: '{b}',
|
||||
backgroundColor: isDark
|
||||
? 'rgba(8,15,31,0.7)'
|
||||
: 'rgba(255,255,255,0.88)',
|
||||
borderColor: isDark
|
||||
? 'rgba(148,163,184,0.16)'
|
||||
: 'rgba(148,163,184,0.2)',
|
||||
borderWidth: 1,
|
||||
borderRadius: 999,
|
||||
padding: [4, 8],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
[
|
||||
computedLayoutSize,
|
||||
computedZoom,
|
||||
countryRegions,
|
||||
isDark,
|
||||
mapNodes,
|
||||
mapPalette,
|
||||
responsiveMapScale,
|
||||
],
|
||||
);
|
||||
|
||||
if (!mapReady) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<EmptyState
|
||||
title={mapFailed ? '全球地图加载失败' : '全球地图加载中'}
|
||||
description={
|
||||
mapFailed
|
||||
? 'ECharts 世界地图资源未能成功注册,请稍后刷新重试。'
|
||||
: '正在按需初始化全球地图,这一步会延后到首屏内容稳定后执行。'
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={chartContainerRef} className="h-full w-full">
|
||||
<ReactEChartsCore
|
||||
echarts={echarts}
|
||||
option={mapOption}
|
||||
notMerge
|
||||
lazyUpdate
|
||||
opts={{ renderer: 'canvas' }}
|
||||
onEvents={{
|
||||
click: (params: { data?: MapNodeDatum }) => {
|
||||
if (params.data?.route) {
|
||||
router.push(params.data.route);
|
||||
}
|
||||
},
|
||||
}}
|
||||
style={{ height: '100%', width: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,344 @@
|
||||
'use client';
|
||||
|
||||
import dynamic from 'next/dynamic';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { EmptyState } from '@/components/feedback/empty-state';
|
||||
import { useTheme } from '@/components/providers/theme-provider';
|
||||
import type {
|
||||
DashboardCapacity,
|
||||
DashboardNodeHealth,
|
||||
DashboardSummary,
|
||||
DashboardTraffic,
|
||||
DistributionItem,
|
||||
} from '@/features/dashboard/types';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
|
||||
type Tone = 'healthy' | 'warning' | 'danger' | 'source';
|
||||
|
||||
const WorldStageMap = dynamic(
|
||||
() =>
|
||||
import('@/features/dashboard/components/world-stage-map').then(
|
||||
(module) => module.WorldStageMap,
|
||||
),
|
||||
{ ssr: false },
|
||||
);
|
||||
|
||||
function formatPercent(value: number) {
|
||||
if (!Number.isFinite(value)) {
|
||||
return '0%';
|
||||
}
|
||||
return `${value.toFixed(value >= 100 ? 0 : 1)}%`;
|
||||
}
|
||||
|
||||
function HeroMetric({
|
||||
label,
|
||||
value,
|
||||
hint,
|
||||
isDark,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
hint: string;
|
||||
isDark: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-[24px] border px-5 py-5 backdrop-blur',
|
||||
isDark
|
||||
? 'border-white/10 bg-white/6'
|
||||
: 'border-sky-100/90 bg-white/80 shadow-[0_18px_40px_rgba(148,163,184,0.12)]',
|
||||
)}
|
||||
>
|
||||
<p
|
||||
className={cn(
|
||||
'text-[11px] tracking-[0.26em] uppercase',
|
||||
isDark ? 'text-slate-300' : 'text-slate-500',
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</p>
|
||||
<p
|
||||
className={cn(
|
||||
'mt-3 text-[30px] leading-none font-semibold',
|
||||
isDark ? 'text-white' : 'text-slate-950',
|
||||
)}
|
||||
>
|
||||
{value}
|
||||
</p>
|
||||
<p
|
||||
className={cn(
|
||||
'mt-2 text-sm',
|
||||
isDark ? 'text-slate-300' : 'text-slate-600',
|
||||
)}
|
||||
>
|
||||
{hint}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LegendPill({
|
||||
label,
|
||||
tone,
|
||||
isDark,
|
||||
}: {
|
||||
label: string;
|
||||
tone: Tone;
|
||||
isDark: boolean;
|
||||
}) {
|
||||
const toneClass =
|
||||
tone === 'healthy'
|
||||
? isDark
|
||||
? 'border-emerald-300/20 bg-emerald-400/10 text-emerald-100'
|
||||
: 'border-emerald-200 bg-emerald-50 text-emerald-700'
|
||||
: tone === 'warning'
|
||||
? isDark
|
||||
? 'border-amber-300/20 bg-amber-400/10 text-amber-100'
|
||||
: 'border-amber-200 bg-amber-50 text-amber-700'
|
||||
: tone === 'source'
|
||||
? isDark
|
||||
? 'border-sky-300/20 bg-sky-400/10 text-sky-100'
|
||||
: 'border-sky-200 bg-sky-50 text-sky-700'
|
||||
: isDark
|
||||
? 'border-rose-300/20 bg-rose-400/10 text-rose-100'
|
||||
: 'border-rose-200 bg-rose-50 text-rose-700';
|
||||
|
||||
return (
|
||||
<div className={cn('rounded-full border px-3 py-1 text-[11px]', toneClass)}>
|
||||
{label}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function WorldStage({
|
||||
summary,
|
||||
traffic,
|
||||
capacity,
|
||||
nodes,
|
||||
sourceCountries,
|
||||
}: {
|
||||
summary: DashboardSummary;
|
||||
traffic: DashboardTraffic;
|
||||
capacity: DashboardCapacity;
|
||||
nodes: DashboardNodeHealth[];
|
||||
sourceCountries: DistributionItem[];
|
||||
}) {
|
||||
const { resolvedTheme } = useTheme();
|
||||
const isDark = resolvedTheme === 'dark';
|
||||
const mapViewportRef = useRef<HTMLDivElement | null>(null);
|
||||
const [shouldRenderMap, setShouldRenderMap] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (shouldRenderMap) {
|
||||
return;
|
||||
}
|
||||
|
||||
const mapViewport = mapViewportRef.current;
|
||||
if (!mapViewport || typeof IntersectionObserver === 'undefined') {
|
||||
setShouldRenderMap(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries.some((entry) => entry.isIntersecting)) {
|
||||
setShouldRenderMap(true);
|
||||
observer.disconnect();
|
||||
}
|
||||
},
|
||||
{ rootMargin: '180px 0px' },
|
||||
);
|
||||
|
||||
observer.observe(mapViewport);
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
};
|
||||
}, [shouldRenderMap]);
|
||||
|
||||
const onlineRate =
|
||||
summary.total_nodes > 0
|
||||
? (summary.online_nodes / summary.total_nodes) * 100
|
||||
: 0;
|
||||
const healthyNodes = Math.max(
|
||||
0,
|
||||
summary.online_nodes - summary.unhealthy_nodes,
|
||||
);
|
||||
const healthyRate =
|
||||
summary.total_nodes > 0 ? (healthyNodes / summary.total_nodes) * 100 : 0;
|
||||
const geoConfiguredNodes = nodes.filter(
|
||||
(node) =>
|
||||
typeof node.geo_latitude === 'number' &&
|
||||
typeof node.geo_longitude === 'number',
|
||||
).length;
|
||||
|
||||
return (
|
||||
<section
|
||||
className={cn(
|
||||
'overflow-hidden rounded-[32px] border transition-colors',
|
||||
isDark
|
||||
? 'border-slate-800/70 bg-[radial-gradient(circle_at_top_left,rgba(56,189,248,0.18),transparent_28%),radial-gradient(circle_at_82%_18%,rgba(56,189,248,0.10),transparent_18%),linear-gradient(135deg,#08111f,#0f172a_45%,#111827)] shadow-[0_32px_80px_rgba(2,6,23,0.35)]'
|
||||
: 'border-sky-100/90 bg-[radial-gradient(circle_at_top_left,rgba(14,165,233,0.18),transparent_28%),radial-gradient(circle_at_82%_18%,rgba(59,130,246,0.12),transparent_20%),linear-gradient(135deg,#f8fbff,#edf5ff_45%,#ffffff)] shadow-[0_32px_80px_rgba(148,163,184,0.18)]',
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'border-b px-6 py-6 md:px-7 md:py-7',
|
||||
isDark ? 'border-white/8' : 'border-slate-200/70',
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col gap-4 xl:flex-row xl:items-end xl:justify-between">
|
||||
<div className="space-y-2">
|
||||
<p
|
||||
className={cn(
|
||||
'text-[11px] tracking-[0.34em] uppercase',
|
||||
isDark ? 'text-sky-200/80' : 'text-sky-700/80',
|
||||
)}
|
||||
>
|
||||
Global Stage
|
||||
</p>
|
||||
<h2
|
||||
className={cn(
|
||||
'text-2xl font-semibold',
|
||||
isDark ? 'text-white' : 'text-slate-950',
|
||||
)}
|
||||
>
|
||||
全球态势板
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 px-6 py-7 md:px-7 md:py-8 xl:grid-cols-[1.32fr_0.88fr]">
|
||||
<div className="space-y-5">
|
||||
<div
|
||||
className={cn(
|
||||
'relative min-h-[540px] overflow-hidden rounded-[28px] border py-6 lg:min-h-[600px]',
|
||||
isDark
|
||||
? 'border-white/10 bg-[linear-gradient(180deg,rgba(15,23,42,0.16),rgba(15,23,42,0.42))]'
|
||||
: 'border-slate-200/80 bg-[linear-gradient(180deg,rgba(255,255,255,0.88),rgba(239,246,255,0.92))]',
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'absolute top-6 left-6 z-10 rounded-full px-3 py-1 text-[11px] tracking-[0.22em] uppercase backdrop-blur',
|
||||
isDark
|
||||
? 'bg-sky-400/20 text-sky-100'
|
||||
: 'bg-sky-100/90 text-sky-700',
|
||||
)}
|
||||
>
|
||||
{sourceCountries.length > 0
|
||||
? '访客来源热度'
|
||||
: geoConfiguredNodes > 0
|
||||
? '节点地理坐标'
|
||||
: '节点覆盖信号'}
|
||||
</div>
|
||||
|
||||
<div className="absolute top-4 right-4 z-10 flex flex-wrap gap-2">
|
||||
<LegendPill
|
||||
label="国家底色: 来源热度"
|
||||
tone="source"
|
||||
isDark={isDark}
|
||||
/>
|
||||
<LegendPill
|
||||
label="绿色: 运行正常"
|
||||
tone="healthy"
|
||||
isDark={isDark}
|
||||
/>
|
||||
<LegendPill
|
||||
label="黄色: 资源承压"
|
||||
tone="warning"
|
||||
isDark={isDark}
|
||||
/>
|
||||
<LegendPill
|
||||
label="红色: 异常待处理"
|
||||
tone="danger"
|
||||
isDark={isDark}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={mapViewportRef}
|
||||
className="absolute inset-x-3 top-16 bottom-6 flex items-center justify-center md:inset-x-4 md:top-18 md:bottom-8"
|
||||
>
|
||||
<div className="h-full w-full min-w-0 overflow-hidden">
|
||||
{shouldRenderMap ? (
|
||||
<WorldStageMap
|
||||
isDark={isDark}
|
||||
nodes={nodes}
|
||||
sourceCountries={sourceCountries}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<EmptyState
|
||||
title="全球地图准备中"
|
||||
description="地图会在进入可视区域后再加载,避免首页滚动和交互被首屏图表拖慢。"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{shouldRenderMap && nodes.length === 0 ? (
|
||||
<div className="pointer-events-none absolute inset-x-6 bottom-10 z-10">
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-2xl border border-dashed px-4 py-4 text-sm backdrop-blur',
|
||||
isDark
|
||||
? 'border-white/15 bg-white/5 text-slate-300'
|
||||
: 'border-slate-300/70 bg-white/78 text-slate-600',
|
||||
)}
|
||||
>
|
||||
当前暂无节点接入。地图已完成初始化,后续会在这里展示节点位置与健康状态。
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-5">
|
||||
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-2">
|
||||
<HeroMetric
|
||||
label="在线覆盖"
|
||||
value={formatPercent(onlineRate)}
|
||||
hint={`${summary.online_nodes}/${summary.total_nodes} 个节点在线`}
|
||||
isDark={isDark}
|
||||
/>
|
||||
<HeroMetric
|
||||
label="运行健康"
|
||||
value={formatPercent(healthyRate)}
|
||||
hint={`${summary.unhealthy_nodes} 个节点存在 OpenResty 异常`}
|
||||
isDark={isDark}
|
||||
/>
|
||||
<HeroMetric
|
||||
label="最近窗口请求"
|
||||
value={traffic.request_count.toLocaleString('zh-CN')}
|
||||
hint={`QPS ${traffic.estimated_qps.toFixed(1)} · ${traffic.reported_nodes} 个节点已上报`}
|
||||
isDark={isDark}
|
||||
/>
|
||||
<HeroMetric
|
||||
label="平均 CPU"
|
||||
value={formatPercent(capacity.average_cpu_usage_percent)}
|
||||
hint={`${capacity.high_cpu_nodes} 个节点 CPU 偏高`}
|
||||
isDark={isDark}
|
||||
/>
|
||||
<HeroMetric
|
||||
label="平均内存"
|
||||
value={formatPercent(capacity.average_memory_usage_percent)}
|
||||
hint={`${capacity.high_memory_nodes} 个高内存节点`}
|
||||
isDark={isDark}
|
||||
/>
|
||||
<HeroMetric
|
||||
label="高存储节点"
|
||||
value={capacity.high_storage_nodes.toLocaleString('zh-CN')}
|
||||
hint={`${summary.offline_nodes} 离线 · ${summary.pending_nodes} 待接入`}
|
||||
isDark={isDark}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,153 @@
|
||||
export interface DashboardSummary {
|
||||
total_nodes: number;
|
||||
online_nodes: number;
|
||||
offline_nodes: number;
|
||||
pending_nodes: number;
|
||||
unhealthy_nodes: number;
|
||||
}
|
||||
|
||||
export interface DashboardTraffic {
|
||||
request_count: number;
|
||||
unique_visitors: number;
|
||||
error_count: number;
|
||||
estimated_qps: number;
|
||||
reported_nodes: number;
|
||||
}
|
||||
|
||||
export interface DashboardCapacity {
|
||||
average_cpu_usage_percent: number;
|
||||
average_memory_usage_percent: number;
|
||||
high_cpu_nodes: number;
|
||||
high_memory_nodes: number;
|
||||
high_storage_nodes: number;
|
||||
}
|
||||
|
||||
export interface DistributionItem {
|
||||
key: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
export type CompactDistributionItem = [string, number];
|
||||
|
||||
export interface TrafficTrendPoint {
|
||||
bucket_started_at: string;
|
||||
request_count: number;
|
||||
error_count: number;
|
||||
unique_visitor_count: number;
|
||||
}
|
||||
|
||||
export interface CapacityTrendPoint {
|
||||
bucket_started_at: string;
|
||||
average_cpu_usage_percent: number;
|
||||
average_memory_usage_percent: number;
|
||||
reported_nodes: number;
|
||||
}
|
||||
|
||||
export interface NetworkTrendPoint {
|
||||
bucket_started_at: string;
|
||||
network_rx_bytes: number;
|
||||
network_tx_bytes: number;
|
||||
openresty_rx_bytes: number;
|
||||
openresty_tx_bytes: number;
|
||||
reported_nodes: number;
|
||||
}
|
||||
|
||||
export interface DiskIOTrendPoint {
|
||||
bucket_started_at: string;
|
||||
disk_read_bytes: number;
|
||||
disk_write_bytes: number;
|
||||
reported_nodes: number;
|
||||
}
|
||||
|
||||
export interface TrafficDistributions {
|
||||
status_codes: DistributionItem[];
|
||||
top_domains: DistributionItem[];
|
||||
source_countries: DistributionItem[];
|
||||
}
|
||||
|
||||
export interface DashboardTrends {
|
||||
traffic_24h: TrafficTrendPoint[];
|
||||
capacity_24h: CapacityTrendPoint[];
|
||||
network_24h: NetworkTrendPoint[];
|
||||
disk_io_24h: DiskIOTrendPoint[];
|
||||
}
|
||||
|
||||
export interface DashboardNodeHealth {
|
||||
id: number;
|
||||
node_id: string;
|
||||
name: string;
|
||||
geo_name: string;
|
||||
geo_latitude?: number | null;
|
||||
geo_longitude?: number | null;
|
||||
status: 'online' | 'offline' | 'pending';
|
||||
openresty_status: 'healthy' | 'unhealthy' | 'unknown';
|
||||
current_version: string;
|
||||
last_seen_at: string;
|
||||
active_event_count: number;
|
||||
cpu_usage_percent: number;
|
||||
memory_usage_percent: number;
|
||||
storage_usage_percent: number;
|
||||
request_count: number;
|
||||
error_count: number;
|
||||
unique_visitor_count: number;
|
||||
}
|
||||
|
||||
export interface DashboardOverview {
|
||||
generated_at: string;
|
||||
summary: DashboardSummary;
|
||||
traffic: DashboardTraffic;
|
||||
capacity: DashboardCapacity;
|
||||
distributions: TrafficDistributions;
|
||||
trends: DashboardTrends;
|
||||
nodes: DashboardNodeHealth[];
|
||||
}
|
||||
|
||||
export type CompactTrafficTrendPoint = [string, number, number, number];
|
||||
export type CompactCapacityTrendPoint = [string, number, number, number];
|
||||
export type CompactNetworkTrendPoint = [
|
||||
string,
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
];
|
||||
export type CompactDiskIOTrendPoint = [string, number, number, number];
|
||||
export type CompactDashboardNodeHealth = [
|
||||
number,
|
||||
string,
|
||||
string,
|
||||
string,
|
||||
number | null,
|
||||
number | null,
|
||||
DashboardNodeHealth['status'],
|
||||
DashboardNodeHealth['openresty_status'],
|
||||
string,
|
||||
string,
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
];
|
||||
|
||||
export interface DashboardOverviewCompact {
|
||||
generated_at: string;
|
||||
summary: DashboardSummary;
|
||||
traffic: DashboardTraffic;
|
||||
capacity: DashboardCapacity;
|
||||
distributions: {
|
||||
status_codes: CompactDistributionItem[];
|
||||
top_domains: CompactDistributionItem[];
|
||||
source_countries: CompactDistributionItem[];
|
||||
};
|
||||
trends: {
|
||||
traffic_24h: CompactTrafficTrendPoint[];
|
||||
capacity_24h: CompactCapacityTrendPoint[];
|
||||
network_24h: CompactNetworkTrendPoint[];
|
||||
disk_io_24h: CompactDiskIOTrendPoint[];
|
||||
};
|
||||
nodes: CompactDashboardNodeHealth[];
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import {apiRequest} from '@/lib/api/client';
|
||||
import type {DnsAccountItem, DnsAccountMutationPayload,} from '@/features/dns-accounts/types';
|
||||
|
||||
export function getDnsAccounts() {
|
||||
return apiRequest<DnsAccountItem[]>('/dns-accounts/');
|
||||
}
|
||||
|
||||
export function createDnsAccount(payload: DnsAccountMutationPayload) {
|
||||
return apiRequest<DnsAccountItem>('/dns-accounts/', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export function updateDnsAccount(
|
||||
id: number,
|
||||
payload: DnsAccountMutationPayload,
|
||||
) {
|
||||
return apiRequest<DnsAccountItem>(`/dns-accounts/${id}/update`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteDnsAccount(id: number) {
|
||||
return apiRequest<void>(`/dns-accounts/${id}/delete`, {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
'use client';
|
||||
|
||||
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query';
|
||||
import {useMemo, useState} from 'react';
|
||||
import Link from 'next/link';
|
||||
import {useForm} from 'react-hook-form';
|
||||
|
||||
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 {createDnsAccount, deleteDnsAccount, getDnsAccounts,} from '@/features/dns-accounts/api/dns-accounts';
|
||||
import type {DnsAccountItem} from '@/features/dns-accounts/types';
|
||||
import {getErrorMessage} from '@/features/websites/utils';
|
||||
import {
|
||||
DangerButton,
|
||||
PrimaryButton,
|
||||
ResourceField,
|
||||
ResourceInput,
|
||||
ResourceSelect,
|
||||
} from '@/features/shared/components/resource-primitives';
|
||||
import {formatDateTime} from '@/lib/utils/date';
|
||||
|
||||
export function DnsAccountsPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const [feedback, setFeedback] = useState<{
|
||||
tone: 'info' | 'success' | 'danger';
|
||||
message: string;
|
||||
} | null>(null);
|
||||
const [isCreateOpen, setIsCreateOpen] = useState(false);
|
||||
|
||||
const dnsAccountsQuery = useQuery({
|
||||
queryKey: ['dns-accounts'],
|
||||
queryFn: getDnsAccounts,
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: deleteDnsAccount,
|
||||
onSuccess: async () => {
|
||||
setFeedback({ tone: 'success', message: 'DNS 账号已删除。' });
|
||||
await queryClient.invalidateQueries({ queryKey: ['dns-accounts'] });
|
||||
},
|
||||
onError: (error) => {
|
||||
setFeedback({ tone: 'danger', message: getErrorMessage(error) });
|
||||
},
|
||||
});
|
||||
|
||||
const handleDelete = (account: DnsAccountItem) => {
|
||||
if (!window.confirm(`确认删除 DNS 账号 ${account.name} 吗?`)) {
|
||||
return;
|
||||
}
|
||||
setFeedback(null);
|
||||
deleteMutation.mutate(account.id);
|
||||
};
|
||||
|
||||
const accounts = useMemo(
|
||||
() => dnsAccountsQuery.data ?? [],
|
||||
[dnsAccountsQuery.data],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="DNS 账号"
|
||||
description="统一管理 DNS 服务商账号,用于 ACME 证书的 DNS 验证申请。"
|
||||
action={
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Link
|
||||
href="/website/certificate"
|
||||
className="inline-flex min-h-[46px] 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>
|
||||
<PrimaryButton
|
||||
type="button"
|
||||
onClick={() => setIsCreateOpen(true)}
|
||||
>
|
||||
添加账号
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{feedback ? (
|
||||
<InlineMessage
|
||||
tone={feedback.tone}
|
||||
message={feedback.message}
|
||||
onClear={() => setFeedback(null)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<AppCard title="DNS 账号列表">
|
||||
{dnsAccountsQuery.isLoading ? (
|
||||
<LoadingState />
|
||||
) : dnsAccountsQuery.isError ? (
|
||||
<ErrorState
|
||||
title="加载失败"
|
||||
description={getErrorMessage(dnsAccountsQuery.error)}
|
||||
/>
|
||||
) : accounts.length === 0 ? (
|
||||
<EmptyState
|
||||
title="暂无 DNS 账号"
|
||||
description="点击右上角“添加账号”开始录入。"
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{accounts.map((account) => (
|
||||
<div
|
||||
key={account.id}
|
||||
className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-semibold text-[var(--foreground-primary)]">
|
||||
{account.name}{' '}
|
||||
<span className="ml-2 text-xs font-normal text-[var(--foreground-secondary)]">
|
||||
({account.type})
|
||||
</span>
|
||||
</p>
|
||||
<div className="text-xs leading-5 text-[var(--foreground-secondary)]">
|
||||
<p>创建于:{formatDateTime(account.created_at)}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<DangerButton
|
||||
type="button"
|
||||
onClick={() => handleDelete(account)}
|
||||
disabled={deleteMutation.isPending}
|
||||
className="px-3 py-2 text-xs"
|
||||
>
|
||||
删除
|
||||
</DangerButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</AppCard>
|
||||
</div>
|
||||
|
||||
{isCreateOpen && (
|
||||
<DnsAccountCreateModal
|
||||
isOpen={isCreateOpen}
|
||||
onClose={() => setIsCreateOpen(false)}
|
||||
onCreated={() => {
|
||||
setFeedback({ tone: 'success', message: 'DNS 账号已添加。' });
|
||||
setIsCreateOpen(false);
|
||||
queryClient.invalidateQueries({ queryKey: ['dns-accounts'] });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function DnsAccountCreateModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
onCreated,
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onCreated: () => void;
|
||||
}) {
|
||||
const [error, setError] = useState('');
|
||||
const { register, handleSubmit, formState } = useForm({
|
||||
defaultValues: { name: '', type: 'cloudflare', authorization: '' },
|
||||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: createDnsAccount,
|
||||
onSuccess: onCreated,
|
||||
onError: (err) => setError(getErrorMessage(err)),
|
||||
});
|
||||
|
||||
const onSubmit = handleSubmit((values) => {
|
||||
setError('');
|
||||
// for cloudflare we wrap the token in JSON if it isn't already (the backend expects JSON)
|
||||
let auth = values.authorization;
|
||||
if (!auth.startsWith('{')) {
|
||||
auth = JSON.stringify({ api_token: values.authorization });
|
||||
}
|
||||
createMutation.mutate({ ...values, authorization: auth });
|
||||
});
|
||||
|
||||
return (
|
||||
<AppModal isOpen={isOpen} onClose={onClose} title="添加 DNS 账号">
|
||||
<form onSubmit={onSubmit} className="space-y-5">
|
||||
{error && <InlineMessage tone="danger" message={error} />}
|
||||
<ResourceField
|
||||
label="账号名称"
|
||||
error={formState.errors.name?.message as string}
|
||||
>
|
||||
<ResourceInput
|
||||
placeholder="Cloudlfare 邮箱账号"
|
||||
{...register('name', { required: '请输入名称' })}
|
||||
/>
|
||||
</ResourceField>
|
||||
<ResourceField label="DNS 服务商">
|
||||
<ResourceSelect {...register('type')}>
|
||||
<option value="cloudflare">Cloudflare</option>
|
||||
</ResourceSelect>
|
||||
</ResourceField>
|
||||
<ResourceField label="API Token" hint="请勿使用 Global API Key">
|
||||
<ResourceInput
|
||||
{...register('authorization', { required: '请输入 Token' })}
|
||||
/>
|
||||
</ResourceField>
|
||||
<PrimaryButton type="submit" disabled={createMutation.isPending}>
|
||||
{createMutation.isPending ? '提交中...' : '提交'}
|
||||
</PrimaryButton>
|
||||
</form>
|
||||
</AppModal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
export interface DnsAccountItem {
|
||||
id: number;
|
||||
name: string;
|
||||
type: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface DnsAccountMutationPayload {
|
||||
name: string;
|
||||
type: string;
|
||||
authorization: string;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import {apiRequest} from '@/lib/api/client';
|
||||
|
||||
import type {
|
||||
ManagedDomainCertificateOption,
|
||||
ManagedDomainItem,
|
||||
ManagedDomainMutationPayload,
|
||||
} from '@/features/managed-domains/types';
|
||||
|
||||
export function getManagedDomains() {
|
||||
return apiRequest<ManagedDomainItem[]>('/managed-domains/');
|
||||
}
|
||||
|
||||
export function createManagedDomain(payload: ManagedDomainMutationPayload) {
|
||||
return apiRequest<ManagedDomainItem>('/managed-domains/', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export function updateManagedDomain(
|
||||
id: number,
|
||||
payload: ManagedDomainMutationPayload,
|
||||
) {
|
||||
return apiRequest<ManagedDomainItem>(`/managed-domains/${id}/update`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteManagedDomain(id: number) {
|
||||
return apiRequest<void>(`/managed-domains/${id}/delete`, {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
export function getManagedDomainCertificates() {
|
||||
return apiRequest<ManagedDomainCertificateOption[]>('/tls-certificates/');
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
export interface ManagedDomainItem {
|
||||
id: number;
|
||||
domain: string;
|
||||
cert_id: number | null;
|
||||
enabled: boolean;
|
||||
remark: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface ManagedDomainMutationPayload {
|
||||
domain: string;
|
||||
cert_id: number | null;
|
||||
enabled: boolean;
|
||||
remark: string;
|
||||
}
|
||||
|
||||
export interface ManagedDomainCertificateOption {
|
||||
id: number;
|
||||
name: string;
|
||||
not_after: string | null;
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import {apiRequest} from '@/lib/api/client';
|
||||
|
||||
import type {
|
||||
NodeAgentReleaseInfo,
|
||||
NodeAgentUpdatePayload,
|
||||
NodeBootstrapToken,
|
||||
NodeItem,
|
||||
NodeMutationPayload,
|
||||
NodeObservability,
|
||||
} from '@/features/nodes/types';
|
||||
import type {ReleaseChannel} from '@/features/update/types';
|
||||
|
||||
export function getNodes() {
|
||||
return apiRequest<NodeItem[]>('/nodes/');
|
||||
}
|
||||
|
||||
export function createNode(payload: NodeMutationPayload) {
|
||||
return apiRequest<NodeItem>('/nodes/', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export function updateNode(id: number, payload: NodeMutationPayload) {
|
||||
return apiRequest<NodeItem>(`/nodes/${id}/update`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteNode(id: number) {
|
||||
return apiRequest<void>(`/nodes/${id}/delete`, {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
export function getNodeBootstrapToken() {
|
||||
return apiRequest<NodeBootstrapToken>('/nodes/bootstrap-token');
|
||||
}
|
||||
|
||||
export function rotateNodeBootstrapToken() {
|
||||
return apiRequest<NodeBootstrapToken>('/nodes/bootstrap-token/rotate', {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
export function getNodeAgentRelease(
|
||||
id: number,
|
||||
channel: ReleaseChannel = 'stable',
|
||||
) {
|
||||
return apiRequest<NodeAgentReleaseInfo>(
|
||||
`/nodes/${id}/agent-release?channel=${channel}`,
|
||||
);
|
||||
}
|
||||
|
||||
export function requestNodeAgentUpdate(
|
||||
id: number,
|
||||
payload?: NodeAgentUpdatePayload,
|
||||
) {
|
||||
return apiRequest<NodeItem>(`/nodes/${id}/agent-update`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload ?? {}),
|
||||
});
|
||||
}
|
||||
|
||||
export function requestNodeForceSync(id: number) {
|
||||
return apiRequest<NodeItem>(`/nodes/${id}/force-sync`, {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
export function requestNodeOpenrestyRestart(id: number) {
|
||||
return apiRequest<NodeItem>(`/nodes/${id}/openresty-restart`, {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
export function getNodeObservability(
|
||||
id: number,
|
||||
options?: { hours?: number; limit?: number },
|
||||
) {
|
||||
const params = new URLSearchParams();
|
||||
if (options?.hours) {
|
||||
params.set('hours', String(options.hours));
|
||||
}
|
||||
if (options?.limit) {
|
||||
params.set('limit', String(options.limit));
|
||||
}
|
||||
const query = params.toString();
|
||||
return apiRequest<NodeObservability>(
|
||||
`/nodes/${id}/observability${query ? `?${query}` : ''}`,
|
||||
);
|
||||
}
|
||||
|
||||
export function cleanupNodeHealthEvents(id: number) {
|
||||
return apiRequest<{ node_id: string; deleted_count: number }>(
|
||||
`/nodes/${id}/observability/cleanup`,
|
||||
{
|
||||
method: 'POST',
|
||||
},
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,641 @@
|
||||
'use client';
|
||||
|
||||
import {zodResolver} from '@hookform/resolvers/zod';
|
||||
import {useEffect, useMemo} from 'react';
|
||||
import {useForm, useWatch} from 'react-hook-form';
|
||||
import {z} from 'zod';
|
||||
|
||||
import {AppModal} from '@/components/ui/app-modal';
|
||||
import worldGeoJson from '@/features/dashboard/data/world-geo.json';
|
||||
import type {NodeItem, NodeMutationPayload} from '@/features/nodes/types';
|
||||
import {
|
||||
PrimaryButton,
|
||||
ResourceField,
|
||||
ResourceInput,
|
||||
ResourceSelect,
|
||||
SecondaryButton,
|
||||
ToggleField,
|
||||
} from '@/features/shared/components/resource-primitives';
|
||||
|
||||
type GeoJsonGeometry = {
|
||||
type: string;
|
||||
coordinates: unknown;
|
||||
};
|
||||
|
||||
type GeoJsonFeature = {
|
||||
geometry?: GeoJsonGeometry;
|
||||
properties?: {
|
||||
name?: string;
|
||||
};
|
||||
};
|
||||
|
||||
type RegionOption = {
|
||||
label: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
};
|
||||
|
||||
const nodeEditorSchema = z
|
||||
.object({
|
||||
type: z.enum(['edge_node', 'tunnel_relay', 'tunnel_client']),
|
||||
name: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, '请输入节点名')
|
||||
.max(128, '节点名不能超过 128 个字符'),
|
||||
ip: z.string().trim().max(64, '节点 IP 不能超过 64 个字符'),
|
||||
ip_manual_override: z.boolean(),
|
||||
auto_update_enabled: z.boolean(),
|
||||
relay_bind_port: z.string().trim(),
|
||||
relay_vhost_http_port: z.string().trim(),
|
||||
relay_client_access_addr: z.string().trim(),
|
||||
relay_agent_access_addr: z.string().trim(),
|
||||
relay_client_proxy_url: z.string().trim(),
|
||||
relay_web_server_enabled: z.boolean(),
|
||||
geo_manual_override: z.boolean(),
|
||||
geo_region: z.string(),
|
||||
geo_name: z.string().trim().max(128, '位置名不能超过 128 个字符'),
|
||||
geo_latitude: z.string().trim(),
|
||||
geo_longitude: z.string().trim(),
|
||||
})
|
||||
.superRefine((values, ctx) => {
|
||||
if (values.ip_manual_override && values.ip.trim() === '') {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['ip'],
|
||||
message: '锁定节点 IP 时必须填写节点 IP',
|
||||
});
|
||||
}
|
||||
|
||||
if (values.type === 'tunnel_relay') {
|
||||
const port = Number(values.relay_bind_port);
|
||||
if (Number.isNaN(port) || port < 1 || port > 65535) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['relay_bind_port'],
|
||||
message: '请填写正确的端口号 (1-65535)',
|
||||
});
|
||||
}
|
||||
const vhostPort = Number(values.relay_vhost_http_port);
|
||||
if (Number.isNaN(vhostPort) || vhostPort < 1 || vhostPort > 65535) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['relay_vhost_http_port'],
|
||||
message: '请填写正确的端口号 (1-65535)',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!values.geo_manual_override) {
|
||||
return;
|
||||
}
|
||||
|
||||
const hasLatitude = values.geo_latitude !== '';
|
||||
const hasLongitude = values.geo_longitude !== '';
|
||||
|
||||
if (hasLatitude !== hasLongitude) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['geo_latitude'],
|
||||
message: '纬度和经度需要同时填写',
|
||||
});
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['geo_longitude'],
|
||||
message: '纬度和经度需要同时填写',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasLatitude) {
|
||||
const latitude = Number(values.geo_latitude);
|
||||
if (Number.isNaN(latitude) || latitude < -90 || latitude > 90) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['geo_latitude'],
|
||||
message: '纬度必须在 -90 到 90 之间',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (hasLongitude) {
|
||||
const longitude = Number(values.geo_longitude);
|
||||
if (Number.isNaN(longitude) || longitude < -180 || longitude > 180) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['geo_longitude'],
|
||||
message: '经度必须在 -180 到 180 之间',
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
type NodeEditorValues = z.infer<typeof nodeEditorSchema>;
|
||||
|
||||
const defaultValues: NodeEditorValues = {
|
||||
type: 'edge_node',
|
||||
name: '',
|
||||
ip: '',
|
||||
ip_manual_override: false,
|
||||
auto_update_enabled: false,
|
||||
relay_bind_port: '7000',
|
||||
relay_vhost_http_port: '8080',
|
||||
relay_client_access_addr: '',
|
||||
relay_agent_access_addr: '',
|
||||
relay_client_proxy_url: '',
|
||||
relay_web_server_enabled: true,
|
||||
geo_manual_override: false,
|
||||
geo_region: '',
|
||||
geo_name: '',
|
||||
geo_latitude: '',
|
||||
geo_longitude: '',
|
||||
};
|
||||
|
||||
function collectPoints(value: unknown, points: Array<[number, number]>) {
|
||||
if (!Array.isArray(value)) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
value.length >= 2 &&
|
||||
typeof value[0] === 'number' &&
|
||||
typeof value[1] === 'number'
|
||||
) {
|
||||
points.push([value[0], value[1]]);
|
||||
return;
|
||||
}
|
||||
for (const item of value) {
|
||||
collectPoints(item, points);
|
||||
}
|
||||
}
|
||||
|
||||
function getRegionCenter(feature: GeoJsonFeature) {
|
||||
const points: Array<[number, number]> = [];
|
||||
collectPoints(feature.geometry?.coordinates, points);
|
||||
if (points.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let minLongitude = Number.POSITIVE_INFINITY;
|
||||
let maxLongitude = Number.NEGATIVE_INFINITY;
|
||||
let minLatitude = Number.POSITIVE_INFINITY;
|
||||
let maxLatitude = Number.NEGATIVE_INFINITY;
|
||||
|
||||
for (const [longitude, latitude] of points) {
|
||||
minLongitude = Math.min(minLongitude, longitude);
|
||||
maxLongitude = Math.max(maxLongitude, longitude);
|
||||
minLatitude = Math.min(minLatitude, latitude);
|
||||
maxLatitude = Math.max(maxLatitude, latitude);
|
||||
}
|
||||
|
||||
return {
|
||||
longitude: Number(((minLongitude + maxLongitude) / 2).toFixed(4)),
|
||||
latitude: Number(((minLatitude + maxLatitude) / 2).toFixed(4)),
|
||||
};
|
||||
}
|
||||
|
||||
function buildRegionOptions() {
|
||||
const features = ((worldGeoJson as { features?: GeoJsonFeature[] })
|
||||
.features ?? []) as GeoJsonFeature[];
|
||||
const options = new Map<string, RegionOption>();
|
||||
|
||||
for (const feature of features) {
|
||||
const label = feature.properties?.name?.trim();
|
||||
if (!label || options.has(label)) {
|
||||
continue;
|
||||
}
|
||||
const center = getRegionCenter(feature);
|
||||
if (!center) {
|
||||
continue;
|
||||
}
|
||||
options.set(label, {
|
||||
label,
|
||||
latitude: center.latitude,
|
||||
longitude: center.longitude,
|
||||
});
|
||||
}
|
||||
|
||||
return Array.from(options.values()).sort((a, b) =>
|
||||
a.label.localeCompare(b.label),
|
||||
);
|
||||
}
|
||||
|
||||
function buildFormValues(node?: Partial<NodeItem> | null): NodeEditorValues {
|
||||
if (!node) {
|
||||
return defaultValues;
|
||||
}
|
||||
|
||||
return {
|
||||
type: node.node_type ?? 'edge_node',
|
||||
name: node.name ?? '',
|
||||
ip: node.ip ?? '',
|
||||
ip_manual_override: node.ip_manual_override ?? false,
|
||||
auto_update_enabled: node.auto_update_enabled ?? false,
|
||||
relay_bind_port: String(node.relay_bind_port ?? 7000),
|
||||
relay_vhost_http_port: String(node.relay_vhost_http_port ?? 8080),
|
||||
relay_client_access_addr: node.relay_client_access_addr ?? '',
|
||||
relay_agent_access_addr: node.relay_agent_access_addr ?? '',
|
||||
relay_client_proxy_url: node.relay_client_proxy_url ?? '',
|
||||
relay_web_server_enabled: node.relay_web_server_enabled ?? false,
|
||||
geo_manual_override: node.geo_manual_override ?? false,
|
||||
geo_region: node.geo_manual_override ? (node.geo_name ?? '') : '',
|
||||
geo_name: node.geo_name ?? '',
|
||||
geo_latitude:
|
||||
node.geo_latitude === undefined || node.geo_latitude === null
|
||||
? ''
|
||||
: String(node.geo_latitude),
|
||||
geo_longitude:
|
||||
node.geo_longitude === undefined || node.geo_longitude === null
|
||||
? ''
|
||||
: String(node.geo_longitude),
|
||||
};
|
||||
}
|
||||
|
||||
function toPayload(values: NodeEditorValues): NodeMutationPayload {
|
||||
const basePayload = {
|
||||
node_type: values.type,
|
||||
name: values.name.trim(),
|
||||
ip: values.ip.trim(),
|
||||
ip_manual_override: values.ip_manual_override,
|
||||
auto_update_enabled: values.auto_update_enabled,
|
||||
relay_bind_port:
|
||||
values.type === 'tunnel_relay'
|
||||
? Number(values.relay_bind_port)
|
||||
: undefined,
|
||||
relay_vhost_http_port:
|
||||
values.type === 'tunnel_relay'
|
||||
? Number(values.relay_vhost_http_port)
|
||||
: undefined,
|
||||
relay_client_access_addr:
|
||||
values.type === 'tunnel_relay'
|
||||
? values.relay_client_access_addr.trim()
|
||||
: undefined,
|
||||
relay_agent_access_addr:
|
||||
values.type === 'tunnel_relay'
|
||||
? values.relay_agent_access_addr.trim()
|
||||
: undefined,
|
||||
relay_client_proxy_url:
|
||||
values.type === 'tunnel_relay'
|
||||
? values.relay_client_proxy_url.trim()
|
||||
: undefined,
|
||||
relay_web_server_enabled:
|
||||
values.type === 'tunnel_relay'
|
||||
? values.relay_web_server_enabled
|
||||
: undefined,
|
||||
};
|
||||
|
||||
if (!values.geo_manual_override) {
|
||||
return {
|
||||
...basePayload,
|
||||
geo_manual_override: false,
|
||||
geo_name: '',
|
||||
geo_latitude: null,
|
||||
geo_longitude: null,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...basePayload,
|
||||
geo_manual_override: true,
|
||||
geo_name: values.geo_name.trim(),
|
||||
geo_latitude:
|
||||
values.geo_latitude.trim() === '' ? null : Number(values.geo_latitude),
|
||||
geo_longitude:
|
||||
values.geo_longitude.trim() === '' ? null : Number(values.geo_longitude),
|
||||
};
|
||||
}
|
||||
|
||||
export function NodeEditorModal({
|
||||
isOpen,
|
||||
node,
|
||||
isSubmitting,
|
||||
title,
|
||||
description,
|
||||
submitLabel,
|
||||
onClose,
|
||||
onSubmit,
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
node?: Partial<NodeItem> | null;
|
||||
isSubmitting: boolean;
|
||||
title: string;
|
||||
description: string;
|
||||
submitLabel: string;
|
||||
onClose: () => void;
|
||||
onSubmit: (payload: NodeMutationPayload) => void;
|
||||
}) {
|
||||
const form = useForm<NodeEditorValues>({
|
||||
resolver: zodResolver(nodeEditorSchema),
|
||||
defaultValues,
|
||||
});
|
||||
|
||||
const watchedType = useWatch({
|
||||
control: form.control,
|
||||
name: 'type',
|
||||
});
|
||||
const watchedAutoUpdate = useWatch({
|
||||
control: form.control,
|
||||
name: 'auto_update_enabled',
|
||||
});
|
||||
const watchedIPManualOverride = useWatch({
|
||||
control: form.control,
|
||||
name: 'ip_manual_override',
|
||||
});
|
||||
const watchedGeoManualOverride = useWatch({
|
||||
control: form.control,
|
||||
name: 'geo_manual_override',
|
||||
});
|
||||
const regionOptions = useMemo(() => buildRegionOptions(), []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
return;
|
||||
}
|
||||
form.reset(buildFormValues(node));
|
||||
}, [form, isOpen, node]);
|
||||
|
||||
const ipField = form.register('ip');
|
||||
const handleSubmit = form.handleSubmit((values) => {
|
||||
onSubmit(toPayload(values));
|
||||
});
|
||||
|
||||
return (
|
||||
<AppModal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title={title}
|
||||
description={description}
|
||||
footer={
|
||||
<div className="flex flex-wrap justify-end gap-3">
|
||||
<SecondaryButton
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
取消
|
||||
</SecondaryButton>
|
||||
<PrimaryButton
|
||||
type="submit"
|
||||
form="node-editor-form"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? '保存中...' : submitLabel}
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<form id="node-editor-form" className="space-y-5" onSubmit={handleSubmit}>
|
||||
<ResourceField
|
||||
label="节点类型"
|
||||
hint="边缘节点用于常规的网站代理和缓存;隧道中继节点用于内网穿透的流量中转服务器。"
|
||||
>
|
||||
<ResourceSelect {...form.register('type')} disabled={!!node}>
|
||||
<option value="edge_node">边缘节点 (Edge Node)</option>
|
||||
<option value="tunnel_relay">隧道中继节点 (Tunnel Relay)</option>
|
||||
<option value="tunnel_client">隧道客户端 (Tunnel Client)</option>
|
||||
</ResourceSelect>
|
||||
</ResourceField>
|
||||
|
||||
<ResourceField
|
||||
label="节点名"
|
||||
hint="示例:shanghai-edge-1"
|
||||
error={form.formState.errors.name?.message}
|
||||
>
|
||||
<ResourceInput
|
||||
placeholder="shanghai-edge-1"
|
||||
{...form.register('name')}
|
||||
/>
|
||||
</ResourceField>
|
||||
|
||||
{watchedType === 'tunnel_relay' && (
|
||||
<div className="border-border bg-muted/50 space-y-5 rounded-lg border p-4">
|
||||
<h4 className="text-sm font-medium">中继配置 (Relay Config)</h4>
|
||||
<ResourceField
|
||||
label="中继绑定端口 (Bind Port)"
|
||||
hint="中继服务端在此端口监听并接受 Flared 客户端连接。"
|
||||
error={form.formState.errors.relay_bind_port?.message}
|
||||
>
|
||||
<ResourceInput
|
||||
placeholder="7000"
|
||||
{...form.register('relay_bind_port')}
|
||||
/>
|
||||
</ResourceField>
|
||||
|
||||
<ResourceField
|
||||
label="中继 Vhost HTTP 端口 (Vhost HTTP Port)"
|
||||
hint="中继服务端在此端口监听 HTTP 虚拟主机流量,用于 Openresty 转发到中继。"
|
||||
error={form.formState.errors.relay_vhost_http_port?.message}
|
||||
>
|
||||
<ResourceInput
|
||||
placeholder="8080"
|
||||
{...form.register('relay_vhost_http_port')}
|
||||
/>
|
||||
</ResourceField>
|
||||
|
||||
<ResourceField
|
||||
label="边缘节点接入地址 (Edge Node Access Addr)"
|
||||
hint="用于 Edge Node 转发请求。默认与 Client Access Addr 或 IP + 绑定端口一致。"
|
||||
error={form.formState.errors.relay_agent_access_addr?.message}
|
||||
>
|
||||
<ResourceInput
|
||||
placeholder="例如: 10.0.0.1:7000"
|
||||
{...form.register('relay_agent_access_addr')}
|
||||
/>
|
||||
</ResourceField>
|
||||
|
||||
<ResourceField
|
||||
label="客户端接入地址 (Client Access Addr)"
|
||||
hint="如果不填默认使用节点的 IP + 绑定端口。"
|
||||
error={form.formState.errors.relay_client_access_addr?.message}
|
||||
>
|
||||
<ResourceInput
|
||||
placeholder="例如: relay.example.com:7000"
|
||||
{...form.register('relay_client_access_addr')}
|
||||
/>
|
||||
</ResourceField>
|
||||
|
||||
<ResourceField
|
||||
label="代理地址 (Proxy URL)"
|
||||
hint="可选,下发给客户端。当客户端连接中继需要经过 HTTP 代理时使用。"
|
||||
error={form.formState.errors.relay_client_proxy_url?.message}
|
||||
>
|
||||
<ResourceInput
|
||||
placeholder="例如: http://10.0.0.1:3128"
|
||||
{...form.register('relay_client_proxy_url')}
|
||||
/>
|
||||
</ResourceField>
|
||||
|
||||
<ToggleField
|
||||
label="开启 FRPS WebUI"
|
||||
description="访问端口 17500。用户 admin 密码为当前中继节点的接入 Token。"
|
||||
checked={form.watch('relay_web_server_enabled')}
|
||||
onChange={(checked) =>
|
||||
form.setValue('relay_web_server_enabled', checked, {
|
||||
shouldDirty: true,
|
||||
shouldValidate: true,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ResourceField
|
||||
label="节点 IP"
|
||||
hint={
|
||||
watchedIPManualOverride
|
||||
? '已锁定为指定值,不会被更新。'
|
||||
: '留空或关闭锁定时,等待 Agent 自动更新。'
|
||||
}
|
||||
error={form.formState.errors.ip?.message}
|
||||
>
|
||||
<ResourceInput
|
||||
placeholder="203.0.113.10"
|
||||
{...ipField}
|
||||
onChange={(event) => {
|
||||
ipField.onChange(event);
|
||||
const nextIP = event.target.value.trim();
|
||||
const previousIP = (node?.ip ?? '').trim();
|
||||
if (nextIP !== '' && nextIP !== previousIP) {
|
||||
form.setValue('ip_manual_override', true, {
|
||||
shouldDirty: true,
|
||||
shouldValidate: true,
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</ResourceField>
|
||||
|
||||
<ToggleField
|
||||
label="锁定节点 IP"
|
||||
description="开启后以管理端填写的 IP 为准,Agent 后续上报不会覆盖该字段。"
|
||||
checked={watchedIPManualOverride}
|
||||
onChange={(checked) =>
|
||||
form.setValue('ip_manual_override', checked, {
|
||||
shouldDirty: true,
|
||||
shouldValidate: true,
|
||||
})
|
||||
}
|
||||
/>
|
||||
|
||||
{watchedType === 'edge_node' && (
|
||||
<ToggleField
|
||||
label="启用自动更新"
|
||||
description="开启后 Agent 心跳返回会提示节点自动执行自更新。"
|
||||
checked={watchedAutoUpdate}
|
||||
onChange={(checked) =>
|
||||
form.setValue('auto_update_enabled', checked, {
|
||||
shouldDirty: true,
|
||||
shouldValidate: true,
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ToggleField
|
||||
label="手动指定地图地区"
|
||||
description="关闭时,节点会根据当前 IP 自动解析归属地;开启后使用你手动选择的地区与坐标。"
|
||||
checked={watchedGeoManualOverride}
|
||||
onChange={(checked) => {
|
||||
form.setValue('geo_manual_override', checked, {
|
||||
shouldDirty: true,
|
||||
shouldValidate: true,
|
||||
});
|
||||
if (!checked) {
|
||||
form.setValue('geo_region', '', { shouldDirty: true });
|
||||
form.setValue('geo_name', '', { shouldDirty: true });
|
||||
form.setValue('geo_latitude', '', { shouldDirty: true });
|
||||
form.setValue('geo_longitude', '', { shouldDirty: true });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
{watchedGeoManualOverride ? (
|
||||
<ResourceField
|
||||
label="地区选择"
|
||||
hint="选择后会自动填充位置名与地图坐标,你也可以继续微调。"
|
||||
>
|
||||
<ResourceSelect
|
||||
value={form.watch('geo_region')}
|
||||
onChange={(event) => {
|
||||
const regionName = event.target.value;
|
||||
form.setValue('geo_region', regionName, {
|
||||
shouldDirty: true,
|
||||
});
|
||||
const selectedRegion = regionOptions.find(
|
||||
(item) => item.label === regionName,
|
||||
);
|
||||
if (!selectedRegion) {
|
||||
return;
|
||||
}
|
||||
form.setValue('geo_name', selectedRegion.label, {
|
||||
shouldDirty: true,
|
||||
shouldValidate: true,
|
||||
});
|
||||
form.setValue('geo_latitude', String(selectedRegion.latitude), {
|
||||
shouldDirty: true,
|
||||
shouldValidate: true,
|
||||
});
|
||||
form.setValue(
|
||||
'geo_longitude',
|
||||
String(selectedRegion.longitude),
|
||||
{
|
||||
shouldDirty: true,
|
||||
shouldValidate: true,
|
||||
},
|
||||
);
|
||||
}}
|
||||
>
|
||||
<option value="">请选择地区</option>
|
||||
{regionOptions.map((option) => (
|
||||
<option key={option.label} value={option.label}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</ResourceSelect>
|
||||
</ResourceField>
|
||||
) : null}
|
||||
|
||||
<ResourceField
|
||||
label="地图位置名"
|
||||
hint={
|
||||
watchedGeoManualOverride
|
||||
? '可在自动填充后继续修改,例如使用更贴近业务的展示名称。'
|
||||
: '自动解析模式下,该字段会由系统根据节点 IP 回填。'
|
||||
}
|
||||
error={form.formState.errors.geo_name?.message}
|
||||
>
|
||||
<ResourceInput
|
||||
placeholder="Shanghai"
|
||||
disabled={!watchedGeoManualOverride}
|
||||
{...form.register('geo_name')}
|
||||
/>
|
||||
</ResourceField>
|
||||
|
||||
<div className="grid gap-5 md:grid-cols-2">
|
||||
<ResourceField
|
||||
label="纬度"
|
||||
hint="范围 -90 到 90,例如上海约为 31.2304"
|
||||
error={form.formState.errors.geo_latitude?.message}
|
||||
>
|
||||
<ResourceInput
|
||||
placeholder="31.2304"
|
||||
disabled={!watchedGeoManualOverride}
|
||||
{...form.register('geo_latitude')}
|
||||
/>
|
||||
</ResourceField>
|
||||
|
||||
<ResourceField
|
||||
label="经度"
|
||||
hint="范围 -180 到 180,例如上海约为 121.4737"
|
||||
error={form.formState.errors.geo_longitude?.message}
|
||||
>
|
||||
<ResourceInput
|
||||
placeholder="121.4737"
|
||||
disabled={!watchedGeoManualOverride}
|
||||
{...form.register('geo_longitude')}
|
||||
/>
|
||||
</ResourceField>
|
||||
</div>
|
||||
</form>
|
||||
</AppModal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import type {NodeObservability} from '@/features/nodes/types';
|
||||
|
||||
export type FeedbackState = {
|
||||
tone: 'info' | 'success' | 'danger';
|
||||
message: string;
|
||||
};
|
||||
|
||||
export type HealthEventFilter = 'all' | 'active' | 'resolved';
|
||||
|
||||
export type NodeDetailTab = 'dashboard' | 'info';
|
||||
|
||||
export function getErrorMessage(error: unknown) {
|
||||
return error instanceof Error ? error.message : '请求失败,请稍后重试。';
|
||||
}
|
||||
|
||||
export async function copyToClipboard(value: string) {
|
||||
await navigator.clipboard.writeText(value);
|
||||
}
|
||||
|
||||
export function formatUsageRatio(used?: number | null, total?: number | null) {
|
||||
if (!used || !total || total <= 0) {
|
||||
return null;
|
||||
}
|
||||
return Math.max(0, Math.min(100, (used / total) * 100));
|
||||
}
|
||||
|
||||
export function formatUptime(seconds?: number | null) {
|
||||
if (!seconds || seconds <= 0) {
|
||||
return '—';
|
||||
}
|
||||
|
||||
const days = Math.floor(seconds / 86400);
|
||||
const hours = Math.floor((seconds % 86400) / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
|
||||
if (days > 0) {
|
||||
return `${days} 天 ${hours} 小时`;
|
||||
}
|
||||
if (hours > 0) {
|
||||
return `${hours} 小时 ${minutes} 分钟`;
|
||||
}
|
||||
return `${minutes} 分钟`;
|
||||
}
|
||||
|
||||
export function formatTrendHour(value: string) {
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return '—';
|
||||
}
|
||||
return `${date.getHours().toString().padStart(2, '0')}:00`;
|
||||
}
|
||||
|
||||
export function parseTrafficMap(value?: string | null) {
|
||||
if (!value) {
|
||||
return {} as Record<string, number>;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(value) as Record<string, number>;
|
||||
return Object.entries(parsed).reduce<Record<string, number>>(
|
||||
(result, [key, count]) => {
|
||||
if (typeof count === 'number' && Number.isFinite(count)) {
|
||||
result[key] = count;
|
||||
}
|
||||
return result;
|
||||
},
|
||||
{},
|
||||
);
|
||||
} catch {
|
||||
return {} as Record<string, number>;
|
||||
}
|
||||
}
|
||||
|
||||
export function aggregateTrafficBreakdown(
|
||||
reports: NodeObservability['traffic_reports'],
|
||||
field: 'status_codes_json' | 'top_domains_json',
|
||||
) {
|
||||
const summary = new Map<string, number>();
|
||||
for (const report of reports) {
|
||||
const parsed = parseTrafficMap(report[field]);
|
||||
for (const [key, value] of Object.entries(parsed)) {
|
||||
summary.set(key, (summary.get(key) ?? 0) + value);
|
||||
}
|
||||
}
|
||||
return Array.from(summary.entries())
|
||||
.sort((left, right) => {
|
||||
if (right[1] === left[1]) {
|
||||
return left[0].localeCompare(right[0]);
|
||||
}
|
||||
return right[1] - left[1];
|
||||
})
|
||||
.slice(0, 6)
|
||||
.map(([label, value]) => ({ label, value }));
|
||||
}
|
||||
|
||||
export function getHealthEventVariant(
|
||||
event: NodeObservability['health_events'][number],
|
||||
): 'success' | 'warning' | 'danger' | 'info' {
|
||||
if (event.status === 'resolved') {
|
||||
return 'success';
|
||||
}
|
||||
if (event.severity === 'critical') {
|
||||
return 'danger';
|
||||
}
|
||||
if (event.severity === 'warning') {
|
||||
return 'warning';
|
||||
}
|
||||
return 'info';
|
||||
}
|
||||
|
||||
export function getHealthEventLabel(
|
||||
event: NodeObservability['health_events'][number],
|
||||
) {
|
||||
return event.event_type.replaceAll('_', ' ');
|
||||
}
|
||||
|
||||
export function MetricBar({
|
||||
label,
|
||||
value,
|
||||
progress,
|
||||
hint,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
progress?: number | null;
|
||||
hint?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-2 rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
|
||||
{label}
|
||||
</p>
|
||||
{hint ? (
|
||||
<p className="mt-1 text-xs text-[var(--foreground-muted)]">
|
||||
{hint}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="text-sm font-semibold text-[var(--foreground-primary)]">
|
||||
{value}
|
||||
</p>
|
||||
</div>
|
||||
<div className="h-2 overflow-hidden rounded-full bg-[var(--surface-muted)]">
|
||||
<div
|
||||
className="h-full rounded-full bg-[var(--status-info-foreground)] transition-[width]"
|
||||
style={{ width: `${progress ?? 0}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SummaryStat({
|
||||
label,
|
||||
value,
|
||||
hint,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
hint: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
|
||||
<p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
|
||||
{label}
|
||||
</p>
|
||||
<p className="mt-3 text-2xl font-semibold text-[var(--foreground-primary)]">
|
||||
{value}
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-[var(--foreground-secondary)]">{hint}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,475 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import {useSearchParams} 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 {getConfigVersions} from '@/features/config-versions/api/config-versions';
|
||||
import {createNode, deleteNode, getNodes, updateNode,} from '@/features/nodes/api/nodes';
|
||||
import {NodeEditorModal} from '@/features/nodes/components/node-editor-modal';
|
||||
import type {NodeItem, NodeMutationPayload} from '@/features/nodes/types';
|
||||
import {DangerButton, SecondaryButton,} from '@/features/shared/components/resource-primitives';
|
||||
import {formatDateTime, formatRelativeTime} from '@/lib/utils/date';
|
||||
import {
|
||||
getApplyLabel,
|
||||
getApplyVariant,
|
||||
getNodeStatusLabel,
|
||||
getNodeStatusVariant,
|
||||
getOpenrestyStatusLabel,
|
||||
getOpenrestyStatusVariant,
|
||||
getRelayStatusLabel,
|
||||
getRelayStatusVariant,
|
||||
isMeaningfulTime,
|
||||
isNodeAbnormal,
|
||||
isWSConnectedLastSeen,
|
||||
} from '@/features/nodes/utils';
|
||||
|
||||
const nodesQueryKey = ['nodes'];
|
||||
const supportedNodeFilters = [
|
||||
'all',
|
||||
'edge',
|
||||
'relay',
|
||||
'tunnel',
|
||||
'abnormal',
|
||||
] as const;
|
||||
|
||||
type NodeFilter = (typeof supportedNodeFilters)[number];
|
||||
|
||||
type FeedbackState = {
|
||||
tone: 'info' | 'success' | 'danger';
|
||||
message: string;
|
||||
};
|
||||
|
||||
function getErrorMessage(error: unknown) {
|
||||
return error instanceof Error ? error.message : '请求失败,请稍后重试。';
|
||||
}
|
||||
|
||||
export function NodesPage() {
|
||||
const searchParams = useSearchParams();
|
||||
const queryClient = useQueryClient();
|
||||
const [feedback, setFeedback] = useState<FeedbackState | null>(null);
|
||||
const [editingNode, setEditingNode] = useState<NodeItem | null>(null);
|
||||
const [isEditorOpen, setIsEditorOpen] = useState(false);
|
||||
|
||||
const nodesQuery = useQuery({
|
||||
queryKey: nodesQueryKey,
|
||||
queryFn: getNodes,
|
||||
refetchInterval: 5000,
|
||||
});
|
||||
|
||||
const configVersionsQuery = useQuery({
|
||||
queryKey: ['config-versions'],
|
||||
queryFn: getConfigVersions,
|
||||
refetchInterval: 5000,
|
||||
});
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: async (payload: NodeMutationPayload) => {
|
||||
return editingNode
|
||||
? updateNode(editingNode.id, payload)
|
||||
: createNode(payload);
|
||||
},
|
||||
onSuccess: async () => {
|
||||
setFeedback({
|
||||
tone: 'success',
|
||||
message: editingNode ? '节点已更新。' : '节点已创建。',
|
||||
});
|
||||
setEditingNode(null);
|
||||
setIsEditorOpen(false);
|
||||
await queryClient.invalidateQueries({ queryKey: nodesQueryKey });
|
||||
},
|
||||
onError: (error) => {
|
||||
setFeedback({ tone: 'danger', message: getErrorMessage(error) });
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: deleteNode,
|
||||
onSuccess: async () => {
|
||||
setFeedback({ tone: 'success', message: '节点已删除。' });
|
||||
setEditingNode(null);
|
||||
await queryClient.invalidateQueries({ queryKey: nodesQueryKey });
|
||||
},
|
||||
onError: (error) => {
|
||||
setFeedback({ tone: 'danger', message: getErrorMessage(error) });
|
||||
},
|
||||
});
|
||||
|
||||
const nodes = useMemo(() => nodesQuery.data ?? [], [nodesQuery.data]);
|
||||
const activeVersion = useMemo(
|
||||
() =>
|
||||
(configVersionsQuery.data ?? []).find((item) => item.is_active)
|
||||
?.version ?? '',
|
||||
[configVersionsQuery.data],
|
||||
);
|
||||
const nodeFilter = useMemo<NodeFilter>(() => {
|
||||
const current = searchParams.get('filter')?.trim().toLowerCase() ?? '';
|
||||
if (supportedNodeFilters.includes(current as NodeFilter)) {
|
||||
return current as NodeFilter;
|
||||
}
|
||||
|
||||
const legacyRisk = searchParams.get('risk')?.trim().toLowerCase() ?? '';
|
||||
if (
|
||||
legacyRisk === 'offline' ||
|
||||
legacyRisk === 'unhealthy' ||
|
||||
legacyRisk === 'lagging'
|
||||
) {
|
||||
return 'abnormal';
|
||||
}
|
||||
|
||||
return 'all';
|
||||
}, [searchParams]);
|
||||
const filteredNodes = useMemo(() => {
|
||||
switch (nodeFilter) {
|
||||
case 'all':
|
||||
return nodes;
|
||||
case 'relay':
|
||||
return nodes.filter((node) => node.node_type === 'tunnel_relay');
|
||||
case 'tunnel':
|
||||
return nodes.filter((node) => node.node_type === 'tunnel_client');
|
||||
case 'abnormal':
|
||||
return nodes.filter((node) => isNodeAbnormal(node, activeVersion));
|
||||
case 'edge':
|
||||
default:
|
||||
return nodes.filter((node) => node.node_type === 'edge_node');
|
||||
}
|
||||
}, [activeVersion, nodeFilter, nodes]);
|
||||
|
||||
const filterDescription = useMemo(() => {
|
||||
switch (nodeFilter) {
|
||||
case 'all':
|
||||
return '当前展示全部节点。';
|
||||
case 'relay':
|
||||
return '当前仅展示 Relay 节点。';
|
||||
case 'tunnel':
|
||||
return '当前仅展示 Tunnel 节点。';
|
||||
case 'abnormal':
|
||||
return activeVersion
|
||||
? `当前仅展示异常节点(离线、运行异常或未追平激活版本 ${activeVersion})。`
|
||||
: '当前仅展示异常节点(离线或运行异常)。';
|
||||
case 'edge':
|
||||
default:
|
||||
return '当前仅展示 Edge 节点。';
|
||||
}
|
||||
}, [activeVersion, nodeFilter]);
|
||||
|
||||
const handleReset = () => {
|
||||
setFeedback(null);
|
||||
setEditingNode(null);
|
||||
setIsEditorOpen(false);
|
||||
};
|
||||
|
||||
const handleCreate = () => {
|
||||
setFeedback(null);
|
||||
setEditingNode(null);
|
||||
setIsEditorOpen(true);
|
||||
};
|
||||
|
||||
const handleEdit = (node: NodeItem) => {
|
||||
setFeedback(null);
|
||||
setEditingNode(node);
|
||||
setIsEditorOpen(true);
|
||||
};
|
||||
|
||||
const handleDelete = (nodeId: number, nodeName: string) => {
|
||||
if (
|
||||
!window.confirm(
|
||||
`确认删除节点“${nodeName}”吗?删除后该节点需要重新创建并重新接入。`,
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
setFeedback(null);
|
||||
deleteMutation.mutate(nodeId);
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="节点管理"
|
||||
description="显示节点相关信息。"
|
||||
action={
|
||||
<>
|
||||
<SecondaryButton type="button" onClick={handleCreate}>
|
||||
新增节点
|
||||
</SecondaryButton>
|
||||
<Link
|
||||
href="/apply-log"
|
||||
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>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
{feedback ? (
|
||||
<InlineMessage
|
||||
tone={feedback.tone}
|
||||
message={feedback.message}
|
||||
onClear={() => setFeedback(null)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<AppCard
|
||||
title="节点列表"
|
||||
description={filterDescription}
|
||||
action={
|
||||
<SecondaryButton
|
||||
type="button"
|
||||
onClick={() =>
|
||||
void queryClient.invalidateQueries({ queryKey: nodesQueryKey })
|
||||
}
|
||||
>
|
||||
立即刷新
|
||||
</SecondaryButton>
|
||||
}
|
||||
>
|
||||
{nodesQuery.isLoading ? (
|
||||
<LoadingState />
|
||||
) : nodesQuery.isError ? (
|
||||
<ErrorState
|
||||
title="节点列表加载失败"
|
||||
description={getErrorMessage(nodesQuery.error)}
|
||||
/>
|
||||
) : filteredNodes.length === 0 ? (
|
||||
<EmptyState
|
||||
title={nodes.length === 0 ? '暂无节点' : '当前筛选无结果'}
|
||||
description={
|
||||
nodes.length === 0
|
||||
? '请先创建一个节点,然后进入详情页查看专属部署命令。'
|
||||
: '可以返回总览继续排查,或切换到其他筛选查看完整列表。'
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Link
|
||||
href="/node"
|
||||
className={`inline-flex items-center rounded-full border px-3 py-1.5 text-xs transition ${
|
||||
nodeFilter === 'all'
|
||||
? 'border-[var(--border-strong)] bg-[var(--accent-soft)] text-[var(--foreground-primary)]'
|
||||
: 'border-[var(--border-default)] text-[var(--foreground-secondary)] hover:bg-[var(--control-background-hover)]'
|
||||
}`}
|
||||
>
|
||||
全部节点
|
||||
</Link>
|
||||
<Link
|
||||
href="/node?filter=edge"
|
||||
className={`inline-flex items-center rounded-full border px-3 py-1.5 text-xs transition ${
|
||||
nodeFilter === 'edge'
|
||||
? 'border-[var(--border-strong)] bg-[var(--accent-soft)] text-[var(--foreground-primary)]'
|
||||
: 'border-[var(--border-default)] text-[var(--foreground-secondary)] hover:bg-[var(--control-background-hover)]'
|
||||
}`}
|
||||
>
|
||||
Edge
|
||||
</Link>
|
||||
<Link
|
||||
href="/node?filter=relay"
|
||||
className={`inline-flex items-center rounded-full border px-3 py-1.5 text-xs transition ${
|
||||
nodeFilter === 'relay'
|
||||
? 'border-[var(--border-strong)] bg-[var(--accent-soft)] text-[var(--foreground-primary)]'
|
||||
: 'border-[var(--border-default)] text-[var(--foreground-secondary)] hover:bg-[var(--control-background-hover)]'
|
||||
}`}
|
||||
>
|
||||
Relay
|
||||
</Link>
|
||||
<Link
|
||||
href="/node?filter=tunnel"
|
||||
className={`inline-flex items-center rounded-full border px-3 py-1.5 text-xs transition ${
|
||||
nodeFilter === 'tunnel'
|
||||
? 'border-[var(--border-strong)] bg-[var(--accent-soft)] text-[var(--foreground-primary)]'
|
||||
: 'border-[var(--border-default)] text-[var(--foreground-secondary)] hover:bg-[var(--control-background-hover)]'
|
||||
}`}
|
||||
>
|
||||
Tunnel
|
||||
</Link>
|
||||
<Link
|
||||
href="/node?filter=abnormal"
|
||||
className={`inline-flex items-center rounded-full border px-3 py-1.5 text-xs transition ${
|
||||
nodeFilter === 'abnormal'
|
||||
? 'border-[var(--border-strong)] bg-[var(--accent-soft)] text-[var(--foreground-primary)]'
|
||||
: 'border-[var(--border-default)] text-[var(--foreground-secondary)] hover:bg-[var(--control-background-hover)]'
|
||||
}`}
|
||||
>
|
||||
异常
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-[var(--border-default)] text-left text-sm">
|
||||
<thead>
|
||||
<tr className="text-[var(--foreground-secondary)]">
|
||||
<th className="px-3 py-3 font-medium">节点</th>
|
||||
<th className="px-3 py-3 font-medium">状态</th>
|
||||
<th className="px-3 py-3 font-medium">Version</th>
|
||||
<th className="px-3 py-3 font-medium">运行健康</th>
|
||||
<th className="px-3 py-3 font-medium">当前版本</th>
|
||||
<th className="px-3 py-3 font-medium">最近应用</th>
|
||||
<th className="px-3 py-3 font-medium">最近心跳</th>
|
||||
<th className="px-3 py-3 font-medium">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-[var(--border-default)]">
|
||||
{filteredNodes.map((node) => (
|
||||
<tr key={node.id} className="align-top">
|
||||
<td className="px-3 py-4">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="font-medium text-[var(--foreground-primary)]">
|
||||
{node.name}
|
||||
</p>
|
||||
{node.node_type === 'tunnel_relay' ? (
|
||||
<span className="rounded-full border border-blue-500/20 bg-blue-50 px-2 py-0.5 text-xs text-blue-700 dark:bg-blue-900/30 dark:text-blue-300">
|
||||
Relay
|
||||
</span>
|
||||
) : node.node_type === 'tunnel_client' ? (
|
||||
<span className="rounded-full border border-purple-500/20 bg-purple-50 px-2 py-0.5 text-xs text-purple-700 dark:bg-purple-900/30 dark:text-purple-300">
|
||||
Client
|
||||
</span>
|
||||
) : (
|
||||
<span className="rounded-full border border-gray-500/20 bg-gray-50 px-2 py-0.5 text-xs text-gray-700 dark:bg-gray-800/50 dark:text-gray-300">
|
||||
Edge
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-[var(--foreground-secondary)]">
|
||||
IP:{node.ip || 'null'}
|
||||
{node.ip_manual_override ? '(已锁定)' : ''}
|
||||
</p>
|
||||
<p className="text-xs text-[var(--foreground-secondary)]">
|
||||
位置:{node.geo_name || '未配置地图点位'}
|
||||
</p>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-4">
|
||||
<StatusBadge
|
||||
label={getNodeStatusLabel(node.status)}
|
||||
variant={getNodeStatusVariant(node.status)}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-4 text-sm text-[var(--foreground-secondary)]">
|
||||
{node.version || 'unknown'}
|
||||
</td>
|
||||
<td className="px-3 py-4">
|
||||
<div className="space-y-2">
|
||||
{node.node_type === 'tunnel_relay' ? (
|
||||
<StatusBadge
|
||||
label={getRelayStatusLabel(node.relay_status)}
|
||||
variant={getRelayStatusVariant(
|
||||
node.relay_status,
|
||||
)}
|
||||
/>
|
||||
) : node.node_type === 'tunnel_client' ? (
|
||||
<StatusBadge
|
||||
label={
|
||||
node.status === 'online' ? '运行中' : '未知'
|
||||
}
|
||||
variant={
|
||||
node.status === 'online'
|
||||
? 'success'
|
||||
: 'warning'
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<StatusBadge
|
||||
label={getOpenrestyStatusLabel(
|
||||
node.openresty_status,
|
||||
)}
|
||||
variant={getOpenrestyStatusVariant(
|
||||
node.openresty_status,
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-4 text-[var(--foreground-secondary)]">
|
||||
{node.current_version ||
|
||||
(node.node_type === 'tunnel_relay'
|
||||
? '实时配置'
|
||||
: '未应用')}
|
||||
</td>
|
||||
<td className="px-3 py-4">
|
||||
<div className="space-y-2">
|
||||
{node.node_type === 'tunnel_relay' ? (
|
||||
<span className="text-sm text-[var(--foreground-secondary)]">
|
||||
—
|
||||
</span>
|
||||
) : (
|
||||
<StatusBadge
|
||||
label={getApplyLabel(node.latest_apply_result)}
|
||||
variant={getApplyVariant(
|
||||
node.latest_apply_result,
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-4 text-[var(--foreground-secondary)]">
|
||||
{isWSConnectedLastSeen(node.last_seen_at)
|
||||
? 'WS 已连接'
|
||||
: isMeaningfulTime(node.last_seen_at)
|
||||
? `${formatRelativeTime(
|
||||
node.last_seen_at,
|
||||
)} · ${formatDateTime(node.last_seen_at)}`
|
||||
: '暂无'}
|
||||
</td>
|
||||
<td className="px-3 py-4">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Link
|
||||
href={`/node/detail?id=${node.id}`}
|
||||
className="inline-flex items-center justify-center rounded-2xl border border-[var(--border-default)] bg-[var(--control-background)] px-3 py-2 text-xs font-medium text-[var(--foreground-primary)] transition hover:bg-[var(--control-background-hover)]"
|
||||
>
|
||||
详情
|
||||
</Link>
|
||||
<SecondaryButton
|
||||
type="button"
|
||||
onClick={() => handleEdit(node)}
|
||||
className="px-3 py-2 text-xs"
|
||||
>
|
||||
编辑
|
||||
</SecondaryButton>
|
||||
<DangerButton
|
||||
type="button"
|
||||
onClick={() => handleDelete(node.id, node.name)}
|
||||
disabled={deleteMutation.isPending}
|
||||
className="px-3 py-2 text-xs"
|
||||
>
|
||||
删除
|
||||
</DangerButton>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</AppCard>
|
||||
</div>
|
||||
<NodeEditorModal
|
||||
isOpen={isEditorOpen}
|
||||
node={editingNode}
|
||||
isSubmitting={saveMutation.isPending}
|
||||
title={editingNode ? '编辑节点' : '新增节点'}
|
||||
onClose={handleReset}
|
||||
description="预创建节点后可在详情页查看专属 Token、部署命令与更新控制。"
|
||||
submitLabel={editingNode ? '保存修改' : '新增节点'}
|
||||
onSubmit={(payload) => {
|
||||
setFeedback(null);
|
||||
saveMutation.mutate(payload);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,246 @@
|
||||
import type { ReleaseChannel } from '@/features/update/types';
|
||||
|
||||
export interface NodeItem {
|
||||
id: number;
|
||||
node_id: string;
|
||||
node_type: 'edge_node' | 'tunnel_relay' | 'tunnel_client';
|
||||
name: string;
|
||||
ip: string;
|
||||
ip_manual_override: boolean;
|
||||
relay_bind_port: number;
|
||||
relay_vhost_http_port: number;
|
||||
relay_client_access_addr: string;
|
||||
relay_agent_access_addr: string;
|
||||
relay_client_proxy_url: string;
|
||||
relay_auth_token: string;
|
||||
relay_status: string;
|
||||
relay_web_server_enabled: boolean;
|
||||
relay_frps_connections: number;
|
||||
relay_frps_proxy_count: number;
|
||||
geo_name: string;
|
||||
geo_latitude?: number | null;
|
||||
geo_longitude?: number | null;
|
||||
geo_manual_override: boolean;
|
||||
access_token: string;
|
||||
auto_update_enabled: boolean;
|
||||
update_requested: boolean;
|
||||
update_channel: ReleaseChannel;
|
||||
update_tag: string;
|
||||
restart_openresty_requested: boolean;
|
||||
version: string;
|
||||
ext_version: string;
|
||||
openresty_status: 'healthy' | 'unhealthy' | 'unknown';
|
||||
openresty_message: string;
|
||||
status: 'online' | 'offline' | 'pending';
|
||||
current_version: string;
|
||||
last_seen_at: string;
|
||||
last_error: string;
|
||||
latest_apply_result: 'success' | 'warning' | 'failed' | '';
|
||||
latest_apply_message: string;
|
||||
latest_apply_checksum: string;
|
||||
latest_main_config_checksum: string;
|
||||
latest_route_config_checksum: string;
|
||||
latest_support_file_count: number;
|
||||
latest_apply_at?: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface NodeBootstrapToken {
|
||||
discovery_token: string;
|
||||
}
|
||||
|
||||
export interface NodeMutationPayload {
|
||||
node_type: 'edge_node' | 'tunnel_relay' | 'tunnel_client';
|
||||
name: string;
|
||||
ip: string;
|
||||
ip_manual_override: boolean;
|
||||
relay_bind_port?: number;
|
||||
relay_vhost_http_port?: number;
|
||||
relay_client_access_addr?: string;
|
||||
relay_agent_access_addr?: string;
|
||||
relay_client_proxy_url?: string;
|
||||
relay_web_server_enabled?: boolean;
|
||||
auto_update_enabled: boolean;
|
||||
geo_name: string;
|
||||
geo_latitude?: number | null;
|
||||
geo_longitude?: number | null;
|
||||
geo_manual_override: boolean;
|
||||
}
|
||||
|
||||
export interface NodeAgentReleaseInfo {
|
||||
tag_name: string;
|
||||
body: string;
|
||||
html_url: string;
|
||||
published_at: string;
|
||||
current_version: string;
|
||||
has_update: boolean;
|
||||
channel: ReleaseChannel;
|
||||
prerelease: boolean;
|
||||
update_requested: boolean;
|
||||
requested_channel: ReleaseChannel;
|
||||
requested_tag: string;
|
||||
}
|
||||
|
||||
export interface NodeAgentUpdatePayload {
|
||||
channel?: ReleaseChannel;
|
||||
tag_name?: string;
|
||||
}
|
||||
|
||||
export interface NodeSystemProfile {
|
||||
hostname: string;
|
||||
os_name: string;
|
||||
os_version: string;
|
||||
kernel_version: string;
|
||||
architecture: string;
|
||||
cpu_model: string;
|
||||
cpu_cores: number;
|
||||
total_memory_bytes: number;
|
||||
total_disk_bytes: number;
|
||||
uptime_seconds: number;
|
||||
reported_at: string;
|
||||
}
|
||||
|
||||
export interface NodeMetricSnapshot {
|
||||
captured_at: string;
|
||||
cpu_usage_percent: number;
|
||||
memory_used_bytes: number;
|
||||
memory_total_bytes: number;
|
||||
storage_used_bytes: number;
|
||||
storage_total_bytes: number;
|
||||
disk_read_bytes: number;
|
||||
disk_write_bytes: number;
|
||||
network_rx_bytes: number;
|
||||
network_tx_bytes: number;
|
||||
openresty_rx_bytes: number;
|
||||
openresty_tx_bytes: number;
|
||||
openresty_connections: number;
|
||||
}
|
||||
|
||||
export interface NodeTrafficReport {
|
||||
window_started_at: string;
|
||||
window_ended_at: string;
|
||||
request_count: number;
|
||||
error_count: number;
|
||||
unique_visitor_count: number;
|
||||
status_codes_json: string;
|
||||
top_domains_json: string;
|
||||
source_countries_json: string;
|
||||
}
|
||||
|
||||
export interface NodeTrafficTrendPoint {
|
||||
bucket_started_at: string;
|
||||
request_count: number;
|
||||
error_count: number;
|
||||
unique_visitor_count: number;
|
||||
}
|
||||
|
||||
export interface NodeCapacityTrendPoint {
|
||||
bucket_started_at: string;
|
||||
average_cpu_usage_percent: number;
|
||||
average_memory_usage_percent: number;
|
||||
reported_nodes: number;
|
||||
}
|
||||
|
||||
export interface NodeNetworkTrendPoint {
|
||||
bucket_started_at: string;
|
||||
network_rx_bytes: number;
|
||||
network_tx_bytes: number;
|
||||
openresty_rx_bytes: number;
|
||||
openresty_tx_bytes: number;
|
||||
reported_nodes: number;
|
||||
}
|
||||
|
||||
export interface NodeDiskIOTrendPoint {
|
||||
bucket_started_at: string;
|
||||
disk_read_bytes: number;
|
||||
disk_write_bytes: number;
|
||||
reported_nodes: number;
|
||||
}
|
||||
|
||||
export interface NodeDistributionItem {
|
||||
key: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
export interface NodeTrafficDistributions {
|
||||
status_codes: NodeDistributionItem[];
|
||||
top_domains: NodeDistributionItem[];
|
||||
source_countries: NodeDistributionItem[];
|
||||
}
|
||||
|
||||
export interface NodeTrafficSummary {
|
||||
window_started_at: string;
|
||||
window_ended_at: string;
|
||||
request_count: number;
|
||||
unique_visitor_count: number;
|
||||
error_count: number;
|
||||
estimated_qps: number;
|
||||
error_rate_percent: number;
|
||||
}
|
||||
|
||||
export interface NodeHealthSummary {
|
||||
active_alerts: number;
|
||||
critical_alerts: number;
|
||||
warning_alerts: number;
|
||||
info_alerts: number;
|
||||
resolved_alerts: number;
|
||||
has_capacity_risk: boolean;
|
||||
has_traffic_risk: boolean;
|
||||
has_runtime_risk: boolean;
|
||||
}
|
||||
|
||||
export interface NodeObservabilityAnalytics {
|
||||
traffic: NodeTrafficSummary;
|
||||
distributions: NodeTrafficDistributions;
|
||||
health: NodeHealthSummary;
|
||||
}
|
||||
|
||||
export interface NodeObservabilityTrends {
|
||||
traffic_24h: NodeTrafficTrendPoint[];
|
||||
capacity_24h: NodeCapacityTrendPoint[];
|
||||
network_24h: NodeNetworkTrendPoint[];
|
||||
disk_io_24h: NodeDiskIOTrendPoint[];
|
||||
}
|
||||
|
||||
export interface NodeHealthEvent {
|
||||
event_type: string;
|
||||
severity: string;
|
||||
status: string;
|
||||
message: string;
|
||||
metadata_json?: string;
|
||||
first_triggered_at: string;
|
||||
last_triggered_at: string;
|
||||
reported_at: string;
|
||||
resolved_at?: string | null;
|
||||
}
|
||||
|
||||
export interface RelayProxyStat {
|
||||
name: string;
|
||||
type: string;
|
||||
status: 'online' | 'offline';
|
||||
client_version: string;
|
||||
last_start_time: string;
|
||||
last_close_time: string;
|
||||
client_addr: string;
|
||||
}
|
||||
|
||||
export interface RelayDashboardSnapshot {
|
||||
total_proxies: number;
|
||||
online_proxies: number;
|
||||
offline_proxies: number;
|
||||
proxies: RelayProxyStat[];
|
||||
total_connections: number;
|
||||
client_counts: number;
|
||||
}
|
||||
|
||||
export interface NodeObservability {
|
||||
node_id: string;
|
||||
profile: NodeSystemProfile | null;
|
||||
metric_snapshots: NodeMetricSnapshot[];
|
||||
traffic_reports: NodeTrafficReport[];
|
||||
health_events: NodeHealthEvent[];
|
||||
analytics: NodeObservabilityAnalytics;
|
||||
trends: NodeObservabilityTrends;
|
||||
relay_dashboard?: RelayDashboardSnapshot | null;
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
import type { NodeItem } from '@/features/nodes/types';
|
||||
|
||||
export const WS_CONNECTED_LAST_SEEN = '__OPENFLARE_WS_CONNECTED__';
|
||||
export const FLARED_WS_CONNECTED_LAST_SEEN =
|
||||
'__OPENFLARE_FLARED_WS_CONNECTED__';
|
||||
|
||||
export function isWSConnectedLastSeen(value: string | null | undefined) {
|
||||
return (
|
||||
value === WS_CONNECTED_LAST_SEEN || value === FLARED_WS_CONNECTED_LAST_SEEN
|
||||
);
|
||||
}
|
||||
|
||||
export function isMeaningfulTime(value: string | null | undefined) {
|
||||
return (
|
||||
Boolean(value) &&
|
||||
!isWSConnectedLastSeen(value) &&
|
||||
!String(value).startsWith('0001-01-01')
|
||||
);
|
||||
}
|
||||
|
||||
export function getNodeStatusVariant(status: NodeItem['status']) {
|
||||
if (status === 'online') {
|
||||
return 'success';
|
||||
}
|
||||
|
||||
if (status === 'pending') {
|
||||
return 'warning';
|
||||
}
|
||||
|
||||
return 'danger';
|
||||
}
|
||||
|
||||
export function getNodeStatusLabel(status: NodeItem['status']) {
|
||||
if (status === 'online') {
|
||||
return '在线';
|
||||
}
|
||||
|
||||
if (status === 'pending') {
|
||||
return '待接入';
|
||||
}
|
||||
|
||||
return '离线';
|
||||
}
|
||||
|
||||
export function getApplyVariant(result: NodeItem['latest_apply_result']) {
|
||||
if (result === 'success') {
|
||||
return 'success';
|
||||
}
|
||||
|
||||
if (result === 'warning') {
|
||||
return 'warning';
|
||||
}
|
||||
|
||||
if (result === 'failed') {
|
||||
return 'danger';
|
||||
}
|
||||
|
||||
return 'warning';
|
||||
}
|
||||
|
||||
export function getApplyLabel(result: NodeItem['latest_apply_result']) {
|
||||
if (result === 'success') {
|
||||
return '成功';
|
||||
}
|
||||
|
||||
if (result === 'warning') {
|
||||
return '警告';
|
||||
}
|
||||
|
||||
if (result === 'failed') {
|
||||
return '失败';
|
||||
}
|
||||
|
||||
return '暂无';
|
||||
}
|
||||
|
||||
export function getUpdateMode(node: NodeItem) {
|
||||
if (node.update_requested) {
|
||||
if (node.update_channel === 'preview') {
|
||||
return { label: '等待预览更新', variant: 'warning' as const };
|
||||
}
|
||||
|
||||
return { label: '等待更新', variant: 'warning' as const };
|
||||
}
|
||||
|
||||
if (node.auto_update_enabled) {
|
||||
return { label: '自动', variant: 'success' as const };
|
||||
}
|
||||
|
||||
return { label: '手动', variant: 'info' as const };
|
||||
}
|
||||
|
||||
export function getOpenrestyStatusVariant(
|
||||
status: NodeItem['openresty_status'],
|
||||
) {
|
||||
if (status === 'healthy') {
|
||||
return 'success';
|
||||
}
|
||||
|
||||
if (status === 'unhealthy') {
|
||||
return 'danger';
|
||||
}
|
||||
|
||||
return 'warning';
|
||||
}
|
||||
|
||||
export function getOpenrestyStatusLabel(status: NodeItem['openresty_status']) {
|
||||
if (status === 'healthy') {
|
||||
return '健康';
|
||||
}
|
||||
|
||||
if (status === 'unhealthy') {
|
||||
return '异常';
|
||||
}
|
||||
|
||||
return '未知';
|
||||
}
|
||||
|
||||
export function isNodeAbnormal(node: NodeItem, activeVersion?: string) {
|
||||
if (node.status === 'offline') {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (activeVersion && node.current_version !== activeVersion) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (node.node_type === 'tunnel_relay') {
|
||||
return node.relay_status === 'unhealthy';
|
||||
}
|
||||
|
||||
if (node.node_type === 'edge_node') {
|
||||
return node.openresty_status === 'unhealthy';
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function parseVersionParts(version: string) {
|
||||
const normalized = version.trim().replace(/^v/i, '');
|
||||
if (!normalized || normalized.toLowerCase() === 'unknown') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return normalized.split('.').map((segment) => {
|
||||
const matched = segment.trim().match(/^\d+/);
|
||||
return matched ? Number.parseInt(matched[0], 10) : 0;
|
||||
});
|
||||
}
|
||||
|
||||
function isOlderVersion(current: string, target: string) {
|
||||
const currentParts = parseVersionParts(current);
|
||||
const targetParts = parseVersionParts(target);
|
||||
if (!currentParts || !targetParts) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const maxLength = Math.max(currentParts.length, targetParts.length);
|
||||
for (let index = 0; index < maxLength; index += 1) {
|
||||
const currentPart = currentParts[index] ?? 0;
|
||||
const targetPart = targetParts[index] ?? 0;
|
||||
if (currentPart < targetPart) {
|
||||
return true;
|
||||
}
|
||||
if (currentPart > targetPart) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export function shouldShowManualUpdate(
|
||||
agentVersion: string,
|
||||
serverVersion: string,
|
||||
) {
|
||||
const normalizedServerVersion = serverVersion.trim();
|
||||
const normalizedAgentVersion = agentVersion.trim();
|
||||
|
||||
if (
|
||||
!normalizedServerVersion ||
|
||||
normalizedServerVersion.toLowerCase() === 'dev' ||
|
||||
!normalizedAgentVersion ||
|
||||
normalizedAgentVersion.toLowerCase() === 'unknown'
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return isOlderVersion(normalizedAgentVersion, normalizedServerVersion);
|
||||
}
|
||||
|
||||
export function getServerUrl(value: string) {
|
||||
return value.trim().replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
const installerScriptUrl =
|
||||
'https://raw.githubusercontent.com/Rain-kl/OpenFlare/main/scripts/install-agent.sh';
|
||||
|
||||
export function buildNodeInstallCommand(serverUrl: string, agentToken: string) {
|
||||
return [
|
||||
`curl -fsSL ${installerScriptUrl} | bash -s -- \\`,
|
||||
` --server-url ${serverUrl} \\`,
|
||||
` --agent-token ${agentToken}`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
export function buildNodeDockerInstallCommand(
|
||||
serverUrl: string,
|
||||
agentToken: string,
|
||||
) {
|
||||
const image = 'ghcr.io/rain-kl/openflare-agent:latest';
|
||||
|
||||
return [
|
||||
`docker pull ${image}`,
|
||||
`docker rm -f openflare-agent 2>/dev/null || true`,
|
||||
`docker run -d --name openflare-agent --restart unless-stopped \\`,
|
||||
` -p 80:80 -p 443:443/tcp -p 443:443/udp \\`,
|
||||
` -e OPENFLARE_SERVER_URL=${serverUrl} \\`,
|
||||
` -e OPENFLARE_AGENT_TOKEN=${agentToken} \\`,
|
||||
` ${image}`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
export function getRelayStatusVariant(status: string | null | undefined) {
|
||||
if (status === 'healthy') {
|
||||
return 'success';
|
||||
}
|
||||
if (status === 'unhealthy') {
|
||||
return 'danger';
|
||||
}
|
||||
return 'warning';
|
||||
}
|
||||
|
||||
export function getRelayStatusLabel(status: string | null | undefined) {
|
||||
if (status === 'healthy') {
|
||||
return '健康';
|
||||
}
|
||||
if (status === 'unhealthy') {
|
||||
return '异常';
|
||||
}
|
||||
return '未知';
|
||||
}
|
||||
|
||||
const relayInstallerScriptUrl =
|
||||
'https://raw.githubusercontent.com/Rain-kl/OpenFlare/main/scripts/install-relay.sh';
|
||||
|
||||
export function buildRelayInstallCommand(
|
||||
serverUrl: string,
|
||||
discoveryToken: string,
|
||||
) {
|
||||
return [
|
||||
`curl -fsSL ${relayInstallerScriptUrl} | bash -s -- \\`,
|
||||
` --server-url ${serverUrl} \\`,
|
||||
` --discovery-token ${discoveryToken}`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
export function buildRelayDockerInstallCommand(
|
||||
serverUrl: string,
|
||||
discoveryToken: string,
|
||||
) {
|
||||
const image = 'ghcr.io/rain-kl/openflare-relay:latest';
|
||||
|
||||
return [
|
||||
`docker pull ${image}`,
|
||||
`docker rm -f openflare-relay 2>/dev/null || true`,
|
||||
`docker run -d --name openflare-relay --net host --restart unless-stopped \\`,
|
||||
` -e OPENFLARE_SERVER_URL=${serverUrl} \\`,
|
||||
` -e OPENFLARE_DISCOVERY_TOKEN=${discoveryToken} \\`,
|
||||
` ${image}`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
export function buildTunnelInstallCommand(
|
||||
serverUrl: string,
|
||||
tunnelToken: string,
|
||||
) {
|
||||
const flaredInstallerScriptUrl =
|
||||
'https://raw.githubusercontent.com/Rain-kl/OpenFlare/main/scripts/install-flared.sh';
|
||||
return [
|
||||
`curl -fsSL ${flaredInstallerScriptUrl} | bash -s -- \\`,
|
||||
` --server-url ${serverUrl} \\`,
|
||||
` --tunnel-token ${tunnelToken}`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
export function buildTunnelDockerInstallCommand(
|
||||
serverUrl: string,
|
||||
tunnelToken: string,
|
||||
) {
|
||||
const image = 'ghcr.io/rain-kl/openflared:latest';
|
||||
|
||||
return [
|
||||
`docker pull ${image}`,
|
||||
`docker rm -f openflared 2>/dev/null || true`,
|
||||
`docker run -d --name openflared --restart unless-stopped \\`,
|
||||
` -e OPENFLARE_SERVER_URL=${serverUrl} \\`,
|
||||
` -e OPENFLARE_TUNNEL_TOKEN=${tunnelToken} \\`,
|
||||
` ${image}`,
|
||||
].join('\n');
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import {apiRequest} from '@/lib/api/client';
|
||||
|
||||
import type {OriginDetail, OriginItem, OriginMutationPayload,} from '@/features/origins/types';
|
||||
|
||||
export function getOrigins() {
|
||||
return apiRequest<OriginItem[]>('/origins/');
|
||||
}
|
||||
|
||||
export function getOrigin(id: number) {
|
||||
return apiRequest<OriginDetail>(`/origins/${id}`);
|
||||
}
|
||||
|
||||
export function createOrigin(payload: OriginMutationPayload) {
|
||||
return apiRequest<OriginItem>('/origins/', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export function updateOrigin(id: number, payload: OriginMutationPayload) {
|
||||
return apiRequest<OriginItem>(`/origins/${id}/update`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteOrigin(id: number) {
|
||||
return apiRequest<void>(`/origins/${id}/delete`, {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import {useRouter} from 'next/navigation';
|
||||
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query';
|
||||
import {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 {deleteOrigin, getOrigin} from '@/features/origins/api/origins';
|
||||
import {OriginEditorModal} from '@/features/origins/components/origin-editor-modal';
|
||||
import {DangerButton, SecondaryButton,} from '@/features/shared/components/resource-primitives';
|
||||
import {formatDateTime} from '@/lib/utils/date';
|
||||
|
||||
export function OriginDetailPage({ originId }: { originId: string }) {
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
const [feedback, setFeedback] = useState<{
|
||||
tone: 'success' | 'danger';
|
||||
message: string;
|
||||
} | null>(null);
|
||||
const [isEditorOpen, setIsEditorOpen] = useState(false);
|
||||
|
||||
const originQuery = useQuery({
|
||||
queryKey: ['origins', originId],
|
||||
queryFn: () => getOrigin(Number(originId)),
|
||||
enabled: originId !== '',
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: deleteOrigin,
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ['origins'] });
|
||||
router.push('/origin');
|
||||
},
|
||||
onError: (error) => {
|
||||
setFeedback({
|
||||
tone: 'danger',
|
||||
message:
|
||||
error instanceof Error ? error.message : '请求失败,请稍后重试。',
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
if (originQuery.isLoading) {
|
||||
return <LoadingState />;
|
||||
}
|
||||
|
||||
if (originQuery.isError) {
|
||||
return (
|
||||
<ErrorState
|
||||
title="源站详情加载失败"
|
||||
description={
|
||||
originQuery.error instanceof Error
|
||||
? originQuery.error.message
|
||||
: '请求失败,请稍后重试。'
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const origin = originQuery.data;
|
||||
if (!origin) {
|
||||
return (
|
||||
<EmptyState
|
||||
title="源站不存在"
|
||||
description="该源站可能已被删除,或当前 ID 无法匹配到源站记录。"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const handleDelete = () => {
|
||||
if (!window.confirm(`确认删除源站 ${origin.name} 吗?`)) {
|
||||
return;
|
||||
}
|
||||
deleteMutation.mutate(origin.id);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title={origin.name}
|
||||
description="源站详情"
|
||||
action={
|
||||
<>
|
||||
<Link
|
||||
href="/origin"
|
||||
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>
|
||||
<SecondaryButton
|
||||
type="button"
|
||||
onClick={() => setIsEditorOpen(true)}
|
||||
>
|
||||
编辑源站
|
||||
</SecondaryButton>
|
||||
<DangerButton
|
||||
type="button"
|
||||
onClick={handleDelete}
|
||||
disabled={deleteMutation.isPending}
|
||||
>
|
||||
删除源站
|
||||
</DangerButton>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
{feedback ? (
|
||||
<InlineMessage
|
||||
tone={feedback.tone}
|
||||
message={feedback.message}
|
||||
onClear={() => setFeedback(null)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-4 xl:grid-cols-4">
|
||||
<AppCard title="源站地址">
|
||||
<p className="text-sm text-[var(--foreground-primary)]">
|
||||
{origin.address}
|
||||
</p>
|
||||
</AppCard>
|
||||
<AppCard title="绑定规则">
|
||||
<div className="space-y-3">
|
||||
<StatusBadge
|
||||
label={`${origin.route_count} 条规则`}
|
||||
variant={origin.route_count > 0 ? 'success' : 'warning'}
|
||||
/>
|
||||
<p className="text-sm text-[var(--foreground-secondary)]">
|
||||
编辑地址后,绑定规则的主源站地址会一起更新。
|
||||
</p>
|
||||
</div>
|
||||
</AppCard>
|
||||
<AppCard title="创建时间">
|
||||
<p className="text-sm text-[var(--foreground-secondary)]">
|
||||
{formatDateTime(origin.created_at)}
|
||||
</p>
|
||||
</AppCard>
|
||||
<AppCard title="更新时间">
|
||||
<p className="text-sm text-[var(--foreground-secondary)]">
|
||||
{formatDateTime(origin.updated_at)}
|
||||
</p>
|
||||
</AppCard>
|
||||
</div>
|
||||
|
||||
<AppCard title="备注">
|
||||
<p className="text-sm text-[var(--foreground-secondary)]">
|
||||
{origin.remark || '暂无备注'}
|
||||
</p>
|
||||
</AppCard>
|
||||
|
||||
<AppCard
|
||||
title="关联规则"
|
||||
description="展示当前源站作为主源站绑定的规则。"
|
||||
>
|
||||
{origin.routes.length === 0 ? (
|
||||
<EmptyState
|
||||
title="暂无关联规则"
|
||||
description="当前源站还没有被任何规则引用。"
|
||||
/>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-[var(--border-default)] text-left text-sm">
|
||||
<thead>
|
||||
<tr className="text-[var(--foreground-secondary)]">
|
||||
<th className="px-3 py-3 font-medium">域名</th>
|
||||
<th className="px-3 py-3 font-medium">源站地址</th>
|
||||
<th className="px-3 py-3 font-medium">状态</th>
|
||||
<th className="px-3 py-3 font-medium">更新时间</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-[var(--border-default)]">
|
||||
{origin.routes.map((route) => (
|
||||
<tr key={route.id}>
|
||||
<td className="px-3 py-4 font-medium text-[var(--foreground-primary)]">
|
||||
{route.domain}
|
||||
</td>
|
||||
<td className="px-3 py-4 text-[var(--foreground-secondary)]">
|
||||
{route.origin_url}
|
||||
</td>
|
||||
<td className="px-3 py-4">
|
||||
<StatusBadge
|
||||
label={route.enabled ? '启用' : '停用'}
|
||||
variant={route.enabled ? 'success' : 'warning'}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-4 text-[var(--foreground-secondary)]">
|
||||
{formatDateTime(route.updated_at)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</AppCard>
|
||||
</div>
|
||||
|
||||
{isEditorOpen ? (
|
||||
<OriginEditorModal
|
||||
isOpen={isEditorOpen}
|
||||
origin={origin}
|
||||
onClose={() => setIsEditorOpen(false)}
|
||||
onSaved={() => {
|
||||
setFeedback({ tone: 'success', message: '源站已更新。' });
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: ['origins', origin.id],
|
||||
});
|
||||
void queryClient.invalidateQueries({ queryKey: ['origins'] });
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
'use client';
|
||||
|
||||
import {zodResolver} from '@hookform/resolvers/zod';
|
||||
import {useMutation, useQueryClient} from '@tanstack/react-query';
|
||||
import {useEffect} from 'react';
|
||||
import {useForm} from 'react-hook-form';
|
||||
import {z} from 'zod';
|
||||
|
||||
import {AppModal} from '@/components/ui/app-modal';
|
||||
import {createOrigin, updateOrigin} from '@/features/origins/api/origins';
|
||||
import type {OriginItem, OriginMutationPayload,} from '@/features/origins/types';
|
||||
import {
|
||||
PrimaryButton,
|
||||
ResourceField,
|
||||
ResourceInput,
|
||||
ResourceTextarea,
|
||||
SecondaryButton,
|
||||
} from '@/features/shared/components/resource-primitives';
|
||||
|
||||
const originSchema = z.object({
|
||||
name: z.string().max(255, '源站名不能超过 255 个字符'),
|
||||
address: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, '请输入源站地址')
|
||||
.refine(
|
||||
(value) => !/[/?#]/.test(value) && !value.includes('://'),
|
||||
'源站地址格式不合法',
|
||||
),
|
||||
remark: z.string().max(255, '备注不能超过 255 个字符'),
|
||||
});
|
||||
|
||||
type OriginFormValues = z.infer<typeof originSchema>;
|
||||
|
||||
function toPayload(values: OriginFormValues): OriginMutationPayload {
|
||||
return {
|
||||
name: values.name.trim(),
|
||||
address: values.address.trim(),
|
||||
remark: values.remark.trim(),
|
||||
};
|
||||
}
|
||||
|
||||
function toFormValues(origin?: OriginItem | null): OriginFormValues {
|
||||
if (!origin) {
|
||||
return {
|
||||
name: '',
|
||||
address: '',
|
||||
remark: '',
|
||||
};
|
||||
}
|
||||
return {
|
||||
name: origin.name,
|
||||
address: origin.address,
|
||||
remark: origin.remark || '',
|
||||
};
|
||||
}
|
||||
|
||||
export function OriginEditorModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
origin,
|
||||
onSaved,
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
origin?: OriginItem | null;
|
||||
onSaved?: (origin: OriginItem, mode: 'create' | 'update') => void;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const form = useForm<OriginFormValues>({
|
||||
resolver: zodResolver(originSchema),
|
||||
defaultValues: toFormValues(origin),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
form.reset(toFormValues(origin));
|
||||
}, [form, origin, isOpen]);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async (values: OriginFormValues) => {
|
||||
const payload = toPayload(values);
|
||||
return origin ? updateOrigin(origin.id, payload) : createOrigin(payload);
|
||||
},
|
||||
onSuccess: async (savedOrigin) => {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ['origins'] }),
|
||||
queryClient.invalidateQueries({ queryKey: ['proxy-routes'] }),
|
||||
]);
|
||||
onSaved?.(savedOrigin, origin ? 'update' : 'create');
|
||||
onClose();
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = form.handleSubmit((values) => {
|
||||
mutation.mutate(values);
|
||||
});
|
||||
|
||||
return (
|
||||
<AppModal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title={origin ? '编辑源站' : '新增源站'}
|
||||
description="源站会作为规则里的可复用地址目录,协议和端口仍由规则决定。"
|
||||
footer={
|
||||
<div className="flex flex-wrap justify-end gap-3">
|
||||
<SecondaryButton type="button" onClick={onClose}>
|
||||
取消
|
||||
</SecondaryButton>
|
||||
<PrimaryButton
|
||||
type="submit"
|
||||
form="origin-editor-form"
|
||||
disabled={mutation.isPending}
|
||||
>
|
||||
{mutation.isPending
|
||||
? '保存中...'
|
||||
: origin
|
||||
? '保存修改'
|
||||
: '新增源站'}
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<form
|
||||
id="origin-editor-form"
|
||||
className="space-y-5"
|
||||
onSubmit={handleSubmit}
|
||||
>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<ResourceField
|
||||
label="源站地址"
|
||||
hint="支持 IP、域名或主机名,例如 10.0.0.10、origin.internal。"
|
||||
error={form.formState.errors.address?.message}
|
||||
>
|
||||
<ResourceInput
|
||||
placeholder="origin.internal"
|
||||
{...form.register('address')}
|
||||
/>
|
||||
</ResourceField>
|
||||
<ResourceField
|
||||
label="源站名"
|
||||
hint="可选,留空时默认使用源站地址。"
|
||||
error={form.formState.errors.name?.message}
|
||||
>
|
||||
<ResourceInput placeholder="主站源站" {...form.register('name')} />
|
||||
</ResourceField>
|
||||
</div>
|
||||
|
||||
<ResourceField
|
||||
label="备注"
|
||||
error={form.formState.errors.remark?.message}
|
||||
>
|
||||
<ResourceTextarea
|
||||
placeholder="例如:主站内网入口"
|
||||
{...form.register('remark')}
|
||||
/>
|
||||
</ResourceField>
|
||||
|
||||
{mutation.isError ? (
|
||||
<p className="text-sm text-[var(--status-danger-foreground)]">
|
||||
{mutation.error instanceof Error
|
||||
? mutation.error.message
|
||||
: '请求失败,请稍后重试。'}
|
||||
</p>
|
||||
) : null}
|
||||
</form>
|
||||
</AppModal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
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 {deleteOrigin, getOrigins} from '@/features/origins/api/origins';
|
||||
import {OriginEditorModal} from '@/features/origins/components/origin-editor-modal';
|
||||
import type {OriginItem} from '@/features/origins/types';
|
||||
import {DangerButton, PrimaryButton, SecondaryButton,} from '@/features/shared/components/resource-primitives';
|
||||
import {formatDateTime} from '@/lib/utils/date';
|
||||
|
||||
type FeedbackState = {
|
||||
tone: 'success' | 'danger';
|
||||
message: string;
|
||||
};
|
||||
|
||||
export function OriginsPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const [feedback, setFeedback] = useState<FeedbackState | null>(null);
|
||||
const [editingOrigin, setEditingOrigin] = useState<OriginItem | null>(null);
|
||||
const [isEditorOpen, setIsEditorOpen] = useState(false);
|
||||
|
||||
const originsQuery = useQuery({
|
||||
queryKey: ['origins'],
|
||||
queryFn: getOrigins,
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: deleteOrigin,
|
||||
onSuccess: async () => {
|
||||
setFeedback({ tone: 'success', message: '源站已删除。' });
|
||||
await queryClient.invalidateQueries({ queryKey: ['origins'] });
|
||||
},
|
||||
onError: (error) => {
|
||||
setFeedback({
|
||||
tone: 'danger',
|
||||
message:
|
||||
error instanceof Error ? error.message : '请求失败,请稍后重试。',
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const origins = useMemo(() => originsQuery.data ?? [], [originsQuery.data]);
|
||||
|
||||
const handleDelete = (origin: OriginItem) => {
|
||||
if (!window.confirm(`确认删除源站 ${origin.name} 吗?`)) {
|
||||
return;
|
||||
}
|
||||
setFeedback(null);
|
||||
deleteMutation.mutate(origin.id);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="源站"
|
||||
description="集中维护规则复用的源站地址,减少批量改地址时的重复操作。"
|
||||
action={
|
||||
<PrimaryButton
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setEditingOrigin(null);
|
||||
setFeedback(null);
|
||||
setIsEditorOpen(true);
|
||||
}}
|
||||
>
|
||||
新增源站
|
||||
</PrimaryButton>
|
||||
}
|
||||
/>
|
||||
|
||||
{feedback ? (
|
||||
<InlineMessage
|
||||
tone={feedback.tone}
|
||||
message={feedback.message}
|
||||
onClear={() => setFeedback(null)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<AppCard
|
||||
title="源站列表"
|
||||
description="编辑源站地址后,所有引用该源站的规则会同步更新。"
|
||||
>
|
||||
{originsQuery.isLoading ? (
|
||||
<LoadingState />
|
||||
) : originsQuery.isError ? (
|
||||
<ErrorState
|
||||
title="源站列表加载失败"
|
||||
description={
|
||||
originsQuery.error instanceof Error
|
||||
? originsQuery.error.message
|
||||
: '请求失败,请稍后重试。'
|
||||
}
|
||||
/>
|
||||
) : origins.length === 0 ? (
|
||||
<EmptyState
|
||||
title="暂无源站"
|
||||
description="点击右上角“新增源站”开始录入。后续规则可直接复用这些地址。"
|
||||
/>
|
||||
) : (
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
{origins.map((origin) => (
|
||||
<article
|
||||
key={origin.id}
|
||||
className="rounded-[28px] border border-[var(--border-default)] bg-[var(--surface-elevated)] p-5"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h2 className="text-lg font-semibold text-[var(--foreground-primary)]">
|
||||
{origin.name}
|
||||
</h2>
|
||||
<StatusBadge
|
||||
label={`${origin.route_count} 条规则`}
|
||||
variant={
|
||||
origin.route_count > 0 ? 'success' : 'warning'
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-sm text-[var(--foreground-primary)]">
|
||||
{origin.address}
|
||||
</p>
|
||||
<p className="text-sm text-[var(--foreground-secondary)]">
|
||||
{origin.remark || '暂无备注'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-panel)] px-4 py-3 text-sm text-[var(--foreground-secondary)]">
|
||||
最后更新:{formatDateTime(origin.updated_at)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row gap-2">
|
||||
<Link
|
||||
href={`/origin/detail?id=${origin.id}`}
|
||||
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>
|
||||
<SecondaryButton
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setEditingOrigin(origin);
|
||||
setFeedback(null);
|
||||
setIsEditorOpen(true);
|
||||
}}
|
||||
>
|
||||
编辑
|
||||
</SecondaryButton>
|
||||
<DangerButton
|
||||
type="button"
|
||||
onClick={() => handleDelete(origin)}
|
||||
disabled={deleteMutation.isPending}
|
||||
>
|
||||
删除
|
||||
</DangerButton>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</AppCard>
|
||||
</div>
|
||||
|
||||
{isEditorOpen ? (
|
||||
<OriginEditorModal
|
||||
isOpen={isEditorOpen}
|
||||
origin={editingOrigin}
|
||||
onClose={() => setIsEditorOpen(false)}
|
||||
onSaved={(origin, mode) => {
|
||||
setFeedback({
|
||||
tone: 'success',
|
||||
message: mode === 'create' ? '源站已创建。' : '源站已更新。',
|
||||
});
|
||||
setEditingOrigin(origin);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
export interface OriginItem {
|
||||
id: number;
|
||||
name: string;
|
||||
address: string;
|
||||
remark: string;
|
||||
route_count: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface OriginRouteSummary {
|
||||
id: number;
|
||||
domain: string;
|
||||
origin_url: string;
|
||||
enabled: boolean;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface OriginDetail extends OriginItem {
|
||||
routes: OriginRouteSummary[];
|
||||
}
|
||||
|
||||
export interface OriginMutationPayload {
|
||||
name: string;
|
||||
address: string;
|
||||
remark: string;
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import {ApiError, apiRequest, getApiUrl} from '@/lib/api/client';
|
||||
import type {ApiEnvelope} from '@/types/api';
|
||||
|
||||
import type {PagesDeployment, PagesProject, PagesProjectPayload,} from '@/features/pages/types';
|
||||
|
||||
export function getPagesProjects() {
|
||||
return apiRequest<PagesProject[]>('/pages/');
|
||||
}
|
||||
|
||||
export function getPagesProject(id: number) {
|
||||
return apiRequest<PagesProject>(`/pages/${id}`);
|
||||
}
|
||||
|
||||
export function createPagesProject(payload: PagesProjectPayload) {
|
||||
return apiRequest<PagesProject>('/pages/', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export function updatePagesProject(id: number, payload: PagesProjectPayload) {
|
||||
return apiRequest<PagesProject>(`/pages/${id}/update`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export function deletePagesProject(id: number) {
|
||||
return apiRequest<void>(`/pages/${id}/delete`, {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
export function getPagesDeployments(projectId: number) {
|
||||
return apiRequest<PagesDeployment[]>(`/pages/${projectId}/deployments`);
|
||||
}
|
||||
|
||||
export function uploadPagesDeployment(
|
||||
projectId: number,
|
||||
file: File,
|
||||
rootDir = '',
|
||||
entryFile = 'index.html',
|
||||
onProgress?: (percent: number) => void,
|
||||
) {
|
||||
if (!onProgress) {
|
||||
const formData = new FormData();
|
||||
formData.append('package', file);
|
||||
formData.append('root_dir', rootDir);
|
||||
formData.append('entry_file', entryFile);
|
||||
return apiRequest<PagesDeployment>(
|
||||
`/pages/${projectId}/deployments/upload`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return new Promise<PagesDeployment>((resolve, reject) => {
|
||||
const formData = new FormData();
|
||||
formData.append('package', file);
|
||||
formData.append('root_dir', rootDir);
|
||||
formData.append('entry_file', entryFile);
|
||||
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open('POST', getApiUrl(`/pages/${projectId}/deployments/upload`));
|
||||
xhr.withCredentials = true;
|
||||
|
||||
xhr.upload.addEventListener('progress', (event) => {
|
||||
if (event.lengthComputable) {
|
||||
const percent = Math.round((event.loaded / event.total) * 100);
|
||||
onProgress(percent);
|
||||
}
|
||||
});
|
||||
|
||||
xhr.onload = () => {
|
||||
let payload: ApiEnvelope<PagesDeployment> | null = null;
|
||||
try {
|
||||
payload = JSON.parse(xhr.responseText) as ApiEnvelope<PagesDeployment>;
|
||||
} catch {
|
||||
payload = null;
|
||||
}
|
||||
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
if (payload && payload.success) {
|
||||
resolve(payload.data);
|
||||
} else {
|
||||
reject(new ApiError(payload?.message || '请求失败', xhr.status));
|
||||
}
|
||||
} else {
|
||||
reject(
|
||||
new ApiError(
|
||||
payload?.message || `请求失败(${xhr.status})`,
|
||||
xhr.status,
|
||||
),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
xhr.onerror = () => {
|
||||
reject(new ApiError('网络请求失败', 0));
|
||||
};
|
||||
|
||||
xhr.send(formData);
|
||||
});
|
||||
}
|
||||
|
||||
export function activatePagesDeployment(
|
||||
projectId: number,
|
||||
deploymentId: number,
|
||||
) {
|
||||
return apiRequest<PagesProject>(
|
||||
`/pages/${projectId}/deployments/${deploymentId}/activate`,
|
||||
{ method: 'POST' },
|
||||
);
|
||||
}
|
||||
|
||||
export function deletePagesDeployment(projectId: number, deploymentId: number) {
|
||||
return apiRequest<void>(
|
||||
`/pages/${projectId}/deployments/${deploymentId}/delete`,
|
||||
{ method: 'POST' },
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
'use client';
|
||||
|
||||
import {useMutation, useQueryClient} from '@tanstack/react-query';
|
||||
import {useRef, useState} from 'react';
|
||||
import {File as FileIcon, UploadCloud, X} from 'lucide-react';
|
||||
|
||||
import {AppModal} from '@/components/ui/app-modal';
|
||||
import {activatePagesDeployment, uploadPagesDeployment,} from '@/features/pages/api/pages';
|
||||
import {PrimaryButton, SecondaryButton,} from '@/features/shared/components/resource-primitives';
|
||||
import {cn} from '@/lib/utils/cn';
|
||||
import {deploymentsQueryKey, projectQueryKey, projectsQueryKey,} from '../utils';
|
||||
|
||||
interface PagesDeploymentUploadModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
projectId: number;
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number, decimals = 2) {
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
const k = 1024;
|
||||
const dm = decimals < 0 ? 0 : decimals;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
export function PagesDeploymentUploadModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
projectId,
|
||||
}: PagesDeploymentUploadModalProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [isDragActive, setIsDragActive] = useState(false);
|
||||
const [uploadProgress, setUploadProgress] = useState<number | null>(null);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const resetForm = () => {
|
||||
setFile(null);
|
||||
setIsDragActive(false);
|
||||
setUploadProgress(null);
|
||||
setErrorMessage(null);
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
resetForm();
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleDrag = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (e.type === 'dragenter' || e.type === 'dragover') {
|
||||
setIsDragActive(true);
|
||||
} else if (e.type === 'dragleave') {
|
||||
setIsDragActive(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragActive(false);
|
||||
if (e.dataTransfer.files && e.dataTransfer.files[0]) {
|
||||
const droppedFile = e.dataTransfer.files[0];
|
||||
if (droppedFile.name.toLowerCase().endsWith('.zip')) {
|
||||
setFile(droppedFile);
|
||||
setErrorMessage(null);
|
||||
} else {
|
||||
setErrorMessage('仅支持 zip 格式的文件');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const uploadMutation = useMutation({
|
||||
mutationFn: async ({ shouldActivate }: { shouldActivate: boolean }) => {
|
||||
if (!file) {
|
||||
throw new Error('请选择 zip 文件');
|
||||
}
|
||||
setUploadProgress(0);
|
||||
setErrorMessage(null);
|
||||
|
||||
const deployment = await uploadPagesDeployment(
|
||||
projectId,
|
||||
file,
|
||||
'',
|
||||
'index.html',
|
||||
(percent) => {
|
||||
setUploadProgress(percent);
|
||||
},
|
||||
);
|
||||
|
||||
if (shouldActivate) {
|
||||
await activatePagesDeployment(projectId, deployment.id);
|
||||
}
|
||||
return deployment;
|
||||
},
|
||||
onSuccess: () => {
|
||||
resetForm();
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: deploymentsQueryKey(projectId),
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: projectQueryKey(projectId),
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: projectsQueryKey,
|
||||
});
|
||||
onClose();
|
||||
},
|
||||
onError: (error) => {
|
||||
setUploadProgress(null);
|
||||
setErrorMessage(error instanceof Error ? error.message : '上传失败');
|
||||
},
|
||||
});
|
||||
|
||||
const handleUploadOnly = () => {
|
||||
uploadMutation.mutate({ shouldActivate: false });
|
||||
};
|
||||
|
||||
const handleUploadAndDeploy = () => {
|
||||
uploadMutation.mutate({ shouldActivate: true });
|
||||
};
|
||||
|
||||
return (
|
||||
<AppModal
|
||||
isOpen={isOpen}
|
||||
onClose={handleClose}
|
||||
title="上传部署包"
|
||||
description="上传已构建的 zip 静态资源包。项目将使用配置的根目录与入口文件路径。"
|
||||
footer={
|
||||
<div className="flex flex-wrap justify-end gap-3">
|
||||
<SecondaryButton
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
disabled={uploadMutation.isPending}
|
||||
>
|
||||
取消
|
||||
</SecondaryButton>
|
||||
<SecondaryButton
|
||||
type="button"
|
||||
disabled={!file || uploadMutation.isPending}
|
||||
onClick={handleUploadOnly}
|
||||
>
|
||||
{uploadMutation.isPending &&
|
||||
!uploadMutation.variables?.shouldActivate
|
||||
? `上传中 (${uploadProgress ?? 0}%)...`
|
||||
: '上传'}
|
||||
</SecondaryButton>
|
||||
<PrimaryButton
|
||||
type="button"
|
||||
disabled={!file || uploadMutation.isPending}
|
||||
onClick={handleUploadAndDeploy}
|
||||
>
|
||||
{uploadMutation.isPending &&
|
||||
uploadMutation.variables?.shouldActivate
|
||||
? `上传并部署中 (${uploadProgress ?? 0}%)...`
|
||||
: '上传并部署'}
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{/* Hidden File Input */}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".zip,application/zip"
|
||||
onChange={(event) => {
|
||||
const selectedFile = event.target.files?.[0] ?? null;
|
||||
if (selectedFile) {
|
||||
if (selectedFile.name.toLowerCase().endsWith('.zip')) {
|
||||
setFile(selectedFile);
|
||||
setErrorMessage(null);
|
||||
} else {
|
||||
setErrorMessage('仅支持 zip 格式的文件');
|
||||
}
|
||||
}
|
||||
}}
|
||||
className="hidden"
|
||||
/>
|
||||
|
||||
<div className="block space-y-2">
|
||||
<span className="flex items-center gap-2 text-sm font-medium text-[var(--foreground-primary)]">
|
||||
<span>部署包</span>
|
||||
</span>
|
||||
|
||||
<div
|
||||
onDragEnter={handleDrag}
|
||||
onDragOver={handleDrag}
|
||||
onDragLeave={handleDrag}
|
||||
onDrop={handleDrop}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className={cn(
|
||||
'group relative flex min-h-48 cursor-pointer flex-col items-center justify-center rounded-2xl border-2 border-dashed border-[var(--border-default)] bg-[var(--surface-elevated)] p-6 text-center transition-all duration-200 hover:border-[var(--brand-primary)] hover:bg-[var(--surface-hover)]',
|
||||
isDragActive &&
|
||||
'scale-[0.99] border-[var(--brand-primary)] bg-[var(--accent-soft)]/20 shadow-inner',
|
||||
file && 'border-solid border-[var(--brand-primary)]',
|
||||
)}
|
||||
>
|
||||
{file ? (
|
||||
<div
|
||||
className="flex flex-col items-center space-y-3"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-[var(--accent-soft)] text-[var(--brand-primary)] transition duration-200 group-hover:scale-110">
|
||||
<FileIcon className="h-6 w-6" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="max-w-[300px] truncate text-sm font-medium text-[var(--foreground-primary)]">
|
||||
{file.name}
|
||||
</p>
|
||||
<p className="text-xs text-[var(--foreground-secondary)]">
|
||||
大小: {formatBytes(file.size)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setFile(null);
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
}}
|
||||
className="inline-flex h-8 items-center gap-1.5 rounded-lg border border-[var(--border-default)] bg-[var(--control-background)] px-3 text-xs font-medium text-[var(--foreground-primary)] transition hover:border-[var(--status-danger-border)] hover:bg-[var(--status-danger-soft)] hover:text-[var(--status-danger-foreground)]"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
清除文件
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="pointer-events-none flex flex-col items-center space-y-3">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-[var(--surface-muted)] text-[var(--foreground-muted)] transition duration-200 group-hover:scale-110 group-hover:bg-[var(--accent-soft)] group-hover:text-[var(--brand-primary)]">
|
||||
<UploadCloud className="h-6 w-6" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium text-[var(--foreground-primary)]">
|
||||
点击或拖拽 zip 部署包到此处
|
||||
</p>
|
||||
<p className="text-xs text-[var(--foreground-secondary)]">
|
||||
仅支持 zip,Server 会校验文件数量、体积、路径逃逸和入口文件
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{errorMessage && (
|
||||
<span className="mt-1 block text-xs text-[var(--status-danger-foreground)]">
|
||||
{errorMessage}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{uploadProgress !== null && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-xs font-medium text-[var(--foreground-secondary)]">
|
||||
<span>上传进度</span>
|
||||
<span>{uploadProgress}%</span>
|
||||
</div>
|
||||
<div className="h-2 w-full overflow-hidden rounded-full bg-[var(--surface-muted)]">
|
||||
<div
|
||||
className="h-full rounded-full bg-[var(--brand-primary)] transition-all duration-300 ease-out"
|
||||
style={{ width: `${uploadProgress}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</AppModal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
'use client';
|
||||
|
||||
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query';
|
||||
import Link from 'next/link';
|
||||
import {useRouter} from 'next/navigation';
|
||||
import {useState} from 'react';
|
||||
|
||||
import {EmptyState} from '@/components/feedback/empty-state';
|
||||
import {ErrorState} from '@/components/feedback/error-state';
|
||||
import {LoadingState} from '@/components/feedback/loading-state';
|
||||
import {PageHeader} from '@/components/layout/page-header';
|
||||
import {AppCard} from '@/components/ui/app-card';
|
||||
import {
|
||||
activatePagesDeployment,
|
||||
deletePagesDeployment,
|
||||
deletePagesProject,
|
||||
getPagesDeployments,
|
||||
getPagesProject,
|
||||
} from '@/features/pages/api/pages';
|
||||
import {DangerButton, PrimaryButton, SecondaryButton,} from '@/features/shared/components/resource-primitives';
|
||||
import {PagesProjectEditorModal} from './pages-project-editor-modal';
|
||||
import {PagesDeploymentUploadModal} from './pages-deployment-upload-modal';
|
||||
import {formatBytes} from '@/lib/utils/metrics';
|
||||
import {deploymentsQueryKey, projectQueryKey, projectsQueryKey,} from '../utils';
|
||||
|
||||
export function PagesProjectDetailPage({ projectId }: { projectId: string }) {
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
const [isEditModalOpen, setEditModalOpen] = useState(false);
|
||||
const [isUploadModalOpen, setUploadModalOpen] = useState(false);
|
||||
|
||||
const parsedProjectId = Number(projectId);
|
||||
const projectQuery = useQuery({
|
||||
queryKey: projectQueryKey(projectId),
|
||||
queryFn: () => getPagesProject(parsedProjectId),
|
||||
enabled: projectId !== '' && Number.isFinite(parsedProjectId),
|
||||
});
|
||||
const deploymentsQuery = useQuery({
|
||||
queryKey: deploymentsQueryKey(parsedProjectId),
|
||||
queryFn: () => getPagesDeployments(parsedProjectId),
|
||||
enabled: projectId !== '' && Number.isFinite(parsedProjectId),
|
||||
});
|
||||
|
||||
const activateMutation = useMutation({
|
||||
mutationFn: (deploymentId: number) =>
|
||||
activatePagesDeployment(parsedProjectId, deploymentId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: deploymentsQueryKey(parsedProjectId),
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: projectQueryKey(projectId) });
|
||||
queryClient.invalidateQueries({ queryKey: projectsQueryKey });
|
||||
},
|
||||
});
|
||||
|
||||
const deleteDeploymentMutation = useMutation({
|
||||
mutationFn: (deploymentId: number) =>
|
||||
deletePagesDeployment(parsedProjectId, deploymentId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: deploymentsQueryKey(parsedProjectId),
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: projectQueryKey(projectId) });
|
||||
queryClient.invalidateQueries({ queryKey: projectsQueryKey });
|
||||
},
|
||||
});
|
||||
|
||||
const deleteProjectMutation = useMutation({
|
||||
mutationFn: () => deletePagesProject(parsedProjectId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: projectsQueryKey });
|
||||
router.push('/pages');
|
||||
},
|
||||
});
|
||||
|
||||
if (projectId === '' || !Number.isFinite(parsedProjectId)) {
|
||||
return (
|
||||
<EmptyState
|
||||
title="Pages 项目不存在"
|
||||
description="缺少有效的 Pages 项目 ID,请从项目列表重新进入。"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (projectQuery.isLoading) {
|
||||
return <LoadingState />;
|
||||
}
|
||||
|
||||
if (projectQuery.isError) {
|
||||
return (
|
||||
<ErrorState
|
||||
title="Pages 项目加载失败"
|
||||
description={projectQuery.error.message}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const project = projectQuery.data;
|
||||
if (!project) {
|
||||
return (
|
||||
<EmptyState
|
||||
title="Pages 项目不存在"
|
||||
description="该项目可能已被删除,或当前 ID 无法匹配到项目记录。"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const handleDeleteProject = () => {
|
||||
if (!window.confirm(`确认删除 Pages 项目 ${project.name} 吗?`)) {
|
||||
return;
|
||||
}
|
||||
deleteProjectMutation.mutate();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title={project.name}
|
||||
description={`${project.slug} · Pages 静态站点项目详情`}
|
||||
action={
|
||||
<>
|
||||
<Link
|
||||
href="/pages"
|
||||
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>
|
||||
<SecondaryButton
|
||||
type="button"
|
||||
onClick={() => setEditModalOpen(true)}
|
||||
>
|
||||
编辑项目
|
||||
</SecondaryButton>
|
||||
<PrimaryButton
|
||||
type="button"
|
||||
onClick={() => setUploadModalOpen(true)}
|
||||
>
|
||||
上传部署包
|
||||
</PrimaryButton>
|
||||
<DangerButton
|
||||
type="button"
|
||||
disabled={deleteProjectMutation.isPending}
|
||||
onClick={handleDeleteProject}
|
||||
>
|
||||
删除项目
|
||||
</DangerButton>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<AppCard
|
||||
title="部署历史"
|
||||
description="部署不可变;激活后发布配置,Agent 才会拉取并切换静态资源。"
|
||||
>
|
||||
{deploymentsQuery.isLoading ? (
|
||||
<p className="text-sm text-[var(--foreground-secondary)]">
|
||||
加载中...
|
||||
</p>
|
||||
) : deploymentsQuery.isError ? (
|
||||
<p className="text-sm text-[var(--status-danger-foreground)]">
|
||||
{deploymentsQuery.error.message}
|
||||
</p>
|
||||
) : (deploymentsQuery.data ?? []).length === 0 ? (
|
||||
<EmptyState
|
||||
title="暂无部署"
|
||||
description="上传 zip 部署包后,可以在这里激活某个部署版本。"
|
||||
>
|
||||
</EmptyState>
|
||||
) : (
|
||||
<div className="overflow-hidden rounded-2xl border border-[var(--border-default)]">
|
||||
{(deploymentsQuery.data ?? []).map((deployment) => (
|
||||
<div
|
||||
key={deployment.id}
|
||||
className="flex flex-col gap-3 border-b border-[var(--border-default)] p-4 last:border-b-0 md:flex-row md:items-center md:justify-between"
|
||||
>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[var(--foreground-primary)]">
|
||||
#{deployment.deployment_number}{' '}
|
||||
{deployment.status === 'active' ? '· 已激活' : ''}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-[var(--foreground-secondary)]">
|
||||
{deployment.checksum.slice(0, 16)} · {deployment.file_count}{' '}
|
||||
files · {formatBytes(deployment.total_size)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<SecondaryButton
|
||||
type="button"
|
||||
disabled={
|
||||
deployment.status === 'active' ||
|
||||
activateMutation.isPending
|
||||
}
|
||||
onClick={() => {
|
||||
if (
|
||||
window.confirm(
|
||||
`确认激活部署 #${deployment.deployment_number} 吗?`,
|
||||
)
|
||||
) {
|
||||
activateMutation.mutate(deployment.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
激活
|
||||
</SecondaryButton>
|
||||
<DangerButton
|
||||
type="button"
|
||||
disabled={
|
||||
deployment.status === 'active' ||
|
||||
deleteDeploymentMutation.isPending
|
||||
}
|
||||
onClick={() => {
|
||||
if (
|
||||
window.confirm(
|
||||
`确认删除部署 #${deployment.deployment_number} 吗?`,
|
||||
)
|
||||
) {
|
||||
deleteDeploymentMutation.mutate(deployment.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
删除
|
||||
</DangerButton>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</AppCard>
|
||||
|
||||
<PagesProjectEditorModal
|
||||
isOpen={isEditModalOpen}
|
||||
onClose={() => setEditModalOpen(false)}
|
||||
project={project}
|
||||
/>
|
||||
|
||||
<PagesDeploymentUploadModal
|
||||
isOpen={isUploadModalOpen}
|
||||
onClose={() => setUploadModalOpen(false)}
|
||||
projectId={parsedProjectId}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
'use client';
|
||||
|
||||
import {useQuery} from '@tanstack/react-query';
|
||||
import {useState} from 'react';
|
||||
|
||||
import {AppCard} from '@/components/ui/app-card';
|
||||
import {getPagesProjects} from '@/features/pages/api/pages';
|
||||
import {PrimaryButton,} from '@/features/shared/components/resource-primitives';
|
||||
import {PagesProjectEditorModal} from './pages-project-editor-modal';
|
||||
import {PagesProjectListItem} from './pages-project-list-item';
|
||||
import {projectsQueryKey} from '../utils';
|
||||
|
||||
export { PagesProjectDetailPage } from './pages-detail-page';
|
||||
|
||||
export function PagesPage() {
|
||||
const [isCreateModalOpen, setCreateModalOpen] = useState(false);
|
||||
|
||||
const projectsQuery = useQuery({
|
||||
queryKey: projectsQueryKey,
|
||||
queryFn: getPagesProjects,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium text-[var(--foreground-secondary)]">
|
||||
OpenFlare Pages
|
||||
</p>
|
||||
<h1 className="text-2xl font-semibold text-[var(--foreground-primary)]">
|
||||
边缘静态站点托管
|
||||
</h1>
|
||||
<p className="max-w-3xl text-sm leading-6 text-[var(--foreground-secondary)]">
|
||||
创建 Pages 项目,上传已构建 of zip 静态资源包,然后在规则中选择
|
||||
Pages 项目作为上游。发布后 Agent
|
||||
会拉取部署包并在边缘节点本地服务静态文件。
|
||||
</p>
|
||||
</div>
|
||||
<PrimaryButton
|
||||
type="button"
|
||||
className="w-full lg:w-auto"
|
||||
onClick={() => setCreateModalOpen(true)}
|
||||
>
|
||||
新建 Pages 项目
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{projectsQuery.isLoading ? (
|
||||
<AppCard>正在加载 Pages 项目...</AppCard>
|
||||
) : projectsQuery.error ? (
|
||||
<AppCard>
|
||||
<p className="text-sm text-[var(--status-danger-foreground)]">
|
||||
{projectsQuery.error.message}
|
||||
</p>
|
||||
</AppCard>
|
||||
) : (projectsQuery.data ?? []).length === 0 ? (
|
||||
<AppCard
|
||||
title="还没有 Pages 项目"
|
||||
description="先创建一个项目,再上传静态资源包。"
|
||||
/>
|
||||
) : (
|
||||
(projectsQuery.data ?? []).map((project) => (
|
||||
<PagesProjectListItem key={project.id} project={project} />
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
<PagesProjectEditorModal
|
||||
isOpen={isCreateModalOpen}
|
||||
onClose={() => setCreateModalOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
'use client';
|
||||
|
||||
import {zodResolver} from '@hookform/resolvers/zod';
|
||||
import {useMutation, useQueryClient} from '@tanstack/react-query';
|
||||
import {useEffect} from 'react';
|
||||
import {useForm} from 'react-hook-form';
|
||||
|
||||
import {AppModal} from '@/components/ui/app-modal';
|
||||
import {createPagesProject, updatePagesProject,} from '@/features/pages/api/pages';
|
||||
import type {PagesProject} from '@/features/pages/types';
|
||||
import {
|
||||
PrimaryButton,
|
||||
ResourceField,
|
||||
ResourceInput,
|
||||
SecondaryButton,
|
||||
ToggleField,
|
||||
} from '@/features/shared/components/resource-primitives';
|
||||
import {projectQueryKey, projectsQueryKey} from '../utils';
|
||||
import {type PagesProjectFormValues, pagesProjectSchema} from '../schemas';
|
||||
|
||||
interface PagesProjectEditorModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
project?: PagesProject | null;
|
||||
onSaved?: (project: PagesProject, mode: 'create' | 'update') => void;
|
||||
}
|
||||
|
||||
function toFormValues(project?: PagesProject | null): PagesProjectFormValues {
|
||||
if (!project) {
|
||||
return {
|
||||
name: '',
|
||||
slug: '',
|
||||
description: '',
|
||||
spa_fallback_enabled: false,
|
||||
spa_fallback_path: '/index.html',
|
||||
api_proxy_enabled: false,
|
||||
api_proxy_path: '',
|
||||
api_proxy_pass: '',
|
||||
api_proxy_rewrite: '',
|
||||
root_dir: '',
|
||||
entry_file: 'index.html',
|
||||
};
|
||||
}
|
||||
return {
|
||||
name: project.name,
|
||||
slug: project.slug,
|
||||
description: project.description || '',
|
||||
spa_fallback_enabled: project.spa_fallback_enabled,
|
||||
spa_fallback_path: project.spa_fallback_path,
|
||||
api_proxy_enabled: project.api_proxy_enabled || false,
|
||||
api_proxy_path: project.api_proxy_path || '',
|
||||
api_proxy_pass: project.api_proxy_pass || '',
|
||||
api_proxy_rewrite: project.api_proxy_rewrite || '',
|
||||
root_dir: project.root_dir || '',
|
||||
entry_file: project.entry_file || 'index.html',
|
||||
};
|
||||
}
|
||||
|
||||
export function PagesProjectEditorModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
project,
|
||||
onSaved,
|
||||
}: PagesProjectEditorModalProps) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const form = useForm<PagesProjectFormValues>({
|
||||
resolver: zodResolver(pagesProjectSchema),
|
||||
defaultValues: toFormValues(project),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
form.reset(toFormValues(project));
|
||||
}
|
||||
}, [form, project, isOpen]);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (values: PagesProjectFormValues) => {
|
||||
const payload = {
|
||||
name: values.name.trim(),
|
||||
slug: values.slug?.trim() || '',
|
||||
description: values.description?.trim() || '',
|
||||
enabled: project ? project.enabled : true,
|
||||
spa_fallback_enabled: values.spa_fallback_enabled,
|
||||
spa_fallback_path: values.spa_fallback_enabled
|
||||
? values.spa_fallback_path.trim()
|
||||
: project
|
||||
? project.spa_fallback_path
|
||||
: '/index.html',
|
||||
api_proxy_enabled: values.api_proxy_enabled,
|
||||
api_proxy_path: values.api_proxy_enabled
|
||||
? values.api_proxy_path.trim()
|
||||
: '',
|
||||
api_proxy_pass: values.api_proxy_enabled
|
||||
? values.api_proxy_pass.trim()
|
||||
: '',
|
||||
api_proxy_rewrite: values.api_proxy_enabled
|
||||
? values.api_proxy_rewrite.trim()
|
||||
: '',
|
||||
root_dir: values.root_dir?.trim() || '',
|
||||
entry_file: values.entry_file?.trim() || 'index.html',
|
||||
};
|
||||
return project
|
||||
? updatePagesProject(project.id, payload)
|
||||
: createPagesProject(payload);
|
||||
},
|
||||
onSuccess: (savedProject) => {
|
||||
onClose();
|
||||
if (project) {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: projectQueryKey(project.id),
|
||||
});
|
||||
}
|
||||
queryClient.invalidateQueries({ queryKey: projectsQueryKey });
|
||||
onSaved?.(savedProject, project ? 'update' : 'create');
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = form.handleSubmit((values) => {
|
||||
mutation.mutate(values);
|
||||
});
|
||||
|
||||
const spaFallbackEnabled = form.watch('spa_fallback_enabled');
|
||||
const apiProxyEnabled = form.watch('api_proxy_enabled');
|
||||
|
||||
return (
|
||||
<AppModal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title={project ? '编辑 Pages 项目' : '新建 Pages 项目'}
|
||||
description={
|
||||
project
|
||||
? '修改静态站点项目的基础配置与高级反代规则。'
|
||||
: '配置静态站点项目的基础信息与高级反代规则。创建后再上传已构建的 zip 部署包。'
|
||||
}
|
||||
footer={
|
||||
<div className="flex flex-wrap justify-end gap-3">
|
||||
<SecondaryButton type="button" onClick={onClose}>
|
||||
取消
|
||||
</SecondaryButton>
|
||||
<PrimaryButton
|
||||
type="submit"
|
||||
form="pages-project-editor-form"
|
||||
disabled={mutation.isPending || !form.formState.isValid}
|
||||
>
|
||||
{mutation.isPending
|
||||
? '保存中...'
|
||||
: project
|
||||
? '保存修改'
|
||||
: '创建项目'}
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<form
|
||||
id="pages-project-editor-form"
|
||||
className="grid gap-4 md:grid-cols-2"
|
||||
onSubmit={handleSubmit}
|
||||
>
|
||||
<ResourceField
|
||||
label="项目名称"
|
||||
error={form.formState.errors.name?.message}
|
||||
>
|
||||
<ResourceInput
|
||||
placeholder="Marketing Site"
|
||||
{...form.register('name')}
|
||||
required
|
||||
/>
|
||||
</ResourceField>
|
||||
<ResourceField
|
||||
label="项目标识"
|
||||
hint="留空时会按名称自动生成。"
|
||||
error={form.formState.errors.slug?.message}
|
||||
>
|
||||
<ResourceInput
|
||||
placeholder="marketing-site"
|
||||
{...form.register('slug')}
|
||||
/>
|
||||
</ResourceField>
|
||||
<ResourceField
|
||||
label="描述"
|
||||
className="md:col-span-2"
|
||||
error={form.formState.errors.description?.message}
|
||||
>
|
||||
<ResourceInput
|
||||
placeholder="这个项目托管的静态站点用途"
|
||||
{...form.register('description')}
|
||||
/>
|
||||
</ResourceField>
|
||||
<ResourceField
|
||||
label="根目录 (可选)"
|
||||
hint="静态资源的根文件夹路径 (例如 build 或 dist),若为空则默认为 zip 包根目录。"
|
||||
error={form.formState.errors.root_dir?.message}
|
||||
>
|
||||
<ResourceInput
|
||||
placeholder="例如: build"
|
||||
{...form.register('root_dir')}
|
||||
/>
|
||||
</ResourceField>
|
||||
<ResourceField
|
||||
label="入口文件"
|
||||
hint="基于根目录下的入口文件路径。"
|
||||
error={form.formState.errors.entry_file?.message}
|
||||
>
|
||||
<ResourceInput
|
||||
placeholder="index.html"
|
||||
{...form.register('entry_file')}
|
||||
required
|
||||
/>
|
||||
</ResourceField>
|
||||
<ToggleField
|
||||
label="启用 SPA fallback"
|
||||
description="开启后未命中的路径会回退到指定文件,适合 React/Vue history 路由。"
|
||||
checked={spaFallbackEnabled}
|
||||
onChange={(checked) =>
|
||||
form.setValue('spa_fallback_enabled', checked, {
|
||||
shouldValidate: true,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<ResourceField
|
||||
label="SPA 回退路径"
|
||||
hint="以 / 开头,例如 /index.html 或 /app.html。关闭 fallback 时不会生效。"
|
||||
error={form.formState.errors.spa_fallback_path?.message}
|
||||
>
|
||||
<ResourceInput
|
||||
placeholder="/index.html"
|
||||
disabled={!spaFallbackEnabled}
|
||||
{...form.register('spa_fallback_path')}
|
||||
/>
|
||||
</ResourceField>
|
||||
<ToggleField
|
||||
label="启用 API 反向代理"
|
||||
description="允许为该静态站点配置反代后端(例如反代指定 API 路径至您的后端服务)。"
|
||||
checked={apiProxyEnabled}
|
||||
onChange={(checked) =>
|
||||
form.setValue('api_proxy_enabled', checked, {
|
||||
shouldValidate: true,
|
||||
})
|
||||
}
|
||||
/>
|
||||
{apiProxyEnabled && (
|
||||
<>
|
||||
<ResourceField
|
||||
label="反代匹配路径"
|
||||
hint="以 / 开头,例如 /api 或 /api/v1。匹配该前缀的请求将被转发。"
|
||||
error={form.formState.errors.api_proxy_path?.message}
|
||||
>
|
||||
<ResourceInput
|
||||
placeholder="/api"
|
||||
{...form.register('api_proxy_path')}
|
||||
required
|
||||
/>
|
||||
</ResourceField>
|
||||
<ResourceField
|
||||
label="后端服务地址"
|
||||
hint="包含协议和主机的完整 URL,例如 http://127.0.0.1:8080。"
|
||||
error={form.formState.errors.api_proxy_pass?.message}
|
||||
>
|
||||
<ResourceInput
|
||||
placeholder="http://127.0.0.1:8080"
|
||||
{...form.register('api_proxy_pass')}
|
||||
required
|
||||
/>
|
||||
</ResourceField>
|
||||
<ResourceField
|
||||
label="路径重写目标"
|
||||
hint="可选。如果配置为 /,请求 /api/users 将被重写转发至后端 /users 路径。"
|
||||
error={form.formState.errors.api_proxy_rewrite?.message}
|
||||
>
|
||||
<ResourceInput
|
||||
placeholder="/"
|
||||
{...form.register('api_proxy_rewrite')}
|
||||
/>
|
||||
</ResourceField>
|
||||
</>
|
||||
)}
|
||||
{mutation.isError ? (
|
||||
<p className="text-sm text-[var(--status-danger-foreground)] md:col-span-2">
|
||||
{mutation.error instanceof Error
|
||||
? mutation.error.message
|
||||
: '操作失败,请稍后重试。'}
|
||||
</p>
|
||||
) : null}
|
||||
</form>
|
||||
</AppModal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
|
||||
import {StatusBadge} from '@/components/ui/status-badge';
|
||||
import type {PagesProject} from '@/features/pages/types';
|
||||
import {formatDateTime} from '@/lib/utils/date';
|
||||
|
||||
export function PagesProjectListItem({ project }: { project: PagesProject }) {
|
||||
return (
|
||||
<Link
|
||||
href={`/pages/detail?id=${project.id}`}
|
||||
className="group block rounded-[28px] border border-[var(--border-default)] bg-[var(--surface-panel)] p-5 shadow-[var(--shadow-card)] transition hover:-translate-y-0.5 hover:border-[var(--border-strong)] hover:shadow-[var(--shadow-soft)]"
|
||||
>
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<div className="min-w-0 space-y-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h2 className="text-lg font-semibold text-[var(--foreground-primary)]">
|
||||
{project.name}
|
||||
</h2>
|
||||
<StatusBadge
|
||||
label={project.enabled ? '已启用' : '已停用'}
|
||||
variant={project.enabled ? 'success' : 'warning'}
|
||||
/>
|
||||
<StatusBadge
|
||||
label={project.spa_fallback_enabled ? 'SPA fallback' : '严格 404'}
|
||||
variant={project.spa_fallback_enabled ? 'info' : 'warning'}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-sm text-[var(--foreground-secondary)]">
|
||||
{project.slug}
|
||||
</p>
|
||||
{project.description ? (
|
||||
<p className="line-clamp-2 text-sm leading-6 text-[var(--foreground-secondary)]">
|
||||
{project.description}
|
||||
</p>
|
||||
) : null}
|
||||
{project.spa_fallback_enabled ? (
|
||||
<p className="text-xs text-[var(--foreground-secondary)]">
|
||||
回退路径:{project.spa_fallback_path || '/index.html'}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="grid shrink-0 grid-cols-1 gap-3 text-sm md:min-w-40">
|
||||
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-muted)] px-4 py-3">
|
||||
<p className="text-xs text-[var(--foreground-secondary)]">
|
||||
当前激活
|
||||
</p>
|
||||
<p className="mt-1 font-semibold text-[var(--foreground-primary)]">
|
||||
{project.active_deployment
|
||||
? `#${project.active_deployment.deployment_number}`
|
||||
: '暂无'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 flex items-center justify-between border-t border-[var(--border-default)] pt-4">
|
||||
<p className="text-xs text-[var(--foreground-secondary)]">
|
||||
激活时间:
|
||||
{project.active_deployment?.activated_at
|
||||
? formatDateTime(project.active_deployment.activated_at)
|
||||
: '未激活'}
|
||||
</p>
|
||||
<span className="text-sm font-medium text-[var(--brand-primary)] transition group-hover:translate-x-1">
|
||||
查看详情 →
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import {z} from 'zod';
|
||||
|
||||
export const pagesProjectSchema = z
|
||||
.object({
|
||||
name: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, '请输入项目名称')
|
||||
.max(255, '项目名称不能超过 255 个字符'),
|
||||
slug: z
|
||||
.string()
|
||||
.trim()
|
||||
.max(255, '项目标识不能超过 255 个字符')
|
||||
.optional()
|
||||
.or(z.literal('')),
|
||||
description: z
|
||||
.string()
|
||||
.trim()
|
||||
.max(1000, '描述不能超过 1000 个字符')
|
||||
.optional()
|
||||
.or(z.literal('')),
|
||||
spa_fallback_enabled: z.boolean(),
|
||||
spa_fallback_path: z.string().trim(),
|
||||
api_proxy_enabled: z.boolean(),
|
||||
api_proxy_path: z.string().trim(),
|
||||
api_proxy_pass: z.string().trim(),
|
||||
api_proxy_rewrite: z.string().trim(),
|
||||
root_dir: z
|
||||
.string()
|
||||
.trim()
|
||||
.max(512, '根目录不能超过 512 个字符')
|
||||
.optional()
|
||||
.or(z.literal('')),
|
||||
entry_file: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, '请输入入口文件')
|
||||
.max(512, '入口文件不能超过 512 个字符'),
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
if (data.spa_fallback_enabled) {
|
||||
if (!data.spa_fallback_path) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['spa_fallback_path'],
|
||||
message: '启用 SPA fallback 时必须输入回退路径',
|
||||
});
|
||||
} else if (!data.spa_fallback_path.startsWith('/')) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['spa_fallback_path'],
|
||||
message: '回退路径必须以 / 开头,例如 /index.html',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (data.api_proxy_enabled) {
|
||||
if (!data.api_proxy_path) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['api_proxy_path'],
|
||||
message: '启用 API 反向代理时必须输入匹配路径',
|
||||
});
|
||||
} else if (!data.api_proxy_path.startsWith('/')) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['api_proxy_path'],
|
||||
message: '匹配路径必须以 / 开头,例如 /api',
|
||||
});
|
||||
}
|
||||
|
||||
if (!data.api_proxy_pass) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['api_proxy_pass'],
|
||||
message: '启用 API 反向代理时必须输入后端服务地址',
|
||||
});
|
||||
} else if (!/^https?:\/\//i.test(data.api_proxy_pass)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['api_proxy_pass'],
|
||||
message: '后端服务地址必须以 http:// 或 https:// 开头',
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export type PagesProjectFormValues = z.infer<typeof pagesProjectSchema>;
|
||||
@@ -0,0 +1,50 @@
|
||||
export interface PagesDeployment {
|
||||
id: number;
|
||||
project_id: number;
|
||||
deployment_number: number;
|
||||
checksum: string;
|
||||
status: 'uploaded' | 'active';
|
||||
file_count: number;
|
||||
total_size: number;
|
||||
root_dir?: string;
|
||||
entry_file: string;
|
||||
created_by: string;
|
||||
created_at: string;
|
||||
activated_at?: string | null;
|
||||
}
|
||||
|
||||
export interface PagesProject {
|
||||
id: number;
|
||||
name: string;
|
||||
slug: string;
|
||||
description: string;
|
||||
enabled: boolean;
|
||||
spa_fallback_enabled: boolean;
|
||||
spa_fallback_path: string;
|
||||
api_proxy_enabled: boolean;
|
||||
api_proxy_path: string;
|
||||
api_proxy_pass: string;
|
||||
api_proxy_rewrite: string;
|
||||
root_dir?: string;
|
||||
entry_file: string;
|
||||
active_deployment_id?: number | null;
|
||||
active_deployment?: PagesDeployment | null;
|
||||
deployment_count: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface PagesProjectPayload {
|
||||
name: string;
|
||||
slug: string;
|
||||
description: string;
|
||||
enabled: boolean;
|
||||
spa_fallback_enabled: boolean;
|
||||
spa_fallback_path: string;
|
||||
api_proxy_enabled: boolean;
|
||||
api_proxy_path: string;
|
||||
api_proxy_pass: string;
|
||||
api_proxy_rewrite: string;
|
||||
root_dir?: string;
|
||||
entry_file: string;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export const projectsQueryKey = ['pages-projects'];
|
||||
|
||||
export function projectQueryKey(projectId: string | number) {
|
||||
return ['pages-project', String(projectId)];
|
||||
}
|
||||
|
||||
export function deploymentsQueryKey(projectId: string | number) {
|
||||
return ['pages-deployments', Number(projectId)];
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,50 @@
|
||||
import {apiRequest} from '@/lib/api/client';
|
||||
|
||||
import type {
|
||||
ManagedDomainMatchResult,
|
||||
ProxyRouteItem,
|
||||
ProxyRouteMutationPayload,
|
||||
TlsCertificateItem,
|
||||
} from '@/features/proxy-routes/types';
|
||||
|
||||
export function getProxyRoutes() {
|
||||
return apiRequest<ProxyRouteItem[]>('/proxy-routes/');
|
||||
}
|
||||
|
||||
export function getProxyRoute(id: number) {
|
||||
return apiRequest<ProxyRouteItem>(`/proxy-routes/${id}`);
|
||||
}
|
||||
|
||||
export function createProxyRoute(payload: ProxyRouteMutationPayload) {
|
||||
return apiRequest<ProxyRouteItem>('/proxy-routes/', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export function updateProxyRoute(
|
||||
id: number,
|
||||
payload: ProxyRouteMutationPayload,
|
||||
) {
|
||||
return apiRequest<ProxyRouteItem>(`/proxy-routes/${id}/update`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteProxyRoute(id: number) {
|
||||
return apiRequest<void>(`/proxy-routes/${id}/delete`, {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
export function getTlsCertificates() {
|
||||
return apiRequest<TlsCertificateItem[]>('/tls-certificates/');
|
||||
}
|
||||
|
||||
export function matchManagedDomainCertificate(domain: string) {
|
||||
const searchParams = new URLSearchParams({ domain });
|
||||
return apiRequest<ManagedDomainMatchResult>(
|
||||
`/managed-domains/match?${searchParams.toString()}`,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
'use client';
|
||||
|
||||
import {useId, useMemo} from 'react';
|
||||
import {Minus, Plus} from 'lucide-react';
|
||||
|
||||
import type {TlsCertificateItem} from '@/features/tls-certificates/types';
|
||||
import {ResourceInput, ResourceSelect, SecondaryButton,} from '@/features/shared/components/resource-primitives';
|
||||
|
||||
export type DomainListRow = {
|
||||
domain: string;
|
||||
certificateId: string;
|
||||
};
|
||||
|
||||
const actionButtonBaseClassName = 'h-11 w-11 shrink-0 rounded-2xl px-0';
|
||||
const removeButtonClassName =
|
||||
'border-[var(--border-default)] bg-[var(--surface-elevated)] text-[var(--foreground-secondary)] hover:border-[var(--status-danger-border)] hover:bg-[var(--status-danger-soft)] hover:text-[var(--status-danger-foreground)] disabled:border-[var(--border-default)] disabled:bg-[var(--surface-muted)] disabled:text-[var(--foreground-muted)]';
|
||||
const addButtonClassName =
|
||||
'border-dashed border-[var(--border-default)] bg-[var(--surface-muted)] text-[var(--foreground-secondary)] hover:border-[var(--brand-primary)] hover:bg-[var(--brand-primary-soft)] hover:text-[var(--brand-primary)]';
|
||||
|
||||
function ensureRows(rows: DomainListRow[]) {
|
||||
return rows.length > 0 ? rows : [{ domain: '', certificateId: '' }];
|
||||
}
|
||||
|
||||
function buildSuggestionSources(domains: string[]) {
|
||||
const values = new Set<string>();
|
||||
|
||||
for (const domain of domains) {
|
||||
const normalized = domain.trim().toLowerCase().replace(/^\*\./, '');
|
||||
if (!normalized) {
|
||||
continue;
|
||||
}
|
||||
|
||||
values.add(normalized);
|
||||
|
||||
const segments = normalized.split('.');
|
||||
for (let index = 1; index < segments.length - 1; index += 1) {
|
||||
values.add(segments.slice(index).join('.'));
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(values);
|
||||
}
|
||||
|
||||
function buildDomainSuggestions(
|
||||
input: string,
|
||||
sources: string[],
|
||||
rows: DomainListRow[],
|
||||
) {
|
||||
const normalizedInput = input.trim().toLowerCase();
|
||||
if (!normalizedInput) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const existingDomains = new Set(
|
||||
rows
|
||||
.map((row) => row.domain.trim().toLowerCase())
|
||||
.filter((row) => row && row !== normalizedInput),
|
||||
);
|
||||
const suggestions: string[] = [];
|
||||
|
||||
for (const source of sources) {
|
||||
if (source.startsWith(normalizedInput) && source !== normalizedInput) {
|
||||
suggestions.push(source);
|
||||
}
|
||||
|
||||
const separatorIndex = normalizedInput.lastIndexOf('.');
|
||||
if (separatorIndex <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const prefix = normalizedInput.slice(0, separatorIndex);
|
||||
const suffixInput = normalizedInput.slice(separatorIndex + 1);
|
||||
|
||||
if (!suffixInput || source.startsWith(suffixInput)) {
|
||||
suggestions.push(`${prefix}.${source}`);
|
||||
}
|
||||
}
|
||||
|
||||
return suggestions.filter((suggestion, index) => {
|
||||
return (
|
||||
suggestion !== normalizedInput &&
|
||||
!existingDomains.has(suggestion) &&
|
||||
suggestions.indexOf(suggestion) === index
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export function buildDomainRowsFromRoute(
|
||||
domains: string[],
|
||||
domainCertIDs: number[],
|
||||
certIDs: number[],
|
||||
): DomainListRow[] {
|
||||
if (domains.length === 0) {
|
||||
return ensureRows([]);
|
||||
}
|
||||
|
||||
if (domainCertIDs.length === domains.length) {
|
||||
return domains.map((domain, index) => ({
|
||||
domain,
|
||||
certificateId: domainCertIDs[index] ? String(domainCertIDs[index]) : '',
|
||||
}));
|
||||
}
|
||||
|
||||
if (certIDs.length === 0) {
|
||||
return domains.map((domain) => ({ domain, certificateId: '' }));
|
||||
}
|
||||
|
||||
if (certIDs.length === 1) {
|
||||
return domains.map((domain) => ({
|
||||
domain,
|
||||
certificateId: String(certIDs[0]),
|
||||
}));
|
||||
}
|
||||
|
||||
return domains.map((domain, index) => ({
|
||||
domain,
|
||||
certificateId: certIDs[index] ? String(certIDs[index]) : '',
|
||||
}));
|
||||
}
|
||||
|
||||
export function DomainListInput({
|
||||
rows,
|
||||
onChange,
|
||||
onBlur,
|
||||
suggestionSources = [],
|
||||
certificates = [],
|
||||
domainPlaceholder = 'app.example.com',
|
||||
}: {
|
||||
rows: DomainListRow[];
|
||||
onChange: (rows: DomainListRow[]) => void;
|
||||
onBlur?: () => void;
|
||||
suggestionSources?: string[];
|
||||
certificates?: TlsCertificateItem[];
|
||||
domainPlaceholder?: string;
|
||||
}) {
|
||||
const listId = useId();
|
||||
const safeRows = ensureRows(rows);
|
||||
const normalizedSources = useMemo(
|
||||
() => buildSuggestionSources(suggestionSources),
|
||||
[suggestionSources],
|
||||
);
|
||||
|
||||
const updateRows = (nextRows: DomainListRow[]) => {
|
||||
onChange(ensureRows(nextRows));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{safeRows.map((row, index) => {
|
||||
const suggestions = buildDomainSuggestions(
|
||||
row.domain,
|
||||
normalizedSources,
|
||||
safeRows,
|
||||
).slice(0, 4);
|
||||
|
||||
return (
|
||||
<div key={`${index}-${safeRows.length}`} className="space-y-2">
|
||||
<div className="grid gap-3 md:grid-cols-[44px_minmax(0,1fr)_280px] md:items-start">
|
||||
<SecondaryButton
|
||||
type="button"
|
||||
aria-label={`删除域名输入框 ${index + 1}`}
|
||||
className={`${actionButtonBaseClassName} ${removeButtonClassName}`}
|
||||
disabled={safeRows.length === 1}
|
||||
onClick={() => {
|
||||
if (safeRows.length === 1) {
|
||||
updateRows([{ domain: '', certificateId: '' }]);
|
||||
return;
|
||||
}
|
||||
|
||||
updateRows(
|
||||
safeRows.filter((_, rowIndex) => rowIndex !== index),
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Minus aria-hidden="true" className="h-[14px] w-[14px]" />
|
||||
</SecondaryButton>
|
||||
|
||||
<div className="min-w-0 space-y-2">
|
||||
<ResourceInput
|
||||
value={row.domain}
|
||||
list={`${listId}-${index}`}
|
||||
aria-label={`域名 ${index + 1}`}
|
||||
placeholder={
|
||||
index === 0 ? domainPlaceholder : 'www.example.com'
|
||||
}
|
||||
onBlur={onBlur}
|
||||
onChange={(event) => {
|
||||
const nextRows = safeRows.slice();
|
||||
nextRows[index] = {
|
||||
...nextRows[index],
|
||||
domain: event.target.value,
|
||||
};
|
||||
updateRows(nextRows);
|
||||
}}
|
||||
className="h-12"
|
||||
/>
|
||||
<datalist id={`${listId}-${index}`}>
|
||||
{suggestions.map((suggestion) => (
|
||||
<option key={suggestion} value={suggestion} />
|
||||
))}
|
||||
</datalist>
|
||||
|
||||
{suggestions.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{suggestions.map((suggestion) => (
|
||||
<button
|
||||
key={suggestion}
|
||||
type="button"
|
||||
className="inline-flex items-center rounded-full border border-[var(--border-default)] bg-[var(--surface-panel)] px-3 py-1 text-xs text-[var(--foreground-secondary)] transition hover:border-[var(--border-strong)] hover:text-[var(--foreground-primary)]"
|
||||
onClick={() => {
|
||||
const nextRows = safeRows.slice();
|
||||
nextRows[index] = {
|
||||
...nextRows[index],
|
||||
domain: suggestion,
|
||||
};
|
||||
updateRows(nextRows);
|
||||
}}
|
||||
>
|
||||
{suggestion}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<ResourceSelect
|
||||
aria-label={`证书 ${index + 1}`}
|
||||
value={row.certificateId}
|
||||
onChange={(event) => {
|
||||
const nextRows = safeRows.slice();
|
||||
nextRows[index] = {
|
||||
...nextRows[index],
|
||||
certificateId: event.target.value,
|
||||
};
|
||||
updateRows(nextRows);
|
||||
}}
|
||||
className="h-12"
|
||||
>
|
||||
<option value="">
|
||||
{certificates.length === 0 ? '暂无可选证书' : '选择证书'}
|
||||
</option>
|
||||
{certificates.map((certificate) => (
|
||||
<option key={certificate.id} value={certificate.id}>
|
||||
{certificate.name}
|
||||
</option>
|
||||
))}
|
||||
</ResourceSelect>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<SecondaryButton
|
||||
type="button"
|
||||
aria-label="新增域名输入框"
|
||||
className={`${actionButtonBaseClassName} ${addButtonClassName}`}
|
||||
onClick={() => {
|
||||
updateRows([...safeRows, { domain: '', certificateId: '' }]);
|
||||
}}
|
||||
>
|
||||
<Plus aria-hidden="true" className="h-[14px] w-[14px]" />
|
||||
</SecondaryButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,523 @@
|
||||
'use client';
|
||||
|
||||
import {zodResolver} from '@hookform/resolvers/zod';
|
||||
import {useMutation, useQuery} from '@tanstack/react-query';
|
||||
import {useEffect, useMemo} from 'react';
|
||||
import {Controller, useForm} from 'react-hook-form';
|
||||
import {z} from 'zod';
|
||||
|
||||
import {Drawer} from '@/components/ui/drawer';
|
||||
import {getManagedDomains} from '@/features/managed-domains/api/managed-domains';
|
||||
import {getPagesProjects} from '@/features/pages/api/pages';
|
||||
import {createProxyRoute} from '@/features/proxy-routes/api/proxy-routes';
|
||||
import {DomainListInput, type DomainListRow,} from '@/features/proxy-routes/components/domain-list-input';
|
||||
import {
|
||||
buildOriginUrl,
|
||||
getErrorMessage,
|
||||
parseOriginUrl,
|
||||
parseOriginUrls,
|
||||
validateDomains,
|
||||
} from '@/features/proxy-routes/helpers';
|
||||
import {getNodes} from '@/features/nodes/api/nodes';
|
||||
import type {ProxyRouteItem} from '@/features/proxy-routes/types';
|
||||
import {getTlsCertificates} from '@/features/tls-certificates/api/tls-certificates';
|
||||
import {
|
||||
PrimaryButton,
|
||||
ResourceField,
|
||||
ResourceInput,
|
||||
ResourceTextarea,
|
||||
ToggleField,
|
||||
} from '@/features/shared/components/resource-primitives';
|
||||
|
||||
const domainRowSchema = z.object({
|
||||
domain: z.string(),
|
||||
certificateId: z.string(),
|
||||
});
|
||||
|
||||
const createWebsiteSchema = z
|
||||
.object({
|
||||
site_name: z.string().trim().max(255, '站点标识不能超过 255 个字符'),
|
||||
domain_rows: z.array(domainRowSchema).min(1),
|
||||
upstream_type: z.enum(['direct', 'tunnel', 'pages']),
|
||||
origin_urls_text: z.string().trim(),
|
||||
tunnel_id: z.string().optional(),
|
||||
tunnel_target_addr: z.string().trim().optional(),
|
||||
tunnel_target_protocol: z.enum(['http', 'https']).optional(),
|
||||
pages_project_id: z.string().optional(),
|
||||
enabled: z.boolean(),
|
||||
redirect_http: z.boolean(),
|
||||
remark: z.string().max(255, '备注不能超过 255 个字符'),
|
||||
})
|
||||
.superRefine((value, context) => {
|
||||
const domains = value.domain_rows
|
||||
.map((item) => item.domain.trim().toLowerCase())
|
||||
.filter(Boolean);
|
||||
const domainError = validateDomains(domains);
|
||||
if (domainError) {
|
||||
context.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['domain_rows'],
|
||||
message: domainError,
|
||||
});
|
||||
}
|
||||
|
||||
if (value.upstream_type === 'direct') {
|
||||
if (!value.origin_urls_text.trim()) {
|
||||
context.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['origin_urls_text'],
|
||||
message: '请至少填写一个上游地址',
|
||||
});
|
||||
} else {
|
||||
const { error } = parseOriginUrls(value.origin_urls_text);
|
||||
if (error) {
|
||||
context.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['origin_urls_text'],
|
||||
message: error,
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (value.upstream_type === 'tunnel') {
|
||||
if (!value.tunnel_id) {
|
||||
context.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['tunnel_id'],
|
||||
message: '请选择内网穿透隧道',
|
||||
});
|
||||
}
|
||||
if (!value.tunnel_target_addr) {
|
||||
context.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['tunnel_target_addr'],
|
||||
message: '请填写内网服务地址 (如 127.0.0.1:8080)',
|
||||
});
|
||||
}
|
||||
} else if (!value.pages_project_id) {
|
||||
context.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['pages_project_id'],
|
||||
message: '请选择 Pages 项目',
|
||||
});
|
||||
}
|
||||
|
||||
const selectedCertificateCount = new Set(
|
||||
value.domain_rows
|
||||
.map((item) => Number(item.certificateId))
|
||||
.filter((item) => Number.isFinite(item) && item > 0),
|
||||
).size;
|
||||
if (value.redirect_http && selectedCertificateCount === 0) {
|
||||
context.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['redirect_http'],
|
||||
message: '启用 HTTP 跳转前,请先为域名选择证书',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
type CreateWebsiteFormValues = z.infer<typeof createWebsiteSchema>;
|
||||
|
||||
const defaultValues: CreateWebsiteFormValues = {
|
||||
site_name: '',
|
||||
domain_rows: [{ domain: '', certificateId: '' }],
|
||||
upstream_type: 'direct',
|
||||
origin_urls_text: '',
|
||||
tunnel_id: '',
|
||||
tunnel_target_addr: '',
|
||||
tunnel_target_protocol: 'http',
|
||||
pages_project_id: '',
|
||||
enabled: true,
|
||||
redirect_http: false,
|
||||
remark: '',
|
||||
};
|
||||
|
||||
function normalizeSelectedCertificateIDs(rows: DomainListRow[]) {
|
||||
return Array.from(
|
||||
new Set(
|
||||
rows
|
||||
.filter((item) => item.domain.trim() !== '')
|
||||
.map((item) => Number(item.certificateId))
|
||||
.filter((item) => Number.isFinite(item) && item > 0),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function buildDomainCertificateIDs(rows: DomainListRow[]) {
|
||||
return rows
|
||||
.filter((item) => item.domain.trim() !== '')
|
||||
.map((item) => {
|
||||
const certificateID = Number(item.certificateId);
|
||||
return Number.isFinite(certificateID) && certificateID > 0
|
||||
? certificateID
|
||||
: 0;
|
||||
});
|
||||
}
|
||||
|
||||
export function ProxyRouteCreateDrawer({
|
||||
open,
|
||||
onOpenChange,
|
||||
onCreated,
|
||||
domainSuggestionSources = [],
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onCreated: (route: ProxyRouteItem) => void;
|
||||
domainSuggestionSources?: string[];
|
||||
}) {
|
||||
const form = useForm<CreateWebsiteFormValues>({
|
||||
resolver: zodResolver(createWebsiteSchema),
|
||||
defaultValues,
|
||||
});
|
||||
const managedDomainsQuery = useQuery({
|
||||
queryKey: ['managed-domains'],
|
||||
queryFn: getManagedDomains,
|
||||
enabled: open,
|
||||
});
|
||||
const certificatesQuery = useQuery({
|
||||
queryKey: ['tls-certificates', 'list'],
|
||||
queryFn: getTlsCertificates,
|
||||
enabled: open,
|
||||
});
|
||||
const tunnelsQuery = useQuery({
|
||||
queryKey: ['nodes'],
|
||||
queryFn: getNodes,
|
||||
enabled: open,
|
||||
});
|
||||
const pagesProjectsQuery = useQuery({
|
||||
queryKey: ['pages-projects'],
|
||||
queryFn: getPagesProjects,
|
||||
enabled: open,
|
||||
});
|
||||
const tunnelClients = (tunnelsQuery.data ?? []).filter(
|
||||
(node) => node.node_type === 'tunnel_client',
|
||||
);
|
||||
const pagesProjects = (pagesProjectsQuery.data ?? []).filter(
|
||||
(project) => project.enabled && project.active_deployment_id,
|
||||
);
|
||||
|
||||
const combinedDomainSuggestions = useMemo(
|
||||
() => [
|
||||
...domainSuggestionSources,
|
||||
...(managedDomainsQuery.data?.map((item) => item.domain) ?? []),
|
||||
],
|
||||
[domainSuggestionSources, managedDomainsQuery.data],
|
||||
);
|
||||
const selectedCertificateIDs = normalizeSelectedCertificateIDs(
|
||||
form.watch('domain_rows'),
|
||||
);
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: async (values: CreateWebsiteFormValues) => {
|
||||
const domains = values.domain_rows
|
||||
.map((item) => item.domain.trim().toLowerCase())
|
||||
.filter(Boolean);
|
||||
const domainCertIDs = buildDomainCertificateIDs(values.domain_rows);
|
||||
const selectedCertIDs = normalizeSelectedCertificateIDs(
|
||||
values.domain_rows,
|
||||
);
|
||||
|
||||
let originUrl = '';
|
||||
const originHost = '';
|
||||
let upstreams: string[] = [];
|
||||
|
||||
if (values.upstream_type === 'direct') {
|
||||
const { urls } = parseOriginUrls(values.origin_urls_text);
|
||||
const primaryOrigin = parseOriginUrl(urls[0]);
|
||||
originUrl = buildOriginUrl(
|
||||
primaryOrigin.scheme,
|
||||
primaryOrigin.address,
|
||||
primaryOrigin.port,
|
||||
primaryOrigin.uri,
|
||||
);
|
||||
upstreams = urls.slice(1);
|
||||
} else if (values.upstream_type === 'tunnel') {
|
||||
originUrl = `${values.tunnel_target_protocol}://${values.tunnel_target_addr}`;
|
||||
} else {
|
||||
originUrl = 'http://127.0.0.1';
|
||||
}
|
||||
|
||||
return createProxyRoute({
|
||||
site_name: values.site_name.trim() || domains[0],
|
||||
domain: domains[0],
|
||||
domains,
|
||||
origin_id: null,
|
||||
origin_url: originUrl,
|
||||
origin_scheme:
|
||||
values.upstream_type === 'direct'
|
||||
? parseOriginUrl(originUrl).scheme
|
||||
: 'http',
|
||||
origin_address:
|
||||
values.upstream_type === 'direct'
|
||||
? parseOriginUrl(originUrl).address
|
||||
: values.tunnel_target_addr || '127.0.0.1',
|
||||
origin_port:
|
||||
values.upstream_type === 'direct'
|
||||
? parseOriginUrl(originUrl).port
|
||||
: '80',
|
||||
origin_uri:
|
||||
values.upstream_type === 'direct'
|
||||
? parseOriginUrl(originUrl).uri
|
||||
: '',
|
||||
origin_host: originHost,
|
||||
upstreams,
|
||||
enabled: values.enabled,
|
||||
enable_https: selectedCertIDs.length > 0,
|
||||
cert_id: selectedCertIDs.length > 0 ? selectedCertIDs[0] : null,
|
||||
cert_ids: selectedCertIDs,
|
||||
domain_cert_ids: domainCertIDs,
|
||||
redirect_http:
|
||||
selectedCertIDs.length > 0 ? values.redirect_http : false,
|
||||
limit_conn_per_server: 0,
|
||||
limit_conn_per_ip: 0,
|
||||
limit_rate: '',
|
||||
cache_enabled: false,
|
||||
cache_policy: 'url',
|
||||
cache_rules: [],
|
||||
custom_headers: [],
|
||||
pow_enabled: false,
|
||||
pow_config: '{}',
|
||||
basic_auth_enabled: false,
|
||||
remark: values.remark.trim(),
|
||||
upstream_type: values.upstream_type,
|
||||
tunnel_node_id:
|
||||
values.upstream_type === 'tunnel' && values.tunnel_id
|
||||
? Number(values.tunnel_id)
|
||||
: null,
|
||||
tunnel_target_addr:
|
||||
values.upstream_type === 'tunnel' ? values.tunnel_target_addr : '',
|
||||
tunnel_target_protocol:
|
||||
values.upstream_type === 'tunnel'
|
||||
? values.tunnel_target_protocol
|
||||
: '',
|
||||
pages_project_id:
|
||||
values.upstream_type === 'pages' && values.pages_project_id
|
||||
? Number(values.pages_project_id)
|
||||
: null,
|
||||
});
|
||||
},
|
||||
onSuccess: (route) => {
|
||||
form.reset(defaultValues);
|
||||
onOpenChange(false);
|
||||
onCreated(route);
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
form.reset(defaultValues);
|
||||
}
|
||||
}, [form, open]);
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
direction="right"
|
||||
title="新建规则"
|
||||
footer={
|
||||
<div className="flex items-center justify-end gap-3">
|
||||
<PrimaryButton
|
||||
type="submit"
|
||||
form="create-website-form"
|
||||
disabled={createMutation.isPending}
|
||||
>
|
||||
{createMutation.isPending ? '创建中...' : '创建'}
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<form
|
||||
id="create-website-form"
|
||||
className="space-y-5"
|
||||
onSubmit={form.handleSubmit((values) => createMutation.mutate(values))}
|
||||
>
|
||||
<ResourceField
|
||||
label="站点标识"
|
||||
hint="可选,留空时会自动使用第一个域名。"
|
||||
error={form.formState.errors.site_name?.message}
|
||||
>
|
||||
<ResourceInput
|
||||
{...form.register('site_name')}
|
||||
placeholder="marketing-site"
|
||||
/>
|
||||
</ResourceField>
|
||||
|
||||
<ResourceField
|
||||
label="域名列表"
|
||||
hint="每行配置一个域名,可按需为该行选择证书。保存时会自动汇总站点证书集合。"
|
||||
error={
|
||||
form.formState.errors.domain_rows?.message as string | undefined
|
||||
}
|
||||
container="div"
|
||||
>
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="domain_rows"
|
||||
render={({ field }) => (
|
||||
<DomainListInput
|
||||
rows={field.value}
|
||||
onChange={field.onChange}
|
||||
onBlur={field.onBlur}
|
||||
suggestionSources={combinedDomainSuggestions}
|
||||
certificates={certificatesQuery.data ?? []}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</ResourceField>
|
||||
|
||||
<ToggleField
|
||||
label="HTTP 自动跳转到 HTTPS"
|
||||
description={
|
||||
selectedCertificateIDs.length > 0
|
||||
? '勾选后会额外生成 80 端口重定向规则。'
|
||||
: '至少为一个域名选择证书后才能启用。'
|
||||
}
|
||||
checked={form.watch('redirect_http')}
|
||||
disabled={selectedCertificateIDs.length === 0}
|
||||
onChange={(checked) =>
|
||||
form.setValue('redirect_http', checked, { shouldDirty: true })
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="space-y-3">
|
||||
<label className="block text-sm font-medium text-[var(--foreground-primary)]">
|
||||
回源方式
|
||||
</label>
|
||||
<div className="flex gap-4">
|
||||
<label className="flex cursor-pointer items-center gap-2 text-sm text-[var(--foreground-primary)]">
|
||||
<input
|
||||
type="radio"
|
||||
value="direct"
|
||||
{...form.register('upstream_type')}
|
||||
className="h-4 w-4 border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
直连上游
|
||||
</label>
|
||||
<label className="flex cursor-pointer items-center gap-2 text-sm text-[var(--foreground-primary)]">
|
||||
<input
|
||||
type="radio"
|
||||
value="tunnel"
|
||||
{...form.register('upstream_type')}
|
||||
className="h-4 w-4 border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
内网穿透 (Tunnel)
|
||||
</label>
|
||||
<label className="flex cursor-pointer items-center gap-2 text-sm text-[var(--foreground-primary)]">
|
||||
<input
|
||||
type="radio"
|
||||
value="pages"
|
||||
{...form.register('upstream_type')}
|
||||
className="h-4 w-4 border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
Pages 静态站点
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{form.watch('upstream_type') === 'direct' ? (
|
||||
<ResourceField
|
||||
label="上游地址"
|
||||
hint="每行一个完整 URL。第一行作为主回源,多上游模式请保持相同协议且不要包含 path 或 query。"
|
||||
error={form.formState.errors.origin_urls_text?.message}
|
||||
>
|
||||
<ResourceTextarea
|
||||
aria-label="上游地址"
|
||||
placeholder={
|
||||
'https://origin-a.internal:443\nhttps://origin-b.internal:443'
|
||||
}
|
||||
{...form.register('origin_urls_text')}
|
||||
/>
|
||||
</ResourceField>
|
||||
) : form.watch('upstream_type') === 'tunnel' ? (
|
||||
<div className="space-y-4 rounded-xl border border-[var(--border-default)] bg-[var(--surface-muted)] p-4">
|
||||
<ResourceField
|
||||
label="选择内网穿透隧道"
|
||||
hint="将请求转发到该隧道连接的客户端节点。"
|
||||
error={form.formState.errors.tunnel_id?.message}
|
||||
>
|
||||
<select
|
||||
{...form.register('tunnel_id')}
|
||||
className="block w-full rounded-xl border border-[var(--border-default)] bg-[var(--control-background)] px-4 py-2.5 text-sm text-[var(--foreground-primary)] placeholder-[var(--foreground-muted)] transition outline-none focus:border-[var(--border-strong)] focus:ring-1 focus:ring-[var(--border-strong)]"
|
||||
>
|
||||
<option value="">请选择...</option>
|
||||
{tunnelClients.map((tunnel) => (
|
||||
<option key={tunnel.id} value={tunnel.id}>
|
||||
{tunnel.name} (
|
||||
{tunnel.status === 'online' ? '在线' : '离线'})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</ResourceField>
|
||||
|
||||
<ResourceField
|
||||
label="内网服务协议"
|
||||
hint="转发到内网服务时使用的协议。"
|
||||
error={form.formState.errors.tunnel_target_protocol?.message}
|
||||
>
|
||||
<select
|
||||
{...form.register('tunnel_target_protocol')}
|
||||
className="block w-full rounded-xl border border-[var(--border-default)] bg-[var(--control-background)] px-4 py-2.5 text-sm text-[var(--foreground-primary)] placeholder-[var(--foreground-muted)] transition outline-none focus:border-[var(--border-strong)] focus:ring-1 focus:ring-[var(--border-strong)]"
|
||||
>
|
||||
<option value="http">HTTP</option>
|
||||
<option value="https">HTTPS</option>
|
||||
</select>
|
||||
</ResourceField>
|
||||
|
||||
<ResourceField
|
||||
label="内网服务地址"
|
||||
hint="例如: 127.0.0.1:8080 或 192.168.1.10:80"
|
||||
error={form.formState.errors.tunnel_target_addr?.message}
|
||||
>
|
||||
<ResourceInput
|
||||
placeholder="127.0.0.1:8080"
|
||||
{...form.register('tunnel_target_addr')}
|
||||
/>
|
||||
</ResourceField>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4 rounded-xl border border-[var(--border-default)] bg-[var(--surface-muted)] p-4">
|
||||
<ResourceField
|
||||
label="选择 Pages 项目"
|
||||
hint="仅显示已启用且已有激活部署的 Pages 项目。"
|
||||
error={form.formState.errors.pages_project_id?.message}
|
||||
>
|
||||
<select
|
||||
{...form.register('pages_project_id')}
|
||||
className="block w-full rounded-xl border border-[var(--border-default)] bg-[var(--control-background)] px-4 py-2.5 text-sm text-[var(--foreground-primary)] placeholder-[var(--foreground-muted)] transition outline-none focus:border-[var(--border-strong)] focus:ring-1 focus:ring-[var(--border-strong)]"
|
||||
>
|
||||
<option value="">请选择...</option>
|
||||
{pagesProjects.map((project) => (
|
||||
<option key={project.id} value={project.id}>
|
||||
{project.name} ({project.slug})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</ResourceField>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ToggleField
|
||||
label="创建后立即启用"
|
||||
description="关闭后站点会以草稿保存,后续仍可继续编辑。"
|
||||
checked={form.watch('enabled')}
|
||||
onChange={(checked) =>
|
||||
form.setValue('enabled', checked, { shouldDirty: true })
|
||||
}
|
||||
/>
|
||||
|
||||
<ResourceField
|
||||
label="备注"
|
||||
error={form.formState.errors.remark?.message}
|
||||
>
|
||||
<ResourceTextarea {...form.register('remark')} />
|
||||
</ResourceField>
|
||||
|
||||
{createMutation.isError ? (
|
||||
<p className="text-sm text-[var(--status-danger-foreground)]">
|
||||
{getErrorMessage(createMutation.error)}
|
||||
</p>
|
||||
) : null}
|
||||
</form>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,404 @@
|
||||
'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}
|
||||
onClear={() => setFeedback(null)}
|
||||
/>
|
||||
) : 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`);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
import type {ProxyRouteCustomHeader, ProxyRouteItem, ProxyRouteMutationPayload,} from '@/features/proxy-routes/types';
|
||||
|
||||
export const websiteConfigSections = [
|
||||
{
|
||||
key: 'domains',
|
||||
label: '域名设置',
|
||||
description: '维护站点标识、域名列表和证书绑定。',
|
||||
},
|
||||
{
|
||||
key: 'limits',
|
||||
label: '流量限制',
|
||||
description: '设置连接数和限速。',
|
||||
},
|
||||
{
|
||||
key: 'proxy',
|
||||
label: '反向代理',
|
||||
description: '配置主回源和上游地址。',
|
||||
},
|
||||
{
|
||||
key: 'cache',
|
||||
label: '缓存',
|
||||
description: '配置站点缓存策略。',
|
||||
},
|
||||
{
|
||||
key: 'waf',
|
||||
label: 'WAF',
|
||||
description: '绑定 WAF 规则组,并查看当前站点生效策略。',
|
||||
},
|
||||
{
|
||||
key: 'auth',
|
||||
label: '认证配置',
|
||||
description: '配置基础鉴权访问,需要输入账号密码才能访问网站。',
|
||||
},
|
||||
] as const;
|
||||
|
||||
export type WebsiteConfigSectionKey =
|
||||
(typeof websiteConfigSections)[number]['key'];
|
||||
|
||||
const domainPattern =
|
||||
/^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/i;
|
||||
const originHostPattern =
|
||||
/^(?:(?:[a-z0-9-]+\.)*[a-z0-9-]+|\[[0-9a-f:.]+\]|[0-9.]+)(?::\d{1,5})?$/i;
|
||||
const headerKeyPattern = /^[A-Za-z0-9_-]+$/;
|
||||
const limitRatePattern = /^\d+(?:[kKmM])?$/;
|
||||
|
||||
export function getErrorMessage(error: unknown) {
|
||||
return error instanceof Error ? error.message : '请求失败,请稍后重试。';
|
||||
}
|
||||
|
||||
export function getWebsiteConfigSection(
|
||||
value: string | null | undefined,
|
||||
): WebsiteConfigSectionKey {
|
||||
return websiteConfigSections.some((section) => section.key === value)
|
||||
? (value as WebsiteConfigSectionKey)
|
||||
: 'domains';
|
||||
}
|
||||
|
||||
export function linesFromTextarea(value: string) {
|
||||
return value
|
||||
.split(/\r?\n/)
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
export function validateDomains(domains: string[]) {
|
||||
if (domains.length === 0) {
|
||||
return '请至少填写一个域名';
|
||||
}
|
||||
|
||||
const seen = new Set<string>();
|
||||
for (const domain of domains) {
|
||||
const normalized = domain.trim().toLowerCase();
|
||||
if (!domainPattern.test(normalized)) {
|
||||
return `域名格式不合法:${domain}`;
|
||||
}
|
||||
if (seen.has(normalized)) {
|
||||
return `域名重复:${domain}`;
|
||||
}
|
||||
seen.add(normalized);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function parseOriginUrls(value: string) {
|
||||
const urls = linesFromTextarea(value);
|
||||
if (urls.length === 0) {
|
||||
return { urls: [], error: '请至少填写一个上游地址' };
|
||||
}
|
||||
|
||||
let sharedScheme = '';
|
||||
for (const originUrl of urls) {
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(originUrl);
|
||||
} catch {
|
||||
return { urls: [], error: `上游地址格式不合法:${originUrl}` };
|
||||
}
|
||||
|
||||
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
||||
return {
|
||||
urls: [],
|
||||
error: `上游地址必须以 http:// 或 https:// 开头:${originUrl}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (!parsed.hostname) {
|
||||
return { urls: [], error: `上游地址缺少主机名:${originUrl}` };
|
||||
}
|
||||
|
||||
if (urls.length > 1) {
|
||||
if ((parsed.pathname && parsed.pathname !== '/') || parsed.search) {
|
||||
return {
|
||||
urls: [],
|
||||
error: '多上游模式暂不支持带路径或查询参数的地址',
|
||||
};
|
||||
}
|
||||
|
||||
if (!sharedScheme) {
|
||||
sharedScheme = parsed.protocol;
|
||||
} else if (sharedScheme !== parsed.protocol) {
|
||||
return {
|
||||
urls: [],
|
||||
error: '同一站点的多个上游必须使用相同协议',
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { urls, error: null };
|
||||
}
|
||||
|
||||
export function parseOriginUrl(originUrl: string) {
|
||||
const parsed = new URL(originUrl);
|
||||
const port = parsed.port || (parsed.protocol === 'http:' ? '80' : '443');
|
||||
const path = parsed.pathname === '/' ? '' : parsed.pathname;
|
||||
|
||||
return {
|
||||
scheme: parsed.protocol.replace(':', '') as 'http' | 'https',
|
||||
address: parsed.hostname,
|
||||
port,
|
||||
uri: parsed.search ? `${path}${parsed.search}` || parsed.search : path,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildOriginUrl(
|
||||
scheme: 'http' | 'https',
|
||||
address: string,
|
||||
port: string,
|
||||
uri: string,
|
||||
) {
|
||||
const normalizedAddress = address.trim();
|
||||
const normalizedPort = port.trim();
|
||||
const normalizedURI = uri.trim();
|
||||
if (!normalizedAddress || !normalizedPort) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const host =
|
||||
normalizedAddress.includes(':') && !normalizedAddress.startsWith('[')
|
||||
? `[${normalizedAddress}]`
|
||||
: normalizedAddress;
|
||||
|
||||
return `${scheme}://${host}:${normalizedPort}${normalizedURI}`;
|
||||
}
|
||||
|
||||
export function validateOriginHost(value: string) {
|
||||
const normalized = value.trim();
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
if (
|
||||
normalized.includes('://') ||
|
||||
/[\/\\\s]/.test(normalized) ||
|
||||
!originHostPattern.test(normalized)
|
||||
) {
|
||||
return '回源 Host 格式不合法';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function parseCustomHeadersText(value: string) {
|
||||
const lines = linesFromTextarea(value);
|
||||
const headers: ProxyRouteCustomHeader[] = [];
|
||||
|
||||
for (const line of lines) {
|
||||
const separatorIndex = line.indexOf(':');
|
||||
if (separatorIndex <= 0) {
|
||||
return {
|
||||
headers: [],
|
||||
error: `自定义请求头格式不合法:${line}`,
|
||||
};
|
||||
}
|
||||
|
||||
const key = line.slice(0, separatorIndex).trim();
|
||||
const headerValue = line.slice(separatorIndex + 1).trim();
|
||||
|
||||
if (!headerKeyPattern.test(key)) {
|
||||
return {
|
||||
headers: [],
|
||||
error: `自定义请求头名称不合法:${key}`,
|
||||
};
|
||||
}
|
||||
|
||||
headers.push({ key, value: headerValue });
|
||||
}
|
||||
|
||||
return { headers, error: null };
|
||||
}
|
||||
|
||||
export function customHeadersToText(headers: ProxyRouteCustomHeader[]) {
|
||||
return headers.map((header) => `${header.key}: ${header.value}`).join('\n');
|
||||
}
|
||||
|
||||
export function validateLimitRate(value: string) {
|
||||
const normalized = value.trim();
|
||||
if (!normalized || normalized === '0') {
|
||||
return null;
|
||||
}
|
||||
if (!limitRatePattern.test(normalized)) {
|
||||
return '限速格式不合法,请使用 512k、1m 或纯数字';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function normalizeLimitRate(value: string) {
|
||||
const normalized = value.trim().toLowerCase();
|
||||
return normalized === '0' ? '' : normalized;
|
||||
}
|
||||
|
||||
export function validateCacheRules(
|
||||
policy: 'url' | 'suffix' | 'path_prefix' | 'path_exact',
|
||||
rules: string[],
|
||||
) {
|
||||
if (policy === 'url') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (rules.length === 0) {
|
||||
return '当前缓存策略至少需要一条规则';
|
||||
}
|
||||
|
||||
if (policy === 'suffix') {
|
||||
for (const rule of rules) {
|
||||
const normalized = rule.replace(/^\./, '');
|
||||
if (!normalized || /[\/\\\s]/.test(normalized)) {
|
||||
return `缓存后缀格式不合法:${rule}`;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
for (const rule of rules) {
|
||||
if (!rule.startsWith('/') || rule.includes('://') || /[\s]/.test(rule)) {
|
||||
return `缓存路径规则格式不合法:${rule}`;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function buildPayloadFromRoute(
|
||||
route: ProxyRouteItem,
|
||||
overrides: Partial<ProxyRouteMutationPayload>,
|
||||
): ProxyRouteMutationPayload {
|
||||
const primaryOrigin =
|
||||
route.upstream_type === 'pages'
|
||||
? parseOriginUrl('http://127.0.0.1')
|
||||
: parseOriginUrl(route.origin_url);
|
||||
|
||||
return {
|
||||
site_name: route.site_name,
|
||||
domain: route.primary_domain,
|
||||
domains: route.domains,
|
||||
origin_id: null,
|
||||
origin_url: route.origin_url,
|
||||
origin_scheme: primaryOrigin.scheme,
|
||||
origin_address: primaryOrigin.address,
|
||||
origin_port: primaryOrigin.port,
|
||||
origin_uri: primaryOrigin.uri,
|
||||
origin_host: route.origin_host || '',
|
||||
upstreams: route.upstream_list.slice(1),
|
||||
enabled: route.enabled,
|
||||
enable_https: route.enable_https,
|
||||
cert_id: route.cert_id,
|
||||
cert_ids: route.cert_ids,
|
||||
domain_cert_ids: route.domain_cert_ids,
|
||||
redirect_http: route.redirect_http,
|
||||
limit_conn_per_server: route.limit_conn_per_server,
|
||||
limit_conn_per_ip: route.limit_conn_per_ip,
|
||||
limit_rate: route.limit_rate,
|
||||
cache_enabled: route.cache_enabled,
|
||||
cache_policy: route.cache_policy || 'url',
|
||||
cache_rules: route.cache_rule_list,
|
||||
custom_headers: route.custom_header_list,
|
||||
remark: route.remark || '',
|
||||
pow_enabled: route.pow_enabled,
|
||||
pow_config: JSON.stringify(route.pow_config),
|
||||
basic_auth_enabled: route.basic_auth_enabled,
|
||||
basic_auth_username: route.basic_auth_username,
|
||||
basic_auth_password: route.basic_auth_password,
|
||||
upstream_type: route.upstream_type,
|
||||
tunnel_node_id: route.tunnel_node_id ?? route.tunnel_id ?? null,
|
||||
tunnel_target_addr: route.tunnel_target_addr || '',
|
||||
tunnel_target_protocol: route.tunnel_target_protocol || '',
|
||||
pages_project_id: route.pages_project_id ?? null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
export function getUpstreamSummary(route: ProxyRouteItem) {
|
||||
if (route.upstream_type === 'pages') {
|
||||
return route.pages_project_id
|
||||
? `Pages 项目 #${route.pages_project_id}`
|
||||
: 'Pages 项目未绑定';
|
||||
}
|
||||
if (route.upstream_type === 'tunnel') {
|
||||
const protocol = route.tunnel_target_protocol || 'http';
|
||||
const target = route.tunnel_target_addr || '未配置目标';
|
||||
return `Tunnel → ${protocol}://${target}`;
|
||||
}
|
||||
if (route.upstream_list.length <= 1) {
|
||||
return route.origin_url;
|
||||
}
|
||||
return `${route.upstream_list.length} 个上游,主上游 ${route.origin_url}`;
|
||||
}
|
||||
|
||||
export function getWebsiteStatusBadges(route: ProxyRouteItem) {
|
||||
return [
|
||||
route.enabled
|
||||
? { label: '已启用', variant: 'success' as const }
|
||||
: { label: '已停用', variant: 'warning' as const },
|
||||
route.enable_https
|
||||
? { label: 'HTTPS', variant: 'info' as const }
|
||||
: { label: 'HTTP', variant: 'warning' as const },
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
export interface ProxyRouteCustomHeader {
|
||||
key: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface ProxyRoutePoWListConfig {
|
||||
ips: string[];
|
||||
ip_cidrs: string[];
|
||||
paths: string[];
|
||||
path_regexes: string[];
|
||||
user_agents: string[];
|
||||
}
|
||||
|
||||
export interface ProxyRoutePoWConfig {
|
||||
difficulty: number;
|
||||
algorithm: 'fast' | 'slow';
|
||||
session_ttl: number;
|
||||
challenge_ttl: number;
|
||||
whitelist: ProxyRoutePoWListConfig;
|
||||
blacklist: ProxyRoutePoWListConfig;
|
||||
}
|
||||
|
||||
export interface ProxyRouteItem {
|
||||
id: number;
|
||||
site_name: string;
|
||||
domain: string;
|
||||
domains: string[];
|
||||
primary_domain: string;
|
||||
domain_count: number;
|
||||
origin_id: number | null;
|
||||
origin_url: string;
|
||||
origin_host: string;
|
||||
upstreams: string;
|
||||
upstream_list: string[];
|
||||
enabled: boolean;
|
||||
enable_https: boolean;
|
||||
cert_id: number | null;
|
||||
cert_ids: number[];
|
||||
domain_cert_ids: number[];
|
||||
redirect_http: boolean;
|
||||
limit_conn_per_server: number;
|
||||
limit_conn_per_ip: number;
|
||||
limit_rate: string;
|
||||
cache_enabled: boolean;
|
||||
cache_policy: string;
|
||||
cache_rules: string;
|
||||
cache_rule_list: string[];
|
||||
custom_headers: string;
|
||||
custom_header_list: ProxyRouteCustomHeader[];
|
||||
pow_enabled: boolean;
|
||||
pow_config: ProxyRoutePoWConfig;
|
||||
basic_auth_enabled: boolean;
|
||||
basic_auth_username: string;
|
||||
basic_auth_password: string;
|
||||
remark: string;
|
||||
upstream_type: 'direct' | 'tunnel' | 'pages';
|
||||
tunnel_node_id?: number | null;
|
||||
tunnel_id?: number | null;
|
||||
tunnel_target_addr?: string;
|
||||
tunnel_target_protocol?: string;
|
||||
pages_project_id?: number | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface ProxyRouteMutationPayload {
|
||||
site_name?: string;
|
||||
domain: string;
|
||||
domains?: string[];
|
||||
origin_id: number | null;
|
||||
origin_url: string;
|
||||
origin_scheme: 'http' | 'https';
|
||||
origin_address: string;
|
||||
origin_port: string;
|
||||
origin_uri: string;
|
||||
origin_host: string;
|
||||
upstreams: string[];
|
||||
enabled: boolean;
|
||||
enable_https: boolean;
|
||||
cert_id: number | null;
|
||||
cert_ids?: number[];
|
||||
domain_cert_ids?: number[];
|
||||
redirect_http: boolean;
|
||||
limit_conn_per_server?: number;
|
||||
limit_conn_per_ip?: number;
|
||||
limit_rate?: string;
|
||||
cache_enabled: boolean;
|
||||
cache_policy: string;
|
||||
cache_rules: string[];
|
||||
custom_headers: ProxyRouteCustomHeader[];
|
||||
pow_enabled: boolean;
|
||||
pow_config: string;
|
||||
basic_auth_enabled: boolean;
|
||||
basic_auth_username?: string;
|
||||
basic_auth_password?: string;
|
||||
remark: string;
|
||||
upstream_type?: 'direct' | 'tunnel' | 'pages';
|
||||
tunnel_node_id?: number | null;
|
||||
tunnel_id?: number | null;
|
||||
tunnel_target_addr?: string;
|
||||
tunnel_target_protocol?: string;
|
||||
pages_project_id?: number | null;
|
||||
}
|
||||
|
||||
export interface TlsCertificateItem {
|
||||
id: number;
|
||||
name: string;
|
||||
not_after?: string | null;
|
||||
}
|
||||
|
||||
export interface ManagedDomainMatchCandidate {
|
||||
managed_domain_id: number;
|
||||
domain: string;
|
||||
match_type: 'exact' | 'wildcard';
|
||||
certificate_id: number;
|
||||
certificate_name: string;
|
||||
}
|
||||
|
||||
export interface ManagedDomainMatchResult {
|
||||
domain: string;
|
||||
matched: boolean;
|
||||
candidate?: ManagedDomainMatchCandidate;
|
||||
candidates: ManagedDomainMatchCandidate[];
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import type { ManagedDomainItem } from '@/features/managed-domains/types';
|
||||
|
||||
export function isWildcardManagedDomain(domain: string) {
|
||||
return domain.startsWith('*.');
|
||||
}
|
||||
|
||||
export function buildRouteDomain(
|
||||
managedDomain: string | undefined,
|
||||
subdomainLabel: string,
|
||||
) {
|
||||
if (!managedDomain) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (!isWildcardManagedDomain(managedDomain)) {
|
||||
return managedDomain.toLowerCase();
|
||||
}
|
||||
|
||||
const normalizedLabel = subdomainLabel.trim().toLowerCase();
|
||||
if (!normalizedLabel) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return `${normalizedLabel}.${managedDomain.slice(2).toLowerCase()}`;
|
||||
}
|
||||
|
||||
export function findManagedDomainForRoute(
|
||||
routeDomain: string,
|
||||
managedDomains: ManagedDomainItem[],
|
||||
) {
|
||||
const normalizedRouteDomain = routeDomain.trim().toLowerCase();
|
||||
const exactMatch = managedDomains.find(
|
||||
(item) => item.domain.toLowerCase() === normalizedRouteDomain,
|
||||
);
|
||||
|
||||
if (exactMatch) {
|
||||
return {
|
||||
managedDomainId: exactMatch.domain,
|
||||
subdomainLabel: '',
|
||||
};
|
||||
}
|
||||
|
||||
const wildcardMatch = managedDomains.find((item) => {
|
||||
if (!isWildcardManagedDomain(item.domain)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const suffix = item.domain.slice(2).toLowerCase();
|
||||
const suffixWithDot = `.${suffix}`;
|
||||
if (!normalizedRouteDomain.endsWith(suffixWithDot)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const label = normalizedRouteDomain.slice(
|
||||
0,
|
||||
normalizedRouteDomain.length - suffixWithDot.length,
|
||||
);
|
||||
|
||||
return Boolean(label) && !label.includes('.');
|
||||
});
|
||||
|
||||
if (!wildcardMatch) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
managedDomainId: wildcardMatch.domain,
|
||||
subdomainLabel: normalizedRouteDomain.slice(
|
||||
0,
|
||||
normalizedRouteDomain.length - wildcardMatch.domain.slice(1).length,
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import {apiRequest} from '@/lib/api/client';
|
||||
import {setStoredOpenFlareToken} from '@/lib/api/auth-token';
|
||||
|
||||
import type {
|
||||
AuthSource,
|
||||
AuthSourcePayload,
|
||||
BootstrapTokenPayload,
|
||||
DatabaseCleanupPayload,
|
||||
DatabaseCleanupResult,
|
||||
ExternalAccountBinding,
|
||||
GeoIPLookupResult,
|
||||
OptionBatchPayload,
|
||||
OptionItem,
|
||||
SettingsProfile,
|
||||
UpdateSelfPayload,
|
||||
} from '@/features/settings/types';
|
||||
|
||||
export function getOptions() {
|
||||
return apiRequest<OptionItem[]>('/option/');
|
||||
}
|
||||
|
||||
export function updateOption(key: string, value: string) {
|
||||
return apiRequest<void>('/option/update', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ key, value }),
|
||||
});
|
||||
}
|
||||
|
||||
export function updateOptions(options: OptionBatchPayload['options']) {
|
||||
return apiRequest<void>('/option/update-batch', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ options }),
|
||||
});
|
||||
}
|
||||
|
||||
export function lookupGeoIP(provider: string, ip: string) {
|
||||
return apiRequest<GeoIPLookupResult>('/option/geoip/lookup', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ provider, ip }),
|
||||
});
|
||||
}
|
||||
|
||||
export function cleanupDatabaseObservability(payload: DatabaseCleanupPayload) {
|
||||
return apiRequest<DatabaseCleanupResult>('/option/database/cleanup', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export function getAuthSources() {
|
||||
return apiRequest<AuthSource[]>('/auth-sources/');
|
||||
}
|
||||
|
||||
export function createAuthSource(payload: AuthSourcePayload) {
|
||||
return apiRequest<AuthSource>('/auth-sources/', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export function updateAuthSource(id: number, payload: AuthSourcePayload) {
|
||||
return apiRequest<AuthSource>(`/auth-sources/${id}/update`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export function toggleAuthSource(id: number, isActive: boolean) {
|
||||
return apiRequest<void>(`/auth-sources/${id}/toggle`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ is_active: isActive }),
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteAuthSource(id: number) {
|
||||
return apiRequest<void>(`/auth-sources/${id}/delete`, {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
export function getExternalAccountBindings() {
|
||||
return apiRequest<ExternalAccountBinding[]>('/oauth/external-accounts/');
|
||||
}
|
||||
|
||||
export function deleteExternalAccountBinding(id: number) {
|
||||
return apiRequest<void>(`/oauth/external-accounts/${id}/delete`, {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
export function getBootstrapToken() {
|
||||
return apiRequest<BootstrapTokenPayload>('/nodes/bootstrap-token');
|
||||
}
|
||||
|
||||
export function rotateBootstrapToken() {
|
||||
return apiRequest<BootstrapTokenPayload>('/nodes/bootstrap-token/rotate', {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
export function getSettingsProfile() {
|
||||
return apiRequest<SettingsProfile>('/user/self');
|
||||
}
|
||||
|
||||
export function updateSelf(payload: UpdateSelfPayload) {
|
||||
return apiRequest<void>('/user/self/update', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export function generateAccessToken() {
|
||||
return apiRequest<string>('/user/token').then((token) => {
|
||||
setStoredOpenFlareToken(token);
|
||||
return token;
|
||||
});
|
||||
}
|
||||
|
||||
export function bindWeChat(code: string) {
|
||||
return apiRequest<void>(
|
||||
`/oauth/wechat/bind?code=${encodeURIComponent(code)}`,
|
||||
);
|
||||
}
|
||||
|
||||
export function bindEmail(email: string, code: string) {
|
||||
const searchParams = new URLSearchParams({ email, code });
|
||||
return apiRequest<void>(`/oauth/email/bind?${searchParams.toString()}`);
|
||||
}
|
||||
|
||||
export function getAboutContent() {
|
||||
return apiRequest<string>('/about');
|
||||
}
|
||||
|
||||
export function syncUptimeKuma() {
|
||||
return apiRequest<void>('/uptimekuma/sync', {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,447 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { ErrorState } from '@/components/feedback/error-state';
|
||||
import { InlineMessage } from '@/components/feedback/inline-message';
|
||||
import { LoadingState } from '@/components/feedback/loading-state';
|
||||
import { AppModal } from '@/components/ui/app-modal';
|
||||
import {
|
||||
createAuthSource,
|
||||
deleteAuthSource,
|
||||
toggleAuthSource,
|
||||
updateAuthSource,
|
||||
} from '@/features/settings/api/settings';
|
||||
import type {
|
||||
AuthSource,
|
||||
AuthSourcePayload,
|
||||
AuthSourceType,
|
||||
} from '@/features/settings/types';
|
||||
import {
|
||||
DangerButton,
|
||||
PrimaryButton,
|
||||
ResourceField,
|
||||
ResourceInput,
|
||||
ResourceSelect,
|
||||
SecondaryButton,
|
||||
} from '@/features/shared/components/resource-primitives';
|
||||
|
||||
const emptyForm: AuthSourcePayload = {
|
||||
name: '',
|
||||
type: 'github',
|
||||
display_name: '',
|
||||
is_active: false,
|
||||
client_id: '',
|
||||
client_secret: '',
|
||||
openid_discovery_url: '',
|
||||
scopes: 'user:email',
|
||||
icon_url: '',
|
||||
};
|
||||
|
||||
function getErrorMessage(error: unknown) {
|
||||
return error instanceof Error ? error.message : '请求失败,请稍后重试。';
|
||||
}
|
||||
|
||||
function sourceToForm(source: AuthSource): AuthSourcePayload {
|
||||
return {
|
||||
id: source.id,
|
||||
name: source.name,
|
||||
type: source.type,
|
||||
display_name: source.display_name,
|
||||
is_active: source.is_active,
|
||||
client_id: source.client_id,
|
||||
client_secret: '',
|
||||
openid_discovery_url: source.openid_discovery_url,
|
||||
scopes:
|
||||
source.scopes ||
|
||||
(source.type === 'oidc' ? 'openid profile email' : 'user:email'),
|
||||
icon_url: source.icon_url,
|
||||
};
|
||||
}
|
||||
|
||||
function buildCallbackURL(origin: string, sourceName: string) {
|
||||
const normalizedName = sourceName.trim() || '认证源名称';
|
||||
return `${origin || '当前访问地址'}/oauth/${encodeURIComponent(normalizedName)}`;
|
||||
}
|
||||
|
||||
export function AuthSourceModal({
|
||||
isOpen,
|
||||
sources,
|
||||
isLoading,
|
||||
error,
|
||||
onClose,
|
||||
onChanged,
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
sources: AuthSource[];
|
||||
isLoading: boolean;
|
||||
error: unknown;
|
||||
onClose: () => void;
|
||||
onChanged: () => Promise<void>;
|
||||
}) {
|
||||
const [mode, setMode] = useState<'list' | 'edit'>('list');
|
||||
const [editingSource, setEditingSource] = useState<AuthSource | null>(null);
|
||||
const [form, setForm] = useState<AuthSourcePayload>(emptyForm);
|
||||
const [busyKey, setBusyKey] = useState<string | null>(null);
|
||||
const [message, setMessage] = useState<{
|
||||
tone: 'success' | 'danger' | 'info';
|
||||
text: string;
|
||||
} | null>(null);
|
||||
const [browserOrigin, setBrowserOrigin] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
setMode('list');
|
||||
setEditingSource(null);
|
||||
setForm(emptyForm);
|
||||
setBusyKey(null);
|
||||
setMessage(null);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
setBrowserOrigin(window.location.origin);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const startCreate = () => {
|
||||
setEditingSource(null);
|
||||
setForm(emptyForm);
|
||||
setMessage(null);
|
||||
setMode('edit');
|
||||
};
|
||||
|
||||
const startEdit = (source: AuthSource) => {
|
||||
setEditingSource(source);
|
||||
setForm(sourceToForm(source));
|
||||
setMessage(null);
|
||||
setMode('edit');
|
||||
};
|
||||
|
||||
const updateType = (type: AuthSourceType) => {
|
||||
setForm((previous) => ({
|
||||
...previous,
|
||||
type,
|
||||
scopes:
|
||||
previous.scopes === 'user:email' ||
|
||||
previous.scopes === 'openid profile email' ||
|
||||
!previous.scopes
|
||||
? type === 'oidc'
|
||||
? 'openid profile email'
|
||||
: 'user:email'
|
||||
: previous.scopes,
|
||||
}));
|
||||
};
|
||||
|
||||
const runAction = async (key: string, action: () => Promise<void>) => {
|
||||
setBusyKey(key);
|
||||
setMessage(null);
|
||||
try {
|
||||
await action();
|
||||
} catch (actionError) {
|
||||
setMessage({ tone: 'danger', text: getErrorMessage(actionError) });
|
||||
} finally {
|
||||
setBusyKey(null);
|
||||
}
|
||||
};
|
||||
|
||||
const saveForm = () => {
|
||||
void runAction('save', async () => {
|
||||
const payload: AuthSourcePayload = {
|
||||
...form,
|
||||
name: form.name.trim(),
|
||||
display_name: form.display_name.trim(),
|
||||
client_id: form.client_id.trim(),
|
||||
client_secret: form.client_secret.trim(),
|
||||
openid_discovery_url: form.openid_discovery_url.trim(),
|
||||
scopes: form.scopes.trim(),
|
||||
icon_url: form.icon_url.trim(),
|
||||
};
|
||||
if (editingSource) {
|
||||
await updateAuthSource(editingSource.id, payload);
|
||||
} else {
|
||||
await createAuthSource(payload);
|
||||
}
|
||||
await onChanged();
|
||||
setMessage({ tone: 'success', text: '认证源已保存。' });
|
||||
setMode('list');
|
||||
setEditingSource(null);
|
||||
setForm(emptyForm);
|
||||
});
|
||||
};
|
||||
|
||||
const removeSource = (source: AuthSource) => {
|
||||
if (
|
||||
!window.confirm(
|
||||
`确定删除认证源「${source.display_name || source.name}」吗?`,
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
void runAction(`delete-${source.id}`, async () => {
|
||||
await deleteAuthSource(source.id);
|
||||
await onChanged();
|
||||
setMessage({ tone: 'success', text: '认证源已删除。' });
|
||||
});
|
||||
};
|
||||
|
||||
const toggleSource = (source: AuthSource) => {
|
||||
void runAction(`toggle-${source.id}`, async () => {
|
||||
await toggleAuthSource(source.id, !source.is_active);
|
||||
await onChanged();
|
||||
setMessage({ tone: 'success', text: '认证源状态已更新。' });
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<AppModal
|
||||
isOpen={isOpen}
|
||||
title="认证源"
|
||||
description="配置 GitHub 或标准 OIDC 登录入口。启用后会显示在登录页。"
|
||||
size="xl"
|
||||
onClose={onClose}
|
||||
footer={
|
||||
mode === 'edit' ? (
|
||||
<div className="flex justify-end gap-3">
|
||||
<SecondaryButton type="button" onClick={() => setMode('list')}>
|
||||
返回列表
|
||||
</SecondaryButton>
|
||||
<PrimaryButton
|
||||
type="button"
|
||||
onClick={saveForm}
|
||||
disabled={busyKey === 'save'}
|
||||
>
|
||||
{busyKey === 'save' ? '保存中...' : '保存认证源'}
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex justify-end">
|
||||
<SecondaryButton type="button" onClick={onClose}>
|
||||
关闭
|
||||
</SecondaryButton>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
>
|
||||
<div className="space-y-5">
|
||||
{message ? (
|
||||
<InlineMessage tone={message.tone} message={message.text} />
|
||||
) : null}
|
||||
|
||||
{mode === 'list' ? (
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-end">
|
||||
<PrimaryButton type="button" onClick={startCreate}>
|
||||
新增认证源
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
{isLoading ? <LoadingState /> : null}
|
||||
{error ? (
|
||||
<ErrorState
|
||||
title="认证源加载失败"
|
||||
description={getErrorMessage(error)}
|
||||
/>
|
||||
) : null}
|
||||
{!isLoading && !error && sources.length === 0 ? (
|
||||
<div className="rounded-2xl border border-dashed border-[var(--border-default)] px-5 py-8 text-center text-sm text-[var(--foreground-secondary)]">
|
||||
暂无认证源。
|
||||
</div>
|
||||
) : null}
|
||||
{!isLoading && !error && sources.length > 0 ? (
|
||||
<div className="overflow-hidden rounded-2xl border border-[var(--border-default)]">
|
||||
<table className="w-full min-w-[720px] text-left text-sm">
|
||||
<thead className="bg-[var(--surface-elevated)] text-xs text-[var(--foreground-secondary)] uppercase">
|
||||
<tr>
|
||||
<th className="px-4 py-3 font-medium">名称</th>
|
||||
<th className="px-4 py-3 font-medium">类型</th>
|
||||
<th className="px-4 py-3 font-medium">状态</th>
|
||||
<th className="px-4 py-3 font-medium">Client ID</th>
|
||||
<th className="px-4 py-3 text-right font-medium">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-[var(--border-default)]">
|
||||
{sources.map((source) => (
|
||||
<tr key={source.id}>
|
||||
<td className="px-4 py-3">
|
||||
<div className="font-medium text-[var(--foreground-primary)]">
|
||||
{source.display_name || source.name}
|
||||
</div>
|
||||
<div className="text-xs text-[var(--foreground-secondary)]">
|
||||
{source.name}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 uppercase">{source.type}</td>
|
||||
<td className="px-4 py-3">
|
||||
{source.is_active ? '已启用' : '已禁用'}
|
||||
</td>
|
||||
<td className="max-w-[220px] truncate px-4 py-3">
|
||||
{source.client_id || '-'}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
<SecondaryButton
|
||||
type="button"
|
||||
onClick={() => toggleSource(source)}
|
||||
disabled={busyKey === `toggle-${source.id}`}
|
||||
>
|
||||
{source.is_active ? '禁用' : '启用'}
|
||||
</SecondaryButton>
|
||||
<SecondaryButton
|
||||
type="button"
|
||||
onClick={() => startEdit(source)}
|
||||
>
|
||||
修改
|
||||
</SecondaryButton>
|
||||
<DangerButton
|
||||
type="button"
|
||||
onClick={() => removeSource(source)}
|
||||
disabled={busyKey === `delete-${source.id}`}
|
||||
>
|
||||
删除
|
||||
</DangerButton>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-5 md:grid-cols-2">
|
||||
<ResourceField label="认证源名称">
|
||||
<ResourceInput
|
||||
value={form.name}
|
||||
onChange={(event) =>
|
||||
setForm((previous) => ({
|
||||
...previous,
|
||||
name: event.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="GitHub"
|
||||
/>
|
||||
</ResourceField>
|
||||
<ResourceField label="展示名称">
|
||||
<ResourceInput
|
||||
value={form.display_name}
|
||||
onChange={(event) =>
|
||||
setForm((previous) => ({
|
||||
...previous,
|
||||
display_name: event.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="GitHub"
|
||||
/>
|
||||
</ResourceField>
|
||||
<ResourceField label="类型">
|
||||
<ResourceSelect
|
||||
value={form.type}
|
||||
onChange={(event) =>
|
||||
updateType(event.target.value as AuthSourceType)
|
||||
}
|
||||
>
|
||||
<option value="github">GitHub</option>
|
||||
<option value="oidc">OIDC</option>
|
||||
</ResourceSelect>
|
||||
</ResourceField>
|
||||
<ResourceField label="状态">
|
||||
<ResourceSelect
|
||||
value={form.is_active ? 'true' : 'false'}
|
||||
onChange={(event) =>
|
||||
setForm((previous) => ({
|
||||
...previous,
|
||||
is_active: event.target.value === 'true',
|
||||
}))
|
||||
}
|
||||
>
|
||||
<option value="false">禁用</option>
|
||||
<option value="true">启用</option>
|
||||
</ResourceSelect>
|
||||
</ResourceField>
|
||||
<ResourceField label="Client ID">
|
||||
<ResourceInput
|
||||
value={form.client_id}
|
||||
onChange={(event) =>
|
||||
setForm((previous) => ({
|
||||
...previous,
|
||||
client_id: event.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</ResourceField>
|
||||
<ResourceField
|
||||
label="Client Secret"
|
||||
hint={editingSource ? '留空表示不更新现有密钥。' : undefined}
|
||||
>
|
||||
<ResourceInput
|
||||
type="password"
|
||||
value={form.client_secret}
|
||||
onChange={(event) =>
|
||||
setForm((previous) => ({
|
||||
...previous,
|
||||
client_secret: event.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</ResourceField>
|
||||
<div className="rounded-2xl border border-[var(--status-info-border)] bg-[var(--status-info-soft)] px-4 py-3 text-sm leading-6 text-[var(--status-info-foreground)] md:col-span-2">
|
||||
{form.name.trim() ? (
|
||||
<>
|
||||
第三方平台的 Redirect URI / Callback URL 请填写:
|
||||
<span className="font-medium">
|
||||
{' '}
|
||||
{buildCallbackURL(browserOrigin, form.name)}
|
||||
</span>
|
||||
。末尾路径使用上方填写的认证源名称,保存后如修改认证源名称,也需要同步更新第三方平台中的回调地址。
|
||||
</>
|
||||
) : (
|
||||
'请先输入认证源名称获取回调地址'
|
||||
)}
|
||||
</div>
|
||||
{form.type === 'oidc' ? (
|
||||
<ResourceField
|
||||
label="OIDC Discovery URL"
|
||||
className="md:col-span-2"
|
||||
>
|
||||
<ResourceInput
|
||||
value={form.openid_discovery_url}
|
||||
onChange={(event) =>
|
||||
setForm((previous) => ({
|
||||
...previous,
|
||||
openid_discovery_url: event.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="https://auth.example.com/.well-known/openid-configuration"
|
||||
/>
|
||||
</ResourceField>
|
||||
) : null}
|
||||
<ResourceField label="Scopes">
|
||||
<ResourceInput
|
||||
value={form.scopes}
|
||||
onChange={(event) =>
|
||||
setForm((previous) => ({
|
||||
...previous,
|
||||
scopes: event.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</ResourceField>
|
||||
<ResourceField label="图标 URL">
|
||||
<ResourceInput
|
||||
value={form.icon_url}
|
||||
onChange={(event) =>
|
||||
setForm((previous) => ({
|
||||
...previous,
|
||||
icon_url: event.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</ResourceField>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</AppModal>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,223 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { ErrorState } from '@/components/feedback/error-state';
|
||||
import { LoadingState } from '@/components/feedback/loading-state';
|
||||
import { AppModal } from '@/components/ui/app-modal';
|
||||
import { getProxyRoutes } from '@/features/proxy-routes/api/proxy-routes';
|
||||
import {
|
||||
PrimaryButton,
|
||||
ResourceField,
|
||||
ResourceInput,
|
||||
SecondaryButton,
|
||||
} from '@/features/shared/components/resource-primitives';
|
||||
|
||||
function getErrorMessage(error: unknown) {
|
||||
return error instanceof Error ? error.message : '加载站点列表失败。';
|
||||
}
|
||||
|
||||
export function UptimeKumaSiteSelectModal({
|
||||
isOpen,
|
||||
selectedSites,
|
||||
onClose,
|
||||
onSave,
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
selectedSites: string[];
|
||||
onClose: () => void;
|
||||
onSave: (sites: string[]) => void;
|
||||
}) {
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [tempSelected, setTempSelected] = useState<Set<string>>(new Set());
|
||||
|
||||
const {
|
||||
data: routes = [],
|
||||
isLoading,
|
||||
error,
|
||||
} = useQuery({
|
||||
queryKey: ['proxy-routes'],
|
||||
queryFn: getProxyRoutes,
|
||||
enabled: isOpen,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setTempSelected(
|
||||
new Set(selectedSites.map((s) => s.trim()).filter(Boolean)),
|
||||
);
|
||||
setSearchTerm('');
|
||||
}
|
||||
}, [isOpen, selectedSites]);
|
||||
|
||||
const toggleSite = (siteName: string) => {
|
||||
setTempSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(siteName)) {
|
||||
next.delete(siteName);
|
||||
} else {
|
||||
next.add(siteName);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleSelectAll = () => {
|
||||
setTempSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
filteredRoutes.forEach((route) => {
|
||||
next.add(route.site_name);
|
||||
});
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleDeselectAll = () => {
|
||||
setTempSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
filteredRoutes.forEach((route) => {
|
||||
next.delete(route.site_name);
|
||||
});
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
onSave(Array.from(tempSelected));
|
||||
onClose();
|
||||
};
|
||||
|
||||
const filteredRoutes = routes.filter(
|
||||
(route) =>
|
||||
route.site_name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
route.primary_domain.toLowerCase().includes(searchTerm.toLowerCase()),
|
||||
);
|
||||
|
||||
return (
|
||||
<AppModal
|
||||
isOpen={isOpen}
|
||||
title="选择监控站点"
|
||||
description="请选择要同步到 Uptime Kuma 监控的站点,支持按站点名称和域名搜索。"
|
||||
size="lg"
|
||||
onClose={onClose}
|
||||
footer={
|
||||
<div className="flex justify-end gap-3">
|
||||
<SecondaryButton type="button" onClick={onClose}>
|
||||
取消
|
||||
</SecondaryButton>
|
||||
<PrimaryButton type="button" onClick={handleSave}>
|
||||
保存选择
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||
<div className="flex-1">
|
||||
<ResourceField label="搜索站点">
|
||||
<ResourceInput
|
||||
value={searchTerm}
|
||||
onChange={(event) => setSearchTerm(event.target.value)}
|
||||
placeholder="按名称或域名搜索..."
|
||||
/>
|
||||
</ResourceField>
|
||||
</div>
|
||||
<div className="flex gap-2 sm:mt-6">
|
||||
<SecondaryButton type="button" onClick={handleSelectAll}>
|
||||
全选过滤项
|
||||
</SecondaryButton>
|
||||
<SecondaryButton type="button" onClick={handleDeselectAll}>
|
||||
清空过滤项
|
||||
</SecondaryButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading ? <LoadingState /> : null}
|
||||
|
||||
{error ? (
|
||||
<ErrorState
|
||||
title="站点加载失败"
|
||||
description={getErrorMessage(error)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{!isLoading && !error && routes.length === 0 ? (
|
||||
<div className="rounded-2xl border border-dashed border-[var(--border-default)] px-5 py-8 text-center text-sm text-[var(--foreground-secondary)]">
|
||||
暂无可用的代理站点。
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!isLoading && !error && routes.length > 0 ? (
|
||||
<div className="max-h-[350px] overflow-y-auto rounded-2xl border border-[var(--border-default)] bg-[var(--surface-base)]">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead className="sticky top-0 bg-[var(--surface-elevated)] text-xs text-[var(--foreground-secondary)] uppercase">
|
||||
<tr>
|
||||
<th className="w-12 px-4 py-3">选择</th>
|
||||
<th className="px-4 py-3 font-medium">站点名称</th>
|
||||
<th className="px-4 py-3 font-medium">主域名</th>
|
||||
<th className="px-4 py-3 font-medium">状态</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-[var(--border-default)]">
|
||||
{filteredRoutes.map((route) => {
|
||||
const isChecked = tempSelected.has(route.site_name);
|
||||
return (
|
||||
<tr
|
||||
key={route.id}
|
||||
onClick={() => toggleSite(route.site_name)}
|
||||
className="cursor-pointer hover:bg-[var(--surface-elevated)]"
|
||||
>
|
||||
<td
|
||||
className="px-4 py-3"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isChecked}
|
||||
onChange={() => toggleSite(route.site_name)}
|
||||
className="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-4 py-3 font-medium text-[var(--foreground-primary)]">
|
||||
{route.site_name}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-[var(--foreground-secondary)]">
|
||||
{route.primary_domain}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span
|
||||
className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${
|
||||
route.enabled
|
||||
? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400'
|
||||
: 'bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-400'
|
||||
}`}
|
||||
>
|
||||
{route.enabled ? '启用' : '禁用'}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
{filteredRoutes.length === 0 ? (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={4}
|
||||
className="px-4 py-8 text-center text-[var(--foreground-secondary)]"
|
||||
>
|
||||
无匹配的站点
|
||||
</td>
|
||||
</tr>
|
||||
) : null}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="text-right text-xs text-[var(--foreground-muted)]">
|
||||
已选择 {tempSelected.size} 个监控站点
|
||||
</div>
|
||||
</div>
|
||||
</AppModal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import type { AuthUser } from '@/types/auth';
|
||||
|
||||
export interface OptionItem {
|
||||
key: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface OptionBatchPayload {
|
||||
options: OptionItem[];
|
||||
}
|
||||
|
||||
export type AuthSourceType = 'github' | 'oidc';
|
||||
|
||||
export interface AuthSource {
|
||||
id: number;
|
||||
name: string;
|
||||
type: AuthSourceType;
|
||||
display_name: string;
|
||||
is_active: boolean;
|
||||
client_id: string;
|
||||
client_secret?: string;
|
||||
client_secret_configured?: boolean;
|
||||
openid_discovery_url: string;
|
||||
scopes: string;
|
||||
icon_url: string;
|
||||
}
|
||||
|
||||
export interface ExternalAccountBinding {
|
||||
id: number;
|
||||
auth_source_id: number;
|
||||
auth_source_name: string;
|
||||
auth_source_type: AuthSourceType;
|
||||
auth_source_label: string;
|
||||
external_username: string;
|
||||
email: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export type AuthSourcePayload = Omit<
|
||||
AuthSource,
|
||||
'id' | 'client_secret_configured'
|
||||
> & {
|
||||
id?: number;
|
||||
client_secret: string;
|
||||
};
|
||||
|
||||
export interface BootstrapTokenPayload {
|
||||
discovery_token: string;
|
||||
}
|
||||
|
||||
export interface GeoIPLookupResult {
|
||||
provider: string;
|
||||
ip: string;
|
||||
iso_code: string;
|
||||
name: string;
|
||||
latitude?: number | null;
|
||||
longitude?: number | null;
|
||||
}
|
||||
|
||||
export type DatabaseCleanupTarget =
|
||||
| 'node_access_logs'
|
||||
| 'node_metric_snapshots'
|
||||
| 'node_request_reports';
|
||||
|
||||
export interface DatabaseCleanupPayload {
|
||||
target: DatabaseCleanupTarget;
|
||||
retention_days?: number;
|
||||
}
|
||||
|
||||
export interface DatabaseCleanupResult {
|
||||
target: DatabaseCleanupTarget;
|
||||
target_label: string;
|
||||
deleted_count: number;
|
||||
delete_all: boolean;
|
||||
retention_days?: number;
|
||||
cutoff?: string;
|
||||
}
|
||||
|
||||
export interface UpdateSelfPayload {
|
||||
username: string;
|
||||
display_name: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export type SettingsProfile = AuthUser;
|
||||
@@ -0,0 +1,43 @@
|
||||
import {EmptyState} from '@/components/feedback/empty-state';
|
||||
import {ErrorState} from '@/components/feedback/error-state';
|
||||
import {FeaturePlaceholder} from '@/components/feedback/feature-placeholder';
|
||||
import {LoadingState} from '@/components/feedback/loading-state';
|
||||
import {PageHeader} from '@/components/layout/page-header';
|
||||
|
||||
interface ModulePageSkeletonProps {
|
||||
title: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export function ModulePageSkeleton({
|
||||
title,
|
||||
description,
|
||||
}: ModulePageSkeletonProps) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader title={title} description={description} />
|
||||
|
||||
<FeaturePlaceholder
|
||||
title={`${title} 页面骨架`}
|
||||
description="当前阶段先完成页面入口、统一头部和反馈组件接线,真实列表、表单与联动能力将在后续业务迁移阶段接入。"
|
||||
milestones={[
|
||||
'路由入口已创建,可纳入统一导航。',
|
||||
'页面标题区、说明区与内容容器已统一。',
|
||||
'加载态、空态、错误态组件已建立,可在真实查询接入后直接复用。',
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="grid gap-4 xl:grid-cols-3">
|
||||
<LoadingState />
|
||||
<EmptyState
|
||||
title="空态骨架"
|
||||
description="后续接入查询后,可将资源为空时的提示统一沉淀为此组件。"
|
||||
/>
|
||||
<ErrorState
|
||||
title="错误态骨架"
|
||||
description="后续请求失败、鉴权失效与重试提示可在此基础上扩展。"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
import type {
|
||||
ButtonHTMLAttributes,
|
||||
InputHTMLAttributes,
|
||||
ReactNode,
|
||||
SelectHTMLAttributes,
|
||||
TextareaHTMLAttributes,
|
||||
} from 'react';
|
||||
|
||||
import {cn} from '@/lib/utils/cn';
|
||||
|
||||
interface ResourceFieldProps {
|
||||
label: ReactNode;
|
||||
hint?: ReactNode;
|
||||
error?: string;
|
||||
className?: string;
|
||||
tooltip?: string;
|
||||
container?: 'label' | 'div';
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
function FieldTooltip({ content }: { content: string }) {
|
||||
return (
|
||||
<span className="group/tooltip relative inline-flex">
|
||||
<span className="inline-flex h-5 w-5 items-center justify-center rounded-full border border-[var(--border-default)] bg-[var(--surface-elevated)] text-[11px] font-semibold text-[var(--foreground-secondary)] transition group-hover/tooltip:border-[var(--border-strong)] group-hover/tooltip:text-[var(--foreground-primary)]">
|
||||
?
|
||||
</span>
|
||||
<span className="pointer-events-none absolute top-full left-1/2 z-20 mt-2 w-64 -translate-x-1/2 rounded-2xl border border-[var(--border-default)] bg-[var(--surface-panel)] px-3 py-2 text-xs leading-5 text-[var(--foreground-secondary)] opacity-0 shadow-[var(--shadow-soft)] transition-opacity delay-700 duration-200 group-hover/tooltip:opacity-100">
|
||||
{content}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function ResourceField({
|
||||
label,
|
||||
hint,
|
||||
error,
|
||||
className,
|
||||
tooltip,
|
||||
container = 'label',
|
||||
children,
|
||||
}: ResourceFieldProps) {
|
||||
const Container = container;
|
||||
|
||||
return (
|
||||
<Container className={cn('block space-y-2', className)}>
|
||||
<span className="flex items-center gap-2 text-sm font-medium text-[var(--foreground-primary)]">
|
||||
<span>{label}</span>
|
||||
{tooltip ? <FieldTooltip content={tooltip} /> : null}
|
||||
</span>
|
||||
{children}
|
||||
{error ? (
|
||||
<span className="block text-xs text-[var(--status-danger-foreground)]">
|
||||
{error}
|
||||
</span>
|
||||
) : hint ? (
|
||||
<span className="block text-xs text-[var(--foreground-secondary)]">
|
||||
{hint}
|
||||
</span>
|
||||
) : null}
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export function ResourceInput(props: InputHTMLAttributes<HTMLInputElement>) {
|
||||
return (
|
||||
<input
|
||||
{...props}
|
||||
className={cn(
|
||||
'w-full rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-3 text-sm text-[var(--foreground-primary)] transition outline-none placeholder:text-[var(--foreground-muted)] focus:border-[var(--border-strong)] focus:ring-2 focus:ring-[var(--accent-soft)] disabled:cursor-not-allowed disabled:opacity-60',
|
||||
props.className,
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function ResourceTextarea(
|
||||
props: TextareaHTMLAttributes<HTMLTextAreaElement>,
|
||||
) {
|
||||
return (
|
||||
<textarea
|
||||
{...props}
|
||||
className={cn(
|
||||
'min-h-28 w-full rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-3 text-sm text-[var(--foreground-primary)] transition outline-none placeholder:text-[var(--foreground-muted)] focus:border-[var(--border-strong)] focus:ring-2 focus:ring-[var(--accent-soft)] disabled:cursor-not-allowed disabled:opacity-60',
|
||||
props.className,
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function ResourceSelect(props: SelectHTMLAttributes<HTMLSelectElement>) {
|
||||
return (
|
||||
<select
|
||||
{...props}
|
||||
className={cn(
|
||||
'w-full rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-3 text-sm text-[var(--foreground-primary)] transition outline-none focus:border-[var(--border-strong)] focus:ring-2 focus:ring-[var(--accent-soft)] disabled:cursor-not-allowed disabled:opacity-60',
|
||||
props.className,
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function baseButtonClassName(className?: string) {
|
||||
return cn(
|
||||
'inline-flex items-center justify-center rounded-2xl px-4 py-3 text-sm font-medium transition disabled:cursor-not-allowed disabled:opacity-60',
|
||||
className,
|
||||
);
|
||||
}
|
||||
|
||||
export function PrimaryButton({
|
||||
className,
|
||||
...props
|
||||
}: ButtonHTMLAttributes<HTMLButtonElement>) {
|
||||
return (
|
||||
<button
|
||||
{...props}
|
||||
className={baseButtonClassName(
|
||||
cn(
|
||||
'bg-[var(--brand-primary)] text-[var(--foreground-inverse)] hover:opacity-90',
|
||||
className,
|
||||
),
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function SecondaryButton({
|
||||
className,
|
||||
...props
|
||||
}: ButtonHTMLAttributes<HTMLButtonElement>) {
|
||||
return (
|
||||
<button
|
||||
{...props}
|
||||
className={baseButtonClassName(
|
||||
cn(
|
||||
'border border-[var(--border-default)] bg-[var(--control-background)] text-[var(--foreground-primary)] hover:bg-[var(--control-background-hover)]',
|
||||
className,
|
||||
),
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function DangerButton({
|
||||
className,
|
||||
...props
|
||||
}: ButtonHTMLAttributes<HTMLButtonElement>) {
|
||||
return (
|
||||
<button
|
||||
{...props}
|
||||
className={baseButtonClassName(
|
||||
cn(
|
||||
'border border-[var(--status-danger-border)] bg-[var(--status-danger-soft)] text-[var(--status-danger-foreground)] hover:opacity-90',
|
||||
className,
|
||||
),
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface ToggleFieldProps {
|
||||
label: string;
|
||||
description?: string;
|
||||
checked: boolean;
|
||||
disabled?: boolean;
|
||||
tooltip?: string;
|
||||
onChange: (checked: boolean) => void;
|
||||
}
|
||||
|
||||
export function ToggleField({
|
||||
label,
|
||||
description,
|
||||
checked,
|
||||
disabled,
|
||||
tooltip,
|
||||
onChange,
|
||||
}: ToggleFieldProps) {
|
||||
return (
|
||||
<label className="flex cursor-pointer items-start gap-3 self-start rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
disabled={disabled}
|
||||
onChange={(event) => onChange(event.target.checked)}
|
||||
className="mt-1 h-4 w-4 rounded border-[var(--border-default)] accent-[var(--brand-primary)]"
|
||||
/>
|
||||
<span className="flex flex-col">
|
||||
<span className="flex items-center gap-2 text-sm font-medium text-[var(--foreground-primary)]">
|
||||
<span>{label}</span>
|
||||
{tooltip ? <FieldTooltip content={tooltip} /> : null}
|
||||
</span>
|
||||
{description ? (
|
||||
<span className="mt-1 block text-xs leading-5 text-[var(--foreground-secondary)]">
|
||||
{description}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export function CodeBlock({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<pre
|
||||
className={cn(
|
||||
'overflow-x-auto rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4 text-xs leading-6 text-[var(--foreground-primary)]',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { apiRequest } from '@/lib/api/client';
|
||||
|
||||
import type {
|
||||
TlsCertificateContentItem,
|
||||
TlsCertificateDetailItem,
|
||||
TlsCertificateFileImportPayload,
|
||||
TlsCertificateItem,
|
||||
TlsCertificateMutationPayload,
|
||||
TlsCertificateApplyPayload,
|
||||
} from '@/features/tls-certificates/types';
|
||||
|
||||
export function getTlsCertificates() {
|
||||
return apiRequest<TlsCertificateItem[]>('/tls-certificates/');
|
||||
}
|
||||
|
||||
export function createTlsCertificate(payload: TlsCertificateMutationPayload) {
|
||||
return apiRequest<TlsCertificateItem>('/tls-certificates/', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export function getTlsCertificate(id: number) {
|
||||
return apiRequest<TlsCertificateDetailItem>(`/tls-certificates/${id}`);
|
||||
}
|
||||
|
||||
export function getTlsCertificateContent(id: number) {
|
||||
return apiRequest<TlsCertificateContentItem>(
|
||||
`/tls-certificates/${id}/content`,
|
||||
);
|
||||
}
|
||||
|
||||
export function updateTlsCertificate(
|
||||
id: number,
|
||||
payload: TlsCertificateMutationPayload,
|
||||
) {
|
||||
return apiRequest<TlsCertificateItem>(`/tls-certificates/${id}/update`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export function importTlsCertificateFiles(
|
||||
payload: TlsCertificateFileImportPayload,
|
||||
) {
|
||||
const formData = new FormData();
|
||||
formData.append('name', payload.name);
|
||||
formData.append('remark', payload.remark);
|
||||
formData.append('cert_file', payload.certFile);
|
||||
formData.append('key_file', payload.keyFile);
|
||||
|
||||
return apiRequest<TlsCertificateItem>('/tls-certificates/import-file', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteTlsCertificate(id: number) {
|
||||
return apiRequest<void>(`/tls-certificates/${id}/delete`, {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
export function applyTlsCertificate(payload: TlsCertificateApplyPayload) {
|
||||
return apiRequest<TlsCertificateItem>('/tls-certificates/apply', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export function renewTlsCertificate(id: number) {
|
||||
return apiRequest<TlsCertificateItem>(`/tls-certificates/${id}/renew`, {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
export function updateAcmeCertificate(
|
||||
id: number,
|
||||
payload: TlsCertificateApplyPayload,
|
||||
) {
|
||||
return apiRequest<TlsCertificateItem>(`/tls-certificates/${id}/update-acme`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export function convertTlsCertificateToAcme(
|
||||
id: number,
|
||||
payload: TlsCertificateApplyPayload,
|
||||
) {
|
||||
return apiRequest<TlsCertificateItem>(
|
||||
`/tls-certificates/${id}/convert-acme`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,380 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
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 {
|
||||
deleteTlsCertificate,
|
||||
getTlsCertificates,
|
||||
renewTlsCertificate,
|
||||
} from '@/features/tls-certificates/api/tls-certificates';
|
||||
import type { TlsCertificateItem } from '@/features/tls-certificates/types';
|
||||
import { CertificateDetailModal } from '@/features/websites/components/certificate-detail-modal';
|
||||
import { CertificateEditorModal } from '@/features/websites/components/certificate-editor-modal';
|
||||
import { CertificateImportModal } from '@/features/websites/components/certificate-import-modal';
|
||||
import { CertificateApplyModal } from '@/features/websites/components/certificate-apply-modal';
|
||||
import {
|
||||
getCertificateStatus,
|
||||
getErrorMessage,
|
||||
} from '@/features/websites/utils';
|
||||
import {
|
||||
DangerButton,
|
||||
PrimaryButton,
|
||||
SecondaryButton,
|
||||
} from '@/features/shared/components/resource-primitives';
|
||||
import { formatDateTime } from '@/lib/utils/date';
|
||||
|
||||
type FeedbackState = {
|
||||
tone: 'info' | 'success' | 'danger';
|
||||
message: string;
|
||||
};
|
||||
|
||||
const certificatesQueryKey = ['tls-certificates', 'list'] as const;
|
||||
type CertificateApplyMode = 'edit-acme' | 'convert-upload';
|
||||
|
||||
export function TlsCertificatesPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const [feedback, setFeedback] = useState<FeedbackState | null>(null);
|
||||
const [isImportOpen, setIsImportOpen] = useState(false);
|
||||
const [isApplyOpen, setIsApplyOpen] = useState(false);
|
||||
const [selectedCertificateId, setSelectedCertificateId] = useState<
|
||||
number | null
|
||||
>(null);
|
||||
const [isDetailOpen, setIsDetailOpen] = useState(false);
|
||||
const [isEditorOpen, setIsEditorOpen] = useState(false);
|
||||
const [applyCertificate, setApplyCertificate] =
|
||||
useState<TlsCertificateItem | null>(null);
|
||||
const [applyMode, setApplyMode] = useState<CertificateApplyMode>('edit-acme');
|
||||
|
||||
const certificatesQuery = useQuery({
|
||||
queryKey: certificatesQueryKey,
|
||||
queryFn: getTlsCertificates,
|
||||
});
|
||||
|
||||
const deleteCertificateMutation = useMutation({
|
||||
mutationFn: deleteTlsCertificate,
|
||||
onSuccess: async () => {
|
||||
setFeedback({ tone: 'success', message: '证书已删除。' });
|
||||
await queryClient.invalidateQueries({ queryKey: ['tls-certificates'] });
|
||||
},
|
||||
onError: (error) => {
|
||||
setFeedback({ tone: 'danger', message: getErrorMessage(error) });
|
||||
},
|
||||
});
|
||||
|
||||
const renewCertificateMutation = useMutation({
|
||||
mutationFn: renewTlsCertificate,
|
||||
onSuccess: async (cert) => {
|
||||
setFeedback({
|
||||
tone: 'success',
|
||||
message: `证书 ${cert.name} 续期任务已提交。`,
|
||||
});
|
||||
await queryClient.invalidateQueries({ queryKey: ['tls-certificates'] });
|
||||
},
|
||||
onError: (error) => {
|
||||
setFeedback({ tone: 'danger', message: getErrorMessage(error) });
|
||||
},
|
||||
});
|
||||
|
||||
const certificates = useMemo(
|
||||
() => certificatesQuery.data ?? [],
|
||||
[certificatesQuery.data],
|
||||
);
|
||||
|
||||
const handleDeleteCertificate = (certificate: TlsCertificateItem) => {
|
||||
if (!window.confirm(`确认删除证书 ${certificate.name} 吗?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setFeedback(null);
|
||||
deleteCertificateMutation.mutate(certificate.id);
|
||||
};
|
||||
|
||||
const handleRenewCertificate = (certificate: TlsCertificateItem) => {
|
||||
if (!window.confirm(`确认提交证书 ${certificate.name} 的续期申请吗?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setFeedback(null);
|
||||
renewCertificateMutation.mutate(certificate.id);
|
||||
};
|
||||
|
||||
const handleOpenCertificateDetail = (certificate: TlsCertificateItem) => {
|
||||
setSelectedCertificateId(certificate.id);
|
||||
setIsDetailOpen(true);
|
||||
};
|
||||
|
||||
const handleOpenCertificateEditor = (certificate: TlsCertificateItem) => {
|
||||
if (certificate.provider === 'acme') {
|
||||
setApplyMode('edit-acme');
|
||||
setApplyCertificate(certificate);
|
||||
} else {
|
||||
setSelectedCertificateId(certificate.id);
|
||||
setIsEditorOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="证书"
|
||||
description="统一查看、导入、编辑和删除已添加的 TLS 证书。"
|
||||
action={
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Link
|
||||
href="/website"
|
||||
className="inline-flex min-h-[46px] 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>
|
||||
<SecondaryButton
|
||||
type="button"
|
||||
onClick={() =>
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: ['tls-certificates'],
|
||||
})
|
||||
}
|
||||
>
|
||||
刷新证书
|
||||
</SecondaryButton>
|
||||
<Link
|
||||
href="/website/dns-account"
|
||||
className="inline-flex min-h-[46px] 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)]"
|
||||
>
|
||||
DNS 账号
|
||||
</Link>
|
||||
<PrimaryButton
|
||||
type="button"
|
||||
onClick={() => setIsImportOpen(true)}
|
||||
>
|
||||
导入证书
|
||||
</PrimaryButton>
|
||||
<PrimaryButton type="button" onClick={() => setIsApplyOpen(true)}>
|
||||
申请证书
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{feedback ? (
|
||||
<InlineMessage
|
||||
tone={feedback.tone}
|
||||
message={feedback.message}
|
||||
onClear={() => setFeedback(null)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<AppCard
|
||||
title="证书列表"
|
||||
description="展示证书有效期、备注和状态,支持直接查看详情、编辑内容或删除证书。"
|
||||
>
|
||||
{certificatesQuery.isLoading ? (
|
||||
<LoadingState />
|
||||
) : certificatesQuery.isError ? (
|
||||
<ErrorState
|
||||
title="证书列表加载失败"
|
||||
description={getErrorMessage(certificatesQuery.error)}
|
||||
/>
|
||||
) : certificates.length === 0 ? (
|
||||
<EmptyState
|
||||
title="暂无证书"
|
||||
description="点击右上角“添加证书”开始录入。"
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{certificates.map((certificate) => {
|
||||
const status = getCertificateStatus(certificate);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={certificate.id}
|
||||
className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<p className="text-sm font-semibold text-[var(--foreground-primary)]">
|
||||
{certificate.name}
|
||||
</p>
|
||||
<StatusBadge
|
||||
label={status.label}
|
||||
variant={status.variant}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-xs leading-5 text-[var(--foreground-secondary)]">
|
||||
<p>生效:{formatDateTime(certificate.not_before)}</p>
|
||||
<p>到期:{formatDateTime(certificate.not_after)}</p>
|
||||
<p>
|
||||
来源:
|
||||
{certificate.provider === 'acme'
|
||||
? 'ACME 申请'
|
||||
: '手动上传'}
|
||||
</p>
|
||||
{certificate.provider === 'upload' &&
|
||||
certificate.apply_status === 'applying' ? (
|
||||
<p className="text-blue-500">状态:转换申请中...</p>
|
||||
) : certificate.apply_status === 'applying' ? (
|
||||
<p className="text-blue-500">状态:申请中...</p>
|
||||
) : null}
|
||||
{certificate.provider === 'upload' &&
|
||||
certificate.apply_status === 'error' ? (
|
||||
<p className="text-red-500">
|
||||
状态:转换失败 ({certificate.apply_message})
|
||||
</p>
|
||||
) : certificate.apply_status === 'error' ? (
|
||||
<p className="text-red-500">
|
||||
状态:申请失败 ({certificate.apply_message})
|
||||
</p>
|
||||
) : null}
|
||||
<p>备注:{certificate.remark || '暂无备注'}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<SecondaryButton
|
||||
type="button"
|
||||
onClick={() =>
|
||||
handleOpenCertificateDetail(certificate)
|
||||
}
|
||||
className="px-3 py-2 text-xs"
|
||||
>
|
||||
查看
|
||||
</SecondaryButton>
|
||||
<SecondaryButton
|
||||
type="button"
|
||||
onClick={() =>
|
||||
handleOpenCertificateEditor(certificate)
|
||||
}
|
||||
className="px-3 py-2 text-xs"
|
||||
>
|
||||
编辑
|
||||
</SecondaryButton>
|
||||
{certificate.provider === 'acme' && (
|
||||
<SecondaryButton
|
||||
type="button"
|
||||
onClick={() => handleRenewCertificate(certificate)}
|
||||
disabled={renewCertificateMutation.isPending}
|
||||
className="px-3 py-2 text-xs"
|
||||
>
|
||||
续期
|
||||
</SecondaryButton>
|
||||
)}
|
||||
<DangerButton
|
||||
type="button"
|
||||
onClick={() => handleDeleteCertificate(certificate)}
|
||||
disabled={deleteCertificateMutation.isPending}
|
||||
className="px-3 py-2 text-xs"
|
||||
>
|
||||
删除
|
||||
</DangerButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</AppCard>
|
||||
</div>
|
||||
|
||||
{isImportOpen ? (
|
||||
<CertificateImportModal
|
||||
isOpen={isImportOpen}
|
||||
onClose={() => setIsImportOpen(false)}
|
||||
onImported={(certificate) => {
|
||||
setFeedback({
|
||||
tone: 'success',
|
||||
message: `证书 ${certificate.name} 已导入。`,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{isApplyOpen ? (
|
||||
<CertificateApplyModal
|
||||
isOpen={isApplyOpen}
|
||||
onClose={() => setIsApplyOpen(false)}
|
||||
onApplied={(certificate) => {
|
||||
setFeedback({
|
||||
tone: 'success',
|
||||
message: `证书 ${certificate.name} 申请任务已提交。`,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{applyCertificate ? (
|
||||
<CertificateApplyModal
|
||||
isOpen={true}
|
||||
onClose={() => setApplyCertificate(null)}
|
||||
mode={applyMode}
|
||||
certificate={applyCertificate}
|
||||
onApplied={(certificate) => {
|
||||
setFeedback({
|
||||
tone: 'success',
|
||||
message:
|
||||
applyMode === 'convert-upload'
|
||||
? `证书 ${certificate.name} 转换申请已提交。`
|
||||
: `证书 ${certificate.name} 配置已更新,重新申请中...`,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{isDetailOpen ? (
|
||||
<CertificateDetailModal
|
||||
certificateId={selectedCertificateId}
|
||||
isOpen={isDetailOpen}
|
||||
onClose={() => setIsDetailOpen(false)}
|
||||
onEdit={() => {
|
||||
setIsDetailOpen(false);
|
||||
const certificate = certificates.find(
|
||||
(item) => item.id === selectedCertificateId,
|
||||
);
|
||||
if (certificate) {
|
||||
handleOpenCertificateEditor(certificate);
|
||||
}
|
||||
}}
|
||||
onDelete={() => {
|
||||
const certificate = certificates.find(
|
||||
(item) => item.id === selectedCertificateId,
|
||||
);
|
||||
if (certificate) {
|
||||
setIsDetailOpen(false);
|
||||
handleDeleteCertificate(certificate);
|
||||
}
|
||||
}}
|
||||
deleting={deleteCertificateMutation.isPending}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{isEditorOpen ? (
|
||||
<CertificateEditorModal
|
||||
certificateId={selectedCertificateId}
|
||||
isOpen={isEditorOpen}
|
||||
onClose={() => setIsEditorOpen(false)}
|
||||
onSaved={(certificate) => {
|
||||
setFeedback({
|
||||
tone: 'success',
|
||||
message: `证书 ${certificate.name} 已更新。`,
|
||||
});
|
||||
}}
|
||||
onConvert={(certificate) => {
|
||||
setIsEditorOpen(false);
|
||||
setApplyMode('convert-upload');
|
||||
setApplyCertificate(certificate);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
export interface TlsCertificateItem {
|
||||
id: number;
|
||||
name: string;
|
||||
cert_pem?: string;
|
||||
key_pem?: string;
|
||||
provider: string;
|
||||
acme_account_id: number;
|
||||
dns_account_id: number;
|
||||
key_algorithm: string;
|
||||
auto_renew: boolean;
|
||||
primary_domain: string;
|
||||
other_domains: string;
|
||||
disable_cname: boolean;
|
||||
skip_dns: boolean;
|
||||
dns1: string;
|
||||
dns2: string;
|
||||
apply_status: string;
|
||||
apply_message: string;
|
||||
not_before: string;
|
||||
not_after: string;
|
||||
remark: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface TlsCertificateDetailItem extends TlsCertificateItem {
|
||||
cert_pem?: never;
|
||||
key_pem?: never;
|
||||
}
|
||||
|
||||
export interface TlsCertificateContentItem extends TlsCertificateItem {
|
||||
cert_pem: string;
|
||||
key_pem: string;
|
||||
}
|
||||
|
||||
export interface TlsCertificateMutationPayload {
|
||||
name: string;
|
||||
cert_pem: string;
|
||||
key_pem: string;
|
||||
remark: string;
|
||||
}
|
||||
|
||||
export interface TlsCertificateApplyPayload {
|
||||
name: string;
|
||||
remark: string;
|
||||
acme_account_id: number;
|
||||
dns_account_id: number;
|
||||
key_algorithm: string;
|
||||
auto_renew: boolean;
|
||||
primary_domain: string;
|
||||
other_domains: string;
|
||||
disable_cname: boolean;
|
||||
skip_dns: boolean;
|
||||
dns1: string;
|
||||
dns2: string;
|
||||
}
|
||||
|
||||
export interface TlsCertificateFileImportPayload {
|
||||
name: string;
|
||||
remark: string;
|
||||
certFile: File;
|
||||
keyFile: File;
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import { ApiError, apiRequest, getApiUrl } from '@/lib/api/client';
|
||||
import type { ApiEnvelope } from '@/types/api';
|
||||
|
||||
import type {
|
||||
LatestReleaseInfo,
|
||||
ReleaseChannel,
|
||||
UpgradeStreamSnapshot,
|
||||
UploadedServerBinaryInfo,
|
||||
} from '@/features/update/types';
|
||||
|
||||
export function getLatestRelease(channel: ReleaseChannel = 'stable') {
|
||||
return apiRequest<LatestReleaseInfo>(
|
||||
`/update/latest-release?channel=${channel}`,
|
||||
);
|
||||
}
|
||||
|
||||
export function upgradeServer(channel: ReleaseChannel = 'stable') {
|
||||
return apiRequest<LatestReleaseInfo>('/update/upgrade', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ channel }),
|
||||
});
|
||||
}
|
||||
|
||||
export function uploadServerBinary(
|
||||
binary: File,
|
||||
onProgress?: (progress: number) => void,
|
||||
): Promise<UploadedServerBinaryInfo> {
|
||||
const formData = new FormData();
|
||||
formData.append('binary', binary);
|
||||
|
||||
if (!onProgress) {
|
||||
return apiRequest<UploadedServerBinaryInfo>('/update/manual-upload', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open('POST', getApiUrl('/update/manual-upload'));
|
||||
xhr.withCredentials = true;
|
||||
|
||||
xhr.upload.addEventListener('progress', (event) => {
|
||||
if (event.lengthComputable) {
|
||||
onProgress(Math.round((event.loaded / event.total) * 100));
|
||||
}
|
||||
});
|
||||
|
||||
xhr.addEventListener('load', () => {
|
||||
let payload: ApiEnvelope<UploadedServerBinaryInfo> | null = null;
|
||||
try {
|
||||
payload = JSON.parse(
|
||||
xhr.responseText,
|
||||
) as ApiEnvelope<UploadedServerBinaryInfo>;
|
||||
} catch {
|
||||
payload = null;
|
||||
}
|
||||
if (xhr.status < 200 || xhr.status >= 300) {
|
||||
reject(
|
||||
new ApiError(
|
||||
payload?.message || `请求失败(${xhr.status})`,
|
||||
xhr.status,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!payload) {
|
||||
reject(new ApiError('响应格式无效', xhr.status));
|
||||
return;
|
||||
}
|
||||
if (!payload.success) {
|
||||
reject(new ApiError(payload.message || '请求失败', xhr.status));
|
||||
return;
|
||||
}
|
||||
resolve(payload.data);
|
||||
});
|
||||
|
||||
xhr.addEventListener('error', () => {
|
||||
reject(new ApiError('上传过程中网络连接中断,请检查网络后重试', 0));
|
||||
});
|
||||
|
||||
xhr.send(formData);
|
||||
});
|
||||
}
|
||||
|
||||
export function confirmManualServerUpgrade(uploadToken: string) {
|
||||
return apiRequest<UploadedServerBinaryInfo>('/update/manual-upgrade', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ upload_token: uploadToken }),
|
||||
});
|
||||
}
|
||||
|
||||
export function createUpgradeLogsWebSocket() {
|
||||
if (typeof window === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const apiUrl = getApiUrl('/update/logs/ws');
|
||||
const resolvedUrl = apiUrl.startsWith('http://')
|
||||
? `ws://${apiUrl.slice('http://'.length)}`
|
||||
: apiUrl.startsWith('https://')
|
||||
? `wss://${apiUrl.slice('https://'.length)}`
|
||||
: `${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${window.location.host}${apiUrl}`;
|
||||
|
||||
return new WebSocket(resolvedUrl);
|
||||
}
|
||||
|
||||
export function parseUpgradeStreamSnapshot(
|
||||
rawMessage: string,
|
||||
): UpgradeStreamSnapshot | null {
|
||||
try {
|
||||
const parsed = JSON.parse(rawMessage) as Partial<UpgradeStreamSnapshot>;
|
||||
if (
|
||||
typeof parsed.in_progress !== 'boolean' ||
|
||||
typeof parsed.upgrade_status !== 'string' ||
|
||||
!Array.isArray(parsed.upgrade_logs)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
in_progress: parsed.in_progress,
|
||||
upgrade_status: parsed.upgrade_status,
|
||||
upgrade_logs: parsed.upgrade_logs,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,477 @@
|
||||
'use client';
|
||||
|
||||
import { marked } from 'marked';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { EmptyState } from '@/components/feedback/empty-state';
|
||||
import { ErrorState } from '@/components/feedback/error-state';
|
||||
import { LoadingState } from '@/components/feedback/loading-state';
|
||||
import { AppCard } from '@/components/ui/app-card';
|
||||
import { AppModal } from '@/components/ui/app-modal';
|
||||
import { StatusBadge } from '@/components/ui/status-badge';
|
||||
import type {
|
||||
LatestReleaseInfo,
|
||||
ReleaseChannel,
|
||||
UploadedServerBinaryInfo,
|
||||
} from '@/features/update/types';
|
||||
import {
|
||||
PrimaryButton,
|
||||
ResourceField,
|
||||
ResourceInput,
|
||||
SecondaryButton,
|
||||
} from '@/features/shared/components/resource-primitives';
|
||||
import { formatDateTime, formatRelativeTime } from '@/lib/utils/date';
|
||||
|
||||
interface VersionUpgradeModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
currentVersion: string;
|
||||
release: LatestReleaseInfo | null | undefined;
|
||||
selectedChannel: ReleaseChannel;
|
||||
uploadedBinary: UploadedServerBinaryInfo | null;
|
||||
isLoading: boolean;
|
||||
releaseErrorMessage?: string;
|
||||
manualStatusMessage?: string;
|
||||
manualErrorMessage?: string;
|
||||
canUpgrade: boolean;
|
||||
isChecking: boolean;
|
||||
isUpgrading: boolean;
|
||||
isUploadingBinary: boolean;
|
||||
uploadProgress: number;
|
||||
isConfirmingManualUpgrade: boolean;
|
||||
onChannelChange: (channel: ReleaseChannel) => void;
|
||||
onCheck: () => void;
|
||||
onUpgrade: () => void;
|
||||
onUploadBinary: (file: File) => void;
|
||||
onConfirmManualUpgrade: () => void;
|
||||
}
|
||||
|
||||
function getUpgradeBadge(release: LatestReleaseInfo | null | undefined) {
|
||||
if (!release) {
|
||||
return { label: '未检查', variant: 'info' as const };
|
||||
}
|
||||
if (release.in_progress) {
|
||||
return { label: '升级中', variant: 'warning' as const };
|
||||
}
|
||||
if (release.has_update) {
|
||||
return { label: '可升级', variant: 'warning' as const };
|
||||
}
|
||||
return { label: '最新', variant: 'success' as const };
|
||||
}
|
||||
|
||||
function formatLogTimestamp(value: string) {
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return '0000-00-00 00:00:00.000';
|
||||
}
|
||||
|
||||
const year = date.getFullYear();
|
||||
const month = `${date.getMonth() + 1}`.padStart(2, '0');
|
||||
const day = `${date.getDate()}`.padStart(2, '0');
|
||||
const hour = `${date.getHours()}`.padStart(2, '0');
|
||||
const minute = `${date.getMinutes()}`.padStart(2, '0');
|
||||
const second = `${date.getSeconds()}`.padStart(2, '0');
|
||||
const millisecond = `${date.getMilliseconds()}`.padStart(3, '0');
|
||||
|
||||
return `${year}-${month}-${day} ${hour}:${minute}:${second}.${millisecond}`;
|
||||
}
|
||||
|
||||
function formatLogLevel(level: string) {
|
||||
const normalized = (level || 'info').toUpperCase();
|
||||
return normalized.padEnd(8, ' ');
|
||||
}
|
||||
|
||||
export function VersionUpgradeModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
currentVersion,
|
||||
release,
|
||||
selectedChannel,
|
||||
uploadedBinary,
|
||||
isLoading,
|
||||
releaseErrorMessage,
|
||||
manualStatusMessage,
|
||||
manualErrorMessage,
|
||||
canUpgrade,
|
||||
isChecking,
|
||||
isUpgrading,
|
||||
isUploadingBinary,
|
||||
uploadProgress,
|
||||
isConfirmingManualUpgrade,
|
||||
onChannelChange,
|
||||
onCheck,
|
||||
onUpgrade,
|
||||
onUploadBinary,
|
||||
onConfirmManualUpgrade,
|
||||
}: VersionUpgradeModalProps) {
|
||||
const upgradeBadge = getUpgradeBadge(release);
|
||||
const [selectedBinary, setSelectedBinary] = useState<File | null>(null);
|
||||
const uploadPhaseLabel =
|
||||
uploadProgress >= 100 ? '已上传,正在服务端校验版本...' : '上传中...';
|
||||
const selectedChannelLabel =
|
||||
selectedChannel === 'preview' ? '预览版' : '正式版';
|
||||
const toggleChannel = () => {
|
||||
onChannelChange(selectedChannel === 'preview' ? 'stable' : 'preview');
|
||||
};
|
||||
const canConfirmManualUpgrade = Boolean(
|
||||
uploadedBinary?.ready_to_upgrade && uploadedBinary.upload_token,
|
||||
);
|
||||
const showConfirmManualUpgradeAction = canConfirmManualUpgrade;
|
||||
const upgradeLogs = release?.upgrade_logs ?? [];
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
setSelectedBinary(null);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
return (
|
||||
<AppModal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title="版本"
|
||||
description="默认检查正式版更新;你也可以手动检查 preview 发布并选择升级。升级开始后服务会短暂重启。"
|
||||
size="lg"
|
||||
>
|
||||
<div className="space-y-6">
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-2">
|
||||
<AppCard title="服务端版本">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<p className="text-sm font-medium text-[var(--foreground-primary)]">
|
||||
{currentVersion || 'unknown'}
|
||||
</p>
|
||||
<StatusBadge
|
||||
label={upgradeBadge.label}
|
||||
variant={upgradeBadge.variant}
|
||||
/>
|
||||
</div>
|
||||
</AppCard>
|
||||
|
||||
<AppCard title="最新版本">
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<p className="text-sm font-medium text-[var(--foreground-primary)]">
|
||||
{release?.tag_name || '未检查'}
|
||||
</p>
|
||||
{canUpgrade ? (
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<StatusBadge
|
||||
label={selectedChannelLabel}
|
||||
variant={
|
||||
selectedChannel === 'preview' ? 'warning' : 'info'
|
||||
}
|
||||
onClick={toggleChannel}
|
||||
disabled={
|
||||
isChecking || isUpgrading || isUploadingBinary
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<SecondaryButton
|
||||
type="button"
|
||||
onClick={onCheck}
|
||||
disabled={isChecking || isUpgrading || isUploadingBinary}
|
||||
className="w-full md:w-auto"
|
||||
>
|
||||
{isChecking ? '检查中...' : '检查'}
|
||||
</SecondaryButton>
|
||||
</div>
|
||||
</AppCard>
|
||||
</div>
|
||||
|
||||
{isLoading ? <LoadingState /> : null}
|
||||
{!isLoading && releaseErrorMessage ? (
|
||||
<ErrorState title="版本检查失败" description={releaseErrorMessage} />
|
||||
) : null}
|
||||
{!isLoading && !releaseErrorMessage && !release ? (
|
||||
<EmptyState
|
||||
title={`尚未检查${selectedChannelLabel}`}
|
||||
description={`点击“检查${selectedChannelLabel}”后会在这里展示对应 GitHub Release 信息。`}
|
||||
/>
|
||||
) : null}
|
||||
{!isLoading && !releaseErrorMessage && release ? (
|
||||
<AppCard
|
||||
title={`GitHub ${selectedChannelLabel} · ${release.tag_name}`}
|
||||
description={
|
||||
release.published_at
|
||||
? `发布时间:${formatRelativeTime(release.published_at)} · ${formatDateTime(release.published_at)}`
|
||||
: '未提供发布时间'
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<StatusBadge
|
||||
label={release.has_update ? '发现新版本' : '已经是最新版本'}
|
||||
variant={release.has_update ? 'warning' : 'success'}
|
||||
/>
|
||||
{release.prerelease ? (
|
||||
<StatusBadge label="Preview 发布" variant="warning" />
|
||||
) : (
|
||||
<StatusBadge label="正式发布" variant="info" />
|
||||
)}
|
||||
{!release.upgrade_supported ? (
|
||||
<StatusBadge
|
||||
label="当前平台不支持自动升级"
|
||||
variant="danger"
|
||||
/>
|
||||
) : null}
|
||||
{release.in_progress ? (
|
||||
<StatusBadge label="升级任务执行中" variant="warning" />
|
||||
) : null}
|
||||
</div>
|
||||
<div
|
||||
className="prose prose-sm max-w-none text-[var(--foreground-primary)] [&_a]:text-[var(--brand-primary)]"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: marked.parse(
|
||||
release.body || '暂无更新说明',
|
||||
) as string,
|
||||
}}
|
||||
/>
|
||||
<a
|
||||
href={release.html_url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-sm font-medium text-[var(--brand-primary)] transition hover:opacity-80"
|
||||
>
|
||||
查看发布详情
|
||||
</a>
|
||||
{canUpgrade ? (
|
||||
<div className="flex justify-end">
|
||||
<PrimaryButton
|
||||
type="button"
|
||||
onClick={onUpgrade}
|
||||
disabled={
|
||||
!release.has_update ||
|
||||
release.in_progress ||
|
||||
isUpgrading ||
|
||||
!release.upgrade_supported ||
|
||||
isUploadingBinary ||
|
||||
isConfirmingManualUpgrade
|
||||
}
|
||||
>
|
||||
{isUpgrading
|
||||
? '升级中...'
|
||||
: release.in_progress
|
||||
? '升级中...'
|
||||
: selectedChannel === 'preview'
|
||||
? '升级预览版'
|
||||
: '升级正式版'}
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</AppCard>
|
||||
) : null}
|
||||
|
||||
{release ? (
|
||||
<AppCard title="升级日志">
|
||||
{upgradeLogs.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<StatusBadge
|
||||
label={
|
||||
release.upgrade_status === 'failed'
|
||||
? '升级失败'
|
||||
: release.upgrade_status === 'succeeded'
|
||||
? '准备重启'
|
||||
: release.in_progress
|
||||
? '升级中'
|
||||
: '空闲'
|
||||
}
|
||||
variant={
|
||||
release.upgrade_status === 'failed'
|
||||
? 'danger'
|
||||
: release.in_progress
|
||||
? 'warning'
|
||||
: release.upgrade_status === 'succeeded'
|
||||
? 'success'
|
||||
: 'info'
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="max-h-72 overflow-y-auto rounded-2xl border border-[var(--border-default)] bg-[#0f172a] px-4 py-3">
|
||||
{upgradeLogs.map((log, index) => (
|
||||
<pre
|
||||
key={`${log.created_at}-${index}`}
|
||||
className="overflow-x-auto border-b border-white/8 py-2 font-mono text-[12px] leading-6 text-slate-200 last:border-b-0"
|
||||
>
|
||||
<span className="text-slate-400">
|
||||
{formatLogTimestamp(log.created_at)}
|
||||
</span>
|
||||
<span className="text-slate-500"> | </span>
|
||||
<span className="text-cyan-300">
|
||||
{formatLogLevel(log.level)}
|
||||
</span>
|
||||
<span className="text-slate-500"> | </span>
|
||||
<span className="break-all whitespace-pre-wrap text-slate-100">
|
||||
{log.message}
|
||||
</span>
|
||||
</pre>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState title="暂无升级日志" />
|
||||
)}
|
||||
</AppCard>
|
||||
) : null}
|
||||
|
||||
{canUpgrade && (false as boolean) ? (
|
||||
<AppCard title="手动升级">
|
||||
<div className="space-y-4">
|
||||
<ResourceField
|
||||
label="服务端二进制"
|
||||
hint="支持上传已编译好的当前平台 Server 可执行文件。"
|
||||
>
|
||||
<ResourceInput
|
||||
type="file"
|
||||
onChange={(event) => {
|
||||
const file = event.target.files?.[0] ?? null;
|
||||
setSelectedBinary(file);
|
||||
}}
|
||||
disabled={isUploadingBinary || isConfirmingManualUpgrade}
|
||||
/>
|
||||
</ResourceField>
|
||||
|
||||
<div className="flex justify-end">
|
||||
{showConfirmManualUpgradeAction ? (
|
||||
<PrimaryButton
|
||||
type="button"
|
||||
onClick={onConfirmManualUpgrade}
|
||||
disabled={
|
||||
!canConfirmManualUpgrade ||
|
||||
isConfirmingManualUpgrade ||
|
||||
isUploadingBinary ||
|
||||
isUpgrading
|
||||
}
|
||||
>
|
||||
{isConfirmingManualUpgrade ? '升级中...' : '升级'}
|
||||
</PrimaryButton>
|
||||
) : (
|
||||
<PrimaryButton
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (selectedBinary) {
|
||||
onUploadBinary(selectedBinary);
|
||||
}
|
||||
}}
|
||||
disabled={
|
||||
!selectedBinary ||
|
||||
isUploadingBinary ||
|
||||
isConfirmingManualUpgrade
|
||||
}
|
||||
>
|
||||
{isUploadingBinary
|
||||
? uploadProgress >= 100
|
||||
? '服务端校验中...'
|
||||
: '上传中...'
|
||||
: '上传并检查'}
|
||||
</PrimaryButton>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isUploadingBinary ? (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between text-xs text-[var(--foreground-secondary)]">
|
||||
<span>{uploadPhaseLabel}</span>
|
||||
<span>{uploadProgress}%</span>
|
||||
</div>
|
||||
<div className="h-2 w-full overflow-hidden rounded-full bg-[var(--border-default)]">
|
||||
<div
|
||||
className="h-full rounded-full bg-[var(--brand-primary)] transition-all duration-200"
|
||||
style={{ width: `${uploadProgress}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{manualErrorMessage ? (
|
||||
<ErrorState
|
||||
title="手动升级检查失败"
|
||||
description={manualErrorMessage}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{!manualErrorMessage && manualStatusMessage ? (
|
||||
<div className="rounded-2xl border border-[var(--status-warning-border)] bg-[var(--status-warning-soft)] px-4 py-3 text-sm text-[var(--status-warning-foreground)]">
|
||||
{manualStatusMessage}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{uploadedBinary ? (
|
||||
<div className="space-y-4 rounded-3xl border border-[var(--border-default)] bg-[var(--surface-elevated)] p-4">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<StatusBadge
|
||||
label={
|
||||
uploadedBinary.ready_to_upgrade
|
||||
? '可确认升级'
|
||||
: uploadedBinary.has_update
|
||||
? '待确认'
|
||||
: '不可升级'
|
||||
}
|
||||
variant={
|
||||
uploadedBinary.ready_to_upgrade ? 'warning' : 'info'
|
||||
}
|
||||
/>
|
||||
{!uploadedBinary.upgrade_supported ? (
|
||||
<StatusBadge
|
||||
label="当前版本不支持手动升级"
|
||||
variant="danger"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<p className="text-xs text-[var(--foreground-secondary)]">
|
||||
文件名
|
||||
</p>
|
||||
<p className="mt-1 text-sm font-medium text-[var(--foreground-primary)]">
|
||||
{uploadedBinary.file_name}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-[var(--foreground-secondary)]">
|
||||
上传时间
|
||||
</p>
|
||||
<p className="mt-1 text-sm font-medium text-[var(--foreground-primary)]">
|
||||
{uploadedBinary.uploaded_at
|
||||
? formatDateTime(uploadedBinary.uploaded_at)
|
||||
: '未知'}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-[var(--foreground-secondary)]">
|
||||
当前版本
|
||||
</p>
|
||||
<p className="mt-1 text-sm font-medium text-[var(--foreground-primary)]">
|
||||
{uploadedBinary.current_version}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-[var(--foreground-secondary)]">
|
||||
上传版本
|
||||
</p>
|
||||
<p className="mt-1 text-sm font-medium text-[var(--foreground-primary)]">
|
||||
{uploadedBinary.detected_version}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-[var(--foreground-secondary)]">
|
||||
{uploadedBinary.comparison_message}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState title="尚未上传升级包" />
|
||||
)}
|
||||
</div>
|
||||
</AppCard>
|
||||
) : null}
|
||||
</div>
|
||||
</AppModal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
export type ReleaseChannel = 'stable' | 'preview';
|
||||
|
||||
export interface UpgradeLogItem {
|
||||
level: string;
|
||||
message: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface LatestReleaseInfo {
|
||||
tag_name: string;
|
||||
body: string;
|
||||
html_url: string;
|
||||
published_at: string;
|
||||
channel: ReleaseChannel;
|
||||
prerelease: boolean;
|
||||
current_version: string;
|
||||
has_update: boolean;
|
||||
upgrade_supported: boolean;
|
||||
in_progress: boolean;
|
||||
upgrade_status: 'idle' | 'running' | 'succeeded' | 'failed' | string;
|
||||
upgrade_logs: UpgradeLogItem[];
|
||||
}
|
||||
|
||||
export interface UpgradeStreamSnapshot {
|
||||
in_progress: boolean;
|
||||
upgrade_status: 'idle' | 'running' | 'succeeded' | 'failed' | string;
|
||||
upgrade_logs: UpgradeLogItem[];
|
||||
}
|
||||
|
||||
export interface UploadedServerBinaryInfo {
|
||||
upload_token: string;
|
||||
file_name: string;
|
||||
detected_version: string;
|
||||
current_version: string;
|
||||
has_update: boolean;
|
||||
upgrade_supported: boolean;
|
||||
ready_to_upgrade: boolean;
|
||||
comparison_message: string;
|
||||
uploaded_at: string;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { apiRequest } from '@/lib/api/client';
|
||||
|
||||
import type {
|
||||
ManageUserAction,
|
||||
ManageUserResult,
|
||||
UserItem,
|
||||
UserMutationPayload,
|
||||
} from '@/features/users/types';
|
||||
|
||||
export function getUsers(page: number) {
|
||||
return apiRequest<UserItem[]>(`/user/?p=${page}`);
|
||||
}
|
||||
|
||||
export function searchUsers(keyword: string) {
|
||||
return apiRequest<UserItem[]>(
|
||||
`/user/search?keyword=${encodeURIComponent(keyword)}`,
|
||||
);
|
||||
}
|
||||
|
||||
export function getUser(id: number) {
|
||||
return apiRequest<UserItem>(`/user/${id}`);
|
||||
}
|
||||
|
||||
export function createUser(payload: UserMutationPayload) {
|
||||
return apiRequest<void>('/user/', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export function updateUser(payload: UserMutationPayload & { id: number }) {
|
||||
return apiRequest<void>('/user/update', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export function manageUser(username: string, action: ManageUserAction) {
|
||||
return apiRequest<ManageUserResult>('/user/manage', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ username, action }),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,613 @@
|
||||
'use client';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useEffect, useMemo, useState } 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 { useAuth } from '@/components/providers/auth-provider';
|
||||
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 {
|
||||
createUser,
|
||||
getUser,
|
||||
getUsers,
|
||||
manageUser,
|
||||
searchUsers,
|
||||
updateUser,
|
||||
} from '@/features/users/api/users';
|
||||
import type {
|
||||
ManageUserAction,
|
||||
UserItem,
|
||||
UserMutationPayload,
|
||||
} from '@/features/users/types';
|
||||
import {
|
||||
DangerButton,
|
||||
PrimaryButton,
|
||||
ResourceField,
|
||||
ResourceInput,
|
||||
SecondaryButton,
|
||||
} from '@/features/shared/components/resource-primitives';
|
||||
|
||||
const ITEMS_PER_PAGE = 10;
|
||||
const usersListQueryKey = ['users', 'list'] as const;
|
||||
|
||||
const userSchema = z.object({
|
||||
username: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, '请输入用户名')
|
||||
.max(12, '用户名不能超过 12 个字符'),
|
||||
display_name: z.string().trim().max(20, '显示名称不能超过 20 个字符'),
|
||||
password: z
|
||||
.string()
|
||||
.max(20, '密码不能超过 20 个字符')
|
||||
.refine(
|
||||
(value) => value.length === 0 || value.length >= 8,
|
||||
'密码至少需要 8 个字符',
|
||||
),
|
||||
});
|
||||
|
||||
type UserFormValues = z.infer<typeof userSchema>;
|
||||
|
||||
type FeedbackState = {
|
||||
tone: 'info' | 'success' | 'danger';
|
||||
message: string;
|
||||
};
|
||||
|
||||
const defaultValues: UserFormValues = {
|
||||
username: '',
|
||||
display_name: '',
|
||||
password: '',
|
||||
};
|
||||
|
||||
function getErrorMessage(error: unknown) {
|
||||
return error instanceof Error ? error.message : '请求失败,请稍后重试。';
|
||||
}
|
||||
|
||||
function getRoleMeta(role: number) {
|
||||
if (role >= 100) {
|
||||
return { label: '超级管理员', variant: 'warning' as const };
|
||||
}
|
||||
|
||||
if (role >= 10) {
|
||||
return { label: '管理员', variant: 'info' as const };
|
||||
}
|
||||
|
||||
return { label: '普通用户', variant: 'success' as const };
|
||||
}
|
||||
|
||||
function getStatusMeta(status: number) {
|
||||
if (status === 1) {
|
||||
return { label: '已激活', variant: 'success' as const };
|
||||
}
|
||||
|
||||
return { label: '已封禁', variant: 'danger' as const };
|
||||
}
|
||||
|
||||
function toPayload(values: UserFormValues): UserMutationPayload {
|
||||
return {
|
||||
username: values.username.trim(),
|
||||
display_name: values.display_name.trim(),
|
||||
password: values.password,
|
||||
};
|
||||
}
|
||||
|
||||
export function UsersPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const { user: currentUser } = useAuth();
|
||||
const [page, setPage] = useState(0);
|
||||
const [searchInput, setSearchInput] = useState('');
|
||||
const [searchKeyword, setSearchKeyword] = useState('');
|
||||
const [editingUserId, setEditingUserId] = useState<number | null>(null);
|
||||
const [isEditorOpen, setIsEditorOpen] = useState(false);
|
||||
const [feedback, setFeedback] = useState<FeedbackState | null>(null);
|
||||
|
||||
const form = useForm<UserFormValues>({
|
||||
resolver: zodResolver(userSchema),
|
||||
defaultValues,
|
||||
});
|
||||
|
||||
const isAdmin = (currentUser?.role ?? 0) >= 10;
|
||||
const isRoot = (currentUser?.role ?? 0) >= 100;
|
||||
|
||||
const usersQuery = useQuery({
|
||||
queryKey: [...usersListQueryKey, page],
|
||||
queryFn: () => getUsers(page),
|
||||
enabled: isAdmin && searchKeyword.length === 0,
|
||||
});
|
||||
|
||||
const searchQuery = useQuery({
|
||||
queryKey: [...usersListQueryKey, 'search', searchKeyword],
|
||||
queryFn: () => searchUsers(searchKeyword),
|
||||
enabled: isAdmin && searchKeyword.length > 0,
|
||||
});
|
||||
|
||||
const editingUserQuery = useQuery({
|
||||
queryKey: ['users', 'detail', editingUserId],
|
||||
queryFn: () => getUser(editingUserId as number),
|
||||
enabled: isAdmin && editingUserId !== null,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (editingUserQuery.data) {
|
||||
form.reset({
|
||||
username: editingUserQuery.data.username,
|
||||
display_name: editingUserQuery.data.display_name || '',
|
||||
password: '',
|
||||
});
|
||||
}
|
||||
}, [editingUserQuery.data, form]);
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: async (values: UserFormValues) => {
|
||||
if (!editingUserId && values.password.length === 0) {
|
||||
throw new Error('创建用户时必须填写密码。');
|
||||
}
|
||||
|
||||
const payload = toPayload(values);
|
||||
|
||||
if (editingUserId) {
|
||||
await updateUser({ id: editingUserId, ...payload });
|
||||
return '用户信息已更新。';
|
||||
}
|
||||
|
||||
await createUser(payload);
|
||||
return '用户已创建。';
|
||||
},
|
||||
onSuccess: async (message) => {
|
||||
setFeedback({ tone: 'success', message });
|
||||
setEditingUserId(null);
|
||||
setIsEditorOpen(false);
|
||||
form.reset(defaultValues);
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: usersListQueryKey }),
|
||||
queryClient.invalidateQueries({ queryKey: ['users', 'detail'] }),
|
||||
]);
|
||||
},
|
||||
onError: (error) => {
|
||||
setFeedback({ tone: 'danger', message: getErrorMessage(error) });
|
||||
},
|
||||
});
|
||||
|
||||
const manageMutation = useMutation({
|
||||
mutationFn: async ({
|
||||
username,
|
||||
action,
|
||||
}: {
|
||||
username: string;
|
||||
action: ManageUserAction;
|
||||
}) => {
|
||||
await manageUser(username, action);
|
||||
return action;
|
||||
},
|
||||
onSuccess: async (action) => {
|
||||
const actionLabel = {
|
||||
promote: '提升权限',
|
||||
demote: '降级权限',
|
||||
delete: '删除用户',
|
||||
disable: '禁用用户',
|
||||
enable: '启用用户',
|
||||
}[action];
|
||||
|
||||
setFeedback({ tone: 'success', message: `${actionLabel}操作已完成。` });
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: usersListQueryKey }),
|
||||
queryClient.invalidateQueries({ queryKey: ['users', 'detail'] }),
|
||||
]);
|
||||
},
|
||||
onError: (error) => {
|
||||
setFeedback({ tone: 'danger', message: getErrorMessage(error) });
|
||||
},
|
||||
});
|
||||
|
||||
const activeQuery = searchKeyword.length > 0 ? searchQuery : usersQuery;
|
||||
const users = useMemo(() => activeQuery.data ?? [], [activeQuery.data]);
|
||||
|
||||
const handleSearchSubmit = () => {
|
||||
setFeedback(null);
|
||||
setPage(0);
|
||||
setSearchKeyword(searchInput.trim());
|
||||
};
|
||||
|
||||
const handleResetSearch = () => {
|
||||
setSearchInput('');
|
||||
setSearchKeyword('');
|
||||
setPage(0);
|
||||
setFeedback(null);
|
||||
};
|
||||
|
||||
const handleResetForm = () => {
|
||||
setEditingUserId(null);
|
||||
setIsEditorOpen(false);
|
||||
setFeedback(null);
|
||||
form.reset(defaultValues);
|
||||
};
|
||||
|
||||
const handleCreate = () => {
|
||||
setEditingUserId(null);
|
||||
setFeedback(null);
|
||||
form.reset(defaultValues);
|
||||
setIsEditorOpen(true);
|
||||
};
|
||||
|
||||
const handleEdit = (targetUser: UserItem) => {
|
||||
setFeedback(null);
|
||||
setEditingUserId(targetUser.id);
|
||||
setIsEditorOpen(true);
|
||||
};
|
||||
|
||||
const handleManage = (targetUser: UserItem, action: ManageUserAction) => {
|
||||
const actionText = {
|
||||
promote: '提升',
|
||||
demote: '降级',
|
||||
delete: '删除',
|
||||
disable: '禁用',
|
||||
enable: '启用',
|
||||
}[action];
|
||||
|
||||
if (
|
||||
action === 'delete' &&
|
||||
!window.confirm(`确认删除用户“${targetUser.username}”吗?`)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
(action === 'disable' || action === 'enable') &&
|
||||
!window.confirm(`确认${actionText}用户“${targetUser.username}”吗?`)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
setFeedback(null);
|
||||
manageMutation.mutate({ username: targetUser.username, action });
|
||||
};
|
||||
|
||||
const handleSubmit = form.handleSubmit((values) => {
|
||||
setFeedback(null);
|
||||
saveMutation.mutate(values);
|
||||
});
|
||||
|
||||
if (!isAdmin) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="用户管理"
|
||||
description="阶段 4 已迁移用户管理页面,但当前账户没有管理员权限。"
|
||||
/>
|
||||
<EmptyState
|
||||
title="权限不足"
|
||||
description="用户列表、编辑与账户管理动作仅对管理员开放,请使用管理员账号登录后继续。"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="用户管理"
|
||||
description="支持搜索、分页、创建、编辑以及启用、封禁、权限调整等常见账户管理动作。"
|
||||
action={
|
||||
<PrimaryButton type="button" onClick={handleCreate}>
|
||||
新建用户
|
||||
</PrimaryButton>
|
||||
}
|
||||
/>
|
||||
|
||||
{feedback ? (
|
||||
<InlineMessage
|
||||
tone={feedback.tone}
|
||||
message={feedback.message}
|
||||
onClear={() => setFeedback(null)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<AppCard
|
||||
title="用户列表"
|
||||
action={
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<SecondaryButton
|
||||
type="button"
|
||||
onClick={() =>
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: usersListQueryKey,
|
||||
})
|
||||
}
|
||||
>
|
||||
刷新
|
||||
</SecondaryButton>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="space-y-5">
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="flex w-full flex-col gap-3 md:flex-row">
|
||||
<ResourceInput
|
||||
value={searchInput}
|
||||
onChange={(event) => setSearchInput(event.target.value)}
|
||||
placeholder="搜索用户 ID、用户名、显示名称或邮箱地址"
|
||||
className="md:max-w-xl"
|
||||
/>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<PrimaryButton type="button" onClick={handleSearchSubmit}>
|
||||
搜索
|
||||
</PrimaryButton>
|
||||
<SecondaryButton type="button" onClick={handleResetSearch}>
|
||||
清空
|
||||
</SecondaryButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{searchKeyword.length === 0 ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<SecondaryButton
|
||||
type="button"
|
||||
onClick={() => setPage((value) => Math.max(value - 1, 0))}
|
||||
disabled={page === 0 || activeQuery.isLoading}
|
||||
>
|
||||
上一页
|
||||
</SecondaryButton>
|
||||
<SecondaryButton
|
||||
type="button"
|
||||
onClick={() => setPage((value) => value + 1)}
|
||||
disabled={
|
||||
activeQuery.isLoading || users.length < ITEMS_PER_PAGE
|
||||
}
|
||||
>
|
||||
下一页
|
||||
</SecondaryButton>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{activeQuery.isLoading ? (
|
||||
<LoadingState />
|
||||
) : activeQuery.isError ? (
|
||||
<ErrorState
|
||||
title="用户列表加载失败"
|
||||
description={getErrorMessage(activeQuery.error)}
|
||||
/>
|
||||
) : users.length === 0 ? (
|
||||
<EmptyState
|
||||
title="暂无用户"
|
||||
description="当前条件下没有可展示的用户记录。"
|
||||
/>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-[var(--border-default)] text-left text-sm">
|
||||
<thead>
|
||||
<tr className="text-[var(--foreground-secondary)]">
|
||||
<th className="px-3 py-3 font-medium">用户</th>
|
||||
<th className="px-3 py-3 font-medium">邮箱</th>
|
||||
<th className="px-3 py-3 font-medium">角色</th>
|
||||
<th className="px-3 py-3 font-medium">状态</th>
|
||||
<th className="px-3 py-3 font-medium">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-[var(--border-default)]">
|
||||
{users.map((targetUser) => {
|
||||
const roleMeta = getRoleMeta(targetUser.role);
|
||||
const statusMeta = getStatusMeta(targetUser.status);
|
||||
const canManage =
|
||||
(currentUser?.role ?? 0) > targetUser.role;
|
||||
|
||||
return (
|
||||
<tr key={targetUser.id} className="align-top">
|
||||
<td className="px-3 py-4">
|
||||
<div className="space-y-1">
|
||||
<p className="font-medium text-[var(--foreground-primary)]">
|
||||
{targetUser.username}
|
||||
</p>
|
||||
<p className="text-xs text-[var(--foreground-secondary)]">
|
||||
显示名称:{targetUser.display_name || '未设置'}
|
||||
</p>
|
||||
<p className="text-xs text-[var(--foreground-secondary)]">
|
||||
ID:{targetUser.id}
|
||||
</p>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-4 text-[var(--foreground-secondary)]">
|
||||
{targetUser.email || '未绑定'}
|
||||
</td>
|
||||
<td className="px-3 py-4">
|
||||
<StatusBadge
|
||||
label={roleMeta.label}
|
||||
variant={roleMeta.variant}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-4">
|
||||
<StatusBadge
|
||||
label={statusMeta.label}
|
||||
variant={statusMeta.variant}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-4">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<SecondaryButton
|
||||
type="button"
|
||||
onClick={() => handleEdit(targetUser)}
|
||||
className="px-3 py-2 text-xs"
|
||||
disabled={!canManage}
|
||||
>
|
||||
编辑
|
||||
</SecondaryButton>
|
||||
<SecondaryButton
|
||||
type="button"
|
||||
onClick={() =>
|
||||
handleManage(
|
||||
targetUser,
|
||||
targetUser.status === 1
|
||||
? 'disable'
|
||||
: 'enable',
|
||||
)
|
||||
}
|
||||
className="px-3 py-2 text-xs"
|
||||
disabled={
|
||||
!canManage || manageMutation.isPending
|
||||
}
|
||||
>
|
||||
{targetUser.status === 1 ? '禁用' : '启用'}
|
||||
</SecondaryButton>
|
||||
<SecondaryButton
|
||||
type="button"
|
||||
onClick={() =>
|
||||
handleManage(
|
||||
targetUser,
|
||||
targetUser.role >= 10
|
||||
? 'demote'
|
||||
: 'promote',
|
||||
)
|
||||
}
|
||||
className="px-3 py-2 text-xs"
|
||||
disabled={
|
||||
!canManage ||
|
||||
manageMutation.isPending ||
|
||||
(!isRoot && targetUser.role < 10)
|
||||
}
|
||||
>
|
||||
{targetUser.role >= 10 ? '降级' : '提升'}
|
||||
</SecondaryButton>
|
||||
<DangerButton
|
||||
type="button"
|
||||
onClick={() =>
|
||||
handleManage(targetUser, 'delete')
|
||||
}
|
||||
className="px-3 py-2 text-xs"
|
||||
disabled={
|
||||
!canManage || manageMutation.isPending
|
||||
}
|
||||
>
|
||||
删除
|
||||
</DangerButton>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</AppCard>
|
||||
</div>
|
||||
<AppModal
|
||||
isOpen={isEditorOpen}
|
||||
onClose={handleResetForm}
|
||||
title={editingUserId ? '编辑用户' : '创建新用户'}
|
||||
description={
|
||||
editingUserId
|
||||
? '可更新用户名、显示名称与密码。留空密码表示不修改。'
|
||||
: '新建用户默认创建为普通用户。'
|
||||
}
|
||||
footer={
|
||||
<div className="flex flex-wrap justify-end gap-3">
|
||||
<SecondaryButton
|
||||
type="button"
|
||||
onClick={handleResetForm}
|
||||
disabled={saveMutation.isPending}
|
||||
>
|
||||
取消
|
||||
</SecondaryButton>
|
||||
<PrimaryButton
|
||||
type="submit"
|
||||
form="user-editor-form"
|
||||
disabled={saveMutation.isPending}
|
||||
>
|
||||
{saveMutation.isPending
|
||||
? '保存中...'
|
||||
: editingUserId
|
||||
? '保存修改'
|
||||
: '创建用户'}
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{editingUserId && editingUserQuery.isLoading ? (
|
||||
<LoadingState />
|
||||
) : editingUserId && editingUserQuery.isError ? (
|
||||
<ErrorState
|
||||
title="用户详情加载失败"
|
||||
description={getErrorMessage(editingUserQuery.error)}
|
||||
/>
|
||||
) : (
|
||||
<form
|
||||
id="user-editor-form"
|
||||
className="space-y-5"
|
||||
onSubmit={handleSubmit}
|
||||
>
|
||||
<ResourceField
|
||||
label="用户名"
|
||||
error={form.formState.errors.username?.message}
|
||||
>
|
||||
<ResourceInput
|
||||
placeholder="请输入用户名"
|
||||
{...form.register('username')}
|
||||
/>
|
||||
</ResourceField>
|
||||
|
||||
<ResourceField
|
||||
label="显示名称"
|
||||
error={form.formState.errors.display_name?.message}
|
||||
>
|
||||
<ResourceInput
|
||||
placeholder="请输入显示名称"
|
||||
{...form.register('display_name')}
|
||||
/>
|
||||
</ResourceField>
|
||||
|
||||
<ResourceField
|
||||
label={editingUserId ? '新密码' : '密码'}
|
||||
hint={
|
||||
editingUserId
|
||||
? '留空表示保持原密码不变。'
|
||||
: '密码长度需为 8 到 20 个字符。'
|
||||
}
|
||||
error={form.formState.errors.password?.message}
|
||||
>
|
||||
<ResourceInput
|
||||
type="password"
|
||||
placeholder="请输入密码"
|
||||
{...form.register('password')}
|
||||
/>
|
||||
</ResourceField>
|
||||
|
||||
{editingUserId && editingUserQuery.data ? (
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
|
||||
<p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
|
||||
绑定邮箱
|
||||
</p>
|
||||
<p className="mt-2 text-sm break-all text-[var(--foreground-primary)]">
|
||||
{editingUserQuery.data.email || '未绑定'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
|
||||
<p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
|
||||
第三方账号
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-[var(--foreground-primary)]">
|
||||
GitHub:{editingUserQuery.data.github_id || '未绑定'}
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-[var(--foreground-primary)]">
|
||||
微信:{editingUserQuery.data.wechat_id || '未绑定'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</form>
|
||||
)}
|
||||
</AppModal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
export interface UserItem {
|
||||
id: number;
|
||||
username: string;
|
||||
display_name: string;
|
||||
role: number;
|
||||
status: number;
|
||||
email?: string;
|
||||
github_id?: string;
|
||||
wechat_id?: string;
|
||||
}
|
||||
|
||||
export interface UserMutationPayload {
|
||||
id?: number;
|
||||
username: string;
|
||||
display_name: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export type ManageUserAction =
|
||||
| 'promote'
|
||||
| 'demote'
|
||||
| 'delete'
|
||||
| 'disable'
|
||||
| 'enable';
|
||||
|
||||
export interface ManageUserResult {
|
||||
role: number;
|
||||
status: number;
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { apiRequest } from '@/lib/api/client';
|
||||
|
||||
import type {
|
||||
WAFRuleGroup,
|
||||
WAFRuleGroupPayload,
|
||||
WAFIPGroup,
|
||||
WAFIPGroupAutoTestPayload,
|
||||
WAFIPGroupAutoTestResult,
|
||||
WAFIPGroupPayload,
|
||||
WAFIPGroupSyncResult,
|
||||
WAFSiteRuleGroups,
|
||||
} from '@/features/waf/types';
|
||||
|
||||
export function getWAFRuleGroups() {
|
||||
return apiRequest<WAFRuleGroup[]>('/waf/rule-groups');
|
||||
}
|
||||
|
||||
export function createWAFRuleGroup(payload: WAFRuleGroupPayload) {
|
||||
return apiRequest<WAFRuleGroup>('/waf/rule-groups', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export function updateWAFRuleGroup(id: number, payload: WAFRuleGroupPayload) {
|
||||
return apiRequest<WAFRuleGroup>(`/waf/rule-groups/${id}/update`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteWAFRuleGroup(id: number) {
|
||||
return apiRequest<void>(`/waf/rule-groups/${id}/delete`, {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
export function replaceWAFRuleGroupSites(id: number, ids: number[]) {
|
||||
return apiRequest<WAFRuleGroup>(`/waf/rule-groups/${id}/sites`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ ids }),
|
||||
});
|
||||
}
|
||||
|
||||
export function getWAFSiteRuleGroups(routeId: number) {
|
||||
return apiRequest<WAFSiteRuleGroups>(`/waf/sites/${routeId}/rule-groups`);
|
||||
}
|
||||
|
||||
export function replaceWAFSiteRuleGroups(routeId: number, ids: number[]) {
|
||||
return apiRequest<WAFSiteRuleGroups>(`/waf/sites/${routeId}/rule-groups`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ ids }),
|
||||
});
|
||||
}
|
||||
|
||||
export function getWAFIPGroups() {
|
||||
return apiRequest<WAFIPGroup[]>('/waf/ip-groups');
|
||||
}
|
||||
|
||||
export function getWAFIPGroup(id: number) {
|
||||
return apiRequest<WAFIPGroup>(`/waf/ip-groups/${id}`);
|
||||
}
|
||||
|
||||
export function createWAFIPGroup(payload: WAFIPGroupPayload) {
|
||||
return apiRequest<WAFIPGroup>('/waf/ip-groups', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export function updateWAFIPGroup(id: number, payload: WAFIPGroupPayload) {
|
||||
return apiRequest<WAFIPGroup>(`/waf/ip-groups/${id}/update`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteWAFIPGroup(id: number) {
|
||||
return apiRequest<void>(`/waf/ip-groups/${id}/delete`, {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
export function syncWAFIPGroup(id: number) {
|
||||
return apiRequest<WAFIPGroupSyncResult>(`/waf/ip-groups/${id}/sync`, {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
export function testWAFIPGroupAutoConfig(payload: WAFIPGroupAutoTestPayload) {
|
||||
return apiRequest<WAFIPGroupAutoTestResult>('/waf/ip-groups/test', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
import type {ProxyRoutePoWConfig} from '@/features/proxy-routes/types';
|
||||
import type {WAFRuleGroup, WAFRuleGroupPayload} from '@/features/waf/types';
|
||||
import type {
|
||||
CountryOption,
|
||||
ListFieldKey,
|
||||
RuleDimension,
|
||||
RuleListRenderable,
|
||||
RuleListType,
|
||||
RuleModalState,
|
||||
WAFTab,
|
||||
} from './types';
|
||||
|
||||
export const defaultPowConfig: ProxyRoutePoWConfig = {
|
||||
difficulty: 4,
|
||||
algorithm: 'fast',
|
||||
session_ttl: 600,
|
||||
challenge_ttl: 300,
|
||||
whitelist: {
|
||||
ips: [],
|
||||
ip_cidrs: [],
|
||||
paths: [],
|
||||
path_regexes: [],
|
||||
user_agents: [],
|
||||
},
|
||||
blacklist: {
|
||||
ips: [],
|
||||
ip_cidrs: [],
|
||||
paths: [],
|
||||
path_regexes: [],
|
||||
user_agents: [],
|
||||
},
|
||||
};
|
||||
|
||||
export const emptyDraft: WAFRuleGroupPayload = {
|
||||
name: '',
|
||||
enabled: true,
|
||||
block_status_code: 418,
|
||||
block_response_body: '',
|
||||
ip_whitelist: [],
|
||||
ip_blacklist: [],
|
||||
ip_whitelist_group_ids: [],
|
||||
ip_blacklist_group_ids: [],
|
||||
country_whitelist: [],
|
||||
country_blacklist: [],
|
||||
region_whitelist: [],
|
||||
region_blacklist: [],
|
||||
pow_enabled: false,
|
||||
pow_config: defaultPowConfig,
|
||||
remark: '',
|
||||
};
|
||||
|
||||
export const defaultRuleModalState: RuleModalState = {
|
||||
open: false,
|
||||
listType: 'blacklist',
|
||||
dimension: 'ip',
|
||||
ipValue: '',
|
||||
ipGroupIDs: [],
|
||||
countryValues: [],
|
||||
};
|
||||
|
||||
export const tabItems: Array<{
|
||||
id: WAFTab;
|
||||
label: string;
|
||||
}> = [
|
||||
{ id: 'basic', label: '基本信息' },
|
||||
{ id: 'lists', label: '黑白名单' },
|
||||
{ id: 'pow', label: 'PoW' },
|
||||
{ id: 'block', label: '拦截返回' },
|
||||
];
|
||||
|
||||
export function getErrorMessage(error: unknown) {
|
||||
return error instanceof Error ? error.message : '操作失败';
|
||||
}
|
||||
|
||||
export function textToList(text: string) {
|
||||
return text
|
||||
.split(/[\n,,\s]+/)
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
export function listToText(items: string[] | undefined) {
|
||||
return (items ?? []).join('\n');
|
||||
}
|
||||
|
||||
export function parseTextareaList(text: string) {
|
||||
return text
|
||||
.split(/\r?\n/)
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
export function normalizeItems(items: string[]) {
|
||||
return Array.from(
|
||||
new Set(items.map((item) => item.trim()).filter(Boolean)),
|
||||
).sort((left, right) => left.localeCompare(right));
|
||||
}
|
||||
|
||||
export function buildDraft(group: WAFRuleGroup | null): WAFRuleGroupPayload {
|
||||
if (!group) {
|
||||
return { ...emptyDraft };
|
||||
}
|
||||
return {
|
||||
name: group.name,
|
||||
enabled: group.enabled,
|
||||
block_status_code: group.block_status_code || 418,
|
||||
block_response_body: group.block_response_body ?? '',
|
||||
ip_whitelist: group.ip_whitelist ?? [],
|
||||
ip_blacklist: group.ip_blacklist ?? [],
|
||||
ip_whitelist_group_ids: group.ip_whitelist_group_ids ?? [],
|
||||
ip_blacklist_group_ids: group.ip_blacklist_group_ids ?? [],
|
||||
country_whitelist: group.country_whitelist ?? [],
|
||||
country_blacklist: group.country_blacklist ?? [],
|
||||
region_whitelist: group.region_whitelist ?? [],
|
||||
region_blacklist: group.region_blacklist ?? [],
|
||||
pow_enabled: group.pow_enabled ?? false,
|
||||
pow_config: group.pow_config ?? defaultPowConfig,
|
||||
remark: group.remark ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
export function countRuleEntries(group: RuleListRenderable) {
|
||||
return (
|
||||
group.ip_whitelist.length +
|
||||
group.ip_blacklist.length +
|
||||
group.ip_whitelist_group_ids.length +
|
||||
group.ip_blacklist_group_ids.length +
|
||||
group.country_whitelist.length +
|
||||
group.country_blacklist.length +
|
||||
group.region_whitelist.length +
|
||||
group.region_blacklist.length
|
||||
);
|
||||
}
|
||||
|
||||
export function buildCountryOptions() {
|
||||
const zhDisplayNames = new Intl.DisplayNames(['zh-CN'], { type: 'region' });
|
||||
const enDisplayNames = new Intl.DisplayNames(['en'], { type: 'region' });
|
||||
const options: CountryOption[] = [];
|
||||
|
||||
for (let first = 65; first <= 90; first += 1) {
|
||||
for (let second = 65; second <= 90; second += 1) {
|
||||
const code = String.fromCharCode(first, second);
|
||||
const zhName = zhDisplayNames.of(code);
|
||||
const enName = enDisplayNames.of(code);
|
||||
|
||||
if (
|
||||
!zhName ||
|
||||
zhName === code ||
|
||||
/未知/.test(zhName) ||
|
||||
!enName ||
|
||||
enName === code ||
|
||||
/Unknown/.test(enName)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
options.push({
|
||||
code,
|
||||
zhName,
|
||||
label: `${code} ${zhName}`,
|
||||
searchText: `${code} ${zhName} ${enName}`.toLowerCase(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return options.sort((left, right) => left.code.localeCompare(right.code));
|
||||
}
|
||||
|
||||
export function getListFieldKey(
|
||||
listType: RuleListType,
|
||||
dimension: RuleDimension,
|
||||
): ListFieldKey {
|
||||
if (dimension === 'ip') {
|
||||
return listType === 'whitelist' ? 'ip_whitelist' : 'ip_blacklist';
|
||||
}
|
||||
if (dimension === 'ip_group') {
|
||||
return listType === 'whitelist'
|
||||
? 'ip_whitelist_group_ids'
|
||||
: 'ip_blacklist_group_ids';
|
||||
}
|
||||
return listType === 'whitelist' ? 'country_whitelist' : 'country_blacklist';
|
||||
}
|
||||
|
||||
export function updateDraftList(
|
||||
draft: WAFRuleGroupPayload,
|
||||
key: ListFieldKey,
|
||||
updater: (items: string[]) => string[],
|
||||
) {
|
||||
switch (key) {
|
||||
case 'ip_whitelist':
|
||||
return { ...draft, ip_whitelist: updater(draft.ip_whitelist) };
|
||||
case 'ip_blacklist':
|
||||
return { ...draft, ip_blacklist: updater(draft.ip_blacklist) };
|
||||
case 'ip_whitelist_group_ids':
|
||||
return {
|
||||
...draft,
|
||||
ip_whitelist_group_ids: updater(
|
||||
draft.ip_whitelist_group_ids.map(String),
|
||||
).map(Number),
|
||||
};
|
||||
case 'ip_blacklist_group_ids':
|
||||
return {
|
||||
...draft,
|
||||
ip_blacklist_group_ids: updater(
|
||||
draft.ip_blacklist_group_ids.map(String),
|
||||
).map(Number),
|
||||
};
|
||||
case 'country_whitelist':
|
||||
return { ...draft, country_whitelist: updater(draft.country_whitelist) };
|
||||
case 'country_blacklist':
|
||||
return { ...draft, country_blacklist: updater(draft.country_blacklist) };
|
||||
}
|
||||
}
|
||||
|
||||
export function formatCountryItem(code: string, labelMap: Map<string, string>) {
|
||||
return labelMap.get(code) ?? code;
|
||||
}
|
||||
@@ -0,0 +1,784 @@
|
||||
'use client';
|
||||
|
||||
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query';
|
||||
import {ArrowLeft, Download, Eye, Play, Plus, Save, Trash2,} from 'lucide-react';
|
||||
import {useRouter} from 'next/navigation';
|
||||
import {useEffect, useMemo, useState} from 'react';
|
||||
|
||||
import {EmptyState} from '@/components/feedback/empty-state';
|
||||
import {ErrorState} from '@/components/feedback/error-state';
|
||||
import {InlineMessage} from '@/components/feedback/inline-message';
|
||||
import {LoadingState} from '@/components/feedback/loading-state';
|
||||
import {PageHeader} from '@/components/layout/page-header';
|
||||
import {AppCard} from '@/components/ui/app-card';
|
||||
import {AppModal} from '@/components/ui/app-modal';
|
||||
import {
|
||||
createWAFIPGroup,
|
||||
deleteWAFIPGroup,
|
||||
getWAFIPGroups,
|
||||
syncWAFIPGroup,
|
||||
testWAFIPGroupAutoConfig,
|
||||
updateWAFIPGroup,
|
||||
} from '@/features/waf/api/waf';
|
||||
import type {
|
||||
WAFIPGroup,
|
||||
WAFIPGroupAutoTestResult,
|
||||
WAFIPGroupPayload,
|
||||
WAFIPGroupSubscriptionFormat,
|
||||
WAFIPGroupType,
|
||||
} from '@/features/waf/types';
|
||||
import {
|
||||
DangerButton,
|
||||
PrimaryButton,
|
||||
ResourceField,
|
||||
ResourceInput,
|
||||
ResourceSelect,
|
||||
ResourceTextarea,
|
||||
SecondaryButton,
|
||||
ToggleField,
|
||||
} from '@/features/shared/components/resource-primitives';
|
||||
import {cn} from '@/lib/utils/cn';
|
||||
|
||||
import {getErrorMessage, listToText, parseTextareaList} from './helpers';
|
||||
|
||||
type FeedbackState = {
|
||||
tone: 'success' | 'danger' | 'info';
|
||||
message: string;
|
||||
};
|
||||
|
||||
type IPGroupDraft = WAFIPGroupPayload & {
|
||||
ip_list_text: string;
|
||||
auto_config_text: string;
|
||||
};
|
||||
|
||||
const emptyIPGroupDraft: IPGroupDraft = {
|
||||
name: '',
|
||||
type: 'manual',
|
||||
enabled: true,
|
||||
ip_list: [],
|
||||
ip_list_text: '',
|
||||
auto_config: {},
|
||||
auto_config_text: '{}',
|
||||
subscription_url: '',
|
||||
subscription_format: 'text',
|
||||
subscription_mapping_rule: '',
|
||||
sync_interval_minutes: 1440,
|
||||
remark: '',
|
||||
};
|
||||
|
||||
const typeLabels: Record<WAFIPGroupType, string> = {
|
||||
manual: '手动',
|
||||
automatic: '自动',
|
||||
subscription: '订阅',
|
||||
};
|
||||
|
||||
const automaticPresetRules = [
|
||||
{
|
||||
name: '单 IP 404 高频扫描',
|
||||
expr: 'request_count > 100 && StatusRatio(404) >= 0.8',
|
||||
},
|
||||
{
|
||||
name: '单 IP 直连访问异常',
|
||||
expr: 'ip_host_count > 50 && ip_host_ratio > 0.5',
|
||||
},
|
||||
];
|
||||
|
||||
function buildDraft(group: WAFIPGroup | null): IPGroupDraft {
|
||||
if (!group) {
|
||||
return { ...emptyIPGroupDraft };
|
||||
}
|
||||
return {
|
||||
name: group.name,
|
||||
type: group.type,
|
||||
enabled: group.enabled,
|
||||
ip_list: group.ip_list ?? [],
|
||||
ip_list_text: listToText(group.ip_list),
|
||||
auto_config: group.auto_config ?? {},
|
||||
auto_config_text: JSON.stringify(group.auto_config ?? {}, null, 2),
|
||||
subscription_url: group.subscription_url ?? '',
|
||||
subscription_format: group.subscription_format ?? 'text',
|
||||
subscription_mapping_rule: group.subscription_mapping_rule ?? '',
|
||||
sync_interval_minutes: group.sync_interval_minutes || 1440,
|
||||
remark: group.remark ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
function buildPayload(draft: IPGroupDraft): WAFIPGroupPayload {
|
||||
const autoConfig =
|
||||
draft.type === 'automatic'
|
||||
? parseAutomaticConfig(draft.auto_config_text)
|
||||
: {};
|
||||
return {
|
||||
name: draft.name,
|
||||
type: draft.type,
|
||||
enabled: draft.enabled,
|
||||
ip_list: parseTextareaList(draft.ip_list_text),
|
||||
auto_config: autoConfig,
|
||||
subscription_url: draft.subscription_url,
|
||||
subscription_format: draft.subscription_format,
|
||||
subscription_mapping_rule: draft.subscription_mapping_rule,
|
||||
sync_interval_minutes: draft.sync_interval_minutes,
|
||||
remark: draft.remark,
|
||||
};
|
||||
}
|
||||
|
||||
function parseAutomaticConfig(text: string): Record<string, unknown> {
|
||||
const parsed = JSON.parse(text || '{}') as unknown;
|
||||
if (!parsed || Array.isArray(parsed) || typeof parsed !== 'object') {
|
||||
throw new Error('自动配置必须是 JSON 对象。');
|
||||
}
|
||||
return parsed as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function appendAutomaticPresetRule(
|
||||
autoConfigText: string,
|
||||
rule: (typeof automaticPresetRules)[number],
|
||||
) {
|
||||
const parsed = JSON.parse(autoConfigText || '{}') as unknown;
|
||||
if (!parsed || Array.isArray(parsed) || typeof parsed !== 'object') {
|
||||
throw new Error('自动配置必须是 JSON 对象。');
|
||||
}
|
||||
const config = parsed as Record<string, unknown>;
|
||||
const rules = Array.isArray(config.rules) ? config.rules : [];
|
||||
const exists = rules.some(
|
||||
(item) =>
|
||||
item &&
|
||||
typeof item === 'object' &&
|
||||
'expr' in item &&
|
||||
(item as { expr?: unknown }).expr === rule.expr,
|
||||
);
|
||||
const nextRules = exists ? rules : [...rules, rule];
|
||||
return JSON.stringify(
|
||||
{
|
||||
lookback_minutes:
|
||||
typeof config.lookback_minutes === 'number'
|
||||
? config.lookback_minutes
|
||||
: 60,
|
||||
...config,
|
||||
rules: nextRules,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
);
|
||||
}
|
||||
|
||||
export function WAFIPGroupsPage() {
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
const [selectedID, setSelectedID] = useState<number | null>(null);
|
||||
const [draft, setDraft] = useState<IPGroupDraft>(emptyIPGroupDraft);
|
||||
const [feedback, setFeedback] = useState<FeedbackState | null>(null);
|
||||
const [autoTestResult, setAutoTestResult] =
|
||||
useState<WAFIPGroupAutoTestResult | null>(null);
|
||||
const [isCapturedIPsModalOpen, setIsCapturedIPsModalOpen] = useState(false);
|
||||
|
||||
const groupsQuery = useQuery({
|
||||
queryKey: ['waf', 'ip-groups'],
|
||||
queryFn: getWAFIPGroups,
|
||||
});
|
||||
|
||||
const groups = useMemo(() => groupsQuery.data ?? [], [groupsQuery.data]);
|
||||
const selectedGroup = useMemo(
|
||||
() =>
|
||||
selectedID === 0
|
||||
? null
|
||||
: (groups.find((group) => group.id === selectedID) ??
|
||||
groups[0] ??
|
||||
null),
|
||||
[groups, selectedID],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedGroup) {
|
||||
setSelectedID(selectedGroup.id);
|
||||
setDraft(buildDraft(selectedGroup));
|
||||
}
|
||||
}, [selectedGroup]);
|
||||
|
||||
const invalidate = async () => {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ['waf', 'ip-groups'] }),
|
||||
queryClient.invalidateQueries({ queryKey: ['waf', 'rule-groups'] }),
|
||||
queryClient.invalidateQueries({ queryKey: ['config-versions', 'diff'] }),
|
||||
]);
|
||||
};
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: (payload: WAFIPGroupPayload) => {
|
||||
if (selectedGroup) {
|
||||
return updateWAFIPGroup(selectedGroup.id, payload);
|
||||
}
|
||||
return createWAFIPGroup(payload);
|
||||
},
|
||||
onSuccess: async (group) => {
|
||||
setSelectedID(group.id);
|
||||
setFeedback({ tone: 'success', message: 'IP 组已保存。' });
|
||||
await invalidate();
|
||||
},
|
||||
onError: (error) => {
|
||||
setFeedback({ tone: 'danger', message: getErrorMessage(error) });
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: deleteWAFIPGroup,
|
||||
onSuccess: async () => {
|
||||
setSelectedID(null);
|
||||
setFeedback({ tone: 'success', message: 'IP 组已删除。' });
|
||||
await invalidate();
|
||||
},
|
||||
onError: (error) => {
|
||||
setFeedback({ tone: 'danger', message: getErrorMessage(error) });
|
||||
},
|
||||
});
|
||||
|
||||
const syncMutation = useMutation({
|
||||
mutationFn: syncWAFIPGroup,
|
||||
onSuccess: async (result) => {
|
||||
setSelectedID(result.group.id);
|
||||
setFeedback({ tone: 'success', message: result.message });
|
||||
await invalidate();
|
||||
},
|
||||
onError: (error) => {
|
||||
setFeedback({ tone: 'danger', message: getErrorMessage(error) });
|
||||
},
|
||||
});
|
||||
|
||||
const testMutation = useMutation({
|
||||
mutationFn: testWAFIPGroupAutoConfig,
|
||||
onSuccess: (result) => {
|
||||
setAutoTestResult(result);
|
||||
setFeedback({
|
||||
tone: result.matched_count > 0 ? 'success' : 'info',
|
||||
message:
|
||||
result.matched_count > 0
|
||||
? `规则测试完成,命中 ${result.matched_count} 个 IP。`
|
||||
: '规则测试完成,当前未命中任何 IP。',
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
setAutoTestResult(null);
|
||||
setFeedback({ tone: 'danger', message: getErrorMessage(error) });
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setAutoTestResult(null);
|
||||
}, [draft.type, draft.auto_config_text, selectedID]);
|
||||
|
||||
if (groupsQuery.isLoading) {
|
||||
return <LoadingState />;
|
||||
}
|
||||
if (groupsQuery.isError) {
|
||||
return (
|
||||
<ErrorState
|
||||
title="IP 组加载失败"
|
||||
description={getErrorMessage(groupsQuery.error)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const saveDraft = () => {
|
||||
try {
|
||||
saveMutation.mutate(buildPayload(draft));
|
||||
} catch (error) {
|
||||
setFeedback({ tone: 'danger', message: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
const testDraft = () => {
|
||||
try {
|
||||
testMutation.mutate({
|
||||
auto_config: parseAutomaticConfig(draft.auto_config_text),
|
||||
});
|
||||
} catch (error) {
|
||||
setAutoTestResult(null);
|
||||
setFeedback({ tone: 'danger', message: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="IP 组"
|
||||
description="维护可被 WAF IP 黑白名单引用的手动、自动与订阅 IP 集合。"
|
||||
action={
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<SecondaryButton type="button" onClick={() => router.push('/waf')}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
返回 WAF
|
||||
</SecondaryButton>
|
||||
<PrimaryButton
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSelectedID(0);
|
||||
setDraft({ ...emptyIPGroupDraft, name: '自定义 IP 组' });
|
||||
}}
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
新建 IP 组
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{feedback ? (
|
||||
<InlineMessage
|
||||
tone={feedback.tone}
|
||||
message={feedback.message}
|
||||
onClear={() => setFeedback(null)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-5 xl:grid-cols-[360px_minmax(0,1fr)]">
|
||||
<AppCard title="IP 组列表">
|
||||
{groups.length === 0 && selectedID !== 0 ? (
|
||||
<EmptyState title="暂无 IP 组" />
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{groups.map((group) => (
|
||||
<button
|
||||
key={group.id}
|
||||
type="button"
|
||||
onClick={() => setSelectedID(group.id)}
|
||||
className={cn(
|
||||
'w-full rounded-2xl border px-4 py-3 text-left transition',
|
||||
selectedGroup?.id === group.id
|
||||
? 'border-[var(--border-strong)] bg-[var(--accent-soft)]'
|
||||
: 'border-[var(--border-default)] bg-[var(--surface-elevated)] hover:bg-[var(--surface-muted)]',
|
||||
)}
|
||||
>
|
||||
<span className="flex items-center justify-between gap-3">
|
||||
<span className="truncate text-sm font-semibold text-[var(--foreground-primary)]">
|
||||
{group.name}
|
||||
</span>
|
||||
<span className="text-xs text-[var(--foreground-secondary)]">
|
||||
{typeLabels[group.type]}
|
||||
</span>
|
||||
</span>
|
||||
<span className="mt-2 block text-xs text-[var(--foreground-secondary)]">
|
||||
{group.enabled ? '启用' : '停用'} · {group.ip_list.length}{' '}
|
||||
条 · 被引用 {group.referenced_by_rule_count} 次
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</AppCard>
|
||||
|
||||
<AppCard
|
||||
title={selectedGroup ? selectedGroup.name : '新建 IP 组'}
|
||||
description={
|
||||
draft.type === 'automatic'
|
||||
? '自动 IP 组会按 Expr 规则定时从请求日志中聚合命中 IP。'
|
||||
: '保存后可在 WAF 规则组黑白名单中引用。'
|
||||
}
|
||||
action={
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{draft.type === 'automatic' ? (
|
||||
<SecondaryButton
|
||||
type="button"
|
||||
disabled={testMutation.isPending}
|
||||
onClick={testDraft}
|
||||
>
|
||||
<Play className="mr-2 h-4 w-4" />
|
||||
{testMutation.isPending ? '测试中...' : '测试规则'}
|
||||
</SecondaryButton>
|
||||
) : null}
|
||||
{selectedGroup?.type === 'subscription' ||
|
||||
selectedGroup?.type === 'automatic' ? (
|
||||
<SecondaryButton
|
||||
type="button"
|
||||
disabled={syncMutation.isPending}
|
||||
onClick={() => syncMutation.mutate(selectedGroup.id)}
|
||||
>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
{syncMutation.isPending
|
||||
? '执行中...'
|
||||
: selectedGroup.type === 'automatic'
|
||||
? '立即执行'
|
||||
: '立即同步'}
|
||||
</SecondaryButton>
|
||||
) : null}
|
||||
{selectedGroup?.type === 'automatic' ? (
|
||||
<SecondaryButton
|
||||
type="button"
|
||||
onClick={() => setIsCapturedIPsModalOpen(true)}
|
||||
>
|
||||
<Eye className="mr-2 h-4 w-4" />
|
||||
查看已抓取 IP
|
||||
</SecondaryButton>
|
||||
) : null}
|
||||
<PrimaryButton
|
||||
type="button"
|
||||
disabled={saveMutation.isPending}
|
||||
onClick={saveDraft}
|
||||
>
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
{saveMutation.isPending ? '保存中...' : '保存 IP 组'}
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="space-y-6">
|
||||
<div className="grid gap-5 xl:grid-cols-2">
|
||||
<ResourceField label="IP 组名称">
|
||||
<ResourceInput
|
||||
value={draft.name}
|
||||
onChange={(event) =>
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
name: event.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</ResourceField>
|
||||
<ResourceField label="类型">
|
||||
<ResourceSelect
|
||||
value={draft.type}
|
||||
onChange={(event) =>
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
type: event.target.value as WAFIPGroupType,
|
||||
}))
|
||||
}
|
||||
>
|
||||
<option value="manual">手动</option>
|
||||
<option value="automatic">自动</option>
|
||||
<option value="subscription">订阅</option>
|
||||
</ResourceSelect>
|
||||
</ResourceField>
|
||||
<ToggleField
|
||||
label="启用 IP 组"
|
||||
description="关闭后保留配置,但发布时不会展开到 WAF 运行时名单。"
|
||||
checked={draft.enabled}
|
||||
onChange={(checked) =>
|
||||
setDraft((current) => ({ ...current, enabled: checked }))
|
||||
}
|
||||
/>
|
||||
<ResourceField label="备注">
|
||||
<ResourceInput
|
||||
value={draft.remark}
|
||||
onChange={(event) =>
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
remark: event.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</ResourceField>
|
||||
</div>
|
||||
|
||||
{draft.type === 'subscription' ? (
|
||||
<div className="grid gap-5 xl:grid-cols-2">
|
||||
<ResourceField label="订阅 URL">
|
||||
<ResourceInput
|
||||
value={draft.subscription_url}
|
||||
placeholder="https://example.com/ip-list.txt"
|
||||
onChange={(event) =>
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
subscription_url: event.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</ResourceField>
|
||||
<ResourceField label="订阅格式">
|
||||
<ResourceSelect
|
||||
value={draft.subscription_format}
|
||||
onChange={(event) =>
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
subscription_format: event.target
|
||||
.value as WAFIPGroupSubscriptionFormat,
|
||||
}))
|
||||
}
|
||||
>
|
||||
<option value="text">文本列表</option>
|
||||
<option value="json">JSON</option>
|
||||
</ResourceSelect>
|
||||
</ResourceField>
|
||||
<ResourceField
|
||||
label="同步间隔(分钟)"
|
||||
hint="最小 5 分钟,默认 1440 分钟。"
|
||||
>
|
||||
<ResourceInput
|
||||
type="number"
|
||||
min={5}
|
||||
value={draft.sync_interval_minutes}
|
||||
onChange={(event) =>
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
sync_interval_minutes: Number(event.target.value),
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</ResourceField>
|
||||
<ResourceField
|
||||
label="JSON 映射规则"
|
||||
hint="留空表示根数组;示例:data.items[]。文本格式无需填写。"
|
||||
>
|
||||
<ResourceInput
|
||||
value={draft.subscription_mapping_rule}
|
||||
disabled={draft.subscription_format !== 'json'}
|
||||
onChange={(event) =>
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
subscription_mapping_rule: event.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</ResourceField>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{draft.type === 'automatic' ? (
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-5 xl:grid-cols-2">
|
||||
<ResourceField
|
||||
label="同步间隔(分钟)"
|
||||
hint="定时从请求日志挖掘恶意 IP 的周期。最小 5 分钟,默认 1440 分钟。"
|
||||
>
|
||||
<ResourceInput
|
||||
type="number"
|
||||
min={5}
|
||||
value={draft.sync_interval_minutes}
|
||||
onChange={(event) =>
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
sync_interval_minutes: Number(event.target.value),
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</ResourceField>
|
||||
</div>
|
||||
<ResourceField
|
||||
label="预设规则"
|
||||
hint="表达式按单个 IP 的请求日志聚合指标计算。"
|
||||
container="div"
|
||||
>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{automaticPresetRules.map((rule) => (
|
||||
<SecondaryButton
|
||||
key={rule.expr}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
try {
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
auto_config_text: appendAutomaticPresetRule(
|
||||
current.auto_config_text,
|
||||
rule,
|
||||
),
|
||||
}));
|
||||
} catch (error) {
|
||||
setFeedback({
|
||||
tone: 'danger',
|
||||
message: getErrorMessage(error),
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
{rule.name}
|
||||
</SecondaryButton>
|
||||
))}
|
||||
</div>
|
||||
</ResourceField>
|
||||
<ResourceField
|
||||
label="自动配置 JSON"
|
||||
hint="可用字段:request_count、status_404_count、status_404_ratio、ip_host_count、ip_host_ratio。方法:StatusCount(code)、StatusRatio(code)。支持 ttl(秒,默认 -1 永久拉黑)。"
|
||||
>
|
||||
<ResourceTextarea
|
||||
value={draft.auto_config_text}
|
||||
className="min-h-64 font-mono"
|
||||
onChange={(event) =>
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
auto_config_text: event.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</ResourceField>
|
||||
{autoTestResult ? (
|
||||
<div className="space-y-3 rounded-2xl border border-[var(--border-default)] bg-[var(--surface-muted)] p-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="text-sm font-semibold text-[var(--foreground-primary)]">
|
||||
测试结果
|
||||
</div>
|
||||
<div className="text-xs text-[var(--foreground-secondary)]">
|
||||
回看 {autoTestResult.lookback_minutes} 分钟 · 规则{' '}
|
||||
{autoTestResult.rule_count} 条
|
||||
</div>
|
||||
</div>
|
||||
<InlineMessage
|
||||
tone={
|
||||
autoTestResult.matched_count > 0 ? 'success' : 'info'
|
||||
}
|
||||
message={
|
||||
autoTestResult.matched_count > 0
|
||||
? `命中 ${autoTestResult.matched_count} 个 IP。`
|
||||
: '当前没有匹配到任何 IP。'
|
||||
}
|
||||
/>
|
||||
{autoTestResult.matched_count > 0 ? (
|
||||
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] p-3">
|
||||
<div className="mb-2 text-xs font-medium text-[var(--foreground-secondary)]">
|
||||
命中 IP 列表
|
||||
</div>
|
||||
<pre className="overflow-x-auto text-sm break-all whitespace-pre-wrap text-[var(--foreground-primary)]">
|
||||
{autoTestResult.matched_ips.join('\n')}
|
||||
</pre>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<ResourceField
|
||||
label="IP / IP 段"
|
||||
hint={
|
||||
draft.type === 'subscription'
|
||||
? '订阅同步会覆盖此列表;也可以先手动保存当前内容。'
|
||||
: '支持单个 IP 或 CIDR,每行一个。'
|
||||
}
|
||||
>
|
||||
<ResourceTextarea
|
||||
value={draft.ip_list_text}
|
||||
className="min-h-72 font-mono"
|
||||
placeholder={'203.0.113.10\n198.51.100.0/24'}
|
||||
onChange={(event) =>
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
ip_list_text: event.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</ResourceField>
|
||||
)}
|
||||
|
||||
{selectedGroup ? (
|
||||
<div className="flex flex-wrap justify-between gap-3 border-t border-[var(--border-default)] pt-6">
|
||||
<div className="text-sm text-[var(--foreground-secondary)]">
|
||||
{selectedGroup.last_sync_status
|
||||
? `${selectedGroup.last_sync_status}: ${selectedGroup.last_sync_message}`
|
||||
: '尚无同步记录'}
|
||||
</div>
|
||||
<DangerButton
|
||||
type="button"
|
||||
disabled={deleteMutation.isPending}
|
||||
onClick={() => {
|
||||
if (
|
||||
window.confirm(
|
||||
`确认删除 IP 组 ${selectedGroup.name} 吗?`,
|
||||
)
|
||||
) {
|
||||
deleteMutation.mutate(selectedGroup.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
删除
|
||||
</DangerButton>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</AppCard>
|
||||
</div>
|
||||
{selectedGroup?.type === 'automatic' ? (
|
||||
<AppModal
|
||||
isOpen={isCapturedIPsModalOpen}
|
||||
title={`已抓取 IP 列表 - ${selectedGroup.name}`}
|
||||
description="展示当前自动挖掘并放入黑名单的 IP 列表及其到期状态。"
|
||||
size="lg"
|
||||
onClose={() => setIsCapturedIPsModalOpen(false)}
|
||||
footer={
|
||||
<div className="flex justify-end">
|
||||
<SecondaryButton
|
||||
type="button"
|
||||
onClick={() => setIsCapturedIPsModalOpen(false)}
|
||||
>
|
||||
关闭
|
||||
</SecondaryButton>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{!selectedGroup.ext_ips || selectedGroup.ext_ips.length === 0 ? (
|
||||
<EmptyState
|
||||
title="暂无抓取记录"
|
||||
description="该自动规则暂未抓取或命中任何恶意 IP,点击「立即执行」手动触发抓取。"
|
||||
/>
|
||||
) : (
|
||||
<div className="overflow-hidden rounded-2xl border border-[var(--border-default)]">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-[var(--border-default)]">
|
||||
<thead className="bg-[var(--surface-muted)]">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-[var(--foreground-secondary)]">
|
||||
IP 地址
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-[var(--foreground-secondary)]">
|
||||
抓取时间
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-[var(--foreground-secondary)]">
|
||||
封禁剩余时间
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-[var(--border-default)] bg-[var(--surface-elevated)]">
|
||||
{selectedGroup.ext_ips.map((item) => {
|
||||
const autoConfig = selectedGroup.auto_config as
|
||||
| { ttl?: number }
|
||||
| undefined;
|
||||
const ttl = autoConfig?.ttl ?? -1;
|
||||
let expireText = '永久';
|
||||
if (ttl > 0) {
|
||||
const capturedDate = new Date(item.captured_at);
|
||||
const expireDate = new Date(
|
||||
capturedDate.getTime() + ttl * 1000,
|
||||
);
|
||||
const now = new Date();
|
||||
if (expireDate.getTime() <= now.getTime()) {
|
||||
expireText = '已过期';
|
||||
} else {
|
||||
const diffMs = expireDate.getTime() - now.getTime();
|
||||
const diffMins = Math.round(diffMs / (60 * 1000));
|
||||
if (diffMins < 60) {
|
||||
expireText = `${diffMins} 分钟后`;
|
||||
} else {
|
||||
const diffHours = Math.round(diffMins / 60);
|
||||
expireText = `${diffHours} 小时后`;
|
||||
}
|
||||
}
|
||||
}
|
||||
return (
|
||||
<tr key={item.ip}>
|
||||
<td className="px-4 py-3 font-mono text-sm font-medium whitespace-nowrap text-[var(--foreground-primary)]">
|
||||
{item.ip}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm whitespace-nowrap text-[var(--foreground-secondary)]">
|
||||
{new Date(item.captured_at).toLocaleString()}
|
||||
</td>
|
||||
<td
|
||||
className={cn(
|
||||
'px-4 py-3 text-sm font-medium whitespace-nowrap',
|
||||
ttl > 0 ? 'text-amber-500' : 'text-emerald-500',
|
||||
)}
|
||||
>
|
||||
{expireText}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</AppModal>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
import {useEffect, useState} from 'react';
|
||||
import type {ProxyRoutePoWConfig} from '@/features/proxy-routes/types';
|
||||
import {
|
||||
ResourceField,
|
||||
ResourceInput,
|
||||
ResourceTextarea,
|
||||
ToggleField,
|
||||
} from '@/features/shared/components/resource-primitives';
|
||||
import {listToText, parseTextareaList} from './helpers';
|
||||
|
||||
export function PowTabPanel({
|
||||
enabled,
|
||||
config,
|
||||
onChange,
|
||||
}: {
|
||||
enabled: boolean;
|
||||
config: ProxyRoutePoWConfig;
|
||||
onChange: (enabled: boolean, config: ProxyRoutePoWConfig) => void;
|
||||
}) {
|
||||
const [draft, setDraft] = useState(() => ({
|
||||
whitelist: {
|
||||
ips: listToText(config.whitelist?.ips),
|
||||
ip_cidrs: listToText(config.whitelist?.ip_cidrs),
|
||||
paths: listToText(config.whitelist?.paths),
|
||||
path_regexes: listToText(config.whitelist?.path_regexes),
|
||||
user_agents: listToText(config.whitelist?.user_agents),
|
||||
},
|
||||
blacklist: {
|
||||
ips: listToText(config.blacklist?.ips),
|
||||
ip_cidrs: listToText(config.blacklist?.ip_cidrs),
|
||||
paths: listToText(config.blacklist?.paths),
|
||||
path_regexes: listToText(config.blacklist?.path_regexes),
|
||||
user_agents: listToText(config.blacklist?.user_agents),
|
||||
},
|
||||
}));
|
||||
|
||||
useEffect(() => {
|
||||
setDraft({
|
||||
whitelist: {
|
||||
ips: listToText(config.whitelist?.ips),
|
||||
ip_cidrs: listToText(config.whitelist?.ip_cidrs),
|
||||
paths: listToText(config.whitelist?.paths),
|
||||
path_regexes: listToText(config.whitelist?.path_regexes),
|
||||
user_agents: listToText(config.whitelist?.user_agents),
|
||||
},
|
||||
blacklist: {
|
||||
ips: listToText(config.blacklist?.ips),
|
||||
ip_cidrs: listToText(config.blacklist?.ip_cidrs),
|
||||
paths: listToText(config.blacklist?.paths),
|
||||
path_regexes: listToText(config.blacklist?.path_regexes),
|
||||
user_agents: listToText(config.blacklist?.user_agents),
|
||||
},
|
||||
});
|
||||
}, [config]);
|
||||
|
||||
const updateConfig = (
|
||||
newEnabled: boolean,
|
||||
newConfig: Partial<ProxyRoutePoWConfig>,
|
||||
newDraft?: typeof draft,
|
||||
) => {
|
||||
const nextConfig = { ...config, ...newConfig };
|
||||
if (newDraft) {
|
||||
setDraft(newDraft);
|
||||
nextConfig.whitelist = {
|
||||
ips: parseTextareaList(newDraft.whitelist.ips),
|
||||
ip_cidrs: parseTextareaList(newDraft.whitelist.ip_cidrs),
|
||||
paths: parseTextareaList(newDraft.whitelist.paths),
|
||||
path_regexes: parseTextareaList(newDraft.whitelist.path_regexes),
|
||||
user_agents: parseTextareaList(newDraft.whitelist.user_agents),
|
||||
};
|
||||
nextConfig.blacklist = {
|
||||
ips: parseTextareaList(newDraft.blacklist.ips),
|
||||
ip_cidrs: parseTextareaList(newDraft.blacklist.ip_cidrs),
|
||||
paths: parseTextareaList(newDraft.blacklist.paths),
|
||||
path_regexes: parseTextareaList(newDraft.blacklist.path_regexes),
|
||||
user_agents: parseTextareaList(newDraft.blacklist.user_agents),
|
||||
};
|
||||
}
|
||||
onChange(newEnabled, nextConfig);
|
||||
};
|
||||
|
||||
const updateList = (
|
||||
scope: 'whitelist' | 'blacklist',
|
||||
key: keyof ProxyRoutePoWConfig['whitelist'],
|
||||
value: string,
|
||||
) => {
|
||||
const nextDraft = {
|
||||
...draft,
|
||||
[scope]: {
|
||||
...draft[scope],
|
||||
[key]: value,
|
||||
},
|
||||
};
|
||||
updateConfig(enabled, {}, nextDraft);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<ToggleField
|
||||
label="启用 PoW 防护"
|
||||
description="启用后,命中该规则组的请求需要完成浏览器计算挑战。"
|
||||
checked={enabled}
|
||||
onChange={(newEnabled) => updateConfig(newEnabled, {})}
|
||||
/>
|
||||
|
||||
<div className="grid gap-5 md:grid-cols-2 xl:grid-cols-4">
|
||||
<ResourceField label="算法">
|
||||
<select
|
||||
value={config.algorithm}
|
||||
onChange={(event) =>
|
||||
updateConfig(enabled, {
|
||||
algorithm: event.target.value as 'fast' | 'slow',
|
||||
})
|
||||
}
|
||||
className="w-full rounded-2xl border border-[var(--border-default)] bg-[var(--control-background)] px-4 py-3 text-sm text-[var(--foreground-primary)] transition outline-none focus:border-[var(--border-strong)]"
|
||||
>
|
||||
<option value="fast">Fast</option>
|
||||
<option value="slow">Slow</option>
|
||||
</select>
|
||||
</ResourceField>
|
||||
<ResourceField label="难度">
|
||||
<ResourceInput
|
||||
type="number"
|
||||
min={1}
|
||||
max={16}
|
||||
value={config.difficulty}
|
||||
onChange={(event) =>
|
||||
updateConfig(enabled, { difficulty: Number(event.target.value) })
|
||||
}
|
||||
/>
|
||||
</ResourceField>
|
||||
<ResourceField label="会话 TTL">
|
||||
<ResourceInput
|
||||
type="number"
|
||||
min={60}
|
||||
value={config.session_ttl}
|
||||
onChange={(event) =>
|
||||
updateConfig(enabled, { session_ttl: Number(event.target.value) })
|
||||
}
|
||||
/>
|
||||
</ResourceField>
|
||||
<ResourceField label="挑战 TTL">
|
||||
<ResourceInput
|
||||
type="number"
|
||||
min={30}
|
||||
value={config.challenge_ttl}
|
||||
onChange={(event) =>
|
||||
updateConfig(enabled, {
|
||||
challenge_ttl: Number(event.target.value),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</ResourceField>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-5 xl:grid-cols-2">
|
||||
{(['whitelist', 'blacklist'] as const).map((scope) => (
|
||||
<div
|
||||
key={scope}
|
||||
className="rounded-[26px] border border-[var(--border-default)] bg-[var(--surface-elevated)] p-5"
|
||||
>
|
||||
<h3 className="text-sm font-semibold text-[var(--foreground-primary)]">
|
||||
{scope === 'whitelist'
|
||||
? '白名单(跳过 PoW)'
|
||||
: '黑名单(必须 PoW)'}
|
||||
</h3>
|
||||
<div className="mt-4 space-y-4">
|
||||
<ResourceField label="IP">
|
||||
<ResourceTextarea
|
||||
className="min-h-20"
|
||||
value={draft[scope].ips}
|
||||
onChange={(event) =>
|
||||
updateList(scope, 'ips', event.target.value)
|
||||
}
|
||||
/>
|
||||
</ResourceField>
|
||||
<ResourceField label="IP CIDR">
|
||||
<ResourceTextarea
|
||||
className="min-h-20"
|
||||
value={draft[scope].ip_cidrs}
|
||||
onChange={(event) =>
|
||||
updateList(scope, 'ip_cidrs', event.target.value)
|
||||
}
|
||||
/>
|
||||
</ResourceField>
|
||||
<ResourceField label="路径">
|
||||
<ResourceTextarea
|
||||
className="min-h-20"
|
||||
value={draft[scope].paths}
|
||||
onChange={(event) =>
|
||||
updateList(scope, 'paths', event.target.value)
|
||||
}
|
||||
/>
|
||||
</ResourceField>
|
||||
<ResourceField label="路径正则">
|
||||
<ResourceTextarea
|
||||
className="min-h-20"
|
||||
value={draft[scope].path_regexes}
|
||||
onChange={(event) =>
|
||||
updateList(scope, 'path_regexes', event.target.value)
|
||||
}
|
||||
/>
|
||||
</ResourceField>
|
||||
<ResourceField label="User-Agent">
|
||||
<ResourceTextarea
|
||||
className="min-h-20"
|
||||
value={draft[scope].user_agents}
|
||||
onChange={(event) =>
|
||||
updateList(scope, 'user_agents', event.target.value)
|
||||
}
|
||||
/>
|
||||
</ResourceField>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import {cn} from '@/lib/utils/cn';
|
||||
|
||||
export function RuleChip({
|
||||
label,
|
||||
tone,
|
||||
onRemove,
|
||||
}: {
|
||||
label: string;
|
||||
tone: 'whitelist' | 'blacklist';
|
||||
onRemove: () => void;
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center gap-2 rounded-full border px-3 py-2 text-sm',
|
||||
tone === 'whitelist'
|
||||
? 'border-emerald-500/30 bg-emerald-500/18 text-[var(--foreground-primary)]'
|
||||
: 'border-rose-500/30 bg-rose-500/18 text-[var(--foreground-primary)]',
|
||||
)}
|
||||
>
|
||||
<span className="break-all">{label}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRemove}
|
||||
className="inline-flex h-5 w-5 items-center justify-center rounded-full border border-current/20 text-xs text-[var(--foreground-primary)] transition hover:bg-black/10"
|
||||
aria-label={`移除 ${label}`}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
import {useEffect, useMemo, useState} from 'react';
|
||||
import {Search} from 'lucide-react';
|
||||
import {AppModal} from '@/components/ui/app-modal';
|
||||
import type {WAFIPGroup} from '@/features/waf/types';
|
||||
import {
|
||||
PrimaryButton,
|
||||
ResourceField,
|
||||
ResourceTextarea,
|
||||
SecondaryButton,
|
||||
} from '@/features/shared/components/resource-primitives';
|
||||
import {cn} from '@/lib/utils/cn';
|
||||
import {normalizeItems} from './helpers';
|
||||
import type {CountryOption, RuleDimension, RuleListType, RuleModalState,} from './types';
|
||||
|
||||
export function RuleEntryModal({
|
||||
state,
|
||||
countryOptions,
|
||||
ipGroups,
|
||||
pending,
|
||||
onClose,
|
||||
onChange,
|
||||
onSubmit,
|
||||
}: {
|
||||
state: RuleModalState;
|
||||
countryOptions: CountryOption[];
|
||||
ipGroups: WAFIPGroup[];
|
||||
pending: boolean;
|
||||
onClose: () => void;
|
||||
onChange: (patch: Partial<RuleModalState>) => void;
|
||||
onSubmit: () => void;
|
||||
}) {
|
||||
const [keyword, setKeyword] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (!state.open) {
|
||||
return;
|
||||
}
|
||||
setKeyword('');
|
||||
}, [state.dimension, state.open]);
|
||||
|
||||
const selectedCountrySet = useMemo(
|
||||
() => new Set(state.countryValues),
|
||||
[state.countryValues],
|
||||
);
|
||||
const selectedIPGroupSet = useMemo(
|
||||
() => new Set(state.ipGroupIDs),
|
||||
[state.ipGroupIDs],
|
||||
);
|
||||
|
||||
const filteredCountries = useMemo(() => {
|
||||
const normalized = keyword.trim().toLowerCase();
|
||||
|
||||
return countryOptions
|
||||
.filter((option) => !normalized || option.searchText.includes(normalized))
|
||||
.sort((left, right) => {
|
||||
const leftSelected = selectedCountrySet.has(left.code) ? 1 : 0;
|
||||
const rightSelected = selectedCountrySet.has(right.code) ? 1 : 0;
|
||||
return (
|
||||
rightSelected - leftSelected || left.code.localeCompare(right.code)
|
||||
);
|
||||
});
|
||||
}, [countryOptions, keyword, selectedCountrySet]);
|
||||
|
||||
const toggleCountry = (code: string) => {
|
||||
const values = selectedCountrySet.has(code)
|
||||
? state.countryValues.filter((item) => item !== code)
|
||||
: normalizeItems([...state.countryValues, code]);
|
||||
onChange({ countryValues: values });
|
||||
};
|
||||
|
||||
const selectFiltered = () => {
|
||||
onChange({
|
||||
countryValues: normalizeItems([
|
||||
...state.countryValues,
|
||||
...filteredCountries.map((option) => option.code),
|
||||
]),
|
||||
});
|
||||
};
|
||||
|
||||
const clearCountries = () => onChange({ countryValues: [] });
|
||||
const toggleIPGroup = (id: number) => {
|
||||
const values = selectedIPGroupSet.has(id)
|
||||
? state.ipGroupIDs.filter((item) => item !== id)
|
||||
: [...state.ipGroupIDs, id].sort((left, right) => left - right);
|
||||
onChange({ ipGroupIDs: values });
|
||||
};
|
||||
|
||||
const typeLabel = state.listType === 'blacklist' ? '黑名单' : '白名单';
|
||||
const dimensionLabel =
|
||||
state.dimension === 'ip'
|
||||
? 'IP'
|
||||
: state.dimension === 'ip_group'
|
||||
? 'IP 组'
|
||||
: '地域';
|
||||
|
||||
return (
|
||||
<AppModal
|
||||
isOpen={state.open}
|
||||
title={`添加${typeLabel}规则`}
|
||||
description={`当前准备新增 ${dimensionLabel} 维度的${typeLabel}项。`}
|
||||
size="lg"
|
||||
onClose={onClose}
|
||||
footer={
|
||||
<div className="flex justify-end gap-3">
|
||||
<SecondaryButton type="button" onClick={onClose}>
|
||||
取消
|
||||
</SecondaryButton>
|
||||
<PrimaryButton type="button" disabled={pending} onClick={onSubmit}>
|
||||
{pending ? '处理中...' : '添加'}
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="space-y-6">
|
||||
<div className="grid gap-5 md:grid-cols-2">
|
||||
<ResourceField label="类型" container="div">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{[
|
||||
{ value: 'blacklist', label: '黑名单' },
|
||||
{ value: 'whitelist', label: '白名单' },
|
||||
].map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() =>
|
||||
onChange({ listType: option.value as RuleListType })
|
||||
}
|
||||
className={cn(
|
||||
'rounded-2xl border px-4 py-3 text-sm font-medium transition',
|
||||
state.listType === option.value
|
||||
? 'border-[var(--border-strong)] bg-[var(--accent-soft)] text-[var(--foreground-primary)]'
|
||||
: 'border-[var(--border-default)] bg-[var(--surface-elevated)] text-[var(--foreground-secondary)] hover:bg-[var(--surface-muted)]',
|
||||
)}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</ResourceField>
|
||||
<ResourceField label="维度" container="div">
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{[
|
||||
{ value: 'ip', label: 'IP' },
|
||||
{ value: 'ip_group', label: 'IP 组' },
|
||||
{ value: 'country', label: '地域' },
|
||||
].map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() =>
|
||||
onChange({ dimension: option.value as RuleDimension })
|
||||
}
|
||||
className={cn(
|
||||
'rounded-2xl border px-4 py-3 text-sm font-medium transition',
|
||||
state.dimension === option.value
|
||||
? 'border-[var(--border-strong)] bg-[var(--accent-soft)] text-[var(--foreground-primary)]'
|
||||
: 'border-[var(--border-default)] bg-[var(--surface-elevated)] text-[var(--foreground-secondary)] hover:bg-[var(--surface-muted)]',
|
||||
)}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</ResourceField>
|
||||
</div>
|
||||
|
||||
{state.dimension === 'ip' ? (
|
||||
<ResourceField
|
||||
label="IP / IP 段"
|
||||
hint="支持单个 IP、CIDR,或使用换行/逗号一次添加多个。"
|
||||
>
|
||||
<ResourceTextarea
|
||||
value={state.ipValue}
|
||||
placeholder="例如 1.1.1.1 或 192.168.0.0/24"
|
||||
onChange={(event) => onChange({ ipValue: event.target.value })}
|
||||
/>
|
||||
</ResourceField>
|
||||
) : state.dimension === 'ip_group' ? (
|
||||
<div className="rounded-[26px] border border-[var(--border-default)] bg-[var(--surface-elevated)] p-5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-[var(--foreground-primary)]">
|
||||
选择 IP 组
|
||||
</h3>
|
||||
<p className="mt-1 text-xs leading-5 text-[var(--foreground-secondary)]">
|
||||
发布版本只保存引用 ID,IP 组成员由 Agent 按 checksum
|
||||
差异同步。
|
||||
</p>
|
||||
</div>
|
||||
<span className="rounded-full border border-[var(--border-default)] px-2.5 py-1 text-xs font-medium text-[var(--foreground-secondary)]">
|
||||
已选 {state.ipGroupIDs.length}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-4 max-h-80 space-y-2 overflow-y-auto pr-1">
|
||||
{ipGroups.length > 0 ? (
|
||||
ipGroups.map((group) => {
|
||||
const selected = selectedIPGroupSet.has(group.id);
|
||||
return (
|
||||
<label
|
||||
key={group.id}
|
||||
className={cn(
|
||||
'flex cursor-pointer items-center gap-3 rounded-2xl border px-4 py-3 transition',
|
||||
selected
|
||||
? 'border-[var(--border-strong)] bg-[var(--accent-soft)]'
|
||||
: 'border-[var(--border-default)] bg-[var(--surface-panel)] hover:bg-[var(--surface-muted)]',
|
||||
)}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected}
|
||||
onChange={() => toggleIPGroup(group.id)}
|
||||
className="h-4 w-4 rounded border-[var(--border-default)] accent-[var(--brand-primary)]"
|
||||
/>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm font-medium text-[var(--foreground-primary)]">
|
||||
{group.name}
|
||||
</span>
|
||||
<span className="mt-1 block text-xs text-[var(--foreground-secondary)]">
|
||||
{group.type} · {group.ip_list.length} 条 ·{' '}
|
||||
{group.enabled ? '启用' : '停用'}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<p className="text-sm text-[var(--foreground-muted)]">
|
||||
暂无 IP 组,请先进入 IP 组管理页面创建。
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3 rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-3">
|
||||
<Search className="h-4 w-4 text-[var(--foreground-secondary)]" />
|
||||
<input
|
||||
value={keyword}
|
||||
onChange={(event) => setKeyword(event.target.value)}
|
||||
placeholder="搜索国家代码或中文名"
|
||||
className="min-w-0 flex-1 bg-transparent text-sm text-[var(--foreground-primary)] outline-none placeholder:text-[var(--foreground-muted)]"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={selectFiltered}
|
||||
className="text-xs font-medium text-[var(--brand-primary)]"
|
||||
>
|
||||
全选当前
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={clearCountries}
|
||||
className="text-xs font-medium text-[var(--foreground-secondary)]"
|
||||
>
|
||||
清空
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="rounded-[26px] border border-[var(--border-default)] bg-[var(--surface-elevated)] p-5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-[var(--foreground-primary)]">
|
||||
地域多选
|
||||
</h3>
|
||||
<p className="mt-1 text-xs leading-5 text-[var(--foreground-secondary)]">
|
||||
选项显示为「国家代码 国家中文名」。
|
||||
</p>
|
||||
</div>
|
||||
<span className="rounded-full border border-[var(--border-default)] px-2.5 py-1 text-xs font-medium text-[var(--foreground-secondary)]">
|
||||
已选 {state.countryValues.length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 max-h-80 space-y-2 overflow-y-auto pr-1">
|
||||
{filteredCountries.map((option) => {
|
||||
const selected = selectedCountrySet.has(option.code);
|
||||
return (
|
||||
<label
|
||||
key={option.code}
|
||||
className={cn(
|
||||
'flex cursor-pointer items-center gap-3 rounded-2xl border px-4 py-3 transition',
|
||||
selected
|
||||
? 'border-[var(--border-strong)] bg-[var(--accent-soft)]'
|
||||
: 'border-[var(--border-default)] bg-[var(--surface-panel)] hover:bg-[var(--surface-muted)]',
|
||||
)}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected}
|
||||
onChange={() => toggleCountry(option.code)}
|
||||
className="h-4 w-4 rounded border-[var(--border-default)] accent-[var(--brand-primary)]"
|
||||
/>
|
||||
<span className="min-w-0">
|
||||
<span className="block text-sm font-medium text-[var(--foreground-primary)]">
|
||||
{option.label}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</AppModal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import {RuleChip} from './rule-chip';
|
||||
|
||||
export function RuleListSection({
|
||||
title,
|
||||
description,
|
||||
items,
|
||||
groupItems = [],
|
||||
tone,
|
||||
emptyText,
|
||||
onRemove,
|
||||
onRemoveGroup,
|
||||
}: {
|
||||
title: string;
|
||||
description: string;
|
||||
items: string[];
|
||||
groupItems?: Array<{ id: number; name: string; enabled: boolean }>;
|
||||
tone: 'whitelist' | 'blacklist';
|
||||
emptyText: string;
|
||||
onRemove: (item: string) => void;
|
||||
onRemoveGroup?: (id: number) => void;
|
||||
}) {
|
||||
const total = items.length + groupItems.length;
|
||||
return (
|
||||
<div className="rounded-[26px] border border-[var(--border-default)] bg-[var(--surface-elevated)] p-5">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-[var(--foreground-primary)]">
|
||||
{title}
|
||||
</h3>
|
||||
<p className="mt-1 text-xs leading-5 text-[var(--foreground-secondary)]">
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
<span className="shrink-0 rounded-full bg-[var(--surface-muted)] px-3 py-1 text-xs font-semibold text-[var(--foreground-primary)]">
|
||||
{total}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-5">
|
||||
{total > 0 ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{groupItems.map((group) => (
|
||||
<RuleChip
|
||||
key={`group-${group.id}`}
|
||||
label={`IP组: ${group.name}${group.enabled ? '' : ' (停用)'}`}
|
||||
tone={tone}
|
||||
onRemove={() => onRemoveGroup?.(group.id)}
|
||||
/>
|
||||
))}
|
||||
{items.map((item) => (
|
||||
<RuleChip
|
||||
key={item}
|
||||
label={item}
|
||||
tone={tone}
|
||||
onRemove={() => onRemove(item)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-[var(--foreground-muted)]">{emptyText}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Check, Search } from 'lucide-react';
|
||||
import { Drawer } from '@/components/ui/drawer';
|
||||
import type { ProxyRouteItem } from '@/features/proxy-routes/types';
|
||||
import {
|
||||
PrimaryButton,
|
||||
SecondaryButton,
|
||||
} from '@/features/shared/components/resource-primitives';
|
||||
import type { WAFRuleGroup } from '@/features/waf/types';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
|
||||
export function SiteApplyDrawer({
|
||||
group,
|
||||
routes,
|
||||
open,
|
||||
onOpenChange,
|
||||
onSave,
|
||||
pending,
|
||||
}: {
|
||||
group: WAFRuleGroup | null;
|
||||
routes: ProxyRouteItem[];
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSave: (ids: number[]) => void;
|
||||
pending: boolean;
|
||||
}) {
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [selectedIDs, setSelectedIDs] = useState<number[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedIDs(group?.applied_site_ids ?? []);
|
||||
setKeyword('');
|
||||
}, [group, open]);
|
||||
|
||||
const filteredRoutes = useMemo(() => {
|
||||
const normalized = keyword.trim().toLowerCase();
|
||||
if (!normalized) {
|
||||
return routes;
|
||||
}
|
||||
return routes.filter((route) =>
|
||||
[route.site_name, route.primary_domain, ...route.domains]
|
||||
.join(' ')
|
||||
.toLowerCase()
|
||||
.includes(normalized),
|
||||
);
|
||||
}, [keyword, routes]);
|
||||
|
||||
const selectedSet = useMemo(() => new Set(selectedIDs), [selectedIDs]);
|
||||
const toggleID = (id: number) => {
|
||||
setSelectedIDs((current) =>
|
||||
current.includes(id)
|
||||
? current.filter((item) => item !== id)
|
||||
: [...current, id].sort((left, right) => left - right),
|
||||
);
|
||||
};
|
||||
|
||||
const selectFiltered = () => {
|
||||
const next = new Set(selectedIDs);
|
||||
filteredRoutes.forEach((route) => next.add(route.id));
|
||||
setSelectedIDs([...next].sort((left, right) => left - right));
|
||||
};
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
direction="right"
|
||||
title={group ? `应用 ${group.name}` : '应用规则组'}
|
||||
description="选择这个自定义规则组要叠加到哪些网站。"
|
||||
footer={
|
||||
<div className="flex justify-end gap-3">
|
||||
<SecondaryButton type="button" onClick={() => onOpenChange(false)}>
|
||||
取消
|
||||
</SecondaryButton>
|
||||
<PrimaryButton
|
||||
type="button"
|
||||
disabled={!group || pending}
|
||||
onClick={() => onSave(selectedIDs)}
|
||||
>
|
||||
{pending ? '保存中...' : '保存应用范围'}
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3 rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-3">
|
||||
<Search className="h-4 w-4 text-[var(--foreground-secondary)]" />
|
||||
<input
|
||||
value={keyword}
|
||||
onChange={(event) => setKeyword(event.target.value)}
|
||||
placeholder="搜索网站或域名"
|
||||
className="min-w-0 flex-1 bg-transparent text-sm text-[var(--foreground-primary)] outline-none placeholder:text-[var(--foreground-muted)]"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={selectFiltered}
|
||||
className="text-xs font-medium text-[var(--brand-primary)]"
|
||||
>
|
||||
全选当前
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{filteredRoutes.map((route) => (
|
||||
<button
|
||||
key={route.id}
|
||||
type="button"
|
||||
onClick={() => toggleID(route.id)}
|
||||
className={cn(
|
||||
'flex w-full items-center gap-3 rounded-2xl border px-4 py-3 text-left transition',
|
||||
selectedSet.has(route.id)
|
||||
? 'border-[var(--border-strong)] bg-[var(--accent-soft)]'
|
||||
: 'border-[var(--border-default)] bg-[var(--surface-elevated)] hover:bg-[var(--surface-muted)]',
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'flex h-5 w-5 items-center justify-center rounded-md border',
|
||||
selectedSet.has(route.id)
|
||||
? 'border-[var(--brand-primary)] bg-[var(--brand-primary)] text-[var(--foreground-inverse)]'
|
||||
: 'border-[var(--border-default)]',
|
||||
)}
|
||||
>
|
||||
{selectedSet.has(route.id) ? (
|
||||
<Check className="h-3 w-3" />
|
||||
) : null}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm font-medium text-[var(--foreground-primary)]">
|
||||
{route.site_name}
|
||||
</span>
|
||||
<span className="block truncate text-xs text-[var(--foreground-secondary)]">
|
||||
{route.domains.join(', ')}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
|
||||
export function StatCard({
|
||||
icon: Icon,
|
||||
label,
|
||||
value,
|
||||
hint,
|
||||
}: {
|
||||
icon: LucideIcon;
|
||||
label: string;
|
||||
value: string;
|
||||
hint?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-[26px] border border-[var(--border-default)] bg-[var(--surface-elevated)] p-5">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-medium tracking-[0.22em] text-[var(--foreground-muted)] uppercase">
|
||||
{label}
|
||||
</p>
|
||||
<p className="text-2xl font-semibold text-[var(--foreground-primary)]">
|
||||
{value}
|
||||
</p>
|
||||
</div>
|
||||
<span className="inline-flex h-11 w-11 items-center justify-center rounded-2xl border border-[var(--border-default)] bg-[var(--surface-panel)] text-[var(--foreground-primary)]">
|
||||
<Icon className="h-5 w-5" />
|
||||
</span>
|
||||
</div>
|
||||
{hint && (
|
||||
<p className="mt-3 text-sm leading-6 text-[var(--foreground-secondary)]">
|
||||
{hint}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
|
||||
export function TabButton({
|
||||
label,
|
||||
active,
|
||||
onClick,
|
||||
}: {
|
||||
label: string;
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
'group rounded-[24px] border px-4 py-4 text-left transition',
|
||||
active
|
||||
? 'border-[var(--border-strong)] bg-[var(--accent-soft)] shadow-[var(--shadow-soft)]'
|
||||
: 'border-[var(--border-default)] bg-[var(--surface-elevated)] hover:border-[var(--border-strong)] hover:bg-[var(--surface-muted)]',
|
||||
)}
|
||||
>
|
||||
<p className="text-sm font-semibold text-[var(--foreground-primary)]">
|
||||
{label}
|
||||
</p>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { WAFRuleGroupPayload } from '@/features/waf/types';
|
||||
|
||||
export type FeedbackState = {
|
||||
tone: 'success' | 'danger' | 'info';
|
||||
message: string;
|
||||
};
|
||||
|
||||
export type WAFTab = 'basic' | 'lists' | 'pow' | 'block';
|
||||
export type RuleListType = 'whitelist' | 'blacklist';
|
||||
export type RuleDimension = 'ip' | 'ip_group' | 'country';
|
||||
export type ListFieldKey =
|
||||
| 'ip_whitelist'
|
||||
| 'ip_blacklist'
|
||||
| 'ip_whitelist_group_ids'
|
||||
| 'ip_blacklist_group_ids'
|
||||
| 'country_whitelist'
|
||||
| 'country_blacklist';
|
||||
|
||||
export type CountryOption = {
|
||||
code: string;
|
||||
zhName: string;
|
||||
label: string;
|
||||
searchText: string;
|
||||
};
|
||||
|
||||
export type RuleModalState = {
|
||||
open: boolean;
|
||||
listType: RuleListType;
|
||||
dimension: RuleDimension;
|
||||
ipValue: string;
|
||||
ipGroupIDs: number[];
|
||||
countryValues: string[];
|
||||
};
|
||||
|
||||
export type RuleListRenderable = Pick<
|
||||
WAFRuleGroupPayload,
|
||||
| 'ip_whitelist'
|
||||
| 'ip_blacklist'
|
||||
| 'ip_whitelist_group_ids'
|
||||
| 'ip_blacklist_group_ids'
|
||||
| 'country_whitelist'
|
||||
| 'country_blacklist'
|
||||
| 'region_whitelist'
|
||||
| 'region_blacklist'
|
||||
>;
|
||||
@@ -0,0 +1,742 @@
|
||||
'use client';
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Globe2, Network, Plus, Save, ShieldCheck, Trash2 } from 'lucide-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 { getProxyRoutes } from '@/features/proxy-routes/api/proxy-routes';
|
||||
import {
|
||||
DangerButton,
|
||||
PrimaryButton,
|
||||
ResourceField,
|
||||
ResourceInput,
|
||||
ResourceTextarea,
|
||||
SecondaryButton,
|
||||
ToggleField,
|
||||
} from '@/features/shared/components/resource-primitives';
|
||||
import {
|
||||
createWAFRuleGroup,
|
||||
deleteWAFRuleGroup,
|
||||
getWAFIPGroups,
|
||||
getWAFRuleGroups,
|
||||
replaceWAFRuleGroupSites,
|
||||
updateWAFRuleGroup,
|
||||
} from '@/features/waf/api/waf';
|
||||
import type {
|
||||
WAFIPGroup,
|
||||
WAFRuleGroup,
|
||||
WAFRuleGroupPayload,
|
||||
} from '@/features/waf/types';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
|
||||
import { RuleEntryModal } from './rule-entry-modal';
|
||||
import { SiteApplyDrawer } from './site-apply-drawer';
|
||||
import { PowTabPanel } from './pow-tab-panel';
|
||||
import { RuleListSection } from './rule-list-section';
|
||||
import { TabButton } from './tab-button';
|
||||
import {
|
||||
buildCountryOptions,
|
||||
buildDraft,
|
||||
countRuleEntries,
|
||||
defaultRuleModalState,
|
||||
emptyDraft,
|
||||
formatCountryItem,
|
||||
getErrorMessage,
|
||||
getListFieldKey,
|
||||
normalizeItems,
|
||||
tabItems,
|
||||
textToList,
|
||||
updateDraftList,
|
||||
} from './helpers';
|
||||
import type {
|
||||
FeedbackState,
|
||||
ListFieldKey,
|
||||
RuleModalState,
|
||||
WAFTab,
|
||||
} from './types';
|
||||
|
||||
export function WAFPage() {
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
const [selectedID, setSelectedID] = useState<number | null>(null);
|
||||
const [activeTab, setActiveTab] = useState<WAFTab>('basic');
|
||||
const [draft, setDraft] = useState<WAFRuleGroupPayload>(emptyDraft);
|
||||
const [feedback, setFeedback] = useState<FeedbackState | null>(null);
|
||||
const [applyGroup, setApplyGroup] = useState<WAFRuleGroup | null>(null);
|
||||
const [ruleModal, setRuleModal] = useState<RuleModalState>(
|
||||
defaultRuleModalState,
|
||||
);
|
||||
|
||||
const groupsQuery = useQuery({
|
||||
queryKey: ['waf', 'rule-groups'],
|
||||
queryFn: getWAFRuleGroups,
|
||||
});
|
||||
const ipGroupsQuery = useQuery({
|
||||
queryKey: ['waf', 'ip-groups'],
|
||||
queryFn: getWAFIPGroups,
|
||||
});
|
||||
const routesQuery = useQuery({
|
||||
queryKey: ['proxy-routes'],
|
||||
queryFn: getProxyRoutes,
|
||||
});
|
||||
|
||||
const groups = useMemo(() => groupsQuery.data ?? [], [groupsQuery.data]);
|
||||
const ipGroups = useMemo(
|
||||
() => ipGroupsQuery.data ?? [],
|
||||
[ipGroupsQuery.data],
|
||||
);
|
||||
const routes = useMemo(() => routesQuery.data ?? [], [routesQuery.data]);
|
||||
const countryOptions = useMemo(() => buildCountryOptions(), []);
|
||||
const countryLabelMap = useMemo(
|
||||
() => new Map(countryOptions.map((option) => [option.code, option.label])),
|
||||
[countryOptions],
|
||||
);
|
||||
|
||||
const selectedGroup = useMemo(
|
||||
() =>
|
||||
selectedID === 0
|
||||
? null
|
||||
: (groups.find((group) => group.id === selectedID) ??
|
||||
groups[0] ??
|
||||
null),
|
||||
[groups, selectedID],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedGroup) {
|
||||
setSelectedID(selectedGroup.id);
|
||||
setDraft(buildDraft(selectedGroup));
|
||||
}
|
||||
}, [selectedGroup]);
|
||||
|
||||
const invalidate = async () => {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ['waf', 'rule-groups'] }),
|
||||
queryClient.invalidateQueries({ queryKey: ['waf', 'ip-groups'] }),
|
||||
queryClient.invalidateQueries({ queryKey: ['config-versions', 'diff'] }),
|
||||
]);
|
||||
};
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: (payload: WAFRuleGroupPayload) => {
|
||||
if (selectedGroup) {
|
||||
return updateWAFRuleGroup(selectedGroup.id, payload);
|
||||
}
|
||||
return createWAFRuleGroup(payload);
|
||||
},
|
||||
onSuccess: async (group) => {
|
||||
setSelectedID(group.id);
|
||||
setFeedback({ tone: 'success', message: 'WAF 规则组已保存。' });
|
||||
await invalidate();
|
||||
},
|
||||
onError: (error) => {
|
||||
setFeedback({ tone: 'danger', message: getErrorMessage(error) });
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: deleteWAFRuleGroup,
|
||||
onSuccess: async () => {
|
||||
setSelectedID(null);
|
||||
setFeedback({ tone: 'success', message: 'WAF 规则组已删除。' });
|
||||
await invalidate();
|
||||
},
|
||||
onError: (error) => {
|
||||
setFeedback({ tone: 'danger', message: getErrorMessage(error) });
|
||||
},
|
||||
});
|
||||
|
||||
const applyMutation = useMutation({
|
||||
mutationFn: ({ id, ids }: { id: number; ids: number[] }) =>
|
||||
replaceWAFRuleGroupSites(id, ids),
|
||||
onSuccess: async () => {
|
||||
setApplyGroup(null);
|
||||
setFeedback({ tone: 'success', message: '规则组应用范围已更新。' });
|
||||
await invalidate();
|
||||
},
|
||||
onError: (error) => {
|
||||
setFeedback({ tone: 'danger', message: getErrorMessage(error) });
|
||||
},
|
||||
});
|
||||
|
||||
if (
|
||||
groupsQuery.isLoading ||
|
||||
routesQuery.isLoading ||
|
||||
ipGroupsQuery.isLoading
|
||||
) {
|
||||
return <LoadingState />;
|
||||
}
|
||||
if (groupsQuery.isError) {
|
||||
return (
|
||||
<ErrorState
|
||||
title="WAF 加载失败"
|
||||
description={getErrorMessage(groupsQuery.error)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (routesQuery.isError) {
|
||||
return (
|
||||
<ErrorState
|
||||
title="网站列表加载失败"
|
||||
description={getErrorMessage(routesQuery.error)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (ipGroupsQuery.isError) {
|
||||
return (
|
||||
<ErrorState
|
||||
title="IP 组加载失败"
|
||||
description={getErrorMessage(ipGroupsQuery.error)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (!selectedGroup && groups.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
title="WAF 尚未初始化"
|
||||
description="刷新页面后系统会自动创建全局规则组。"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const currentRuleCount = countRuleEntries(draft);
|
||||
const appliedSiteNames = selectedGroup?.is_global
|
||||
? ['全部网站']
|
||||
: (selectedGroup?.applied_site_ids ?? [])
|
||||
.map(
|
||||
(id) =>
|
||||
routes.find((route) => route.id === id)?.site_name ?? `网站 #${id}`,
|
||||
)
|
||||
.sort((left, right) => left.localeCompare(right));
|
||||
const ipGroupByID = new Map(ipGroups.map((group) => [group.id, group]));
|
||||
const whitelistGroupItems = draft.ip_whitelist_group_ids
|
||||
.map((id) => ipGroupByID.get(id))
|
||||
.filter((group): group is WAFIPGroup => Boolean(group));
|
||||
const blacklistGroupItems = draft.ip_blacklist_group_ids
|
||||
.map((id) => ipGroupByID.get(id))
|
||||
.filter((group): group is WAFIPGroup => Boolean(group));
|
||||
|
||||
const openRuleModal = () => {
|
||||
setRuleModal({ ...defaultRuleModalState, open: true });
|
||||
};
|
||||
|
||||
const closeRuleModal = () => {
|
||||
setRuleModal(defaultRuleModalState);
|
||||
};
|
||||
|
||||
const applyRuleModal = () => {
|
||||
const values =
|
||||
ruleModal.dimension === 'ip'
|
||||
? textToList(ruleModal.ipValue)
|
||||
: ruleModal.dimension === 'ip_group'
|
||||
? ruleModal.ipGroupIDs.map(String)
|
||||
: normalizeItems(ruleModal.countryValues);
|
||||
|
||||
if (values.length === 0) {
|
||||
setFeedback({
|
||||
tone: 'danger',
|
||||
message:
|
||||
ruleModal.dimension === 'ip'
|
||||
? '请先输入 IP 或 IP 段。'
|
||||
: ruleModal.dimension === 'ip_group'
|
||||
? '请先选择 IP 组。'
|
||||
: '请先选择地域。',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const listKey = getListFieldKey(ruleModal.listType, ruleModal.dimension);
|
||||
setDraft((current) =>
|
||||
updateDraftList(current, listKey, (items) =>
|
||||
normalizeItems([...items, ...values]),
|
||||
),
|
||||
);
|
||||
setFeedback({
|
||||
tone: 'info',
|
||||
message: '名单项已添加到当前草稿,保存后生效。',
|
||||
});
|
||||
closeRuleModal();
|
||||
setActiveTab('lists');
|
||||
};
|
||||
|
||||
const removeRuleItem = (key: ListFieldKey, value: string) => {
|
||||
setDraft((current) =>
|
||||
updateDraftList(current, key, (items) =>
|
||||
items.filter((item) => item !== value),
|
||||
),
|
||||
);
|
||||
};
|
||||
const removeRuleGroup = (key: ListFieldKey, id: number) => {
|
||||
setDraft((current) =>
|
||||
updateDraftList(current, key, (items) =>
|
||||
items.filter((item) => item !== String(id)),
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const overviewItems: Array<{ label: string; value: ReactNode }> = [
|
||||
{
|
||||
label: '当前规则组',
|
||||
value: selectedGroup ? selectedGroup.name : '新建规则组',
|
||||
},
|
||||
{ label: '启用状态', value: draft.enabled ? '启用中' : '已停用' },
|
||||
{ label: '当前规则数', value: `${currentRuleCount} 条` },
|
||||
{
|
||||
label: '生效范围',
|
||||
value: selectedGroup?.is_global
|
||||
? '全部网站'
|
||||
: `${selectedGroup?.applied_site_count ?? 0} 个网站`,
|
||||
},
|
||||
{
|
||||
label: '拦截返回',
|
||||
value: draft.block_response_body.trim()
|
||||
? `${draft.block_status_code} + 自定义页面`
|
||||
: `${draft.block_status_code} 状态码`,
|
||||
},
|
||||
{
|
||||
label: '最后更新',
|
||||
value: selectedGroup?.updated_at
|
||||
? new Date(selectedGroup.updated_at).toLocaleString('zh-CN')
|
||||
: '未保存',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="WAF"
|
||||
description="按规则组维护 WAF 与 PoW 防护规则,全局规则组始终应用到所有网站。"
|
||||
action={
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<SecondaryButton
|
||||
type="button"
|
||||
onClick={() => router.push('/waf/ip-groups')}
|
||||
>
|
||||
<Network className="mr-2 h-4 w-4" />
|
||||
管理 IP 组
|
||||
</SecondaryButton>
|
||||
<PrimaryButton
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSelectedID(0);
|
||||
setActiveTab('basic');
|
||||
setDraft({ ...emptyDraft, name: '自定义规则组' });
|
||||
}}
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
新建规则组
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{feedback ? (
|
||||
<InlineMessage
|
||||
tone={feedback.tone}
|
||||
message={feedback.message}
|
||||
onClear={() => setFeedback(null)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-5 xl:grid-cols-[360px_minmax(0,1fr)]">
|
||||
<AppCard title="规则组">
|
||||
<div className="space-y-2">
|
||||
{groups.map((group) => (
|
||||
<button
|
||||
key={group.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSelectedID(group.id);
|
||||
setActiveTab('basic');
|
||||
}}
|
||||
className={cn(
|
||||
'w-full rounded-2xl border px-4 py-3 text-left transition',
|
||||
selectedGroup?.id === group.id
|
||||
? 'border-[var(--border-strong)] bg-[var(--accent-soft)]'
|
||||
: 'border-[var(--border-default)] bg-[var(--surface-elevated)] hover:bg-[var(--surface-muted)]',
|
||||
)}
|
||||
>
|
||||
<span className="flex items-center justify-between gap-3">
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
{group.is_global ? (
|
||||
<Globe2 className="h-4 w-4" />
|
||||
) : (
|
||||
<ShieldCheck className="h-4 w-4" />
|
||||
)}
|
||||
<span className="truncate text-sm font-semibold text-[var(--foreground-primary)]">
|
||||
{group.name}
|
||||
</span>
|
||||
</span>
|
||||
<span className="text-xs text-[var(--foreground-secondary)]">
|
||||
{group.enabled ? '启用' : '停用'}
|
||||
</span>
|
||||
</span>
|
||||
<span className="mt-2 block text-xs text-[var(--foreground-secondary)]">
|
||||
{group.is_global
|
||||
? '应用全部网站'
|
||||
: `已应用 ${group.applied_site_count} 个网站`}{' '}
|
||||
· {countRuleEntries(group)} 条规则
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</AppCard>
|
||||
|
||||
<AppCard
|
||||
title={selectedGroup ? selectedGroup.name : '新建规则组'}
|
||||
description="简介:白名单命中后直接放行;未命中白名单时继续判断黑名单。"
|
||||
action={
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{selectedGroup && !selectedGroup.is_global ? (
|
||||
<SecondaryButton
|
||||
type="button"
|
||||
onClick={() => setApplyGroup(selectedGroup)}
|
||||
>
|
||||
一键应用
|
||||
</SecondaryButton>
|
||||
) : null}
|
||||
<PrimaryButton
|
||||
type="button"
|
||||
disabled={saveMutation.isPending}
|
||||
onClick={() => saveMutation.mutate(draft)}
|
||||
>
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
{saveMutation.isPending ? '保存中...' : '保存规则组'}
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="space-y-6">
|
||||
<div className="grid gap-3 md:grid-cols-4">
|
||||
{tabItems.map((tab) => (
|
||||
<TabButton
|
||||
key={tab.id}
|
||||
label={tab.label}
|
||||
active={activeTab === tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{activeTab === 'basic' ? (
|
||||
<div className="space-y-5">
|
||||
<div className="grid gap-5 xl:grid-cols-2">
|
||||
<ResourceField label="规则组名称">
|
||||
<ResourceInput
|
||||
value={draft.name}
|
||||
disabled={selectedGroup?.is_global}
|
||||
onChange={(event) =>
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
name: event.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</ResourceField>
|
||||
<ToggleField
|
||||
label="启用规则组"
|
||||
description="关闭后保留配置,但不会参与匹配。"
|
||||
checked={draft.enabled}
|
||||
onChange={(checked) =>
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
enabled: checked,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<ResourceField
|
||||
label="备注"
|
||||
className="xl:col-span-2"
|
||||
hint="用于记录规则组用途、业务说明或变更备注。"
|
||||
>
|
||||
<ResourceInput
|
||||
value={draft.remark}
|
||||
onChange={(event) =>
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
remark: event.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</ResourceField>
|
||||
</div>
|
||||
<div className="grid gap-5">
|
||||
<div className="space-y-5">
|
||||
<div className="rounded-[26px] border border-[var(--border-default)] bg-[var(--surface-elevated)] p-5">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-[var(--foreground-primary)]">
|
||||
配置总览
|
||||
</h3>
|
||||
</div>
|
||||
<span className="rounded-full border border-[var(--border-default)] px-2.5 py-1 text-xs font-medium text-[var(--foreground-secondary)]">
|
||||
{selectedGroup?.is_global ? '全局' : '自定义'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<dl className="mt-5 grid gap-4 sm:grid-cols-2">
|
||||
{overviewItems.map((item) => (
|
||||
<div
|
||||
key={item.label}
|
||||
className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-panel)] px-4 py-4"
|
||||
>
|
||||
<dt className="text-xs font-medium tracking-[0.18em] text-[var(--foreground-muted)] uppercase">
|
||||
{item.label}
|
||||
</dt>
|
||||
<dd className="mt-2 text-sm font-medium text-[var(--foreground-primary)]">
|
||||
{item.value}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
|
||||
<div className="mt-5">
|
||||
<p className="text-xs font-medium tracking-[0.18em] text-[var(--foreground-muted)] uppercase">
|
||||
当前应用网站
|
||||
</p>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{appliedSiteNames.length > 0 ? (
|
||||
appliedSiteNames.map((name) => (
|
||||
<span
|
||||
key={name}
|
||||
className="rounded-full border border-[var(--border-default)] bg-[var(--surface-panel)] px-3 py-2 text-sm text-[var(--foreground-secondary)]"
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
))
|
||||
) : (
|
||||
<span className="rounded-2xl border border-dashed border-[var(--border-default)] px-4 py-3 text-sm text-[var(--foreground-secondary)]">
|
||||
尚未绑定网站,可点击右上角「一键应用」进行配置。
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{activeTab === 'lists' ? (
|
||||
<div className="space-y-5">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-[var(--foreground-primary)]">
|
||||
黑白名单规则
|
||||
</h3>
|
||||
</div>
|
||||
<PrimaryButton type="button" onClick={openRuleModal}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
添加
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 xl:grid-cols-2">
|
||||
<RuleListSection
|
||||
title="IP 白名单"
|
||||
description="命中后直接放行,不再继续判断黑名单。"
|
||||
items={draft.ip_whitelist}
|
||||
groupItems={whitelistGroupItems}
|
||||
tone="whitelist"
|
||||
emptyText="暂无 IP 白名单规则。"
|
||||
onRemove={(item) => removeRuleItem('ip_whitelist', item)}
|
||||
onRemoveGroup={(id) =>
|
||||
removeRuleGroup('ip_whitelist_group_ids', id)
|
||||
}
|
||||
/>
|
||||
<RuleListSection
|
||||
title="IP 黑名单"
|
||||
description="未命中白名单时,命中这些 IP / IP 段将被拦截。"
|
||||
items={draft.ip_blacklist}
|
||||
groupItems={blacklistGroupItems}
|
||||
tone="blacklist"
|
||||
emptyText="暂无 IP 黑名单规则。"
|
||||
onRemove={(item) => removeRuleItem('ip_blacklist', item)}
|
||||
onRemoveGroup={(id) =>
|
||||
removeRuleGroup('ip_blacklist_group_ids', id)
|
||||
}
|
||||
/>
|
||||
<RuleListSection
|
||||
title="地域白名单"
|
||||
description="显示格式为国家代码与中文名,命中后直接放行。"
|
||||
items={draft.country_whitelist.map((code) =>
|
||||
formatCountryItem(code, countryLabelMap),
|
||||
)}
|
||||
tone="whitelist"
|
||||
emptyText="暂无地域白名单规则。"
|
||||
onRemove={(item) => {
|
||||
const code = item.split(' ')[0] ?? item;
|
||||
removeRuleItem('country_whitelist', code);
|
||||
}}
|
||||
/>
|
||||
<RuleListSection
|
||||
title="地域黑名单"
|
||||
description="当请求未命中白名单时,命中这些地域将被拦截。"
|
||||
items={draft.country_blacklist.map((code) =>
|
||||
formatCountryItem(code, countryLabelMap),
|
||||
)}
|
||||
tone="blacklist"
|
||||
emptyText="暂无地域黑名单规则。"
|
||||
onRemove={(item) => {
|
||||
const code = item.split(' ')[0] ?? item;
|
||||
removeRuleItem('country_blacklist', code);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{activeTab === 'pow' ? (
|
||||
<PowTabPanel
|
||||
key={selectedID}
|
||||
enabled={draft.pow_enabled}
|
||||
config={draft.pow_config}
|
||||
onChange={(enabled, config) =>
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
pow_enabled: enabled,
|
||||
pow_config: config,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{activeTab === 'block' ? (
|
||||
<div className="grid gap-5 xl:grid-cols-[360px_minmax(0,1fr)]">
|
||||
<div className="space-y-5">
|
||||
<div className="rounded-[26px] border border-[var(--border-default)] bg-[var(--surface-elevated)] p-5">
|
||||
<h3 className="text-sm font-semibold text-[var(--foreground-primary)]">
|
||||
拦截返回状态码
|
||||
</h3>
|
||||
<p className="mt-1 text-xs leading-5 text-[var(--foreground-secondary)]">
|
||||
建议使用 403、418、451 等明确表达策略拦截含义的状态码。
|
||||
</p>
|
||||
<div className="mt-4 space-y-4">
|
||||
<ResourceField label="状态码">
|
||||
<ResourceInput
|
||||
type="number"
|
||||
min={400}
|
||||
max={599}
|
||||
value={draft.block_status_code}
|
||||
onChange={(event) =>
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
block_status_code: Number(event.target.value),
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</ResourceField>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{[403, 418, 451, 503].map((code) => (
|
||||
<button
|
||||
key={code}
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
block_status_code: code,
|
||||
}))
|
||||
}
|
||||
className={cn(
|
||||
'rounded-full border px-3 py-2 text-sm transition',
|
||||
draft.block_status_code === code
|
||||
? 'border-[var(--border-strong)] bg-[var(--accent-soft)] text-[var(--foreground-primary)]'
|
||||
: 'border-[var(--border-default)] bg-[var(--surface-panel)] text-[var(--foreground-secondary)] hover:bg-[var(--surface-muted)]',
|
||||
)}
|
||||
>
|
||||
{code}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-[26px] border border-[var(--border-default)] bg-[var(--surface-elevated)] p-5">
|
||||
<ResourceField
|
||||
label="拦截页面"
|
||||
hint="支持直接输入 HTML 或纯文本。留空时只返回状态码。"
|
||||
>
|
||||
<ResourceTextarea
|
||||
value={draft.block_response_body}
|
||||
className="min-h-72"
|
||||
placeholder="<html><body><h1>Request blocked</h1></body></html>"
|
||||
onChange={(event) =>
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
block_response_body: event.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</ResourceField>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-wrap justify-between gap-3 border-t border-[var(--border-default)] pt-6">
|
||||
<div>
|
||||
{selectedGroup && !selectedGroup.is_global ? (
|
||||
<DangerButton
|
||||
type="button"
|
||||
disabled={deleteMutation.isPending}
|
||||
onClick={() => {
|
||||
if (
|
||||
window.confirm(
|
||||
`确认删除 WAF 规则组 ${selectedGroup.name} 吗?`,
|
||||
)
|
||||
) {
|
||||
deleteMutation.mutate(selectedGroup.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
删除
|
||||
</DangerButton>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AppCard>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<RuleEntryModal
|
||||
state={ruleModal}
|
||||
countryOptions={countryOptions}
|
||||
ipGroups={ipGroups}
|
||||
pending={saveMutation.isPending}
|
||||
onClose={closeRuleModal}
|
||||
onChange={(patch) =>
|
||||
setRuleModal((current) => ({ ...current, ...patch, open: true }))
|
||||
}
|
||||
onSubmit={applyRuleModal}
|
||||
/>
|
||||
|
||||
<SiteApplyDrawer
|
||||
group={applyGroup}
|
||||
routes={routes}
|
||||
open={Boolean(applyGroup)}
|
||||
pending={applyMutation.isPending}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setApplyGroup(null);
|
||||
}
|
||||
}}
|
||||
onSave={(ids) => {
|
||||
if (applyGroup) {
|
||||
applyMutation.mutate({ id: applyGroup.id, ids });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import type { ProxyRoutePoWConfig } from '@/features/proxy-routes/types';
|
||||
|
||||
export interface WAFRuleGroup {
|
||||
id: number;
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
is_global: boolean;
|
||||
block_status_code: number;
|
||||
block_response_body: string;
|
||||
ip_whitelist: string[];
|
||||
ip_blacklist: string[];
|
||||
ip_whitelist_group_ids: number[];
|
||||
ip_blacklist_group_ids: number[];
|
||||
country_whitelist: string[];
|
||||
country_blacklist: string[];
|
||||
region_whitelist: string[];
|
||||
region_blacklist: string[];
|
||||
pow_enabled: boolean;
|
||||
pow_config: ProxyRoutePoWConfig;
|
||||
remark: string;
|
||||
applied_site_ids: number[];
|
||||
applied_site_count: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface WAFRuleGroupPayload {
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
block_status_code: number;
|
||||
block_response_body: string;
|
||||
ip_whitelist: string[];
|
||||
ip_blacklist: string[];
|
||||
ip_whitelist_group_ids: number[];
|
||||
ip_blacklist_group_ids: number[];
|
||||
country_whitelist: string[];
|
||||
country_blacklist: string[];
|
||||
region_whitelist: string[];
|
||||
region_blacklist: string[];
|
||||
pow_enabled: boolean;
|
||||
pow_config: ProxyRoutePoWConfig;
|
||||
remark: string;
|
||||
}
|
||||
|
||||
export interface WAFSiteRuleGroups {
|
||||
route_id: number;
|
||||
global_rule_group: WAFRuleGroup | null;
|
||||
rule_groups: WAFRuleGroup[];
|
||||
applied_rule_groups: WAFRuleGroup[];
|
||||
applied_ids: number[];
|
||||
}
|
||||
|
||||
export type WAFIPGroupType = 'manual' | 'automatic' | 'subscription';
|
||||
export type WAFIPGroupSubscriptionFormat = 'text' | 'json';
|
||||
|
||||
export interface WAFIPGroupExtIP {
|
||||
ip: string;
|
||||
captured_at: string;
|
||||
}
|
||||
|
||||
export interface WAFIPGroup {
|
||||
id: number;
|
||||
name: string;
|
||||
type: WAFIPGroupType;
|
||||
enabled: boolean;
|
||||
ip_list: string[];
|
||||
auto_config: Record<string, unknown>;
|
||||
ext_ips?: WAFIPGroupExtIP[];
|
||||
subscription_url: string;
|
||||
subscription_format: WAFIPGroupSubscriptionFormat;
|
||||
subscription_mapping_rule: string;
|
||||
sync_interval_minutes: number;
|
||||
last_synced_at?: string;
|
||||
next_sync_at?: string;
|
||||
last_sync_status: string;
|
||||
last_sync_message: string;
|
||||
remark: string;
|
||||
referenced_by_rule_count: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface WAFIPGroupPayload {
|
||||
name: string;
|
||||
type: WAFIPGroupType;
|
||||
enabled: boolean;
|
||||
ip_list: string[];
|
||||
auto_config: Record<string, unknown>;
|
||||
subscription_url: string;
|
||||
subscription_format: WAFIPGroupSubscriptionFormat;
|
||||
subscription_mapping_rule: string;
|
||||
sync_interval_minutes: number;
|
||||
remark: string;
|
||||
}
|
||||
|
||||
export interface WAFIPGroupSyncResult {
|
||||
group: WAFIPGroup;
|
||||
ip_count: number;
|
||||
synced_at: string;
|
||||
next_sync_at: string;
|
||||
status: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface WAFIPGroupAutoTestPayload {
|
||||
auto_config: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface WAFIPGroupAutoTestResult {
|
||||
matched_ips: string[];
|
||||
matched_count: number;
|
||||
lookback_minutes: number;
|
||||
rule_count: number;
|
||||
tested_at: string;
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
'use client';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
|
||||
import { InlineMessage } from '@/components/feedback/inline-message';
|
||||
import { AppModal } from '@/components/ui/app-modal';
|
||||
import {
|
||||
applyTlsCertificate,
|
||||
convertTlsCertificateToAcme,
|
||||
updateAcmeCertificate,
|
||||
} from '@/features/tls-certificates/api/tls-certificates';
|
||||
import type { TlsCertificateItem } from '@/features/tls-certificates/types';
|
||||
import { getDnsAccounts } from '@/features/dns-accounts/api/dns-accounts';
|
||||
import { getDefaultAcmeAccount } from '@/features/acme-accounts/api/acme-accounts';
|
||||
import {
|
||||
acmeApplySchema,
|
||||
defaultAcmeApplyValues,
|
||||
type AcmeApplyFormValues,
|
||||
} from '@/features/websites/schemas';
|
||||
import { getErrorMessage } from '@/features/websites/utils';
|
||||
import {
|
||||
PrimaryButton,
|
||||
ResourceField,
|
||||
ResourceInput,
|
||||
ResourceSelect,
|
||||
ToggleField,
|
||||
} from '@/features/shared/components/resource-primitives';
|
||||
|
||||
interface CertificateApplyModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onApplied?: (certificate: TlsCertificateItem) => void;
|
||||
mode?: 'create' | 'edit-acme' | 'convert-upload';
|
||||
certificate?: TlsCertificateItem | null;
|
||||
}
|
||||
|
||||
export function CertificateApplyModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
onApplied,
|
||||
mode = 'create',
|
||||
certificate,
|
||||
}: CertificateApplyModalProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const [feedback, setFeedback] = useState<{
|
||||
tone: 'success' | 'danger';
|
||||
message: string;
|
||||
} | null>(null);
|
||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||
|
||||
const dnsAccountsQuery = useQuery({
|
||||
queryKey: ['dns-accounts'],
|
||||
queryFn: getDnsAccounts,
|
||||
enabled: isOpen,
|
||||
});
|
||||
|
||||
const defaultAcmeAccountQuery = useQuery({
|
||||
queryKey: ['acme-accounts', 'default'],
|
||||
queryFn: getDefaultAcmeAccount,
|
||||
enabled: isOpen,
|
||||
});
|
||||
|
||||
const form = useForm<AcmeApplyFormValues>({
|
||||
resolver: zodResolver(acmeApplySchema),
|
||||
defaultValues: defaultAcmeApplyValues,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
setFeedback(null);
|
||||
setShowAdvanced(false);
|
||||
|
||||
if (certificate) {
|
||||
form.reset({
|
||||
name: certificate.name,
|
||||
primary_domain:
|
||||
mode === 'convert-upload' ? '' : certificate.primary_domain || '',
|
||||
other_domains:
|
||||
mode === 'convert-upload' ? '' : certificate.other_domains || '',
|
||||
remark: certificate.remark || '',
|
||||
acme_account_id:
|
||||
mode === 'convert-upload' ? 0 : certificate.acme_account_id,
|
||||
dns_account_id:
|
||||
mode === 'convert-upload' ? 0 : certificate.dns_account_id,
|
||||
key_algorithm: certificate.key_algorithm || 'EC256',
|
||||
auto_renew: mode === 'convert-upload' ? true : certificate.auto_renew,
|
||||
dns1: mode === 'convert-upload' ? '' : certificate.dns1 || '',
|
||||
dns2: mode === 'convert-upload' ? '' : certificate.dns2 || '',
|
||||
disable_cname:
|
||||
mode === 'convert-upload' ? false : certificate.disable_cname,
|
||||
skip_dns: mode === 'convert-upload' ? false : certificate.skip_dns,
|
||||
});
|
||||
if (
|
||||
mode !== 'convert-upload' &&
|
||||
(certificate.dns1 ||
|
||||
certificate.dns2 ||
|
||||
certificate.disable_cname ||
|
||||
certificate.skip_dns)
|
||||
) {
|
||||
setShowAdvanced(true);
|
||||
}
|
||||
} else {
|
||||
form.reset(defaultAcmeApplyValues);
|
||||
}
|
||||
}, [isOpen, form, mode, certificate]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
defaultAcmeAccountQuery.data &&
|
||||
form.getValues('acme_account_id') === 0
|
||||
) {
|
||||
form.setValue('acme_account_id', defaultAcmeAccountQuery.data.id);
|
||||
}
|
||||
}, [defaultAcmeAccountQuery.data, form, isOpen]);
|
||||
|
||||
const applyMutation = useMutation({
|
||||
mutationFn: (values: AcmeApplyFormValues) => {
|
||||
if (mode === 'edit-acme' && certificate) {
|
||||
return updateAcmeCertificate(certificate.id, values);
|
||||
}
|
||||
if (mode === 'convert-upload' && certificate) {
|
||||
return convertTlsCertificateToAcme(certificate.id, values);
|
||||
}
|
||||
return applyTlsCertificate(values);
|
||||
},
|
||||
onSuccess: async (certificate) => {
|
||||
await queryClient.invalidateQueries({ queryKey: ['tls-certificates'] });
|
||||
onApplied?.(certificate);
|
||||
onClose();
|
||||
},
|
||||
onError: (error) => {
|
||||
setFeedback({ tone: 'danger', message: getErrorMessage(error) });
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = form.handleSubmit((values) => {
|
||||
setFeedback(null);
|
||||
applyMutation.mutate(values);
|
||||
});
|
||||
|
||||
return (
|
||||
<AppModal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title={
|
||||
mode === 'edit-acme'
|
||||
? '编辑并重新申请证书'
|
||||
: mode === 'convert-upload'
|
||||
? '转换为申请证书'
|
||||
: '申请证书'
|
||||
}
|
||||
description={
|
||||
mode === 'edit-acme'
|
||||
? '修改 ACME 证书配置。保存后将使用新配置重新申请证书。'
|
||||
: mode === 'convert-upload'
|
||||
? '填写 ACME 申请资料。申请成功后,当前手动证书会原地转换为可自动续签的申请证书。'
|
||||
: "使用 ACME (Let's Encrypt 等) 自动申请和续期证书,支持通配符域名。"
|
||||
}
|
||||
size="xl"
|
||||
>
|
||||
<form className="space-y-5" onSubmit={onSubmit}>
|
||||
{feedback ? (
|
||||
<InlineMessage
|
||||
tone={feedback.tone}
|
||||
message={feedback.message}
|
||||
onClear={() => setFeedback(null)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<ResourceField
|
||||
label="证书名称"
|
||||
error={form.formState.errors.name?.message}
|
||||
>
|
||||
<ResourceInput
|
||||
placeholder="例如:主站证书"
|
||||
{...form.register('name')}
|
||||
/>
|
||||
</ResourceField>
|
||||
<ResourceField
|
||||
label="主域名"
|
||||
error={form.formState.errors.primary_domain?.message}
|
||||
>
|
||||
<ResourceInput
|
||||
placeholder="example.com 或 *.example.com"
|
||||
{...form.register('primary_domain')}
|
||||
/>
|
||||
</ResourceField>
|
||||
</div>
|
||||
|
||||
<ResourceField
|
||||
label="其他域名"
|
||||
hint="每行一个域名。如申请通配符证书,请填写对应的根域名以便一并签发。"
|
||||
error={form.formState.errors.other_domains?.message}
|
||||
>
|
||||
<textarea
|
||||
className="w-full rounded-xl border border-[var(--border-default)] bg-[var(--control-background)] px-4 py-3 text-sm text-[var(--foreground-primary)] transition outline-none focus:border-[var(--brand-primary)]"
|
||||
rows={3}
|
||||
placeholder="example.net"
|
||||
{...form.register('other_domains')}
|
||||
/>
|
||||
</ResourceField>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<ResourceField
|
||||
label="DNS 服务商账号"
|
||||
error={form.formState.errors.dns_account_id?.message}
|
||||
>
|
||||
<ResourceSelect {...form.register('dns_account_id')}>
|
||||
<option value={0}>请选择 DNS 账号</option>
|
||||
{dnsAccountsQuery.data?.map((acc) => (
|
||||
<option key={acc.id} value={acc.id}>
|
||||
{acc.name} ({acc.type})
|
||||
</option>
|
||||
))}
|
||||
</ResourceSelect>
|
||||
</ResourceField>
|
||||
|
||||
<ResourceField
|
||||
label="密钥算法"
|
||||
error={form.formState.errors.key_algorithm?.message}
|
||||
>
|
||||
<ResourceSelect {...form.register('key_algorithm')}>
|
||||
<option value="RSA2048">RSA 2048</option>
|
||||
<option value="RSA4096">RSA 4096</option>
|
||||
<option value="EC256">ECC 256</option>
|
||||
<option value="EC384">ECC 384</option>
|
||||
</ResourceSelect>
|
||||
</ResourceField>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-1">
|
||||
<ResourceField
|
||||
label="备注"
|
||||
error={form.formState.errors.remark?.message}
|
||||
>
|
||||
<ResourceInput
|
||||
placeholder="可选,用于记录证书用途。"
|
||||
{...form.register('remark')}
|
||||
/>
|
||||
</ResourceField>
|
||||
|
||||
<ToggleField
|
||||
label="开启自动续签"
|
||||
description="开启后,将在证书过期前 7 天自动续期。"
|
||||
checked={form.watch('auto_renew')}
|
||||
onChange={(checked) => form.setValue('auto_renew', checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="overflow-hidden rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)]">
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center justify-between px-4 py-3 text-sm font-medium text-[var(--foreground-primary)] transition hover:bg-[var(--surface-muted)]"
|
||||
onClick={() => setShowAdvanced(!showAdvanced)}
|
||||
>
|
||||
<span>高级选项</span>
|
||||
<svg
|
||||
className={`h-4 w-4 transition-transform duration-200 ${showAdvanced ? 'rotate-180' : ''}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M19 9l-7 7-7-7"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{showAdvanced && (
|
||||
<div className="space-y-4 border-t border-[var(--border-default)] px-4 py-4">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<ResourceField
|
||||
label="DNS 验证服务器 1"
|
||||
hint="可选,如 8.8.8.8"
|
||||
error={form.formState.errors.dns1?.message}
|
||||
>
|
||||
<ResourceInput
|
||||
placeholder="为空则使用默认权威 DNS"
|
||||
{...form.register('dns1')}
|
||||
/>
|
||||
</ResourceField>
|
||||
<ResourceField
|
||||
label="DNS 验证服务器 2"
|
||||
hint="可选,如 1.1.1.1"
|
||||
error={form.formState.errors.dns2?.message}
|
||||
>
|
||||
<ResourceInput
|
||||
placeholder="为空则使用默认权威 DNS"
|
||||
{...form.register('dns2')}
|
||||
/>
|
||||
</ResourceField>
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<ToggleField
|
||||
label="跳过 CNAME 检查"
|
||||
description="在执行 DNS-01 验证时不追踪 CNAME 记录。"
|
||||
checked={form.watch('disable_cname')}
|
||||
onChange={(checked) =>
|
||||
form.setValue('disable_cname', checked)
|
||||
}
|
||||
/>
|
||||
<ToggleField
|
||||
label="跳过 DNS 前置检查"
|
||||
description="直接请求 Let's Encrypt 验证而不做本地校验。"
|
||||
checked={form.watch('skip_dns')}
|
||||
onChange={(checked) => form.setValue('skip_dns', checked)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<PrimaryButton type="submit" disabled={applyMutation.isPending}>
|
||||
{applyMutation.isPending
|
||||
? '提交中...'
|
||||
: mode === 'edit-acme'
|
||||
? '保存并申请'
|
||||
: mode === 'convert-upload'
|
||||
? '开始转换'
|
||||
: '开始申请'}
|
||||
</PrimaryButton>
|
||||
</form>
|
||||
</AppModal>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user