| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420 |
- <template>
- <view class="page">
- <view class="header">
- <text class="car-no">{{ carNo }}</text>
- </view>
- <view class="header-divider"></view>
- <scroll-view class="timeline-scroll" scroll-y :scroll-into-view="scrollIntoViewStopId"
- :scroll-with-animation="true">
- <view class="timeline">
- <view v-for="item in stopList" :key="item.key" class="stop-item" :style="{
- '--dot-size': item.dotSize + 'rpx',
- '--line-top': item.lineTop + 'rpx',
- '--line-bottom': item.lineBottom + 'rpx',
- '--line-top-color': item.lineTopColor,
- '--line-bottom-color': item.lineBottomColor
- }" :id="'stop_' + item.key">
- <view class="left">
- <view class="line top" :class="{ hidden: item.isFirst }"></view>
- <view class="dot" :class="dotClass(item)">
- <image v-if="item.stage === 'start'" class="dot-icon dot-icon-start" src="/static/images/strat.svg"
- mode="aspectFit" />
- <image v-else-if="item.stage === 'end'" class="dot-icon dot-icon-end" src="/static/images/end.svg"
- mode="aspectFit" />
- </view>
- <view class="line bottom" :class="{ hidden: item.isLast }"></view>
- </view>
- <view class="right">
- <view class="name-row">
- <view class="stop-name" :class="nameClass(item)">{{ item.name }}</view>
- </view>
- <view v-if="item.tip" class="tip-row">
- <image class="bus-icon" src="/static/images/bus.svg" mode="aspectFit" />
- <text class="tip-text" :class="tipClass(item)">{{ item.tip }}</text>
- </view>
- </view>
- </view>
- </view>
- </scroll-view>
- <!-- 调试:切换车辆站点 -->
- <view class="debug-fab">
- <view class="fab-btn" @click="goPrevStop">上一站</view>
- <view class="fab-btn fab-primary" @click="goNextAction">{{ nextActionText }}</view>
- </view>
- </view>
- </template>
- <script setup>
- import { computed, ref, nextTick, watch, onMounted } from 'vue'
- const carNo = ref('粤A88888')
- const busIndex = ref(0) // mock:车辆当前所在站点索引
- const busState = ref('at') // at | enroute(用于模拟“即将到达/到达”两步)
- const childStopKey = ref('s4') // mock:家长孩子所在站点(用蓝色标记)
- const scrollIntoViewStopId = ref('')
- const stops = ref([
- { key: 's0', name: '学校' },
- { key: 's1', name: '莲花山头' },
- { key: 's2', name: '北塘四环公交站' },
- { key: 's3', name: '莲花山(山尾)' },
- { key: 's4', name: '碧桂园天玺山' },
- { key: 's5', name: '锦城花园' },
- { key: 's6', name: '华大路口' },
- { key: 's7', name: '海丰中专1站' },
- { key: 's8', name: '海丰中专2站' },
- { key: 's9', name: '结束点' },
- ])
- const startKey = computed(() => stops.value[0]?.key || '')
- const endKey = computed(() => stops.value[stops.value.length - 1]?.key || '')
- const isBusAtStart = computed(() => busIndex.value === 0 && busState.value === 'at')
- const isBusAtEnd = computed(() => busIndex.value === Math.max(0, stops.value.length - 1) && busState.value === 'at')
- const clampBusIndex = (next) => Math.min(Math.max(next, 0), Math.max(0, stops.value.length - 1))
- const goPrevStop = () => {
- busIndex.value = clampBusIndex(busIndex.value - 1)
- busState.value = 'at'
- }
- const nextActionText = computed(() => {
- if (busIndex.value >= Math.max(0, stops.value.length - 1)) return '已到终点'
- return busState.value === 'at' ? '即将到下一站' : '到下一站'
- })
- const goNextAction = () => {
- const last = Math.max(0, stops.value.length - 1)
- if (busIndex.value >= last) return
- if (busState.value === 'at') {
- busState.value = 'enroute'
- return
- }
- busIndex.value = clampBusIndex(busIndex.value + 1)
- busState.value = 'at'
- }
- const activeStopKey = computed(() => {
- const last = Math.max(0, stops.value.length - 1)
- if (!stops.value.length) return ''
- if (busState.value === 'enroute' && busIndex.value < last) return stops.value[busIndex.value + 1]?.key || ''
- return stops.value[busIndex.value]?.key || ''
- })
- const scrollToActiveStop = () => {
- const key = activeStopKey.value
- if (!key) return
- scrollIntoViewStopId.value = ''
- nextTick(() => {
- scrollIntoViewStopId.value = `stop_${key}`
- })
- }
- watch([busIndex, busState], () => {
- scrollToActiveStop()
- }, { immediate: true })
- // 页面初次渲染时,scroll-view 可能还未完成布局,做一次轻量重试确保能自动定位到当前/即将站点
- onMounted(() => {
- const tryScroll = (tries = 0) => {
- scrollToActiveStop()
- if (tries >= 6) return
- setTimeout(() => tryScroll(tries + 1), 60)
- }
- tryScroll(0)
- })
- const stopList = computed(() => {
- const lastIndex = Math.max(0, stops.value.length - 1)
- // 规则:不会同时存在“已到达”和“即将到达”
- // - at:仅“已到达”(当前站)
- // - enroute:仅“即将到达”(下一站)
- const arrivedIndex = busState.value === 'at' ? busIndex.value : -1
- const arrivingIndex =
- busState.value === 'enroute' && busIndex.value < lastIndex ? busIndex.value + 1 : -1
- return stops.value.map((s, idx) => ({
- ...s,
- isFirst: idx === 0,
- isLast: idx === stops.value.length - 1,
- isChildStop: s.key === childStopKey.value,
- stage:
- idx === 0
- ? 'start'
- : idx === lastIndex
- ? 'end'
- : idx < busIndex.value
- ? 'past'
- : busState.value === 'enroute' && idx === busIndex.value
- ? 'past'
- : idx === arrivedIndex
- ? 'arrived'
- : idx === arrivingIndex
- ? 'arriving'
- : 'future',
- tip:
- idx === arrivingIndex && arrivingIndex !== -1
- ? '即将到达(距离340米)'
- : idx === arrivedIndex && arrivedIndex > 0 && arrivedIndex < lastIndex
- ? '已到达'
- : '',
- dotSize:
- idx === 0 || idx === lastIndex || idx === arrivedIndex || idx === arrivingIndex
- ? 56
- : 36,
- lineTop: idx === 0 ? 0 : 8,
- lineBottom: idx === lastIndex ? 0 : 52,
- lineTopColor: arrivingIndex === idx ? '#e59f40' : '#c3c7cb',
- lineBottomColor: busState.value === 'enroute' && idx === busIndex.value ? '#e59f40' : '#c3c7cb',
- }))
- })
- const dotClass = (item) => {
- if (item.stage === 'start') return isBusAtStart.value ? 'dot-start dot-active' : 'dot-start'
- if (item.stage === 'end') {
- if (isBusAtEnd.value) return 'dot-end dot-active'
- // 即将到达结束点:给结束点加橙色边框
- if (busState.value === 'enroute' && busIndex.value + 1 === Math.max(0, stops.value.length - 1)) return 'dot-end dot-end-arriving'
- return 'dot-end'
- }
- if (item.stage === 'arriving') return 'dot-arriving'
- if (item.stage === 'arrived') return 'dot-arrived'
- // 家长孩子所在站点:默认蓝底灰框(不覆盖“即将到达/已到达/始发/结束”)
- if (item.isChildStop) return 'dot-child'
- if (item.stage === 'past') return 'dot-past'
- return 'dot-future'
- }
- const nameClass = (item) => {
- if (item.stage === 'arriving' || item.stage === 'arrived') return 'name-strong'
- return 'name-muted'
- }
- const tipClass = (item) => {
- if (!item.tip) return ''
- return 'tip-current'
- }
- </script>
- <style lang="scss" scoped>
- .page {
- height: 100vh;
- background: #f2f3f4;
- padding: 0 28rpx 24rpx;
- box-sizing: border-box;
- display: flex;
- flex-direction: column;
- }
- .header {
- height: 100rpx;
- display: flex;
- align-items: center;
- justify-content: center;
- }
- .car-no {
- font-size: 40rpx; // 20px
- color: #666;
- font-weight: 600;
- }
- .header-divider {
- width: 99%;
- height: 2rpx; // 约 2px
- background: #e6e6e6;
- margin: 0 auto;
- }
- .timeline-scroll {
- flex: 1;
- height: 0;
- }
- .timeline {
- padding-top: 28rpx;
- width: 90%;
- margin: 0 auto;
- padding-bottom: 120rpx; // 给悬浮按钮留空间
- }
- .stop-item {
- display: flex;
- align-items: flex-start;
- height: calc(var(--line-top) + var(--dot-size) + var(--line-bottom));
- overflow: visible;
- }
- .left {
- width: 72rpx;
- display: flex;
- flex-direction: column;
- align-items: center;
- flex-shrink: 0;
- }
- .line {
- width: 4rpx;
- flex: none;
- }
- .line.top {
- height: var(--line-top);
- background: var(--line-top-color);
- }
- .line.bottom {
- height: var(--line-bottom);
- background: var(--line-bottom-color);
- }
- .line.hidden {
- background: transparent;
- }
- .dot {
- width: var(--dot-size);
- height: var(--dot-size);
- border-radius: 9999rpx;
- box-sizing: border-box;
- display: flex;
- align-items: center;
- justify-content: center;
- }
- .dot-icon {
- width: 40rpx;
- height: 40rpx;
- }
- .dot-icon-start {
- width: 30rpx;
- height: 25rpx;
- }
- .dot-icon-end {
- width: 40rpx;
- height: 40rpx;
- }
- .dot-past {
- background: #c3c7cb;
- }
- .dot-future {
- background: #ffffff;
- border: 2rpx solid #c3c7cb;
- }
- .dot-start {
- background: #c3c7cb;
- }
- .dot-end {
- background: #c3c7cb;
- }
- .dot-end-arriving {
- border: 4rpx solid #e59f40;
- }
- .dot-active {
- background: #e59f40;
- }
- .dot-arriving {
- background: #ffffff;
- border: 4rpx solid #e59f40;
- }
- .dot-arrived {
- background: #e59f40;
- }
- .dot-child {
- background: #357cdf;
- border: 2rpx solid #c3c7cb;
- }
- .right {
- flex: 1;
- padding: 0;
- padding-top: var(--line-top);
- }
- .name-row {
- height: var(--dot-size);
- display: flex;
- align-items: center;
- }
- .stop-name {
- font-size: 40rpx;
- line-height: 1.1;
- }
- .name-muted {
- color: #999;
- font-weight: 400;
- }
- .name-strong {
- color: #000;
- font-weight: 700;
- font-size: 48rpx;
- }
- .tip-row {
- display: flex;
- align-items: center;
- gap: 10rpx;
- margin-top: 6rpx;
- }
- .bus-icon {
- width: 80rpx;
- height: 40rpx;
- }
- .tip-text {
- font-size: 32rpx;
- color: #e59f40;
- line-height: 1.2;
- }
- .debug-fab {
- position: fixed;
- right: 24rpx;
- bottom: 48rpx;
- display: flex;
- flex-direction: column;
- gap: 16rpx;
- z-index: 999;
- }
- .fab-btn {
- width: 200rpx;
- height: 72rpx;
- border-radius: 12rpx;
- background: #ffffff;
- border: 2rpx solid #c3c7cb;
- display: flex;
- align-items: center;
- justify-content: center;
- font-size: 28rpx;
- color: #333;
- box-shadow: 0 6rpx 18rpx rgba(0, 0, 0, 0.08);
- }
- .fab-primary {
- border-color: #e59f40;
- color: #e59f40;
- }
- .fab-btn:active {
- opacity: 0.9;
- }
- </style>
|