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

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

View File

@@ -12,10 +12,11 @@
class="explain-scroll"
:class="{
'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>

View File

@@ -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 || '')
}
: null)
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)
}
})
.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(),

View File

@@ -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] || []

View File

@@ -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

View File

@@ -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"
}

View File

@@ -1,137 +0,0 @@
<template>
<GuidePageFrame
active-tab="explain"
variant="static"
:show-top-tabs="false"
@tab-change="handleTopTabChange"
@back="handleBack"
>
<view class="explain-business-unit-page">
<ExplainHallSelect
:business-units="explainBusinessUnitItems"
stage="unit"
:selected-hall-name="selectedExplainHallName"
:loading="explainLoading"
:error="explainError"
@business-unit-click="handleExplainBusinessUnitClick"
@back="handleBack"
/>
</view>
</GuidePageFrame>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import GuidePageFrame from '@/components/navigation/GuidePageFrame.vue'
import ExplainHallSelect, {
type ExplainBusinessUnitSelectItem
} from '@/components/explain/ExplainHallSelect.vue'
import {
explainUseCase
} from '@/usecases/explainUseCase'
import type {
ExplainBusinessUnit
} from '@/domain/museum'
import {
navigateToGuideTopTab,
type GuideTopTab
} from '@/utils/guideTopTabs'
const selectedExplainHallId = ref('')
const selectedExplainHallName = ref('')
const explainBusinessUnits = ref<ExplainBusinessUnit[]>([])
const explainLoading = ref(false)
const explainError = ref('')
const explainBusinessUnitItems = computed<ExplainBusinessUnitSelectItem[]>(() => (
explainBusinessUnits.value.map((unit) => ({
id: unit.id,
name: unit.name,
hallId: unit.hallId,
previewImageUrl: unit.previewImageUrl,
guideStopCount: unit.guideStopCount
}))
))
const syncPageTitle = (title: string) => {
uni.setNavigationBarTitle({ title })
if (typeof document !== 'undefined') document.title = title
}
onLoad(async (options: any = {}) => {
const hallId = Array.isArray(options.hallId) ? options.hallId[0] : options.hallId
const hallName = Array.isArray(options.hallName) ? options.hallName[0] : options.hallName
selectedExplainHallId.value = hallId || ''
selectedExplainHallName.value = hallName || '业务单元'
syncPageTitle(selectedExplainHallName.value)
if (!selectedExplainHallId.value) {
explainError.value = '缺少展厅参数,请返回讲解列表后重试'
return
}
explainLoading.value = true
try {
explainBusinessUnits.value = await explainUseCase.loadTemporaryBusinessUnitsByHall(selectedExplainHallId.value)
} catch (error) {
explainError.value = '展厅讲解点加载失败,请稍后重试'
} finally {
explainLoading.value = false
}
})
const handleExplainBusinessUnitClick = (unitId: string) => {
const unit = explainBusinessUnits.value.find((item) => item.id === unitId)
const params = new URLSearchParams({
hallId: selectedExplainHallId.value,
hallName: selectedExplainHallName.value,
unitId,
unitName: unit?.name || '讲解点'
})
uni.navigateTo({
url: `/pages/explain/guide-stop-list?${params.toString()}`
})
}
const goBackToExplainHallList = () => {
uni.redirectTo({
url: '/pages/explain/list',
fail: () => {
uni.reLaunch({ url: '/pages/explain/list' })
}
})
}
const handleBack = () => {
const pages = getCurrentPages()
const previousPage = pages.length >= 2 ? pages[pages.length - 2] : null
if (
previousPage?.route === 'pages/explain/list'
|| previousPage?.route === 'pages/index/index'
) {
uni.navigateBack({
delta: 1,
fail: goBackToExplainHallList
})
return
}
goBackToExplainHallList()
}
const handleTopTabChange = (tab: GuideTopTab) => {
navigateToGuideTopTab(tab)
}
</script>
<style scoped lang="scss">
.explain-business-unit-page {
position: absolute;
inset: 0;
background: #edf0ea;
}
</style>

View File

@@ -1,19 +1,18 @@
<template>
<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,147 +20,158 @@
</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) => ({
id: stop.id,
name: stop.name,
hallId: stop.hallId,
hallName: stop.hallName,
floorId: stop.floorId,
poiId: stop.poiId,
mapX: stop.mapX,
mapY: stop.mapY,
coverImageUrl: stop.coverImageUrl,
description: stop.description,
hasAudio: stop.hasAudio,
audioStatus: stop.audioStatus,
guideLevel: stop.guideLevel,
playTargetType: stop.playTargetType,
playTargetId: stop.playTargetId
}))
)
const toGuideStopItems = (stops: ExplainGuideStop[]): ExplainGuideStopSelectItem[] => stops.map((stop) => ({
id: stop.id,
name: stop.name,
hallId: stop.hallId,
hallName: stop.hallName,
floorId: stop.floorId,
poiId: stop.poiId,
mapX: stop.mapX,
mapY: stop.mapY,
coverImageUrl: stop.imageStatus === 'MISSING' ? undefined : stop.coverImageUrl,
description: stop.description,
hasAudio: stop.hasAudio,
audioStatus: stop.audioStatus,
guideLevel: stop.guideLevel,
playTargetType: stop.playTargetType,
playTargetId: stop.playTargetId
}))
const syncPageTitle = (title: string) => {
uni.setNavigationBarTitle({ title })
if (typeof document !== 'undefined') document.title = title
}
onLoad(async (options: any = {}) => {
selectedExplainHallId.value = Array.isArray(options.hallId) ? options.hallId[0] : options.hallId || ''
selectedExplainHallName.value = Array.isArray(options.hallName) ? options.hallName[0] : options.hallName || ''
selectedExplainBusinessUnitId.value = Array.isArray(options.unitId) ? options.unitId[0] : options.unitId || ''
selectedExplainBusinessUnitName.value = Array.isArray(options.unitName) ? options.unitName[0] : options.unitName || '讲解点'
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 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) {
explainError.value = '缺少业务单元参数,请返回上一级后重试'
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
}
reloadFirstPage()
})
explainLoading.value = true
try {
const unit = await explainUseCase.selectBusinessUnit(
selectedExplainHallId.value,
selectedExplainBusinessUnitId.value
)
if (!unit) {
explainError.value = '未找到对应业务单元,请返回上一级后重试'
return
}
selectedExplainBusinessUnitName.value = unit.name || selectedExplainBusinessUnitName.value
syncPageTitle(selectedExplainBusinessUnitName.value)
const stops = await explainUseCase.listGuideStopsByBusinessUnit(
selectedExplainHallId.value,
selectedExplainBusinessUnitId.value
)
explainGuideStopItems.value = toGuideStopItems(stops)
} catch (error) {
explainError.value = '讲解点加载失败,请稍后重试'
} finally {
explainLoading.value = false
}
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
}
fallbackToHallList()
}
const handleTopTabChange = (tab: GuideTopTab) => {
navigateToGuideTopTab(tab)
}
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>

View File

@@ -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 || '讲解')
})
}

View File

@@ -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 || '讲解')
})
}

View File

@@ -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) {

View File

@@ -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)
}

View File

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