This commit is contained in:
@@ -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>
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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] || []
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 || '讲解')
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 || '讲解')
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
4
src/utils/explainNavigation.ts
Normal file
4
src/utils/explainNavigation.ts
Normal 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()}`
|
||||||
|
}
|
||||||
40
tests/e2e/explain-hall-page.spec.ts
Normal file
40
tests/e2e/explain-hall-page.spec.ts
Normal 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'])
|
||||||
|
})
|
||||||
57
tests/unit/BackendExplainContentProvider.spec.ts
Normal file
57
tests/unit/BackendExplainContentProvider.spec.ts
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
122
tests/unit/ExplainGuideStopListNavigation.spec.ts
Normal file
122
tests/unit/ExplainGuideStopListNavigation.spec.ts
Normal 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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 () => {
|
||||||
|
|||||||
13
tests/unit/ExplainNavigation.spec.ts
Normal file
13
tests/unit/ExplainNavigation.spec.ts
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
102
tests/unit/ExplainUseCase.spec.ts
Normal file
102
tests/unit/ExplainUseCase.spec.ts
Normal 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'])
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user