'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}}'} 。留空则使用服务端内置默认页。