441 lines
11 KiB
Vue
441 lines
11 KiB
Vue
<template>
|
||
<GuidePageFrame
|
||
:active-tab="activeTopTab"
|
||
variant="static"
|
||
:show-top-tabs="false"
|
||
show-back
|
||
@back="handleBack"
|
||
>
|
||
<view class="hall-explain-page">
|
||
<scroll-view class="content" scroll-y :show-scrollbar="false">
|
||
<view class="page-head">
|
||
<text class="hall-title">{{ hall.name }}</text>
|
||
<view class="hall-meta-row">
|
||
<text class="hall-meta">{{ hall.floorLabel }} · {{ explainCountText }}</text>
|
||
<view
|
||
class="location-action"
|
||
:class="{ disabled: !hallLocationPoiId }"
|
||
@tap="handleNavigate"
|
||
>
|
||
<svg class="location-icon" width="16" height="16" viewBox="0 0 24 24" fill="none">
|
||
<path d="M12 21C12 21 18 14.9 18 9.8C18 6.5 15.3 4 12 4C8.7 4 6 6.5 6 9.8C6 14.9 12 21 12 21Z" stroke="currentColor" stroke-width="1.7"/>
|
||
<circle cx="12" cy="10" r="2.2" stroke="currentColor" stroke-width="1.7"/>
|
||
</svg>
|
||
<text class="location-text">查看展厅位置</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<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>
|
||
</view>
|
||
|
||
<view v-else-if="exhibits.length" class="explain-list">
|
||
<view
|
||
v-for="exhibit in exhibits"
|
||
:key="exhibit.id"
|
||
class="explain-item"
|
||
@tap="handleExhibitClick(exhibit)"
|
||
>
|
||
<view
|
||
class="explain-icon"
|
||
:class="{ playable: exhibit.audioStatus === 'playable' }"
|
||
>
|
||
<svg v-if="exhibit.audioStatus === 'playable'" width="22" height="22" viewBox="0 0 24 24" fill="none">
|
||
<path d="M8 5V19L19 12L8 5Z" fill="currentColor"/>
|
||
</svg>
|
||
<svg v-else width="22" height="22" viewBox="0 0 24 24" fill="none">
|
||
<path d="M7 3.5H14.5L18 7V20.5H7V3.5Z" fill="#747970"/>
|
||
<path d="M14.5 3.5V7H18" fill="#b8bcb4"/>
|
||
<path d="M9.5 11H15.5M9.5 14H15.5M9.5 17H13.5" stroke="white" stroke-width="1.2" stroke-linecap="round"/>
|
||
</svg>
|
||
</view>
|
||
|
||
<view class="explain-main">
|
||
<text class="explain-title">{{ exhibit.title }}</text>
|
||
<text class="explain-meta">{{ itemMeta(exhibit) }}</text>
|
||
</view>
|
||
|
||
<text class="item-arrow">›</text>
|
||
</view>
|
||
</view>
|
||
|
||
<view v-else class="state-block empty">
|
||
<text class="state-title">暂无讲解内容</text>
|
||
<text class="state-desc">该展厅暂未接入可展示的讲解条目。</text>
|
||
</view>
|
||
</scroll-view>
|
||
</view>
|
||
</GuidePageFrame>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { computed, ref } from 'vue'
|
||
import { onLoad } from '@dcloudio/uni-app'
|
||
import GuidePageFrame from '@/components/navigation/GuidePageFrame.vue'
|
||
import {
|
||
explainUseCase
|
||
} from '@/usecases/explainUseCase'
|
||
import {
|
||
guideUseCase
|
||
} from '@/usecases/guideUseCase'
|
||
import {
|
||
toExplainExhibitViewModel,
|
||
toHallDetailViewModel,
|
||
type ExplainExhibitViewModel,
|
||
type HallDetailViewModel
|
||
} from '@/view-models/explainViewModels'
|
||
import {
|
||
isGuideTopTab,
|
||
type GuideTopTab
|
||
} from '@/utils/guideTopTabs'
|
||
|
||
const activeTopTab = ref<GuideTopTab>('explain')
|
||
const loading = ref(false)
|
||
const error = ref('')
|
||
const resolvedHallPoiId = ref('')
|
||
|
||
const hall = ref<HallDetailViewModel>({
|
||
id: '',
|
||
name: '展厅讲解',
|
||
floorLabel: '楼层待补充',
|
||
description: '该展厅暂无介绍文案。',
|
||
image: '/static/hall-placeholder.jpg',
|
||
exhibitCount: 0,
|
||
area: '待补充'
|
||
})
|
||
|
||
const exhibits = ref<ExplainExhibitViewModel[]>([])
|
||
|
||
const explainCountText = computed(() => (
|
||
exhibits.value.length > 0 ? `${exhibits.value.length} 条讲解` : '暂无讲解'
|
||
))
|
||
|
||
const hallLocationPoiId = computed(() => hall.value.poiId || resolvedHallPoiId.value)
|
||
|
||
const normalizePoiName = (value: string) => value
|
||
.trim()
|
||
.replace(/[\s()()【】[\]_-]/g, '')
|
||
.toLowerCase()
|
||
|
||
// 展厅数据里可能没有直接写入 poiId,优先走内容层映射,再按展厅名称兜底匹配导览点位。
|
||
const applyMatchedHallPoi = (matchedPoi: Awaited<ReturnType<typeof guideUseCase.getPoiById>>) => {
|
||
if (!matchedPoi) return
|
||
|
||
resolvedHallPoiId.value = matchedPoi.id
|
||
hall.value = {
|
||
...hall.value,
|
||
poiId: matchedPoi.id,
|
||
location: hall.value.location || {
|
||
status: 'exact',
|
||
poiId: matchedPoi.id,
|
||
sourcePoiId: matchedPoi.id,
|
||
actionText: '查看三维位置',
|
||
previewOnly: true,
|
||
floorId: matchedPoi.floorId,
|
||
floorLabel: matchedPoi.floorLabel,
|
||
note: '已定位到该展厅的三维点位。'
|
||
}
|
||
}
|
||
}
|
||
|
||
const resolveHallGuidePoi = async () => {
|
||
const directPoiId = hall.value.location?.poiId || hall.value.poiId
|
||
if (directPoiId) {
|
||
const directPoi = await guideUseCase.getPoiById(directPoiId)
|
||
if (directPoi) return directPoi
|
||
}
|
||
|
||
const normalizedTarget = normalizePoiName(hall.value.name)
|
||
if (!normalizedTarget) return null
|
||
|
||
const candidates = await guideUseCase.searchPois(hall.value.name)
|
||
return candidates.find((candidate) => {
|
||
const candidateName = normalizePoiName(candidate.name)
|
||
const candidateHallName = normalizePoiName(candidate.hallName || '')
|
||
return candidate.kind === 'hall'
|
||
|| candidate.primaryCategory.id === 'exhibition_hall'
|
||
|| candidateName === normalizedTarget
|
||
|| candidateHallName === normalizedTarget
|
||
|| candidateName.includes(normalizedTarget)
|
||
|| normalizedTarget.includes(candidateName)
|
||
}) || candidates[0] || null
|
||
}
|
||
|
||
const resolveHallGuidePoiInBackground = () => {
|
||
void resolveHallGuidePoi()
|
||
.then(applyMatchedHallPoi)
|
||
.catch((err) => {
|
||
console.warn('展厅三维位置后台匹配失败:', err)
|
||
})
|
||
}
|
||
|
||
onLoad(async (options: any = {}) => {
|
||
const tab = Array.isArray(options.tab) ? options.tab[0] : options.tab
|
||
if (isGuideTopTab(tab)) {
|
||
activeTopTab.value = tab
|
||
}
|
||
|
||
const hallId = Array.isArray(options.id) ? options.id[0] : options.id
|
||
if (!hallId) {
|
||
error.value = '缺少展厅信息'
|
||
return
|
||
}
|
||
|
||
loading.value = true
|
||
error.value = ''
|
||
|
||
try {
|
||
const hallData = await explainUseCase.getHallById(hallId)
|
||
if (!hallData) {
|
||
error.value = '未找到该展厅'
|
||
return
|
||
}
|
||
|
||
hall.value = toHallDetailViewModel(hallData)
|
||
const hallExhibits = await explainUseCase.listExhibitsByHallId(hallData.id)
|
||
exhibits.value = hallExhibits.map(toExplainExhibitViewModel)
|
||
loading.value = false
|
||
resolveHallGuidePoiInBackground()
|
||
} catch (err) {
|
||
console.error('加载展厅讲解失败:', err)
|
||
error.value = '展厅讲解加载失败,请稍后重试'
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
})
|
||
|
||
const itemMeta = (exhibit: ExplainExhibitViewModel) => {
|
||
if (exhibit.audioStatus === 'playable') {
|
||
return [exhibit.durationLabel, '音频讲解'].filter(Boolean).join(' · ') || '音频讲解'
|
||
}
|
||
return '图文讲解'
|
||
}
|
||
|
||
const handleExhibitClick = (exhibit: ExplainExhibitViewModel) => {
|
||
uni.navigateTo({
|
||
url: `/pages/exhibit/detail?id=${encodeURIComponent(exhibit.id)}&tab=${activeTopTab.value}`
|
||
})
|
||
}
|
||
|
||
const handleNavigate = async () => {
|
||
const matchedPoi = await resolveHallGuidePoi()
|
||
if (!matchedPoi) {
|
||
uni.showToast({
|
||
title: '该展厅暂无三维位置数据',
|
||
icon: 'none'
|
||
})
|
||
return
|
||
}
|
||
|
||
resolvedHallPoiId.value = matchedPoi.id
|
||
applyMatchedHallPoi(matchedPoi)
|
||
|
||
uni.navigateTo({
|
||
url: `/pages/route/detail?facilityId=${encodeURIComponent(matchedPoi.id)}&target=${encodeURIComponent(hall.value.name)}&state=preview`
|
||
})
|
||
}
|
||
|
||
const handleBack = () => {
|
||
uni.navigateBack({
|
||
delta: 1,
|
||
fail: () => {
|
||
uni.reLaunch({
|
||
url: '/pages/index/index?tab=explain'
|
||
})
|
||
}
|
||
})
|
||
}
|
||
</script>
|
||
|
||
<style scoped lang="scss">
|
||
.hall-explain-page {
|
||
position: relative;
|
||
width: 100%;
|
||
height: 100%;
|
||
overflow: hidden;
|
||
background: #f9fafb;
|
||
}
|
||
|
||
.content {
|
||
height: 100%;
|
||
padding: 84px 20px calc(28px + env(safe-area-inset-bottom));
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
.page-head {
|
||
padding-bottom: 24px;
|
||
}
|
||
|
||
.hall-title,
|
||
.hall-meta,
|
||
.location-text,
|
||
.state-title,
|
||
.state-desc,
|
||
.explain-title,
|
||
.explain-meta {
|
||
display: block;
|
||
}
|
||
|
||
.hall-title {
|
||
font-size: 29px;
|
||
line-height: 38px;
|
||
font-weight: 800;
|
||
color: #151713;
|
||
}
|
||
|
||
.hall-meta-row {
|
||
margin-top: 10px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 12px;
|
||
}
|
||
|
||
.hall-meta {
|
||
min-width: 0;
|
||
flex: 1;
|
||
font-size: 16px;
|
||
line-height: 22px;
|
||
color: #4f574b;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.location-action {
|
||
flex-shrink: 0;
|
||
height: 38px;
|
||
padding: 0 11px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 6px;
|
||
box-sizing: border-box;
|
||
background: #ffffff;
|
||
border: 1px solid #d8dcd3;
|
||
border-radius: 8px;
|
||
color: #151713;
|
||
}
|
||
|
||
.location-action.disabled {
|
||
color: #8d9488;
|
||
background: #f2f4ef;
|
||
}
|
||
|
||
.location-icon {
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.location-text {
|
||
font-size: 13px;
|
||
line-height: 18px;
|
||
font-weight: 600;
|
||
color: currentColor;
|
||
}
|
||
|
||
.explain-list {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 14px;
|
||
}
|
||
|
||
.explain-item {
|
||
min-height: 96px;
|
||
padding: 14px 13px 14px 16px;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 14px;
|
||
box-sizing: border-box;
|
||
background: #ffffff;
|
||
border: 1px solid #e4e6df;
|
||
border-radius: 8px;
|
||
box-shadow: 0 8px 18px rgba(36, 49, 42, 0.05);
|
||
}
|
||
|
||
.explain-item:active {
|
||
background: #f7f8f3;
|
||
}
|
||
|
||
.explain-icon {
|
||
flex-shrink: 0;
|
||
width: 46px;
|
||
height: 46px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
color: #747970;
|
||
background: #f0f2ed;
|
||
border-radius: 8px;
|
||
}
|
||
|
||
.explain-icon.playable {
|
||
color: #151713;
|
||
background: #e0df00;
|
||
}
|
||
|
||
.explain-main {
|
||
flex: 1;
|
||
min-width: 0;
|
||
}
|
||
|
||
.explain-title {
|
||
font-size: 17px;
|
||
line-height: 24px;
|
||
font-weight: 800;
|
||
color: #151713;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.explain-meta {
|
||
margin-top: 6px;
|
||
font-size: 15px;
|
||
line-height: 21px;
|
||
color: #555c51;
|
||
}
|
||
|
||
.item-arrow {
|
||
flex-shrink: 0;
|
||
font-size: 30px;
|
||
line-height: 30px;
|
||
color: #151713;
|
||
}
|
||
|
||
.state-block {
|
||
margin: 28px 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>
|