diff --git a/src/components/explain/ExplainGuideStopCatalog.vue b/src/components/explain/ExplainGuideStopCatalog.vue
index 16a7998..32c618d 100644
--- a/src/components/explain/ExplainGuideStopCatalog.vue
+++ b/src/components/explain/ExplainGuideStopCatalog.vue
@@ -8,33 +8,21 @@
-
-
-
-
-
- {{ filter.label }}
-
-
-
-
-
正在加载讲解对象稍后将展示该展厅的讲解对象。
讲解对象加载失败{{ error }}
-
-
+
+
{{ stop.name }}
- {{ primaryMeta(stop) }}
- {{ secondaryMeta(stop) }}
+ {{ availabilityLabel(stop) }}
- {{ keyword || activeFilter !== 'all' ? '未找到匹配的讲解对象' : '该展厅暂无讲解对象' }}{{ keyword || activeFilter !== 'all' ? '可尝试调整搜索词或筛选条件。' : '可返回选择其他展厅。' }}
+ 该展厅暂无讲解对象可返回选择其他展厅。
正在加载更多讲解对象
{{ loadMoreError }}
@@ -46,7 +34,7 @@
diff --git a/src/components/explain/ExplainHallSelect.vue b/src/components/explain/ExplainHallSelect.vue
index f25bf24..1164920 100644
--- a/src/components/explain/ExplainHallSelect.vue
+++ b/src/components/explain/ExplainHallSelect.vue
@@ -66,9 +66,6 @@
{{ hall.name }}
- {{ hallCardTheme(hall).englishName }}
- {{ hallFloorMetaText(hall) }}
- GO ›
@@ -190,33 +187,33 @@ const emit = defineEmits<{
retryMore: []
}>()
-const HALL_PREVIEW_BASE = '/static/explain/hall-previews'
+const HALL_PREVIEW_BASE = '/static/icons/halls'
const HALL_PREVIEW_EAGER_COUNT = 4
const loadedHallPreviewIds = ref(new Set())
const failedHallPreviewIds = ref(new Set())
const hallPreviewMap: Record = {
- 宇宙厅: `${HALL_PREVIEW_BASE}/universe.webp`,
- 地球厅: `${HALL_PREVIEW_BASE}/earth.webp`,
- 演化厅: `${HALL_PREVIEW_BASE}/evolution.webp`,
- 恐龙厅: `${HALL_PREVIEW_BASE}/dinosaur.webp`,
- 人类厅: `${HALL_PREVIEW_BASE}/human.webp`,
- 动物厅: `${HALL_PREVIEW_BASE}/biology.webp`,
- 生物厅: `${HALL_PREVIEW_BASE}/biology.webp`,
- 生态厅: `${HALL_PREVIEW_BASE}/ecology.webp`,
- 家园厅: `${HALL_PREVIEW_BASE}/homeland.webp`
+ 宇宙厅: `${HALL_PREVIEW_BASE}/normalized/universe.png`,
+ 地球厅: `${HALL_PREVIEW_BASE}/normalized/earth.png`,
+ 演化厅: `${HALL_PREVIEW_BASE}/normalized/evolution.png`,
+ 恐龙厅: `${HALL_PREVIEW_BASE}/normalized/dinosaur.png`,
+ 人类厅: `${HALL_PREVIEW_BASE}/normalized/human.png`,
+ 动物厅: `${HALL_PREVIEW_BASE}/normalized/biology.png`,
+ 生物厅: `${HALL_PREVIEW_BASE}/normalized/biology.png`,
+ 生态厅: `${HALL_PREVIEW_BASE}/normalized/ecology.png`,
+ 家园厅: `${HALL_PREVIEW_BASE}/normalized/homeland.png`
}
const hallCardThemeMap: Record = {
- 宇宙厅: { color: '#57c7d9', englishName: 'Universe Hall' },
- 地球厅: { color: '#99de00', englishName: 'Earth Hall' },
- 演化厅: { color: '#b896f2', englishName: 'Evolution Hall' },
- 恐龙厅: { color: '#c7a15c', englishName: 'Dinosaur Hall' },
- 人类厅: { color: '#e8e500', englishName: 'Human Hall' },
- 动物厅: { color: '#08c4b5', englishName: 'Biology Hall' },
- 生物厅: { color: '#08c4b5', englishName: 'Biology Hall' },
- 生态厅: { color: '#8fe5b8', englishName: 'Ecology Hall' },
- 家园厅: { color: '#ffc2a6', englishName: 'Homeland Hall' }
+ 宇宙厅: { color: '#65d2e4', englishName: '' },
+ 地球厅: { color: '#b8e51b', englishName: '' },
+ 演化厅: { color: '#cba7ff', englishName: '' },
+ 恐龙厅: { color: '#d9b06c', englishName: '' },
+ 人类厅: { color: '#e9ea16', englishName: '' },
+ 动物厅: { color: '#20c9c0', englishName: '' },
+ 生物厅: { color: '#20c9c0', englishName: '' },
+ 生态厅: { color: '#94e7be', englishName: '' },
+ 家园厅: { color: '#ffc6a5', englishName: '' }
}
const filteredHalls = computed(() => props.halls)
@@ -273,14 +270,6 @@ const handleHallPreviewError = (hallId: string) => {
}
}
-const hallMetaText = (hall: ExplainHallSelectItem) => (
- typeof hall.guideStopCount === 'number'
- ? `${hall.guideStopCount || 0} 个讲解对象`
- : hall.explainCount > 0 ? `${hall.explainCount} 个展项` : '展厅'
-)
-
-const hallFloorMetaText = (hall: ExplainHallSelectItem) => `${hall.floorLabel || '楼层待补充'} · ${hallMetaText(hall)}`
-
const hallCardTheme = (hall: ExplainHallSelectItem) => (
hallCardThemeMap[hall.name.trim()] || { color: '#dce5d5', englishName: '' }
)
@@ -806,4 +795,66 @@ const handleBack = () => {
border: 0;
border-radius: 6px;
}
+
+/* Figma hall-browser layout: title plus a single themed icon per card. */
+.explain-hall-select {
+ background: #f7f9f2;
+}
+
+.explain-page-header {
+ background: #f7f9f2;
+}
+
+.header-back {
+ width: 44px;
+}
+
+.header-back-text {
+ display: none;
+}
+
+.header-back-icon {
+ width: 20px;
+ height: 20px;
+}
+
+.header-back-icon::before {
+ width: 10px;
+ height: 10px;
+ transform: translateX(3px) rotate(45deg);
+}
+
+.explain-scroll.hall-stage {
+ padding: 88px 16px 24px;
+}
+
+.hall-overview-list {
+ grid-auto-rows: 138px;
+}
+
+.hall-overview-card {
+ min-height: 138px;
+}
+
+.hall-overview-card .hall-card-art {
+ top: 50px;
+ right: 10px;
+ width: 72px;
+ height: 72px;
+}
+
+.hall-card-art-image {
+ mix-blend-mode: multiply;
+ filter: grayscale(1) brightness(0.72) contrast(1.65);
+}
+
+.hall-overview-copy {
+ top: 24px;
+}
+
+.hall-overview-card .hall-name {
+ max-width: 96px;
+ font-size: 21px;
+ line-height: 28px;
+}
diff --git a/src/pages/explain/guide-stop-list.vue b/src/pages/explain/guide-stop-list.vue
index 22ea435..703967e 100644
--- a/src/pages/explain/guide-stop-list.vue
+++ b/src/pages/explain/guide-stop-list.vue
@@ -1,9 +1,8 @@
- ([])
+const explainGuideStopItems = ref([])
const explainLoading = ref(false)
const loadingMore = ref(false)
const explainError = ref('')
@@ -42,7 +41,7 @@ const nextPageNo = ref(1)
let requestVersion = 0
let disposed = false
-const toGuideStopItems = (stops: ExplainGuideStop[]): ExplainGuideStopSelectItem[] => stops.map((stop) => ({
+const toGuideStopItems = (stops: ExplainGuideStop[]): ExplainGuideStopCatalogItem[] => stops.map((stop) => ({
id: stop.id,
name: stop.name,
hallId: stop.hallId,
@@ -54,6 +53,7 @@ const toGuideStopItems = (stops: ExplainGuideStop[]): ExplainGuideStopSelectItem
coverImageUrl: stop.imageStatus === 'MISSING' ? undefined : stop.coverImageUrl,
description: stop.description,
hasAudio: stop.hasAudio,
+ hasTextRecord: stop.hasTextRecord,
audioStatus: stop.audioStatus,
guideLevel: stop.guideLevel,
playTargetType: stop.playTargetType,
@@ -67,7 +67,7 @@ const syncPageTitle = (title: string) => {
const loadPage = async (pageNo: number, replace: boolean) => {
const hallId = selectedExplainHallId.value
- if (!hallId || disposed || (!replace && (loadingMore.value || !hasMore.value))) return
+ if (!hallId || disposed || (!replace && (loadingMore.value || !hasMore.value))) return false
const version = ++requestVersion
if (replace) {
explainLoading.value = true
@@ -89,10 +89,12 @@ const loadPage = async (pageNo: number, replace: boolean) => {
}
nextPageNo.value = page.pageNo + 1
hasMore.value = page.hasMore && explainGuideStopItems.value.length < page.total
+ return true
} catch (error) {
if (disposed || version !== requestVersion) return
if (replace) explainError.value = '讲解对象加载失败,请稍后重试'
else loadMoreError.value = '加载更多讲解对象失败,请重试'
+ return false
} finally {
if (!disposed && version === requestVersion) {
explainLoading.value = false
@@ -127,7 +129,7 @@ onUnmounted(() => {
requestVersion += 1
})
-const handleExplainGuideStopClick = (stop: ExplainGuideStopSelectItem) => {
+const handleExplainGuideStopClick = (stop: ExplainGuideStopCatalogItem) => {
const target = stop.playTargetType && stop.playTargetId
? { targetType: stop.playTargetType, targetId: stop.playTargetId }
: normalizeExplainDetailTargetFromGuideStop({ id: stop.id })
diff --git a/static/icons/halls/normalized/animal.png b/static/icons/halls/normalized/animal.png
new file mode 100644
index 0000000..c262bc5
Binary files /dev/null and b/static/icons/halls/normalized/animal.png differ
diff --git a/static/icons/halls/normalized/biology.png b/static/icons/halls/normalized/biology.png
new file mode 100644
index 0000000..c262bc5
Binary files /dev/null and b/static/icons/halls/normalized/biology.png differ
diff --git a/static/icons/halls/normalized/dinosaur.png b/static/icons/halls/normalized/dinosaur.png
new file mode 100644
index 0000000..49bced2
Binary files /dev/null and b/static/icons/halls/normalized/dinosaur.png differ
diff --git a/static/icons/halls/normalized/earth.png b/static/icons/halls/normalized/earth.png
new file mode 100644
index 0000000..9ceb29c
Binary files /dev/null and b/static/icons/halls/normalized/earth.png differ
diff --git a/static/icons/halls/normalized/ecology.png b/static/icons/halls/normalized/ecology.png
new file mode 100644
index 0000000..24bb15d
Binary files /dev/null and b/static/icons/halls/normalized/ecology.png differ
diff --git a/static/icons/halls/normalized/evolution.png b/static/icons/halls/normalized/evolution.png
new file mode 100644
index 0000000..a4a6405
Binary files /dev/null and b/static/icons/halls/normalized/evolution.png differ
diff --git a/static/icons/halls/normalized/homeland.png b/static/icons/halls/normalized/homeland.png
new file mode 100644
index 0000000..20264ff
Binary files /dev/null and b/static/icons/halls/normalized/homeland.png differ
diff --git a/static/icons/halls/normalized/human.png b/static/icons/halls/normalized/human.png
new file mode 100644
index 0000000..ccc991b
Binary files /dev/null and b/static/icons/halls/normalized/human.png differ
diff --git a/static/icons/halls/normalized/universe.png b/static/icons/halls/normalized/universe.png
new file mode 100644
index 0000000..a5000cc
Binary files /dev/null and b/static/icons/halls/normalized/universe.png differ
diff --git a/tests/e2e/explain-hall-page.spec.ts b/tests/e2e/explain-hall-page.spec.ts
index b2e9700..90672a8 100644
--- a/tests/e2e/explain-hall-page.spec.ts
+++ b/tests/e2e/explain-hall-page.spec.ts
@@ -55,15 +55,8 @@ test('hall goes directly to paged guide objects without outline requests', async
expect(layout.first.width).toBeCloseTo(layout.listWidth, 0)
expect(layout.second.y).toBeGreaterThan(layout.first.bottom)
}
- const search = page.getByRole('textbox')
- await search.fill('讲解对象21')
- await expect(page.getByText('讲解对象21', { exact: true })).toBeVisible()
- await search.fill('')
- const audioFilter = page.getByText('有音频', { exact: true })
- await audioFilter.click()
- await expect(page.getByText('讲解对象1', { exact: true })).toBeVisible()
- await expect(page.getByText('讲解对象2', { exact: true })).not.toBeVisible()
- await page.getByText('全部', { exact: true }).click()
+ await expect(page.getByRole('textbox')).toHaveCount(0)
+ await expect(page.locator('.filter-chip')).toHaveCount(0)
await page.locator('.explain-scroll').hover()
await page.mouse.wheel(0, 10000)
await expect(page.getByText('讲解对象21', { exact: true })).toBeVisible()
diff --git a/tests/unit/ExplainGuideStopCatalog.spec.ts b/tests/unit/ExplainGuideStopCatalog.spec.ts
index 79e1e20..f4e38fa 100644
--- a/tests/unit/ExplainGuideStopCatalog.spec.ts
+++ b/tests/unit/ExplainGuideStopCatalog.spec.ts
@@ -13,29 +13,26 @@ const stops = [
const mountCatalog = () => mount(ExplainGuideStopCatalog, { props: { guideStops: stops, selectedHallName: '宇宙厅', hasMore: true } })
describe('ExplainGuideStopCatalog', () => {
- it('renders a stable single-column list with real availability copy and no arrows', () => {
+ it('renders a stable single-column list with one truthful availability tag and no arrows', () => {
const wrapper = mountCatalog()
expect(wrapper.find('.stop-list').exists()).toBe(true)
expect(wrapper.find('.stop-grid').exists()).toBe(false)
expect(wrapper.findAll('.stop-card')).toHaveLength(3)
expect(wrapper.findAll('.stop-cover.placeholder')).toHaveLength(2)
- expect(wrapper.text()).toContain('音频可播放')
- expect(wrapper.text()).toContain('图文可查看')
- expect(wrapper.text()).toContain('暂无可用内容')
+ expect(wrapper.findAll('.stop-status')).toHaveLength(3)
+ expect(wrapper.text()).toContain('音频')
+ expect(wrapper.text()).toContain('图文')
+ expect(wrapper.text()).toContain('暂无内容')
expect(wrapper.find('.hall-arrow').exists()).toBe(false)
expect(wrapper.find('.stop-name').classes()).toContain('stop-name')
})
- it('searches and uses only data-driven fallback filters when guide levels are absent', async () => {
+ it('does not expose search or category controls in the focused list', () => {
const wrapper = mountCatalog()
expect(wrapper.text()).not.toContain('核心展品')
- expect(wrapper.get('input').attributes('placeholder')).toBe('搜索讲解对象')
- await wrapper.get('input').setValue('图文')
- expect(wrapper.findAll('.stop-card')).toHaveLength(1)
- expect(wrapper.emitted('requestAll')).toHaveLength(1)
- await wrapper.get('input').setValue('')
- await wrapper.get('.filter-chip:nth-child(2)').trigger('tap')
- expect(wrapper.findAll('.stop-card')).toHaveLength(1)
+ expect(wrapper.find('input').exists()).toBe(false)
+ expect(wrapper.find('.catalog-controls').exists()).toBe(false)
+ expect(wrapper.find('.filter-chip').exists()).toBe(false)
})
it('keeps loading and empty states available for the single-column list', async () => {
@@ -47,14 +44,12 @@ describe('ExplainGuideStopCatalog', () => {
expect(wrapper.find('.stop-list').exists()).toBe(false)
})
- it('emits card, back, retry, retry-more, and request-all actions', async () => {
+ it('emits card, back, retry, and retry-more actions', async () => {
const wrapper = mountCatalog()
await wrapper.get('.stop-card').trigger('tap')
await wrapper.get('.header-back').trigger('tap')
- await wrapper.get('.filter-chip:nth-child(2)').trigger('tap')
expect(wrapper.emitted('guideStopClick')?.[0][0]).toMatchObject({ id: 'ready' })
expect(wrapper.emitted('back')).toHaveLength(1)
- expect(wrapper.emitted('requestAll')).toHaveLength(1)
await wrapper.setProps({ error: '失败' })
await wrapper.get('.state-retry').trigger('tap')
diff --git a/tests/unit/ExplainGuideStopListNavigation.spec.ts b/tests/unit/ExplainGuideStopListNavigation.spec.ts
index d8c50b1..f3ce7cd 100644
--- a/tests/unit/ExplainGuideStopListNavigation.spec.ts
+++ b/tests/unit/ExplainGuideStopListNavigation.spec.ts
@@ -4,6 +4,7 @@ import { defineComponent } from 'vue'
import { flushPromises, mount } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import ExplainGuideStopListPage from '@/pages/explain/guide-stop-list.vue'
+import { explainUseCase } from '@/usecases/explainUseCase'
const testState = vi.hoisted(() => ({
onLoadHandler: null as null | ((options?: Record) => void)
@@ -29,17 +30,18 @@ const GuidePageFrameStub = defineComponent({
template: ''
})
-const ExplainHallSelectStub = defineComponent({
- name: 'ExplainHallSelect',
- emits: ['back'],
- template: ''
+const ExplainGuideStopCatalogStub = defineComponent({
+ name: 'ExplainGuideStopCatalog',
+ props: ['guideStops', 'selectedHallName', 'loading', 'loadingMore', 'hasMore', 'error', 'loadMoreError'],
+ emits: ['back', 'retry', 'retryMore', 'guideStopClick'],
+ template: ''
})
const mountGuideStopList = () => mount(ExplainGuideStopListPage, {
global: {
stubs: {
GuidePageFrame: GuidePageFrameStub,
- ExplainHallSelect: ExplainHallSelectStub
+ ExplainGuideStopCatalog: ExplainGuideStopCatalogStub
}
}
})
@@ -53,6 +55,8 @@ beforeEach(() => {
setNavigationBarTitle: vi.fn()
})
vi.stubGlobal('getCurrentPages', vi.fn(() => []))
+ vi.mocked(explainUseCase.listGuideStopsPageByHall).mockReset()
+ vi.mocked(explainUseCase.listGuideStopsPageByHall).mockResolvedValue({ items: [], total: 0, pageNo: 1, pageSize: 20, hasMore: false })
})
afterEach(() => {
@@ -61,6 +65,34 @@ afterEach(() => {
})
describe('讲解对象列表返回', () => {
+ it('ignores a delayed first-page response after unmount', async () => {
+ let resolvePage: ((page: { items: Array<{ id: string, name: string }>, total: number, pageNo: number, pageSize: number, hasMore: boolean }) => void) | undefined
+ vi.mocked(explainUseCase.listGuideStopsPageByHall).mockImplementationOnce(() => new Promise((resolve) => { resolvePage = resolve }))
+ const wrapper = mountGuideStopList()
+ testState.onLoadHandler?.({ hallId: 'hall-1' })
+ wrapper.unmount()
+ resolvePage?.({ items: [{ id: 'late', name: '延迟' }], total: 1, pageNo: 1, pageSize: 20, hasMore: false })
+ await flushPromises()
+ expect(explainUseCase.listGuideStopsPageByHall).toHaveBeenCalledTimes(1)
+ })
+
+ it('permits retry-more recovery after a failed page', async () => {
+ vi.mocked(explainUseCase.listGuideStopsPageByHall)
+ .mockResolvedValueOnce({ items: [{ id: 'one', name: '一号' }], total: 2, pageNo: 1, pageSize: 20, hasMore: true })
+ .mockRejectedValueOnce(new Error('temporary'))
+ .mockResolvedValueOnce({ items: [{ id: 'two', name: '二号' }], total: 2, pageNo: 2, pageSize: 20, hasMore: false })
+ const wrapper = mountGuideStopList()
+ testState.onLoadHandler?.({ hallId: 'hall-1' })
+ await flushPromises()
+ await wrapper.get('[data-testid="retry-more"]').trigger('click')
+ await flushPromises()
+ expect(explainUseCase.listGuideStopsPageByHall).toHaveBeenCalledTimes(2)
+ await wrapper.get('[data-testid="retry-more"]').trigger('click')
+ await flushPromises()
+ expect(explainUseCase.listGuideStopsPageByHall).toHaveBeenCalledTimes(3)
+ wrapper.unmount()
+ })
+
it('上一页是独立展厅列表时回到该列表', async () => {
vi.stubGlobal('getCurrentPages', vi.fn(() => [
{ route: 'pages/explain/list', options: {} },
diff --git a/tests/unit/ExplainHallSelect.spec.ts b/tests/unit/ExplainHallSelect.spec.ts
index c129e6a..d5bc072 100644
--- a/tests/unit/ExplainHallSelect.spec.ts
+++ b/tests/unit/ExplainHallSelect.spec.ts
@@ -28,14 +28,14 @@ describe('讲解展厅选择列表', () => {
const cards = wrapper.findAll('.hall-card')
expect(cards).toHaveLength(1)
expect(wrapper.text()).toContain('生命演化厅')
- expect(wrapper.text()).toContain('6 个讲解对象')
+ expect(wrapper.text()).not.toContain('6 个讲解对象')
expect(wrapper.text()).not.toContain('业务单元')
await cards[0]?.trigger('tap')
expect(wrapper.emitted('hallClick')).toEqual([['hall-evolution']])
})
- it('展厅阶段按卡片网格展示英文名和进入入口', () => {
+ it('展厅阶段按主题色卡展示展厅名称和图标', () => {
const wrapper = mount(ExplainHallSelect, {
props: {
stage: 'hall',
@@ -52,10 +52,11 @@ describe('讲解展厅选择列表', () => {
const card = wrapper.get('.hall-overview-card')
expect(wrapper.get('.header-title').text()).toBe('展厅讲解')
- expect(card.attributes('style')).toContain('--hall-card-color: #c7a15c')
- expect(card.text()).toContain('Dinosaur Hall')
- expect(card.text()).toContain('L-2 · 35 个讲解对象')
- expect(card.get('.hall-go-entry').text()).toBe('GO ›')
+ expect(card.attributes('style')).toContain('--hall-card-color: #d9b06c')
+ expect(card.text()).toContain('恐龙厅')
+ expect(card.text()).not.toContain('Dinosaur Hall')
+ expect(card.text()).not.toContain('35 个讲解对象')
+ expect(card.find('.hall-go-entry').exists()).toBe(false)
})
it('READY 讲解点不显示可讲解标签,非 READY 讲解点保留图文标签和点击事件', async () => {