效果预览

与本站首页的“天气-侧边栏卡片“同款

添加方法

创建页面文件

[blogroot]/themes/butterfly/layout/includes/widget中新建card_weather.pug文件,并添加如下内容

1
2
3
4
5
6
7
8
9
10
11
12
13
.card-widget.card-weather

.item-headline
i.fas.fa-cloud-sun-rain
span 天气

.weather-widget#weather_widget
.loading
.loading-spinner
.loading-text 加载天气中...

!= js('/js/weather-icons.js')
!= js('/js/card_weather.js')

引入页面文件

将该结构插入到对应的位置,可以在当前文件夹[blogroot]/themes/butterfly/layout/includes/widget中的index.pug找到对应内容,如下:

  • 修改前
1
2
3
4
5
else
//- page
!=partial('includes/widget/card_author', {}, {cache: true})
!=partial('includes/widget/card_announcement', {}, {cache: true})
!=partial('includes/widget/card_top_self', {}, {cache: true})
  • 修改后
1
2
3
4
5
6
else
//- page
!=partial('includes/widget/card_author', {}, {cache: true})
!=partial('includes/widget/card_announcement', {}, {cache: true})
!=partial('includes/widget/card_weather', {}, {cache: true})
!=partial('includes/widget/card_top_self', {}, {cache: true})

插入位置可以根据顺序自行调整

创建脚本文件

[blogroot]/source/js中创建并引入location.js文件,详情点击下方链接

[blogroot]/source/js中新建weather-icons.js文件,并添加如下内容

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
// weather-icons.js

