# 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` 后)追加: ```go ConfigKeySWOfflineDomains = "sw_offline_domains" // 离线兜底生效域名列表(JSON 数组,空则仅总开关无效) ``` - [ ] **Step 2: 追加 validator 与注册** 在 `openresty_validators.go` 顶部常量块追加: ```go const ( maxOriginErrorPageHTMLBytes = 256 << 10 // 256 KiB maxSWOfflineDomains = 1000 ) ``` (把现有单行 `const maxOriginErrorPageHTMLBytes = 256 << 10` 改为上方块。) 在 `openRestyOptionValidators` map(`:65-66`)追加: ```go model.ConfigKeySWOfflineDomains: validateSWOfflineDomains, ``` 文件末尾追加实现(JSON 数组、去重、小写规范化、格式校验): ```go 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` 追加: ```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`: ```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: 提交** ```bash 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` 后)追加: ```go // 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` 后)追加: ```go SWOfflineDomains []string `json:"sw_offline_domains,omitempty"` ``` - [ ] **Step 3: build 读取配置** 在 `buildOpenRestyConfigSnapshot` 内 `getStringConfig` helper 定义后(`:517` 后)追加: ```go 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` 赋值后追加: ```go SWOfflineDomains: getStringSliceConfig(model.ConfigKeySWOfflineDomains, nil), ``` - [ ] **Step 4: diff 与 option keys 追加** `logics.go` `diffOpenRestyOptionDetails`(`:542` 后)追加: ```go appendIfChanged("SWOfflineDomains", strings.Join(left.SWOfflineDomains, ","), strings.Join(right.SWOfflineDomains, ",")) ``` 确认 logics.go 已 import `strings`(未用则补)。 `openRestyOptionKeys()`(`:611` 后)追加: ```go "SWOfflineDomains", ``` - [ ] **Step 5: 运行测试** Run: `go build ./... && go test ./internal/apps/openflare/config_version/... ./pkg/render/openresty/...` Expected: PASS - [ ] **Step 6: 提交** ```bash 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` 追加: ```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` 追加: ```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` 删除开头两行: ```go if !cfg.SWOfflineEnabled { return "" } ``` (调用方保证仅在 swEnabled 时调用;函数签名与其余逻辑不变。) - [ ] **Step 5: 更新 renderAccessBlockWithSW 去开关判断** `render.go` `renderAccessBlockWithSW` 删除: ```go if !cfg.SWOfflineEnabled { return renderAccessBlock(siteName, powEnabled) } ``` - [ ] **Step 6: RenderRouteConfig 计算 swEnabled 并下传** `render.go` `RenderRouteConfig` 循环内(`renderPagesRoute`/`renderProxyRoute` 调用处)改为: ```go 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)` 替换为: ```go 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` 改为: ```go 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: 提交** ```bash 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 类型与解构签名替换为: ```tsx 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);` 替换为: ```tsx 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`: ```ts 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: 提交** ```bash 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 扩展** ```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`)。 - 确认回调 `onSubmit([...selected])`(域名字符串数组)。 组件骨架(沿用 member-add-dialog 的 Dialog 结构,`sw` 前缀类名无冲突): ```tsx '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>(() => new Set()); const [collapsedZones, setCollapsedZones] = useState>( () => 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 ( 选择生效域名 仅对选中的 HTTPS 域名注入离线兜底;搜索筛选与批量勾选。 域名
setKeyword(event.target.value)} placeholder='搜索域名或顶级域…' className='pl-8' disabled={pending} />
已选 {selectedSet.size} 可见 {visibleDomains.length}
{zones.length === 0 ? (
暂无可用域名,请先在 Zone 管理中注册域名。
) : filtered.length === 0 ? (
没有匹配的域名
) : (
{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 ( toggleCollapsed(group.zoneDomain)} >
toggleGroup(group.domains)} aria-label={`选择顶级域 ${group.zoneDomain}`} className='ml-1' />
{group.domains.map((domain) => { const checked = selectedSet.has(domain); const isApex = domain === group.zoneDomain; return ( ); })}
); })}
)}
); } ``` - [ ] **Step 3: contact-page-tab.tsx 扩展** - 移除 `Textarea` import(HTML 编辑器移到卡片 2)。 - 引入: ```tsx 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): ```tsx 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: ```tsx { key: KEY_SW_DOMAINS, value: JSON.stringify(fields.domains) }, ``` - 布局改为两张卡片: - 卡片 1「离线兜底」:Switch + 生效范围区块(badge 列表 + 移除按钮 + 「添加域名」按钮),`disabled={!fields.enabled}` 时置灰;HTML Textarea 部分删除。 - 卡片 2「联系页 HTML」:` setFields((prev) => ({ ...prev, html: v }))} preview={(html) => html} footerHint={null} />`,`disabled={!fields.enabled}` 时置灰(外层 `pointer-events-none opacity-60` 包裹)。 - 生效范围区块骨架: ```tsx

仅对选中的 HTTPS 域名生效;留空则不注入。

{fields.domains.length === 0 ? (

{fields.enabled ? '尚未选择生效域名,保存后不注入任何站点。' : '启用离线兜底后可选择生效域名。'}

) : (
{fields.domains.map((domain) => ( {domain} ))}
)}
``` - 卡片 1 的 CardContent 中 HTML 部分移除后,将新生效范围区块放在 Switch 下方。 - 渲染 ` { 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: 提交** ```bash 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 离线兜底条目,更新为包含作用域能力(保持单条、中文、用户可读、说明效果): ```markdown - 支持 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: 提交** ```bash 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 前字节一致。