mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-12 02:06:37 +08:00
ca21ff3a5b
fix(openresty): scope sw injection per cert partition fix(lint): satisfy revive and biome format for sw offline feature docs: sw offline scope changelog fix(frontend): use scoped query key for sw scope zones fix(frontend): hide preview link in sw contact page editor feat(frontend): add sw scope domain picker and contact page fields refactor(frontend): generalize html editor workspace for reuse feat(openresty): scope sw offline injection by route domains feat(openresty): add sw offline domains snapshot field feat(option): add sw offline domains scope option docs: fill html editor workspace generalization detail docs: sw offline scope implementation plan docs: sw offline scope design test(openresty): assert single merged access block in sw enabled servers fix(openresty): restrict sw intercept to https server blocks fix(openresty): version sw offline cache by html content fix(agent): escape redir in sw challenge page to prevent xss fix(agent): return sw.runtime module table and add lua spec docs: sw offline fallback changelog fix(frontend): memoize option map to preserve unsaved contact page edits feat(frontend): add response pages module with contact page tab feat(agent): ship sw offline lua assets and placeholder substitution feat(config): wire sw offline options into config snapshot feat(openresty): render sw offline assets and challenge intercept feat(openresty): add sw offline ConfigSnapshot fields and placeholder feat(db): seed sw offline options feat(option): add sw offline config keys and validation docs: add service worker offline fallback implementation plan docs: adopt global-option pattern for SW offline fallback (matches origin error page) docs: unify offline contact page with error pages as response pages docs: service worker offline fallback design (issue #23)
154 lines
4.6 KiB
TypeScript
154 lines
4.6 KiB
TypeScript
'use client';
|
|
|
|
import Link from 'next/link';
|
|
import { useMemo, useState } from 'react';
|
|
import { useQuery } from '@tanstack/react-query';
|
|
import { FileWarning, MessageSquareText } from 'lucide-react';
|
|
|
|
import { EmptyStateWithBorder } from '@/components/layout/empty';
|
|
import { ErrorInline } from '@/components/layout/error';
|
|
import { LoadingStateWithBorder } from '@/components/layout/loading';
|
|
import { useAuth } from '@/components/providers/auth-provider';
|
|
import { Button } from '@/components/ui/button';
|
|
import {
|
|
Card,
|
|
CardContent,
|
|
CardDescription,
|
|
CardHeader,
|
|
CardTitle,
|
|
} from '@/components/ui/card';
|
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
|
import { OptionService } from '@/lib/services/openflare';
|
|
|
|
import { ContactPageTab } from './components/contact-page-tab';
|
|
import { OPTIONS_QUERY_KEY, optionsToMap } from './components/shared';
|
|
|
|
export default function ResponsesPage() {
|
|
const { user, loading: authLoading } = useAuth();
|
|
const [activeTab, setActiveTab] = useState('error');
|
|
|
|
const optionsQuery = useQuery({
|
|
queryKey: OPTIONS_QUERY_KEY,
|
|
queryFn: () => OptionService.list(),
|
|
enabled: !!user?.is_admin,
|
|
});
|
|
|
|
const optionMap = useMemo(
|
|
() => optionsToMap(optionsQuery.data ?? []),
|
|
[optionsQuery.data],
|
|
);
|
|
|
|
if (authLoading) {
|
|
return (
|
|
<div className='w-full py-6 px-1'>
|
|
<LoadingStateWithBorder
|
|
icon={MessageSquareText}
|
|
description='加载权限信息...'
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (!user?.is_admin) {
|
|
return (
|
|
<div className='w-full py-6 px-1'>
|
|
<EmptyStateWithBorder
|
|
icon={MessageSquareText}
|
|
title='权限不足'
|
|
description='只有管理员可以访问响应页面设置。'
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (optionsQuery.isLoading) {
|
|
return (
|
|
<div className='w-full py-6 px-1'>
|
|
<LoadingStateWithBorder
|
|
icon={MessageSquareText}
|
|
description='加载响应页面配置...'
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (optionsQuery.isError) {
|
|
return (
|
|
<div className='w-full py-6 px-1'>
|
|
<ErrorInline
|
|
message={
|
|
optionsQuery.error instanceof Error
|
|
? optionsQuery.error.message
|
|
: '加载失败'
|
|
}
|
|
onRetry={() => void optionsQuery.refetch()}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (!optionsQuery.data) return null;
|
|
|
|
return (
|
|
<div className='flex w-full flex-col gap-6 py-6 px-1'>
|
|
<div className='flex items-center gap-2'>
|
|
<MessageSquareText className='size-5 text-primary' />
|
|
<div>
|
|
<h1 className='text-2xl font-semibold tracking-tight'>响应页面</h1>
|
|
<p className='text-sm text-muted-foreground'>
|
|
配置源站错误页与离线兜底联系页。保存后需发布配置版本后生效。
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<Tabs value={activeTab} onValueChange={setActiveTab} className='w-full'>
|
|
<TabsList variant='line' className='mb-6 inline-flex w-fit gap-8'>
|
|
<TabsTrigger
|
|
value='error'
|
|
className='px-0 pb-2 text-xs font-semibold'
|
|
>
|
|
错误页
|
|
</TabsTrigger>
|
|
<TabsTrigger
|
|
value='contact'
|
|
className='px-0 pb-2 text-xs font-semibold'
|
|
>
|
|
联系页
|
|
</TabsTrigger>
|
|
</TabsList>
|
|
|
|
<TabsContent
|
|
value='error'
|
|
className='space-y-4 focus-visible:outline-none'
|
|
>
|
|
<Card className='border-dashed shadow-none'>
|
|
<CardHeader className='flex flex-row items-start justify-between gap-4 space-y-0'>
|
|
<div className='space-y-1.5'>
|
|
<CardTitle className='text-base'>源站错误页</CardTitle>
|
|
<CardDescription>
|
|
配置源站/网关错误响应时的统一 HTML 页面与触发策略。
|
|
</CardDescription>
|
|
</div>
|
|
<Button size='sm' className='shrink-0' asChild>
|
|
<Link href='/error-pages'>
|
|
<FileWarning className='size-3.5' />
|
|
前往错误页设置
|
|
</Link>
|
|
</Button>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<p className='text-sm text-muted-foreground'>
|
|
错误页的触发策略、模板编辑与页面预览仍在「错误页」页面维护。
|
|
</p>
|
|
</CardContent>
|
|
</Card>
|
|
</TabsContent>
|
|
|
|
<TabsContent value='contact' className='focus-visible:outline-none'>
|
|
<ContactPageTab optionMap={optionMap} />
|
|
</TabsContent>
|
|
</Tabs>
|
|
</div>
|
|
);
|
|
}
|