chore: freeze guide and explain update

This commit is contained in:
lyf
2026-06-24 18:00:25 +08:00
parent feb7310a46
commit 67c6609ae6
104 changed files with 3203572 additions and 40713 deletions

View File

@@ -1,38 +1,30 @@
<template>
<view class="explain-list">
<view class="spatial-stage">
<view class="stage-copy">
<text class="stage-kicker">SGS 场景讲解</text>
<text class="stage-title">{{ activeFloorLabel }} · {{ filteredCards.length }} 个讲解内容</text>
<text class="stage-desc">
{{ hallSummaries.length }} 个场景空间 · {{ unavailableAudioCount }} 个音频待同步
</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 }} 个音频待同步
<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="summary-chip">
<text class="summary-chip-text">位置预览</text>
<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>
@@ -57,19 +49,18 @@
</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-if="loading" class="state-block">
<text class="state-title">正在加载讲解内容</text>
<text class="state-desc">稍后将展示当前楼层的场景空间与展品讲解</text>
</view>
<view v-else-if="error" class="state-block error">
<text class="state-title">讲解内容加载失败</text>
<text class="state-desc">{{ error }}</text>
</view>
<view v-else-if="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">
<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>
@@ -87,7 +78,7 @@
<view class="featured-body">
<text class="featured-title">{{ featuredCard.title }}</text>
<text class="featured-meta">{{ cardMeta(featuredCard) }}</text>
<text class="featured-summary">{{ featuredCard.summary || '讲解文案来自 SGS 场景设置数据,正式 CMS 接入后可继续替换。' }}</text>
<text class="featured-summary">{{ featuredCard.summary || '讲解暂无文稿。' }}</text>
<view class="featured-actions">
<view
v-if="featuredCard.audioStatus === 'playable'"
@@ -100,11 +91,11 @@
<text class="action-text disabled">{{ featuredCard.audioStatusText }}</text>
</view>
<view
v-if="featuredCard.poiId"
v-if="featuredCard.poiId && featuredCard.locationActionText"
class="action-btn secondary"
@tap.stop="handleLocationClick(featuredCard)"
>
<text class="action-text">查看位置</text>
<text class="action-text">{{ featuredCard.locationActionText }}</text>
</view>
</view>
</view>
@@ -124,28 +115,38 @@
</view>
</view>
<view v-if="hallSummaries.length" class="hall-section">
<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>
<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)"
>
<text class="hall-name">{{ hall.name }}</text>
<text class="hall-count">{{ hall.count }}</text>
<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">讲解列表</text>
<text class="section-count">{{ displayedCards.length }} </text>
<text class="section-title">{{ listTitle }}</text>
<text class="section-count">{{ listCountText }}</text>
</view>
<view v-if="displayedCards.length" class="card-list">
@@ -186,14 +187,34 @@
<text class="small-action-text">详情</text>
</view>
<view
v-if="card.poiId"
v-if="card.poiId && card.locationActionText"
class="small-action"
@tap.stop="handleLocationClick(card)"
>
<text class="small-action-text">位置</text>
<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">
@@ -201,9 +222,8 @@
<text class="state-desc">试试空间名称标本名称楼层或主题关键词</text>
</view>
</view>
</template>
</scroll-view>
</view>
</template>
</scroll-view>
</view>
</template>
@@ -228,13 +248,23 @@ const props = withDefaults(defineProps<{
loading?: boolean
error?: string
hotSearches?: string[]
listTitle?: string
initialRenderLimit?: number
pageSize?: number
showBrowseAll?: boolean
pageMode?: 'home' | 'all'
}>(), {
cards: () => [],
exhibits: () => [],
filters: () => [],
loading: false,
error: '',
hotSearches: () => []
hotSearches: () => [],
listTitle: '讲解列表',
initialRenderLimit: 16,
pageSize: 24,
showBrowseAll: false,
pageMode: 'home'
})
const emit = defineEmits<{
@@ -242,87 +272,19 @@ const emit = defineEmits<{
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 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 isAllListMode = computed(() => props.pageMode === 'all')
const contentTypeText = (type: ExplainCardViewModel['contentType']) => {
const textMap: Record<ExplainCardViewModel['contentType'], string> = {
@@ -334,6 +296,133 @@ const contentTypeText = (type: ExplainCardViewModel['contentType']) => {
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,
@@ -347,34 +436,57 @@ const normalizedBadges = (card: ExplainCardViewModel) => {
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') {
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)
}
@@ -386,6 +498,21 @@ const handleAudioClick = (card: ExplainCardViewModel) => {
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">
@@ -394,39 +521,37 @@ const handleLocationClick = (card: ExplainCardViewModel) => {
width: 100%;
height: 100%;
overflow: hidden;
background: #edf0ea;
background: #f6f7f4;
}
.spatial-stage {
position: absolute;
top: 44px;
left: 0;
right: 0;
height: 298px;
padding: 18px 16px 0;
.explain-scroll {
height: 100%;
padding: 0 16px calc(28px + env(safe-area-inset-bottom));
box-sizing: border-box;
background: #dfe5dc;
}
.stage-copy {
position: relative;
z-index: 2;
width: 220px;
.explain-hero {
padding: 18px 0 14px;
box-sizing: border-box;
}
.stage-kicker,
.stage-title,
.stage-desc {
.hero-copy {
min-width: 0;
}
.hero-kicker,
.hero-title,
.hero-desc {
display: block;
}
.stage-kicker {
.hero-kicker {
font-size: 12px;
line-height: 16px;
color: #5e6658;
}
.stage-title {
.hero-title {
margin-top: 4px;
font-size: 22px;
line-height: 30px;
@@ -434,174 +559,67 @@ const handleLocationClick = (card: ExplainCardViewModel) => {
color: #151713;
}
.stage-desc {
.hero-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;
.hero-meta-row {
margin-top: 14px;
display: flex;
align-items: center;
gap: 8px;
overflow-x: auto;
}
.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;
.hero-chip {
height: 28px;
padding: 0 10px;
padding: 0 11px;
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
flex-shrink: 0;
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;
.hero-browse-all {
height: 28px;
padding: 0 10px;
padding: 0 13px;
display: flex;
align-items: center;
justify-content: center;
background: #eef1e8;
box-sizing: border-box;
flex-shrink: 0;
background: #ffffff;
border: 1px solid #d8dbd1;
border-radius: 6px;
}
.summary-chip-text {
.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 {
flex-shrink: 0;
margin: 0 16px 12px;
margin: 0 0 16px;
height: 42px;
display: flex;
align-items: center;
@@ -636,13 +654,6 @@ const handleLocationClick = (card: ExplainCardViewModel) => {
justify-content: center;
}
.panel-scroll {
flex: 1;
height: 0;
padding: 0 16px;
box-sizing: border-box;
}
.section-heading {
margin: 12px 0 10px;
display: flex;
@@ -830,32 +841,106 @@ const handleLocationClick = (card: ExplainCardViewModel) => {
.hall-chip {
width: calc(50% - 4px);
height: 42px;
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(28px + 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;