diff --git a/frontend/app/(main)/error-pages/page.tsx b/frontend/app/(main)/error-pages/page.tsx new file mode 100644 index 00000000..1bef975e --- /dev/null +++ b/frontend/app/(main)/error-pages/page.tsx @@ -0,0 +1,411 @@ +'use client'; + +import Link from 'next/link'; +import { useEffect, useMemo, useState } from 'react'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { + ExternalLink, + FileWarning, + Loader2, + RotateCcw, + Save, + Sparkles, +} from 'lucide-react'; +import { toast } from 'sonner'; + +import { useAuth } from '@/components/providers/auth-provider'; +import { EmptyStateWithBorder } from '@/components/layout/empty'; +import { ErrorInline } from '@/components/layout/error'; +import { LoadingStateWithBorder } from '@/components/layout/loading'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog'; +import { Button } from '@/components/ui/button'; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card'; +import { Label } from '@/components/ui/label'; +import { Switch } from '@/components/ui/switch'; +import { TagsInput } from '@/components/ui/tags-input'; +import { Textarea } from '@/components/ui/textarea'; +import { + DEFAULT_ORIGIN_ERROR_PAGE_HTML, + ORIGIN_ERROR_PAGE_HTML_MAX_BYTES, + previewOriginErrorPageHTML, +} from '@/lib/openflare/default-origin-error-page-html'; +import { + DEFAULT_ORIGIN_ERROR_PAGE_STATUS_TAGS, + parseStatusCodeTagsJSON, + validateStatusCodeTagMessage, + validateStatusCodeTags, +} from '@/lib/openflare/status-code-tags'; +import { OptionService } from '@/lib/services/openflare'; + +const optionsQueryKey = ['openflare', 'options'] as const; + +const KEY_ENABLED = 'origin_error_page_enabled'; +const KEY_STATUS_CODES = 'origin_error_page_status_codes'; +const KEY_HTML = 'origin_error_page_html'; + +type ErrorPageFields = { + enabled: boolean; + statusCodes: string[]; + html: string; +}; + +const defaultFields: ErrorPageFields = { + enabled: true, + statusCodes: [...DEFAULT_ORIGIN_ERROR_PAGE_STATUS_TAGS], + html: '', +}; + +function optionsToMap(options: Array<{ key: string; value: string }>) { + return options.reduce>((acc, option) => { + acc[option.key] = option.value; + return acc; + }, {}); +} + +function mapOptionsToFields( + optionMap: Record, +): ErrorPageFields { + const enabledRaw = optionMap[KEY_ENABLED]; + return { + enabled: enabledRaw === undefined ? true : enabledRaw === 'true', + statusCodes: parseStatusCodeTagsJSON(optionMap[KEY_STATUS_CODES]), + html: optionMap[KEY_HTML] ?? '', + }; +} + +function validateFields(fields: ErrorPageFields) { + validateStatusCodeTags(fields.statusCodes); + + const htmlBytes = new TextEncoder().encode(fields.html).length; + if (htmlBytes > ORIGIN_ERROR_PAGE_HTML_MAX_BYTES) { + throw new Error( + `HTML 超过最大长度限制(${ORIGIN_ERROR_PAGE_HTML_MAX_BYTES} 字节)`, + ); + } +} + +export default function ErrorPagesPage() { + const { user, loading: authLoading } = useAuth(); + const queryClient = useQueryClient(); + const [fields, setFields] = useState(defaultFields); + const [restoreOpen, setRestoreOpen] = useState(false); + const [tagError, setTagError] = useState(null); + + const optionsQuery = useQuery({ + queryKey: optionsQueryKey, + queryFn: () => OptionService.list(), + enabled: !!user?.is_admin, + }); + + useEffect(() => { + if (!optionsQuery.data) return; + setFields(mapOptionsToFields(optionsToMap(optionsQuery.data))); + setTagError(null); + }, [optionsQuery.data]); + + const previewSrcDoc = useMemo( + () => previewOriginErrorPageHTML(fields.html), + [fields.html], + ); + + const saveMutation = useMutation({ + mutationFn: async () => { + validateFields(fields); + await OptionService.updateBatch([ + { key: KEY_ENABLED, value: String(fields.enabled) }, + { + key: KEY_STATUS_CODES, + value: JSON.stringify(fields.statusCodes), + }, + { key: KEY_HTML, value: fields.html }, + ]); + }, + onSuccess: async () => { + toast.success('源站错误页已保存,请前往版本发布使配置生效'); + await Promise.all([ + queryClient.invalidateQueries({ queryKey: optionsQueryKey }), + queryClient.invalidateQueries({ + queryKey: ['openflare', 'config-preview'], + }), + queryClient.invalidateQueries({ + queryKey: ['openflare', 'config-versions'], + }), + ]); + }, + onError: (error) => { + toast.error(error instanceof Error ? error.message : '保存失败'); + }, + }); + + const handleStatusCodesChange = (statusCodes: string[]) => { + setFields((prev) => ({ ...prev, statusCodes })); + setTagError(null); + }; + + const handleValidateTag = (tag: string) => { + const message = validateStatusCodeTagMessage(tag); + if (message) { + setTagError(message); + toast.error(message); + return message; + } + setTagError(null); + return null; + }; + + const loadDefaultTemplate = () => { + setFields((prev) => ({ + ...prev, + html: DEFAULT_ORIGIN_ERROR_PAGE_HTML, + })); + toast.success('已加载默认 HTML 模板到编辑器'); + }; + + const restoreDefault = () => { + setFields((prev) => ({ + ...prev, + html: '', + statusCodes: [...DEFAULT_ORIGIN_ERROR_PAGE_STATUS_TAGS], + })); + setRestoreOpen(false); + setTagError(null); + toast.success('已恢复默认:状态码 500-599,HTML 使用服务端内置模板'); + }; + + if (authLoading) { + return ( +
+ +
+ ); + } + + if (!user?.is_admin) { + return ( +
+ +
+ ); + } + + if (optionsQuery.isLoading) { + return ( +
+ +
+ ); + } + + if (optionsQuery.isError) { + return ( +
+ void optionsQuery.refetch()} + /> +
+ ); + } + + return ( +
+
+
+ +
+

错误页

+

+ 配置源站/网关错误响应时的统一 HTML + 页面。保存后需发布配置版本后生效。 +

+
+
+
+ + +
+
+ + + +
+ 启用源站错误页 + + 关闭后透传源站或 Nginx 默认错误响应,不注入 error_page 指令。 + +
+ + setFields((prev) => ({ ...prev, enabled })) + } + aria-label='启用源站错误页' + /> +
+
+ + + + 触发状态码 + + 支持单码(如 502)或闭区间(如 500-599),范围 400–599。默认 + 500-599。 + + + + + + {tagError ? ( +

{tagError}

+ ) : ( +

+ 输入后按 Enter 或逗号添加;Backspace 可删除最后一个标签。 +

+ )} +
+
+ +
+ + +
+ HTML 模板 + + 支持占位符 {'{{status}}'} 与 {'{{host}}'} + 。留空则使用服务端内置默认页。 + +
+
+ + +
+
+ +