Browse Source

推进活动列表第一刀与联调回归

zhangyan 3 days ago
parent
commit
527b4c78a9

+ 190 - 2
b2f.md

@@ -1,6 +1,6 @@
 # b2f
-> 文档版本:v1.19
-> 最后更新:2026-04-03 16:43:25
+> 文档版本:v1.26
+> 最后更新:2026-04-03 19:18:34
 
 
 说明:
@@ -12,6 +12,177 @@
 
 ## 待确认
 
+### B2F-038
+
+- 时间:2026-04-03 19:13:57
+- 谁提的:backend
+- 当前事实:
+  - backend 已按“活动卡片列表最小产品化第一刀”补齐以下返回中的活动卡片最小摘要字段:
+    - `GET /cards`
+    - `GET /home`
+    - `GET /me/entry-home`
+  - 当前最小摘要字段为:
+    - `summary`
+    - `status`
+    - `statusCode`
+    - `timeWindow`
+    - `ctaText`
+    - `isDefaultExperience`
+    - `eventType`
+    - `currentPresentation`
+    - `currentContentBundle`
+  - backend 当前希望 frontend 这轮优先做的是:
+    - 活动列表页按这组字段完成最小接线
+    - 详情页继续沿用:
+      - `play.canLaunch`
+      - `currentPresentation`
+      - `currentContentBundle`
+      这组已发布 release 语义
+    - 联调时继续通过 frontend 调试日志回传以下事实:
+      - 列表页实际拿到的 `cardEventIds`
+      - 点击卡片后的 `eventId`
+      - 详情页实际显示的 `status / canLaunch / currentPresentation / currentContentBundle`
+- 需要对方确认什么:
+  - frontend 请按这组字段完成活动卡片列表最小实现,并回写:
+    - 当前字段是否足够
+    - 列表页是否还缺必需字段
+    - 最新日志里是否已能稳定看到:
+      - `cardEventIds`
+      - `clickedEventId`
+      - `detail.status`
+      - `detail.canLaunch`
+- 是否已解决:否
+
+### B2F-037
+
+- 时间:2026-04-03 22:52:10
+- 谁提的:backend
+- 当前事实:
+  - backend 已根据 frontend 在 `F2B-013` 的结构化日志,确认 manual 多赛道当前不显示赛道选择区的根因不在 frontend 展示层
+  - 当前 frontend 日志事实为:
+    - `event-play.pageEventId = evt_demo_variant_manual_001`
+    - `event-play.variantCount = 0`
+    - `event-prepare.variantCount = 0`
+    - `event-prepare.selectableVariantCount = 0`
+    - `event-prepare.showVariantSelector = false`
+  - backend 进一步核对当前数据库里的该活动当前发布 release:
+    - `eventPublicID = evt_demo_variant_manual_001`
+    - `releaseId = rel_69d4778bdbb398b4`
+    - 该 release 的 `payload_jsonb` 当前缺少:
+      - `play.assignmentMode`
+      - `play.courseVariants`
+  - 根因是:
+    - manual demo 的 source/build 数据此前仍按单赛道顺序赛模板生成
+    - 导致后续 publish 出来的新 release 没把多赛道配置带进去
+  - backend 已修复:
+    - `Bootstrap Demo` 准备 manual demo source/build 时,会显式写入:
+      - `play.assignmentMode = manual`
+      - `play.courseVariants = [variant_a, variant_b]`
+- 需要对方确认什么:
+  - 无,当前这条已通过本轮联调日志确认
+- 是否已解决:是
+
+### B2F-036
+
+- 时间:2026-04-03 22:34:08
+- 谁提的:backend
+- 当前事实:
+  - backend 已按活动卡片列表最小产品化第一刀,统一补齐以下返回里的卡片摘要字段:
+    - `GET /cards`
+    - `GET /home`
+    - `GET /me/entry-home`
+  - 当前新增/补齐字段为:
+    - `summary`
+    - `status`
+    - `statusCode`
+    - `timeWindow`
+    - `ctaText`
+    - `isDefaultExperience`
+    - `eventType`
+    - `currentPresentation`
+    - `currentContentBundle`
+  - 当前口径固定如下:
+    - `summary` 缺失时回退:`当前暂无活动摘要`
+    - `timeWindow` 缺失时回退:`时间待公布`
+    - `ctaText` 当前由 backend 派生:
+      - 默认体验活动:`进入体验`
+      - 进行中:`进入活动`
+      - 已结束:`查看回顾`
+      - 其余:`查看详情`
+    - `currentPresentation / currentContentBundle` 继续表示当前已发布 release 摘要,不是 event 草稿默认值
+  - backend 已给 `cards` 落显式字段:
+    - `is_default_experience`
+  - 当前 demo 数据已标记:
+    - 顺序赛为默认体验活动
+    - 积分赛、多赛道为普通活动
+- 需要对方确认什么:
+  - frontend 可按以上字段和降级规则开始活动卡片列表最小产品化第一刀
+  - frontend 请回写:
+    - 当前字段是否足够启动列表页最小实现
+    - 是否还缺列表页必需名称摘要
+- 是否已解决:否
+
+### B2F-035
+
+- 时间:2026-04-03 18:16:19
+- 谁提的:backend
+- 当前事实:
+  - backend 已根据 frontend 在 `F2B-012` 的反馈,正式收紧 `play.canLaunch` 和 `POST /events/{eventPublicID}/launch` 的前置条件
+  - 当前规则已改为:
+    - 仅当当前 event 满足以下条件时,`play.canLaunch = true`
+      - event `status = active`
+      - 已存在当前发布 release
+      - 当前发布 release 有 `manifest`
+      - 当前发布 release 已绑定 `runtime`
+      - 当前发布 release 已绑定 `presentation`
+      - 当前发布 release 已绑定 `content bundle`
+  - 当前若缺任一项,backend 会返回更明确原因,例如:
+    - `current published release is missing runtime binding`
+    - `current published release is missing presentation binding`
+    - `current published release is missing content bundle binding`
+  - `launch` 当前也已按同一套规则阻断,避免出现:
+    - `play.canLaunch = false`
+    - 但直接调用 `launch` 仍能进局
+- 需要对方确认什么:
+  - frontend 请在 backend 重启后复验:
+    - 当 `currentPresentation / currentContentBundle / runtime` 任意缺失时,`play.canLaunch` 是否已变为 `false`
+    - `play.reason` 是否已返回更具体缺失原因
+  - frontend 页面当前可继续沿用:
+    - `canLaunch=false` 时禁用进入动作
+    - 同时展示 backend 返回的 `reason`
+- 是否已解决:否
+
+### B2F-034
+
+- 时间:2026-04-03 18:05:19
+- 谁提的:backend
+- 当前事实:
+  - backend 当前已确认一个需要 frontend 明确区分的语义:
+    - `currentPresentation`
+    - `currentContentBundle`
+    当前表示的是“当前已发布 release 上实际绑定的展示版本 / 内容包版本摘要”
+  - 它们当前不是:
+    - 活动草稿默认值
+    - event 默认绑定草稿态
+  - 这也解释了为什么:
+    - 后台未完成导入 + 默认绑定 + publish 之前,这两项可能为空
+    - 一旦跑过后台发布链,它们就会开始显示
+  - backend 当前正式规则也已明确:
+    - 玩家进入游戏必须基于“已发布 release”
+    - 不能基于未发布默认配置直接放行
+- 需要对方确认什么:
+  - frontend 请按以下口径调整页面语义:
+    - 文案优先改成:
+      - `当前发布展示版本`
+      - `当前发布内容包版本`
+    - 玩家能否继续进入,优先只看:
+      - `play.canLaunch`
+    - 当这两项为空时,优先解释为:
+      - 当前发布 release 未绑定
+      - 或当前尚未发布
+    - 不要把它们展示成“活动默认配置已存在,只是未显示”
+- 是否已解决:否
+
 ### B2F-032
 
 - 时间:2026-04-03 16:43:25
@@ -269,6 +440,23 @@
 
 ## 已确认
 
+### B2F-033
+
+- 时间:2026-04-03 17:25:35
+- 谁提的:backend
+- 当前事实:
+  - backend 已把玩法切换对应的联调资源补齐到 workbench:
+    - `presentation schema`
+    - `content manifest`
+    - `asset manifest`
+  - 玩法切换现在会自动填真实 dev 资源地址,不再继续保留 `example.com` 占位:
+    - `GET /dev/demo-assets/presentations/{demoKey}`
+    - `GET /dev/demo-assets/content-manifests/{demoKey}`
+  - 当前联调样例文案也已统一成中文活动样例,便于 frontend 直接核对页面显示与日志事实
+- 需要对方确认什么:
+  - frontend 如需核对当前玩法对应的展示/内容输入,可直接对照 workbench 当前表单值与上述两条 dev 资源地址
+- 是否已解决:是
+
 ### B2F-027
 
 - 时间:2026-04-03 14:37:00

+ 120 - 2
b2t.md

@@ -1,6 +1,6 @@
 # B2T 协作清单
-> 文档版本:v1.18
-> 最后更新:2026-04-03 16:16:38
+> 文档版本:v1.22
+> 最后更新:2026-04-03 19:21:23
 
 说明:
 
@@ -36,6 +36,63 @@
 
 ## 已确认
 
+### B2T-031
+
+- 时间:2026-04-03 19:21:23
+- 谁提的:backend
+- 当前事实:
+  - frontend 本轮已通过结构化调试日志确认以下链路正常:
+    - 活动列表页当前能稳定拿到 3 张 demo 卡片
+    - 多赛道入口点击后能进入正确活动:
+      - `evt_demo_variant_manual_001`
+    - 多赛道详情当前已拿到:
+      - `assignmentMode = manual`
+      - `variantCount = 2`
+      - `detailCanLaunch = true`
+      - 当前发布 `presentation / content bundle` 摘要正常
+  - backend 当前可确认:
+    - manual 多赛道 demo 的当前发布 release 已切到正确版本
+    - 活动列表最小产品化第一刀与前端当前接线口径一致
+  - frontend 最新 `f2b.md` 当前也已将:
+    - `F2B-014`
+    标记为已确认
+  - 当前仍留一条未完全收口的旧项:
+    - `F2B-011`
+    - 即 demo 活动历史 ongoing session 回收口径,需要后续单独收掉
+- 需要对方确认什么:
+  - 无
+- 是否已解决:是
+
+### B2T-029
+
+- 时间:2026-04-03 22:34:08
+- 谁提的:backend
+- 当前事实:
+  - backend 已按 `活动卡片列表最小产品化配合阶段` 落完第一刀最小摘要字段
+  - 当前以下返回已统一补齐活动卡片最小摘要:
+    - `GET /cards`
+    - `GET /home`
+    - `GET /me/entry-home`
+  - 当前摘要字段包括:
+    - `summary`
+    - `status`
+    - `statusCode`
+    - `timeWindow`
+    - `ctaText`
+    - `isDefaultExperience`
+    - `eventType`
+    - `currentPresentation`
+    - `currentContentBundle`
+  - 当前实现继续保持:
+    - 不新增对象层级
+    - 不改活动详情页现有语义
+    - `currentPresentation / currentContentBundle` 仍表示当前已发布 release 摘要
+  - backend 同时已通过 `0011_card_summary.sql` 给 `cards` 落了显式字段:
+    - `is_default_experience`
+- 需要对方确认什么:
+  - 无
+- 是否已解决:是
+
 ### B2T-028
 
 - 时间:2026-04-03 16:16:38
@@ -184,6 +241,67 @@
 
 ## 已完成
 
+### B2T-030
+
+- 时间:2026-04-03 19:08:55
+- 谁提的:backend
+- 当前事实:
+  - backend 当前已完成并稳定运行的主线可概括为三段:
+    - 联调标准化阶段
+    - 真实输入替换第一刀
+    - 活动卡片列表最小产品化第一刀
+  - 联调标准化阶段当前已具备:
+    - 一键测试链
+    - 详细日志
+    - 稳定 demo 数据
+    - workbench 回归结果汇总
+    - frontend 调试日志通道
+  - 真实输入替换第一刀当前已完成:
+    - 真实 KML
+    - 真实地图 URL
+    - demo content manifest / presentation schema 通过 backend dev 资源入口提供
+    - 中文活动文案样例
+  - 活动卡片列表最小产品化第一刀当前已完成:
+    - `GET /cards`
+    - `GET /home`
+    - `GET /me/entry-home`
+    统一补齐活动卡片摘要字段
+  - 当前卡片最小摘要字段包括:
+    - `summary`
+    - `status`
+    - `statusCode`
+    - `timeWindow`
+    - `ctaText`
+    - `isDefaultExperience`
+    - `eventType`
+    - `currentPresentation`
+    - `currentContentBundle`
+  - 当前阶段 backend 仍保持:
+    - 不扩新对象层级
+    - 不推翻现有 `Event / EventRelease / Session`
+    - 继续以标准联调链为唯一基线
+- 需要对方确认什么:
+  - 无
+- 是否已解决:是
+
+### B2T-029
+
+- 时间:2026-04-03 17:25:35
+- 谁提的:backend
+- 当前事实:
+  - backend 已把“真实输入替换第一刀”继续推进到:
+    - `content manifest`
+    - `presentation schema`
+    - 中文活动文案样例
+  - 当前 workbench 的玩法切换会自动填充 backend 内置 demo 资源:
+    - `GET /dev/demo-assets/presentations/{demoKey}`
+    - `GET /dev/demo-assets/content-manifests/{demoKey}`
+  - 这两条路由只服务联调,不进入正式客户端运行链路
+  - `Bootstrap Demo` 当前准备的联调样例文案已统一为中文活动样例,不再继续暴露一批 `Demo ...` 名称
+- 需要对方确认什么:
+  - 无
+- 是否已解决:是
+
 ### B2T-024
 
 - 时间:2026-04-03 14:21:24

+ 63 - 2
backend/README.md

@@ -1,6 +1,6 @@
 # Backend
-> 文档版本:v1.17
-> 最后更新:2026-04-03 16:16:38
+> 文档版本:v1.22
+> 最后更新:2026-04-03 18:56:46
 
 
 这套后端现在已经能支撑一条完整主链:
@@ -14,6 +14,22 @@
 - 真正进入游戏时客户端消费的是 `manifest_url`
 - `session` 会固化当时实际绑定的 `release`
 
+当前还要明确一条业务规则:
+
+- 玩家进入游戏,必须基于“已发布 release”
+- `event` 默认绑定、活动草稿配置、未发布 presentation / content bundle 都不能直接作为玩家正式进入依据
+- 当前 `currentPresentation` / `currentContentBundle` 在玩家链路里表示的是:
+  - 当前已发布 release 实际绑定的展示版本摘要
+  - 当前已发布 release 实际绑定的内容包摘要
+- 它们不是 event 草稿默认值摘要
+- 当前 `play.canLaunch` 和 `launch` 也已按同一套规则收口:
+  - 只有当当前发布 release 同时具备:
+    - `manifest`
+    - `runtime`
+    - `presentation`
+    - `content bundle`
+    时,玩家才允许正式进入
+
 当前 workbench 里新增的“当前 Launch 实际配置摘要”仅用于调试:
 
 - 它会由 backend 代读当前 launch 对应的 manifest
@@ -35,6 +51,51 @@
 - backend 会临时保留最近 200 条日志,供 workbench 查看与清空
 - 这块只用于联调排查,不替代正式生产日志体系
 
+当前 demo 真实输入第一刀也已经接入:
+
+- workbench 的玩法切换会自动填入 backend 内置的:
+  - `presentation schema`
+  - `content manifest`
+- 这些 demo 资源通过 backend 提供的 dev 路由读取:
+  - `GET /dev/demo-assets/presentations/{demoKey}`
+  - `GET /dev/demo-assets/content-manifests/{demoKey}`
+
+当前 workbench 的 `Bootstrap` 语义也已经拆开:
+
+- `Bootstrap Demo(只准备数据)`
+  - 只准备 demo 测试数据,不额外重新发布当前玩法
+- `Bootstrap + 发布当前玩法`
+  - 先准备 demo,再对当前选中的玩法执行一遍“发布活动配置(自动补 Runtime)”
+- 这两条路由只服务联调,不进入正式客户端发布链
+- 当前联调样例文案也已从 `Demo ...` 收口为中文活动样例,便于前端和总控直接对口排查
+
+当前活动卡片列表最小产品化第一刀也已经进入 backend:
+
+- `/cards`
+- `/home`
+- `/me/entry-home`
+
+这三处当前已统一补齐最小活动卡片摘要字段:
+
+- `summary`
+- `status`
+- `statusCode`
+- `timeWindow`
+- `ctaText`
+- `isDefaultExperience`
+- `eventType`
+- `currentPresentation`
+- `currentContentBundle`
+
+当前口径:
+
+- 卡片摘要与详情页继续共用同一套“当前发布 release 摘要”语义
+- `currentPresentation / currentContentBundle` 仍表示:
+  - 当前已发布 release 实际绑定的展示版本摘要
+  - 当前已发布 release 实际绑定的内容包摘要
+- `isDefaultExperience` 当前由卡片显式字段控制
+- `timeWindow / ctaText` 当前先按后端派生规则提供,允许后续继续演进
+
 ## 文档导航
 
 - [文档索引](D:/dev/cmr-mini/backend/docs/README.md)

