Files
frontend-miniapp/src/pages/hall/detail.vue
lyf 2edb6bd9f2
Some checks failed
CI / verify (push) Has been cancelled
适配H5讲解统一详情接口
2026-08-05 14:52:36 +08:00

438 lines
10 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>
<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>
</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 GuideLoadingState from '@/components/navigation/GuideLoadingState.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'
import {
HALL_PLACEHOLDER_IMAGE
} from '@/utils/placeholders'
const activeTopTab = ref<GuideTopTab>('explain')
const loading = ref(false)
const error = ref('')
const resolvedHallPoiId = ref('')
const hall = ref<HallDetailViewModel>({
id: '',
name: '免费讲解',
floorLabel: '楼层待补充',
description: '该展厅暂无介绍文案。',
image: HALL_PLACEHOLDER_IMAGE,
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 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 () => {
return guideUseCase.resolveContentLocationPreviewTarget({
directPoiId: hall.value.poiId,
location: hall.value.location,
hallId: hall.value.id,
hallName: hall.value.name,
targetName: hall.value.name
})
}
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) => {
const params = new URLSearchParams({
id: exhibit.id,
tab: activeTopTab.value
})
params.set('stopId', exhibit.playTargetId || exhibit.id)
uni.navigateTo({
url: `/pages/exhibit/detail?${params.toString()}`
})
}
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.redirectTo({
url: '/pages/index/index?tab=explain',
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: var(--guide-page-detail-top-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>