window.WEATHER_ICON_MAP = {
// =========================
// 2xx Thunderstorm
// https://openweathermap.org/weather-conditions#Thunderstorm
// =========================

// thunderstorm with light rain
200: {
day: "thunderstorms-day-rain",
night: "thunderstorms-night-rain",
},

// thunderstorm with rain
201: {
day: "thunderstorms-overcast-day-rain",
night: "thunderstorms-overcast-night-rain",
},

// thunderstorm with heavy rain
202: {
day: "thunderstorms-extreme-day-rain",
night: "thunderstorms-extreme-night-rain",
},

// light thunderstorm
210: {
day: "thunderstorms-day",
night: "thunderstorms-night",
},

// thunderstorm
211: {
day: "thunderstorms-overcast-day",
night: "thunderstorms-overcast-night",
},

// heavy thunderstorm
212: {
day: "thunderstorms-extreme-day",
night: "thunderstorms-extreme-night",
},

// ragged thunderstorm
221: {
day: "thunderstorms-extreme-day",
night: "thunderstorms-extreme-night",
},

// thunderstorm with light drizzle
230: {
day: "thunderstorms-day-rain",
night: "thunderstorms-night-rain",
},

// thunderstorm with drizzle
231: {
day: "thunderstorms-overcast-day-rain",
night: "thunderstorms-overcast-night-rain",
},

// thunderstorm with heavy drizzle
232: {
day: "thunderstorms-extreme-day-rain",
night: "thunderstorms-extreme-night-rain",
},

// =========================
// 3xx Drizzle
// https://openweathermap.org/weather-conditions#Drizzle
// =========================

// light intensity drizzle
300: {
day: "partly-cloudy-day-drizzle",
night: "partly-cloudy-night-drizzle",
},

// drizzle
301: {
day: "overcast-day-drizzle",
night: "overcast-night-drizzle",
},

// heavy intensity drizzle
302: {
day: "extreme-day-drizzle",
night: "extreme-night-drizzle",
},

// light intensity drizzle rain
310: {
day: "partly-cloudy-day-drizzle",
night: "partly-cloudy-night-drizzle",
},

// drizzle rain
311: {
day: "overcast-day-drizzle",
night: "overcast-night-drizzle",
},

// heavy intensity drizzle rain
312: {
day: "extreme-day-drizzle",
night: "extreme-night-drizzle",
},

// shower rain and drizzle
313: {
day: "overcast-day-rain",
night: "overcast-night-rain",
},

// heavy shower rain and drizzle
314: {
day: "extreme-day-rain",
night: "extreme-night-rain",
},

// shower drizzle
321: {
day: "overcast-day-drizzle",
night: "overcast-night-drizzle",
},

// =========================
// 5xx Rain
// https://openweathermap.org/weather-conditions#Rain
// =========================

// light rain
500: {
day: "partly-cloudy-day-rain",
night: "partly-cloudy-night-rain",
},

// moderate rain
501: {
day: "overcast-day-rain",
night: "overcast-night-rain",
},

// heavy intensity rain
502: {
day: "extreme-day-rain",
night: "extreme-night-rain",
},

// very heavy rain
503: {
day: "extreme-day-rain",
night: "extreme-night-rain",
},

// extreme rain
504: {
day: "extreme-day-rain",
night: "extreme-night-rain",
},

// freezing rain
511: {
day: "overcast-day-sleet",
night: "overcast-night-sleet",
},

// light intensity shower rain
520: {
day: "partly-cloudy-day-rain",
night: "partly-cloudy-night-rain",
},

// shower rain
521: {
day: "overcast-day-rain",
night: "overcast-night-rain",
},

// heavy intensity shower rain
522: {
day: "extreme-day-rain",
night: "extreme-night-rain",
},

// ragged shower rain
531: {
day: "extreme-day-rain",
night: "extreme-night-rain",
},

// =========================
// 6xx Snow
// https://openweathermap.org/weather-conditions#Snow
// =========================

// light snow
600: {
day: "partly-cloudy-day-snow",
night: "partly-cloudy-night-snow",
},

// snow
601: {
day: "overcast-day-snow",
night: "overcast-night-snow",
},

// heavy snow
602: {
day: "extreme-day-snow",
night: "extreme-night-snow",
},

// sleet
611: {
day: "partly-cloudy-day-sleet",
night: "partly-cloudy-night-sleet",
},

// light shower sleet
612: {
day: "partly-cloudy-day-sleet",
night: "partly-cloudy-night-sleet",
},

// shower sleet
613: {
day: "overcast-day-sleet",
night: "overcast-night-sleet",
},

// light rain and snow
615: {
day: "overcast-day-sleet",
night: "overcast-night-sleet",
},

// rain and snow
616: {
day: "extreme-day-sleet",
night: "extreme-night-sleet",
},

// light shower snow
620: {
day: "partly-cloudy-day-snow",
night: "partly-cloudy-night-snow",
},

// shower snow
621: {
day: "overcast-day-snow",
night: "overcast-night-snow",
},

// heavy shower snow
622: {
day: "extreme-day-snow",
night: "extreme-night-snow",
},

// =========================
// 7xx Atmosphere
// https://openweathermap.org/weather-conditions#Atmosphere
// =========================

// mist
701: {
day: "mist",
night: "mist",
},

// smoke
711: {
day: "smoke",
night: "smoke",
},

// haze
721: {
day: "haze-day",
night: "haze-night",
},

// sand/dust whirls
731: {
day: "dust-day",
night: "dust-night",
},

// fog
741: {
day: "fog-day",
night: "fog-night",
},

// sand
751: {
day: "dust-day",
night: "dust-night",
},

// dust
761: {
day: "dust-day",
night: "dust-night",
},

// volcanic ash
762: {
day: "smoke-particles",
night: "smoke-particles",
},

// squalls
771: {
day: "strong-wind",
night: "strong-wind",
},

// tornado
781: {
day: "tornado",
night: "tornado",
},

// =========================
// 800 Clear
// =========================

// clear sky
800: {
day: "clear-day",
night: "clear-night",
},

// =========================
// 80x Clouds
// https://openweathermap.org/weather-conditions#Clouds
// =========================

// few clouds: 11-25%
801: {
day: "partly-cloudy-day",
night: "partly-cloudy-night",
},

// scattered clouds: 25-50%
802: {
day: "partly-cloudy-day",
night: "partly-cloudy-night",
},

// broken clouds: 51-84%
803: {
day: "overcast-day",
night: "overcast-night",
},

// overcast clouds: 85-100%
804: {
day: "overcast",
night: "overcast",
},
};

