42 KiB
SW 离线兜底生效范围 Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: 将 SW 离线兜底从「全局所有 HTTPS 站点」细化为「总开关 + 域名作用域」:管理员在联系页选择生效域名,仅作用域内域名注入 SW。
Architecture: 新增全局 Option sw_offline_domains(JSON 域名数组),经 config_version snapshot 进入 ConfigSnapshot;渲染层每 route 计算 routeSWEnabled(routeDomains, cfg) 交集,命中才在 HTTPS server 块注入 SW。前端联系页 tab 增加「生效范围」卡片与域名选择弹窗(复用 member-add-dialog 交互、数据源走现有 zones 接口),HTML 编辑器复用泛化后的 HtmlEditorWorkspace。
Tech Stack: Go 1.25+、Gin、GORM、goose(PG/SQLite)、Next.js App Router、TypeScript、shadcn/ui、TanStack Query、CodeMirror。
Global Constraints
- 语义「总开关 && 域名 ∈ 作用域」:总开关关 → 全部不注入;总开关开 + 作用域空 → 不注入;总开关开 + 域名命中 → 注入。
- 与 Cloudflare 指向分组(A 记录同步)完全无关——仅复用「添加域名成员」弹窗的交互模式(搜索/批量勾选/按 Zone 分组)。
- 联系页 HTML(
sw_offline_html)仍全局单份,不分域名定制。 - 作用域域名精确匹配,不跨子域通配。
- 遵循 AGENTS.md 分层:
apps → repository → model;API 错误用response.Abort*。 - API 变更后
make swagger;开发完成make code-check;提交前make format。 - 迁移:PostgreSQL 与 SQLite 各一份 goose SQL(
goose/postgres/、goose/sqlite/),见database-migrationskill。 - 代码/配置变更写入
docs/changelog/index.md的[Unreleased](中文,用户可读)。 - 配置 key 命名:小写 snake_case。
- 前端:根容器
w-full,外层py-6 px-1;标题行flex items-center gap-2;shadcn 用variant+ CSS 变量,业务className不硬编码颜色。 - 测试临时目录只用
t.TempDir()。
Task 1: 后端配置 key + validator + goose 迁移
Files:
- Modify:
internal/model/system_configs.go:120-122(SW 常量块后追加) - Modify:
internal/apps/openflare/option/openresty_validators.go:17(max 常量)、:65-66(map 注册)、:71-81(validateOpenRestyOption)、:253-255(追加函数) - Modify:
internal/apps/openflare/option/openresty_validators_test.go(追加测试) - Create:
internal/infra/persistence/migrator/goose/postgres/202608080002_add_sw_offline_domains.sql - Create:
internal/infra/persistence/migrator/goose/sqlite/202608080002_add_sw_offline_domains.sql - Modify:
internal/infra/persistence/migrator/migrator_test.go:22-26(计数 92 → 93)
Interfaces:
-
Produces: 常量
model.ConfigKeySWOfflineDomains = "sw_offline_domains";validatorvalidateSWOfflineDomains(key, value string) error;DB seed 行。 -
Step 1: 追加 key 常量
在 system_configs.go 的 SW 常量块(ConfigKeySWOfflineHTML 后)追加:
ConfigKeySWOfflineDomains = "sw_offline_domains" // 离线兜底生效域名列表(JSON 数组,空则仅总开关无效)
- Step 2: 追加 validator 与注册
在 openresty_validators.go 顶部常量块追加:
const (
maxOriginErrorPageHTMLBytes = 256 << 10 // 256 KiB
maxSWOfflineDomains = 1000
)
(把现有单行 const maxOriginErrorPageHTMLBytes = 256 << 10 改为上方块。)
在 openRestyOptionValidators map(:65-66)追加:
model.ConfigKeySWOfflineDomains: validateSWOfflineDomains,
文件末尾追加实现(JSON 数组、去重、小写规范化、格式校验):
func validateSWOfflineDomains(key, value string) error {
var domains []string
if err := json.Unmarshal([]byte(value), &domains); err != nil {
return fmt.Errorf("%s 必须为 JSON 字符串数组", key)
}
if len(domains) > maxSWOfflineDomains {
return fmt.Errorf("%s 最多支持 %d 个域名", key, maxSWOfflineDomains)
}
seen := make(map[string]struct{}, len(domains))
for _, raw := range domains {
domain := strings.ToLower(strings.TrimSpace(raw))
if domain == "" {
return fmt.Errorf("%s 包含空域名", key)
}
if _, ok := seen[domain]; ok {
return fmt.Errorf("%s 包含重复域名 %s", key, domain)
}
seen[domain] = struct{}{}
}
return nil
}
(域名格式已由 zone 侧 normalizeDomain 保证;此处只做 JSON 结构、去重、空值与上限校验。若需更强格式校验,复用 zone.normalizeDomain 语义需导出,当前保持最小校验并在计划 Self-Review 记录。)
- Step 3: 写 validator 测试
在 openresty_validators_test.go 追加:
func TestValidateSWOfflineDomains(t *testing.T) {
cases := []struct {
name string
value string
ok bool
}{
{"empty array", `[]`, true},
{"single", `["example.com"]`, true},
{"multiple", `["example.com","api.example.com"]`, true},
{"invalid json", `not-json`, false},
{"empty element", `[""]`, false},
{"duplicate", `["example.com","example.com"]`, false},
{"whitespace dedup", `[" Example.com ","example.com"]`, false},
{"over limit", fmt.Sprintf(`[%s]`, strings.Repeat(`"a.com",`, maxSWOfflineDomains)+`"a.com"`), false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := validateSWOfflineDomains("sw_offline_domains", tc.value)
if tc.ok && err != nil {
t.Fatalf("want ok, got %v", err)
}
if !tc.ok && err == nil {
t.Fatal("want error, got nil")
}
})
}
}
确认测试文件已有 fmt/strings import(若无则补)。
- Step 4: 运行 option 测试
Run: go test ./internal/apps/openflare/option/...
Expected: PASS
- Step 5: 创建 postgres 迁移
创建 goose/postgres/202608080002_add_sw_offline_domains.sql:
-- +goose Up
INSERT INTO w_system_configs (key, value, type, visibility, description, created_at, updated_at)
VALUES ('sw_offline_domains', '[]', 'business', 0, 'SW 离线兜底生效域名列表(JSON 数组,空则仅总开关无效)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
ON CONFLICT (key) DO NOTHING;
-- +goose Down
DELETE FROM w_system_configs WHERE key = 'sw_offline_domains';
- Step 6: 创建 sqlite 迁移
内容与 postgres 完全相同,复制到 goose/sqlite/202608080002_add_sw_offline_domains.sql。
- Step 7: 更新 migrator 测试计数
migrator_test.go:26 expectedMigratedSystemConfigCount 92 → 93;:22-25 注释追加「SW 离线兜底生效域名的 1 项业务配置」。
- Step 8: 运行迁移测试
Run: go test ./internal/infra/persistence/migrator/...
Expected: PASS
- Step 9: 提交
git add internal/model/system_configs.go internal/apps/openflare/option/openresty_validators.go internal/apps/openflare/option/openresty_validators_test.go internal/infra/persistence/migrator/goose/postgres/202608080002_add_sw_offline_domains.sql internal/infra/persistence/migrator/goose/sqlite/202608080002_add_sw_offline_domains.sql internal/infra/persistence/migrator/migrator_test.go
git commit -m "feat(option): add sw offline domains scope option"
Task 2: ConfigSnapshot 字段 + config_version snapshot 接线
Files:
- Modify:
pkg/render/openresty/types.go(ConfigSnapshot末尾,SWOfflineHTML后) - Modify:
internal/apps/openflare/config_version/snapshot.go:148(struct 字段)、:511-517(helper 追加)、:564后(build 赋值) - Modify:
internal/apps/openflare/config_version/logics.go:541-542(diff 追加)、:610-611(option keys 追加)
Interfaces:
-
Consumes:
model.ConfigKeySWOfflineDomains(Task 1)。 -
Produces:
ConfigSnapshot.SWOfflineDomains []string;openRestyConfigSnapshot.SWOfflineDomains []string;snapshot JSON 内含sw_offline_domains字段,参与 checksum。 -
Step 1: ConfigSnapshot 追加字段
pkg/render/openresty/types.go ConfigSnapshot 末尾(SWOfflineHTML 后)追加:
// SWOfflineDomains restricts the offline fallback to matching HTTPS routes.
SWOfflineDomains []string `json:"sw_offline_domains,omitempty"`
- Step 2: snapshot struct 追加字段
internal/apps/openflare/config_version/snapshot.go openRestyConfigSnapshot(:148,SWOfflineHTML 后)追加:
SWOfflineDomains []string `json:"sw_offline_domains,omitempty"`
- Step 3: build 读取配置
在 buildOpenRestyConfigSnapshot 内 getStringConfig helper 定义后(:517 后)追加:
getStringSliceConfig := func(key string, defaultVal []string) []string {
config, err := repository.GetSystemConfigByKey(ctx, key)
if err != nil {
return defaultVal
}
var values []string
if err := json.Unmarshal([]byte(config.Value), &values); err != nil {
return defaultVal
}
return values
}
确认 snapshot.go 已有 encoding/json import(buildSnapshotWAFDocument 等处应已用)。
在 snapshot 字面量 SWOfflineHTML 赋值后追加:
SWOfflineDomains: getStringSliceConfig(model.ConfigKeySWOfflineDomains, nil),
- Step 4: diff 与 option keys 追加
logics.go diffOpenRestyOptionDetails(:542 后)追加:
appendIfChanged("SWOfflineDomains", strings.Join(left.SWOfflineDomains, ","), strings.Join(right.SWOfflineDomains, ","))
确认 logics.go 已 import strings(未用则补)。
openRestyOptionKeys()(:611 后)追加:
"SWOfflineDomains",
- Step 5: 运行测试
Run: go build ./... && go test ./internal/apps/openflare/config_version/... ./pkg/render/openresty/...
Expected: PASS
- Step 6: 提交
git add pkg/render/openresty/types.go internal/apps/openflare/config_version/snapshot.go internal/apps/openflare/config_version/logics.go
git commit -m "feat(openresty): add sw offline domains snapshot field"
Task 3: 渲染层作用域注入
Files:
- Modify:
pkg/render/openresty/render.go:52-54(support files 条件)、:97-117(RenderRouteConfig 计算 swEnabled)、:278(renderHTTPProxyServer 签名)、:325(renderHTTPPagesServer 签名)、:333-343(renderHTTPSServer 签名与注入)、:345-355(renderHTTPSPagesServer 签名与注入)、:451-504(renderAccessBlock / renderAccessBlockWithSW 去开关判断) - Modify:
pkg/render/openresty/render_route.go:46-104(renderPagesRouteHTTPS / renderProxyRouteHTTPS 签名)、:106-151(renderPagesRoute / renderProxyRoute 签名) - Modify:
pkg/render/openresty/service_worker.go(renderServiceWorkerChallenger去开关判断) - Modify:
pkg/render/openresty/service_worker_test.go(适配 + 新增 routeSWEnabled 测试) - Modify:
pkg/render/openresty/render_test.go或相关(如有 SW 相关断言适配)
Interfaces:
-
Consumes:
ConfigSnapshot.SWOfflineDomains(Task 2)。 -
Produces:
routeSWEnabled(routeDomains []string, cfg ConfigSnapshot) bool;renderHTTPSServer/renderHTTPSPagesServer/renderProxyRouteHTTPS/renderPagesRouteHTTPS/renderProxyRoute/renderPagesRoute均新增swEnabled bool参数(置于cfg之前)。 -
Step 1: 写失败测试(routeSWEnabled)
在 service_worker_test.go 追加:
func TestRouteSWEnabled(t *testing.T) {
cfgOff := ConfigSnapshot{SWOfflineEnabled: false, SWOfflineDomains: []string{"example.com"}}
if routeSWEnabled([]string{"example.com"}, cfgOff) {
t.Fatal("expected false when master switch off")
}
cfgEmpty := ConfigSnapshot{SWOfflineEnabled: true, SWOfflineDomains: nil}
if routeSWEnabled([]string{"example.com"}, cfgEmpty) {
t.Fatal("expected false when scope empty")
}
cfgHit := ConfigSnapshot{SWOfflineEnabled: true, SWOfflineDomains: []string{"example.com", "other.com"}}
if !routeSWEnabled([]string{"api.example.com", "example.com"}, cfgHit) {
t.Fatal("expected true on single domain intersection")
}
if routeSWEnabled([]string{"api.example.com", "third.com"}, cfgHit) {
t.Fatal("expected false on no intersection")
}
}
- Step 2: 运行确认失败
Run: go test ./pkg/render/openresty/ -run TestRouteSWEnabled
Expected: FAIL(routeSWEnabled 未定义)
- Step 3: 实现 routeSWEnabled
在 service_worker.go 追加:
// routeSWEnabled returns true when SW offline fallback applies to this route.
func routeSWEnabled(routeDomains []string, cfg ConfigSnapshot) bool {
if !cfg.SWOfflineEnabled || len(cfg.SWOfflineDomains) == 0 {
return false
}
scope := make(map[string]struct{}, len(cfg.SWOfflineDomains))
for _, d := range cfg.SWOfflineDomains {
scope[d] = struct{}{}
}
for _, d := range routeDomains {
if _, ok := scope[d]; ok {
return true
}
}
return false
}
- Step 4: 更新 renderServiceWorkerChallenger 去开关判断
service_worker.go renderServiceWorkerChallenger 删除开头两行:
if !cfg.SWOfflineEnabled {
return ""
}
(调用方保证仅在 swEnabled 时调用;函数签名与其余逻辑不变。)
- Step 5: 更新 renderAccessBlockWithSW 去开关判断
render.go renderAccessBlockWithSW 删除:
if !cfg.SWOfflineEnabled {
return renderAccessBlock(siteName, powEnabled)
}
- Step 6: RenderRouteConfig 计算 swEnabled 并下传
render.go RenderRouteConfig 循环内(renderPagesRoute/renderProxyRoute 调用处)改为:
swEnabled := routeSWEnabled(domains, doc.OpenRestyConfig)
if normalizeRouteUpstreamType(route.UpstreamType) == routeUpstreamTypePages {
if err := renderPagesRoute(&builder, route, displayName, serverNames, certificates, limitConfig, powEnabled, swEnabled, doc.OpenRestyConfig); err != nil {
return "", err
}
continue
}
if err := renderProxyRoute(&builder, route, displayName, serverNames, certificates, cacheConfig, limitConfig, powEnabled, swEnabled, doc.OpenRestyConfig); err != nil {
return "", err
}
- Step 7: 更新 render_route.go 签名链
renderPagesRoute / renderProxyRoute 增加 swEnabled bool 参数(置于 powEnabled 与 cfg 之间),并将 renderPagesRouteHTTPS(...) / renderProxyRouteHTTPS(...) 调用处传入 swEnabled;两个 HTTPS 函数增加 swEnabled bool 参数并透传到 renderHTTPSPagesServer / renderHTTPSServer。HTTP 分支(renderHTTPPagesServer / renderHTTPProxyServer / renderHTTPRedirectServer)调用保持不变(HTTP 不注入 SW)。
-
Step 8: 更新 render.go 四个 server 函数
-
renderHTTPSServer:新增swEnabled bool参数(cfg前),renderAccessBlockWithSW(siteName, powEnabled, cfg)替换为:
if swEnabled {
return fmt.Sprintf("server {\n listen 443 ssl;\n%s http2 on;\n server_name %s;\n ssl_certificate %s;\n ssl_certificate_key %s;\n%s%s%s location / {\n%s%s%s%s%s%s }\n%s%s%s}\n\n", h3Listen, serverNames, certPath, keyPath, h3Header, renderAccessBlockWithSW(siteName, powEnabled, cfg), renderPowLocationBlocks(powEnabled), renderBasicAuthBlock(basicAuthEnabled, basicAuthUsername, basicAuthPassword), renderProxyHeaderBlock(originURL, originHost, customHeaders, upstreamConfig, cfg), renderRouteLimitBlock(limitConfig), renderRouteCacheBlock(cacheConfig, cfg), renderOriginErrorPageIntercept(cfg), renderProxyPassBlock(originURL, upstreamConfig), renderOriginErrorPageServerBits(cfg), renderPowStaticLocationBlock(powEnabled), renderServiceWorkerChallenger(cfg))
}
return fmt.Sprintf("server {\n listen 443 ssl;\n%s http2 on;\n server_name %s;\n ssl_certificate %s;\n ssl_certificate_key %s;\n%s%s%s location / {\n%s%s%s%s%s%s }\n%s%s}\n\n", h3Listen, serverNames, certPath, keyPath, h3Header, renderAccessBlock(siteName, powEnabled), renderPowLocationBlocks(powEnabled), renderBasicAuthBlock(basicAuthEnabled, basicAuthUsername, basicAuthPassword), renderProxyHeaderBlock(originURL, originHost, customHeaders, upstreamConfig, cfg), renderRouteLimitBlock(limitConfig), renderRouteCacheBlock(cacheConfig, cfg), renderOriginErrorPageIntercept(cfg), renderProxyPassBlock(originURL, upstreamConfig), renderOriginErrorPageServerBits(cfg), renderPowStaticLocationBlock(powEnabled))
(即:swEnabled=true 时 renderAccessBlockWithSW + challenger;false 时 renderAccessBlock 无 challenger。)
-
renderHTTPSPagesServer:同样处理(pages 模板,challenger 追加位置在renderPowStaticLocationBlock(powEnabled)之后)。 -
renderHTTPProxyServer/renderHTTPPagesServer:新增swEnabled bool参数(cfg前),内部不使用该参数(HTTP 不注入)——用_ bool命名避免 lint 报错(参考现有renderHTTPPagesServer的_ ConfigSnapshot模式)。 -
Step 9: Render support files 条件
render.go:52-54 改为:
if doc.OpenRestyConfig.SWOfflineEnabled && len(doc.OpenRestyConfig.SWOfflineDomains) > 0 {
files = append(files, ServiceWorkerSupportFiles(doc.OpenRestyConfig)...)
}
- Step 10: 更新测试适配新签名
在 service_worker_test.go:
-
TestRenderAccessBlockWithSWMergesSingleBlock(:95、:117)改为直接测renderAccessBlockWithSW("example.com", powEnabled, ConfigSnapshot{})不再依赖内部开关——renderAccessBlockWithSW现在无条件走合并逻辑,禁用语义由上层swEnabled控制。将两个 case 改为断言合并块内容(pow 分支含 waf/pow/sw 顺序;非 pow 分支含 waf/sw)。 -
TestRenderServiceWorkerChallenger(:159-162):删除「disabled 返回空串」断言(函数已无条件输出 location),改为仅断言启用输入输出含三个 location 与content_by_lua。 -
新增「renderHTTPSServer 作用域命中/未命中」测试:构造 cfg(
SWOfflineEnabled: true+ domains 含/不含 route 域名),断言命中输出含require("sw.runtime").check()与location = /sw.js,未命中输出不含。 -
检查
render_test.go中直接调用renderHTTPSServer/renderHTTPSPagesServer/renderPagesRoute/renderProxyRoute的测试,逐一补swEnabled参数(未命中场景传false,保持字节不变断言)。 -
Step 11: 运行测试
Run: go test ./pkg/render/openresty/...
Expected: PASS(含全部适配与新增)
- Step 12: 提交
git add pkg/render/openresty/render.go pkg/render/openresty/render_route.go pkg/render/openresty/service_worker.go pkg/render/openresty/service_worker_test.go pkg/render/openresty/render_test.go
git commit -m "feat(openresty): scope sw offline injection by route domains"
Task 4: HtmlEditorWorkspace 泛化与复用
Files:
- Create:
frontend/components/common/html-editor-workspace.tsx(从 error-pages 复制并泛化) - Delete:
frontend/app/(main)/error-pages/components/html-editor-workspace.tsx - Modify:
frontend/app/(main)/error-pages/edit/page.tsx:43(import 路径)、:122(调用处适配可选 props)
Interfaces:
-
Consumes:
ORIGIN_ERROR_PAGE_HTML_MAX_BYTES、previewOriginErrorPageHTML(@/lib/openflare/default-origin-error-page-html,已存在)。 -
Produces:
HtmlEditorWorkspace(props:value,onChange,toolbarRight?,maxBytes?默认ORIGIN_ERROR_PAGE_HTML_MAX_BYTES,preview?默认previewOriginErrorPageHTML,footerHint?默认错误页提示)。 -
Step 1: 创建 common 版本
将 error-pages/components/html-editor-workspace.tsx 全文复制到 frontend/components/common/html-editor-workspace.tsx,然后按以下 5 处改造(其余代码保持原样):
改造 1 — props 类型与解构签名替换为:
type HtmlEditorWorkspaceProps = {
value: string;
onChange: (value: string) => void;
toolbarRight?: React.ReactNode;
maxBytes?: number;
preview?: (html: string) => string;
footerHint?: React.ReactNode;
};
export function HtmlEditorWorkspace({
value,
onChange,
toolbarRight,
maxBytes = ORIGIN_ERROR_PAGE_HTML_MAX_BYTES,
preview = previewOriginErrorPageHTML,
footerHint = (
<>
{'{{status}}'}→502 · {'{{host}}'}→example.com
</>
),
}: HtmlEditorWorkspaceProps) {
改造 2 — 原 const previewSrcDoc = previewOriginErrorPageHTML(value); 替换为:
const previewSrcDoc = preview(value);
改造 3 — 原 const htmlBytes = new TextEncoder().encode(value).length; 保持不变,但顶部字节统计处 {ORIGIN_ERROR_PAGE_HTML_MAX_BYTES} 替换为 {maxBytes}。
改造 4 — 预览 footer 中「{{status}}→502 · {{host}}→example.com」的 span 替换为 {footerHint}(footerHint 为 null 时用 {footerHint ?? null} 渲染空,且外层 span 在 footerHint === null 时加 hidden class——保持布局稳定)。
改造 5 — 组件注释改为:/** 通用 HTML 编辑器工作区:左代码 / 右实时预览横向布局,可拖拽分隔。 */
- Step 2: 更新错误页 import
edit/page.tsx:
import { HtmlEditorWorkspace } from '@/components/common/html-editor-workspace';
删除旧相对 import;调用处不变(可选 props 有默认值)。
- Step 3: 删除旧组件
rm frontend/app/(main)/error-pages/components/html-editor-workspace.tsx
确认 error-pages/components/ 下无其他文件引用它(grep html-editor-workspace)。
- Step 4: 联系页接入
本任务只负责泛化与错误页替换;联系页接入在 Task 5 Step 3 完成(见下)。此步无需改动 contact-page-tab.tsx。
- Step 5: 类型检查
Run: cd /Users/ryan/conductor/workspaces/OpenFlare/islamabad/frontend && npx tsc --noEmit
Expected: PASS
- Step 6: 提交
git add "frontend/components/common/html-editor-workspace.tsx" "frontend/app/(main)/error-pages/edit/page.tsx" "frontend/app/(main)/error-pages/components/html-editor-workspace.tsx"
git commit -m "refactor(frontend): generalize html editor workspace for reuse"
Task 5: 前端 shared + 生效范围卡片 + 域名选择弹窗
Files:
- Modify:
frontend/app/(main)/responses/components/shared.ts - Modify:
frontend/app/(main)/responses/components/contact-page-tab.tsx - Create:
frontend/app/(main)/responses/components/scope-domain-dialog.tsx
Interfaces:
-
Consumes:
OptionService.list()/updateBatch()(已存在)、ZoneService.list()/ZoneService.getOverview(id)(已存在)、zoneQueryKey(已存在)。 -
Produces:
KEY_SW_DOMAINS;ContactPageFields.domains: string[];ScopeDomainDialog组件(props:open,onOpenChange,zones: ScopeZoneGroup[],selected: string[],pending,onSubmit(domains: string[]);ScopeZoneGroup = { zoneDomain: string; domains: string[] })。 -
Step 1: shared.ts 扩展
export const KEY_SW_DOMAINS = 'sw_offline_domains';
export type ContactPageFields = {
enabled: boolean;
html: string;
domains: string[];
};
export const defaultContactPageFields: ContactPageFields = {
enabled: false,
html: '',
domains: [],
};
function parseDomains(raw: string | undefined): string[] {
if (!raw) return [];
try {
const parsed: unknown = JSON.parse(raw);
return Array.isArray(parsed)
? parsed.filter((item): item is string => typeof item === 'string')
: [];
} catch {
return [];
}
}
mapOptionsToContactFields 返回值追加 domains: parseDomains(optionMap[KEY_SW_DOMAINS])。
- Step 2: 域名选择弹窗 scope-domain-dialog.tsx
参考 frontend/app/(main)/cloudflare/components/member-add-dialog.tsx 的交互(搜索、按 Zone 分组折叠、组内勾选、全选可见/清空、已选计数、空态),但:
- 无橙云 Switch、无 Cloudflare 依赖。
- 数据源为
{ zoneDomain: string; domains: { domain: string }[] }分组(zone 根域并入)。 - 预勾选:打开时以当前已生效域名初始化
selected(Set<string>)。 - 确认回调
onSubmit([...selected])(域名字符串数组)。
组件骨架(沿用 member-add-dialog 的 Dialog 结构,sw 前缀类名无冲突):
'use client';
import { useEffect, useMemo, useState } from 'react';
import { Check, ChevronDown, Search } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Field, FieldGroup, FieldLabel } from '@/components/ui/field';
import { Input } from '@/components/ui/input';
import { cn } from '@/lib/utils';
type ScopeZoneGroup = {
zoneDomain: string;
domains: string[];
};
export function ScopeDomainDialog({
open,
onOpenChange,
zones,
selected,
pending,
onSubmit,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
zones: ScopeZoneGroup[];
selected: string[];
pending: boolean;
onSubmit: (domains: string[]) => void;
}) {
const [keyword, setKeyword] = useState('');
const [selectedSet, setSelectedSet] = useState<Set<string>>(() => new Set());
const [collapsedZones, setCollapsedZones] = useState<Set<string>>(
() => new Set(),
);
useEffect(() => {
if (!open) return;
setKeyword('');
setSelectedSet(new Set(selected));
setCollapsedZones(new Set());
}, [open, selected]);
const filtered = useMemo(() => {
const normalized = keyword.trim().toLowerCase();
if (!normalized) return zones;
return zones
.map((group) => ({
zoneDomain: group.zoneDomain,
domains: group.domains.filter(
(d) =>
d.toLowerCase().includes(normalized) ||
group.zoneDomain.toLowerCase().includes(normalized),
),
}))
.filter((group) => group.domains.length > 0);
}, [zones, keyword]);
const visibleDomains = useMemo(
() => filtered.flatMap((group) => group.domains),
[filtered],
);
const allVisibleSelected =
visibleDomains.length > 0 &&
visibleDomains.every((d) => selectedSet.has(d));
const someVisibleSelected =
visibleDomains.some((d) => selectedSet.has(d)) && !allVisibleSelected;
const toggleOne = (domain: string) => {
setSelectedSet((prev) => {
const next = new Set(prev);
if (next.has(domain)) next.delete(domain);
else next.add(domain);
return next;
});
};
const toggleGroup = (domains: string[]) => {
setSelectedSet((prev) => {
const next = new Set(prev);
const allSelected = domains.every((d) => next.has(d));
for (const d of domains) {
if (allSelected) next.delete(d);
else next.add(d);
}
return next;
});
};
const toggleAllVisible = () => {
setSelectedSet((prev) => {
const next = new Set(prev);
if (allVisibleSelected) {
for (const d of visibleDomains) next.delete(d);
} else {
for (const d of visibleDomains) next.add(d);
}
return next;
});
};
const toggleCollapsed = (zoneDomain: string) => {
setCollapsedZones((prev) => {
const next = new Set(prev);
if (next.has(zoneDomain)) next.delete(zoneDomain);
else next.add(zoneDomain);
return next;
});
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className='sm:max-w-lg'>
<DialogHeader>
<DialogTitle>选择生效域名</DialogTitle>
<DialogDescription>
仅对选中的 HTTPS 域名注入离线兜底;搜索筛选与批量勾选。
</DialogDescription>
</DialogHeader>
<FieldGroup>
<Field>
<FieldLabel htmlFor='sw-scope-search'>域名</FieldLabel>
<div className='space-y-2'>
<div className='relative'>
<Search className='pointer-events-none absolute top-1/2 left-2.5 size-4 -translate-y-1/2 text-muted-foreground' />
<Input
id='sw-scope-search'
value={keyword}
onChange={(event) => setKeyword(event.target.value)}
placeholder='搜索域名或顶级域…'
className='pl-8'
disabled={pending}
/>
</div>
<div className='flex flex-wrap items-center justify-between gap-2'>
<div className='flex items-center gap-2 text-xs text-muted-foreground'>
<Badge variant='secondary' className='font-normal'>
已选 {selectedSet.size}
</Badge>
<span>可见 {visibleDomains.length}</span>
</div>
<div className='flex items-center gap-1'>
<Button
type='button'
variant='ghost'
size='sm'
className='h-7 text-xs'
disabled={pending || visibleDomains.length === 0}
onClick={toggleAllVisible}
>
{allVisibleSelected ? '取消全选' : '全选可见'}
</Button>
<Button
type='button'
variant='ghost'
size='sm'
className='h-7 text-xs'
disabled={pending || selectedSet.size === 0}
onClick={() => setSelectedSet(new Set())}
>
清空
</Button>
</div>
</div>
{zones.length === 0 ? (
<div className='rounded-lg border border-dashed px-4 py-8 text-center text-sm text-muted-foreground'>
暂无可用域名,请先在 Zone 管理中注册域名。
</div>
) : filtered.length === 0 ? (
<div className='rounded-lg border border-dashed px-4 py-8 text-center text-sm text-muted-foreground'>
没有匹配的域名
</div>
) : (
<div className='max-h-72 space-y-1 overflow-y-auto rounded-lg border p-2'>
{filtered.map((group) => {
const groupSelected = group.domains.filter((d) =>
selectedSet.has(d),
);
const allSelected = groupSelected.length === group.domains.length;
const someSelected = groupSelected.length > 0 && !allSelected;
const open = !collapsedZones.has(group.zoneDomain);
return (
<Collapsible
key={group.zoneDomain}
open={open}
onOpenChange={() => toggleCollapsed(group.zoneDomain)}
>
<div
className={cn(
'rounded-md',
(allSelected || someSelected) && 'bg-muted/30',
)}
>
<div className='flex items-center gap-1 px-1 py-0.5'>
<Checkbox
checked={allSelected ? true : someSelected ? 'indeterminate' : false}
disabled={pending}
onCheckedChange={() => toggleGroup(group.domains)}
aria-label={`选择顶级域 ${group.zoneDomain}`}
className='ml-1'
/>
<CollapsibleTrigger asChild>
<button
type='button'
className='flex min-w-0 flex-1 items-center gap-2 rounded-md px-1.5 py-1.5 text-left text-sm font-medium hover:bg-muted/60'
>
<ChevronDown
className={cn(
'size-4 shrink-0 text-muted-foreground transition-transform',
!open && '-rotate-90',
)}
/>
<span className='truncate'>{group.zoneDomain}</span>
<Badge
variant='outline'
className='ml-auto shrink-0 font-normal text-[10px]'
>
{groupSelected.length}/{group.domains.length}
</Badge>
</button>
</CollapsibleTrigger>
</div>
<CollapsibleContent>
<div className='ml-4 space-y-0.5 border-l border-border/70 py-0.5 pl-2'>
{group.domains.map((domain) => {
const checked = selectedSet.has(domain);
const isApex = domain === group.zoneDomain;
return (
<label
key={domain}
className={cn(
'flex cursor-pointer items-center gap-3 rounded-md px-2 py-1.5 transition-colors hover:bg-muted/60',
checked && 'bg-muted/40',
)}
>
<Checkbox
checked={checked}
disabled={pending}
onCheckedChange={() => toggleOne(domain)}
/>
<span className='min-w-0 flex-1 truncate text-sm'>
{domain}
</span>
{isApex ? (
<Badge
variant='secondary'
className='shrink-0 text-[10px] font-normal'
>
顶级域
</Badge>
) : null}
{checked ? (
<Check className='size-3.5 shrink-0 text-primary' />
) : null}
</label>
);
})}
</div>
</CollapsibleContent>
</div>
</Collapsible>
);
})}
</div>
)}
</div>
</Field>
</FieldGroup>
<DialogFooter>
<Button
variant='outline'
disabled={pending}
onClick={() => onOpenChange(false)}
>
取消
</Button>
<Button
disabled={pending}
onClick={() => onSubmit([...selectedSet].sort())}
>
确定
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
-
Step 3: contact-page-tab.tsx 扩展
-
移除
Textareaimport(HTML 编辑器移到卡片 2)。 -
引入:
import { X, Plus } from 'lucide-react';
import { useQuery } from '@tanstack/react-query';
import { Badge } from '@/components/ui/badge';
import { ZoneService, zoneQueryKey } from '@/lib/services/openflare';
import { HtmlEditorWorkspace } from '@/components/common/html-editor-workspace';
import { ScopeDomainDialog } from './scope-domain-dialog';
import { KEY_SW_DOMAINS } from './shared';
- 状态:
const [scopeOpen, setScopeOpen] = useState(false);。 - zones 查询(admin 已由父页 gate):
const zonesQuery = useQuery({
queryKey: zoneQueryKey,
queryFn: async () => {
const zones = await ZoneService.list();
const overviews = await Promise.all(
zones.map((zone) => ZoneService.getOverview(zone.id)),
);
return overviews.map((ov) => ({
zoneDomain: ov.zone.domain,
domains: [ov.zone.domain, ...ov.domains.map((d) => d.domain)],
}));
},
});
- saveMutation 的 updateBatch 追加第三个 key:
{ key: KEY_SW_DOMAINS, value: JSON.stringify(fields.domains) },
- 布局改为两张卡片:
- 卡片 1「离线兜底」:Switch + 生效范围区块(badge 列表 + 移除按钮 + 「添加域名」按钮),
disabled={!fields.enabled}时置灰;HTML Textarea 部分删除。 - 卡片 2「联系页 HTML」:
<HtmlEditorWorkspace value={fields.html} onChange={(v) => setFields((prev) => ({ ...prev, html: v }))} preview={(html) => html} footerHint={null} />,disabled={!fields.enabled}时置灰(外层pointer-events-none opacity-60包裹)。 - 生效范围区块骨架:
- 卡片 1「离线兜底」:Switch + 生效范围区块(badge 列表 + 移除按钮 + 「添加域名」按钮),
<div className='space-y-2'>
<div className='flex items-center justify-between gap-3'>
<div>
<Label className='text-sm font-medium'>生效范围</Label>
<p className='text-sm text-muted-foreground'>
仅对选中的 HTTPS 域名生效;留空则不注入。
</p>
</div>
<Button
type='button'
size='sm'
variant='outline'
disabled={!fields.enabled || pending}
onClick={() => setScopeOpen(true)}
>
<Plus className='size-3.5' />
添加域名
</Button>
</div>
{fields.domains.length === 0 ? (
<p className='text-sm text-muted-foreground'>
{fields.enabled ? '尚未选择生效域名,保存后不注入任何站点。' : '启用离线兜底后可选择生效域名。'}
</p>
) : (
<div className='flex flex-wrap gap-2'>
{fields.domains.map((domain) => (
<Badge key={domain} variant='secondary' className='gap-1 font-normal'>
{domain}
<button
type='button'
className='hover:text-destructive'
disabled={!fields.enabled}
aria-label={`移除 ${domain}`}
onClick={() =>
setFields((prev) => ({
...prev,
domains: prev.domains.filter((d) => d !== domain),
}))
}
>
<X className='size-3' />
</button>
</Badge>
))}
</div>
)}
</div>
-
卡片 1 的 CardContent 中 HTML 部分移除后,将新生效范围区块放在 Switch 下方。
-
渲染
<ScopeDomainDialog open={scopeOpen} onOpenChange={setScopeOpen} zones={zonesQuery.data ?? []} selected={fields.domains} pending={saveMutation.isPending} onSubmit={(domains) => { setFields((prev) => ({ ...prev, domains })); setScopeOpen(false); }} />。 -
Step 4: 类型检查
Run: cd /Users/ryan/conductor/workspaces/OpenFlare/islamabad/frontend && npx tsc --noEmit
Expected: PASS(HtmlEditorWorkspace 由 Task 4 提供,按序执行即可)
- Step 5: 提交
git add "frontend/app/(main)/responses/components/shared.ts" "frontend/app/(main)/responses/components/contact-page-tab.tsx" "frontend/app/(main)/responses/components/scope-domain-dialog.tsx"
git commit -m "feat(frontend): add sw scope domain picker and contact page fields"
Task 6: Changelog 与收尾验证
Files:
-
Modify:
docs/changelog/index.md -
Step 1: 更新 changelog
docs/changelog/index.md [Unreleased] 下找到 SW 离线兜底条目,更新为包含作用域能力(保持单条、中文、用户可读、说明效果):
- 支持 Service Worker 离线兜底:为启用 HTTPS 的网站下发 Service Worker 并缓存离线联系页,域名无法访问时浏览器展示联系站长页面,减少用户流失。可指定生效域名范围(仅对选中的 HTTPS 域名生效),配置位于「响应页面」-「联系页」,可在版本发布中批量生效。
- Step 2: 全量验证
Run: cd /Users/ryan/conductor/workspaces/OpenFlare/islamabad && make swagger && make code-check && go test ./... && make format
Expected: 全 PASS(go test 若有已知 flaky 测试如 TestStopCancelsRunningProcesses,重跑确认非本分支引入)
- Step 3: 提交
git add docs/changelog/index.md
git commit -m "docs: sw offline scope changelog"
Self-Review
Spec 覆盖检查:
- 数据层 key/迁移/validator/snapshot → Task 1、2 ✓
- 渲染层 routeSWEnabled + 签名链 + support files 条件 → Task 3 ✓
- 前端生效范围卡片 + 域名弹窗 + shared/保存 → Task 5 ✓
- HtmlEditorWorkspace 泛化复用 → Task 4 ✓
- Changelog + 全量验证 → Task 6 ✓
占位符扫描: 无 TBD/TODO;所有代码块完整可抄。
类型一致性:
routeSWEnabled([]string, ConfigSnapshot) bool在 Task 3 定义与使用一致。swEnabled bool参数在renderProxyRoute/renderPagesRoute/renderProxyRouteHTTPS/renderPagesRouteHTTPS/renderHTTPSServer/renderHTTPSPagesServer/renderHTTPProxyServer/renderHTTPPagesServer全部一致(cfg前)。KEY_SW_DOMAINS/ContactPageFields.domains在 Task 5 一致。HtmlEditorWorkspace新 props(maxBytes/preview/footerHint)在 Task 4 定义与 Task 5 使用一致。
已知边界(记录在案):
- Task 1 validator 仅做 JSON 结构/去重/空值/上限校验,未做域名格式校验(zone 侧已保证来源合法;作用域 value 可能被手改 API 写入,格式校验留作后续增强——如需强化可复用 zone 包逻辑,但会引入跨包依赖,当前保持最小校验)。
- Task 3 Step 8 中
renderHTTPSServer的双分支实现为「if swEnabled 返回 A 否则返回 B」;两分支 fmt 模板需保持完全一致(除 access/challenger 部分),测试断言未命中输出与 feature 前字节一致。