From 108808639966e04aec4ae8547d93a8314d5405a5 Mon Sep 17 00:00:00 2001 From: ryan Date: Wed, 3 Jun 2026 17:31:14 +0800 Subject: [PATCH] =?UTF-8?q?[=E6=96=B0=E5=A2=9E]=20OpenFlare=20Pages?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/design/architecture.md | 2 +- docs/design/index.md | 4 +- docs/guide/usage.md | 15 + docs/guildline/development-constraints.md | 5 +- ...e_202606030001_add_pages_static_hosting.go | 2 +- ...02606030002_add_pages_spa_fallback_path.go | 37 ++ openflare_server/model/goose/migrations.go | 1 + openflare_server/model/pages.go | 1 + openflare_server/service/config_version.go | 2 + openflare_server/service/pages.go | 61 +- openflare_server/service/pages_test.go | 21 +- .../utils/render/openresty/render.go | 26 +- .../utils/render/openresty/types.go | 1 + .../web/app/(dashboard)/pages/detail/page.tsx | 11 + .../components/layout/dashboard-sidebar.tsx | 1 - .../web/features/pages/api/pages.ts | 4 + .../features/pages/components/pages-page.tsx | 585 +++++++++++++----- openflare_server/web/features/pages/types.ts | 2 + 18 files changed, 624 insertions(+), 157 deletions(-) create mode 100644 openflare_server/model/goose/goose_202606030002_add_pages_spa_fallback_path.go create mode 100644 openflare_server/web/app/(dashboard)/pages/detail/page.tsx diff --git a/docs/design/architecture.md b/docs/design/architecture.md index 021acd5e..2189ca9d 100644 --- a/docs/design/architecture.md +++ b/docs/design/architecture.md @@ -85,7 +85,7 @@ Agent 本地 Pages 部署目录 Server 不直接 SSH 到节点,也不在线修改节点文件。它只保存控制面状态、生成完整配置版本,并通过 Agent API 让节点主动拉取。 -Pages 静态托管场景中,Server 保存 Pages 项目、不可变部署元数据、文件清单和 zip 部署包;发布版本只记录部署引用和 checksum,不把大体积静态资源写入 `config_versions`。 +Pages 静态托管场景中,Server 保存 Pages 项目、SPA fallback 回退路径、不可变部署元数据、文件清单和 zip 部署包;发布版本只记录部署引用、checksum 与静态渲染策略,不把大体积静态资源写入 `config_versions`。 ## Agent diff --git a/docs/design/index.md b/docs/design/index.md index 4989e1c0..bd92b0d8 100644 --- a/docs/design/index.md +++ b/docs/design/index.md @@ -88,11 +88,11 @@ OpenFlare Pages 面向边缘节点静态站点托管,采用“项目 + 不可 约束: -* Pages 项目保存名称、标识、启用状态、SPA fallback 选项和当前激活部署。 +* Pages 项目保存名称、标识、启用状态、SPA fallback 启用状态、自定义回退路径和当前激活部署。 * Pages 部署由管理端上传预构建 zip 包生成;部署包保存在 Server 本地 Pages 存储目录,数据库只保存部署元数据和文件清单,不保存大体积文件内容。 * 只有项目存在激活部署后,`proxy_routes.upstream_type = 'pages'` 的网站规则才能绑定该项目。 * Pages 网站继续复用网站规则的域名、HTTPS、WAF、PoW、Basic Auth、限流、缓存配置和配置版本发布机制。 -* 发布快照保存 Pages 项目、部署 ID、部署 checksum、入口文件和 SPA fallback 选项。Agent 拉取激活配置时按部署 checksum 下载并校验部署包,解压到本地 `pages_dir` 后再应用 OpenResty 配置。 +* 发布快照保存 Pages 项目、部署 ID、部署 checksum、入口文件、SPA fallback 启用状态和回退路径。Agent 拉取激活配置时按部署 checksum 下载并校验部署包,解压到本地 `pages_dir` 后再应用 OpenResty 配置。 * V1 不支持 Git 自动构建、预览域名、边缘函数、动态 SSR、外部对象存储或多租户隔离。 ## 内网穿透约束 diff --git a/docs/guide/usage.md b/docs/guide/usage.md index 2ba85f15..3ccda5a5 100644 --- a/docs/guide/usage.md +++ b/docs/guide/usage.md @@ -63,6 +63,21 @@ OpenFlare 不直接在线修改节点上的 Nginx/OpenResty 配置。你在管 * 修改源站目录后,检查已发布的网站配置是否需要同步更新源站快照。 * 发布前使用预览或 diff 确认渲染结果。 +## 托管 Pages 静态站点 + +Pages 用于托管已经构建完成的静态资源包。当前阶段只支持 Direct Upload,不执行 Git 构建、边缘函数或 SSR。 + +操作顺序: + +1. 进入 **Pages** 页面,点击 **新建 Pages 项目**。 +2. 填写项目名称、标识、描述;如为前端 history 路由应用,启用 **SPA fallback** 并填写回退路径,默认是 `/index.html`,也可以设置为 `/app.html` 等站点内绝对路径。 +3. 创建后回到 Pages 项目列表,点击项目进入详情。 +4. 在项目详情中上传 zip 静态资源包,并激活某个部署。 +5. 新建或编辑网站规则,将回源方式切换为 **Pages 静态站点**,选择该 Pages 项目。 +6. 发布并激活配置版本,Agent 会下载部署包、校验 checksum、解压到本地 Pages 目录,再由 OpenResty 本地服务静态文件。 + +Pages 项目只有在启用且存在激活部署后,才会出现在网站规则的 Pages 项目选择列表中。 + ## 启用 HTTPS HTTPS 按域名绑定证书,而不是按整个网站统一强制启用。 diff --git a/docs/guildline/development-constraints.md b/docs/guildline/development-constraints.md index 41386778..1e851af4 100644 --- a/docs/guildline/development-constraints.md +++ b/docs/guildline/development-constraints.md @@ -98,6 +98,7 @@ Frontend: * **Pages 与上游关联**: * `proxy_routes.upstream_type = 'pages'` 时,必须指定 `pages_project_id`。 * 被引用的 Pages 项目必须启用,且必须存在当前激活部署。 + * Pages 项目可启用 SPA fallback 并配置站点内绝对回退路径(默认 `/index.html`);回退路径必须经 Server 校验后进入发布快照,不得直接拼接未校验输入到 OpenResty 配置。 * Pages 部署包必须作为 Server 本地文件保存,数据库只保存部署元数据与文件清单;禁止把静态资源内容写入 `config_versions.support_files_json`。 * 发布配置时,Server 将 Pages 上游渲染为 OpenResty `root` + `try_files` 静态服务,并保留网站规则已有的 HTTPS、WAF、PoW、Basic Auth、限流与缓存配置。 @@ -171,7 +172,7 @@ v1-v7 视为历史初始基线,不再维护逐版本升级文件。v8-v17 是 - Token 管理(生成、轮换)。 - 强制同步(触发 Client 立即拉取新配置)。 * **Admin Pages 管理 API** - `/api/pages/*`,要求 Admin Session。 - - CRUD Pages 项目。 + - CRUD Pages 项目,包括 SPA fallback 启用状态与回退路径。 - 上传 zip 部署包、查看部署历史、激活部署、删除非激活部署。 * **Agent Pages 下载 API** - `/api/agent/pages/*`,使用 `X-Agent-Token` 认证。 - Agent 仅能按激活配置引用的部署 ID 拉取静态部署包,不提供任意文件读取或远程命令入口。 @@ -197,7 +198,7 @@ v1-v7 视为历史初始基线,不再维护逐版本升级文件。v8-v17 是 * 自动型 WAF IP 组只能由 Server 定时任务读取请求日志并执行 Expr 布尔规则,OpenResty Lua 与 Agent 不得直接访问请求日志库或执行自动挖掘逻辑。 * 发布版本不得展开 WAF IP 组成员;Agent 必须通过独立的 IP 组 checksum 差异同步和 WebSocket 增量广播维护本地 `waf_ip_groups.json`。 * **Pages 配置扩展**:区分上游类型,为 `upstream_type = 'pages'` 的代理规则生成 Pages 部署快照。 - * OpenResty 侧:将 Pages 上游渲染为本地静态目录 `root` 与 `try_files`,不得渲染 `proxy_pass`。 + * OpenResty 侧:将 Pages 上游渲染为本地静态目录 `root` 与 `try_files`,启用 SPA fallback 时使用项目配置的回退路径,不得渲染 `proxy_pass`。 * Agent 侧:在应用 OpenResty 配置前,必须确保引用的 Pages 部署包已下载、checksum 校验通过并解压到 `pages_dir`。 * **内网穿透配置扩展**:区分上游类型,为 `upstream_type = 'tunnel'` 的代理规则生成独立的 tunnel 配置数据。 * OpenResty 侧:将 tunnel 上游自动渲染为 `http://127.0.0.1:{relay_vhost_port}`,必须保留原始 `Host` 请求头。 diff --git a/openflare_server/model/goose/goose_202606030001_add_pages_static_hosting.go b/openflare_server/model/goose/goose_202606030001_add_pages_static_hosting.go index 8673c631..b7714f30 100644 --- a/openflare_server/model/goose/goose_202606030001_add_pages_static_hosting.go +++ b/openflare_server/model/goose/goose_202606030001_add_pages_static_hosting.go @@ -47,7 +47,7 @@ func validatePagesStaticHosting(db *gorm.DB) error { return fmt.Errorf("column proxy_routes.%s is missing", column) } } - for _, column := range []string{"slug", "active_deployment_id", "spa_fallback_enabled"} { + for _, column := range []string{"slug", "active_deployment_id", "spa_fallback_enabled", "spa_fallback_path"} { if !db.Migrator().HasColumn("pages_projects", column) { return fmt.Errorf("column pages_projects.%s is missing", column) } diff --git a/openflare_server/model/goose/goose_202606030002_add_pages_spa_fallback_path.go b/openflare_server/model/goose/goose_202606030002_add_pages_spa_fallback_path.go new file mode 100644 index 00000000..d8bef72f --- /dev/null +++ b/openflare_server/model/goose/goose_202606030002_add_pages_spa_fallback_path.go @@ -0,0 +1,37 @@ +package goose + +import ( + "fmt" + + presslygoose "github.com/pressly/goose/v3" + "gorm.io/gorm" +) + +const versionPagesSPAFallbackPath int64 = 202606030002 + +// migration202606030002 adds a configurable SPA fallback path for Pages +// projects. Existing projects keep the previous /index.html behavior. +func migration202606030002(backend string, ctx Context) *presslygoose.Migration { + return newGORMMigration( + versionPagesSPAFallbackPath, + "202606030002_add_pages_spa_fallback_path.go", + backend, + ctx, + migratePagesSPAFallbackPath, + ) +} + +func migratePagesSPAFallbackPath(ctx Context, db *gorm.DB, backend string) error { + if err := ctx.ApplyCurrentSchema(db, backend); err != nil { + return err + } + if err := db.Exec( + `UPDATE pages_projects SET spa_fallback_path = '/index.html' WHERE spa_fallback_path IS NULL OR TRIM(spa_fallback_path) = ''`, + ).Error; err != nil { + return fmt.Errorf("backfill pages_projects.spa_fallback_path: %w", err) + } + if !db.Migrator().HasColumn("pages_projects", "spa_fallback_path") { + return fmt.Errorf("column pages_projects.spa_fallback_path is missing") + } + return nil +} diff --git a/openflare_server/model/goose/migrations.go b/openflare_server/model/goose/migrations.go index f0b6b455..af806009 100644 --- a/openflare_server/model/goose/migrations.go +++ b/openflare_server/model/goose/migrations.go @@ -42,6 +42,7 @@ func registeredMigrations(backend string, ctx Context) []*presslygoose.Migration return []*presslygoose.Migration{ migration202606020001(backend, ctx), migration202606030001(backend, ctx), + migration202606030002(backend, ctx), } } diff --git a/openflare_server/model/pages.go b/openflare_server/model/pages.go index c671ee69..eea88464 100644 --- a/openflare_server/model/pages.go +++ b/openflare_server/model/pages.go @@ -14,6 +14,7 @@ type PagesProject struct { Description string `json:"description" gorm:"type:text;not null;default:''"` Enabled bool `json:"enabled" gorm:"not null;default:true"` SPAFallbackEnabled bool `json:"spa_fallback_enabled" gorm:"not null;default:false"` + SPAFallbackPath string `json:"spa_fallback_path" gorm:"size:512;not null;default:'/index.html'"` ActiveDeploymentID *uint `json:"active_deployment_id" gorm:"index"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` diff --git a/openflare_server/service/config_version.go b/openflare_server/service/config_version.go index e54554ff..5abe38de 100644 --- a/openflare_server/service/config_version.go +++ b/openflare_server/service/config_version.go @@ -105,6 +105,7 @@ type snapshotPagesDeployment struct { Checksum string `json:"checksum"` EntryFile string `json:"entry_file"` SPAFallbackEnabled bool `json:"spa_fallback_enabled"` + SPAFallbackPath string `json:"spa_fallback_path"` LocalRoot string `json:"local_root"` } @@ -624,6 +625,7 @@ func buildSnapshotPagesDeployment(projectID *uint) (*snapshotPagesDeployment, er Checksum: deployment.Checksum, EntryFile: deployment.EntryFile, SPAFallbackEnabled: project.SPAFallbackEnabled, + SPAFallbackPath: normalizeStoredPagesFallbackPath(project.SPAFallbackPath), LocalRoot: fmt.Sprintf("%s/deployments/%d/current", openrestyrender.PagesDirPlaceholder, deployment.ID), }, nil } diff --git a/openflare_server/service/pages.go b/openflare_server/service/pages.go index 63731939..9370f48e 100644 --- a/openflare_server/service/pages.go +++ b/openflare_server/service/pages.go @@ -21,9 +21,10 @@ import ( ) const ( - pagesMaxDeploymentFiles = 1000 - pagesMaxDeploymentBytes = 25 * 1024 * 1024 - defaultPagesEntryFile = "index.html" + pagesMaxDeploymentFiles = 1000 + pagesMaxDeploymentBytes = 25 * 1024 * 1024 + defaultPagesEntryFile = "index.html" + defaultPagesFallbackPath = "/index.html" ) var pagesSlugPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,126}[a-z0-9]$|^[a-z0-9]$`) @@ -34,6 +35,7 @@ type PagesProjectInput struct { Description string `json:"description"` Enabled bool `json:"enabled"` SPAFallbackEnabled bool `json:"spa_fallback_enabled"` + SPAFallbackPath string `json:"spa_fallback_path"` } type PagesProjectView struct { @@ -43,6 +45,7 @@ type PagesProjectView struct { Description string `json:"description"` Enabled bool `json:"enabled"` SPAFallbackEnabled bool `json:"spa_fallback_enabled"` + SPAFallbackPath string `json:"spa_fallback_path"` ActiveDeploymentID *uint `json:"active_deployment_id"` ActiveDeployment *PagesDeploymentView `json:"active_deployment,omitempty"` DeploymentCount int64 `json:"deployment_count"` @@ -133,6 +136,7 @@ func UpdatePagesProject(id uint, input PagesProjectInput) (*PagesProjectView, er "description": project.Description, "enabled": project.Enabled, "spa_fallback_enabled": project.SPAFallbackEnabled, + "spa_fallback_path": project.SPAFallbackPath, }).Error; err != nil { if model.IsUniqueConstraintError(err) { return nil, errors.New("Pages 项目标识已存在") @@ -404,6 +408,11 @@ func buildPagesProject(project *model.PagesProject, input PagesProjectInput) (*m project.Description = strings.TrimSpace(input.Description) project.Enabled = input.Enabled project.SPAFallbackEnabled = input.SPAFallbackEnabled + fallbackPath, err := normalizePagesFallbackPath(input.SPAFallbackPath) + if err != nil { + return nil, err + } + project.SPAFallbackPath = fallbackPath return project, nil } @@ -418,6 +427,7 @@ func buildPagesProjectView(project *model.PagesProject) (*PagesProjectView, erro Description: project.Description, Enabled: project.Enabled, SPAFallbackEnabled: project.SPAFallbackEnabled, + SPAFallbackPath: normalizeStoredPagesFallbackPath(project.SPAFallbackPath), ActiveDeploymentID: project.ActiveDeploymentID, CreatedAt: project.CreatedAt, UpdatedAt: project.UpdatedAt, @@ -472,6 +482,51 @@ func normalizePagesSlug(raw string) string { return strings.Trim(builder.String(), "-") } +func normalizePagesFallbackPath(raw string) (string, error) { + value := strings.TrimSpace(raw) + if value == "" { + value = defaultPagesFallbackPath + } + if len(value) > 512 { + return "", errors.New("SPA fallback 回退路径长度不能超过 512") + } + if !strings.HasPrefix(value, "/") { + return "", errors.New("SPA fallback 回退路径必须以 / 开头") + } + if value == "/" || strings.HasSuffix(value, "/") { + return "", errors.New("SPA fallback 回退路径必须指向具体文件") + } + if strings.Contains(value, "\\") || strings.ContainsAny(value, "\"';") { + return "", errors.New("SPA fallback 回退路径包含不支持的字符") + } + for _, r := range value { + if r <= 0x20 || r == 0x7f { + return "", errors.New("SPA fallback 回退路径不能包含空白或控制字符") + } + } + for _, segment := range strings.Split(value, "/") { + if segment == "." || segment == ".." { + return "", errors.New("SPA fallback 回退路径不能包含 . 或 .. 路径段") + } + } + cleaned := path.Clean(value) + if cleaned == "." || !strings.HasPrefix(cleaned, "/") { + return "", errors.New("SPA fallback 回退路径不合法") + } + if cleaned == "/" || strings.HasSuffix(cleaned, "/") { + return "", errors.New("SPA fallback 回退路径必须指向具体文件") + } + return cleaned, nil +} + +func normalizeStoredPagesFallbackPath(value string) string { + normalized, err := normalizePagesFallbackPath(value) + if err != nil { + return defaultPagesFallbackPath + } + return normalized +} + func normalizePagesEntryFile(raw string) string { value := path.Clean(strings.TrimSpace(filepath.ToSlash(raw))) if value == "." || value == "/" { diff --git a/openflare_server/service/pages_test.go b/openflare_server/service/pages_test.go index 5589d2ff..3830abe1 100644 --- a/openflare_server/service/pages_test.go +++ b/openflare_server/service/pages_test.go @@ -18,6 +18,7 @@ func TestPagesUploadActivateAndPublishStaticRoute(t *testing.T) { Slug: "marketing-site", Enabled: true, SPAFallbackEnabled: true, + SPAFallbackPath: "/app.html", }) if err != nil { t.Fatalf("CreatePagesProject failed: %v", err) @@ -68,7 +69,10 @@ func TestPagesUploadActivateAndPublishStaticRoute(t *testing.T) { if !strings.Contains(result.Version.RenderedConfig, "root \"__OPENFLARE_PAGES_DIR__/deployments/") { t.Fatalf("expected rendered config to use pages dir placeholder, got:\n%s", result.Version.RenderedConfig) } - if !strings.Contains(result.Version.RenderedConfig, "try_files $uri $uri/ /index.html;") { + if !strings.Contains(result.Version.SnapshotJSON, `"spa_fallback_path":"/app.html"`) { + t.Fatalf("expected snapshot to include custom SPA fallback path, got %s", result.Version.SnapshotJSON) + } + if !strings.Contains(result.Version.RenderedConfig, "try_files $uri $uri/ /app.html;") { t.Fatalf("expected SPA fallback try_files, got:\n%s", result.Version.RenderedConfig) } if strings.Contains(result.Version.RenderedConfig, "proxy_pass") { @@ -76,6 +80,21 @@ func TestPagesUploadActivateAndPublishStaticRoute(t *testing.T) { } } +func TestPagesProjectRejectsUnsafeFallbackPath(t *testing.T) { + setupServiceTestDB(t) + + _, err := CreatePagesProject(PagesProjectInput{ + Name: "Unsafe Fallback", + Slug: "unsafe-fallback", + Enabled: true, + SPAFallbackEnabled: true, + SPAFallbackPath: "/index.html; proxy_pass http://evil", + }) + if err == nil || !strings.Contains(err.Error(), "回退路径") { + t.Fatalf("expected unsafe SPA fallback path rejection, got %v", err) + } +} + func TestUploadPagesDeploymentRejectsZipSlip(t *testing.T) { setupServiceTestDB(t) diff --git a/openflare_server/utils/render/openresty/render.go b/openflare_server/utils/render/openresty/render.go index 4aa883ef..315955b5 100644 --- a/openflare_server/utils/render/openresty/render.go +++ b/openflare_server/utils/render/openresty/render.go @@ -10,6 +10,7 @@ import ( "errors" "fmt" "net/url" + "path" "regexp" "sort" "strings" @@ -462,7 +463,7 @@ func renderPagesLocationBlock(deployment *PagesDeployment, limitConfig routeLimi var builder strings.Builder builder.WriteString(renderRouteLimitBlock(limitConfig)) if deployment != nil && deployment.SPAFallbackEnabled { - builder.WriteString(" try_files $uri $uri/ /index.html;\n") + builder.WriteString(fmt.Sprintf(" try_files $uri $uri/ %s;\n", pagesFallbackPath(deployment))) } else { builder.WriteString(" try_files $uri $uri/ =404;\n") } @@ -483,6 +484,29 @@ func pagesEntryFile(deployment *PagesDeployment) string { return strings.TrimPrefix(filepathToNginxPath(deployment.EntryFile), "/") } +func pagesFallbackPath(deployment *PagesDeployment) string { + if deployment == nil || strings.TrimSpace(deployment.SPAFallbackPath) == "" { + return "/index.html" + } + value := filepathToNginxPath(strings.TrimSpace(deployment.SPAFallbackPath)) + if !strings.HasPrefix(value, "/") { + value = "/" + value + } + if value == "/" || strings.HasSuffix(value, "/") || strings.Contains(value, "\\") || strings.ContainsAny(value, "\"';") || strings.ContainsAny(value, " \t\r\n") { + return "/index.html" + } + for _, segment := range strings.Split(value, "/") { + if segment == "." || segment == ".." { + return "/index.html" + } + } + cleaned := path.Clean(value) + if cleaned == "/" || strings.HasSuffix(cleaned, "/") { + return "/index.html" + } + return cleaned +} + func renderProxyHeaderBlock(originURL string, originHost string, customHeaders []CustomHeader, upstreamConfig routeUpstreamConfig, cfg ConfigSnapshot) string { var builder strings.Builder if strings.TrimSpace(originHost) != "" { diff --git a/openflare_server/utils/render/openresty/types.go b/openflare_server/utils/render/openresty/types.go index 9ef89f7d..b803fff5 100644 --- a/openflare_server/utils/render/openresty/types.go +++ b/openflare_server/utils/render/openresty/types.go @@ -129,6 +129,7 @@ type PagesDeployment struct { Checksum string `json:"checksum"` EntryFile string `json:"entry_file"` SPAFallbackEnabled bool `json:"spa_fallback_enabled"` + SPAFallbackPath string `json:"spa_fallback_path"` LocalRoot string `json:"local_root"` } diff --git a/openflare_server/web/app/(dashboard)/pages/detail/page.tsx b/openflare_server/web/app/(dashboard)/pages/detail/page.tsx new file mode 100644 index 00000000..0902af12 --- /dev/null +++ b/openflare_server/web/app/(dashboard)/pages/detail/page.tsx @@ -0,0 +1,11 @@ +'use client'; + +import { useSearchParams } from 'next/navigation'; + +import { PagesProjectDetailPage } from '@/features/pages/components/pages-page'; + +export default function PagesProjectDetailRoute() { + const searchParams = useSearchParams(); + + return ; +} diff --git a/openflare_server/web/components/layout/dashboard-sidebar.tsx b/openflare_server/web/components/layout/dashboard-sidebar.tsx index b3106872..303c479f 100644 --- a/openflare_server/web/components/layout/dashboard-sidebar.tsx +++ b/openflare_server/web/components/layout/dashboard-sidebar.tsx @@ -217,7 +217,6 @@ function SidebarContent({