[blogroot]/source/js中新建card_weather.js文件,并添加如下内容

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
// card_weather.js
(() => {
let hourlyPage = 0;

let ITEMS_NUM = 8; // 小时预报总数量,实际显示数量由ITEMS_PER_PAGE控制
let ITEMS_PER_PAGE = 4; // 每页显示的小时预报最大数量,实际显示数量根据窗口宽度动态计算
const DAILY_DAYS = 6; // 每日预报的最大天数,包含当天

let hourlyData = [];
let timezoneOffset = 0;

// =========================
// Meteocons icon
// =========================

function getWeatherIcon(weatherId, iconCode, description = "") {
const entry = window.WEATHER_ICON_MAP[String(weatherId)];

if (!entry) {
return "";
}

const isNight = iconCode.endsWith("n");

const iconName = isNight ? entry.night : entry.day;

return `
<img
src="https://cdn.meteocons.com/3.0.0-next.10/svg/fill/${iconName}.svg"
alt="${description}"
title="${description}"
class="ow-icon"
loading="lazy"
/>
`;
}

// =========================
// 天气严重程度评分(0-100)
// =========================

function getWeatherSeverity(id) {
if (id >= 900 && id <= 906) return 100;
if (id === 781) return 95;
if (id === 771) return 90;
if (id === 762) return 88;
if (id === 602) return 85;
if (id === 504) return 80;
if (id === 503) return 78;
if (id === 502 || id === 622) return 75;
if (id === 511) return 70;
if (id === 501) return 65;
if (id === 621) return 60;
if (id === 500) return 55;
if (id >= 520 && id <= 531) return 50;
if (id >= 300 && id <= 321) return 40;
if (id >= 200 && id <= 232) return 35;
if (id === 600 || id === 601) return 30;
if (id >= 701 && id <= 761) return 25;
if (id === 804) return 15;
if (id === 803) return 12;
if (id === 802) return 10;
if (id === 801) return 8;
if (id === 800) return 5;
return 0;
}

// =========================
// 时间格式化
// =========================

function formatHour(dt, tz = 0) {
const d = new Date((dt + tz) * 1000);

return `${String(d.getUTCHours()).padStart(2, "0")}:00`;
}

function formatDate(dt, tz = 0) {
const d = new Date((dt + tz) * 1000);

const weekdays = [
"星期日",
"星期一",
"星期二",
"星期三",
"星期四",
"星期五",
"星期六",
];

return {
date: `${d.getUTCMonth() + 1}${d.getUTCDate()}日`,
week: weekdays[d.getUTCDay()],
};
}

// =========================
// hourly render
// =========================

function updateHourlyItemsPerPage() {
const hourlyCol = document.querySelector(".hourly-col");

if (!hourlyCol) return;

// 每个hour-item实际占用宽度
const itemWidth = 46;

ITEMS_PER_PAGE = Math.max(1, Math.floor(hourlyCol.clientWidth / itemWidth));
}

function renderHourly() {
const track = document.getElementById("hourlyTrack");

if (!track) return;

const visible = hourlyData.slice(hourlyPage, hourlyPage + ITEMS_PER_PAGE);

track.innerHTML = visible
.map(
(item) => `
<div class="hour-item">

<div class="hour-time">
${formatHour(item.dt, timezoneOffset)}
</div>

<div class="hour-icon">
${getWeatherIcon(item.weather[0].id, item.weather[0].icon, `${item.weather[0].description}${Math.round(item.pop * 100)}%`)}
</div>

<div class="hour-temp">
${Math.round(item.main.temp)}°C
</div>

</div>
`,
)
.join("");

const prevArrow = document.getElementById("prevArrow");

const nextArrow = document.getElementById("nextArrow");

if (prevArrow) {
prevArrow.disabled = hourlyPage === 0;
}

if (nextArrow) {
nextArrow.disabled = hourlyPage >= hourlyData.length - ITEMS_PER_PAGE;
}
}

function nextHourly() {
if (hourlyPage < hourlyData.length - ITEMS_PER_PAGE) {
hourlyPage++;

renderHourly();
}
}

function prevHourly() {
if (hourlyPage > 0) {
hourlyPage--;

renderHourly();
}
}

// =========================
// 加载天气
// =========================

async function loadWeather(location) {
const weather_widget = document.getElementById("weather_widget");

if (!weather_widget) return;

try {
const lat = location.data.lat;

const lon = location.data.lon;

const [currentRes, forecastRes] = await Promise.all([
fetch(
`https://weather-api.o0w0b.top/weather?lat=${lat}&lon=${lon}&units=metric&lang=zh_cn`,
),

fetch(
`https://weather-api.o0w0b.top/forecast?lat=${lat}&lon=${lon}&units=metric&lang=zh_cn`,
),
]);

const current = await currentRes.json();

const forecast = await forecastRes.json();

// =========================
// API错误处理
// =========================

if (!current.main) {
throw new Error(current.message || "当前天气获取失败");
}

if (!forecast.list) {
throw new Error(forecast.message || "天气预报获取失败");
}

// =========================
// hourly
// =========================

hourlyData = forecast.list.slice(0, ITEMS_NUM);

// =========================
// daily
// =========================

const dailyMap = new Map();

timezoneOffset = forecast.city.timezone;

forecast.list.forEach((item) => {
const localDt = (item.dt + timezoneOffset) * 1000;
const d = new Date(localDt);
const key = `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, "0")}-${String(d.getUTCDate()).padStart(2, "0")}`;

if (!dailyMap.has(key)) {
dailyMap.set(key, {
dt: item.dt,
temps: [],
weatherList: [],
});
}

const day = dailyMap.get(key);
day.temps.push(item.main.temp);
day.weatherList.push({
id: item.weather[0].id,
icon: item.weather[0].icon,
pop: item.pop,
description: item.weather[0].description,
hour: d.getUTCHours(),
});
});

const daily = Array.from(dailyMap.values())
.slice(0, DAILY_DAYS)
.filter((day) => day.temps.length >= 1)
.map((day) => {
const worstWeather = day.weatherList.reduce((worst, current) => {
return getWeatherSeverity(current.id) > getWeatherSeverity(worst.id)
? current
: worst;
});

// 修正白天黑夜图标
let icon = worstWeather.icon;
const isActuallyNight =
worstWeather.hour < 6 || worstWeather.hour >= 18;
if (isActuallyNight && icon.endsWith("d")) {
icon = icon.slice(0, -1) + "n";
} else if (!isActuallyNight && icon.endsWith("n")) {
icon = icon.slice(0, -1) + "d";
}

// 时段范围处理
const startHourNum = worstWeather.hour;
const endHourNum = startHourNum + 3;
const startHour = String(startHourNum).padStart(2, "0") + ":00";
const endHour =
endHourNum >= 24
? String(endHourNum - 24).padStart(2, "0") + ":00"
: String(endHourNum).padStart(2, "0") + ":00";
const worstTime =
endHourNum >= 24
? `${startHour}~${endHour}(次日)`
: `${startHour}~${endHour}`;

return {
dt: day.dt,
temps: day.temps,
id: worstWeather.id,
icon: icon,
worstWeatherTime: worstTime,
pop: Math.round(worstWeather.pop * 100),
description: worstWeather.description,
};
});

// =========================
// render
// =========================

weather_widget.innerHTML = `

<div class="main-row">

<div class="weather-left">

<div class="location">

<svg xmlns="http://www.w3.org/2000/svg" width="10" height="12" viewBox="0 0 10 12" style="transform: scale(1.08);filter: drop-shadow(0 0 .35px currentColor);">
<path fill-rule="nonzero" d="M9 5c0-2.265-1.757-4-4-4-2.243 0-4 1.735-4 4 0 1.16 1.32 3.094 4 5.636C7.68 8.094 9 6.16 9 5zm-4 7C1.667 8.967 0 6.634 0 5c0-2.851 2.239-5 5-5s5 2.149 5 5c0 1.634-1.667 3.967-5 7zm0-6a1 1 0 1 1 0-2 1 1 0 0 1 0 2z"></path>
</svg>

<span>
${location.data.city}
</span>

</div>

<div class="temp-big">
${Math.round(current.main.temp)}°C
</div>

</div>

<div class="weather-right">

<div class="weather-icon-large">
${getWeatherIcon(current.weather[0].id, current.weather[0].icon, current.weather[0].description)}
</div>

<div class="weather-desc">
${current.weather[0].description}
</div>

</div>

</div>

<div class="detail-hourly-row">

<div class="details-col">

<div title="风" class="detail-item">

<svg xmlns="http://www.w3.org/2000/svg" width="14" height="12" viewBox="0 0 14 12" style="transform: scale(1.08);filter: drop-shadow(0 0 .35px currentColor);">
<path d="M5.919 1.53a.615.615 0 0 1 .77-.108.68.68 0 0 1 .296.752.642.642 0 0 1-.617.493H.636c-.351 0-.636.298-.636.666C0 3.702.285 4 .636 4h5.729c.863.004 1.623-.603 1.849-1.479.225-.875-.14-1.8-.89-2.254A1.845 1.845 0 0 0 5.016.59a.69.69 0 0 0 .003.943c.249.26.652.258.9-.003zm1.006 9.88c.61.643 1.558.776 2.308.323.75-.453 1.116-1.379.89-2.254C9.897 8.603 9.138 7.996 8.272 8H.636C.285 8 0 8.298 0 8.667c0 .368.285.666.636.666h7.638a.643.643 0 0 1 .62.493.68.68 0 0 1-.296.752.615.615 0 0 1-.77-.108.616.616 0 0 0-.9-.003.69.69 0 0 0-.003.943zm4.173-7.785a.923.923 0 0 1 1.152-.158c.374.227.556.688.444 1.124a.963.963 0 0 1-.92.742H.636C.285 5.333 0 5.632 0 6c0 .368.285.667.636.667h11.139c1.009-.002 1.89-.712 2.15-1.731.26-1.02-.166-2.095-1.039-2.623a2.153 2.153 0 0 0-2.687.368.69.69 0 0 0-.001.943c.248.26.651.261.9.001z"></path>
</svg>

<span>
${current.wind.speed.toFixed(1)}
米/秒
</span>

</div>

<div title="气压" class="detail-item">

<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 14 14" style="transform: scale(1.08);filter: drop-shadow(0 0 .35px currentColor);">
<path d="M9.193 5.707a2.545 2.545 0 1 1-.9-.9l1.252-1.252a.636.636 0 1 1 .9.9L9.193 5.707zm-2.83 6.985v-.601a.636.636 0 0 1 1.273 0v.601a5.73 5.73 0 0 0 5.056-5.056h-.601a.636.636 0 0 1 0-1.272h.601a5.73 5.73 0 0 0-5.056-5.056v.601a.636.636 0 0 1-1.272 0v-.601a5.73 5.73 0 0 0-5.056 5.056h.601a.636.636 0 0 1 0 1.272h-.601a5.73 5.73 0 0 0 5.056 5.056zM7 14A7 7 0 1 1 7 0a7 7 0 0 1 0 14zm0-5.727a1.273 1.273 0 1 0 0-2.546 1.273 1.273 0 0 0 0 2.546z"></path>
</svg>

<span>
${current.main.pressure}
百帕
</span>

</div>

<div title="湿度" class="detail-item">

<svg xmlns="http://www.w3.org/2000/svg" width="12" height="14" viewBox="0 0 12 14" style="transform: scale(1.08);filter: drop-shadow(0 0 .35px currentColor);">
<path d="M2.701 4.838a4.547 4.547 0 0 0-1.013 5.01c.723 1.718 2.424 2.839 4.312 2.839 1.888 0 3.59-1.12 4.312-2.839a4.547 4.547 0 0 0-1.013-5.01L5.997 1.586 2.701 4.838zM6.468.192l3.773 3.717a5.846 5.846 0 0 1 1.302 6.442C10.615 12.56 8.427 14 6 14S1.385 12.56.457 10.35a5.846 5.846 0 0 1 1.301-6.44L5.525.193a.674.674 0 0 1 .943 0z"></path>
</svg>

<span>
${current.main.humidity} %
</span>

</div>

</div>

<div class="hourly-wrapper">

<button
class="hour-arrow"
id="prevArrow">



</button>

<div class="hourly-col">

<div
class="hourly-track"
id="hourlyTrack">

</div>

</div>

<button
class="hour-arrow"
id="nextArrow">



</button>

</div>

</div>

<div class="daily-section">

${daily
.map((day, index) => {
const f = formatDate(day.dt, timezoneOffset);

const max = Math.round(Math.max(...day.temps));

const min = Math.round(Math.min(...day.temps));

return `
<div class="daily-item">

<div class="daily-left">

<div class="daily-date">
${f.date}
</div>

<div class="daily-week">
${(() => {
// 当前本地日期
const now = new Date(
Date.now() + timezoneOffset * 1000,
);

// 明天
const tomorrow = new Date(now);
tomorrow.setUTCDate(
tomorrow.getUTCDate() + 1,
);

const tomorrowText = `${tomorrow.getUTCMonth() + 1}${tomorrow.getUTCDate()}日`;

// 第一项如果是明天
if (
index === 0 &&
f.date === tomorrowText
) {
return "明天";
}

// 第一项否则默认今天
if (index === 0) {
return "今天";
}

return f.week;
})()}
</div>

</div>

<div class="daily-icon">
${getWeatherIcon(day.id, day.icon, `${day.description} - ${day.worstWeatherTime}${day.pop}%`)}
</div>

<div class="daily-temps">

<div class="temp-max">
${max}°C
</div>

<div class="temp-min">
${min}°C
</div>

</div>

</div>
`;
})
.join("")}

</div>
`;

// =========================
// bind
// =========================

const prevArrow = document.getElementById("prevArrow");

const nextArrow = document.getElementById("nextArrow");

if (prevArrow) {
prevArrow.addEventListener("click", prevHourly);
}

if (nextArrow) {
nextArrow.addEventListener("click", nextHourly);
}

updateHourlyItemsPerPage();

renderHourly();
} catch (err) {
weather_widget.innerHTML = `
<div class="error">
获取天气失败:${err.message}
</div>
`;

console.error(err);
}
}

// =========================
// 初始化
// =========================

function initWeather() {
getLocationSmart()
.then((location) => {
loadWeather(location);
})
.catch((err) => {
weather_widget.innerHTML = `
<div class="error">
定位失败:${err.message}
</div>
`;

console.error(err);
});
}

// =========================
// 首次加载
// =========================

document.addEventListener("DOMContentLoaded", initWeather);

// =========================
// PJAX
// =========================

document.addEventListener("pjax:complete", initWeather);

let lastWidth = window.innerWidth;

window.addEventListener("resize", () => {
const currentWidth = window.innerWidth;
if (currentWidth === lastWidth) return; // 宽度没变,不处理
lastWidth = currentWidth;

updateHourlyItemsPerPage();
renderHourly();
});
})();

