Compare commits

...

7 Commits

Author SHA1 Message Date
lyf
e4f339c7ff 修复讲解页返回导航问题
Some checks failed
CI / verify (push) Has been cancelled
- 移除讲解选择页外层重复返回按钮
- 业务单元列表返回时优先回到讲解展厅列表
- 兼容从首页讲解入口直接进入业务单元列表的页面栈

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 17:41:09 +08:00
lyf
764146338a feat: add explain guide stop page 2026-07-10 15:56:40 +08:00
lyf
3eeeccd635 合并馆内模型初始化视角调整 2026-07-10 15:45:16 +08:00
lyf
130458e97e 调整馆内模型初始化视角 2026-07-10 14:31:21 +08:00
lyf
857fb296c6 refactor: split explain hall entry from home tab 2026-07-10 14:01:37 +08:00
lyf
ac81574207 新增讲解多页面流实现计划
- 拆分讲解展厅列表、业务单元列表、讲解点列表页面
- 明确标题、返回、路由参数与回归验证步骤
- 约束在现有 explain 数据边界内实施

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 13:47:00 +08:00
lyf
a5500091bc 新增讲解多页面流设计文档
- 设计展厅列表/业务单元列表/讲解点列表拆页方案
- 明确小程序宿主标题与返回链路
- 保留当前详情页与数据边界

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 13:33:34 +08:00
8 changed files with 1277 additions and 325 deletions

View File