+ 115 - 5
backend/docs/开发说明.md

@@ -1,6 +1,6 @@
 # 开发说明
-> 文档版本:v1.20
-> 最后更新:2026-04-03 16:16:38
+> 文档版本:v1.25
+> 最后更新:2026-04-03 18:56:46
 
 
 ## 1. 环境变量
@@ -45,6 +45,13 @@ cd D:\dev\cmr-mini\backend
   - `Bootstrap Demo`
   - `Use Classic Demo / Use Score-O Demo / Use Manual Variant Demo`
   - `整条链一键验收`
+- 当前玩法切换除了切 `event / release / source / build`,还会自动切换:
+  - `presentation schema`
+  - `content manifest`
+  - `asset manifest`
+- 这些 demo 资源现在由 backend 提供,避免继续在 workbench 里保留 `example.com` 占位地址:
+  - `GET /dev/demo-assets/presentations/{demoKey}`
+  - `GET /dev/demo-assets/content-manifests/{demoKey}`
 - 如果 frontend 需要把页面侧调试日志直接打到 backend,优先使用:
   - `POST /dev/client-logs`
   - 然后在 workbench 的 `前端调试日志` 面板里查看
@@ -67,6 +74,54 @@ cd D:\dev\cmr-mini\backend
   - backend 当前只在内存里保留最近 200 条
   - 适合前端把关键事实直接打进来,避免只靠截图和口头描述
   - 不替代正式生产日志体系
+- `Bootstrap Demo` 准备出的联调文案也已换成中文样例:
+  - `领秀城公园顺序赛`
+  - `领秀城公园积分赛`
+  - `领秀城公园多赛道挑战`
+
+## 4. 活动卡片列表最小摘要
+
+当前 backend 已为以下入口统一补齐活动卡片最小摘要字段:
+
+- `/cards`
+- `/home`
+- `/me/entry-home`
+
+当前字段集:
+
+- `title`
+- `subtitle`
+- `summary`
+- `status`
+- `statusCode`
+- `timeWindow`
+- `ctaText`
+- `coverUrl`
+- `isDefaultExperience`
+- `eventType`
+- `currentPresentation`
+- `currentContentBundle`
+
+当前派生规则:
+
+- `summary`
+  - 无值时回退为:`当前暂无活动摘要`
+- `status`
+  - `running` -> `进行中`
+  - `upcoming` -> `即将开始`
+  - `ended` -> `已结束`
+  - 其余 -> `状态待确认`
+- `timeWindow`
+  - 由 `cards.starts_at / ends_at` 派生
+  - 缺失时回退为:`时间待公布`
+- `ctaText`
+  - 默认体验活动:`进入体验`
+  - 进行中:`进入活动`
+  - 已结束:`查看回顾`
+  - 其余:`查看详情`
+- `currentPresentation / currentContentBundle`
+  - 当前继续表示已发布 release 实际绑定摘要
+  - 不是 event 草稿默认值
 
 默认会设置:
 
@@ -100,13 +155,26 @@ cd D:\dev\cmr-mini\backend
 
 当前推荐顺序:
 
-1. `Bootstrap Demo`
+1. `Bootstrap Demo(只准备数据)`
 2. 选择一种玩法入口:
    - `Use Classic Demo`
    - `Use Score-O Demo`
    - `Use Manual Variant Demo`
-3. `一键补齐 Runtime 并发布`
-4. `一键标准回归`
+3. 如果只是想看发布过程,点 `Bootstrap + 发布当前玩法`
+4. 如果想只测发布链,点 `一键补齐 Runtime 并发布`
+5. 如果想直接验整条链,点 `一键标准回归`
+
+当前这几个按钮的职责已经拆开:
+
+- `Bootstrap Demo(只准备数据)`
+  - 只负责准备 demo event / source / build / release / runtime 等测试数据
+  - 不会基于当前玩法再额外重新发布一版
+- `Bootstrap + 发布当前玩法`
+  - 会先执行一遍 `Bootstrap Demo`
+  - 然后对当前选中的玩法执行“发布活动配置(自动补 Runtime)”
+- `一键补齐 Runtime 并发布`
+  - 不再隐式 bootstrap
+  - 只基于当前已选玩法和当前表单上下文执行发布链
 
 当前这条一键链会自动完成:
 
@@ -220,6 +288,48 @@ dev 环境下,frontend 可直接把关键调试事实发到 backend:
 
 ## 3. 当前开发约定
 
+### 3.0 玩家进入规则
+
+当前要明确一条玩家链路规则:
+
+- 玩家进入游戏,必须基于“已发布 release”
+- 不能基于:
+  - event 草稿默认绑定
+  - 未发布 presentation
+  - 未发布 content bundle
+  - 未发布 runtime
+
+当前接口中的:
+
+- `currentPresentation`
+- `currentContentBundle`
+
+在玩家链路里表示的是:
+
+- 当前已发布 release 上实际绑定的展示版本摘要
+- 当前已发布 release 上实际绑定的内容包摘要
+
+不是:
+
+- event 草稿默认值摘要
+
+所以如果当前 release 还没绑定这些对象,玩家页看到空值是正常行为。前端页面应优先:
+
+- 用 `play.canLaunch` 判定是否允许进入
+- 把空值解释成“当前未发布或当前发布未绑定”
+
+当前 `canLaunch` 已按正式进入规则收紧:
+
+- 只有当当前 event 满足以下条件时,`play.canLaunch = true`
+  - event `status = active`
+  - 已存在当前发布 release
+  - 当前发布 release 有 `manifest`
+  - 当前发布 release 已绑定 `runtime`
+  - 当前发布 release 已绑定 `presentation`
+  - 当前发布 release 已绑定 `content bundle`
+
+当前 `POST /events/{eventPublicID}/launch` 也已与 `canLaunch` 保持同一套前置条件。
+
 ### 3.1 开发阶段先不用 Redis
 
 当前第一版全部依赖:

+ 94 - 2
backend/docs/接口清单.md

@@ -1,6 +1,6 @@
 # API 清单
-> 文档版本:v1.9
-> 最后更新:2026-04-03 16:16:38
+> 文档版本:v1.12
+> 最后更新:2026-04-03 22:34:08
 
 
 本文档只记录当前 backend 已实现接口,不写未来规划接口。
@@ -95,12 +95,23 @@
 用途:
 
 - 返回入口首页卡片
+- 当前卡片摘要字段已统一补齐:
+  - `summary`
+  - `status`
+  - `statusCode`
+  - `timeWindow`
+  - `ctaText`
+  - `isDefaultExperience`
+  - `eventType`
+  - `currentPresentation`
+  - `currentContentBundle`
 
 ### `GET /cards`
 
 用途:
 
 - 只返回卡片列表
+- 当前与 `/home` 使用同一套卡片摘要语义
 
 ### `GET /me/entry-home`
 
@@ -111,6 +122,7 @@
 用途:
 
 - 首页聚合接口
+- 当前 `cards` 也已统一使用活动卡片最小摘要字段
 
 返回重点:
 
@@ -179,6 +191,15 @@
 - `play.ongoingSession`
 - `play.recentSession`
 
+当前 `play.canLaunch=true` 的最小前置条件为:
+
+- event `status = active`
+- 当前已发布 release 存在
+- 当前已发布 release 有 `manifest`
+- 当前已发布 release 已绑定 `runtime`
+- 当前已发布 release 已绑定 `presentation`
+- 当前已发布 release 已绑定 `content bundle`
+
 当前摘要字段最少包括:
 
 - `currentPresentation.presentationId`
@@ -210,6 +231,12 @@
 - 如果当前 release 声明了 `play.courseVariants[]`
 - `launch` 会返回最终绑定的 `launch.variant`
 - 当前为兼容旧调用方,`assignmentMode=manual` 且未传 `variantId` 时,backend 会先回退到首个可选 variant
+- 当前 `launch` 与 `play.canLaunch` 使用同一套前置条件
+- 若当前发布 release 缺少:
+  - `runtime`
+  - `presentation`
+  - `content bundle`
+  之一,`launch` 会直接返回 `409`
 
 返回重点:
 
@@ -530,6 +557,38 @@
   - `playfield.kind`
   - `game.mode`
 
+### `GET /dev/demo-assets/presentations/{demoKey}`
+
+环境:
+
+- 仅 non-production
+
+用途:
+
+- 返回联调用的示例展示定义 schema
+- 给 workbench 的玩法切换自动填充真实 `presentation schema` 资源地址
+
+路径参数:
+
+- `demoKey`
+  - 当前支持:`classic`、`score-o`、`manual-variant`
+
+### `GET /dev/demo-assets/content-manifests/{demoKey}`
+
+环境:
+
+- 仅 non-production
+
+用途:
+
+- 返回联调用的示例内容 manifest
+- 给 workbench 的玩法切换自动填充真实 `content manifest` 资源地址
+
+路径参数:
+
+- `demoKey`
+  - 当前支持:`classic`、`score-o`、`manual-variant`
+
 补充说明:
 
 - 只用于 workbench 联调排查
@@ -1357,3 +1416,36 @@
 - 查看单个运行绑定详情
 
 
+### `GET /home`
+
+用途:
+
+- 返回入口首页摘要
+- 当前卡片摘要字段已统一补齐:
+  - `summary`
+  - `status`
+  - `statusCode`
+  - `timeWindow`
+  - `ctaText`
+  - `isDefaultExperience`
+  - `eventType`
+  - `currentPresentation`
+  - `currentContentBundle`
+
+### `GET /cards`
+
+用途:
+
+- 按入口返回活动卡片列表
+- 当前与 `/home` 使用同一套卡片摘要语义
+
+### `GET /me/entry-home`
+
+鉴权:
+
+- Bearer token
+
+用途:
+
+- 返回“我的首页”聚合
+- 当前 `cards` 也已统一使用活动卡片最小摘要字段

+ 14 - 3
backend/docs/数据模型.md

@@ -1,8 +1,8 @@
 # 数据模型
-> 文档版本:v1.3
-> 最后更新:2026-04-03 12:36:15
+> 文档版本:v1.4
+> 最后更新:2026-04-03 22:34:08
 
-当前 migration 共 10 版。
+当前 migration 共 11 版。
 
 ## 1. 迁移清单
 
@@ -16,6 +16,7 @@
 - [0008_production_skeleton.sql](D:/dev/cmr-mini/backend/migrations/0008_production_skeleton.sql)
 - [0009_event_ops_phase2.sql](D:/dev/cmr-mini/backend/migrations/0009_event_ops_phase2.sql)
 - [0010_event_default_bindings.sql](D:/dev/cmr-mini/backend/migrations/0010_event_default_bindings.sql)
+- [0011_card_summary.sql](D:/dev/cmr-mini/backend/migrations/0011_card_summary.sql)
 
 ## 2. 表分组
 
@@ -78,6 +79,16 @@
 - 支撑首页卡片
 - 运营入口聚合
 - tenant/channel 维度展示控制
+- 默认体验活动标记
+
+当前补充字段:
+
+- `cards.is_default_experience`
+
+当前说明:
+
+- 活动卡片列表第一刀先通过卡片显式字段承接“默认体验活动 / 普通活动”区分
+- `timeWindow / ctaText / status` 当前先由 backend 摘要层派生,不再额外新增对象层级
 
 ### 2.5 运行态
 

+ 13 - 2
backend/docs/核心流程.md

@@ -1,6 +1,6 @@
 # 核心流程
-> 文档版本:v1.2
-> 最后更新:2026-04-03 11:22:50
+> 文档版本:v1.3
+> 最后更新:2026-04-03 18:16:19
 
 
 ## 1. 总流程
@@ -104,6 +104,17 @@ APP 当前主链是手机号验证码:
 - 是否有 ongoing session
 - 当前推荐动作是什么
 
+补充规则:
+
+- `play.canLaunch` 不是“有 event 就能进”
+- 它当前表示“当前发布 release 已完整可启动”
+- 最小要求为:
+  - 已发布 release 存在
+  - manifest 存在
+  - runtime 已绑定
+  - presentation 已绑定
+  - content bundle 已绑定
+
 当前聚合接口:
 
 - `GET /events/{eventPublicID}/play`

File diff suppressed because it is too large
+ 626 - 46
backend/internal/httpapi/handlers/dev_handler.go


+ 2 - 0
backend/internal/httpapi/router.go

@@ -109,6 +109,8 @@ func NewRouter(
 		mux.HandleFunc("GET /dev/client-logs", devHandler.ListClientLogs)
 		mux.HandleFunc("DELETE /dev/client-logs", devHandler.ClearClientLogs)
 		mux.HandleFunc("GET /dev/manifest-summary", devHandler.ManifestSummary)
+		mux.HandleFunc("GET /dev/demo-assets/presentations/{demoKey}", devHandler.DemoPresentationSchema)
+		mux.HandleFunc("GET /dev/demo-assets/content-manifests/{demoKey}", devHandler.DemoContentManifest)
 		mux.HandleFunc("GET /dev/config/local-files", configHandler.ListLocalFiles)
 		mux.HandleFunc("POST /dev/events/{eventPublicID}/config-sources/import-local", configHandler.ImportLocal)
 		mux.HandleFunc("POST /dev/config-builds/preview", configHandler.BuildPreview)

+ 4 - 4
backend/internal/service/event_play_service.go

@@ -129,7 +129,7 @@ func (s *EventPlayService) GetEventPlay(ctx context.Context, input EventPlayInpu
 		}
 	}
 
