Files
frontend-miniapp/src/components/explain/ExplainList.vue

970 lines
22 KiB
Vue

<template>
<view class="explain-list">
<view class="spatial-stage">
<view class="stage-copy">
<text class="stage-kicker">空间讲解</text>
<text class="stage-title">{{ activeFloorLabel }} · {{ filteredCards.length }} 个讲解点</text>
<text class="stage-desc">
{{ playableCount }} 个可播放音频 · {{ hallSummaries.length }} 个展厅
</text>
</view>
<view class="stage-map">
<view class="stage-floor">
<text class="stage-floor-text">{{ activeFloorLabel }}</text>
</view>
<view
v-for="(marker, index) in previewMarkers"
:key="marker.id"
class="stage-marker"
:class="`marker-${index + 1}`"
></view>
</view>
</view>
<view class="content-panel">
<view class="panel-handle"></view>
<view class="panel-summary">
<view class="summary-main">
<text class="summary-title">当前楼层 {{ activeFloorLabel }}</text>
<text class="summary-subtitle">
{{ filteredCards.length }} 个讲解点 · {{ unavailableAudioCount }} 个音频待开放
</text>
</view>
<view class="summary-chip">
<text class="summary-chip-text">位置预览</text>
</view>
</view>
<view class="search-box">
<svg class="search-icon" width="20" height="20" viewBox="0 0 20 20" fill="none">
<circle cx="8.5" cy="8.5" r="5.5" stroke="currentColor" stroke-width="1.5"/>
<path d="M12.5 12.5L17 17" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
<input
class="search-input"
type="text"
placeholder="搜索讲解、展厅、主题"
v-model="searchInputValue"
@input="handleSearchInput"
@confirm="handleSearchConfirm"
/>
<view v-if="searchInputValue" class="clear-btn" @tap="handleClearSearch">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
<circle cx="7" cy="7" r="6" fill="#8b9183"/>
<path d="M4.5 4.5L9.5 9.5M9.5 4.5L4.5 9.5" stroke="white" stroke-width="1.5" stroke-linecap="round"/>
</svg>
</view>
</view>
<scroll-view class="panel-scroll" :scroll-y="true" :show-scrollbar="false">
<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>
<template v-else>
<view v-if="featuredCard" class="featured-section">
<view class="section-heading">
<text class="section-title">今日推荐讲解</text>
<text class="section-count">{{ featuredCard.audioStatusText }}</text>
</view>
<view class="featured-card" @tap="handleCardClick(featuredCard)">
<image
v-if="featuredCard.coverImage"
class="featured-cover"
:src="featuredCard.coverImage"
mode="aspectFill"
/>
<view v-else class="featured-cover placeholder">
<text class="placeholder-text">{{ contentTypeText(featuredCard.contentType).slice(0, 1) }}</text>
</view>
<view class="featured-body">
<text class="featured-title">{{ featuredCard.title }}</text>
<text class="featured-meta">{{ cardMeta(featuredCard) }}</text>
<text class="featured-summary">{{ featuredCard.summary || '正式讲解文案待内容库接入后完善。' }}</text>
<view class="featured-actions">
<view
v-if="featuredCard.audioStatus === 'playable'"
class="action-btn primary"
@tap.stop="handleAudioClick(featuredCard)"
>
<text class="action-text primary">开始讲解</text>
</view>
<view v-else class="action-btn disabled">
<text class="action-text disabled">{{ featuredCard.audioStatusText }}</text>
</view>
<view
v-if="featuredCard.poiId"
class="action-btn secondary"
@tap.stop="handleLocationClick(featuredCard)"
>
<text class="action-text">查看位置</text>
</view>
</view>
</view>
</view>
</view>
<view class="filter-section">
<view
v-for="filter in resolvedFilters"
:key="filter.id"
class="filter-chip"
:class="{ active: activeFilter === filter.id }"
@tap="handleFilterChange(filter.id)"
>
<text class="filter-text">{{ filter.label }}</text>
<text v-if="typeof filter.count === 'number'" class="filter-count">{{ filter.count }}</text>
</view>
</view>
<view v-if="hallSummaries.length" class="hall-section">
<view class="section-heading">
<text class="section-title">展厅讲解</text>
<text class="section-count">{{ hallSummaries.length }} 个展厅</text>
</view>
<view class="hall-grid">
<view
v-for="hall in hallSummaries"
:key="hall.name"
class="hall-chip"
@tap="handleHallFilter(hall.name)"
>
<text class="hall-name">{{ hall.name }}</text>
<text class="hall-count">{{ hall.count }}</text>
</view>
</view>
</view>
<view class="list-section">
<view class="section-heading">
<text class="section-title">讲解列表</text>
<text class="section-count">{{ displayedCards.length }} </text>
</view>
<view v-if="displayedCards.length" class="card-list">
<view
v-for="card in displayedCards"
:key="card.id"
class="explain-card"
@tap="handleCardClick(card)"
>
<view class="card-main">
<view class="card-title-row">
<text class="card-title">{{ card.title }}</text>
<view class="status-pill" :class="`status-${card.audioStatus}`">
<text class="status-text">{{ card.audioStatusText }}</text>
</view>
</view>
<text class="card-meta">{{ cardMeta(card) }}</text>
<text v-if="card.summary" class="card-summary">{{ card.summary }}</text>
<view class="badge-row">
<text
v-for="badge in normalizedBadges(card)"
:key="`${card.id}-${badge}`"
class="badge"
>
{{ badge }}
</text>
</view>
</view>
<view class="card-actions">
<view
v-if="card.audioStatus === 'playable'"
class="small-action primary"
@tap.stop="handleAudioClick(card)"
>
<text class="small-action-text primary">播放</text>
</view>
<view class="small-action" @tap.stop="handleCardClick(card)">
<text class="small-action-text">详情</text>
</view>
<view
v-if="card.poiId"
class="small-action"
@tap.stop="handleLocationClick(card)"
>
<text class="small-action-text">位置</text>
</view>
</view>
</view>
</view>
<view v-else class="state-block empty">
<text class="state-title">未找到相关讲解</text>
<text class="state-desc">试试展厅名称楼层或主题关键词</text>
</view>
</view>
</template>
</scroll-view>
</view>
</view>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import type {
ExplainCardViewModel
} from '@/view-models/explainViewModels'
export interface ExplainFilterOption {
id: 'all' | 'hall' | 'theme' | 'nearby' | 'playable'
label: string
count?: number
}
export type Exhibit = ExplainCardViewModel
const props = withDefaults(defineProps<{
cards?: ExplainCardViewModel[]
exhibits?: ExplainCardViewModel[]
filters?: ExplainFilterOption[]
loading?: boolean
error?: string
hotSearches?: string[]
}>(), {
cards: () => [],
exhibits: () => [],
filters: () => [],
loading: false,
error: '',
hotSearches: () => []
})
const emit = defineEmits<{
exhibitClick: [card: ExplainCardViewModel]
featuredClick: [card: ExplainCardViewModel]
audioClick: [card: ExplainCardViewModel]
locationClick: [card: ExplainCardViewModel]
filterChange: [filter: ExplainFilterOption['id']]
search: [keyword: string]
}>()
const searchInputValue = ref('')
const activeFilter = ref<ExplainFilterOption['id']>('all')
const activeHallName = ref('')
const cards = computed(() => props.cards.length ? props.cards : props.exhibits)
const playableCount = computed(() => cards.value.filter((card) => card.audioStatus === 'playable').length)
const unavailableAudioCount = computed(() => cards.value.filter((card) => card.audioStatus === 'unavailable').length)
const hallSummaries = computed(() => {
const result = new Map<string, number>()
cards.value.forEach((card) => {
const hallName = card.hallName || '未分配展厅'
result.set(hallName, (result.get(hallName) || 0) + 1)
})
return Array.from(result.entries())
.map(([name, count]) => ({ name, count }))
.sort((a, b) => b.count - a.count)
})
const activeFloorLabel = computed(() => {
const counts = new Map<string, number>()
cards.value.forEach((card) => {
if (card.floorLabel) {
counts.set(card.floorLabel, (counts.get(card.floorLabel) || 0) + 1)
}
})
return Array.from(counts.entries()).sort((a, b) => b[1] - a[1])[0]?.[0] || '全馆'
})
const previewMarkers = computed(() => cards.value.slice(0, 5))
const resolvedFilters = computed<ExplainFilterOption[]>(() => props.filters.length ? props.filters : [
{ id: 'all', label: '全部', count: cards.value.length },
{ id: 'hall', label: '展厅', count: hallSummaries.value.length },
{ id: 'theme', label: '主题', count: props.hotSearches.length || hallSummaries.value.length },
{ id: 'nearby', label: '附近', count: cards.value.filter((card) => card.floorLabel === activeFloorLabel.value).length },
{ id: 'playable', label: '可播放', count: playableCount.value }
])
const keywordMatchedCards = computed(() => {
const keyword = searchInputValue.value.trim().toLowerCase()
if (!keyword) return cards.value
return cards.value.filter((card) => [
card.title,
card.subtitle,
card.summary,
card.hallName,
card.floorLabel,
card.audioStatusText,
contentTypeText(card.contentType),
...card.badges
].filter(Boolean).join(' ').toLowerCase().includes(keyword))
})
const filteredCards = computed(() => {
let result = keywordMatchedCards.value
if (activeHallName.value) {
result = result.filter((card) => card.hallName === activeHallName.value)
}
if (activeFilter.value === 'nearby') {
result = result.filter((card) => card.floorLabel === activeFloorLabel.value)
}
if (activeFilter.value === 'playable') {
result = result.filter((card) => card.audioStatus === 'playable')
}
return result
})
const displayedCards = computed(() => filteredCards.value)
const featuredCard = computed(() => cards.value.find((card) => card.audioStatus === 'playable') || cards.value[0])
const contentTypeText = (type: ExplainCardViewModel['contentType']) => {
const textMap: Record<ExplainCardViewModel['contentType'], string> = {
hall: '展厅讲解',
zone: '展区讲解',
exhibit: '讲解点',
theme: '主题讲解'
}
return textMap[type]
}
const cardMeta = (card: ExplainCardViewModel) => [
card.hallName,
card.floorLabel,
card.durationLabel,
card.languageLabel,
contentTypeText(card.contentType)
].filter(Boolean).join(' · ')
const normalizedBadges = (card: ExplainCardViewModel) => {
const badges = card.badges.length ? card.badges : [contentTypeText(card.contentType)]
return badges.slice(0, 3)
}
const handleSearchInput = () => {
emit('search', searchInputValue.value)
}
const handleSearchConfirm = () => {
emit('search', searchInputValue.value.trim())
}
const handleClearSearch = () => {
searchInputValue.value = ''
activeHallName.value = ''
emit('search', '')
}
const handleFilterChange = (filter: ExplainFilterOption['id']) => {
activeFilter.value = filter
if (filter !== 'hall') {
activeHallName.value = ''
}
emit('filterChange', filter)
}
const handleHallFilter = (hallName: string) => {
activeFilter.value = 'hall'
activeHallName.value = activeHallName.value === hallName ? '' : hallName
emit('filterChange', 'hall')
}
const handleCardClick = (card: ExplainCardViewModel) => {
emit('exhibitClick', card)
}
const handleAudioClick = (card: ExplainCardViewModel) => {
emit('audioClick', card)
}
const handleLocationClick = (card: ExplainCardViewModel) => {
emit('locationClick', card)
}
</script>
<style scoped lang="scss">
.explain-list {
position: relative;
width: 100%;
height: 100%;
overflow: hidden;
background: #edf0ea;
}
.spatial-stage {
position: absolute;
top: 44px;
left: 0;
right: 0;
height: 298px;
padding: 18px 16px 0;
box-sizing: border-box;
background: #dfe5dc;
}
.stage-copy {
position: relative;
z-index: 2;
width: 220px;
}
.stage-kicker,
.stage-title,
.stage-desc {
display: block;
}
.stage-kicker {
font-size: 12px;
line-height: 16px;
color: #5e6658;
}
.stage-title {
margin-top: 4px;
font-size: 22px;
line-height: 30px;
font-weight: 700;
color: #151713;
}
.stage-desc {
margin-top: 6px;
font-size: 13px;
line-height: 18px;
color: #5e6658;
}
.stage-map {
position: absolute;
left: 26px;
right: 26px;
bottom: 38px;
height: 128px;
border: 1px solid rgba(88, 95, 78, 0.2);
border-radius: 8px;
background: #f4f5ef;
overflow: hidden;
}
.stage-map::before,
.stage-map::after {
content: '';
position: absolute;
left: 32px;
right: 32px;
height: 1px;
background: rgba(88, 95, 78, 0.2);
}
.stage-map::before {
top: 42px;
}
.stage-map::after {
top: 84px;
}
.stage-floor {
position: absolute;
right: 12px;
top: 12px;
min-width: 44px;
height: 28px;
padding: 0 10px;
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
background: #151713;
border-radius: 6px;
}
.stage-floor-text {
font-size: 12px;
font-weight: 600;
color: var(--museum-accent);
}
.stage-marker {
position: absolute;
width: 14px;
height: 14px;
background: #2f7d59;
border: 3px solid #ffffff;
border-radius: 50%;
box-shadow: 0 3px 8px rgba(0, 0, 0, 0.2);
}
.marker-1 {
left: 28%;
top: 36%;
}
.marker-2 {
left: 48%;
top: 54%;
}
.marker-3 {
left: 62%;
top: 30%;
}
.marker-4 {
left: 74%;
top: 62%;
}
.marker-5 {
left: 38%;
top: 68%;
}
.content-panel {
position: absolute;
left: 0;
right: 0;
top: 232px;
bottom: 0;
display: flex;
flex-direction: column;
box-sizing: border-box;
background: #ffffff;
border-radius: 16px 16px 0 0;
box-shadow: 0 -8px 28px rgba(25, 28, 21, 0.08);
z-index: 10;
}
.panel-handle {
align-self: center;
width: 64px;
height: 4px;
margin-top: 8px;
border-radius: 2px;
background: #d9ddd3;
}
.panel-summary {
flex-shrink: 0;
padding: 12px 16px 10px;
display: flex;
align-items: center;
justify-content: space-between;
box-sizing: border-box;
}
.summary-main {
min-width: 0;
}
.summary-title,
.summary-subtitle {
display: block;
}
.summary-title {
font-size: 18px;
line-height: 25px;
font-weight: 700;
color: #151713;
}
.summary-subtitle {
margin-top: 2px;
font-size: 12px;
line-height: 16px;
color: #697064;
}
.summary-chip {
flex-shrink: 0;
height: 28px;
padding: 0 10px;
display: flex;
align-items: center;
justify-content: center;
background: #eef1e8;
border-radius: 6px;
}
.summary-chip-text {
font-size: 12px;
color: #495143;
}
.search-box {
flex-shrink: 0;
margin: 0 16px 12px;
height: 42px;
display: flex;
align-items: center;
box-sizing: border-box;
background: #f5f6f1;
border: 1px solid #e2e5dc;
border-radius: 8px;
padding: 0 12px;
}
.search-icon {
flex-shrink: 0;
color: #596052;
margin-right: 8px;
}
.search-input {
flex: 1;
min-width: 0;
height: 40px;
font-size: 14px;
color: #151713;
background: transparent;
}
.clear-btn {
flex-shrink: 0;
width: 22px;
height: 22px;
display: flex;
align-items: center;
justify-content: center;
}
.panel-scroll {
flex: 1;
height: 0;
padding: 0 16px;
box-sizing: border-box;
}
.section-heading {
margin: 12px 0 10px;
display: flex;
align-items: center;
justify-content: space-between;
}
.section-title {
font-size: 15px;
line-height: 21px;
font-weight: 700;
color: #151713;
}
.section-count {
font-size: 12px;
color: #788072;
}
.featured-card {
display: flex;
padding: 10px;
box-sizing: border-box;
background: #f7f8f3;
border: 1px solid #ebece5;
border-radius: 8px;
}
.featured-cover {
flex-shrink: 0;
width: 82px;
height: 94px;
border-radius: 8px;
background: #e5e8df;
}
.featured-cover.placeholder {
display: flex;
align-items: center;
justify-content: center;
}
.placeholder-text {
font-size: 18px;
font-weight: 700;
color: #68725d;
}
.featured-body {
flex: 1;
min-width: 0;
margin-left: 12px;
}
.featured-title,
.featured-meta,
.featured-summary {
display: block;
}
.featured-title {
font-size: 17px;
line-height: 24px;
font-weight: 700;
color: #151713;
}
.featured-meta,
.card-meta {
margin-top: 3px;
font-size: 12px;
line-height: 17px;
color: #68725d;
}
.featured-summary,
.card-summary {
margin-top: 6px;
font-size: 12px;
line-height: 18px;
color: #4f574b;
display: -webkit-box;
overflow: hidden;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.featured-actions,
.card-actions,
.filter-section,
.badge-row,
.hall-grid {
display: flex;
align-items: center;
}
.featured-actions {
margin-top: 10px;
gap: 8px;
}
.action-btn,
.small-action {
height: 32px;
padding: 0 12px;
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
border-radius: 8px;
background: #ffffff;
border: 1px solid #d8dbd1;
}
.action-btn.primary,
.small-action.primary {
background: #151713;
border-color: #151713;
}
.action-btn.disabled {
background: #eef1e8;
border-color: #eef1e8;
}
.action-text,
.small-action-text {
font-size: 13px;
color: #151713;
}
.action-text.primary,
.small-action-text.primary {
color: var(--museum-accent);
}
.action-text.disabled {
color: #68725d;
}
.filter-section {
margin-top: 14px;
gap: 8px;
overflow-x: auto;
padding-bottom: 2px;
}
.filter-chip {
flex-shrink: 0;
min-width: 58px;
height: 34px;
padding: 0 12px;
display: flex;
align-items: center;
justify-content: center;
gap: 5px;
box-sizing: border-box;
border-radius: 8px;
background: #f1f3ed;
}
.filter-chip.active {
background: #151713;
}
.filter-text,
.filter-count {
font-size: 13px;
color: #4f574b;
}
.filter-count {
color: #8b9385;
}
.filter-chip.active .filter-text,
.filter-chip.active .filter-count {
color: var(--museum-accent);
}
.hall-grid {
flex-wrap: wrap;
gap: 8px;
}
.hall-chip {
width: calc(50% - 4px);
height: 42px;
padding: 0 12px;
display: flex;
align-items: center;
justify-content: space-between;
box-sizing: border-box;
background: #f7f8f3;
border: 1px solid #ebece5;
border-radius: 8px;
}
.hall-name {
font-size: 13px;
color: #151713;
}
.hall-count {
font-size: 13px;
font-weight: 700;
color: #68725d;
}
.card-list {
padding-bottom: calc(28px + env(safe-area-inset-bottom));
}
.explain-card {
padding: 12px;
margin-bottom: 10px;
box-sizing: border-box;
background: #f7f8f3;
border: 1px solid #ebece5;
border-radius: 8px;
}
.card-title-row {
display: flex;
align-items: flex-start;
gap: 8px;
}
.card-title {
flex: 1;
min-width: 0;
font-size: 16px;
line-height: 22px;
font-weight: 700;
color: #151713;
}
.status-pill {
flex-shrink: 0;
height: 24px;
padding: 0 8px;
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
border-radius: 6px;
background: #eef1e8;
}
.status-playable {
background: #e5e600;
}
.status-text {
font-size: 11px;
color: #4f574b;
}
.status-playable .status-text {
color: #151713;
font-weight: 600;
}
.badge-row {
flex-wrap: wrap;
gap: 6px;
margin-top: 8px;
}
.badge {
height: 22px;
padding: 0 8px;
display: flex;
align-items: center;
box-sizing: border-box;
background: #ecefe7;
border-radius: 6px;
font-size: 11px;
color: #596052;
}
.card-actions {
margin-top: 10px;
gap: 8px;
}
.small-action {
height: 30px;
}
.state-block {
margin: 30px 0;
padding: 22px 16px;
box-sizing: border-box;
text-align: center;
background: #f7f8f3;
border: 1px solid #ebece5;
border-radius: 8px;
}
.state-title,
.state-desc {
display: block;
}
.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>