- 错误页 HTML
- 留空则使用内置默认模板。
+
+
+
+ 页面预览
+
+
+
+
+
-
- setFields((prev) => ({ ...prev, html: v }))}
- preview={previewOriginErrorPageHTML}
- />
+
+
+
+
diff --git a/frontend/app/(main)/responses/contact/edit/page.tsx b/frontend/app/(main)/responses/contact/edit/page.tsx
new file mode 100644
index 00000000..3ea9a55d
--- /dev/null
+++ b/frontend/app/(main)/responses/contact/edit/page.tsx
@@ -0,0 +1,200 @@
+'use client';
+
+import Link from 'next/link';
+import { useEffect, useState } from 'react';
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
+import { ArrowLeft, FileCode2, Loader2, RotateCcw, Save } from 'lucide-react';
+import { toast } from 'sonner';
+
+import { useAuth } from '@/components/providers/auth-provider';
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from '@/components/ui/alert-dialog';
+import { Button } from '@/components/ui/button';
+import { EmptyStateWithBorder } from '@/components/layout/empty';
+import { ErrorInline } from '@/components/layout/error';
+import { LoadingStateWithBorder } from '@/components/layout/loading';
+import { HtmlEditorWorkspace } from '@/components/common/html-editor-workspace';
+import { OptionService } from '@/lib/services/openflare';
+
+import {
+ invalidateResponseQueries,
+ KEY_SW_HTML,
+ mapOptionsToContactFields,
+ OPTIONS_QUERY_KEY,
+ optionsToMap,
+} from '../../components/shared';
+
+const CONTACT_PAGE_HTML_MAX_BYTES = 256 * 1024;
+
+export default function ContactPageEditPage() {
+ const { user, loading: authLoading } = useAuth();
+ const queryClient = useQueryClient();
+ const [html, setHtml] = useState('');
+ const [restoreOpen, setRestoreOpen] = useState(false);
+
+ const optionsQuery = useQuery({
+ queryKey: OPTIONS_QUERY_KEY,
+ queryFn: () => OptionService.list(),
+ enabled: !!user?.is_admin,
+ });
+
+ useEffect(() => {
+ if (!optionsQuery.data) return;
+ setHtml(mapOptionsToContactFields(optionsToMap(optionsQuery.data)).html);
+ }, [optionsQuery.data]);
+
+ const saveMutation = useMutation({
+ mutationFn: async () => {
+ const bytes = new TextEncoder().encode(html).length;
+ if (bytes > CONTACT_PAGE_HTML_MAX_BYTES) {
+ throw new Error('HTML 大小超出限制(最大 256KB)');
+ }
+ await OptionService.updateBatch([{ key: KEY_SW_HTML, value: html }]);
+ },
+ onSuccess: async () => {
+ toast.success('离线联系页 HTML 已保存,请前往版本发布使配置生效');
+ await invalidateResponseQueries(queryClient);
+ },
+ onError: (error) => {
+ toast.error(error instanceof Error ? error.message : '保存失败');
+ },
+ });
+
+ const restoreDefault = () => {
+ setHtml('');
+ setRestoreOpen(false);
+ toast.success('已清空为使用内置默认模板(需保存后生效)');
+ };
+
+ if (authLoading) {
+ return (
+
+
+
+ );
+ }
+
+ if (!user?.is_admin) {
+ return (
+
+
+
+ );
+ }
+
+ if (optionsQuery.isLoading) {
+ return (
+
+
+
+ );
+ }
+
+ if (optionsQuery.isError) {
+ return (
+
+ void optionsQuery.refetch()}
+ />
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
+
+
+ 编辑离线联系页 HTML
+
+
+
+
+
+
+
h}
+ footerHint={null}
+ showPreviewLink={false}
+ previewTitle='离线联系页实时预览'
+ toolbarRight={
+ <>
+
+
+ >
+ }
+ />
+
+
+
+
+ 恢复默认 HTML?
+
+ 将清空编辑器内容,保存后使用内置默认模板。此操作不会自动保存。
+
+
+
+ 取消
+
+ 确认恢复
+
+
+
+
+
+ );
+}
diff --git a/frontend/app/(main)/responses/contact/preview/page.tsx b/frontend/app/(main)/responses/contact/preview/page.tsx
new file mode 100644
index 00000000..2889e1b2
--- /dev/null
+++ b/frontend/app/(main)/responses/contact/preview/page.tsx
@@ -0,0 +1,115 @@
+'use client';
+
+import Link from 'next/link';
+import { useMemo } from 'react';
+import { useQuery } from '@tanstack/react-query';
+import { ArrowLeft, Pencil, X } from 'lucide-react';
+
+import { useAuth } from '@/components/providers/auth-provider';
+import { Button } from '@/components/ui/button';
+import { EmptyStateWithBorder } from '@/components/layout/empty';
+import { ErrorInline } from '@/components/layout/error';
+import { LoadingStateWithBorder } from '@/components/layout/loading';
+import { OptionService } from '@/lib/services/openflare';
+
+import {
+ mapOptionsToContactFields,
+ OPTIONS_QUERY_KEY,
+ optionsToMap,
+} from '../../components/shared';
+
+export default function ContactPagePreviewPage() {
+ const { user, loading: authLoading } = useAuth();
+
+ const optionsQuery = useQuery({
+ queryKey: OPTIONS_QUERY_KEY,
+ queryFn: () => OptionService.list(),
+ enabled: !!user?.is_admin,
+ });
+
+ const html = useMemo(() => {
+ if (!optionsQuery.data) return '';
+ return mapOptionsToContactFields(optionsToMap(optionsQuery.data)).html;
+ }, [optionsQuery.data]);
+
+ if (authLoading) {
+ return (
+
+
+
+ );
+ }
+
+ if (!user?.is_admin) {
+ return (
+
+
+
+ );
+ }
+
+ if (optionsQuery.isLoading) {
+ return (
+
+
+
+ );
+ }
+
+ if (optionsQuery.isError) {
+ return (
+
+ void optionsQuery.refetch()}
+ />
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/frontend/app/(main)/responses/error-page/edit/page.tsx b/frontend/app/(main)/responses/error-page/edit/page.tsx
new file mode 100644
index 00000000..b6deff5e
--- /dev/null
+++ b/frontend/app/(main)/responses/error-page/edit/page.tsx
@@ -0,0 +1,262 @@
+'use client';
+
+import Link from 'next/link';
+import { useEffect, useState } from 'react';
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
+import {
+ ArrowLeft,
+ FileCode2,
+ Loader2,
+ RotateCcw,
+ Save,
+ Sparkles,
+} from 'lucide-react';
+import { toast } from 'sonner';
+
+import { useAuth } from '@/components/providers/auth-provider';
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from '@/components/ui/alert-dialog';
+import { Button } from '@/components/ui/button';
+import { EmptyStateWithBorder } from '@/components/layout/empty';
+import { ErrorInline } from '@/components/layout/error';
+import { LoadingStateWithBorder } from '@/components/layout/loading';
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from '@/components/ui/select';
+import { HtmlEditorWorkspace } from '@/components/common/html-editor-workspace';
+import {
+ ORIGIN_ERROR_PAGE_HTML_MAX_BYTES,
+ previewOriginErrorPageHTML,
+} from '@/lib/openflare/default-origin-error-page-html';
+import {
+ DEFAULT_ORIGIN_ERROR_PAGE_TEMPLATE_ID,
+ getOriginErrorPageTemplate,
+ ORIGIN_ERROR_PAGE_TEMPLATES,
+} from '@/lib/openflare/origin-error-page-templates';
+import { OptionService } from '@/lib/services/openflare';
+
+import {
+ invalidateResponseQueries,
+ KEY_HTML,
+ mapOptionsToErrorFields,
+ OPTIONS_QUERY_KEY,
+ optionsToMap,
+} from '../../components/shared';
+
+export default function ErrorPageEditPage() {
+ const { user, loading: authLoading } = useAuth();
+ const queryClient = useQueryClient();
+ const [html, setHtml] = useState('');
+ const [restoreOpen, setRestoreOpen] = useState(false);
+ const [templateId, setTemplateId] = useState(
+ DEFAULT_ORIGIN_ERROR_PAGE_TEMPLATE_ID,
+ );
+
+ const optionsQuery = useQuery({
+ queryKey: OPTIONS_QUERY_KEY,
+ queryFn: () => OptionService.list(),
+ enabled: !!user?.is_admin,
+ });
+
+ useEffect(() => {
+ if (!optionsQuery.data) return;
+ setHtml(mapOptionsToErrorFields(optionsToMap(optionsQuery.data)).html);
+ }, [optionsQuery.data]);
+
+ const saveMutation = useMutation({
+ mutationFn: async () => {
+ const bytes = new TextEncoder().encode(html).length;
+ if (bytes > ORIGIN_ERROR_PAGE_HTML_MAX_BYTES) {
+ throw new Error('HTML 大小超出限制(最大 256KB)');
+ }
+ await OptionService.updateBatch([{ key: KEY_HTML, value: html }]);
+ },
+ onSuccess: async () => {
+ toast.success('源站错误页 HTML 已保存,请前往版本发布使配置生效');
+ await invalidateResponseQueries(queryClient);
+ },
+ onError: (error) => {
+ toast.error(error instanceof Error ? error.message : '保存失败');
+ },
+ });
+
+ const loadSelectedTemplate = () => {
+ const tmpl = getOriginErrorPageTemplate(templateId);
+ if (!tmpl) {
+ toast.error('未找到所选模板');
+ return;
+ }
+ setHtml(tmpl.html);
+ toast.success(`已加载模板「${tmpl.name}」到编辑器`);
+ };
+
+ const restoreDefault = () => {
+ setHtml('');
+ setRestoreOpen(false);
+ toast.success('已清空为使用服务端内置默认模板(需保存后生效)');
+ };
+
+ if (authLoading) {
+ return (
+
+
+
+ );
+ }
+
+ if (!user?.is_admin) {
+ return (
+
+
+
+ );
+ }
+
+ if (optionsQuery.isLoading) {
+ return (
+
+
+
+ );
+ }
+
+ if (optionsQuery.isError) {
+ return (
+
+ void optionsQuery.refetch()}
+ />
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
+ 内置模板
+
+
+
+
+
+
+ >
+ }
+ />
+
+
+
+
+ 恢复默认 HTML?
+
+ 将清空编辑器内容,保存后使用服务端内置默认模板。此操作不会自动保存。
+
+
+
+ 取消
+
+ 确认恢复
+
+
+
+
+
+ );
+}
diff --git a/frontend/app/(main)/responses/error-page/preview/page.tsx b/frontend/app/(main)/responses/error-page/preview/page.tsx
new file mode 100644
index 00000000..8e3d34c0
--- /dev/null
+++ b/frontend/app/(main)/responses/error-page/preview/page.tsx
@@ -0,0 +1,118 @@
+'use client';
+
+import Link from 'next/link';
+import { useMemo } from 'react';
+import { useQuery } from '@tanstack/react-query';
+import { ArrowLeft, Pencil, X } from 'lucide-react';
+
+import { useAuth } from '@/components/providers/auth-provider';
+import { Button } from '@/components/ui/button';
+import { EmptyStateWithBorder } from '@/components/layout/empty';
+import { ErrorInline } from '@/components/layout/error';
+import { LoadingStateWithBorder } from '@/components/layout/loading';
+import { previewOriginErrorPageHTML } from '@/lib/openflare/default-origin-error-page-html';
+import { OptionService } from '@/lib/services/openflare';
+
+import {
+ mapOptionsToErrorFields,
+ OPTIONS_QUERY_KEY,
+ optionsToMap,
+} from '../../components/shared';
+
+export default function ErrorPagePreviewPage() {
+ const { user, loading: authLoading } = useAuth();
+
+ const optionsQuery = useQuery({
+ queryKey: OPTIONS_QUERY_KEY,
+ queryFn: () => OptionService.list(),
+ enabled: !!user?.is_admin,
+ });
+
+ const html = useMemo(() => {
+ if (!optionsQuery.data) return '';
+ return mapOptionsToErrorFields(optionsToMap(optionsQuery.data)).html;
+ }, [optionsQuery.data]);
+
+ const previewSrcDoc = useMemo(() => previewOriginErrorPageHTML(html), [html]);
+
+ if (authLoading) {
+ return (
+
+
+
+ );
+ }
+
+ if (!user?.is_admin) {
+ return (
+
+
+
+ );
+ }
+
+ if (optionsQuery.isLoading) {
+ return (
+
+
+
+ );
+ }
+
+ if (optionsQuery.isError) {
+ return (
+
+ void optionsQuery.refetch()}
+ />
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
错误页真实预览
+
+ {'{{status}}'}→502 · {'{{host}}'}→example.com · 全屏展示
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/frontend/app/(main)/responses/page.tsx b/frontend/app/(main)/responses/page.tsx
index a7f4c3a9..70b176a8 100644
--- a/frontend/app/(main)/responses/page.tsx
+++ b/frontend/app/(main)/responses/page.tsx
@@ -1,6 +1,7 @@
'use client';
-import { useMemo, useState } from 'react';
+import { Suspense, useMemo } from 'react';
+import { useRouter, useSearchParams } from 'next/navigation';
import { useQuery } from '@tanstack/react-query';
import { MessageSquareText } from 'lucide-react';
@@ -15,9 +16,35 @@ import { ContactPageTab } from './components/contact-page-tab';
import { ErrorPageTab } from './components/error-page-tab';
import { OPTIONS_QUERY_KEY, optionsToMap } from './components/shared';
-export default function ResponsesPage() {
+type ResponseTab = 'error' | 'contact';
+
+function resolveTab(tabParam: string | null): ResponseTab {
+ if (tabParam === 'contact') {
+ return 'contact';
+ }
+ return 'error';
+}
+
+function ResponsesPageContent() {
const { user, loading: authLoading } = useAuth();
- const [activeTab, setActiveTab] = useState('error');
+ const router = useRouter();
+ const searchParams = useSearchParams();
+
+ const activeTab = resolveTab(searchParams.get('tab'));
+
+ const handleTabChange = (value: string) => {
+ const nextTab = resolveTab(value);
+ const params = new URLSearchParams(searchParams.toString());
+ if (nextTab === 'error') {
+ params.delete('tab');
+ } else {
+ params.set('tab', nextTab);
+ }
+ const query = params.toString();
+ router.replace(query ? `/responses?${query}` : '/responses', {
+ scroll: false,
+ });
+ };
const optionsQuery = useQuery({
queryKey: OPTIONS_QUERY_KEY,
@@ -93,7 +120,11 @@ export default function ResponsesPage() {
-
+
);
}
+
+export default function ResponsesPage() {
+ return (
+
+
+
+ }
+ >
+
+
+ );
+}
diff --git a/frontend/lib/navigation/openflare-nav.ts b/frontend/lib/navigation/openflare-nav.ts
index b43292af..b746de5a 100644
--- a/frontend/lib/navigation/openflare-nav.ts
+++ b/frontend/lib/navigation/openflare-nav.ts
@@ -63,7 +63,12 @@ export const openflareWebsiteNavGroup: OpenFlareNavGroup = {
{
title: '响应页面',
url: '/responses',
- childUrls: ['/error-pages'],
+ childUrls: [
+ '/responses/error-page/edit',
+ '/responses/error-page/preview',
+ '/responses/contact/edit',
+ '/responses/contact/preview',
+ ],
},
],
};
diff --git a/frontend/lib/theme/themes.json b/frontend/lib/theme/themes.json
index 40c4d4ba..9818de0f 100644
--- a/frontend/lib/theme/themes.json
+++ b/frontend/lib/theme/themes.json
@@ -4559,4 +4559,4 @@
}
}
}
-]
+]
\ No newline at end of file