Files
OpenFlare/docs/superpowers/plans/2026-08-08-sw-offline-scope.md
T

42 KiB
Raw Blame History

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-migration skill。
  • 代码/配置变更写入 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";validator validateSWOfflineDomains(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 扩展

  • 移除 Textarea import(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 包裹)。
    • 生效范围区块骨架:
<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 前字节一致。