创建样式文件

[blogroot]/themes/source/css/_layout中新建card_weather.styl文件,并添加如下内容

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
.card-weather
line-height 1.2

.weather-widget
width 100%
background none
overflow hidden
padding 12px 8px 0

.location
display flex
align-items center
font-size 17px
color var(--text-highlight-color)
margin-bottom 4px

svg
margin-right 4px
flex-shrink 0
fill currentColor

.main-row
display flex
justify-content space-between
align-items center

.temp-big
font-size 40px
font-weight 700
color var(--text-highlight-color)
margin-top 4px

.weather-left
display flex
flex-direction column
align-items flex-start

.weather-right
display flex
flex-direction column
align-items flex-end

.weather-icon-large
width 52px
height 52px
position relative
right -12px

.weather-desc
margin-top 4px
font-size 15px
color var(--text-highlight-color)

.ow-icon
width 100%
height 100%
object-fit contain
display block
filter drop-shadow(0 1px 2px rgba(0,0,0,.25))

.detail-hourly-row
display flex
margin-top 22px
align-items flex-start

.details-col
display flex
flex-direction column
flex-shrink 0

.detail-item
display flex
align-items center
gap 6px
margin-bottom 13px
color var(--text-highlight-color)
font-size 12px

svg
flex-shrink 0
fill currentColor

