同步 sgs-frontend-mobile 源码
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
lyf
2026-07-27 11:26:01 +08:00
parent 575dca430f
commit 9ea1bfab71
181 changed files with 24395 additions and 5212 deletions

View File

@@ -9,13 +9,26 @@
</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>
<GuideLoadingState v-if="loading" title="正在加载讲解对象" description="请稍候" />
<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="guideStops.length" class="stop-list">
<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" />
<view v-else class="stop-cover placeholder" />
<image
v-if="shouldLoadCover(stop)"
class="stop-cover"
:src="stop.coverImageUrl"
mode="aspectFit"
@error="handleImageError(stop.id)"
/>
<view
v-else-if="stop.coverImageUrl && !failedImageIds.has(stop.id)"
:ref="(element) => registerCoverSlot(stop.id, element)"
class="stop-cover loading"
/>
<view v-else class="stop-cover placeholder">
<text class="stop-cover-fallback">{{ stop.name.slice(0, 1) }}</text>
</view>
<view class="stop-copy">
<text class="stop-name">{{ stop.name }}</text>
</view>
@@ -34,8 +47,9 @@
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { isEmbeddedInWechatMiniProgram } from '@/utils/hostEnvironment'
import GuideLoadingState from '@/components/navigation/GuideLoadingState.vue'
export interface ExplainGuideStopCatalogItem {
id: string
@@ -67,9 +81,70 @@ const props = withDefaults(defineProps<{
const emit = defineEmits<{ guideStopClick: [stop: ExplainGuideStopCatalogItem], back: [], retry: [], retryMore: [] }>()
const shouldUseHostNavigation = computed(() => isEmbeddedInWechatMiniProgram())
const showInternalHeader = computed(() => !shouldUseHostNavigation.value)
const failedImageIds = ref(new Set<string>())
const visibleCoverIds = ref(new Set<string>())
const coverSlots = new Map<string, Element>()
const supportsIntersectionObserver = typeof IntersectionObserver !== 'undefined'
let coverObserver: IntersectionObserver | null = null
const shouldLoadCover = (stop: ExplainGuideStopCatalogItem) => Boolean(
stop.coverImageUrl
&& !failedImageIds.value.has(stop.id)
&& (!supportsIntersectionObserver || visibleCoverIds.value.has(stop.id))
)
const resolveDomElement = (element: unknown): Element | null => {
if (element instanceof Element) return element
if (element && typeof element === 'object' && '$el' in element) {
const componentElement = (element as { $el?: unknown }).$el
if (componentElement instanceof Element) return componentElement
}
return null
}
const registerCoverSlot = (stopId: string, instance: unknown) => {
const element = resolveDomElement(instance)
if (!element) {
coverSlots.delete(stopId)
return
}
coverSlots.set(stopId, element)
coverObserver?.observe(element)
}
onMounted(() => {
if (!supportsIntersectionObserver) return
coverObserver = new IntersectionObserver((entries) => {
const nextVisibleIds = new Set(visibleCoverIds.value)
let changed = false
entries.forEach((entry) => {
if (!entry.isIntersecting) return
const stopId = [...coverSlots.entries()].find(([, element]) => element === entry.target)?.[0]
if (!stopId || nextVisibleIds.has(stopId)) return
nextVisibleIds.add(stopId)
coverObserver?.unobserve(entry.target)
changed = true
})
if (changed) visibleCoverIds.value = nextVisibleIds
}, { rootMargin: '160px 0px' })
coverSlots.forEach((element) => coverObserver?.observe(element))
})
onBeforeUnmount(() => {
coverObserver?.disconnect()
coverObserver = null
coverSlots.clear()
})
const handleImageError = (stopId: string) => {
failedImageIds.value = new Set([...failedImageIds.value, stopId])
}
const availabilityLabel = (stop: ExplainGuideStopCatalogItem) => (
stop.audioStatus === 'READY' || stop.hasAudio ? '音频' : stop.hasTextRecord ? '图文' : '暂无内容'
stop.audioStatus === 'READY' || stop.hasAudio ? '讲解' : stop.hasTextRecord ? '图文' : '暂无内容'
)
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() }
@@ -81,6 +156,6 @@ const handleScroll = (event: Event) => { const target = event.target as HTMLElem
.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-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; }
.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; }
.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; }
.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-cover-fallback { font-size: 22px; line-height: 28px; color: #a9b29b; }.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; }
.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>

View File

@@ -12,6 +12,7 @@
class="explain-scroll"
:class="{
'hall-stage': stage === 'hall',
'unit-stage': stage === 'unit',
'stop-stage': stage === 'stop'
}"
scroll-y
@@ -19,10 +20,11 @@
@scrolltolower="handleScrollToLower"
:show-scrollbar="false"
>
<view v-if="loading" class="state-block">
<text class="state-title">{{ loadingTitle }}</text>
<text class="state-desc">{{ loadingDescription }}</text>
</view>
<GuideLoadingState
v-if="loading"
:title="loadingTitle"
:description="loadingDescription"
/>
<view v-else-if="error" class="state-block error">
<text class="state-title">讲解对象加载失败</text>
@@ -39,35 +41,57 @@
:style="{ '--hall-card-color': hallCardTheme(hall).color }"
@tap="handleHallClick(hall.id)"
>
<view
v-if="hallPreviewUrl(hall)"
class="hall-card-art hall-thumb-shell"
:class="{
'is-image-loaded': isHallPreviewLoaded(hall.id),
'is-image-error': isHallPreviewError(hall.id)
}"
>
<view class="hall-thumb-fallback">
<text class="hall-thumb-text">{{ hallIconText(hall.name) }}</text>
<view
v-if="isCustomHallCard(hall)"
class="hall-full-card-art"
:class="{
'is-image-loaded': isHallPreviewLoaded(hall.id),
'is-image-error': isHallPreviewError(hall.id)
}"
>
<image
v-if="!isHallPreviewError(hall.id)"
class="hall-full-card-image"
:src="hallPreviewUrl(hall)"
mode="aspectFill"
:lazy-load="isHallPreviewLazy(index)"
@load="handleHallPreviewLoad(hall.id)"
@error="handleHallPreviewError(hall.id)"
/>
<view v-else class="hall-full-card-fallback">
<text class="hall-name">{{ hall.name }}</text>
</view>
</view>
<image
v-if="!isHallPreviewError(hall.id)"
class="hall-card-art-image"
:src="hallPreviewUrl(hall)"
mode="aspectFit"
:lazy-load="isHallPreviewLazy(index)"
@load="handleHallPreviewLoad(hall.id)"
@error="handleHallPreviewError(hall.id)"
/>
<template v-else>
<view
v-if="hallPreviewUrl(hall)"
class="hall-card-art hall-thumb-shell"
:class="{
'is-image-loaded': isHallPreviewLoaded(hall.id),
'is-image-error': isHallPreviewError(hall.id)
}"
>
<view class="hall-thumb-fallback">
<text class="hall-thumb-text">{{ hallIconText(hall.name) }}</text>
</view>
<image
v-if="!isHallPreviewError(hall.id)"
class="hall-card-art-image"
:src="hallPreviewUrl(hall)"
mode="aspectFit"
:lazy-load="isHallPreviewLazy(index)"
@load="handleHallPreviewLoad(hall.id)"
@error="handleHallPreviewError(hall.id)"
/>
</view>
<view v-else class="hall-card-art placeholder">
<text class="hall-thumb-text">{{ hallIconText(hall.name) }}</text>
</view>
<view class="hall-overview-copy">
<text class="hall-name">{{ hall.name }}</text>
</view>
</template>
</view>
<view v-else class="hall-card-art placeholder">
<text class="hall-thumb-text">{{ hallIconText(hall.name) }}</text>
</view>
<view class="hall-overview-copy">
<text class="hall-name">{{ hall.name }}</text>
</view>
</view>
</view>
<view v-else-if="stage === 'stop' && guideStops.length" class="hall-list">
@@ -106,6 +130,34 @@
</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 business-unit-card"
@tap="handleBusinessUnitClick(unit.id)"
>
<image
v-if="unit.previewImageUrl"
class="hall-thumb"
:src="unit.previewImageUrl"
mode="aspectFit"
/>
<view v-else class="hall-thumb placeholder">
<text class="hall-thumb-text">{{ hallIconText(unit.name) }}</text>
</view>
<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 class="state-block empty">
<text class="state-title">{{ emptyTitle }}</text>
<text class="state-desc">{{ emptyDescription }}</text>
@@ -126,6 +178,7 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { isEmbeddedInWechatMiniProgram } from '@/utils/hostEnvironment'
import GuideLoadingState from '@/components/navigation/GuideLoadingState.vue'
export interface ExplainHallSelectItem {
id: string
@@ -155,10 +208,19 @@ export interface ExplainGuideStopSelectItem {
playTargetId?: string
}
export type ExplainSelectStage = 'hall' | 'stop'
export interface ExplainBusinessUnitSelectItem {
id: string
name: string
hallId?: string
previewImageUrl?: string
guideStopCount: number
}
export type ExplainSelectStage = 'hall' | 'unit' | 'stop'
const props = withDefaults(defineProps<{
halls?: ExplainHallSelectItem[]
businessUnits?: ExplainBusinessUnitSelectItem[]
guideStops?: ExplainGuideStopSelectItem[]
stage?: ExplainSelectStage
selectedHallName?: string
@@ -170,6 +232,7 @@ const props = withDefaults(defineProps<{
forceInternalHeader?: boolean
}>(), {
halls: () => [],
businessUnits: () => [],
guideStops: () => [],
stage: 'hall',
selectedHallName: '',
@@ -183,27 +246,28 @@ const props = withDefaults(defineProps<{
const emit = defineEmits<{
hallClick: [hallId: string]
businessUnitClick: [unitId: string]
guideStopClick: [stop: ExplainGuideStopSelectItem]
back: []
retry: []
retryMore: []
}>()
const HALL_PREVIEW_BASE = '/static/icons/halls'
const HALL_PREVIEW_BASE = '/static/icons/halls/custom-cards'
const HALL_PREVIEW_EAGER_COUNT = 4
const loadedHallPreviewIds = ref(new Set<string>())
const failedHallPreviewIds = ref(new Set<string>())
const hallPreviewMap: Record<string, string> = {
宇宙厅: `${HALL_PREVIEW_BASE}/normalized/universe.png`,
地球厅: `${HALL_PREVIEW_BASE}/normalized/earth.png`,
演化厅: `${HALL_PREVIEW_BASE}/normalized/evolution.png`,
恐龙厅: `${HALL_PREVIEW_BASE}/normalized/dinosaur.png`,
人类厅: `${HALL_PREVIEW_BASE}/normalized/human.png`,
动物厅: `${HALL_PREVIEW_BASE}/normalized/biology.png`,
生物厅: `${HALL_PREVIEW_BASE}/normalized/biology.png`,
生态厅: `${HALL_PREVIEW_BASE}/normalized/ecology.png`,
家园厅: `${HALL_PREVIEW_BASE}/normalized/homeland.png`
宇宙厅: `${HALL_PREVIEW_BASE}/universe.png`,
地球厅: `${HALL_PREVIEW_BASE}/earth.png`,
演化厅: `${HALL_PREVIEW_BASE}/evolution.png`,
恐龙厅: `${HALL_PREVIEW_BASE}/dinosaur.png`,
人类厅: `${HALL_PREVIEW_BASE}/human.png`,
动物厅: `${HALL_PREVIEW_BASE}/biology.png`,
生物厅: `${HALL_PREVIEW_BASE}/biology.png`,
生态厅: `${HALL_PREVIEW_BASE}/ecology.png`,
家园厅: `${HALL_PREVIEW_BASE}/homeland.png`
}
const hallCardThemeMap: Record<string, { color: string; englishName: string }> = {
@@ -223,29 +287,36 @@ const shouldUseHostNavigation = computed(() => isEmbeddedInWechatMiniProgram())
const showInternalHeader = computed(() => props.forceInternalHeader || !shouldUseHostNavigation.value)
const headerTitle = computed(() => {
if (props.stage === 'stop') return props.selectedHallName || '讲解对象'
if (props.stage === 'unit') return props.selectedHallName || '讲解单元'
return '免费讲解'
})
const loadingTitle = computed(() => {
if (props.stage === 'stop') return '正在加载讲解对象'
return '正在加载展厅讲解'
if (props.stage === 'unit') return '正在加载讲解单元'
return '正在加载免费讲解'
})
const loadingDescription = computed(() => {
if (props.stage === 'stop') return '稍后将展示该展厅的讲解对象。'
if (props.stage === 'unit') return '稍后将展示该展厅的讲解单元。'
return '稍后将展示可选择的展厅。'
})
const emptyTitle = computed(() => {
if (props.stage === 'stop') return '该展厅暂无讲解对象'
if (props.stage === 'unit') return '该展厅暂无讲解单元'
return '未找到相关展厅'
})
const emptyDescription = computed(() => {
if (props.stage === 'stop') return '可返回选择其他展厅。'
return '暂无可选择的展厅讲解。'
if (props.stage === 'unit') return '可返回选择其他展厅。'
return '暂无可选择的免费讲解。'
})
const hallPreviewUrl = (hall: ExplainHallSelectItem) => {
return hallPreviewMap[hall.name.trim()] || hall.image || ''
}
const isCustomHallCard = (hall: ExplainHallSelectItem) => Boolean(hallPreviewMap[hall.name.trim()])
const hallIconText = (name: string) => name.trim().slice(0, 1) || '讲'
const isHallPreviewLoaded = (hallId: string) => loadedHallPreviewIds.value.has(hallId)
@@ -280,6 +351,10 @@ 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) {
@@ -831,11 +906,13 @@ const handleBack = () => {
}
.hall-overview-list {
grid-auto-rows: 138px;
// Keep the eight-hall overview balanced on tall phones while preserving a
// fixed minimum row height for compact screens.
grid-auto-rows: max(138px, calc((100vh - 148px) / 4));
}
.hall-overview-card {
min-height: 138px;
min-height: 0;
}
.hall-overview-card .hall-card-art {
@@ -850,6 +927,28 @@ const handleBack = () => {
filter: grayscale(1) brightness(0.72) contrast(1.65);
}
.hall-full-card-art {
position: absolute;
inset: 0;
overflow: hidden;
background: var(--hall-card-color);
}
.hall-full-card-image {
display: block;
width: 100%;
height: 100%;
}
.hall-full-card-fallback {
position: absolute;
inset: 0;
display: flex;
align-items: center;
padding: 24px;
box-sizing: border-box;
}
.hall-overview-copy {
top: 24px;
}

View File

@@ -49,10 +49,11 @@
</view>
</view>
<view v-if="loading" class="state-block">
<text class="state-title">正在加载讲解内容</text>
<text class="state-desc">稍后将展示当前楼层的场景空间与展品讲解</text>
</view>
<GuideLoadingState
v-if="loading"
title="正在加载讲解内容"
description="请稍候"
/>
<view v-else-if="error" class="state-block error">
<text class="state-title">讲解内容加载失败</text>
@@ -232,6 +233,7 @@ import { computed, ref } from 'vue'
import type {
ExplainCardViewModel
} from '@/view-models/explainViewModels'
import GuideLoadingState from '@/components/navigation/GuideLoadingState.vue'
export interface ExplainFilterOption {
id: 'all' | 'hall' | 'theme' | 'nearby' | 'playable'