WishMeLz

生活其实很有趣

Scriptable 小组件 - 搬瓦工

2026-03-25T07:18:22.png

/***********************
 * 固定配置(直接改这里)
 ***********************/
const baseUrl = "https://api.64clouds.com/v1";
const veid = "2012715";
const api_key = "private_X12fsdffeg345gdfg";
const vpsTitle = 'MEGABOX-PRO'

// ─────────────────────────────────────────────
// 工具函数
// ─────────────────────────────────────────────

function formatBytes(bytes, decimals = 1) {
  const n = Number(bytes || 0);
  if (n === 0) return "0 B";
  const k = 1024;
  const sizes = ["B", "KB", "MB", "GB", "TB"];
  const i = Math.floor(Math.log(n) / Math.log(k));
  return `${(n / Math.pow(k, i)).toFixed(decimals)} ${sizes[i]}`;
}

function clamp(v, min, max) {
  return Math.max(min, Math.min(max, v));
}

function timestampToDate(ts) {
  if (!ts) return null;
  const d = new Date(Number(ts) * 1000);
  return isNaN(d.getTime()) ? null : d;
}

function daysUntil(date) {
  if (!date) return null;
  return Math.ceil((date.getTime() - Date.now()) / 86400000);
}

// 根据使用率返回颜色
function ratioColor(ratio) {
  if (ratio >= 0.9) return new Color("#FF453A");
  if (ratio >= 0.75) return new Color("#FF9F0A");
  return new Color("#30D158");
}

// Light/Dark 动态颜色
function dc(light, dark) {
  return Color.dynamic(new Color(light), new Color(dark));
}

// ─────────────────────────────────────────────
// DrawContext 绘图工具
// ─────────────────────────────────────────────

function makeProgressBar(ratio, width, height, fillColor) {
  const ctx = new DrawContext();
  ctx.size = new Size(width, height);
  ctx.opaque = false;
  ctx.respectScreenScale = true;

  const r = height / 2;

  // 背景轨道
  const bgPath = new Path();
  bgPath.addRoundedRect(new Rect(0, 0, width, height), r, r);
  ctx.setFillColor(dc("#E5E5EA", "#3A3A3C"));
  ctx.addPath(bgPath);
  ctx.fillPath();

  // 填充
  const filled = Math.max(r * 2, width * clamp(ratio, 0, 1));
  const fgPath = new Path();
  fgPath.addRoundedRect(new Rect(0, 0, filled, height), r, r);
  ctx.setFillColor(fillColor || ratioColor(ratio));
  ctx.addPath(fgPath);
  ctx.fillPath();

  return ctx.getImage();
}

// ─────────────────────────────────────────────
// UI 组件辅助
// ─────────────────────────────────────────────

// 小徽章
function addBadge(stack, label, bgHex, fgHex = "#FFFFFF", alpha = 0.9) {
  const badge = stack.addStack();
  badge.backgroundColor = new Color(bgHex, alpha);
  badge.cornerRadius = 4;
  badge.setPadding(2, 5, 2, 5);
  const t = badge.addText(label);
  t.font = Font.boldSystemFont(8);
  t.textColor = new Color(fgHex);
}

// 分区标签
function addSectionTitle(parent, title) {
  const row = parent.addStack();
  row.centerAlignContent();
  const t = row.addText(title.toUpperCase());
  t.font = Font.boldSystemFont(9);
  t.textColor = dc("#9A9A9E", "#636366");
  row.addSpacer();
}

// 左色块 + 标签 + 右值
function addSpecRow(parent, label, value, dotColor) {
  const row = parent.addStack();
  row.centerAlignContent();

  const dot = row.addText("■");
  dot.font = Font.boldSystemFont(9);
  dot.textColor = dotColor;

  row.addSpacer(5);

  const lbl = row.addText(label);
  lbl.font = Font.systemFont(11);
  lbl.textColor = dc("#6C6C70", "#8E8E93");

  row.addSpacer();

  const val = row.addText(value);
  val.font = Font.boldSystemFont(11);
  val.textColor = dc("#1C1C1E", "#EBEBF5");
}

