讲解对象列表接入展厅分页接口
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
lyf
2026-07-17 18:27:24 +08:00
parent 7267dfcac1
commit cf6ad02318
18 changed files with 682 additions and 409 deletions

View File

@@ -12,10 +12,11 @@
class="explain-scroll" class="explain-scroll"
:class="{ :class="{
'hall-stage': stage === 'hall', 'hall-stage': stage === 'hall',
'unit-stage': stage === 'unit',
'stop-stage': stage === 'stop' 'stop-stage': stage === 'stop'
}" }"
scroll-y scroll-y
@scroll="handleScroll"
@scrolltolower="handleScrollToLower"
:show-scrollbar="false" :show-scrollbar="false"
> >
<view v-if="loading" class="state-block"> <view v-if="loading" class="state-block">
@@ -24,8 +25,9 @@
</view> </view>
<view v-else-if="error" class="state-block error"> <view v-else-if="error" class="state-block error">
<text class="state-title">讲解内容加载失败</text> <text class="state-title">讲解对象加载失败</text>
<text class="state-desc">{{ error }}</text> <text class="state-desc">{{ error }}</text>
<button class="state-retry" @tap="emit('retry')">重新加载</button>
</view> </view>
<template v-else> <template v-else>
@@ -75,31 +77,6 @@
</view> </view>
</view> </view>
<view v-else-if="stage === 'unit' && businessUnits.length" class="hall-list">
<view
v-for="unit in businessUnits"
:key="unit.id"
class="hall-card unit-card"
@tap="handleBusinessUnitClick(unit.id)"
>
<image
class="unit-thumb"
:src="unitPreviewImageUrl(unit)"
mode="aspectFill"
@error="handleUnitPreviewError(unit.id)"
/>
<view class="hall-main">
<text class="hall-name">{{ unit.name }}</text>
<view class="hall-meta-row">
<text class="hall-meta-text">{{ unit.guideStopCount }} 个讲解</text>
</view>
</view>
<text class="hall-arrow"></text>
</view>
</view>
<view v-else-if="stage === 'stop' && guideStops.length" class="hall-list"> <view v-else-if="stage === 'stop' && guideStops.length" class="hall-list">
<view <view
v-for="stop in guideStops" v-for="stop in guideStops"
@@ -140,6 +117,14 @@
<text class="state-title">{{ emptyTitle }}</text> <text class="state-title">{{ emptyTitle }}</text>
<text class="state-desc">{{ emptyDescription }}</text> <text class="state-desc">{{ emptyDescription }}</text>
</view> </view>
<view v-if="stage === 'stop' && guideStops.length" class="load-more-state">
<text v-if="loadingMore" class="state-desc">正在加载更多讲解对象</text>
<template v-else-if="loadMoreError">
<text class="state-desc">{{ loadMoreError }}</text>
<button class="state-retry" @tap="emit('retryMore')">重试加载更多</button>
</template>
<text v-else-if="!hasMore" class="state-desc">已加载全部讲解对象</text>
</view>
</template> </template>
</scroll-view> </scroll-view>
</view> </view>
@@ -147,9 +132,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import {
EXPLAIN_UNIT_PREVIEW_FALLBACK
} from '@/utils/explainUnitPreviews'
import { isEmbeddedInWechatMiniProgram } from '@/utils/hostEnvironment' import { isEmbeddedInWechatMiniProgram } from '@/utils/hostEnvironment'
export interface ExplainHallSelectItem { export interface ExplainHallSelectItem {
@@ -158,19 +140,10 @@ export interface ExplainHallSelectItem {
floorLabel?: string floorLabel?: string
image?: string image?: string
explainCount: number explainCount: number
businessUnitCount?: number
guideStopCount?: number guideStopCount?: number
searchText: string searchText: string
} }
export interface ExplainBusinessUnitSelectItem {
id: string
name: string
hallId: string
previewImageUrl?: string
guideStopCount: number
}
export interface ExplainGuideStopSelectItem { export interface ExplainGuideStopSelectItem {
id: string id: string
name: string name: string
@@ -189,40 +162,42 @@ export interface ExplainGuideStopSelectItem {
playTargetId?: string playTargetId?: string
} }
export type ExplainSelectStage = 'hall' | 'unit' | 'stop' export type ExplainSelectStage = 'hall' | 'stop'
const props = withDefaults(defineProps<{ const props = withDefaults(defineProps<{
halls?: ExplainHallSelectItem[] halls?: ExplainHallSelectItem[]
businessUnits?: ExplainBusinessUnitSelectItem[]
guideStops?: ExplainGuideStopSelectItem[] guideStops?: ExplainGuideStopSelectItem[]
stage?: ExplainSelectStage stage?: ExplainSelectStage
selectedHallName?: string selectedHallName?: string
selectedBusinessUnitName?: string
loading?: boolean loading?: boolean
loadingMore?: boolean
hasMore?: boolean
error?: string error?: string
loadMoreError?: string
}>(), { }>(), {
halls: () => [], halls: () => [],
businessUnits: () => [],
guideStops: () => [], guideStops: () => [],
stage: 'hall', stage: 'hall',
selectedHallName: '', selectedHallName: '',
selectedBusinessUnitName: '',
loading: false, loading: false,
error: '' loadingMore: false,
hasMore: false,
error: '',
loadMoreError: ''
}) })
const emit = defineEmits<{ const emit = defineEmits<{
hallClick: [hallId: string] hallClick: [hallId: string]
businessUnitClick: [unitId: string]
guideStopClick: [stop: ExplainGuideStopSelectItem] guideStopClick: [stop: ExplainGuideStopSelectItem]
back: [] back: []
retry: []
retryMore: []
}>() }>()
const HALL_PREVIEW_BASE = '/static/explain/hall-previews' const HALL_PREVIEW_BASE = '/static/explain/hall-previews'
const HALL_PREVIEW_EAGER_COUNT = 4 const HALL_PREVIEW_EAGER_COUNT = 4
const loadedHallPreviewIds = ref(new Set<string>()) const loadedHallPreviewIds = ref(new Set<string>())
const failedHallPreviewIds = ref(new Set<string>()) const failedHallPreviewIds = ref(new Set<string>())
const failedUnitPreviewIds = ref(new Set<string>())
const hallPreviewMap: Record<string, string> = { const hallPreviewMap: Record<string, string> = {
宇宙厅: `${HALL_PREVIEW_BASE}/universe.webp`, 宇宙厅: `${HALL_PREVIEW_BASE}/universe.webp`,
@@ -240,28 +215,23 @@ const filteredHalls = computed(() => props.halls)
const shouldUseHostNavigation = computed(() => isEmbeddedInWechatMiniProgram()) const shouldUseHostNavigation = computed(() => isEmbeddedInWechatMiniProgram())
const showInternalHeader = computed(() => !shouldUseHostNavigation.value) const showInternalHeader = computed(() => !shouldUseHostNavigation.value)
const headerTitle = computed(() => { const headerTitle = computed(() => {
if (props.stage === 'unit') return props.selectedHallName || '选择业务单元' if (props.stage === 'stop') return props.selectedHallName || '讲解对象'
if (props.stage === 'stop') return props.selectedBusinessUnitName || '选择讲解点'
return '讲解' return '讲解'
}) })
const loadingTitle = computed(() => { const loadingTitle = computed(() => {
if (props.stage === 'unit') return '正在加载业务单元' if (props.stage === 'stop') return '正在加载讲解对象'
if (props.stage === 'stop') return '正在加载讲解点'
return '正在加载展厅讲解' return '正在加载展厅讲解'
}) })
const loadingDescription = computed(() => { const loadingDescription = computed(() => {
if (props.stage === 'unit') return '正在按讲解点所属 outline 分组生成临时业务单元。' if (props.stage === 'stop') return '稍后将展示该展厅的讲解对象。'
if (props.stage === 'stop') return '稍后将展示该业务单元下的讲解点。'
return '稍后将展示可选择的展厅。' return '稍后将展示可选择的展厅。'
}) })
const emptyTitle = computed(() => { const emptyTitle = computed(() => {
if (props.stage === 'unit') return '该展厅暂无已标定讲解点' if (props.stage === 'stop') return '该展厅暂无讲解对象'
if (props.stage === 'stop') return '该业务单元暂无讲解点'
return '未找到相关展厅' return '未找到相关展厅'
}) })
const emptyDescription = computed(() => { const emptyDescription = computed(() => {
if (props.stage === 'unit') return '方案 B 只展示已在地图上标定的讲解点。' if (props.stage === 'stop') return '可返回选择其他展厅。'
if (props.stage === 'stop') return '可返回选择其他业务单元。'
return '暂无可选择的展厅讲解。' return '暂无可选择的展厅讲解。'
}) })
@@ -295,19 +265,9 @@ const handleHallPreviewError = (hallId: string) => {
} }
} }
const unitPreviewImageUrl = (unit: ExplainBusinessUnitSelectItem) => (
failedUnitPreviewIds.value.has(unit.id)
? EXPLAIN_UNIT_PREVIEW_FALLBACK
: unit.previewImageUrl || EXPLAIN_UNIT_PREVIEW_FALLBACK
)
const handleUnitPreviewError = (unitId: string) => {
failedUnitPreviewIds.value = new Set([...failedUnitPreviewIds.value, unitId])
}
const hallMetaText = (hall: ExplainHallSelectItem) => ( const hallMetaText = (hall: ExplainHallSelectItem) => (
typeof hall.businessUnitCount === 'number' || typeof hall.guideStopCount === 'number' typeof hall.guideStopCount === 'number'
? `${hall.businessUnitCount || 0} 个业务单元 · ${hall.guideStopCount || 0} 个讲解` ? `${hall.guideStopCount || 0} 个讲解对象`
: hall.explainCount > 0 ? `${hall.explainCount} 个展项` : '展厅' : hall.explainCount > 0 ? `${hall.explainCount} 个展项` : '展厅'
) )
@@ -315,10 +275,6 @@ const handleHallClick = (hallId: string) => {
emit('hallClick', hallId) emit('hallClick', hallId)
} }
const handleBusinessUnitClick = (unitId: string) => {
emit('businessUnitClick', unitId)
}
const handleGuideStopClick = (stopId: string) => { const handleGuideStopClick = (stopId: string) => {
const stop = props.guideStops.find((item) => item.id === stopId) const stop = props.guideStops.find((item) => item.id === stopId)
if (stop) { if (stop) {
@@ -326,6 +282,21 @@ const handleGuideStopClick = (stopId: string) => {
} }
} }
const handleScrollToLower = () => {
requestMore()
}
const requestMore = () => {
if (props.stage === 'stop' && props.hasMore && !props.loading && !props.loadingMore && !props.loadMoreError) {
emit('retryMore')
}
}
const handleScroll = (event: Event) => {
const target = event.target as HTMLElement | null
if (target && target.scrollTop + target.clientHeight >= target.scrollHeight - 8) requestMore()
}
const handleBack = () => { const handleBack = () => {
emit('back') emit('back')
} }
@@ -351,7 +322,6 @@ const handleBack = () => {
overflow: hidden; overflow: hidden;
} }
.explain-scroll.unit-stage,
.explain-scroll.stop-stage { .explain-scroll.stop-stage {
padding-bottom: 0; padding-bottom: 0;
} }
@@ -553,17 +523,6 @@ const handleBack = () => {
color: #83927a; color: #83927a;
} }
.unit-thumb {
flex-shrink: 0;
width: 84px;
height: 58px;
border-radius: 8px;
background: #eef0e4;
border: 1px solid rgba(36, 49, 42, 0.06);
box-sizing: border-box;
object-fit: cover;
}
.hall-main { .hall-main {
flex: 1; flex: 1;
min-width: 0; min-width: 0;
@@ -734,4 +693,21 @@ const handleBack = () => {
border-color: #e5c2c2; border-color: #e5c2c2;
background: #fff7f7; background: #fff7f7;
} }
.load-more-state {
min-height: 52px;
padding: 12px 16px calc(12px + env(safe-area-inset-bottom));
text-align: center;
}
.state-retry {
margin-top: 12px;
padding: 0 14px;
font-size: 13px;
line-height: 32px;
color: #151713;
background: #e0df00;
border: 0;
border-radius: 6px;
}
</style> </style>

View File

@@ -1,6 +1,7 @@
import type { import type {
ExplainBusinessUnit, ExplainBusinessUnit,
ExplainGuideStop, ExplainGuideStop,
ExplainGuideStopPage,
MuseumExhibit, MuseumExhibit,
MuseumHall MuseumHall
} from '@/domain/museum' } from '@/domain/museum'
@@ -84,6 +85,11 @@ interface TimedCacheEntry<T> {
expiresAt: number expiresAt: number
} }
interface BackendCatalogStopPage {
list?: BackendCatalogStopItem[]
total?: number
}
const requireArrayData = <T>(response: CommonResult<T[]>, message: string) => { const requireArrayData = <T>(response: CommonResult<T[]>, message: string) => {
if (response.code !== 0) { if (response.code !== 0) {
throw new Error(response.msg || message) throw new Error(response.msg || message)
@@ -92,6 +98,14 @@ const requireArrayData = <T>(response: CommonResult<T[]>, message: string) => {
return Array.isArray(response.data) ? response.data : [] return Array.isArray(response.data) ? response.data : []
} }
const requirePageData = (response: CommonResult<BackendCatalogStopPage>, message: string) => {
if (response.code !== 0) throw new Error(response.msg || message)
return {
list: Array.isArray(response.data?.list) ? response.data.list : [],
total: Math.max(0, Number(response.data?.total || 0))
}
}
export class BackendExplainContentProvider implements ExplainContentProvider { export class BackendExplainContentProvider implements ExplainContentProvider {
private readonly explainExhibitCache = new Map<string, TimedCacheEntry<MuseumExhibit[]>>() private readonly explainExhibitCache = new Map<string, TimedCacheEntry<MuseumExhibit[]>>()
private readonly explainExhibitInflight = new Map<string, Promise<MuseumExhibit[]>>() private readonly explainExhibitInflight = new Map<string, Promise<MuseumExhibit[]>>()
@@ -100,6 +114,8 @@ export class BackendExplainContentProvider implements ExplainContentProvider {
private readonly hallListInflight = new Map<string, Promise<MuseumHall[]>>() private readonly hallListInflight = new Map<string, Promise<MuseumHall[]>>()
private readonly guideStopCache = new Map<string, TimedCacheEntry<ExplainGuideStop[]>>() private readonly guideStopCache = new Map<string, TimedCacheEntry<ExplainGuideStop[]>>()
private readonly guideStopInflight = new Map<string, Promise<ExplainGuideStop[]>>() private readonly guideStopInflight = new Map<string, Promise<ExplainGuideStop[]>>()
private readonly guideStopPageCache = new Map<string, TimedCacheEntry<ExplainGuideStopPage>>()
private readonly guideStopPageInflight = new Map<string, Promise<ExplainGuideStopPage>>()
private readonly outlineCache = new Map<string, TimedCacheEntry<BackendCatalogOutlineItem[]>>() private readonly outlineCache = new Map<string, TimedCacheEntry<BackendCatalogOutlineItem[]>>()
private readonly outlineInflight = new Map<string, Promise<BackendCatalogOutlineItem[]>>() private readonly outlineInflight = new Map<string, Promise<BackendCatalogOutlineItem[]>>()
private readonly businessUnitCache = new Map<string, TimedCacheEntry<ExplainBusinessUnit[]>>() private readonly businessUnitCache = new Map<string, TimedCacheEntry<ExplainBusinessUnit[]>>()
@@ -222,11 +238,51 @@ export class BackendExplainContentProvider implements ExplainContentProvider {
} }
} }
private async requestCatalogStopsPageByHall(hallId: string, pageNo: number, pageSize: number): Promise<ExplainGuideStopPage> {
const normalizedHallId = hallId.trim()
const normalizedPageNo = Math.max(1, Math.floor(pageNo) || 1)
const normalizedPageSize = Math.min(100, Math.max(1, Math.floor(pageSize) || 20))
if (!normalizedHallId) return { items: [], total: 0, pageNo: normalizedPageNo, pageSize: normalizedPageSize, hasMore: false }
const key = cacheKey('hall', normalizedHallId, 'stops', 'page', String(normalizedPageNo), String(normalizedPageSize))
const cached = this.getCached(this.guideStopPageCache, key)
if (cached) return cached
const inflight = this.guideStopPageInflight.get(key)
if (inflight) return inflight
const promise = (async () => {
const halls = await this.requestCatalogHalls()
const hall = halls.find((item) => item.id === normalizedHallId) || null
const params = new URLSearchParams({ lang: catalogLang(), pageNo: String(normalizedPageNo), pageSize: String(normalizedPageSize) })
const url = `${resolveAppApiBaseUrl()}/gis/guide/catalog/halls/${encodeURIComponent(normalizedHallId)}/stops/page?${params.toString()}`
const data = requirePageData(await requestJson<CommonResult<BackendCatalogStopPage>>(url), '讲解对象目录加载失败')
const items = data.list
.map((item) => toCatalogGuideStop(item, hall, null))
.filter(Boolean) as ExplainGuideStop[]
const page = {
items,
total: data.total,
pageNo: normalizedPageNo,
pageSize: normalizedPageSize,
hasMore: normalizedPageNo * normalizedPageSize < data.total
}
this.setCached(this.guideStopPageCache, key, page)
return page
})()
this.guideStopPageInflight.set(key, promise)
try {
return await promise
} finally {
this.guideStopPageInflight.delete(key)
}
}
private async requestCatalogStopsByHall(hallId: string) { private async requestCatalogStopsByHall(hallId: string) {
const normalizedHallId = hallId.trim() const normalizedHallId = hallId.trim()
if (!normalizedHallId) return [] if (!normalizedHallId) return []
const key = cacheKey('hall', normalizedHallId, 'stops', 'all')
const key = cacheKey('hall', normalizedHallId, 'stops')
const cached = this.getCached(this.guideStopCache, key) const cached = this.getCached(this.guideStopCache, key)
if (cached) return cached if (cached) return cached
@@ -234,28 +290,18 @@ export class BackendExplainContentProvider implements ExplainContentProvider {
if (inflight) return inflight if (inflight) return inflight
const promise = (async () => { const promise = (async () => {
const [halls, outlines] = await Promise.all([ const stops: ExplainGuideStop[] = []
this.requestCatalogHalls(), const seen = new Set<string>()
this.requestCatalogOutlinesByHall(normalizedHallId).catch(() => []) for (let pageNo = 1; ; pageNo += 1) {
]) const page = await this.requestCatalogStopsPageByHall(normalizedHallId, pageNo, 100)
const hall = halls.find((item) => item.id === normalizedHallId) || null page.items.forEach((stop) => {
const outlineById = new Map(flattenCatalogOutlines(outlines).map((outline) => [String(outline.id || ''), outline])) if (!seen.has(stop.id)) {
seen.add(stop.id)
const params = new URLSearchParams({ lang: catalogLang() }) stops.push(stop)
const url = `${resolveAppApiBaseUrl()}/gis/guide/catalog/halls/${encodeURIComponent(normalizedHallId)}/stops?${params.toString()}` }
const response = await requestJson<CommonResult<BackendCatalogStopItem[]>>(url)
const stops = requireArrayData(response, '讲解点目录加载失败')
.map((item) => {
const outline = outlineById.get(String(item.outlineId || ''))
return toCatalogGuideStop(item, hall, outline
? {
id: String(outline.id || ''),
name: String(outline.name || '')
}
: null)
}) })
.filter(Boolean) as ExplainGuideStop[] if (!page.hasMore || stops.length >= page.total) break
}
this.setCached(this.guideStopCache, key, stops) this.setCached(this.guideStopCache, key, stops)
return stops return stops
})() })()
@@ -416,6 +462,17 @@ export class BackendExplainContentProvider implements ExplainContentProvider {
} }
} }
async listGuideStopsPageByHall(hallId: string, pageNo: number, pageSize: number) {
try {
return await this.requestCatalogStopsPageByHall(hallId, pageNo, pageSize)
} catch (error) {
return this.fallbackOrThrow(error, '讲解对象目录加载失败', async () => (
this.fallbackProvider?.listGuideStopsPageByHall?.(hallId, pageNo, pageSize)
|| { items: [], total: 0, pageNo, pageSize, hasMore: false }
))
}
}
async listExplainExhibitsByHall(hallId: string) { async listExplainExhibitsByHall(hallId: string) {
const [halls, stops] = await Promise.all([ const [halls, stops] = await Promise.all([
this.listHalls(), this.listHalls(),

View File

@@ -1,6 +1,7 @@
import type { import type {
ExplainBusinessUnit, ExplainBusinessUnit,
ExplainGuideStop, ExplainGuideStop,
ExplainGuideStopPage,
ExplainTrack, ExplainTrack,
MediaAsset, MediaAsset,
MuseumExhibit, MuseumExhibit,
@@ -44,6 +45,7 @@ export interface ExplainContentProvider extends MuseumContentProvider {
listExplainExhibitsByHall?(hallId: string): Promise<MuseumExhibit[]> listExplainExhibitsByHall?(hallId: string): Promise<MuseumExhibit[]>
searchExplainExhibits?(keyword?: string): Promise<MuseumExhibit[]> searchExplainExhibits?(keyword?: string): Promise<MuseumExhibit[]>
listGuideStopsByHall?(hallId: string): Promise<ExplainGuideStop[]> listGuideStopsByHall?(hallId: string): Promise<ExplainGuideStop[]>
listGuideStopsPageByHall?(hallId: string, pageNo: number, pageSize: number): Promise<ExplainGuideStopPage>
listGuideStopsByBusinessUnit?(hallId: string, unitId: string): Promise<ExplainGuideStop[]> listGuideStopsByBusinessUnit?(hallId: string, unitId: string): Promise<ExplainGuideStop[]>
listTemporaryBusinessUnitsByHall?(hallId: string): Promise<ExplainBusinessUnit[]> listTemporaryBusinessUnitsByHall?(hallId: string): Promise<ExplainBusinessUnit[]>
listTracks(): Promise<ExplainTrack[]> listTracks(): Promise<ExplainTrack[]>
@@ -161,6 +163,20 @@ export class StaticGuideContentProvider implements ExplainContentProvider {
return adapter.guideStops.filter((stop) => stop.hallId === hallId) return adapter.guideStops.filter((stop) => stop.hallId === hallId)
} }
async listGuideStopsPageByHall(hallId: string, pageNo: number, pageSize: number) {
const stops = await this.listGuideStopsByHall(hallId)
const normalizedPageNo = Math.max(1, Math.floor(pageNo) || 1)
const normalizedPageSize = Math.max(1, Math.floor(pageSize) || 20)
const offset = (normalizedPageNo - 1) * normalizedPageSize
return {
items: stops.slice(offset, offset + normalizedPageSize),
total: stops.length,
pageNo: normalizedPageNo,
pageSize: normalizedPageSize,
hasMore: offset + normalizedPageSize < stops.length
}
}
async listTemporaryBusinessUnitsByHall(hallId: string) { async listTemporaryBusinessUnitsByHall(hallId: string) {
const adapter = await this.loadExplainNavigationAdapter() const adapter = await this.loadExplainNavigationAdapter()
return adapter.businessUnitsByHallId[hallId] || [] return adapter.businessUnitsByHallId[hallId] || []

View File

@@ -244,6 +244,14 @@ export interface ExplainGuideStop {
playTargetId?: string playTargetId?: string
} }
export interface ExplainGuideStopPage {
items: ExplainGuideStop[]
total: number
pageNo: number
pageSize: number
hasMore: boolean
}
export interface ExplainBusinessUnit { export interface ExplainBusinessUnit {
id: string id: string
name: string name: string

View File

@@ -30,18 +30,10 @@
"navigationStyle": "custom" "navigationStyle": "custom"
} }
}, },
{
"path": "pages/explain/business-unit-list",
"style": {
"navigationBarTitleText": "业务单元",
"navigationBarBackgroundColor": "#FFFFFF",
"navigationStyle": "custom"
}
},
{ {
"path": "pages/explain/guide-stop-list", "path": "pages/explain/guide-stop-list",
"style": { "style": {
"navigationBarTitleText": "讲解", "navigationBarTitleText": "讲解对象",
"navigationBarBackgroundColor": "#FFFFFF", "navigationBarBackgroundColor": "#FFFFFF",
"navigationStyle": "custom" "navigationStyle": "custom"
} }

View File

@@ -1,137 +0,0 @@
<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'
|| previousPage?.route === 'pages/index/index'
) {
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

@@ -1,19 +1,18 @@
<template> <template>
<GuidePageFrame <GuidePageFrame active-tab="explain" variant="static" :show-top-tabs="false" @tab-change="handleTopTabChange" @back="handleBack">
active-tab="explain"
variant="static"
:show-top-tabs="false"
@tab-change="handleTopTabChange"
@back="handleBack"
>
<view class="explain-guide-stop-page"> <view class="explain-guide-stop-page">
<ExplainHallSelect <ExplainHallSelect
:guide-stops="explainGuideStopItems" :guide-stops="explainGuideStopItems"
stage="stop" stage="stop"
:selected-business-unit-name="selectedExplainBusinessUnitName" :selected-hall-name="selectedExplainHallName"
:loading="explainLoading" :loading="explainLoading"
:loading-more="loadingMore"
:has-more="hasMore"
:error="explainError" :error="explainError"
:load-more-error="loadMoreError"
@guide-stop-click="handleExplainGuideStopClick" @guide-stop-click="handleExplainGuideStopClick"
@retry="reloadFirstPage"
@retry-more="loadNextPage"
@back="handleBack" @back="handleBack"
/> />
</view> </view>
@@ -21,147 +20,158 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue' import { onUnmounted, ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app' import { onLoad } from '@dcloudio/uni-app'
import GuidePageFrame from '@/components/navigation/GuidePageFrame.vue' import GuidePageFrame from '@/components/navigation/GuidePageFrame.vue'
import ExplainHallSelect, { import ExplainHallSelect, { type ExplainGuideStopSelectItem } from '@/components/explain/ExplainHallSelect.vue'
type ExplainGuideStopSelectItem import { explainUseCase } from '@/usecases/explainUseCase'
} from '@/components/explain/ExplainHallSelect.vue' import type { ExplainGuideStop } from '@/domain/museum'
import { import { normalizeExplainDetailTargetFromGuideStop } from '@/domain/explainDetailTarget'
explainUseCase import { navigateToGuideTopTab, type GuideTopTab } from '@/utils/guideTopTabs'
} from '@/usecases/explainUseCase'
import type {
ExplainGuideStop
} from '@/domain/museum'
import {
normalizeExplainDetailTargetFromGuideStop
} from '@/domain/explainDetailTarget'
import {
navigateToGuideTopTab,
type GuideTopTab
} from '@/utils/guideTopTabs'
const PAGE_SIZE = 20
const selectedExplainHallId = ref('') const selectedExplainHallId = ref('')
const selectedExplainHallName = ref('') const selectedExplainHallName = ref('')
const selectedExplainBusinessUnitId = ref('')
const selectedExplainBusinessUnitName = ref('')
const explainGuideStopItems = ref<ExplainGuideStopSelectItem[]>([]) const explainGuideStopItems = ref<ExplainGuideStopSelectItem[]>([])
const explainLoading = ref(false) const explainLoading = ref(false)
const loadingMore = ref(false)
const explainError = ref('') const explainError = ref('')
const loadMoreError = ref('')
const hasMore = ref(false)
const nextPageNo = ref(1)
let requestVersion = 0
let disposed = false
const toGuideStopItems = (stops: ExplainGuideStop[]): ExplainGuideStopSelectItem[] => ( const toGuideStopItems = (stops: ExplainGuideStop[]): ExplainGuideStopSelectItem[] => stops.map((stop) => ({
stops.map((stop) => ({ id: stop.id,
id: stop.id, name: stop.name,
name: stop.name, hallId: stop.hallId,
hallId: stop.hallId, hallName: stop.hallName,
hallName: stop.hallName, floorId: stop.floorId,
floorId: stop.floorId, poiId: stop.poiId,
poiId: stop.poiId, mapX: stop.mapX,
mapX: stop.mapX, mapY: stop.mapY,
mapY: stop.mapY, coverImageUrl: stop.imageStatus === 'MISSING' ? undefined : stop.coverImageUrl,
coverImageUrl: stop.coverImageUrl, description: stop.description,
description: stop.description, hasAudio: stop.hasAudio,
hasAudio: stop.hasAudio, audioStatus: stop.audioStatus,
audioStatus: stop.audioStatus, guideLevel: stop.guideLevel,
guideLevel: stop.guideLevel, playTargetType: stop.playTargetType,
playTargetType: stop.playTargetType, playTargetId: stop.playTargetId
playTargetId: stop.playTargetId }))
}))
)
const syncPageTitle = (title: string) => { const syncPageTitle = (title: string) => {
uni.setNavigationBarTitle({ title }) uni.setNavigationBarTitle({ title })
if (typeof document !== 'undefined') document.title = title if (typeof document !== 'undefined') document.title = title
} }
onLoad(async (options: any = {}) => { const loadPage = async (pageNo: number, replace: boolean) => {
selectedExplainHallId.value = Array.isArray(options.hallId) ? options.hallId[0] : options.hallId || '' const hallId = selectedExplainHallId.value
selectedExplainHallName.value = Array.isArray(options.hallName) ? options.hallName[0] : options.hallName || '' if (!hallId || disposed || (!replace && (loadingMore.value || !hasMore.value))) return
selectedExplainBusinessUnitId.value = Array.isArray(options.unitId) ? options.unitId[0] : options.unitId || '' const version = ++requestVersion
selectedExplainBusinessUnitName.value = Array.isArray(options.unitName) ? options.unitName[0] : options.unitName || '讲解点' if (replace) {
explainLoading.value = true
explainError.value = ''
loadMoreError.value = ''
} else {
loadingMore.value = true
loadMoreError.value = ''
}
try {
const page = await explainUseCase.listGuideStopsPageByHall(hallId, pageNo, PAGE_SIZE)
if (disposed || version !== requestVersion || hallId !== selectedExplainHallId.value) return
const incoming = toGuideStopItems(page.items)
if (replace) {
explainGuideStopItems.value = incoming
} else {
const existingIds = new Set(explainGuideStopItems.value.map((item) => item.id))
explainGuideStopItems.value = [...explainGuideStopItems.value, ...incoming.filter((item) => !existingIds.has(item.id))]
}
nextPageNo.value = page.pageNo + 1
hasMore.value = page.hasMore && explainGuideStopItems.value.length < page.total
} catch (error) {
if (disposed || version !== requestVersion) return
if (replace) explainError.value = '讲解对象加载失败,请稍后重试'
else loadMoreError.value = '加载更多讲解对象失败,请重试'
} finally {
if (!disposed && version === requestVersion) {
explainLoading.value = false
loadingMore.value = false
}
}
}
syncPageTitle(selectedExplainBusinessUnitName.value) const reloadFirstPage = () => {
if (!selectedExplainHallId.value) return
nextPageNo.value = 1
hasMore.value = false
void loadPage(1, true)
}
if (!selectedExplainHallId.value || !selectedExplainBusinessUnitId.value) { const loadNextPage = () => void loadPage(nextPageNo.value, false)
explainError.value = '缺少业务单元参数,请返回上一级后重试'
onLoad((options: Record<string, string | string[] | undefined> = {}) => {
const first = (value: string | string[] | undefined) => Array.isArray(value) ? value[0] : value
selectedExplainHallId.value = first(options.hallId) || ''
selectedExplainHallName.value = first(options.hallName) || '讲解对象'
syncPageTitle('讲解对象')
if (!selectedExplainHallId.value) {
explainError.value = '缺少展厅参数,请返回展厅列表后重试'
return return
} }
reloadFirstPage()
})
explainLoading.value = true onUnmounted(() => {
try { disposed = true
const unit = await explainUseCase.selectBusinessUnit( requestVersion += 1
selectedExplainHallId.value,
selectedExplainBusinessUnitId.value
)
if (!unit) {
explainError.value = '未找到对应业务单元,请返回上一级后重试'
return
}
selectedExplainBusinessUnitName.value = unit.name || selectedExplainBusinessUnitName.value
syncPageTitle(selectedExplainBusinessUnitName.value)
const stops = await explainUseCase.listGuideStopsByBusinessUnit(
selectedExplainHallId.value,
selectedExplainBusinessUnitId.value
)
explainGuideStopItems.value = toGuideStopItems(stops)
} catch (error) {
explainError.value = '讲解点加载失败,请稍后重试'
} finally {
explainLoading.value = false
}
}) })
const handleExplainGuideStopClick = (stop: ExplainGuideStopSelectItem) => { const handleExplainGuideStopClick = (stop: ExplainGuideStopSelectItem) => {
const target = stop.playTargetType && stop.playTargetId const target = stop.playTargetType && stop.playTargetId
? { targetType: stop.playTargetType, targetId: stop.playTargetId } ? { targetType: stop.playTargetType, targetId: stop.playTargetId }
: normalizeExplainDetailTargetFromGuideStop({ id: stop.id }) : normalizeExplainDetailTargetFromGuideStop({ id: stop.id })
const params = new URLSearchParams({ id: stop.id, tab: 'explain', targetType: target.targetType, targetId: target.targetId })
const params = new URLSearchParams({
id: stop.id,
tab: 'explain',
targetType: target.targetType,
targetId: target.targetId
})
if (stop.hallId) params.set('hallId', stop.hallId) if (stop.hallId) params.set('hallId', stop.hallId)
if (stop.hallName) params.set('hallName', stop.hallName) if (stop.hallName) params.set('hallName', stop.hallName)
if (stop.floorId) params.set('floorId', stop.floorId) if (stop.floorId) params.set('floorId', stop.floorId)
if (stop.poiId) params.set('poiId', stop.poiId) if (stop.poiId) params.set('poiId', stop.poiId)
uni.navigateTo({ url: `/pages/exhibit/detail?${params.toString()}` })
uni.navigateTo({
url: `/pages/exhibit/detail?${params.toString()}`
})
} }
const goBackToBusinessUnitList = () => { const fallbackToHallList = () => uni.reLaunch({ url: '/pages/explain/list' })
const params = new URLSearchParams({
hallId: selectedExplainHallId.value,
hallName: selectedExplainHallName.value
})
uni.redirectTo({ type ExplainPageStackEntry = {
url: `/pages/explain/business-unit-list?${params.toString()}`, route?: string
fail: () => { options?: {
uni.reLaunch({ url: `/pages/explain/business-unit-list?${params.toString()}` }) tab?: string | string[]
} }
})
} }
const handleBack = () => { const returnToHallList = () => {
uni.navigateBack({ const pages = getCurrentPages()
delta: 1, const previousPage = pages[pages.length - 2] as ExplainPageStackEntry | undefined
fail: goBackToBusinessUnitList const previousRoute = previousPage?.route
}) const previousTab = Array.isArray(previousPage?.options?.tab)
? previousPage.options.tab[0]
: previousPage?.options?.tab
const canNavigateBackToHallList = (
previousRoute === 'pages/explain/list'
|| (previousRoute === 'pages/index/index' && previousTab === 'explain')
)
if (canNavigateBackToHallList) {
uni.navigateBack({ delta: 1, fail: fallbackToHallList })
return
}
fallbackToHallList()
} }
const handleTopTabChange = (tab: GuideTopTab) => { const handleBack = returnToHallList
navigateToGuideTopTab(tab) const handleTopTabChange = (tab: GuideTopTab) => navigateToGuideTopTab(tab)
}
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
.explain-guide-stop-page { .explain-guide-stop-page { position: absolute; inset: 0; background: #edf0ea; }
position: absolute;
inset: 0;
background: #edf0ea;
}
</style> </style>

View File

@@ -38,6 +38,7 @@ import {
type GuideTopTab type GuideTopTab
} from '@/utils/guideTopTabs' } from '@/utils/guideTopTabs'
import { isEmbeddedInWechatMiniProgram } from '@/utils/hostEnvironment' import { isEmbeddedInWechatMiniProgram } from '@/utils/hostEnvironment'
import { explainGuideStopListUrl } from '@/utils/explainNavigation'
const explainHallItems = ref<ExplainHallSelectItem[]>([]) const explainHallItems = ref<ExplainHallSelectItem[]>([])
const explainLoading = ref(false) const explainLoading = ref(false)
@@ -56,7 +57,6 @@ const buildExplainHallItems = (
floorLabel: hall.floorLabel, floorLabel: hall.floorLabel,
image: hall.image, image: hall.image,
explainCount: hall.exhibitCount || 0, explainCount: hall.exhibitCount || 0,
businessUnitCount: summaries[hall.id]?.businessUnitCount,
guideStopCount: summaries[hall.id]?.guideStopCount, guideStopCount: summaries[hall.id]?.guideStopCount,
searchText: normalizeExplainKeyword([ searchText: normalizeExplainKeyword([
hall.name, hall.name,
@@ -225,13 +225,8 @@ const handleTopTabChange = (tab: GuideTopTab) => {
const handleExplainHallClick = (hallId: string) => { const handleExplainHallClick = (hallId: string) => {
const hall = explainHallItems.value.find((item) => item.id === hallId) const hall = explainHallItems.value.find((item) => item.id === hallId)
const params = new URLSearchParams({
hallId,
hallName: hall?.name || '讲解'
})
uni.navigateTo({ uni.navigateTo({
url: `/pages/explain/business-unit-list?${params.toString()}` url: explainGuideStopListUrl(hallId, hall?.name || '讲解')
}) })
} }

View File

@@ -302,6 +302,7 @@ import type {
import ExplainHallSelect, { import ExplainHallSelect, {
type ExplainHallSelectItem type ExplainHallSelectItem
} from '@/components/explain/ExplainHallSelect.vue' } from '@/components/explain/ExplainHallSelect.vue'
import { explainGuideStopListUrl } from '@/utils/explainNavigation'
import { import {
getLastGuideLocationPreviewUrl, getLastGuideLocationPreviewUrl,
isGuideTopTab, isGuideTopTab,
@@ -990,7 +991,6 @@ const buildExplainHallItems = (
floorLabel: hall.floorLabel, floorLabel: hall.floorLabel,
image: hall.image, image: hall.image,
explainCount: hall.exhibitCount || 0, explainCount: hall.exhibitCount || 0,
businessUnitCount: summaries[hall.id]?.businessUnitCount,
guideStopCount: summaries[hall.id]?.guideStopCount, guideStopCount: summaries[hall.id]?.guideStopCount,
searchText: normalizeExplainKeyword(keywordParts.join(' ')) searchText: normalizeExplainKeyword(keywordParts.join(' '))
} }
@@ -1725,13 +1725,8 @@ const guideSearchText = computed(() => {
// 讲解页面处理 // 讲解页面处理
const handleExplainHallClick = async (hallId: string) => { const handleExplainHallClick = async (hallId: string) => {
const hall = explainHallItems.value.find((item) => item.id === hallId) const hall = explainHallItems.value.find((item) => item.id === hallId)
const params = new URLSearchParams({
hallId,
hallName: hall?.name || '讲解'
})
uni.navigateTo({ uni.navigateTo({
url: `/pages/explain/business-unit-list?${params.toString()}` url: explainGuideStopListUrl(hallId, hall?.name || '讲解')
}) })
} }

View File

@@ -1,6 +1,7 @@
import type { import type {
ExplainBusinessUnit, ExplainBusinessUnit,
ExplainGuideStop, ExplainGuideStop,
ExplainGuideStopPage,
ExplainTrack, ExplainTrack,
MuseumExhibit, MuseumExhibit,
MuseumHall, MuseumHall,
@@ -27,6 +28,7 @@ export interface ExplainRepository {
listHalls(): Promise<MuseumHall[]> listHalls(): Promise<MuseumHall[]>
getHallById(id: string): Promise<MuseumHall | null> getHallById(id: string): Promise<MuseumHall | null>
listGuideStopsByHall(hallId: string): Promise<ExplainGuideStop[]> listGuideStopsByHall(hallId: string): Promise<ExplainGuideStop[]>
listGuideStopsPageByHall(hallId: string, pageNo: number, pageSize: number): Promise<ExplainGuideStopPage>
listGuideStopsByBusinessUnit(hallId: string, unitId: string): Promise<ExplainGuideStop[]> listGuideStopsByBusinessUnit(hallId: string, unitId: string): Promise<ExplainGuideStop[]>
listTemporaryBusinessUnitsByHall(hallId: string): Promise<ExplainBusinessUnit[]> listTemporaryBusinessUnitsByHall(hallId: string): Promise<ExplainBusinessUnit[]>
listTracks(): Promise<ExplainTrack[]> listTracks(): Promise<ExplainTrack[]>
@@ -73,7 +75,36 @@ export class DefaultExplainRepository implements ExplainRepository {
} }
async listGuideStopsByHall(hallId: string) { async listGuideStopsByHall(hallId: string) {
return this.explainContent.listGuideStopsByHall?.(hallId) || [] const pageSize = 100
const items: ExplainGuideStop[] = []
const seen = new Set<string>()
for (let pageNo = 1; ; pageNo += 1) {
const page = await this.listGuideStopsPageByHall(hallId, pageNo, pageSize)
page.items.forEach((item) => {
if (!seen.has(item.id)) {
seen.add(item.id)
items.push(item)
}
})
if (!page.hasMore || items.length >= page.total) return items
}
}
async listGuideStopsPageByHall(hallId: string, pageNo: number, pageSize: number) {
if (this.explainContent.listGuideStopsPageByHall) {
return this.explainContent.listGuideStopsPageByHall(hallId, pageNo, pageSize)
}
const items = await this.explainContent.listGuideStopsByHall?.(hallId) || []
const normalizedPageNo = Math.max(1, Math.floor(pageNo) || 1)
const normalizedPageSize = Math.max(1, Math.floor(pageSize) || 20)
const offset = (normalizedPageNo - 1) * normalizedPageSize
return {
items: items.slice(offset, offset + normalizedPageSize),
total: items.length,
pageNo: normalizedPageNo,
pageSize: normalizedPageSize,
hasMore: offset + normalizedPageSize < items.length
}
} }
async listGuideStopsByBusinessUnit(hallId: string, unitId: string) { async listGuideStopsByBusinessUnit(hallId: string, unitId: string) {

View File

@@ -2,6 +2,7 @@ import type {
AudioPlayTargetType, AudioPlayTargetType,
ExplainBusinessUnit, ExplainBusinessUnit,
ExplainGuideStop, ExplainGuideStop,
ExplainGuideStopPage,
ExplainTrack, ExplainTrack,
MediaAsset, MediaAsset,
MuseumExhibit, MuseumExhibit,
@@ -67,7 +68,6 @@ export interface ExhibitDetailAudioOptions {
export interface ExplainHallSummary { export interface ExplainHallSummary {
hallId: string hallId: string
businessUnitCount: number
guideStopCount: number guideStopCount: number
} }
@@ -487,24 +487,20 @@ export class ExplainUseCase {
if (hall && (typeof hall.outlineCount === 'number' || typeof hall.stopCount === 'number')) { if (hall && (typeof hall.outlineCount === 'number' || typeof hall.stopCount === 'number')) {
return [hallId, { return [hallId, {
hallId, hallId,
businessUnitCount: hall.outlineCount || 0,
guideStopCount: hall.stopCount || 0 guideStopCount: hall.stopCount || 0
}] as const }] as const
} }
try { try {
const units = await this.loadTemporaryBusinessUnitsByHall(hallId) const page = await this.listGuideStopsPageByHall(hallId, 1, 1)
const guideStopCount = units.reduce((total, unit) => total + unit.guideStopCount, 0)
return [hallId, { return [hallId, {
hallId, hallId,
businessUnitCount: units.length, guideStopCount: page.total
guideStopCount
}] as const }] as const
} catch (error) { } catch (error) {
console.warn('讲解展厅统计加载失败:', hallId, error) console.warn('讲解展厅统计加载失败:', hallId, error)
return [hallId, { return [hallId, {
hallId, hallId,
businessUnitCount: 0,
guideStopCount: 0 guideStopCount: 0
}] as const }] as const
} }
@@ -513,6 +509,10 @@ export class ExplainUseCase {
return Object.fromEntries(entries) return Object.fromEntries(entries)
} }
listGuideStopsPageByHall(hallId: string, pageNo: number, pageSize: number): Promise<ExplainGuideStopPage> {
return this.explain.listGuideStopsPageByHall(hallId, pageNo, pageSize)
}
getHallById(id: string) { getHallById(id: string) {
return this.explain.getHallById(id) return this.explain.getHallById(id)
} }

View File

@@ -0,0 +1,4 @@
export const explainGuideStopListUrl = (hallId: string, hallName = '讲解') => {
const params = new URLSearchParams({ hallId, hallName })
return `/pages/explain/guide-stop-list?${params.toString()}`
}

View File

@@ -0,0 +1,40 @@
import { expect, test } from '@playwright/test'
const stops = Array.from({ length: 21 }, (_, index) => ({
stopId: `stop-${index + 1}`,
name: `讲解对象${index + 1}`,
imageStatus: index === 0 ? 'READY' : 'MISSING',
coverImageUrl: index === 0 ? '/one.jpg' : undefined,
playTargetType: 'STOP' as const,
playTargetId: `target-${index + 1}`
}))
test('hall goes directly to paged guide objects without outline requests', async ({ page }) => {
const requests: string[] = []
await page.route('**/app-api/gis/guide/catalog/**', async (route) => {
const url = route.request().url()
requests.push(url)
if (url.includes('/catalog/halls?')) {
await route.fulfill({ json: { code: 0, data: [{ id: 'hall-1', name: '恐龙厅', floorId: 'L1', stopCount: 21 }] } })
return
}
if (url.includes('/halls/hall-1/stops/page')) {
const pageNo = new URL(url).searchParams.get('pageNo')
await route.fulfill({ json: { code: 0, data: { total: 21, list: pageNo === '1' ? stops.slice(0, 20) : stops.slice(20) } } })
return
}
await route.fulfill({ status: 404, json: { code: 404, msg: 'unexpected request' } })
})
await page.goto('/#/pages/explain/list')
await page.getByText('恐龙厅', { exact: true }).click()
await expect(page).toHaveURL(/guide-stop-list\?hallId=hall-1/)
await expect(page.getByText('讲解对象1', { exact: true })).toBeVisible()
await page.locator('.explain-scroll').hover()
await page.mouse.wheel(0, 10000)
await expect(page.getByText('讲解对象21', { exact: true })).toBeVisible()
await page.getByText('讲解对象1', { exact: true }).click()
await expect(page).toHaveURL(/pages\/exhibit\/detail\?.*targetType=STOP.*targetId=target-1/)
expect(requests.some((url) => url.includes('/outlines'))).toBe(false)
expect(requests.filter((url) => url.includes('/stops/page')).map((url) => new URL(url).searchParams.get('pageNo'))).toEqual(['1', '2'])
})

View File

@@ -0,0 +1,57 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { BackendExplainContentProvider } from '@/data/providers/backendExplainContentProvider'
const installRequest = (handler: (url: string) => unknown) => {
vi.stubGlobal('uni', {
request: ({ url, success }: { url: string, success: (response: unknown) => void }) => {
success({ statusCode: 200, data: handler(url) })
}
})
}
describe('BackendExplainContentProvider hall stop paging', () => {
afterEach(() => vi.unstubAllGlobals())
it('uses the paged hall endpoint and maps independent pages without outlines', async () => {
const requests: string[] = []
installRequest((url) => {
requests.push(url)
if (url.includes('/catalog/halls?')) return { code: 0, data: [{ id: 'hall-1', name: '恐龙厅', floorId: 'f1' }] }
const pageNo = new URL(url, 'http://localhost').searchParams.get('pageNo')
return {
code: 0,
data: {
total: 30,
list: pageNo === '1'
? [{ stopId: 'stop-1', name: '对象一', imageStatus: 'READY', coverImageUrl: '/one.jpg', playTargetType: 'STOP', playTargetId: 'target-1' }]
: [{ stopId: 'stop-2', name: '对象二', imageStatus: 'MISSING', playTargetType: 'STOP', playTargetId: 'target-2' }]
}
}
})
const provider = new BackendExplainContentProvider()
const [first, second] = await Promise.all([
provider.listGuideStopsPageByHall('hall-1', 1, 20),
provider.listGuideStopsPageByHall('hall-1', 2, 20)
])
expect(first).toMatchObject({ total: 30, pageNo: 1, pageSize: 20, hasMore: true })
expect(first.items[0]).toMatchObject({ id: 'stop-1', hallName: '恐龙厅', playTargetId: 'target-1' })
expect(second.items.map((item) => item.id)).toEqual(['stop-2'])
expect(requests.filter((url) => url.includes('/stops/page'))).toHaveLength(2)
expect(requests.some((url) => url.includes('/outlines'))).toBe(false)
expect(requests.find((url) => url.includes('pageNo=1') && url.includes('pageSize=20') && url.includes('lang=zh-CN'))).toBeTruthy()
})
it('clears failed inflight requests so the page can be retried', async () => {
let attempts = 0
installRequest((url) => {
if (url.includes('/catalog/halls?')) return { code: 0, data: [{ id: 'hall-1', name: '恐龙厅' }] }
attempts += 1
return attempts === 1 ? { code: 500, msg: 'temporary failure' } : { code: 0, data: { list: [], total: 0 } }
})
const provider = new BackendExplainContentProvider()
await expect(provider.listGuideStopsPageByHall('hall-1', 1, 20)).rejects.toThrow('temporary failure')
await expect(provider.listGuideStopsPageByHall('hall-1', 1, 20)).resolves.toMatchObject({ items: [], total: 0, hasMore: false })
expect(attempts).toBe(2)
})
})

View File

@@ -0,0 +1,122 @@
// @vitest-environment happy-dom
import { defineComponent } from 'vue'
import { flushPromises, mount } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import ExplainGuideStopListPage from '@/pages/explain/guide-stop-list.vue'
const testState = vi.hoisted(() => ({
onLoadHandler: null as null | ((options?: Record<string, string>) => void)
}))
vi.mock('@dcloudio/uni-app', () => ({
onLoad: (handler: (options?: Record<string, string>) => void) => {
testState.onLoadHandler = handler
}
}))
vi.mock('@/usecases/explainUseCase', () => ({
explainUseCase: {
listGuideStopsPageByHall: vi.fn().mockResolvedValue({
items: [], total: 0, pageNo: 1, pageSize: 20, hasMore: false
})
}
}))
const GuidePageFrameStub = defineComponent({
name: 'GuidePageFrame',
emits: ['back'],
template: '<main><button data-testid="frame-back" @click="$emit(\'back\')">返回</button><slot /></main>'
})
const ExplainHallSelectStub = defineComponent({
name: 'ExplainHallSelect',
emits: ['back'],
template: '<button data-testid="list-back" @click="$emit(\'back\')">返回</button>'
})
const mountGuideStopList = () => mount(ExplainGuideStopListPage, {
global: {
stubs: {
GuidePageFrame: GuidePageFrameStub,
ExplainHallSelect: ExplainHallSelectStub
}
}
})
beforeEach(() => {
testState.onLoadHandler = null
vi.stubGlobal('uni', {
navigateBack: vi.fn(),
reLaunch: vi.fn(),
navigateTo: vi.fn(),
setNavigationBarTitle: vi.fn()
})
vi.stubGlobal('getCurrentPages', vi.fn(() => []))
})
afterEach(() => {
vi.restoreAllMocks()
vi.unstubAllGlobals()
})
describe('讲解对象列表返回', () => {
it('上一页是独立展厅列表时回到该列表', async () => {
vi.stubGlobal('getCurrentPages', vi.fn(() => [
{ route: 'pages/explain/list', options: {} },
{ route: 'pages/explain/guide-stop-list', options: {} }
]))
const wrapper = mountGuideStopList()
testState.onLoadHandler?.({ hallId: 'hall-1', hallName: '恐龙厅' })
await flushPromises()
await wrapper.get('[data-testid="frame-back"]').trigger('click')
expect(uni.navigateBack).toHaveBeenCalledWith(expect.objectContaining({ delta: 1 }))
expect(uni.reLaunch).not.toHaveBeenCalled()
wrapper.unmount()
})
it('上一页是首页讲解 Tab 时回到首页中的展厅列表', async () => {
vi.stubGlobal('getCurrentPages', vi.fn(() => [
{ route: 'pages/index/index', options: { tab: 'explain' } },
{ route: 'pages/explain/guide-stop-list', options: {} }
]))
const wrapper = mountGuideStopList()
await wrapper.get('[data-testid="list-back"]').trigger('click')
expect(uni.navigateBack).toHaveBeenCalledWith(expect.objectContaining({ delta: 1 }))
expect(uni.reLaunch).not.toHaveBeenCalled()
wrapper.unmount()
})
it('深链或不相关上一页直接回退至独立展厅列表', async () => {
const wrapper = mountGuideStopList()
await wrapper.get('[data-testid="list-back"]').trigger('click')
expect(uni.navigateBack).not.toHaveBeenCalled()
expect(uni.reLaunch).toHaveBeenCalledWith({ url: '/pages/explain/list' })
wrapper.unmount()
})
it('页面栈返回失败时回退至独立展厅列表', async () => {
vi.stubGlobal('getCurrentPages', vi.fn(() => [
{ route: 'pages/explain/list', options: {} },
{ route: 'pages/explain/guide-stop-list', options: {} }
]))
vi.stubGlobal('uni', {
navigateBack: vi.fn((options: { fail?: () => void }) => options.fail?.()),
reLaunch: vi.fn(),
navigateTo: vi.fn(),
setNavigationBarTitle: vi.fn()
})
const wrapper = mountGuideStopList()
await wrapper.get('[data-testid="list-back"]').trigger('click')
expect(uni.reLaunch).toHaveBeenCalledWith({ url: '/pages/explain/list' })
wrapper.unmount()
})
})

View File

@@ -9,38 +9,30 @@ vi.mock('@/utils/hostEnvironment', () => ({
})) }))
describe('讲解展厅选择列表', () => { describe('讲解展厅选择列表', () => {
it('业务单元卡片显示业务单元标签,并保留讲解数量和点击事件', async () => { it('展厅卡片显示讲解对象数量,不暴露业务单元契约', async () => {
const wrapper = mount(ExplainHallSelect, { const wrapper = mount(ExplainHallSelect, {
props: { props: {
stage: 'unit', stage: 'hall',
businessUnits: [ halls: [
{ {
id: 'unit-evolution', id: 'hall-evolution',
name: '生命演化', name: '生命演化',
hallId: 'hall-evolution', explainCount: 6,
guideStopCount: 6 guideStopCount: 6,
}, searchText: '生命演化厅'
{
id: 'unit-dinosaur',
name: '恐龙世界',
hallId: 'hall-dinosaur',
guideStopCount: 4
} }
] ]
} }
}) })
const cards = wrapper.findAll('.unit-card') const cards = wrapper.findAll('.hall-card')
expect(cards).toHaveLength(2) expect(cards).toHaveLength(1)
expect(wrapper.text()).toContain('生命演化') expect(wrapper.text()).toContain('生命演化')
expect(wrapper.text()).toContain('恐龙世界') expect(wrapper.text()).toContain('6 个讲解对象')
expect(wrapper.text()).toContain('6 个讲解') expect(wrapper.text()).not.toContain('业务单元')
expect(wrapper.text()).toContain('4 个讲解')
expect(cards.map((card) => card.text()).join('')).not.toContain('业务单元')
expect(cards[0]?.find('.floor-badge').exists()).toBe(false)
await cards[0]?.trigger('tap') await cards[0]?.trigger('tap')
expect(wrapper.emitted('businessUnitClick')).toEqual([['unit-evolution']]) expect(wrapper.emitted('hallClick')).toEqual([['hall-evolution']])
}) })
it('READY 讲解点不显示可讲解标签,非 READY 讲解点保留图文标签和点击事件', async () => { it('READY 讲解点不显示可讲解标签,非 READY 讲解点保留图文标签和点击事件', async () => {

View File

@@ -0,0 +1,13 @@
import { describe, expect, it } from 'vitest'
import { explainGuideStopListUrl } from '@/utils/explainNavigation'
describe('explain navigation', () => {
it('builds the hall-only guide-object URL', () => {
const url = explainGuideStopListUrl('hall-1', '恐龙厅')
expect(url.split('?')[0]).toBe('/pages/explain/guide-stop-list')
const params = new URLSearchParams(url.split('?')[1])
expect(params.get('hallId')).toBe('hall-1')
expect(params.get('hallName')).toBe('恐龙厅')
expect(params.has('unitId')).toBe(false)
})
})

View File

@@ -0,0 +1,102 @@
import { describe, expect, it, vi } from 'vitest'
import type { GuideStopInfo } from '@/data/adapters/guideStopInfoAdapter'
import type { AudioPlayInfoRepository } from '@/repositories/AudioPlayInfoRepository'
import type { ExplainRepository } from '@/repositories/ExplainRepository'
import { ExplainUseCase } from '@/usecases/explainUseCase'
import { dataSourceConfig } from '@/config/dataSource'
const createStopInfo = (overrides: Partial<GuideStopInfo> = {}): GuideStopInfo => ({
available: true,
targetType: 'STOP',
targetId: 'stop-1',
lang: 'zh-CN',
title: 'Guide stop',
galleryUrls: [],
imageStatus: 'MISSING',
linkedExhibits: [
{
id: 'exhibit-1',
name: 'Linked exhibit',
coverImageUrl: '/linked-exhibit.jpg'
}
],
playTargetType: 'STOP',
playTargetId: 'stop-1',
hasAudio: false,
hasText: false,
supportedLanguages: [],
audioStatus: 'MISSING',
...overrides
})
const createUseCase = (stopInfo: GuideStopInfo) => {
const explain = {
getExhibitById: vi.fn().mockResolvedValue(null),
listExplainExhibits: vi.fn().mockResolvedValue([])
} as unknown as ExplainRepository
const audioPlayInfo = {
getStopInfo: vi.fn().mockResolvedValue(stopInfo)
} as unknown as AudioPlayInfoRepository
return new ExplainUseCase(explain, audioPlayInfo)
}
describe('ExplainUseCase stop image policy', () => {
it('uses stop-info first in remote mode without requesting a catalog fallback', async () => {
const originalMode = dataSourceConfig.explainContentMode
Object.assign(dataSourceConfig, { explainContentMode: 'remote' })
const explain = {
getExhibitById: vi.fn(),
listExplainExhibits: vi.fn(),
listExplainExhibitsByHall: vi.fn()
} as unknown as ExplainRepository
const audio = { getStopInfo: vi.fn().mockResolvedValue(createStopInfo()) } as unknown as AudioPlayInfoRepository
await new ExplainUseCase(explain, audio).enterExplainDetail({
exhibitId: 'stop-1', targetType: 'STOP', targetId: 'stop-1', hallName: '宇宙厅'
})
expect(audio.getStopInfo).toHaveBeenCalledTimes(1)
expect(explain.getExhibitById).not.toHaveBeenCalled()
expect(explain.listExplainExhibits).not.toHaveBeenCalled()
Object.assign(dataSourceConfig, { explainContentMode: originalMode })
})
it('delegates hall exhibits and business-unit stops without loading full exhibits', async () => {
const explain = {
listExplainExhibitsByHall: vi.fn().mockResolvedValue([]),
listGuideStopsByBusinessUnit: vi.fn().mockResolvedValue([])
} as unknown as ExplainRepository
const useCase = new ExplainUseCase(explain, {} as AudioPlayInfoRepository)
await useCase.listExhibitsByHallId('hall-1')
await useCase.listGuideStopsByBusinessUnit('hall-1', 'outline-1')
expect(explain.listExplainExhibitsByHall).toHaveBeenCalledWith('hall-1')
expect(explain.listGuideStopsByBusinessUnit).toHaveBeenCalledWith('hall-1', 'outline-1')
})
it('does not use linked exhibit images when stop-info reports MISSING', async () => {
const detail = await createUseCase(createStopInfo()).enterExplainDetail({
exhibitId: 'stop-1',
targetType: 'STOP',
targetId: 'stop-1'
})
expect(detail.image).toBeUndefined()
expect(detail.galleryUrls).toEqual([])
expect(detail.linkedExhibits?.[0]?.coverImageUrl).toBe('/linked-exhibit.jpg')
})
it('uses the stop image when stop-info reports READY', async () => {
const detail = await createUseCase(createStopInfo({
imageStatus: 'READY',
coverImageUrl: '/guide-stop.jpg',
galleryUrls: ['/guide-stop-gallery.jpg']
})).enterExplainDetail({
exhibitId: 'stop-1',
targetType: 'STOP',
targetId: 'stop-1'
})
expect(detail.image).toBe('/guide-stop.jpg')
expect(detail.galleryUrls).toEqual(['/guide-stop-gallery.jpg'])
})
})