小组件:天气 · 节日倒计时

经验创意 · 262 次浏览
我的梦想捐钱修路建学校 创建于 2026-05-19 14:47

需要自行申请彩云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, "&amp;")
        .replace(/</g, "&lt;")
        .replace(/>/g, "&gt;")
        .replace(/"/g, "&quot;")
        .replace(/'/g, "&#39;");
    }

    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>

我的梦想捐钱修路建学校 最后更新于 2026/5/19

回复内容
暂无回复
回复主贴