Files
frontend-miniapp/src/pages/hall/detail.vue
2026-06-30 17:22:20 +08:00

441 lines
11 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>
<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>