OpenFlare

-

控制面

) : null} diff --git a/openflare_server/web/features/pages/api/pages.ts b/openflare_server/web/features/pages/api/pages.ts index 43226e70..1b9dbf23 100644 --- a/openflare_server/web/features/pages/api/pages.ts +++ b/openflare_server/web/features/pages/api/pages.ts @@ -10,6 +10,10 @@ export function getPagesProjects() { return apiRequest('/pages/'); } +export function getPagesProject(id: number) { + return apiRequest(`/pages/${id}`); +} + export function createPagesProject(payload: PagesProjectPayload) { return apiRequest('/pages/', { method: 'POST', diff --git a/openflare_server/web/features/pages/components/pages-page.tsx b/openflare_server/web/features/pages/components/pages-page.tsx index a61a9914..26102c6b 100644 --- a/openflare_server/web/features/pages/components/pages-page.tsx +++ b/openflare_server/web/features/pages/components/pages-page.tsx @@ -1,14 +1,23 @@ 'use client'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import Link from 'next/link'; +import { useRouter } from 'next/navigation'; import { useState, type FormEvent } from 'react'; +import { EmptyState } from '@/components/feedback/empty-state'; +import { ErrorState } from '@/components/feedback/error-state'; +import { LoadingState } from '@/components/feedback/loading-state'; +import { PageHeader } from '@/components/layout/page-header'; import { AppCard } from '@/components/ui/app-card'; +import { AppModal } from '@/components/ui/app-modal'; +import { StatusBadge } from '@/components/ui/status-badge'; import { activatePagesDeployment, createPagesProject, deletePagesDeployment, deletePagesProject, + getPagesProject, getPagesDeployments, getPagesProjects, uploadPagesDeployment, @@ -25,6 +34,14 @@ import { const projectsQueryKey = ['pages-projects']; +function projectQueryKey(projectId: string | number) { + return ['pages-project', String(projectId)]; +} + +function deploymentsQueryKey(projectId: string | number) { + return ['pages-deployments', Number(projectId)]; +} + function formatBytes(value: number) { if (value < 1024) { return `${value} B`; @@ -43,104 +60,38 @@ function formatDate(value?: string | null) { } export function PagesPage() { - const queryClient = useQueryClient(); - const [name, setName] = useState(''); - const [slug, setSlug] = useState(''); - const [description, setDescription] = useState(''); - const [spaFallbackEnabled, setSpaFallbackEnabled] = useState(false); + const [isCreateModalOpen, setCreateModalOpen] = useState(false); const projectsQuery = useQuery({ queryKey: projectsQueryKey, queryFn: getPagesProjects, }); - const createMutation = useMutation({ - mutationFn: () => - createPagesProject({ - name, - slug, - description, - enabled: true, - spa_fallback_enabled: spaFallbackEnabled, - }), - onSuccess: () => { - setName(''); - setSlug(''); - setDescription(''); - setSpaFallbackEnabled(false); - queryClient.invalidateQueries({ queryKey: projectsQueryKey }); - }, - }); - - function handleCreate(event: FormEvent) { - event.preventDefault(); - createMutation.mutate(); - } - return (
-
-

- OpenFlare Pages -

-

- 边缘静态站点托管 -

-

- 创建 Pages 项目,上传已构建的 zip 静态资源包,然后在规则中选择 Pages - 项目作为上游。发布后 Agent 会拉取部署包并在边缘节点本地服务静态文件。 -

+
+
+

+ OpenFlare Pages +

+

+ 边缘静态站点托管 +

+

+ 创建 Pages 项目,上传已构建的 zip 静态资源包,然后在规则中选择 Pages + 项目作为上游。发布后 Agent + 会拉取部署包并在边缘节点本地服务静态文件。 +

+
+ setCreateModalOpen(true)} + > + 新建 Pages 项目 +
- -
- - setName(event.target.value)} - required - /> - - - setSlug(event.target.value)} - /> - - - setDescription(event.target.value)} - /> - - -
- - {createMutation.isPending ? '创建中...' : '创建项目'} - -
- {createMutation.error ? ( -

- {createMutation.error.message} -

- ) : null} - -
-
{projectsQuery.isLoading ? ( 正在加载 Pages 项目... @@ -151,28 +102,55 @@ export function PagesPage() {

) : (projectsQuery.data ?? []).length === 0 ? ( - -

- 还没有 Pages 项目。先创建一个项目,再上传静态资源包。 + setCreateModalOpen(true)} + > + 新建 Pages 项目 + + } + > +

+ V1 仅支持 Direct Upload,不执行 Git + 构建或边缘函数运行时。创建后可在项目卡片中上传 zip + 部署包并激活版本。

) : ( (projectsQuery.data ?? []).map((project) => ( - + )) )}
+ + setCreateModalOpen(false)} + />
); } -function PagesProjectCard({ project }: { project: PagesProject }) { +export function PagesProjectDetailPage({ projectId }: { projectId: string }) { + const router = useRouter(); const queryClient = useQueryClient(); const [file, setFile] = useState(null); const [entryFile, setEntryFile] = useState('index.html'); + + const parsedProjectId = Number(projectId); + const projectQuery = useQuery({ + queryKey: projectQueryKey(projectId), + queryFn: () => getPagesProject(parsedProjectId), + enabled: projectId !== '' && Number.isFinite(parsedProjectId), + }); const deploymentsQuery = useQuery({ - queryKey: ['pages-deployments', project.id], - queryFn: () => getPagesDeployments(project.id), + queryKey: deploymentsQueryKey(parsedProjectId), + queryFn: () => getPagesDeployments(parsedProjectId), + enabled: projectId !== '' && Number.isFinite(parsedProjectId), }); const uploadMutation = useMutation({ @@ -180,83 +158,180 @@ function PagesProjectCard({ project }: { project: PagesProject }) { if (!file) { throw new Error('请选择 zip 文件'); } - return uploadPagesDeployment(project.id, file, entryFile); + return uploadPagesDeployment(parsedProjectId, file, entryFile); }, onSuccess: () => { setFile(null); queryClient.invalidateQueries({ - queryKey: ['pages-deployments', project.id], + queryKey: deploymentsQueryKey(parsedProjectId), }); + queryClient.invalidateQueries({ queryKey: projectQueryKey(projectId) }); queryClient.invalidateQueries({ queryKey: projectsQueryKey }); }, }); const activateMutation = useMutation({ mutationFn: (deploymentId: number) => - activatePagesDeployment(project.id, deploymentId), + activatePagesDeployment(parsedProjectId, deploymentId), onSuccess: () => { queryClient.invalidateQueries({ - queryKey: ['pages-deployments', project.id], + queryKey: deploymentsQueryKey(parsedProjectId), }); + queryClient.invalidateQueries({ queryKey: projectQueryKey(projectId) }); queryClient.invalidateQueries({ queryKey: projectsQueryKey }); }, }); const deleteDeploymentMutation = useMutation({ mutationFn: (deploymentId: number) => - deletePagesDeployment(project.id, deploymentId), + deletePagesDeployment(parsedProjectId, deploymentId), onSuccess: () => { queryClient.invalidateQueries({ - queryKey: ['pages-deployments', project.id], + queryKey: deploymentsQueryKey(parsedProjectId), }); + queryClient.invalidateQueries({ queryKey: projectQueryKey(projectId) }); queryClient.invalidateQueries({ queryKey: projectsQueryKey }); }, }); const deleteProjectMutation = useMutation({ - mutationFn: () => deletePagesProject(project.id), + mutationFn: () => deletePagesProject(parsedProjectId), onSuccess: () => { queryClient.invalidateQueries({ queryKey: projectsQueryKey }); + router.push('/pages'); }, }); + if (projectId === '' || !Number.isFinite(parsedProjectId)) { + return ( + + ); + } + + if (projectQuery.isLoading) { + return ; + } + + if (projectQuery.isError) { + return ( + + ); + } + + const project = projectQuery.data; + if (!project) { + return ( + + ); + } + + const handleDeleteProject = () => { + if (!window.confirm(`确认删除 Pages 项目 ${project.name} 吗?`)) { + return; + } + deleteProjectMutation.mutate(); + }; + return ( - { - if (window.confirm(`确认删除 Pages 项目 ${project.name} 吗?`)) { - deleteProjectMutation.mutate(); - } - }} - > - 删除项目 - - } - > -
-
-
-

- 当前激活部署 -

-

- {project.active_deployment +

+ + + 返回列表 + + + 删除项目 + + + } + /> + + +
+ + + +
+

+ 项目状态

-

- 激活时间:{formatDate(project.active_deployment?.activated_at)} +

+ + +
+

+ 需要启用且有激活部署

+
+
-
+
+ +
) : null}
-
+ -
-

