Loading docs/.vuepress/cache.mjs 0 → 100644 +61 −0 Changes for docs/.vuepress/cache.mjs: 61 added lines, 0 removed lines. Original line number Diff line number Diff line // Cache API rejects 206 responses; store each PMTiles byte range separately as 200. export const pmtilesRangePlugin = { cacheKeyWillBeUsed: ({ request }) => { const url = new URL(request.url) url.searchParams.set('__pmtiles_range', request.headers.get('Range')) return new Request(url, request) }, cacheWillUpdate: ({ request, response }) => { if (request.cache === 'no-store' || response.status !== 206) return null return new Response(response.body, { status: 200, headers: response.headers }) }, cachedResponseWillBeUsed: ({ request, cachedResponse }) => { if (!cachedResponse || ['reload', 'no-store', 'no-cache'].includes(request.cache)) return null return new Response(cachedResponse.body, { status: 206, headers: cachedResponse.headers }) }, } // Workbox serializes these callbacks into the worker; keep them self-contained. export const runtimeCaching = [ { urlPattern: ({ url }) => /^(\/api\/public)?\/(routes|stops)(\/|$)/.test(url.pathname), handler: 'NetworkOnly', // The API's HTTP cache headers control reference data freshness. }, { urlPattern: ({ url }) => url.pathname.startsWith('/api/') || /^\/(vehicles|arrivals|notices|schedules)(\/|$)/.test(url.pathname), handler: 'NetworkOnly', options: { fetchOptions: { cache: 'no-store' } }, }, { urlPattern: ({ url, request }) => url.pathname.endsWith('.pmtiles') && request.headers.has('Range'), handler: 'CacheFirst', options: { cacheName: 'pmtiles-ranges-v1', plugins: [pmtilesRangePlugin], expiration: { maxEntries: 512, maxAgeSeconds: 86400, purgeOnQuotaError: true }, }, }, { urlPattern: ({ url, request }) => (url.origin === 'https://bus.sustcra.com' && /^\/(static|geojson)\//.test(url.pathname)) || (url.origin === 'https://mirrors.sustech.edu.cn' && /^\/(osm-tile|site\/pmtiles-data)\//.test(url.pathname)) || (url.origin === 'https://google-proxy.zuzuche.com' && url.pathname.startsWith('/vt/')) || ['image', 'font', 'style', 'script'].includes(request.destination), handler: 'CacheFirst', options: { cacheName: 'static-resources-v1', expiration: { maxEntries: 512, maxAgeSeconds: 86400, purgeOnQuotaError: true }, cacheableResponse: { statuses: [0, 200] }, }, }, { urlPattern: ({ url }) => url.origin === self.location.origin, handler: 'NetworkFirst', options: { cacheName: 'web-cache', networkTimeoutSeconds: 5, expiration: { maxEntries: 50, maxAgeSeconds: 86400, purgeOnQuotaError: true }, cacheableResponse: { statuses: [200] }, }, }, ] docs/.vuepress/cache.test.mjs 0 → 100644 +72 −0 Changes for docs/.vuepress/cache.test.mjs: 72 added lines, 0 removed lines. Original line number Diff line number Diff line import assert from 'node:assert/strict' import test from 'node:test' import { pmtilesRangePlugin, runtimeCaching } from './cache.mjs' test('live APIs bypass caches before static/site rules; reference data uses HTTP caching', () => { globalThis.self = { location: { origin: 'https://sustech.online' } } try { const ruleFor = (url, destination = '') => runtimeCaching.find(({ urlPattern }) => urlPattern({ url: new URL(url), request: { destination, headers: new Headers() }, })) for (const path of ['/vehicles/all', '/arrivals/s1', '/notices', '/schedules', '/api/public/vehicles', '/api/routes', '/api/v2/monitor_osm/', '/api/v3/r/d/s']) { for (const origin of ['https://sustech.online', 'https://buseta.sustcra.com', 'https://bus.sustcra.com']) { const rule = ruleFor(origin + path, 'image') assert.equal(rule.handler, 'NetworkOnly') assert.equal(rule.options.fetchOptions.cache, 'no-store') } } for (const path of ['/routes', '/stops/id', '/api/public/routes/id']) { const rule = ruleFor('https://buseta.sustcra.com' + path) assert.equal(rule.handler, 'NetworkOnly') assert.equal(rule.options, undefined) } for (const url of [ 'https://bus.sustcra.com/static/protomaps/pmtiles-style/pmtiles-light.json', 'https://bus.sustcra.com/static/protomaps/fonts/a.pbf', 'https://bus.sustcra.com/geojson/sustech_bldg.json', 'https://mirrors.sustech.edu.cn/osm-tile/tiles/14/1/2.pbf', 'https://mirrors.sustech.edu.cn/site/pmtiles-data/fonts/Noto%20Sans/0-255.pbf', 'https://google-proxy.zuzuche.com/vt/?lyrs=s&x=1&y=2&z=14', ]) { const rule = ruleFor(url) assert.equal(rule.handler, 'CacheFirst') assert.ok(rule.options.expiration.maxEntries > 0) assert.ok(rule.options.expiration.maxAgeSeconds > 0) } assert.equal(ruleFor('https://sustech.online/transport/bustimer.html').handler, 'NetworkFirst') } finally { delete globalThis.self } }) test('PMTiles caches distinct ranges as 200 and returns unchanged bytes and ETag as 206', async () => { const request = new Request('https://bus.sustcra.com/static/map.pmtiles?v=2', { headers: { Range: 'bytes=10-13' } }) assert.equal(runtimeCaching.find(({ urlPattern }) => urlPattern({ url: new URL(request.url), request })).options.cacheName, 'pmtiles-ranges-v1') const key = pmtilesRangePlugin.cacheKeyWillBeUsed({ request }) const otherKey = pmtilesRangePlugin.cacheKeyWillBeUsed({ request: new Request(request, { headers: { Range: 'bytes=14-17' } }) }) assert.notEqual(key.url, otherKey.url) assert.equal(new URL(key.url).searchParams.get('v'), '2') const headers = { ETag: '"archive-v2"', 'Content-Range': 'bytes 10-13/100', 'Content-Length': '4' } const stored = pmtilesRangePlugin.cacheWillUpdate({ request, response: new Response(new Uint8Array([0, 1, 254, 255]), { status: 206, headers }) }) assert.equal(stored.status, 200) const replayed = pmtilesRangePlugin.cachedResponseWillBeUsed({ request: key, cachedResponse: stored }) assert.equal(replayed.status, 206) assert.equal(replayed.headers.get('ETag'), headers.ETag) assert.equal(replayed.headers.get('Content-Range'), headers['Content-Range']) assert.deepEqual([...new Uint8Array(await replayed.arrayBuffer())], [0, 1, 254, 255]) }) test('PMTiles reload bypasses stale ranges and no-store/error responses are not persisted', () => { for (const cache of ['reload', 'no-cache', 'no-store']) { const request = new Request('https://bus.sustcra.com/map.pmtiles', { cache, headers: { Range: 'bytes=0-3' } }) const key = pmtilesRangePlugin.cacheKeyWillBeUsed({ request }) assert.equal(key.cache, cache) assert.equal(pmtilesRangePlugin.cachedResponseWillBeUsed({ request: key, cachedResponse: new Response('old') }), null) const stored = pmtilesRangePlugin.cacheWillUpdate({ request, response: new Response('new', { status: 206 }) }) assert.equal(stored?.status ?? null, cache === 'no-store' ? null : 200) } const request = new Request('https://bus.sustcra.com/map.pmtiles') for (const status of [200, 404, 416, 500]) { assert.equal(pmtilesRangePlugin.cacheWillUpdate({ request, response: new Response('error', { status }) }), null) } }) docs/.vuepress/components/bus-v2/BusScheduleRowsV2.vue +6 −1 Changes for docs/.vuepress/components/bus-v2/BusScheduleRowsV2.vue: 6 added lines, 1 removed line. Original line number Diff line number Diff line Loading @@ -11,7 +11,7 @@ <strong>{{ group.routeName }}</strong><span>{{ group.directionName }}</span><small>{{ serviceLabel(group) }}</small> </div> <div class="times"> <span v-for="(item, index) in displayedTimes(group)" :key="index" :class="['time', item.status, item.vehicleType?.toLowerCase()]">{{ item.time }}</span> <span v-for="(item, index) in displayedTimes(group)" :key="index" :class="['time', item.status, item.vehicleType?.toLowerCase(), { changed: item.change === 'CANCELLED' || item.change === 'EXTRA' }]" :title="item.note || undefined"><del v-if="item.change === 'CANCELLED'">{{ item.time }}</del><template v-else>{{ item.time }}</template><small v-if="changeLabel(item)"> {{ changeLabel(item) }}</small></span> <span v-if="!displayedTimes(group).length" class="muted">{{ text('无运行中或待发车班次', 'No running or upcoming trips') }}</span> <button v-if="isCollapsed(group.key) && visibleTimes(group.times).length > 1" class="ellipsis" type="button" :aria-label="text('展开线路时刻表', 'Expand route schedule')" @click="toggleRoute(group.key)">······</button> </div> Loading @@ -28,6 +28,7 @@ const collapsedRoutes = ref(new Set()) const portrait = ref(false) let orientationQuery const text = (zh, en) => props.language === 'zh' ? zh : en const changeLabel = (item) => ({ EXTRA: text('加班', 'Extra'), CANCELLED: text('停运', 'Cancelled') })[item.change] || '' 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 const isCollapsed = (groupKey) => portrait.value && collapsedRoutes.value.has(groupKey) Loading @@ -47,4 +48,8 @@ onBeforeUnmount(() => { orientationQuery?.removeEventListener?.('change', update .route-info.collapsible { cursor: pointer; } .ellipsis { border: 0; padding: .13rem .38rem; background: transparent; color: var(--bus-v2-muted); font: inherit; letter-spacing: .15em; cursor: pointer; } .route-info small, .muted { color: var(--bus-v2-muted); } .time.changed { border: 1px dashed currentColor; } .time.cancelled { color: var(--bus-v2-muted); } .time small { font-family: inherit; font-size: .8em; } .time del { opacity: .7; } </style> docs/.vuepress/components/bus-v2/BusSchedulesV2.vue +17 −11 Changes for docs/.vuepress/components/bus-v2/BusSchedulesV2.vue: 17 added lines, 11 removed lines. Original line number Diff line number Diff line <template> <section class="bus-schedules" :lang="language === 'zh' ? 'zh-CN' : 'en'"> <header class="section-title"><div class="schedule-title"><h3>{{ text('全部时刻表', 'All schedules') }}</h3><p class="schedule-note">{{ text('表中时间均为首站发车时间。', 'Times are departures from the terminus.') }}</p></div></header> <div class="day-select" role="group" :aria-label="text('计划类型', 'Schedule type')"><button :class="{ active: dayType === 'WORKDAY' }" type="button" @click="load('WORKDAY')">{{ text('工作日', 'Workday') }}</button><button :class="{ active: dayType === 'HOLIDAY' }" type="button" @click="load('HOLIDAY')">{{ text('节假日', 'Holiday') }}</button></div> <label class="day-select">{{ text('运营日期', 'Service date (Shanghai)') }}<input v-model="serviceDate" type="date" required @change="load" /></label> <section v-if="loading && !groups.length" class="state">{{ busText('loading') }}</section> <section v-if="error" class="state error" role="alert"><strong>{{ busText('loadFailed') }}</strong><span>{{ error }}</span><button type="button" @click="load(retryDayType)">{{ busText('retry') }}</button></section> <section v-if="error" class="state error" role="alert"><strong>{{ busText('loadFailed') }}</strong><span>{{ error }}</span><button type="button" @click="load">{{ busText('retry') }}</button></section> <section v-if="!loading && !error && !groups.length" class="state">{{ busText('empty') }}</section> <BusScheduleRowsV2 v-if="groups.length" :groups="groups" :language="language" /> <p class="note">{{ text('“运行中”按线路预计运行时间加 20% 估算。', 'Running status uses the estimated route duration plus 20%.') }}</p> Loading @@ -19,23 +19,26 @@ import BusScheduleRowsV2 from './BusScheduleRowsV2.vue' let routesCache = null, routesRequest, scheduleRequest = 0 const loadRoutes = () => routesCache ? Promise.resolve(routesCache) : (routesRequest ||= busApi.routes().then((data) => routesCache = Array.isArray(data) ? data : []).finally(() => { routesRequest = undefined })) const language = busLanguage; const schedules = ref([]); const routes = ref([]); const loading = ref(true); const error = ref(''); const dayType = ref('WORKDAY'); const retryDayType = ref(''); const now = ref(new Date()) const language = busLanguage; const schedules = ref([]); const routes = ref([]); const loading = ref(true); const error = ref(''); const serviceDate = ref(new Intl.DateTimeFormat('en-CA', { timeZone: 'Asia/Shanghai' }).format(new Date())); const now = ref(new Date()) const text = (zh, en) => language.value === 'zh' ? zh : en const nowMinutes = computed(() => now.value.getHours() * 60 + now.value.getMinutes()) const nowMinutes = computed(() => { const today = new Intl.DateTimeFormat('en-CA', { timeZone: 'Asia/Shanghai' }).format(now.value) const parts = new Intl.DateTimeFormat('en-GB', { timeZone: 'Asia/Shanghai', hour: '2-digit', minute: '2-digit', hourCycle: 'h23' }).format(now.value).split(':') return (Date.parse(today) - Date.parse(serviceDate.value)) / 60000 + Number(parts[0]) * 60 + Number(parts[1]) }) const groups = computed(() => groupSchedules(schedules.value, nowMinutes.value, routes.value).map((item) => ({ ...item, color: item.route_color, routeName: item[language.value === 'zh' ? 'route_name_zh' : 'route_name_en'] || item.route_name_zh || item.route_name_en || item.route_id, directionName: item[language.value === 'zh' ? 'direction_name_zh' : 'direction_name_en'] || item.direction_name_zh || item.direction_name_en || item.route_direction_id }))) async function load(requestedDayType) { async function load() { now.value = new Date() if (!serviceDate.value) { error.value = text('请选择运营日期。', 'Select a service date.'); return } const request = ++scheduleRequest retryDayType.value = requestedDayType || '' if (requestedDayType) dayType.value = requestedDayType loading.value = true error.value = '' schedules.value = [] try { const [data, routeData] = await Promise.all([busApi.schedules(requestedDayType ? `day_type=${requestedDayType}` : ''), loadRoutes()]) const [data, routeData] = await Promise.all([busApi.schedules(`date=${encodeURIComponent(serviceDate.value)}`), loadRoutes()]) if (request !== scheduleRequest) return schedules.value = Array.isArray(data) ? data : [] routes.value = routeData dayType.value = schedules.value[0]?.day_type || requestedDayType || (new Date().getDay() % 6 ? 'WORKDAY' : 'HOLIDAY') } catch (reason) { if (request === scheduleRequest) error.value = reason.message || String(reason) } finally { Loading @@ -46,10 +49,13 @@ onMounted(load); defineExpose({ load }) </script> <style scoped lang="scss"> .bus-schedules { max-width: 1100px; margin: 0 auto; color: var(--c-text, #243043); }.section-title { display: flex; align-items: center; justify-content: space-between; gap: .75rem; }.schedule-title { display: flex; flex-wrap: wrap; align-items: baseline; gap: .5rem; }.section-title h3 { margin: 0; padding-top: 0; font-size: 1rem; line-height: 1.3; }.bus-schedules p { margin-top: 0; }.schedule-note, .muted, .note { color: #667085; }.schedule-note { margin-top: 0 !important; margin-bottom: 0; font-size: .875rem; font-weight: 400; white-space: nowrap; }.bus-schedules button { padding: .42rem .75rem; border: 1px solid #a9c2dc; background: transparent; color: inherit; font: inherit; cursor: pointer; }.day-select { display: flex; margin: 1rem 0; }.day-select button { flex: 1; }.day-select button:first-child { border-radius: .4rem 0 0 .4rem; }.day-select button:last-child { border-radius: 0 .4rem .4rem 0; }.day-select .active { border-color: #1765ac; background: #1765ac; color: #fff; }.toolbar { display: flex; justify-content: flex-end; margin-bottom: 1rem; font-size: .86rem; }.toolbar time { font-family: ui-monospace, monospace; }.state { display: flex; align-items: center; gap: .5rem; padding: 1rem; border: 1px solid #d9e2ec; border-radius: .55rem; }.error { color: #b42318; }.note { margin-top: 1rem; font-size: .85rem; } @media (max-width: 600px) { .section-title { align-items: flex-start; flex-direction: column; } .toolbar { justify-content: flex-start; } } .bus-schedules { max-width: 1100px; margin: 0 auto; color: var(--c-text, #243043); }.section-title { display: flex; align-items: center; justify-content: space-between; gap: .75rem; }.schedule-title { display: flex; flex-wrap: wrap; align-items: baseline; gap: .5rem; }.section-title h3 { margin: 0; padding-top: 0; font-size: 1rem; line-height: 1.3; }.bus-schedules p { margin-top: 0; }.schedule-note, .muted, .note { color: #667085; }.schedule-note { margin-top: 0 !important; margin-bottom: 0; font-size: .875rem; font-weight: 400; white-space: nowrap; }.bus-schedules button { padding: .42rem .75rem; border: 1px solid #a9c2dc; background: transparent; color: inherit; font: inherit; cursor: pointer; }.day-select { display: flex; margin: 1rem 0; }.toolbar { display: flex; justify-content: flex-end; margin-bottom: 1rem; font-size: .86rem; }.toolbar time { font-family: ui-monospace, monospace; }.state { display: flex; align-items: center; gap: .5rem; padding: 1rem; border: 1px solid #d9e2ec; border-radius: .55rem; }.error { color: #b42318; }.note { margin-top: 1rem; font-size: .85rem; } @media (max-width: 600px) { .section-title { align-items: flex-start; flex-direction: column; } .toolbar { justify-content: flex-start; } } .bus-schedules { color: var(--bus-v2-text); } .bus-schedules .schedule-note, .bus-schedules .muted, .bus-schedules .note { color: var(--bus-v2-muted); } .bus-schedules button, .bus-schedules .state { border-color: var(--bus-v2-border); } .bus-schedules .day-select .active { border-color: var(--bus-v2-link); background: var(--bus-v2-link); color: var(--vp-c-accent-text); } .bus-schedules .state { background: var(--bus-v2-bg); } .day-select { align-items: center; gap: .75rem; flex-wrap: wrap; } .day-select input { min-height: 44px; max-width: 100%; padding: .4rem .6rem; border: 1px solid var(--bus-v2-border); border-radius: .4rem; background: var(--bus-v2-bg); color: inherit; font: inherit; } </style> docs/.vuepress/components/bus-v2/api.mjs +4 −4 Changes for docs/.vuepress/components/bus-v2/api.mjs: 4 added lines, 4 removed lines. Original line number Diff line number Diff line import { resolveBusApiBase } from './core.mjs' export const busApiBase = () => resolveBusApiBase(import.meta.env.PROD) export const busApiBase = () => resolveBusApiBase(import.meta.env?.PROD) export async function publicApi(path, options = {}) { const base = busApiBase() const separator = path.includes('?') ? '&' : '?' const url = `${base}${path.startsWith('/') ? path : `/${path}`}${separator}_=${Date.now()}` const url = `${base}${path.startsWith('/') ? path : `/${path}`}` const reference = /^\/(routes|stops)(\/|$)/.test(new URL(url).pathname) const response = await fetch(url, { ...options, cache: 'no-store', cache: reference ? 'default' : 'no-store', credentials: 'omit', headers: { Accept: 'application/json', ...options.headers }, }) Loading Loading
docs/.vuepress/cache.mjs 0 → 100644 +61 −0 Changes for docs/.vuepress/cache.mjs: 61 added lines, 0 removed lines. Original line number Diff line number Diff line // Cache API rejects 206 responses; store each PMTiles byte range separately as 200. export const pmtilesRangePlugin = { cacheKeyWillBeUsed: ({ request }) => { const url = new URL(request.url) url.searchParams.set('__pmtiles_range', request.headers.get('Range')) return new Request(url, request) }, cacheWillUpdate: ({ request, response }) => { if (request.cache === 'no-store' || response.status !== 206) return null return new Response(response.body, { status: 200, headers: response.headers }) }, cachedResponseWillBeUsed: ({ request, cachedResponse }) => { if (!cachedResponse || ['reload', 'no-store', 'no-cache'].includes(request.cache)) return null return new Response(cachedResponse.body, { status: 206, headers: cachedResponse.headers }) }, } // Workbox serializes these callbacks into the worker; keep them self-contained. export const runtimeCaching = [ { urlPattern: ({ url }) => /^(\/api\/public)?\/(routes|stops)(\/|$)/.test(url.pathname), handler: 'NetworkOnly', // The API's HTTP cache headers control reference data freshness. }, { urlPattern: ({ url }) => url.pathname.startsWith('/api/') || /^\/(vehicles|arrivals|notices|schedules)(\/|$)/.test(url.pathname), handler: 'NetworkOnly', options: { fetchOptions: { cache: 'no-store' } }, }, { urlPattern: ({ url, request }) => url.pathname.endsWith('.pmtiles') && request.headers.has('Range'), handler: 'CacheFirst', options: { cacheName: 'pmtiles-ranges-v1', plugins: [pmtilesRangePlugin], expiration: { maxEntries: 512, maxAgeSeconds: 86400, purgeOnQuotaError: true }, }, }, { urlPattern: ({ url, request }) => (url.origin === 'https://bus.sustcra.com' && /^\/(static|geojson)\//.test(url.pathname)) || (url.origin === 'https://mirrors.sustech.edu.cn' && /^\/(osm-tile|site\/pmtiles-data)\//.test(url.pathname)) || (url.origin === 'https://google-proxy.zuzuche.com' && url.pathname.startsWith('/vt/')) || ['image', 'font', 'style', 'script'].includes(request.destination), handler: 'CacheFirst', options: { cacheName: 'static-resources-v1', expiration: { maxEntries: 512, maxAgeSeconds: 86400, purgeOnQuotaError: true }, cacheableResponse: { statuses: [0, 200] }, }, }, { urlPattern: ({ url }) => url.origin === self.location.origin, handler: 'NetworkFirst', options: { cacheName: 'web-cache', networkTimeoutSeconds: 5, expiration: { maxEntries: 50, maxAgeSeconds: 86400, purgeOnQuotaError: true }, cacheableResponse: { statuses: [200] }, }, }, ]
docs/.vuepress/cache.test.mjs 0 → 100644 +72 −0 Changes for docs/.vuepress/cache.test.mjs: 72 added lines, 0 removed lines. Original line number Diff line number Diff line import assert from 'node:assert/strict' import test from 'node:test' import { pmtilesRangePlugin, runtimeCaching } from './cache.mjs' test('live APIs bypass caches before static/site rules; reference data uses HTTP caching', () => { globalThis.self = { location: { origin: 'https://sustech.online' } } try { const ruleFor = (url, destination = '') => runtimeCaching.find(({ urlPattern }) => urlPattern({ url: new URL(url), request: { destination, headers: new Headers() }, })) for (const path of ['/vehicles/all', '/arrivals/s1', '/notices', '/schedules', '/api/public/vehicles', '/api/routes', '/api/v2/monitor_osm/', '/api/v3/r/d/s']) { for (const origin of ['https://sustech.online', 'https://buseta.sustcra.com', 'https://bus.sustcra.com']) { const rule = ruleFor(origin + path, 'image') assert.equal(rule.handler, 'NetworkOnly') assert.equal(rule.options.fetchOptions.cache, 'no-store') } } for (const path of ['/routes', '/stops/id', '/api/public/routes/id']) { const rule = ruleFor('https://buseta.sustcra.com' + path) assert.equal(rule.handler, 'NetworkOnly') assert.equal(rule.options, undefined) } for (const url of [ 'https://bus.sustcra.com/static/protomaps/pmtiles-style/pmtiles-light.json', 'https://bus.sustcra.com/static/protomaps/fonts/a.pbf', 'https://bus.sustcra.com/geojson/sustech_bldg.json', 'https://mirrors.sustech.edu.cn/osm-tile/tiles/14/1/2.pbf', 'https://mirrors.sustech.edu.cn/site/pmtiles-data/fonts/Noto%20Sans/0-255.pbf', 'https://google-proxy.zuzuche.com/vt/?lyrs=s&x=1&y=2&z=14', ]) { const rule = ruleFor(url) assert.equal(rule.handler, 'CacheFirst') assert.ok(rule.options.expiration.maxEntries > 0) assert.ok(rule.options.expiration.maxAgeSeconds > 0) } assert.equal(ruleFor('https://sustech.online/transport/bustimer.html').handler, 'NetworkFirst') } finally { delete globalThis.self } }) test('PMTiles caches distinct ranges as 200 and returns unchanged bytes and ETag as 206', async () => { const request = new Request('https://bus.sustcra.com/static/map.pmtiles?v=2', { headers: { Range: 'bytes=10-13' } }) assert.equal(runtimeCaching.find(({ urlPattern }) => urlPattern({ url: new URL(request.url), request })).options.cacheName, 'pmtiles-ranges-v1') const key = pmtilesRangePlugin.cacheKeyWillBeUsed({ request }) const otherKey = pmtilesRangePlugin.cacheKeyWillBeUsed({ request: new Request(request, { headers: { Range: 'bytes=14-17' } }) }) assert.notEqual(key.url, otherKey.url) assert.equal(new URL(key.url).searchParams.get('v'), '2') const headers = { ETag: '"archive-v2"', 'Content-Range': 'bytes 10-13/100', 'Content-Length': '4' } const stored = pmtilesRangePlugin.cacheWillUpdate({ request, response: new Response(new Uint8Array([0, 1, 254, 255]), { status: 206, headers }) }) assert.equal(stored.status, 200) const replayed = pmtilesRangePlugin.cachedResponseWillBeUsed({ request: key, cachedResponse: stored }) assert.equal(replayed.status, 206) assert.equal(replayed.headers.get('ETag'), headers.ETag) assert.equal(replayed.headers.get('Content-Range'), headers['Content-Range']) assert.deepEqual([...new Uint8Array(await replayed.arrayBuffer())], [0, 1, 254, 255]) }) test('PMTiles reload bypasses stale ranges and no-store/error responses are not persisted', () => { for (const cache of ['reload', 'no-cache', 'no-store']) { const request = new Request('https://bus.sustcra.com/map.pmtiles', { cache, headers: { Range: 'bytes=0-3' } }) const key = pmtilesRangePlugin.cacheKeyWillBeUsed({ request }) assert.equal(key.cache, cache) assert.equal(pmtilesRangePlugin.cachedResponseWillBeUsed({ request: key, cachedResponse: new Response('old') }), null) const stored = pmtilesRangePlugin.cacheWillUpdate({ request, response: new Response('new', { status: 206 }) }) assert.equal(stored?.status ?? null, cache === 'no-store' ? null : 200) } const request = new Request('https://bus.sustcra.com/map.pmtiles') for (const status of [200, 404, 416, 500]) { assert.equal(pmtilesRangePlugin.cacheWillUpdate({ request, response: new Response('error', { status }) }), null) } })
docs/.vuepress/components/bus-v2/BusScheduleRowsV2.vue +6 −1 Changes for docs/.vuepress/components/bus-v2/BusScheduleRowsV2.vue: 6 added lines, 1 removed line. Original line number Diff line number Diff line Loading @@ -11,7 +11,7 @@ <strong>{{ group.routeName }}</strong><span>{{ group.directionName }}</span><small>{{ serviceLabel(group) }}</small> </div> <div class="times"> <span v-for="(item, index) in displayedTimes(group)" :key="index" :class="['time', item.status, item.vehicleType?.toLowerCase()]">{{ item.time }}</span> <span v-for="(item, index) in displayedTimes(group)" :key="index" :class="['time', item.status, item.vehicleType?.toLowerCase(), { changed: item.change === 'CANCELLED' || item.change === 'EXTRA' }]" :title="item.note || undefined"><del v-if="item.change === 'CANCELLED'">{{ item.time }}</del><template v-else>{{ item.time }}</template><small v-if="changeLabel(item)"> {{ changeLabel(item) }}</small></span> <span v-if="!displayedTimes(group).length" class="muted">{{ text('无运行中或待发车班次', 'No running or upcoming trips') }}</span> <button v-if="isCollapsed(group.key) && visibleTimes(group.times).length > 1" class="ellipsis" type="button" :aria-label="text('展开线路时刻表', 'Expand route schedule')" @click="toggleRoute(group.key)">······</button> </div> Loading @@ -28,6 +28,7 @@ const collapsedRoutes = ref(new Set()) const portrait = ref(false) let orientationQuery const text = (zh, en) => props.language === 'zh' ? zh : en const changeLabel = (item) => ({ EXTRA: text('加班', 'Extra'), CANCELLED: text('停运', 'Cancelled') })[item.change] || '' 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 const isCollapsed = (groupKey) => portrait.value && collapsedRoutes.value.has(groupKey) Loading @@ -47,4 +48,8 @@ onBeforeUnmount(() => { orientationQuery?.removeEventListener?.('change', update .route-info.collapsible { cursor: pointer; } .ellipsis { border: 0; padding: .13rem .38rem; background: transparent; color: var(--bus-v2-muted); font: inherit; letter-spacing: .15em; cursor: pointer; } .route-info small, .muted { color: var(--bus-v2-muted); } .time.changed { border: 1px dashed currentColor; } .time.cancelled { color: var(--bus-v2-muted); } .time small { font-family: inherit; font-size: .8em; } .time del { opacity: .7; } </style>
docs/.vuepress/components/bus-v2/BusSchedulesV2.vue +17 −11 Changes for docs/.vuepress/components/bus-v2/BusSchedulesV2.vue: 17 added lines, 11 removed lines. Original line number Diff line number Diff line <template> <section class="bus-schedules" :lang="language === 'zh' ? 'zh-CN' : 'en'"> <header class="section-title"><div class="schedule-title"><h3>{{ text('全部时刻表', 'All schedules') }}</h3><p class="schedule-note">{{ text('表中时间均为首站发车时间。', 'Times are departures from the terminus.') }}</p></div></header> <div class="day-select" role="group" :aria-label="text('计划类型', 'Schedule type')"><button :class="{ active: dayType === 'WORKDAY' }" type="button" @click="load('WORKDAY')">{{ text('工作日', 'Workday') }}</button><button :class="{ active: dayType === 'HOLIDAY' }" type="button" @click="load('HOLIDAY')">{{ text('节假日', 'Holiday') }}</button></div> <label class="day-select">{{ text('运营日期', 'Service date (Shanghai)') }}<input v-model="serviceDate" type="date" required @change="load" /></label> <section v-if="loading && !groups.length" class="state">{{ busText('loading') }}</section> <section v-if="error" class="state error" role="alert"><strong>{{ busText('loadFailed') }}</strong><span>{{ error }}</span><button type="button" @click="load(retryDayType)">{{ busText('retry') }}</button></section> <section v-if="error" class="state error" role="alert"><strong>{{ busText('loadFailed') }}</strong><span>{{ error }}</span><button type="button" @click="load">{{ busText('retry') }}</button></section> <section v-if="!loading && !error && !groups.length" class="state">{{ busText('empty') }}</section> <BusScheduleRowsV2 v-if="groups.length" :groups="groups" :language="language" /> <p class="note">{{ text('“运行中”按线路预计运行时间加 20% 估算。', 'Running status uses the estimated route duration plus 20%.') }}</p> Loading @@ -19,23 +19,26 @@ import BusScheduleRowsV2 from './BusScheduleRowsV2.vue' let routesCache = null, routesRequest, scheduleRequest = 0 const loadRoutes = () => routesCache ? Promise.resolve(routesCache) : (routesRequest ||= busApi.routes().then((data) => routesCache = Array.isArray(data) ? data : []).finally(() => { routesRequest = undefined })) const language = busLanguage; const schedules = ref([]); const routes = ref([]); const loading = ref(true); const error = ref(''); const dayType = ref('WORKDAY'); const retryDayType = ref(''); const now = ref(new Date()) const language = busLanguage; const schedules = ref([]); const routes = ref([]); const loading = ref(true); const error = ref(''); const serviceDate = ref(new Intl.DateTimeFormat('en-CA', { timeZone: 'Asia/Shanghai' }).format(new Date())); const now = ref(new Date()) const text = (zh, en) => language.value === 'zh' ? zh : en const nowMinutes = computed(() => now.value.getHours() * 60 + now.value.getMinutes()) const nowMinutes = computed(() => { const today = new Intl.DateTimeFormat('en-CA', { timeZone: 'Asia/Shanghai' }).format(now.value) const parts = new Intl.DateTimeFormat('en-GB', { timeZone: 'Asia/Shanghai', hour: '2-digit', minute: '2-digit', hourCycle: 'h23' }).format(now.value).split(':') return (Date.parse(today) - Date.parse(serviceDate.value)) / 60000 + Number(parts[0]) * 60 + Number(parts[1]) }) const groups = computed(() => groupSchedules(schedules.value, nowMinutes.value, routes.value).map((item) => ({ ...item, color: item.route_color, routeName: item[language.value === 'zh' ? 'route_name_zh' : 'route_name_en'] || item.route_name_zh || item.route_name_en || item.route_id, directionName: item[language.value === 'zh' ? 'direction_name_zh' : 'direction_name_en'] || item.direction_name_zh || item.direction_name_en || item.route_direction_id }))) async function load(requestedDayType) { async function load() { now.value = new Date() if (!serviceDate.value) { error.value = text('请选择运营日期。', 'Select a service date.'); return } const request = ++scheduleRequest retryDayType.value = requestedDayType || '' if (requestedDayType) dayType.value = requestedDayType loading.value = true error.value = '' schedules.value = [] try { const [data, routeData] = await Promise.all([busApi.schedules(requestedDayType ? `day_type=${requestedDayType}` : ''), loadRoutes()]) const [data, routeData] = await Promise.all([busApi.schedules(`date=${encodeURIComponent(serviceDate.value)}`), loadRoutes()]) if (request !== scheduleRequest) return schedules.value = Array.isArray(data) ? data : [] routes.value = routeData dayType.value = schedules.value[0]?.day_type || requestedDayType || (new Date().getDay() % 6 ? 'WORKDAY' : 'HOLIDAY') } catch (reason) { if (request === scheduleRequest) error.value = reason.message || String(reason) } finally { Loading @@ -46,10 +49,13 @@ onMounted(load); defineExpose({ load }) </script> <style scoped lang="scss"> .bus-schedules { max-width: 1100px; margin: 0 auto; color: var(--c-text, #243043); }.section-title { display: flex; align-items: center; justify-content: space-between; gap: .75rem; }.schedule-title { display: flex; flex-wrap: wrap; align-items: baseline; gap: .5rem; }.section-title h3 { margin: 0; padding-top: 0; font-size: 1rem; line-height: 1.3; }.bus-schedules p { margin-top: 0; }.schedule-note, .muted, .note { color: #667085; }.schedule-note { margin-top: 0 !important; margin-bottom: 0; font-size: .875rem; font-weight: 400; white-space: nowrap; }.bus-schedules button { padding: .42rem .75rem; border: 1px solid #a9c2dc; background: transparent; color: inherit; font: inherit; cursor: pointer; }.day-select { display: flex; margin: 1rem 0; }.day-select button { flex: 1; }.day-select button:first-child { border-radius: .4rem 0 0 .4rem; }.day-select button:last-child { border-radius: 0 .4rem .4rem 0; }.day-select .active { border-color: #1765ac; background: #1765ac; color: #fff; }.toolbar { display: flex; justify-content: flex-end; margin-bottom: 1rem; font-size: .86rem; }.toolbar time { font-family: ui-monospace, monospace; }.state { display: flex; align-items: center; gap: .5rem; padding: 1rem; border: 1px solid #d9e2ec; border-radius: .55rem; }.error { color: #b42318; }.note { margin-top: 1rem; font-size: .85rem; } @media (max-width: 600px) { .section-title { align-items: flex-start; flex-direction: column; } .toolbar { justify-content: flex-start; } } .bus-schedules { max-width: 1100px; margin: 0 auto; color: var(--c-text, #243043); }.section-title { display: flex; align-items: center; justify-content: space-between; gap: .75rem; }.schedule-title { display: flex; flex-wrap: wrap; align-items: baseline; gap: .5rem; }.section-title h3 { margin: 0; padding-top: 0; font-size: 1rem; line-height: 1.3; }.bus-schedules p { margin-top: 0; }.schedule-note, .muted, .note { color: #667085; }.schedule-note { margin-top: 0 !important; margin-bottom: 0; font-size: .875rem; font-weight: 400; white-space: nowrap; }.bus-schedules button { padding: .42rem .75rem; border: 1px solid #a9c2dc; background: transparent; color: inherit; font: inherit; cursor: pointer; }.day-select { display: flex; margin: 1rem 0; }.toolbar { display: flex; justify-content: flex-end; margin-bottom: 1rem; font-size: .86rem; }.toolbar time { font-family: ui-monospace, monospace; }.state { display: flex; align-items: center; gap: .5rem; padding: 1rem; border: 1px solid #d9e2ec; border-radius: .55rem; }.error { color: #b42318; }.note { margin-top: 1rem; font-size: .85rem; } @media (max-width: 600px) { .section-title { align-items: flex-start; flex-direction: column; } .toolbar { justify-content: flex-start; } } .bus-schedules { color: var(--bus-v2-text); } .bus-schedules .schedule-note, .bus-schedules .muted, .bus-schedules .note { color: var(--bus-v2-muted); } .bus-schedules button, .bus-schedules .state { border-color: var(--bus-v2-border); } .bus-schedules .day-select .active { border-color: var(--bus-v2-link); background: var(--bus-v2-link); color: var(--vp-c-accent-text); } .bus-schedules .state { background: var(--bus-v2-bg); } .day-select { align-items: center; gap: .75rem; flex-wrap: wrap; } .day-select input { min-height: 44px; max-width: 100%; padding: .4rem .6rem; border: 1px solid var(--bus-v2-border); border-radius: .4rem; background: var(--bus-v2-bg); color: inherit; font: inherit; } </style>
docs/.vuepress/components/bus-v2/api.mjs +4 −4 Changes for docs/.vuepress/components/bus-v2/api.mjs: 4 added lines, 4 removed lines. Original line number Diff line number Diff line import { resolveBusApiBase } from './core.mjs' export const busApiBase = () => resolveBusApiBase(import.meta.env.PROD) export const busApiBase = () => resolveBusApiBase(import.meta.env?.PROD) export async function publicApi(path, options = {}) { const base = busApiBase() const separator = path.includes('?') ? '&' : '?' const url = `${base}${path.startsWith('/') ? path : `/${path}`}${separator}_=${Date.now()}` const url = `${base}${path.startsWith('/') ? path : `/${path}`}` const reference = /^\/(routes|stops)(\/|$)/.test(new URL(url).pathname) const response = await fetch(url, { ...options, cache: 'no-store', cache: reference ? 'default' : 'no-store', credentials: 'omit', headers: { Accept: 'application/json', ...options.headers }, }) Loading