docs: rename contact page references to offline page in changelog

refactor(frontend): rename contact page to offline page and update routes/components

feat(frontend): add preset templates suite for offline contact page
This commit is contained in:
ryan
2026-08-08 21:27:21 +08:00
parent f083da20f2
commit fda727cf53
12 changed files with 651 additions and 54 deletions
@@ -25,28 +25,28 @@ import {
import { ScopeDomainDialog } from './scope-domain-dialog';
import {
defaultContactPageFields,
defaultOfflinePageFields,
invalidateResponseQueries,
KEY_SW_DOMAINS,
KEY_SW_ENABLED,
KEY_SW_HTML,
mapOptionsToContactFields,
type ContactPageFields,
mapOptionsToOfflineFields,
type OfflinePageFields,
} from './shared';
export function ContactPageTab({
export function OfflinePageTab({
optionMap,
}: {
optionMap: Record<string, string>;
}) {
const queryClient = useQueryClient();
const [fields, setFields] = useState<ContactPageFields>(
defaultContactPageFields,
const [fields, setFields] = useState<OfflinePageFields>(
defaultOfflinePageFields,
);
const [scopeOpen, setScopeOpen] = useState(false);
useEffect(() => {
setFields(mapOptionsToContactFields(optionMap));
setFields(mapOptionsToOfflineFields(optionMap));
}, [optionMap]);
const zonesQuery = useQuery({
@@ -72,7 +72,7 @@ export function ContactPageTab({
]);
},
onSuccess: async () => {
toast.success('联系页已保存,请前往版本发布使配置生效');
toast.success('离线页已保存,请前往版本发布使配置生效');
await invalidateResponseQueries(queryClient);
},
onError: (error) => {
@@ -88,7 +88,7 @@ export function ContactPageTab({
<CardTitle className='text-base'>离线兜底</CardTitle>
<CardDescription>
启用后给启用 HTTPS 的网站下发 Service
Worker,域名被墙时浏览器从缓存展示此联系页。
Worker,域名被墙时浏览器从缓存展示此离线页。
</CardDescription>
</div>
<Button
@@ -186,13 +186,13 @@ export function ContactPageTab({
</div>
<div className='flex shrink-0 flex-wrap gap-2'>
<Button variant='outline' size='sm' asChild>
<Link href='/responses/contact/preview'>
<Link href='/responses/offline/preview'>
<Expand className='size-3.5' />
真实预览
</Link>
</Button>
<Button size='sm' asChild>
<Link href='/responses/contact/edit'>
<Link href='/responses/offline/edit'>
<Pencil className='size-3.5' />
编辑
</Link>
@@ -202,7 +202,7 @@ export function ContactPageTab({
<CardContent>
<div className='overflow-hidden rounded-md border bg-muted/30'>
<iframe
title='离线联系页预览'
title='离线页预览'
sandbox=''
srcDoc={fields.html}
className='h-[32rem] w-full bg-background'
@@ -28,13 +28,13 @@ export const defaultErrorPageFields: ErrorPageFields = {
html: '',
};
export type ContactPageFields = {
export type OfflinePageFields = {
enabled: boolean;
html: string;
domains: string[];
};
export const defaultContactPageFields: ContactPageFields = {
export const defaultOfflinePageFields: OfflinePageFields = {
enabled: false,
html: '',
domains: [],
@@ -72,9 +72,9 @@ export function mapOptionsToErrorFields(
};
}
export function mapOptionsToContactFields(
export function mapOptionsToOfflineFields(
optionMap: Record<string, string>,
): ContactPageFields {
): OfflinePageFields {
return {
enabled: optionMap[KEY_SW_ENABLED] === 'true',
html: optionMap[KEY_SW_HTML] ?? '',
@@ -3,7 +3,14 @@
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 {
ArrowLeft,
FileCode2,
Loader2,
RotateCcw,
Save,
Sparkles,
} from 'lucide-react';
import { toast } from 'sonner';
import { useAuth } from '@/components/providers/auth-provider';
@@ -21,24 +28,39 @@ 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 {
OFFLINE_PAGE_TEMPLATES,
DEFAULT_OFFLINE_PAGE_TEMPLATE_ID,
getOfflinePageTemplate,
} from '@/lib/openflare/offline-page-templates';
import { OptionService } from '@/lib/services/openflare';
import {
invalidateResponseQueries,
KEY_SW_HTML,
mapOptionsToContactFields,
mapOptionsToOfflineFields,
OPTIONS_QUERY_KEY,
optionsToMap,
} from '../../components/shared';
const CONTACT_PAGE_HTML_MAX_BYTES = 256 * 1024;
const OFFLINE_PAGE_HTML_MAX_BYTES = 256 * 1024;
export default function ContactPageEditPage() {
export default function OfflinePageEditPage() {
const { user, loading: authLoading } = useAuth();
const queryClient = useQueryClient();
const [html, setHtml] = useState('');
const [restoreOpen, setRestoreOpen] = useState(false);
const [templateId, setTemplateId] = useState(
DEFAULT_OFFLINE_PAGE_TEMPLATE_ID,
);
const optionsQuery = useQuery({
queryKey: OPTIONS_QUERY_KEY,
@@ -48,19 +70,19 @@ export default function ContactPageEditPage() {
useEffect(() => {
if (!optionsQuery.data) return;
setHtml(mapOptionsToContactFields(optionsToMap(optionsQuery.data)).html);
setHtml(mapOptionsToOfflineFields(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) {
if (bytes > OFFLINE_PAGE_HTML_MAX_BYTES) {
throw new Error('HTML 大小超出限制(最大 256KB)');
}
await OptionService.updateBatch([{ key: KEY_SW_HTML, value: html }]);
},
onSuccess: async () => {
toast.success('离线联系页 HTML 已保存,请前往版本发布使配置生效');
toast.success('离线页 HTML 已保存,请前往版本发布使配置生效');
await invalidateResponseQueries(queryClient);
},
onError: (error) => {
@@ -68,6 +90,16 @@ export default function ContactPageEditPage() {
},
});
const loadSelectedTemplate = () => {
const tmpl = getOfflinePageTemplate(templateId);
if (!tmpl) {
toast.error('未找到所选模板');
return;
}
setHtml(tmpl.html);
toast.success(`已加载模板「${tmpl.name}」到编辑器`);
};
const restoreDefault = () => {
setHtml('');
setRestoreOpen(false);
@@ -91,7 +123,7 @@ export default function ContactPageEditPage() {
<EmptyStateWithBorder
icon={FileCode2}
title='权限不足'
description='只有管理员可以编辑联系页 HTML。'
description='只有管理员可以编辑离线页 HTML。'
/>
</div>
);
@@ -102,7 +134,7 @@ export default function ContactPageEditPage() {
<div className='w-full py-6 px-1'>
<LoadingStateWithBorder
icon={FileCode2}
description='加载联系页配置...'
description='加载离线页配置...'
/>
</div>
);
@@ -128,7 +160,7 @@ export default function ContactPageEditPage() {
<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='/responses?tab=contact' aria-label='返回联系页'>
<Link href='/responses?tab=offline' aria-label='返回离线页'>
<ArrowLeft className='size-4' />
</Link>
</Button>
@@ -136,7 +168,7 @@ export default function ContactPageEditPage() {
<FileCode2 className='size-5 text-primary' />
<div>
<h1 className='text-2xl font-semibold tracking-tight'>
编辑离线联系页 HTML
编辑离线页 HTML
</h1>
</div>
</div>
@@ -149,9 +181,40 @@ export default function ContactPageEditPage() {
preview={(h) => h}
footerHint={null}
showPreviewLink={false}
previewTitle='离线联系页实时预览'
previewTitle='离线页实时预览'
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>
{OFFLINE_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'
@@ -13,12 +13,12 @@ import { LoadingStateWithBorder } from '@/components/layout/loading';
import { OptionService } from '@/lib/services/openflare';
import {
mapOptionsToContactFields,
mapOptionsToOfflineFields,
OPTIONS_QUERY_KEY,
optionsToMap,
} from '../../components/shared';
export default function ContactPagePreviewPage() {
export default function OfflinePagePreviewPage() {
const { user, loading: authLoading } = useAuth();
const optionsQuery = useQuery({
@@ -29,7 +29,7 @@ export default function ContactPagePreviewPage() {
const html = useMemo(() => {
if (!optionsQuery.data) return '';
return mapOptionsToContactFields(optionsToMap(optionsQuery.data)).html;
return mapOptionsToOfflineFields(optionsToMap(optionsQuery.data)).html;
}, [optionsQuery.data]);
if (authLoading) {
@@ -45,7 +45,7 @@ export default function ContactPagePreviewPage() {
<div className='w-full py-6 px-1'>
<EmptyStateWithBorder
title='权限不足'
description='只有管理员可以预览联系页。'
description='只有管理员可以预览离线页。'
/>
</div>
);
@@ -54,7 +54,7 @@ export default function ContactPagePreviewPage() {
if (optionsQuery.isLoading) {
return (
<div className='w-full py-6 px-1'>
<LoadingStateWithBorder description='加载联系页配置...' />
<LoadingStateWithBorder description='加载离线页配置...' />
</div>
);
}
@@ -79,12 +79,12 @@ export default function ContactPagePreviewPage() {
<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='/responses?tab=contact' aria-label='返回联系页'>
<Link href='/responses?tab=offline' aria-label='返回离线页'>
<ArrowLeft className='size-4' />
</Link>
</Button>
<div className='min-w-0'>
<p className='text-sm font-semibold truncate'>离线联系页真实预览</p>
<p className='text-sm font-semibold truncate'>离线页真实预览</p>
<p className='text-[11px] text-muted-foreground font-mono truncate'>
全屏展示
</p>
@@ -92,20 +92,20 @@ export default function ContactPagePreviewPage() {
</div>
<div className='flex items-center gap-2 shrink-0'>
<Button variant='outline' size='sm' asChild>
<Link href='/responses/contact/edit'>
<Link href='/responses/offline/edit'>
<Pencil className='size-3.5' />
编辑
</Link>
</Button>
<Button variant='ghost' size='icon' className='h-8 w-8' asChild>
<Link href='/responses?tab=contact' aria-label='关闭预览'>
<Link href='/responses?tab=offline' aria-label='关闭预览'>
<X className='size-4' />
</Link>
</Button>
</div>
</div>
<iframe
title='离线联系页真实预览'
title='离线页真实预览'
sandbox=''
srcDoc={html}
className='flex-1 w-full border-0 bg-background min-h-0'
+9 -9
View File
@@ -12,15 +12,15 @@ import { useAuth } from '@/components/providers/auth-provider';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { OptionService } from '@/lib/services/openflare';
import { ContactPageTab } from './components/contact-page-tab';
import { ErrorPageTab } from './components/error-page-tab';
import { OfflinePageTab } from './components/offline-page-tab';
import { OPTIONS_QUERY_KEY, optionsToMap } from './components/shared';
type ResponseTab = 'error' | 'contact';
type ResponseTab = 'error' | 'offline';
function resolveTab(tabParam: string | null): ResponseTab {
if (tabParam === 'contact') {
return 'contact';
if (tabParam === 'offline') {
return 'offline';
}
return 'error';
}
@@ -115,7 +115,7 @@ function ResponsesPageContent() {
<div>
<h1 className='text-2xl font-semibold tracking-tight'>响应页面</h1>
<p className='text-sm text-muted-foreground'>
配置源站错误页与离线兜底联系页。保存后需发布配置版本后生效。
配置源站错误页与离线页。保存后需发布配置版本后生效。
</p>
</div>
</div>
@@ -133,10 +133,10 @@ function ResponsesPageContent() {
错误页
</TabsTrigger>
<TabsTrigger
value='contact'
value='offline'
className='px-0 pb-2 text-xs font-semibold'
>
联系页
离线页
</TabsTrigger>
</TabsList>
@@ -144,8 +144,8 @@ function ResponsesPageContent() {
<ErrorPageTab optionMap={optionMap} />
</TabsContent>
<TabsContent value='contact' className='focus-visible:outline-none'>
<ContactPageTab optionMap={optionMap} />
<TabsContent value='offline' className='focus-visible:outline-none'>
<OfflinePageTab optionMap={optionMap} />
</TabsContent>
</Tabs>
</div>