@@ -0,0 +1,665 @@
# 讲解流独立页面拆分 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"
```

View File

@@ -0,0 +1,255 @@
# 讲解流拆分为独立页面设计
日期2026-07-10
## 背景
当前讲解流中的展厅列表、业务单元列表、讲解点列表都承载在 `src/pages/explain/list.vue` 的同一个页面内,通过 `explainStage``hall / unit / stop` 三种状态间切换。该结构在普通 H5 中可以通过页面内自绘标题栏和返回按钮维持体验,但在嵌入微信小程序 web-view 时存在明显问题:
1. 小程序宿主只能稳定感知“页面路由变化”,不能天然理解同一 H5 页面内部的状态切换。
2. 隐藏 H5 自绘标题栏后,业务单元列表和讲解点列表缺少可被宿主导航栏接管的真实页面标题。
3. 小程序宿主返回按钮更接近页面级返回,而不是单页内部 `stage` 状态回退,导致返回链路与讲解流层级不一致。
因此,本设计将讲解流拆成真实多页面结构,让标题和返回都回到页面路由语义上解决,而不再依赖单页状态机和 history 补丁。
## 目标
将讲解流调整为真实页面路由:
- 展厅列表页:标题固定为“讲解”
- 业务单元列表页:标题为当前展厅名
- 讲解点列表页:标题为当前业务单元名
- 返回链路:讲解点列表 → 业务单元列表 → 展厅列表 → 讲解首页/入口页
并满足以下要求:
- 嵌入小程序时,宿主标题和返回行为与页面层级一致。
- 普通 H5 中现有讲解流视觉风格和交互尽量保持不变。
- 继续复用当前 explain use case / repository 数据边界,不引入新的页面直接数据耦合。
- 讲解点进入 `pages/exhibit/detail` 的既有详情能力保持可用。
## 非目标
本次设计不包含以下内容:
- 不重做讲解详情页的数据模型或播放器架构。
- 不改造 explain 数据来源边界。
- 不新增小程序宿主桥接协议。
- 不重构 `ExplainHallSelect.vue` 为多个纯展示组件,除非实现时为降低复杂度必须做最小拆分。
## 方案比较
### 方案 A保留单页状态机继续补宿主桥接
继续使用 `pages/explain/list.vue` 承载全部层级,通过 `uni.setNavigationBarTitle``document.title``window.history.pushState` 与未来的小程序桥接能力修补宿主标题/返回问题。
优点:改动集中,短期页面数不增加。
缺点:本质上仍让小程序宿主面对单页内部状态机;标题和返回仍依赖额外桥接与补丁,稳定性和可理解性都较差。
### 方案 B拆成真实多页面复用现有列表组件推荐
将讲解流拆为多个真实页面路由,但短期继续复用 `ExplainHallSelect.vue`,每个页面只使用其中一种模式。
优点:
- 宿主标题与返回自然跟随页面路由。
- 页面职责变清晰。
- 改动面比“页面 + 组件同时重构”小。
- 后续还可继续把列表组件分拆为更清晰的纯展示组件。
缺点:`ExplainHallSelect.vue` 仍暂时保留多模式能力,组件层职责还不够彻底收敛。
### 方案 C完整拆页并同步拆分组件
同时新增多页面和多展示组件,分别为 HallList / BusinessUnitList / GuideStopList。
优点:结构最纯粹。
缺点:一次性改动面过大,风险更高,不适合作为当前问题的第一步修复。
## 结论
采用方案 B**页面层拆开,组件层先复用。**
## 页面结构设计
### 1. 展厅列表页
保留 `src/pages/explain/list.vue`,职责收敛为:
- 加载展厅列表数据
- 标题固定为“讲解”
- 点击展厅后跳转业务单元列表页
- 页面返回时回讲解首页/入口页
该页不再维护 `unit``stop` 两级状态,也不再承担业务单元和讲解点的数据恢复职责。
### 2. 业务单元列表页
新增 `src/pages/explain/business-unit-list.vue`
职责:
- 接收 `hallId`,必要时带 `hallName`
- 根据 `hallId` 调用 `explainUseCase.loadTemporaryBusinessUnitsByHall(hallId)` 加载业务单元
- 标题显示当前展厅名
- 点击业务单元后跳转讲解点列表页
- 返回时回展厅列表页
当路由参数缺失或展厅数据加载失败时,页面应给出可理解的错误态,并允许返回展厅列表页。
### 3. 讲解点列表页
新增 `src/pages/explain/guide-stop-list.vue`
职责:
- 接收 `hallId``unitId`,必要时带 `unitName`
- 根据 `hallId` 加载业务单元,再从中定位目标 `unitId`,或通过现有 use case 能力直接恢复业务单元
- 标题显示当前业务单元名
- 点击讲解点后进入 `pages/exhibit/detail`
- 返回时回业务单元列表页
### 4. 讲解详情页
保留 `src/pages/exhibit/detail.vue`
当前设计下不强制改造详情页结构,只要求:
- 从讲解点列表进入详情页时,浏览器 / uni 页面返回能自然回到讲解点列表页
- 现有 `navigateBack` 与 fallback 逻辑继续工作
## 路由与参数设计
### 展厅列表 → 业务单元列表
参数:
- `hallId`:必需
- `hallName`:可选,用于首屏标题占位和回退兜底
### 业务单元列表 → 讲解点列表
参数:
- `hallId`:必需
- `hallName`:可选
- `unitId`:必需
- `unitName`:可选,用于首屏标题占位和回退兜底
### 讲解点列表 → 讲解详情页
沿用当前详情参数:
- `id`
- `tab=explain`
- `targetType`
- `targetId`
## 标题策略
每个独立页面都在 `onLoad` 后设置页面标题:
- 展厅列表页:`讲解`
- 业务单元列表页:当前展厅名
- 讲解点列表页:当前业务单元名
页面层统一执行:
- `uni.setNavigationBarTitle({ title })`
- H5 场景下同步 `document.title = title`
这样可以同时兼容:
- uni H5 页面标题
- 普通浏览器标签页标题
- 小程序宿主在页面级别可感知的标题变化
## 返回策略
返回一律按照真实页面栈处理,不再用单页 `stage` 模拟:
- 讲解点列表页返回:`navigateBack()`,失败则带参数跳业务单元列表页
- 业务单元列表页返回:`navigateBack()`,失败则回展厅列表页
- 展厅列表页返回:`navigateBack()`,失败则回讲解首页/入口页
这样嵌入小程序时,宿主返回按钮和页面内返回按钮都会走同一个页面级链路。
## 组件策略
短期继续复用 `src/components/explain/ExplainHallSelect.vue`,但每个页面只传入一种模式:
- 展厅列表页:只传 `stage="hall"`
- 业务单元列表页:只传 `stage="unit"`
- 讲解点列表页:只传 `stage="stop"`
同时,父页面负责页面级标题和页面级返回,组件只保留内容展示和列表内部点击事件发射。
如果实现中发现多模式分支继续妨碍可读性,可做最小拆分,但目标仍应保持“本次先解决页面路由问题,而不是顺手做大规模组件重构”。
## 数据流设计
- 展厅列表页:`explainUseCase.loadExplainHalls()` + `loadExplainHallSummaries()`
- 业务单元列表页:`explainUseCase.loadTemporaryBusinessUnitsByHall(hallId)`
- 讲解点列表页:
- 先通过 `hallId` 载入业务单元集合
- 再根据 `unitId` 选中目标业务单元
- 从目标业务单元中映射出讲解点列表
页面消费的仍是 view-model 化后的轻量数据,不直接依赖底层源字段。
## 错误处理
### 业务单元列表页
`hallId` 缺失、展厅不存在或业务单元加载失败时:
- 标题使用 `hallName` 或兜底值
- 页面显示错误态
- 返回按钮仍可回展厅列表页
### 讲解点列表页
`hallId` / `unitId` 缺失、业务单元不存在或讲解点为空时:
- 标题使用 `unitName` 或兜底值
- 页面显示空态或错误态
- 返回按钮仍可回业务单元列表页
## 测试与验证
实现后至少验证:
1. 普通 H5
- 展厅列表 → 业务单元列表 → 讲解点列表 → 详情页
- 浏览器返回逐级回退
- 标题与页面内容一致
2. 嵌入小程序:
- 宿主标题按页面层级变化
- 宿主返回逐级回退
- H5 自绘头部隐藏后仍有清晰导航语义
3. 回退兜底:
- 直接打开业务单元列表页 / 讲解点列表页缺少参数时有合理错误态与返回路径
4. 回归:
- 讲解详情进入与返回不被破坏
- `pnpm type-check`
- `pnpm lint`
- `pnpm build:h5`
## 实施边界
本次实施应优先关注:
- 页面拆分
- 路由参数
- 标题设置
- 返回链路
- 现有讲解流回归
不应顺手做:
- explain 数据源重构
- 详情页播放器逻辑重写
- 小程序宿主专有桥接协议设计
- 与当前任务无关的全局导航大改

View File

@@ -134,11 +134,29 @@ const SGS_VISUAL_RENDER_CONFIG = {
} as const
const OVERVIEW_INITIAL_CAMERA_PARAMS = {
position: new THREE.Vector3(496.3356, 708.6058, 137.501),
position: new THREE.Vector3(305.4812, 441.3596, 205.0497),
target: new THREE.Vector3(-4.1432, -31.3659, 3.3982),
zoom: 1
} as const
const getIndoorInitialCameraDirection = () => (
OVERVIEW_INITIAL_CAMERA_PARAMS.position
.clone()
.sub(OVERVIEW_INITIAL_CAMERA_PARAMS.target)
.normalize()
)
const INDOOR_INITIAL_MODEL_PARAMS = {
position: new THREE.Vector3(0, 0, 0),
rotation: new THREE.Euler(
THREE.MathUtils.degToRad(0),
THREE.MathUtils.degToRad(0),
THREE.MathUtils.degToRad(0),
'XYZ'
),
scale: new THREE.Vector3(1, 1, 1)
} as const
const MODEL_ADJUST_IDLE_REPORT_DELAY_MS = 800
const degToRad = (degrees: number) => THREE.MathUtils.degToRad(degrees)
@@ -3430,7 +3448,7 @@ const fitCameraToObject = (object: THREE.Object3D, preset: CameraPreset = 'obliq
? new THREE.Vector3(0.02, 1, 0.02)
: activeView.value === 'overview'
? new THREE.Vector3(0.76, 0.48, 1)
: new THREE.Vector3(0.85, 0.62, 1)
: getIndoorInitialCameraDirection()
const overviewFitOptions: CameraFitOptions = activeView.value === 'overview'
? {
distanceFactor: 0.9,
@@ -3448,6 +3466,13 @@ const fitCameraToObject = (object: THREE.Object3D, preset: CameraPreset = 'obliq
setCameraView(center, maxDim, direction, overviewFitOptions)
}
const applyIndoorInitialModelTransform = (model: THREE.Object3D) => {
model.position.copy(INDOOR_INITIAL_MODEL_PARAMS.position)
model.rotation.copy(INDOOR_INITIAL_MODEL_PARAMS.rotation)
model.scale.copy(INDOOR_INITIAL_MODEL_PARAMS.scale)
model.updateMatrixWorld(true)
}
const applyOverviewInitialCamera = () => {
if (!camera || !controls) return
@@ -3576,7 +3601,9 @@ const loadOverview = async () => {
if (!targetScene) throw createStaleModelLoadError()
activeModel = cachedOverviewModel
activeModel.name = 'GuideOverviewModel'
activeModel.visible = true
applyIndoorInitialModelTransform(activeModel)
applyModelVisibilityForView(activeModel, 'overview')
cacheAutoSwitchOverviewMaxDim(activeModel)
targetScene.add(activeModel)
@@ -3606,6 +3633,7 @@ const loadOverview = async () => {
activeModel = gltf.scene
activeModel.name = 'GuideOverviewModel'
prepareModel(activeModel)
applyIndoorInitialModelTransform(activeModel)
applyModelVisibilityForView(activeModel, 'overview')
cacheAutoSwitchOverviewMaxDim(activeModel)
cachedOverviewModel = activeModel
@@ -3644,6 +3672,7 @@ const prepareFloorScene = async (
model.name = `GuideFloorModel_${requestedFloorId}`
model.userData.floorId = requestedFloorId
model.visible = true
applyIndoorInitialModelTransform(model)
applyModelVisibilityForView(model, 'floor', requestedFloorId)
} else {
if (!options.suppressProgress) {
@@ -3668,6 +3697,7 @@ const prepareFloorScene = async (
model.name = `GuideFloorModel_${requestedFloorId}`
model.userData.floorId = requestedFloorId
prepareModel(model)
applyIndoorInitialModelTransform(model)
applyModelVisibilityForView(model, 'floor', requestedFloorId)
cacheAsSharedModel = Boolean(floor.sharedModelAsset)

View File

@@ -30,6 +30,22 @@
"navigationStyle": "custom"
}
},
{
"path": "pages/explain/business-unit-list",
"style": {
"navigationBarTitleText": "业务单元",
"navigationBarBackgroundColor": "#FFFFFF",
"navigationStyle": "custom"
}
},
{
"path": "pages/explain/guide-stop-list",
"style": {
"navigationBarTitleText": "讲解点",
"navigationBarBackgroundColor": "#FFFFFF",
"navigationStyle": "custom"
}
},
{
"path": "pages/hall/detail",
"style": {

View File

@@ -0,0 +1,134 @@
<template>
<GuidePageFrame
active-tab="explain"
variant="static"
:show-top-tabs="false"
@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>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import GuidePageFrame from '@/components/navigation/GuidePageFrame.vue'
import ExplainHallSelect, {
type ExplainBusinessUnitSelectItem
} from '@/components/explain/ExplainHallSelect.vue'
import {
explainUseCase
} from '@/usecases/explainUseCase'
import type {
ExplainBusinessUnit
} from '@/domain/museum'
import {
navigateToGuideTopTab,
type GuideTopTab
} from '@/utils/guideTopTabs'
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(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
}
})
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 = () => {
const pages = getCurrentPages()
const previousPage = pages.length >= 2 ? pages[pages.length - 2] : null
if (previousPage?.route === 'pages/explain/list') {
uni.navigateBack({
delta: 1,
fail: goBackToExplainHallList
})
return
}
goBackToExplainHallList()
}
const handleTopTabChange = (tab: GuideTopTab) => {
navigateToGuideTopTab(tab)
}
</script>
<style scoped lang="scss">
.explain-business-unit-page {
position: absolute;
inset: 0;
background: #edf0ea;
}
</style>

View File

@@ -0,0 +1,159 @@
<template>
<GuidePageFrame
active-tab="explain"
variant="static"
:show-top-tabs="false"
@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>
<script setup lang="ts">
import { ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import GuidePageFrame from '@/components/navigation/GuidePageFrame.vue'
import ExplainHallSelect, {
type ExplainGuideStopSelectItem
} from '@/components/explain/ExplainHallSelect.vue'
import {
explainUseCase
} from '@/usecases/explainUseCase'
import type {
ExplainBusinessUnit
} from '@/domain/museum'
import {
normalizeExplainDetailTargetFromGuideStop
} from '@/domain/explainDetailTarget'
import {
navigateToGuideTopTab,
type GuideTopTab
} from '@/utils/guideTopTabs'
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
}))
)
const syncPageTitle = (title: string) => {
uni.setNavigationBarTitle({ title })
if (typeof document !== 'undefined') document.title = title
}
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
}
})
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
})
}
const handleTopTabChange = (tab: GuideTopTab) => {
navigateToGuideTopTab(tab)
}
</script>
<style scoped lang="scss">
.explain-guide-stop-page {
position: absolute;
inset: 0;
background: #edf0ea;
}
</style>

View File

@@ -3,48 +3,34 @@
active-tab="explain"
variant="static"
:show-top-tabs="false"
show-back
back-label="讲解首页"
@tab-change="handleTopTabChange"
@back="handleBack"
>
<view class="explain-all-page">
<ExplainHallSelect
:halls="explainHallItems"
:business-units="explainBusinessUnitItems"
:guide-stops="explainGuideStopItems"
:stage="explainStage"
:selected-hall-name="selectedExplainHallName"
:selected-business-unit-name="selectedExplainBusinessUnitName"
stage="hall"
:loading="explainLoading"
:error="explainError"
@hall-click="handleExplainHallClick"
@business-unit-click="handleExplainBusinessUnitClick"
@guide-stop-click="handleExplainGuideStopClick"
@back="handlePanelBack"
@back="handleBack"
/>
</view>
</GuidePageFrame>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { computed, ref } from 'vue'
import { onLoad, onUnload } from '@dcloudio/uni-app'
import GuidePageFrame from '@/components/navigation/GuidePageFrame.vue'
import ExplainHallSelect, {
type ExplainBusinessUnitSelectItem,
type ExplainGuideStopSelectItem,
type ExplainHallSelectItem,
type ExplainSelectStage
type ExplainHallSelectItem
} from '@/components/explain/ExplainHallSelect.vue'
import {
explainUseCase
} from '@/usecases/explainUseCase'
import {
normalizeExplainDetailTargetFromGuideStop
} from '@/domain/explainDetailTarget'
import type {
ExplainBusinessUnit,
MuseumHall
} from '@/domain/museum'
import {
@@ -54,12 +40,6 @@ import {
import { isEmbeddedInWechatMiniProgram } from '@/utils/hostEnvironment'
const explainHallItems = ref<ExplainHallSelectItem[]>([])
const explainBusinessUnits = ref<ExplainBusinessUnit[]>([])
const explainStage = ref<ExplainSelectStage>('hall')
const selectedExplainHallId = ref('')
const selectedExplainHallName = ref('')
const selectedExplainBusinessUnitId = ref('')
const selectedExplainBusinessUnitName = ref('')
const explainLoading = ref(false)
const explainError = ref('')
let explainLoadPromise: Promise<void> | null = null
@@ -87,83 +67,21 @@ const buildExplainHallItems = (
}))
)
const selectedExplainBusinessUnit = computed(() => (
explainBusinessUnits.value.find((unit) => unit.id === selectedExplainBusinessUnitId.value) || null
))
const explainPageTitle = computed(() => {
if (explainStage.value === 'unit') return selectedExplainHallName.value || '选择业务单元'
if (explainStage.value === 'stop') return selectedExplainBusinessUnitName.value || '选择讲解点'
return '讲解'
})
const shouldUseHostNavigation = computed(() => isEmbeddedInWechatMiniProgram())
const syncHostNavigationTitle = () => {
const title = explainPageTitle.value
if (typeof uni !== 'undefined') {
uni.setNavigationBarTitle({ title })
}
if (typeof document !== 'undefined') {
document.title = title
}
}
const explainBusinessUnitItems = computed<ExplainBusinessUnitSelectItem[]>(() => (
explainBusinessUnits.value.map((unit) => ({
id: unit.id,
name: unit.name,
hallId: unit.hallId,
previewImageUrl: unit.previewImageUrl,
guideStopCount: unit.guideStopCount
}))
))
[]))
const explainGuideStopItems = computed<ExplainGuideStopSelectItem[]>(() => (
selectedExplainBusinessUnit.value?.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
})) || []
))
type ExplainHistoryState = {
museumExplainList?: boolean
stage?: ExplainSelectStage
hallId?: string
hallName?: string
unitId?: string
unitName?: string
}
let syncingExplainHistory = false
let applyingExplainHistory = false
const stageRank: Record<ExplainSelectStage, number> = {
hall: 0,
unit: 1,
stop: 2
}
const currentExplainHistoryState = (): ExplainHistoryState => ({
museumExplainList: true,
stage: explainStage.value,
hallId: selectedExplainHallId.value,
hallName: selectedExplainHallName.value,
unitId: selectedExplainBusinessUnitId.value,
unitName: selectedExplainBusinessUnitName.value
museumExplainList: true
})
const writeExplainHistoryState = (replace = false) => {
@@ -188,48 +106,6 @@ const applyExplainHistoryState = async (state: ExplainHistoryState | null) => {
if (!state?.museumExplainList || applyingExplainHistory) return
applyingExplainHistory = true
const targetStage = state.stage || 'hall'
if (targetStage === 'hall') {
explainStage.value = 'hall'
selectedExplainHallId.value = ''
selectedExplainHallName.value = ''
selectedExplainBusinessUnitId.value = ''
selectedExplainBusinessUnitName.value = ''
explainBusinessUnits.value = []
explainError.value = ''
applyingExplainHistory = false
return
}
if (state.hallId) {
selectedExplainHallId.value = state.hallId
selectedExplainHallName.value = state.hallName || '业务单元'
selectedExplainBusinessUnitId.value = ''
selectedExplainBusinessUnitName.value = ''
explainStage.value = 'unit'
if (!explainBusinessUnits.value.length || explainBusinessUnits.value[0]?.hallId !== state.hallId) {
explainLoading.value = true
explainError.value = ''
try {
explainBusinessUnits.value = await explainUseCase.loadTemporaryBusinessUnitsByHall(state.hallId)
} catch (error) {
console.error('恢复展厅讲解点失败:', error)
explainError.value = '展厅讲解点加载失败,请稍后重试'
explainBusinessUnits.value = []
} finally {
explainLoading.value = false
}
}
}
if (targetStage === 'stop' && state.unitId) {
selectedExplainBusinessUnitId.value = state.unitId
selectedExplainBusinessUnitName.value = state.unitName || '讲解点'
explainStage.value = 'stop'
}
applyingExplainHistory = false
}
@@ -238,26 +114,10 @@ const handleExplainHistoryPopState = (event: PopStateEvent) => {
void applyExplainHistoryState((event.state || null) as ExplainHistoryState | null)
}
const pushExplainStageHistory = (previousStage: ExplainSelectStage) => {
if (!shouldUseHostNavigation.value || applyingExplainHistory) return
if (stageRank[explainStage.value] <= stageRank[previousStage]) return
writeExplainHistoryState()
}
const replaceExplainStageHistory = () => {
writeExplainHistoryState(true)
}
watch(explainPageTitle, syncHostNavigationTitle)
watch(shouldUseHostNavigation, (value) => {
if (value) {
syncHostNavigationTitle()
replaceExplainStageHistory()
}
})
const loadExplainHalls = async () => {
if (explainHallItems.value.length) return
if (explainLoadPromise) return explainLoadPromise
@@ -284,7 +144,6 @@ const loadExplainHalls = async () => {
}
onLoad(() => {
syncHostNavigationTitle()
replaceExplainStageHistory()
void loadExplainHalls()
@@ -319,82 +178,18 @@ const handleTopTabChange = (tab: GuideTopTab) => {
navigateToGuideTopTab(tab)
}
const handleExplainHallClick = async (hallId: string) => {
const previousStage = explainStage.value
const handleExplainHallClick = (hallId: string) => {
const hall = explainHallItems.value.find((item) => item.id === hallId)
selectedExplainHallId.value = hallId
selectedExplainHallName.value = hall?.name || '业务单元'
selectedExplainBusinessUnitId.value = ''
selectedExplainBusinessUnitName.value = ''
explainBusinessUnits.value = []
explainLoading.value = true
explainError.value = ''
try {
explainBusinessUnits.value = await explainUseCase.loadTemporaryBusinessUnitsByHall(hallId)
explainStage.value = 'unit'
pushExplainStageHistory(previousStage)
} catch (error) {
console.error('加载展厅讲解点失败:', error)
explainError.value = '展厅讲解点加载失败,请稍后重试'
explainStage.value = 'unit'
pushExplainStageHistory(previousStage)
} finally {
explainLoading.value = false
}
}
const handleExplainBusinessUnitClick = (unitId: string) => {
const previousStage = explainStage.value
const unit = explainBusinessUnits.value.find((item) => item.id === unitId)
selectedExplainBusinessUnitId.value = unitId
selectedExplainBusinessUnitName.value = unit?.name || '讲解点'
explainStage.value = 'stop'
pushExplainStageHistory(previousStage)
}
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
hallId,
hallName: hall?.name || '讲解'
})
uni.navigateTo({
url: `/pages/exhibit/detail?${params.toString()}`
url: `/pages/explain/business-unit-list?${params.toString()}`
})
}
const handlePanelBack = () => {
if (explainStage.value === 'stop') {
explainStage.value = 'unit'
selectedExplainBusinessUnitId.value = ''
selectedExplainBusinessUnitName.value = ''
explainError.value = ''
replaceExplainStageHistory()
return
}
if (explainStage.value === 'unit') {
explainStage.value = 'hall'
selectedExplainHallId.value = ''
selectedExplainHallName.value = ''
selectedExplainBusinessUnitId.value = ''
selectedExplainBusinessUnitName.value = ''
explainBusinessUnits.value = []
explainError.value = ''
replaceExplainStageHistory()
return
}
handleBack()
}
</script>
<style scoped lang="scss">

View File

@@ -247,16 +247,10 @@
<view v-else-if="currentTab === 'explain'" class="explain-page">
<ExplainHallSelect
:halls="explainHallItems"
:business-units="explainBusinessUnitItems"
:guide-stops="explainGuideStopItems"
:stage="explainStage"
:selected-hall-name="selectedExplainHallName"
:selected-business-unit-name="selectedExplainBusinessUnitName"
stage="hall"
:loading="explainLoading"
:error="explainError"
@hall-click="handleExplainHallClick"
@business-unit-click="handleExplainBusinessUnitClick"
@guide-stop-click="handleExplainGuideStopClick"
@back="handleExplainBack"
/>
</view>
@@ -317,9 +311,6 @@ import type {
RoutePointOption
} from '@/components/navigation/RoutePointPicker.vue'
import ExplainHallSelect, {
type ExplainBusinessUnitSelectItem,
type ExplainGuideStopSelectItem,
type ExplainSelectStage,
type ExplainHallSelectItem
} from '@/components/explain/ExplainHallSelect.vue'
import {
@@ -350,7 +341,6 @@ import {
import type {
GuideRouteResult,
GuideRouteTarget,
ExplainBusinessUnit,
MuseumFloor,
MuseumHall
} from '@/domain/museum'
@@ -749,48 +739,10 @@ const routeSimulationStepText = computed(() => {
})
const explainHallItems = ref<ExplainHallSelectItem[]>([])
const explainBusinessUnits = ref<ExplainBusinessUnit[]>([])
const explainStage = ref<ExplainSelectStage>('hall')
const selectedExplainHallId = ref('')
const selectedExplainHallName = ref('')
const selectedExplainBusinessUnitId = ref('')
const selectedExplainBusinessUnitName = ref('')
const explainLoading = ref(false)
const explainError = ref('')
let explainLoadPromise: Promise<void> | null = null
const selectedExplainBusinessUnit = computed(() => (
explainBusinessUnits.value.find((unit) => unit.id === selectedExplainBusinessUnitId.value) || null
))
const explainBusinessUnitItems = computed<ExplainBusinessUnitSelectItem[]>(() => (
explainBusinessUnits.value.map((unit) => ({
id: unit.id,
name: unit.name,
hallId: unit.hallId,
previewImageUrl: unit.previewImageUrl,
guideStopCount: unit.guideStopCount
}))
))
const explainGuideStopItems = computed<ExplainGuideStopSelectItem[]>(() => (
selectedExplainBusinessUnit.value?.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
})) || []
))
// Tab 切换处理
const syncTopTabToUrl = (tabId: GuideTopTab) => {
if (typeof window === 'undefined') return
@@ -1456,71 +1408,17 @@ const guideSearchText = computed(() => {
// 讲解页面处理
const handleExplainHallClick = async (hallId: string) => {
const hall = explainHallItems.value.find((item) => item.id === hallId)
selectedExplainHallId.value = hallId
selectedExplainHallName.value = hall?.name || '业务单元'
selectedExplainBusinessUnitId.value = ''
selectedExplainBusinessUnitName.value = ''
explainBusinessUnits.value = []
explainError.value = ''
explainLoading.value = true
try {
explainBusinessUnits.value = await explainUseCase.loadTemporaryBusinessUnitsByHall(hallId)
explainStage.value = 'unit'
} catch (error) {
console.error('加载展厅讲解点失败:', error)
explainError.value = '展厅讲解点加载失败,请稍后重试'
explainStage.value = 'unit'
} finally {
explainLoading.value = false
}
}
const handleExplainBusinessUnitClick = (unitId: string) => {
const unit = explainBusinessUnits.value.find((item) => item.id === unitId)
selectedExplainBusinessUnitId.value = unitId
selectedExplainBusinessUnitName.value = unit?.name || '讲解点'
explainStage.value = 'stop'
}
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
hallId,
hallName: hall?.name || '讲解'
})
uni.navigateTo({
url: `/pages/exhibit/detail?${params.toString()}`
url: `/pages/explain/business-unit-list?${params.toString()}`
})
}
const handleExplainBack = () => {
if (explainStage.value === 'stop') {
explainStage.value = 'unit'
selectedExplainBusinessUnitId.value = ''
selectedExplainBusinessUnitName.value = ''
explainError.value = ''
return
}
if (explainStage.value === 'unit') {
explainStage.value = 'hall'
selectedExplainHallId.value = ''
selectedExplainHallName.value = ''
selectedExplainBusinessUnitId.value = ''
selectedExplainBusinessUnitName.value = ''
explainBusinessUnits.value = []
explainError.value = ''
return
}
currentTab.value = 'guide'
syncTopTabToUrl('guide')
loadTopTabData('guide')