-	canLaunch := event.Status == "active" && event.CurrentReleaseID != nil && event.ManifestURL != nil
+	canLaunch, launchReason := evaluateEventLaunchReadiness(event)
 	result.Play.CanLaunch = canLaunch
 	if canLaunch {
 		result.Play.LaunchSource = LaunchSourceEventCurrentRelease
@@ -141,13 +141,13 @@ func (s *EventPlayService) GetEventPlay(ctx context.Context, input EventPlayInpu
 		result.Play.Reason = "user has an ongoing session for this event"
 	case canLaunch:
 		result.Play.PrimaryAction = "start"
-		result.Play.Reason = "event is active and launchable"
+		result.Play.Reason = launchReason
 	case result.Play.RecentSession != nil:
 		result.Play.PrimaryAction = "review_last_result"
-		result.Play.Reason = "event is not launchable, but user has previous session history"
+		result.Play.Reason = launchReason + ", but user has previous session history"
 	default:
 		result.Play.PrimaryAction = "unavailable"
-		result.Play.Reason = "event is not launchable"
+		result.Play.Reason = launchReason
 	}
 
 	return result, nil

+ 2 - 5
backend/internal/service/event_service.go

@@ -152,11 +152,8 @@ func (s *EventService) LaunchEvent(ctx context.Context, input LaunchEventInput)
 	if event == nil {
 		return nil, apperr.New(http.StatusNotFound, "event_not_found", "event not found")
 	}
-	if event.Status != "active" {
-		return nil, apperr.New(http.StatusConflict, "event_not_launchable", "event is not active")
-	}
-	if event.CurrentReleaseID == nil || event.CurrentReleasePubID == nil || event.ConfigLabel == nil || event.ManifestURL == nil {
-		return nil, apperr.New(http.StatusConflict, "event_release_missing", "event does not have a published release")
+	if canLaunch, reason := evaluateEventLaunchReadiness(event); !canLaunch {
+		return nil, launchReadinessError(reason)
 	}
 	if input.ReleaseID != "" && input.ReleaseID != *event.CurrentReleasePubID {
 		return nil, apperr.New(http.StatusConflict, "release_not_launchable", "requested release is not the current published release")

+ 169 - 16
backend/internal/service/home_service.go

@@ -24,17 +24,27 @@ type ListCardsInput struct {
 }
 
 type CardResult struct {
-	ID              string  `json:"id"`
-	Type            string  `json:"type"`
-	Title           string  `json:"title"`
-	Subtitle        *string `json:"subtitle,omitempty"`
-	CoverURL        *string `json:"coverUrl,omitempty"`
-	DisplaySlot     string  `json:"displaySlot"`
-	DisplayPriority int     `json:"displayPriority"`
-	Event           *struct {
+	ID                   string                    `json:"id"`
+	Type                 string                    `json:"type"`
+	Title                string                    `json:"title"`
+	Subtitle             *string                   `json:"subtitle,omitempty"`
+	Summary              *string                   `json:"summary,omitempty"`
+	CoverURL             *string                   `json:"coverUrl,omitempty"`
+	DisplaySlot          string                    `json:"displaySlot"`
+	DisplayPriority      int                       `json:"displayPriority"`
+	Status               string                    `json:"status"`
+	StatusCode           string                    `json:"statusCode"`
+	TimeWindow           string                    `json:"timeWindow"`
+	CTAText              string                    `json:"ctaText"`
+	IsDefaultExperience  bool                      `json:"isDefaultExperience"`
+	EventType            *string                   `json:"eventType,omitempty"`
+	CurrentPresentation  *PresentationSummaryView  `json:"currentPresentation,omitempty"`
+	CurrentContentBundle *ContentBundleSummaryView `json:"currentContentBundle,omitempty"`
+	Event                *struct {
 		ID          string  `json:"id"`
 		DisplayName string  `json:"displayName"`
 		Summary     *string `json:"summary,omitempty"`
+		Status      *string `json:"status,omitempty"`
 	} `json:"event,omitempty"`
 	HTMLURL *string `json:"htmlUrl,omitempty"`
 }
@@ -128,23 +138,35 @@ func normalizeSlot(slot string) string {
 func mapCards(cards []postgres.Card) []CardResult {
 	results := make([]CardResult, 0, len(cards))
 	for _, card := range cards {
+		statusCode, statusText := deriveCardStatus(card)
 		item := CardResult{
-			ID:              card.PublicID,
-			Type:            card.CardType,
-			Title:           card.Title,
-			Subtitle:        card.Subtitle,
-			CoverURL:        card.CoverURL,
-			DisplaySlot:     card.DisplaySlot,
-			DisplayPriority: card.DisplayPriority,
-			HTMLURL:         card.HTMLURL,
+			ID:                   card.PublicID,
+			Type:                 card.CardType,
+			Title:                fallbackCardTitle(card.Title),
+			Subtitle:             card.Subtitle,
+			Summary:              fallbackCardSummary(card.EventSummary),
+			CoverURL:             card.CoverURL,
+			DisplaySlot:          card.DisplaySlot,
+			DisplayPriority:      card.DisplayPriority,
+			Status:               statusText,
+			StatusCode:           statusCode,
+			TimeWindow:           deriveCardTimeWindow(card),
+			CTAText:              deriveCardCTAText(card, statusCode),
+			IsDefaultExperience:  card.IsDefaultExperience,
+			EventType:            deriveCardEventType(card),
+			CurrentPresentation:  buildCardPresentationSummary(card),
+			CurrentContentBundle: buildCardContentBundleSummary(card),
+			HTMLURL:              card.HTMLURL,
 		}
 		if card.EventPublicID != nil || card.EventDisplayName != nil {
 			item.Event = &struct {
 				ID          string  `json:"id"`
 				DisplayName string  `json:"displayName"`
 				Summary     *string `json:"summary,omitempty"`
+				Status      *string `json:"status,omitempty"`
 			}{
 				Summary: card.EventSummary,
+				Status:  card.EventStatus,
 			}
 			if card.EventPublicID != nil {
 				item.Event.ID = *card.EventPublicID
@@ -157,3 +179,134 @@ func mapCards(cards []postgres.Card) []CardResult {
 	}
 	return results
 }
+
+func fallbackCardTitle(title string) string {
+	title = strings.TrimSpace(title)
+	if title == "" {
+		return "未命名活动"
+	}
+	return title
+}
+
+func fallbackCardSummary(summary *string) *string {
+	if summary != nil && strings.TrimSpace(*summary) != "" {
+		return summary
+	}
+	text := "当前暂无活动摘要"
+	return &text
+}
+
+func deriveCardStatus(card postgres.Card) (string, string) {
+	if card.EventStatus == nil {
+		return "pending", "状态待确认"
+	}
+	switch strings.TrimSpace(*card.EventStatus) {
+	case "active":
+		if card.EventCurrentReleasePubID == nil {
+			return "upcoming", "即将开始"
+		}
+		if card.EventRuntimeBindingID == nil || card.EventPresentationID == nil || card.EventContentBundleID == nil {
+			return "upcoming", "即将开始"
+		}
+		return "running", "进行中"
+	case "archived", "disabled", "inactive":
+		return "ended", "已结束"
+	default:
+		return "pending", "状态待确认"
+	}
+}
+
+func deriveCardTimeWindow(card postgres.Card) string {
+	if card.StartsAt == nil && card.EndsAt == nil {
+		return "时间待公布"
+	}
+	const layout = "01-02 15:04"
+	switch {
+	case card.StartsAt != nil && card.EndsAt != nil:
+		return card.StartsAt.Local().Format(layout) + " - " + card.EndsAt.Local().Format(layout)
+	case card.StartsAt != nil:
+		return "开始于 " + card.StartsAt.Local().Format(layout)
+	default:
+		return "截止至 " + card.EndsAt.Local().Format(layout)
+	}
+}
+
+func deriveCardCTAText(card postgres.Card, statusCode string) string {
+	if card.IsDefaultExperience {
+		return "进入体验"
+	}
+	switch statusCode {
+	case "running":
+		return "进入活动"
+	case "ended":
+		return "查看回顾"
+	default:
+		return "查看详情"
+	}
+}
+
+func deriveCardEventType(card postgres.Card) *string {
+	if card.EventReleasePayloadJSON != nil {
+		payload, err := decodeJSONObject(*card.EventReleasePayloadJSON)
+		if err == nil {
+			if game, ok := payload["game"].(map[string]any); ok {
+				if rawMode, ok := game["mode"].(string); ok {
+					switch strings.TrimSpace(rawMode) {
+					case "classic-sequential":
+						text := "顺序赛"
+						return &text
+					case "score-o":
+						text := "积分赛"
+						return &text
+					}
+				}
+			}
+			if plan := resolveVariantPlan(card.EventReleasePayloadJSON); plan.AssignmentMode != nil && *plan.AssignmentMode == AssignmentModeManual {
+				text := "多赛道"
+				return &text
+			}
+		}
+	}
+	if card.IsDefaultExperience {
+		text := "体验活动"
+		return &text
+	}
+	return nil
+}
+
+func buildCardPresentationSummary(card postgres.Card) *PresentationSummaryView {
+	if card.EventPresentationID == nil {
+		return nil
+	}
+	summary := &PresentationSummaryView{
+		PresentationID:   *card.EventPresentationID,
+		Name:             card.EventPresentationName,
+		PresentationType: card.EventPresentationType,
+	}
+	if card.EventPresentationSchemaJSON != nil && strings.TrimSpace(*card.EventPresentationSchemaJSON) != "" {
+		if schema, err := decodeJSONObject(*card.EventPresentationSchemaJSON); err == nil {
+			summary.TemplateKey = readStringField(schema, "templateKey")
+			summary.Version = readStringField(schema, "version")
+		}
+	}
+	return summary
+}
+
+func buildCardContentBundleSummary(card postgres.Card) *ContentBundleSummaryView {
+	if card.EventContentBundleID == nil {
+		return nil
+	}
+	summary := &ContentBundleSummaryView{
+		ContentBundleID: *card.EventContentBundleID,
+		Name:            card.EventContentBundleName,
+		EntryURL:        card.EventContentEntryURL,
+		AssetRootURL:    card.EventContentAssetRootURL,
+	}
+	if card.EventContentMetadataJSON != nil && strings.TrimSpace(*card.EventContentMetadataJSON) != "" {
+		if metadata, err := decodeJSONObject(*card.EventContentMetadataJSON); err == nil {
+			summary.BundleType = readStringField(metadata, "bundleType")
+			summary.Version = readStringField(metadata, "version")
+		}
+	}
+	return summary
+}

+ 56 - 0
backend/internal/service/launch_rules.go

@@ -0,0 +1,56 @@
+package service
+
+import (
+	"net/http"
+
+	"cmr-backend/internal/apperr"
+	"cmr-backend/internal/store/postgres"
+)
+
+const (
+	launchReadyReasonOK                  = "event is active and launchable"
+	launchReadyReasonNotActive           = "event is not active"
+	launchReadyReasonReleaseMissing      = "event does not have a published release"
+	launchReadyReasonRuntimeMissing      = "current published release is missing runtime binding"
+	launchReadyReasonPresentationMissing = "current published release is missing presentation binding"
+	launchReadyReasonContentMissing      = "current published release is missing content bundle binding"
+)
+
+func evaluateEventLaunchReadiness(event *postgres.Event) (bool, string) {
+	if event == nil {
+		return false, launchReadyReasonReleaseMissing
+	}
+	if event.Status != "active" {
+		return false, launchReadyReasonNotActive
+	}
+	if event.CurrentReleaseID == nil || event.CurrentReleasePubID == nil || event.ConfigLabel == nil || event.ManifestURL == nil {
+		return false, launchReadyReasonReleaseMissing
+	}
+	if buildRuntimeSummaryFromEvent(event) == nil {
+		return false, launchReadyReasonRuntimeMissing
+	}
+	if buildPresentationSummaryFromEvent(event) == nil {
+		return false, launchReadyReasonPresentationMissing
+	}
+	if buildContentBundleSummaryFromEvent(event) == nil {
+		return false, launchReadyReasonContentMissing
+	}
+	return true, launchReadyReasonOK
+}
+
+func launchReadinessError(reason string) error {
+	switch reason {
+	case launchReadyReasonNotActive:
+		return apperr.New(http.StatusConflict, "event_not_launchable", reason)
+	case launchReadyReasonReleaseMissing:
+		return apperr.New(http.StatusConflict, "event_release_missing", reason)
+	case launchReadyReasonRuntimeMissing:
+		return apperr.New(http.StatusConflict, "event_release_runtime_missing", reason)
+	case launchReadyReasonPresentationMissing:
+		return apperr.New(http.StatusConflict, "event_release_presentation_missing", reason)
+	case launchReadyReasonContentMissing:
+		return apperr.New(http.StatusConflict, "event_release_content_bundle_missing", reason)
+	default:
+		return apperr.New(http.StatusConflict, "event_not_launchable", reason)
+	}
+}

+ 71 - 13
backend/internal/store/postgres/card_store.go

@@ -7,19 +7,37 @@ import (
 )
 
 type Card struct {
-	ID               string
-	PublicID         string
-	CardType         string
-	Title            string
-	Subtitle         *string
-	CoverURL         *string
-	DisplaySlot      string
-	DisplayPriority  int
-	EntryChannelID   *string
-	EventPublicID    *string
-	EventDisplayName *string
-	EventSummary     *string
-	HTMLURL          *string
+	ID                          string
+	PublicID                    string
+	CardType                    string
+	Title                       string
+	Subtitle                    *string
+	CoverURL                    *string
+	DisplaySlot                 string
+	DisplayPriority             int
+	IsDefaultExperience         bool
+	StartsAt                    *time.Time
+	EndsAt                      *time.Time
+	EntryChannelID              *string
+	EventPublicID               *string
+	EventDisplayName            *string
+	EventSummary                *string
+	EventStatus                 *string
+	EventCurrentReleasePubID    *string
+	EventConfigLabel            *string
+	EventRouteCode              *string
+	EventReleasePayloadJSON     *string
+	EventRuntimeBindingID       *string
+	EventPresentationID         *string
+	EventPresentationName       *string
+	EventPresentationType       *string
+	EventPresentationSchemaJSON *string
+	EventContentBundleID        *string
+	EventContentBundleName      *string
+	EventContentEntryURL        *string
+	EventContentAssetRootURL    *string
+	EventContentMetadataJSON    *string
+	HTMLURL                     *string
 }
 
 func (s *Store) ListCardsForEntry(ctx context.Context, tenantID string, entryChannelID *string, slot string, now time.Time, limit int) ([]Card, error) {
@@ -40,13 +58,35 @@ func (s *Store) ListCardsForEntry(ctx context.Context, tenantID string, entryCha
 			c.cover_url,
 			c.display_slot,
 			c.display_priority,
+			c.is_default_experience,
+			c.starts_at,
+			c.ends_at,
 			c.entry_channel_id,
 			e.event_public_id,
 			e.display_name,
 			e.summary,
+			e.status,
+			er.release_public_id,
+			er.config_label,
+			er.route_code,
+			er.payload_jsonb::text,
+			mrb.runtime_binding_public_id,
+			ep.presentation_public_id,
+			ep.name,
+			ep.presentation_type,
+			ep.schema_jsonb::text,
+			cb.content_bundle_public_id,
+			cb.name,
+			cb.entry_url,
+			cb.asset_root_url,
+			cb.metadata_jsonb::text,
 			c.html_url
 		FROM cards c
 		LEFT JOIN events e ON e.id = c.event_id
+		LEFT JOIN event_releases er ON er.id = e.current_release_id
+		LEFT JOIN map_runtime_bindings mrb ON mrb.id = er.runtime_binding_id
+		LEFT JOIN event_presentations ep ON ep.id = er.presentation_id
+		LEFT JOIN content_bundles cb ON cb.id = er.content_bundle_id
 		WHERE c.tenant_id = $1
 		  AND ($2::uuid IS NULL OR c.entry_channel_id = $2 OR c.entry_channel_id IS NULL)
 		  AND c.display_slot = $3
@@ -76,10 +116,28 @@ func (s *Store) ListCardsForEntry(ctx context.Context, tenantID string, entryCha
 			&card.CoverURL,
 			&card.DisplaySlot,
 			&card.DisplayPriority,
+			&card.IsDefaultExperience,
+			&card.StartsAt,
+			&card.EndsAt,
 			&card.EntryChannelID,
 			&card.EventPublicID,
 			&card.EventDisplayName,
 			&card.EventSummary,
+			&card.EventStatus,
+			&card.EventCurrentReleasePubID,
+			&card.EventConfigLabel,
+			&card.EventRouteCode,
+			&card.EventReleasePayloadJSON,
+			&card.EventRuntimeBindingID,
+			&card.EventPresentationID,
+			&card.EventPresentationName,
+			&card.EventPresentationType,
+			&card.EventPresentationSchemaJSON,
+			&card.EventContentBundleID,
+			&card.EventContentBundleName,
+			&card.EventContentEntryURL,
+			&card.EventContentAssetRootURL,
+			&card.EventContentMetadataJSON,
 			&card.HTMLURL,
 		); err != nil {
 			return nil, fmt.Errorf("scan card: %w", err)

+ 298 - 40
backend/internal/store/postgres/dev_store.go

@@ -31,6 +31,11 @@ type DemoBootstrapSummary struct {
 	VariantManualEventID   string `json:"variantManualEventId"`
 	VariantManualRelease   string `json:"variantManualReleaseId"`
 	VariantManualCardID    string `json:"variantManualCardId"`
+	VariantManualSourceID  string `json:"variantManualSourceId"`
+	VariantManualBuildID   string `json:"variantManualBuildId"`
+	VariantManualCourseSet string `json:"variantManualCourseSetId"`
+	VariantManualVariantID string `json:"variantManualCourseVariantId"`
+	VariantManualRuntimeID string `json:"variantManualRuntimeBindingId"`
 	CleanedSessionCount    int64  `json:"cleanedSessionCount"`
 }
 
@@ -44,7 +49,7 @@ func (s *Store) EnsureDemoData(ctx context.Context) (*DemoBootstrapSummary, erro
 	var tenantID string
 	if err := tx.QueryRow(ctx, `
 		INSERT INTO tenants (tenant_code, name, status)
-		VALUES ('tenant_demo', 'Demo Tenant', 'active')
+		VALUES ('tenant_demo', '联调租户', 'active')
 		ON CONFLICT (tenant_code) DO UPDATE SET
 			name = EXCLUDED.name,
 			status = EXCLUDED.status
@@ -58,7 +63,7 @@ func (s *Store) EnsureDemoData(ctx context.Context) (*DemoBootstrapSummary, erro
 		INSERT INTO entry_channels (
 			tenant_id, channel_code, channel_type, platform_app_id, display_name, status, is_default
 		)
-		VALUES ($1, 'mini-demo', 'wechat_mini', 'wx-demo-appid', 'Demo Mini Channel', 'active', true)
+		VALUES ($1, 'mini-demo', 'wechat_mini', 'wx-demo-appid', '小程序联调入口', 'active', true)
 		ON CONFLICT (tenant_id, channel_code) DO UPDATE SET
 			channel_type = EXCLUDED.channel_type,
 			platform_app_id = EXCLUDED.platform_app_id,
@@ -75,7 +80,7 @@ func (s *Store) EnsureDemoData(ctx context.Context) (*DemoBootstrapSummary, erro
 		INSERT INTO events (
 			tenant_id, event_public_id, slug, display_name, summary, status
 		)
-		VALUES ($1, 'evt_demo_001', 'demo-city-run', 'Demo City Run', 'Launch flow demo event', 'active')
+		VALUES ($1, 'evt_demo_001', 'city-park-classic', '领秀城公园顺序赛', '顺序赛联调样例活动', 'active')
 		ON CONFLICT (event_public_id) DO UPDATE SET
 			tenant_id = EXCLUDED.tenant_id,
 			slug = EXCLUDED.slug,
@@ -106,7 +111,7 @@ func (s *Store) EnsureDemoData(ctx context.Context) (*DemoBootstrapSummary, erro
 			'rel_demo_001',
 			$1,
 			1,
-			'Demo Config v1',
+			'顺序赛联调配置 v1',
 			'https://oss-mbh5.colormaprun.com/gotomars/event/releases/evt_demo_001/rel_e7dd953743c5c0d2/manifest.json',
 			'demo-checksum-001',
 			'route-demo-001',
@@ -132,7 +137,7 @@ func (s *Store) EnsureDemoData(ctx context.Context) (*DemoBootstrapSummary, erro
 		return nil, fmt.Errorf("attach demo release: %w", err)
 	}
 
-	sourceNotes := "demo source config imported from local event sample"
+	sourceNotes := "顺序赛联调 source 配置"
 	source, err := s.UpsertEventConfigSource(ctx, tx, UpsertEventConfigSourceParams{
 		EventID:         eventID,
 		SourceVersionNo: 1,
@@ -144,7 +149,7 @@ func (s *Store) EnsureDemoData(ctx context.Context) (*DemoBootstrapSummary, erro
 		Source: map[string]any{
 			"app": map[string]any{
 				"id":    "sample-classic-001",
-				"title": "顺序赛示例",
+				"title": "领秀城公园顺序赛",
 			},
 			"branding": map[string]any{
 				"tenantCode":   "tenant_demo",
@@ -173,7 +178,7 @@ func (s *Store) EnsureDemoData(ctx context.Context) (*DemoBootstrapSummary, erro
 		return nil, fmt.Errorf("ensure demo event config source: %w", err)
 	}
 
-	buildLog := "demo build generated from sample classic-sequential.json"
+	buildLog := "顺序赛联调 build 产物"
 	build, err := s.UpsertEventConfigBuild(ctx, tx, UpsertEventConfigBuildParams{
 		EventID:     eventID,
 		SourceID:    source.ID,
@@ -186,7 +191,7 @@ func (s *Store) EnsureDemoData(ctx context.Context) (*DemoBootstrapSummary, erro
 			"version":       "2026.04.01",
 			"app": map[string]any{
 				"id":    "sample-classic-001",
-				"title": "顺序赛示例",
+				"title": "领秀城公园顺序赛",
 			},
 			"map": map[string]any{
 				"tiles":   "https://oss-mbh5.colormaprun.com/gotomars/map/lxcb-001/tiles/",
@@ -296,20 +301,26 @@ func (s *Store) EnsureDemoData(ctx context.Context) (*DemoBootstrapSummary, erro
 			event_id,
 			display_slot,
 			display_priority,
-			status
+			status,
+			is_default_experience,
+			starts_at,
+			ends_at
 		)
 		VALUES (
 			'card_demo_001',
 			$1,
 			$2,
 			'event',
-			'Demo City Run',
-			'今日推荐路线',
+			'领秀城公园顺序赛',
+			'顺序赛推荐入口',
 			'https://oss-mbh5.colormaprun.com/gotomars/assets/demo-cover.jpg',
 			$3,
 			'home_primary',
 			100,
-			'active'
+			'active',
+			true,
+			NOW() - INTERVAL '1 day',
+			NOW() + INTERVAL '30 day'
 		)
 		ON CONFLICT (card_public_id) DO UPDATE SET
 			tenant_id = EXCLUDED.tenant_id,
@@ -321,7 +332,10 @@ func (s *Store) EnsureDemoData(ctx context.Context) (*DemoBootstrapSummary, erro
 			event_id = EXCLUDED.event_id,
 			display_slot = EXCLUDED.display_slot,
 			display_priority = EXCLUDED.display_priority,
-			status = EXCLUDED.status
+			status = EXCLUDED.status,
+			is_default_experience = EXCLUDED.is_default_experience,
+			starts_at = EXCLUDED.starts_at,
+			ends_at = EXCLUDED.ends_at
 		RETURNING card_public_id
 	`, tenantID, channelID, eventID).Scan(&cardPublicID); err != nil {
 		return nil, fmt.Errorf("ensure demo card: %w", err)
@@ -333,7 +347,7 @@ func (s *Store) EnsureDemoData(ctx context.Context) (*DemoBootstrapSummary, erro
 			place_public_id, code, name, region, status
 		)
 		VALUES (
-			'place_demo_001', 'place-demo-001', 'Demo Park', 'Shanghai', 'active'
+			'place_demo_001', 'place-demo-001', '领秀城公园', 'Shanghai', 'active'
 		)
 		ON CONFLICT (code) DO UPDATE SET
 			name = EXCLUDED.name,
@@ -350,7 +364,7 @@ func (s *Store) EnsureDemoData(ctx context.Context) (*DemoBootstrapSummary, erro
 			map_asset_public_id, place_id, code, name, map_type, status
 		)
 		VALUES (
-			'mapasset_demo_001', $1, 'mapasset-demo-001', 'Demo Asset Map', 'standard', 'active'
+			'mapasset_demo_001', $1, 'mapasset-demo-001', '领秀城公园基础底图', 'standard', 'active'
 		)
 		ON CONFLICT (code) DO UPDATE SET
 			place_id = EXCLUDED.place_id,
@@ -429,7 +443,7 @@ func (s *Store) EnsureDemoData(ctx context.Context) (*DemoBootstrapSummary, erro
 			course_set_public_id, place_id, map_asset_id, code, mode, name, status
 		)
 		VALUES (
-			'cset_demo_001', $1, $2, 'cset-demo-001', 'classic-sequential', 'Demo Course Set', 'active'
+			'cset_demo_001', $1, $2, 'cset-demo-001', 'classic-sequential', '顺序赛标准赛道', 'active'
 		)
 		ON CONFLICT (code) DO UPDATE SET
 			place_id = EXCLUDED.place_id,
@@ -448,7 +462,7 @@ func (s *Store) EnsureDemoData(ctx context.Context) (*DemoBootstrapSummary, erro
 			course_variant_public_id, course_set_id, source_id, name, route_code, mode, control_count, status, is_default
 		)
 		VALUES (
-			'cvariant_demo_001', $1, $2, 'Demo Variant A', 'route-demo-a', 'classic-sequential', 8, 'active', true
+			'cvariant_demo_001', $1, $2, '顺序赛 A 线', 'route-demo-a', 'classic-sequential', 8, 'active', true
 		)
 		ON CONFLICT (course_variant_public_id) DO UPDATE SET
 			course_set_id = EXCLUDED.course_set_id,
@@ -470,7 +484,7 @@ func (s *Store) EnsureDemoData(ctx context.Context) (*DemoBootstrapSummary, erro
 			course_variant_public_id, course_set_id, source_id, name, route_code, mode, control_count, status, is_default
 		)
 		VALUES (
-			'cvariant_demo_002', $1, $2, 'Demo Variant B', 'route-demo-b', 'classic-sequential', 10, 'active', false
+			'cvariant_demo_002', $1, $2, '顺序赛 B 线', 'route-demo-b', 'classic-sequential', 10, 'active', false
 		)
 		ON CONFLICT (course_variant_public_id) DO UPDATE SET
 			course_set_id = EXCLUDED.course_set_id,
@@ -500,7 +514,7 @@ func (s *Store) EnsureDemoData(ctx context.Context) (*DemoBootstrapSummary, erro
 			runtime_binding_public_id, event_id, place_id, map_asset_id, tile_release_id, course_set_id, course_variant_id, status, notes
 		)
 		VALUES (
-			'runtime_demo_001', $1, $2, $3, $4, $5, $6, 'active', 'demo runtime binding'
+			'runtime_demo_001', $1, $2, $3, $4, $5, $6, 'active', '顺序赛联调运行绑定'
 		)
 		ON CONFLICT (runtime_binding_public_id) DO UPDATE SET
 			event_id = EXCLUDED.event_id,
@@ -521,7 +535,7 @@ func (s *Store) EnsureDemoData(ctx context.Context) (*DemoBootstrapSummary, erro
 		INSERT INTO events (
 			tenant_id, event_public_id, slug, display_name, summary, status
 		)
-		VALUES ($1, 'evt_demo_variant_manual_001', 'demo-variant-manual-run', 'Demo Variant Manual Run', 'Manual 多赛道联调活动', 'active')
+		VALUES ($1, 'evt_demo_variant_manual_001', 'city-park-manual-variant', '领秀城公园多赛道挑战', '手动多赛道联调样例活动', 'active')
 		ON CONFLICT (event_public_id) DO UPDATE SET
 			tenant_id = EXCLUDED.tenant_id,
 			slug = EXCLUDED.slug,
@@ -553,7 +567,7 @@ func (s *Store) EnsureDemoData(ctx context.Context) (*DemoBootstrapSummary, erro
 			'rel_demo_variant_manual_001',
 			$1,
 			1,
-			'Demo Variant Manual Config v1',
+			'多赛道联调配置 v1',
 			'https://oss-mbh5.colormaprun.com/gotomars/event/releases/evt_demo_001/rel_e7dd953743c5c0d2/manifest.json',
 			'demo-variant-checksum-001',
 			'route-variant-a',
@@ -614,20 +628,26 @@ func (s *Store) EnsureDemoData(ctx context.Context) (*DemoBootstrapSummary, erro
 			event_id,
 			display_slot,
 			display_priority,
-			status
+			status,
+			is_default_experience,
+			starts_at,
+			ends_at
 		)
 		VALUES (
 			'card_demo_variant_manual_001',
 			$1,
 			$2,
 			'event',
-			'Demo Variant Manual Run',
-			'多赛道手动选择联调',
+			'领秀城公园多赛道挑战',
+			'手动选择赛道入口',
 			'https://oss-mbh5.colormaprun.com/gotomars/assets/demo-cover.jpg',
 			$3,
 			'home_primary',
 			95,
-			'active'
+			'active',
+			false,
+			NOW() - INTERVAL '1 day',
+			NOW() + INTERVAL '30 day'
 		)
 		ON CONFLICT (card_public_id) DO UPDATE SET
 			tenant_id = EXCLUDED.tenant_id,
@@ -639,18 +659,242 @@ func (s *Store) EnsureDemoData(ctx context.Context) (*DemoBootstrapSummary, erro
 			event_id = EXCLUDED.event_id,
 			display_slot = EXCLUDED.display_slot,
 			display_priority = EXCLUDED.display_priority,
-			status = EXCLUDED.status
+			status = EXCLUDED.status,
+			is_default_experience = EXCLUDED.is_default_experience,
+			starts_at = EXCLUDED.starts_at,
+			ends_at = EXCLUDED.ends_at
 		RETURNING card_public_id
 	`, tenantID, channelID, manualEventID).Scan(&manualCardPublicID); err != nil {
 		return nil, fmt.Errorf("ensure variant manual demo card: %w", err)
 	}
 
+	manualSourceNotes := "多赛道联调 source 配置"
+	manualSource, err := s.UpsertEventConfigSource(ctx, tx, UpsertEventConfigSourceParams{
+		EventID:         manualEventID,
+		SourceVersionNo: 1,
+		SourceKind:      "event_bundle",
+		SchemaID:        "event-source",
+		SchemaVersion:   "1",
+		Status:          "active",
+		Notes:           &manualSourceNotes,
+		Source: map[string]any{
+			"schemaVersion": "1",
+			"app": map[string]any{
+				"id":    "sample-variant-manual-001",
+				"title": "领秀城公园多赛道挑战",
+			},
+			"branding": map[string]any{
+				"tenantCode":   "tenant_demo",
+				"entryChannel": "mini-demo",
+			},
+			"map": map[string]any{
+				"tiles":   "../map/lxcb-001/tiles/",
+				"mapmeta": "../map/lxcb-001/tiles/meta.json",
+			},
+			"playfield": map[string]any{
+				"kind": "course",
+				"source": map[string]any{
+					"type": "kml",
+					"url":  "../kml/lxcb-001/10/c01.kml",
+				},
+			},
+			"game": map[string]any{
+				"mode": "classic-sequential",
+			},
+			"play": map[string]any{
+				"assignmentMode": "manual",
+				"courseVariants": []map[string]any{
+					{
+						"id":          "variant_a",
+						"name":        "A 线",
+						"description": "短线体验版(c01.kml)",
+						"routeCode":   "route-variant-a",
+						"selectable":  true,
+					},
+					{
+						"id":          "variant_b",
+						"name":        "B 线",
+						"description": "长线挑战版(c02.kml)",
+						"routeCode":   "route-variant-b",
+						"selectable":  true,
+					},
+				},
+			},
+			"content": map[string]any{
+				"h5Template": "content-h5-test-template.html",
+			},
+		},
+	})
+	if err != nil {
+		return nil, fmt.Errorf("ensure variant manual demo event config source: %w", err)
+	}
+
+	manualBuildLog := "多赛道联调 build 产物"
+	manualBuild, err := s.UpsertEventConfigBuild(ctx, tx, UpsertEventConfigBuildParams{
+		EventID:     manualEventID,
+		SourceID:    manualSource.ID,
+		BuildNo:     1,
+		BuildStatus: "success",
+		BuildLog:    &manualBuildLog,
+		Manifest: map[string]any{
+			"schemaVersion": "1",
+			"releaseId":     "rel_demo_variant_manual_001",
+			"version":       "2026.04.01",
+			"app": map[string]any{
+				"id":    "sample-variant-manual-001",
+				"title": "领秀城公园多赛道挑战",
+			},
+			"map": map[string]any{
+				"tiles":   "https://oss-mbh5.colormaprun.com/gotomars/map/lxcb-001/tiles/",
+				"mapmeta": "https://oss-mbh5.colormaprun.com/gotomars/map/lxcb-001/tiles/meta.json",
+			},
+			"playfield": map[string]any{
+				"kind": "course",
+				"source": map[string]any{
+					"type": "kml",
+					"url":  "https://oss-mbh5.colormaprun.com/gotomars/kml/lxcb-001/10/c01.kml",
+				},
+			},
+			"game": map[string]any{
+				"mode": "classic-sequential",
+			},
+			"play": map[string]any{
+				"assignmentMode": "manual",
+				"courseVariants": []map[string]any{
+					{
+						"id":          "variant_a",
+						"name":        "A 线",
+						"description": "短线体验版(c01.kml)",
+						"routeCode":   "route-variant-a",
+						"selectable":  true,
+					},
+					{
+						"id":          "variant_b",
+						"name":        "B 线",
+						"description": "长线挑战版(c02.kml)",
+						"routeCode":   "route-variant-b",
+						"selectable":  true,
+					},
+				},
+			},
+			"assets": map[string]any{
+				"contentHtml": "https://oss-mbh5.colormaprun.com/gotomars/event/content-h5-test-template.html",
+			},
+		},
+		AssetIndex: []map[string]any{
+			{"assetType": "manifest", "assetKey": "manifest"},
+			{"assetType": "mapmeta", "assetKey": "mapmeta"},
+			{"assetType": "playfield", "assetKey": "playfield-kml"},
+			{"assetType": "content_html", "assetKey": "content-html"},
+		},
+	})
+	if err != nil {
+		return nil, fmt.Errorf("ensure variant manual demo event config build: %w", err)
+	}
+
+	if err := s.AttachBuildToRelease(ctx, tx, manualReleaseRow.ID, manualBuild.ID); err != nil {
+		return nil, fmt.Errorf("attach variant manual demo build to release: %w", err)
+	}
+
+	var manualCourseSetID, manualCourseSetPublicID string
+	if err := tx.QueryRow(ctx, `
+		INSERT INTO course_sets (
+			course_set_public_id, place_id, map_asset_id, code, mode, name, status
+		)
+		VALUES (
+			'cset_demo_variant_manual_001', $1, $2, 'cset-demo-variant-manual-001', 'classic-sequential', '多赛道挑战赛道集', 'active'
+		)
+		ON CONFLICT (code) DO UPDATE SET
+			place_id = EXCLUDED.place_id,
+			map_asset_id = EXCLUDED.map_asset_id,
+			mode = EXCLUDED.mode,
+			name = EXCLUDED.name,
+			status = EXCLUDED.status
+		RETURNING id, course_set_public_id
+	`, placeID, mapAssetID).Scan(&manualCourseSetID, &manualCourseSetPublicID); err != nil {
+		return nil, fmt.Errorf("ensure variant manual demo course set: %w", err)
+	}
+
+	var manualVariantAID string
+	if err := tx.QueryRow(ctx, `
+		INSERT INTO course_variants (
+			course_variant_public_id, course_set_id, source_id, name, route_code, mode, control_count, status, is_default
+		)
+		VALUES (
+			'cvariant_demo_variant_manual_a', $1, $2, '多赛道 A 线', 'route-variant-a', 'classic-sequential', 8, 'active', false
+		)
+		ON CONFLICT (course_variant_public_id) DO UPDATE SET
+			course_set_id = EXCLUDED.course_set_id,
+			source_id = EXCLUDED.source_id,
+			name = EXCLUDED.name,
+			route_code = EXCLUDED.route_code,
+			mode = EXCLUDED.mode,
+			control_count = EXCLUDED.control_count,
+			status = EXCLUDED.status,
+			is_default = EXCLUDED.is_default
+		RETURNING id
+	`, manualCourseSetID, courseSourceID).Scan(&manualVariantAID); err != nil {
+		return nil, fmt.Errorf("ensure variant manual demo variant a: %w", err)
+	}
+
+	var manualVariantBID, manualVariantBPublicID string
+	if err := tx.QueryRow(ctx, `
+		INSERT INTO course_variants (
+			course_variant_public_id, course_set_id, source_id, name, route_code, mode, control_count, status, is_default
+		)
+		VALUES (
+			'cvariant_demo_variant_manual_b', $1, $2, '多赛道 B 线', 'route-variant-b', 'classic-sequential', 10, 'active', true
+		)
+		ON CONFLICT (course_variant_public_id) DO UPDATE SET
+			course_set_id = EXCLUDED.course_set_id,
+			source_id = EXCLUDED.source_id,
+			name = EXCLUDED.name,
+			route_code = EXCLUDED.route_code,
+			mode = EXCLUDED.mode,
+			control_count = EXCLUDED.control_count,
+			status = EXCLUDED.status,
+			is_default = EXCLUDED.is_default
+		RETURNING id, course_variant_public_id
+	`, manualCourseSetID, courseSourceVariantBID).Scan(&manualVariantBID, &manualVariantBPublicID); err != nil {
+		return nil, fmt.Errorf("ensure variant manual demo variant b: %w", err)
+	}
+
+	if _, err := tx.Exec(ctx, `
+		UPDATE course_sets
+		SET current_variant_id = $2
+		WHERE id = $1
+	`, manualCourseSetID, manualVariantBID); err != nil {
+		return nil, fmt.Errorf("attach variant manual demo course variant: %w", err)
+	}
+
+	var manualRuntimeBindingID, manualRuntimeBindingPublicID string
+	if err := tx.QueryRow(ctx, `
+		INSERT INTO map_runtime_bindings (
+			runtime_binding_public_id, event_id, place_id, map_asset_id, tile_release_id, course_set_id, course_variant_id, status, notes
+		)
+		VALUES (
+			'runtime_demo_variant_manual_001', $1, $2, $3, $4, $5, $6, 'active', '多赛道联调运行绑定'
+		)
+		ON CONFLICT (runtime_binding_public_id) DO UPDATE SET
+			event_id = EXCLUDED.event_id,
+			place_id = EXCLUDED.place_id,
+			map_asset_id = EXCLUDED.map_asset_id,
+			tile_release_id = EXCLUDED.tile_release_id,
+			course_set_id = EXCLUDED.course_set_id,
+			course_variant_id = EXCLUDED.course_variant_id,
+			status = EXCLUDED.status,
+			notes = EXCLUDED.notes
+		RETURNING id, runtime_binding_public_id
+	`, manualEventID, placeID, mapAssetID, tileReleaseID, manualCourseSetID, manualVariantBID).Scan(&manualRuntimeBindingID, &manualRuntimeBindingPublicID); err != nil {
+		return nil, fmt.Errorf("ensure variant manual demo runtime binding: %w", err)
+	}
+
 	var scoreOEventID string
 	if err := tx.QueryRow(ctx, `
 		INSERT INTO events (
 			tenant_id, event_public_id, slug, display_name, summary, status
 		)
-		VALUES ($1, 'evt_demo_score_o_001', 'demo-score-o-run', 'Demo Score-O Run', '积分赛联调活动', 'active')
+		VALUES ($1, 'evt_demo_score_o_001', 'city-park-score-o', '领秀城公园积分赛', '积分赛联调样例活动', 'active')
 		ON CONFLICT (event_public_id) DO UPDATE SET
 			tenant_id = EXCLUDED.tenant_id,
 			slug = EXCLUDED.slug,
@@ -681,7 +925,7 @@ func (s *Store) EnsureDemoData(ctx context.Context) (*DemoBootstrapSummary, erro
 			'rel_demo_score_o_001',
 			$1,
 			1,
-			'Demo Score-O Config v1',
+			'积分赛联调配置 v1',
 			'https://oss-mbh5.colormaprun.com/gotomars/event/score-o.json',
 			'demo-score-o-checksum-001',
 			'route-score-o-001',
@@ -707,7 +951,7 @@ func (s *Store) EnsureDemoData(ctx context.Context) (*DemoBootstrapSummary, erro
 		return nil, fmt.Errorf("attach score-o demo release: %w", err)
 	}
 
-	scoreOSourceNotes := "demo source config imported from local event sample score-o"
+	scoreOSourceNotes := "积分赛联调 source 配置"
 	scoreOSource, err := s.UpsertEventConfigSource(ctx, tx, UpsertEventConfigSourceParams{
 		EventID:         scoreOEventID,
 		SourceVersionNo: 1,
@@ -720,7 +964,7 @@ func (s *Store) EnsureDemoData(ctx context.Context) (*DemoBootstrapSummary, erro
 			"schemaVersion": "1",
 			"app": map[string]any{
 				"id":    "sample-score-o-001",
-				"title": "积分赛示例",
+				"title": "领秀城公园积分赛",
 			},
 			"branding": map[string]any{
 				"tenantCode":   "tenant_demo",
@@ -749,7 +993,7 @@ func (s *Store) EnsureDemoData(ctx context.Context) (*DemoBootstrapSummary, erro
 		return nil, fmt.Errorf("ensure score-o demo event config source: %w", err)
 	}
 
-	scoreOBuildLog := "demo build generated from sample score-o.json"
+	scoreOBuildLog := "积分赛联调 build 产物"
 	scoreOBuild, err := s.UpsertEventConfigBuild(ctx, tx, UpsertEventConfigBuildParams{
 		EventID:     scoreOEventID,
 		SourceID:    scoreOSource.ID,
@@ -762,7 +1006,7 @@ func (s *Store) EnsureDemoData(ctx context.Context) (*DemoBootstrapSummary, erro
 			"version":       "2026.04.01",
 			"app": map[string]any{
 				"id":    "sample-score-o-001",
-				"title": "积分赛示例",
+				"title": "领秀城公园积分赛",
 			},
 			"map": map[string]any{
 				"tiles":   "https://oss-mbh5.colormaprun.com/gotomars/map/lxcb-001/tiles/",
@@ -810,20 +1054,26 @@ func (s *Store) EnsureDemoData(ctx context.Context) (*DemoBootstrapSummary, erro
 			event_id,
 			display_slot,
 			display_priority,
-			status
+			status,
+			is_default_experience,
+			starts_at,
+			ends_at
 		)
 		VALUES (
 			'card_demo_score_o_001',
 			$1,
 			$2,
 			'event',
-			'Demo Score-O Run',
-			'积分赛联调入口',
+			'领秀城公园积分赛',
+			'积分赛推荐入口',
 			'https://oss-mbh5.colormaprun.com/gotomars/assets/demo-cover.jpg',
 			$3,
 			'home_primary',
 			98,
-			'active'
+			'active',
+			false,
+			NOW() - INTERVAL '1 day',
+			NOW() + INTERVAL '30 day'
 		)
 		ON CONFLICT (card_public_id) DO UPDATE SET
 			tenant_id = EXCLUDED.tenant_id,
@@ -835,7 +1085,10 @@ func (s *Store) EnsureDemoData(ctx context.Context) (*DemoBootstrapSummary, erro
 			event_id = EXCLUDED.event_id,
 			display_slot = EXCLUDED.display_slot,
 			display_priority = EXCLUDED.display_priority,
-			status = EXCLUDED.status
+			status = EXCLUDED.status,
+			is_default_experience = EXCLUDED.is_default_experience,
+			starts_at = EXCLUDED.starts_at,
+			ends_at = EXCLUDED.ends_at
 		RETURNING card_public_id
 	`, tenantID, channelID, scoreOEventID).Scan(&scoreOCardPublicID); err != nil {
 		return nil, fmt.Errorf("ensure score-o demo card: %w", err)
@@ -847,7 +1100,7 @@ func (s *Store) EnsureDemoData(ctx context.Context) (*DemoBootstrapSummary, erro
 			course_set_public_id, place_id, map_asset_id, code, mode, name, status
 		)
 		VALUES (
-			'cset_demo_score_o_001', $1, $2, 'cset-demo-score-o-001', 'score-o', 'Demo Score-O Course Set', 'active'
+			'cset_demo_score_o_001', $1, $2, 'cset-demo-score-o-001', 'score-o', '积分赛标准赛道', 'active'
 		)
 		ON CONFLICT (code) DO UPDATE SET
 			place_id = EXCLUDED.place_id,
@@ -866,7 +1119,7 @@ func (s *Store) EnsureDemoData(ctx context.Context) (*DemoBootstrapSummary, erro
 			course_variant_public_id, course_set_id, source_id, name, route_code, mode, control_count, status, is_default
 		)
 		VALUES (
-			'cvariant_demo_score_o_001', $1, $2, 'Demo Score-O Variant', 'route-score-o-001', 'score-o', 10, 'active', true
+			'cvariant_demo_score_o_001', $1, $2, '积分赛主赛道', 'route-score-o-001', 'score-o', 10, 'active', true
 		)
 		ON CONFLICT (course_variant_public_id) DO UPDATE SET
 			course_set_id = EXCLUDED.course_set_id,
@@ -896,7 +1149,7 @@ func (s *Store) EnsureDemoData(ctx context.Context) (*DemoBootstrapSummary, erro
 			runtime_binding_public_id, event_id, place_id, map_asset_id, tile_release_id, course_set_id, course_variant_id, status, notes
 		)
 		VALUES (
-			'runtime_demo_score_o_001', $1, $2, $3, $4, $5, $6, 'active', 'demo score-o runtime binding'
+			'runtime_demo_score_o_001', $1, $2, $3, $4, $5, $6, 'active', '积分赛联调运行绑定'
 		)
 		ON CONFLICT (runtime_binding_public_id) DO UPDATE SET
 			event_id = EXCLUDED.event_id,
@@ -959,6 +1212,11 @@ func (s *Store) EnsureDemoData(ctx context.Context) (*DemoBootstrapSummary, erro
 		VariantManualEventID:   "evt_demo_variant_manual_001",
 		VariantManualRelease:   manualReleaseRow.PublicID,
 		VariantManualCardID:    manualCardPublicID,
+		VariantManualSourceID:  manualSource.ID,
+		VariantManualBuildID:   manualBuild.ID,
+		VariantManualCourseSet: manualCourseSetPublicID,
+		VariantManualVariantID: manualVariantBPublicID,
+		VariantManualRuntimeID: manualRuntimeBindingPublicID,
 		CleanedSessionCount:    cleanedSessionCount,
 	}, nil
 }

+ 6 - 0
backend/migrations/0011_card_summary.sql

@@ -0,0 +1,6 @@
+BEGIN;
+
+ALTER TABLE cards
+ADD COLUMN is_default_experience BOOLEAN NOT NULL DEFAULT false;
+
+COMMIT;

+ 304 - 0
doc/gameplay/活动卡片列表最小产品方案.md

@@ -0,0 +1,304 @@
+# 活动卡片列表最小产品方案
+> 文档版本:v1.2
+> 最后更新:2026-04-03 19:26:23
+
+## 1. 当前定位
+
+当前阶段“活动卡片列表页”第一刀已落地,当前文档转为:
+
+1. 第一刀范围说明
+2. 当前最小字段口径
+3. 当前联调回归重点
+
+也就是说,这份文档当前是:
+
+**第一刀已完成后的最小产品方案文档**
+
+---
+
+## 2. 当前阶段边界
+
+### 2.1 当前已完成
+
+- 独立活动列表页:`/pages/events/events`
+- 最小筛选:`全部 / 体验`
+- 最小卡片展示
+- 列表跳转活动详情页
+- 首页补“活动列表”独立入口
+
+### 2.2 当前不做
+
+- 不重构首页现有卡片入口
+- 不设计复杂运营样式
+- 不展开 `presentation schema`
+- 不展开 `content bundle` 明细
+- 不直接下发报名、签到、排行榜等复杂前台流程
+
+---
+
+## 3. 最小字段表
+
+以下字段按“当前第一刀已使用的最小口径”划分。
+
+### 3.1 必需字段
+
+- `eventId`
+  - 活动唯一标识
+- `title`
+  - 活动主标题
+- `subtitle`
+  - 副标题或活动短说明
+- `summary`
+  - 卡片摘要
+- `status`
+  - 活动状态
+- `timeWindow`
+  - 活动时间窗口摘要
+- `ctaText`
+  - 主动作文案
+
+### 3.2 推荐字段
+
+- `coverUrl`
+  - 封面图
+- `isDefaultExperience`
+  - 是否默认体验活动
+- `eventType`
+  - 活动类型摘要
+- `currentPresentation`
+  - 当前展示版本摘要
+- `currentContentBundle`
+  - 当前内容包摘要
+
+### 3.3 可选字段
+
+- `locationName`
+  - 地点摘要
+- `tagText`
+  - 轻标签
+- `participantHint`
+  - 人群或参与提示
+- `ruleHint`
+  - 规则提示
+
+当前阶段不建议为了列表页再额外增加复杂字段。
+
+---
+
+## 4. 字段展示建议
+
+### 4.1 卡片主信息
+
+- 主标题:`title`
+- 副标题:`subtitle`
+- 摘要:`summary`
+
+### 4.2 卡片状态信息
+
+- 状态:`status`
+- 时间:`timeWindow`
+- 主动作:`ctaText`
+
+### 4.3 卡片轻运营信息
+
+- 封面:`coverUrl`
+- 体验标识:`isDefaultExperience`
+- 展示版本摘要:`currentPresentation`
+- 内容包摘要:`currentContentBundle`
+
+当前阶段只建议做“摘要显示”,不要做复杂 schema 级解释。
+
+---
+
+## 5. 缺字段降级策略
+
+活动卡片列表属于前台浏览层,必须允许后端阶段性缺字段时仍可工作。
+
+### 5.1 标题缺失
+
+- `title` 缺失时:显示 `未命名活动`
+
+### 5.2 副标题缺失
+
+- `subtitle` 缺失时:整行不显示
+
+### 5.3 摘要缺失
+
+- `summary` 缺失时:显示 `当前暂无活动摘要`
+
+### 5.4 状态缺失
+
+- `status` 缺失时:显示 `状态待确认`
+- 不影响点击进入详情页
+
+### 5.5 时间窗口缺失
+
+- `timeWindow` 缺失时:显示 `时间待公布`
+
+### 5.6 CTA 缺失
+
+- `ctaText` 缺失时:统一回退成 `查看详情`
+
+### 5.7 封面缺失
+
+- `coverUrl` 缺失时:使用默认卡片背景,不阻断渲染
+
+### 5.8 `currentPresentation` 缺失
+
+- 显示:`当前未声明展示版本`
+
+### 5.9 `currentContentBundle` 缺失
+
+- 显示:`当前未声明内容包版本`
+
+### 5.10 体验标识缺失
+
+- `isDefaultExperience` 缺失时:按普通活动处理
+
+---
+
+## 6. 最小页面结构建议
+
+当前结构已按第一刀最小版本落地。
+
+建议的最小结构如下:
+
+```text
+活动列表页
+├─ 顶部说明区
+│  ├─ 标题
+│  ├─ 频道/地区摘要
+│  └─ 当前状态说明
+├─ 筛选区
+│  ├─ 全部
+│  ├─ 体验活动
+│  ├─ 进行中
+│  ├─ 即将开始
+│  └─ 已结束
+├─ 卡片列表区
+│  ├─ 活动卡片
+│  ├─ 活动卡片
+│  └─ 活动卡片
+└─ 空状态 / 错误状态
+```
+
+当前第一刀阶段保留这套最小结构,不扩复杂分组和复杂运营区。
+
+---
+
+## 7. 最小卡片状态建议
+
+### 7.1 默认体验活动
+
+- 标识:`体验`
+- 主动作:`进入体验`
+
+### 7.2 进行中
+
+- 标识:`进行中`
+- 主动作:`进入活动`
+
+### 7.3 即将开始
+
+- 标识:`即将开始`
+- 主动作:`查看详情`
+
+### 7.4 已结束
+
+- 标识:`已结束`
+- 主动作:`查看详情`
+  或
+- `查看回顾`
+
+---
+
+## 8. 与当前架构的关系
+
+活动卡片列表层只消费轻摘要,不直接消费后台复杂生产对象。
+
+建议关系保持为:
+
+```text
+Event
+-> EventRelease
+-> currentPresentation / currentContentBundle / runtime 摘要
+-> 活动卡片列表层
+```
+
+前端列表层当前只认:
+
+- 活动摘要
+- 当前状态
+- 轻运营摘要
+- 主动作文案
+
+不要把:
+
+- 原始 `EventPresentation schema`
+- 原始 `ContentBundle` 明细
+- backend 内部生产对象
+
+直接推到卡片列表层。
+
+---
+
+## 9. 与活动详情页的分工
+
+### 9.1 活动卡片列表层
+
+负责:
+
+- 浏览
+- 粗筛
+- 判断要不要进入活动详情页
+
+### 9.2 活动详情页
+
+负责:
+
+- 活动介绍
+- 展示版本摘要
+- 内容包摘要
+- 准备页入口
+- 后续更深的运营信息
+
+这样列表层可以一直保持轻。
+
+---
+
+## 10. 当前阶段输出物
+
+当前第一刀的输出物已经包括:
+
+1. 最小字段表
+2. 缺字段降级规则
+3. 最小页面结构落地
+4. 列表页到详情页的最小跳转链
+
+当前阶段不应继续包括:
+
+- 列表页重构
+- 卡片系统深度样式扩张
+- 复杂运营专区方案
+- 更多玩家侧新入口
+
+---
+
+## 11. 下一步建议
+
+当前第一刀之后,下一步建议按下面顺序推进:
+
+1. 先做联调回归与小范围修复
+2. 后端继续收口字段稳定性和默认值
+3. 前端确认分组和卡片信息层级是否顺手
+4. 再决定是否进入第二刀产品扩展
+
+---
+
+## 12. 一句话结论
+
+当前“活动卡片列表最小产品方案”已从准备阶段进入第一刀落地阶段:
+
+**独立列表页 + 最小筛选 + 最小卡片字段 + 跳详情页链路**
+
+接下来先做联调回归和小范围修复,不直接扩第二刀。

+ 3 - 2
doc/文档索引.md

@@ -1,6 +1,6 @@
 # 文档索引
-> 文档版本:v1.4
-> 最后更新:2026-04-03 16:59:19
+> 文档版本:v1.6
+> 最后更新:2026-04-03 22:05:00
 
 维护约定:
 
@@ -45,6 +45,7 @@
 - [多赛道 Variant 前后端最小契约](/D:/dev/cmr-mini/doc/gameplay/多赛道Variant前后端最小契约.md)
 - [多线程联调协作方式](/D:/dev/cmr-mini/doc/gameplay/多线程联调协作方式.md)
 - [联调架构阶段总结](/D:/dev/cmr-mini/doc/gameplay/联调架构阶段总结.md)
+- [活动卡片列表最小产品方案](/D:/dev/cmr-mini/doc/gameplay/活动卡片列表最小产品方案.md)
 - [APP全局产品架构草案](/D:/dev/cmr-mini/doc/gameplay/APP全局产品架构草案.md)
 - [故障恢复机制](/D:/dev/cmr-mini/doc/gameplay/故障恢复机制.md)
 - [活动运营域摘要第一刀联调回归清单](/D:/dev/cmr-mini/doc/gameplay/活动运营域摘要第一刀联调回归清单.md)

+ 120 - 3
f2b.md

@@ -1,6 +1,6 @@
 # F2B 协作清单
-> 文档版本:v1.9
-> 最后更新:2026-04-03 16:45:26
+> 文档版本:v1.16
+> 最后更新:2026-04-03 23:58:00
 
 
 说明:
@@ -14,6 +14,49 @@
 
 ## 待确认
 
+### F2B-014
+
+- 时间:2026-04-03 23:18:00
+- 提出方:前端
+- 当前事实:
+  - backend 在 `B2F-037` 中已确认,本次“准备页没有赛道选择区”的直接原因不是前端显示条件,而是当前发布 release 的 `payload_jsonb` 缺少:
+    - `play.assignmentMode`
+    - `play.courseVariants`
+  - backend 已说明修复方式为重新跑:
+    - `Bootstrap Demo`
+    - `Use Manual Variant Demo`
+    - `发布活动配置(自动补 Runtime)` 或 `整条链一键验收`
+  - 前端当前逻辑已经兼容:
+    - 明确 `assignmentMode=manual` 时显示赛道选择区
+    - 即使 `assignmentMode` 缺失,只要 `courseVariants` 中存在 2 条以上可选赛道,也会显示赛道选择区
+  - 因此前端现在是否显示赛道选择区,取决于 backend 新发布的 release 是否真的回出了多赛道字段
+- 需要对方确认什么:
+  - 该问题已由 backend 在 `B2F-037` 中确认修复完成,当前不再需要继续追问
+  - 后续多赛道联调以修复后的 demo/publish 链为准
+- 状态:已确认
+
+### F2B-013
+
+- 时间:2026-04-03 22:28:00
+- 提出方:前端
+- 当前事实:
+  - 手动多赛道活动当前已能进入准备页,但准备页仍未出现赛道选择区
+  - 这次前端已排除“仅仅是 `assignmentMode` 没回 manual”这一种情况:
+    - 当前前端兼容逻辑已放宽为:只要 `courseVariants` 中存在 2 条以上可选赛道,即使 `assignmentMode` 缺失,也会显示赛道选择区
+  - 但当前实际页面仍显示:
+    - `赛道模式:默认单赛道`
+    - `赛道摘要:当前未声明额外赛道版本,启动时按默认赛道进入`
+  - 这说明前端当前实际拿到的更像是:
+    - `play.courseVariants = []` 或未返回
+  - 前端已追加准备页诊断日志字段,后端可从 `event-prepare` 日志直接核对:
+    - `details.variantCount`
+    - `details.selectableVariantCount`
+    - `details.showVariantSelector`
+- 需要对方确认什么:
+  - 该问题根因已由 backend 在 `B2F-037` 中定位完成,当前不再需要继续从前端显示层排查
+  - 后续请转看 `F2B-014`
+- 状态:已解决
+
 ### F2B-011
 
 - 时间:2026-04-03
@@ -27,7 +70,7 @@
 - 需要对方确认什么:
   - 请 backend 核对该用户在 `evt_demo_variant_manual_001` 下是否仍有 `launched / running` session 未清掉
   - 如这是预期行为,请说明推荐的标准清理路径;如不是预期,请修正 ongoing 判定或测试环境回收逻辑
-- 状态:待确认
+- 状态:待后续单独收口(当前不阻塞主线)
 
 ---
 
@@ -175,6 +218,62 @@
   - 无
 - 状态:已确认
 
+### F2B-C011
+
+- 时间:2026-04-03 22:20:00
+- 提出方:前端
+- 当前事实:
+  - backend 已通过 `B2F-035` 正式收紧 `play.canLaunch` 与 `launch` 的前置条件
+  - 当前规则为:缺 `runtime / presentation / content bundle / manifest / 当前发布 release` 任一项时,均不可进入游戏
+  - 前端已按该契约复测,当前结果正常:
+    - `canLaunch=false` 时页面会禁用进入动作
+    - `play.reason` 会给出更具体的缺失原因
+    - backend 也不会再允许直接 `launch` 绕过阻断
+- 需要对方确认什么:
+  - 无
+- 状态:已确认
+
+### F2B-C012
+
+- 时间:2026-04-03 23:52:00
+- 提出方:前端
+- 当前事实:
+  - backend 已在 `B2F-037` 中确认:manual 多赛道准备页不显示选择区的根因是发布 release 缺少:
+    - `play.assignmentMode`
+    - `play.courseVariants`
+  - backend 已修复 `Bootstrap Demo` 与发布链,当前问题已通过联调日志确认收口
+  - frontend 当前已保留多赛道兜底展示逻辑,但该问题主因不在前端显示层
+- 需要对方确认什么:
+  - 无
+- 状态:已确认
+
+### F2B-C013
+
+- 时间:2026-04-03 23:52:00
+- 提出方:前端
+- 当前事实:
+  - backend 在 `B2F-038` 中要求的活动卡片列表第一刀字段,frontend 当前已按最小方案接入:
+    - `summary`
+    - `status`
+    - `statusCode`
+    - `timeWindow`
+    - `ctaText`
+    - `isDefaultExperience`
+    - `eventType`
+    - `currentPresentation`
+    - `currentContentBundle`
+  - frontend 当前列表页和详情页日志也已补齐:
+    - `cardEventIds`
+    - `clickedEventId`
+    - `detailStatus`
+    - `detailCanLaunch`
+    - `detailCurrentPresentation`
+    - `detailCurrentContentBundle`
+- 需要对方确认什么:
+  - 当前字段已足够支撑活动卡片列表最小实现
+  - 当前没有发现必须新增的列表页名称摘要字段
+- 状态:已确认
+
 ---
 
 ## 阻塞
@@ -345,6 +444,24 @@
   - 无
 - 状态:已完成
 
+### F2B-D010
+
+- 时间:2026-04-03 22:12:00
+- 提出方:前端
+- 当前事实:
+  - 已按 `B2F-034` 对活动页和准备页做语义收口:
+    - `展示版本` 改成 `当前发布展示版本`
+    - `内容包版本` 改成 `当前发布内容包版本`
+  - 当 `currentPresentation / currentContentBundle` 为空时,前端当前统一解释为:
+    - `当前发布 release 未绑定展示版本,或当前尚未发布`
+    - `当前发布 release 未绑定内容包版本,或当前尚未发布`
+  - 活动页与准备页当前进入动作都已优先受 `play.canLaunch` 控制:
+    - `canLaunch=false` 时按钮禁用
+    - 同时阻止继续进入准备页或地图
+- 需要对方确认什么:
+  - 无
+- 状态:已完成
+
 ---
 
 ## 下一步

+ 69 - 3
f2t.md

@@ -1,6 +1,6 @@
 # F2T 协作清单
-> 文档版本:v1.9
-> 最后更新:2026-04-03 16:45:26
+> 文档版本:v1.12
+> 最后更新:2026-04-03 23:42:00
 
 说明:
 
@@ -207,8 +207,74 @@
   - 无
 - 是否已解决:是
 
+### F2T-D008
+
+- 时间:2026-04-03 22:05:00
+- 谁提的:frontend
+- 当前事实:
+  - 已按总控当前口径更新 [活动卡片列表最小产品方案](D:/dev/cmr-mini/doc/gameplay/活动卡片列表最小产品方案.md)
+  - 当前文档只收 3 类准备项:
+    - 最小字段表
+    - 缺字段降级策略
+    - 最小页面结构建议
+  - 当前未启动活动卡片列表页正式开发
+  - 当前未扩新页面链,也未改首页现有活动入口实现
+- 需要确认什么:
+  - 无
+- 是否已解决:是
+
+### F2T-D009
+
+- 时间:2026-04-03 22:12:00
+- 谁提的:frontend
+- 当前事实:
+  - 已按 backend 对 `currentPresentation / currentContentBundle` 的语义要求完成前端小范围修正
+  - 活动页与准备页当前统一使用:
+    - `当前发布展示版本`
+    - `当前发布内容包版本`
+  - 当两项为空时,前端当前统一解释为:
+    - 当前发布 release 未绑定
+    - 或当前尚未发布
+  - 活动页与准备页的继续进入动作,当前统一优先受 `play.canLaunch` 控制
+- 需要确认什么:
+  - 无
+- 是否已解决:是
+
+### F2T-D010
+
+- 时间:2026-04-03 23:42:00
+- 谁提的:frontend
+- 当前事实:
+  - 已按总控当前 `v1.9` 口径启动“活动卡片列表最小产品化第一刀”
+  - 当前已落地:
+    - 独立活动列表页:`/pages/events/events`
+    - 最小筛选:`全部 / 体验`
+    - 最小卡片展示:
+      - `title`
+      - `subtitle`
+      - `summary`
+      - `status`
+      - `timeWindow`
+      - `ctaText`
+      - `isDefaultExperience`
+      - `eventType`
+      - `currentPresentation`
+      - `currentContentBundle`
+    - 从列表跳活动详情页
+  - 当前第一刀仍保持边界:
+    - 不重做首页现有入口区
+    - 仅在首页补一个“活动列表”独立入口
+    - 不扩更多玩家侧新链
+- 需要确认什么:
+  - 无
+- 是否已解决:是
+
 ---
 
 ## 下一步
 
-- 当前进入活动运营域摘要第一刀在 backend 一键测试环境下的联调回归与小范围修复阶段
+- 当前进入活动卡片列表最小产品化第一刀联调回归与小范围修复阶段
+- 当前重点验证:
+  - 列表字段是否足够
+  - `全部 / 体验` 分组是否符合预期
+  - 卡片点击进入活动详情页是否稳定

+ 1 - 0
miniprogram/app.json

@@ -3,6 +3,7 @@
     "pages/index/index",
     "pages/login/login",
     "pages/home/home",
+    "pages/events/events",
     "pages/event/event",
     "pages/event-prepare/event-prepare",
     "pages/result/result",

+ 102 - 9
miniprogram/pages/event-prepare/event-prepare.ts

@@ -13,6 +13,7 @@ const DEBUG_MOCK_AUTO_CONNECT_STORAGE_KEY = 'cmr.debug.autoConnectMockSources.v1
 type EventPreparePageData = {
   eventId: string
   loading: boolean
+  canLaunch: boolean
   titleText: string
   summaryText: string
   releaseText: string
@@ -29,6 +30,8 @@ type EventPreparePageData = {
   runtimeRouteCodeText: string
   selectedVariantId: string
   selectedVariantText: string
+  showVariantSelector: boolean
+  variantSelectorEmptyText: string
   selectableVariants: Array<{
     id: string
     name: string
@@ -54,6 +57,27 @@ type EventPreparePageData = {
   mockSourceStatusText: string
 }
 
+function detectMultiVariantContext(result: BackendEventPlayResult): boolean {
+  const assignmentMode = result.play.assignmentMode
+  if (assignmentMode === 'manual' || assignmentMode === 'random' || assignmentMode === 'server-assigned') {
+    return true
+  }
+
+  const variants = result.play.courseVariants || []
+  if (variants.length > 0) {
+    return true
+  }
+
+  const haystacks = [
+    result.event.displayName,
+    result.event.summary,
+    result.release ? result.release.configLabel : '',
+    result.resolvedRelease ? result.resolvedRelease.configLabel : '',
+  ]
+
+  return haystacks.some((item) => typeof item === 'string' && item.indexOf('多赛道') >= 0)
+}
+
 function formatAssignmentMode(mode?: string | null): string {
   if (mode === 'manual') {
     return '手动选择'
@@ -77,6 +101,7 @@ function formatVariantSummary(result: BackendEventPlayResult): string {
     const title = item.routeCode || item.name
     return item.selectable === false ? `${title}(固定)` : title
   }).join(' / ')
+  const selectableCount = variants.filter((item) => item.selectable !== false).length
 
   if (result.play.assignmentMode === 'manual') {
     return `当前活动支持 ${variants.length} 条赛道。本阶段前端先展示赛道信息,最终绑定以后端 launch 返回为准:${preview}`
@@ -90,13 +115,17 @@ function formatVariantSummary(result: BackendEventPlayResult): string {
     return `当前活动赛道由后台预先指定:${preview}`
   }
 
+  if (selectableCount > 1) {
+    return `当前活动支持 ${variants.length} 条赛道。后端当前未明确返回赛道模式,前端先按手动选择兼容显示:${preview}`
+  }
+
   return preview
 }
 
 function formatPresentationSummary(result: BackendEventPlayResult): string {
   const currentPresentation = result.currentPresentation
   if (!currentPresentation) {
-    return '当前未声明展示版本'
+    return '当前发布 release 未绑定展示版本,或当前尚未发布'
   }
 
   return `${currentPresentation.presentationId || '--'} / ${currentPresentation.templateKey || '--'} / ${currentPresentation.version || '--'}`
@@ -105,7 +134,7 @@ function formatPresentationSummary(result: BackendEventPlayResult): string {
 function formatContentBundleSummary(result: BackendEventPlayResult): string {
   const currentContentBundle = result.currentContentBundle
   if (!currentContentBundle) {
-    return '当前未声明内容包版本'
+    return '当前发布 release 未绑定内容包版本,或当前尚未发布'
   }
 
   return `${currentContentBundle.bundleId || '--'} / ${currentContentBundle.bundleType || '--'} / ${currentContentBundle.version || '--'}`
@@ -115,12 +144,13 @@ function resolveSelectedVariantId(
   currentVariantId: string,
   assignmentMode?: string | null,
   variants?: BackendCourseVariantSummary[] | null,
+  forceVisible?: boolean,
 ): string {
-  if (assignmentMode !== 'manual' || !variants || !variants.length) {
+  if (!shouldShowVariantSelector(assignmentMode, variants, forceVisible)) {
     return ''
   }
 
-  const selectable = variants.filter((item) => item.selectable !== false)
+  const selectable = (variants || []).filter((item) => item.selectable !== false)
   if (!selectable.length) {
     return ''
   }
@@ -137,8 +167,9 @@ function buildSelectableVariants(
   selectedVariantId: string,
   assignmentMode?: string | null,
   variants?: BackendCourseVariantSummary[] | null,
+  forceVisible?: boolean,
 ) {
-  if (assignmentMode !== 'manual' || !variants || !variants.length) {
+  if (!shouldShowVariantSelector(assignmentMode, variants, forceVisible) || !variants || !variants.length) {
     return []
   }
 
@@ -153,6 +184,32 @@ function buildSelectableVariants(
     }))
 }
 
+function shouldShowVariantSelector(
+  assignmentMode?: string | null,
+  variants?: BackendCourseVariantSummary[] | null,
+  forceVisible?: boolean,
+): boolean {
+  if (forceVisible) {
+    return true
+  }
+
+  const normalizedVariants = variants || []
+
+  if (!normalizedVariants.length) {
+    return false
+  }
+
+  if (assignmentMode === 'manual') {
+    return true
+  }
+
+  if (assignmentMode === 'random' || assignmentMode === 'server-assigned') {
+    return false
+  }
+
+  return normalizedVariants.filter((item) => item.selectable !== false).length > 1
+}
+
 let prepareHeartRateController: HeartRateController | null = null
 
 function getAccessToken(): string | null {
@@ -202,6 +259,7 @@ Page({
   data: {
     eventId: '',
     loading: false,
+    canLaunch: false,
     titleText: '开始前准备',
     summaryText: '未加载',
     releaseText: '--',
@@ -218,6 +276,8 @@ Page({
     runtimeRouteCodeText: '待 launch 确认',
     selectedVariantId: '',
     selectedVariantText: '当前无需手动指定赛道',
+    showVariantSelector: false,
+    variantSelectorEmptyText: '当前无需手动指定赛道',
     selectableVariants: [],
     locationStatusText: '待进入地图后校验定位权限与实时精度',
     heartRateStatusText: '局前心率带连接入口待接入,本轮先保留骨架',
@@ -286,17 +346,25 @@ Page({
   },
 
   applyEventPlay(result: BackendEventPlayResult) {
+    const multiVariantContext = detectMultiVariantContext(result)
     const selectedVariantId = resolveSelectedVariantId(
       this.data.selectedVariantId,
       result.play.assignmentMode,
       result.play.courseVariants,
+      multiVariantContext,
     )
     const assignmentMode = result.play.assignmentMode ? result.play.assignmentMode : null
+    const showVariantSelector = shouldShowVariantSelector(
+      result.play.assignmentMode,
+      result.play.courseVariants,
+      multiVariantContext,
+    )
     const logVariantId = assignmentMode === 'manual' && selectedVariantId ? selectedVariantId : null
     const selectableVariants = buildSelectableVariants(
       selectedVariantId,
       result.play.assignmentMode,
       result.play.courseVariants,
+      multiVariantContext,
     )
     const selectedVariant = selectableVariants.find((item) => item.id === selectedVariantId) || null
     reportBackendClientLog({
@@ -315,10 +383,20 @@ Page({
         resultEventId: result.event.id || '',
         selectedVariantId: logVariantId,
         assignmentMode,
+        variantCount: result.play.courseVariants ? result.play.courseVariants.length : 0,
+        selectableVariantCount: result.play.courseVariants
+          ? result.play.courseVariants.filter((item) => item.selectable !== false).length
+          : 0,
+        showVariantSelector,
+        multiVariantContext,
       },
     })
+    const variantSelectorEmptyText = multiVariantContext
+      ? '当前活动按多赛道处理,但后端暂未返回可选赛道,请稍后刷新或联系后台。'
+      : '当前无需手动指定赛道'
     this.setData({
       loading: false,
+      canLaunch: result.play.canLaunch,
       titleText: `${result.event.displayName} / 开始前准备`,
       summaryText: result.event.summary || '暂无活动简介',
       releaseText: result.resolvedRelease
@@ -327,7 +405,9 @@ Page({
       actionText: formatBackendPlayActionText(result.play.primaryAction, result.play.reason),
       statusText: formatBackendPlayStatusText(result.play.canLaunch, result.play.primaryAction, result.play.reason),
       assignmentMode: result.play.assignmentMode || '',
-      variantModeText: formatAssignmentMode(result.play.assignmentMode),
+      variantModeText: result.play.assignmentMode
+        ? formatAssignmentMode(result.play.assignmentMode)
+        : (showVariantSelector ? '手动选择' : '默认单赛道'),
       variantSummaryText: formatVariantSummary(result),
       presentationText: formatPresentationSummary(result),
       contentBundleText: formatContentBundleSummary(result),
@@ -346,7 +426,9 @@ Page({
       selectedVariantId,
       selectedVariantText: selectedVariant
         ? `${selectedVariant.name} / ${selectedVariant.routeCodeText}`
-        : '当前无需手动指定赛道',
+        : variantSelectorEmptyText,
+      showVariantSelector,
+      variantSelectorEmptyText,
       selectableVariants,
     })
   },
@@ -591,6 +673,17 @@ Page({
       return
     }
 
+    if (!this.data.canLaunch) {
+      this.setData({
+        statusText: '当前发布状态不可进入地图',
+      })
+      wx.showToast({
+        title: '当前发布状态不可进入地图',
+        icon: 'none',
+      })
+      return
+    }
+
     if (!this.data.locationPermissionGranted) {
       this.setData({
         statusText: '进入地图前请先完成定位授权',
@@ -608,7 +701,7 @@ Page({
 
     try {
       const assignmentMode = this.data.assignmentMode ? this.data.assignmentMode : null
-      const selectedVariantId = assignmentMode === 'manual' && this.data.selectedVariantId
+      const selectedVariantId = this.data.showVariantSelector && this.data.selectedVariantId
         ? this.data.selectedVariantId
         : null
       reportBackendClientLog({
@@ -641,7 +734,7 @@ Page({
         baseUrl: loadBackendBaseUrl(),
         eventId: this.data.eventId,
         accessToken,
-        variantId: this.data.assignmentMode === 'manual' ? this.data.selectedVariantId : undefined,
+          variantId: this.data.showVariantSelector ? this.data.selectedVariantId : undefined,
         clientType: 'wechat',
         deviceKey: 'mini-dev-device-001',
       })

+ 7 - 6
miniprogram/pages/event-prepare/event-prepare.wxml

@@ -18,13 +18,13 @@
 
     <view class="panel">
       <view class="panel__title">活动运营摘要</view>
-      <view class="summary">当前阶段先展示活动运营对象摘要,不展开复杂 schema。</view>
+      <view class="summary">当前阶段先展示当前发布 release 绑定的活动运营对象摘要,不展开复杂 schema。</view>
       <view class="row">
-        <view class="row__label">展示版本</view>
+        <view class="row__label">当前发布展示版本</view>
         <view class="row__value">{{presentationText}}</view>
       </view>
       <view class="row">
-        <view class="row__label">内容包版本</view>
+        <view class="row__label">当前发布内容包版本</view>
         <view class="row__value">{{contentBundleText}}</view>
       </view>
     </view>
@@ -50,10 +50,11 @@
       </view>
     </view>
 
-    <view class="panel" wx:if="{{assignmentMode === 'manual' && selectableVariants.length}}">
+    <view class="panel" wx:if="{{showVariantSelector}}">
       <view class="panel__title">赛道选择</view>
       <view class="summary">当前活动要求手动指定赛道。这里的选择会随 launch 一起带给后端,最终绑定以后端返回为准。</view>
-      <view class="variant-list">
+      <view wx:if="{{!selectableVariants.length}}" class="summary">{{variantSelectorEmptyText}}</view>
+      <view wx:if="{{selectableVariants.length}}" class="variant-list">
         <view wx:for="{{selectableVariants}}" wx:key="id" class="variant-card {{item.selected ? 'variant-card--active' : ''}}" data-variant-id="{{item.id}}" bindtap="handleSelectVariant">
           <view class="variant-card__main">
             <view class="variant-card__title-row">
@@ -109,7 +110,7 @@
       <view class="actions">
         <button class="btn btn--secondary" bindtap="handleBack">返回活动页</button>
         <button class="btn btn--ghost" bindtap="handleRefresh">刷新</button>
-        <button class="btn btn--primary" bindtap="handleLaunch">进入地图</button>
+        <button class="btn btn--primary" bindtap="handleLaunch" disabled="{{!canLaunch}}">进入地图</button>
       </view>
     </view>
   </view>

+ 10 - 2
miniprogram/pages/event/event.ts

@@ -45,7 +45,7 @@ function formatVariantSummary(result: BackendEventPlayResult): string {
 function formatPresentationSummary(result: BackendEventPlayResult): string {
   const currentPresentation = result.currentPresentation
   if (!currentPresentation) {
-    return '当前未声明展示版本'
+    return '当前发布 release 未绑定展示版本,或当前尚未发布'
   }
 
   const presentationId = currentPresentation.presentationId || '--'
@@ -57,7 +57,7 @@ function formatPresentationSummary(result: BackendEventPlayResult): string {
 function formatContentBundleSummary(result: BackendEventPlayResult): string {
   const currentContentBundle = result.currentContentBundle
   if (!currentContentBundle) {
-    return '当前未声明内容包版本'
+    return '当前发布 release 未绑定内容包版本,或当前尚未发布'
   }
 
   const bundleId = currentContentBundle.bundleId || '--'
@@ -147,6 +147,14 @@ Page({
         pageEventId: this.data.eventId || '',
         resultEventId: result.event.id || '',
         primaryAction: result.play.primaryAction || '',
+        detailStatus: result.play.reason || '',
+        detailCanLaunch: result.play.canLaunch,
+        detailCurrentPresentation: result.currentPresentation
+          ? `${result.currentPresentation.presentationId || '--'} / ${result.currentPresentation.templateKey || '--'} / ${result.currentPresentation.version || '--'}`
+          : '',
+        detailCurrentContentBundle: result.currentContentBundle
+          ? `${result.currentContentBundle.bundleId || '--'} / ${result.currentContentBundle.bundleType || '--'} / ${result.currentContentBundle.version || '--'}`
+          : '',
         assignmentMode,
         variantCount: result.play.courseVariants ? result.play.courseVariants.length : 0,
       },

+ 2 - 2
miniprogram/pages/event/event.wxml

@@ -13,8 +13,8 @@
       <view class="summary">状态:{{statusText}}</view>
       <view class="summary">赛道模式:{{variantModeText}}</view>
       <view class="summary">赛道摘要:{{variantSummaryText}}</view>
-      <view class="summary">展示版本:{{presentationText}}</view>
-      <view class="summary">内容包版本:{{contentBundleText}}</view>
+      <view class="summary">当前发布展示版本:{{presentationText}}</view>
+      <view class="summary">当前发布内容包版本:{{contentBundleText}}</view>
       <view class="actions">
         <button class="btn btn--secondary" bindtap="handleRefresh">刷新</button>
         <button class="btn btn--primary" bindtap="handleLaunch">前往准备页</button>

+ 220 - 0
miniprogram/pages/events/events.ts

@@ -0,0 +1,220 @@
+import { loadBackendAuthTokens, loadBackendBaseUrl } from '../../utils/backendAuth'
+import {
+  getEntryHome,
+  type BackendCardResult,
+  type BackendContentBundleSummary,
+  type BackendPresentationSummary,
+} from '../../utils/backendApi'
+import { reportBackendClientLog } from '../../utils/backendClientLogs'
+
+const DEFAULT_CHANNEL_CODE = 'mini-demo'
+const DEFAULT_CHANNEL_TYPE = 'wechat_mini'
+
+type EventListFilter = 'all' | 'experience'
+
+type EventCardView = {
+  id: string
+  eventId: string
+  titleText: string
+  subtitleText: string
+  summaryText: string
+  statusText: string
+  timeWindowText: string
+  ctaText: string
+  badgeText: string
+  eventTypeText: string
+  presentationText: string
+  contentBundleText: string
+  coverUrl: string
+  disabled: boolean
+}
+
+type EventsPageData = {
+  loading: boolean
+  statusText: string
+  currentFilter: EventListFilter
+  cards: EventCardView[]
+}
+
+function requireAuthToken(): string | null {
+  const app = getApp<IAppOption>()
+  const tokens = app.globalData && app.globalData.backendAuthTokens
+    ? app.globalData.backendAuthTokens
+    : loadBackendAuthTokens()
+  return tokens && tokens.accessToken ? tokens.accessToken : null
+}
+
+function formatPresentationSummary(summary?: BackendPresentationSummary | null): string {
+  if (!summary) {
+    return '当前未声明展示版本'
+  }
+
+  return summary.version || summary.templateKey || summary.presentationId || '当前未声明展示版本'
+}
+
+function formatContentBundleSummary(summary?: BackendContentBundleSummary | null): string {
+  if (!summary) {
+    return '当前未声明内容包版本'
+  }
+
+  return summary.version || summary.bundleType || summary.bundleId || '当前未声明内容包版本'
+}
+
+function buildCardView(card: BackendCardResult): EventCardView {
+  const eventId = card.event && card.event.id ? card.event.id : ''
+  const statusText = card.status || card.statusCode || '状态待确认'
+  const badgeText = card.isDefaultExperience ? '体验' : '活动'
+  const eventTypeText = card.eventType || '类型待确认'
+  const subtitleText = card.subtitle || (card.event && card.event.displayName ? card.event.displayName : '')
+
+  return {
+    id: card.id,
+    eventId,
+    titleText: card.title || '未命名活动',
+    subtitleText,
+    summaryText: card.summary || (card.event && card.event.summary ? card.event.summary : '当前暂无活动摘要'),
+    statusText,
+    timeWindowText: card.timeWindow || '时间待公布',
+    ctaText: card.ctaText || '查看详情',
+    badgeText,
+    eventTypeText,
+    presentationText: formatPresentationSummary(card.currentPresentation),
+    contentBundleText: formatContentBundleSummary(card.currentContentBundle),
+    coverUrl: card.coverUrl || '',
+    disabled: !eventId,
+  }
+}
+
+function applyFilter(cards: BackendCardResult[], filter: EventListFilter): EventCardView[] {
+  const filtered = filter === 'experience'
+    ? cards.filter((item) => item.isDefaultExperience === true)
+    : cards
+
+  return filtered
+    .slice()
+    .sort((left, right) => {
+      const leftPriority = typeof left.displayPriority === 'number' ? left.displayPriority : 0
+      const rightPriority = typeof right.displayPriority === 'number' ? right.displayPriority : 0
+      return rightPriority - leftPriority
+    })
+    .map(buildCardView)
+}
+
+Page({
+  data: {
+    loading: false,
+    statusText: '准备加载活动列表',
+    currentFilter: 'all',
+    cards: [],
+  } as EventsPageData,
+
+  onLoad() {
+    this.loadCards()
+  },
+
+  onShow() {
+    this.loadCards()
+  },
+
+  async loadCards() {
+    const accessToken = requireAuthToken()
+    if (!accessToken) {
+      wx.redirectTo({ url: '/pages/login/login' })
+      return
+    }
+
+    this.setData({
+      loading: true,
+      statusText: '正在加载活动列表',
+    })
+
+    try {
+      const result = await getEntryHome({
+        baseUrl: loadBackendBaseUrl(),
+        accessToken,
+        channelCode: DEFAULT_CHANNEL_CODE,
+        channelType: DEFAULT_CHANNEL_TYPE,
+      })
+      this.applyCards(result.cards || [])
+    } catch (error) {
+      const message = error && (error as { message?: string }).message ? (error as { message: string }).message : '未知错误'
+      this.setData({
+        loading: false,
+        statusText: `活动列表加载失败:${message}`,
+        cards: [],
+      })
+    }
+  },
+
+  applyCards(cards: BackendCardResult[]) {
+    reportBackendClientLog({
+      level: 'info',
+      category: 'event-cards',
+      message: 'event cards loaded',
+      details: {
+        filter: this.data.currentFilter,
+        cardCount: cards.length,
+        experienceCount: cards.filter((item) => item.isDefaultExperience === true).length,
+        cardEventIds: cards.map((item) => (item.event && item.event.id ? item.event.id : '')),
+      },
+    })
+
+    const filteredCards = applyFilter(cards, this.data.currentFilter)
+    this.setData({
+      loading: false,
+      statusText: filteredCards.length ? '活动列表加载完成' : '当前没有可显示活动',
+      cards: filteredCards,
+    })
+    const pageInstance = this as unknown as WechatMiniprogram.Page.Instance<EventsPageData, Record<string, never>> & {
+      rawCards?: BackendCardResult[]
+    }
+    pageInstance.rawCards = cards
+  },
+
+  handleSwitchFilter(event: WechatMiniprogram.TouchEvent) {
+    const filter = event.currentTarget.dataset.filter as EventListFilter | undefined
+    if (!filter || filter === this.data.currentFilter) {
+      return
+    }
+
+    const pageInstance = this as unknown as WechatMiniprogram.Page.Instance<EventsPageData, Record<string, never>> & {
+      rawCards?: BackendCardResult[]
+    }
+    const rawCards = pageInstance.rawCards || []
+    const filteredCards = applyFilter(rawCards, filter)
+    this.setData({
+      currentFilter: filter,
+      statusText: filteredCards.length ? '活动列表加载完成' : '当前筛选下没有活动',
+      cards: filteredCards,
+    })
+  },
+
+  handleRefresh() {
+    this.loadCards()
+  },
+
+  handleOpenCard(event: WechatMiniprogram.TouchEvent) {
+    const eventId = event.currentTarget.dataset.eventId as string | undefined
+    reportBackendClientLog({
+      level: 'info',
+      category: 'event-cards',
+      message: 'event card clicked',
+      eventId: eventId || '',
+      details: {
+        clickedEventId: eventId || '',
+        filter: this.data.currentFilter,
+      },
+    })
+    if (!eventId) {
+      wx.showToast({
+        title: '该卡片暂无活动入口',
+        icon: 'none',
+      })
+      return
+    }
+
+    wx.navigateTo({
+      url: `/pages/event/event?eventId=${encodeURIComponent(eventId)}`,
+    })
+  },
+})

+ 47 - 0
miniprogram/pages/events/events.wxml

@@ -0,0 +1,47 @@
+<scroll-view class="page" scroll-y>
+  <view class="shell">
+    <view class="hero">
+      <view class="hero__eyebrow">Activity List</view>
+      <view class="hero__title">活动列表</view>
+      <view class="hero__desc">当前阶段先做独立列表页第一刀,不重构首页入口区。</view>
+    </view>
+
+    <view class="panel">
+      <view class="panel__title">筛选</view>
+      <view class="summary">{{statusText}}</view>
+      <view class="filters">
+        <view class="filter-chip {{currentFilter === 'all' ? 'filter-chip--active' : ''}}" data-filter="all" bindtap="handleSwitchFilter">全部</view>
+        <view class="filter-chip {{currentFilter === 'experience' ? 'filter-chip--active' : ''}}" data-filter="experience" bindtap="handleSwitchFilter">体验</view>
+      </view>
+      <view class="actions">
+        <button class="btn btn--secondary" bindtap="handleRefresh">刷新列表</button>
+      </view>
+    </view>
+
+    <view class="panel">
+      <view class="panel__title">活动卡片</view>
+      <view wx:if="{{!cards.length}}" class="summary">当前筛选下没有活动卡片</view>
+      <view wx:for="{{cards}}" wx:key="id" class="card {{item.disabled ? 'card--disabled' : ''}}" bindtap="handleOpenCard" data-event-id="{{item.eventId}}">
+        <image wx:if="{{item.coverUrl}}" class="card__cover" src="{{item.coverUrl}}" mode="aspectFill"></image>
+        <view class="card__top">
+          <text class="card__badge">{{item.badgeText}}</text>
+          <text class="card__type">{{item.eventTypeText}}</text>
+        </view>
+        <view class="card__title">{{item.titleText}}</view>
+        <view wx:if="{{item.subtitleText}}" class="card__subtitle">{{item.subtitleText}}</view>
+        <view class="card__summary">{{item.summaryText}}</view>
+        <view class="card__meta-row">
+          <text class="card__meta">{{item.statusText}}</text>
+          <text class="card__meta">{{item.timeWindowText}}</text>
+        </view>
+        <view class="card__meta-row">
+          <text class="card__meta">展示:{{item.presentationText}}</text>
+        </view>
+        <view class="card__meta-row">
+          <text class="card__meta">内容:{{item.contentBundleText}}</text>
+        </view>
+        <view class="card__cta">{{item.ctaText}}</view>
+      </view>
+    </view>
+  </view>
+</scroll-view>

+ 186 - 0
miniprogram/pages/events/events.wxss

@@ -0,0 +1,186 @@
+page {
+  min-height: 100vh;
+  background: linear-gradient(180deg, #eff4fb 0%, #e8eff7 100%);
+}
+
+.page {
+  min-height: 100vh;
+}
+
+.shell {
+  display: grid;
+  gap: 24rpx;
+  padding: 28rpx 24rpx 40rpx;
+}
+
+.hero,
+.panel {
+  display: grid;
+  gap: 16rpx;
+  padding: 24rpx;
+  border-radius: 24rpx;
+}
+
+.hero {
+  background: linear-gradient(135deg, #163a66 0%, #1f5da1 100%);
+  color: #ffffff;
+}
+
+.hero__eyebrow {
+  font-size: 22rpx;
+  letter-spacing: 0.16em;
+  text-transform: uppercase;
+  color: rgba(255, 255, 255, 0.72);
+}
+
+.hero__title {
+  font-size: 40rpx;
+  font-weight: 700;
+}
+
+.hero__desc {
+  font-size: 24rpx;
+  color: rgba(255, 255, 255, 0.84);
+}
+
+.panel {
+  background: rgba(255, 255, 255, 0.94);
+  box-shadow: 0 14rpx 32rpx rgba(40, 63, 95, 0.08);
+}
+
+.panel__title {
+  font-size: 30rpx;
+  font-weight: 700;
+  color: #17345a;
+}
+
+.summary {
+  font-size: 24rpx;
+  line-height: 1.6;
+  color: #30465f;
+}
+
+.filters {
+  display: flex;
+  gap: 16rpx;
+  flex-wrap: wrap;
+}
+
+.filter-chip {
+  min-width: 120rpx;
+  padding: 14rpx 20rpx;
+  border-radius: 999rpx;
+  background: #eef3f8;
+  color: #50677f;
+  font-size: 24rpx;
+  text-align: center;
+}
+
+.filter-chip--active {
+  background: #173d73;
+  color: #ffffff;
+}
+
+.actions {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 16rpx;
+}
+
+.btn {
+  margin: 0;
+  min-height: 76rpx;
+  padding: 0 24rpx;
+  line-height: 76rpx;
+  border-radius: 18rpx;
+  font-size: 26rpx;
+}
+
+.btn::after {
+  border: 0;
+}
+
+.btn--secondary {
+  background: #dfeaf8;
+  color: #173d73;
+}
+
+.card {
+  display: grid;
+  gap: 12rpx;
+  padding: 22rpx;
+  border-radius: 22rpx;
+  background: #f6f9fc;
+}
+
+.card--disabled {
+  opacity: 0.7;
+}
+
+.card__cover {
+  width: 100%;
+  height: 220rpx;
+  border-radius: 18rpx;
+  background: #d7e4f2;
+}
+
+.card__top {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  gap: 16rpx;
+}
+
+.card__badge {
+  display: inline-flex;
+  align-items: center;
+  min-height: 40rpx;
+  padding: 0 14rpx;
+  border-radius: 999rpx;
+  background: #dce9fb;
+  color: #173d73;
+  font-size: 22rpx;
+  font-weight: 700;
+}
+
+.card__type {
+  font-size: 22rpx;
+  color: #64748b;
+}
+
+.card__title {
+  font-size: 30rpx;
+  font-weight: 700;
+  color: #17345a;
+}
+
+.card__subtitle,
+.card__summary,
+.card__meta,
+.card__cta {
+  font-size: 24rpx;
+  line-height: 1.6;
+}
+
+.card__subtitle {
+  color: #4f627a;
+}
+
+.card__summary {
+  color: #30465f;
+}
+
+.card__meta-row {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 16rpx;
+}
+
+.card__meta {
+  color: #64748b;
+}
+
+.card__cta {
+  color: #173d73;
+  font-weight: 700;
+}

+ 6 - 0
miniprogram/pages/home/home.ts

@@ -153,6 +153,12 @@ Page({
     })
   },
 
+  handleOpenEventList() {
+    wx.navigateTo({
+      url: '/pages/events/events',
+    })
+  },
+
   handleLogout() {
     clearBackendAuthTokens()
     setGlobalMockDebugBridgeEnabled(false)

+ 1 - 0
miniprogram/pages/home/home.wxml

@@ -16,6 +16,7 @@
       <view class="summary">最近一局运行对象:{{recentRuntimeText}}</view>
       <view class="actions">
         <button class="btn btn--secondary" bindtap="handleRefresh">刷新首页</button>
+        <button class="btn btn--ghost" bindtap="handleOpenEventList">活动列表</button>
         <button class="btn btn--ghost" bindtap="handleOpenRecentResult">查看结果</button>
         <button class="btn btn--ghost" bindtap="handleLogout">退出登录</button>
       </view>

+ 9 - 0
miniprogram/utils/backendApi.ts

@@ -94,6 +94,15 @@ export interface BackendCardResult {
   type: string
   title: string
   subtitle?: string | null
+  summary?: string | null
+  status?: string | null
+  statusCode?: string | null
+  timeWindow?: string | null
+  ctaText?: string | null
+  isDefaultExperience?: boolean
+  eventType?: string | null
+  currentPresentation?: BackendPresentationSummary | null
+  currentContentBundle?: BackendContentBundleSummary | null
   coverUrl?: string | null
   displaySlot: string
   displayPriority: number

+ 20 - 10
readme-develop.md

@@ -1,6 +1,6 @@
 # CMR Mini 开发架构阶段总结
-> 文档版本:v1.16
-> 最后更新:2026-04-03 16:59:19
+> 文档版本:v1.20
+> 最后更新:2026-04-03 19:26:23
 
 文档维护约定:
 
@@ -15,6 +15,8 @@
 - 多线程联调场景下,正式架构与长期结论优先沉淀到 `doc/`。
 - 当前联调架构的阶段总结见:
   - [联调架构阶段总结](D:/dev/cmr-mini/doc/gameplay/联调架构阶段总结.md)
+- 活动列表最小产品方案见:
+  - [活动卡片列表最小产品方案](D:/dev/cmr-mini/doc/gameplay/活动卡片列表最小产品方案.md)
 - 面向后端线程的阶段性实施说明,优先写入根目录 [t2b.md](D:/dev/cmr-mini/t2b.md)。
 - backend 新增写给总控线程的回写板:
   - [b2t.md](D:/dev/cmr-mini/b2t.md)
@@ -62,16 +64,20 @@
       - 预期判定
   - 下一步建议:
     - 联调标准化第一版视为已完成
-    - 当前主线进入“真实输入替换第二刀”
-    - 当前优先替换:
+    - 真实输入替换第二刀已完成:
       - `content manifest`
       - `presentation schema`
       - `活动文案样例`
-    - `KML / 赛道文件` 与 `地图资源 URL` 已接入,不再作为本轮重点
-    - backend 在联调标准化阶段应优先保证:
+    - 活动卡片列表最小产品化第一刀已完成
+    - 当前主线进入“活动卡片列表第一刀联调回归与小范围修复阶段”
+    - backend 当前应优先保证:
       - 从空白环境直接可跑
       - workbench 日志能明确定位失败步骤
       - 同一条测试链可重复执行
+    - backend 当前分工:
+      - 维护活动卡片列表第一刀所需最小摘要字段稳定
+      - 响应列表页联调中暴露的字段、默认值和语义问题
+      - 保持列表页与活动详情页摘要口径一致
 - 前端线程建议正式上场时机:
   - 现在已完成活动运营域摘要接线第一刀
   - 当前已完成:
@@ -87,16 +93,20 @@
       - 活动准备页
       - 会话快照
   - 当前建议:
-    - frontend 进入联调标准化配合与小范围修复阶段
-    - 只做字段修正、摘要打磨、一致性修复
+    - frontend 已完成活动卡片列表最小产品化第一刀
+    - frontend 当前进入联调回归与小范围修复阶段
     - 优先复用 backend 一键测试环境做回归
     - 优先复用:
       - `回归结果汇总`
       - `当前 Launch 实际配置摘要`
       - `前端调试日志`
-    - 不继续扩新页面链
+    - 当前不扩更多玩家侧新链
+    - 不重做首页现有入口区
     - 不做复杂运营样式
-    - 不启动活动卡片(列表)产品化开发
+    - frontend 当前分工:
+      - 活动列表页第一刀回归与小修
+      - 结构化日志补充
+      - 配合 backend 收口字段与默认值
 
 当前阶段的核心目标已经从“把地图画出来”升级为“建立一套可长期扩展的运动地图游戏底座”。  
 这套底座已经具备以下关键能力:

+ 30 - 11
t2b.md

@@ -1,6 +1,6 @@
 # T2B 协作清单
-> 文档版本:v1.12
-> 最后更新:2026-04-03 16:55:07
+> 文档版本:v1.15
+> 最后更新:2026-04-03 19:26:23
 
 说明:
 
@@ -47,22 +47,23 @@ backend 当前已完成:
 2. 固化详细日志口径,失败时明确定位在哪一步
 3. 固化稳定测试数据,并逐步支持更接近生产的真实输入
 
-当前认为“联调标准化第一版”已经完成,backend 下一步应进入
+当前认为“真实输入替换第二刀”已经完成,backend 当前已完成
 
-**真实输入替换第一刀**
+**活动卡片列表最小产品化配合第一刀**
 
 优先顺序建议:
 
-1. 继续推进真实 `content manifest`
-2. 再推进真实 `presentation schema`
-3. 最后补真实 `活动文案样例`
+1. 维持现有一键回归链稳定
+2. 为活动列表页补最小卡片摘要接口或字段
+3. 保持活动详情页与列表页使用同一套活动摘要口径
 
 说明:
 
-- 真实 `KML / 赛道文件`
-- 真实 `地图资源 URL`
+- 真实 `content manifest`
+- 真实 `presentation schema`
+- 真实 `活动文案样例`
 
-这类输入已接入,当前不再作为本轮重点。
+这类输入已接入,当前不再作为本轮重点。
 
 原则:
 
@@ -72,11 +73,29 @@ backend 当前已完成:
 
 当前 backend 不建议切去做:
 
-- 活动卡片列表产品化
 - 新的玩家侧页面入口
 - 更多管理对象
 - 更复杂后台 UI
 
+### 0.5 当前分工
+
+backend 当前这一轮已完成:
+
+1. 活动卡片列表最小字段集对应的后端摘要
+2. 卡片摘要接口/返回字段收口
+3. 默认体验活动与普通活动的状态标记
+
+当前进入:
+
+**活动卡片列表最小产品化第一刀联调回归与小范围修复配合阶段**
+
+要求:
+
+- 继续挂在现有标准 demo 与一键回归链上
+- 不新开流程
+- 不新开对象层级
+- 优先响应前端在列表页第一刀联调中暴露的字段、默认值和语义问题
+
 当前进一步明确 backend 的执行口径如下:
 
 ### 0.1 一键测试链路

+ 51 - 21
t2f.md

@@ -1,6 +1,6 @@
 # T2F 协作清单
-> 文档版本:v1.7
-> 最后更新:2026-04-03 16:55:07
+> 文档版本:v1.10
+> 最后更新:2026-04-03 19:26:23
 
 说明:
 
@@ -12,18 +12,21 @@
 
 ## 1. 当前目标
 
-当前前端线程已完成**活动运营域摘要接线第一刀**,进入联调标准化配合与小范围修复阶段。
+当前前端线程已完成:
+
+- 活动运营域摘要接线第一刀
+- 活动卡片列表最小产品化第一刀
+
+当前进入:
+
+**活动卡片列表最小产品化第一刀联调回归与小范围修复阶段**
 
 本阶段目标:
 
-- 验证活动运营域摘要接线是否稳定
-- 修正联调中发现的小范围字段、展示、一致性问题
-- 使用 backend 当前统一的“一键测试环境”和稳定 demo 数据做回归
-- 使用 backend 当前统一的结构化诊断入口做回归:
-  - `回归结果汇总`
-  - `当前 Launch 实际配置摘要`
-  - `前端调试日志`
-- 继续保持 runtime 主链稳定,不扩新页面链
+- 在 backend 一键测试环境下回归活动列表页第一刀
+- 验证卡片字段、分组、跳转与详情页链路稳定
+- 只做小范围修复,不扩更多玩家侧新链
+- 继续保持 runtime 主链稳定
 
 ---
 
@@ -101,7 +104,32 @@
 - 不消费完整 `EventPresentation` 结构
 - 不把 `ContentBundle` 展开成资源明细
 - 不重构首页、结果页、历史页已有结构
-- 不启动活动卡片(列表)产品化开发
+- 不做复杂运营化列表
+- 不重做首页现有入口区
+
+### 3.5 当前活动列表第一刀已完成
+
+当前已落地:
+
+1. 独立活动列表页:`/pages/events/events`
+2. 最小卡片样式
+3. 最小筛选:`全部 / 体验`
+4. 从列表跳活动详情页
+5. 首页补“活动列表”独立入口
+
+当前第一刀最小字段已覆盖:
+
+- `eventId`
+- `title`
+- `subtitle`
+- `summary`
+- `status`
+- `timeWindow`
+- `ctaText`
+- `coverUrl`
+- `isDefaultExperience`
+- `currentPresentation`
+- `currentContentBundle`
 
 ---
 
@@ -112,7 +140,7 @@
 - `resolvedRelease / business / variant` 旧字段继续保留
 - runtime 主链已经稳定,不要为了活动运营摘要去动 runtime 主链
 - 先做“看得见活动运营对象”,不先做复杂运营化样式
-- 当前进入联调回归阶段,优先修问题,不主动扩新页面入口
+- 当前活动列表第一刀允许扩一个独立列表页,但不扩更多玩家侧新链
 - 当前联调应优先复用 backend 一键测试环境,不再各自手工铺多份 demo 对象
 - 当前联调应优先复用 backend 提供的结构化诊断链,不再依赖截图 + 口头描述排查
 
@@ -122,10 +150,11 @@
 
 请前端线程后续重点回写:
 
-1. 联调过程中是否发现字段缺失或命名不稳
-2. 当前展示是否有明显歧义或信息层级问题
-3. 是否需要后端补更多名称摘要或默认字段
-4. 有没有因为活动运营摘要接线影响到 runtime 稳定主链
+1. 列表字段是否足够支持当前最小卡片
+2. `全部 / 体验` 分组是否符合当前产品预期
+3. 卡片点击进入活动详情页是否稳定
+4. 是否需要 backend 再补名称摘要、状态字段或默认值
+5. 有没有因为活动列表接线影响到 runtime 稳定主链
 
 ---
 
@@ -134,18 +163,19 @@
 1. 活动运营域摘要第一刀视为已完成
 2. 前端当前进入联调回归与小范围修复阶段
 3. 当前只接受字段修正、摘要打磨、一致性修复
-4. 不继续扩新页面链,不做复杂运营样式
+4. 当前不继续扩更多玩家侧新链,不做复杂运营样式
 5. 如果前端发现缺字段,再由总控统一回写给 backend
-6. 当前前端下一步重点是配合 backend 的一键测试环境做稳定回归,不再新增玩家侧功能入口
+6. 当前前端下一步重点是配合 backend 的一键测试环境做稳定回归
 7. 当前前端继续只做:
    - 联调回归
    - 小范围修复
    - 结构化日志补充
+8. 当前活动列表第一刀已完成,暂不进入第二刀产品扩展
 
 ---
 
 ## 7. 一句话结论
 
-当前前端最重要的事不是继续扩新,而是:
+当前前端最重要的事不是继续扩新页面,而是:
 
-**把活动运营域摘要第一刀先稳住,并统一切到 backend 一键测试环境下做联调回归和小范围修复。**
+**把活动卡片列表最小产品化第一刀先稳住,并统一切到 backend 一键测试环境下做联调回归和小范围修复。**

Some files were not shown because too many files changed in this diff