.hourly-wrapper
flex 1
display flex
align-items center
justify-content flex-end
min-width 0

.hour-arrow
width 14px
height 16px
border none
background transparent
color #999
font-size 30px
cursor pointer
display flex
align-items center
justify-content center
flex-shrink 0
padding 0
transition .2s ease

&:hover
color #333

&:disabled
opacity 0
cursor default

.hourly-col
overflow hidden
width 100%

.hourly-track
display flex
gap 10px
justify-content center

.hour-item
min-width 36px
display flex
flex-direction column
align-items center

.hour-time
font-size 12px
color var(--font-color)

.hour-icon
width 32px
height 32px
margin-top 2px

.hour-temp
font-size 14px
font-weight 700
color var(--text-highlight-color)
margin-top 2px

.daily-section
max-height 150px
overflow-y auto
padding-right 4px
scrollbar-width none
-ms-overflow-style none

&::-webkit-scrollbar
display none

.daily-item
display flex
align-items center
padding-top 8px

.daily-left
width 86px
flex-shrink 0

.daily-date
font-size 12px
color var(--font-color)

.daily-week
font-size 15px
color var(--text-highlight-color)

.daily-icon
width 40px
height 40px
margin-left auto
flex-shrink 0

.daily-temps
display flex
margin-left auto
font-size 14px
flex-shrink 0

