Files
frontend-miniapp/docs/superpowers/plans/2026-07-10-explain-multi-page-flow.md
lyf ac81574207 新增讲解多页面流实现计划
- 拆分讲解展厅列表、业务单元列表、讲解点列表页面
- 明确标题、返回、路由参数与回归验证步骤
- 约束在现有 explain 数据边界内实施

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 13:47:00 +08:00

666 lines
21 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 讲解流独立页面拆分 Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 将讲解流从单页 `stage` 状态机拆分为展厅列表页、业务单元列表页、讲解点列表页三个真实路由页面,使嵌入小程序时标题与返回都跟随页面层级工作。
**Architecture:** 保留现有 `explainUseCase` / repository 数据边界,页面层拆开但组件层短期继续复用 `ExplainHallSelect.vue``pages/explain/list.vue` 收敛为展厅列表页,新增 `business-unit-list.vue``guide-stop-list.vue` 分别承载业务单元和讲解点,详情页继续沿用 `pages/exhibit/detail.vue`
**Tech Stack:** uni-app、Vue 3 `<script setup>`、TypeScript、SCSS、现有 explain use case/repository/view-model 层
## Global Constraints
- 嵌入小程序时,宿主标题和返回行为与页面层级一致。
- 普通 H5 中现有讲解流视觉风格和交互尽量保持不变。
- 继续复用当前 explain use case / repository 数据边界,不引入新的页面直接数据耦合。
- 讲解点进入 `pages/exhibit/detail` 的既有详情能力保持可用。
- 不重做讲解详情页的数据模型或播放器架构。
- 不改造 explain 数据来源边界。
- 不新增小程序宿主桥接协议。
- 不重构 `ExplainHallSelect.vue` 为多个纯展示组件,除非实现时为降低复杂度必须做最小拆分。
- 标题规则固定为:展厅列表页 `讲解`,业务单元列表页 `当前展厅名`,讲解点列表页 `当前业务单元名`
- 返回链路固定为:讲解点列表 → 业务单元列表 → 展厅列表 → 讲解首页/入口页。
- 当前项目没有独立 `test` 脚本,验证以 `pnpm type-check``pnpm lint``pnpm build:h5` 和 H5 手工回归为准。
---
### Task 1: 收敛讲解首页入口,改为展厅列表独立页跳转
**Files:**
- Modify: `src/pages/index/index.vue`
- Modify: `src/pages/explain/list.vue`
- Test: `src/pages/index/index.vue`(手工回归入口)
**Interfaces:**
- Consumes:
- `explainUseCase.loadExplainHalls(): Promise<MuseumHall[]>`
- `explainUseCase.loadExplainHallSummaries(hallIds: string[]): Promise<Record<string, ExplainHallSummary>>`
- `navigateToGuideTopTab(tab: GuideTopTab): void`
- Produces:
- 首页讲解 Tab 点击展厅时调用 `uni.navigateTo({ url: '/pages/explain/list' })` 或直接在独立页内部继续流转
- `src/pages/explain/list.vue` 仅暴露展厅列表行为,不再承担 `unit` / `stop` 状态切换
- [ ] **Step 1: 阅读并锁定当前讲解入口代码**
阅读以下代码块并记录要删除或迁移的行为:
```ts
// src/pages/index/index.vue
const explainHallItems = ref<ExplainHallSelectItem[]>([])
const explainBusinessUnits = ref<ExplainBusinessUnit[]>([])
const explainStage = ref<ExplainSelectStage>('hall')
const selectedExplainHallId = ref('')
const selectedExplainBusinessUnitId = ref('')
```
重点确认:
- `ExplainHallSelect` 在首页 explain Tab 中被直接渲染
- `handleExplainHallClick / handleExplainBusinessUnitClick / handleExplainGuideStopClick / handleExplainBack` 仍由首页维护
- [ ] **Step 2: 先写实现后验证“首页 explain Tab 点击展厅跳独立页”的手工检查点**
将以下检查点写进任务注释或临时核对清单,不写入生产代码:
```text
入口回归预期:
1. 首页切到讲解 Tab仍能看到展厅列表
2. 点击任一展厅,不再在首页内部切到业务单元 stage
3. 而是跳转到 /pages/explain/business-unit-list
```
- [ ] **Step 3: 将首页 explain Tab 的点击行为改为页面跳转**
把首页 explain 逻辑从“内部 stage 切换”改为“跳到独立页”。保留 explain Tab 首屏展厅列表展示,但去掉首页中业务单元和讲解点的状态推进逻辑。实现目标示意:
```ts
const handleExplainHallClick = async (hallId: string) => {
const hall = explainHallItems.value.find((item) => item.id === hallId)
const params = new URLSearchParams({
hallId,
hallName: hall?.name || '讲解'
})
uni.navigateTo({
url: `/pages/explain/business-unit-list?${params.toString()}`
})
}
```
同时移除首页中对以下逻辑的业务依赖:
```ts
explainStage.value = 'unit'
selectedExplainBusinessUnitId.value = ''
selectedExplainBusinessUnitName.value = ''
```
并把 `handleExplainBusinessUnitClick` / `handleExplainBack` 调整为不再驱动首页内部多级状态。
- [ ] **Step 4: 收敛 `pages/explain/list.vue` 为展厅列表页**
删除 `src/pages/explain/list.vue` 中仅为单页多阶段服务的逻辑,目标代码形态示意:
```ts
const explainHallItems = ref<ExplainHallSelectItem[]>([])
const explainLoading = ref(false)
const explainError = ref('')
const handleExplainHallClick = (hallId: string) => {
const hall = explainHallItems.value.find((item) => item.id === hallId)
const params = new URLSearchParams({
hallId,
hallName: hall?.name || '讲解'
})
uni.navigateTo({
url: `/pages/explain/business-unit-list?${params.toString()}`
})
}
```
模板里只保留:
```vue
<ExplainHallSelect
:halls="explainHallItems"
stage="hall"
:loading="explainLoading"
:error="explainError"
@hall-click="handleExplainHallClick"
@back="handleBack"
/>
```
移除 `business-units``guide-stops``selected-*``history.pushState/popstate` 相关逻辑。
- [ ] **Step 5: 运行类型检查验证页面收敛没有破坏现有引用**
Run: `pnpm type-check`
Expected: exit 0`pages/index/index.vue``pages/explain/list.vue` 不再报 `ExplainSelectStage` / `selectedExplainBusinessUnit` 等已删除字段错误
- [ ] **Step 6: 提交**
```bash
git add src/pages/index/index.vue src/pages/explain/list.vue
git commit -m "refactor: split explain hall entry from home tab"
```
### Task 2: 新增业务单元列表页并接管展厅级标题与返回
**Files:**
- Create: `src/pages/explain/business-unit-list.vue`
- Modify: `src/pages.json`
- Test: `src/pages/explain/business-unit-list.vue`
**Interfaces:**
- Consumes:
- `explainUseCase.loadTemporaryBusinessUnitsByHall(hallId: string): Promise<ExplainBusinessUnit[]>`
- `ExplainHallSelect.vue``stage="unit"` 展示模式
- Produces:
- 新页面路由:`/pages/explain/business-unit-list?hallId=<id>&hallName=<name>`
- 页面级标题设置函数:`syncPageTitle(title: string): void`
- 页面级返回:`goBackToExplainHallList(): void`
- [ ] **Step 1: 在 `pages.json` 注册新页面**
新增页面配置:
```json
{
"path": "pages/explain/business-unit-list",
"style": {
"navigationBarTitleText": "业务单元",
"navigationBarBackgroundColor": "#FFFFFF",
"navigationStyle": "custom"
}
}
```
插入位置紧跟 `pages/explain/list`,保持 explain 路由集中。
- [ ] **Step 2: 创建业务单元页骨架并先放失败态/空态兜底**
创建 `src/pages/explain/business-unit-list.vue`,先写最小骨架:
```vue
<template>
<GuidePageFrame
active-tab="explain"
variant="static"
:show-top-tabs="false"
show-back
@tab-change="handleTopTabChange"
@back="handleBack"
>
<view class="explain-business-unit-page">
<ExplainHallSelect
:business-units="explainBusinessUnitItems"
stage="unit"
:selected-hall-name="selectedExplainHallName"
:loading="explainLoading"
:error="explainError"
@business-unit-click="handleExplainBusinessUnitClick"
@back="handleBack"
/>
</view>
</GuidePageFrame>
</template>
```
- [ ] **Step 3: 实现参数解析、标题同步与数据加载**
在脚本中实现以下最小接口:
```ts
const selectedExplainHallId = ref('')
const selectedExplainHallName = ref('')
const explainBusinessUnits = ref<ExplainBusinessUnit[]>([])
const explainLoading = ref(false)
const explainError = ref('')
const explainBusinessUnitItems = computed<ExplainBusinessUnitSelectItem[]>(() => (
explainBusinessUnits.value.map((unit) => ({
id: unit.id,
name: unit.name,
hallId: unit.hallId,
previewImageUrl: unit.previewImageUrl,
guideStopCount: unit.guideStopCount
}))
))
const syncPageTitle = (title: string) => {
uni.setNavigationBarTitle({ title })
if (typeof document !== 'undefined') document.title = title
}
```
`onLoad` 内实现:
```ts
onLoad(async (options: any = {}) => {
const hallId = Array.isArray(options.hallId) ? options.hallId[0] : options.hallId
const hallName = Array.isArray(options.hallName) ? options.hallName[0] : options.hallName
selectedExplainHallId.value = hallId || ''
selectedExplainHallName.value = hallName || '业务单元'
syncPageTitle(selectedExplainHallName.value)
if (!selectedExplainHallId.value) {
explainError.value = '缺少展厅参数,请返回讲解列表后重试'
return
}
explainLoading.value = true
try {
explainBusinessUnits.value = await explainUseCase.loadTemporaryBusinessUnitsByHall(selectedExplainHallId.value)
} catch (error) {
explainError.value = '展厅讲解点加载失败,请稍后重试'
} finally {
explainLoading.value = false
}
})
```
- [ ] **Step 4: 实现业务单元点击和页面级返回**
```ts
const handleExplainBusinessUnitClick = (unitId: string) => {
const unit = explainBusinessUnits.value.find((item) => item.id === unitId)
const params = new URLSearchParams({
hallId: selectedExplainHallId.value,
hallName: selectedExplainHallName.value,
unitId,
unitName: unit?.name || '讲解点'
})
uni.navigateTo({
url: `/pages/explain/guide-stop-list?${params.toString()}`
})
}
const goBackToExplainHallList = () => {
uni.redirectTo({
url: '/pages/explain/list',
fail: () => {
uni.reLaunch({ url: '/pages/explain/list' })
}
})
}
const handleBack = () => {
uni.navigateBack({
delta: 1,
fail: goBackToExplainHallList
})
}
```
- [ ] **Step 5: 运行 lint 验证新页面与 pages.json 兼容**
Run: `pnpm lint`
Expected: exit 0`business-unit-list.vue` 没有未使用导入、`pages.json` 对应引用未触发脚本错误
- [ ] **Step 6: 提交**
```bash
git add src/pages.json src/pages/explain/business-unit-list.vue
git commit -m "feat: add explain business unit page"
```
### Task 3: 新增讲解点列表页并接管业务单元级标题与返回
**Files:**
- Create: `src/pages/explain/guide-stop-list.vue`
- Modify: `src/pages.json`
- Test: `src/pages/explain/guide-stop-list.vue`
**Interfaces:**
- Consumes:
- `explainUseCase.selectBusinessUnit(hallId: string, unitId: string): Promise<ExplainBusinessUnit | null>`
- `normalizeExplainDetailTargetFromGuideStop({ id: string }): ExplainDetailTarget`
- `ExplainHallSelect.vue``stage="stop"` 展示模式
- Produces:
- 新页面路由:`/pages/explain/guide-stop-list?hallId=<id>&hallName=<name>&unitId=<id>&unitName=<name>`
- 点击讲解点后进入 `pages/exhibit/detail`
- 页面级返回:`goBackToBusinessUnitList(): void`
- [ ] **Step 1: 在 `pages.json` 注册讲解点列表页**
新增页面配置:
```json
{
"path": "pages/explain/guide-stop-list",
"style": {
"navigationBarTitleText": "讲解点",
"navigationBarBackgroundColor": "#FFFFFF",
"navigationStyle": "custom"
}
}
```
- [ ] **Step 2: 创建讲解点页骨架**
```vue
<template>
<GuidePageFrame
active-tab="explain"
variant="static"
:show-top-tabs="false"
show-back
@tab-change="handleTopTabChange"
@back="handleBack"
>
<view class="explain-guide-stop-page">
<ExplainHallSelect
:guide-stops="explainGuideStopItems"
stage="stop"
:selected-business-unit-name="selectedExplainBusinessUnitName"
:loading="explainLoading"
:error="explainError"
@guide-stop-click="handleExplainGuideStopClick"
@back="handleBack"
/>
</view>
</GuidePageFrame>
</template>
```
- [ ] **Step 3: 实现参数解析、标题同步与业务单元恢复**
```ts
const selectedExplainHallId = ref('')
const selectedExplainHallName = ref('')
const selectedExplainBusinessUnitId = ref('')
const selectedExplainBusinessUnitName = ref('')
const explainGuideStopItems = ref<ExplainGuideStopSelectItem[]>([])
const explainLoading = ref(false)
const explainError = ref('')
const toGuideStopItems = (unit: ExplainBusinessUnit): ExplainGuideStopSelectItem[] => (
unit.stops.map((stop) => ({
id: stop.id,
name: stop.name,
hallId: stop.hallId,
hallName: stop.hallName,
floorId: stop.floorId,
poiId: stop.poiId,
mapX: stop.mapX,
mapY: stop.mapY,
coverImageUrl: stop.coverImageUrl,
description: stop.description,
hasAudio: stop.hasAudio,
audioStatus: stop.audioStatus,
guideLevel: stop.guideLevel,
playTargetType: stop.playTargetType,
playTargetId: stop.playTargetId
}))
)
```
`onLoad` 中:
```ts
onLoad(async (options: any = {}) => {
selectedExplainHallId.value = Array.isArray(options.hallId) ? options.hallId[0] : options.hallId || ''
selectedExplainHallName.value = Array.isArray(options.hallName) ? options.hallName[0] : options.hallName || ''
selectedExplainBusinessUnitId.value = Array.isArray(options.unitId) ? options.unitId[0] : options.unitId || ''
selectedExplainBusinessUnitName.value = Array.isArray(options.unitName) ? options.unitName[0] : options.unitName || '讲解点'
syncPageTitle(selectedExplainBusinessUnitName.value)
if (!selectedExplainHallId.value || !selectedExplainBusinessUnitId.value) {
explainError.value = '缺少业务单元参数,请返回上一级后重试'
return
}
explainLoading.value = true
try {
const unit = await explainUseCase.selectBusinessUnit(
selectedExplainHallId.value,
selectedExplainBusinessUnitId.value
)
if (!unit) {
explainError.value = '未找到对应业务单元,请返回上一级后重试'
return
}
selectedExplainBusinessUnitName.value = unit.name || selectedExplainBusinessUnitName.value
syncPageTitle(selectedExplainBusinessUnitName.value)
explainGuideStopItems.value = toGuideStopItems(unit)
} catch (error) {
explainError.value = '讲解点加载失败,请稍后重试'
} finally {
explainLoading.value = false
}
})
```
- [ ] **Step 4: 实现讲解点跳详情和业务单元页回退**
```ts
const handleExplainGuideStopClick = (stop: ExplainGuideStopSelectItem) => {
const target = stop.playTargetType && stop.playTargetId
? { targetType: stop.playTargetType, targetId: stop.playTargetId }
: normalizeExplainDetailTargetFromGuideStop({ id: stop.id })
const params = new URLSearchParams({
id: stop.id,
tab: 'explain',
targetType: target.targetType,
targetId: target.targetId
})
uni.navigateTo({
url: `/pages/exhibit/detail?${params.toString()}`
})
}
const goBackToBusinessUnitList = () => {
const params = new URLSearchParams({
hallId: selectedExplainHallId.value,
hallName: selectedExplainHallName.value
})
uni.redirectTo({
url: `/pages/explain/business-unit-list?${params.toString()}`,
fail: () => {
uni.reLaunch({ url: `/pages/explain/business-unit-list?${params.toString()}` })
}
})
}
const handleBack = () => {
uni.navigateBack({
delta: 1,
fail: goBackToBusinessUnitList
})
}
```
- [ ] **Step 5: 运行类型检查确认 explain 详情入参链路未被破坏**
Run: `pnpm type-check`
Expected: exit 0`guide-stop-list.vue``pages/exhibit/detail``targetType/targetId` 参数类型与详情页现有解析逻辑兼容
- [ ] **Step 6: 提交**
```bash
git add src/pages.json src/pages/explain/guide-stop-list.vue
git commit -m "feat: add explain guide stop page"
```
### Task 4: 收敛 ExplainHallSelect 组件为“页面驱动、单模式复用”
**Files:**
- Modify: `src/components/explain/ExplainHallSelect.vue`
- Test: `src/components/explain/ExplainHallSelect.vue`
**Interfaces:**
- Consumes:
- 父页面传入的 `stage: 'hall' | 'unit' | 'stop'`
- 父页面传入的 `selectedHallName?: string`
- 父页面传入的 `selectedBusinessUnitName?: string`
- Produces:
- 保持 `hallClick(hallId: string)`
- 保持 `businessUnitClick(unitId: string)`
- 保持 `guideStopClick(stop: ExplainGuideStopSelectItem)`
- 保持 `back()`
- [ ] **Step 1: 阅读组件中仅服务单页状态机的逻辑**
检查以下内容是否仍有必要:
```ts
const shouldUseHostNavigation = computed(() => isEmbeddedInWechatMiniProgram())
const headerTitle = computed(() => {
if (props.stage === 'unit') return props.selectedHallName || '选择业务单元'
if (props.stage === 'stop') return props.selectedBusinessUnitName || '选择讲解点'
return '讲解'
})
```
判断原则:页面已拆开后,组件内部标题不再是宿主标题来源,只是普通 H5 自绘页头文案。
- [ ] **Step 2: 删除不再需要的宿主导航分支和单页补丁依赖**
如果页面层已经承接宿主标题与返回,则组件只保留纯展示逻辑。最小目标是让组件不再承担“宿主导航是否可用”的页面级责任。目标代码形态:
```ts
const headerTitle = computed(() => {
if (props.stage === 'unit') return props.selectedHallName || '业务单元'
if (props.stage === 'stop') return props.selectedBusinessUnitName || '讲解点'
return '讲解'
})
```
如确认嵌入小程序场景仍需隐藏自绘页头,可保留 `shouldUseHostNavigation`,但不要再让组件承担页面级 history/title 逻辑。
- [ ] **Step 3: 确认三种模式在拆页后都能独立渲染**
核对模板三块:
```vue
v-if="stage === 'hall'"
v-else-if="stage === 'unit'"
v-else-if="stage === 'stop'"
```
确保:
- hall 模式不依赖 `businessUnits`
- unit 模式不依赖 `guideStops`
- stop 模式不依赖首页 explain 旧状态
- [ ] **Step 4: 运行 lint 保证组件收敛后无未使用代码**
Run: `pnpm lint`
Expected: exit 0`ExplainHallSelect.vue` 无未使用 import / computed / props 分支
- [ ] **Step 5: 提交**
```bash
git add src/components/explain/ExplainHallSelect.vue
git commit -m "refactor: narrow explain hall select responsibilities"
```
### Task 5: 清理旧的单页讲解状态逻辑并完成全链路回归
**Files:**
- Modify: `src/pages/explain/list.vue`
- Modify: `src/pages/index/index.vue`
- Modify: `src/pages/exhibit/detail.vue`(仅当详情返回链路需要最小调整)
- Test: `src/pages/explain/list.vue`
- Test: `src/pages/explain/business-unit-list.vue`
- Test: `src/pages/explain/guide-stop-list.vue`
- Test: `src/pages/exhibit/detail.vue`
**Interfaces:**
- Consumes:
- 新页面路由 `/pages/explain/business-unit-list`
- 新页面路由 `/pages/explain/guide-stop-list`
- 现有详情页 `handleBack()` / `buildDetailRoute()` 逻辑
- Produces:
- 不再存在依赖 `explainStage` 的宿主标题 / history patch
- 完整回归链路:展厅列表 → 业务单元 → 讲解点 → 详情 → 返回
- [ ] **Step 1: 删除 `pages/explain/list.vue` 中旧的单页 history 补丁**
删除以下类型逻辑:
```ts
type ExplainHistoryState = { ... }
window.history.pushState(...)
window.history.replaceState(...)
window.addEventListener('popstate', ...)
```
因为页面拆分后它们不再是正确抽象。
- [ ] **Step 2: 删除首页中旧的 explain 多阶段状态字段**
`src/pages/index/index.vue` 删除或收敛以下字段:
```ts
const explainBusinessUnits = ref<ExplainBusinessUnit[]>([])
const explainStage = ref<ExplainSelectStage>('hall')
const selectedExplainHallId = ref('')
const selectedExplainBusinessUnitId = ref('')
const selectedExplainBusinessUnitName = ref('')
```
并同步删除只为这些字段服务的 computed / handlers。
- [ ] **Step 3: 最小检查详情返回是否还需要补充来源兜底**
检查 `src/pages/exhibit/detail.vue` 当前逻辑:
```ts
const handleBack = () => {
if (typeof window !== 'undefined' && window.history.length > 1) {
window.history.back()
...
return
}
uni.navigateBack({
delta: 1,
fail: fallbackToExplainHome
})
}
```
如果从新讲解点页进入详情后,`navigateBack()` 已能自然回到讲解点列表,则不改代码;如果仍会误回首页,再补最小来源参数(例如 `from=guide-stop-list` 与返回兜底路由),但禁止顺手改动播放器或详情正文逻辑。
- [ ] **Step 4: 运行完整静态验证**
Run: `pnpm type-check`
Expected: exit 0
Run: `pnpm lint`
Expected: exit 0
Run: `pnpm build:h5`
Expected: build complete允许保留项目既有 Sass / 字体告警,但不得有新的 explain 路由构建错误
- [ ] **Step 5: 执行 H5 手工回归**
按以下顺序人工验证:
```text
1. 首页进入讲解 Tab展厅列表正常显示
2. 点击展厅,进入业务单元列表页,标题为当前展厅名
3. 点击业务单元,进入讲解点列表页,标题为当前业务单元名
4. 点击讲解点,进入讲解详情页
5. 页面返回:详情 -> 讲解点列表 -> 业务单元列表 -> 展厅列表 -> 讲解首页/入口页
6. 直接打开业务单元列表页缺少 hallId 时显示错误态并可返回
7. 直接打开讲解点列表页缺少 hallId/unitId 时显示错误态并可返回
```
记录结果到提交说明或任务备注中,不写入生产代码。
- [ ] **Step 6: 提交**
```bash
git add src/pages/index/index.vue src/pages/explain/list.vue src/pages/exhibit/detail.vue src/components/explain/ExplainHallSelect.vue src/pages/explain/business-unit-list.vue src/pages/explain/guide-stop-list.vue src/pages.json
git commit -m "feat: split explain flow into dedicated pages"
```