feat: refine guide explain UX and QA docs
This commit is contained in:
@@ -1,26 +1,50 @@
|
||||
<template>
|
||||
<view class="exhibit-card" @tap="handleClick">
|
||||
<image v-if="exhibit.image" class="exhibit-image" :src="exhibit.image" mode="aspectFill" />
|
||||
<image
|
||||
v-if="coverImage"
|
||||
class="exhibit-image"
|
||||
:src="coverImage"
|
||||
mode="aspectFill"
|
||||
/>
|
||||
<view v-else class="exhibit-image placeholder">
|
||||
<text class="placeholder-text">讲</text>
|
||||
</view>
|
||||
<view class="exhibit-content">
|
||||
<text class="exhibit-name">{{ exhibit.name }}</text>
|
||||
<text v-if="exhibit.artist" class="exhibit-artist">{{ exhibit.artist }}</text>
|
||||
<view v-if="exhibit.hall" class="exhibit-meta">
|
||||
<text class="meta-text">📍 {{ exhibit.hall }}</text>
|
||||
<text class="exhibit-name">{{ title }}</text>
|
||||
<text v-if="subtitle" class="exhibit-subtitle">{{ subtitle }}</text>
|
||||
<view class="exhibit-meta">
|
||||
<svg class="meta-icon" width="14" height="14" 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.8"/>
|
||||
<circle cx="12" cy="10" r="2.2" stroke="currentColor" stroke-width="1.8"/>
|
||||
</svg>
|
||||
<text class="meta-text">{{ metaText }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view v-if="exhibit.hasAudio" class="audio-badge">
|
||||
<text class="audio-icon">🎧</text>
|
||||
<view class="audio-badge" :class="`status-${audioStatus}`">
|
||||
<text class="audio-text">{{ audioStatusText }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type {
|
||||
ExplainAudioStatus
|
||||
} from '@/view-models/explainViewModels'
|
||||
|
||||
interface Exhibit {
|
||||
id: string
|
||||
name: string
|
||||
artist?: string
|
||||
title?: string
|
||||
name?: string
|
||||
subtitle?: string
|
||||
hall?: string
|
||||
hallName?: string
|
||||
floor?: string
|
||||
floorLabel?: string
|
||||
coverImage?: string
|
||||
image?: string
|
||||
audioStatus?: ExplainAudioStatus
|
||||
audioStatusText?: string
|
||||
hasAudio?: boolean
|
||||
}
|
||||
|
||||
@@ -32,6 +56,18 @@ const emit = defineEmits<{
|
||||
click: [exhibit: Exhibit]
|
||||
}>()
|
||||
|
||||
const title = computed(() => props.exhibit.title || props.exhibit.name || '讲解内容')
|
||||
const subtitle = computed(() => props.exhibit.subtitle || props.exhibit.hall || props.exhibit.hallName)
|
||||
const coverImage = computed(() => props.exhibit.coverImage || props.exhibit.image)
|
||||
const audioStatus = computed<ExplainAudioStatus>(() => (
|
||||
props.exhibit.audioStatus || (props.exhibit.hasAudio ? 'playable' : 'unavailable')
|
||||
))
|
||||
const audioStatusText = computed(() => props.exhibit.audioStatusText || (audioStatus.value === 'playable' ? '音频' : '图文'))
|
||||
const metaText = computed(() => [
|
||||
props.exhibit.floorLabel || props.exhibit.floor,
|
||||
audioStatusText.value
|
||||
].filter(Boolean).join(' · '))
|
||||
|
||||
const handleClick = () => {
|
||||
emit('click', props.exhibit)
|
||||
}
|
||||
@@ -40,12 +76,16 @@ const handleClick = () => {
|
||||
<style scoped lang="scss">
|
||||
.exhibit-card {
|
||||
position: relative;
|
||||
display: flex;
|
||||
min-height: 104px;
|
||||
padding: 10px;
|
||||
box-sizing: border-box;
|
||||
background-color: var(--museum-bg-surface);
|
||||
border-radius: var(--radius-card);
|
||||
border: 1px solid #ebece5;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
box-shadow: var(--shadow-sm);
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.exhibit-card:active {
|
||||
@@ -53,33 +93,59 @@ const handleClick = () => {
|
||||
}
|
||||
|
||||
.exhibit-image {
|
||||
width: 100%;
|
||||
height: 160px;
|
||||
flex-shrink: 0;
|
||||
width: 76px;
|
||||
height: 84px;
|
||||
border-radius: 8px;
|
||||
background-color: var(--museum-bg-light);
|
||||
}
|
||||
|
||||
.exhibit-image.placeholder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #eef1e8;
|
||||
}
|
||||
|
||||
.placeholder-text {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: #68725d;
|
||||
}
|
||||
|
||||
.exhibit-content {
|
||||
padding: var(--space-md);
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding-left: 10px;
|
||||
}
|
||||
|
||||
.exhibit-name {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
line-height: 21px;
|
||||
font-weight: 700;
|
||||
color: var(--museum-text-primary);
|
||||
margin-bottom: var(--space-xs);
|
||||
display: block;
|
||||
}
|
||||
|
||||
.exhibit-artist {
|
||||
font-size: 13px;
|
||||
.exhibit-subtitle {
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
line-height: 17px;
|
||||
color: var(--museum-text-secondary);
|
||||
margin-bottom: var(--space-sm);
|
||||
display: block;
|
||||
}
|
||||
|
||||
.exhibit-meta {
|
||||
margin-top: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.meta-icon {
|
||||
flex-shrink: 0;
|
||||
color: #68725d;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.meta-text {
|
||||
@@ -89,19 +155,30 @@ const handleClick = () => {
|
||||
|
||||
.audio-badge {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
background-color: var(--museum-accent);
|
||||
border-radius: 50%;
|
||||
right: 10px;
|
||||
bottom: 10px;
|
||||
min-width: 44px;
|
||||
height: 24px;
|
||||
padding: 0 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: var(--shadow-sm);
|
||||
box-sizing: border-box;
|
||||
background-color: #eef1e8;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.audio-icon {
|
||||
font-size: 16px;
|
||||
.audio-badge.status-playable {
|
||||
background-color: var(--museum-accent);
|
||||
}
|
||||
|
||||
.audio-text {
|
||||
font-size: 11px;
|
||||
color: #4f574b;
|
||||
}
|
||||
|
||||
.audio-badge.status-playable .audio-text {
|
||||
color: #151713;
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -117,7 +117,7 @@ const emit = defineEmits<{
|
||||
// POI ID: 7043042949572197975
|
||||
const mapCenter = ref<MapCenter>({
|
||||
latitude: 22.692763, // 深圳自然博物馆纬度
|
||||
longitude: 114.363987 // 深圳自然博物馆经度(向右偏移,让建筑显示在左侧)
|
||||
longitude: 114.363572 // 深圳自然博物馆经度(调整到室外 2D 视觉中心)
|
||||
})
|
||||
|
||||
// 地图缩放级别(调整为适中比例,既能看到建筑全貌又能看清细节)
|
||||
|
||||
@@ -2,121 +2,173 @@ import type {
|
||||
MuseumExhibit,
|
||||
MuseumHall
|
||||
} from '@/domain/museum'
|
||||
import {
|
||||
formatNavFloorLabel
|
||||
} from '@/data/adapters/navAssetsAdapter'
|
||||
import {
|
||||
defaultStaticNavAssetsProvider,
|
||||
type StaticNavAssetsProvider,
|
||||
type StaticNavPoiPayload
|
||||
} from '@/data/providers/staticNavAssetsProvider'
|
||||
|
||||
export interface MuseumContentProvider {
|
||||
listExhibits(): Promise<MuseumExhibit[]>
|
||||
listHalls(): Promise<MuseumHall[]>
|
||||
}
|
||||
|
||||
const transitionalHalls: MuseumHall[] = [
|
||||
{
|
||||
id: 'hall-evolution',
|
||||
name: '演化厅',
|
||||
floorId: 'L-1',
|
||||
floorLabel: 'B1',
|
||||
description: '自然演化主题内容。当前为内容仓储过渡数据,待 CMS/API 接入后替换。',
|
||||
image: '/static/hall-placeholder.jpg',
|
||||
exhibitCount: 3,
|
||||
area: '待补充'
|
||||
},
|
||||
{
|
||||
id: 'hall-dinosaur',
|
||||
name: '恐龙厅',
|
||||
floorId: 'L2',
|
||||
floorLabel: '2F',
|
||||
description: '恐龙与古生物主题内容。当前为内容仓储过渡数据,待 CMS/API 接入后替换。',
|
||||
image: '/static/hall-placeholder.jpg',
|
||||
exhibitCount: 2,
|
||||
area: '待补充'
|
||||
},
|
||||
{
|
||||
id: 'hall-biology',
|
||||
name: '生物厅',
|
||||
floorId: 'L3',
|
||||
floorLabel: '3F',
|
||||
description: '生物多样性主题内容。当前为内容仓储过渡数据,待 CMS/API 接入后替换。',
|
||||
image: '/static/hall-placeholder.jpg',
|
||||
exhibitCount: 2,
|
||||
area: '待补充'
|
||||
}
|
||||
]
|
||||
const contentImage = '/static/exhibit-placeholder.jpg'
|
||||
const hallImage = '/static/hall-placeholder.jpg'
|
||||
|
||||
const transitionalExhibits: MuseumExhibit[] = [
|
||||
{
|
||||
id: 'exhibit-ginkgo-fossil',
|
||||
name: '银杏化石',
|
||||
hallId: 'hall-evolution',
|
||||
hallName: '演化厅',
|
||||
floorId: 'L-1',
|
||||
floorLabel: 'B1',
|
||||
image: '/static/exhibit-placeholder.jpg',
|
||||
description: '银杏化石内容待由正式内容库补充。当前仅用于讲解业务架构迁移。',
|
||||
tags: ['化石', '古植物']
|
||||
},
|
||||
{
|
||||
id: 'exhibit-trilobite-fossil',
|
||||
name: '三叶虫化石',
|
||||
hallId: 'hall-evolution',
|
||||
hallName: '演化厅',
|
||||
floorId: 'L-1',
|
||||
floorLabel: 'B1',
|
||||
image: '/static/exhibit-placeholder.jpg',
|
||||
description: '三叶虫化石内容待由正式内容库补充。当前仅用于讲解业务架构迁移。',
|
||||
tags: ['化石', '古生物']
|
||||
},
|
||||
{
|
||||
id: 'exhibit-brachiosaurus',
|
||||
name: '腕龙骨骼',
|
||||
hallId: 'hall-dinosaur',
|
||||
hallName: '恐龙厅',
|
||||
floorId: 'L2',
|
||||
floorLabel: '2F',
|
||||
image: '/static/exhibit-placeholder.jpg',
|
||||
description: '腕龙骨骼内容待由正式内容库补充。当前仅用于讲解业务架构迁移。',
|
||||
tags: ['恐龙', '骨骼']
|
||||
},
|
||||
{
|
||||
id: 'exhibit-archaeopteryx',
|
||||
name: '始祖鸟标本',
|
||||
hallId: 'hall-evolution',
|
||||
hallName: '演化厅',
|
||||
floorId: 'L-1',
|
||||
floorLabel: 'B1',
|
||||
image: '/static/exhibit-placeholder.jpg',
|
||||
description: '始祖鸟标本内容待由正式内容库补充。当前仅用于讲解业务架构迁移。',
|
||||
tags: ['演化', '标本']
|
||||
},
|
||||
{
|
||||
id: 'exhibit-mammoth-tusk',
|
||||
name: '猛犸象象牙',
|
||||
hallId: 'hall-biology',
|
||||
hallName: '生物厅',
|
||||
floorId: 'L3',
|
||||
floorLabel: '3F',
|
||||
image: '/static/exhibit-placeholder.jpg',
|
||||
description: '猛犸象象牙内容待由正式内容库补充。当前仅用于讲解业务架构迁移。',
|
||||
tags: ['古生物', '标本']
|
||||
},
|
||||
{
|
||||
id: 'exhibit-coral-reef',
|
||||
name: '珊瑚礁标本',
|
||||
hallId: 'hall-biology',
|
||||
hallName: '生物厅',
|
||||
floorId: 'L3',
|
||||
floorLabel: '3F',
|
||||
image: '/static/exhibit-placeholder.jpg',
|
||||
description: '珊瑚礁标本内容待由正式内容库补充。当前仅用于讲解业务架构迁移。',
|
||||
tags: ['海洋生物', '标本']
|
||||
}
|
||||
]
|
||||
const cleanPoiName = (poi: StaticNavPoiPayload) => poi.name
|
||||
.replace(new RegExp(`^${poi.floorId}\\s*`), '')
|
||||
.replace(/^L\d+(?:\.\d+)?\s+/, '')
|
||||
.replace(/^L-\d+(?:\.\d+)?\s+/, '')
|
||||
.replace(/^L\d+(?:\.\d+)?\s+/, '')
|
||||
.trim()
|
||||
|
||||
const normalizeContentId = (prefix: string, value: string) => `${prefix}-${value}`
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9一-龥]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
|
||||
const isTouringPoi = (poi: StaticNavPoiPayload) => (
|
||||
poi.primaryCategory === 'touring_poi'
|
||||
|| poi.categories?.some((category) => category.topCategory === 'touring_poi') === true
|
||||
)
|
||||
|
||||
const isHallPoi = (poi: StaticNavPoiPayload) => {
|
||||
const name = cleanPoiName(poi)
|
||||
return /展厅|影院|剧场|报告厅|活动室/.test(name)
|
||||
&& !/展柜|装饰/.test(name)
|
||||
}
|
||||
|
||||
const isExhibitCandidate = (poi: StaticNavPoiPayload) => /展柜|装饰/.test(cleanPoiName(poi))
|
||||
|
||||
const hallKeyFromName = (name: string) => {
|
||||
const match = name.match(/(展厅\s*\d+\s*[^\s展柜装饰]+|临展厅\d+|[^\s]+厅|[^\s]+影院|自然剧场|学术报告厅|博物馆之友活动室)/)
|
||||
return (match?.[1] || name)
|
||||
.replace(/\s+/g, '')
|
||||
.replace(/L\d+\s*/g, '')
|
||||
}
|
||||
|
||||
const exhibitNameFromPoi = (poi: StaticNavPoiPayload) => cleanPoiName(poi)
|
||||
.replace(/\s+/g, ' ')
|
||||
.replace(/^(展厅\s*\d+\s*)/, '')
|
||||
.trim()
|
||||
|
||||
const hallDescription = (hallName: string, floorLabel: string) => (
|
||||
`${hallName}位于${floorLabel},来源于当前导览资源包中的游览 POI。当前内容用于讲解业务结构占位,正式展陈文案、音频和图文媒体待 CMS/API 接入后替换。`
|
||||
)
|
||||
|
||||
const exhibitDescription = (exhibitName: string, hallName: string, floorLabel: string) => (
|
||||
`${exhibitName}是${hallName}的讲解点,位置关联到${floorLabel}的导览 POI。当前仅展示内容结构和位置关联,不代表正式展陈文案或可播放音频。`
|
||||
)
|
||||
|
||||
export class StaticMuseumContentProvider implements MuseumContentProvider {
|
||||
private hallCache: MuseumHall[] | null = null
|
||||
private exhibitCache: MuseumExhibit[] | null = null
|
||||
private loading: Promise<void> | null = null
|
||||
|
||||
constructor(
|
||||
private readonly navAssets: StaticNavAssetsProvider = defaultStaticNavAssetsProvider
|
||||
) {}
|
||||
|
||||
async listExhibits() {
|
||||
return transitionalExhibits
|
||||
await this.ensureLoaded()
|
||||
return this.exhibitCache || []
|
||||
}
|
||||
|
||||
async listHalls() {
|
||||
return transitionalHalls
|
||||
await this.ensureLoaded()
|
||||
return this.hallCache || []
|
||||
}
|
||||
|
||||
private async ensureLoaded() {
|
||||
if (this.hallCache && this.exhibitCache) return
|
||||
if (this.loading) return this.loading
|
||||
|
||||
this.loading = this.navAssets.loadPoiIndex()
|
||||
.then((pois) => {
|
||||
const touringPois = pois.filter(isTouringPoi)
|
||||
const hallPois = touringPois.filter(isHallPoi)
|
||||
const hallByKey = new Map<string, MuseumHall>()
|
||||
|
||||
hallPois.forEach((poi) => {
|
||||
const hallName = cleanPoiName(poi)
|
||||
const key = `${poi.floorId}:${hallKeyFromName(hallName)}`
|
||||
const floorLabel = formatNavFloorLabel(poi.floorId)
|
||||
hallByKey.set(key, {
|
||||
id: normalizeContentId('hall', poi.id),
|
||||
name: hallName,
|
||||
floorId: poi.floorId,
|
||||
floorLabel,
|
||||
description: hallDescription(hallName, floorLabel),
|
||||
image: hallImage,
|
||||
exhibitCount: 0,
|
||||
area: '以导览资源包 POI 为准',
|
||||
poiId: poi.id
|
||||
})
|
||||
})
|
||||
|
||||
const fallbackHallFor = (poi: StaticNavPoiPayload) => {
|
||||
const poiName = cleanPoiName(poi)
|
||||
const key = `${poi.floorId}:${hallKeyFromName(poiName)}`
|
||||
const floorLabel = formatNavFloorLabel(poi.floorId)
|
||||
const hallName = hallKeyFromName(poiName)
|
||||
const existing = hallByKey.get(key)
|
||||
if (existing) return existing
|
||||
|
||||
const hall: MuseumHall = {
|
||||
id: normalizeContentId('hall', `${poi.floorId}-${hallName}`),
|
||||
name: hallName,
|
||||
floorId: poi.floorId,
|
||||
floorLabel,
|
||||
description: hallDescription(hallName, floorLabel),
|
||||
image: hallImage,
|
||||
exhibitCount: 0,
|
||||
area: '以导览资源包 POI 为准'
|
||||
}
|
||||
hallByKey.set(key, hall)
|
||||
return hall
|
||||
}
|
||||
|
||||
const exhibits = touringPois
|
||||
.filter(isExhibitCandidate)
|
||||
.map((poi): MuseumExhibit => {
|
||||
const hall = fallbackHallFor(poi)
|
||||
const exhibitName = exhibitNameFromPoi(poi)
|
||||
return {
|
||||
id: normalizeContentId('exhibit', poi.id),
|
||||
name: exhibitName,
|
||||
hallId: hall.id,
|
||||
hallName: hall.name,
|
||||
floorId: poi.floorId,
|
||||
floorLabel: formatNavFloorLabel(poi.floorId),
|
||||
image: contentImage,
|
||||
description: exhibitDescription(exhibitName, hall.name, formatNavFloorLabel(poi.floorId)),
|
||||
poiId: poi.id,
|
||||
tags: ['资源包POI', poi.primaryCategoryZh, poi.iconType || '展项'].filter(Boolean)
|
||||
}
|
||||
})
|
||||
|
||||
const exhibitCountByHallId = exhibits.reduce((result, exhibit) => {
|
||||
if (exhibit.hallId) {
|
||||
result.set(exhibit.hallId, (result.get(exhibit.hallId) || 0) + 1)
|
||||
}
|
||||
return result
|
||||
}, new Map<string, number>())
|
||||
|
||||
this.hallCache = Array.from(hallByKey.values()).map((hall) => ({
|
||||
...hall,
|
||||
exhibitCount: exhibitCountByHallId.get(hall.id) || hall.exhibitCount || 0
|
||||
}))
|
||||
this.exhibitCache = exhibits
|
||||
})
|
||||
.finally(() => {
|
||||
this.loading = null
|
||||
})
|
||||
|
||||
return this.loading
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,60 +6,141 @@
|
||||
>
|
||||
<view class="detail-page">
|
||||
<scroll-view class="content" scroll-y>
|
||||
<!-- 展品图片 -->
|
||||
<view class="exhibit-hero">
|
||||
<image class="hero-image" :src="exhibit.image" mode="aspectFill" />
|
||||
<view class="audio-control" @tap="handlePlayAudio">
|
||||
<text class="audio-icon">{{ isPlaying ? '⏸' : '▶' }}</text>
|
||||
<view class="detail-hero">
|
||||
<image
|
||||
v-if="heroImage"
|
||||
class="hero-image"
|
||||
:src="heroImage"
|
||||
mode="aspectFill"
|
||||
/>
|
||||
<view v-else class="hero-placeholder">
|
||||
<text class="hero-placeholder-text">讲解</text>
|
||||
</view>
|
||||
<view
|
||||
v-if="exhibit.audio.status === 'playable'"
|
||||
class="audio-control"
|
||||
@tap="handlePlayAudio"
|
||||
>
|
||||
<svg v-if="!isPlaying" width="28" height="28" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M8 5V19L19 12L8 5Z" fill="currentColor"/>
|
||||
</svg>
|
||||
<svg v-else width="28" height="28" viewBox="0 0 24 24" fill="none">
|
||||
<rect x="6" y="5" width="4" height="14" rx="1" fill="currentColor"/>
|
||||
<rect x="14" y="5" width="4" height="14" rx="1" fill="currentColor"/>
|
||||
</svg>
|
||||
</view>
|
||||
<view v-else class="audio-status">
|
||||
<text class="audio-status-text">{{ audioStatusText }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 展品信息 -->
|
||||
<view class="exhibit-info">
|
||||
<text class="exhibit-title">{{ exhibit.name }}</text>
|
||||
<text v-if="exhibit.artist" class="exhibit-artist">{{ exhibit.artist }}</text>
|
||||
<view class="detail-info">
|
||||
<text class="content-type">{{ contentTypeText }}</text>
|
||||
<text class="detail-title">{{ exhibit.title }}</text>
|
||||
<text v-if="exhibit.subtitle" class="detail-subtitle">{{ exhibit.subtitle }}</text>
|
||||
|
||||
<view class="info-grid">
|
||||
<view v-if="exhibit.year" class="info-item">
|
||||
<text class="info-label">创作年代</text>
|
||||
<text class="info-value">{{ exhibit.year }}</text>
|
||||
<view class="meta-grid">
|
||||
<view class="meta-item">
|
||||
<text class="meta-label">内容类型</text>
|
||||
<text class="meta-value">{{ contentTypeText }}</text>
|
||||
</view>
|
||||
<view v-if="exhibit.material" class="info-item">
|
||||
<text class="info-label">材质</text>
|
||||
<text class="info-value">{{ exhibit.material }}</text>
|
||||
<view v-if="exhibit.hallName" class="meta-item">
|
||||
<text class="meta-label">所属展厅</text>
|
||||
<text class="meta-value">{{ exhibit.hallName }}</text>
|
||||
</view>
|
||||
<view v-if="exhibit.size" class="info-item">
|
||||
<text class="info-label">尺寸</text>
|
||||
<text class="info-value">{{ exhibit.size }}</text>
|
||||
<view v-if="exhibit.floorLabel" class="meta-item">
|
||||
<text class="meta-label">所在楼层</text>
|
||||
<text class="meta-value">{{ exhibit.floorLabel }}</text>
|
||||
</view>
|
||||
<view v-if="exhibit.hall" class="info-item">
|
||||
<text class="info-label">展厅位置</text>
|
||||
<text class="info-value">{{ exhibit.hall }}</text>
|
||||
<view class="meta-item">
|
||||
<text class="meta-label">讲解状态</text>
|
||||
<text class="meta-value">{{ audioStatusText }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="description-section">
|
||||
<text class="section-title">作品介绍</text>
|
||||
<text class="description-text">{{ exhibit.description }}</text>
|
||||
<view v-if="exhibit.audio.status !== 'playable'" class="audio-note">
|
||||
<text class="audio-note-title">音频待开放</text>
|
||||
<text class="audio-note-desc">
|
||||
{{ exhibit.audio.unavailableReason || '当前仅提供图文讲解,正式音频接入后将显示播放入口。' }}
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<view class="content-section">
|
||||
<text class="section-title">讲解摘要</text>
|
||||
<text class="section-text">{{ exhibit.summary }}</text>
|
||||
</view>
|
||||
|
||||
<view class="content-section">
|
||||
<text class="section-title">讲解介绍</text>
|
||||
<text class="section-text">{{ exhibit.body }}</text>
|
||||
</view>
|
||||
|
||||
<view class="content-section">
|
||||
<view class="section-header">
|
||||
<text class="section-title">章节</text>
|
||||
<text class="section-state">{{ exhibit.chapters.length ? `${exhibit.chapters.length} 段` : '待开放' }}</text>
|
||||
</view>
|
||||
<view v-if="exhibit.chapters.length" class="chapter-list">
|
||||
<view
|
||||
v-for="chapter in exhibit.chapters"
|
||||
:key="chapter.id"
|
||||
class="chapter-item"
|
||||
>
|
||||
<text class="chapter-title">{{ chapter.title }}</text>
|
||||
<text v-if="typeof chapter.startTime === 'number'" class="chapter-time">
|
||||
{{ formatChapterTime(chapter.startTime) }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
<text v-else class="section-text muted">正式章节数据接入后将在这里展示。</text>
|
||||
</view>
|
||||
|
||||
<view class="content-section">
|
||||
<view class="section-header">
|
||||
<text class="section-title">讲解文稿</text>
|
||||
<text class="section-state">{{ exhibit.transcript ? '已接入' : '待开放' }}</text>
|
||||
</view>
|
||||
<text class="section-text muted">
|
||||
{{ exhibit.transcript || '正式文稿数据接入后将在这里展示。' }}
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<view v-if="exhibit.location" class="location-note">
|
||||
<text class="location-title">位置说明</text>
|
||||
<text class="location-desc">当前支持三维位置预览,馆内路线规划待开放。</text>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
<!-- 底部操作栏 -->
|
||||
<view class="action-bar">
|
||||
<view class="action-btn-group">
|
||||
<view class="action-btn" @tap="handleNavigate">
|
||||
<text class="btn-icon">🗺️</text>
|
||||
<text class="btn-text">查看位置</text>
|
||||
</view>
|
||||
<view class="action-btn" @tap="handleCollect">
|
||||
<text class="btn-icon">{{ isCollected ? '❤️' : '🤍' }}</text>
|
||||
<text class="btn-text">收藏</text>
|
||||
</view>
|
||||
<view class="action-btn" @tap="handleShare">
|
||||
<text class="btn-icon">📤</text>
|
||||
<text class="btn-text">分享</text>
|
||||
</view>
|
||||
<view
|
||||
class="action-btn primary"
|
||||
:class="{ disabled: !exhibit.location }"
|
||||
@tap="handleNavigate"
|
||||
>
|
||||
<svg class="btn-icon" width="18" height="18" 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="btn-text">{{ exhibit.location?.actionText || '暂无位置' }}</text>
|
||||
</view>
|
||||
<view class="action-btn" @tap="handleCollect">
|
||||
<svg class="btn-icon" width="18" height="18" viewBox="0 0 24 24" fill="none">
|
||||
<path
|
||||
:fill="isCollected ? 'currentColor' : 'none'"
|
||||
d="M12 20.2L10.8 19.1C6.4 15.1 3.5 12.5 3.5 9.2C3.5 6.6 5.6 4.5 8.2 4.5C9.7 4.5 11.1 5.2 12 6.3C12.9 5.2 14.3 4.5 15.8 4.5C18.4 4.5 20.5 6.6 20.5 9.2C20.5 12.5 17.6 15.1 13.2 19.1L12 20.2Z"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.7"
|
||||
/>
|
||||
</svg>
|
||||
<text class="btn-text">收藏</text>
|
||||
</view>
|
||||
<view class="action-btn" @tap="handleShare">
|
||||
<svg class="btn-icon" width="18" height="18" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M8 12H6.8C5.8 12 5 12.8 5 13.8V18.2C5 19.2 5.8 20 6.8 20H17.2C18.2 20 19 19.2 19 18.2V13.8C19 12.8 18.2 12 17.2 12H16" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/>
|
||||
<path d="M12 15V4M12 4L8.5 7.5M12 4L15.5 7.5" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
<text class="btn-text">分享</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
@@ -67,15 +148,16 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { onLoad } from '@dcloudio/uni-app'
|
||||
import GuidePageFrame from '@/components/navigation/GuidePageFrame.vue'
|
||||
import {
|
||||
explainUseCase
|
||||
} from '@/usecases/explainUseCase'
|
||||
import {
|
||||
toExplainDetailViewModel,
|
||||
type ExplainDetailViewModel
|
||||
toExplainDetailPageViewModel,
|
||||
type ExplainContentType,
|
||||
type ExplainDetailPageViewModel
|
||||
} from '@/view-models/explainViewModels'
|
||||
import {
|
||||
isGuideTopTab,
|
||||
@@ -83,25 +165,53 @@ import {
|
||||
type GuideTopTab
|
||||
} from '@/utils/guideTopTabs'
|
||||
|
||||
const exhibit = ref<ExplainDetailViewModel>({
|
||||
const defaultDetail: ExplainDetailPageViewModel = {
|
||||
id: '',
|
||||
name: '展项内容',
|
||||
hall: '展厅位置待补充',
|
||||
image: '/static/exhibit-placeholder.jpg',
|
||||
description: '该展项介绍待正式内容库补充。',
|
||||
hasAudio: false,
|
||||
audioUnavailableReason: '正式讲解音频尚未接入媒体仓储'
|
||||
})
|
||||
title: '讲解内容',
|
||||
subtitle: '位置待补充',
|
||||
contentType: 'exhibit',
|
||||
coverImages: ['/static/exhibit-placeholder.jpg'],
|
||||
summary: '该讲解内容待正式内容库补充。',
|
||||
body: '该讲解内容待正式内容库补充。',
|
||||
audio: {
|
||||
status: 'unavailable',
|
||||
unavailableReason: '正式讲解音频尚未接入媒体仓储'
|
||||
},
|
||||
chapters: [],
|
||||
relatedItems: []
|
||||
}
|
||||
|
||||
const exhibit = ref<ExplainDetailPageViewModel>(defaultDetail)
|
||||
const isPlaying = ref(false)
|
||||
const isCollected = ref(false)
|
||||
const activeTopTab = ref<GuideTopTab>('guide')
|
||||
|
||||
const heroImage = computed(() => exhibit.value.coverImages[0])
|
||||
const audioStatusText = computed(() => {
|
||||
if (exhibit.value.audio.status === 'playable') {
|
||||
return exhibit.value.audio.durationLabel || '可播放'
|
||||
}
|
||||
if (exhibit.value.audio.status === 'none') {
|
||||
return '图文讲解'
|
||||
}
|
||||
return '音频待开放'
|
||||
})
|
||||
|
||||
const contentTypeText = computed(() => {
|
||||
const map: Record<ExplainContentType, string> = {
|
||||
hall: '展厅讲解',
|
||||
zone: '展区讲解',
|
||||
exhibit: '讲解点',
|
||||
theme: '主题讲解'
|
||||
}
|
||||
return map[exhibit.value.contentType]
|
||||
})
|
||||
|
||||
onLoad(async (options: any) => {
|
||||
if (options.id) {
|
||||
const exhibitData = await explainUseCase.getExhibitById(options.id)
|
||||
if (exhibitData) {
|
||||
exhibit.value = toExplainDetailViewModel(exhibitData)
|
||||
exhibit.value = toExplainDetailPageViewModel(exhibitData)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,35 +221,46 @@ onLoad(async (options: any) => {
|
||||
}
|
||||
})
|
||||
|
||||
const formatChapterTime = (seconds: number) => {
|
||||
const minute = Math.floor(seconds / 60)
|
||||
const second = Math.floor(seconds % 60)
|
||||
return `${minute}:${String(second).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
const handlePlayAudio = async () => {
|
||||
const selection = exhibit.value.id
|
||||
? await explainUseCase.selectAudioForExhibit(exhibit.value.id)
|
||||
: null
|
||||
|
||||
uni.showToast({
|
||||
title: selection?.unavailableMessage || exhibit.value.audioUnavailableReason || '该展项暂无讲解音频',
|
||||
icon: 'none'
|
||||
})
|
||||
if (!selection?.playable || !selection.media?.url) {
|
||||
uni.showToast({
|
||||
title: selection?.unavailableMessage || exhibit.value.audio.unavailableReason || '该讲解暂无可播放音频',
|
||||
icon: 'none'
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
isPlaying.value = !isPlaying.value
|
||||
}
|
||||
|
||||
const handleNavigate = () => {
|
||||
if (!exhibit.value.poiId) {
|
||||
if (!exhibit.value.location?.poiId) {
|
||||
uni.showToast({
|
||||
title: '该展项暂无三维位置数据',
|
||||
title: '该讲解暂无三维位置数据',
|
||||
icon: 'none'
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
uni.navigateTo({
|
||||
url: `/pages/route/detail?facilityId=${encodeURIComponent(exhibit.value.poiId)}&target=${encodeURIComponent(exhibit.value.name)}&state=preview`
|
||||
url: `/pages/route/detail?facilityId=${encodeURIComponent(exhibit.value.location.poiId)}&target=${encodeURIComponent(exhibit.value.title)}&state=preview`
|
||||
})
|
||||
}
|
||||
|
||||
const handleCollect = () => {
|
||||
isCollected.value = !isCollected.value
|
||||
uni.showToast({
|
||||
title: isCollected.value ? '已收藏' : '已取消收藏',
|
||||
title: isCollected.value ? '已加入本地收藏' : '已取消本地收藏',
|
||||
icon: 'none'
|
||||
})
|
||||
}
|
||||
@@ -151,16 +272,17 @@ const handleShare = () => {
|
||||
const handleTopTabChange = (tab: GuideTopTab) => {
|
||||
navigateToGuideTopTab(tab)
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.detail-page {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: var(--museum-bg-light);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.content {
|
||||
@@ -168,147 +290,266 @@ const handleTopTabChange = (tab: GuideTopTab) => {
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.exhibit-hero {
|
||||
.detail-hero {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 450px;
|
||||
background-color: var(--museum-bg-map);
|
||||
height: 250px;
|
||||
background: #edf0ea;
|
||||
}
|
||||
|
||||
.hero-image {
|
||||
.hero-image,
|
||||
.hero-placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.audio-control {
|
||||
position: absolute;
|
||||
bottom: 20px;
|
||||
right: 20px;
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
background-color: var(--museum-accent);
|
||||
border-radius: 50%;
|
||||
.hero-placeholder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
background: #e6eadf;
|
||||
}
|
||||
|
||||
.audio-control:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.audio-icon {
|
||||
font-size: 28px;
|
||||
color: var(--museum-text-primary);
|
||||
}
|
||||
|
||||
.exhibit-info {
|
||||
padding: 20px 16px 32px;
|
||||
background-color: var(--museum-bg-surface);
|
||||
}
|
||||
|
||||
.exhibit-title {
|
||||
font-size: 26px;
|
||||
.hero-placeholder-text {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: var(--museum-text-primary);
|
||||
margin-bottom: 6px;
|
||||
display: block;
|
||||
line-height: 1.3;
|
||||
color: #67705f;
|
||||
}
|
||||
|
||||
.exhibit-artist {
|
||||
font-size: 15px;
|
||||
color: var(--museum-text-secondary);
|
||||
margin-bottom: 20px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.info-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px 16px;
|
||||
margin-bottom: 20px;
|
||||
padding: 16px;
|
||||
background-color: var(--museum-bg-light);
|
||||
border-radius: var(--radius-card);
|
||||
}
|
||||
|
||||
.info-item {
|
||||
.audio-control {
|
||||
position: absolute;
|
||||
right: 20px;
|
||||
bottom: 18px;
|
||||
width: 58px;
|
||||
height: 58px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: var(--museum-accent);
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.18);
|
||||
color: #151713;
|
||||
}
|
||||
|
||||
.info-label {
|
||||
.audio-status {
|
||||
position: absolute;
|
||||
right: 16px;
|
||||
bottom: 16px;
|
||||
height: 32px;
|
||||
padding: 0 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
box-sizing: border-box;
|
||||
background: rgba(21, 23, 19, 0.86);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.audio-status-text {
|
||||
font-size: 12px;
|
||||
color: var(--museum-text-disabled);
|
||||
font-weight: 500;
|
||||
font-weight: 600;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.info-value {
|
||||
.detail-info {
|
||||
padding: 22px 16px 116px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.content-type,
|
||||
.detail-title,
|
||||
.detail-subtitle,
|
||||
.section-title,
|
||||
.section-text,
|
||||
.audio-note-title,
|
||||
.audio-note-desc,
|
||||
.location-title,
|
||||
.location-desc {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.content-type {
|
||||
font-size: 12px;
|
||||
line-height: 16px;
|
||||
color: #68725d;
|
||||
}
|
||||
|
||||
.detail-title {
|
||||
margin-top: 4px;
|
||||
font-size: 24px;
|
||||
line-height: 32px;
|
||||
font-weight: 700;
|
||||
color: #151713;
|
||||
}
|
||||
|
||||
.detail-subtitle {
|
||||
margin-top: 6px;
|
||||
font-size: 14px;
|
||||
color: var(--museum-text-primary);
|
||||
font-weight: 400;
|
||||
line-height: 20px;
|
||||
color: #68725d;
|
||||
}
|
||||
|
||||
.description-section {
|
||||
margin-top: 20px;
|
||||
.meta-grid {
|
||||
margin-top: 18px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.meta-item {
|
||||
min-height: 62px;
|
||||
padding: 10px 12px;
|
||||
box-sizing: border-box;
|
||||
background: #f7f8f3;
|
||||
border: 1px solid #ebece5;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.meta-label,
|
||||
.meta-value {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.meta-label {
|
||||
font-size: 12px;
|
||||
line-height: 16px;
|
||||
color: #818a7a;
|
||||
}
|
||||
|
||||
.meta-value {
|
||||
margin-top: 4px;
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
color: #151713;
|
||||
}
|
||||
|
||||
.audio-note,
|
||||
.location-note {
|
||||
margin-top: 14px;
|
||||
padding: 12px;
|
||||
box-sizing: border-box;
|
||||
background: #f1f4ea;
|
||||
border: 1px solid #e2e7d7;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.audio-note-title,
|
||||
.location-title {
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
font-weight: 700;
|
||||
color: #151713;
|
||||
}
|
||||
|
||||
.audio-note-desc,
|
||||
.location-desc {
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
color: #5d6656;
|
||||
}
|
||||
|
||||
.content-section {
|
||||
margin-top: 22px;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
color: var(--museum-text-primary);
|
||||
margin-bottom: 10px;
|
||||
display: block;
|
||||
line-height: 24px;
|
||||
font-weight: 700;
|
||||
color: #151713;
|
||||
}
|
||||
|
||||
.description-text {
|
||||
.section-state {
|
||||
font-size: 12px;
|
||||
color: #818a7a;
|
||||
}
|
||||
|
||||
.section-text {
|
||||
margin-top: 10px;
|
||||
font-size: 14px;
|
||||
line-height: 1.7;
|
||||
color: var(--museum-text-secondary);
|
||||
line-height: 24px;
|
||||
color: #333a2f;
|
||||
}
|
||||
|
||||
.section-text.muted {
|
||||
color: #818a7a;
|
||||
}
|
||||
|
||||
.chapter-list {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.chapter-item {
|
||||
min-height: 44px;
|
||||
padding: 0 12px;
|
||||
margin-bottom: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
box-sizing: border-box;
|
||||
background: #f7f8f3;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.chapter-title {
|
||||
font-size: 14px;
|
||||
color: #151713;
|
||||
}
|
||||
|
||||
.chapter-time {
|
||||
font-size: 12px;
|
||||
color: #818a7a;
|
||||
}
|
||||
|
||||
.action-bar {
|
||||
background-color: var(--museum-bg-surface);
|
||||
border-top: 1px solid var(--museum-border-light);
|
||||
padding: 8px 16px;
|
||||
padding-bottom: calc(8px + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
.action-btn-group {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
padding: 10px 12px calc(10px + env(safe-area-inset-bottom));
|
||||
display: grid;
|
||||
grid-template-columns: 1.4fr 1fr 1fr;
|
||||
gap: 8px;
|
||||
box-sizing: border-box;
|
||||
background: rgba(255, 255, 255, 0.96);
|
||||
border-top: 1px solid #e7e9e0;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
flex: 1;
|
||||
height: 46px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 10px 8px;
|
||||
background-color: var(--museum-bg-light);
|
||||
border-radius: var(--radius-button);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
box-sizing: border-box;
|
||||
background: #f7f8f3;
|
||||
border: 1px solid #e5e8df;
|
||||
border-radius: 8px;
|
||||
color: #151713;
|
||||
}
|
||||
|
||||
.action-btn:active {
|
||||
background-color: var(--museum-border-light);
|
||||
transform: scale(0.98);
|
||||
.action-btn.primary {
|
||||
background: #151713;
|
||||
border-color: #151713;
|
||||
color: var(--museum-accent);
|
||||
}
|
||||
|
||||
.action-btn.disabled {
|
||||
background: #f1f3ed;
|
||||
border-color: #e5e8df;
|
||||
color: #8b9385;
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
font-size: 22px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.btn-text {
|
||||
font-size: 11px;
|
||||
color: var(--museum-text-secondary);
|
||||
font-weight: 500;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: currentColor;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -22,17 +22,17 @@
|
||||
<view class="stats-row">
|
||||
<view class="stat-item">
|
||||
<text class="stat-value">{{ hall.exhibitCount }}</text>
|
||||
<text class="stat-label">展品数量</text>
|
||||
<text class="stat-label">讲解点</text>
|
||||
</view>
|
||||
<view class="stat-item">
|
||||
<text class="stat-value">{{ hall.area }}</text>
|
||||
<text class="stat-label">展厅面积</text>
|
||||
<text class="stat-value">{{ hall.floorLabel }}</text>
|
||||
<text class="stat-label">所在楼层</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 展品列表 -->
|
||||
<!-- 讲解列表 -->
|
||||
<view class="exhibits-section">
|
||||
<text class="section-title">展厅展品</text>
|
||||
<text class="section-title">展厅讲解内容</text>
|
||||
<view class="exhibits-grid">
|
||||
<ExhibitCard
|
||||
v-for="exhibit in exhibits"
|
||||
@@ -48,7 +48,7 @@
|
||||
<!-- 底部操作栏 -->
|
||||
<view class="action-bar">
|
||||
<view class="action-btn primary" @tap="handleNavigate">
|
||||
<text class="btn-text">查看展厅位置</text>
|
||||
<text class="btn-text">查看三维位置</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -83,10 +83,13 @@
|
||||
|
||||
<view v-else-if="currentTab === 'explain'" class="explain-page">
|
||||
<ExplainList
|
||||
:exhibits="explainExhibits"
|
||||
:cards="explainExhibits"
|
||||
:loading="explainLoading"
|
||||
:error="explainError"
|
||||
@exhibit-click="handleExplainExhibitClick"
|
||||
@featured-click="handleExplainFeaturedClick"
|
||||
@audio-click="handleAudioClick"
|
||||
@location-click="handleExplainLocationClick"
|
||||
/>
|
||||
|
||||
<FloatingAudioButton
|
||||
@@ -130,7 +133,7 @@ import {
|
||||
explainUseCase
|
||||
} from '@/usecases/explainUseCase'
|
||||
import {
|
||||
toExplainExhibitViewModel
|
||||
toExplainCardViewModel
|
||||
} from '@/view-models/explainViewModels'
|
||||
import type {
|
||||
MuseumFloor
|
||||
@@ -168,6 +171,8 @@ const showAudioPlayer = ref(false)
|
||||
const isAudioPlaying = ref(false)
|
||||
const currentAudio = ref<AudioItem | null>(null)
|
||||
const explainExhibits = ref<Exhibit[]>([])
|
||||
const explainLoading = ref(false)
|
||||
const explainError = ref('')
|
||||
|
||||
// 选中的标记详情
|
||||
interface MarkerDetail {
|
||||
@@ -227,12 +232,17 @@ const loadGuideFloors = async () => {
|
||||
}
|
||||
|
||||
const loadExplainExhibits = async () => {
|
||||
explainLoading.value = true
|
||||
explainError.value = ''
|
||||
try {
|
||||
const exhibits = await explainUseCase.listExhibits()
|
||||
explainExhibits.value = exhibits.map(toExplainExhibitViewModel)
|
||||
explainExhibits.value = exhibits.map(toExplainCardViewModel)
|
||||
} catch (error) {
|
||||
console.error('加载讲解内容失败:', error)
|
||||
explainError.value = '讲解内容加载失败,请稍后重试'
|
||||
explainExhibits.value = []
|
||||
} finally {
|
||||
explainLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -442,7 +452,7 @@ const handleAudioClick = async (exhibit: Exhibit) => {
|
||||
|
||||
if (!selection?.playable || !selection.media?.url) {
|
||||
uni.showToast({
|
||||
title: selection?.unavailableMessage || exhibit.audioUnavailableReason || '该展项暂无讲解音频',
|
||||
title: selection?.unavailableMessage || exhibit.audioStatusText || '该讲解暂无可播放音频',
|
||||
icon: 'none'
|
||||
})
|
||||
return
|
||||
@@ -465,7 +475,7 @@ const handleFloatingButtonClick = () => {
|
||||
showAudioPlayer.value = true
|
||||
} else {
|
||||
uni.showToast({
|
||||
title: '请先选择一个展品',
|
||||
title: '请先选择一个讲解内容',
|
||||
icon: 'none'
|
||||
})
|
||||
}
|
||||
@@ -496,6 +506,20 @@ const handleExplainExhibitClick = (exhibit: Exhibit) => {
|
||||
const handleExplainFeaturedClick = async (exhibit: Exhibit) => {
|
||||
await handleAudioClick(exhibit)
|
||||
}
|
||||
|
||||
const handleExplainLocationClick = (exhibit: Exhibit) => {
|
||||
if (!exhibit.poiId) {
|
||||
uni.showToast({
|
||||
title: '该讲解暂无三维位置数据',
|
||||
icon: 'none'
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
uni.navigateTo({
|
||||
url: `/pages/route/detail?facilityId=${encodeURIComponent(exhibit.poiId)}&target=${encodeURIComponent(exhibit.title)}&state=preview`
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
|
||||
@@ -89,7 +89,7 @@ export class DefaultExplainRepository implements ExplainRepository {
|
||||
.map<SearchIndexItem>((exhibit) => ({
|
||||
id: exhibit.id,
|
||||
name: exhibit.name,
|
||||
desc: `${exhibit.hallName || '展项'} · ${exhibit.floorLabel || ''}`.trim(),
|
||||
desc: `${exhibit.hallName || '讲解点'} · ${exhibit.floorLabel || ''}`.trim(),
|
||||
type: 'exhibit',
|
||||
exhibitId: exhibit.id,
|
||||
hallId: exhibit.hallId,
|
||||
@@ -106,7 +106,7 @@ export class DefaultExplainRepository implements ExplainRepository {
|
||||
.map<SearchIndexItem>((hall) => ({
|
||||
id: hall.id,
|
||||
name: hall.name,
|
||||
desc: `${hall.floorLabel || ''} · ${hall.exhibitCount || 0}件展项`.trim(),
|
||||
desc: `${hall.floorLabel || ''} · ${hall.exhibitCount || 0}个讲解点`.trim(),
|
||||
type: 'hall',
|
||||
hallId: hall.id,
|
||||
poiId: hall.poiId,
|
||||
|
||||
@@ -81,7 +81,7 @@ export class DefaultMuseumContentRepository implements MuseumContentRepository {
|
||||
.map<SearchIndexItem>((exhibit) => ({
|
||||
id: exhibit.id,
|
||||
name: exhibit.name,
|
||||
desc: `${exhibit.hallName || '展项'} · ${exhibit.floorLabel || ''}`.trim(),
|
||||
desc: `${exhibit.hallName || '讲解点'} · ${exhibit.floorLabel || ''}`.trim(),
|
||||
type: 'exhibit',
|
||||
exhibitId: exhibit.id,
|
||||
poiId: exhibit.poiId,
|
||||
@@ -97,7 +97,7 @@ export class DefaultMuseumContentRepository implements MuseumContentRepository {
|
||||
.map<SearchIndexItem>((hall) => ({
|
||||
id: hall.id,
|
||||
name: hall.name,
|
||||
desc: `${hall.floorLabel || ''} · ${hall.exhibitCount || 0}件展项`.trim(),
|
||||
desc: `${hall.floorLabel || ''} · ${hall.exhibitCount || 0}个讲解点`.trim(),
|
||||
type: 'hall',
|
||||
hallId: hall.id,
|
||||
poiId: hall.poiId,
|
||||
|
||||
@@ -4,32 +4,62 @@ import type {
|
||||
SearchIndexItem
|
||||
} from '@/domain/museum'
|
||||
|
||||
export interface ExplainExhibitViewModel {
|
||||
export type ExplainContentType = 'hall' | 'zone' | 'exhibit' | 'theme'
|
||||
export type ExplainAudioStatus = 'playable' | 'unavailable' | 'none'
|
||||
|
||||
export interface ExplainCardViewModel {
|
||||
id: string
|
||||
name: string
|
||||
hall?: string
|
||||
floor?: string
|
||||
image?: string
|
||||
hasAudio: boolean
|
||||
audioUnavailableReason?: string
|
||||
title: string
|
||||
subtitle?: string
|
||||
summary?: string
|
||||
coverImage?: string
|
||||
contentType: ExplainContentType
|
||||
hallName?: string
|
||||
floorLabel?: string
|
||||
durationLabel?: string
|
||||
languageLabel?: string
|
||||
audioStatus: ExplainAudioStatus
|
||||
audioStatusText: string
|
||||
badges: string[]
|
||||
progress?: number
|
||||
poiId?: string
|
||||
locationActionText?: string
|
||||
}
|
||||
|
||||
export interface ExplainDetailViewModel {
|
||||
export interface ExplainDetailPageViewModel {
|
||||
id: string
|
||||
name: string
|
||||
artist?: string
|
||||
year?: string
|
||||
material?: string
|
||||
size?: string
|
||||
hall?: string
|
||||
image: string
|
||||
description: string
|
||||
hasAudio: boolean
|
||||
audioUnavailableReason?: string
|
||||
poiId?: string
|
||||
title: string
|
||||
subtitle?: string
|
||||
contentType: ExplainContentType
|
||||
coverImages: string[]
|
||||
hallName?: string
|
||||
floorLabel?: string
|
||||
summary: string
|
||||
body: string
|
||||
audio: {
|
||||
status: ExplainAudioStatus
|
||||
url?: string
|
||||
durationLabel?: string
|
||||
language?: string
|
||||
unavailableReason?: string
|
||||
}
|
||||
chapters: Array<{
|
||||
id: string
|
||||
title: string
|
||||
startTime?: number
|
||||
}>
|
||||
transcript?: string
|
||||
location?: {
|
||||
poiId: string
|
||||
actionText: string
|
||||
previewOnly: boolean
|
||||
}
|
||||
relatedItems: ExplainCardViewModel[]
|
||||
}
|
||||
|
||||
export type ExplainExhibitViewModel = ExplainCardViewModel
|
||||
export type ExplainDetailViewModel = ExplainDetailPageViewModel
|
||||
|
||||
export interface HallDetailViewModel {
|
||||
id: string
|
||||
name: string
|
||||
@@ -41,36 +71,76 @@ export interface HallDetailViewModel {
|
||||
poiId?: string
|
||||
}
|
||||
|
||||
export const toExplainExhibitViewModel = (exhibit: MuseumExhibit): ExplainExhibitViewModel => ({
|
||||
const contentTypeFor = (exhibit: MuseumExhibit): ExplainContentType => {
|
||||
if (exhibit.tags?.some((tag) => /主题/.test(tag))) return 'theme'
|
||||
if (exhibit.hallName && exhibit.tags?.some((tag) => /展厅/.test(tag))) return 'hall'
|
||||
return 'exhibit'
|
||||
}
|
||||
|
||||
const buildSubtitle = (exhibit: MuseumExhibit) => [
|
||||
exhibit.hallName,
|
||||
exhibit.floorLabel
|
||||
].filter(Boolean).join(' · ')
|
||||
|
||||
const buildBadges = (exhibit: MuseumExhibit) => {
|
||||
const badges = [
|
||||
exhibit.hallName ? '展厅讲解' : '讲解点',
|
||||
exhibit.floorLabel || '',
|
||||
...(exhibit.tags || [])
|
||||
].filter(Boolean)
|
||||
|
||||
return Array.from(new Set(badges)).slice(0, 3)
|
||||
}
|
||||
|
||||
export const toExplainCardViewModel = (exhibit: MuseumExhibit): ExplainCardViewModel => ({
|
||||
id: exhibit.id,
|
||||
name: exhibit.name,
|
||||
hall: exhibit.hallName && exhibit.floorLabel
|
||||
? `${exhibit.hallName} ${exhibit.floorLabel}`
|
||||
: exhibit.hallName || exhibit.floorLabel,
|
||||
floor: exhibit.floorLabel,
|
||||
image: exhibit.image,
|
||||
hasAudio: false,
|
||||
audioUnavailableReason: '正式讲解音频尚未接入媒体仓储',
|
||||
poiId: exhibit.poiId
|
||||
title: exhibit.name,
|
||||
subtitle: buildSubtitle(exhibit),
|
||||
summary: exhibit.description || '讲解内容待正式内容库补充。',
|
||||
coverImage: exhibit.image,
|
||||
contentType: contentTypeFor(exhibit),
|
||||
hallName: exhibit.hallName,
|
||||
floorLabel: exhibit.floorLabel,
|
||||
durationLabel: undefined,
|
||||
languageLabel: undefined,
|
||||
audioStatus: 'unavailable',
|
||||
audioStatusText: '图文讲解',
|
||||
badges: buildBadges(exhibit),
|
||||
progress: undefined,
|
||||
poiId: exhibit.poiId,
|
||||
locationActionText: '查看位置'
|
||||
})
|
||||
|
||||
export const toExplainDetailViewModel = (exhibit: MuseumExhibit): ExplainDetailViewModel => ({
|
||||
export const toExplainExhibitViewModel = (exhibit: MuseumExhibit): ExplainExhibitViewModel => toExplainCardViewModel(exhibit)
|
||||
|
||||
export const toExplainDetailPageViewModel = (exhibit: MuseumExhibit): ExplainDetailPageViewModel => ({
|
||||
id: exhibit.id,
|
||||
name: exhibit.name,
|
||||
artist: exhibit.artist,
|
||||
year: exhibit.year,
|
||||
material: exhibit.material,
|
||||
size: exhibit.size,
|
||||
hall: exhibit.hallName && exhibit.floorLabel
|
||||
? `${exhibit.hallName} ${exhibit.floorLabel}`
|
||||
: exhibit.hallName || exhibit.floorLabel,
|
||||
image: exhibit.image || '/static/exhibit-placeholder.jpg',
|
||||
description: exhibit.description || '该展项介绍待正式内容库补充。',
|
||||
hasAudio: false,
|
||||
audioUnavailableReason: '正式讲解音频尚未接入媒体仓储',
|
||||
poiId: exhibit.poiId
|
||||
title: exhibit.name,
|
||||
subtitle: buildSubtitle(exhibit),
|
||||
contentType: contentTypeFor(exhibit),
|
||||
coverImages: [exhibit.image || '/static/exhibit-placeholder.jpg'].filter(Boolean),
|
||||
hallName: exhibit.hallName,
|
||||
floorLabel: exhibit.floorLabel,
|
||||
summary: exhibit.description || '该讲解内容待正式内容库补充。',
|
||||
body: exhibit.description || '该讲解内容待正式内容库补充。',
|
||||
audio: {
|
||||
status: 'unavailable',
|
||||
unavailableReason: '正式讲解音频尚未接入媒体仓储'
|
||||
},
|
||||
chapters: [],
|
||||
transcript: undefined,
|
||||
location: exhibit.poiId
|
||||
? {
|
||||
poiId: exhibit.poiId,
|
||||
actionText: '查看三维位置',
|
||||
previewOnly: true
|
||||
}
|
||||
: undefined,
|
||||
relatedItems: []
|
||||
})
|
||||
|
||||
export const toExplainDetailViewModel = (exhibit: MuseumExhibit): ExplainDetailViewModel => toExplainDetailPageViewModel(exhibit)
|
||||
|
||||
export const toHallDetailViewModel = (hall: MuseumHall): HallDetailViewModel => ({
|
||||
id: hall.id,
|
||||
name: hall.name,
|
||||
|
||||
Reference in New Issue
Block a user