Files
frontend-miniapp/src/components/explain/ExplainHallSelect.vue
lyf aefab4c188
Some checks failed
CI / verify (push) Has been cancelled
移除讲解点列表编号信息
2026-07-09 23:30:45 +08:00

730 lines
17 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<view class="explain-hall-select">
<view class="explain-page-header">
<view class="header-back" @tap="handleBack">
<text class="header-back-icon"></text>
<text class="header-back-text">返回</text>
</view>
<text class="header-title">{{ headerTitle }}</text>
</view>
<scroll-view
class="explain-scroll"
:class="{
'hall-stage': stage === 'hall',
'unit-stage': stage === 'unit',
'stop-stage': stage === 'stop'
}"
scroll-y
:show-scrollbar="false"
>
<view v-if="loading" class="state-block">
<text class="state-title">{{ loadingTitle }}</text>
<text class="state-desc">{{ loadingDescription }}</text>
</view>
<view v-else-if="error" class="state-block error">
<text class="state-title">讲解内容加载失败</text>
<text class="state-desc">{{ error }}</text>
</view>
<template v-else>
<view v-if="stage === 'hall' && filteredHalls.length" class="hall-list hall-overview-list">
<view
v-for="(hall, index) in filteredHalls"
:key="hall.id"
class="hall-card hall-overview-card"
@tap="handleHallClick(hall.id)"
>
<view
v-if="hallPreviewUrl(hall)"
class="hall-thumb 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-thumb-image"
:src="hallPreviewUrl(hall)"
mode="aspectFill"
:lazy-load="isHallPreviewLazy(index)"
@load="handleHallPreviewLoad(hall.id)"
@error="handleHallPreviewError(hall.id)"
/>
</view>
<view v-else class="hall-thumb placeholder">
<text class="hall-thumb-text">{{ hallIconText(hall.name) }}</text>
</view>
<view class="hall-main">
<text class="hall-name">{{ hall.name }}</text>
<view class="hall-meta-row">
<view class="floor-badge">
<text class="floor-badge-text">{{ hall.floorLabel || '楼层待补充' }}</text>
</view>
<text class="hall-meta-text hall-overview-meta">{{ hallMetaText(hall) }}</text>
</view>
</view>
<text class="hall-arrow"></text>
</view>
</view>
<view v-else-if="stage === 'unit' && businessUnits.length" class="hall-list">
<view
v-for="unit in businessUnits"
:key="unit.id"
class="hall-card unit-card"
@tap="handleBusinessUnitClick(unit.id)"
>
<image
class="unit-thumb"
:src="unitPreviewImageUrl(unit)"
mode="aspectFill"
@error="handleUnitPreviewError(unit.id)"
/>
<view class="hall-main">
<text class="hall-name">{{ unit.name }}</text>
<view class="hall-meta-row">
<view class="floor-badge">
<text class="floor-badge-text">业务单元</text>
</view>
<text class="hall-meta-text">{{ unit.guideStopCount }} 个讲解点</text>
</view>
</view>
<text class="hall-arrow"></text>
</view>
</view>
<view v-else-if="stage === 'stop' && guideStops.length" class="hall-list">
<view
v-for="stop in guideStops"
:key="stop.id"
class="hall-card stop-card"
@tap="handleGuideStopClick(stop.id)"
>
<image
v-if="stop.coverImageUrl"
class="hall-thumb"
:src="stop.coverImageUrl"
mode="aspectFill"
/>
<view v-else class="hall-thumb placeholder">
<text class="hall-thumb-text">{{ hallIconText(stop.name) }}</text>
</view>
<view class="hall-main">
<view class="stop-title-row">
<text class="hall-name">{{ stop.name }}</text>
<view v-if="stop.guideLevel" class="guide-level-badge">
<text class="guide-level-text">{{ stop.guideLevel }}</text>
</view>
</view>
<text v-if="stop.description" class="stop-desc">{{ stop.description }}</text>
<view class="hall-meta-row">
<view class="floor-badge">
<text class="floor-badge-text">{{ stop.audioStatus === 'READY' ? '可讲解' : '图文' }}</text>
</view>
</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>
</view>
</template>
</scroll-view>
</view>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import {
EXPLAIN_UNIT_PREVIEW_FALLBACK
} from '@/utils/explainUnitPreviews'
export interface ExplainHallSelectItem {
id: string
name: string
floorLabel?: string
image?: string
explainCount: number
businessUnitCount?: number
guideStopCount?: number
searchText: string
}
export interface ExplainBusinessUnitSelectItem {
id: string
name: string
hallId: string
previewImageUrl?: string
guideStopCount: number
}
export interface ExplainGuideStopSelectItem {
id: string
name: string
hallId?: string
hallName?: string
floorId?: string
poiId?: string
mapX?: number
mapY?: number
coverImageUrl?: string
description?: string
hasAudio?: boolean
audioStatus?: string
guideLevel?: string
playTargetType?: 'ITEM' | 'STOP'
playTargetId?: string
}
export type ExplainSelectStage = 'hall' | 'unit' | 'stop'
const props = withDefaults(defineProps<{
halls?: ExplainHallSelectItem[]
businessUnits?: ExplainBusinessUnitSelectItem[]
guideStops?: ExplainGuideStopSelectItem[]
stage?: ExplainSelectStage
selectedHallName?: string
selectedBusinessUnitName?: string
loading?: boolean
error?: string
}>(), {
halls: () => [],
businessUnits: () => [],
guideStops: () => [],
stage: 'hall',
selectedHallName: '',
selectedBusinessUnitName: '',
loading: false,
error: ''
})
const emit = defineEmits<{
hallClick: [hallId: string]
businessUnitClick: [unitId: string]
guideStopClick: [stop: ExplainGuideStopSelectItem]
back: []
}>()
const HALL_PREVIEW_BASE = '/static/explain/hall-previews'
const HALL_PREVIEW_EAGER_COUNT = 4
const loadedHallPreviewIds = ref(new Set<string>())
const failedHallPreviewIds = ref(new Set<string>())
const failedUnitPreviewIds = ref(new Set<string>())
const hallPreviewMap: Record<string, string> = {
宇宙厅: `${HALL_PREVIEW_BASE}/universe.webp`,
地球厅: `${HALL_PREVIEW_BASE}/earth.webp`,
演化厅: `${HALL_PREVIEW_BASE}/evolution.webp`,
恐龙厅: `${HALL_PREVIEW_BASE}/dinosaur.webp`,
人类厅: `${HALL_PREVIEW_BASE}/human.webp`,
动物厅: `${HALL_PREVIEW_BASE}/biology.webp`,
生物厅: `${HALL_PREVIEW_BASE}/biology.webp`,
生态厅: `${HALL_PREVIEW_BASE}/ecology.webp`,
家园厅: `${HALL_PREVIEW_BASE}/homeland.webp`
}
const filteredHalls = computed(() => props.halls)
const headerTitle = computed(() => {
if (props.stage === 'unit') return props.selectedHallName || '选择业务单元'
if (props.stage === 'stop') return props.selectedBusinessUnitName || '选择讲解点'
return '讲解'
})
const loadingTitle = computed(() => {
if (props.stage === 'unit') return '正在加载业务单元'
if (props.stage === 'stop') return '正在加载讲解点'
return '正在加载展厅讲解'
})
const loadingDescription = computed(() => {
if (props.stage === 'unit') return '正在按讲解点所属 outline 分组生成临时业务单元。'
if (props.stage === 'stop') return '稍后将展示该业务单元下的讲解点。'
return '稍后将展示可选择的展厅。'
})
const emptyTitle = computed(() => {
if (props.stage === 'unit') return '该展厅暂无已标定讲解点'
if (props.stage === 'stop') return '该业务单元暂无讲解点'
return '未找到相关展厅'
})
const emptyDescription = computed(() => {
if (props.stage === 'unit') return '方案 B 只展示已在地图上标定的讲解点。'
if (props.stage === 'stop') return '可返回选择其他业务单元。'
return '暂无可选择的展厅讲解。'
})
const hallPreviewUrl = (hall: ExplainHallSelectItem) => {
return hallPreviewMap[hall.name.trim()] || hall.image || ''
}
const hallIconText = (name: string) => name.trim().slice(0, 1) || '讲'
const isHallPreviewLoaded = (hallId: string) => loadedHallPreviewIds.value.has(hallId)
const isHallPreviewError = (hallId: string) => failedHallPreviewIds.value.has(hallId)
const isHallPreviewLazy = (index: number) => index >= HALL_PREVIEW_EAGER_COUNT
const handleHallPreviewLoad = (hallId: string) => {
loadedHallPreviewIds.value = new Set([...loadedHallPreviewIds.value, hallId])
if (failedHallPreviewIds.value.has(hallId)) {
const nextFailedIds = new Set(failedHallPreviewIds.value)
nextFailedIds.delete(hallId)
failedHallPreviewIds.value = nextFailedIds
}
}
const handleHallPreviewError = (hallId: string) => {
failedHallPreviewIds.value = new Set([...failedHallPreviewIds.value, hallId])
if (loadedHallPreviewIds.value.has(hallId)) {
const nextLoadedIds = new Set(loadedHallPreviewIds.value)
nextLoadedIds.delete(hallId)
loadedHallPreviewIds.value = nextLoadedIds
}
}
const unitPreviewImageUrl = (unit: ExplainBusinessUnitSelectItem) => (
failedUnitPreviewIds.value.has(unit.id)
? EXPLAIN_UNIT_PREVIEW_FALLBACK
: unit.previewImageUrl || EXPLAIN_UNIT_PREVIEW_FALLBACK
)
const handleUnitPreviewError = (unitId: string) => {
failedUnitPreviewIds.value = new Set([...failedUnitPreviewIds.value, unitId])
}
const hallMetaText = (hall: ExplainHallSelectItem) => (
typeof hall.businessUnitCount === 'number' || typeof hall.guideStopCount === 'number'
? `${hall.businessUnitCount || 0} 个业务单元 · ${hall.guideStopCount || 0} 个讲解点`
: hall.explainCount > 0 ? `${hall.explainCount} 个展项` : '展厅'
)
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) {
emit('guideStopClick', stop)
}
}
const handleBack = () => {
emit('back')
}
</script>
<style scoped lang="scss">
.explain-hall-select {
position: relative;
width: 100%;
height: 100%;
overflow: hidden;
background: #f9fafb;
}
.explain-scroll {
height: 100%;
padding: 60px 12px calc(82px + env(safe-area-inset-bottom));
box-sizing: border-box;
}
.explain-scroll.hall-stage {
padding: 58px 12px 0;
overflow: hidden;
}
.explain-scroll.unit-stage,
.explain-scroll.stop-stage {
padding-bottom: 0;
}
.state-title,
.state-desc,
.hall-name,
.hall-meta-text {
display: block;
}
.explain-page-header {
position: absolute;
top: 0;
left: 0;
right: 0;
height: 56px;
display: flex;
align-items: center;
justify-content: center;
padding: 0 20px;
box-sizing: border-box;
background: #ffffff;
border-bottom: 1px solid #eceee8;
z-index: 2001;
}
.header-back {
position: absolute;
left: 12px;
top: 0;
height: 56px;
width: 82px;
display: flex;
align-items: center;
justify-content: flex-start;
gap: 6px;
z-index: 1;
}
.header-back-icon {
position: relative;
width: 18px;
height: 18px;
display: flex;
align-items: center;
justify-content: center;
font-size: 0;
line-height: 0;
color: #151713;
}
.header-back-icon::before {
content: '';
width: 9px;
height: 9px;
border-left: 2px solid currentColor;
border-bottom: 2px solid currentColor;
transform: translateX(2px) rotate(45deg);
}
.header-back-text {
display: flex;
align-items: center;
height: 20px;
font-size: 15px;
line-height: 20px;
font-weight: 500;
color: #151713;
}
.header-title {
max-width: calc(100% - 188px);
padding: 0 8px;
box-sizing: border-box;
font-size: 18px;
line-height: 25px;
font-weight: 800;
color: #151713;
text-align: center;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.hall-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.hall-overview-list {
height: 100%;
gap: 0;
}
.explain-scroll.unit-stage .hall-list,
.explain-scroll.stop-stage .hall-list {
gap: 0;
}
.hall-card {
min-height: 96px;
padding: 10px 11px 10px 12px;
display: flex;
align-items: center;
gap: 10px;
box-sizing: border-box;
background: #ffffff;
border: 1px solid #e4e6df;
border-radius: 8px;
box-shadow: 0 8px 18px rgba(36, 49, 42, 0.05);
}
.hall-overview-card {
min-height: 0;
flex: 1 1 0;
gap: 7px;
padding: 4px 10px;
}
.hall-card:active {
background: #f7f8f3;
}
.hall-thumb {
flex-shrink: 0;
width: 56px;
height: 56px;
position: relative;
overflow: hidden;
border-radius: 8px;
background:
radial-gradient(circle at 70% 24%, rgba(224, 225, 0, 0.28), rgba(224, 225, 0, 0) 42%),
linear-gradient(135deg, #f4f6ed 0%, #e4eadc 100%);
border: 1px solid rgba(36, 49, 42, 0.06);
box-sizing: border-box;
object-fit: contain;
}
.hall-overview-card .hall-thumb {
width: 82px;
height: 56px;
}
.hall-thumb-shell.is-image-loaded {
background: #eef0e4;
}
.hall-thumb-fallback {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
opacity: 1;
transition: opacity 0.2s ease;
}
.hall-thumb-shell.is-image-loaded .hall-thumb-fallback {
opacity: 0;
}
.hall-thumb-shell.is-image-error .hall-thumb-fallback {
opacity: 1;
}
.hall-thumb-image {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
opacity: 0;
transition: opacity 0.22s ease;
}
.hall-thumb-shell.is-image-loaded .hall-thumb-image {
opacity: 1;
}
.hall-thumb.placeholder {
display: flex;
align-items: center;
justify-content: center;
}
.hall-thumb-text {
font-size: 20px;
line-height: 28px;
font-weight: 800;
color: #83927a;
}
.unit-thumb {
flex-shrink: 0;
width: 84px;
height: 58px;
border-radius: 8px;
background: #eef0e4;
border: 1px solid rgba(36, 49, 42, 0.06);
box-sizing: border-box;
object-fit: cover;
}
.hall-main {
flex: 1;
min-width: 0;
}
.hall-overview-card .hall-main {
flex: 1;
}
.stop-desc {
display: block;
margin-top: 5px;
font-size: 12px;
line-height: 18px;
color: #68725d;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.hall-name {
min-width: 0;
font-size: 16px;
line-height: 22px;
font-weight: 800;
color: #151713;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.hall-overview-card .hall-name {
font-size: 15px;
line-height: 20px;
}
.hall-overview-card .hall-meta-row {
margin-top: 3px;
}
.stop-title-row {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.guide-level-badge {
flex-shrink: 0;
max-width: 92px;
height: 22px;
padding: 0 7px;
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
background: #151713;
border-radius: 6px;
}
.guide-level-text {
display: block;
max-width: 100%;
font-size: 12px;
line-height: 16px;
font-weight: 700;
color: #e0df00;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.hall-meta-row {
margin-top: 7px;
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
}
.floor-badge {
flex-shrink: 0;
min-width: 28px;
height: 22px;
padding: 0 5px;
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
background: #ffffff;
border: 1px solid #e0df00;
border-radius: 6px;
}
.floor-badge-text {
font-size: 12px;
line-height: 16px;
font-weight: 600;
color: #aaa900;
}
.hall-meta-text {
min-width: 0;
font-size: 13px;
line-height: 18px;
color: #555c51;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.hall-overview-card .floor-badge {
height: 19px;
border-radius: 5px;
}
.hall-overview-card .floor-badge-text {
font-size: 11px;
line-height: 15px;
}
.hall-overview-meta {
min-width: 0;
font-size: 11px;
line-height: 15px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.hall-arrow {
flex-shrink: 0;
font-size: 24px;
line-height: 24px;
color: #151713;
}
.hall-overview-card .hall-arrow {
font-size: 20px;
line-height: 20px;
}
.state-block {
margin: 34px 0;
padding: 22px 16px;
box-sizing: border-box;
text-align: center;
background: #ffffff;
border: 1px solid #e4e6df;
border-radius: 8px;
}
.state-title {
font-size: 15px;
line-height: 21px;
font-weight: 700;
color: #151713;
}
.state-desc {
margin-top: 6px;
font-size: 12px;
line-height: 18px;
color: #68725d;
}
.state-block.error {
border-color: #e5c2c2;
background: #fff7f7;
}
</style>