.temp-max
color var(--text-highlight-color)
margin-left 8px
min-width 34px
text-align right
font-weight 500

.temp-min
color var(--font-color)
margin-left 8px
min-width 34px
text-align right
font-weight 400

.loading,
.error
padding 60px 24px
text-align center
color var(--font-color)

.loading
display flex
flex-direction column
align-items center
justify-content center
gap 14px

.loading-spinner
width 28px
height 28px

border 3px solid var(--font-color)
border-top-color var(--default-bg-color)

border-radius 50%

animation weather-spin 0.8s linear infinite

backdrop-filter blur(10px)

.loading-text
font-size 16px
font-weight 500
color var(--text-highlight-color)
letter-spacing .5px

@keyframes weather-spin
from
transform rotate(0deg)

to
transform rotate(360deg)

@media screen and (max-width: 768px)

.hourly-col
width 200px

自建 API(可选)

准备工作

项目 说明
账号 免费 Cloudflare 账户
域名 任意已接入 Cloudflare 的域名(可选,后期绑定)

天气 API

为了避免自己的 API Key暴露,使用 Cloudflare Worker 反向代理 OpenWeather API(免费版)

创建 Worker

1. 登录 Cloudflare Dashboard
2. 侧边栏 Workers → 创建服务 → 名称随意(例如 weather-api)→ 创建
3. 点击右上角编辑代码,清空默认代码,替换为下方内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
export default {

async fetch(request) {

const url = new URL(request.url);

const pathname = url.pathname;

const lat = url.searchParams.get("lat");
const lon = url.searchParams.get("lon");

if (!lat || !lon) {

return Response.json(
{
error: "missing lat/lon"
},
{
status: 400
}
);

}

const API_KEY = "OpenWeather API Key";

let targetUrl = "";

// 当前天气
if (pathname === "/weather") {

targetUrl =
`https://api.openweathermap.org/data/2.5/weather?lat=${lat}&lon=${lon}&appid=${API_KEY}&units=metric&lang=zh_cn`;

}

// 天气预报
else if (pathname === "/forecast") {

targetUrl =
`https://api.openweathermap.org/data/2.5/forecast?lat=${lat}&lon=${lon}&appid=${API_KEY}&units=metric&lang=zh_cn`;

}

else {

return Response.json(
{
error: "not found"
},
{
status: 404
}
);

}

const res = await fetch(targetUrl, {
cf: {
cacheTtl: 600,
cacheEverything: true
}
});

return new Response(
await res.text(),
{
headers: {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": "*"
}
}
);

}

};

4. 点击右上角部署,Workers 的访问链接 https://xxx.xxx.workers.dev 就是 API 地址

绑定自己的域名(可选)

网上有很多 Cloudflare Worker 绑定域名的教程