diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index c75a5267..ce54caef 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -14,7 +14,8 @@ export default defineConfig({ 'components/**', 'snippets/**', 'plan/**', - 'guideline/**' + 'guideline/**', + 'superpowers/**' ], markdown: { diff --git a/docs/changelog/index.md b/docs/changelog/index.md index 5c756889..5cd7633d 100644 --- a/docs/changelog/index.md +++ b/docs/changelog/index.md @@ -26,6 +26,10 @@ sidebar: false - 支持 Service Worker 离线兜底:为启用 HTTPS 的网站下发 Service Worker 并缓存离线联系页,域名无法访问时浏览器展示联系站长页面,减少用户流失。可指定生效域名范围(仅对选中的 HTTPS 域名生效),配置位于「响应页面」-「联系页」,可在版本发布中批量生效。 +### 改进 + +- 响应页面统一:将源站错误页与 Service Worker 离线兜底联系页整合至统一的「响应页面」(`/responses`)标签页中,移除原 `/error-pages` 独立路由。 + ## [v3.4.5] - 2026-08-08 ### 改进 diff --git a/docs/superpowers/plans/2026-08-08-sw-offline-scope.md b/docs/superpowers/plans/2026-08-08-sw-offline-scope.md index 3b567726..44a3b7b7 100644 --- a/docs/superpowers/plans/2026-08-08-sw-offline-scope.md +++ b/docs/superpowers/plans/2026-08-08-sw-offline-scope.md @@ -546,7 +546,7 @@ function parseDomains(raw: string | undefined): string[] { - 无橙云 Switch、无 Cloudflare 依赖。 - 数据源为 `{ zoneDomain: string; domains: { domain: string }[] }` 分组(zone 根域并入)。 -- 预勾选:打开时以当前已生效域名初始化 `selected`(Set)。 +- 预勾选:打开时以当前已生效域名初始化 `selected`(`Set`)。 - 确认回调 `onSubmit([...selected])`(域名字符串数组)。 组件骨架(沿用 member-add-dialog 的 Dialog 结构,`sw` 前缀类名无冲突): diff --git a/frontend/app/(main)/error-pages/components/error-page-gate.tsx b/frontend/app/(main)/error-pages/components/error-page-gate.tsx deleted file mode 100644 index 007737b3..00000000 --- a/frontend/app/(main)/error-pages/components/error-page-gate.tsx +++ /dev/null @@ -1,70 +0,0 @@ -'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, Error>; - children: ReactNode; -}; - -export function ErrorPageGate({ optionsQuery, children }: ErrorPageGateProps) { - const { user, loading: authLoading } = useAuth(); - - if (authLoading) { - return ( -
- -
- ); - } - - if (!user?.is_admin) { - return ( -
- -
- ); - } - - if (optionsQuery.isLoading) { - return ( -
- -
- ); - } - - if (optionsQuery.isError) { - return ( -
- void optionsQuery.refetch()} - /> -
- ); - } - - return <>{children}; -} diff --git a/frontend/app/(main)/error-pages/components/shared.ts b/frontend/app/(main)/error-pages/components/shared.ts deleted file mode 100644 index 511ca26d..00000000 --- a/frontend/app/(main)/error-pages/components/shared.ts +++ /dev/null @@ -1,77 +0,0 @@ -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 const KEY_GET_ONLY = 'origin_error_page_get_only'; - -export type ErrorPageFields = { - enabled: boolean; - getOnly: boolean; - statusCodes: string[]; - html: string; -}; - -export const defaultErrorPageFields: ErrorPageFields = { - enabled: true, - getOnly: false, - statusCodes: [...DEFAULT_ORIGIN_ERROR_PAGE_STATUS_TAGS], - html: '', -}; - -export function optionsToMap(options: Array<{ key: string; value: string }>) { - return options.reduce>((acc, option) => { - acc[option.key] = option.value; - return acc; - }, {}); -} - -export function mapOptionsToFields( - optionMap: Record, -): ErrorPageFields { - const enabledRaw = optionMap[KEY_ENABLED]; - const getOnlyRaw = optionMap[KEY_GET_ONLY]; - return { - enabled: enabledRaw === undefined ? true : enabledRaw === 'true', - getOnly: getOnlyRaw === undefined ? false : getOnlyRaw === '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; -}) { - await Promise.all([ - queryClient.invalidateQueries({ queryKey: OPTIONS_QUERY_KEY }), - queryClient.invalidateQueries({ - queryKey: ['openflare', 'config-preview'], - }), - queryClient.invalidateQueries({ - queryKey: ['openflare', 'config-versions'], - }), - ]); -} diff --git a/frontend/app/(main)/error-pages/edit/page.tsx b/frontend/app/(main)/error-pages/edit/page.tsx deleted file mode 100644 index f8084e5b..00000000 --- a/frontend/app/(main)/error-pages/edit/page.tsx +++ /dev/null @@ -1,204 +0,0 @@ -'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 { HtmlEditorWorkspace } from '@/components/common/html-editor-workspace'; -import { ErrorPageGate } from '../components/error-page-gate'; -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 ( - -
-
-
- -
- -
-

编辑

-
-
-
-
- - -
- - 内置模板 - - - -
- - - - } - /> -
- - - - - 恢复默认 HTML? - - 将清空编辑器内容,保存后使用服务端内置默认模板。此操作不会自动保存。 - - - - 取消 - - 确认恢复 - - - - -
- ); -} diff --git a/frontend/app/(main)/error-pages/page.tsx b/frontend/app/(main)/error-pages/page.tsx deleted file mode 100644 index 6612d45a..00000000 --- a/frontend/app/(main)/error-pages/page.tsx +++ /dev/null @@ -1,236 +0,0 @@ -'use client'; - -import Link from 'next/link'; -import { useEffect, useMemo, useState } from 'react'; -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { Expand, FileWarning, Loader2, Pencil, Save } from 'lucide-react'; -import { toast } from 'sonner'; - -import { useAuth } from '@/components/providers/auth-provider'; -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 { previewOriginErrorPageHTML } from '@/lib/openflare/default-origin-error-page-html'; -import { - validateStatusCodeTagMessage, - validateStatusCodeTags, -} from '@/lib/openflare/status-code-tags'; -import { OptionService } from '@/lib/services/openflare'; - -import { ErrorPageGate } from './components/error-page-gate'; -import { - defaultErrorPageFields, - invalidateErrorPageQueries, - KEY_ENABLED, - KEY_GET_ONLY, - KEY_STATUS_CODES, - mapOptionsToFields, - OPTIONS_QUERY_KEY, - optionsToMap, - type ErrorPageFields, -} from './components/shared'; - -export default function ErrorPagesPage() { - const { user } = useAuth(); - const queryClient = useQueryClient(); - const [fields, setFields] = useState(defaultErrorPageFields); - const [tagError, setTagError] = useState(null); - - const optionsQuery = useQuery({ - queryKey: OPTIONS_QUERY_KEY, - 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], - ); - - /** 仅保存策略项(HTML 在编辑页单独保存) */ - const savePolicyMutation = useMutation({ - mutationFn: async () => { - validateStatusCodeTags(fields.statusCodes); - await OptionService.updateBatch([ - { key: KEY_ENABLED, value: String(fields.enabled) }, - { key: KEY_GET_ONLY, value: String(fields.getOnly) }, - { - key: KEY_STATUS_CODES, - value: JSON.stringify(fields.statusCodes), - }, - ]); - }, - onSuccess: async () => { - toast.success('触发策略已保存,请前往版本发布使配置生效'); - await invalidateErrorPageQueries(queryClient); - }, - onError: (error) => { - toast.error(error instanceof Error ? error.message : '保存失败'); - }, - }); - - const handleValidateTag = (tag: string) => { - const message = validateStatusCodeTagMessage(tag); - if (message) { - setTagError(message); - toast.error(message); - return message; - } - setTagError(null); - return null; - }; - - return ( - -
-
- -
-

错误页

-

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

-
-
- - - -
- 触发策略 - - 配置源站错误页的启用条件与触发状态码。修改后需点击保存。 - -
- -
- -
-
- -

- 关闭后会透传源站或 Nginx 默认错误响应。 -

-
- - setFields((prev) => ({ ...prev, enabled })) - } - aria-label='启用源站错误页' - className='mt-0.5 shrink-0' - /> -
- -
-
- -

- 开启后仅对 GET 请求的匹配错误状态码返回自定义错误页;POST/PUT - 等其它方法直接透传源站响应。 -

-
- - setFields((prev) => ({ ...prev, getOnly })) - } - aria-label='仅针对 GET 请求' - className='mt-0.5 shrink-0' - /> -
- -
-
- -

- 支持单码(如 502)或闭区间(如 500-599),范围 400–599。默认 - 500-599。 -

-
- { - setFields((prev) => ({ ...prev, statusCodes })); - setTagError(null); - }} - validateTag={handleValidateTag} - placeholder='例如 502 或 500-599,回车添加' - aria-invalid={!!tagError} - disabled={!fields.enabled} - /> - {tagError ? ( -

{tagError}

- ) : null} -
-
-
- - - -
- 页面预览 -
-
- - -
-
- -
-