Files
frontend-miniapp/src/components/explain/ExplainHallSelect.vue
lyf 130f155bdf
Some checks failed
CI / verify (push) Has been cancelled
接入编号讲解后端查询
- 使用真实讲解编号解析接口替换本地演示映射
- 成功后复用现有详情页并自动播放讲解
- 增加查询状态、业务错误提示和相关测试

Made-with: Proma
2026-08-25 18:01:20 +08:00

1182 lines
31 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" :class="{ 'host-navigation': shouldUseHostNavigation && !showInternalHeader }">
<view v-if="showInternalHeader" 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
@scroll="handleScroll"
@scrolltolower="handleScrollToLower"
:show-scrollbar="false"
>
<GuideLoadingState
v-if="loading"
:title="loadingTitle"
:description="loadingDescription"
/>
<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="stage === 'hall'" class="code-entry-section">
<button class="code-entry-button" @tap="openCodeEntry">
<view class="code-entry-icon" aria-hidden="true">
<view class="headphone-band" />
<view class="headphone-ear left" />
<view class="headphone-ear right" />
</view>
<view class="code-entry-copy">
<text class="code-entry-title">编号讲解</text>
<text class="code-entry-desc">输入展品旁的编号直接收听讲解</text>
</view>
</button>
</view>
<view v-if="stage === 'hall'" class="hall-section-heading">
<text class="hall-section-title">按展厅选择</text>
</view>
<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"
:style="{ '--hall-card-color': hallCardTheme(hall).color }"
@tap="handleHallClick(hall.id)"
>
<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="aspectFit"
:lazy-load="isHallPreviewLazy(index)"
@load="handleHallPreviewLoad(hall.id)"
@error="handleHallPreviewError(hall.id)"
/>
<view v-if="isHallPreviewError(hall.id)" class="hall-full-card-fallback">
<text class="hall-thumb-text">{{ hallIconText(hall.name) }}</text>
</view>
<view class="hall-full-card-copy">
<text class="hall-full-card-name">{{ hall.name }}</text>
<text v-if="hallCardTheme(hall).englishName" class="hall-full-card-english">
{{ hallCardTheme(hall).englishName }}
</text>
</view>
</view>
<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>
<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 v-if="stop.audioStatus !== 'READY'" class="hall-meta-row">
<view class="floor-badge">
<text class="floor-badge-text">图文</text>
</view>
</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 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>
</view>
<view v-if="stage === 'stop' && 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 v-if="codeEntryVisible" class="code-entry-overlay" @tap="closeCodeEntry">
<view class="code-entry-sheet" @tap.stop>
<view class="code-entry-content">
<text class="code-entry-sheet-title">输入讲解编号</text>
<text class="code-entry-sheet-desc">请输入展品旁标注的 4 位编号</text>
<view class="code-digit-fields" :class="{ error: codeEntryError }">
<view
v-for="index in 4"
:key="index"
class="code-digit-cell"
:class="{ active: codeEntryValue.length === index - 1 }"
>
<text class="code-digit-value">{{ codeEntryValue[index - 1] || '' }}</text>
</view>
<input
v-if="codeEntryVisible"
class="code-entry-native-input"
:value="codeEntryValue"
type="number"
inputmode="numeric"
confirm-type="go"
maxlength="4"
:focus="codeEntryVisible && !codeSubmitting"
:disabled="codeSubmitting"
@input="handleCodeEntryInput"
@confirm="submitCodeEntry"
/>
</view>
<text v-if="codeEntryError" class="code-entry-error">{{ codeEntryError }}</text>
<text v-else class="code-entry-hint">请输入展品标签上的四位编号</text>
<button
class="code-entry-action primary"
:disabled="codeEntryValue.length !== 4 || codeSubmitting"
@tap="submitCodeEntry"
>{{ codeSubmitting ? '正在查询' : '开始讲解' }}</button>
</view>
</view>
</view>
</view>
</template>
<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
name: string
floorLabel?: string
image?: string
explainCount: number
guideStopCount?: number
searchText: string
}
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
}
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
loading?: boolean
loadingMore?: boolean
hasMore?: boolean
error?: string
loadMoreError?: string
forceInternalHeader?: boolean
codeSubmitting?: boolean
}>(), {
halls: () => [],
businessUnits: () => [],
guideStops: () => [],
stage: 'hall',
selectedHallName: '',
loading: false,
loadingMore: false,
hasMore: false,
error: '',
loadMoreError: '',
forceInternalHeader: false,
codeSubmitting: false
})
const emit = defineEmits<{
hallClick: [hallId: string]
codeSubmit: [code: string]
businessUnitClick: [unitId: string]
guideStopClick: [stop: ExplainGuideStopSelectItem]
back: []
retry: []
retryMore: []
}>()
const HALL_PREVIEW_BASE = '/static/icons/halls/portal-icons'
const HALL_PREVIEW_EAGER_COUNT = 4
const loadedHallPreviewIds = ref(new Set<string>())
const failedHallPreviewIds = ref(new Set<string>())
const codeEntryVisible = ref(false)
const codeEntryValue = ref('')
const codeEntryError = ref('')
const hallPreviewMap: Record<string, string> = {
宇宙厅: `${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 }> = {
宇宙厅: { color: '#5ed0e4', englishName: 'Cosmos Hall' },
地球厅: { color: '#94d40b', englishName: 'Earth Hall' },
演化厅: { color: '#bc9afb', englishName: 'Evolution Hall' },
恐龙厅: { color: '#bb965a', englishName: 'Dinosaur Hall' },
人类厅: { color: '#e0e100', englishName: 'Human Hall' },
动物厅: { color: '#00c3b3', englishName: 'Biology Hall' },
生物厅: { color: '#00c3b3', englishName: 'Biology Hall' },
生态厅: { color: '#87e0b0', englishName: 'Ecology Hall' },
家园厅: { color: '#ffbe9e', englishName: 'Homeland Hall' }
}
const filteredHalls = computed(() => props.halls)
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 || '讲解单元'
if (codeEntryVisible.value) return '编号讲解'
return '免费讲解'
})
const loadingTitle = computed(() => {
if (props.stage === 'stop') 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 '可返回选择其他展厅。'
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)
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 hallCardTheme = (hall: ExplainHallSelectItem) => (
hallCardThemeMap[hall.name.trim()] || { color: '#dce5d5', englishName: '' }
)
const handleHallClick = (hallId: string) => {
emit('hallClick', hallId)
}
const openCodeEntry = () => {
codeEntryError.value = ''
codeEntryVisible.value = true
}
const closeCodeEntry = () => {
codeEntryVisible.value = false
codeEntryValue.value = ''
codeEntryError.value = ''
}
const handleCodeEntryInput = (event: Event) => {
const inputEvent = event as Event & { detail?: { value?: string } }
codeEntryValue.value = (inputEvent.detail?.value || '')
.replace(/\D/g, '')
.slice(0, 4)
codeEntryError.value = ''
}
const submitCodeEntry = () => {
if (props.codeSubmitting) return
const code = codeEntryValue.value
if (code.length !== 4) {
codeEntryError.value = '请输入完整的 4 位讲解编号'
return
}
emit('codeSubmit', code)
}
const showCodeEntryError = (message: string) => {
codeEntryError.value = message
}
const completeCodeEntry = () => {
closeCodeEntry()
}
defineExpose({ showCodeEntryError, completeCodeEntry })
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 handleScrollToLower = () => {
requestMore()
}
const requestMore = () => {
if (props.stage === 'stop' && 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 handleBack = () => {
if (codeEntryVisible.value) {
closeCodeEntry()
return
}
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: 76px 16px 16px;
}
.code-entry-section {
padding-bottom: 10px;
}
.code-entry-button {
position: relative;
width: 100%;
min-height: 82px;
margin: 0;
padding: 13px 72px;
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
color: #171713;
border: 1px solid #cfd4c7;
border-radius: 8px;
background: #ffffff;
box-shadow: 0 3px 10px rgba(36, 49, 42, 0.06);
}
.code-entry-button::after { border: 0; }
.code-entry-button:active { background: #f0f3e8; }
.code-entry-icon { position: absolute; left: 28px; top: 50%; width: 44px; height: 44px; border-radius: 8px; background: #e0e100; transform: translateY(-50%); }
.headphone-band { position: absolute; left: 10px; top: 9px; width: 24px; height: 23px; box-sizing: border-box; border: 3px solid #171713; border-bottom: 0; border-radius: 14px 14px 0 0; }
.headphone-ear { position: absolute; top: 25px; width: 7px; height: 11px; border-radius: 3px; background: #171713; }
.headphone-ear.left { left: 8px; }
.headphone-ear.right { right: 8px; }
.code-entry-copy { width: 100%; min-width: 0; text-align: center; }
.code-entry-title, .code-entry-desc { display: block; }
.code-entry-title { font-size: 17px; line-height: 23px; font-weight: 700; color: #171713; }
.code-entry-desc { margin-top: 3px; font-size: 12px; line-height: 18px; color: #68725d; }
.hall-section-heading { height: 30px; display: flex; align-items: center; justify-content: center; }
.hall-section-title { font-size: 13px; line-height: 19px; font-weight: 600; color: #68725d; }
.code-entry-overlay { position: absolute; z-index: 3000; top: 64px; right: 0; bottom: 0; left: 0; background: #f7f9f2; }
.code-entry-sheet { position: absolute; inset: 0; padding: 24px 24px calc(24px + env(safe-area-inset-bottom)); box-sizing: border-box; background: #f7f9f2; }
.code-entry-content { width: min(100%, 330px); min-height: 100%; margin: 0 auto; display: flex; flex-direction: column; align-items: center; justify-content: center; box-sizing: border-box; transform: translateY(-28px); }
.code-entry-sheet-title, .code-entry-sheet-desc, .code-entry-error, .code-entry-demo { display: block; text-align: center; }
.code-entry-sheet-title { font-size: 22px; line-height: 30px; font-weight: 700; color: #171713; }
.code-entry-sheet-desc { margin-top: 8px; font-size: 14px; line-height: 20px; color: #68725d; }
.code-entry-action::after { border: 0; }
.code-digit-fields { position: relative; width: 100%; margin-top: 32px; display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 10px; }
.code-digit-cell { height: 68px; display: flex; align-items: center; justify-content: center; box-sizing: border-box; border: 1.5px solid #aeb5a5; border-radius: 6px; background: #ffffff; }
.code-digit-cell.active { border-color: #777d00; box-shadow: 0 0 0 1px #e0e100 inset; }
.code-digit-fields.error .code-digit-cell { border-color: #b63f3f; }
.code-digit-value { font-size: 30px; line-height: 36px; font-weight: 700; color: #171713; }
.code-entry-native-input { position: absolute; inset: 0; width: 100%; height: 68px; opacity: 0.01; color: transparent; caret-color: transparent; }
.code-entry-error, .code-entry-hint { min-height: 19px; margin-top: 10px; font-size: 12px; line-height: 19px; }
.code-entry-error { color: #a62f2f; }
.code-entry-hint { color: #7a8271; }
.code-entry-action { width: 100%; height: 48px; margin: 28px 0 0; padding: 0 12px; font-size: 15px; line-height: 48px; font-weight: 700; border: 0; border-radius: 6px; }
.code-entry-action.primary { color: #171713; background: #e0e100; }
.code-entry-action.primary[disabled] { color: #909489; background: #e4e6df; opacity: 1; }
.explain-hall-select.host-navigation .code-entry-overlay { top: 0; }
.explain-scroll.stop-stage {
padding-bottom: 0;
}
.explain-hall-select.host-navigation .explain-scroll {
padding-top: 12px;
}
.explain-hall-select.host-navigation .explain-scroll.hall-stage {
padding-top: 12px;
}
.state-title,
.state-desc,
.hall-name,
.hall-meta-text {
display: block;
}
.explain-page-header {
position: absolute;
top: 0;
left: 0;
right: 0;
height: 64px;
display: flex;
align-items: center;
justify-content: center;
padding: 0 20px;
box-sizing: border-box;
background: #ffffff;
z-index: 2001;
}
.header-back {
position: absolute;
left: 12px;
top: 10px;
height: 44px;
width: 64px;
display: flex;
align-items: center;
justify-content: flex-start;
gap: 4px;
z-index: 1;
}
.header-back-icon {
position: relative;
width: 16px;
height: 16px;
display: flex;
align-items: center;
justify-content: center;
font-size: 0;
line-height: 0;
color: #151713;
}
.header-back-icon::before {
content: '';
width: 8px;
height: 8px;
border-left: 1.5px solid currentColor;
border-bottom: 1.5px solid currentColor;
transform: translateX(2px) rotate(45deg);
}
.header-back-text {
display: flex;
align-items: center;
height: 20px;
font-size: 14px;
line-height: 20px;
font-weight: 400;
color: #151713;
}
.header-title {
max-width: calc(100% - 152px);
padding: 0 8px;
box-sizing: border-box;
font-size: 18px;
line-height: 25px;
font-weight: 700;
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 {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
grid-auto-rows: 154px;
gap: 12px;
}
.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 {
position: relative;
min-height: 154px;
display: block;
overflow: hidden;
padding: 0;
border: 0;
border-radius: 8px;
box-shadow: none;
background: var(--hall-card-color);
}
.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:active {
opacity: 0.86;
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 {
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;
}
.hall-main {
flex: 1;
min-width: 0;
}
.hall-overview-card .hall-main {
flex: 1;
}
.hall-overview-copy {
position: absolute;
top: 14px;
left: 14px;
right: 14px;
min-width: 0;
}
.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 {
display: block;
max-width: 94px;
font-size: 19px;
line-height: 25px;
font-weight: 700;
}
.hall-overview-english {
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 {
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 {
display: block;
max-width: 92px;
margin-top: 5px;
font-size: 9px;
line-height: 13px;
color: #444754;
overflow: hidden;
text-overflow: ellipsis;
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 {
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;
}
.load-more-state {
min-height: 52px;
padding: 12px 16px calc(12px + env(safe-area-inset-bottom));
text-align: center;
}
.state-retry {
margin-top: 12px;
padding: 0 14px;
font-size: 13px;
line-height: 32px;
color: #151713;
background: #e0df00;
border: 0;
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 12px;
overflow: hidden;
}
.hall-overview-list {
grid-template-rows: repeat(4, minmax(0, 1fr));
grid-auto-rows: minmax(0, 1fr);
height: calc(100vh - 222px);
gap: 10px 12px;
}
.hall-overview-card {
min-height: 0;
}
.explain-hall-select.host-navigation .hall-overview-list {
height: calc(100vh - 146px);
}
.hall-overview-card {
min-height: 0;
}
.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-full-card-art {
position: absolute;
inset: 0;
overflow: hidden;
background: var(--hall-card-color);
}
.hall-full-card-image {
position: absolute;
left: 50%;
top: 12px;
display: block;
width: 78%;
height: calc(100% - 66px);
transform: translateX(-50%);
}
.hall-full-card-fallback {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
padding-bottom: 46px;
box-sizing: border-box;
}
.hall-full-card-copy {
position: absolute;
left: 8px;
right: 8px;
bottom: 9px;
display: flex;
flex-direction: column;
align-items: center;
min-width: 0;
}
.hall-full-card-name,
.hall-full-card-english {
display: block;
max-width: 100%;
overflow: hidden;
text-align: center;
text-overflow: ellipsis;
white-space: nowrap;
}
.hall-full-card-name {
font-size: 19px;
line-height: 25px;
font-weight: 500;
color: #151713;
}
.hall-full-card-english {
margin-top: 0;
font-size: 12px;
line-height: 16px;
font-weight: 300;
color: rgba(21, 23, 19, 0.72);
}
.hall-overview-copy {
top: 24px;
}
.hall-overview-card .hall-name {
max-width: 96px;
font-size: 21px;
line-height: 28px;
}
</style>