This commit is contained in:
120
src/components/explain/ExplainGuideStopCatalog.vue
Normal file
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>
|
||||
Reference in New Issue
Block a user