// ─────────────────────────────────────────────
// API 请求
// ─────────────────────────────────────────────

async function loadServiceInfo() {
  const url = `${baseUrl}/getServiceInfo?veid=${veid}&api_key=${api_key}`;
  const req = new Request(url);
  req.method = "GET";
  const json = await req.loadJSON();
  if (json.error && json.error !== 0) throw new Error(json.message || "API 错误");
  return json;
}

// ─────────────────────────────────────────────
// 渲染:小尺寸
// ─────────────────────────────────────────────

async function renderSmall(widget, data) {
  const multiplier = Number(data.monthly_data_multiplier || 1);
  const total = Number(data.plan_monthly_data || 0) * multiplier;
  const used = Number(data.data_counter || 0) * multiplier;
  const ratio = clamp(total > 0 ? used / total : 0, 0, 1);
  const resetDate = timestampToDate(data.data_next_reset);
  const daysLeft = daysUntil(resetDate);
  const ip = (data.ip_addresses || [])[0] || "N/A";
  const color = ratioColor(ratio);
  const isSuspended = !!data.suspended;

  const grad = new LinearGradient();
  grad.locations = [0, 1];
  grad.colors = [dc("#F2F2F7", "#1C1C1E"), dc("#FFFFFF", "#111113")];
  widget.backgroundGradient = grad;
  widget.setPadding(12, 14, 12, 14);

  // 顶部:状态点 + 主机名
  const topRow = widget.addStack();
  topRow.centerAlignContent();

  const statusDot = topRow.addText("●");
  statusDot.font = Font.boldSystemFont(10);
  statusDot.textColor = isSuspended ? new Color("#FF453A") : new Color("#30D158");

  topRow.addSpacer(5);

  const hostT = topRow.addText((vpsTitle || "VPS").split(".")[0]);
  hostT.font = Font.boldSystemFont(12);
  hostT.textColor = dc("#000000", "#FFFFFF");
  hostT.lineLimit = 1;
  hostT.minimumScaleFactor = 0.6;

  widget.addSpacer(6);

  // 大百分比
  const pctT = widget.addText(`${Math.round(ratio * 100)}%`);
  pctT.font = Font.boldSystemFont(38);
  pctT.textColor = color;

  widget.addSpacer(4);

  // 进度条
  widget.addImage(makeProgressBar(ratio, 130, 7, color));

  widget.addSpacer(5);

  // 流量数值
  const trafficT = widget.addText(`${formatBytes(used)} / ${formatBytes(total)}`);
  trafficT.font = Font.systemFont(9);
  trafficT.textColor = dc("#6C6C70", "#8E8E93");
  trafficT.minimumScaleFactor = 0.7;

  widget.addSpacer();

  // 底部:IP + 剩余天数
  const botRow = widget.addStack();
  botRow.centerAlignContent();

  const ipT = botRow.addText(ip);
  ipT.font = new Font("Menlo", 9);
  ipT.textColor = dc("#6C6C70", "#8E8E93");

  botRow.addSpacer();

  if (daysLeft !== null) {
    const dT = botRow.addText(`${daysLeft}d`);
    dT.font = Font.systemFont(9);
    dT.textColor = daysLeft <= 3 ? new Color("#FF9F0A") : dc("#6C6C70", "#8E8E93");
  }
}

// ─────────────────────────────────────────────
// 渲染:中号
// ─────────────────────────────────────────────