- 部署历史 -

+ {deploymentsQuery.isLoading ? (

加载中...

- ) : (deploymentsQuery.data ?? []).length === 0 ? ( -

- 暂无部署。上传 zip 后再激活部署。 + ) : deploymentsQuery.isError ? ( +

+ {deploymentsQuery.error.message}

+ ) : (deploymentsQuery.data ?? []).length === 0 ? ( + ) : (
{(deploymentsQuery.data ?? []).map((deployment) => ( @@ -357,8 +437,223 @@ function PagesProjectCard({ project }: { project: PagesProject }) { ))}
)} -
+
- +
+ ); +} + +function PagesProjectCreateModal({ + isOpen, + onClose, +}: { + isOpen: boolean; + onClose: () => void; +}) { + const queryClient = useQueryClient(); + const [name, setName] = useState(''); + const [slug, setSlug] = useState(''); + const [description, setDescription] = useState(''); + const [spaFallbackEnabled, setSpaFallbackEnabled] = useState(false); + const [spaFallbackPath, setSpaFallbackPath] = useState('/index.html'); + + const resetForm = () => { + setName(''); + setSlug(''); + setDescription(''); + setSpaFallbackEnabled(false); + setSpaFallbackPath('/index.html'); + }; + + const closeModal = () => { + resetForm(); + onClose(); + }; + + const createMutation = useMutation({ + mutationFn: () => + createPagesProject({ + name, + slug, + description, + enabled: true, + spa_fallback_enabled: spaFallbackEnabled, + spa_fallback_path: spaFallbackPath, + }), + onSuccess: () => { + resetForm(); + onClose(); + queryClient.invalidateQueries({ queryKey: projectsQueryKey }); + }, + }); + + function handleCreate(event: FormEvent) { + event.preventDefault(); + createMutation.mutate(); + } + + return ( + + + 取消 + + + {createMutation.isPending ? '创建中...' : '创建项目'} + +
+ } + > +
+ + setName(event.target.value)} + required + /> + + + setSlug(event.target.value)} + /> + + + setDescription(event.target.value)} + /> + + + + setSpaFallbackPath(event.target.value)} + /> + +
+ V1 仅保存项目和部署包,不执行构建任务;请先在本地或 CI + 中完成静态资源构建。 +
+ {createMutation.error ? ( +

+ {createMutation.error.message} +

+ ) : null} + + + ); +} + +function PagesProjectListItem({ project }: { project: PagesProject }) { + return ( + +
+
+
+

+ {project.name} +

+ + +
+

+ {project.slug} +

+ {project.description ? ( +

+ {project.description} +

+ ) : null} + {project.spa_fallback_enabled ? ( +

+ 回退路径:{project.spa_fallback_path || '/index.html'} +

+ ) : null} +
+ +
+
+

部署数

+

+ {project.deployment_count} +

+
+
+

+ 当前激活 +

+

+ {project.active_deployment + ? `#${project.active_deployment.deployment_number}` + : '暂无'} +

+
+
+
+
+

+ 激活时间:{formatDate(project.active_deployment?.activated_at)} +

+ + 查看详情 → + +
+ + ); +} + +function OverviewItem({ + label, + value, + hint, +}: { + label: string; + value: string; + hint: string; +}) { + return ( +
+

{label}

+

+ {value} +

+

{hint}

+
); } diff --git a/openflare_server/web/features/pages/types.ts b/openflare_server/web/features/pages/types.ts index d47add88..4e324b33 100644 --- a/openflare_server/web/features/pages/types.ts +++ b/openflare_server/web/features/pages/types.ts @@ -19,6 +19,7 @@ export interface PagesProject { description: string; enabled: boolean; spa_fallback_enabled: boolean; + spa_fallback_path: string; active_deployment_id?: number | null; active_deployment?: PagesDeployment | null; deployment_count: number; @@ -32,4 +33,5 @@ export interface PagesProjectPayload { description: string; enabled: boolean; spa_fallback_enabled: boolean; + spa_fallback_path: string; }