refactor(error-pages): preset templates with OpenFlare branding

This commit is contained in:
ryan
2026-08-06 14:43:01 +08:00
parent 076bf8b95c
commit ba1c9222c2
20 changed files with 1759 additions and 535 deletions
@@ -0,0 +1,70 @@
'use client';
import type { ReactNode } from 'react';
import { FileWarning } from 'lucide-react';
import type { UseQueryResult } from '@tanstack/react-query';
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';
type ErrorPageGateProps = {
optionsQuery: UseQueryResult<Array<{ key: string; value: string }>, Error>;
children: ReactNode;
};
export function ErrorPageGate({ optionsQuery, children }: ErrorPageGateProps) {
const { user, loading: authLoading } = useAuth();
if (authLoading) {
return (
<div className='py-6 px-1'>
<LoadingStateWithBorder
icon={FileWarning}
description='加载权限信息...'
/>
</div>
);
}
if (!user?.is_admin) {
return (
<div className='py-6 px-1'>
<EmptyStateWithBorder
icon={FileWarning}
title='权限不足'
description='只有管理员可以访问源站错误页设置。'
/>
</div>
);
}
if (optionsQuery.isLoading) {
return (
<div className='py-6 px-1'>
<LoadingStateWithBorder
icon={FileWarning}
description='加载错误页配置...'
/>
</div>
);
}
if (optionsQuery.isError) {
return (
<div className='py-6 px-1'>
<ErrorInline
message={
optionsQuery.error instanceof Error
? optionsQuery.error.message
: '加载失败'
}
onRetry={() => void optionsQuery.refetch()}
/>
</div>
);
}
return <>{children}</>;
}
@@ -0,0 +1,141 @@
'use client';
import { useRef, useState } from 'react';
import { useTheme } from 'next-themes';
import CodeMirror from '@uiw/react-codemirror';
import { html } from '@codemirror/lang-html';
import { Expand, Terminal } from 'lucide-react';
import Link from 'next/link';
import { Button } from '@/components/ui/button';
import {
ORIGIN_ERROR_PAGE_HTML_MAX_BYTES,
previewOriginErrorPageHTML,
} from '@/lib/openflare/default-origin-error-page-html';
type HtmlEditorWorkspaceProps = {
value: string;
onChange: (value: string) => void;
toolbarRight?: React.ReactNode;
};
/**
* SQL 查询终端同款工作区:整体边框 + 工具栏 + 可拖拽分隔;
* 本页为左代码 / 右实时预览横向布局。
*/
export function HtmlEditorWorkspace({
value,
onChange,
toolbarRight,
}: HtmlEditorWorkspaceProps) {
const containerRef = useRef<HTMLDivElement>(null);
const [editorWidthPercent, setEditorWidthPercent] = useState(48);
const { resolvedTheme } = useTheme();
const cmTheme = resolvedTheme === 'dark' ? 'dark' : 'light';
const previewSrcDoc = previewOriginErrorPageHTML(value);
const htmlBytes = new TextEncoder().encode(value).length;
const handleMouseDown = (e: React.MouseEvent) => {
e.preventDefault();
const container = containerRef.current;
if (!container) return;
const rect = container.getBoundingClientRect();
const handleMouseMove = (moveEvent: MouseEvent) => {
const x = moveEvent.clientX - rect.left;
const pct = (x / rect.width) * 100;
if (pct > 25 && pct < 75) {
setEditorWidthPercent(pct);
}
};
const handleMouseUp = () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
};
return (
<div
ref={containerRef}
className='w-full border border-border/40 bg-card/60 backdrop-blur-md rounded-lg overflow-hidden flex flex-col shadow-sm h-[calc(100vh-160px)] min-h-[520px]'
>
<div className='flex items-center justify-between px-4 py-2 border-b bg-muted/40 shrink-0 gap-4 flex-wrap'>
<div className='flex items-center gap-2 min-w-0'>
<Terminal className='size-4 text-primary shrink-0' />
<span className='text-xs font-semibold'>HTML 编辑器</span>
<span className='text-[11px] text-muted-foreground font-mono truncate'>
{htmlBytes} / {ORIGIN_ERROR_PAGE_HTML_MAX_BYTES} 字节
{value.trim() === '' ? ' · 空则使用内置默认' : ''}
</span>
</div>
<div className='flex items-center gap-1.5 flex-wrap'>{toolbarRight}</div>
</div>
<div className='flex flex-1 min-h-0 overflow-hidden'>
<div
style={{ width: `${editorWidthPercent}%` }}
className='h-full overflow-hidden relative min-w-[200px] bg-background border-r border-border/40'
>
<CodeMirror
value={value}
height='100%'
extensions={[html()]}
theme={cmTheme}
onChange={onChange}
className='h-full text-xs font-mono'
basicSetup={{
lineNumbers: true,
foldGutter: true,
dropCursor: true,
allowMultipleSelections: false,
indentOnInput: true,
}}
placeholder='留空使用内置默认模板…'
/>
</div>
<div
onMouseDown={handleMouseDown}
className='w-1.5 bg-border/60 hover:bg-primary/50 cursor-col-resize transition-colors flex items-center justify-center shrink-0 select-none z-10'
title='拖动调整宽度'
>
<div className='h-8 w-1 rounded bg-muted-foreground/30' />
</div>
<div className='flex-1 min-w-[200px] flex flex-col overflow-hidden bg-muted/10'>
<div className='flex items-center justify-between px-4 py-1.5 border-b bg-muted/20 shrink-0 text-[11px] text-muted-foreground font-mono gap-2'>
<span className='font-semibold'>实时预览</span>
<div className='flex items-center gap-2'>
<span className='hidden sm:inline'>
{'{{status}}'}→502 · {'{{host}}'}→example.com
</span>
<Button
variant='ghost'
size='sm'
className='h-6 px-2 text-[11px]'
asChild
>
<Link href='/error-pages/preview'>
<Expand className='size-3' />
真实预览
</Link>
</Button>
</div>
</div>
<div className='flex-1 min-h-0 overflow-hidden bg-background'>
<iframe
title='源站错误页实时预览'
sandbox=''
srcDoc={previewSrcDoc}
className='h-full w-full border-0 bg-background'
/>
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,72 @@
import { ORIGIN_ERROR_PAGE_HTML_MAX_BYTES } from '@/lib/openflare/default-origin-error-page-html';
import {
DEFAULT_ORIGIN_ERROR_PAGE_STATUS_TAGS,
parseStatusCodeTagsJSON,
validateStatusCodeTags,
} from '@/lib/openflare/status-code-tags';
export const OPTIONS_QUERY_KEY = ['openflare', 'options'] as const;
export const KEY_ENABLED = 'origin_error_page_enabled';
export const KEY_STATUS_CODES = 'origin_error_page_status_codes';
export const KEY_HTML = 'origin_error_page_html';
export type ErrorPageFields = {
enabled: boolean;
statusCodes: string[];
html: string;
};
export const defaultErrorPageFields: ErrorPageFields = {
enabled: true,
statusCodes: [...DEFAULT_ORIGIN_ERROR_PAGE_STATUS_TAGS],
html: '',
};
export function optionsToMap(options: Array<{ key: string; value: string }>) {
return options.reduce<Record<string, string>>((acc, option) => {
acc[option.key] = option.value;
return acc;
}, {});
}
export function mapOptionsToFields(
optionMap: Record<string, string>,
): ErrorPageFields {
const enabledRaw = optionMap[KEY_ENABLED];
return {
enabled: enabledRaw === undefined ? true : enabledRaw === 'true',
statusCodes: parseStatusCodeTagsJSON(optionMap[KEY_STATUS_CODES]),
html: optionMap[KEY_HTML] ?? '',
};
}
export function validateErrorPageFields(fields: ErrorPageFields) {
validateStatusCodeTags(fields.statusCodes);
validateErrorPageHTML(fields.html);
}
export function validateErrorPageHTML(html: string) {
const htmlBytes = new TextEncoder().encode(html).length;
if (htmlBytes > ORIGIN_ERROR_PAGE_HTML_MAX_BYTES) {
throw new Error(
`HTML 超过最大长度限制(${ORIGIN_ERROR_PAGE_HTML_MAX_BYTES} 字节)`,
);
}
}
export async function invalidateErrorPageQueries(queryClient: {
invalidateQueries: (opts: {
queryKey: readonly unknown[];
}) => Promise<unknown>;
}) {
await Promise.all([
queryClient.invalidateQueries({ queryKey: OPTIONS_QUERY_KEY }),
queryClient.invalidateQueries({
queryKey: ['openflare', 'config-preview'],
}),
queryClient.invalidateQueries({
queryKey: ['openflare', 'config-versions'],
}),
]);
}
@@ -0,0 +1,206 @@
'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 {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
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 { ErrorPageGate } from '../components/error-page-gate';
import { HtmlEditorWorkspace } from '../components/html-editor-workspace';
import {
invalidateErrorPageQueries,
KEY_HTML,
mapOptionsToFields,
OPTIONS_QUERY_KEY,
optionsToMap,
validateErrorPageHTML,
} from '../components/shared';
export default function ErrorPageEditPage() {
const { user } = 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(mapOptionsToFields(optionsToMap(optionsQuery.data)).html);
}, [optionsQuery.data]);
const saveMutation = useMutation({
mutationFn: async () => {
validateErrorPageHTML(html);
await OptionService.updateBatch([{ key: KEY_HTML, value: html }]);
},
onSuccess: async () => {
toast.success('HTML 模板已保存,请前往版本发布使配置生效');
await invalidateErrorPageQueries(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('已清空为使用服务端内置默认模板(需保存后生效)');
};
return (
<ErrorPageGate optionsQuery={optionsQuery}>
<div className='flex flex-col gap-4 py-6 px-1'>
<div className='flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between'>
<div className='flex items-center gap-3'>
<Button variant='outline' size='icon' className='h-8 w-8' asChild>
<Link href='/error-pages' aria-label='返回错误页'>
<ArrowLeft className='size-4' />
</Link>
</Button>
<div className='flex items-center gap-2'>
<FileCode2 className='size-5 text-primary' />
<div>
<h1 className='text-2xl font-semibold tracking-tight'>
编辑
</h1>
</div>
</div>
</div>
</div>
<HtmlEditorWorkspace
value={html}
onChange={setHtml}
toolbarRight={
<>
<div className='flex items-center gap-1.5'>
<span className='text-[11px] text-muted-foreground hidden sm:inline'>
内置模板
</span>
<Select value={templateId} onValueChange={setTemplateId}>
<SelectTrigger className='h-7 w-[160px] text-[11px] bg-background'>
<SelectValue placeholder='选择模板' />
</SelectTrigger>
<SelectContent>
{ORIGIN_ERROR_PAGE_TEMPLATES.map((tmpl) => (
<SelectItem
key={tmpl.id}
value={tmpl.id}
className='text-xs'
>
{tmpl.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
type='button'
variant='ghost'
size='sm'
className='h-7 px-2 text-[11px]'
onClick={loadSelectedTemplate}
>
<Sparkles className='size-3.5' />
加载模板
</Button>
</div>
<Button
type='button'
variant='ghost'
size='sm'
className='h-7 px-2 text-[11px]'
onClick={() => setRestoreOpen(true)}
>
<RotateCcw className='size-3.5' />
恢复默认
</Button>
<Button
size='sm'
className='h-7 px-3 text-[11px]'
disabled={saveMutation.isPending}
onClick={() => saveMutation.mutate()}
>
{saveMutation.isPending ? (
<Loader2 className='size-3 animate-spin' />
) : (
<Save className='size-3' />
)}
保存
</Button>
</>
}
/>
</div>
<AlertDialog open={restoreOpen} onOpenChange={setRestoreOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>恢复默认 HTML?</AlertDialogTitle>
<AlertDialogDescription>
将清空编辑器内容,保存后使用服务端内置默认模板。此操作不会自动保存。
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>取消</AlertDialogCancel>
<AlertDialogAction onClick={restoreDefault}>
确认恢复
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</ErrorPageGate>
);
}
+97 -297
View File
@@ -4,29 +4,15 @@ import Link from 'next/link';
import { useEffect, useMemo, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import {
ExternalLink,
Expand,
FileWarning,
Loader2,
RotateCcw,
Pencil,
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,
@@ -38,76 +24,33 @@ import {
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 { previewOriginErrorPageHTML } from '@/lib/openflare/default-origin-error-page-html';
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<Record<string, string>>((acc, option) => {
acc[option.key] = option.value;
return acc;
}, {});
}
function mapOptionsToFields(
optionMap: Record<string, string>,
): 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} 字节)`,
);
}
}
import { ErrorPageGate } from './components/error-page-gate';
import {
defaultErrorPageFields,
invalidateErrorPageQueries,
KEY_ENABLED,
KEY_STATUS_CODES,
mapOptionsToFields,
OPTIONS_QUERY_KEY,
optionsToMap,
type ErrorPageFields,
} from './components/shared';
export default function ErrorPagesPage() {
const { user, loading: authLoading } = useAuth();
const { user } = useAuth();
const queryClient = useQueryClient();
const [fields, setFields] = useState<ErrorPageFields>(defaultFields);
const [restoreOpen, setRestoreOpen] = useState(false);
const [fields, setFields] = useState<ErrorPageFields>(defaultErrorPageFields);
const [tagError, setTagError] = useState<string | null>(null);
const optionsQuery = useQuery({
queryKey: optionsQueryKey,
queryKey: OPTIONS_QUERY_KEY,
queryFn: () => OptionService.list(),
enabled: !!user?.is_admin,
});
@@ -123,40 +66,27 @@ export default function ErrorPagesPage() {
[fields.html],
);
const saveMutation = useMutation({
/** 仅保存启用开关 + 触发状态码(HTML 在编辑页单独保存) */
const savePolicyMutation = useMutation({
mutationFn: async () => {
validateFields(fields);
validateStatusCodeTags(fields.statusCodes);
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'],
}),
]);
toast.success('触发策略已保存,请前往版本发布使配置生效');
await invalidateErrorPageQueries(queryClient);
},
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) {
@@ -168,213 +98,101 @@ export default function ErrorPagesPage() {
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 (
<div className='py-6 px-1'>
<LoadingStateWithBorder
icon={FileWarning}
description='加载权限信息...'
/>
</div>
);
}
if (!user?.is_admin) {
return (
<div className='py-6 px-1'>
<EmptyStateWithBorder
icon={FileWarning}
title='权限不足'
description='只有管理员可以访问源站错误页设置。'
/>
</div>
);
}
if (optionsQuery.isLoading) {
return (
<div className='py-6 px-1'>
<LoadingStateWithBorder
icon={FileWarning}
description='加载错误页配置...'
/>
</div>
);
}
if (optionsQuery.isError) {
return (
<div className='py-6 px-1'>
<ErrorInline
message={
optionsQuery.error instanceof Error
? optionsQuery.error.message
: '加载失败'
}
onRetry={() => void optionsQuery.refetch()}
/>
</div>
);
}
return (
<div className='flex flex-col gap-6 py-6 px-1'>
<div className='flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between'>
<ErrorPageGate optionsQuery={optionsQuery}>
<div className='flex flex-col gap-6 py-6 px-1'>
<div className='flex items-center gap-2'>
<FileWarning className='size-5 text-primary' />
<div>
<h1 className='text-2xl font-semibold tracking-tight'>错误页</h1>
<p className='text-sm text-muted-foreground'>
配置源站/网关错误响应时的统一 HTML
页面。保存后需发布配置版本后生效。
页面。策略与模板保存后需发布配置版本后生效。
</p>
</div>
</div>
<div className='flex flex-wrap gap-2'>
<Button variant='outline' size='sm' asChild>
<Link href='/config-versions'>
<ExternalLink className='size-3.5' />
查看配置预览
</Link>
</Button>
<Button
size='sm'
disabled={saveMutation.isPending}
onClick={() => saveMutation.mutate()}
>
{saveMutation.isPending ? (
<Loader2 className='size-3.5 animate-spin' />
) : (
<Save className='size-3.5' />
)}
保存
</Button>
</div>
</div>
<Card className='border-dashed shadow-none'>
<CardHeader className='flex flex-row items-center justify-between gap-4'>
<div>
<CardTitle className='text-base'>启用源站错误页</CardTitle>
<CardDescription>
关闭后透传源站或 Nginx 默认错误响应,不注入 error_page 指令。
</CardDescription>
</div>
<Switch
checked={fields.enabled}
onCheckedChange={(enabled) =>
setFields((prev) => ({ ...prev, enabled }))
}
aria-label='启用源站错误页'
/>
</CardHeader>
</Card>
<Card className='border-dashed shadow-none'>
<CardHeader>
<CardTitle className='text-base'>触发状态码</CardTitle>
<CardDescription>
支持单码(如 502)或闭区间(如 500-599),范围 400–599。默认
500-599。
</CardDescription>
</CardHeader>
<CardContent className='flex flex-col gap-2'>
<Label htmlFor='origin-error-status-codes' className='sr-only'>
状态码标签
</Label>
<TagsInput
id='origin-error-status-codes'
value={fields.statusCodes}
onChange={handleStatusCodesChange}
validateTag={handleValidateTag}
placeholder='例如 502 或 500-599,回车添加'
aria-invalid={!!tagError}
/>
{tagError ? (
<p className='text-xs text-destructive'>{tagError}</p>
) : (
<p className='text-xs text-muted-foreground'>
输入后按 Enter 或逗号添加;Backspace 可删除最后一个标签。
</p>
)}
</CardContent>
</Card>
<div className='grid gap-6 xl:grid-cols-2'>
<Card className='border-dashed shadow-none'>
<CardHeader className='flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between'>
<CardHeader className='flex flex-row items-center justify-between gap-4'>
<div>
<CardTitle className='text-base'>HTML 模板</CardTitle>
<CardTitle className='text-base'>启用源站错误页</CardTitle>
<CardDescription>
支持占位符 {'{{status}}'} 与 {'{{host}}'}
。留空则使用服务端内置默认页。
关闭后会透传源站或 Nginx 默认错误响应。
</CardDescription>
</div>
<div className='flex flex-wrap gap-2'>
<Button
type='button'
variant='outline'
size='sm'
onClick={loadDefaultTemplate}
>
<Sparkles className='size-3.5' />
加载默认模板
</Button>
<Button
type='button'
variant='outline'
size='sm'
onClick={() => setRestoreOpen(true)}
>
<RotateCcw className='size-3.5' />
恢复默认
</Button>
</div>
</CardHeader>
<CardContent>
<Textarea
id='origin-error-html'
value={fields.html}
onChange={(event) =>
setFields((prev) => ({ ...prev, html: event.target.value }))
<Switch
checked={fields.enabled}
onCheckedChange={(enabled) =>
setFields((prev) => ({ ...prev, enabled }))
}
placeholder='留空使用内置默认模板…'
className='min-h-[28rem] font-mono text-xs leading-relaxed'
spellCheck={false}
aria-label='启用源站错误页'
/>
<p className='mt-2 text-xs text-muted-foreground'>
当前 {new TextEncoder().encode(fields.html).length} /{' '}
{ORIGIN_ERROR_PAGE_HTML_MAX_BYTES} 字节
{fields.html.trim() === '' ? '(使用内置默认)' : ''}
</p>
</CardContent>
</CardHeader>
</Card>
<Card className='border-dashed shadow-none'>
<CardHeader>
<CardTitle className='text-base'>预览</CardTitle>
<CardDescription>
客户端预览:{'{{status}}'}→502,{'{{host}}'}→example.com
</CardDescription>
<CardHeader className='flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between space-y-0'>
<div className='space-y-1.5'>
<CardTitle className='text-base'>触发状态码</CardTitle>
<CardDescription>
支持单码(如 502)或闭区间(如 500-599),范围
400–599。默认 500-599。修改启用开关或状态码后需点击保存。
</CardDescription>
</div>
<Button
size='sm'
className='shrink-0'
disabled={savePolicyMutation.isPending}
onClick={() => savePolicyMutation.mutate()}
>
{savePolicyMutation.isPending ? (
<Loader2 className='size-3.5 animate-spin' />
) : (
<Save className='size-3.5' />
)}
保存
</Button>
</CardHeader>
<CardContent className='flex flex-col gap-2'>
<Label htmlFor='origin-error-status-codes' className='sr-only'>
状态码标签
</Label>
<TagsInput
id='origin-error-status-codes'
value={fields.statusCodes}
onChange={(statusCodes) => {
setFields((prev) => ({ ...prev, statusCodes }));
setTagError(null);
}}
validateTag={handleValidateTag}
placeholder='例如 502 或 500-599,回车添加'
aria-invalid={!!tagError}
/>
{tagError ? (
<p className='text-xs text-destructive'>{tagError}</p>
) : (null)}
</CardContent>
</Card>
<Card className='border-dashed shadow-none overflow-hidden'>
<CardHeader className='flex flex-row items-start justify-between gap-3 space-y-0'>
<div className='space-y-1.5'>
<CardTitle className='text-base'>页面预览</CardTitle>
</div>
<div className='flex shrink-0 flex-wrap gap-2'>
<Button variant='outline' size='sm' asChild>
<Link href='/error-pages/preview'>
<Expand className='size-3.5' />
预览
</Link>
</Button>
<Button size='sm' asChild>
<Link href='/error-pages/edit'>
<Pencil className='size-3.5' />
编辑
</Link>
</Button>
</div>
</CardHeader>
<CardContent>
<div className='overflow-hidden rounded-md border bg-muted/30'>
@@ -388,24 +206,6 @@ export default function ErrorPagesPage() {
</CardContent>
</Card>
</div>
<AlertDialog open={restoreOpen} onOpenChange={setRestoreOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>恢复默认配置?</AlertDialogTitle>
<AlertDialogDescription>
将状态码重置为 500-599,并将 HTML
清空为「使用服务端内置默认模板」。此操作不会自动保存,仍需点击保存并发布配置版本。
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>取消</AlertDialogCancel>
<AlertDialogAction onClick={restoreDefault}>
确认恢复
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</ErrorPageGate>
);
}
@@ -0,0 +1,82 @@
'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 { previewOriginErrorPageHTML } from '@/lib/openflare/default-origin-error-page-html';
import { OptionService } from '@/lib/services/openflare';
import { ErrorPageGate } from '../components/error-page-gate';
import {
mapOptionsToFields,
OPTIONS_QUERY_KEY,
optionsToMap,
} from '../components/shared';
/**
* 全屏「真实预览」:覆盖主布局,按访客视角展示替换后的 HTML。
*/
export default function ErrorPagePreviewPage() {
const { user } = useAuth();
const optionsQuery = useQuery({
queryKey: OPTIONS_QUERY_KEY,
queryFn: () => OptionService.list(),
enabled: !!user?.is_admin,
});
const html = useMemo(() => {
if (!optionsQuery.data) return '';
return mapOptionsToFields(optionsToMap(optionsQuery.data)).html;
}, [optionsQuery.data]);
const previewSrcDoc = useMemo(
() => previewOriginErrorPageHTML(html),
[html],
);
return (
<ErrorPageGate optionsQuery={optionsQuery}>
<div className='fixed inset-0 z-50 flex flex-col bg-background'>
<div className='flex items-center justify-between gap-3 border-b bg-background/95 px-4 py-2 backdrop-blur shrink-0'>
<div className='flex items-center gap-2 min-w-0'>
<Button variant='outline' size='icon' className='h-8 w-8' asChild>
<Link href='/error-pages' aria-label='返回错误页'>
<ArrowLeft className='size-4' />
</Link>
</Button>
<div className='min-w-0'>
<p className='text-sm font-semibold truncate'>错误页真实预览</p>
<p className='text-[11px] text-muted-foreground font-mono truncate'>
{'{{status}}'}→502 · {'{{host}}'}→example.com · 全屏展示
</p>
</div>
</div>
<div className='flex items-center gap-2 shrink-0'>
<Button variant='outline' size='sm' asChild>
<Link href='/error-pages/edit'>
<Pencil className='size-3.5' />
编辑
</Link>
</Button>
<Button variant='ghost' size='icon' className='h-8 w-8' asChild>
<Link href='/error-pages' aria-label='关闭预览'>
<X className='size-4' />
</Link>
</Button>
</div>
</div>
<iframe
title='源站错误页真实预览'
sandbox=''
srcDoc={previewSrcDoc}
className='flex-1 w-full border-0 bg-background min-h-0'
/>
</div>
</ErrorPageGate>
);
}