小组件 美元汇率

经验创意 · 95 次浏览
我的梦想捐钱修路建学校 创建于 10小时39分钟前

好动作 希望CL可以加入官方适配

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:#081018;
      --bg1:#0c1421;
      --bg2:#111b2c;
      --line:rgba(185,220,255,.16);
      --line2:rgba(133,184,255,.12);
      --text:#e8f2ff;
      --muted:rgba(232,242,255,.66);
      --soft:rgba(255,255,255,.04);
      --soft2:rgba(255,255,255,.06);
      --accent:#ffb1df;
      --orange:#ffbe6b;
      --red:#ff6f7f;
      --blue:#7db5ff;
      --green:#7fe3bb;
      --shadow:0 18px 40px rgba(0,0,0,.34);
      --radius:18px;
    }

    *{ box-sizing:border-box; }
    html,body{
      margin:0;
      width:100%;
      height:100%;
      overflow:hidden;
      background:transparent;
      font-family:"Microsoft YaHei",sans-serif;
      color:var(--text);
      -webkit-font-smoothing:antialiased;
      text-rendering:optimizeLegibility;
    }

    .card{
      position:relative;
      width:100%;
      height:100%;
      overflow:hidden;
      border-radius:var(--radius);
      border:1px solid var(--line);
      box-sizing:border-box;
      background:
        radial-gradient(120% 120% at 0% 0%, rgba(117,163,255,.16) 0%, rgba(117,163,255,0) 45%),
        radial-gradient(90% 90% at 100% 0%, rgba(255,160,214,.12) 0%, rgba(255,160,214,0) 52%),
        linear-gradient(180deg, #101a2a 0%, #0d1523 42%, #0a111b 100%);
      box-shadow:
        inset 0 1px 0 rgba(255,255,255,.08),
        inset 0 -1px 0 rgba(255,255,255,.03),
        0 0 0 1px rgba(255,255,255,.02),
        var(--shadow);
    }

    .card::before{
      content:"";
      position:absolute;
      inset:1px;
      border-radius:calc(var(--radius) - 1px);
      pointer-events:none;
      background:
        linear-gradient(180deg, rgba(255,255,255,.09), rgba(255,255,255,0) 12%);
      mask:linear-gradient(#000,#000);
      opacity:.55;
    }

    .card::after{
      content:"";
      position:absolute;
      inset:-40% -20% auto auto;
      width:170px;
      height:170px;
      pointer-events:none;
      background:radial-gradient(circle, rgba(150,200,255,.11), rgba(150,200,255,0) 70%);
      filter:blur(2px);
      opacity:.9;
    }

    .wrap{
      position:relative;
      z-index:1;
      width:100%;
      height:100%;
      display:flex;
      flex-direction:column;
      padding:12px 12px 10px;
      gap:10px;
      min-width:0;
      min-height:0;
    }

    .topbar{
      display:flex;
      align-items:flex-start;
      justify-content:space-between;
      gap:10px;
      min-width:0;
    }

    .titleBox{
      display:flex;
      flex-direction:column;
      gap:4px;
      min-width:0;
      flex:1;
    }

    .titleRow{
      display:flex;
      align-items:center;
      gap:8px;
      min-width:0;
    }

    .badge{
      flex:0 0 auto;
      width:8px;
      height:8px;
      border-radius:999px;
      background:linear-gradient(180deg, #ffd2ee, #ff8cc7);
      box-shadow:0 0 0 4px rgba(255,143,199,.12);
      margin-top:2px;
    }

    .title{
      font-size:16px;
      font-weight:700;
      letter-spacing:.2px;
      color:var(--accent);
      line-height:1.1;
      user-select:none;
      cursor:grab;
      white-space:nowrap;
      overflow:hidden;
      text-overflow:ellipsis;
    }

    .subtitle{
      font-size:11px;
      color:var(--muted);
      line-height:1.35;
      white-space:nowrap;
      overflow:hidden;
      text-overflow:ellipsis;
    }

    .actions{
      display:flex;
      align-items:center;
      gap:8px;
      flex:0 0 auto;
    }

    .btn{
      appearance:none;
      border:1px solid var(--line2);
      background:linear-gradient(180deg, rgba(255,255,255,.08), rgba(255,255,255,.03));
      color:var(--text);
      border-radius:10px;
      height:30px;
      padding:0 10px;
      display:inline-flex;
      align-items:center;
      justify-content:center;
      gap:6px;
      font-size:12px;
      line-height:1;
      cursor:pointer;
      user-select:none;
      transition:transform .12s ease, background .12s ease, border-color .12s ease, box-shadow .12s ease, opacity .12s ease;
      box-shadow:inset 0 1px 0 rgba(255,255,255,.06);
    }
    .btn:hover{
      background:linear-gradient(180deg, rgba(255,255,255,.12), rgba(255,255,255,.05));
      border-color:rgba(150,200,255,.26);
      box-shadow:0 8px 18px rgba(0,0,0,.16), inset 0 1px 0 rgba(255,255,255,.08);
    }
    .btn:active{
      transform:translateY(1px) scale(.99);
      background:linear-gradient(180deg, rgba(255,255,255,.06), rgba(255,255,255,.02));
      border-color:rgba(150,200,255,.18);
    }
    .btn.primary{
      border-color:rgba(125,181,255,.34);
      background:linear-gradient(180deg, rgba(125,181,255,.20), rgba(125,181,255,.08));
    }

    .searchRow{
      display:flex;
      gap:8px;
      align-items:center;
      min-width:0;
    }

    .input{
      flex:1;
      min-width:0;
      height:32px;
      border-radius:11px;
      border:1px solid rgba(150,200,255,.16);
      background:rgba(255,255,255,.035);
      color:var(--text);
      padding:0 11px;
      outline:none;
      font-size:12px;
      box-shadow:inset 0 1px 0 rgba(255,255,255,.04);
      transition:border-color .12s ease, background .12s ease, box-shadow .12s ease;
    }
    .input::placeholder{ color:rgba(232,242,255,.38); }
    .input:hover{
      border-color:rgba(150,200,255,.24);
      background:rgba(255,255,255,.05);
    }
    .input:focus{
      border-color:rgba(125,181,255,.44);
      background:rgba(255,255,255,.07);
      box-shadow:0 0 0 3px rgba(125,181,255,.10), inset 0 1px 0 rgba(255,255,255,.05);
    }

    .meta{
      display:flex;
      align-items:center;
      justify-content:space-between;
      gap:8px;
      min-width:0;
      font-size:11px;
      color:var(--muted);
    }

    .meta .status{
      display:flex;
      align-items:center;
      gap:6px;
      min-width:0;
    }
    .dot{
      width:7px;
      height:7px;
      border-radius:50%;
      background:var(--green);
      box-shadow:0 0 0 4px rgba(127,227,187,.12);
      flex:0 0 auto;
    }
    .statusText{
      overflow:hidden;
      text-overflow:ellipsis;
      white-space:nowrap;
    }

    .list{
      flex:1;
      min-height:0;
      overflow:auto;
      padding-right:2px;
      scrollbar-width:thin;
      scrollbar-color:rgba(154,188,232,.34) rgba(255,255,255,.04);
    }
    .list::-webkit-scrollbar{ width:8px; }
    .list::-webkit-scrollbar-track{
      background:rgba(255,255,255,.04);
      border-radius:999px;
    }
    .list::-webkit-scrollbar-thumb{
      background:rgba(154,188,232,.28);
      border-radius:999px;
      border:2px solid rgba(255,255,255,.03);
    }
    .list::-webkit-scrollbar-thumb:hover{
      background:rgba(154,188,232,.42);
    }

    .row{
      display:grid;
      grid-template-columns:auto 1fr auto;
      gap:8px;
      align-items:center;
      min-width:0;
      padding:9px 10px;
      margin-bottom:7px;
      border-radius:13px;
      border:1px solid rgba(255,255,255,.06);
      background:linear-gradient(180deg, rgba(255,255,255,.05), rgba(255,255,255,.025));
      box-shadow:inset 0 1px 0 rgba(255,255,255,.03);
    }
    .row:last-child{ margin-bottom:0; }

    .flag{
      font-size:16px;
      line-height:1;
      flex:0 0 auto;
      width:22px;
      text-align:center;
      filter:saturate(1.05);
    }

    .currency{
      min-width:0;
      display:flex;
      flex-direction:column;
      gap:2px;
    }
    .currency .name{
      color:var(--orange);
      font-size:13px;
      font-weight:600;
      line-height:1.1;
      white-space:nowrap;
      overflow:hidden;
      text-overflow:ellipsis;
    }
    .currency .code{
      color:rgba(232,242,255,.46);
      font-size:10px;
      line-height:1.1;
      letter-spacing:.3px;
    }

    .rate{
      color:var(--red);
      font-size:14px;
      font-weight:700;
      letter-spacing:.2px;
      font-variant-numeric:tabular-nums;
      white-space:nowrap;
    }

    .empty{
      display:none;
      padding:16px 10px;
      text-align:center;
      color:rgba(232,242,255,.55);
      font-size:12px;
      border:1px dashed rgba(150,200,255,.16);
      border-radius:13px;
      background:rgba(255,255,255,.03);
    }
    .empty.show{ display:block; }

    .footer{
      display:flex;
      align-items:center;
      justify-content:space-between;
      gap:10px;
      min-width:0;
      font-size:11px;
      color:var(--muted);
      padding-top:2px;
    }

    .footer .left,
    .footer .right{
      min-width:0;
      white-space:nowrap;
      overflow:hidden;
      text-overflow:ellipsis;
    }

    .footer .left b{
      color:var(--blue);
      font-weight:600;
    }

    .hint{
      color:rgba(232,242,255,.42);
    }

    .resize{
      position:absolute;
      right:7px;
      bottom:7px;
      width:16px;
      height:16px;
      border-radius:5px;
      background:
        linear-gradient(135deg, transparent 0 42%, rgba(125,181,255,.22) 42% 55%, transparent 55% 100%),
        linear-gradient(135deg, transparent 0 62%, rgba(255,255,255,.12) 62% 72%, transparent 72% 100%);
      opacity:.9;
      cursor:nwse-resize;
      user-select:none;
    }

    .dragArea{
      display:inline-flex;
      align-items:center;
      gap:8px;
      min-width:0;
    }

    .dragIcon{
      flex:0 0 auto;
      width:11px;
      height:11px;
      border-radius:3px;
      border:1px solid rgba(255,177,223,.40);
      background:linear-gradient(180deg, rgba(255,177,223,.22), rgba(255,177,223,.06));
      box-shadow:inset 0 1px 0 rgba(255,255,255,.08);
    }

    .pulse{
      display:inline-block;
      width:6px;
      height:6px;
      border-radius:50%;
      background:var(--green);
      margin-right:6px;
      vertical-align:middle;
      box-shadow:0 0 0 4px rgba(127,227,187,.10);
    }

    .noSelect{
      -webkit-user-select:none;
      user-select:none;
    }

    @media (max-width: 280px){
      .actions{ gap:6px; }
      .btn{ padding:0 8px; }
      .row{ grid-template-columns:auto 1fr; }
      .rate{ grid-column:2; justify-self:end; }
    }
  </style>
</head>
<body>
  <div class="card">
    <div class="wrap">
      <div class="topbar">
        <div class="titleBox">
          <div class="titleRow">
            <span class="badge" aria-hidden="true"></span>
            <div class="dragArea">
              <span class="title yanm-drag" data-yanm-drag="true">美元汇率</span>
            </div>
          </div>
          <div class="subtitle" id="subtitle">USD → CNY / EUR / JPY / GBP / AUD / SGD</div>
        </div>
        <div class="actions">
          <button class="btn" id="btnReset" type="button">重置</button>
          <button class="btn primary" id="btnRefresh" type="button">刷新</button>
        </div>
      </div>

      <div class="searchRow">
        <input class="input" id="filter" type="text" inputmode="text" placeholder="搜索货币名称或代码…" />
        <button class="btn" id="btnClear" type="button">清除</button>
      </div>

      <div class="meta">
        <div class="status"><span class="dot"></span><span class="statusText" id="statusText">准备就绪</span></div>
        <div class="hint" id="countText">6 项</div>
      </div>

      <div class="list" id="list"></div>
      <div class="empty" id="empty">没有匹配的货币。</div>

      <div class="footer">
        <div class="left"><b>更新于</b> <span id="updatedAt">--</span></div>
        <div class="right" id="sourceText">来源:本地缓存 / 在线 API</div>
      </div>
    </div>

    <div class="resize yanm-resize" data-yanm-resize="true" aria-hidden="true"></div>
  </div>

  <script>
    (() => {
      const API_URL = "https://api.exchangerate-api.com/v4/latest/USD";
      const STORAGE_KEY = "yanmu_fx_widget_state_v1";
      const HOST_KEY = "yanmu_fx_widget_state_v1";

      const CURRENCIES = {
        CNY: { name: "人民币", flag: "🇨🇳" },
        EUR: { name: "欧元",   flag: "🇪🇺" },
        JPY: { name: "日元",   flag: "🇯🇵" },
        GBP: { name: "英镑",   flag: "🇬🇧" },
        AUD: { name: "澳元",   flag: "🇦🇺" },
        SGD: { name: "新元",   flag: "🇸🇬" }
      };

      const COLOR = {
        title: "#ffb1df",
        currency: "#ffbe6b",
        rate: "#ff6f7f",
        footer: "#7db5ff"
      };

      const $ = (s) => document.querySelector(s);
      const listEl = $("#list");
      const emptyEl = $("#empty");
      const subtitleEl = $("#subtitle");
      const updatedEl = $("#updatedAt");
      const statusEl = $("#statusText");
      const countEl = $("#countText");
      const sourceEl = $("#sourceText");
      const filterEl = $("#filter");
      const btnRefresh = $("#btnRefresh");
      const btnReset = $("#btnReset");
      const btnClear = $("#btnClear");

      const nowISO = () => new Date().toISOString();
      const fmtTime = (iso) => {
        if (!iso) return "--";
        const d = new Date(iso);
        if (Number.isNaN(d.getTime())) return "--";
        const pad = (n) => String(n).padStart(2, "0");
        return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
      };

      const defaultRates = {
        CNY: 7.20,
        EUR: 0.92,
        JPY: 156.50,
        GBP: 0.79,
        AUD: 1.52,
        SGD: 1.34
      };

      const defaultState = {
        title: "美元汇率",
        query: "",
        rates: defaultRates,
        updatedAt: "",
        source: "本地缓存 / 在线 API",
        loading: false
      };

      let state = loadState();
      let renderTimer = 0;

      function loadState() {
        try {
          const raw = localStorage.getItem(STORAGE_KEY);
          if (raw) {
            const parsed = JSON.parse(raw);
            return {
              ...defaultState,
              ...parsed,
              rates: { ...defaultRates, ...(parsed.rates || {}) }
            };
          }
        } catch (_) {}
        return structuredClone(defaultState);
      }

      function saveState() {
        const payload = JSON.stringify({
          title: state.title,
          query: state.query,
          rates: state.rates,
          updatedAt: state.updatedAt,
          source: state.source
        });

        try { localStorage.setItem(STORAGE_KEY, payload); } catch (_) {}

        try {
          if (window.yanm && typeof window.yanm.invoke === "function") {
            window.yanm.invoke("state.set", {
              key: HOST_KEY,
              value: payload
            });
          }
        } catch (_) {}
      }

      function setState(patch, persist = true) {
        state = { ...state, ...patch };
        render();
        if (persist) saveState();
      }

      function setLoading(loading, text) {
        state.loading = loading;
        if (text) statusEl.textContent = text;
        btnRefresh.textContent = loading ? "刷新中…" : "刷新";
        btnRefresh.disabled = loading;
        btnReset.disabled = loading;
        btnClear.disabled = loading;
        filterEl.disabled = loading;
      }

      function normalize(v) {
        return String(v || "").trim().toLowerCase();
      }

      function getDisplayRows() {
        const q = normalize(state.query);
        const rows = Object.entries(CURRENCIES).map(([code, details]) => ({
          code,
          ...details,
          rate: state.rates?.[code]
        }));

        if (!q) return rows;
        return rows.filter(item => {
          return normalize(item.code).includes(q) || normalize(item.name).includes(q);
        });
      }

      function render() {
        if (renderTimer) cancelAnimationFrame(renderTimer);
        renderTimer = requestAnimationFrame(() => {
          filterEl.value = state.query || "";
          updatedEl.textContent = fmtTime(state.updatedAt);
          sourceEl.textContent = state.source || "本地缓存 / 在线 API";
          subtitleEl.textContent = state.title === "美元汇率"
            ? "USD → CNY / EUR / JPY / GBP / AUD / SGD"
            : state.title;

          const rows = getDisplayRows();
          countEl.textContent = `${rows.length} 项`;
          emptyEl.classList.toggle("show", rows.length === 0);
          listEl.innerHTML = "";

          rows.forEach((item) => {
            const row = document.createElement("div");
            row.className = "row";

            const flag = document.createElement("div");
            flag.className = "flag";
            flag.textContent = item.flag || "🏳️";

            const currency = document.createElement("div");
            currency.className = "currency";

            const name = document.createElement("div");
            name.className = "name";
            name.style.color = COLOR.currency;
            name.textContent = `${item.name} (${item.code})`;

            const code = document.createElement("div");
            code.className = "code";
            code.textContent = `USD 兑 ${item.code}`;

            currency.appendChild(name);
            currency.appendChild(code);

            const rate = document.createElement("div");
            rate.className = "rate";
            rate.style.color = COLOR.rate;
            rate.textContent = typeof item.rate === "number" ? item.rate.toFixed(2) : "--";

            row.appendChild(flag);
            row.appendChild(currency);
            row.appendChild(rate);

            listEl.appendChild(row);
          });

          if (rows.length === 0) {
            listEl.innerHTML = "";
          }
        });
      }

      function seededFallbackRates() {
        const base = {
          CNY: 7.18,
          EUR: 0.92,
          JPY: 156.80,
          GBP: 0.79,
          AUD: 1.51,
          SGD: 1.34
        };
        const t = Math.floor(Date.now() / 3600000);
        const jitter = (seed, spread) => {
          const x = Math.sin(seed + t * 1.37) * 10000;
          return (x - Math.floor(x) - 0.5) * spread;
        };
        return {
          CNY: +(base.CNY + jitter(1, 0.08)).toFixed(2),
          EUR: +(base.EUR + jitter(2, 0.02)).toFixed(2),
          JPY: +(base.JPY + jitter(3, 1.2)).toFixed(2),
          GBP: +(base.GBP + jitter(4, 0.02)).toFixed(2),
          AUD: +(base.AUD + jitter(5, 0.03)).toFixed(2),
          SGD: +(base.SGD + jitter(6, 0.02)).toFixed(2)
        };
      }

      async function refreshRates() {
        setLoading(true, "正在获取汇率…");
        let ok = false;

        try {
          const controller = new AbortController();
          const timer = setTimeout(() => controller.abort(), 7000);

          const res = await fetch(API_URL, {
            method: "GET",
            cache: "no-store",
            signal: controller.signal
          });

          clearTimeout(timer);

          if (!res.ok) throw new Error(`HTTP ${res.status}`);

          const data = await res.json();
          const rates = {};
          for (const code of Object.keys(CURRENCIES)) {
            const value = Number(data?.rates?.[code]);
            rates[code] = Number.isFinite(value) ? +value.toFixed(2) : defaultRates[code];
          }

          ok = true;
          setState({
            rates,
            updatedAt: data?.date ? `${data.date} ${new Date().toLocaleTimeString("zh-CN", { hour12:false })}` : nowISO(),
            source: "在线 API",
          }, true);
          statusEl.textContent = "已更新";
        } catch (_) {
          const rates = seededFallbackRates();
          setState({
            rates,
            updatedAt: nowISO(),
            source: "离线兜底"
          }, true);
          statusEl.textContent = "网络不可用,已切换离线兜底";
        } finally {
          setLoading(false, ok ? "已获取最新汇率" : "已使用离线兜底");
        }
      }

      function bind() {
        filterEl.value = state.query || "";
        filterEl.addEventListener("input", () => {
          setState({ query: filterEl.value }, true);
        });

        btnClear.addEventListener("click", () => {
          setState({ query: "" }, true);
          filterEl.focus();
        });

        btnReset.addEventListener("click", () => {
          setState({
            ...structuredClone(defaultState),
            rates: seededFallbackRates(),
            updatedAt: nowISO(),
            source: "重置完成"
          }, true);
          statusEl.textContent = "已重置";
        });

        btnRefresh.addEventListener("click", refreshRates);

        document.addEventListener("keydown", (e) => {
          if (e.key === "Escape") {
            setState({ query: "" }, true);
          }
          if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "r") {
            e.preventDefault();
            refreshRates();
          }
        });
      }

      function init() {
        if (!state.rates) state.rates = seededFallbackRates();
        if (!state.updatedAt) state.updatedAt = nowISO();
        if (!state.source) state.source = "本地缓存 / 在线 API";
        render();
        bind();
        saveState();
        refreshRates();
      }

      init();
    })();
  </script>
</body>
</html>

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

回复内容
困困君 10小时22分钟前
#1

更新动作,更新动作已经可以用了

回复主贴