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
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- OpenFlare Pages -
-- 创建 Pages 项目,上传已构建的 zip 静态资源包,然后在规则中选择 Pages - 项目作为上游。发布后 Agent 会拉取部署包并在边缘节点本地服务静态文件。 -
++ OpenFlare Pages +
++ 创建 Pages 项目,上传已构建的 zip 静态资源包,然后在规则中选择 Pages + 项目作为上游。发布后 Agent + 会拉取部署包并在边缘节点本地服务静态文件。 +
+
- 还没有 Pages 项目。先创建一个项目,再上传静态资源包。
+
+ V1 仅支持 Direct Upload,不执行 Git
+ 构建或边缘函数运行时。创建后可在项目卡片中上传 zip
+ 部署包并激活版本。
- 当前激活部署 -
-- {project.active_deployment +
+ 项目状态
-- 激活时间:{formatDate(project.active_deployment?.activated_at)} +
+ 需要启用且有激活部署
加载中...
- ) : (deploymentsQuery.data ?? []).length === 0 ? ( -- 暂无部署。上传 zip 后再激活部署。 + ) : deploymentsQuery.isError ? ( +
+ {deploymentsQuery.error.message}
+ ) : (deploymentsQuery.data ?? []).length === 0 ? ( ++ {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)} +
+ + 查看详情 → + +{label}
++ {value} +
+{hint}
+