diff --git a/src/apps/weather/Weather.vue b/src/apps/weather/Weather.vue new file mode 100644 index 0000000..08f6b78 --- /dev/null +++ b/src/apps/weather/Weather.vue @@ -0,0 +1,118 @@ + + + + + + {{ c }} + + + + {{ c }}{{ descText(c) }} + {{ tempText(c) }} + ✕ + + + + + + ⏳正在更新{{ cur }}的天气… + + + + {{ cur }}{{ curData.simulated ? '(模拟数据)' : curData.stale ? '(缓存)' : '' }} + {{ curData.temp }}° + {{ icoText(curData.code) }} + 最高 {{ curData.daily[0].hi }}° 最低 {{ curData.daily[0].lo }}° + + + 逐小时预报 + + + {{ h.t === new Date().getHours() ? '现在' : h.t + '时' }} + {{ descCode(h.code)[1] }} + {{ h.temp }}° + + + + + 7 日预报 + + + {{ d.day }} + {{ descCode(d.code)[1] }} + {{ d.lo }}° + + {{ d.hi }}° + + + + + + + + + diff --git a/src/apps/weather/index.ts b/src/apps/weather/index.ts index cda7b73..d2c7b14 100644 --- a/src/apps/weather/index.ts +++ b/src/apps/weather/index.ts @@ -1,80 +1,15 @@ -// 天气应用 — 从 js/apps3.js 手动转写 -import { el, debounce } from '../../utils' -import { store as Store } from '../../composables/useStore' +// 天气应用 — Vue 组件化 +import { h, render } from 'vue' import { Apps, stdMenus } from '../../composables/useApps' - -const WMO: Record = { - 0: ['晴', '☀️'], 1: ['大部晴朗', '🌤'], 2: ['局部多云', '⛅'], 3: ['阴', '☁️'], - 45: ['雾', '🌫'], 48: ['雾凇', '🌫'], 51: ['小毛毛雨', '🌦'], 53: ['毛毛雨', '🌦'], 55: ['大毛毛雨', '🌦'], - 61: ['小雨', '🌧'], 63: ['中雨', '🌧'], 65: ['大雨', '🌧'], 66: ['冻雨', '🌨'], 67: ['冻雨', '🌨'], - 71: ['小雪', '🌨'], 73: ['中雪', '🌨'], 75: ['大雪', '❄️'], 77: ['雪粒', '❄️'], - 80: ['小阵雨', '🌦'], 81: ['阵雨', '🌧'], 82: ['强阵雨', '🌧'], 85: ['阵雪', '🌨'], 86: ['强阵雪', '❄️'], - 95: ['雷暴', '⛈'], 96: ['雷暴伴冰雹', '⛈'], 99: ['强雷暴', '⛈'], -} -const WEATHER_CITIES: Record = { - '北京': [39.9042, 116.4074], '上海': [31.2304, 121.4737], '广州': [23.1291, 113.2644], - '深圳': [22.5431, 114.0579], '成都': [30.5728, 104.0668], '杭州': [30.2741, 120.1551], - '西安': [34.3416, 108.9398], '哈尔滨': [45.8038, 126.5349], '拉萨': [29.6520, 91.1721], - '香港': [22.3193, 114.1694], '三亚': [18.2528, 109.5119], '乌鲁木齐': [43.8256, 87.6168], -} +import WeatherComponent from './Weather.vue' export const WeatherApp = { id: 'weather', name: '天气', icon: '/assets/icons/weather.svg', w: 780, h: 560, minW: 520, minH: 400, menus() { return stdMenus(this) }, - store: { - get(): any { return Store.get('weather', { cities: ['北京', '上海'], cur: '北京', cache: {} }) }, - set(v: any) { Store.set('weather', v) } - }, - desc(code: number) { return (WMO[code] || ['多云', '⛅']) }, - simulate(city: string) { - let seed = 0; for (const c of city) seed = (seed * 31 + c.charCodeAt(0)) >>> 0 - const rnd = () => { seed = (seed * 1103515245 + 12345) >>> 0; return seed / 4294967296 } - const base = 18 + Math.round(rnd() * 14) - 4 - const codes = [0, 1, 2, 3, 61, 80, 95] - const hourly: any[] = [], daily: any[] = [] - const now = new Date() - for (let i = 0; i < 12; i++) hourly.push({ t: new Date(now.getTime() + i * 3600000).getHours(), temp: base + Math.round(Math.sin(i / 3) * 4), code: codes[Math.floor(rnd() * 3)] }) - const wd = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'] - for (let d = 0; d < 7; d++) daily.push({ day: d === 0 ? '今天' : wd[(now.getDay() + d) % 7], hi: base + 3 + Math.round(rnd() * 4), lo: base - 5 - Math.round(rnd() * 3), code: codes[Math.floor(rnd() * codes.length)] }) - return { temp: base, code: codes[Math.floor(rnd() * 3)], hourly, daily, simulated: true, ts: Date.now() } - }, - async fetchCity(city: string) { - const ll = WEATHER_CITIES[city]; if (!ll) return this.simulate(city) - const cache = this.store.get().cache[city] - try { - const ctrl = new AbortController(); const to = setTimeout(() => ctrl.abort(), 5000) - const url = `https://api.open-meteo.com/v1/forecast?latitude=${ll[0]}&longitude=${ll[1]}¤t=temperature_2m,weather_code&hourly=temperature_2m,weather_code&daily=weather_code,temperature_2m_max,temperature_2m_min&forecast_days=7&timezone=auto` - const res = await fetch(url, { signal: ctrl.signal }); clearTimeout(to) - if (!res.ok) throw new Error('http ' + res.status) - const j = await res.json(); const wd = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'] - const data = { temp: Math.round(j.current.temperature_2m), code: j.current.weather_code, - hourly: j.hourly.time.slice(0, 12).map((t: string, i: number) => ({ t: new Date(t).getHours(), temp: Math.round(j.hourly.temperature_2m[i]), code: j.hourly.weather_code[i] })), - daily: j.daily.time.map((t: string, i: number) => ({ day: i === 0 ? '今天' : wd[new Date(t).getDay()], hi: Math.round(j.daily.temperature_2m_max[i]), lo: Math.round(j.daily.temperature_2m_min[i]), code: j.daily.weather_code[i] })), ts: Date.now() } - const st = this.store.get(); st.cache[city] = data; this.store.set(st); return data - } catch (e) { if (cache) return Object.assign({ stale: true }, cache); return this.simulate(city) } - }, - widgetData() { - const st = this.store.get(); const d = st.cache[st.cur] || st.cache[st.cities[0]] - if (!d) return null; const [txt] = this.desc(d.code) - return { city: st.cur, temp: d.temp, desc: txt, hi: d.daily?.[0]?.hi ?? d.temp, lo: d.daily?.[0]?.lo ?? d.temp } - }, render(win: any) { - const st = win.appState = { data: this.store.get(), loading: false } - win.body.classList.add('weather-body') - const side = el('div', { class: 'weather-side' }) - const search = el('input', { class: 'text-input weather-search', type: 'search', placeholder: '搜索城市' }) as HTMLInputElement - const sug = el('div', { class: 'weather-sug hidden' }) - const main = el('div', { class: 'weather-main' }) - win.body.append(el('div', { class: 'weather-left' }, search, sug, side), main) - const save = () => this.store.set(st.data) - const renderSide = () => { side.innerHTML = ''; st.data.cities.forEach((c: string) => { const d = st.data.cache[c]; const row = el('div', { class: 'weather-city' + (c === st.data.cur ? ' sel' : '') }, el('div', null, el('b', { text: c }), el('small', { text: d ? this.desc(d.code)[0] : '—' })), el('span', { class: 'weather-city-temp', text: d ? d.temp + '°' : '…' }), el('button', { class: 'weather-del', text: '✕', title: '删除城市', onclick: (e: Event) => { e.stopPropagation(); st.data.cities = st.data.cities.filter((x: string) => x !== c); if (st.data.cur === c) st.data.cur = st.data.cities[0] || '北京'; save(); renderAll() } })); row.addEventListener('click', () => { st.data.cur = c; save(); renderAll() }); side.append(row) }) } - const renderMain = async () => { const c = st.data.cur; main.innerHTML = ''; main.append(el('div', { class: 'empty-state', style: { height: '160px' } }, el('div', { class: 'es-icon', text: '⏳' }), el('div', { text: `正在更新${c}的天气…` }))); const d = await this.fetchCity(c); if (st.data.cur !== c || !document.body.contains(main)) return; st.data.cache[c] = d; save(); main.innerHTML = ''; const [txt, ico] = this.desc(d.code); main.append(el('div', { class: 'weather-now' }, el('div', { class: 'weather-now-city', text: c + (d.simulated ? '(模拟数据)' : d.stale ? '(缓存)' : '') }), el('div', { class: 'weather-now-temp', text: d.temp + '°' }), el('div', { class: 'weather-now-desc', text: `${ico} ${txt}` }), el('div', { class: 'weather-now-hl', text: `最高 ${d.daily[0].hi}° 最低 ${d.daily[0].lo}°` }))); const strip = el('div', { class: 'weather-strip' }); d.hourly.forEach((h: any, i: number) => strip.append(el('div', { class: 'weather-hour' }, el('span', { text: i === 0 ? '现在' : h.t + '时' }), el('span', { class: 'weather-hour-ico', text: this.desc(h.code)[1] }), el('b', { text: h.temp + '°' })))); main.append(el('div', { class: 'weather-card' }, el('div', { class: 'cc-title', text: '逐小时预报' }), strip)); const list = el('div', { class: 'weather-days' }); d.daily.forEach((x: any) => { list.append(el('div', { class: 'weather-day' }, el('span', { class: 'weather-day-name', text: x.day }), el('span', { text: this.desc(x.code)[1] }), el('span', { class: 'weather-lo', text: x.lo + '°' }), el('div', { class: 'weather-range' }, el('div', { class: 'weather-range-fill', style: { left: '20%', width: '60%' } })), el('span', { class: 'weather-hi', text: x.hi + '°' }))) }); main.append(el('div', { class: 'weather-card' }, el('div', { class: 'cc-title', text: '7 日预报' }), list)) } - const renderAll = () => { renderSide(); renderMain() } - search.addEventListener('input', debounce(() => { const q = search.value.trim(); sug.innerHTML = ''; if (!q) { sug.classList.add('hidden'); return }; const hits = Object.keys(WEATHER_CITIES).filter((c: string) => c.includes(q) && !st.data.cities.includes(c)); sug.classList.remove('hidden'); if (!hits.length) sug.append(el('div', { class: 'maps-result', text: '无匹配城市' })); hits.slice(0, 6).forEach((c: string) => { const r = el('div', { class: 'maps-result', text: c }); r.addEventListener('click', () => { st.data.cities.push(c); st.data.cur = c; save(); search.value = ''; sug.classList.add('hidden'); renderAll() }); sug.append(r) }) }, 200)) - search.addEventListener('keydown', (e: KeyboardEvent) => e.stopPropagation()) - renderAll() - } + const vnode = h(WeatherComponent, { win }) + render(vnode, win.body) + }, } -Apps.register(WeatherApp); -(window as any).WeatherApp = WeatherApp +Apps.register(WeatherApp)