完善官网 SEO 地基与精确路由
补充 robots、sitemap、SEO 元信息、结构化数据与首页语义内容 统一三套 Nginx 与 Pingora 的 62 条 SPA 路由和真实 404 行为 新增路由一致性检查、网关测试并同步技术文档与项目记忆
This commit is contained in:
@@ -215,8 +215,18 @@ http {
|
||||
return 404;
|
||||
}
|
||||
|
||||
# BEGIN GENARRATIVE MAIN SPA ROUTES
|
||||
location = / {
|
||||
try_files /index.html =404;
|
||||
}
|
||||
|
||||
location ~* "^/(?:bark-battle|big-fish|child-motion-demo|creation|creation/baby-object-match|creation/baby-object-match/generating|creation/baby-object-match/result|creation/bark-battle|creation/bark-battle/generating|creation/bark-battle/result|creation/big-fish|creation/big-fish/generating|creation/big-fish/result|creation/creative-agent|creation/jump-hop|creation/jump-hop/generating|creation/jump-hop/result|creation/match3d|creation/match3d/generating|creation/match3d/result|creation/puzzle|creation/puzzle-clear|creation/puzzle-clear/generating|creation/puzzle-clear/result|creation/puzzle/generating|creation/puzzle/result|creation/rpg|creation/rpg/agent|creation/rpg/generating|creation/rpg/result|creation/square-hole|creation/square-hole/generating|creation/square-hole/result|creation/visual-novel|creation/visual-novel/generating|creation/visual-novel/result|creation/wooden-fish|creation/wooden-fish/generating|creation/wooden-fish/result|editor/canvas|gallery/jump-hop/detail|gallery/puzzle/detail|gallery/visual-novel/detail|match3d|project|puzzle|runtime/baby-love-drawing|runtime/baby-object-match|runtime/bark-battle|runtime/big-fish|runtime/jump-hop|runtime/match3d|runtime/puzzle|runtime/puzzle-clear|runtime/rpg/adventure|runtime/rpg/characters|runtime/square-hole|runtime/visual-novel|runtime/wooden-fish|works/detail|worlds/detail)/?$" {
|
||||
try_files $uri /index.html =404;
|
||||
}
|
||||
# END GENARRATIVE MAIN SPA ROUTES
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
try_files $uri $uri/ =404;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -249,6 +249,28 @@ server {
|
||||
return 404;
|
||||
}
|
||||
|
||||
# BEGIN GENARRATIVE MAIN SPA ROUTES
|
||||
location = / {
|
||||
error_page 503 /maintenance.html;
|
||||
|
||||
if ($genarrative_maintenance) {
|
||||
return 503;
|
||||
}
|
||||
|
||||
try_files /index.html =404;
|
||||
}
|
||||
|
||||
location ~* "^/(?:bark-battle|big-fish|child-motion-demo|creation|creation/baby-object-match|creation/baby-object-match/generating|creation/baby-object-match/result|creation/bark-battle|creation/bark-battle/generating|creation/bark-battle/result|creation/big-fish|creation/big-fish/generating|creation/big-fish/result|creation/creative-agent|creation/jump-hop|creation/jump-hop/generating|creation/jump-hop/result|creation/match3d|creation/match3d/generating|creation/match3d/result|creation/puzzle|creation/puzzle-clear|creation/puzzle-clear/generating|creation/puzzle-clear/result|creation/puzzle/generating|creation/puzzle/result|creation/rpg|creation/rpg/agent|creation/rpg/generating|creation/rpg/result|creation/square-hole|creation/square-hole/generating|creation/square-hole/result|creation/visual-novel|creation/visual-novel/generating|creation/visual-novel/result|creation/wooden-fish|creation/wooden-fish/generating|creation/wooden-fish/result|editor/canvas|gallery/jump-hop/detail|gallery/puzzle/detail|gallery/visual-novel/detail|match3d|project|puzzle|runtime/baby-love-drawing|runtime/baby-object-match|runtime/bark-battle|runtime/big-fish|runtime/jump-hop|runtime/match3d|runtime/puzzle|runtime/puzzle-clear|runtime/rpg/adventure|runtime/rpg/characters|runtime/square-hole|runtime/visual-novel|runtime/wooden-fish|works/detail|worlds/detail)/?$" {
|
||||
error_page 503 /maintenance.html;
|
||||
|
||||
if ($genarrative_maintenance) {
|
||||
return 503;
|
||||
}
|
||||
|
||||
try_files $uri /index.html =404;
|
||||
}
|
||||
# END GENARRATIVE MAIN SPA ROUTES
|
||||
|
||||
location / {
|
||||
error_page 503 /maintenance.html;
|
||||
|
||||
@@ -256,6 +278,6 @@ server {
|
||||
return 503;
|
||||
}
|
||||
|
||||
try_files $uri $uri/ /index.html;
|
||||
try_files $uri $uri/ =404;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -269,6 +269,28 @@ server {
|
||||
return 404;
|
||||
}
|
||||
|
||||
# BEGIN GENARRATIVE MAIN SPA ROUTES
|
||||
location = / {
|
||||
error_page 503 /maintenance.html;
|
||||
|
||||
if ($genarrative_maintenance) {
|
||||
return 503;
|
||||
}
|
||||
|
||||
try_files /index.html =404;
|
||||
}
|
||||
|
||||
location ~* "^/(?:bark-battle|big-fish|child-motion-demo|creation|creation/baby-object-match|creation/baby-object-match/generating|creation/baby-object-match/result|creation/bark-battle|creation/bark-battle/generating|creation/bark-battle/result|creation/big-fish|creation/big-fish/generating|creation/big-fish/result|creation/creative-agent|creation/jump-hop|creation/jump-hop/generating|creation/jump-hop/result|creation/match3d|creation/match3d/generating|creation/match3d/result|creation/puzzle|creation/puzzle-clear|creation/puzzle-clear/generating|creation/puzzle-clear/result|creation/puzzle/generating|creation/puzzle/result|creation/rpg|creation/rpg/agent|creation/rpg/generating|creation/rpg/result|creation/square-hole|creation/square-hole/generating|creation/square-hole/result|creation/visual-novel|creation/visual-novel/generating|creation/visual-novel/result|creation/wooden-fish|creation/wooden-fish/generating|creation/wooden-fish/result|editor/canvas|gallery/jump-hop/detail|gallery/puzzle/detail|gallery/visual-novel/detail|match3d|project|puzzle|runtime/baby-love-drawing|runtime/baby-object-match|runtime/bark-battle|runtime/big-fish|runtime/jump-hop|runtime/match3d|runtime/puzzle|runtime/puzzle-clear|runtime/rpg/adventure|runtime/rpg/characters|runtime/square-hole|runtime/visual-novel|runtime/wooden-fish|works/detail|worlds/detail)/?$" {
|
||||
error_page 503 /maintenance.html;
|
||||
|
||||
if ($genarrative_maintenance) {
|
||||
return 503;
|
||||
}
|
||||
|
||||
try_files $uri /index.html =404;
|
||||
}
|
||||
# END GENARRATIVE MAIN SPA ROUTES
|
||||
|
||||
location / {
|
||||
error_page 503 /maintenance.html;
|
||||
|
||||
@@ -276,6 +298,6 @@ server {
|
||||
return 503;
|
||||
}
|
||||
|
||||
try_files $uri $uri/ /index.html;
|
||||
try_files $uri $uri/ =404;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -359,17 +359,93 @@
|
||||
},
|
||||
{
|
||||
"id": "web_spa_fallback",
|
||||
"samplePath": "/some/spa/path",
|
||||
"samplePath": "/creation/puzzle/result",
|
||||
"expect": {
|
||||
"kind": "static",
|
||||
"root": "web",
|
||||
"mode": "spa_fallback"
|
||||
},
|
||||
"nginx": {
|
||||
"production": ["location /", "try_files $uri $uri/ /index.html;"],
|
||||
"development": ["location /", "try_files $uri $uri/ /index.html;"]
|
||||
"production": [
|
||||
"# BEGIN GENARRATIVE MAIN SPA ROUTES",
|
||||
"try_files $uri /index.html =404;"
|
||||
],
|
||||
"development": [
|
||||
"# BEGIN GENARRATIVE MAIN SPA ROUTES",
|
||||
"try_files $uri /index.html =404;"
|
||||
]
|
||||
},
|
||||
"docs": ["其它路径", "失败回退 `/index.html`"]
|
||||
"docs": ["主站 SPA allowlist", "失败回退 `/index.html`"]
|
||||
},
|
||||
{
|
||||
"id": "web_spa_case_trailing_slash",
|
||||
"samplePath": "/CREATION/PUZZLE/RESULT/",
|
||||
"expect": {
|
||||
"kind": "static",
|
||||
"root": "web",
|
||||
"mode": "spa_fallback"
|
||||
},
|
||||
"nginx": {
|
||||
"production": ["location ~*", "try_files $uri /index.html =404;"],
|
||||
"development": ["location ~*", "try_files $uri /index.html =404;"]
|
||||
},
|
||||
"docs": ["大小写不敏感", "一个尾部斜杠"]
|
||||
},
|
||||
{
|
||||
"id": "web_unknown_path_exact",
|
||||
"samplePath": "/some/spa/path",
|
||||
"expect": {
|
||||
"kind": "static",
|
||||
"root": "web",
|
||||
"mode": "exact"
|
||||
},
|
||||
"nginx": {
|
||||
"production": ["location /", "try_files $uri $uri/ =404;"],
|
||||
"development": ["location /", "try_files $uri $uri/ =404;"]
|
||||
},
|
||||
"docs": ["其它 Web 路径", "缺失时返回真实 404"]
|
||||
},
|
||||
{
|
||||
"id": "creation_unknown_path_exact",
|
||||
"samplePath": "/creation/not-exist",
|
||||
"expect": {
|
||||
"kind": "static",
|
||||
"root": "web",
|
||||
"mode": "exact"
|
||||
},
|
||||
"nginx": {
|
||||
"production": ["try_files $uri $uri/ =404;"],
|
||||
"development": ["try_files $uri $uri/ =404;"]
|
||||
},
|
||||
"docs": ["`/creation/not-exist`"]
|
||||
},
|
||||
{
|
||||
"id": "runtime_unknown_path_exact",
|
||||
"samplePath": "/runtime/not-exist",
|
||||
"expect": {
|
||||
"kind": "static",
|
||||
"root": "web",
|
||||
"mode": "exact"
|
||||
},
|
||||
"nginx": {
|
||||
"production": ["try_files $uri $uri/ =404;"],
|
||||
"development": ["try_files $uri $uri/ =404;"]
|
||||
},
|
||||
"docs": ["`/runtime/not-exist`"]
|
||||
},
|
||||
{
|
||||
"id": "puzzle_unknown_path_exact",
|
||||
"samplePath": "/puzzle/not-exist",
|
||||
"expect": {
|
||||
"kind": "static",
|
||||
"root": "web",
|
||||
"mode": "exact"
|
||||
},
|
||||
"nginx": {
|
||||
"production": ["try_files $uri $uri/ =404;"],
|
||||
"development": ["try_files $uri $uri/ =404;"]
|
||||
},
|
||||
"docs": ["`/puzzle/not-exist`"]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
## 快速入口
|
||||
|
||||
- [Agent 工作入口与执行准则](./%E3%80%90%E5%8D%8F%E4%BD%9C%E8%A7%84%E8%8C%83%E3%80%91Agent%E5%B7%A5%E4%BD%9C%E5%85%A5%E5%8F%A3%E4%B8%8E%E6%89%A7%E8%A1%8C%E5%87%86%E5%88%99-2026-06-22.md):复杂任务前的 Agent 阅读顺序、执行边界、技能路由、文档规则和验证口径。
|
||||
- [官网 SEO 地基实施约定](./technical/【SEO】官网SEO地基实施约定-2026-07-10.md):首页基础 head、robots/sitemap、唯一 H1、精确 SPA 路由与未知路径 404 的长期技术边界。
|
||||
- [经验沉淀](./experience/README.md):项目开发经验、UI 交接、历史实现经验。
|
||||
- [审计与复盘](./audits/README.md):工程审查、文本/乱码审计、专项落地审计。
|
||||
- [系统设计](./design/README.md):玩法、关系、物品与对话设计。
|
||||
|
||||
@@ -3890,3 +3890,10 @@
|
||||
- 性能:`api-server` 只在当前判定涉及的已启用 gate 配置了用户标签白名单时读取用户标签;不因无关 gate 或纯用户 ID / 百分比灰度触发额外标签读取。
|
||||
- 影响范围:`feature_gate_config`、`spacetime-client` runtime facade、`api-server` 创作入口配置与路由熔断、`apps/admin-web` 灰度发布页。
|
||||
- 验证方式:`npm run spacetime:generate`、`npm run check:spacetime-schema`、`cargo test -p module-runtime --manifest-path server-rs/Cargo.toml feature_gate`、`cargo test -p api-server --manifest-path server-rs/Cargo.toml creation_entry_feature_gate`、`npm run admin-web:typecheck`、后台灰度页 Vitest、`npm run check:encoding`、`git diff --check`。
|
||||
|
||||
## 2026-07-10 官网 SEO 与主站 SPA 404 边界
|
||||
|
||||
- 背景:主站 Nginx 和 Pingora 原先会把任意未知路径回退到 `index.html`,导致 soft 404;共享 `index.html` 也缺少首页 SEO head,robots 和 sitemap 请求会落入 SPA fallback。
|
||||
- 决策:新增真实 `robots.txt` 和仅首页的 `sitemap.xml`,首页共享 head 提供基础 SEO/OG/JSON-LD 文本但不使用未确认的 image/logo URL;首页 DOM 只保留一个稳定产品定位 H1。Nginx 与 Pingora 只允许当前完整 SPA 路径回退 `index.html`,同前缀未知路径必须返回 404;`/admin` 继续走独立子应用。路由变化必须同步三套 Nginx、Pingora、route parity matrix 和自动门禁。
|
||||
- 影响范围:`index.html`、`public/robots.txt`、`public/sitemap.xml`、首页组件、三套 Nginx、Pingora 网关和路由 parity 门禁。
|
||||
- 验证方式:前端定向测试与构建、`npm run check:nginx-spa-routes`、`npm run check:pingora-route-parity`、`npm run check:pingora-gateway-smoke`、`npm run check:encoding`、`git diff --check`,部署后同时抽查根级未知路径和 `/creation/not-exist` 等同前缀未知路径。
|
||||
|
||||
@@ -2893,3 +2893,11 @@
|
||||
- 处理:`applyGateTarget` 进入不存在的新 target 时必须重置为新建态:`enabled=false`、`rolloutPercent=0`、allow / deny 列表为空,并使用 target 默认描述。只有显式点已有 gate 才 `fillForm` 复制服务端规则。
|
||||
- 验证:`npm run test -- apps/admin-web/src/pages/AdminGrayReleaseConfigPage.test.tsx`。
|
||||
- 关联:`apps/admin-web/src/pages/AdminGrayReleaseConfigPage.tsx`、`apps/admin-web/src/pages/AdminGrayReleaseConfigPage.test.tsx`。
|
||||
|
||||
## SPA 路由白名单不能只按一级目录放行
|
||||
|
||||
- 现象:`/not-exist` 已返回 404,但 `/creation/not-exist`、`/runtime/not-exist` 或 `/puzzle/not-exist` 仍返回 200 首页,搜索引擎继续判定为 soft 404。
|
||||
- 原因:Nginx 或 Pingora 使用 `/creation/*`、`/runtime/*` 等宽前缀作为 SPA fallback,前端对未知路径又回到平台首页;只验收根级未知 URL 无法发现该问题。
|
||||
- 处理:SPA fallback 必须精确匹配当前真实完整路径,同时允许前端已有的大小写归一和尾部斜杠;最终 catch-all 只提供真实静态文件,失败返回 404。路由增删同步三套 Nginx、Pingora、route parity matrix 和路由门禁。
|
||||
- 验证:除全部真实 SPA 路径外,至少检查 `/not-exist`、`/creation/not-exist`、`/runtime/not-exist` 和 `/puzzle/not-exist` 均返回 404;维护模式仍保持页面 503 优先语义。
|
||||
- 关联:`src/routing/appRoutes.tsx`、`src/routing/appPageRoutes.ts`、`deploy/nginx/`、`deploy/container/nginx.conf`、`server-rs/crates/pingora-gateway/src/main.rs`。
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
# 官网 SEO 地基实施约定
|
||||
|
||||
更新时间:`2026-07-10`
|
||||
|
||||
## 本轮边界
|
||||
|
||||
本轮只建立陶泥儿中文首页的 SEO 基础设施,不新增英文站、长尾落地页、SSR、动态 sitemap、前端访问埋点或分享图。
|
||||
|
||||
- `public/robots.txt` 是真实静态文件;非 SEO 产品路径本轮阻止抓取,但不把 `Disallow` 解释为保证不收录。
|
||||
- `public/sitemap.xml` 本轮只包含 `https://www.genarrative.world/`,不写难以持续维护的 `lastmod`。
|
||||
- `index.html` 提供首页 title、description、canonical、robots、基础 OG/Twitter 文本和 JSON-LD;没有正式分享图时不写 `og:image`、`twitter:image` 或 JSON-LD `logo`。
|
||||
- 首页渲染后只有一个稳定、可见的产品定位 H1;动态作品名、卡片标题、按钮内部标题和隐藏 Tab 不作为 H1。
|
||||
- Nginx 和 Pingora 只对当前已知完整 SPA 路径回退 `index.html`;未知路径以及 `/creation/not-exist` 等同前缀未知路径返回 HTTP 404。
|
||||
- Nginx access log 继续使用现有 `$http_referer`,本轮不新增前端 pageview 或用户身份采集。
|
||||
|
||||
## 路由事实源
|
||||
|
||||
主站 SPA 路由以以下源码为事实源:
|
||||
|
||||
- `src/routing/appRoutes.tsx`
|
||||
- `src/routing/appPageRoutes.ts`
|
||||
|
||||
`/creation/rpg/agent` 仍被现有刷新恢复链路使用,当前作为兼容深链保留。新增或删除前端路由时,必须同步三套 Nginx 配置、Pingora 路由、`deploy/pingora/nginx-route-parity.matrix.json` 和对应自动门禁。不得把 `/creation/*`、`/runtime/*` 等一级目录整体设为 SPA fallback。
|
||||
|
||||
后续新增 SEO 落地页时,还必须同时满足:返回 200、不被 robots.txt 阻止抓取、加入 sitemap,并提供独立 title、description、canonical、H1、正文和内链入口。纯 SPA 页面需要独立 head 时,应评估构建时静态 HTML、预渲染或 SSR。
|
||||
|
||||
## 验收口径
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
npm run typecheck
|
||||
npm run check:nginx-spa-routes
|
||||
npm run check:pingora-route-parity
|
||||
npm run check:pingora-gateway-smoke
|
||||
npm run check:encoding
|
||||
git diff --check
|
||||
```
|
||||
|
||||
部署后至少验证:
|
||||
|
||||
```bash
|
||||
curl -I https://www.genarrative.world/robots.txt
|
||||
curl -I https://www.genarrative.world/sitemap.xml
|
||||
curl -I https://www.genarrative.world/not-exist-test
|
||||
curl -I https://www.genarrative.world/creation/not-exist-test
|
||||
curl -I https://www.genarrative.world/runtime/not-exist-test
|
||||
curl -I https://www.genarrative.world/puzzle/not-exist-test
|
||||
```
|
||||
|
||||
真实 SPA 路径不得误 404;以上未知路径必须返回 404。首页浏览器 DOM 应只有一个 H1,并确认桌面和移动端布局、导航、推荐流和创作入口没有因 SEO 文案变形。
|
||||
@@ -42,6 +42,7 @@ cargo run -p pingora-gateway --manifest-path server-rs/Cargo.toml
|
||||
|
||||
```bash
|
||||
npm run check:pingora-gateway-smoke
|
||||
npm run check:nginx-spa-routes
|
||||
npm run check:pingora-route-parity
|
||||
npm run check:nginx-pingora-canary
|
||||
npm run check:pingora-canary-docker
|
||||
@@ -59,9 +60,11 @@ npm run check:pingora-cutover-evidence-audit
|
||||
npm run check:pingora-release-readiness
|
||||
```
|
||||
|
||||
`check:pingora-gateway-smoke` 会临时启动 mock `api-server`、mock SpacetimeDB、mock Gitea 和 `pingora-gateway`,覆盖 SPA fallback、后台静态路由、HTML / 普通静态资源 `no-cache`、Vite 指纹静态资源 immutable 缓存、静态 `ETag` / `Last-Modified` 与 `304` 协商缓存、静态 `HEAD` 响应、静态 Range、静态 access log method/path/status 对账、gzip 最小长度、小响应不压缩、图片资源不压缩、大响应压缩、ACME、TLS 直连、HTTP/2 ALPN、HTTP 到 HTTPS 重定向、内部路由拒绝、shadow probe、API 代理头(`Host` / `X-Forwarded-Host` / `X-Forwarded-Proto` / `X-Real-IP` / `X-Forwarded-For`)、Gitea Host 整站转发、请求体上限、429 接流保护、上游断连 / 超时 JSON 错误、维护模式、维护模式不拦截 Gitea Host 和 SpacetimeDB WebSocket Upgrade,并复用 `check-pingora-direct-live.mjs` 对临时 HTTPS / HTTP redirect / WSS subscribe 入口做 live smoke。该本地 fixture 会让首页同时引用普通静态资源和 Vite 指纹静态资源,direct live JSON 必须确认指纹资源 GET / HEAD / `Range: bytes=0-0` 以及 access log method/path/status 证据,避免正式直连前只证明普通静态读取。排查失败时可追加 `-- --verbose` 输出网关 stderr / stdout;已确认二进制无需重编时可追加 `-- --skip-build`。
|
||||
`check:pingora-gateway-smoke` 会临时启动 mock `api-server`、mock SpacetimeDB、mock Gitea 和 `pingora-gateway`,覆盖精确主站 SPA fallback、大小写与尾部斜杠兼容、同前缀未知路径真实 404、后台静态路由、HTML / 普通静态资源 `no-cache`、Vite 指纹静态资源 immutable 缓存、静态 `ETag` / `Last-Modified` 与 `304` 协商缓存、静态 `HEAD` 响应、静态 Range、静态 access log method/path/status 对账、gzip 最小长度、小响应不压缩、图片资源不压缩、大响应压缩、ACME、TLS 直连、HTTP/2 ALPN、HTTP 到 HTTPS 重定向、内部路由拒绝、shadow probe、API 代理头(`Host` / `X-Forwarded-Host` / `X-Forwarded-Proto` / `X-Real-IP` / `X-Forwarded-For`)、Gitea Host 整站转发、请求体上限、429 接流保护、上游断连 / 超时 JSON 错误、维护模式、维护模式不拦截 Gitea Host 和 SpacetimeDB WebSocket Upgrade,并复用 `check-pingora-direct-live.mjs` 对临时 HTTPS / HTTP redirect / WSS subscribe 入口做 live smoke。该本地 fixture 会让首页同时引用普通静态资源和 Vite 指纹静态资源,direct live JSON 必须确认指纹资源 GET / HEAD / `Range: bytes=0-0` 以及 access log method/path/status 证据,避免正式直连前只证明普通静态读取。排查失败时可追加 `-- --verbose` 输出网关 stderr / stdout;已确认二进制无需重编时可追加 `-- --skip-build`。
|
||||
|
||||
`check:pingora-route-parity` 读取 `deploy/pingora/nginx-route-parity.matrix.json`,静态确认生产 / 开发 Nginx 模板、Pingora Rust 路由单测和本文档都覆盖同一组核心路由。`cargo test -p pingora-gateway --manifest-path server-rs/Cargo.toml matches_nginx_route_parity_matrix` 会读取同一份矩阵,逐条断言 `classify_path` 的路由结果、body limit 和接流保护分组。
|
||||
`check:nginx-spa-routes` 从 `appPageRoutes.ts` 的 `STAGE_ROUTE_ENTRIES` / `APP_RUNTIME_ROUTES`、`appRoutes.tsx` 的精确路由判断和兼容恢复路径 `/creation/rpg/agent` 提取当前主站 SPA allowlist,确认生产、开发和容器三套 Nginx 模板集合一致,并验证大小写、尾部斜杠和 `/creation/not-exist`、`/runtime/not-exist`、`/puzzle/not-exist` 等未知反例。
|
||||
|
||||
`check:pingora-route-parity` 会先执行同一 Nginx SPA 路由门禁,再读取 `deploy/pingora/nginx-route-parity.matrix.json`,静态确认生产 / 开发 Nginx 模板、Pingora Rust 路由 allowlist / 单测和本文档都覆盖同一组核心路由。`cargo test -p pingora-gateway --manifest-path server-rs/Cargo.toml matches_nginx_route_parity_matrix` 会读取同一份矩阵,逐条断言 `classify_path` 的路由结果、body limit 和接流保护分组。
|
||||
|
||||
`check:nginx-pingora-canary` 会静态校验 `deploy/nginx/snippets/genarrative-pingora-canary.conf` 的本机来源限制、handoff 响应头、probe token 占位、前缀 rewrite、低缓冲和 WebSocket Upgrade 设置,也会校验 `deploy/nginx/snippets/genarrative-pingora-realpath-canary.conf` 只能作为独立 loopback `server` 片段使用、默认监听 `127.0.0.1:18083`、写独立 access log、没有 rewrite、覆盖真实 `/api` / `/v1` / `/assets` 代表路径。本机安装了 Nginx 时脚本会额外把两个 snippet 包进临时 `http {}` 执行 `nginx -t`;需要在 CI / 目标 agent 上强制要求真实 Nginx 语法检查时执行 `node scripts/check-nginx-pingora-canary.mjs --require-nginx`。
|
||||
|
||||
@@ -539,7 +542,8 @@ dev 根盘空间在安装后曾接近满盘;2026-06-17 进入 canary 前已清
|
||||
| `/v1/database/{db}/subscribe`、`/v1/identity*` | 转发到 SpacetimeDB,保留 WebSocket Upgrade 头。 |
|
||||
| `/__genarrative_pingora/healthz` | 仅在携带 `X-Genarrative-Pingora-Probe` 且匹配配置 token 时返回 shadow JSON,否则 404。 |
|
||||
| `/v1/*`、`/generated-*`、`/healthz*`、`/readyz*` | 返回 404,保持生产公网不暴露口径。 |
|
||||
| 其它路径 | 先读取静态文件或目录 index,失败回退 `/index.html`,HTML 默认 `no-cache`,并支持条件请求返回 `304` 与单段 `Range: bytes=` 返回 `206` / 越界返回 `416`。 |
|
||||
| 主站 SPA allowlist | 只对当前前端完整路由及兼容恢复路径 `/creation/rpg/agent` 失败回退 `/index.html`;匹配大小写不敏感并允许一个尾部斜杠,HTML 默认 `no-cache`。 |
|
||||
| 其它 Web 路径 | 只读取真实静态文件或目录 index,缺失时返回真实 404;`/creation/not-exist`、`/runtime/not-exist`、`/puzzle/not-exist` 不进入 SPA fallback。 |
|
||||
|
||||
维护模式下,API-like 路由返回 JSON `503`,Web 静态路由优先返回 `maintenance.html`,不存在时返回纯文本 `503`。
|
||||
代理失败时,API / SpacetimeDB 等代理路由返回统一 JSON 网关错误;本地静态路由仍保持对应 HTTP 错误状态。
|
||||
|
||||
+39
-1
@@ -7,7 +7,45 @@
|
||||
content="width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover"
|
||||
/>
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<title>陶泥儿</title>
|
||||
<title>陶泥儿 Genarrative|游戏美术AI创作工具与美术Agent工作台</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="陶泥儿 Genarrative 是面向个人游戏创作者、独立开发者和UGC游戏社群作者的游戏美术AI创作工具,当前重点提供美术Agent与无限画布,支持素材库管理、AI生成、画布编排、图层编辑、角色素材、游戏图标和宣发图制作,帮助创作者更快准备可展示的游戏美术素材。"
|
||||
/>
|
||||
<link rel="canonical" href="https://www.genarrative.world/" />
|
||||
<meta name="robots" content="index,follow" />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:site_name" content="陶泥儿 Genarrative" />
|
||||
<meta
|
||||
property="og:title"
|
||||
content="陶泥儿|游戏美术AI创作工具与美术Agent工作台"
|
||||
/>
|
||||
<meta
|
||||
property="og:description"
|
||||
content="面向个人游戏创作者的游戏美术AI创作工具,当前重点提供美术Agent与无限画布,支持角色素材、游戏图标、场景概念和宣发图制作。"
|
||||
/>
|
||||
<meta property="og:url" content="https://www.genarrative.world/" />
|
||||
<meta name="twitter:card" content="summary" />
|
||||
<meta
|
||||
name="twitter:title"
|
||||
content="陶泥儿|游戏美术AI创作工具与美术Agent工作台"
|
||||
/>
|
||||
<meta
|
||||
name="twitter:description"
|
||||
content="面向个人游戏创作者的游戏美术AI创作工具,支持美术Agent、无限画布、角色素材、游戏图标和宣发图制作。"
|
||||
/>
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "SoftwareApplication",
|
||||
"name": "陶泥儿 Genarrative",
|
||||
"alternateName": "陶泥儿",
|
||||
"url": "https://www.genarrative.world/",
|
||||
"applicationCategory": "DesignApplication",
|
||||
"operatingSystem": "Web",
|
||||
"description": "陶泥儿 Genarrative 是面向个人游戏创作者、独立开发者和UGC游戏社群作者的游戏美术AI创作工具,当前重点提供美术Agent与无限画布,支持素材库管理、AI生成、画布编排、图层编辑、角色素材、游戏图标和宣发图制作。"
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -36,6 +36,7 @@
|
||||
"check:production-api-deploy": "node scripts/check-production-api-deploy.mjs",
|
||||
"check:pingora-gateway-smoke": "node scripts/check-pingora-gateway-smoke.mjs",
|
||||
"check:nginx-pingora-canary": "node scripts/check-nginx-pingora-canary.mjs",
|
||||
"check:nginx-spa-routes": "node scripts/check-nginx-spa-routes.mjs",
|
||||
"check:pingora-route-parity": "node scripts/check-pingora-route-parity.mjs",
|
||||
"check:pingora-canary-live": "node scripts/check-pingora-canary-live.mjs",
|
||||
"check:pingora-canary-live-guard": "node scripts/check-pingora-canary-live-guard.mjs",
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
User-agent: *
|
||||
Allow: /
|
||||
|
||||
Disallow: /api
|
||||
Disallow: /v1
|
||||
Disallow: /admin
|
||||
Disallow: /creation
|
||||
Disallow: /runtime
|
||||
Disallow: /editor
|
||||
Disallow: /project
|
||||
Disallow: /profile
|
||||
Disallow: /works/detail
|
||||
Disallow: /worlds/detail
|
||||
Disallow: /gallery
|
||||
Disallow: /puzzle
|
||||
Disallow: /big-fish
|
||||
Disallow: /match3d
|
||||
Disallow: /bark-battle
|
||||
Disallow: /child-motion-demo
|
||||
|
||||
Sitemap: https://www.genarrative.world/sitemap.xml
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||
<url>
|
||||
<loc>https://www.genarrative.world/</loc>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>1.0</priority>
|
||||
</url>
|
||||
</urlset>
|
||||
@@ -0,0 +1,173 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
const APP_PAGE_ROUTES_PATH = 'src/routing/appPageRoutes.ts';
|
||||
const APP_ROUTES_PATH = 'src/routing/appRoutes.tsx';
|
||||
const COMPATIBILITY_ROUTES = ['/creation/rpg/agent'];
|
||||
const NGINX_PATHS = [
|
||||
'deploy/nginx/genarrative.conf',
|
||||
'deploy/nginx/genarrative-dev-http.conf',
|
||||
'deploy/container/nginx.conf',
|
||||
];
|
||||
const SPA_BLOCK_START = '# BEGIN GENARRATIVE MAIN SPA ROUTES';
|
||||
const SPA_BLOCK_END = '# END GENARRATIVE MAIN SPA ROUTES';
|
||||
const UNKNOWN_ROUTE_SAMPLES = [
|
||||
'/unknown-root',
|
||||
'/creation/not-exist',
|
||||
'/runtime/not-exist',
|
||||
'/puzzle/not-exist',
|
||||
];
|
||||
|
||||
const failures = [];
|
||||
|
||||
function fail(message) {
|
||||
failures.push(message);
|
||||
}
|
||||
|
||||
function extractSourceBlock(source, pattern, label) {
|
||||
const match = source.match(pattern);
|
||||
if (!match) {
|
||||
fail(`${label} 未找到。`);
|
||||
return '';
|
||||
}
|
||||
return match[1];
|
||||
}
|
||||
|
||||
function collectExpectedMainSpaRoutes() {
|
||||
const appPageRoutes = readFileSync(APP_PAGE_ROUTES_PATH, 'utf8');
|
||||
const appRoutes = readFileSync(APP_ROUTES_PATH, 'utf8');
|
||||
const stageEntries = extractSourceBlock(
|
||||
appPageRoutes,
|
||||
/const STAGE_ROUTE_ENTRIES = \[([\s\S]*?)\] as const/u,
|
||||
`${APP_PAGE_ROUTES_PATH} STAGE_ROUTE_ENTRIES`,
|
||||
);
|
||||
const runtimeEntries = extractSourceBlock(
|
||||
appPageRoutes,
|
||||
/export const APP_RUNTIME_ROUTES[^=]*= \{([\s\S]*?)\n\};/u,
|
||||
`${APP_PAGE_ROUTES_PATH} APP_RUNTIME_ROUTES`,
|
||||
);
|
||||
|
||||
const routes = [
|
||||
...Array.from(
|
||||
stageEntries.matchAll(/\[\s*'[^']+'\s*,\s*'([^']+)'\s*\]/gu),
|
||||
(match) => match[1],
|
||||
),
|
||||
...Array.from(
|
||||
runtimeEntries.matchAll(/'[^']+'\s*:\s*'([^']+)'/gu),
|
||||
(match) => match[1],
|
||||
),
|
||||
...Array.from(
|
||||
appRoutes.matchAll(/normalizedPath === '([^']+)'/gu),
|
||||
(match) => match[1],
|
||||
),
|
||||
...COMPATIBILITY_ROUTES,
|
||||
];
|
||||
|
||||
const uniqueRoutes = [...new Set(routes)].sort();
|
||||
if (uniqueRoutes.length === 0) {
|
||||
fail('未从前端路由源提取到主站 SPA 路由。');
|
||||
}
|
||||
for (const route of uniqueRoutes) {
|
||||
if (!/^\/(?:[a-z0-9-]+(?:\/[a-z0-9-]+)*)?$/u.test(route)) {
|
||||
fail(`前端路由源包含门禁暂不支持的路径格式: ${route}`);
|
||||
}
|
||||
}
|
||||
return uniqueRoutes;
|
||||
}
|
||||
|
||||
function compareRouteSets(actualRoutes, expectedRoutes, label) {
|
||||
const actual = new Set(actualRoutes);
|
||||
const expected = new Set(expectedRoutes);
|
||||
const missing = expectedRoutes.filter((route) => !actual.has(route));
|
||||
const extra = actualRoutes.filter((route) => !expected.has(route));
|
||||
if (missing.length > 0) {
|
||||
fail(`${label} 缺少 SPA 路由: ${missing.join(', ')}`);
|
||||
}
|
||||
if (extra.length > 0) {
|
||||
fail(`${label} 包含非当前路由: ${extra.join(', ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
function validateNginxRoutes(nginxPath, expectedRoutes) {
|
||||
const source = readFileSync(nginxPath, 'utf8');
|
||||
const blockStart = source.indexOf(SPA_BLOCK_START);
|
||||
const blockEnd = source.indexOf(SPA_BLOCK_END);
|
||||
if (blockStart < 0 || blockEnd <= blockStart) {
|
||||
fail(`${nginxPath} 缺少完整 SPA allowlist 标记。`);
|
||||
return;
|
||||
}
|
||||
|
||||
const block = source.slice(blockStart, blockEnd + SPA_BLOCK_END.length);
|
||||
if (!/location\s+=\s+\/\s*\{/u.test(block)) {
|
||||
fail(`${nginxPath} SPA allowlist 缺少根路径精确 location。`);
|
||||
}
|
||||
if (!block.includes('try_files /index.html =404;')) {
|
||||
fail(`${nginxPath} 根路径没有精确回退 index.html。`);
|
||||
}
|
||||
if (!block.includes('try_files $uri /index.html =404;')) {
|
||||
fail(`${nginxPath} SPA allowlist 没有精确回退 index.html。`);
|
||||
}
|
||||
|
||||
const regexMatch = block.match(/location\s+~\*\s+"([^"]+)"\s*\{/u);
|
||||
if (!regexMatch) {
|
||||
fail(`${nginxPath} 缺少大小写不敏感的 SPA allowlist regex location。`);
|
||||
return;
|
||||
}
|
||||
|
||||
const nginxPattern = regexMatch[1];
|
||||
const alternativesMatch = nginxPattern.match(/^\^\/\(\?:(.+)\)\/\?\$$/u);
|
||||
if (!alternativesMatch) {
|
||||
fail(`${nginxPath} SPA allowlist 必须锚定完整路径并允许一个尾部斜杠。`);
|
||||
return;
|
||||
}
|
||||
|
||||
const configuredRoutes = [
|
||||
'/',
|
||||
...alternativesMatch[1].split('|').map((route) => `/${route}`),
|
||||
].sort();
|
||||
compareRouteSets(configuredRoutes, expectedRoutes, nginxPath);
|
||||
|
||||
const matcher = new RegExp(nginxPattern, 'iu');
|
||||
for (const route of expectedRoutes.filter((candidate) => candidate !== '/')) {
|
||||
if (!matcher.test(route)) {
|
||||
fail(`${nginxPath} SPA allowlist 未匹配完整路径: ${route}`);
|
||||
}
|
||||
if (!matcher.test(`${route.toUpperCase()}/`)) {
|
||||
fail(`${nginxPath} SPA allowlist 未允许大小写差异和尾部斜杠: ${route}`);
|
||||
}
|
||||
}
|
||||
for (const route of UNKNOWN_ROUTE_SAMPLES) {
|
||||
if (matcher.test(route) || matcher.test(`${route}/`)) {
|
||||
fail(`${nginxPath} SPA allowlist 错误接收未知路径: ${route}`);
|
||||
}
|
||||
}
|
||||
|
||||
const defaultLocation = source.slice(blockEnd + SPA_BLOCK_END.length);
|
||||
if (!defaultLocation.includes('try_files $uri $uri/ =404;')) {
|
||||
fail(
|
||||
`${nginxPath} 未命中 SPA allowlist 的路径必须只读真实静态文件并返回 404。`,
|
||||
);
|
||||
}
|
||||
if (defaultLocation.includes('try_files $uri $uri/ /index.html;')) {
|
||||
fail(`${nginxPath} 默认 location 仍存在全路径 SPA fallback。`);
|
||||
}
|
||||
}
|
||||
|
||||
export const expectedMainSpaRoutes = collectExpectedMainSpaRoutes();
|
||||
|
||||
for (const nginxPath of NGINX_PATHS) {
|
||||
validateNginxRoutes(nginxPath, expectedMainSpaRoutes);
|
||||
}
|
||||
|
||||
if (failures.length > 0) {
|
||||
console.error('[check:nginx-spa-routes] FAILED');
|
||||
for (const failure of failures) {
|
||||
console.error(`- ${failure}`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[check:nginx-spa-routes] OK (${expectedMainSpaRoutes.length} SPA routes, ${NGINX_PATHS.length} Nginx templates)`,
|
||||
);
|
||||
@@ -568,15 +568,36 @@ async function runSmokeCases(
|
||||
);
|
||||
await expectHttp(
|
||||
baseUrl,
|
||||
'/some/deep/link',
|
||||
'/creation/puzzle/result',
|
||||
200,
|
||||
'site-shell',
|
||||
'主站深链回退 index.html',
|
||||
'主站 allowlist 深链回退 index.html',
|
||||
{
|
||||
validate: (response) =>
|
||||
response.headers['cache-control'] === 'no-cache',
|
||||
},
|
||||
);
|
||||
await expectHttp(
|
||||
baseUrl,
|
||||
'/CREATION/PUZZLE/RESULT/',
|
||||
200,
|
||||
'site-shell',
|
||||
'主站 allowlist 允许大小写差异和尾部斜杠',
|
||||
);
|
||||
for (const unknownPath of [
|
||||
'/some/deep/link',
|
||||
'/creation/not-exist',
|
||||
'/runtime/not-exist',
|
||||
'/puzzle/not-exist',
|
||||
]) {
|
||||
await expectHttp(
|
||||
baseUrl,
|
||||
unknownPath,
|
||||
404,
|
||||
'',
|
||||
`主站未知路径返回真实 404: ${unknownPath}`,
|
||||
);
|
||||
}
|
||||
await expectHttp(baseUrl, '/admin', 301, '', '/admin 301 到 /admin/', {
|
||||
validate: (response) => response.headers.location === '/admin/',
|
||||
});
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
import { expectedMainSpaRoutes } from './check-nginx-spa-routes.mjs';
|
||||
|
||||
const MATRIX_PATH = 'deploy/pingora/nginx-route-parity.matrix.json';
|
||||
const PRODUCTION_NGINX_PATH = 'deploy/nginx/genarrative.conf';
|
||||
const DEVELOPMENT_NGINX_PATH = 'deploy/nginx/genarrative-dev-http.conf';
|
||||
@@ -46,6 +48,11 @@ const REQUIRED_ROUTE_IDS = [
|
||||
'readyz_forbidden',
|
||||
'generated_assets_forbidden',
|
||||
'web_spa_fallback',
|
||||
'web_spa_case_trailing_slash',
|
||||
'web_unknown_path_exact',
|
||||
'creation_unknown_path_exact',
|
||||
'runtime_unknown_path_exact',
|
||||
'puzzle_unknown_path_exact',
|
||||
];
|
||||
|
||||
const files = {
|
||||
@@ -221,6 +228,9 @@ function validateRustTestUsesMatrix() {
|
||||
'serde_json::from_str(ROUTE_PARITY_MATRIX_JSON)',
|
||||
'protection_class_for_route(&route, &case.sample_path)',
|
||||
'fn matches_nginx_route_parity_matrix()',
|
||||
'fn is_main_spa_path(path: &str)',
|
||||
"path.strip_suffix('/')",
|
||||
'normalized.eq_ignore_ascii_case(candidate)',
|
||||
]) {
|
||||
if (!pingoraGatewaySource.includes(fragment)) {
|
||||
fail(`Pingora Rust 路由 parity 测试缺少矩阵接入片段: ${fragment}`);
|
||||
@@ -228,8 +238,34 @@ function validateRustTestUsesMatrix() {
|
||||
}
|
||||
}
|
||||
|
||||
function validateRustMainSpaRoutes() {
|
||||
const routeBlock = pingoraGatewaySource.match(
|
||||
/const MAIN_SPA_PATHS: &\[&str\] = &\[([\s\S]*?)\n\];/u,
|
||||
);
|
||||
if (!routeBlock) {
|
||||
fail('Pingora Rust 缺少 MAIN_SPA_PATHS allowlist。');
|
||||
return;
|
||||
}
|
||||
|
||||
const rustRoutes = Array.from(
|
||||
routeBlock[1].matchAll(/"([^"]+)"/gu),
|
||||
(match) => match[1],
|
||||
).sort();
|
||||
const expected = new Set(expectedMainSpaRoutes);
|
||||
const actual = new Set(rustRoutes);
|
||||
const missing = expectedMainSpaRoutes.filter((route) => !actual.has(route));
|
||||
const extra = rustRoutes.filter((route) => !expected.has(route));
|
||||
if (missing.length > 0) {
|
||||
fail(`Pingora MAIN_SPA_PATHS 缺少当前前端路由: ${missing.join(', ')}`);
|
||||
}
|
||||
if (extra.length > 0) {
|
||||
fail(`Pingora MAIN_SPA_PATHS 包含非当前前端路由: ${extra.join(', ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
validateMatrixShape();
|
||||
validateRustTestUsesMatrix();
|
||||
validateRustMainSpaRoutes();
|
||||
|
||||
if (failures.length > 0) {
|
||||
console.error('[check:pingora-route-parity] FAILED');
|
||||
|
||||
@@ -54,6 +54,70 @@ const SHADOW_PROBE_HEADER: &str = "x-genarrative-pingora-probe";
|
||||
const PAYLOAD_TOO_LARGE_CONTEXT: &str = "genarrative_payload_too_large";
|
||||
const PROTECTION_STATE_TTL: Duration = Duration::from_secs(600);
|
||||
const PROTECTION_CLEANUP_INTERVAL: Duration = Duration::from_secs(60);
|
||||
const MAIN_SPA_PATHS: &[&str] = &[
|
||||
"/",
|
||||
"/bark-battle",
|
||||
"/big-fish",
|
||||
"/child-motion-demo",
|
||||
"/creation",
|
||||
"/creation/baby-object-match",
|
||||
"/creation/baby-object-match/generating",
|
||||
"/creation/baby-object-match/result",
|
||||
"/creation/bark-battle",
|
||||
"/creation/bark-battle/generating",
|
||||
"/creation/bark-battle/result",
|
||||
"/creation/big-fish",
|
||||
"/creation/big-fish/generating",
|
||||
"/creation/big-fish/result",
|
||||
"/creation/creative-agent",
|
||||
"/creation/jump-hop",
|
||||
"/creation/jump-hop/generating",
|
||||
"/creation/jump-hop/result",
|
||||
"/creation/match3d",
|
||||
"/creation/match3d/generating",
|
||||
"/creation/match3d/result",
|
||||
"/creation/puzzle",
|
||||
"/creation/puzzle-clear",
|
||||
"/creation/puzzle-clear/generating",
|
||||
"/creation/puzzle-clear/result",
|
||||
"/creation/puzzle/generating",
|
||||
"/creation/puzzle/result",
|
||||
"/creation/rpg",
|
||||
"/creation/rpg/agent",
|
||||
"/creation/rpg/generating",
|
||||
"/creation/rpg/result",
|
||||
"/creation/square-hole",
|
||||
"/creation/square-hole/generating",
|
||||
"/creation/square-hole/result",
|
||||
"/creation/visual-novel",
|
||||
"/creation/visual-novel/generating",
|
||||
"/creation/visual-novel/result",
|
||||
"/creation/wooden-fish",
|
||||
"/creation/wooden-fish/generating",
|
||||
"/creation/wooden-fish/result",
|
||||
"/editor/canvas",
|
||||
"/gallery/jump-hop/detail",
|
||||
"/gallery/puzzle/detail",
|
||||
"/gallery/visual-novel/detail",
|
||||
"/match3d",
|
||||
"/project",
|
||||
"/puzzle",
|
||||
"/runtime/baby-love-drawing",
|
||||
"/runtime/baby-object-match",
|
||||
"/runtime/bark-battle",
|
||||
"/runtime/big-fish",
|
||||
"/runtime/jump-hop",
|
||||
"/runtime/match3d",
|
||||
"/runtime/puzzle",
|
||||
"/runtime/puzzle-clear",
|
||||
"/runtime/rpg/adventure",
|
||||
"/runtime/rpg/characters",
|
||||
"/runtime/square-hole",
|
||||
"/runtime/visual-novel",
|
||||
"/runtime/wooden-fish",
|
||||
"/works/detail",
|
||||
"/worlds/detail",
|
||||
];
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct GatewayConfig {
|
||||
@@ -1887,12 +1951,31 @@ fn classify_path(path: &str) -> RouteDecision {
|
||||
});
|
||||
}
|
||||
|
||||
if is_main_spa_path(path) {
|
||||
return RouteDecision::Local(LocalResponse::Static {
|
||||
root: StaticRoot::Web,
|
||||
mode: StaticMode::SpaFallback,
|
||||
});
|
||||
}
|
||||
|
||||
RouteDecision::Local(LocalResponse::Static {
|
||||
root: StaticRoot::Web,
|
||||
mode: StaticMode::SpaFallback,
|
||||
mode: StaticMode::Exact,
|
||||
})
|
||||
}
|
||||
|
||||
fn is_main_spa_path(path: &str) -> bool {
|
||||
let normalized = if path.len() > 1 {
|
||||
path.strip_suffix('/').unwrap_or(path)
|
||||
} else {
|
||||
path
|
||||
};
|
||||
|
||||
MAIN_SPA_PATHS
|
||||
.iter()
|
||||
.any(|candidate| normalized.eq_ignore_ascii_case(candidate))
|
||||
}
|
||||
|
||||
fn classify_http_redirect_path(path: &str) -> RouteDecision {
|
||||
if path.starts_with("/.well-known/acme-challenge/") {
|
||||
return RouteDecision::Local(LocalResponse::Static {
|
||||
|
||||
+6
-2
@@ -161,7 +161,9 @@ describe('App title sync', () => {
|
||||
test('主站阶段变化会同步浏览器与宿主标题', () => {
|
||||
renderApp();
|
||||
|
||||
expect(appTitleMock.syncAppTitle).toHaveBeenLastCalledWith('陶泥儿');
|
||||
expect(appTitleMock.syncAppTitle).toHaveBeenLastCalledWith(
|
||||
'陶泥儿 Genarrative|游戏美术AI创作工具与美术Agent工作台',
|
||||
);
|
||||
|
||||
act(() => {
|
||||
fireEvent.click(screen.getByRole('button', { name: '打开拼图创作' }));
|
||||
@@ -187,7 +189,9 @@ describe('App title sync', () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: '退出 RPG' }));
|
||||
});
|
||||
|
||||
expect(appTitleMock.syncAppTitle).toHaveBeenLastCalledWith('陶泥儿');
|
||||
expect(appTitleMock.syncAppTitle).toHaveBeenLastCalledWith(
|
||||
'陶泥儿 Genarrative|游戏美术AI创作工具与美术Agent工作台',
|
||||
);
|
||||
});
|
||||
|
||||
test('启动时回读宿主 runtime 后刷新壳能力 UI', async () => {
|
||||
|
||||
@@ -158,11 +158,11 @@ describe('CreationLandingView', () => {
|
||||
expect(screen.getByRole('main', { name: '陶泥儿创作主页' })).toBeTruthy();
|
||||
expect(
|
||||
screen.getByRole('heading', {
|
||||
name: '陶泥儿 - 开启全民精品游戏创作',
|
||||
name: '游戏美术AI工作台,让个人创作者更快做出角色、场景与宣发素材',
|
||||
}),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
screen.getByText('登录即送100泥点,可以免费制作50个素材'),
|
||||
screen.getByText(/陶泥儿 Genarrative 以美术 Agent 与无限画布/u),
|
||||
).toBeTruthy();
|
||||
expect(screen.getByRole('heading', { name: '创作工具' })).toBeTruthy();
|
||||
expect(screen.getByText('游戏视觉规范')).toBeTruthy();
|
||||
|
||||
@@ -859,8 +859,13 @@ export function CreationLandingView({
|
||||
<section className="creation-landing__hero">
|
||||
<div className="creation-landing__hero-copy">
|
||||
<span className="creation-landing__eyebrow">陶泥儿创作工具</span>
|
||||
<h1>陶泥儿 - 开启全民精品游戏创作</h1>
|
||||
<p>登录即送100泥点,可以免费制作50个素材</p>
|
||||
<h1>
|
||||
游戏美术AI工作台,让个人创作者更快做出角色、场景与宣发素材
|
||||
</h1>
|
||||
<p>
|
||||
陶泥儿 Genarrative 以美术 Agent
|
||||
与无限画布帮助个人创作者准备角色、场景与宣发素材;登录即送100泥点,可以免费制作50个素材。
|
||||
</p>
|
||||
<div className="creation-landing__hero-actions">
|
||||
<PlatformActionButton
|
||||
size="md"
|
||||
|
||||
@@ -95,7 +95,10 @@ test('renders mobile home welcome copy and closes with the acknowledge button',
|
||||
);
|
||||
|
||||
expect(screen.getByRole('dialog', { name: '欢迎' })).toBeTruthy();
|
||||
expect(screen.getByRole('heading', { name: '欢迎' })).toBeTruthy();
|
||||
expect(
|
||||
screen.getByRole('heading', { level: 2, name: '欢迎' }),
|
||||
).toBeTruthy();
|
||||
expect(screen.queryByRole('heading', { level: 1 })).toBeNull();
|
||||
const copy = document.querySelector(
|
||||
'.platform-mobile-home-welcome-dialog__copy',
|
||||
);
|
||||
|
||||
@@ -68,7 +68,7 @@ export function PlatformMobileHomeWelcomeDialog({
|
||||
alt=""
|
||||
className="platform-mobile-home-welcome-dialog__icon"
|
||||
/>
|
||||
<h1 className="platform-mobile-home-welcome-dialog__title">欢迎</h1>
|
||||
<h2 className="platform-mobile-home-welcome-dialog__title">欢迎</h2>
|
||||
<p className="platform-mobile-home-welcome-dialog__copy">
|
||||
尼嚎,新陶泥er!
|
||||
<br />
|
||||
|
||||
@@ -378,6 +378,9 @@ const {
|
||||
};
|
||||
});
|
||||
|
||||
const HOME_SEO_HEADING =
|
||||
'游戏美术AI工作台,让个人创作者更快做出角色、场景与宣发素材';
|
||||
|
||||
const {
|
||||
mockGetPublicAuthUserByCode,
|
||||
mockGetPublicAuthUserById,
|
||||
@@ -4280,6 +4283,39 @@ test('desktop home shows empty states when public shelves are unavailable', () =
|
||||
expect(screen.getByText('暂时还没有推荐作品。')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('desktop home exposes one product h1 and section h2 headings', () => {
|
||||
renderLoggedOutHomeView(vi.fn(), {}, 'home', true);
|
||||
|
||||
const productHeading = screen.getByRole('heading', {
|
||||
level: 1,
|
||||
name: HOME_SEO_HEADING,
|
||||
});
|
||||
|
||||
expect(screen.getAllByRole('heading', { level: 1 })).toHaveLength(1);
|
||||
expect(productHeading.closest('button')).toBeNull();
|
||||
expect(
|
||||
screen.getByRole('heading', { level: 2, name: '今日游戏' }),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
screen.getByRole('heading', { level: 2, name: '推荐' }),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
screen.getByRole('heading', { level: 2, name: '作品分类' }),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
test('mobile shell exposes one product h1 outside interactive content', () => {
|
||||
renderLoggedOutHomeView(vi.fn(), {}, 'profile');
|
||||
|
||||
const productHeading = screen.getByRole('heading', {
|
||||
level: 1,
|
||||
name: HOME_SEO_HEADING,
|
||||
});
|
||||
|
||||
expect(screen.getAllByRole('heading', { level: 1 })).toHaveLength(1);
|
||||
expect(productHeading.closest('button')).toBeNull();
|
||||
});
|
||||
|
||||
test('logged in create tab shows real wallet balance beside the brand', () => {
|
||||
mockNarrowMobileLayout();
|
||||
|
||||
|
||||
@@ -1020,13 +1020,27 @@ function SectionHeader({ title, detail }: { title: string; detail: string }) {
|
||||
<div className="text-[10px] font-semibold tracking-[0.26em] text-[var(--platform-text-soft)]">
|
||||
{detail}
|
||||
</div>
|
||||
<div className="mt-1 text-base font-semibold text-[var(--platform-text-strong)]">
|
||||
<h2 className="mt-1 text-base font-semibold text-[var(--platform-text-strong)]">
|
||||
{title}
|
||||
</div>
|
||||
</h2>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function HomeSeoIntro() {
|
||||
return (
|
||||
<header className="mb-2 min-w-0 shrink-0 px-1">
|
||||
<h1 className="text-sm font-semibold leading-5 text-[var(--platform-text-strong)]">
|
||||
游戏美术AI工作台,让个人创作者更快做出角色、场景与宣发素材
|
||||
</h1>
|
||||
<p className="mt-0.5 text-xs leading-5 text-[var(--platform-text-soft)]">
|
||||
在陶泥儿 Genarrative,用美术 Agent
|
||||
快速生成与迭代素材,再在无限画布中整理角色、场景和宣发灵感。
|
||||
</p>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
function PublicCodeSearchBar({
|
||||
value,
|
||||
onChange,
|
||||
@@ -4578,6 +4592,8 @@ export function RpgEntryHomeView({
|
||||
|
||||
const desktopHomeContent: ReactNode = (
|
||||
<div className={DESKTOP_PAGE_STAGE_CLASS}>
|
||||
<HomeSeoIntro />
|
||||
|
||||
{platformError ? (
|
||||
<PlatformStatusMessage
|
||||
tone="error"
|
||||
@@ -5160,6 +5176,8 @@ export function RpgEntryHomeView({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<HomeSeoIntro />
|
||||
|
||||
<div className="platform-tab-panel-stack min-w-0 flex-1">
|
||||
{tabPanels}
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
APP_HOME_TITLE,
|
||||
resolveAppTitleForSelectionStage,
|
||||
syncAppTitle,
|
||||
} from './appTitle';
|
||||
@@ -22,7 +23,10 @@ afterEach(() => {
|
||||
|
||||
describe('appTitle', () => {
|
||||
test('按平台阶段生成可读页面标题', () => {
|
||||
expect(resolveAppTitleForSelectionStage('platform')).toBe('陶泥儿');
|
||||
expect(resolveAppTitleForSelectionStage('platform')).toBe(APP_HOME_TITLE);
|
||||
expect(resolveAppTitleForSelectionStage('creation-home')).toBe(
|
||||
APP_HOME_TITLE,
|
||||
);
|
||||
expect(resolveAppTitleForSelectionStage('work-detail')).toBe(
|
||||
'作品详情 - 陶泥儿',
|
||||
);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user