async function renderMedium(widget, data) {
  const multiplier = Number(data.monthly_data_multiplier || 1);
  const total = Number(data.plan_monthly_data || 0) * multiplier;
  const used = Number(data.data_counter || 0) * multiplier;
  const ratio = clamp(total > 0 ? used / total : 0, 0, 1);
  const resetDate = timestampToDate(data.data_next_reset);
  const daysLeft = daysUntil(resetDate);
  const ip = (data.ip_addresses || [])[0] || "N/A";
  const isSuspended = !!data.suspended;
  const color = ratioColor(ratio);
  const vmType = (data.vm_type || "KVM").toUpperCase();
  const osRaw = data.os || "";
  const osShort = osRaw.split("-").slice(0, 2).join(" ");

  const grad = new LinearGradient();
  grad.locations = [0, 1];
  grad.colors = [dc("#F7F7F7", "#1C1C1E"), dc("#FFFFFF", "#111113")];
  widget.backgroundGradient = grad;
  widget.setPadding(14, 16, 14, 16);

  // ── 标题行 ──
  const topRow = widget.addStack();
  topRow.centerAlignContent();

  const statusDot = topRow.addText("●");
  statusDot.font = Font.boldSystemFont(11);
  statusDot.textColor = isSuspended ? new Color("#FF453A") : new Color("#30D158");
  topRow.addSpacer(6);

  const hostT = topRow.addText(vpsTitle || "VPS");
  hostT.font = Font.boldSystemFont(15);
  hostT.textColor = dc("#000000", "#FFFFFF");
  hostT.lineLimit = 1;
  hostT.minimumScaleFactor = 0.65;
  topRow.addSpacer();

  addBadge(topRow, vmType, vmType === "KVM" ? "#0A84FF" : "#9B59B6");

  if (data.location_ipv6_ready) {
    topRow.addSpacer(4);
    addBadge(topRow, "IPv6", "#30D158");
  }
  if (isSuspended) {
    topRow.addSpacer(4);
    addBadge(topRow, "暂停", "#FF453A");
  }

  widget.addSpacer(3);

  // ── 副信息行 ──
  const subRow = widget.addStack();
  subRow.centerAlignContent();

  const locT = subRow.addText(`📍 ${data.node_location || "Unknown"}`);
  locT.font = Font.systemFont(10);
  locT.textColor = dc("#6C6C70", "#8E8E93");

  subRow.addSpacer(8);

  const planT = subRow.addText(data.plan || "");
  planT.font = Font.systemFont(10);
  planT.textColor = dc("#6C6C70", "#8E8E93");
  planT.lineLimit = 1;
  planT.minimumScaleFactor = 0.7;

  widget.addSpacer(10);

  // ── 流量主区域 ──
  const mainRow = widget.addStack();
  mainRow.centerAlignContent();

  // 左:大百分比
  const leftCol = mainRow.addStack();
  leftCol.layoutVertically();

  const pctT = leftCol.addText(`${Math.round(ratio * 100)}%`);
  pctT.font = Font.boldSystemFont(40);
  pctT.textColor = color;
  pctT.minimumScaleFactor = 0.7;

  const pctLabel = leftCol.addText("月流量使用");
  pctLabel.font = Font.systemFont(9);
  pctLabel.textColor = dc("#9A9A9E", "#636366");

  mainRow.addSpacer();

  // 右:详细数值
  const rightCol = mainRow.addStack();
  rightCol.layoutVertically();

  const usedT = rightCol.addText(formatBytes(used));
  usedT.font = Font.boldSystemFont(14);
  usedT.textColor = dc("#1C1C1E", "#EBEBF5");
  usedT.rightAlignText();

  const totalT = rightCol.addText(`共 ${formatBytes(total)}`);
  totalT.font = Font.systemFont(10);
  totalT.textColor = dc("#6C6C70", "#8E8E93");
  totalT.rightAlignText();

  rightCol.addSpacer(6);

  const resetT = rightCol.addText(
    daysLeft !== null ? `${daysLeft} 天后重置` : "—"
  );
  resetT.font = Font.systemFont(10);
  resetT.textColor =
    daysLeft !== null && daysLeft <= 3
      ? new Color("#FF9F0A")
      : dc("#6C6C70", "#8E8E93");
  resetT.rightAlignText();

  widget.addSpacer(8);

  // ── 进度条 ──
  widget.addImage(makeProgressBar(ratio, 290, 8, color));

  widget.addSpacer(10);

  // ── 底部行:IP + OS ──
  const botRow = widget.addStack();
  botRow.centerAlignContent();

  const ipT = botRow.addText(ip);
  ipT.font = new Font("Menlo", 10);
  ipT.textColor = dc("#3C3C43", "#EBEBF5");

  botRow.addSpacer();

  const osT = botRow.addText(osShort);
  osT.font = Font.systemFont(10);
  osT.textColor = dc("#6C6C70", "#8E8E93");
  osT.lineLimit = 1;
  osT.minimumScaleFactor = 0.7;
}

