需要自行申请彩云API:https://platform.caiyunapp.com/application/manage
经纬度获取:https://jingweidu.bmcx.com/
GPT老师改的Scriptable脚本

<!doctype html><html lang="zh-CN"><head> <meta charset="utf-8" /> <meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no" /> <title>天气 · 节日倒计时</title> <style> :root{ --bg0:#071018; --bg1:#0b1520; --bg2:#101f30; --line:rgba(173,214,255,.16); --line2:rgba(173,214,255,.08); --text:#e8f2ff; --muted:rgba(232,242,255,.72); --soft:rgba(232,242,255,.54); --accent:#8bd0ff; --accent2:#7c8cff; --warn:#ffdd7a; --good:#8ff0b1; --bad:#ff8f9b; --shadow:0 18px 40px rgba(0,0,0,.28); --radius:18px; --scroll:rgba(255,255,255,.14); --scrollTrack:rgba(255,255,255,.05); --glass:rgba(255,255,255,.04); }
*{box-sizing:border-box} html,body{ margin:0; width:100%; height:100%; overflow:hidden; background:transparent; font-family:"Microsoft YaHei",sans-serif; -webkit-font-smoothing:antialiased; text-rendering:geometricPrecision; }
body{color:var(--text)}
.card{ width:100%; height:100%; position:relative; overflow:hidden; border-radius:var(--radius); box-sizing:border-box; padding:12px; background: radial-gradient(1200px 420px at 18% 0%, rgba(112,193,255,.20), transparent 52%), radial-gradient(900px 380px at 92% 12%, rgba(116,123,255,.18), transparent 48%), linear-gradient(160deg, rgba(11,19,29,.96), rgba(10,18,27,.94) 42%, rgba(8,14,22,.97)); border:1px solid var(--line); box-shadow:inset 0 1px 0 rgba(255,255,255,.08), inset 0 -1px 0 rgba(255,255,255,.03), var(--shadow); }
.card::before{ content:""; position:absolute; inset:0; border-radius:inherit; pointer-events:none; background: linear-gradient(145deg, rgba(255,255,255,.12), transparent 20%), linear-gradient(340deg, rgba(255,255,255,.04), transparent 18%); mix-blend-mode:screen; opacity:.35; }
.bgImage{ position:absolute; inset:0; background: radial-gradient(circle at 20% 18%, rgba(136,208,255,.10), transparent 18%), radial-gradient(circle at 85% 15%, rgba(126,138,255,.12), transparent 18%), radial-gradient(circle at 78% 80%, rgba(143,240,177,.08), transparent 18%); pointer-events:none; opacity:1; }
.main{ position:relative; z-index:1; width:100%; height:100%; display:flex; flex-direction:column; gap:10px; min-height:0; }
.topbar{ display:flex; align-items:center; justify-content:space-between; gap:10px; min-height:26px; flex:0 0 auto; }
.brand{ display:flex; align-items:center; gap:10px; min-width:0; }
.logo{ width:24px; height:24px; border-radius:8px; display:grid; place-items:center; color:#dbf1ff; background:linear-gradient(145deg, rgba(139,208,255,.28), rgba(124,140,255,.18)); border:1px solid rgba(180,223,255,.18); box-shadow:inset 0 1px 0 rgba(255,255,255,.18); flex:0 0 auto; font-size:14px; }
.titleWrap{min-width:0} .title{ font-size:14px; font-weight:700; letter-spacing:.2px; line-height:1.15; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; } .subTitle{ margin-top:2px; font-size:11px; color:var(--muted); line-height:1.15; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.toolbar{ display:flex; align-items:center; gap:8px; flex:0 0 auto; }
.btn{ appearance:none; border:1px solid rgba(180,223,255,.16); background:linear-gradient(180deg, rgba(255,255,255,.08), rgba(255,255,255,.03)); color:var(--text); height:28px; padding:0 10px; border-radius:10px; font-size:12px; cursor:pointer; transition:transform .12s ease, border-color .12s ease, background .12s ease, box-shadow .12s ease, opacity .12s ease; box-shadow:inset 0 1px 0 rgba(255,255,255,.08); user-select:none; } .btn:hover{ border-color:rgba(156,214,255,.34); background:linear-gradient(180deg, rgba(255,255,255,.12), rgba(255,255,255,.05)); } .btn:active{ transform:translateY(1px) scale(.99); background:linear-gradient(180deg, rgba(255,255,255,.06), rgba(255,255,255,.02)); } .btn.secondary{ color:rgba(232,242,255,.9); padding:0 9px; } .btn.primary{ border-color:rgba(139,208,255,.28); background:linear-gradient(180deg, rgba(139,208,255,.22), rgba(124,140,255,.08)); } .btn.danger{ border-color:rgba(255,143,155,.22); }
.bodyGrid{ display:grid; grid-template-columns:minmax(168px, .95fr) minmax(0, 1.25fr); gap:10px; min-height:0; flex:1 1 auto; }
.panel{ position:relative; min-height:0; border:1px solid rgba(180,223,255,.10); border-radius:16px; background:linear-gradient(180deg, rgba(255,255,255,.05), rgba(255,255,255,.02)); box-shadow:inset 0 1px 0 rgba(255,255,255,.05); overflow:hidden; }
.panelInner{ width:100%; height:100%; padding:10px; display:flex; flex-direction:column; gap:9px; min-height:0; }
.hero{ display:flex; align-items:flex-start; justify-content:space-between; gap:10px; min-height:0; }
.dateBox{ min-width:0; flex:1 1 auto; }
.dateLine{ font-size:12px; color:rgba(232,242,255,.82); font-weight:600; letter-spacing:.2px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.locLine{ margin-top:4px; font-size:11px; color:var(--soft); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.tempRow{ display:flex; align-items:flex-end; gap:8px; margin-top:2px; min-height:0; }
.temp{ font-size:34px; line-height:1; font-weight:800; letter-spacing:-1px; }
.unit{ font-size:14px; color:rgba(232,242,255,.78); padding-bottom:5px; }
.iconChip{ width:42px; height:42px; border-radius:14px; display:grid; place-items:center; background:linear-gradient(180deg, rgba(139,208,255,.20), rgba(124,140,255,.08)); border:1px solid rgba(180,223,255,.12); box-shadow:inset 0 1px 0 rgba(255,255,255,.10); font-size:22px; flex:0 0 auto; }
.metaCols{ display:grid; grid-template-columns:1fr 1fr; gap:8px; min-height:0; }
.stat{ padding:9px 10px; border-radius:14px; background:rgba(255,255,255,.035); border:1px solid rgba(180,223,255,.09); min-height:0; }
.statLabel{ font-size:11px; color:var(--soft); line-height:1; margin-bottom:5px; }
.statValue{ font-size:13px; font-weight:700; line-height:1.2; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.aqiGood{color:var(--good)} .aqiMid{color:var(--warn)} .aqiBad{color:var(--bad)}
.festivals{ display:flex; flex-wrap:wrap; gap:6px; min-height:0; }
.chip{ padding:7px 9px; border-radius:999px; font-size:11px; line-height:1; background:rgba(255,255,255,.04); border:1px solid rgba(180,223,255,.09); color:rgba(232,242,255,.92); white-space:nowrap; box-shadow:inset 0 1px 0 rgba(255,255,255,.04); }
.chip .n{color:#ffffff;font-weight:700} .chip .m{color:var(--soft)}
.sectionTitle{ display:flex; align-items:center; justify-content:space-between; gap:8px; font-size:11px; color:rgba(232,242,255,.88); font-weight:700; letter-spacing:.2px; line-height:1; flex:0 0 auto; margin-bottom:2px; }
.sectionHint{ color:var(--soft); font-weight:500; font-size:11px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.scrollX{ display:flex; gap:8px; overflow:auto hidden; padding-bottom:3px; scrollbar-width:thin; scrollbar-color:var(--scroll) var(--scrollTrack); min-height:0; -webkit-overflow-scrolling:touch; } .scrollX::-webkit-scrollbar{height:7px} .scrollX::-webkit-scrollbar-track{background:var(--scrollTrack);border-radius:999px} .scrollX::-webkit-scrollbar-thumb{background:var(--scroll);border-radius:999px} .scrollX::-webkit-scrollbar-thumb:hover{background:rgba(255,255,255,.22)}
.hourCard{ flex:0 0 auto; min-width:58px; padding:8px 8px 7px; border-radius:14px; background:rgba(255,255,255,.035); border:1px solid rgba(180,223,255,.08); text-align:center; }
.hourTime{ font-size:10px; color:var(--soft); line-height:1; } .hourIcon{ font-size:14px; margin:6px 0 5px; line-height:1; } .hourTemp{ font-size:12px; font-weight:700; line-height:1; }
.dailyList{ display:flex; flex-direction:column; gap:6px; overflow:auto; min-height:0; padding-right:2px; scrollbar-width:thin; scrollbar-color:var(--scroll) var(--scrollTrack); -webkit-overflow-scrolling:touch; } .dailyList::-webkit-scrollbar{width:7px} .dailyList::-webkit-scrollbar-track{background:var(--scrollTrack);border-radius:999px} .dailyList::-webkit-scrollbar-thumb{background:var(--scroll);border-radius:999px} .dailyList::-webkit-scrollbar-thumb:hover{background:rgba(255,255,255,.22)}
.dayRow{ display:grid; grid-template-columns:72px 1fr auto; gap:8px; align-items:center; padding:8px 9px; border-radius:14px; background:rgba(255,255,255,.034); border:1px solid rgba(180,223,255,.08); min-height:0; }
.dayName{ font-size:12px; font-weight:700; color:rgba(232,242,255,.94); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.dayDesc{ font-size:11px; color:var(--soft); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.dayTemp{ font-size:12px; font-weight:700; white-space:nowrap; }
.muted{color:var(--soft)} .ok{color:var(--good)} .warn{color:var(--warn)} .err{color:var(--bad)}
.loading{ display:flex; align-items:center; gap:8px; color:rgba(232,242,255,.78); font-size:12px; line-height:1.2; }
.dot{ width:8px; height:8px; border-radius:50%; background:linear-gradient(180deg, #8bd0ff, #7c8cff); box-shadow:0 0 0 6px rgba(139,208,255,.08); animation:pulse 1.2s ease-in-out infinite; flex:0 0 auto; } @keyframes pulse{ 0%,100%{transform:scale(.86);opacity:.65} 50%{transform:scale(1);opacity:1} }
.settings{ position:absolute; right:12px; top:46px; z-index:3; width:min(320px, calc(100% - 24px)); border-radius:16px; border:1px solid rgba(180,223,255,.14); background:linear-gradient(180deg, rgba(10,18,27,.98), rgba(12,20,31,.96)); box-shadow:0 16px 36px rgba(0,0,0,.36), inset 0 1px 0 rgba(255,255,255,.06); padding:10px; display:none; backdrop-filter:blur(8px); } .settings.open{display:block}
.formGrid{ display:grid; grid-template-columns:1fr 1fr; gap:8px; } .field{ display:flex; flex-direction:column; gap:5px; min-width:0; } .field.full{grid-column:1 / -1} .label{ font-size:11px; color:var(--soft); line-height:1; } .input{ width:100%; height:30px; border-radius:10px; border:1px solid rgba(180,223,255,.12); background:rgba(255,255,255,.04); color:var(--text); padding:0 10px; font:inherit; font-size:12px; outline:none; transition:border-color .12s ease, background .12s ease, box-shadow .12s ease; } .input:focus{ border-color:rgba(139,208,255,.42); box-shadow:0 0 0 3px rgba(139,208,255,.10); background:rgba(255,255,255,.055); }
.switchRow{ display:flex; gap:8px; align-items:center; flex-wrap:wrap; }
.mini{ height:24px; padding:0 8px; font-size:11px; border-radius:9px; }
.statusBar{ display:flex; align-items:center; justify-content:space-between; gap:10px; min-height:0; color:var(--soft); font-size:11px; line-height:1.2; flex:0 0 auto; }
.statusText{ min-width:0; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.linkLike{ color:rgba(190,227,255,.92); text-decoration:none; }
.hidden{display:none !important;}
@media (max-width: 430px){ .bodyGrid{grid-template-columns:1fr} .dailyList{max-height:112px} .temp{font-size:30px} .hero{align-items:flex-start} } </style></head><body> <div class="card" id="card"> <div class="bgImage"></div>
<div class="main"> <div class="topbar"> <div class="brand"> <div class="logo">☁</div> <div class="titleWrap"> <div class="title">天气 · 节日倒计时</div> <div class="subTitle" id="subtitle">本地兜底已加载,正在等待宿主数据</div> </div> </div> <div class="toolbar"> <button class="btn secondary" id="btnRefresh" type="button">刷新</button> <button class="btn primary" id="btnSettings" type="button">设置</button> </div> </div>
<div class="bodyGrid"> <div class="panel"> <div class="panelInner"> <div class="hero"> <div class="dateBox"> <div class="dateLine" id="dateLine">--月--日 --</div> <div class="locLine" id="locLine">经纬度:-- · --</div> <div class="tempRow"> <div class="temp" id="tempMain">--</div> <div class="unit">°C</div> <div class="iconChip" id="iconChip">☁</div> </div> </div> </div>
<div class="metaCols"> <div class="stat"> <div class="statLabel">空气质量</div> <div class="statValue" id="aqiLine">--</div> </div> <div class="stat"> <div class="statLabel">天气现象</div> <div class="statValue" id="descLine">--</div> </div> </div>
<div> <div class="sectionTitle"> <span>节日倒计时</span> <span class="sectionHint">取最近 3 项</span> </div> <div class="festivals" id="festivalBox"></div> </div>
<div class="statusBar"> <div class="statusText" id="statusText">准备同步天气数据</div> </div> </div> </div>
<div class="panel"> <div class="panelInner"> <div> <div class="sectionTitle"> <span>未来几小时</span> <span class="sectionHint">6 条</span> </div> <div class="scrollX" id="hourlyBox"></div> </div>
<div style="display:flex;flex-direction:column;min-height:0;gap:4px;"> <div class="sectionTitle"> <span>未来三天</span> <span class="sectionHint">日级预报</span> </div> <div class="dailyList" id="dailyBox"></div> </div> </div> </div> </div> </div>
<div class="settings" id="settingsPanel" aria-hidden="true"> <div class="formGrid"> <div class="field full"> <div class="label">彩云 API Key</div> <input class="input" id="inpApiKey" type="text" autocomplete="off" spellcheck="false" /> </div> <div class="field"> <div class="label">纬度</div> <input class="input" id="inpLat" type="text" inputmode="decimal" /> </div> <div class="field"> <div class="label">经度</div> <input class="input" id="inpLng" type="text" inputmode="decimal" /> </div> <div class="field full"> <div class="label">操作</div> <div class="switchRow"> <button class="btn mini primary" id="btnSave" type="button">保存并刷新</button> <button class="btn mini" id="btnUseDefault" type="button">恢复默认</button> <button class="btn mini danger" id="btnClose" type="button">关闭</button> </div> </div> </div> </div> </div>
<script> (function(){ "use strict";
var BOOT_TRIES = 0; var MAX_BOOT_TRIES = 120;
var STATE_KEY_SETTINGS = "yanmu.weather.settings"; var STATE_KEY_CACHE = "yanmu.weather.cache";
var DEFAULTS = { apiKey: "cPfGZtm6tFVImXl0", latitude: 39.90504785214505, longitude: 116.7244748284355 };
var state = { settings: clone(DEFAULTS), cache: null };
var ui = {}; var lastWeather = null; var refreshing = false; var saveTimer = null;
var FESTIVALS = [ { name: "元旦", date: "2026-01-01" }, { name: "春节", date: "2026-02-17" }, { name: "清明节", date: "2026-04-05" }, { name: "劳动节", date: "2026-05-01" }, { name: "端午节", date: "2026-06-19" }, { name: "中秋节", date: "2026-09-25" }, { name: "国庆节", date: "2026-10-01" },
{ name: "小寒", date: "2026-01-05" }, { name: "大寒", date: "2026-01-20" }, { name: "立春", date: "2026-02-04" }, { name: "雨水", date: "2026-02-18" }, { name: "惊蛰", date: "2026-03-05" }, { name: "春分", date: "2026-03-20" }, { name: "清明", date: "2026-04-05" }, { name: "谷雨", date: "2026-04-20" }, { name: "立夏", date: "2026-05-05" }, { name: "小满", date: "2026-05-21" }, { name: "芒种", date: "2026-06-05" }, { name: "夏至", date: "2026-06-21" }, { name: "小暑", date: "2026-07-07" }, { name: "大暑", date: "2026-07-23" }, { name: "立秋", date: "2026-08-07" }, { name: "处暑", date: "2026-08-23" }, { name: "白露", date: "2026-09-07" }, { name: "秋分", date: "2026-09-23" }, { name: "寒露", date: "2026-10-08" }, { name: "霜降", date: "2026-10-23" }, { name: "立冬", date: "2026-11-07" }, { name: "小雪", date: "2026-11-22" }, { name: "大雪", date: "2026-12-07" }, { name: "冬至", date: "2026-12-22" },
{ name: "元宵节", date: "2026-03-03" }, { name: "七夕节", date: "2026-08-19" }, { name: "重阳节", date: "2026-10-26" } ];
var WEATHER_MAP = { CLEAR_DAY: { label: "晴(白天)", emoji: "☀️" }, CLEAR_NIGHT: { label: "晴(夜间)", emoji: "🌙" }, PARTLY_CLOUDY_DAY: { label: "多云(白天)", emoji: "🌤️" }, PARTLY_CLOUDY_NIGHT:{ label:"多云(夜间)", emoji: "🌥️" }, CLOUDY: { label: "阴", emoji: "☁️" },
LIGHT_HAZE: { label: "轻度雾霾", emoji: "🌫️" }, MODERATE_HAZE: { label: "中度雾霾", emoji: "🌫️" }, HEAVY_HAZE: { label: "重度雾霾", emoji: "🌫️" },
LIGHT_RAIN: { label: "小雨", emoji: "🌦️" }, MODERATE_RAIN: { label: "中雨", emoji: "🌧️" }, HEAVY_RAIN: { label: "大雨", emoji: "🌧️" }, STORM_RAIN: { label: "暴雨", emoji: "⛈️" },
FOG: { label: "雾", emoji: "🌁" },
LIGHT_SNOW: { label: "小雪", emoji: "🌨️" }, MODERATE_SNOW: { label: "中雪", emoji: "❄️" }, HEAVY_SNOW: { label: "大雪", emoji: "❄️" }, STORM_SNOW: { label: "暴雪", emoji: "🌨️" },
DUST: { label: "浮尘", emoji: "🌪️" }, SAND: { label: "沙尘", emoji: "🌪️" },
WIND: { label: "大风", emoji: "💨" } };
function $(id){ return document.getElementById(id); }
function clone(obj){ return JSON.parse(JSON.stringify(obj)); }
function pad2(n){ return (n < 10 ? "0" : "") + n; }
function hasFudao(){ return !!(window.fudao && typeof window.fudao.invoke === "function"); }
function safeJSONParse(text, fallback){ try{ if (text == null || text === "") return fallback; if (typeof text !== "string") return text; return JSON.parse(text); }catch(e){ return fallback; } }
function extractStateText(res){ if (typeof res === "string") return res; if (!res) return ""; if (typeof res.value === "string") return res.value; if (typeof res.text === "string") return res.text; if (typeof res.result === "string") return res.result; if (typeof res.data === "string") return res.data; return ""; }
function formatDateLine(now){ var wd = "日一二三四五六"[now.getDay()]; return now.getMonth() + 1 + "月" + pad2(now.getDate()) + "日 周" + wd; }
function formatLocLine(lat, lng){ return "经纬度:" + Number(lat).toFixed(6) + " · " + Number(lng).toFixed(6); }
function weatherInfo(code){ return WEATHER_MAP[code] || { label: code ? String(code) : "未知天气现象", emoji: "☁️" }; }
function weatherEmoji(code){ return weatherInfo(code).emoji; }
function weatherLabel(code){ return weatherInfo(code).label; }
function aqiClass(aqi){ if (aqi <= 50) return "ok"; if (aqi <= 100) return "warn"; return "err"; }
function getCountdowns(now){ var list = FESTIVALS.map(function(item){ var festivalDate = new Date(item.date + "T00:00:00"); var days = Math.ceil((festivalDate.getTime() - now.getTime()) / 86400000); if (days < 0) { festivalDate.setFullYear(festivalDate.getFullYear() + 1); days = Math.ceil((festivalDate.getTime() - now.getTime()) / 86400000); } return { name: item.name, days: days }; }); list.sort(function(a,b){ return a.days - b.days; }); return list.slice(0, 3); }
function renderFestivals(){ var box = ui.festivalBox; box.innerHTML = ""; var now = new Date(); var items = getCountdowns(now); if (!items.length){ box.innerHTML = '<div class="chip"><span class="m">暂无数据</span></div>'; return; } items.forEach(function(item){ var div = document.createElement("div"); div.className = "chip"; div.innerHTML = '<span class="n">' + escapeHTML(item.name) + '</span> <span class="m">还有</span> <span class="n">' + item.days + '</span> <span class="m">天</span>'; box.appendChild(div); }); }
function renderLoading(){ ui.statusText.innerHTML = '<span class="loading"><span class="dot"></span>正在同步天气数据</span>'; }
function renderError(msg){ ui.statusText.textContent = msg || "数据同步失败"; ui.subtitle.textContent = "使用本地兜底内容"; }
function renderWeatherFallback(){ var now = new Date(); ui.dateLine.textContent = formatDateLine(now); ui.locLine.textContent = formatLocLine(state.settings.latitude, state.settings.longitude); ui.tempMain.textContent = "--"; ui.iconChip.textContent = "☁"; ui.aqiLine.innerHTML = '<span class="muted">等待刷新</span>'; ui.descLine.textContent = "暂无天气数据"; ui.hourlyBox.innerHTML = buildFallbackHours(); ui.dailyBox.innerHTML = buildFallbackDays(); renderFestivals(); ui.subtitle.textContent = "本地兜底已加载,等待天气接口"; ui.statusText.textContent = "本地兜底显示中"; }
function buildFallbackHours(){ var html = ""; for (var i=0;i<6;i++){ html += '' + '<div class="hourCard">' + '<div class="hourTime">' + pad2((new Date().getHours() + i) % 24) + '时</div>' + '<div class="hourIcon">☁️</div>' + '<div class="hourTemp">--°</div>' + '</div>'; } return html; }
function buildFallbackDays(){ var names = ["今天","明天","后天"]; var html = ""; for (var i=0;i<3;i++){ html += '' + '<div class="dayRow">' + '<div>' + '<div class="dayName">' + names[i] + '</div>' + '<div class="dayDesc muted">等待接口返回</div>' + '</div>' + '<div class="dayDesc">☁️</div>' + '<div class="dayTemp">--/--°</div>' + '</div>'; } return html; }
function renderWeather(data){ if (!data || !data.result) { renderError("接口无有效返回"); return; }
lastWeather = data; var rt = data.result.realtime || {}; var hourly = data.result.hourly || {}; var daily = data.result.daily || {};
var now = new Date(); ui.dateLine.textContent = formatDateLine(now); ui.locLine.textContent = formatLocLine(state.settings.latitude, state.settings.longitude);
var temp = rt.temperature; ui.tempMain.textContent = (typeof temp === "number" ? Math.round(temp) : "--"); ui.iconChip.textContent = weatherEmoji(rt.skycon || "CLOUDY");
var aqi = rt.air_quality && rt.air_quality.aqi ? rt.air_quality.aqi.chn : null; var aqiDesc = rt.air_quality && rt.air_quality.description ? rt.air_quality.description.chn : ""; if (typeof aqi === "number") { ui.aqiLine.innerHTML = '<span class="' + aqiClass(aqi) + '">' + aqi + '</span><span class="muted"> · ' + escapeHTML(aqiDesc || "未知") + '</span>'; } else { ui.aqiLine.innerHTML = '<span class="muted">暂无 AQI</span>'; } ui.descLine.textContent = weatherLabel(rt.skycon || "CLOUDY");
ui.hourlyBox.innerHTML = buildHourly(hourly); ui.dailyBox.innerHTML = buildDaily(daily);
renderFestivals(); ui.statusText.textContent = "天气已更新 · " + new Date().toLocaleTimeString([], {hour:"2-digit", minute:"2-digit"}); ui.subtitle.textContent = "最后更新:" + new Date().toLocaleString([], { month:"short", day:"numeric", hour:"2-digit", minute:"2-digit" });
saveCache(data); }
function buildHourly(hourly){ var list = Array.isArray(hourly.temperature) ? hourly.temperature : []; var sky = Array.isArray(hourly.skycon) ? hourly.skycon : []; var html = ""; for (var i=0; i<Math.min(6, list.length); i++){ var t = list[i]; var hour = "--"; if (t && t.datetime) { var d = new Date(t.datetime); if (!isNaN(d.getTime())) hour = pad2(d.getHours()) + "时"; } var code = sky[i] && sky[i].value ? sky[i].value : "CLOUDY"; var icon = weatherEmoji(code); var temp = (t && typeof t.value === "number") ? Math.round(t.value) : "--"; html += '' + '<div class="hourCard">' + '<div class="hourTime">' + hour + '</div>' + '<div class="hourIcon">' + icon + '</div>' + '<div class="hourTemp">' + temp + '°</div>' + '</div>'; } if (!html) html = buildFallbackHours(); return html; }
function buildDaily(daily){ var tempList = Array.isArray(daily.temperature) ? daily.temperature : []; var skyList = Array.isArray(daily.skycon) ? daily.skycon : []; var html = ""; var week = "日一二三四五六"; for (var i=0; i<Math.min(3, tempList.length); i++){ var item = tempList[i] || {}; var d = item.date ? new Date(item.date) : null; var label = i === 0 ? "今天" : i === 1 ? "明天" : "后天"; if (d && !isNaN(d.getTime())) label = "周" + week[d.getDay()]; var code = skyList[i] && skyList[i].value ? skyList[i].value : "CLOUDY"; var icon = weatherEmoji(code); var desc = weatherLabel(code); var min = (typeof item.min === "number") ? Math.round(item.min) : "--"; var max = (typeof item.max === "number") ? Math.round(item.max) : "--"; html += '' + '<div class="dayRow">' + '<div>' + '<div class="dayName">' + label + '</div>' + '<div class="dayDesc">' + escapeHTML(desc) + '</div>' + '</div>' + '<div class="dayDesc">' + icon + '</div>' + '<div class="dayTemp">' + min + '°/' + max + '°</div>' + '</div>'; } if (!html) html = buildFallbackDays(); return html; }
function escapeHTML(str){ return String(str == null ? "" : str) .replace(/&/g, "&") .replace(/</g, "<") .replace(/>/g, ">") .replace(/"/g, """) .replace(/'/g, "'"); }
function applySettingsToForm(){ ui.inpApiKey.value = state.settings.apiKey || ""; ui.inpLat.value = String(state.settings.latitude); ui.inpLng.value = String(state.settings.longitude); }
function openSettings(open){ ui.settingsPanel.classList.toggle("open", !!open); ui.settingsPanel.setAttribute("aria-hidden", open ? "false" : "true"); }
function readSettings(){ if (!hasFudao()) return Promise.resolve(); return window.fudao.invoke("state.read", { key: STATE_KEY_SETTINGS, defaultValue: JSON.stringify(DEFAULTS) }).then(function(res){ var raw = extractStateText(res); var parsed = safeJSONParse(raw, null); if (parsed && typeof parsed === "object") { state.settings = Object.assign(clone(DEFAULTS), parsed); } }).catch(function(){}); }
function readCache(){ if (!hasFudao()) return Promise.resolve(); return window.fudao.invoke("state.read", { key: STATE_KEY_CACHE, defaultValue: "" }).then(function(res){ var raw = extractStateText(res); var parsed = safeJSONParse(raw, null); if (parsed && typeof parsed === "object") { state.cache = parsed; } }).catch(function(){}); }
function saveSettings(){ if (!hasFudao()) return Promise.resolve(); var payload = JSON.stringify(state.settings); return window.fudao.invoke("state.write", { key: STATE_KEY_SETTINGS, value: payload }).catch(function(){}); }
function saveCache(data){ if (!hasFudao()) return Promise.resolve(); state.cache = data; return window.fudao.invoke("state.write", { key: STATE_KEY_CACHE, value: JSON.stringify(data) }).catch(function(){}); }
function normalizeSettingsFromForm(){ var apiKey = trim(ui.inpApiKey.value) || DEFAULTS.apiKey; var lat = parseFloat(ui.inpLat.value); var lng = parseFloat(ui.inpLng.value); if (!isFinite(lat)) lat = DEFAULTS.latitude; if (!isFinite(lng)) lng = DEFAULTS.longitude; state.settings.apiKey = apiKey; state.settings.latitude = lat; state.settings.longitude = lng; }
function trim(s){ return String(s == null ? "" : s).replace(/^\s+|\s+$/g, ""); }
function buildWeatherUrl(){ return "https://api.caiyunapp.com/v2.6/" + encodeURIComponent(state.settings.apiKey) + "/" + encodeURIComponent(state.settings.longitude) + "," + encodeURIComponent(state.settings.latitude) + "/weather?dailysteps=3&hourlysteps=6"; }
function fetchWeather(){ if (!hasFudao()) return Promise.reject(new Error("fudao unavailable")); if (refreshing) return Promise.resolve(); refreshing = true; renderLoading();
var url = buildWeatherUrl(); return window.fudao.invoke("http.get", { url: url, headers: { "Accept": "application/json,text/plain,*/*", "Cache-Control": "no-cache", "Pragma": "no-cache" }, timeoutMs: 10000 }).then(function(res){ refreshing = false; if (!res || res.ok === false){ var msg = "接口请求失败"; if (res && typeof res.status !== "undefined") msg += " · HTTP " + res.status; throw new Error(msg); } var txt = typeof res.text === "string" ? res.text : ""; var data = safeJSONParse(txt, null); if (!data) throw new Error("JSON 解析失败"); renderWeather(data); }).catch(function(err){ refreshing = false; if (state.cache && state.cache.result) { renderWeather(state.cache); ui.statusText.textContent = "接口异常,已回退到缓存"; ui.subtitle.textContent = "缓存时间:" + (state.cache.__savedAt || "未知"); } else { renderError((err && err.message) ? err.message : "请求失败"); } }); }
function refreshAll(){ renderWeatherFallback(); applySettingsToForm(); return fetchWeather(); }
function scheduleSaveAndMaybeRefresh(refresh){ clearTimeout(saveTimer); saveTimer = setTimeout(function(){ normalizeSettingsFromForm(); saveSettings().then(function(){ if (refresh) fetchWeather(); }); }, 180); }
function wireEvents(){ ui.btnSettings.addEventListener("click", function(){ openSettings(!ui.settingsPanel.classList.contains("open")); });
ui.btnClose.addEventListener("click", function(){ openSettings(false); });
ui.btnRefresh.addEventListener("click", function(){ fetchWeather(); });
ui.btnSave.addEventListener("click", function(){ normalizeSettingsFromForm(); saveSettings().then(function(){ openSettings(false); fetchWeather(); }); });
ui.btnUseDefault.addEventListener("click", function(){ state.settings = clone(DEFAULTS); applySettingsToForm(); saveSettings().then(fetchWeather); });
[ui.inpApiKey, ui.inpLat, ui.inpLng].forEach(function(el){ el.addEventListener("input", function(){ scheduleSaveAndMaybeRefresh(false); });
el.addEventListener("blur", function(){ normalizeSettingsFromForm(); saveSettings(); });
el.addEventListener("keydown", function(e){ if (e.key === "Enter") { normalizeSettingsFromForm(); saveSettings().then(function(){ openSettings(false); fetchWeather(); }); } }); }); }
function initUI(){ ui = { subtitle: $("subtitle"), btnRefresh: $("btnRefresh"), btnSettings: $("btnSettings"), settingsPanel: $("settingsPanel"), inpApiKey: $("inpApiKey"), inpLat: $("inpLat"), inpLng: $("inpLng"), btnSave: $("btnSave"), btnUseDefault: $("btnUseDefault"), btnClose: $("btnClose"), dateLine: $("dateLine"), locLine: $("locLine"), tempMain: $("tempMain"), iconChip: $("iconChip"), aqiLine: $("aqiLine"), descLine: $("descLine"), festivalBox: $("festivalBox"), hourlyBox: $("hourlyBox"), dailyBox: $("dailyBox"), statusText: $("statusText") }; wireEvents(); renderWeatherFallback(); }
function init(){ initUI();
Promise.resolve() .then(readSettings) .then(readCache) .then(function(){ applySettingsToForm(); renderWeatherFallback(); return fetchWeather(); }) .catch(function(){ renderWeatherFallback(); });
setInterval(function(){ if (document.visibilityState !== "hidden") fetchWeather(); }, 10 * 60 * 1000);
setInterval(function(){ renderFestivals(); }, 60 * 1000); }
function boot(){ if (hasFudao()) { init(); return; } BOOT_TRIES++; if (BOOT_TRIES > MAX_BOOT_TRIES) { init(); return; } setTimeout(boot, 120); }
boot(); })(); </script></body></html>