diff --git a/.gitignore b/.gitignore index 97be300..083acd3 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,9 @@ dist/ build/ coverage/ project.private.config.json +project.config.json +private.wx0c8b079993bb9d7a.key +wX5FOd926R.txt *.log npm-debug.log* yarn-debug.log* diff --git a/doc/config-option-dictionary.md b/doc/config-option-dictionary.md index c9f9f9e..56b75b9 100644 --- a/doc/config-option-dictionary.md +++ b/doc/config-option-dictionary.md @@ -195,6 +195,18 @@ - 类型:`string` - 说明:打点完成后自动弹出的标题 +#### `template` + +- 类型:`string` +- 说明:原生内容卡模板 +- 当前支持: + - `minimal` + - `story` + - `focus` +- 建议默认值: + - 起点/终点:`focus` + - 普通点:`story` + #### `body` - 类型:`string` @@ -233,11 +245,86 @@ - 普通点:`1` - 终点:`2` +#### `contentExperience` + +- 类型:`object` +- 说明:打点完成后使用的体验承载配置 +- 当前支持: + - `native` + - `h5` + +#### `contentExperience.type` + +- 类型:`string` +- 说明:自动弹出内容的承载方式 +- 当前支持: + - `native` + - `h5` + +#### `contentExperience.url` + +- 类型:`string` +- 说明:当 `type = "h5"` 时对应的 H5 页面地址 + +#### `contentExperience.bridge` + +- 类型:`string` +- 说明:H5 bridge 版本 +- 建议默认值:`"content-v1"` + +#### `contentExperience.presentation` + +- 类型:`string` +- 说明:H5 内容的展示形态 +- 当前支持: + - `sheet` + - `dialog` + - `fullscreen` +- 建议默认值:`sheet` + +#### `clickExperience` + +- 类型:`object` +- 说明:点击控制点时使用的体验承载配置 +- 当前支持: + - `native` + - `h5` + +#### `clickExperience.type` + +- 类型:`string` +- 说明:点击内容的承载方式 +- 当前支持: + - `native` + - `h5` + +#### `clickExperience.url` + +- 类型:`string` +- 说明:当 `type = "h5"` 时对应的 H5 页面地址 + +#### `clickExperience.bridge` + +- 类型:`string` +- 说明:H5 bridge 版本 +- 建议默认值:`"content-v1"` + +#### `clickExperience.presentation` + +- 类型:`string` +- 说明:点击内容的展示形态 +- 当前支持: + - `sheet` + - `dialog` + - `fullscreen` +- 建议默认值:`sheet` + ### 6.3 示例 ```json "controlOverrides": { "start-1": { + "template": "focus", "title": "比赛开始", "body": "从这里出发,先熟悉地图方向。", "autoPopup": true, @@ -247,6 +334,7 @@ "clickBody": "点击起点可再次查看起跑说明。" }, "control-2": { + "template": "minimal", "score": 20, "title": "教学楼南侧", "body": "这里是重要转折点。", @@ -254,9 +342,22 @@ "once": true, "priority": 1, "clickTitle": "教学楼南侧", - "clickBody": "这个点配置成点击查看。" + "clickBody": "这个点配置成点击查看。", + "contentExperience": { + "type": "h5", + "url": "https://example.com/content/control-2", + "bridge": "content-v1", + "presentation": "sheet" + }, + "clickExperience": { + "type": "h5", + "url": "https://example.com/content/control-2-click", + "bridge": "content-v1", + "presentation": "dialog" + } }, "finish-1": { + "template": "focus", "title": "比赛结束", "body": "恭喜完成本次路线。", "autoPopup": true, diff --git a/doc/experience-shell-proposal.md b/doc/experience-shell-proposal.md new file mode 100644 index 0000000..1033869 --- /dev/null +++ b/doc/experience-shell-proposal.md @@ -0,0 +1,232 @@ +# Experience Shell 方案 + +本文档用于定义小程序中 H5 定制内容的承载方式。目标不是把 H5 做成真正的同页弹窗,而是做成: + +- 独立页面路由 +- 原生壳子控制外观 +- `web-view` 只负责内容区 + +这样既保留了 H5 的定制能力,也能让用户感受更接近“弹窗”或“抽屉”。 + +--- + +## 1. 设计目标 + +当前 H5 内容页已经能打开,但整页全屏切换比较生硬,用户体验不够好。 +新的 `experience-shell` 目标是: + +- 视觉上像弹窗 +- 保持原生关闭、回退、失败兜底逻辑 +- 不把地图主页面和 `web-view` 强绑在一起 +- 为后续结果页 H5、文创内容 H5 复用 + +--- + +## 2. 核心原则 + +### 2.1 不做真正同页 H5 弹窗 + +微信小程序里的 `web-view` 更适合放在独立页面中承载。 +不要尝试把 `web-view` 直接叠在地图页上方做真弹窗,否则后续很容易遇到: + +- 层级冲突 +- 手势冲突 +- iOS / Android 表现不一致 +- 遮罩和关闭逻辑变脏 + +### 2.2 原生壳子 + H5 内容区 + +最终结构应该是: + +- 原生遮罩 +- 原生标题栏 +- 原生关闭按钮 +- `web-view` 内容区 + +也就是: + +```text +experience-shell + ├─ backdrop + ├─ native header + └─ web-view body +``` + +--- + +## 3. 支持的展示方式 + +第一阶段只支持 3 种: + +- `sheet` +- `dialog` +- `fullscreen` + +### 3.1 `sheet` + +适合: + +- 打点后的文创内容 +- 拍照任务 +- 轻互动内容 + +视觉: + +- 自底部升起 +- 圆角卡片 +- 半透明暗背景 + +### 3.2 `dialog` + +适合: + +- 结果页 +- 中短内容 +- 重要说明 + +视觉: + +- 居中大卡片 +- 更聚焦 + +### 3.3 `fullscreen` + +适合: + +- 长内容 +- 强定制专题页 +- 复杂表单/小游戏 + +--- + +## 4. 配置结构 + +H5 内容配置建议支持: + +```json +{ + "type": "h5", + "url": "https://example.com/content/control-1", + "bridge": "content-v1", + "presentation": "sheet" +} +``` + +字段说明: + +- `type` + 当前支持 `native` / `h5` +- `url` + H5 页面地址 +- `bridge` + bridge 版本 +- `presentation` + 展示方式,支持: + - `sheet` + - `dialog` + - `fullscreen` + +默认值建议: + +- 内容体验默认 `sheet` +- 结果页默认 `dialog` + +--- + +## 5. 原生壳子职责 + +原生壳子负责: + +- 遮罩 +- 标题、副标题 +- 关闭按钮 +- 页面进入/退出动画 +- H5 打开失败回退 + +原生壳子不负责: + +- H5 页面内部业务逻辑 +- H5 具体视觉排版 + +--- + +## 6. 关闭与回退逻辑 + +### 6.1 原生关闭 + +原生必须始终支持: + +- 右上/头部关闭 +- 返回键关闭 +- 失败时自动关闭并回退 + +### 6.2 H5 请求关闭 + +H5 可以通过 bridge 发: + +- `close` + +然后由原生统一关闭壳子页。 + +### 6.3 H5 失败回退 + +如果出现: + +- URL 无效 +- 页面打不开 +- bridge 初始化失败 + +统一回退到: + +- 原生内容卡 +- 原生结果页 + +--- + +## 7. 动画建议 + +### `sheet` + +- 遮罩淡入 +- 面板自下而上出现 + +### `dialog` + +- 遮罩淡入 +- 面板轻微放大进入 + +### `lite` + +在低端机或 `lite` 模式下: + +- 只保留 opacity +- 降低位移动画强度 + +--- + +## 8. 推荐接入顺序 + +### 第一阶段 + +- 先把当前 `experience-webview` 升级成 shell +- 先支持 `sheet` +- 先接 `content-v1` + +### 第二阶段 + +- 补 `dialog` +- 结果页 H5 开始复用壳子 + +### 第三阶段 + +- 主题样式可配置 +- 过场动画接入 + +--- + +## 9. 一句话结论 + +小程序里的 H5 不应该直接作为“生硬全页”使用,也不应该强行做成“地图页上的真弹窗”。 +最稳的方案是: + +**独立页面承载,但由原生壳子把它做成 `sheet / dialog / fullscreen` 三种体验形态。** diff --git a/doc/h5-experience-integration-proposal.md b/doc/h5-experience-integration-proposal.md index 79cee14..931674f 100644 --- a/doc/h5-experience-integration-proposal.md +++ b/doc/h5-experience-integration-proposal.md @@ -29,7 +29,7 @@ 当前最适合 H5 承接的是: - 结算页 -- 打点后的定制内容页 +- 打点后的定制**详情页/互动页** - 文创详情页 - 活动品牌页 - 富图文任务页 @@ -45,6 +45,7 @@ - HUD - GPS / 心率等实时能力主链 - 需要强实时状态同步的高频游戏弹层 +- 游戏中的即时原生内容弹窗 一句话: @@ -52,6 +53,28 @@ --- +## 2.1 当前阶段的定案 + +经过真机验证,当前项目已经明确: + +- 小程序 `web-view` 在企业主体环境下可以正常打开 +- 但它不适合作为“原生弹窗里的局部 H5 内容区”使用 +- 真机上更接近整页原生容器,局部裁切、壳子覆盖、原生关闭按钮都不稳定 + +因此当前正式定案为: + +- **打点后的即时内容:原生内容卡** +- **H5:只作为详情页 / 互动任务页 / 全屏结果页** + +也就是说: + +- `content popup` 继续原生 +- 原生内容卡上提供 `查看详情` +- 点 `查看详情` 后再进入 H5 +- H5 打不开时,原生内容卡继续兜底 + +--- + ## 3. 总体架构 推荐分成三层: @@ -93,12 +116,12 @@ ### 4.1 Content Experience Page -用于游戏中途的内容体验页。 +用于游戏中途的**详情体验页**或**互动任务页**。 典型场景: -- 控制点打卡后弹文创详情 -- 控制点点击后查看图文内容 +- 控制点打卡后点击 `查看详情` +- 控制点点击后进入图文详情页 - 拍照上传任务 - 语音留言任务 - 小游戏互动页 @@ -129,6 +152,7 @@ - 打点成功必须先由原生确认 - 比赛结束必须先由原生确认 - H5 只是附加体验,不拥有核心状态 +- 原生内容卡必须先可独立工作 ### 原则 2:H5 打不开时回退原生 @@ -185,6 +209,11 @@ H5 可以展示、收集信息、提交任务结果。 } ``` +这个字段当前应理解为: + +- `contentExperience` = 原生内容卡上的 H5 **详情/互动扩展** +- 不是直接顶替原生内容弹窗 + 或: ```json diff --git a/doc/hybrid-experience-architecture.md b/doc/hybrid-experience-architecture.md index a6a663a..b9d0067 100644 --- a/doc/hybrid-experience-architecture.md +++ b/doc/hybrid-experience-architecture.md @@ -10,6 +10,12 @@ - 原生有限 DSL - H5 扩展页 +当前阶段已经进一步定案: + +- **即时内容弹窗:原生** +- **详情页 / 互动任务页:H5** +- **结算页:原生兜底 + H5 全屏增强** + --- ## 1. 为什么需要混合方案 @@ -134,6 +140,7 @@ - 品牌化结算页 - 长图文故事页 +- 原生内容卡上的“查看详情”页 - 拍照上传任务 - 语音留言页 - 小游戏互动页 @@ -180,6 +187,19 @@ H5 只负责: - 结束后至少能看到原生结果页 - H5 打不开时,主流程不受影响 +## 4.4 不再尝试 H5 弹窗本体 + +真机验证后,当前项目已经明确: + +- 小程序 `web-view` 不适合作为“原生弹窗里的局部 H5 内容区” +- 它更适合作为整页/全屏体验容器来使用 + +因此这条边界正式定为: + +- 原生内容卡负责即时弹窗体验 +- H5 不直接顶替原生弹窗 +- H5 只通过原生 CTA 进入详情页/任务页 + --- ## 5. 推荐的数据流 @@ -205,6 +225,18 @@ H5 只负责: **先稳定 ViewModel,再让模板与承载方式变化。** +当前内容体验链已经调整成: + +```text +控制点触发 + ↓ +原生内容卡(template) + ↓ +CTA: 查看详情(可选) + ↓ +H5 详情页 / 互动任务页 +``` + --- ## 6. ViewModel 的作用 diff --git a/event/classic-sequential.json b/event/classic-sequential.json index 99e6558..e731aa9 100644 --- a/event/classic-sequential.json +++ b/event/classic-sequential.json @@ -23,27 +23,31 @@ "CPRadius": 6, "controlOverrides": { "start-1": { + "template": "focus", "title": "比赛开始", - "body": "从这里出发,先熟悉地图方向,再推进到第一个目标点。", + "body": "从这里出发,先熟悉地图方向,再推进到第一个目标点。点击“查看详情”可打开 H5 详情页。", "autoPopup": true, "once": true, "priority": 1, "contentExperience": { "type": "h5", "url": "https://oss-mbh5.colormaprun.com/gotomars/h5/content-h5-test-template.html", - "bridge": "content-v1" + "bridge": "content-v1", + "presentation": "dialog" }, "clickTitle": "起点说明", "clickBody": "点击起点可再次查看起跑说明与路线背景。", "clickExperience": { "type": "h5", "url": "https://oss-mbh5.colormaprun.com/gotomars/h5/content-h5-test-template.html", - "bridge": "content-v1" + "bridge": "content-v1", + "presentation": "dialog" } }, "control-1": { + "template": "story", "title": "图书馆前广场", - "body": "这是第一检查点,完成后沿主路继续前进。", + "body": "这是第一检查点,完成后沿主路继续前进。卡片先原生弹出,再可进入 H5 详情。", "autoPopup": true, "once": false, "priority": 1, @@ -52,17 +56,20 @@ "contentExperience": { "type": "h5", "url": "https://oss-mbh5.colormaprun.com/gotomars/h5/content-h5-test-template.html", - "bridge": "content-v1" + "bridge": "content-v1", + "presentation": "dialog" }, "clickExperience": { "type": "h5", "url": "https://oss-mbh5.colormaprun.com/gotomars/h5/content-h5-test-template.html", - "bridge": "content-v1" + "bridge": "content-v1", + "presentation": "dialog" } }, "control-2": { + "template": "minimal", "title": "教学楼南侧", - "body": "注意这里地形开阔,适合快速判断下一段方向。", + "body": "注意这里地形开阔,适合快速判断下一段方向。这个点配置成手动查看后可进 H5。", "autoPopup": false, "once": true, "priority": 1, @@ -71,10 +78,12 @@ "clickExperience": { "type": "h5", "url": "https://oss-mbh5.colormaprun.com/gotomars/h5/content-h5-test-template.html", - "bridge": "content-v1" + "bridge": "content-v1", + "presentation": "dialog" } }, "control-3": { + "template": "story", "title": "湖边步道", "body": "经过这里时可以观察水边和林带的边界关系。", "autoPopup": true, @@ -84,8 +93,9 @@ "clickBody": "点击可查看更详细的路线观察建议。" }, "finish-1": { + "template": "focus", "title": "终点到达", - "body": "恭喜完成本次顺序赛,准备查看结果。", + "body": "恭喜完成本次顺序赛,准备查看结果。这里也保留 H5 详情入口用于测试。", "autoPopup": true, "once": true, "priority": 2, @@ -94,7 +104,8 @@ "clickExperience": { "type": "h5", "url": "https://oss-mbh5.colormaprun.com/gotomars/h5/content-h5-test-template.html", - "bridge": "content-v1" + "bridge": "content-v1", + "presentation": "dialog" } } }, diff --git a/event/score-o.json b/event/score-o.json index 78d80c5..993f66d 100644 --- a/event/score-o.json +++ b/event/score-o.json @@ -23,36 +23,44 @@ "CPRadius": 6, "controlOverrides": { "start-1": { + "template": "focus", "title": "比赛开始", - "body": "从这里触发,先熟悉地图方向。", + "body": "从这里触发,先熟悉地图方向。原生内容卡会先弹出,再可进入 H5 详情。", "autoPopup": true, "once": true, "priority": 1, "contentExperience": { "type": "h5", "url": "https://oss-mbh5.colormaprun.com/gotomars/h5/content-h5-test-template.html", - "bridge": "content-v1" + "bridge": "content-v1", + "presentation": "dialog" }, "clickTitle": "积分赛起点", "clickBody": "点击起点可查看自由打点规则与终点说明。", "clickExperience": { "type": "h5", "url": "https://oss-mbh5.colormaprun.com/gotomars/h5/content-h5-test-template.html", - "bridge": "content-v1" + "bridge": "content-v1", + "presentation": "dialog" } }, "control-1": { + "template": "minimal", "score": 10, "clickTitle": "1号点", "clickBody": "这是一个基础积分点,适合作为开局热身。", "clickExperience": { "type": "h5", "url": "https://oss-mbh5.colormaprun.com/gotomars/h5/content-h5-test-template.html", - "bridge": "content-v1" + "bridge": "content-v1", + "presentation": "dialog" } }, "control-2": { + "template": "minimal", "score": 20, + "title": "2号点", + "body": "这个点配置成手动查看。点击“查看内容”后先出原生卡,再可进入 H5。", "autoPopup": false, "once": true, "priority": 1, @@ -61,13 +69,15 @@ "clickExperience": { "type": "h5", "url": "https://oss-mbh5.colormaprun.com/gotomars/h5/content-h5-test-template.html", - "bridge": "content-v1" + "bridge": "content-v1", + "presentation": "dialog" } }, "control-3": { + "template": "story", "score": 30, "title": "湖边步道", - "body": "这里适合短暂停留观察周边地形。", + "body": "这里适合短暂停留观察周边地形。自动弹原生内容卡,并提供 H5 详情入口。", "autoPopup": true, "once": false, "priority": 1, @@ -76,7 +86,8 @@ "contentExperience": { "type": "h5", "url": "https://oss-mbh5.colormaprun.com/gotomars/h5/content-h5-test-template.html", - "bridge": "content-v1" + "bridge": "content-v1", + "presentation": "dialog" } }, "control-4": { @@ -86,6 +97,7 @@ "score": 50 }, "control-6": { + "template": "focus", "score": 60, "title": "悬崖边", "body": "这里很危险啊。", @@ -102,8 +114,9 @@ "score": 80 }, "finish-1": { + "template": "focus", "title": "比赛结束", - "body": "恭喜完成本次路线,准备查看结果。", + "body": "恭喜完成本次路线,准备查看结果。这里也保留 H5 详情入口用于测试。", "autoPopup": true, "once": true, "priority": 2, @@ -112,7 +125,8 @@ "clickExperience": { "type": "h5", "url": "https://oss-mbh5.colormaprun.com/gotomars/h5/content-h5-test-template.html", - "bridge": "content-v1" + "bridge": "content-v1", + "presentation": "dialog" } } }, diff --git a/miniprogram/engine/map/mapEngine.ts b/miniprogram/engine/map/mapEngine.ts index fbad9ad..ebc7f53 100644 --- a/miniprogram/engine/map/mapEngine.ts +++ b/miniprogram/engine/map/mapEngine.ts @@ -227,8 +227,11 @@ export interface MapEngineViewState { punchFeedbackText: string punchFeedbackTone: 'neutral' | 'success' | 'warning' contentCardVisible: boolean + contentCardTemplate: 'minimal' | 'story' | 'focus' contentCardTitle: string contentCardBody: string + contentCardActionVisible: boolean + contentCardActionText: string pendingContentEntryVisible: boolean pendingContentEntryText: string punchButtonFxClass: string @@ -252,6 +255,7 @@ export interface MapEngineCallbacks { } interface ContentCardEntry { + template: 'minimal' | 'story' | 'focus' title: string body: string motionClass: string @@ -381,8 +385,11 @@ const VIEW_SYNC_KEYS: Array = [ 'punchFeedbackText', 'punchFeedbackTone', 'contentCardVisible', + 'contentCardTemplate', 'contentCardTitle', 'contentCardBody', + 'contentCardActionVisible', + 'contentCardActionText', 'pendingContentEntryVisible', 'pendingContentEntryText', 'punchButtonFxClass', @@ -1281,8 +1288,11 @@ export class MapEngine { punchFeedbackText: '', punchFeedbackTone: 'neutral', contentCardVisible: false, + contentCardTemplate: 'story', contentCardTitle: '', contentCardBody: '', + contentCardActionVisible: false, + contentCardActionText: '查看详情', pendingContentEntryVisible: false, pendingContentEntryText: '', punchButtonFxClass: '', @@ -1801,8 +1811,10 @@ export class MapEngine { return { kind: 'content', title: title || resolved.control.label || '内容体验', + subtitle: resolved.displayMode === 'click' ? '点击查看内容' : '打点内容体验', url: experienceConfig.url, bridgeVersion: experienceConfig.bridge || 'content-v1', + presentation: experienceConfig.presentation || 'sheet', context: { eventId: this.configAppId || '', configTitle: this.state.mapName || '', @@ -1847,33 +1859,13 @@ export class MapEngine { openContentCardEntry(item: ContentCardEntry): void { this.clearContentCardTimer() - if (item.h5Request && this.onOpenH5Experience) { - this.setState({ - contentCardVisible: false, - contentCardFxClass: '', - pendingContentEntryVisible: false, - pendingContentEntryText: '', - }, true) - this.currentContentCardPriority = item.priority - this.currentContentCard = item - this.currentH5ExperienceOpen = true - if (item.once && item.contentKey) { - this.shownContentCardKeys[item.contentKey] = true - } - try { - this.onOpenH5Experience(item.h5Request) - return - } catch { - this.currentH5ExperienceOpen = false - this.currentContentCardPriority = 0 - this.currentContentCard = null - } - } - this.setState({ contentCardVisible: true, + contentCardTemplate: item.template, contentCardTitle: item.title, contentCardBody: item.body, + contentCardActionVisible: !!item.h5Request, + contentCardActionText: '查看详情', contentCardFxClass: item.motionClass, pendingContentEntryVisible: false, pendingContentEntryText: '', @@ -1883,18 +1875,77 @@ export class MapEngine { if (item.once && item.contentKey) { this.shownContentCardKeys[item.contentKey] = true } + if (item.h5Request) { + return + } this.contentCardTimer = setTimeout(() => { this.contentCardTimer = 0 this.currentContentCardPriority = 0 this.currentContentCard = null this.setState({ contentCardVisible: false, + contentCardTemplate: 'story', contentCardFxClass: '', + contentCardActionVisible: false, + contentCardActionText: '查看详情', }, true) this.flushQueuedContentCards() }, 2600) as unknown as number } + openCurrentContentCardDetail(): void { + if (!this.currentContentCard) { + this.setState({ + statusText: `当前没有可打开的内容详情 (${this.buildVersion})`, + }, true) + return + } + + if (!this.currentContentCard.h5Request) { + this.setState({ + statusText: `当前内容未配置 H5 详情 (${this.buildVersion})`, + }, true) + return + } + + if (!this.onOpenH5Experience) { + this.setState({ + statusText: `H5 详情入口未就绪 (${this.buildVersion})`, + }, true) + return + } + + if (this.currentH5ExperienceOpen) { + this.setState({ + statusText: `H5 详情页已在打开中 (${this.buildVersion})`, + }, true) + return + } + + const request = this.currentContentCard.h5Request + this.clearContentCardTimer() + this.setState({ + contentCardVisible: false, + contentCardTemplate: 'story', + contentCardTitle: '', + contentCardBody: '', + contentCardFxClass: '', + contentCardActionVisible: false, + contentCardActionText: '查看详情', + }, true) + this.currentH5ExperienceOpen = true + + try { + this.onOpenH5Experience(request) + } catch { + this.currentH5ExperienceOpen = false + this.openContentCardEntry({ + ...this.currentContentCard, + h5Request: null, + }) + } + } + flushQueuedContentCards(): void { if (this.state.contentCardVisible || !this.pendingContentCards.length) { this.syncPendingContentEntryState() @@ -1949,8 +2000,11 @@ export class MapEngine { punchFeedbackTone: 'neutral', punchFeedbackFxClass: '', contentCardVisible: false, + contentCardTemplate: 'story', contentCardTitle: '', contentCardBody: '', + contentCardActionVisible: false, + contentCardActionText: '查看详情', pendingContentEntryVisible: this.getPendingManualContentCount() > 0, pendingContentEntryText: this.buildPendingContentEntryText(), contentCardFxClass: '', @@ -2137,7 +2191,9 @@ export class MapEngine { const once = !!(options && options.once) const priority = options && typeof options.priority === 'number' ? options.priority : 0 const contentKey = options && options.contentKey ? options.contentKey : '' + const resolved = this.resolveContentControlByKey(contentKey) const entry = { + template: resolved && resolved.control.displayContent ? resolved.control.displayContent.template : 'story', title, body, motionClass, @@ -2182,7 +2238,12 @@ export class MapEngine { this.currentH5ExperienceOpen = false this.setState({ contentCardVisible: false, + contentCardTemplate: 'story', + contentCardTitle: '', + contentCardBody: '', contentCardFxClass: '', + contentCardActionVisible: false, + contentCardActionText: '查看详情', }, true) this.flushQueuedContentCards() } @@ -2228,6 +2289,7 @@ export class MapEngine { this.currentContentCardPriority = 0 this.currentContentCard = null this.openContentCardEntry({ + template: 'story', ...fallback, h5Request: null, }) diff --git a/miniprogram/game/content/courseToGameDefinition.ts b/miniprogram/game/content/courseToGameDefinition.ts index 814bb8a..02cd8d5 100644 --- a/miniprogram/game/content/courseToGameDefinition.ts +++ b/miniprogram/game/content/courseToGameDefinition.ts @@ -35,6 +35,7 @@ function applyExperienceOverride( url: null, bridge: 'content-v1', fallback: 'native', + presentation: 'sheet', } } @@ -44,6 +45,7 @@ function applyExperienceOverride( url: override.url, bridge: override.bridge || (baseExperience ? baseExperience.bridge : 'content-v1'), fallback: override.fallback || 'native', + presentation: override.presentation || (baseExperience ? baseExperience.presentation : 'sheet'), } } @@ -59,6 +61,7 @@ function applyDisplayContentOverride( } return { + template: override.template || baseContent.template, title: override.title || baseContent.title, body: override.body || baseContent.body, autoPopup: override.autoPopup !== undefined ? override.autoPopup : baseContent.autoPopup, @@ -100,6 +103,7 @@ export function buildGameDefinitionFromCourse( sequence: null, score: null, displayContent: applyDisplayContentOverride({ + template: 'focus', title: '比赛开始', body: `${start.label || '开始点'}已激活,按提示前往下一个目标点。`, autoPopup: true, @@ -128,6 +132,7 @@ export function buildGameDefinitionFromCourse( sequence: control.sequence, score, displayContent: applyDisplayContentOverride({ + template: 'story', title: score !== null ? `收集 ${label} (+${score}分)` : `收集 ${label}`, body: score !== null ? `${buildDisplayBody(label, control.sequence)} · ${score}分` : buildDisplayBody(label, control.sequence), autoPopup: true, @@ -154,6 +159,7 @@ export function buildGameDefinitionFromCourse( sequence: null, score: null, displayContent: applyDisplayContentOverride({ + template: 'focus', title: '完成路线', body: `${finish.label || '结束点'}已完成,准备查看本局结果。`, autoPopup: true, diff --git a/miniprogram/game/core/gameDefinition.ts b/miniprogram/game/core/gameDefinition.ts index f60d5de..a73afde 100644 --- a/miniprogram/game/core/gameDefinition.ts +++ b/miniprogram/game/core/gameDefinition.ts @@ -1,5 +1,6 @@ import { type LonLatPoint } from '../../utils/projection' import { type GameAudioConfig } from '../audio/audioConfig' +import { type H5ExperiencePresentation } from '../experience/h5Experience' export type GameMode = 'classic-sequential' | 'score-o' export type GameControlKind = 'start' | 'control' | 'finish' @@ -10,6 +11,7 @@ export interface GameContentExperienceConfig { url: string | null bridge: string fallback: 'native' + presentation: H5ExperiencePresentation } export interface GameContentExperienceConfigOverride { @@ -17,9 +19,11 @@ export interface GameContentExperienceConfigOverride { url?: string bridge?: string fallback?: 'native' + presentation?: H5ExperiencePresentation } export interface GameControlDisplayContent { + template: 'minimal' | 'story' | 'focus' title: string body: string autoPopup: boolean @@ -32,6 +36,7 @@ export interface GameControlDisplayContent { } export interface GameControlDisplayContentOverride { + template?: 'minimal' | 'story' | 'focus' title?: string body?: string autoPopup?: boolean diff --git a/miniprogram/game/experience/h5Experience.ts b/miniprogram/game/experience/h5Experience.ts index 8a9e819..49f67fb 100644 --- a/miniprogram/game/experience/h5Experience.ts +++ b/miniprogram/game/experience/h5Experience.ts @@ -1,4 +1,5 @@ export type H5ExperienceKind = 'content' | 'result' +export type H5ExperiencePresentation = 'sheet' | 'dialog' | 'fullscreen' export interface H5ExperienceFallbackPayload { title: string @@ -13,8 +14,10 @@ export interface H5ExperienceFallbackPayload { export interface H5ExperienceRequest { kind: H5ExperienceKind title: string + subtitle?: string url: string bridgeVersion: string + presentation: H5ExperiencePresentation context: Record fallback: H5ExperienceFallbackPayload } diff --git a/miniprogram/pages/experience-webview/experience-webview.js b/miniprogram/pages/experience-webview/experience-webview.js index b577245..c239a79 100644 --- a/miniprogram/pages/experience-webview/experience-webview.js +++ b/miniprogram/pages/experience-webview/experience-webview.js @@ -39,19 +39,29 @@ function emitCloseAndBack(payload) { Page({ data: { + pageTitle: '内容体验', + pageSubtitle: '', + presentation: 'sheet', webViewSrc: '', webViewReady: false, loadErrorText: '', + panelBodyHeightPx: 420, }, onLoad() { + const systemInfo = wx.getSystemInfoSync() + const windowHeight = typeof systemInfo.windowHeight === 'number' ? systemInfo.windowHeight : 700 pageResolved = false currentRequest = null currentEventChannel = null this.setData({ + pageTitle: '内容体验', + pageSubtitle: '', + presentation: 'sheet', webViewSrc: '', webViewReady: false, loadErrorText: '', + panelBodyHeightPx: Math.max(420, Math.floor(windowHeight * 0.62)), }) try { @@ -66,14 +76,21 @@ Page({ currentEventChannel.on('init', (request) => { currentRequest = request - wx.setNavigationBarTitle({ - title: request.title || '内容体验', - fail() {}, - }) + const presentation = request.presentation || 'sheet' + const panelHeightPx = presentation === 'dialog' + ? Math.max(420, Math.floor(windowHeight * 0.7)) + : presentation === 'fullscreen' + ? Math.max(520, windowHeight - 24) + : Math.max(420, Math.floor(windowHeight * 0.72)) + const headerHeightPx = presentation === 'fullscreen' ? 84 : 76 this.setData({ + pageTitle: request.title || '内容体验', + pageSubtitle: request.subtitle || '', + presentation, webViewSrc: buildWebViewSrc(request), webViewReady: true, loadErrorText: '', + panelBodyHeightPx: Math.max(240, panelHeightPx - headerHeightPx), }) }) }, @@ -124,4 +141,8 @@ Page({ }) emitFallbackAndClose() }, + + handleCloseTap() { + emitCloseAndBack({}) + }, }) diff --git a/miniprogram/pages/experience-webview/experience-webview.json b/miniprogram/pages/experience-webview/experience-webview.json index ce090f1..3859c36 100644 --- a/miniprogram/pages/experience-webview/experience-webview.json +++ b/miniprogram/pages/experience-webview/experience-webview.json @@ -1,3 +1,5 @@ { + "navigationStyle": "custom", + "disableScroll": true, "navigationBarTitleText": "内容体验" } diff --git a/miniprogram/pages/experience-webview/experience-webview.ts b/miniprogram/pages/experience-webview/experience-webview.ts index a58d31f..9c09cb1 100644 --- a/miniprogram/pages/experience-webview/experience-webview.ts +++ b/miniprogram/pages/experience-webview/experience-webview.ts @@ -1,9 +1,13 @@ import { type H5BridgeMessage, type H5ExperienceRequest } from '../../game/experience/h5Experience' type ExperienceWebViewPageData = { + pageTitle: string + pageSubtitle: string + presentation: 'sheet' | 'dialog' | 'fullscreen' webViewSrc: string webViewReady: boolean loadErrorText: string + panelBodyHeightPx: number } let currentRequest: H5ExperienceRequest | null = null @@ -47,19 +51,29 @@ function emitCloseAndBack(payload?: Record) { Page({ data: { + pageTitle: '内容体验', + pageSubtitle: '', + presentation: 'sheet', webViewSrc: '', webViewReady: false, loadErrorText: '', + panelBodyHeightPx: 420, }, onLoad() { + const systemInfo = wx.getSystemInfoSync() + const windowHeight = typeof systemInfo.windowHeight === 'number' ? systemInfo.windowHeight : 700 pageResolved = false currentRequest = null currentEventChannel = null this.setData({ + pageTitle: '内容体验', + pageSubtitle: '', + presentation: 'sheet', webViewSrc: '', webViewReady: false, loadErrorText: '', + panelBodyHeightPx: Math.max(420, Math.floor(windowHeight * 0.62)), }) try { @@ -74,14 +88,21 @@ Page({ currentEventChannel.on('init', (request: H5ExperienceRequest) => { currentRequest = request - wx.setNavigationBarTitle({ - title: request.title || '内容体验', - fail: () => {}, - }) + const presentation = request.presentation || 'sheet' + const panelHeightPx = presentation === 'dialog' + ? Math.max(420, Math.floor(windowHeight * 0.7)) + : presentation === 'fullscreen' + ? Math.max(520, windowHeight - 24) + : Math.max(420, Math.floor(windowHeight * 0.72)) + const headerHeightPx = presentation === 'fullscreen' ? 84 : 76 this.setData({ + pageTitle: request.title || '内容体验', + pageSubtitle: request.subtitle || '', + presentation, webViewSrc: buildWebViewSrc(request), webViewReady: true, loadErrorText: '', + panelBodyHeightPx: Math.max(240, panelHeightPx - headerHeightPx), }) }) }, @@ -133,4 +154,8 @@ Page({ }) emitFallbackAndClose() }, + + handleCloseTap() { + emitCloseAndBack({}) + }, }) diff --git a/miniprogram/pages/experience-webview/experience-webview.wxml b/miniprogram/pages/experience-webview/experience-webview.wxml index 3da6ffe..ea8d1e7 100644 --- a/miniprogram/pages/experience-webview/experience-webview.wxml +++ b/miniprogram/pages/experience-webview/experience-webview.wxml @@ -1,11 +1,27 @@ - - 内容页加载中 - {{loadErrorText}} - + + + + + + {{pageTitle}} + {{pageSubtitle}} + + 关闭 + - + + + 内容页加载中 + {{loadErrorText}} + + + + + + diff --git a/miniprogram/pages/experience-webview/experience-webview.wxss b/miniprogram/pages/experience-webview/experience-webview.wxss index 8607d17..0e91898 100644 --- a/miniprogram/pages/experience-webview/experience-webview.wxss +++ b/miniprogram/pages/experience-webview/experience-webview.wxss @@ -1,12 +1,94 @@ -.page { - height: 100%; -} page { - background: #f5f7f6; + background: transparent; +} + +.experience-shell { + position: relative; + min-height: 100vh; + overflow: hidden; +} + +.experience-shell__backdrop { + position: absolute; + inset: 0; + background: rgba(14, 18, 17, 0.48); +} + +.experience-shell__panel { + position: absolute; + left: 24rpx; + right: 24rpx; + background: #f6faf7; + border: 2rpx solid rgba(21, 36, 27, 0.08); + box-shadow: 0 24rpx 64rpx rgba(19, 31, 25, 0.22); + overflow: hidden; +} + +.experience-shell__panel--sheet { + bottom: 24rpx; + border-radius: 36rpx 36rpx 24rpx 24rpx; +} + +.experience-shell__panel--dialog { + top: 50%; + transform: translateY(-50%); + border-radius: 32rpx; +} + +.experience-shell__panel--fullscreen { + top: 12rpx; + bottom: 12rpx; + left: 12rpx; + right: 12rpx; + border-radius: 24rpx; +} + +.experience-shell__header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 20rpx; + padding: 24rpx 24rpx 18rpx; + background: linear-gradient(180deg, rgba(227, 243, 234, 0.96), rgba(246, 250, 247, 0.96)); + border-bottom: 2rpx solid rgba(30, 63, 46, 0.08); +} + +.experience-shell__header-copy { + min-width: 0; + flex: 1; +} + +.experience-shell__title { + color: #13241c; + font-size: 32rpx; + font-weight: 700; + line-height: 1.25; +} + +.experience-shell__subtitle { + margin-top: 6rpx; + color: #557463; + font-size: 22rpx; + line-height: 1.35; +} + +.experience-shell__close { + flex-shrink: 0; + padding: 12rpx 22rpx; + border-radius: 999rpx; + background: rgba(23, 46, 34, 0.08); + color: #244432; + font-size: 24rpx; + font-weight: 600; +} + +.experience-shell__body { + position: relative; + background: #f6faf7; } .experience-webview__loading { - min-height: 100vh; + height: 100%; display: flex; flex-direction: column; align-items: center; diff --git a/miniprogram/pages/map/map.ts b/miniprogram/pages/map/map.ts index 84ae48a..89c6762 100644 --- a/miniprogram/pages/map/map.ts +++ b/miniprogram/pages/map/map.ts @@ -800,8 +800,11 @@ Page({ punchFeedbackText: '', punchFeedbackTone: 'neutral', contentCardVisible: false, + contentCardTemplate: 'story', contentCardTitle: '', contentCardBody: '', + contentCardActionVisible: false, + contentCardActionText: '查看详情', punchButtonFxClass: '', panelProgressFxClass: '', panelDistanceFxClass: '', @@ -1117,8 +1120,11 @@ Page({ punchFeedbackText: '', punchFeedbackTone: 'neutral', contentCardVisible: false, + contentCardTemplate: 'story', contentCardTitle: '', contentCardBody: '', + contentCardActionVisible: false, + contentCardActionText: '查看详情', punchButtonFxClass: '', panelProgressFxClass: '', panelDistanceFxClass: '', @@ -1903,6 +1909,19 @@ Page({ } }, + handleOpenContentCardDetail() { + if (mapEngine) { + wx.showToast({ + title: '打开详情', + icon: 'none', + duration: 900, + }) + mapEngine.openCurrentContentCardDetail() + } + }, + + handleContentCardTap() {}, + openH5Experience(request: H5ExperienceRequest) { wx.navigateTo({ url: '/pages/experience-webview/experience-webview', diff --git a/miniprogram/pages/map/map.wxml b/miniprogram/pages/map/map.wxml index e513521..9b7c0aa 100644 --- a/miniprogram/pages/map/map.wxml +++ b/miniprogram/pages/map/map.wxml @@ -29,13 +29,6 @@ {{punchFeedbackText}} - - {{contentCardTitle}} - {{contentCardBody}} - 点击关闭 - - - @@ -80,6 +73,23 @@ + + {{contentCardTitle}} + {{contentCardBody}} + + {{contentCardActionText}} + 关闭 + + + {{punchHintText}} × diff --git a/miniprogram/pages/map/map.wxss b/miniprogram/pages/map/map.wxss index 13be461..e9a9b5a 100644 --- a/miniprogram/pages/map/map.wxss +++ b/miniprogram/pages/map/map.wxss @@ -2003,7 +2003,24 @@ background: rgba(248, 251, 244, 0.96); box-shadow: 0 18rpx 48rpx rgba(22, 48, 32, 0.18); box-sizing: border-box; - z-index: 17; + z-index: 33; + pointer-events: auto; +} + +.game-content-card--minimal { + width: 396rpx; + padding: 24rpx 24rpx 20rpx; + border-radius: 24rpx; + background: rgba(248, 251, 244, 0.94); +} + +.game-content-card--focus { + width: 468rpx; + padding: 30rpx 30rpx 26rpx; + border-radius: 30rpx; + background: linear-gradient(180deg, rgba(240, 248, 241, 0.98), rgba(248, 251, 244, 0.96)); + box-shadow: 0 22rpx 54rpx rgba(22, 48, 32, 0.2); + border: 2rpx solid rgba(92, 139, 109, 0.14); } .game-content-card__title { @@ -2013,6 +2030,15 @@ color: #163020; } +.game-content-card--minimal .game-content-card__title { + font-size: 30rpx; +} + +.game-content-card--focus .game-content-card__title { + font-size: 36rpx; + color: #103020; +} + .game-content-card__body { margin-top: 12rpx; font-size: 24rpx; @@ -2020,10 +2046,52 @@ color: #45624b; } -.game-content-card__hint { - margin-top: 16rpx; - font-size: 20rpx; - color: #809284; +.game-content-card--minimal .game-content-card__body { + margin-top: 10rpx; + font-size: 22rpx; +} + +.game-content-card--focus .game-content-card__body { + margin-top: 14rpx; + color: #3f5f49; +} + +.game-content-card__action-row { + margin-top: 18rpx; + display: flex; + align-items: center; + justify-content: flex-end; + gap: 16rpx; +} + +.game-content-card__action-row--split { + justify-content: space-between; +} + +.game-content-card__action { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 56rpx; + padding: 0 22rpx; + border-radius: 999rpx; + background: rgba(25, 78, 47, 0.1); + color: #18472d; + font-size: 22rpx; + font-weight: 700; +} + +.game-content-card__close { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 56rpx; + padding: 0 22rpx; + border-radius: 999rpx; + background: rgba(16, 32, 20, 0.06); + color: #5a685f; + font-size: 22rpx; + font-weight: 600; } .game-content-card--fx-pop { diff --git a/miniprogram/utils/remoteMapConfig.ts b/miniprogram/utils/remoteMapConfig.ts index 77d28c8..e240484 100644 --- a/miniprogram/utils/remoteMapConfig.ts +++ b/miniprogram/utils/remoteMapConfig.ts @@ -250,6 +250,7 @@ function parseContentExperienceOverride( return { type: 'native', fallback: 'native', + presentation: 'sheet', } } @@ -265,12 +266,19 @@ function parseContentExperienceOverride( const bridgeValue = typeof normalized.bridge === 'string' && normalized.bridge.trim() ? normalized.bridge.trim() : 'content-v1' + const rawPresentation = typeof normalized.presentation === 'string' + ? normalized.presentation.trim().toLowerCase() + : '' + const presentationValue = rawPresentation === 'dialog' || rawPresentation === 'fullscreen' + ? rawPresentation + : 'sheet' return { type: 'h5', url: resolveUrl(baseUrl, rawUrl), bridge: bridgeValue, fallback: 'native', + presentation: presentationValue, } } @@ -818,6 +826,12 @@ function parseGameConfigFromJson(text: string, gameConfigUrl: string): ParsedGam const titleValue = typeof (item as Record).title === 'string' ? ((item as Record).title as string).trim() : '' + const templateRaw = typeof (item as Record).template === 'string' + ? ((item as Record).template as string).trim().toLowerCase() + : '' + const templateValue = templateRaw === 'minimal' || templateRaw === 'story' || templateRaw === 'focus' + ? templateRaw + : '' const bodyValue = typeof (item as Record).body === 'string' ? ((item as Record).body as string).trim() : '' @@ -836,7 +850,8 @@ function parseGameConfigFromJson(text: string, gameConfigUrl: string): ParsedGam const hasOnce = typeof onceValue === 'boolean' const hasPriority = Number.isFinite(priorityNumeric) if ( - titleValue + templateValue + || titleValue || bodyValue || clickTitleValue || clickBodyValue @@ -847,6 +862,7 @@ function parseGameConfigFromJson(text: string, gameConfigUrl: string): ParsedGam || clickExperienceValue ) { controlContentOverrides[key] = { + ...(templateValue ? { template: templateValue } : {}), ...(titleValue ? { title: titleValue } : {}), ...(bodyValue ? { body: bodyValue } : {}), ...(clickTitleValue ? { clickTitle: clickTitleValue } : {}), diff --git a/readme-develop.md b/readme-develop.md index 81e5551..e6e561f 100644 --- a/readme-develop.md +++ b/readme-develop.md @@ -1476,3 +1476,66 @@ GPS: 详细说明见: - [platform-capability-notes.md](D:/dev/cmr-mini/doc/platform-capability-notes.md) + +--- + +## 23. 内容体验与 H5 分工定案 + +这一阶段又把“原生内容”和 “H5 定制内容”的边界试清楚了。 + +### 23.1 已确认的边界 + +在企业主体环境下: + +- `web-view` 已经可以正常打开 +- 但它不适合作为“原生弹窗里的局部 H5 内容区” +- 真机上更接近整页原生容器 + +因此当前正式定案为: + +- **即时内容弹窗:原生** +- **详情页 / 互动任务页:H5** +- **结果页:原生兜底 + H5 全屏增强** + +### 23.2 当前已经落地的内容体验链 + +现在控制点内容已经不是单一文本弹层,而是: + +- 原生内容卡模板 + - `minimal` + - `story` + - `focus` +- 配置驱动的展示控制 + - `title` + - `body` + - `clickTitle` + - `clickBody` + - `autoPopup` + - `once` + - `priority` +- 原生内容卡 CTA + - `查看详情` + +当前行为是: + +- 打点或点击后先显示原生内容卡 +- 如果该内容配置了 H5 详情,则卡片中显示 `查看详情` +- 点击后再进入 H5 详情页 +- H5 失败时继续回退原生内容 + +### 23.3 这一步的意义 + +这一步非常关键,因为它把过去“内容到底原生还是 H5”的混乱边界收清楚了: + +- 地图过程中的节奏控制,交给原生 +- 深度内容和强互动,交给 H5 +- 原生永远保底 + +后面继续扩展: + +- 拍照上传 +- 语音留言 +- 小游戏 +- 定制结果页 + +都会沿这条边界继续推进,而不是重新混在一个弹层里。