// ─────────────────────────────────────────────
// 渲染:大尺寸
// ─────────────────────────────────────────────

async function renderLarge(widget, data) {
  const multiplier = Number(data.monthly_data_multiplier || 1);
  const total = Number(data.plan_monthly_data || 0) * multiplier;
  const used = Number(data.data_counter || 0) * multiplier;
  const ratio = clamp(total > 0 ? used / total : 0, 0, 1);
  const resetDate = timestampToDate(data.data_next_reset);
  const daysLeft = daysUntil(resetDate);
  const ip = (data.ip_addresses || [])[0] || "N/A";
  const isSuspended = !!data.suspended;
  const color = ratioColor(ratio);
  const vmType = (data.vm_type || "KVM").toUpperCase();
  const osRaw = data.os || "";
  const osShort = osRaw.split("-").slice(0, 2).join(" ");

  const ram = Number(data.plan_ram || 0);
  const disk = Number(data.plan_disk || 0);
  const swap = Number(data.plan_swap || 0);
  const abusePoints = Number(data.total_abuse_points || 0);
  const maxAbuse = Number(data.max_abuse_points || 100);
  const abuseRatio = clamp(maxAbuse > 0 ? abusePoints / maxAbuse : 0, 0, 1);
  const suspCount = Number(data.suspension_count || 0);
  const policyViolation = !!data.policy_violation;

  const grad = new LinearGradient();
  grad.locations = [0, 1];
  grad.colors = [dc("#F7F7F7", "#1C1C1E"), dc("#FFFFFF", "#111113")];
  widget.backgroundGradient = grad;
  widget.setPadding(16, 16, 16, 16);

  // ── 标题行 ──
  const topRow = widget.addStack();
  topRow.centerAlignContent();

  const statusDot = topRow.addText("●");
  statusDot.font = Font.boldSystemFont(12);
  statusDot.textColor = isSuspended ? new Color("#FF453A") : new Color("#30D158");
  topRow.addSpacer(6);

  const hostT = topRow.addText(vpsTitle || "VPS");
  hostT.font = Font.boldSystemFont(16);
  hostT.textColor = dc("#000000", "#FFFFFF");
  hostT.lineLimit = 1;
  hostT.minimumScaleFactor = 0.65;
  topRow.addSpacer();

  addBadge(topRow, vmType, vmType === "KVM" ? "#0A84FF" : "#9B59B6");
  if (data.location_ipv6_ready) {
    topRow.addSpacer(4);
    addBadge(topRow, "IPv6", "#30D158");
  }
  if (isSuspended) {
    topRow.addSpacer(4);
    addBadge(topRow, "已暂停", "#FF453A");
  }
  if (policyViolation) {
    topRow.addSpacer(4);
    addBadge(topRow, "违规", "#FF9F0A", "#1C1C1E");
  }

  widget.addSpacer(3);

  // ── 副信息 ──
  const infoRow = widget.addStack();
  const infoT = infoRow.addText(
    `📍 ${data.node_location || "Unknown"}  ·  ${data.plan || ""}  ·  ${osShort}`
  );
  infoT.font = Font.systemFont(10);
  infoT.textColor = dc("#6C6C70", "#8E8E93");
  infoT.lineLimit = 1;
  infoT.minimumScaleFactor = 0.7;

  widget.addSpacer(12);

  // ══ 月流量 ══
  addSectionTitle(widget, "月流量");
  widget.addSpacer(5);

  const trafficRow = widget.addStack();
  trafficRow.centerAlignContent();

  const pctT = trafficRow.addText(`${Math.round(ratio * 100)}%`);
  pctT.font = Font.boldSystemFont(34);
  pctT.textColor = color;
  pctT.minimumScaleFactor = 0.7;

  trafficRow.addSpacer(14);

  const trafficDetails = trafficRow.addStack();
  trafficDetails.layoutVertically();

  const usedT = trafficDetails.addText(`已用  ${formatBytes(used)}`);
  usedT.font = Font.boldSystemFont(12);
  usedT.textColor = dc("#1C1C1E", "#EBEBF5");

  const totalT = trafficDetails.addText(`共    ${formatBytes(total)}`);
  totalT.font = Font.systemFont(11);
  totalT.textColor = dc("#6C6C70", "#8E8E93");

  trafficDetails.addSpacer(3);

  const resetT = trafficDetails.addText(
    daysLeft !== null ? `⏱ ${daysLeft} 天后重置` : "⏱ —"
  );
  resetT.font = Font.systemFont(10);
  resetT.textColor =
    daysLeft !== null && daysLeft <= 3
      ? new Color("#FF9F0A")
      : dc("#6C6C70", "#8E8E93");

  widget.addSpacer(6);
  widget.addImage(makeProgressBar(ratio, 292, 8, color));

  widget.addSpacer(14);

  // ══ 套餐规格 ══
  addSectionTitle(widget, "套餐规格");
  widget.addSpacer(6);
  addSpecRow(widget, "RAM", formatBytes(ram, 0), new Color("#0A84FF"));
  widget.addSpacer(5);
  addSpecRow(widget, "磁盘", formatBytes(disk, 0), new Color("#30D158"));
  widget.addSpacer(5);
  addSpecRow(widget, "SWAP", formatBytes(swap, 0), new Color("#FF9F0A"));

  widget.addSpacer(14);

  // ══ 安全状态 ══
  addSectionTitle(widget, "安全状态");
  widget.addSpacer(5);

  const abuseRow = widget.addStack();
  abuseRow.centerAlignContent();

  const abuseLabel = abuseRow.addText("滥用积分");
  abuseLabel.font = Font.systemFont(11);
  abuseLabel.textColor = dc("#6C6C70", "#8E8E93");
  abuseRow.addSpacer();

  const abuseVal = abuseRow.addText(`${abusePoints} / ${maxAbuse}`);
  abuseVal.font = Font.boldSystemFont(11);
  abuseVal.textColor =
    abuseRatio >= 0.75 ? new Color("#FF453A") : dc("#1C1C1E", "#EBEBF5");

  widget.addSpacer(4);
  widget.addImage(makeProgressBar(abuseRatio, 292, 5, ratioColor(abuseRatio)));

  widget.addSpacer(12);

  // ══ 底部 ══
  const botRow = widget.addStack();
  botRow.centerAlignContent();

  const ipT = botRow.addText(ip);
  ipT.font = new Font("Menlo", 10);
  ipT.textColor = dc("#3C3C43", "#EBEBF5");

  botRow.addSpacer();

  if (suspCount > 0) {
    const suspT = botRow.addText(`暂停 ${suspCount} 次`);
    suspT.font = Font.systemFont(10);
    suspT.textColor = new Color("#FF9F0A");
    botRow.addSpacer(8);
  }

  const ipCountText = botRow.addText(
    `${(data.ip_addresses || []).length} IPs`
  );
  ipCountText.font = Font.systemFont(10);
  ipCountText.textColor = dc("#6C6C70", "#8E8E93");
}

// ─────────────────────────────────────────────
// 主入口
// ─────────────────────────────────────────────

async function main() {
  const widget = new ListWidget();

  try {
    const data = await loadServiceInfo();
    const family = config.widgetFamily;

    if (family === "small") {
      await renderSmall(widget, data);
    } else if (family === "large") {
      await renderLarge(widget, data);
    } else {
      await renderMedium(widget, data);
    }
  } catch (e) {
    widget.backgroundColor = new Color("#1C1C1E");
    widget.setPadding(16, 16, 16, 16);
    const title = widget.addText("⚠️ 加载失败");
    title.font = Font.boldSystemFont(14);
    title.textColor = new Color("#FF453A");
    widget.addSpacer(6);
    const msg = widget.addText(e.message || String(e));
    msg.font = Font.systemFont(10);
    msg.textColor = new Color("#8E8E93");
    msg.lineLimit = 3;
  }

  Script.setWidget(widget);
  Script.complete();
}

await main();