优化讲解展厅与对象列表视觉
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
lyf
2026-07-18 01:16:30 +08:00
parent b8ab414bac
commit 5a711377b0
16 changed files with 161 additions and 121 deletions

View File

@@ -8,33 +8,21 @@
<text class="header-title">{{ selectedHallName || '讲解对象' }}</text> <text class="header-title">{{ selectedHallName || '讲解对象' }}</text>
</view> </view>
<view class="catalog-controls">
<input class="catalog-search" :value="keyword" placeholder="搜索讲解对象" @input="handleKeywordInput" />
<scroll-view class="filter-scroll" scroll-x :show-scrollbar="false">
<view class="filter-list">
<view v-for="filter in filters" :key="filter.id" class="filter-chip" :class="{ selected: activeFilter === filter.id }" @tap="selectFilter(filter.id)">
<text>{{ filter.label }}</text>
</view>
</view>
</scroll-view>
</view>
<scroll-view class="explain-scroll" scroll-y :show-scrollbar="false" @scroll="handleScroll" @scrolltolower="requestMore"> <scroll-view class="explain-scroll" scroll-y :show-scrollbar="false" @scroll="handleScroll" @scrolltolower="requestMore">
<view v-if="loading" class="state-block"><text class="state-title">正在加载讲解对象</text><text class="state-desc">稍后将展示该展厅的讲解对象</text></view> <view v-if="loading" class="state-block"><text class="state-title">正在加载讲解对象</text><text class="state-desc">稍后将展示该展厅的讲解对象</text></view>
<view v-else-if="error" class="state-block error"><text class="state-title">讲解对象加载失败</text><text class="state-desc">{{ error }}</text><button class="state-retry" @tap="emit('retry')">重新加载</button></view> <view v-else-if="error" class="state-block error"><text class="state-title">讲解对象加载失败</text><text class="state-desc">{{ error }}</text><button class="state-retry" @tap="emit('retry')">重新加载</button></view>
<template v-else> <template v-else>
<view v-if="filteredStops.length" class="stop-list"> <view v-if="guideStops.length" class="stop-list">
<view v-for="stop in filteredStops" :key="stop.id" class="stop-card" @tap="emit('guideStopClick', stop)"> <view v-for="stop in guideStops" :key="stop.id" class="stop-card" @tap="emit('guideStopClick', stop)">
<image v-if="stop.coverImageUrl" class="stop-cover" :src="stop.coverImageUrl" mode="aspectFit" /> <image v-if="stop.coverImageUrl" class="stop-cover" :src="stop.coverImageUrl" mode="aspectFit" />
<view v-else class="stop-cover placeholder" /> <view v-else class="stop-cover placeholder" />
<view class="stop-copy"> <view class="stop-copy">
<text class="stop-name">{{ stop.name }}</text> <text class="stop-name">{{ stop.name }}</text>
<text class="stop-meta">{{ primaryMeta(stop) }}</text> </view>
<text class="stop-meta">{{ secondaryMeta(stop) }}</text> <view class="stop-status"><text class="stop-status-text">{{ availabilityLabel(stop) }}</text></view>
</view> </view>
</view> </view>
</view> <view v-else class="state-block empty"><text class="state-title">该展厅暂无讲解对象</text><text class="state-desc">可返回选择其他展厅</text></view>
<view v-else class="state-block empty"><text class="state-title">{{ keyword || activeFilter !== 'all' ? '未找到匹配的讲解对象' : '该展厅暂无讲解对象' }}</text><text class="state-desc">{{ keyword || activeFilter !== 'all' ? '可尝试调整搜索词或筛选条件。' : '可返回选择其他展厅。' }}</text></view>
<view v-if="guideStops.length" class="load-more-state"> <view v-if="guideStops.length" class="load-more-state">
<text v-if="loadingMore" class="state-desc">正在加载更多讲解对象</text> <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> <template v-else-if="loadMoreError"><text class="state-desc">{{ loadMoreError }}</text><button class="state-retry" @tap="emit('retryMore')">重试加载更多</button></template>
@@ -46,7 +34,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref } from 'vue' import { computed } from 'vue'
import { isEmbeddedInWechatMiniProgram } from '@/utils/hostEnvironment' import { isEmbeddedInWechatMiniProgram } from '@/utils/hostEnvironment'
export interface ExplainGuideStopCatalogItem { export interface ExplainGuideStopCatalogItem {
@@ -76,45 +64,23 @@ const props = withDefaults(defineProps<{
loadMoreError?: string loadMoreError?: string
}>(), { guideStops: () => [], selectedHallName: '', loading: false, loadingMore: false, hasMore: false, error: '', loadMoreError: '' }) }>(), { guideStops: () => [], selectedHallName: '', loading: false, loadingMore: false, hasMore: false, error: '', loadMoreError: '' })
const emit = defineEmits<{ guideStopClick: [stop: ExplainGuideStopCatalogItem], back: [], retry: [], retryMore: [], requestAll: [] }>() const emit = defineEmits<{ guideStopClick: [stop: ExplainGuideStopCatalogItem], back: [], retry: [], retryMore: [] }>()
const keyword = ref('')
const activeFilter = ref('all')
const shouldUseHostNavigation = computed(() => isEmbeddedInWechatMiniProgram()) const shouldUseHostNavigation = computed(() => isEmbeddedInWechatMiniProgram())
const showInternalHeader = computed(() => !shouldUseHostNavigation.value) const showInternalHeader = computed(() => !shouldUseHostNavigation.value)
const levels = computed(() => [...new Set(props.guideStops.map((item) => item.guideLevel?.trim()).filter((item): item is string => Boolean(item)))])
const filters = computed(() => {
const levelFilters = levels.value.map((level) => ({ id: `level:${level}`, label: level }))
return [{ id: 'all', label: '全部' }, ...levelFilters, { id: 'audio', label: '有音频' }, { id: 'text', label: '图文' }]
})
const filteredStops = computed(() => props.guideStops.filter((stop) => {
const normalizedKeyword = keyword.value.trim().toLowerCase()
if (normalizedKeyword && !`${stop.name} ${stop.description || ''}`.toLowerCase().includes(normalizedKeyword)) return false
if (activeFilter.value === 'audio') return stop.audioStatus === 'READY' || stop.hasAudio === true
if (activeFilter.value === 'text') return stop.hasTextRecord === true
if (activeFilter.value.startsWith('level:')) return stop.guideLevel === activeFilter.value.slice(6)
return true
}))
const primaryMeta = (stop: ExplainGuideStopCatalogItem) => [stop.guideLevel, stop.audioStatus === 'READY' || stop.hasAudio ? '音频' : stop.hasTextRecord ? '图文' : '暂无内容'].filter(Boolean).join(' · ') const availabilityLabel = (stop: ExplainGuideStopCatalogItem) => (
const secondaryMeta = (stop: ExplainGuideStopCatalogItem) => stop.audioStatus === 'READY' || stop.hasAudio ? '音频可播放' : stop.hasTextRecord ? '图文可查看' : '暂无可用内容' stop.audioStatus === 'READY' || stop.hasAudio ? '音频' : stop.hasTextRecord ? '图文' : '暂无内容'
const requestAllIfNeeded = () => { if ((keyword.value.trim() || activeFilter.value !== 'all') && props.hasMore && !props.loadingMore) emit('requestAll') } )
const handleKeywordInput = (event: Event & { detail?: { value?: string } }) => {
keyword.value = event.detail?.value ?? (event.target as HTMLInputElement).value ?? ''
requestAllIfNeeded()
}
const selectFilter = (filterId: string) => { activeFilter.value = filterId; requestAllIfNeeded() }
const requestMore = () => { if (props.hasMore && !props.loading && !props.loadingMore && !props.loadMoreError) emit('retryMore') } const requestMore = () => { if (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 handleScroll = (event: Event) => { const target = event.target as HTMLElement | null; if (target && target.scrollTop + target.clientHeight >= target.scrollHeight - 8) requestMore() }
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
.guide-stop-catalog { position: relative; width: 100%; height: 100%; overflow: hidden; background: #f9fafb; } .guide-stop-catalog { position: relative; width: 100%; height: 100%; overflow: hidden; background: #f7f9f2; }
.catalog-header { position: absolute; z-index: 2; top: 0; left: 0; right: 0; height: 64px; display: flex; align-items: center; justify-content: center; background: #fff; } .catalog-header { position: absolute; z-index: 2; top: 0; left: 0; right: 0; height: 64px; display: flex; align-items: center; justify-content: center; background: #f7f9f2; }
.header-back { position: absolute; left: 12px; top: 10px; width: 64px; height: 44px; display: flex; align-items: center; gap: 4px; } .header-back { position: absolute; left: 12px; top: 10px; width: 44px; height: 44px; display: flex; align-items: center; }.header-back-icon { width: 20px; height: 20px; position: relative; } .header-back-icon::before { content: ''; position: absolute; left: 3px; top: 4px; width: 10px; height: 10px; border-left: 1.5px solid #262421; border-bottom: 1.5px solid #262421; transform: rotate(45deg); }
.header-back-icon { width: 16px; height: 16px; position: relative; } .header-back-icon::before { content: ''; position: absolute; left: 4px; top: 4px; width: 8px; height: 8px; border-left: 1.5px solid #141412; border-bottom: 1.5px solid #141412; transform: rotate(45deg); } .header-back-text { display: none; }.header-title { max-width: calc(100% - 152px); padding: 0 8px; font-size: 18px; line-height: 26px; font-weight: 700; color: #262421; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.header-back-text { font-size: 14px; line-height: 20px; color: #141412; }.header-title { max-width: calc(100% - 152px); padding: 0 8px; font-size: 18px; line-height: 25px; font-weight: 700; color: #141412; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .explain-scroll { height: 100%; padding: 80px 16px calc(16px + env(safe-area-inset-bottom)); box-sizing: border-box; background: #f7f9f2; }.host-navigation .explain-scroll { padding-top: 16px; }
.catalog-controls { position: absolute; z-index: 1; top: 64px; left: 0; right: 0; height: 88px; padding: 8px 16px; box-sizing: border-box; background: #f9fafb; }.host-navigation .catalog-controls { top: 0; }.catalog-search { display: block; width: 100%; height: 36px; padding: 0 12px; box-sizing: border-box; border: 0; border-radius: 8px; background: #f3f3f3; font-size: 13px; color: #141412; }.filter-scroll { height: 28px; margin-top: 8px; white-space: nowrap; }.filter-list { display: inline-flex; gap: 8px; min-width: 100%; }.filter-chip { display: flex; align-items: center; height: 26px; padding: 0 10px; border-radius: 8px; color: #444754; font-size: 12px; line-height: 17px; background: #fff; }.filter-chip.selected { background: #e0df00; color: #141412; font-weight: 700; } .stop-list { display: flex; width: 100%; min-width: 0; flex-direction: column; gap: 4px; }.stop-card { width: 100%; min-width: 0; height: 80px; padding: 8px 0; display: flex; align-items: center; gap: 12px; box-sizing: border-box; overflow: hidden; border: 0; border-bottom: 1px solid #dbded4; border-radius: 0; background: transparent; }.stop-card:active { background: rgba(227, 235, 201, 0.4); }.stop-cover { flex: 0 0 64px; width: 64px; height: 64px; overflow: hidden; border-radius: 6px; background: #f5f5ed; }.stop-copy { min-width: 0; display: flex; flex: 1; overflow: hidden; }.stop-name { display: -webkit-box; max-height: 40px; font-size: 14px; line-height: 20px; font-weight: 700; color: #262421; overflow: hidden; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }.stop-status { flex: 0 0 44px; width: 44px; height: 24px; display: flex; align-items: center; justify-content: center; border-radius: 12px; background: #e3ebc9; }.stop-status-text { font-size: 11px; line-height: 16px; color: #262421; white-space: nowrap; }
.explain-scroll { height: 100%; padding: 164px 16px calc(16px + env(safe-area-inset-bottom)); box-sizing: border-box; background: #fff; }.host-navigation .explain-scroll { padding-top: 100px; }
.stop-list { display: flex; width: 100%; min-width: 0; flex-direction: column; gap: 12px; }.stop-card { width: 100%; min-width: 0; height: 80px; padding: 8px; display: flex; align-items: center; gap: 10px; box-sizing: border-box; overflow: hidden; border: 1px solid #e4e6df; border-radius: 8px; background: #fff; }.stop-card:active { background: #f7f8f3; }.stop-cover { flex: 0 0 54px; width: 54px; height: 54px; overflow: hidden; border-radius: 4px; background: #f5f5ed; }.stop-copy { min-width: 0; height: 64px; display: flex; flex: 1; flex-direction: column; gap: 2px; overflow: hidden; }.stop-name { display: block; font-size: 15px; line-height: 21px; font-weight: 700; color: #141412; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }.stop-meta { display: block; font-size: 11px; line-height: 15px; color: #444754; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.state-block { margin: 34px 0; padding: 22px 16px; box-sizing: border-box; text-align: center; border: 1px solid #e4e6df; border-radius: 8px; background: #fff; }.state-block.error { border-color: #e5c2c2; background: #fff7f7; }.state-title,.state-desc { display: block; }.state-title { font-size: 15px; line-height: 21px; font-weight: 700; color: #141412; }.state-desc { margin-top: 6px; font-size: 12px; line-height: 18px; color: #68725d; }.state-retry { margin-top: 12px; padding: 0 14px; font-size: 13px; line-height: 32px; color: #141412; background: #e0df00; border: 0; border-radius: 6px; }.load-more-state { min-height: 52px; padding: 12px 16px calc(12px + env(safe-area-inset-bottom)); text-align: center; } .state-block { margin: 34px 0; padding: 22px 16px; box-sizing: border-box; text-align: center; border: 1px solid #e4e6df; border-radius: 8px; background: #fff; }.state-block.error { border-color: #e5c2c2; background: #fff7f7; }.state-title,.state-desc { display: block; }.state-title { font-size: 15px; line-height: 21px; font-weight: 700; color: #141412; }.state-desc { margin-top: 6px; font-size: 12px; line-height: 18px; color: #68725d; }.state-retry { margin-top: 12px; padding: 0 14px; font-size: 13px; line-height: 32px; color: #141412; background: #e0df00; border: 0; border-radius: 6px; }.load-more-state { min-height: 52px; padding: 12px 16px calc(12px + env(safe-area-inset-bottom)); text-align: center; }
</style> </style>

View File

@@ -66,9 +66,6 @@
<view class="hall-overview-copy"> <view class="hall-overview-copy">
<text class="hall-name">{{ hall.name }}</text> <text class="hall-name">{{ hall.name }}</text>
<text v-if="hallCardTheme(hall).englishName" class="hall-overview-english">{{ hallCardTheme(hall).englishName }}</text>
<text class="hall-meta-text hall-overview-meta">{{ hallFloorMetaText(hall) }}</text>
<view class="hall-go-entry"><text class="hall-go-entry-text">GO </text></view>
</view> </view>
</view> </view>
</view> </view>
@@ -190,33 +187,33 @@ const emit = defineEmits<{
retryMore: [] retryMore: []
}>() }>()
const HALL_PREVIEW_BASE = '/static/explain/hall-previews' const HALL_PREVIEW_BASE = '/static/icons/halls'
const HALL_PREVIEW_EAGER_COUNT = 4 const HALL_PREVIEW_EAGER_COUNT = 4
const loadedHallPreviewIds = ref(new Set<string>()) const loadedHallPreviewIds = ref(new Set<string>())
const failedHallPreviewIds = ref(new Set<string>()) const failedHallPreviewIds = ref(new Set<string>())
const hallPreviewMap: Record<string, string> = { const hallPreviewMap: Record<string, string> = {
宇宙厅: `${HALL_PREVIEW_BASE}/universe.webp`, 宇宙厅: `${HALL_PREVIEW_BASE}/normalized/universe.png`,
地球厅: `${HALL_PREVIEW_BASE}/earth.webp`, 地球厅: `${HALL_PREVIEW_BASE}/normalized/earth.png`,
演化厅: `${HALL_PREVIEW_BASE}/evolution.webp`, 演化厅: `${HALL_PREVIEW_BASE}/normalized/evolution.png`,
恐龙厅: `${HALL_PREVIEW_BASE}/dinosaur.webp`, 恐龙厅: `${HALL_PREVIEW_BASE}/normalized/dinosaur.png`,
人类厅: `${HALL_PREVIEW_BASE}/human.webp`, 人类厅: `${HALL_PREVIEW_BASE}/normalized/human.png`,
动物厅: `${HALL_PREVIEW_BASE}/biology.webp`, 动物厅: `${HALL_PREVIEW_BASE}/normalized/biology.png`,
生物厅: `${HALL_PREVIEW_BASE}/biology.webp`, 生物厅: `${HALL_PREVIEW_BASE}/normalized/biology.png`,
生态厅: `${HALL_PREVIEW_BASE}/ecology.webp`, 生态厅: `${HALL_PREVIEW_BASE}/normalized/ecology.png`,
家园厅: `${HALL_PREVIEW_BASE}/homeland.webp` 家园厅: `${HALL_PREVIEW_BASE}/normalized/homeland.png`
} }
const hallCardThemeMap: Record<string, { color: string; englishName: string }> = { const hallCardThemeMap: Record<string, { color: string; englishName: string }> = {
宇宙厅: { color: '#57c7d9', englishName: 'Universe Hall' }, 宇宙厅: { color: '#65d2e4', englishName: '' },
地球厅: { color: '#99de00', englishName: 'Earth Hall' }, 地球厅: { color: '#b8e51b', englishName: '' },
演化厅: { color: '#b896f2', englishName: 'Evolution Hall' }, 演化厅: { color: '#cba7ff', englishName: '' },
恐龙厅: { color: '#c7a15c', englishName: 'Dinosaur Hall' }, 恐龙厅: { color: '#d9b06c', englishName: '' },
人类厅: { color: '#e8e500', englishName: 'Human Hall' }, 人类厅: { color: '#e9ea16', englishName: '' },
动物厅: { color: '#08c4b5', englishName: 'Biology Hall' }, 动物厅: { color: '#20c9c0', englishName: '' },
生物厅: { color: '#08c4b5', englishName: 'Biology Hall' }, 生物厅: { color: '#20c9c0', englishName: '' },
生态厅: { color: '#8fe5b8', englishName: 'Ecology Hall' }, 生态厅: { color: '#94e7be', englishName: '' },
家园厅: { color: '#ffc2a6', englishName: 'Homeland Hall' } 家园厅: { color: '#ffc6a5', englishName: '' }
} }
const filteredHalls = computed(() => props.halls) const filteredHalls = computed(() => props.halls)
@@ -273,14 +270,6 @@ const handleHallPreviewError = (hallId: string) => {
} }
} }
const hallMetaText = (hall: ExplainHallSelectItem) => (
typeof hall.guideStopCount === 'number'
? `${hall.guideStopCount || 0} 个讲解对象`
: hall.explainCount > 0 ? `${hall.explainCount} 个展项` : '展厅'
)
const hallFloorMetaText = (hall: ExplainHallSelectItem) => `${hall.floorLabel || '楼层待补充'} · ${hallMetaText(hall)}`
const hallCardTheme = (hall: ExplainHallSelectItem) => ( const hallCardTheme = (hall: ExplainHallSelectItem) => (
hallCardThemeMap[hall.name.trim()] || { color: '#dce5d5', englishName: '' } hallCardThemeMap[hall.name.trim()] || { color: '#dce5d5', englishName: '' }
) )
@@ -806,4 +795,66 @@ const handleBack = () => {
border: 0; border: 0;
border-radius: 6px; border-radius: 6px;
} }
/* Figma hall-browser layout: title plus a single themed icon per card. */
.explain-hall-select {
background: #f7f9f2;
}
.explain-page-header {
background: #f7f9f2;
}
.header-back {
width: 44px;
}
.header-back-text {
display: none;
}
.header-back-icon {
width: 20px;
height: 20px;
}
.header-back-icon::before {
width: 10px;
height: 10px;
transform: translateX(3px) rotate(45deg);
}
.explain-scroll.hall-stage {
padding: 88px 16px 24px;
}
.hall-overview-list {
grid-auto-rows: 138px;
}
.hall-overview-card {
min-height: 138px;
}
.hall-overview-card .hall-card-art {
top: 50px;
right: 10px;
width: 72px;
height: 72px;
}
.hall-card-art-image {
mix-blend-mode: multiply;
filter: grayscale(1) brightness(0.72) contrast(1.65);
}
.hall-overview-copy {
top: 24px;
}
.hall-overview-card .hall-name {
max-width: 96px;
font-size: 21px;
line-height: 28px;
}
</style> </style>

View File

@@ -1,9 +1,8 @@
<template> <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"> <view class="explain-guide-stop-page">
<ExplainHallSelect <ExplainGuideStopCatalog
:guide-stops="explainGuideStopItems" :guide-stops="explainGuideStopItems"
stage="stop"
:selected-hall-name="selectedExplainHallName" :selected-hall-name="selectedExplainHallName"
:loading="explainLoading" :loading="explainLoading"
:loading-more="loadingMore" :loading-more="loadingMore"
@@ -23,7 +22,7 @@
import { onUnmounted, ref } from 'vue' import { onUnmounted, ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app' import { onLoad } from '@dcloudio/uni-app'
import GuidePageFrame from '@/components/navigation/GuidePageFrame.vue' import GuidePageFrame from '@/components/navigation/GuidePageFrame.vue'
import ExplainHallSelect, { type ExplainGuideStopSelectItem } from '@/components/explain/ExplainHallSelect.vue' import ExplainGuideStopCatalog, { type ExplainGuideStopCatalogItem } from '@/components/explain/ExplainGuideStopCatalog.vue'
import { explainUseCase } from '@/usecases/explainUseCase' import { explainUseCase } from '@/usecases/explainUseCase'
import type { ExplainGuideStop } from '@/domain/museum' import type { ExplainGuideStop } from '@/domain/museum'
import { normalizeExplainDetailTargetFromGuideStop } from '@/domain/explainDetailTarget' import { normalizeExplainDetailTargetFromGuideStop } from '@/domain/explainDetailTarget'
@@ -32,7 +31,7 @@ import { navigateToGuideTopTab, type GuideTopTab } from '@/utils/guideTopTabs'
const PAGE_SIZE = 20 const PAGE_SIZE = 20
const selectedExplainHallId = ref('') const selectedExplainHallId = ref('')
const selectedExplainHallName = ref('') const selectedExplainHallName = ref('')
const explainGuideStopItems = ref<ExplainGuideStopSelectItem[]>([]) const explainGuideStopItems = ref<ExplainGuideStopCatalogItem[]>([])
const explainLoading = ref(false) const explainLoading = ref(false)
const loadingMore = ref(false) const loadingMore = ref(false)
const explainError = ref('') const explainError = ref('')
@@ -42,7 +41,7 @@ const nextPageNo = ref(1)
let requestVersion = 0 let requestVersion = 0
let disposed = false let disposed = false
const toGuideStopItems = (stops: ExplainGuideStop[]): ExplainGuideStopSelectItem[] => stops.map((stop) => ({ const toGuideStopItems = (stops: ExplainGuideStop[]): ExplainGuideStopCatalogItem[] => stops.map((stop) => ({
id: stop.id, id: stop.id,
name: stop.name, name: stop.name,
hallId: stop.hallId, hallId: stop.hallId,
@@ -54,6 +53,7 @@ const toGuideStopItems = (stops: ExplainGuideStop[]): ExplainGuideStopSelectItem
coverImageUrl: stop.imageStatus === 'MISSING' ? undefined : stop.coverImageUrl, coverImageUrl: stop.imageStatus === 'MISSING' ? undefined : stop.coverImageUrl,
description: stop.description, description: stop.description,
hasAudio: stop.hasAudio, hasAudio: stop.hasAudio,
hasTextRecord: stop.hasTextRecord,
audioStatus: stop.audioStatus, audioStatus: stop.audioStatus,
guideLevel: stop.guideLevel, guideLevel: stop.guideLevel,
playTargetType: stop.playTargetType, playTargetType: stop.playTargetType,
@@ -67,7 +67,7 @@ const syncPageTitle = (title: string) => {
const loadPage = async (pageNo: number, replace: boolean) => { const loadPage = async (pageNo: number, replace: boolean) => {
const hallId = selectedExplainHallId.value const hallId = selectedExplainHallId.value
if (!hallId || disposed || (!replace && (loadingMore.value || !hasMore.value))) return if (!hallId || disposed || (!replace && (loadingMore.value || !hasMore.value))) return false
const version = ++requestVersion const version = ++requestVersion
if (replace) { if (replace) {
explainLoading.value = true explainLoading.value = true
@@ -89,10 +89,12 @@ const loadPage = async (pageNo: number, replace: boolean) => {
} }
nextPageNo.value = page.pageNo + 1 nextPageNo.value = page.pageNo + 1
hasMore.value = page.hasMore && explainGuideStopItems.value.length < page.total hasMore.value = page.hasMore && explainGuideStopItems.value.length < page.total
return true
} catch (error) { } catch (error) {
if (disposed || version !== requestVersion) return if (disposed || version !== requestVersion) return
if (replace) explainError.value = '讲解对象加载失败,请稍后重试' if (replace) explainError.value = '讲解对象加载失败,请稍后重试'
else loadMoreError.value = '加载更多讲解对象失败,请重试' else loadMoreError.value = '加载更多讲解对象失败,请重试'
return false
} finally { } finally {
if (!disposed && version === requestVersion) { if (!disposed && version === requestVersion) {
explainLoading.value = false explainLoading.value = false
@@ -127,7 +129,7 @@ onUnmounted(() => {
requestVersion += 1 requestVersion += 1
}) })
const handleExplainGuideStopClick = (stop: ExplainGuideStopSelectItem) => { const handleExplainGuideStopClick = (stop: ExplainGuideStopCatalogItem) => {
const target = stop.playTargetType && stop.playTargetId const target = stop.playTargetType && stop.playTargetId
? { targetType: stop.playTargetType, targetId: stop.playTargetId } ? { targetType: stop.playTargetType, targetId: stop.playTargetId }
: normalizeExplainDetailTargetFromGuideStop({ id: stop.id }) : normalizeExplainDetailTargetFromGuideStop({ id: stop.id })

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

View File

@@ -55,15 +55,8 @@ test('hall goes directly to paged guide objects without outline requests', async
expect(layout.first.width).toBeCloseTo(layout.listWidth, 0) expect(layout.first.width).toBeCloseTo(layout.listWidth, 0)
expect(layout.second.y).toBeGreaterThan(layout.first.bottom) expect(layout.second.y).toBeGreaterThan(layout.first.bottom)
} }
const search = page.getByRole('textbox') await expect(page.getByRole('textbox')).toHaveCount(0)
await search.fill('讲解对象21') await expect(page.locator('.filter-chip')).toHaveCount(0)
await expect(page.getByText('讲解对象21', { exact: true })).toBeVisible()
await search.fill('')
const audioFilter = page.getByText('有音频', { exact: true })
await audioFilter.click()
await expect(page.getByText('讲解对象1', { exact: true })).toBeVisible()
await expect(page.getByText('讲解对象2', { exact: true })).not.toBeVisible()
await page.getByText('全部', { exact: true }).click()
await page.locator('.explain-scroll').hover() await page.locator('.explain-scroll').hover()
await page.mouse.wheel(0, 10000) await page.mouse.wheel(0, 10000)
await expect(page.getByText('讲解对象21', { exact: true })).toBeVisible() await expect(page.getByText('讲解对象21', { exact: true })).toBeVisible()

View File

@@ -13,29 +13,26 @@ const stops = [
const mountCatalog = () => mount(ExplainGuideStopCatalog, { props: { guideStops: stops, selectedHallName: '宇宙厅', hasMore: true } }) const mountCatalog = () => mount(ExplainGuideStopCatalog, { props: { guideStops: stops, selectedHallName: '宇宙厅', hasMore: true } })
describe('ExplainGuideStopCatalog', () => { describe('ExplainGuideStopCatalog', () => {
it('renders a stable single-column list with real availability copy and no arrows', () => { it('renders a stable single-column list with one truthful availability tag and no arrows', () => {
const wrapper = mountCatalog() const wrapper = mountCatalog()
expect(wrapper.find('.stop-list').exists()).toBe(true) expect(wrapper.find('.stop-list').exists()).toBe(true)
expect(wrapper.find('.stop-grid').exists()).toBe(false) expect(wrapper.find('.stop-grid').exists()).toBe(false)
expect(wrapper.findAll('.stop-card')).toHaveLength(3) expect(wrapper.findAll('.stop-card')).toHaveLength(3)
expect(wrapper.findAll('.stop-cover.placeholder')).toHaveLength(2) expect(wrapper.findAll('.stop-cover.placeholder')).toHaveLength(2)
expect(wrapper.text()).toContain('音频可播放') expect(wrapper.findAll('.stop-status')).toHaveLength(3)
expect(wrapper.text()).toContain('图文可查看') expect(wrapper.text()).toContain('音频')
expect(wrapper.text()).toContain('暂无可用内容') expect(wrapper.text()).toContain('图文')
expect(wrapper.text()).toContain('暂无内容')
expect(wrapper.find('.hall-arrow').exists()).toBe(false) expect(wrapper.find('.hall-arrow').exists()).toBe(false)
expect(wrapper.find('.stop-name').classes()).toContain('stop-name') expect(wrapper.find('.stop-name').classes()).toContain('stop-name')
}) })
it('searches and uses only data-driven fallback filters when guide levels are absent', async () => { it('does not expose search or category controls in the focused list', () => {
const wrapper = mountCatalog() const wrapper = mountCatalog()
expect(wrapper.text()).not.toContain('核心展品') expect(wrapper.text()).not.toContain('核心展品')
expect(wrapper.get('input').attributes('placeholder')).toBe('搜索讲解对象') expect(wrapper.find('input').exists()).toBe(false)
await wrapper.get('input').setValue('图文') expect(wrapper.find('.catalog-controls').exists()).toBe(false)
expect(wrapper.findAll('.stop-card')).toHaveLength(1) expect(wrapper.find('.filter-chip').exists()).toBe(false)
expect(wrapper.emitted('requestAll')).toHaveLength(1)
await wrapper.get('input').setValue('')
await wrapper.get('.filter-chip:nth-child(2)').trigger('tap')
expect(wrapper.findAll('.stop-card')).toHaveLength(1)
}) })
it('keeps loading and empty states available for the single-column list', async () => { it('keeps loading and empty states available for the single-column list', async () => {
@@ -47,14 +44,12 @@ describe('ExplainGuideStopCatalog', () => {
expect(wrapper.find('.stop-list').exists()).toBe(false) expect(wrapper.find('.stop-list').exists()).toBe(false)
}) })
it('emits card, back, retry, retry-more, and request-all actions', async () => { it('emits card, back, retry, and retry-more actions', async () => {
const wrapper = mountCatalog() const wrapper = mountCatalog()
await wrapper.get('.stop-card').trigger('tap') await wrapper.get('.stop-card').trigger('tap')
await wrapper.get('.header-back').trigger('tap') await wrapper.get('.header-back').trigger('tap')
await wrapper.get('.filter-chip:nth-child(2)').trigger('tap')
expect(wrapper.emitted('guideStopClick')?.[0][0]).toMatchObject({ id: 'ready' }) expect(wrapper.emitted('guideStopClick')?.[0][0]).toMatchObject({ id: 'ready' })
expect(wrapper.emitted('back')).toHaveLength(1) expect(wrapper.emitted('back')).toHaveLength(1)
expect(wrapper.emitted('requestAll')).toHaveLength(1)
await wrapper.setProps({ error: '失败' }) await wrapper.setProps({ error: '失败' })
await wrapper.get('.state-retry').trigger('tap') await wrapper.get('.state-retry').trigger('tap')

View File

@@ -4,6 +4,7 @@ import { defineComponent } from 'vue'
import { flushPromises, mount } from '@vue/test-utils' import { flushPromises, mount } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import ExplainGuideStopListPage from '@/pages/explain/guide-stop-list.vue' import ExplainGuideStopListPage from '@/pages/explain/guide-stop-list.vue'
import { explainUseCase } from '@/usecases/explainUseCase'
const testState = vi.hoisted(() => ({ const testState = vi.hoisted(() => ({
onLoadHandler: null as null | ((options?: Record<string, string>) => void) onLoadHandler: null as null | ((options?: Record<string, string>) => void)
@@ -29,17 +30,18 @@ const GuidePageFrameStub = defineComponent({
template: '<main><button data-testid="frame-back" @click="$emit(\'back\')">返回</button><slot /></main>' template: '<main><button data-testid="frame-back" @click="$emit(\'back\')">返回</button><slot /></main>'
}) })
const ExplainHallSelectStub = defineComponent({ const ExplainGuideStopCatalogStub = defineComponent({
name: 'ExplainHallSelect', name: 'ExplainGuideStopCatalog',
emits: ['back'], props: ['guideStops', 'selectedHallName', 'loading', 'loadingMore', 'hasMore', 'error', 'loadMoreError'],
template: '<button data-testid="list-back" @click="$emit(\'back\')">返回</button>' emits: ['back', 'retry', 'retryMore', 'guideStopClick'],
template: '<button data-testid="list-back" @click="$emit(\'back\')">返回</button><button data-testid="retry-more" @click="$emit(\'retryMore\')">重试更多</button>'
}) })
const mountGuideStopList = () => mount(ExplainGuideStopListPage, { const mountGuideStopList = () => mount(ExplainGuideStopListPage, {
global: { global: {
stubs: { stubs: {
GuidePageFrame: GuidePageFrameStub, GuidePageFrame: GuidePageFrameStub,
ExplainHallSelect: ExplainHallSelectStub ExplainGuideStopCatalog: ExplainGuideStopCatalogStub
} }
} }
}) })
@@ -53,6 +55,8 @@ beforeEach(() => {
setNavigationBarTitle: vi.fn() setNavigationBarTitle: vi.fn()
}) })
vi.stubGlobal('getCurrentPages', vi.fn(() => [])) vi.stubGlobal('getCurrentPages', vi.fn(() => []))
vi.mocked(explainUseCase.listGuideStopsPageByHall).mockReset()
vi.mocked(explainUseCase.listGuideStopsPageByHall).mockResolvedValue({ items: [], total: 0, pageNo: 1, pageSize: 20, hasMore: false })
}) })
afterEach(() => { afterEach(() => {
@@ -61,6 +65,34 @@ afterEach(() => {
}) })
describe('讲解对象列表返回', () => { describe('讲解对象列表返回', () => {
it('ignores a delayed first-page response after unmount', async () => {
let resolvePage: ((page: { items: Array<{ id: string, name: string }>, total: number, pageNo: number, pageSize: number, hasMore: boolean }) => void) | undefined
vi.mocked(explainUseCase.listGuideStopsPageByHall).mockImplementationOnce(() => new Promise((resolve) => { resolvePage = resolve }))
const wrapper = mountGuideStopList()
testState.onLoadHandler?.({ hallId: 'hall-1' })
wrapper.unmount()
resolvePage?.({ items: [{ id: 'late', name: '延迟' }], total: 1, pageNo: 1, pageSize: 20, hasMore: false })
await flushPromises()
expect(explainUseCase.listGuideStopsPageByHall).toHaveBeenCalledTimes(1)
})
it('permits retry-more recovery after a failed page', async () => {
vi.mocked(explainUseCase.listGuideStopsPageByHall)
.mockResolvedValueOnce({ items: [{ id: 'one', name: '一号' }], total: 2, pageNo: 1, pageSize: 20, hasMore: true })
.mockRejectedValueOnce(new Error('temporary'))
.mockResolvedValueOnce({ items: [{ id: 'two', name: '二号' }], total: 2, pageNo: 2, pageSize: 20, hasMore: false })
const wrapper = mountGuideStopList()
testState.onLoadHandler?.({ hallId: 'hall-1' })
await flushPromises()
await wrapper.get('[data-testid="retry-more"]').trigger('click')
await flushPromises()
expect(explainUseCase.listGuideStopsPageByHall).toHaveBeenCalledTimes(2)
await wrapper.get('[data-testid="retry-more"]').trigger('click')
await flushPromises()
expect(explainUseCase.listGuideStopsPageByHall).toHaveBeenCalledTimes(3)
wrapper.unmount()
})
it('上一页是独立展厅列表时回到该列表', async () => { it('上一页是独立展厅列表时回到该列表', async () => {
vi.stubGlobal('getCurrentPages', vi.fn(() => [ vi.stubGlobal('getCurrentPages', vi.fn(() => [
{ route: 'pages/explain/list', options: {} }, { route: 'pages/explain/list', options: {} },

View File

@@ -28,14 +28,14 @@ describe('讲解展厅选择列表', () => {
const cards = wrapper.findAll('.hall-card') const cards = wrapper.findAll('.hall-card')
expect(cards).toHaveLength(1) expect(cards).toHaveLength(1)
expect(wrapper.text()).toContain('生命演化厅') expect(wrapper.text()).toContain('生命演化厅')
expect(wrapper.text()).toContain('6 个讲解对象') expect(wrapper.text()).not.toContain('6 个讲解对象')
expect(wrapper.text()).not.toContain('业务单元') expect(wrapper.text()).not.toContain('业务单元')
await cards[0]?.trigger('tap') await cards[0]?.trigger('tap')
expect(wrapper.emitted('hallClick')).toEqual([['hall-evolution']]) expect(wrapper.emitted('hallClick')).toEqual([['hall-evolution']])
}) })
it('展厅阶段按卡片网格展示英文名和进入入口', () => { it('展厅阶段按主题色卡展示展厅名称和图标', () => {
const wrapper = mount(ExplainHallSelect, { const wrapper = mount(ExplainHallSelect, {
props: { props: {
stage: 'hall', stage: 'hall',
@@ -52,10 +52,11 @@ describe('讲解展厅选择列表', () => {
const card = wrapper.get('.hall-overview-card') const card = wrapper.get('.hall-overview-card')
expect(wrapper.get('.header-title').text()).toBe('展厅讲解') expect(wrapper.get('.header-title').text()).toBe('展厅讲解')
expect(card.attributes('style')).toContain('--hall-card-color: #c7a15c') expect(card.attributes('style')).toContain('--hall-card-color: #d9b06c')
expect(card.text()).toContain('Dinosaur Hall') expect(card.text()).toContain('恐龙厅')
expect(card.text()).toContain('L-2 · 35 个讲解对象') expect(card.text()).not.toContain('Dinosaur Hall')
expect(card.get('.hall-go-entry').text()).toBe('GO ') expect(card.text()).not.toContain('35 个讲解对象')
expect(card.find('.hall-go-entry').exists()).toBe(false)
}) })
it('READY 讲解点不显示可讲解标签,非 READY 讲解点保留图文标签和点击事件', async () => { it('READY 讲解点不显示可讲解标签,非 READY 讲解点保留图文标签和点击事件', async () => {