This commit is contained in:
@@ -12,10 +12,11 @@
|
||||
class="explain-scroll"
|
||||
:class="{
|
||||
'hall-stage': stage === 'hall',
|
||||
'unit-stage': stage === 'unit',
|
||||
'stop-stage': stage === 'stop'
|
||||
}"
|
||||
scroll-y
|
||||
@scroll="handleScroll"
|
||||
@scrolltolower="handleScrollToLower"
|
||||
:show-scrollbar="false"
|
||||
>
|
||||
<view v-if="loading" class="state-block">
|
||||
@@ -24,8 +25,9 @@
|
||||
</view>
|
||||
|
||||
<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>
|
||||
<button class="state-retry" @tap="emit('retry')">重新加载</button>
|
||||
</view>
|
||||
|
||||
<template v-else>
|
||||
@@ -75,31 +77,6 @@
|
||||
</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-for="stop in guideStops"
|
||||
@@ -140,6 +117,14 @@
|
||||
<text class="state-title">{{ emptyTitle }}</text>
|
||||
<text class="state-desc">{{ emptyDescription }}</text>
|
||||
</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>
|
||||
</scroll-view>
|
||||
</view>
|
||||
@@ -147,9 +132,6 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import {
|
||||
EXPLAIN_UNIT_PREVIEW_FALLBACK
|
||||
} from '@/utils/explainUnitPreviews'
|
||||
import { isEmbeddedInWechatMiniProgram } from '@/utils/hostEnvironment'
|
||||
|
||||
export interface ExplainHallSelectItem {
|
||||
@@ -158,19 +140,10 @@ export interface ExplainHallSelectItem {
|
||||
floorLabel?: string
|
||||
image?: string
|
||||
explainCount: number
|
||||
businessUnitCount?: number
|
||||
guideStopCount?: number
|
||||
searchText: string
|
||||
}
|
||||
|
||||
export interface ExplainBusinessUnitSelectItem {
|
||||
id: string
|
||||
name: string
|
||||
hallId: string
|
||||
previewImageUrl?: string
|
||||
guideStopCount: number
|
||||
}
|
||||
|
||||
export interface ExplainGuideStopSelectItem {
|
||||
id: string
|
||||
name: string
|
||||
@@ -189,40 +162,42 @@ export interface ExplainGuideStopSelectItem {
|
||||
playTargetId?: string
|
||||
}
|
||||
|
||||
export type ExplainSelectStage = 'hall' | 'unit' | 'stop'
|
||||
export type ExplainSelectStage = 'hall' | 'stop'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
halls?: ExplainHallSelectItem[]
|
||||
businessUnits?: ExplainBusinessUnitSelectItem[]
|
||||
guideStops?: ExplainGuideStopSelectItem[]
|
||||
stage?: ExplainSelectStage
|
||||
selectedHallName?: string
|
||||
selectedBusinessUnitName?: string
|
||||
loading?: boolean
|
||||
loadingMore?: boolean
|
||||
hasMore?: boolean
|
||||
error?: string
|
||||
loadMoreError?: string
|
||||
}>(), {
|
||||
halls: () => [],
|
||||
businessUnits: () => [],
|
||||
guideStops: () => [],
|
||||
stage: 'hall',
|
||||
selectedHallName: '',
|
||||
selectedBusinessUnitName: '',
|
||||
loading: false,
|
||||
error: ''
|
||||
loadingMore: false,
|
||||
hasMore: false,
|
||||
error: '',
|
||||
loadMoreError: ''
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
hallClick: [hallId: string]
|
||||
businessUnitClick: [unitId: string]
|
||||
guideStopClick: [stop: ExplainGuideStopSelectItem]
|
||||
back: []
|
||||
retry: []
|
||||
retryMore: []
|
||||
}>()
|
||||
|
||||
const HALL_PREVIEW_BASE = '/static/explain/hall-previews'
|
||||
const HALL_PREVIEW_EAGER_COUNT = 4
|
||||
const loadedHallPreviewIds = ref(new Set<string>())
|
||||
const failedHallPreviewIds = ref(new Set<string>())
|
||||
const failedUnitPreviewIds = ref(new Set<string>())
|
||||
|
||||
const hallPreviewMap: Record<string, string> = {
|
||||
宇宙厅: `${HALL_PREVIEW_BASE}/universe.webp`,
|
||||
@@ -240,28 +215,23 @@ const filteredHalls = computed(() => props.halls)
|
||||
const shouldUseHostNavigation = computed(() => isEmbeddedInWechatMiniProgram())
|
||||
const showInternalHeader = computed(() => !shouldUseHostNavigation.value)
|
||||
const headerTitle = computed(() => {
|
||||
if (props.stage === 'unit') return props.selectedHallName || '选择业务单元'
|
||||
if (props.stage === 'stop') return props.selectedBusinessUnitName || '选择讲解点'
|
||||
if (props.stage === 'stop') return props.selectedHallName || '讲解对象'
|
||||
return '讲解'
|
||||
})
|
||||
const loadingTitle = computed(() => {
|
||||
if (props.stage === 'unit') return '正在加载业务单元'
|
||||
if (props.stage === 'stop') return '正在加载讲解点'
|
||||
if (props.stage === 'stop') return '正在加载讲解对象'
|
||||
return '正在加载展厅讲解'
|
||||
})
|
||||
const loadingDescription = computed(() => {
|
||||
if (props.stage === 'unit') return '正在按讲解点所属 outline 分组生成临时业务单元。'
|
||||
if (props.stage === 'stop') return '稍后将展示该业务单元下的讲解点。'
|
||||
if (props.stage === 'stop') return '稍后将展示该展厅的讲解对象。'
|
||||
return '稍后将展示可选择的展厅。'
|
||||
})
|
||||
const emptyTitle = computed(() => {
|
||||
if (props.stage === 'unit') return '该展厅暂无已标定讲解点'
|
||||
if (props.stage === 'stop') return '该业务单元暂无讲解点'
|
||||
if (props.stage === 'stop') return '该展厅暂无讲解对象'
|
||||
return '未找到相关展厅'
|
||||
})
|
||||
const emptyDescription = computed(() => {
|
||||
if (props.stage === 'unit') return '方案 B 只展示已在地图上标定的讲解点。'
|
||||
if (props.stage === 'stop') return '可返回选择其他业务单元。'
|
||||
if (props.stage === 'stop') 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) => (
|
||||
typeof hall.businessUnitCount === 'number' || typeof hall.guideStopCount === 'number'
|
||||
? `${hall.businessUnitCount || 0} 个业务单元 · ${hall.guideStopCount || 0} 个讲解点`
|
||||
typeof hall.guideStopCount === 'number'
|
||||
? `${hall.guideStopCount || 0} 个讲解对象`
|
||||
: hall.explainCount > 0 ? `${hall.explainCount} 个展项` : '展厅'
|
||||
)
|
||||
|
||||
@@ -315,10 +275,6 @@ const handleHallClick = (hallId: string) => {
|
||||
emit('hallClick', hallId)
|
||||
}
|
||||
|
||||
const handleBusinessUnitClick = (unitId: string) => {
|
||||
emit('businessUnitClick', unitId)
|
||||
}
|
||||
|
||||
const handleGuideStopClick = (stopId: string) => {
|
||||
const stop = props.guideStops.find((item) => item.id === stopId)
|
||||
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 = () => {
|
||||
emit('back')
|
||||
}
|
||||
@@ -351,7 +322,6 @@ const handleBack = () => {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.explain-scroll.unit-stage,
|
||||
.explain-scroll.stop-stage {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
@@ -553,17 +523,6 @@ const handleBack = () => {
|
||||
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 {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
@@ -734,4 +693,21 @@ const handleBack = () => {
|
||||
border-color: #e5c2c2;
|
||||
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>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type {
|
||||
ExplainBusinessUnit,
|
||||
ExplainGuideStop,
|
||||
ExplainGuideStopPage,
|
||||
MuseumExhibit,
|
||||
MuseumHall
|
||||
} from '@/domain/museum'
|
||||
@@ -84,6 +85,11 @@ interface TimedCacheEntry<T> {
|
||||
expiresAt: number
|
||||
}
|
||||
|
||||
interface BackendCatalogStopPage {
|
||||
list?: BackendCatalogStopItem[]
|
||||
total?: number
|
||||
}
|
||||
|
||||
const requireArrayData = <T>(response: CommonResult<T[]>, message: string) => {
|
||||
if (response.code !== 0) {
|
||||
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 : []
|
||||
}
|
||||
|
||||
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 {
|
||||
private readonly explainExhibitCache = new Map<string, TimedCacheEntry<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 guideStopCache = new Map<string, TimedCacheEntry<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 outlineInflight = new Map<string, Promise<BackendCatalogOutlineItem[]>>()
|
||||
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) {
|
||||
const normalizedHallId = hallId.trim()
|
||||
if (!normalizedHallId) return []
|
||||
|
||||
const key = cacheKey('hall', normalizedHallId, 'stops')
|
||||
const key = cacheKey('hall', normalizedHallId, 'stops', 'all')
|
||||
const cached = this.getCached(this.guideStopCache, key)
|
||||
if (cached) return cached
|
||||
|
||||
@@ -234,28 +290,18 @@ export class BackendExplainContentProvider implements ExplainContentProvider {
|
||||
if (inflight) return inflight
|
||||
|
||||
const promise = (async () => {
|
||||
const [halls, outlines] = await Promise.all([
|
||||
this.requestCatalogHalls(),
|
||||
this.requestCatalogOutlinesByHall(normalizedHallId).catch(() => [])
|
||||
])
|
||||
const hall = halls.find((item) => item.id === normalizedHallId) || null
|
||||
const outlineById = new Map(flattenCatalogOutlines(outlines).map((outline) => [String(outline.id || ''), outline]))
|
||||
|
||||
const params = new URLSearchParams({ lang: catalogLang() })
|
||||
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 || '')
|
||||
const stops: ExplainGuideStop[] = []
|
||||
const seen = new Set<string>()
|
||||
for (let pageNo = 1; ; pageNo += 1) {
|
||||
const page = await this.requestCatalogStopsPageByHall(normalizedHallId, pageNo, 100)
|
||||
page.items.forEach((stop) => {
|
||||
if (!seen.has(stop.id)) {
|
||||
seen.add(stop.id)
|
||||
stops.push(stop)
|
||||
}
|
||||
: null)
|
||||
})
|
||||
.filter(Boolean) as ExplainGuideStop[]
|
||||
|
||||
if (!page.hasMore || stops.length >= page.total) break
|
||||
}
|
||||
this.setCached(this.guideStopCache, key, 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) {
|
||||
const [halls, stops] = await Promise.all([
|
||||
this.listHalls(),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type {
|
||||
ExplainBusinessUnit,
|
||||
ExplainGuideStop,
|
||||
ExplainGuideStopPage,
|
||||
ExplainTrack,
|
||||
MediaAsset,
|
||||
MuseumExhibit,
|
||||
@@ -44,6 +45,7 @@ export interface ExplainContentProvider extends MuseumContentProvider {
|
||||
listExplainExhibitsByHall?(hallId: string): Promise<MuseumExhibit[]>
|
||||
searchExplainExhibits?(keyword?: string): Promise<MuseumExhibit[]>
|
||||
listGuideStopsByHall?(hallId: string): Promise<ExplainGuideStop[]>
|
||||
listGuideStopsPageByHall?(hallId: string, pageNo: number, pageSize: number): Promise<ExplainGuideStopPage>
|
||||
listGuideStopsByBusinessUnit?(hallId: string, unitId: string): Promise<ExplainGuideStop[]>
|
||||
listTemporaryBusinessUnitsByHall?(hallId: string): Promise<ExplainBusinessUnit[]>
|
||||
listTracks(): Promise<ExplainTrack[]>
|
||||
@@ -161,6 +163,20 @@ export class StaticGuideContentProvider implements ExplainContentProvider {
|
||||
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) {
|
||||
const adapter = await this.loadExplainNavigationAdapter()
|
||||
return adapter.businessUnitsByHallId[hallId] || []
|
||||
|
||||
@@ -244,6 +244,14 @@ export interface ExplainGuideStop {
|
||||
playTargetId?: string
|
||||
}
|
||||
|
||||
export interface ExplainGuideStopPage {
|
||||
items: ExplainGuideStop[]
|
||||
total: number
|
||||
pageNo: number
|
||||
pageSize: number
|
||||
hasMore: boolean
|
||||
}
|
||||
|
||||
export interface ExplainBusinessUnit {
|
||||
id: string
|
||||
name: string
|
||||
|
||||
@@ -30,18 +30,10 @@
|
||||
"navigationStyle": "custom"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/explain/business-unit-list",
|
||||
"style": {
|
||||
"navigationBarTitleText": "业务单元",
|
||||
"navigationBarBackgroundColor": "#FFFFFF",
|
||||
"navigationStyle": "custom"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/explain/guide-stop-list",
|
||||
"style": {
|
||||
"navigationBarTitleText": "讲解点",
|
||||
"navigationBarTitleText": "讲解对象",
|
||||
"navigationBarBackgroundColor": "#FFFFFF",
|
||||
"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>
|
||||
<GuidePageFrame
|
||||
active-tab="explain"
|
||||
variant="static"
|
||||
:show-top-tabs="false"
|
||||
@tab-change="handleTopTabChange"
|
||||
@back="handleBack"
|
||||
>
|
||||
<GuidePageFrame active-tab="explain" variant="static" :show-top-tabs="false" @tab-change="handleTopTabChange" @back="handleBack">
|
||||
<view class="explain-guide-stop-page">
|
||||
<ExplainHallSelect
|
||||
:guide-stops="explainGuideStopItems"
|
||||
stage="stop"
|
||||
:selected-business-unit-name="selectedExplainBusinessUnitName"
|
||||
:selected-hall-name="selectedExplainHallName"
|
||||
:loading="explainLoading"
|
||||
:loading-more="loadingMore"
|
||||
:has-more="hasMore"
|
||||
:error="explainError"
|
||||
:load-more-error="loadMoreError"
|
||||
@guide-stop-click="handleExplainGuideStopClick"
|
||||
@retry="reloadFirstPage"
|
||||
@retry-more="loadNextPage"
|
||||
@back="handleBack"
|
||||
/>
|
||||
</view>
|
||||
@@ -21,36 +20,29 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { onUnmounted, ref } from 'vue'
|
||||
import { onLoad } from '@dcloudio/uni-app'
|
||||
import GuidePageFrame from '@/components/navigation/GuidePageFrame.vue'
|
||||
import ExplainHallSelect, {
|
||||
type ExplainGuideStopSelectItem
|
||||
} from '@/components/explain/ExplainHallSelect.vue'
|
||||
import {
|
||||
explainUseCase
|
||||
} from '@/usecases/explainUseCase'
|
||||
import type {
|
||||
ExplainGuideStop
|
||||
} from '@/domain/museum'
|
||||
import {
|
||||
normalizeExplainDetailTargetFromGuideStop
|
||||
} from '@/domain/explainDetailTarget'
|
||||
import {
|
||||
navigateToGuideTopTab,
|
||||
type GuideTopTab
|
||||
} from '@/utils/guideTopTabs'
|
||||
import ExplainHallSelect, { type ExplainGuideStopSelectItem } from '@/components/explain/ExplainHallSelect.vue'
|
||||
import { explainUseCase } 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 selectedExplainHallName = ref('')
|
||||
const selectedExplainBusinessUnitId = ref('')
|
||||
const selectedExplainBusinessUnitName = ref('')
|
||||
const explainGuideStopItems = ref<ExplainGuideStopSelectItem[]>([])
|
||||
const explainLoading = ref(false)
|
||||
const loadingMore = ref(false)
|
||||
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[] => (
|
||||
stops.map((stop) => ({
|
||||
const toGuideStopItems = (stops: ExplainGuideStop[]): ExplainGuideStopSelectItem[] => stops.map((stop) => ({
|
||||
id: stop.id,
|
||||
name: stop.name,
|
||||
hallId: stop.hallId,
|
||||
@@ -59,7 +51,7 @@ const toGuideStopItems = (stops: ExplainGuideStop[]): ExplainGuideStopSelectItem
|
||||
poiId: stop.poiId,
|
||||
mapX: stop.mapX,
|
||||
mapY: stop.mapY,
|
||||
coverImageUrl: stop.coverImageUrl,
|
||||
coverImageUrl: stop.imageStatus === 'MISSING' ? undefined : stop.coverImageUrl,
|
||||
description: stop.description,
|
||||
hasAudio: stop.hasAudio,
|
||||
audioStatus: stop.audioStatus,
|
||||
@@ -67,101 +59,119 @@ const toGuideStopItems = (stops: ExplainGuideStop[]): ExplainGuideStopSelectItem
|
||||
playTargetType: stop.playTargetType,
|
||||
playTargetId: stop.playTargetId
|
||||
}))
|
||||
)
|
||||
|
||||
const syncPageTitle = (title: string) => {
|
||||
uni.setNavigationBarTitle({ title })
|
||||
if (typeof document !== 'undefined') document.title = title
|
||||
}
|
||||
|
||||
onLoad(async (options: any = {}) => {
|
||||
selectedExplainHallId.value = Array.isArray(options.hallId) ? options.hallId[0] : options.hallId || ''
|
||||
selectedExplainHallName.value = Array.isArray(options.hallName) ? options.hallName[0] : options.hallName || ''
|
||||
selectedExplainBusinessUnitId.value = Array.isArray(options.unitId) ? options.unitId[0] : options.unitId || ''
|
||||
selectedExplainBusinessUnitName.value = Array.isArray(options.unitName) ? options.unitName[0] : options.unitName || '讲解点'
|
||||
|
||||
syncPageTitle(selectedExplainBusinessUnitName.value)
|
||||
|
||||
if (!selectedExplainHallId.value || !selectedExplainBusinessUnitId.value) {
|
||||
explainError.value = '缺少业务单元参数,请返回上一级后重试'
|
||||
return
|
||||
}
|
||||
|
||||
const loadPage = async (pageNo: number, replace: boolean) => {
|
||||
const hallId = selectedExplainHallId.value
|
||||
if (!hallId || disposed || (!replace && (loadingMore.value || !hasMore.value))) return
|
||||
const version = ++requestVersion
|
||||
if (replace) {
|
||||
explainLoading.value = true
|
||||
explainError.value = ''
|
||||
loadMoreError.value = ''
|
||||
} else {
|
||||
loadingMore.value = true
|
||||
loadMoreError.value = ''
|
||||
}
|
||||
try {
|
||||
const unit = await explainUseCase.selectBusinessUnit(
|
||||
selectedExplainHallId.value,
|
||||
selectedExplainBusinessUnitId.value
|
||||
)
|
||||
if (!unit) {
|
||||
explainError.value = '未找到对应业务单元,请返回上一级后重试'
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const reloadFirstPage = () => {
|
||||
if (!selectedExplainHallId.value) return
|
||||
nextPageNo.value = 1
|
||||
hasMore.value = false
|
||||
void loadPage(1, true)
|
||||
}
|
||||
|
||||
const loadNextPage = () => void loadPage(nextPageNo.value, false)
|
||||
|
||||
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
|
||||
}
|
||||
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
|
||||
}
|
||||
reloadFirstPage()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
disposed = true
|
||||
requestVersion += 1
|
||||
})
|
||||
|
||||
const handleExplainGuideStopClick = (stop: ExplainGuideStopSelectItem) => {
|
||||
const target = stop.playTargetType && stop.playTargetId
|
||||
? { targetType: stop.playTargetType, targetId: stop.playTargetId }
|
||||
: normalizeExplainDetailTargetFromGuideStop({ id: stop.id })
|
||||
|
||||
const params = new URLSearchParams({
|
||||
id: stop.id,
|
||||
tab: 'explain',
|
||||
targetType: target.targetType,
|
||||
targetId: target.targetId
|
||||
})
|
||||
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.hallName) params.set('hallName', stop.hallName)
|
||||
if (stop.floorId) params.set('floorId', stop.floorId)
|
||||
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 params = new URLSearchParams({
|
||||
hallId: selectedExplainHallId.value,
|
||||
hallName: selectedExplainHallName.value
|
||||
})
|
||||
const fallbackToHallList = () => uni.reLaunch({ url: '/pages/explain/list' })
|
||||
|
||||
uni.redirectTo({
|
||||
url: `/pages/explain/business-unit-list?${params.toString()}`,
|
||||
fail: () => {
|
||||
uni.reLaunch({ url: `/pages/explain/business-unit-list?${params.toString()}` })
|
||||
type ExplainPageStackEntry = {
|
||||
route?: string
|
||||
options?: {
|
||||
tab?: string | string[]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handleBack = () => {
|
||||
uni.navigateBack({
|
||||
delta: 1,
|
||||
fail: goBackToBusinessUnitList
|
||||
})
|
||||
const returnToHallList = () => {
|
||||
const pages = getCurrentPages()
|
||||
const previousPage = pages[pages.length - 2] as ExplainPageStackEntry | undefined
|
||||
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
|
||||
}
|
||||
|
||||
const handleTopTabChange = (tab: GuideTopTab) => {
|
||||
navigateToGuideTopTab(tab)
|
||||
fallbackToHallList()
|
||||
}
|
||||
|
||||
const handleBack = returnToHallList
|
||||
const handleTopTabChange = (tab: GuideTopTab) => navigateToGuideTopTab(tab)
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.explain-guide-stop-page {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: #edf0ea;
|
||||
}
|
||||
.explain-guide-stop-page { position: absolute; inset: 0; background: #edf0ea; }
|
||||
</style>
|
||||
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
type GuideTopTab
|
||||
} from '@/utils/guideTopTabs'
|
||||
import { isEmbeddedInWechatMiniProgram } from '@/utils/hostEnvironment'
|
||||
import { explainGuideStopListUrl } from '@/utils/explainNavigation'
|
||||
|
||||
const explainHallItems = ref<ExplainHallSelectItem[]>([])
|
||||
const explainLoading = ref(false)
|
||||
@@ -56,7 +57,6 @@ const buildExplainHallItems = (
|
||||
floorLabel: hall.floorLabel,
|
||||
image: hall.image,
|
||||
explainCount: hall.exhibitCount || 0,
|
||||
businessUnitCount: summaries[hall.id]?.businessUnitCount,
|
||||
guideStopCount: summaries[hall.id]?.guideStopCount,
|
||||
searchText: normalizeExplainKeyword([
|
||||
hall.name,
|
||||
@@ -225,13 +225,8 @@ const handleTopTabChange = (tab: GuideTopTab) => {
|
||||
|
||||
const handleExplainHallClick = (hallId: string) => {
|
||||
const hall = explainHallItems.value.find((item) => item.id === hallId)
|
||||
const params = new URLSearchParams({
|
||||
hallId,
|
||||
hallName: hall?.name || '讲解'
|
||||
})
|
||||
|
||||
uni.navigateTo({
|
||||
url: `/pages/explain/business-unit-list?${params.toString()}`
|
||||
url: explainGuideStopListUrl(hallId, hall?.name || '讲解')
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -302,6 +302,7 @@ import type {
|
||||
import ExplainHallSelect, {
|
||||
type ExplainHallSelectItem
|
||||
} from '@/components/explain/ExplainHallSelect.vue'
|
||||
import { explainGuideStopListUrl } from '@/utils/explainNavigation'
|
||||
import {
|
||||
getLastGuideLocationPreviewUrl,
|
||||
isGuideTopTab,
|
||||
@@ -990,7 +991,6 @@ const buildExplainHallItems = (
|
||||
floorLabel: hall.floorLabel,
|
||||
image: hall.image,
|
||||
explainCount: hall.exhibitCount || 0,
|
||||
businessUnitCount: summaries[hall.id]?.businessUnitCount,
|
||||
guideStopCount: summaries[hall.id]?.guideStopCount,
|
||||
searchText: normalizeExplainKeyword(keywordParts.join(' '))
|
||||
}
|
||||
@@ -1725,13 +1725,8 @@ const guideSearchText = computed(() => {
|
||||
// 讲解页面处理
|
||||
const handleExplainHallClick = async (hallId: string) => {
|
||||
const hall = explainHallItems.value.find((item) => item.id === hallId)
|
||||
const params = new URLSearchParams({
|
||||
hallId,
|
||||
hallName: hall?.name || '讲解'
|
||||
})
|
||||
|
||||
uni.navigateTo({
|
||||
url: `/pages/explain/business-unit-list?${params.toString()}`
|
||||
url: explainGuideStopListUrl(hallId, hall?.name || '讲解')
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type {
|
||||
ExplainBusinessUnit,
|
||||
ExplainGuideStop,
|
||||
ExplainGuideStopPage,
|
||||
ExplainTrack,
|
||||
MuseumExhibit,
|
||||
MuseumHall,
|
||||
@@ -27,6 +28,7 @@ export interface ExplainRepository {
|
||||
listHalls(): Promise<MuseumHall[]>
|
||||
getHallById(id: string): Promise<MuseumHall | null>
|
||||
listGuideStopsByHall(hallId: string): Promise<ExplainGuideStop[]>
|
||||
listGuideStopsPageByHall(hallId: string, pageNo: number, pageSize: number): Promise<ExplainGuideStopPage>
|
||||
listGuideStopsByBusinessUnit(hallId: string, unitId: string): Promise<ExplainGuideStop[]>
|
||||
listTemporaryBusinessUnitsByHall(hallId: string): Promise<ExplainBusinessUnit[]>
|
||||
listTracks(): Promise<ExplainTrack[]>
|
||||
@@ -73,7 +75,36 @@ export class DefaultExplainRepository implements ExplainRepository {
|
||||
}
|
||||
|
||||
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) {
|
||||
|
||||
@@ -2,6 +2,7 @@ import type {
|
||||
AudioPlayTargetType,
|
||||
ExplainBusinessUnit,
|
||||
ExplainGuideStop,
|
||||
ExplainGuideStopPage,
|
||||
ExplainTrack,
|
||||
MediaAsset,
|
||||
MuseumExhibit,
|
||||
@@ -67,7 +68,6 @@ export interface ExhibitDetailAudioOptions {
|
||||
|
||||
export interface ExplainHallSummary {
|
||||
hallId: string
|
||||
businessUnitCount: number
|
||||
guideStopCount: number
|
||||
}
|
||||
|
||||
@@ -487,24 +487,20 @@ export class ExplainUseCase {
|
||||
if (hall && (typeof hall.outlineCount === 'number' || typeof hall.stopCount === 'number')) {
|
||||
return [hallId, {
|
||||
hallId,
|
||||
businessUnitCount: hall.outlineCount || 0,
|
||||
guideStopCount: hall.stopCount || 0
|
||||
}] as const
|
||||
}
|
||||
|
||||
try {
|
||||
const units = await this.loadTemporaryBusinessUnitsByHall(hallId)
|
||||
const guideStopCount = units.reduce((total, unit) => total + unit.guideStopCount, 0)
|
||||
const page = await this.listGuideStopsPageByHall(hallId, 1, 1)
|
||||
return [hallId, {
|
||||
hallId,
|
||||
businessUnitCount: units.length,
|
||||
guideStopCount
|
||||
guideStopCount: page.total
|
||||
}] as const
|
||||
} catch (error) {
|
||||
console.warn('讲解展厅统计加载失败:', hallId, error)
|
||||
return [hallId, {
|
||||
hallId,
|
||||
businessUnitCount: 0,
|
||||
guideStopCount: 0
|
||||
}] as const
|
||||
}
|
||||
@@ -513,6 +509,10 @@ export class ExplainUseCase {
|
||||
return Object.fromEntries(entries)
|
||||
}
|
||||
|
||||
listGuideStopsPageByHall(hallId: string, pageNo: number, pageSize: number): Promise<ExplainGuideStopPage> {
|
||||
return this.explain.listGuideStopsPageByHall(hallId, pageNo, pageSize)
|
||||
}
|
||||
|
||||
getHallById(id: string) {
|
||||
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('讲解展厅选择列表', () => {
|
||||
it('业务单元卡片不显示业务单元标签,并保留讲解数量和点击事件', async () => {
|
||||
it('展厅卡片只显示讲解对象数量,不暴露业务单元契约', async () => {
|
||||
const wrapper = mount(ExplainHallSelect, {
|
||||
props: {
|
||||
stage: 'unit',
|
||||
businessUnits: [
|
||||
stage: 'hall',
|
||||
halls: [
|
||||
{
|
||||
id: 'unit-evolution',
|
||||
name: '生命演化',
|
||||
hallId: 'hall-evolution',
|
||||
guideStopCount: 6
|
||||
},
|
||||
{
|
||||
id: 'unit-dinosaur',
|
||||
name: '恐龙世界',
|
||||
hallId: 'hall-dinosaur',
|
||||
guideStopCount: 4
|
||||
id: 'hall-evolution',
|
||||
name: '生命演化厅',
|
||||
explainCount: 6,
|
||||
guideStopCount: 6,
|
||||
searchText: '生命演化厅'
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
const cards = wrapper.findAll('.unit-card')
|
||||
expect(cards).toHaveLength(2)
|
||||
expect(wrapper.text()).toContain('生命演化')
|
||||
expect(wrapper.text()).toContain('恐龙世界')
|
||||
expect(wrapper.text()).toContain('6 个讲解')
|
||||
expect(wrapper.text()).toContain('4 个讲解')
|
||||
expect(cards.map((card) => card.text()).join('')).not.toContain('业务单元')
|
||||
expect(cards[0]?.find('.floor-badge').exists()).toBe(false)
|
||||
const cards = wrapper.findAll('.hall-card')
|
||||
expect(cards).toHaveLength(1)
|
||||
expect(wrapper.text()).toContain('生命演化厅')
|
||||
expect(wrapper.text()).toContain('6 个讲解对象')
|
||||
expect(wrapper.text()).not.toContain('业务单元')
|
||||
|
||||
await cards[0]?.trigger('tap')
|
||||
expect(wrapper.emitted('businessUnitClick')).toEqual([['unit-evolution']])
|
||||
expect(wrapper.emitted('hallClick')).toEqual([['hall-evolution']])
|
||||
})
|
||||
|
||||
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