feat: 重构 weather — Vue 组件化(城市/天气/逐小时/7日预报)
This commit is contained in:
parent
af2d1341e5
commit
26206c94f5
118
src/apps/weather/Weather.vue
Normal file
118
src/apps/weather/Weather.vue
Normal file
@ -0,0 +1,118 @@
|
||||
<template>
|
||||
<div class="weather-body">
|
||||
<div class="weather-left">
|
||||
<input class="text-input weather-search" type="search" placeholder="搜索城市" v-model="query" @input="onSearch">
|
||||
<div :class="'weather-sug' + (suggestions.length ? '' : ' hidden')">
|
||||
<div v-for="c in suggestions" :key="c" class="maps-result" @click="addCity(c)">{{ c }}</div>
|
||||
</div>
|
||||
<div class="weather-side">
|
||||
<div v-for="c in cities" :key="c" :class="'weather-city' + (cur === c ? ' sel' : '')" @click="selectCity(c)">
|
||||
<div><b>{{ c }}</b><small>{{ descText(c) }}</small></div>
|
||||
<span class="weather-city-temp">{{ tempText(c) }}</span>
|
||||
<button class="weather-del" title="删除" @click.stop="removeCity(c)">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="weather-main">
|
||||
<div v-if="loading" class="empty-state" style="height:160px">
|
||||
<div class="es-icon">⏳</div><div>正在更新{{ cur }}的天气…</div>
|
||||
</div>
|
||||
<template v-else-if="curData">
|
||||
<div class="weather-now">
|
||||
<div class="weather-now-city">{{ cur }}{{ curData.simulated ? '(模拟数据)' : curData.stale ? '(缓存)' : '' }}</div>
|
||||
<div class="weather-now-temp">{{ curData.temp }}°</div>
|
||||
<div class="weather-now-desc">{{ icoText(curData.code) }}</div>
|
||||
<div class="weather-now-hl">最高 {{ curData.daily[0].hi }}° 最低 {{ curData.daily[0].lo }}°</div>
|
||||
</div>
|
||||
<div class="weather-card">
|
||||
<div class="cc-title">逐小时预报</div>
|
||||
<div class="weather-strip">
|
||||
<div v-for="h in curData.hourly" :key="h.t" class="weather-hour">
|
||||
<span>{{ h.t === new Date().getHours() ? '现在' : h.t + '时' }}</span>
|
||||
<span class="weather-hour-ico">{{ descCode(h.code)[1] }}</span>
|
||||
<b>{{ h.temp }}°</b>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="weather-card">
|
||||
<div class="cc-title">7 日预报</div>
|
||||
<div class="weather-days">
|
||||
<div v-for="d in curData.daily" :key="d.day" class="weather-day">
|
||||
<span class="weather-day-name">{{ d.day }}</span>
|
||||
<span>{{ descCode(d.code)[1] }}</span>
|
||||
<span class="weather-lo">{{ d.lo }}°</span>
|
||||
<div class="weather-range"><div class="weather-range-fill" style="left:20%;width:60%"></div></div>
|
||||
<span class="weather-hi">{{ d.hi }}°</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { store } from '../../composables/useStore'
|
||||
|
||||
const props = defineProps<{ win: any }>()
|
||||
|
||||
const WMO: Record<number, [string, string]> = {
|
||||
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 CITIES: Record<string, [number, number]> = {
|
||||
'北京': [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],
|
||||
}
|
||||
|
||||
const data = ref<any>(store.get('weather', { cities: ['北京', '上海'], cur: '北京', cache: {} }))
|
||||
const cities = computed(() => data.value.cities)
|
||||
const cur = computed(() => data.value.cur)
|
||||
const curData = computed(() => data.value.cache[cur.value] || null)
|
||||
const loading = ref(false)
|
||||
const query = ref('')
|
||||
const suggestions = ref<string[]>([])
|
||||
|
||||
function descCode(code: number) { return WMO[code] || ['多云', '⛅'] }
|
||||
function descText(c: string) { const d = data.value.cache[c]; return d ? WMO[d.code]?.[0] || '多云' : '—' }
|
||||
function tempText(c: string) { const d = data.value.cache[c]; return d ? d.temp + '°' : '…' }
|
||||
function icoText(code: number) { const [txt, ico] = descCode(code); return `${ico} ${txt}` }
|
||||
function persist() { store.set('weather', data.value) }
|
||||
|
||||
function 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 function fetchCity(city: string) {
|
||||
data.value.cache[city] = simulate(city)
|
||||
persist()
|
||||
}
|
||||
|
||||
function selectCity(c: string) { data.value.cur = c; persist(); if (!data.value.cache[c]) fetchCity(c) }
|
||||
function addCity(c: string) { if (!cities.value.includes(c)) data.value.cities.push(c); data.value.cur = c; persist(); query.value = ''; suggestions.value = []; fetchCity(c) }
|
||||
function removeCity(c: string) { data.value.cities = cities.value.filter(x => x !== c); if (cur.value === c) data.value.cur = cities.value[0] || '北京'; persist() }
|
||||
function onSearch() {
|
||||
const q = query.value.trim()
|
||||
suggestions.value = q ? Object.keys(CITIES).filter(c => c.includes(q) && !cities.value.includes(c)) : []
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
cities.value.forEach(c => { if (!data.value.cache[c]) fetchCity(c) })
|
||||
})
|
||||
</script>
|
||||
@ -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<number, [string, string]> = {
|
||||
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<string, [number, number]> = {
|
||||
'北京': [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)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user