Compare commits
2 Commits
f4e7a5f147
...
72d1f30c89
| Author | SHA1 | Date | |
|---|---|---|---|
| 72d1f30c89 | |||
| d8e5a84f02 |
120
src/components/explain/ExplainGuideStopCatalog.vue
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
<template>
|
||||||
|
<view class="guide-stop-catalog" :class="{ 'host-navigation': shouldUseHostNavigation }">
|
||||||
|
<view v-if="showInternalHeader" class="catalog-header">
|
||||||
|
<view class="header-back" @tap="emit('back')">
|
||||||
|
<text class="header-back-icon" />
|
||||||
|
<text class="header-back-text">返回</text>
|
||||||
|
</view>
|
||||||
|
<text class="header-title">{{ selectedHallName || '讲解对象' }}</text>
|
||||||
|
</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">
|
||||||
|
<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>
|
||||||
|
<template v-else>
|
||||||
|
<view v-if="filteredStops.length" class="stop-list">
|
||||||
|
<view v-for="stop in filteredStops" :key="stop.id" class="stop-card" @tap="emit('guideStopClick', stop)">
|
||||||
|
<image v-if="stop.coverImageUrl" class="stop-cover" :src="stop.coverImageUrl" mode="aspectFit" />
|
||||||
|
<view v-else class="stop-cover placeholder" />
|
||||||
|
<view class="stop-copy">
|
||||||
|
<text class="stop-name">{{ stop.name }}</text>
|
||||||
|
<text class="stop-meta">{{ primaryMeta(stop) }}</text>
|
||||||
|
<text class="stop-meta">{{ secondaryMeta(stop) }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</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">
|
||||||
|
<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>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import { isEmbeddedInWechatMiniProgram } from '@/utils/hostEnvironment'
|
||||||
|
|
||||||
|
export interface ExplainGuideStopCatalogItem {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
hallId?: string
|
||||||
|
hallName?: string
|
||||||
|
floorId?: string
|
||||||
|
poiId?: string
|
||||||
|
coverImageUrl?: string
|
||||||
|
description?: string
|
||||||
|
audioStatus?: string
|
||||||
|
hasAudio?: boolean
|
||||||
|
hasTextRecord?: boolean
|
||||||
|
guideLevel?: string
|
||||||
|
playTargetType?: 'ITEM' | 'STOP'
|
||||||
|
playTargetId?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
guideStops?: ExplainGuideStopCatalogItem[]
|
||||||
|
selectedHallName?: string
|
||||||
|
loading?: boolean
|
||||||
|
loadingMore?: boolean
|
||||||
|
hasMore?: boolean
|
||||||
|
error?: string
|
||||||
|
loadMoreError?: string
|
||||||
|
}>(), { guideStops: () => [], selectedHallName: '', loading: false, loadingMore: false, hasMore: false, error: '', loadMoreError: '' })
|
||||||
|
|
||||||
|
const emit = defineEmits<{ guideStopClick: [stop: ExplainGuideStopCatalogItem], back: [], retry: [], retryMore: [], requestAll: [] }>()
|
||||||
|
const keyword = ref('')
|
||||||
|
const activeFilter = ref('all')
|
||||||
|
const shouldUseHostNavigation = computed(() => isEmbeddedInWechatMiniProgram())
|
||||||
|
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 secondaryMeta = (stop: ExplainGuideStopCatalogItem) => 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 handleScroll = (event: Event) => { const target = event.target as HTMLElement | null; if (target && target.scrollTop + target.clientHeight >= target.scrollHeight - 8) requestMore() }
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.guide-stop-catalog { position: relative; width: 100%; height: 100%; overflow: hidden; background: #f9fafb; }
|
||||||
|
.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; }
|
||||||
|
.header-back { position: absolute; left: 12px; top: 10px; width: 64px; height: 44px; display: flex; align-items: center; gap: 4px; }
|
||||||
|
.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 { 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; }
|
||||||
|
.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; }
|
||||||
|
.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; }
|
||||||
|
</style>
|
||||||
@@ -36,11 +36,12 @@
|
|||||||
v-for="(hall, index) in filteredHalls"
|
v-for="(hall, index) in filteredHalls"
|
||||||
:key="hall.id"
|
:key="hall.id"
|
||||||
class="hall-card hall-overview-card"
|
class="hall-card hall-overview-card"
|
||||||
|
:style="{ '--hall-card-color': hallCardTheme(hall).color }"
|
||||||
@tap="handleHallClick(hall.id)"
|
@tap="handleHallClick(hall.id)"
|
||||||
>
|
>
|
||||||
<view
|
<view
|
||||||
v-if="hallPreviewUrl(hall)"
|
v-if="hallPreviewUrl(hall)"
|
||||||
class="hall-thumb hall-thumb-shell"
|
class="hall-card-art hall-thumb-shell"
|
||||||
:class="{
|
:class="{
|
||||||
'is-image-loaded': isHallPreviewLoaded(hall.id),
|
'is-image-loaded': isHallPreviewLoaded(hall.id),
|
||||||
'is-image-error': isHallPreviewError(hall.id)
|
'is-image-error': isHallPreviewError(hall.id)
|
||||||
@@ -51,29 +52,24 @@
|
|||||||
</view>
|
</view>
|
||||||
<image
|
<image
|
||||||
v-if="!isHallPreviewError(hall.id)"
|
v-if="!isHallPreviewError(hall.id)"
|
||||||
class="hall-thumb-image"
|
class="hall-card-art-image"
|
||||||
:src="hallPreviewUrl(hall)"
|
:src="hallPreviewUrl(hall)"
|
||||||
mode="aspectFill"
|
mode="aspectFit"
|
||||||
:lazy-load="isHallPreviewLazy(index)"
|
:lazy-load="isHallPreviewLazy(index)"
|
||||||
@load="handleHallPreviewLoad(hall.id)"
|
@load="handleHallPreviewLoad(hall.id)"
|
||||||
@error="handleHallPreviewError(hall.id)"
|
@error="handleHallPreviewError(hall.id)"
|
||||||
/>
|
/>
|
||||||
</view>
|
</view>
|
||||||
<view v-else class="hall-thumb placeholder">
|
<view v-else class="hall-card-art placeholder">
|
||||||
<text class="hall-thumb-text">{{ hallIconText(hall.name) }}</text>
|
<text class="hall-thumb-text">{{ hallIconText(hall.name) }}</text>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view class="hall-main">
|
<view class="hall-overview-copy">
|
||||||
<text class="hall-name">{{ hall.name }}</text>
|
<text class="hall-name">{{ hall.name }}</text>
|
||||||
<view class="hall-meta-row">
|
<text v-if="hallCardTheme(hall).englishName" class="hall-overview-english">{{ hallCardTheme(hall).englishName }}</text>
|
||||||
<view class="floor-badge">
|
<text class="hall-meta-text hall-overview-meta">{{ hallFloorMetaText(hall) }}</text>
|
||||||
<text class="floor-badge-text">{{ hall.floorLabel || '楼层待补充' }}</text>
|
<view class="hall-go-entry"><text class="hall-go-entry-text">GO ›</text></view>
|
||||||
</view>
|
|
||||||
<text class="hall-meta-text hall-overview-meta">{{ hallMetaText(hall) }}</text>
|
|
||||||
</view>
|
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<text class="hall-arrow">›</text>
|
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
@@ -211,12 +207,24 @@ const hallPreviewMap: Record<string, string> = {
|
|||||||
家园厅: `${HALL_PREVIEW_BASE}/homeland.webp`
|
家园厅: `${HALL_PREVIEW_BASE}/homeland.webp`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const hallCardThemeMap: Record<string, { color: string; englishName: string }> = {
|
||||||
|
宇宙厅: { color: '#57c7d9', englishName: 'Universe Hall' },
|
||||||
|
地球厅: { color: '#99de00', englishName: 'Earth Hall' },
|
||||||
|
演化厅: { color: '#b896f2', englishName: 'Evolution Hall' },
|
||||||
|
恐龙厅: { color: '#c7a15c', englishName: 'Dinosaur Hall' },
|
||||||
|
人类厅: { color: '#e8e500', englishName: 'Human Hall' },
|
||||||
|
动物厅: { color: '#08c4b5', englishName: 'Biology Hall' },
|
||||||
|
生物厅: { color: '#08c4b5', englishName: 'Biology Hall' },
|
||||||
|
生态厅: { color: '#8fe5b8', englishName: 'Ecology Hall' },
|
||||||
|
家园厅: { color: '#ffc2a6', englishName: 'Homeland Hall' }
|
||||||
|
}
|
||||||
|
|
||||||
const filteredHalls = computed(() => props.halls)
|
const filteredHalls = computed(() => props.halls)
|
||||||
const shouldUseHostNavigation = computed(() => isEmbeddedInWechatMiniProgram())
|
const shouldUseHostNavigation = computed(() => isEmbeddedInWechatMiniProgram())
|
||||||
const showInternalHeader = computed(() => !shouldUseHostNavigation.value)
|
const showInternalHeader = computed(() => !shouldUseHostNavigation.value)
|
||||||
const headerTitle = computed(() => {
|
const headerTitle = computed(() => {
|
||||||
if (props.stage === 'stop') return props.selectedHallName || '讲解对象'
|
if (props.stage === 'stop') return props.selectedHallName || '讲解对象'
|
||||||
return '讲解'
|
return '展厅讲解'
|
||||||
})
|
})
|
||||||
const loadingTitle = computed(() => {
|
const loadingTitle = computed(() => {
|
||||||
if (props.stage === 'stop') return '正在加载讲解对象'
|
if (props.stage === 'stop') return '正在加载讲解对象'
|
||||||
@@ -271,6 +279,12 @@ const hallMetaText = (hall: ExplainHallSelectItem) => (
|
|||||||
: hall.explainCount > 0 ? `${hall.explainCount} 个展项` : '展厅'
|
: hall.explainCount > 0 ? `${hall.explainCount} 个展项` : '展厅'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const hallFloorMetaText = (hall: ExplainHallSelectItem) => `${hall.floorLabel || '楼层待补充'} · ${hallMetaText(hall)}`
|
||||||
|
|
||||||
|
const hallCardTheme = (hall: ExplainHallSelectItem) => (
|
||||||
|
hallCardThemeMap[hall.name.trim()] || { color: '#dce5d5', englishName: '' }
|
||||||
|
)
|
||||||
|
|
||||||
const handleHallClick = (hallId: string) => {
|
const handleHallClick = (hallId: string) => {
|
||||||
emit('hallClick', hallId)
|
emit('hallClick', hallId)
|
||||||
}
|
}
|
||||||
@@ -318,8 +332,7 @@ const handleBack = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.explain-scroll.hall-stage {
|
.explain-scroll.hall-stage {
|
||||||
padding: 58px 12px 0;
|
padding: 76px 16px 16px;
|
||||||
overflow: hidden;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.explain-scroll.stop-stage {
|
.explain-scroll.stop-stage {
|
||||||
@@ -346,34 +359,33 @@ const handleBack = () => {
|
|||||||
top: 0;
|
top: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
height: 56px;
|
height: 64px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: 0 20px;
|
padding: 0 20px;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
background: #ffffff;
|
background: #ffffff;
|
||||||
border-bottom: 1px solid #eceee8;
|
|
||||||
z-index: 2001;
|
z-index: 2001;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-back {
|
.header-back {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 12px;
|
left: 12px;
|
||||||
top: 0;
|
top: 10px;
|
||||||
height: 56px;
|
height: 44px;
|
||||||
width: 82px;
|
width: 64px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
gap: 6px;
|
gap: 4px;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-back-icon {
|
.header-back-icon {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 18px;
|
width: 16px;
|
||||||
height: 18px;
|
height: 16px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -384,10 +396,10 @@ const handleBack = () => {
|
|||||||
|
|
||||||
.header-back-icon::before {
|
.header-back-icon::before {
|
||||||
content: '';
|
content: '';
|
||||||
width: 9px;
|
width: 8px;
|
||||||
height: 9px;
|
height: 8px;
|
||||||
border-left: 2px solid currentColor;
|
border-left: 1.5px solid currentColor;
|
||||||
border-bottom: 2px solid currentColor;
|
border-bottom: 1.5px solid currentColor;
|
||||||
transform: translateX(2px) rotate(45deg);
|
transform: translateX(2px) rotate(45deg);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -395,19 +407,19 @@ const handleBack = () => {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
height: 20px;
|
height: 20px;
|
||||||
font-size: 15px;
|
font-size: 14px;
|
||||||
line-height: 20px;
|
line-height: 20px;
|
||||||
font-weight: 500;
|
font-weight: 400;
|
||||||
color: #151713;
|
color: #151713;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-title {
|
.header-title {
|
||||||
max-width: calc(100% - 188px);
|
max-width: calc(100% - 152px);
|
||||||
padding: 0 8px;
|
padding: 0 8px;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
line-height: 25px;
|
line-height: 25px;
|
||||||
font-weight: 800;
|
font-weight: 700;
|
||||||
color: #151713;
|
color: #151713;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -422,8 +434,10 @@ const handleBack = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.hall-overview-list {
|
.hall-overview-list {
|
||||||
height: 100%;
|
display: grid;
|
||||||
gap: 0;
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
grid-auto-rows: 154px;
|
||||||
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.explain-scroll.unit-stage .hall-list,
|
.explain-scroll.unit-stage .hall-list,
|
||||||
@@ -445,10 +459,15 @@ const handleBack = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.hall-overview-card {
|
.hall-overview-card {
|
||||||
min-height: 0;
|
position: relative;
|
||||||
flex: 1 1 0;
|
min-height: 154px;
|
||||||
gap: 7px;
|
display: block;
|
||||||
padding: 4px 10px;
|
overflow: hidden;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: none;
|
||||||
|
background: var(--hall-card-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.hall-card:active {
|
.hall-card:active {
|
||||||
@@ -470,9 +489,44 @@ const handleBack = () => {
|
|||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hall-overview-card .hall-thumb {
|
.hall-overview-card:active {
|
||||||
width: 82px;
|
opacity: 0.86;
|
||||||
height: 56px;
|
background: var(--hall-card-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hall-overview-card .hall-card-art {
|
||||||
|
position: absolute;
|
||||||
|
top: 46px;
|
||||||
|
right: 14px;
|
||||||
|
width: 62px;
|
||||||
|
height: 62px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hall-card-art-image {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.22s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hall-overview-card .hall-card-art.is-image-loaded {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hall-overview-card .hall-card-art.is-image-loaded .hall-card-art-image {
|
||||||
|
opacity: 0.96;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hall-card-art.placeholder {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: rgba(255, 255, 255, 0.24);
|
||||||
|
border-radius: 50%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hall-thumb-shell.is-image-loaded {
|
.hall-thumb-shell.is-image-loaded {
|
||||||
@@ -532,6 +586,14 @@ const handleBack = () => {
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.hall-overview-copy {
|
||||||
|
position: absolute;
|
||||||
|
top: 14px;
|
||||||
|
left: 14px;
|
||||||
|
right: 14px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.stop-desc {
|
.stop-desc {
|
||||||
display: block;
|
display: block;
|
||||||
margin-top: 5px;
|
margin-top: 5px;
|
||||||
@@ -555,12 +617,23 @@ const handleBack = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.hall-overview-card .hall-name {
|
.hall-overview-card .hall-name {
|
||||||
font-size: 15px;
|
display: block;
|
||||||
line-height: 20px;
|
max-width: 94px;
|
||||||
|
font-size: 19px;
|
||||||
|
line-height: 25px;
|
||||||
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hall-overview-card .hall-meta-row {
|
.hall-overview-english {
|
||||||
margin-top: 3px;
|
display: block;
|
||||||
|
max-width: 96px;
|
||||||
|
margin-top: 1px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 17px;
|
||||||
|
color: #444754;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stop-title-row {
|
.stop-title-row {
|
||||||
@@ -645,14 +718,37 @@ const handleBack = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.hall-overview-meta {
|
.hall-overview-meta {
|
||||||
min-width: 0;
|
display: block;
|
||||||
font-size: 11px;
|
max-width: 92px;
|
||||||
line-height: 15px;
|
margin-top: 5px;
|
||||||
|
font-size: 9px;
|
||||||
|
line-height: 13px;
|
||||||
|
color: #444754;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.hall-go-entry {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 98px;
|
||||||
|
width: 50px;
|
||||||
|
height: 25px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
box-sizing: border-box;
|
||||||
|
background: #ffffff;
|
||||||
|
border-radius: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hall-go-entry-text {
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 17px;
|
||||||
|
color: #262421;
|
||||||
|
}
|
||||||
|
|
||||||
.hall-arrow {
|
.hall-arrow {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
font-size: 24px;
|
font-size: 24px;
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 4.8 KiB After Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 4.5 KiB After Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 5.2 KiB After Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 3.0 KiB |
@@ -5,6 +5,9 @@ const stops = Array.from({ length: 21 }, (_, index) => ({
|
|||||||
name: `讲解对象${index + 1}`,
|
name: `讲解对象${index + 1}`,
|
||||||
imageStatus: index === 0 ? 'READY' : 'MISSING',
|
imageStatus: index === 0 ? 'READY' : 'MISSING',
|
||||||
coverImageUrl: index === 0 ? '/one.jpg' : undefined,
|
coverImageUrl: index === 0 ? '/one.jpg' : undefined,
|
||||||
|
audioStatus: index === 0 ? 'READY' : 'MISSING',
|
||||||
|
hasAudio: index === 0,
|
||||||
|
hasTextRecord: index !== 0,
|
||||||
playTargetType: 'STOP' as const,
|
playTargetType: 'STOP' as const,
|
||||||
playTargetId: `target-${index + 1}`
|
playTargetId: `target-${index + 1}`
|
||||||
}))
|
}))
|
||||||
@@ -30,6 +33,37 @@ test('hall goes directly to paged guide objects without outline requests', async
|
|||||||
await page.getByText('恐龙厅', { exact: true }).click()
|
await page.getByText('恐龙厅', { exact: true }).click()
|
||||||
await expect(page).toHaveURL(/guide-stop-list\?hallId=hall-1/)
|
await expect(page).toHaveURL(/guide-stop-list\?hallId=hall-1/)
|
||||||
await expect(page.getByText('讲解对象1', { exact: true })).toBeVisible()
|
await expect(page.getByText('讲解对象1', { exact: true })).toBeVisible()
|
||||||
|
const cards = page.locator('.stop-card')
|
||||||
|
await expect(cards).toHaveCount(20)
|
||||||
|
for (const width of [375, 390, 430]) {
|
||||||
|
await page.setViewportSize({ width, height: 844 })
|
||||||
|
const layout = await page.locator('.stop-list').evaluate((list) => {
|
||||||
|
const cards = Array.from(list.querySelectorAll<HTMLElement>('.stop-card'))
|
||||||
|
const listRect = list.getBoundingClientRect()
|
||||||
|
const firstRect = cards[0].getBoundingClientRect()
|
||||||
|
const secondRect = cards[1].getBoundingClientRect()
|
||||||
|
return {
|
||||||
|
clientWidth: list.clientWidth,
|
||||||
|
scrollWidth: list.scrollWidth,
|
||||||
|
listWidth: listRect.width,
|
||||||
|
first: { x: firstRect.x, width: firstRect.width, bottom: firstRect.bottom },
|
||||||
|
second: { x: secondRect.x, y: secondRect.y }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
expect(layout.scrollWidth).toBeLessThanOrEqual(layout.clientWidth)
|
||||||
|
expect(layout.first.x).toBeCloseTo(layout.second.x, 0)
|
||||||
|
expect(layout.first.width).toBeCloseTo(layout.listWidth, 0)
|
||||||
|
expect(layout.second.y).toBeGreaterThan(layout.first.bottom)
|
||||||
|
}
|
||||||
|
const search = page.getByRole('textbox')
|
||||||
|
await search.fill('讲解对象21')
|
||||||
|
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()
|
||||||
|
|||||||
66
tests/unit/ExplainGuideStopCatalog.spec.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
// @vitest-environment happy-dom
|
||||||
|
|
||||||
|
import { mount } from '@vue/test-utils'
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import ExplainGuideStopCatalog from '@/components/explain/ExplainGuideStopCatalog.vue'
|
||||||
|
|
||||||
|
const stops = [
|
||||||
|
{ id: 'ready', name: '一个很长很长很长很长很长的讲解对象名称', coverImageUrl: '/cover.jpg', audioStatus: 'READY', hasAudio: true, hasTextRecord: true },
|
||||||
|
{ id: 'text', name: '图文对象', hasTextRecord: true },
|
||||||
|
{ id: 'none', name: '无内容对象' }
|
||||||
|
]
|
||||||
|
|
||||||
|
const mountCatalog = () => mount(ExplainGuideStopCatalog, { props: { guideStops: stops, selectedHallName: '宇宙厅', hasMore: true } })
|
||||||
|
|
||||||
|
describe('ExplainGuideStopCatalog', () => {
|
||||||
|
it('renders a stable single-column list with real availability copy and no arrows', () => {
|
||||||
|
const wrapper = mountCatalog()
|
||||||
|
expect(wrapper.find('.stop-list').exists()).toBe(true)
|
||||||
|
expect(wrapper.find('.stop-grid').exists()).toBe(false)
|
||||||
|
expect(wrapper.findAll('.stop-card')).toHaveLength(3)
|
||||||
|
expect(wrapper.findAll('.stop-cover.placeholder')).toHaveLength(2)
|
||||||
|
expect(wrapper.text()).toContain('音频可播放')
|
||||||
|
expect(wrapper.text()).toContain('图文可查看')
|
||||||
|
expect(wrapper.text()).toContain('暂无可用内容')
|
||||||
|
expect(wrapper.find('.hall-arrow').exists()).toBe(false)
|
||||||
|
expect(wrapper.find('.stop-name').classes()).toContain('stop-name')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('searches and uses only data-driven fallback filters when guide levels are absent', async () => {
|
||||||
|
const wrapper = mountCatalog()
|
||||||
|
expect(wrapper.text()).not.toContain('核心展品')
|
||||||
|
expect(wrapper.get('input').attributes('placeholder')).toBe('搜索讲解对象')
|
||||||
|
await wrapper.get('input').setValue('图文')
|
||||||
|
expect(wrapper.findAll('.stop-card')).toHaveLength(1)
|
||||||
|
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 () => {
|
||||||
|
const wrapper = mount(ExplainGuideStopCatalog, { props: { loading: true } })
|
||||||
|
expect(wrapper.text()).toContain('正在加载讲解对象')
|
||||||
|
|
||||||
|
await wrapper.setProps({ loading: false, guideStops: [] })
|
||||||
|
expect(wrapper.text()).toContain('该展厅暂无讲解对象')
|
||||||
|
expect(wrapper.find('.stop-list').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('emits card, back, retry, retry-more, and request-all actions', async () => {
|
||||||
|
const wrapper = mountCatalog()
|
||||||
|
await wrapper.get('.stop-card').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('back')).toHaveLength(1)
|
||||||
|
expect(wrapper.emitted('requestAll')).toHaveLength(1)
|
||||||
|
|
||||||
|
await wrapper.setProps({ error: '失败' })
|
||||||
|
await wrapper.get('.state-retry').trigger('tap')
|
||||||
|
expect(wrapper.emitted('retry')).toHaveLength(1)
|
||||||
|
await wrapper.setProps({ error: '', loadMoreError: '更多失败' })
|
||||||
|
await wrapper.get('.state-retry').trigger('tap')
|
||||||
|
expect(wrapper.emitted('retryMore')).toHaveLength(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -35,6 +35,29 @@ describe('讲解展厅选择列表', () => {
|
|||||||
expect(wrapper.emitted('hallClick')).toEqual([['hall-evolution']])
|
expect(wrapper.emitted('hallClick')).toEqual([['hall-evolution']])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('展厅阶段按卡片网格展示英文名和进入入口', () => {
|
||||||
|
const wrapper = mount(ExplainHallSelect, {
|
||||||
|
props: {
|
||||||
|
stage: 'hall',
|
||||||
|
halls: [{
|
||||||
|
id: 'hall-dinosaur',
|
||||||
|
name: '恐龙厅',
|
||||||
|
floorLabel: 'L-2',
|
||||||
|
explainCount: 35,
|
||||||
|
guideStopCount: 35,
|
||||||
|
searchText: '恐龙厅'
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const card = wrapper.get('.hall-overview-card')
|
||||||
|
expect(wrapper.get('.header-title').text()).toBe('展厅讲解')
|
||||||
|
expect(card.attributes('style')).toContain('--hall-card-color: #c7a15c')
|
||||||
|
expect(card.text()).toContain('Dinosaur Hall')
|
||||||
|
expect(card.text()).toContain('L-2 · 35 个讲解对象')
|
||||||
|
expect(card.get('.hall-go-entry').text()).toBe('GO ›')
|
||||||
|
})
|
||||||
|
|
||||||
it('READY 讲解点不显示可讲解标签,非 READY 讲解点保留图文标签和点击事件', async () => {
|
it('READY 讲解点不显示可讲解标签,非 READY 讲解点保留图文标签和点击事件', async () => {
|
||||||
const readyStop = {
|
const readyStop = {
|
||||||
id: 'stop-ready',
|
id: 'stop-ready',
|
||||||
|
|||||||