parent.vue 9.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420
  1. <template>
  2. <view class="page">
  3. <view class="header">
  4. <text class="car-no">{{ carNo }}</text>
  5. </view>
  6. <view class="header-divider"></view>
  7. <scroll-view class="timeline-scroll" scroll-y :scroll-into-view="scrollIntoViewStopId"
  8. :scroll-with-animation="true">
  9. <view class="timeline">
  10. <view v-for="item in stopList" :key="item.key" class="stop-item" :style="{
  11. '--dot-size': item.dotSize + 'rpx',
  12. '--line-top': item.lineTop + 'rpx',
  13. '--line-bottom': item.lineBottom + 'rpx',
  14. '--line-top-color': item.lineTopColor,
  15. '--line-bottom-color': item.lineBottomColor
  16. }" :id="'stop_' + item.key">
  17. <view class="left">
  18. <view class="line top" :class="{ hidden: item.isFirst }"></view>
  19. <view class="dot" :class="dotClass(item)">
  20. <image v-if="item.stage === 'start'" class="dot-icon dot-icon-start" src="/static/images/strat.svg"
  21. mode="aspectFit" />
  22. <image v-else-if="item.stage === 'end'" class="dot-icon dot-icon-end" src="/static/images/end.svg"
  23. mode="aspectFit" />
  24. </view>
  25. <view class="line bottom" :class="{ hidden: item.isLast }"></view>
  26. </view>
  27. <view class="right">
  28. <view class="name-row">
  29. <view class="stop-name" :class="nameClass(item)">{{ item.name }}</view>
  30. </view>
  31. <view v-if="item.tip" class="tip-row">
  32. <image class="bus-icon" src="/static/images/bus.svg" mode="aspectFit" />
  33. <text class="tip-text" :class="tipClass(item)">{{ item.tip }}</text>
  34. </view>
  35. </view>
  36. </view>
  37. </view>
  38. </scroll-view>
  39. <!-- 调试:切换车辆站点 -->
  40. <view class="debug-fab">
  41. <view class="fab-btn" @click="goPrevStop">上一站</view>
  42. <view class="fab-btn fab-primary" @click="goNextAction">{{ nextActionText }}</view>
  43. </view>
  44. </view>
  45. </template>
  46. <script setup>
  47. import { computed, ref, nextTick, watch, onMounted } from 'vue'
  48. const carNo = ref('粤A88888')
  49. const busIndex = ref(0) // mock:车辆当前所在站点索引
  50. const busState = ref('at') // at | enroute(用于模拟“即将到达/到达”两步)
  51. const childStopKey = ref('s4') // mock:家长孩子所在站点(用蓝色标记)
  52. const scrollIntoViewStopId = ref('')
  53. const stops = ref([
  54. { key: 's0', name: '学校' },
  55. { key: 's1', name: '莲花山头' },
  56. { key: 's2', name: '北塘四环公交站' },
  57. { key: 's3', name: '莲花山(山尾)' },
  58. { key: 's4', name: '碧桂园天玺山' },
  59. { key: 's5', name: '锦城花园' },
  60. { key: 's6', name: '华大路口' },
  61. { key: 's7', name: '海丰中专1站' },
  62. { key: 's8', name: '海丰中专2站' },
  63. { key: 's9', name: '结束点' },
  64. ])
  65. const startKey = computed(() => stops.value[0]?.key || '')
  66. const endKey = computed(() => stops.value[stops.value.length - 1]?.key || '')
  67. const isBusAtStart = computed(() => busIndex.value === 0 && busState.value === 'at')
  68. const isBusAtEnd = computed(() => busIndex.value === Math.max(0, stops.value.length - 1) && busState.value === 'at')
  69. const clampBusIndex = (next) => Math.min(Math.max(next, 0), Math.max(0, stops.value.length - 1))
  70. const goPrevStop = () => {
  71. busIndex.value = clampBusIndex(busIndex.value - 1)
  72. busState.value = 'at'
  73. }
  74. const nextActionText = computed(() => {
  75. if (busIndex.value >= Math.max(0, stops.value.length - 1)) return '已到终点'
  76. return busState.value === 'at' ? '即将到下一站' : '到下一站'
  77. })
  78. const goNextAction = () => {
  79. const last = Math.max(0, stops.value.length - 1)
  80. if (busIndex.value >= last) return
  81. if (busState.value === 'at') {
  82. busState.value = 'enroute'
  83. return
  84. }
  85. busIndex.value = clampBusIndex(busIndex.value + 1)
  86. busState.value = 'at'
  87. }
  88. const activeStopKey = computed(() => {
  89. const last = Math.max(0, stops.value.length - 1)
  90. if (!stops.value.length) return ''
  91. if (busState.value === 'enroute' && busIndex.value < last) return stops.value[busIndex.value + 1]?.key || ''
  92. return stops.value[busIndex.value]?.key || ''
  93. })
  94. const scrollToActiveStop = () => {
  95. const key = activeStopKey.value
  96. if (!key) return
  97. scrollIntoViewStopId.value = ''
  98. nextTick(() => {
  99. scrollIntoViewStopId.value = `stop_${key}`
  100. })
  101. }
  102. watch([busIndex, busState], () => {
  103. scrollToActiveStop()
  104. }, { immediate: true })
  105. // 页面初次渲染时,scroll-view 可能还未完成布局,做一次轻量重试确保能自动定位到当前/即将站点
  106. onMounted(() => {
  107. const tryScroll = (tries = 0) => {
  108. scrollToActiveStop()
  109. if (tries >= 6) return
  110. setTimeout(() => tryScroll(tries + 1), 60)
  111. }
  112. tryScroll(0)
  113. })
  114. const stopList = computed(() => {
  115. const lastIndex = Math.max(0, stops.value.length - 1)
  116. // 规则:不会同时存在“已到达”和“即将到达”
  117. // - at:仅“已到达”(当前站)
  118. // - enroute:仅“即将到达”(下一站)
  119. const arrivedIndex = busState.value === 'at' ? busIndex.value : -1
  120. const arrivingIndex =
  121. busState.value === 'enroute' && busIndex.value < lastIndex ? busIndex.value + 1 : -1
  122. return stops.value.map((s, idx) => ({
  123. ...s,
  124. isFirst: idx === 0,
  125. isLast: idx === stops.value.length - 1,
  126. isChildStop: s.key === childStopKey.value,
  127. stage:
  128. idx === 0
  129. ? 'start'
  130. : idx === lastIndex
  131. ? 'end'
  132. : idx < busIndex.value
  133. ? 'past'
  134. : busState.value === 'enroute' && idx === busIndex.value
  135. ? 'past'
  136. : idx === arrivedIndex
  137. ? 'arrived'
  138. : idx === arrivingIndex
  139. ? 'arriving'
  140. : 'future',
  141. tip:
  142. idx === arrivingIndex && arrivingIndex !== -1
  143. ? '即将到达(距离340米)'
  144. : idx === arrivedIndex && arrivedIndex > 0 && arrivedIndex < lastIndex
  145. ? '已到达'
  146. : '',
  147. dotSize:
  148. idx === 0 || idx === lastIndex || idx === arrivedIndex || idx === arrivingIndex
  149. ? 56
  150. : 36,
  151. lineTop: idx === 0 ? 0 : 8,
  152. lineBottom: idx === lastIndex ? 0 : 52,
  153. lineTopColor: arrivingIndex === idx ? '#e59f40' : '#c3c7cb',
  154. lineBottomColor: busState.value === 'enroute' && idx === busIndex.value ? '#e59f40' : '#c3c7cb',
  155. }))
  156. })
  157. const dotClass = (item) => {
  158. if (item.stage === 'start') return isBusAtStart.value ? 'dot-start dot-active' : 'dot-start'
  159. if (item.stage === 'end') {
  160. if (isBusAtEnd.value) return 'dot-end dot-active'
  161. // 即将到达结束点:给结束点加橙色边框
  162. if (busState.value === 'enroute' && busIndex.value + 1 === Math.max(0, stops.value.length - 1)) return 'dot-end dot-end-arriving'
  163. return 'dot-end'
  164. }
  165. if (item.stage === 'arriving') return 'dot-arriving'
  166. if (item.stage === 'arrived') return 'dot-arrived'
  167. // 家长孩子所在站点:默认蓝底灰框(不覆盖“即将到达/已到达/始发/结束”)
  168. if (item.isChildStop) return 'dot-child'
  169. if (item.stage === 'past') return 'dot-past'
  170. return 'dot-future'
  171. }
  172. const nameClass = (item) => {
  173. if (item.stage === 'arriving' || item.stage === 'arrived') return 'name-strong'
  174. return 'name-muted'
  175. }
  176. const tipClass = (item) => {
  177. if (!item.tip) return ''
  178. return 'tip-current'
  179. }
  180. </script>
  181. <style lang="scss" scoped>
  182. .page {
  183. height: 100vh;
  184. background: #f2f3f4;
  185. padding: 0 28rpx 24rpx;
  186. box-sizing: border-box;
  187. display: flex;
  188. flex-direction: column;
  189. }
  190. .header {
  191. height: 100rpx;
  192. display: flex;
  193. align-items: center;
  194. justify-content: center;
  195. }
  196. .car-no {
  197. font-size: 40rpx; // 20px
  198. color: #666;
  199. font-weight: 600;
  200. }
  201. .header-divider {
  202. width: 99%;
  203. height: 2rpx; // 约 2px
  204. background: #e6e6e6;
  205. margin: 0 auto;
  206. }
  207. .timeline-scroll {
  208. flex: 1;
  209. height: 0;
  210. }
  211. .timeline {
  212. padding-top: 28rpx;
  213. width: 90%;
  214. margin: 0 auto;
  215. padding-bottom: 120rpx; // 给悬浮按钮留空间
  216. }
  217. .stop-item {
  218. display: flex;
  219. align-items: flex-start;
  220. height: calc(var(--line-top) + var(--dot-size) + var(--line-bottom));
  221. overflow: visible;
  222. }
  223. .left {
  224. width: 72rpx;
  225. display: flex;
  226. flex-direction: column;
  227. align-items: center;
  228. flex-shrink: 0;
  229. }
  230. .line {
  231. width: 4rpx;
  232. flex: none;
  233. }
  234. .line.top {
  235. height: var(--line-top);
  236. background: var(--line-top-color);
  237. }
  238. .line.bottom {
  239. height: var(--line-bottom);
  240. background: var(--line-bottom-color);
  241. }
  242. .line.hidden {
  243. background: transparent;
  244. }
  245. .dot {
  246. width: var(--dot-size);
  247. height: var(--dot-size);
  248. border-radius: 9999rpx;
  249. box-sizing: border-box;
  250. display: flex;
  251. align-items: center;
  252. justify-content: center;
  253. }
  254. .dot-icon {
  255. width: 40rpx;
  256. height: 40rpx;
  257. }
  258. .dot-icon-start {
  259. width: 30rpx;
  260. height: 25rpx;
  261. }
  262. .dot-icon-end {
  263. width: 40rpx;
  264. height: 40rpx;
  265. }
  266. .dot-past {
  267. background: #c3c7cb;
  268. }
  269. .dot-future {
  270. background: #ffffff;
  271. border: 2rpx solid #c3c7cb;
  272. }
  273. .dot-start {
  274. background: #c3c7cb;
  275. }
  276. .dot-end {
  277. background: #c3c7cb;
  278. }
  279. .dot-end-arriving {
  280. border: 4rpx solid #e59f40;
  281. }
  282. .dot-active {
  283. background: #e59f40;
  284. }
  285. .dot-arriving {
  286. background: #ffffff;
  287. border: 4rpx solid #e59f40;
  288. }
  289. .dot-arrived {
  290. background: #e59f40;
  291. }
  292. .dot-child {
  293. background: #357cdf;
  294. border: 2rpx solid #c3c7cb;
  295. }
  296. .right {
  297. flex: 1;
  298. padding: 0;
  299. padding-top: var(--line-top);
  300. }
  301. .name-row {
  302. height: var(--dot-size);
  303. display: flex;
  304. align-items: center;
  305. }
  306. .stop-name {
  307. font-size: 40rpx;
  308. line-height: 1.1;
  309. }
  310. .name-muted {
  311. color: #999;
  312. font-weight: 400;
  313. }
  314. .name-strong {
  315. color: #000;
  316. font-weight: 700;
  317. font-size: 48rpx;
  318. }
  319. .tip-row {
  320. display: flex;
  321. align-items: center;
  322. gap: 10rpx;
  323. margin-top: 6rpx;
  324. }
  325. .bus-icon {
  326. width: 80rpx;
  327. height: 40rpx;
  328. }
  329. .tip-text {
  330. font-size: 32rpx;
  331. color: #e59f40;
  332. line-height: 1.2;
  333. }
  334. .debug-fab {
  335. position: fixed;
  336. right: 24rpx;
  337. bottom: 48rpx;
  338. display: flex;
  339. flex-direction: column;
  340. gap: 16rpx;
  341. z-index: 999;
  342. }
  343. .fab-btn {
  344. width: 200rpx;
  345. height: 72rpx;
  346. border-radius: 12rpx;
  347. background: #ffffff;
  348. border: 2rpx solid #c3c7cb;
  349. display: flex;
  350. align-items: center;
  351. justify-content: center;
  352. font-size: 28rpx;
  353. color: #333;
  354. box-shadow: 0 6rpx 18rpx rgba(0, 0, 0, 0.08);
  355. }
  356. .fab-primary {
  357. border-color: #e59f40;
  358. color: #e59f40;
  359. }
  360. .fab-btn:active {
  361. opacity: 0.9;
  362. }
  363. </style>