diff --git a/openflare_server/web/app/globals.css b/openflare_server/web/app/globals.css
index 2c4b2c46..2b4ae997 100644
--- a/openflare_server/web/app/globals.css
+++ b/openflare_server/web/app/globals.css
@@ -184,6 +184,6 @@ button {
/* Hide scrollbar for IE, Edge and Firefox */
.no-scrollbar {
- -ms-overflow-style: none; /* IE and Edge */
- scrollbar-width: none; /* Firefox */
+ -ms-overflow-style: none; /* IE and Edge */
+ scrollbar-width: none; /* Firefox */
}
diff --git a/openflare_server/web/components/feedback/empty-state.tsx b/openflare_server/web/components/feedback/empty-state.tsx
index 77e6ec05..f4d491b3 100644
--- a/openflare_server/web/components/feedback/empty-state.tsx
+++ b/openflare_server/web/components/feedback/empty-state.tsx
@@ -21,4 +21,3 @@ export function EmptyState({ title, description, children }: EmptyStateProps) {
);
}
-
diff --git a/openflare_server/web/components/layout/dashboard-sidebar.tsx b/openflare_server/web/components/layout/dashboard-sidebar.tsx
index b982c5bf..94216c88 100644
--- a/openflare_server/web/components/layout/dashboard-sidebar.tsx
+++ b/openflare_server/web/components/layout/dashboard-sidebar.tsx
@@ -221,7 +221,7 @@ function SidebarContent({
) : null}
-
- {formatBytes(observability.profile.total_memory_bytes)}
+ {formatBytes(
+ observability.profile.total_memory_bytes,
+ )}
@@ -735,7 +734,9 @@ function EdgeNodeDetailPage({ node }: { node: NodeItem }) {
@@ -852,7 +857,9 @@ function EdgeNodeDetailPage({ node }: { node: NodeItem }) {
{trafficSummary
- ? trafficSummary.request_count.toLocaleString('zh-CN')
+ ? trafficSummary.request_count.toLocaleString(
+ 'zh-CN',
+ )
: '—'}
@@ -1172,8 +1179,12 @@ function EdgeNodeDetailPage({ node }: { node: NodeItem }) {
variant={getHealthEventVariant(event)}
/>
diff --git a/openflare_server/web/features/nodes/components/node-editor-modal.tsx b/openflare_server/web/features/nodes/components/node-editor-modal.tsx
index acc0b36e..1e453a3b 100644
--- a/openflare_server/web/features/nodes/components/node-editor-modal.tsx
+++ b/openflare_server/web/features/nodes/components/node-editor-modal.tsx
@@ -257,12 +257,30 @@ function toPayload(values: NodeEditorValues): NodeMutationPayload {
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,
+ 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) {
@@ -389,30 +407,39 @@ export function NodeEditorModal({
{watchedType === 'tunnel_relay' && (
-
+
中继配置 (Relay Config)
-
+
-
+
-
+
-
+
-
+
-
+
(null);
- const [editingNode, setEditingNode] = useState(null);
- const [isEditorOpen, setIsEditorOpen] = useState(false);
+ const searchParams = useSearchParams();
+ const queryClient = useQueryClient();
+ const [feedback, setFeedback] = useState(null);
+ const [editingNode, setEditingNode] = useState(null);
+ const [isEditorOpen, setIsEditorOpen] = useState(false);
- const nodesQuery = useQuery({
- queryKey: nodesQueryKey,
- queryFn: getNodes,
- refetchInterval: 5000,
- });
+ const nodesQuery = useQuery({
+ queryKey: nodesQueryKey,
+ queryFn: getNodes,
+ refetchInterval: 5000,
+ });
- const configVersionsQuery = useQuery({
- queryKey: ['config-versions'],
- queryFn: getConfigVersions,
- 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 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 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(() => {
- const current = searchParams.get('filter')?.trim().toLowerCase() ?? '';
- if (supportedNodeFilters.includes(current as NodeFilter)) {
- return current as NodeFilter;
- }
+ const nodes = useMemo(() => nodesQuery.data ?? [], [nodesQuery.data]);
+ const activeVersion = useMemo(
+ () =>
+ (configVersionsQuery.data ?? []).find((item) => item.is_active)
+ ?.version ?? '',
+ [configVersionsQuery.data],
+ );
+ const nodeFilter = useMemo(() => {
+ 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';
- }
+ 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]);
+ 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 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 handleReset = () => {
+ setFeedback(null);
+ setEditingNode(null);
+ setIsEditorOpen(false);
+ };
- const handleCreate = () => {
- setFeedback(null);
- setEditingNode(null);
- setIsEditorOpen(true);
- };
+ const handleCreate = () => {
+ setFeedback(null);
+ setEditingNode(null);
+ setIsEditorOpen(true);
+ };
- const handleEdit = (node: NodeItem) => {
- setFeedback(null);
- setEditingNode(node);
- setIsEditorOpen(true);
- };
+ const handleEdit = (node: NodeItem) => {
+ setFeedback(null);
+ setEditingNode(node);
+ setIsEditorOpen(true);
+ };
- const handleDelete = (nodeId: number, nodeName: string) => {
- if (
- !window.confirm(
- `确认删除节点“${nodeName}”吗?删除后该节点需要重新创建并重新接入。`,
- )
- ) {
- return;
- }
+ const handleDelete = (nodeId: number, nodeName: string) => {
+ if (
+ !window.confirm(
+ `确认删除节点“${nodeName}”吗?删除后该节点需要重新创建并重新接入。`,
+ )
+ ) {
+ return;
+ }
- setFeedback(null);
- deleteMutation.mutate(nodeId);
- };
- return (
- <>
-
-
-
- 新增节点
-
-
- 应用记录
-
- >
- }
- />
+ setFeedback(null);
+ deleteMutation.mutate(nodeId);
+ };
+ return (
+ <>
+
+
+
+ 新增节点
+
+
+ 应用记录
+
+ >
+ }
+ />
- {feedback ? (
- setFeedback(null)}
- />
- ) : null}
+ {feedback ? (
+ setFeedback(null)}
+ />
+ ) : null}
-
- void queryClient.invalidateQueries({queryKey: nodesQueryKey})
- }
- >
- 立即刷新
-
- }
+
+ void queryClient.invalidateQueries({ queryKey: nodesQueryKey })
+ }
+ >
+ 立即刷新
+
+ }
+ >
+ {nodesQuery.isLoading ? (
+
+ ) : nodesQuery.isError ? (
+
+ ) : filteredNodes.length === 0 ? (
+
+ ) : (
+
+
+
- {nodesQuery.isLoading ? (
-
- ) : nodesQuery.isError ? (
-
- ) : filteredNodes.length === 0 ? (
-
- ) : (
-
-
-
- 全部节点
-
-
- Edge
-
-
- Relay
-
-
- Tunnel
-
-
- 异常
-
-
+ 全部节点
+
+
+ Edge
+
+
+ Relay
+
+
+ Tunnel
+
+
+ 异常
+
+
-
-
-
-
- | 节点 |
- 状态 |
- Version |
- 运行健康 |
- 当前版本 |
- 最近应用 |
- 最近心跳 |
- 操作 |
-
-
-
- {filteredNodes.map((node) => (
-
-
-
-
-
- {node.name}
-
- {node.node_type === 'tunnel_relay' ? (
-
+
+
+
+
+ | 节点 |
+ 状态 |
+ Version |
+ 运行健康 |
+ 当前版本 |
+ 最近应用 |
+ 最近心跳 |
+ 操作 |
+
+
+
+ {filteredNodes.map((node) => (
+
+
+
+
+
+ {node.name}
+
+ {node.node_type === 'tunnel_relay' ? (
+
Relay
- ) : node.node_type === 'tunnel_client' ? (
-
+ ) : node.node_type === 'tunnel_client' ? (
+
Client
- ) : (
-
+ ) : (
+
Edge
- )}
-
-
- IP:{node.ip || 'null'}
- {node.ip_manual_override ? '(已锁定)' : ''}
-
-
- 位置:{node.geo_name || '未配置地图点位'}
-
-
- |
-
-
- |
-
- {node.version || 'unknown'}
- |
-
-
- {node.node_type === 'tunnel_relay' ? (
-
- ) : node.node_type === 'tunnel_client' ? (
-
- ) : (
-
- )}
-
- |
-
- {node.current_version || (node.node_type === 'tunnel_relay' ? '实时配置' : '未应用')}
- |
-
-
- {node.node_type === 'tunnel_relay' ? (
- —
- ) : (
-
- )}
-
- |
-
- {isWSConnectedLastSeen(node.last_seen_at)
- ? 'WS 已连接'
- : isMeaningfulTime(node.last_seen_at)
- ? `${formatRelativeTime(
- node.last_seen_at,
- )} · ${formatDateTime(node.last_seen_at)}`
- : '暂无'}
- |
-
-
-
- 详情
-
- handleEdit(node)}
- className="px-3 py-2 text-xs"
- >
- 编辑
-
- handleDelete(node.id, node.name)}
- disabled={deleteMutation.isPending}
- className="px-3 py-2 text-xs"
- >
- 删除
-
-
- |
-
- ))}
-
-
+ )}
-
- )}
-
+
+ IP:{node.ip || 'null'}
+ {node.ip_manual_override ? '(已锁定)' : ''}
+
+
+ 位置:{node.geo_name || '未配置地图点位'}
+
+
+ |
+
+
+ |
+
+ {node.version || 'unknown'}
+ |
+
+
+ {node.node_type === 'tunnel_relay' ? (
+
+ ) : node.node_type === 'tunnel_client' ? (
+
+ ) : (
+
+ )}
+
+ |
+
+ {node.current_version ||
+ (node.node_type === 'tunnel_relay'
+ ? '实时配置'
+ : '未应用')}
+ |
+
+
+ {node.node_type === 'tunnel_relay' ? (
+
+ —
+
+ ) : (
+
+ )}
+
+ |
+
+ {isWSConnectedLastSeen(node.last_seen_at)
+ ? 'WS 已连接'
+ : isMeaningfulTime(node.last_seen_at)
+ ? `${formatRelativeTime(
+ node.last_seen_at,
+ )} · ${formatDateTime(node.last_seen_at)}`
+ : '暂无'}
+ |
+
+
+
+ 详情
+
+ handleEdit(node)}
+ className="px-3 py-2 text-xs"
+ >
+ 编辑
+
+ handleDelete(node.id, node.name)}
+ disabled={deleteMutation.isPending}
+ className="px-3 py-2 text-xs"
+ >
+ 删除
+
+
+ |
+
+ ))}
+
+
+
-
{
- setFeedback(null);
- saveMutation.mutate(payload);
- }}
- />
- >
- );
+ )}
+
+
+ {
+ setFeedback(null);
+ saveMutation.mutate(payload);
+ }}
+ />
+ >
+ );
}
diff --git a/openflare_server/web/features/nodes/components/relay-detail-page.tsx b/openflare_server/web/features/nodes/components/relay-detail-page.tsx
index 1ec0ab05..3d8132d8 100644
--- a/openflare_server/web/features/nodes/components/relay-detail-page.tsx
+++ b/openflare_server/web/features/nodes/components/relay-detail-page.tsx
@@ -730,8 +730,6 @@ export function RelayDetailPage({ node }: { node: NodeItem }) {
)}
-
-
>
) : null}
@@ -766,7 +764,7 @@ export function RelayDetailPage({ node }: { node: NodeItem }) {
href={`http://${node.ip || '127.0.0.1'}:${node.relay_bind_port + 500}`}
target="_blank"
rel="noreferrer"
- className="text-[var(--accent-strong)] hover:underline font-medium"
+ className="font-medium text-[var(--accent-strong)] hover:underline"
>
点击打开 Web 界面
diff --git a/openflare_server/web/features/nodes/components/tunnel-detail-page.tsx b/openflare_server/web/features/nodes/components/tunnel-detail-page.tsx
index a620a46e..a07361a5 100644
--- a/openflare_server/web/features/nodes/components/tunnel-detail-page.tsx
+++ b/openflare_server/web/features/nodes/components/tunnel-detail-page.tsx
@@ -27,10 +27,7 @@ import {
updateNode,
} from '@/features/nodes/api/nodes';
import { NodeEditorModal } from '@/features/nodes/components/node-editor-modal';
-import type {
- NodeItem,
- NodeAgentReleaseInfo,
-} from '@/features/nodes/types';
+import type { NodeItem, NodeAgentReleaseInfo } from '@/features/nodes/types';
import {
CodeBlock,
DangerButton,
@@ -217,9 +214,11 @@ export function TunnelDetailPage({ node }: { node: NodeItem }) {
});
const handleDelete = () => {
- if (!window.confirm(
- `确认删除节点“${node.name}”吗?删除后该节点需要重新创建并重新接入。`,
- )) {
+ if (
+ !window.confirm(
+ `确认删除节点“${node.name}”吗?删除后该节点需要重新创建并重新接入。`,
+ )
+ ) {
return;
}
setFeedback(null);
@@ -266,7 +265,12 @@ export function TunnelDetailPage({ node }: { node: NodeItem }) {
default:
return observability?.health_events ?? [];
}
- }, [activeHealthEvents, healthEventFilter, observability?.health_events, resolvedHealthEvents]);
+ }, [
+ activeHealthEvents,
+ healthEventFilter,
+ observability?.health_events,
+ resolvedHealthEvents,
+ ]);
const tabs = useMemo(
() =>
@@ -550,7 +554,9 @@ export function TunnelDetailPage({ node }: { node: NodeItem }) {
总内存
- {formatBytes(observability.profile.total_memory_bytes)}
+ {formatBytes(
+ observability.profile.total_memory_bytes,
+ )}
@@ -594,7 +600,9 @@ export function TunnelDetailPage({ node }: { node: NodeItem }) {
@@ -1126,12 +1138,8 @@ export function TunnelDetailPage({ node }: { node: NodeItem }) {
/>
) : (
-
- 该操作会物理删除此节点在控制端记录的所有健康诊断事件历史。
-
-
- 这不会影响节点在后续运行中继续捕获并上报新的故障。
-
+
该操作会物理删除此节点在控制端记录的所有健康诊断事件历史。
+
这不会影响节点在后续运行中继续捕获并上报新的故障。
)}
@@ -1200,8 +1208,12 @@ export function TunnelDetailPage({ node }: { node: NodeItem }) {
{selectedReleaseChannel === 'preview' ? '预览版' : '正式版'}
@@ -1254,7 +1266,9 @@ export function TunnelDetailPage({ node }: { node: NodeItem }) {
? '发现可升级版本'
: '当前已是最新版本'
}
- variant={selectedAgentRelease.has_update ? 'warning' : 'success'}
+ variant={
+ selectedAgentRelease.has_update ? 'warning' : 'success'
+ }
/>
{selectedAgentRelease.prerelease ? (
diff --git a/openflare_server/web/features/nodes/utils.ts b/openflare_server/web/features/nodes/utils.ts
index 9d1f3b68..3569f341 100644
--- a/openflare_server/web/features/nodes/utils.ts
+++ b/openflare_server/web/features/nodes/utils.ts
@@ -1,10 +1,13 @@
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 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;
+ return (
+ value === WS_CONNECTED_LAST_SEEN || value === FLARED_WS_CONNECTED_LAST_SEEN
+ );
}
export function isMeaningfulTime(value: string | null | undefined) {
diff --git a/openflare_server/web/features/pages/api/pages.ts b/openflare_server/web/features/pages/api/pages.ts
index 73a36656..fc29c7ca 100644
--- a/openflare_server/web/features/pages/api/pages.ts
+++ b/openflare_server/web/features/pages/api/pages.ts
@@ -49,10 +49,13 @@ export function uploadPagesDeployment(
const formData = new FormData();
formData.append('package', file);
formData.append('entry_file', entryFile);
- return apiRequest(`/pages/${projectId}/deployments/upload`, {
- method: 'POST',
- body: formData,
- });
+ return apiRequest(
+ `/pages/${projectId}/deployments/upload`,
+ {
+ method: 'POST',
+ body: formData,
+ },
+ );
}
return new Promise((resolve, reject) => {
@@ -86,7 +89,12 @@ export function uploadPagesDeployment(
reject(new ApiError(payload?.message || '请求失败', xhr.status));
}
} else {
- reject(new ApiError(payload?.message || `请求失败(${xhr.status})`, xhr.status));
+ reject(
+ new ApiError(
+ payload?.message || `请求失败(${xhr.status})`,
+ xhr.status,
+ ),
+ );
}
};
diff --git a/openflare_server/web/features/pages/components/pages-deployment-upload-modal.tsx b/openflare_server/web/features/pages/components/pages-deployment-upload-modal.tsx
new file mode 100644
index 00000000..3a567c40
--- /dev/null
+++ b/openflare_server/web/features/pages/components/pages-deployment-upload-modal.tsx
@@ -0,0 +1,174 @@
+'use client';
+
+import { useMutation, useQueryClient } from '@tanstack/react-query';
+import { useState } from 'react';
+
+import { AppModal } from '@/components/ui/app-modal';
+import {
+ activatePagesDeployment,
+ uploadPagesDeployment,
+} from '@/features/pages/api/pages';
+import {
+ PrimaryButton,
+ ResourceField,
+ ResourceInput,
+ SecondaryButton,
+} from '@/features/shared/components/resource-primitives';
+import {
+ deploymentsQueryKey,
+ projectQueryKey,
+ projectsQueryKey,
+} from '../utils';
+
+interface PagesDeploymentUploadModalProps {
+ isOpen: boolean;
+ onClose: () => void;
+ projectId: number;
+}
+
+export function PagesDeploymentUploadModal({
+ isOpen,
+ onClose,
+ projectId,
+}: PagesDeploymentUploadModalProps) {
+ const queryClient = useQueryClient();
+ const [file, setFile] = useState(null);
+ const [entryFile, setEntryFile] = useState('index.html');
+ const [uploadProgress, setUploadProgress] = useState(null);
+ const [errorMessage, setErrorMessage] = useState(null);
+
+ const resetForm = () => {
+ setFile(null);
+ setEntryFile('index.html');
+ setUploadProgress(null);
+ setErrorMessage(null);
+ };
+
+ const handleClose = () => {
+ resetForm();
+ onClose();
+ };
+
+ const uploadMutation = useMutation({
+ mutationFn: async ({ shouldActivate }: { shouldActivate: boolean }) => {
+ if (!file) {
+ throw new Error('请选择 zip 文件');
+ }
+ setUploadProgress(0);
+ setErrorMessage(null);
+
+ const deployment = await uploadPagesDeployment(
+ projectId,
+ file,
+ entryFile,
+ (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 (
+
+
+ 取消
+
+
+ {uploadMutation.isPending &&
+ !uploadMutation.variables?.shouldActivate
+ ? `上传中 (${uploadProgress ?? 0}%)...`
+ : '上传'}
+
+
+ {uploadMutation.isPending &&
+ uploadMutation.variables?.shouldActivate
+ ? `上传并部署中 (${uploadProgress ?? 0}%)...`
+ : '上传并部署'}
+
+
+ }
+ >
+
+
+ setFile(event.target.files?.[0] ?? null)}
+ />
+
+
+ setEntryFile(event.target.value)}
+ />
+
+ {uploadProgress !== null && (
+
+
+ 上传进度
+ {uploadProgress}%
+
+
+
+ )}
+
+
+ );
+}
diff --git a/openflare_server/web/features/pages/components/pages-detail-page.tsx b/openflare_server/web/features/pages/components/pages-detail-page.tsx
new file mode 100644
index 00000000..0052196b
--- /dev/null
+++ b/openflare_server/web/features/pages/components/pages-detail-page.tsx
@@ -0,0 +1,267 @@
+'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,
+ getPagesProject,
+ getPagesDeployments,
+} 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 (
+
+ );
+ }
+
+ if (projectQuery.isLoading) {
+ return
;
+ }
+
+ if (projectQuery.isError) {
+ return (
+
+ );
+ }
+
+ const project = projectQuery.data;
+ if (!project) {
+ return (
+
+ );
+ }
+
+ const handleDeleteProject = () => {
+ if (!window.confirm(`确认删除 Pages 项目 ${project.name} 吗?`)) {
+ return;
+ }
+ deleteProjectMutation.mutate();
+ };
+
+ return (
+
+
+
+ 返回列表
+
+ setEditModalOpen(true)}
+ >
+ 编辑项目
+
+ setUploadModalOpen(true)}
+ >
+ 上传部署包
+
+
+ 删除项目
+
+ >
+ }
+ />
+
+ 0 ? (
+ setUploadModalOpen(true)}
+ >
+ 上传部署包
+
+ ) : null
+ }
+ >
+ {deploymentsQuery.isLoading ? (
+
+ 加载中...
+
+ ) : deploymentsQuery.isError ? (
+
+ {deploymentsQuery.error.message}
+
+ ) : (deploymentsQuery.data ?? []).length === 0 ? (
+
+ setUploadModalOpen(true)}
+ >
+ 上传部署包
+
+
+ ) : (
+
+ {(deploymentsQuery.data ?? []).map((deployment) => (
+
+
+
+ #{deployment.deployment_number}{' '}
+ {deployment.status === 'active' ? '· 已激活' : ''}
+
+
+ {deployment.checksum.slice(0, 16)} · {deployment.file_count}{' '}
+ files · {formatBytes(deployment.total_size)}
+
+
+
+ {
+ if (
+ window.confirm(
+ `确认激活部署 #${deployment.deployment_number} 吗?`,
+ )
+ ) {
+ activateMutation.mutate(deployment.id);
+ }
+ }}
+ >
+ 激活
+
+ {
+ if (
+ window.confirm(
+ `确认删除部署 #${deployment.deployment_number} 吗?`,
+ )
+ ) {
+ deleteDeploymentMutation.mutate(deployment.id);
+ }
+ }}
+ >
+ 删除
+
+
+
+ ))}
+
+ )}
+
+
+ setEditModalOpen(false)}
+ project={project}
+ />
+
+ setUploadModalOpen(false)}
+ projectId={parsedProjectId}
+ />
+
+ );
+}
diff --git a/openflare_server/web/features/pages/components/pages-page.tsx b/openflare_server/web/features/pages/components/pages-page.tsx
index cb6255f9..5eb72838 100644
--- a/openflare_server/web/features/pages/components/pages-page.tsx
+++ b/openflare_server/web/features/pages/components/pages-page.tsx
@@ -1,64 +1,19 @@
'use client';
-import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
-import Link from 'next/link';
-import { useRouter } from 'next/navigation';
-import { useEffect, useState, type FormEvent } from 'react';
+import { useQuery } from '@tanstack/react-query';
+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 { AppModal } from '@/components/ui/app-modal';
-import { StatusBadge } from '@/components/ui/status-badge';
+import { getPagesProjects } from '@/features/pages/api/pages';
import {
- activatePagesDeployment,
- createPagesProject,
- deletePagesDeployment,
- deletePagesProject,
- getPagesProject,
- getPagesDeployments,
- getPagesProjects,
- updatePagesProject,
- uploadPagesDeployment,
-} from '@/features/pages/api/pages';
-import type { PagesProject } from '@/features/pages/types';
-import {
- DangerButton,
PrimaryButton,
- ResourceField,
- ResourceInput,
SecondaryButton,
- ToggleField,
} from '@/features/shared/components/resource-primitives';
+import { PagesProjectEditorModal } from './pages-project-editor-modal';
+import { PagesProjectListItem } from './pages-project-list-item';
+import { projectsQueryKey } from '../utils';
-const projectsQueryKey = ['pages-projects'];
-
-function projectQueryKey(projectId: string | number) {
- return ['pages-project', String(projectId)];
-}
-
-function deploymentsQueryKey(projectId: string | number) {
- return ['pages-deployments', Number(projectId)];
-}
-
-function formatBytes(value: number) {
- if (value < 1024) {
- return `${value} B`;
- }
- if (value < 1024 * 1024) {
- return `${(value / 1024).toFixed(1)} KiB`;
- }
- return `${(value / 1024 / 1024).toFixed(1)} MiB`;
-}
-
-function formatDate(value?: string | null) {
- if (!value) {
- return '未激活';
- }
- return new Date(value).toLocaleString();
-}
+export { PagesProjectDetailPage } from './pages-detail-page';
export function PagesPage() {
const [isCreateModalOpen, setCreateModalOpen] = useState(false);
@@ -79,8 +34,8 @@ export function PagesPage() {
边缘静态站点托管
- 创建 Pages 项目,上传已构建的 zip 静态资源包,然后在规则中选择 Pages
- 项目作为上游。发布后 Agent
+ 创建 Pages 项目,上传已构建 of zip 静态资源包,然后在规则中选择
+ Pages 项目作为上游。发布后 Agent
会拉取部署包并在边缘节点本地服务静态文件。
@@ -114,8 +69,7 @@ export function PagesPage() {
新建 Pages 项目
}
- >
-
+ />
) : (
(projectsQuery.data ?? []).map((project) => (
@@ -123,769 +77,10 @@ export function PagesPage() {
)}
- setCreateModalOpen(false)}
/>
);
}
-
-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 (
-
- );
- }
-
- if (projectQuery.isLoading) {
- return ;
- }
-
- if (projectQuery.isError) {
- return (
-
- );
- }
-
- const project = projectQuery.data;
- if (!project) {
- return (
-
- );
- }
-
- const handleDeleteProject = () => {
- if (!window.confirm(`确认删除 Pages 项目 ${project.name} 吗?`)) {
- return;
- }
- deleteProjectMutation.mutate();
- };
-
- return (
-
-
-
- 返回列表
-
- setEditModalOpen(true)}
- >
- 编辑项目
-
- setUploadModalOpen(true)}
- >
- 上传部署包
-
-
- 删除项目
-
- >
- }
- />
-
- 0 ? (
- setUploadModalOpen(true)}
- >
- 上传部署包
-
- ) : null
- }
- >
- {deploymentsQuery.isLoading ? (
-
- 加载中...
-
- ) : deploymentsQuery.isError ? (
-
- {deploymentsQuery.error.message}
-
- ) : (deploymentsQuery.data ?? []).length === 0 ? (
-
- setUploadModalOpen(true)}
- >
- 上传部署包
-
-
- ) : (
-
- {(deploymentsQuery.data ?? []).map((deployment) => (
-
-
-
- #{deployment.deployment_number}{' '}
- {deployment.status === 'active' ? '· 已激活' : ''}
-
-
- {deployment.checksum.slice(0, 16)} ·{' '}
- {deployment.file_count} files ·{' '}
- {formatBytes(deployment.total_size)}
-
-
-
- {
- if (
- window.confirm(
- `确认激活部署 #${deployment.deployment_number} 吗?`,
- )
- ) {
- activateMutation.mutate(deployment.id);
- }
- }}
- >
- 激活
-
- {
- if (
- window.confirm(
- `确认删除部署 #${deployment.deployment_number} 吗?`,
- )
- ) {
- deleteDeploymentMutation.mutate(deployment.id);
- }
- }}
- >
- 删除
-
-
-
- ))}
-
- )}
-
-
- setEditModalOpen(false)}
- project={project}
- />
-
- setUploadModalOpen(false)}
- projectId={parsedProjectId}
- />
-
- );
-}
-
-function PagesDeploymentUploadModal({
- isOpen,
- onClose,
- projectId,
-}: {
- isOpen: boolean;
- onClose: () => void;
- projectId: number;
-}) {
- const queryClient = useQueryClient();
- const [file, setFile] = useState(null);
- const [entryFile, setEntryFile] = useState('index.html');
- const [uploadProgress, setUploadProgress] = useState(null);
- const [errorMessage, setErrorMessage] = useState(null);
-
- const resetForm = () => {
- setFile(null);
- setEntryFile('index.html');
- setUploadProgress(null);
- setErrorMessage(null);
- };
-
- const handleClose = () => {
- resetForm();
- onClose();
- };
-
- const uploadMutation = useMutation({
- mutationFn: async ({ shouldActivate }: { shouldActivate: boolean }) => {
- if (!file) {
- throw new Error('请选择 zip 文件');
- }
- setUploadProgress(0);
- setErrorMessage(null);
-
- const deployment = await uploadPagesDeployment(
- projectId,
- file,
- entryFile,
- (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 (
-
-
- 取消
-
-
- {uploadMutation.isPending && !uploadMutation.variables?.shouldActivate
- ? `上传中 (${uploadProgress ?? 0}%)...`
- : '上传'}
-
-
- {uploadMutation.isPending && uploadMutation.variables?.shouldActivate
- ? `上传并部署中 (${uploadProgress ?? 0}%)...`
- : '上传并部署'}
-
-
- }
- >
-
-
- setFile(event.target.files?.[0] ?? null)}
- />
-
-
- setEntryFile(event.target.value)}
- />
-
- {uploadProgress !== null && (
-
-
- 上传进度
- {uploadProgress}%
-
-
-
- )}
- {errorMessage ? (
-
- {errorMessage}
-
- ) : null}
-
-
- );
-}
-
-
-function PagesProjectEditModal({
- isOpen,
- onClose,
- project,
-}: {
- isOpen: boolean;
- onClose: () => void;
- project: PagesProject;
-}) {
- const queryClient = useQueryClient();
- const [name, setName] = useState(project.name);
- const [slug, setSlug] = useState(project.slug);
- const [description, setDescription] = useState(project.description || '');
- const [spaFallbackEnabled, setSpaFallbackEnabled] = useState(project.spa_fallback_enabled);
- const [spaFallbackPath, setSpaFallbackPath] = useState(project.spa_fallback_path);
- const [apiProxyEnabled, setApiProxyEnabled] = useState(project.api_proxy_enabled || false);
- const [apiProxyPath, setApiProxyPath] = useState(project.api_proxy_path || '');
- const [apiProxyPass, setApiProxyPass] = useState(project.api_proxy_pass || '');
- const [apiProxyRewrite, setApiProxyRewrite] = useState(project.api_proxy_rewrite || '');
-
- useEffect(() => {
- setName(project.name);
- setSlug(project.slug);
- setDescription(project.description || '');
- setSpaFallbackEnabled(project.spa_fallback_enabled);
- setSpaFallbackPath(project.spa_fallback_path);
- setApiProxyEnabled(project.api_proxy_enabled || false);
- setApiProxyPath(project.api_proxy_path || '');
- setApiProxyPass(project.api_proxy_pass || '');
- setApiProxyRewrite(project.api_proxy_rewrite || '');
- }, [project]);
-
- const updateMutation = useMutation({
- mutationFn: () =>
- updatePagesProject(project.id, {
- name,
- slug,
- description,
- enabled: project.enabled,
- spa_fallback_enabled: spaFallbackEnabled,
- spa_fallback_path: spaFallbackPath,
- api_proxy_enabled: apiProxyEnabled,
- api_proxy_path: apiProxyPath,
- api_proxy_pass: apiProxyPass,
- api_proxy_rewrite: apiProxyRewrite,
- }),
- onSuccess: () => {
- onClose();
- queryClient.invalidateQueries({ queryKey: projectQueryKey(project.id) });
- queryClient.invalidateQueries({ queryKey: projectsQueryKey });
- },
- });
-
- function handleUpdate(event: FormEvent) {
- event.preventDefault();
- updateMutation.mutate();
- }
-
- return (
-
-
- 取消
-
-
- {updateMutation.isPending ? '保存中...' : '保存修改'}
-
-
- }
- >
-
-
- );
-}
-
-function PagesProjectCreateModal({
- isOpen,
- onClose,
-}: {
- isOpen: boolean;
- onClose: () => void;
-}) {
- const queryClient = useQueryClient();
- const [name, setName] = useState('');
- const [slug, setSlug] = useState('');
- const [description, setDescription] = useState('');
- const [spaFallbackEnabled, setSpaFallbackEnabled] = useState(false);
- const [spaFallbackPath, setSpaFallbackPath] = useState('/index.html');
-
- const resetForm = () => {
- setName('');
- setSlug('');
- setDescription('');
- setSpaFallbackEnabled(false);
- setSpaFallbackPath('/index.html');
- };
-
- const closeModal = () => {
- resetForm();
- onClose();
- };
-
- const createMutation = useMutation({
- mutationFn: () =>
- createPagesProject({
- name,
- slug,
- description,
- enabled: true,
- spa_fallback_enabled: spaFallbackEnabled,
- spa_fallback_path: spaFallbackPath,
- api_proxy_enabled: false,
- api_proxy_path: '',
- api_proxy_pass: '',
- api_proxy_rewrite: '',
- }),
- onSuccess: () => {
- resetForm();
- onClose();
- queryClient.invalidateQueries({ queryKey: projectsQueryKey });
- },
- });
-
- function handleCreate(event: FormEvent) {
- event.preventDefault();
- createMutation.mutate();
- }
-
- return (
-
-
- 取消
-
-
- {createMutation.isPending ? '创建中...' : '创建项目'}
-
-
- }
- >
-
-
- );
-}
-
-function PagesProjectListItem({ project }: { project: PagesProject }) {
- return (
-
-
-
-
-
- {project.name}
-
-
-
-
-
- {project.slug}
-
- {project.description ? (
-
- {project.description}
-
- ) : null}
- {project.spa_fallback_enabled ? (
-
- 回退路径:{project.spa_fallback_path || '/index.html'}
-
- ) : null}
-
-
-
-
-
部署数
-
- {project.deployment_count}
-
-
-
-
- 当前激活
-
-
- {project.active_deployment
- ? `#${project.active_deployment.deployment_number}`
- : '暂无'}
-
-
-
-
-
-
- 激活时间:{formatDate(project.active_deployment?.activated_at)}
-
-
- 查看详情 →
-
-
-
- );
-}
-
diff --git a/openflare_server/web/features/pages/components/pages-project-editor-modal.tsx b/openflare_server/web/features/pages/components/pages-project-editor-modal.tsx
new file mode 100644
index 00000000..a0d5853d
--- /dev/null
+++ b/openflare_server/web/features/pages/components/pages-project-editor-modal.tsx
@@ -0,0 +1,265 @@
+'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 { pagesProjectSchema, type PagesProjectFormValues } 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: '',
+ };
+ }
+ 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 || '',
+ };
+}
+
+export function PagesProjectEditorModal({
+ isOpen,
+ onClose,
+ project,
+ onSaved,
+}: PagesProjectEditorModalProps) {
+ const queryClient = useQueryClient();
+
+ const form = useForm({
+ 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()
+ : '',
+ };
+ 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 (
+
+
+ 取消
+
+
+ {mutation.isPending
+ ? '保存中...'
+ : project
+ ? '保存修改'
+ : '创建项目'}
+
+
+ }
+ >
+
+
+ );
+}
diff --git a/openflare_server/web/features/pages/components/pages-project-list-item.tsx b/openflare_server/web/features/pages/components/pages-project-list-item.tsx
new file mode 100644
index 00000000..4006771f
--- /dev/null
+++ b/openflare_server/web/features/pages/components/pages-project-list-item.tsx
@@ -0,0 +1,77 @@
+'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 (
+
+
+
+
+
+ {project.name}
+
+
+
+
+
+ {project.slug}
+
+ {project.description ? (
+
+ {project.description}
+
+ ) : null}
+ {project.spa_fallback_enabled ? (
+
+ 回退路径:{project.spa_fallback_path || '/index.html'}
+
+ ) : null}
+
+
+
+
+
部署数
+
+ {project.deployment_count}
+
+
+
+
+ 当前激活
+
+
+ {project.active_deployment
+ ? `#${project.active_deployment.deployment_number}`
+ : '暂无'}
+
+
+
+
+
+
+ 激活时间:
+ {project.active_deployment?.activated_at
+ ? formatDateTime(project.active_deployment.activated_at)
+ : '未激活'}
+
+
+ 查看详情 →
+
+
+
+ );
+}
diff --git a/openflare_server/web/features/pages/schemas.ts b/openflare_server/web/features/pages/schemas.ts
new file mode 100644
index 00000000..4738a7ea
--- /dev/null
+++ b/openflare_server/web/features/pages/schemas.ts
@@ -0,0 +1,77 @@
+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(),
+ })
+ .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;
diff --git a/openflare_server/web/features/pages/utils.ts b/openflare_server/web/features/pages/utils.ts
new file mode 100644
index 00000000..c4bdc032
--- /dev/null
+++ b/openflare_server/web/features/pages/utils.ts
@@ -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)];
+}
diff --git a/openflare_server/web/features/performance/components/performance-page.tsx b/openflare_server/web/features/performance/components/performance-page.tsx
index 4601e0d9..c350b132 100644
--- a/openflare_server/web/features/performance/components/performance-page.tsx
+++ b/openflare_server/web/features/performance/components/performance-page.tsx
@@ -206,7 +206,8 @@ export function PerformancePage() {
const optionMap = optionsToMap(optionsQuery.data);
setPerformanceFields({
- OpenRestyDefaultServerReturnStatus: optionMap.OpenRestyDefaultServerReturnStatus ?? '421',
+ OpenRestyDefaultServerReturnStatus:
+ optionMap.OpenRestyDefaultServerReturnStatus ?? '421',
OpenRestyWorkerProcesses: optionMap.OpenRestyWorkerProcesses ?? 'auto',
OpenRestyWorkerConnections:
optionMap.OpenRestyWorkerConnections ?? '4096',
@@ -235,10 +236,7 @@ export function PerformancePage() {
optionMap.OpenRestyWebsocketEnabled,
true,
),
- OpenRestyHTTP3Enabled: toBoolean(
- optionMap.OpenRestyHTTP3Enabled,
- false,
- ),
+ OpenRestyHTTP3Enabled: toBoolean(optionMap.OpenRestyHTTP3Enabled, false),
OpenRestyProxyRequestBufferingEnabled: toBoolean(
optionMap.OpenRestyProxyRequestBufferingEnabled,
false,
diff --git a/openflare_server/web/features/proxy-routes/components/proxy-route-config-page.tsx b/openflare_server/web/features/proxy-routes/components/proxy-route-config-page.tsx
index e0242b35..04c5ff88 100644
--- a/openflare_server/web/features/proxy-routes/components/proxy-route-config-page.tsx
+++ b/openflare_server/web/features/proxy-routes/components/proxy-route-config-page.tsx
@@ -580,8 +580,11 @@ function ReverseProxySection({
origin_host: route.origin_host || '',
tunnel_id: route.tunnel_node_id ? String(route.tunnel_node_id) : '',
tunnel_target_addr: route.tunnel_target_addr || '',
- tunnel_target_protocol: (route.tunnel_target_protocol as 'http' | 'https') || 'http',
- pages_project_id: route.pages_project_id ? String(route.pages_project_id) : '',
+ tunnel_target_protocol:
+ (route.tunnel_target_protocol as 'http' | 'https') || 'http',
+ pages_project_id: route.pages_project_id
+ ? String(route.pages_project_id)
+ : '',
custom_headers_text: customHeadersToText(route.custom_header_list),
remark: route.remark || '',
},
@@ -594,8 +597,11 @@ function ReverseProxySection({
origin_host: route.origin_host || '',
tunnel_id: route.tunnel_node_id ? String(route.tunnel_node_id) : '',
tunnel_target_addr: route.tunnel_target_addr || '',
- tunnel_target_protocol: (route.tunnel_target_protocol as 'http' | 'https') || 'http',
- pages_project_id: route.pages_project_id ? String(route.pages_project_id) : '',
+ tunnel_target_protocol:
+ (route.tunnel_target_protocol as 'http' | 'https') || 'http',
+ pages_project_id: route.pages_project_id
+ ? String(route.pages_project_id)
+ : '',
custom_headers_text: customHeadersToText(route.custom_header_list),
remark: route.remark || '',
});
@@ -678,32 +684,34 @@ function ReverseProxySection({
})}
>
-
+
-