1055 lines
26 KiB
Vue
1055 lines
26 KiB
Vue
<template>
|
||
<view class="explain-list">
|
||
<scroll-view
|
||
class="explain-scroll"
|
||
:scroll-y="true"
|
||
:show-scrollbar="false"
|
||
:lower-threshold="120"
|
||
@scrolltolower="handleScrollToLower"
|
||
>
|
||
<view class="explain-hero">
|
||
<view class="hero-copy">
|
||
<text class="hero-kicker">{{ isAllListMode ? '全部讲解' : '馆内讲解' }}</text>
|
||
<text class="hero-title">{{ heroTitle }}</text>
|
||
<text class="hero-desc">
|
||
{{ heroDescription }}
|
||
</text>
|
||
</view>
|
||
<view class="hero-meta-row">
|
||
<view v-if="showBrowseAll && !isAllListMode" class="hero-browse-all" @tap="handleBrowseAll">
|
||
<text class="hero-browse-all-text">全部</text>
|
||
</view>
|
||
<view class="hero-chip">
|
||
<text class="hero-chip-text">位置预览</text>
|
||
</view>
|
||
<view class="hero-chip muted">
|
||
<text class="hero-chip-text">{{ textOnlyCount }} 个图文讲解</text>
|
||
</view>
|
||
</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>
|
||
|
||
<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 && !isAllListMode" 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 && featuredCard.locationActionText"
|
||
class="action-btn secondary"
|
||
@tap.stop="handleLocationClick(featuredCard)"
|
||
>
|
||
<text class="action-text">{{ featuredCard.locationActionText }}</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 && (!isAllListMode || activeFilter === 'hall')" 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"
|
||
:class="{ active: activeHallName === hall.name }"
|
||
@tap="handleHallFilter(hall.name)"
|
||
>
|
||
<view class="hall-chip-main">
|
||
<text class="hall-name">{{ hall.name }}</text>
|
||
<text class="hall-count">{{ hall.count }}</text>
|
||
</view>
|
||
<view
|
||
v-if="hall.id"
|
||
class="hall-detail-action"
|
||
@tap.stop="handleHallDetailClick(hall.id)"
|
||
>
|
||
<text class="hall-detail-text">查看空间</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<view class="list-section">
|
||
<view class="section-heading">
|
||
<text class="section-title">{{ listTitle }}</text>
|
||
<text class="section-count">{{ listCountText }}</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 && card.locationActionText"
|
||
class="small-action"
|
||
@tap.stop="handleLocationClick(card)"
|
||
>
|
||
<text class="small-action-text">{{ card.locationActionText }}</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<view v-if="hasMoreCards" class="list-more">
|
||
<view
|
||
v-if="showBrowseAll"
|
||
class="more-action primary"
|
||
@tap="handleBrowseAll"
|
||
>
|
||
<text class="more-action-text primary">查看全部 {{ filteredCards.length }} 条</text>
|
||
</view>
|
||
<view
|
||
v-else
|
||
class="more-action"
|
||
@tap="handleLoadMore"
|
||
>
|
||
<text class="more-action-text">加载更多 {{ nextLoadCount }} 条</text>
|
||
</view>
|
||
<text class="more-desc">
|
||
已展示 {{ displayedCards.length }} 条,剩余 {{ remainingCardsCount }} 条
|
||
</text>
|
||
</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>
|
||
</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[]
|
||
listTitle?: string
|
||
initialRenderLimit?: number
|
||
pageSize?: number
|
||
showBrowseAll?: boolean
|
||
pageMode?: 'home' | 'all'
|
||
}>(), {
|
||
cards: () => [],
|
||
exhibits: () => [],
|
||
filters: () => [],
|
||
loading: false,
|
||
error: '',
|
||
hotSearches: () => [],
|
||
listTitle: '讲解列表',
|
||
initialRenderLimit: 16,
|
||
pageSize: 24,
|
||
showBrowseAll: false,
|
||
pageMode: 'home'
|
||
})
|
||
|
||
const emit = defineEmits<{
|
||
exhibitClick: [card: ExplainCardViewModel]
|
||
featuredClick: [card: ExplainCardViewModel]
|
||
audioClick: [card: ExplainCardViewModel]
|
||
locationClick: [card: ExplainCardViewModel]
|
||
hallClick: [hallId: string]
|
||
filterChange: [filter: ExplainFilterOption['id']]
|
||
search: [keyword: string]
|
||
browseAll: []
|
||
}>()
|
||
|
||
const searchInputValue = ref('')
|
||
const activeFilter = ref<ExplainFilterOption['id']>('all')
|
||
const activeHallName = ref('')
|
||
const visibleCardLimit = ref(props.initialRenderLimit)
|
||
|
||
const cards = computed(() => props.cards.length ? props.cards : props.exhibits)
|
||
const isAllListMode = computed(() => props.pageMode === 'all')
|
||
|
||
const contentTypeText = (type: ExplainCardViewModel['contentType']) => {
|
||
const textMap: Record<ExplainCardViewModel['contentType'], string> = {
|
||
hall: '空间讲解',
|
||
zone: '展区讲解',
|
||
exhibit: '展品讲解',
|
||
theme: '主题讲解'
|
||
}
|
||
return textMap[type]
|
||
}
|
||
|
||
const searchableCards = computed(() => cards.value.map((card) => ({
|
||
card,
|
||
searchText: [
|
||
card.title,
|
||
card.subtitle,
|
||
card.summary,
|
||
card.hallName,
|
||
card.floorLabel,
|
||
card.audioStatusText,
|
||
contentTypeText(card.contentType),
|
||
...card.badges
|
||
].filter(Boolean).join(' ').toLowerCase()
|
||
})))
|
||
|
||
const summarizeHalls = (sourceCards: ExplainCardViewModel[]) => {
|
||
const result = new Map<string, { id?: string, count: number }>()
|
||
sourceCards.forEach((card) => {
|
||
if (!card.hallName) return
|
||
|
||
const hallName = card.hallName
|
||
const existing = result.get(hallName)
|
||
result.set(hallName, {
|
||
id: existing?.id || card.hallId,
|
||
count: (existing?.count || 0) + 1
|
||
})
|
||
})
|
||
return Array.from(result.entries())
|
||
.map(([name, summary]) => ({ name, id: summary.id, count: summary.count }))
|
||
.sort((a, b) => b.count - a.count)
|
||
}
|
||
|
||
const getDominantFloorLabel = (sourceCards: ExplainCardViewModel[]) => {
|
||
const counts = new Map<string, number>()
|
||
sourceCards.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 keywordMatchedCards = computed(() => {
|
||
const keyword = searchInputValue.value.trim().toLowerCase()
|
||
if (!keyword) return cards.value
|
||
|
||
return searchableCards.value
|
||
.filter((item) => item.searchText.includes(keyword))
|
||
.map((item) => item.card)
|
||
})
|
||
|
||
const hallSummaries = computed(() => summarizeHalls(keywordMatchedCards.value))
|
||
const baseFilteredCards = computed(() => {
|
||
let result = keywordMatchedCards.value
|
||
|
||
if (activeHallName.value) {
|
||
result = result.filter((card) => card.hallName === activeHallName.value)
|
||
}
|
||
|
||
return result
|
||
})
|
||
const dominantFloorLabel = computed(() => getDominantFloorLabel(baseFilteredCards.value))
|
||
const trimmedSearchKeyword = computed(() => searchInputValue.value.trim())
|
||
|
||
const filteredCards = computed(() => {
|
||
let result = baseFilteredCards.value
|
||
|
||
if (activeFilter.value === 'nearby') {
|
||
result = result.filter((card) => card.floorLabel === dominantFloorLabel.value)
|
||
}
|
||
|
||
if (activeFilter.value === 'playable') {
|
||
result = result.filter((card) => card.audioStatus === 'playable')
|
||
}
|
||
|
||
return result
|
||
})
|
||
|
||
const activeFloorLabel = computed(() => getDominantFloorLabel(filteredCards.value))
|
||
const heroTitle = computed(() => {
|
||
if (trimmedSearchKeyword.value) {
|
||
return `搜索“${trimmedSearchKeyword.value}” · ${filteredCards.value.length} 条结果`
|
||
}
|
||
if (activeFilter.value === 'playable') {
|
||
return `可播放讲解 · ${filteredCards.value.length} 条`
|
||
}
|
||
if (activeFilter.value === 'hall' && activeHallName.value) {
|
||
return `${activeHallName.value} · ${filteredCards.value.length} 条`
|
||
}
|
||
if (activeFilter.value === 'nearby') {
|
||
return `${activeFloorLabel.value}附近 · ${filteredCards.value.length} 条`
|
||
}
|
||
if (isAllListMode.value) {
|
||
return `${filteredCards.value.length} 条讲解`
|
||
}
|
||
return `馆内讲解 · ${filteredCards.value.length} 条`
|
||
})
|
||
const heroDescription = computed(() => (
|
||
isAllListMode.value
|
||
? `${playableCount.value} 个可播放音频 · ${textOnlyCount.value} 个图文讲解`
|
||
: `${hallSummaries.value.length} 个展厅 · ${playableCount.value} 个可播放音频`
|
||
))
|
||
const playableCount = computed(() => filteredCards.value.filter((card) => card.audioStatus === 'playable').length)
|
||
const textOnlyCount = computed(() => filteredCards.value.filter((card) => card.audioStatus !== 'playable').length)
|
||
const displayedCards = computed(() => filteredCards.value.slice(0, visibleCardLimit.value))
|
||
const hasMoreCards = computed(() => displayedCards.value.length < filteredCards.value.length)
|
||
const remainingCardsCount = computed(() => Math.max(filteredCards.value.length - displayedCards.value.length, 0))
|
||
const nextLoadCount = computed(() => Math.min(props.pageSize, remainingCardsCount.value))
|
||
const listCountText = computed(() => (
|
||
hasMoreCards.value
|
||
? `${displayedCards.value.length}/${filteredCards.value.length} 条`
|
||
: `${filteredCards.value.length} 条`
|
||
))
|
||
const defaultFilters = computed<ExplainFilterOption[]>(() => [
|
||
{ id: 'all', label: '全部', count: keywordMatchedCards.value.length },
|
||
{ id: 'hall', label: '展厅', count: hallSummaries.value.length },
|
||
{ id: 'nearby', label: '附近', count: baseFilteredCards.value.filter((card) => card.floorLabel === dominantFloorLabel.value).length },
|
||
{ id: 'playable', label: '可播放', count: baseFilteredCards.value.filter((card) => card.audioStatus === 'playable').length }
|
||
])
|
||
const resolvedFilters = computed<ExplainFilterOption[]>(() => (
|
||
props.filters.length ? props.filters : defaultFilters.value
|
||
).filter((filter) => filter.id !== 'theme'))
|
||
const featuredCard = computed(() => (
|
||
filteredCards.value.find((card) => card.audioStatus === 'playable')
|
||
|| filteredCards.value[0]
|
||
))
|
||
|
||
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 resetDisplayedCards = () => {
|
||
visibleCardLimit.value = props.initialRenderLimit
|
||
}
|
||
|
||
const handleSearchInput = () => {
|
||
resetDisplayedCards()
|
||
emit('search', searchInputValue.value)
|
||
}
|
||
|
||
const handleSearchConfirm = () => {
|
||
resetDisplayedCards()
|
||
emit('search', searchInputValue.value.trim())
|
||
}
|
||
|
||
const handleClearSearch = () => {
|
||
searchInputValue.value = ''
|
||
activeHallName.value = ''
|
||
resetDisplayedCards()
|
||
emit('search', '')
|
||
}
|
||
|
||
const handleFilterChange = (filter: ExplainFilterOption['id']) => {
|
||
if (filter === 'theme') {
|
||
activeFilter.value = 'all'
|
||
activeHallName.value = ''
|
||
resetDisplayedCards()
|
||
emit('filterChange', filter)
|
||
return
|
||
}
|
||
|
||
activeFilter.value = filter
|
||
if (filter === 'hall') {
|
||
activeHallName.value = activeHallName.value || hallSummaries.value[0]?.name || ''
|
||
} else {
|
||
activeHallName.value = ''
|
||
}
|
||
resetDisplayedCards()
|
||
emit('filterChange', filter)
|
||
}
|
||
|
||
const handleHallFilter = (hallName: string) => {
|
||
activeFilter.value = 'hall'
|
||
activeHallName.value = activeHallName.value === hallName ? '' : hallName
|
||
resetDisplayedCards()
|
||
emit('filterChange', 'hall')
|
||
}
|
||
|
||
const handleHallDetailClick = (hallId: string) => {
|
||
emit('hallClick', hallId)
|
||
}
|
||
|
||
const handleCardClick = (card: ExplainCardViewModel) => {
|
||
emit('exhibitClick', card)
|
||
}
|
||
|
||
const handleAudioClick = (card: ExplainCardViewModel) => {
|
||
emit('audioClick', card)
|
||
}
|
||
|
||
const handleLocationClick = (card: ExplainCardViewModel) => {
|
||
emit('locationClick', card)
|
||
}
|
||
|
||
const handleLoadMore = () => {
|
||
if (!hasMoreCards.value) return
|
||
visibleCardLimit.value += props.pageSize
|
||
}
|
||
|
||
const handleScrollToLower = () => {
|
||
if (!props.showBrowseAll) {
|
||
handleLoadMore()
|
||
}
|
||
}
|
||
|
||
const handleBrowseAll = () => {
|
||
emit('browseAll')
|
||
}
|
||
</script>
|
||
|
||
<style scoped lang="scss">
|
||
.explain-list {
|
||
position: relative;
|
||
width: 100%;
|
||
height: 100%;
|
||
overflow: hidden;
|
||
background: #f6f7f4;
|
||
}
|
||
|
||
.explain-scroll {
|
||
height: 100%;
|
||
padding: 0 16px calc(110px + env(safe-area-inset-bottom));
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
.explain-hero {
|
||
padding: 18px 0 14px;
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
.hero-copy {
|
||
min-width: 0;
|
||
}
|
||
|
||
.hero-kicker,
|
||
.hero-title,
|
||
.hero-desc {
|
||
display: block;
|
||
}
|
||
|
||
.hero-kicker {
|
||
font-size: 12px;
|
||
line-height: 16px;
|
||
color: #5e6658;
|
||
}
|
||
|
||
.hero-title {
|
||
margin-top: 4px;
|
||
font-size: 22px;
|
||
line-height: 30px;
|
||
font-weight: 700;
|
||
color: #151713;
|
||
}
|
||
|
||
.hero-desc {
|
||
margin-top: 6px;
|
||
font-size: 13px;
|
||
line-height: 18px;
|
||
color: #5e6658;
|
||
}
|
||
|
||
.hero-meta-row {
|
||
margin-top: 14px;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
overflow-x: auto;
|
||
}
|
||
|
||
.hero-chip {
|
||
height: 28px;
|
||
padding: 0 11px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
box-sizing: border-box;
|
||
flex-shrink: 0;
|
||
background: #151713;
|
||
border-radius: 6px;
|
||
}
|
||
|
||
.hero-browse-all {
|
||
height: 28px;
|
||
padding: 0 13px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
box-sizing: border-box;
|
||
flex-shrink: 0;
|
||
background: #ffffff;
|
||
border: 1px solid #d8dbd1;
|
||
border-radius: 6px;
|
||
}
|
||
|
||
.hero-browse-all-text {
|
||
font-size: 12px;
|
||
font-weight: 600;
|
||
color: #151713;
|
||
}
|
||
|
||
.hero-chip.muted {
|
||
background: #eef1e8;
|
||
}
|
||
|
||
.hero-chip-text {
|
||
font-size: 12px;
|
||
color: var(--museum-accent);
|
||
}
|
||
|
||
.hero-chip.muted .hero-chip-text {
|
||
color: #495143;
|
||
}
|
||
|
||
.search-box {
|
||
margin: 0 0 16px;
|
||
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;
|
||
}
|
||
|
||
.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);
|
||
min-height: 54px;
|
||
padding: 0 12px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 8px;
|
||
box-sizing: border-box;
|
||
background: #f7f8f3;
|
||
border: 1px solid #ebece5;
|
||
border-radius: 8px;
|
||
}
|
||
|
||
.hall-chip.active {
|
||
background: #eef4e8;
|
||
border-color: rgba(103, 118, 90, 0.28);
|
||
}
|
||
|
||
.hall-chip-main {
|
||
min-width: 0;
|
||
}
|
||
|
||
.hall-name {
|
||
display: block;
|
||
font-size: 13px;
|
||
line-height: 18px;
|
||
color: #151713;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.hall-count {
|
||
display: block;
|
||
font-size: 13px;
|
||
line-height: 18px;
|
||
font-weight: 700;
|
||
color: #68725d;
|
||
}
|
||
|
||
.hall-detail-action {
|
||
flex-shrink: 0;
|
||
padding: 4px 8px;
|
||
box-sizing: border-box;
|
||
background: #ffffff;
|
||
border: 1px solid #dfe5d5;
|
||
border-radius: 999px;
|
||
}
|
||
|
||
.hall-detail-text {
|
||
display: block;
|
||
font-size: 12px;
|
||
line-height: 16px;
|
||
color: #546548;
|
||
}
|
||
|
||
.card-list {
|
||
padding-bottom: calc(110px + env(safe-area-inset-bottom));
|
||
}
|
||
|
||
.list-more {
|
||
padding: 6px 0 18px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
gap: 8px;
|
||
}
|
||
|
||
.more-action {
|
||
min-width: 156px;
|
||
height: 36px;
|
||
padding: 0 16px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
box-sizing: border-box;
|
||
background: #ffffff;
|
||
border: 1px solid #d8dbd1;
|
||
border-radius: 8px;
|
||
}
|
||
|
||
.more-action.primary {
|
||
background: #151713;
|
||
border-color: #151713;
|
||
}
|
||
|
||
.more-action-text {
|
||
font-size: 13px;
|
||
color: #151713;
|
||
}
|
||
|
||
.more-action-text.primary {
|
||
color: var(--museum-accent);
|
||
}
|
||
|
||
.more-desc {
|
||
font-size: 12px;
|
||
line-height: 17px;
|
||
color: #788072;
|
||
}
|
||
|
||
.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>
|