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

21 KiB
Raw Blame History

讲解流独立页面拆分 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.vuepages/explain/list.vue 收敛为展厅列表页,新增 business-unit-list.vueguide-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-checkpnpm lintpnpm 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: 阅读并锁定当前讲解入口代码

阅读以下代码块并记录要删除或迁移的行为:

// 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 点击展厅跳独立页”的手工检查点

将以下检查点写进任务注释或临时核对清单,不写入生产代码:

入口回归预期:
1. 首页切到讲解 Tab仍能看到展厅列表
2. 点击任一展厅,不再在首页内部切到业务单元 stage
3. 而是跳转到 /pages/explain/business-unit-list
  • Step 3: 将首页 explain Tab 的点击行为改为页面跳转

把首页 explain 逻辑从“内部 stage 切换”改为“跳到独立页”。保留 explain Tab 首屏展厅列表展示,但去掉首页中业务单元和讲解点的状态推进逻辑。实现目标示意:

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()}`
  })
}

同时移除首页中对以下逻辑的业务依赖:

explainStage.value = 'unit'
selectedExplainBusinessUnitId.value = ''
selectedExplainBusinessUnitName.value = ''

并把 handleExplainBusinessUnitClick / handleExplainBack 调整为不再驱动首页内部多级状态。

  • Step 4: 收敛 pages/explain/list.vue 为展厅列表页

删除 src/pages/explain/list.vue 中仅为单页多阶段服务的逻辑,目标代码形态示意:

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()}`
  })
}

模板里只保留:

<ExplainHallSelect
  :halls="explainHallItems"
  stage="hall"
  :loading="explainLoading"
  :error="explainError"
  @hall-click="handleExplainHallClick"
  @back="handleBack"
/>

移除 business-unitsguide-stopsselected-*history.pushState/popstate 相关逻辑。

  • Step 5: 运行类型检查验证页面收敛没有破坏现有引用

Run: pnpm type-check Expected: exit 0pages/index/index.vuepages/explain/list.vue 不再报 ExplainSelectStage / selectedExplainBusinessUnit 等已删除字段错误

  • Step 6: 提交
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.vuestage="unit" 展示模式
  • Produces:

    • 新页面路由:/pages/explain/business-unit-list?hallId=<id>&hallName=<name>
    • 页面级标题设置函数:syncPageTitle(title: string): void
    • 页面级返回:goBackToExplainHallList(): void
  • Step 1: 在 pages.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,先写最小骨架:

<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: 实现参数解析、标题同步与数据加载

在脚本中实现以下最小接口:

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 内实现:

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: 实现业务单元点击和页面级返回
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 0business-unit-list.vue 没有未使用导入、pages.json 对应引用未触发脚本错误

  • Step 6: 提交
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.vuestage="stop" 展示模式
  • Produces:

    • 新页面路由:/pages/explain/guide-stop-list?hallId=<id>&hallName=<name>&unitId=<id>&unitName=<name>
    • 点击讲解点后进入 pages/exhibit/detail
    • 页面级返回:goBackToBusinessUnitList(): void
  • Step 1: 在 pages.json 注册讲解点列表页

新增页面配置:

{
  "path": "pages/explain/guide-stop-list",
  "style": {
    "navigationBarTitleText": "讲解点",
    "navigationBarBackgroundColor": "#FFFFFF",
    "navigationStyle": "custom"
  }
}
  • Step 2: 创建讲解点页骨架
<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: 实现参数解析、标题同步与业务单元恢复
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 中:

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: 实现讲解点跳详情和业务单元页回退
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 0guide-stop-list.vuepages/exhibit/detailtargetType/targetId 参数类型与详情页现有解析逻辑兼容

  • Step 6: 提交
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: 阅读组件中仅服务单页状态机的逻辑

检查以下内容是否仍有必要:

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: 删除不再需要的宿主导航分支和单页补丁依赖

如果页面层已经承接宿主标题与返回,则组件只保留纯展示逻辑。最小目标是让组件不再承担“宿主导航是否可用”的页面级责任。目标代码形态:

const headerTitle = computed(() => {
  if (props.stage === 'unit') return props.selectedHallName || '业务单元'
  if (props.stage === 'stop') return props.selectedBusinessUnitName || '讲解点'
  return '讲解'
})

如确认嵌入小程序场景仍需隐藏自绘页头,可保留 shouldUseHostNavigation,但不要再让组件承担页面级 history/title 逻辑。

  • Step 3: 确认三种模式在拆页后都能独立渲染

核对模板三块:

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 0ExplainHallSelect.vue 无未使用 import / computed / props 分支

  • Step 5: 提交
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 补丁

删除以下类型逻辑:

type ExplainHistoryState = { ... }
window.history.pushState(...)
window.history.replaceState(...)
window.addEventListener('popstate', ...)

因为页面拆分后它们不再是正确抽象。

  • Step 2: 删除首页中旧的 explain 多阶段状态字段

src/pages/index/index.vue 删除或收敛以下字段:

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 当前逻辑:

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 手工回归

按以下顺序人工验证:

1. 首页进入讲解 Tab展厅列表正常显示
2. 点击展厅,进入业务单元列表页,标题为当前展厅名
3. 点击业务单元,进入讲解点列表页,标题为当前业务单元名
4. 点击讲解点,进入讲解详情页
5. 页面返回:详情 -> 讲解点列表 -> 业务单元列表 -> 展厅列表 -> 讲解首页/入口页
6. 直接打开业务单元列表页缺少 hallId 时显示错误态并可返回
7. 直接打开讲解点列表页缺少 hallId/unitId 时显示错误态并可返回

记录结果到提交说明或任务备注中,不写入生产代码。

  • Step 6: 提交
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"