Unverified Commit 894b2ccd authored by Sparkf's avatar Sparkf 🏙️ Committed by GitHub
Browse files

Merge pull request #395 from SUSTech-CRA/BusTimerV2

New Bustimer
parents 50396398 1f927cd1
Loading
Loading
Loading
Loading
+10 −1
Original line number Diff line number Diff line
@@ -13,6 +13,11 @@ import Canteen from './components/Canteen.vue'
import AdSenseInline from './components/adsense-inline.vue'
import AdSenseDisplayAD from './components/adsense-displayad.vue'
import TalksTodayNav from './components/TalksTodayNav.vue'
import { BusHomeV2 } from './components/bus-v2/index.mjs'
import BusRouteV2 from './components/bus-v2/BusRouteV2.vue'
import BusStopV2 from './components/bus-v2/BusStopV2.vue'
import BusVehiclesV2 from './components/bus-v2/BusVehiclesV2.vue'
import BusSchedulesV2 from './components/bus-v2/BusSchedulesV2.vue'

export default defineClientConfig({
  enhance({ app }) {
@@ -28,10 +33,14 @@ export default defineClientConfig({
    app.component("AdSenseInline", AdSenseInline)
    app.component("AdSenseDisplayAD", AdSenseDisplayAD)
    app.component("TalksTodayNav", TalksTodayNav)
    app.component("BusHomeV2", BusHomeV2)
    app.component("BusRouteV2", BusRouteV2)
    app.component("BusStopV2", BusStopV2)
    app.component("BusVehiclesV2", BusVehiclesV2)
    app.component("BusSchedulesV2", BusSchedulesV2)

    // 含有echart的组件,注意需要用non-ssr模式
    app.component("BusChartVue", BusChartVue)
    app.component("Canteen", Canteen)
  },
})
+223 −0
Original line number Diff line number Diff line
<template>
  <main class="bus-home" :lang="busLanguage === 'zh' ? 'zh-CN' : 'en'">
    <header class="bus-home__header">
      <div>
        <h1>{{ busLanguage === 'zh' ? '校园巴士' : 'Campus bus' }}</h1>
        <p>{{ busLanguage === 'zh' ? '实时到站与出行信息' : 'Live arrivals and travel information' }}</p>
      </div>
      <button class="text-button" type="button" @click="setBusLanguage(busLanguage === 'zh' ? 'en' : 'zh')">{{ busText('language') }}</button>
    </header>

    <section class="panel search" :aria-label="busText('search')">
      <label class="sr-only" for="bus-search">{{ busText('search') }}</label>
      <input id="bus-search" v-model="query" type="search" :placeholder="busText('search')" @keydown.enter="openFirstResult">
      <div v-if="query" class="search-results">
        <button v-for="result in searchResults" :key="`${result.kind}-${result.item.id}`" type="button" @click="openResult(result)">
          <small>{{ busText(result.kind === 'route' ? 'routeLabel' : 'stopLabel') }}</small>
          {{ result.kind === 'stop' ? displayStopName(result.item, busLanguage) : displayName(result.item, busLanguage) }}
        </button>
        <p v-if="!searchResults.length" class="muted">{{ busText('searchEmpty') }}</p>
      </div>
    </section>

    <section v-if="loading" class="panel status"><span class="spinner" aria-hidden="true" /> {{ busText('loading') }}</section>
    <section v-else-if="error" class="panel status error" role="alert">
      <strong>{{ busText('loadFailed') }}</strong><span>{{ error }}</span><button type="button" @click="load">{{ busText('retry') }}</button>
    </section>

    <template v-else>
      <section class="panel notices">
        <h3>{{ busText('announcements') }}</h3>
        <p v-if="!notices.length" class="muted">{{ busText('empty') }}</p>
        <details v-for="notice in notices" :key="notice.id" :open="noticeScope(notice) === 'global'">
          <summary>
            <span>{{ noticeTitle(notice) }}</span>
            <small>{{ busText(noticeScope(notice)) }}<template v-if="noticeTime(notice)"> · <time :datetime="notice.starts_at">{{ noticeTime(notice) }}</time></template></small>
          </summary>
          <div class="markdown" v-html="renderNoticeMarkdown(notice.body_markdown)" />
        </details>
      </section>

      <section class="panel nearby">
        <div class="section-title">
          <h3>{{ busText('nearby') }}</h3>
          <button type="button" :disabled="locationState === 'loading'" @click="locate">{{ busText(locationState === 'loading' ? 'locating' : 'locate') }}</button>
        </div>
        <p v-if="locationState === 'idle'" class="muted">{{ busText('noLocation') }}</p>
        <p v-else-if="locationState === 'denied' || locationState === 'failed'" class="muted">{{ locationError || busText('locationDenied') }}</p>
        <p v-else-if="locationState === 'ready' && !nearbyStops.length" class="muted">{{ busText('empty') }}</p>
        <article v-for="stop in visibleNearbyStops" :key="stop.id" class="nearby-stop">
          <div class="nearby-stop__head">
            <button type="button" class="link-title" @click="openStop(stop.id)">{{ displayStopName(stop, busLanguage) }}</button>
            <span>{{ formatDistance(stop.distance) }}</span>
          </div>
          <p v-if="arrivals[stop.id]?.loading" class="muted">{{ busText('loading') }}</p>
          <p v-else-if="arrivals[stop.id]?.error" class="muted">{{ busText('unavailable') }}</p>
          <ul v-else class="arrival-list">
            <li v-for="arrival in arrivals[stop.id]?.items" :key="`${arrival.route_direction_id}-${arrival.source}-${arrival.trip_id || arrival.planned_arrival_at || ''}`">
              <i :style="{ background: arrival.route_color || '#2878c8' }" />
              <a class="arrival-link" :href="routeDirectionHref(arrival)">{{ arrivalName(arrival) }}</a>
              <strong>{{ arrivalText(arrival) }}</strong>
            </li>
            <li v-if="!arrivals[stop.id]?.items?.length" class="muted">{{ busText('empty') }}</li>
          </ul>
        </article>
        <button v-if="nearbyStops.length > 2" type="button" class="nearby-toggle" :aria-expanded="allNearbyStops" @click="allNearbyStops = !allNearbyStops">{{ allNearbyStops ? (busLanguage === 'zh' ? '收起' : 'Show less') : (busLanguage === 'zh' ? '展开' : 'Show all') }}</button>
      </section>

      <section class="panel favorites">
        <h3>{{ busText('favorites') }}</h3>
        <div v-if="favoriteRoutes.length || favoriteStops.length" class="quick-links">
          <button v-for="route in favoriteRoutes" :key="route.id" type="button" @click="openRoute(route.id)">🚌 {{ displayName(route, busLanguage) }}</button>
          <button v-for="stop in favoriteStops" :key="stop.id" type="button" @click="openStop(stop.id)">⌖ {{ displayStopName(stop, busLanguage) }}</button>
        </div>
        <p v-else class="muted">{{ busText('noFavorites') }}</p>
      </section>

      <nav class="feature-links" :aria-label="busLanguage === 'zh' ? '巴士功能' : 'Bus features'">
        <a :href="vehiclesHref"><span>{{ busText('vehicles') }}</span></a>
        <a :href="schedulesHref"><span>{{ busText('schedules') }}</span></a>
        <a :href="filesHref"><span>{{ busText('files') }}</span></a>
      </nav>
    </template>
  </main>
</template>

<script setup>
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { busApi } from './api.mjs'
import { displayName, displayStopName, formatDistance, formatLocalDateTime, haversineMeters, isTerminalArrival, matchesSearch, sortArrivalsByEstimatedTime, unavailableReasonTextKey } from './core.mjs'
import { favoriteRouteIds, favoriteStopIds, loadFavorites } from './favorites.mjs'
import { busLanguage, busText, setBusLanguage } from './i18n.mjs'
import { renderNoticeMarkdown } from './markdown.mjs'

const props = defineProps({
  routeHref: { type: String, default: '/transport/bustimer_v2_route.html?id=' },
  stopHref: { type: String, default: '/transport/bustimer_v2_stop.html?id=' },
  vehiclesHref: { type: String, default: '/transport/bustimer_v2_vehicles.html' },
  schedulesHref: { type: String, default: '/transport/bustimer_v2_schedules.html' },
  filesHref: { type: String, default: '/transport/bustimer_v2_files.html' },
})

const loading = ref(true)
const error = ref('')
const routes = ref([])
const stops = ref([])
const notices = ref([])
const query = ref('')
const arrivals = ref({})
const locationState = ref('idle')
const locationError = ref('')
const nearbyStops = ref([])
const allNearbyStops = ref(false)
let nearbyCandidates = []
let refreshTimer

const searchResults = computed(() => [
  ...routes.value.filter((route) => matchesSearch(route, query.value, busLanguage.value)).map((item) => ({ kind: 'route', item })),
  ...stops.value.filter((stop) => matchesSearch(stop, query.value, busLanguage.value)).map((item) => ({ kind: 'stop', item })),
].slice(0, 8))
const favoriteRoutes = computed(() => routes.value.filter((route) => favoriteRouteIds.value.includes(route.id)))
const favoriteStops = computed(() => stops.value.filter((stop) => favoriteStopIds.value.includes(stop.id)))
const visibleNearbyStops = computed(() => nearbyStops.value.slice(0, allNearbyStops.value ? 5 : 2))

function go(href, id) {
  if (typeof window !== 'undefined') window.location.assign(`${href}${encodeURIComponent(id)}`)
}
const openRoute = (id) => go(props.routeHref, id)
const openStop = (id) => go(props.stopHref, id)
const routeDirectionHref = (arrival) => `${props.routeHref}${encodeURIComponent(arrival.route_id)}&direction=${encodeURIComponent(arrival.route_direction_id)}`
const openResult = (result) => result.kind === 'route' ? openRoute(result.item.id) : openStop(result.item.id)
const openFirstResult = () => { if (searchResults.value[0]) openResult(searchResults.value[0]) }
const noticeScope = (notice) => notice.route_id ? 'route' : notice.stop_id ? 'stop' : 'global'
const noticeTitle = (notice) => notice[busLanguage.value === 'en' ? 'title_en' : 'title_zh'] || notice.title_zh || notice.title_en
const noticeTime = (notice) => formatLocalDateTime(notice.starts_at)
const arrivalName = (arrival) => `${arrival[busLanguage.value === 'en' ? 'route_name_en' : 'route_name_zh'] || arrival.route_name_zh || arrival.route_name_en || ''} · ${arrival[busLanguage.value === 'en' ? 'direction_name_en' : 'direction_name_zh'] || arrival.direction_name_zh || arrival.direction_name_en || ''}`

function arrivalText(arrival) {
  if (arrival.source === 'real_time') return busLanguage.value === 'zh' ? `${arrival.eta_minutes} 分钟` : `${arrival.eta_minutes} min`
  if (arrival.source === 'planned') {
    const time = new Date(arrival.planned_arrival_at).toLocaleTimeString(busLanguage.value === 'zh' ? 'zh-CN' : 'en', { hour: '2-digit', minute: '2-digit', hour12: false })
    return busLanguage.value === 'zh' ? `预计 ${time}` : `Scheduled ${time}`
  }
  return busText(unavailableReasonTextKey(arrival.unavailable_reason))
}

async function loadArrivals(stopList) {
  const results = await Promise.all(stopList.map(async (stop) => {
    arrivals.value = { ...arrivals.value, [stop.id]: { loading: true, items: [] } }
    try {
      const result = await busApi.arrivals(stop.id)
      const rawItems = result.arrivals || []
      const items = sortArrivalsByEstimatedTime(rawItems.filter((arrival) => !isTerminalArrival(arrival, stop.id, routes.value)))
      arrivals.value = { ...arrivals.value, [stop.id]: { loading: false, items } }
      return { stop, hidden: rawItems.length > 0 && !items.length }
    } catch {
      arrivals.value = { ...arrivals.value, [stop.id]: { loading: false, error: true, items: [] } }
      return { stop, hidden: false }
    }
  }))
  nearbyStops.value = results.filter(({ hidden }) => !hidden).map(({ stop }) => stop)
  return results
}

function locate() {
  if (!navigator.geolocation) { locationState.value = 'failed'; locationError.value = busText('locationUnavailable'); return }
  allNearbyStops.value = false
  locationState.value = 'loading'
  navigator.geolocation.getCurrentPosition(async ({ coords }) => {
    nearbyCandidates = stops.value.filter((stop) => Number.isFinite(stop.latitude) && Number.isFinite(stop.longitude)).map((stop) => ({
      ...stop, distance: haversineMeters(coords.latitude, coords.longitude, stop.latitude, stop.longitude),
    })).sort((left, right) => left.distance - right.distance).slice(0, 6)
    nearbyStops.value = nearbyCandidates
    locationState.value = 'ready'
    await loadArrivals(nearbyCandidates)
  }, (reason) => {
    locationState.value = reason.code === 1 ? 'denied' : 'failed'
    locationError.value = reason.message || busText('locationDenied')
  }, { enableHighAccuracy: false, timeout: 10000, maximumAge: 60000 })
}

async function load() {
  loading.value = true
  error.value = ''
  try {
    const [routeData, stopData, noticeData] = await Promise.all([busApi.routes(), busApi.stops(), busApi.notices()])
    routes.value = Array.isArray(routeData) ? routeData : []
    stops.value = Array.isArray(stopData) ? stopData : []
    notices.value = (Array.isArray(noticeData) ? noticeData : []).sort((left, right) => right.priority - left.priority)
  } catch (reason) { error.value = reason.message || String(reason) } finally { loading.value = false }
}

onMounted(() => { loadFavorites(); load(); refreshTimer = setInterval(() => { if (nearbyCandidates.length) loadArrivals(nearbyCandidates) }, 30000) })
onBeforeUnmount(() => clearInterval(refreshTimer))
defineExpose({ load, locate, openRoute, openStop, favoriteRouteIds, favoriteStopIds })
</script>

<style scoped lang="scss">
.notices > .muted { margin: 1rem 0 0; }
.notices > details > .markdown { margin-top: 0; }
.notices summary { list-style: none; }
.notices summary::-webkit-details-marker { display: none; }
.notices summary > span::after { content: ' ▸'; color: #687386; }
.notices details[open] > summary > span::after { content: ' ▾'; }
.bus-home { max-width: 900px; margin: 0 auto; color: var(--c-text, #243043); }
.bus-home__header, .section-title, .nearby-stop__head { display: flex; align-items: center; justify-content: space-between; gap: .75rem; }
.bus-home__header { margin: .5rem 0 .75rem; } .bus-home__header h1, .panel h3 { margin: 0; padding-top: 0; } .bus-home__header h1 { font-size: 1.4rem; line-height: 1.25; } .bus-home__header p, .muted { color: #687386; }
.panel h3 { font-size: 1rem; line-height: 1.3; }
.panel { margin: 1rem 0; padding: 1rem; border: 1px solid var(--c-border, #dce2ea); border-radius: .6rem; background: var(--c-bg-soft, #fff); }
.search { position: relative; padding: 0; } .search input { box-sizing: border-box; width: 100%; padding: .85rem 1rem; border: 0; border-radius: .6rem; font: inherit; background: transparent; color: inherit; }
.search-results { position: absolute; z-index: 2; top: calc(100% + .25rem); width: 100%; overflow: hidden; border: 1px solid #dce2ea; border-radius: .5rem; background: #fff; box-shadow: 0 .5rem 1rem rgba(0,0,0,.12); }
.search-results button, .quick-links button { display: block; width: 100%; padding: .65rem 1rem; border: 0; text-align: left; background: transparent; color: inherit; cursor: pointer; } .search-results button:hover, .quick-links button:hover { background: #f3f7fc; }
.search-results small, summary small { margin-right: .5rem; color: #64748b; } .status { display: flex; align-items: center; gap: .5rem; } .error { color: #a32727; } button { font: inherit; cursor: pointer; } button:disabled { cursor: wait; opacity: .6; }
.spinner { width: 1em; height: 1em; border: 2px solid #b7c9dd; border-top-color: #2672bc; border-radius: 50%; animation: spin .8s linear infinite; } @keyframes spin { to { transform: rotate(360deg); } }
details + details { border-top: 1px solid #e7edf4; } summary { display: flex; justify-content: space-between; padding: .65rem 0; cursor: pointer; font-weight: 600; } .markdown { margin: 1rem 0 0; padding-bottom: .25rem; } .markdown :deep(p) { margin: 1rem 0 0; } .markdown :deep(:first-child) { margin-top: 0; } .markdown :deep(:last-child) { margin-bottom: 0; } .markdown :deep(code) { padding: .1em .3em; background: #edf2f7; border-radius: .2em; }
.section-title button, .status button, .text-button { padding: .4rem .65rem; border: 1px solid #a9c2dc; border-radius: .35rem; background: transparent; color: inherit; } .text-button { border: 0; color: #2166a8; }
.nearby-stop { padding: .75rem 0; border-top: 1px solid #e7edf4; } .nearby-stop__head { align-items: baseline; } .link-title { padding: 0; border: 0; background: transparent; color: #1765ac; font-weight: 700; text-align: left; }
.nearby-toggle { padding: .4rem .65rem; border: 1px solid #a9c2dc; border-radius: .35rem; background: transparent; color: inherit; font: inherit; }
.arrival-list { margin: .45rem 0 0; padding: 0; list-style: none; } .arrival-list li { display: grid; grid-template-columns: .35rem minmax(0, 1fr) auto; gap: .4rem; align-items: center; padding: .3rem 0; } .arrival-list i { width: .3rem; height: 1.2rem; border-radius: 2px; } .arrival-link { color: #1765ac; text-decoration: none; } .arrival-link:hover, .arrival-link:focus-visible { text-decoration: underline; } .arrival-list .muted { display: block; }
.quick-links { display: flex; flex-wrap: wrap; gap: .5rem; } .quick-links button { width: auto; border: 1px solid #d6e1ec; border-radius: .35rem; }
.feature-links { display: grid; grid-template-columns: repeat(3, 1fr); gap: .75rem; } .feature-links a { display: flex; min-height: 4rem; align-items: center; justify-content: center; padding: 0 .75rem; border-radius: .6rem; background: #eaf3fc; color: #165c9d; text-align: center; text-decoration: none; font-weight: 600; }
.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0,0,0,0); }
@media (max-width: 560px) { .panel { margin: .75rem 0; } .feature-links { grid-template-columns: 1fr; } }
</style>
+260 −0

File added.

Preview size limit exceeded, changes collapsed.

+145 −0

File added.

Preview size limit exceeded, changes collapsed.

+34 −0
Original line number Diff line number Diff line
<template>
  <div class="schedule-toolbar">
    <div class="schedule-legend" :aria-label="text('时刻表图例', 'Schedule legend')">
      <span><i class="swatch bus" />{{ text('巴士', 'Bus') }}</span><span><i class="swatch shuttle" />{{ text('电瓶车', 'EV Shuttle') }}</span><span><b class="time bus next">07:20</b>{{ text('下一班', 'Next') }}</span><span><b class="time bus running">07:10</b>{{ text('运行中', 'Running') }}</span>
    </div>
    <label class="schedule-filter"><input v-model="activeOnly" type="checkbox"> {{ text('仅显示运行中/待发车', 'Only running/upcoming') }}</label>
  </div>
  <div class="schedule-list">
    <article v-for="group in groups" :key="group.key" class="schedule-row">
      <div class="route-info" :style="{ borderColor: group.color || '#2878c8' }">
        <strong>{{ group.routeName }}</strong><span>{{ group.directionName }}</span><small>{{ serviceLabel(group) }}</small>
      </div>
      <div class="times">
        <span v-for="item in visibleTimes(group.times)" :key="`${item.time}-${item.vehicleType}`" :class="['time', item.status, item.vehicleType?.toLowerCase()]">{{ item.time }}</span>
        <span v-if="!visibleTimes(group.times).length" class="muted">{{ text('无运行中或待发车班次', 'No running or upcoming trips') }}</span>
      </div>
    </article>
  </div>
</template>

<script setup>
import { ref } from 'vue'

const props = defineProps({ groups: { type: Array, default: () => [] }, language: { type: String, default: 'zh' } })
const activeOnly = ref(false)
const text = (zh, en) => props.language === 'zh' ? zh : en
const serviceLabel = (group) => [group.serviceType && group.serviceType !== 'NORMAL' ? group.serviceType : '', group.vehicleTypes?.join(' / ')].filter(Boolean).join(' · ') || text('常规服务', 'Normal service')
const visibleTimes = (times) => activeOnly.value ? times.filter((item) => item.status !== 'past') : times
</script>

<style scoped lang="scss">
.schedule-toolbar { display: flex; flex-wrap: wrap; gap: .55rem 1rem; align-items: center; justify-content: space-between; margin-bottom: .7rem; font-size: .84rem; }.schedule-legend { display: flex; flex-wrap: wrap; gap: .55rem 1rem; align-items: center; }.schedule-legend > span { display: inline-flex; align-items: center; gap: .3rem; }.schedule-filter { cursor: pointer; white-space: nowrap; }.swatch { width: .72rem; height: .72rem; border-radius: 50%; }.swatch.bus { background: #ed6c00; }.swatch.shuttle { background: #00bcd4; }.schedule-list { overflow: hidden; border: 1px solid #d9e2ec; border-radius: .6rem; container-type: inline-size; }.schedule-row { display: flex; flex-wrap: wrap; border-bottom: 1px solid #e7edf4; }.schedule-row:last-child { border: 0; }.route-info { box-sizing: border-box; display: flex; min-width: 8rem; flex: 1 1 9rem; flex-direction: column; justify-content: center; gap: .2rem; padding: .75rem; border-left: .4rem solid; background: var(--c-bg-soft, #f8fafc); }.route-info small, .muted { color: #667085; }.times { box-sizing: border-box; display: flex; min-width: 8rem; flex: 999 1 8rem; flex-wrap: wrap; align-content: flex-start; gap: .45rem; padding: .75rem; }.time { --vehicle-color: #ed6c00; padding: .13rem .38rem; border-radius: .25rem; color: var(--vehicle-color); font-family: ui-monospace, SFMono-Regular, Consolas, monospace; }.time.shuttle { --vehicle-color: #00bcd4; }.time.running { border: 1px solid var(--vehicle-color); background: color-mix(in srgb, var(--vehicle-color) 10%, transparent); }.time.next { background: var(--vehicle-color); color: #fff; font-weight: 700; }.time.past { opacity: .35; }
@container (max-width: 24rem) { .schedule-row { flex-direction: column; }.route-info { width: 100%; min-width: 0; flex: none; flex-direction: row; flex-wrap: wrap; align-items: baseline; justify-content: flex-start; border-left: 0; border-top: .35rem solid; padding: .5rem; }.times { width: 100%; min-width: 0; flex: none; } }
</style>
Loading