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:#06101c; --bg1:#0b1627; --bg2:#0d1a2f; --line:rgba(167, 196, 255, .18); --line2:rgba(255,255,255,.07); --text:#edf4ff; --muted:rgba(237,244,255,.66); --muted2:rgba(237,244,255,.46); --accent:#78adff; --accent2:#77f0ff; --good:#52e0bf; --warn:#ffcc66; --bad:#ff7e8d; --shadow:0 22px 54px rgba(0,0,0,.34); --r:18px; }
*{box-sizing:border-box} html,body{ margin:0; width:100%; height:100%; overflow:hidden; background:transparent; } body{ font-family:"Microsoft YaHei", sans-serif; color:var(--text); -webkit-font-smoothing:antialiased; -moz-osx-font-smoothing:grayscale; user-select:none; }
.card{ width:100%; height:100%; position:relative; overflow:hidden; border-radius:18px; box-sizing:border-box; border:1px solid var(--line); background: radial-gradient(120% 120% at 0% 0%, rgba(120,173,255,.18) 0%, rgba(120,173,255,0) 42%), radial-gradient(110% 110% at 100% 0%, rgba(119,240,255,.11) 0%, rgba(119,240,255,0) 44%), linear-gradient(180deg, rgba(13,26,47,.98) 0%, rgba(7,16,28,.98) 100%); box-shadow:var(--shadow), inset 0 1px 0 rgba(255,255,255,.05); backdrop-filter: blur(12px); } .card::before{ content:""; position:absolute; inset:0; pointer-events:none; border-radius:18px; background:linear-gradient(180deg, rgba(255,255,255,.09) 0%, rgba(255,255,255,0) 16%); opacity:.55; }
.shell{ position:absolute; inset:0; padding:12px; display:flex; flex-direction:column; gap:10px; min-height:0; }
.topbar{ display:flex; align-items:center; justify-content:space-between; gap:8px; min-width:0; } .titleWrap{ display:flex; align-items:center; gap:10px; min-width:0; } .badge{ width:11px; height:11px; border-radius:50%; flex:0 0 auto; background:linear-gradient(135deg, var(--accent) 0%, var(--accent2) 100%); box-shadow:0 0 0 4px rgba(120,173,255,.12); } .titleBlock{ min-width:0; display:flex; flex-direction:column; gap:2px; } .title{ font-size:16px; font-weight:800; line-height:1.15; letter-spacing:.2px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; } .subtitle{ font-size:11px; color:var(--muted); line-height:1.2; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; } .topActions{ display:flex; gap:8px; align-items:center; flex:0 0 auto; } .statusPill{ max-width:120px; padding:7px 10px; border-radius:999px; border:1px solid var(--line2); background:rgba(255,255,255,.04); color:var(--muted); font-size:11px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.btn{ appearance:none; border:none; outline:none; cursor:pointer; height:31px; padding:0 12px; border-radius:12px; font-size:12px; font-weight:700; letter-spacing:.1px; color:var(--text); border:1px solid rgba(120,173,255,.25); background:linear-gradient(180deg, rgba(120,173,255,.22), rgba(120,173,255,.10)); box-shadow:inset 0 1px 0 rgba(255,255,255,.08); transition:transform .12s ease, border-color .12s ease, background .12s ease, opacity .12s ease; } .btn:hover{ border-color:rgba(120,173,255,.38); background:linear-gradient(180deg, rgba(120,173,255,.30), rgba(120,173,255,.14)); } .btn:active{ transform:translateY(1px) scale(.99); } .btn.secondary{ color:var(--muted); border-color:rgba(255,255,255,.08); background:rgba(255,255,255,.045); } .btn.secondary:hover{ color:var(--text); border-color:rgba(255,255,255,.13); background:rgba(255,255,255,.065); } .btn.danger{ border-color:rgba(255,126,141,.22); background:linear-gradient(180deg, rgba(255,126,141,.18), rgba(255,126,141,.08)); } .btn.danger:hover{ border-color:rgba(255,126,141,.36); background:linear-gradient(180deg, rgba(255,126,141,.24), rgba(255,126,141,.12)); }
.main{ flex:1 1 auto; min-height:0; display:grid; grid-template-columns: 128px minmax(0,1fr); gap:10px; transition:opacity .14s ease, transform .14s ease, filter .14s ease; } body.menu-open .main{ opacity:0; pointer-events:none; transform:scale(.985); filter:blur(1px); }
.hero{ border-radius:16px; border:1px solid rgba(255,255,255,.08); background:linear-gradient(180deg, rgba(255,255,255,.055), rgba(255,255,255,.028)); box-shadow:inset 0 1px 0 rgba(255,255,255,.04); padding:12px 10px; display:flex; flex-direction:column; align-items:center; justify-content:center; min-height:0; }
.meter{ width:104px; aspect-ratio:1; border-radius:50%; position:relative; display:grid; place-items:center; background:conic-gradient(from 180deg, var(--good) 0deg, var(--accent2) 102deg, rgba(255,255,255,.10) 102deg 360deg); box-shadow:0 0 0 1px rgba(255,255,255,.06), inset 0 0 18px rgba(0,0,0,.25); flex:0 0 auto; } .meter::before{ content:""; position:absolute; inset:10px; border-radius:50%; background:linear-gradient(180deg, rgba(11,22,39,.98), rgba(6,14,24,.98)); box-shadow:inset 0 1px 0 rgba(255,255,255,.06); } .meter::after{ content:""; position:absolute; inset:0; border-radius:50%; background:radial-gradient(circle at 30% 24%, rgba(255,255,255,.18), rgba(255,255,255,0) 54%); pointer-events:none; } .meterInner{ position:relative; z-index:1; width:72px; text-align:center; display:flex; flex-direction:column; align-items:center; gap:2px; } .meterValue{ font-size:21px; line-height:1; font-weight:900; letter-spacing:.2px; } .meterLabel{ font-size:11px; color:var(--muted); line-height:1.2; } .meterMeta{ margin-top:9px; width:100%; display:flex; flex-direction:column; gap:4px; align-items:center; } .tiny{ width:100%; text-align:center; font-size:11px; line-height:1.35; color:var(--muted); word-break:break-word; }
.right{ min-width:0; min-height:0; display:flex; flex-direction:column; gap:8px; justify-content:flex-start; }
.summary{ display:flex; flex-direction:column; gap:7px; min-width:0; flex:1 1 auto; } .stat{ border-radius:14px; padding:9px 9px 8px; border:1px solid rgba(255,255,255,.07); background:linear-gradient(180deg, rgba(255,255,255,.045), rgba(255,255,255,.025)); min-width:0; } .stat .k{ font-size:11px; color:var(--muted); line-height:1.2; margin-bottom:4px; } .stat .v{ font-size:16px; font-weight:900; line-height:1.08; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; letter-spacing:.1px; } .stat .s{ margin-top:4px; font-size:10px; color:var(--muted2); line-height:1.2; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.foot{ display:flex; align-items:center; justify-content:space-between; gap:8px; font-size:10px; line-height:1.2; color:var(--muted2); padding:0 2px; min-width:0; } .foot strong{ color:var(--muted); font-weight:700; }
.toast{ position:absolute; left:50%; bottom:12px; transform:translateX(-50%) translateY(10px); opacity:0; pointer-events:none; padding:8px 11px; border-radius:999px; border:1px solid rgba(255,255,255,.10); background:rgba(7,13,23,.88); color:var(--text); font-size:11px; line-height:1.2; box-shadow:0 10px 26px rgba(0,0,0,.24); transition:opacity .16s ease, transform .16s ease; max-width:calc(100% - 24px); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; } .toast.show{ opacity:1; transform:translateX(-50%) translateY(0); }
.menuMask{ position:absolute; inset:0; background:rgba(2,7,14,.42); opacity:0; pointer-events:none; transition:opacity .16s ease; } .menuMask.show{ opacity:1; pointer-events:auto; } .menuPanel{ position:absolute; left:12px; right:12px; top:50px; bottom:12px; border-radius:18px; overflow:hidden; border:1px solid rgba(167,196,255,.18); background: radial-gradient(120% 120% at 0% 0%, rgba(120,173,255,.14) 0%, rgba(120,173,255,0) 42%), linear-gradient(180deg, rgba(11,22,39,.99), rgba(7,13,23,.99)); box-shadow:0 20px 50px rgba(0,0,0,.44), inset 0 1px 0 rgba(255,255,255,.04); transform:translateY(-8px) scale(.985); opacity:0; pointer-events:none; transition:opacity .16s ease, transform .16s ease; display:flex; flex-direction:column; min-height:0; } .menuMask.show .menuPanel{ opacity:1; pointer-events:auto; transform:translateY(0) scale(1); }
.menuHeader{ padding:12px; border-bottom:1px solid rgba(255,255,255,.07); display:flex; align-items:center; justify-content:space-between; gap:10px; flex:0 0 auto; } .tabs{ display:flex; gap:8px; flex-wrap:wrap; min-width:0; } .tab{ height:30px; padding:0 12px; border-radius:999px; border:1px solid rgba(255,255,255,.08); background:rgba(255,255,255,.04); color:var(--muted); font-size:12px; font-weight:700; cursor:pointer; transition:background .12s ease, border-color .12s ease, color .12s ease, transform .12s ease; } .tab:hover{ color:var(--text); background:rgba(255,255,255,.07); } .tab.active{ color:var(--text); border-color:rgba(120,173,255,.34); background:linear-gradient(180deg, rgba(120,173,255,.24), rgba(120,173,255,.12)); }
.menuBody{ flex:1 1 auto; min-height:0; overflow:auto; padding:12px; } .menuBody::-webkit-scrollbar, .list::-webkit-scrollbar, .debugText::-webkit-scrollbar{ width:8px; height:8px; } .menuBody::-webkit-scrollbar-track, .list::-webkit-scrollbar-track, .debugText::-webkit-scrollbar-track{ background:rgba(255,255,255,.03); border-radius:999px; } .menuBody::-webkit-scrollbar-thumb, .list::-webkit-scrollbar-thumb, .debugText::-webkit-scrollbar-thumb{ background:rgba(154,188,255,.22); border:1px solid rgba(255,255,255,.08); border-radius:999px; } .menuBody::-webkit-scrollbar-thumb:hover, .list::-webkit-scrollbar-thumb:hover, .debugText::-webkit-scrollbar-thumb:hover{ background:rgba(154,188,255,.32); }
.section{ display:none; flex-direction:column; gap:10px; min-height:0; } .section.active{display:flex;}
.form{ display:grid; gap:8px; padding:10px; border-radius:16px; background:linear-gradient(180deg, rgba(255,255,255,.04), rgba(255,255,255,.025)); border:1px solid rgba(255,255,255,.07); } .row2{ display:grid; grid-template-columns:1fr 1fr; gap:8px; } .field{ display:flex; flex-direction:column; gap:5px; min-width:0; } .label{ font-size:11px; color:var(--muted); line-height:1.2; } input{ width:100%; height:34px; border-radius:11px; border:1px solid rgba(255,255,255,.09); background:rgba(4,11,20,.36); color:var(--text); padding:0 10px; outline:none; font-size:12px; min-width:0; transition:border-color .12s ease, background .12s ease, box-shadow .12s ease; user-select:text; } input::placeholder{color:rgba(237,244,255,.34)} input:hover{ border-color:rgba(255,255,255,.14); background:rgba(4,11,20,.42); } input:focus{ border-color:rgba(120,173,255,.45); box-shadow:0 0 0 3px rgba(120,173,255,.12); background:rgba(4,11,20,.5); }
.buttonRow{ display:flex; flex-wrap:wrap; gap:8px; align-items:center; } .listWrap{ flex:1 1 auto; min-height:0; display:flex; flex-direction:column; gap:8px; padding:10px; border-radius:16px; background:linear-gradient(180deg, rgba(255,255,255,.035), rgba(255,255,255,.02)); border:1px solid rgba(255,255,255,.07); overflow:hidden; } .sectionTitle{ display:flex; align-items:center; justify-content:space-between; gap:8px; font-size:12px; font-weight:700; color:var(--muted); letter-spacing:.15px; } .pill{ padding:3px 8px; border-radius:999px; background:rgba(255,255,255,.05); border:1px solid rgba(255,255,255,.07); color:var(--muted); font-size:11px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; max-width:100%; } .sep{ height:1px; background:linear-gradient(90deg, rgba(255,255,255,0), rgba(255,255,255,.09), rgba(255,255,255,0)); } .list{ flex:1 1 auto; min-height:0; overflow:auto; padding-right:4px; } .item{ display:grid; grid-template-columns:1fr auto; gap:10px; align-items:center; padding:9px 10px; margin-bottom:8px; border-radius:13px; border:1px solid rgba(255,255,255,.07); background:rgba(255,255,255,.03); transition:background .12s ease, border-color .12s ease; } .item:hover{ background:rgba(255,255,255,.05); border-color:rgba(120,173,255,.18); } .item.active{ background:linear-gradient(180deg, rgba(120,173,255,.12), rgba(255,255,255,.035)); border-color:rgba(120,173,255,.28); box-shadow:inset 0 1px 0 rgba(255,255,255,.05); } .itemMain{ min-width:0; display:flex; flex-direction:column; gap:4px; } .itemTitle{ font-size:13px; font-weight:800; line-height:1.2; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; } .itemMeta{ display:flex; flex-wrap:wrap; gap:6px; min-width:0; } .itemActions{ display:flex; gap:6px; align-items:center; } .mini{ height:28px; padding:0 10px; border-radius:10px; font-size:11px; }
.debugBox{ padding:10px; border-radius:14px; border:1px solid rgba(255,255,255,.07); background:rgba(255,255,255,.03); display:flex; flex-direction:column; gap:8px; min-height:0; } .debugText{ max-height:170px; overflow:auto; font-size:11px; line-height:1.5; color:var(--muted); word-break:break-all; white-space:pre-wrap; }
.empty{ padding:14px 8px; text-align:center; color:var(--muted); font-size:12px; line-height:1.6; }
.yanm-drag{ -webkit-app-region:drag; app-region:drag; } .no-drag, button, input, .item, .tab{ -webkit-app-region:no-drag; app-region:no-drag; }
.resizeGrip{ position:absolute; right:7px; bottom:7px; width:16px; height:16px; border-radius:5px; background:linear-gradient(135deg, rgba(120,173,255,.24), rgba(119,240,255,.22)); border:1px solid rgba(255,255,255,.12); opacity:.62; pointer-events:none; }
@media (max-width: 390px){ .main{ grid-template-columns:1fr; } .hero{ flex-direction:row; justify-content:flex-start; gap:12px; } .meterMeta{ margin-top:0; align-items:flex-start; } .tiny{text-align:left} .summary{ grid-template-columns:1fr; } .row2{ grid-template-columns:1fr; } } </style></head><body> <div class="card"> <div class="shell"> <div class="topbar"> <div class="titleWrap"> <div class="badge"></div> <div class="titleBlock"> <div class="title yanm-drag" data-yanm-drag="true">鸡厂订阅信息</div> <div class="subtitle" id="subTitle">本地兜底已加载,正在同步宿主状态…</div> </div> </div> <div class="topActions"> <div class="statusPill" id="statusPill">准备中</div> <button class="btn secondary no-drag" id="btnMenu">管理</button> <button class="btn secondary no-drag" id="btnRefresh">刷新</button> </div> </div>
<div class="main"> <div class="hero"> <div class="meter" id="meter"> <div class="meterInner"> <div class="meterValue" id="meterValue">--%</div> <div class="meterLabel">剩余</div> </div> </div> <div class="meterMeta"> <div class="tiny" id="heroTitle">未选择订阅</div> <div class="tiny" id="heroMeta">请通过“管理”同步订阅与用量数据</div> </div> </div>
<div class="right"> <div class="summary"> <div class="stat"> <div class="k">剩余流量</div> <div class="v" id="statRest">0 B</div> <div class="s" id="statRestPct">--%</div> </div> <div class="stat"> <div class="k">累计使用</div> <div class="v" id="statUsed">0 B</div> <div class="s" id="statUsedPct">--%</div> </div> <div class="stat"> <div class="k">今日上传</div> <div class="v" id="statToday">0 B</div> <div class="s" id="statTodayPct">--%</div> </div> </div> </div> </div>
<div class="foot"> <div><strong>状态链路:</strong>内存 → localStorage → 宿主 state.set</div> <div id="footHint">http.request / state.get</div> </div> </div>
<div class="menuMask" id="menuMask"> <div class="menuPanel"> <div class="menuHeader"> <div class="tabs"> <button class="tab active no-drag" data-tab="edit">编辑订阅</button> <button class="tab no-drag" data-tab="list">订阅列表</button> <button class="tab no-drag" data-tab="debug">调试</button> </div> <button class="btn secondary no-drag" id="btnCloseMenu">关闭</button> </div>
<div class="menuBody"> <div class="section active" id="sec-edit"> <div class="form"> <div class="sectionTitle"> <span>编辑订阅</span> <span id="activeTag2" class="pill">未选中</span> </div>
<div class="row2"> <div class="field"> <div class="label">鸡厂名</div> <input id="inputTitle" type="text" placeholder="例如:默认订阅" autocomplete="off" /> </div> <div class="field"> <div class="label">自定义 UA</div> <input id="inputUA" type="text" placeholder="例如:Mozilla/5.0 ..." autocomplete="off" /> </div> </div>
<div class="field"> <div class="label">订阅地址</div> <input id="inputUrl" type="text" placeholder="粘贴订阅链接" autocomplete="off" /> </div>
<div class="buttonRow"> <button class="btn no-drag" id="btnSave">保存/更新</button> <button class="btn secondary no-drag" id="btnTest">测试订阅</button> <button class="btn secondary no-drag" id="btnAdd">新增空白项</button> <button class="btn danger no-drag" id="btnDelete">删除当前</button> </div> </div> </div>
<div class="section" id="sec-list"> <div class="listWrap"> <div class="sectionTitle"> <span>订阅列表</span> <span id="countTag" class="pill">0 项</span> </div> <div class="sep"></div> <div class="list" id="list"></div> </div> </div>
<div class="section" id="sec-debug"> <div class="debugBox"> <div class="sectionTitle"> <span>调试</span> <span id="debugTag" class="pill">等待请求</span> </div> <div class="buttonRow"> <button class="btn no-drag" id="btnCopyDebug">一键复制所有调试信息</button> <button class="btn secondary no-drag" id="btnClearDebug">清空调试</button> </div> <div class="debugText" id="debugText">未开始请求。</div> </div> </div> </div> </div> </div>
<div class="toast" id="toast"></div> <div class="resizeGrip no-drag" data-yanm-resize="true"></div> </div>
<script> (function () { const KEY = "yanm.subscription.panel.v2"; const DEFAULT_UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36";
const DEFAULT_STATE = { accounts: [ { id: "default-1", title: "默认订阅", url: "", ua: DEFAULT_UA } ], activeId: "default-1", metrics: { total: 0, used: 0, rest: 0, upload: 0, download: 0, today: 0 }, lastUpdate: 0, status: "本地兜底已加载", lastDebug: "" };
const $ = (id) => document.getElementById(id); const els = { body: document.body, meter: $("meter"), meterValue: $("meterValue"), heroTitle: $("heroTitle"), heroMeta: $("heroMeta"), statRest: $("statRest"), statUsed: $("statUsed"), statToday: $("statToday"), statRestPct: $("statRestPct"), statUsedPct: $("statUsedPct"), statTodayPct: $("statTodayPct"), subTitle: $("subTitle"), statusPill: $("statusPill"), footHint: $("footHint"), menuMask: $("menuMask"), toast: $("toast"), btnMenu: $("btnMenu"), btnRefresh: $("btnRefresh"), btnCloseMenu: $("btnCloseMenu"), btnSave: $("btnSave"), btnTest: $("btnTest"), btnAdd: $("btnAdd"), btnDelete: $("btnDelete"), btnCopyDebug: $("btnCopyDebug"), btnClearDebug: $("btnClearDebug"), inputTitle: $("inputTitle"), inputUA: $("inputUA"), inputUrl: $("inputUrl"), list: $("list"), activeTag2: $("activeTag2"), countTag: $("countTag"), debugTag: $("debugTag"), debugText: $("debugText"), secEdit: $("sec-edit"), secList: $("sec-list"), secDebug: $("sec-debug") };
let state = deepClone(DEFAULT_STATE); let activeTab = "edit"; let toastTimer = null; let requestSeq = 0; let saving = false;
function deepClone(v) { return JSON.parse(JSON.stringify(v)); }
function now() { return Date.now ? Date.now() : new Date().getTime(); }
function clamp(n, min, max) { return Math.max(min, Math.min(max, n)); }
function fmtBytes(n) { let num = Number(n) || 0; if (!isFinite(num) || num <= 0) return "0 B"; const units = ["B", "KB", "MB", "GB", "TB", "PB"]; let i = 0; while (num >= 1024 && i < units.length - 1) { num /= 1024; i++; } const dec = num >= 100 ? 0 : num >= 10 ? 1 : 2; return `${num.toFixed(dec)} ${units[i]}`; }
function setStatus(text) { els.statusPill.textContent = text; els.subTitle.textContent = text; }
function showToast(text, ms = 1700) { els.toast.textContent = text; els.toast.classList.add("show"); clearTimeout(toastTimer); toastTimer = setTimeout(() => els.toast.classList.remove("show"), ms); }
function setDebug(tag, text) { els.debugTag.textContent = tag || "调试"; els.debugText.textContent = text || ""; }
function persistLocal() { try { localStorage.setItem(KEY, JSON.stringify(state)); } catch (e) {} }
async function persistHost() { if (!(window.yanm && typeof window.yanm.invoke === "function")) return; try { await window.yanm.invoke("state.set", { key: KEY, value: JSON.stringify(state) }); } catch (e) {} }
function persist() { persistLocal(); if (!saving) { saving = true; Promise.resolve().then(() => persistHost()).finally(() => { saving = false; }); } }
function normalizeState(input) { const base = deepClone(DEFAULT_STATE); if (!input || typeof input !== "object") return base;
if (Array.isArray(input.accounts) && input.accounts.length) { base.accounts = input.accounts.map((item, idx) => ({ id: item && item.id ? String(item.id) : `acc-${idx + 1}-${now()}`, title: item && item.title != null ? String(item.title) : `订阅 ${idx + 1}`, url: item && item.url != null ? String(item.url) : "", ua: item && item.ua != null ? String(item.ua) : DEFAULT_UA })); }
if (typeof input.activeId === "string") base.activeId = input.activeId; if (input.metrics && typeof input.metrics === "object") { base.metrics = { total: Number(input.metrics.total) || 0, used: Number(input.metrics.used) || 0, rest: Number(input.metrics.rest) || 0, upload: Number(input.metrics.upload) || 0, download: Number(input.metrics.download) || 0, today: Number(input.metrics.today) || 0 }; } if (typeof input.lastUpdate === "number") base.lastUpdate = input.lastUpdate; if (typeof input.status === "string") base.status = input.status; if (typeof input.lastDebug === "string") base.lastDebug = input.lastDebug;
if (!base.accounts.length) base.accounts = deepClone(DEFAULT_STATE.accounts); if (!base.accounts.some(a => a.id === base.activeId)) base.activeId = base.accounts[0].id; return base; }
function activeAccount() { return state.accounts.find(a => a.id === state.activeId) || state.accounts[0] || null; }
function applyState(next, reason) { state = normalizeState(next); render(); persist(); if (reason) setStatus(reason); }
function readLocal() { try { const raw = localStorage.getItem(KEY); if (!raw) return null; return JSON.parse(raw); } catch (e) { return null; } }
async function readHost() { if (!(window.yanm && typeof window.yanm.invoke === "function")) return null; try { const res = await window.yanm.invoke("state.get", { key: KEY }); if (typeof res === "string") return JSON.parse(res || "{}"); if (res && typeof res === "object") { if (typeof res.value === "string") return JSON.parse(res.value || "{}"); if (res.value && typeof res.value === "object") return res.value; return res; } return null; } catch (e) { return null; } }
function updateFields() { const a = activeAccount(); els.inputTitle.value = a ? (a.title || "") : ""; els.inputUrl.value = a ? (a.url || "") : ""; els.inputUA.value = a ? (a.ua || DEFAULT_UA) : DEFAULT_UA; }
function renderMeter() { const m = state.metrics || DEFAULT_STATE.metrics; const total = Number(m.total) || 0; const rest = Number(m.rest) || 0; const used = Number(m.used) || 0; const today = Number(m.today) || 0;
const restPct = total > 0 ? clamp(Math.round((rest / total) * 100), 0, 100) : 0; const usedPct = total > 0 ? clamp(Math.round((used / total) * 100), 0, 100) : 0; const todayPct = total > 0 ? clamp(Math.round((today / total) * 100), 0, 100) : 0; const arc = restPct * 3.6;
els.meter.style.background = `conic-gradient(from 180deg, var(--good) 0deg, var(--accent2) ${arc}deg, rgba(255,255,255,.10) ${arc}deg 360deg)`; els.meterValue.textContent = total > 0 ? `${restPct}%` : "--%"; els.statRest.textContent = fmtBytes(rest); els.statUsed.textContent = fmtBytes(used); els.statToday.textContent = fmtBytes(today); els.statRestPct.textContent = total > 0 ? `${restPct}% of total` : "无总量"; els.statUsedPct.textContent = total > 0 ? `${usedPct}% of total` : "无总量"; els.statTodayPct.textContent = total > 0 ? `${todayPct}% of total` : "无总量"; }
function renderList() { const a = activeAccount(); els.countTag.textContent = `${state.accounts.length} 项`; els.activeTag2.textContent = a ? (a.title || "未命名") : "未选中";
if (!state.accounts.length) { els.list.innerHTML = `<div class="empty">暂无订阅项<br>点击“新增空白项”开始</div>`; return; }
els.list.innerHTML = state.accounts.map((item, idx) => { const active = item.id === state.activeId ? "active" : ""; const urlTip = item.url ? (item.url.length > 36 ? `${item.url.slice(0, 16)} … ${item.url.slice(-12)}` : item.url) : "未填写订阅地址"; const uaTip = item.ua ? (item.ua.length > 34 ? `${item.ua.slice(0, 34)}…` : item.ua) : "默认 UA"; return ` <div class="item ${active}" data-id="${escapeHtml(item.id)}"> <div class="itemMain"> <div class="itemTitle">${escapeHtml(item.title || `订阅 ${idx + 1}`)}</div> <div class="itemMeta"><span class="pill">${escapeHtml(urlTip)}</span></div> <div class="itemMeta"><span class="pill">${escapeHtml(uaTip)}</span></div> </div> <div class="itemActions"> <button class="btn mini secondary no-drag" data-act="use" data-id="${escapeHtml(item.id)}">选中</button> <button class="btn mini danger no-drag" data-act="del" data-id="${escapeHtml(item.id)}">删</button> </div> </div> `; }).join("");
[...els.list.querySelectorAll(".item")].forEach(item => { item.addEventListener("click", (ev) => { if (ev.target && ev.target.closest && ev.target.closest("button")) return; selectAccount(item.getAttribute("data-id")); }); });
[...els.list.querySelectorAll("button[data-act]")].forEach(btn => { btn.addEventListener("click", (ev) => { ev.stopPropagation(); const id = btn.getAttribute("data-id"); if (btn.getAttribute("data-act") === "use") selectAccount(id); if (btn.getAttribute("data-act") === "del") deleteAccount(id); }); }); }
function renderHeader() { const a = activeAccount(); els.heroTitle.textContent = a ? (a.title || "未命名订阅") : "未选择订阅"; els.heroMeta.textContent = a && a.url ? "可同步订阅与流量信息" : "请先填写订阅地址再执行测试或保存"; els.footHint.textContent = `最后更新:${state.lastUpdate ? new Date(state.lastUpdate).toLocaleString("zh-CN") : "未更新"}`; if (state.lastDebug) setDebug("最近响应", state.lastDebug); }
function render() { renderHeader(); renderMeter(); renderList(); updateFields(); }
function escapeHtml(str) { return String(str == null ? "" : str) .replace(/&/g, "&") .replace(/</g, "<") .replace(/>/g, ">") .replace(/"/g, """); }
function selectAccount(id) { const item = state.accounts.find(a => a.id === id); if (!item) return; state.activeId = item.id; render(); persist(); setStatus(`已选中:${item.title || "未命名"}`); showToast(`已选中 ${item.title || "未命名"}`); }
function newId() { return `acc-${now()}-${Math.random().toString(16).slice(2, 8)}`; }
function addBlankAccount() { const base = activeAccount() || DEFAULT_STATE.accounts[0]; const item = { id: newId(), title: "新订阅", url: "", ua: base ? (base.ua || DEFAULT_UA) : DEFAULT_UA }; state.accounts.unshift(item); state.activeId = item.id; render(); persist(); setStatus("已新增空白订阅项"); showToast("已新增空白项"); }
function upsertCurrent() { const title = String(els.inputTitle.value || "").trim(); const url = String(els.inputUrl.value || "").trim(); const ua = String(els.inputUA.value || "").trim() || DEFAULT_UA;
if (!title) { setStatus("请先填写机场名"); showToast("机场名不能为空"); return; } if (!url) { setStatus("请先填写订阅地址"); showToast("订阅地址不能为空"); return; }
let item = state.accounts.find(a => a.id === state.activeId); if (!item) { item = { id: newId(), title, url, ua }; state.accounts.unshift(item); state.activeId = item.id; } else { item.title = title; item.url = url; item.ua = ua; }
render(); persist(); setStatus(`已保存:${title}`); showToast("已保存"); }
function deleteAccount(id) { const target = state.accounts.find(a => a.id === id); if (!target) return; state.accounts = state.accounts.filter(a => a.id !== id); if (!state.accounts.length) { state.accounts = deepClone(DEFAULT_STATE.accounts); } if (!state.accounts.some(a => a.id === state.activeId)) { state.activeId = state.accounts[0].id; } render(); persist(); setStatus(`已删除:${target.title || "未命名"}`); showToast("已删除"); }
function buildHeaders(ua) { const v = ua || DEFAULT_UA; return { "User-Agent": v, "user-agent": v, "Accept": "*/*", "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8", "Cache-Control": "no-cache", "Pragma": "no-cache" }; }
function parseUserInfo(headerValue) { const s = String(headerValue || ""); const get = (name) => { const m = s.match(new RegExp(`${name}=([0-9]+)`, "i")); return m ? Number(m[1]) : 0; }; const upload = get("upload"); const download = get("download"); const total = get("total"); const used = upload + download; const rest = Math.max(total - used, 0); return { upload, download, total, used, rest }; }
function normalizeResponse(res) { if (!res) return null; if (typeof res === "string") return { ok: true, status: 200, text: res, headers: {} }; return res; }
function buildDebug(res, meta, parsed) { const lines = []; lines.push(`URL: ${meta.url}`); lines.push(`UA: ${meta.ua || "(default)"}`); lines.push(`Status: ${res && res.status != null ? res.status : "unknown"}`); lines.push(`ok: ${res && typeof res.ok !== "undefined" ? String(res.ok) : "unknown"}`); lines.push(`finalUrl: ${res && res.finalUrl ? res.finalUrl : "(none)"}`); if (res && res.contentType) lines.push(`contentType: ${res.contentType}`); if (res && res.truncated != null) lines.push(`truncated: ${String(res.truncated)}`); if (res && res.headers) { const keys = Object.keys(res.headers).slice(0, 12); if (keys.length) { lines.push("headers:"); keys.forEach(k => lines.push(` ${k}: ${String(res.headers[k])}`)); } } if (parsed) { lines.push(`upload: ${parsed.upload}`); lines.push(`download: ${parsed.download}`); lines.push(`total: ${parsed.total}`); lines.push(`used: ${parsed.used}`); lines.push(`rest: ${parsed.rest}`); } return lines.join("\n"); }
async function invokeHost(method, args) { if (!(window.yanm && typeof window.yanm.invoke === "function")) { throw new Error("host unavailable"); } return await window.yanm.invoke(method, args); }
async function fetchSubscription() { const seq = ++requestSeq; const a = activeAccount(); if (!a || !a.url) { const msg = "未填写订阅地址"; setStatus(msg); setDebug("空地址", msg); showToast("请先填写订阅地址"); return; }
const url = String(a.url || "").trim(); const ua = String(a.ua || "").trim() || DEFAULT_UA;
setStatus("正在拉取订阅信息…"); setDebug("请求中", `准备请求:${url}`); showToast("正在请求订阅");
try { const resRaw = await invokeHost("http.request", { url, method: "GET", headers: buildHeaders(ua), timeoutMs: 12000 });
if (seq !== requestSeq) return;
const res = normalizeResponse(resRaw); const headers = (res && res.headers) || {}; const userInfo = headers["subscription-userinfo"] || headers["Subscription-Userinfo"] || headers["subscription-userinfo".toLowerCase()] || ""; const parsed = parseUserInfo(userInfo);
if (res && res.ok === false) { const dbg = buildDebug(res, { url, ua }, parsed); state.lastDebug = dbg; setDebug("请求失败", dbg); renderHeader(); persist(); if (res.status === 404) { setStatus("HTTP 404:订阅链接无效"); showToast("404:请核对订阅链接"); return; } throw new Error(`HTTP ${res.status || "error"}`); }
if (!parsed.total) { const dbg = buildDebug(res, { url, ua }, parsed); state.lastDebug = dbg; setDebug("缺少头信息", dbg); renderHeader(); persist(); throw new Error("缺少 subscription-userinfo"); }
state.metrics = { upload: parsed.upload, download: parsed.download, total: parsed.total, used: parsed.used, rest: parsed.rest, today: parsed.upload }; state.lastUpdate = now(); state.status = `已更新:${a.title || "未命名"}`; state.lastDebug = buildDebug(res, { url, ua }, parsed);
render(); persist(); setStatus(`数据已更新:${a.title || "未命名"}`); setDebug("更新成功", state.lastDebug); showToast(`更新成功 · 剩余 ${fmtBytes(parsed.rest)}`); } catch (err) { const msg = err && err.message ? err.message : "未知错误"; const dbg = [ `URL: ${a.url || ""}`, `UA: ${a.ua || ""}`, `Error: ${msg}` ].join("\n"); state.lastDebug = dbg; setDebug("异常", dbg); setStatus(`更新失败:${msg}`); showToast("请求失败"); } }
function copyDebugAll() { const a = activeAccount(); const text = [ `状态: ${state.status || ""}`, `最后更新时间: ${state.lastUpdate ? new Date(state.lastUpdate).toLocaleString("zh-CN") : "未更新"}`, `活动订阅: ${a ? (a.title || "") : ""}`, `账户数量: ${state.accounts.length}`, `--- 当前调试信息 ---`, state.lastDebug || "暂无调试信息" ].join("\n");
const done = () => { showToast("调试信息已复制"); setStatus("已复制调试信息"); };
if (window.yanm && typeof window.yanm.invoke === "function") { window.yanm.invoke("clipboard.write", { text }) .then(done) .catch(async () => { try { if (navigator.clipboard && navigator.clipboard.writeText) { await navigator.clipboard.writeText(text); done(); } else { throw new Error("clipboard unavailable"); } } catch (e) { setStatus("复制失败"); showToast("复制失败"); } }); return; }
try { const ta = document.createElement("textarea"); ta.value = text; ta.style.position = "fixed"; ta.style.left = "-9999px"; document.body.appendChild(ta); ta.focus(); ta.select(); document.execCommand("copy"); document.body.removeChild(ta); done(); } catch (e) { setStatus("复制失败"); showToast("复制失败"); } }
function clearDebug() { state.lastDebug = "暂无调试信息"; setDebug("已清空", state.lastDebug); persist(); showToast("已清空调试"); }
function showMenu() { els.menuMask.classList.add("show"); els.body.classList.add("menu-open"); }
function hideMenu() { els.menuMask.classList.remove("show"); els.body.classList.remove("menu-open"); }
function setTab(name) { activeTab = name; ["edit", "list", "debug"].forEach(t => { const btn = document.querySelector(`.tab[data-tab="${t}"]`); const sec = $("sec-" + t); if (btn) btn.classList.toggle("active", t === name); if (sec) sec.classList.toggle("active", t === name); }); }
function bind() { els.btnMenu.addEventListener("click", showMenu); els.btnRefresh.addEventListener("click", fetchSubscription); els.btnCloseMenu.addEventListener("click", hideMenu); els.menuMask.addEventListener("click", (e) => { if (e.target === els.menuMask) hideMenu(); });
els.btnSave.addEventListener("click", upsertCurrent); els.btnTest.addEventListener("click", fetchSubscription); els.btnAdd.addEventListener("click", addBlankAccount); els.btnDelete.addEventListener("click", () => { const a = activeAccount(); if (a) deleteAccount(a.id); });
els.btnCopyDebug.addEventListener("click", copyDebugAll); els.btnClearDebug.addEventListener("click", clearDebug);
document.querySelectorAll(".tab").forEach(btn => { btn.addEventListener("click", () => setTab(btn.getAttribute("data-tab"))); });
[els.inputTitle, els.inputUrl, els.inputUA].forEach(input => { input.addEventListener("keydown", (e) => { if (e.key === "Enter") { e.preventDefault(); upsertCurrent(); } }); });
els.inputTitle.addEventListener("input", () => { const a = activeAccount(); if (!a) return; a.title = els.inputTitle.value; renderHeader(); renderList(); });
els.inputUrl.addEventListener("input", () => { const a = activeAccount(); if (!a) return; a.url = els.inputUrl.value; renderHeader(); renderList(); });
els.inputUA.addEventListener("input", () => { const a = activeAccount(); if (!a) return; a.ua = els.inputUA.value; renderList(); });
window.addEventListener("storage", (e) => { if (e.key !== KEY) return; try { if (e.newValue) { state = normalizeState(JSON.parse(e.newValue)); render(); } } catch (err) {} }); }
async function init() { try { const local = readLocal(); if (local) applyState(local, "本地状态已恢复"); else render();
const host = await readHost(); if (host) applyState(host, "宿主状态已同步"); else setStatus("宿主状态不可用,使用本地兜底");
const a = activeAccount(); if (a) { els.inputTitle.value = a.title || ""; els.inputUrl.value = a.url || ""; els.inputUA.value = a.ua || DEFAULT_UA; }
render();
if (a && a.url && String(a.url).trim()) { setTimeout(fetchSubscription, 240); } else { setDebug("等待输入", "未发现有效订阅地址,暂不发起请求。"); } } catch (e) { setStatus("初始化失败,继续使用本地状态"); render(); } }
function boot() { if (window.yanm && typeof window.yanm.invoke === "function") { bind(); init(); } else { setTimeout(boot, 180); } }
boot(); })(); </script></body></html>