diff --git a/.env.development b/.env.development index 31263c9..09d51de 100644 --- a/.env.development +++ b/.env.development @@ -25,7 +25,7 @@ VITE_PUBLIC_LEGACY_AUDIO_HOST= # Vite 开发服务器代理目标,只由 vite.config.ts 读取。 # /engine 由 public/engine 本地静态资源提供,保持和大屏端一致,不再代理到地图管理端。 -DEV_PROXY_APP_API_TARGET=http://localhost:48080 +DEV_PROXY_APP_API_TARGET=https://guide.whaoyue.com:4433 DEV_PROXY_ENGINE_TARGET= DEV_PROXY_SDK_TARGET= DEV_PROXY_MUSEUM_ASSETS_TARGET=http://1.92.206.90:9000 diff --git a/src/components/explain/ExplainHallSelect.vue b/src/components/explain/ExplainHallSelect.vue index 971b117..6b892d2 100644 --- a/src/components/explain/ExplainHallSelect.vue +++ b/src/components/explain/ExplainHallSelect.vue @@ -33,6 +33,24 @@ + + + + + 输入讲解编号 + 请输入展品旁标注的 4 位编号 + + + {{ codeEntryValue[index - 1] || '' }} + + + + {{ codeEntryError }} + 请输入展品标签上的四位编号 + + + + @@ -234,6 +291,7 @@ const props = withDefaults(defineProps<{ error?: string loadMoreError?: string forceInternalHeader?: boolean + codeSubmitting?: boolean }>(), { halls: () => [], businessUnits: () => [], @@ -245,11 +303,13 @@ const props = withDefaults(defineProps<{ hasMore: false, error: '', loadMoreError: '', - forceInternalHeader: false + forceInternalHeader: false, + codeSubmitting: false }) const emit = defineEmits<{ hallClick: [hallId: string] + codeSubmit: [code: string] businessUnitClick: [unitId: string] guideStopClick: [stop: ExplainGuideStopSelectItem] back: [] @@ -261,6 +321,9 @@ const HALL_PREVIEW_BASE = '/static/icons/halls/portal-icons' const HALL_PREVIEW_EAGER_COUNT = 4 const loadedHallPreviewIds = ref(new Set()) const failedHallPreviewIds = ref(new Set()) +const codeEntryVisible = ref(false) +const codeEntryValue = ref('') +const codeEntryError = ref('') const hallPreviewMap: Record = { 宇宙厅: `${HALL_PREVIEW_BASE}/universe.png`, @@ -292,6 +355,7 @@ const showInternalHeader = computed(() => props.forceInternalHeader || !shouldUs const headerTitle = computed(() => { if (props.stage === 'stop') return props.selectedHallName || '讲解对象' if (props.stage === 'unit') return props.selectedHallName || '讲解单元' + if (codeEntryVisible.value) return '编号讲解' return '免费讲解' }) const loadingTitle = computed(() => { @@ -355,6 +419,45 @@ const handleHallClick = (hallId: string) => { emit('hallClick', hallId) } +const openCodeEntry = () => { + codeEntryError.value = '' + codeEntryVisible.value = true +} + +const closeCodeEntry = () => { + codeEntryVisible.value = false + codeEntryValue.value = '' + codeEntryError.value = '' +} + +const handleCodeEntryInput = (event: Event) => { + const inputEvent = event as Event & { detail?: { value?: string } } + codeEntryValue.value = (inputEvent.detail?.value || '') + .replace(/\D/g, '') + .slice(0, 4) + codeEntryError.value = '' +} + +const submitCodeEntry = () => { + if (props.codeSubmitting) return + const code = codeEntryValue.value + if (code.length !== 4) { + codeEntryError.value = '请输入完整的 4 位讲解编号' + return + } + emit('codeSubmit', code) +} + +const showCodeEntryError = (message: string) => { + codeEntryError.value = message +} + +const completeCodeEntry = () => { + closeCodeEntry() +} + +defineExpose({ showCodeEntryError, completeCodeEntry }) + const handleBusinessUnitClick = (unitId: string) => { emit('businessUnitClick', unitId) } @@ -382,6 +485,10 @@ const handleScroll = (event: Event) => { } const handleBack = () => { + if (codeEntryVisible.value) { + closeCodeEntry() + return + } emit('back') } @@ -405,6 +512,64 @@ const handleBack = () => { padding: 76px 16px 16px; } +.code-entry-section { + padding-bottom: 10px; +} + +.code-entry-button { + position: relative; + width: 100%; + min-height: 82px; + margin: 0; + padding: 13px 72px; + display: flex; + align-items: center; + justify-content: center; + box-sizing: border-box; + color: #171713; + border: 1px solid #cfd4c7; + border-radius: 8px; + background: #ffffff; + box-shadow: 0 3px 10px rgba(36, 49, 42, 0.06); +} + +.code-entry-button::after { border: 0; } +.code-entry-button:active { background: #f0f3e8; } +.code-entry-icon { position: absolute; left: 28px; top: 50%; width: 44px; height: 44px; border-radius: 8px; background: #e0e100; transform: translateY(-50%); } +.headphone-band { position: absolute; left: 10px; top: 9px; width: 24px; height: 23px; box-sizing: border-box; border: 3px solid #171713; border-bottom: 0; border-radius: 14px 14px 0 0; } +.headphone-ear { position: absolute; top: 25px; width: 7px; height: 11px; border-radius: 3px; background: #171713; } +.headphone-ear.left { left: 8px; } +.headphone-ear.right { right: 8px; } +.code-entry-copy { width: 100%; min-width: 0; text-align: center; } +.code-entry-title, .code-entry-desc { display: block; } +.code-entry-title { font-size: 17px; line-height: 23px; font-weight: 700; color: #171713; } +.code-entry-desc { margin-top: 3px; font-size: 12px; line-height: 18px; color: #68725d; } + +.hall-section-heading { height: 30px; display: flex; align-items: center; justify-content: center; } +.hall-section-title { font-size: 13px; line-height: 19px; font-weight: 600; color: #68725d; } + +.code-entry-overlay { position: absolute; z-index: 3000; top: 64px; right: 0; bottom: 0; left: 0; background: #f7f9f2; } +.code-entry-sheet { position: absolute; inset: 0; padding: 24px 24px calc(24px + env(safe-area-inset-bottom)); box-sizing: border-box; background: #f7f9f2; } +.code-entry-content { width: min(100%, 330px); min-height: 100%; margin: 0 auto; display: flex; flex-direction: column; align-items: center; justify-content: center; box-sizing: border-box; transform: translateY(-28px); } +.code-entry-sheet-title, .code-entry-sheet-desc, .code-entry-error, .code-entry-demo { display: block; text-align: center; } +.code-entry-sheet-title { font-size: 22px; line-height: 30px; font-weight: 700; color: #171713; } +.code-entry-sheet-desc { margin-top: 8px; font-size: 14px; line-height: 20px; color: #68725d; } +.code-entry-action::after { border: 0; } +.code-digit-fields { position: relative; width: 100%; margin-top: 32px; display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 10px; } +.code-digit-cell { height: 68px; display: flex; align-items: center; justify-content: center; box-sizing: border-box; border: 1.5px solid #aeb5a5; border-radius: 6px; background: #ffffff; } +.code-digit-cell.active { border-color: #777d00; box-shadow: 0 0 0 1px #e0e100 inset; } +.code-digit-fields.error .code-digit-cell { border-color: #b63f3f; } +.code-digit-value { font-size: 30px; line-height: 36px; font-weight: 700; color: #171713; } +.code-entry-native-input { position: absolute; inset: 0; width: 100%; height: 68px; opacity: 0.01; color: transparent; caret-color: transparent; } +.code-entry-error, .code-entry-hint { min-height: 19px; margin-top: 10px; font-size: 12px; line-height: 19px; } +.code-entry-error { color: #a62f2f; } +.code-entry-hint { color: #7a8271; } +.code-entry-action { width: 100%; height: 48px; margin: 28px 0 0; padding: 0 12px; font-size: 15px; line-height: 48px; font-weight: 700; border: 0; border-radius: 6px; } +.code-entry-action.primary { color: #171713; background: #e0e100; } +.code-entry-action.primary[disabled] { color: #909489; background: #e4e6df; opacity: 1; } + +.explain-hall-select.host-navigation .code-entry-overlay { top: 0; } + .explain-scroll.stop-stage { padding-bottom: 0; } @@ -906,13 +1071,23 @@ const handleBack = () => { } .explain-scroll.hall-stage { - padding: 88px 16px 24px; + padding: 88px 16px 12px; + overflow: hidden; } .hall-overview-list { - // Keep the eight-hall overview balanced on tall phones while preserving a - // fixed minimum row height for compact screens. - grid-auto-rows: max(138px, calc((100vh - 148px) / 4)); + grid-template-rows: repeat(4, minmax(0, 1fr)); + grid-auto-rows: minmax(0, 1fr); + height: calc(100vh - 222px); + gap: 10px 12px; +} + +.hall-overview-card { + min-height: 0; +} + +.explain-hall-select.host-navigation .hall-overview-list { + height: calc(100vh - 146px); } .hall-overview-card { diff --git a/src/pages/exhibit/detail.vue b/src/pages/exhibit/detail.vue index 19a4370..98af532 100644 --- a/src/pages/exhibit/detail.vue +++ b/src/pages/exhibit/detail.vue @@ -246,6 +246,7 @@ const detailTextLoadedByLanguage = ref>({ 'en-US': false }) const shouldUseHostNavigation = computed(() => isEmbeddedInWechatMiniProgram()) +const shouldAutoplayOnEntry = ref(false) const isAudioLanguage = (value: unknown): value is AudioLanguage => ( value === 'zh-CN' || value === 'yue-HK' || value === 'en-US' @@ -531,6 +532,8 @@ onLoad(async (options: any = {}) => { return } const rawLang = Array.isArray(options.lang) ? options.lang[0] : options.lang + const rawAutoplay = Array.isArray(options.autoplay) ? options.autoplay[0] : options.autoplay + shouldAutoplayOnEntry.value = rawAutoplay === '1' || rawAutoplay === 'true' const lang = typeof rawLang === 'string' ? normalizeGuideAudioLanguage(rawLang) : 'zh-CN' @@ -545,6 +548,18 @@ onLoad(async (options: any = {}) => { poiId: Array.isArray(options.poiId) ? options.poiId[0] : options.poiId } await loadExplainDetail(detailEntryRequest.value, lang) + if (shouldAutoplayOnEntry.value) { + shouldAutoplayOnEntry.value = false + try { + await handlePlayAudio() + } catch (error) { + console.warn('自动播放讲解失败,将等待用户手动播放:', error) + uni.showToast({ + title: '点击底部播放键开始讲解', + icon: 'none' + }) + } + } } catch (error) { console.error('讲解详情加载失败:', error) detailState.value = 'error' diff --git a/src/pages/explain/list.vue b/src/pages/explain/list.vue index e1d2bdb..a051328 100644 --- a/src/pages/explain/list.vue +++ b/src/pages/explain/list.vue @@ -8,11 +8,14 @@ > @@ -41,12 +44,15 @@ import { } from '@/utils/guideTopTabs' import { isEmbeddedInWechatMiniProgram } from '@/utils/hostEnvironment' import { explainGuideStopListUrl } from '@/utils/explainNavigation' +import { explainCodeDetailUrl, explainCodeErrorMessage } from '@/utils/explainCodeEntry' import { returnToWechatMiniProgram } from '@/services/WechatMiniProgramBridgeService' import { syncHostNavigationTitle } from '@/utils/hostNavigationTitle' const explainHallItems = ref([]) const explainLoading = ref(false) const explainError = ref('') +const hallSelectRef = ref | null>(null) +const codeSubmitting = ref(false) let explainLoadPromise: Promise | null = null const normalizeExplainKeyword = (value?: string) => (value || '').trim().toLowerCase() @@ -270,6 +276,20 @@ const handleExplainHallClick = (hallId: string) => { }) } +const handleExplainCodeSubmit = async (code: string) => { + if (codeSubmitting.value) return + codeSubmitting.value = true + try { + const target = await explainUseCase.resolveExplainCode(code) + hallSelectRef.value?.completeCodeEntry() + uni.navigateTo({ url: explainCodeDetailUrl(target) }) + } catch (error) { + hallSelectRef.value?.showCodeEntryError(explainCodeErrorMessage(error)) + } finally { + codeSubmitting.value = false + } +} +