logo NodeSeekbeta

dmit 小组件分享

早上看见大佬分享的 scriptable dmit小组件
https://www.nodeseek.com/post-661149-1
优化了一下,多加了一些信息和小功能,需要可自取,因为我只有一台设备,单设备使用是没问题的,多设备不保证哈~~


更新 发现长时间运行会被 cf 风控,今天摸鱼想想办法 xhj001
iiHs83sjs0rcDoeP9yLG8hYSfuUa1IPP.webp
OKcdiFHzdi3gTCWDKPy0ftrCiOLn651A.webp
73ZaEK1pmLaYwZETSpl1T9A5UrMpXVF5.webp

const ScriptName = "DMIT";
const COOKIE_NAME = `${ScriptName}_WHMCSlogin_auth_tk`;
const PRODUCTS_CACHE_KEY = `${ScriptName}_products_cache`;
const CANVAS_SIZE = 50;
const RADIUS = 20;
const LINE_WIDTH = 4;
const productsIndex = args.widgetParameter ? parseInt(args.widgetParameter) - 1 : 0;
function getCookie() {
    if (Keychain.contains(COOKIE_NAME)) {
        return Keychain.get(COOKIE_NAME);
    }
    return null;
}
function getCachedProducts() {
    if (Keychain.contains(PRODUCTS_CACHE_KEY)) {
        try {
            const cached = JSON.parse(Keychain.get(PRODUCTS_CACHE_KEY));
            if (cached.data && cached.data.length > 0) return cached.data;
        } catch (e) {}
    }
    return null;
}
function setCachedProducts(products) {
    Keychain.set(PRODUCTS_CACHE_KEY, JSON.stringify({ data: products }));
}
function formatMB(mb) {
    if (mb >= 1024) return `${(mb / 1024).toFixed(1)} GB`;
    return `${mb.toFixed(1)} MB`;
}
function formatUptime(seconds) {
    const days = Math.floor(seconds / 86400);
    const hours = Math.floor((seconds % 86400) / 3600);
    const mins = Math.floor((seconds % 3600) / 60);
    return { days, hours, mins };
}
const BROWSER_HEADERS = {
    "User-Agent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1",
    "Accept": "application/json, text/html, */*",
    "Accept-Language": "en-US,en;q=0.9,zh-CN;q=0.8",
    "Referer": "https://www.dmit.io/clientarea.php",
    "X-Requested-With": "XMLHttpRequest",
};
async function safeLoadJSON(request) {
    request.headers = { ...BROWSER_HEADERS, ...request.headers };
    const text = await request.loadString();
    try {
        return JSON.parse(text);
    } catch (e) {
        console.log(`[DMIT] CF blocked: ${request.url.split("subaction=")[1] || "unknown"}`);
        throw new Error("服务器返回了非 JSON 数据,可能被 Cloudflare 拦截");
    }
}
async function loadProductDetailStandard(id) {
    const resp = new Request(`https://www.dmit.io/clientarea.php?action=productdetails&id=${id}&json=1&pure=1&page=standard&subaction=whmcsdetail`);
    resp.method = "GET";
    const cookie = getCookie();
    if (!cookie) throw new Error("未找到 Cookie");
    resp.headers = { "Cookie": cookie };
    const result = await safeLoadJSON(resp);
    if (result.result === "success" && result.success) {
        return result.success;
    }
    throw new Error(result.error || "未知错误");
}
async function loadProducts() {
    try {
        const reqp = new Request("https://www.dmit.io/clientarea.php?action=services");
        reqp.method = "GET";
        const cookie = getCookie();
        if (!cookie) {
            throw new Error("未找到 Cookie");
        }
        reqp.headers = { ...BROWSER_HEADERS, "Cookie": cookie };
        const htmlContent = await reqp.loadString();
        const products = parseProductsFromHtml(htmlContent);
        if (products.length > 0) {
            setCachedProducts(products);
            return products;
        }
        return getCachedProducts() || [];
    }
    catch (error) {
        console.error(error);
        return getCachedProducts() || [];
    }
}
async function loadAllGraphData(id) {
    try {
        const resp = new Request(`https://www.dmit.io/clientarea.php?action=productdetails&id=${id}&json=1&pure=1&page=graph&subaction=charts`);
        resp.method = "POST";
        resp.body = "data%5Btimeframe%5D=hour";
        const cookie = getCookie();
        if (!cookie) throw new Error("未找到 Cookie");
        resp.headers = { "Cookie": cookie };
        const result = await safeLoadJSON(resp);
        if (result.result === "success" && result.success) return result.success;
        return {};
    } catch (error) {
        console.error(error);
        return {};
    }
}
async function loadVMDetails(id) {
    try {
        const resp = new Request(`https://www.dmit.io/clientarea.php?action=productdetails&id=${id}&json=1&pure=1&page=home&subaction=detailsVM`);
        resp.method = "GET";
        const cookie = getCookie();
        if (!cookie) throw new Error("未找到 Cookie");
        resp.headers = { "Cookie": cookie };
        const result = await safeLoadJSON(resp);
        if (result.result === "success" && result.success) return result.success;
        return null;
    } catch (error) {
        console.error(error);
        return null;
    }
}
function applyPrimaryTextColor(text) {
    text.textColor = Color.dynamic(Color.black(), Color.white());
}
function applySecondaryTextColor(text) {
    text.textColor = Color.dynamic(Color.gray(), Color.lightGray());
}
function pickProductWithFallback(products, preferredIndex) {
    if (products.length === 0) {
        return null;
    }
    const startIndex = Math.min(Math.max(preferredIndex, 0), products.length - 1);
    for (let i = startIndex; i >= 0; i--) {
        const product = products[i];
        if (product) {
            return product;
        }
    }
    for (let i = startIndex + 1; i < products.length; i++) {
        const product = products[i];
        if (product) {
            return product;
        }
    }
    return null;
}
function showWidgetErrorAndFinish(widget, message) {
    const errorText = widget.addText(message);
    errorText.textColor = Color.red();
    errorText.font = Font.semiboldSystemFont(14);
    Script.setWidget(widget);
    Script.complete();
}
async function handleSettingsMenu() {
    const setting = new Alert();
    setting.title = "设置";
    setting.message = "配置DMIT小组件";
    setting.addAction("设置 Cookie");
    setting.addDestructiveAction("删除 Cookie");
    setting.addAction("取消");
    const response = await setting.present();
    if (response === 0) {
        const cookieInput = new Alert();
        cookieInput.title = "输入 Cookie";
        cookieInput.message = "请输入您的 cookie 值:";
        cookieInput.addTextField("Cookie");
        cookieInput.addAction("保存");
        cookieInput.addAction("取消");
        const cookieResponse = await cookieInput.present();
        if (cookieResponse === 0) {
            const cookieValue = (cookieInput.textFieldValue(0) ?? "").trim();
            if (!cookieValue) {
                const invalidAlert = new Alert();
                invalidAlert.title = "Cookie 输入有误";
                invalidAlert.message = "Cookie 不能为空,请重新输入。";
                invalidAlert.addAction("好的");
                await invalidAlert.present();
            }
            else {
                Keychain.set(COOKIE_NAME, cookieValue);
                const confirmAlert = new Alert();
                confirmAlert.title = "保存成功";
                confirmAlert.message = "您的 cookie 已成功保存。";
                confirmAlert.addAction("好的");
                await confirmAlert.present();
            }
        }
        return true;
    }
    if (response === 1) {
        const confirmDelete = new Alert();
        confirmDelete.title = "确认删除";
        confirmDelete.message = "您确定要删除已保存的 Cookie 吗?";
        confirmDelete.addDestructiveAction("删除");
        confirmDelete.addAction("取消");
        const deleteResponse = await confirmDelete.present();
        if (deleteResponse === 0 && Keychain.contains(COOKIE_NAME)) {
            Keychain.remove(COOKIE_NAME);
            const deletedAlert = new Alert();
            deletedAlert.title = "删除成功";
            deletedAlert.message = "已成功删除保存的 Cookie。";
            deletedAlert.addAction("好的");
            await deletedAlert.present();
        }
        return true;
    }
    return false;
}
function parseProductsFromHtml(htmlContent) {
    const products = [];
    const cleanText = (value) => {
        return value
            .replace(/<[^>]*>/g, " ")
            .replace(/&nbsp;/g, " ")
            .replace(/\s+/g, " ")
            .trim();
    };
    const rowRegex = /<li[^>]*class="[^"]*\bproducts-item\b[^"]*"[^>]*products_id="(\d+)"[^>]*>[\s\S]*?<\/li>/g;
    let rowMatch;
    while ((rowMatch = rowRegex.exec(htmlContent)) !== null) {
        const rowHtml = rowMatch[0];
        const id = parseInt(rowMatch[1], 10);
        const nameMatch = rowHtml.match(/<div class="products-title-text">([\s\S]*?)<\/div>/);
        const name = nameMatch ? cleanText(nameMatch[1]) : "";
        const domainMatch = rowHtml.match(/<div class="products-domain">([\s\S]*?)<\/div>/);
        let code = "";
        if (domainMatch) {
            const domainText = cleanText(domainMatch[1]);
            code = domainText.split(" - ")[0].trim();
        }
        if (!code) {
            const fallbackCodeMatch = rowHtml.match(/products-name="([^"]+)"/);
            code = fallbackCodeMatch ? fallbackCodeMatch[1].trim() : "";
        }
        if (!name || !code || Number.isNaN(id)) {
            continue;
        }
        products.push({ id, name, code });
    }
    return products;
}
function gradientColorByRatio(ratio) {
    const t = Math.max(0, Math.min(1, ratio));
    const start = { r: 46, g: 204, b: 113 };
    const end = { r: 231, g: 76, b: 60 };
    const r = Math.round(start.r + (end.r - start.r) * t);
    const g = Math.round(start.g + (end.g - start.g) * t);
    const b = Math.round(start.b + (end.b - start.b) * t);
    return new Color(`#${r.toString(16).padStart(2, "0")}${g.toString(16).padStart(2, "0")}${b.toString(16).padStart(2, "0")}`);
}
function drawCircleProgress(progress) {
    const clampedProgress = Math.max(0, Math.min(1, progress));
    const context = new DrawContext();
    context.size = new Size(CANVAS_SIZE, CANVAS_SIZE);
    context.opaque = false;
    context.respectScreenScale = true;
    const center = new Point(CANVAS_SIZE / 2, CANVAS_SIZE / 2);
    context.setStrokeColor(new Color("#222"));
    context.setLineWidth(LINE_WIDTH);
    context.strokeEllipse(new Rect(center.x - RADIUS, center.y - RADIUS, RADIUS * 2, RADIUS * 2));
    const totalDegrees = 360 * clampedProgress;
    const stepDegrees = 3.6;
    for (let i = 0; i < totalDegrees; i += stepDegrees) {
        const ratio = totalDegrees === 0 ? 0 : i / totalDegrees;
        context.setFillColor(gradientColorByRatio(ratio));
        const angle = (i - 90) * (Math.PI / 180);
        const x = center.x + RADIUS * Math.cos(angle);
        const y = center.y + RADIUS * Math.sin(angle);
        context.fillEllipse(new Rect(x - LINE_WIDTH / 2, y - LINE_WIDTH / 2, LINE_WIDTH, LINE_WIDTH));
    }
    const percentText = `${Math.round(clampedProgress * 100)}%`;
    context.setTextColor(new Color("#FFFFFF"));
    context.setFont(Font.semiboldSystemFont(10));
    context.setTextAlignedCenter();
    context.drawTextInRect(percentText, new Rect(0, 18, CANVAS_SIZE, 14));
    return context.getImage();
}
function createProgressBarImage(total, current, width, height, fillColor, backgroundColor) {
    const context = new DrawContext();
    context.size = new Size(width, height);
    context.opaque = false;
    context.respectScreenScale = true;
    const bgPath = new Path();
    bgPath.addRoundedRect(new Rect(0, 0, width, height), height / 2, height / 2);
    context.setFillColor(backgroundColor);
    context.addPath(bgPath);
    context.fillPath();
    const progressWidth = total > 0 ? Math.floor(width * Math.min(current / total, 1)) : 0;
    const fillPath = new Path();
    fillPath.addRoundedRect(new Rect(0, 0, progressWidth, height), height / 2, height / 2);
    context.setFillColor(fillColor);
    context.addPath(fillPath);
    context.fillPath();
    return context.getImage();
}
function createLineChartImage(data, width, height, lineColor = new Color("#1F6FD4"), secondaryLineColor = new Color("#16A085")) {
    const context = new DrawContext();
    context.size = new Size(width, height);
    context.opaque = false;
    context.respectScreenScale = true;
    const seriesList = Array.isArray(data[0])
        ? data.filter((series) => series.length > 0)
        : [data];
    if (seriesList.length === 0) {
        return context.getImage();
    }
    const padding = 4;
    const chartWidth = Math.max(1, width - padding * 2);
    const chartHeight = Math.max(1, height - padding * 2);
    const mergedData = seriesList.flat();
    const minValue = Math.min(...mergedData);
    const maxValue = Math.max(...mergedData);
    const range = Math.max(1e-6, maxValue - minValue);
    const getPoints = (series) => {
        return series.map((value, index) => {
            const x = series.length === 1
                ? padding + chartWidth / 2
                : padding + (index / (series.length - 1)) * chartWidth;
            const normalized = (value - minValue) / range;
            const y = padding + chartHeight * (1 - normalized);
            return new Point(x, y);
        });
    };
    const drawSeries = (points, color, fillAlpha) => {
        if (points.length === 0) {
            return;
        }
        const areaPath = new Path();
        areaPath.move(new Point(points[0].x, height - padding));
        for (const point of points) {
            areaPath.addLine(point);
        }
        areaPath.addLine(new Point(points[points.length - 1].x, height - padding));
        areaPath.closeSubpath();
        context.setFillColor(new Color(color.hex, fillAlpha));
        context.addPath(areaPath);
        context.fillPath();
        if (points.length === 1) {
            context.setFillColor(color);
            context.fillEllipse(new Rect(points[0].x - 1.5, points[0].y - 1.5, 3, 3));
            return;
        }
        const linePath = new Path();
        linePath.move(points[0]);
        for (let i = 1; i < points.length; i++) {
            const mid = new Point((points[i - 1].x + points[i].x) / 2, (points[i - 1].y + points[i].y) / 2);
            linePath.addQuadCurve(mid, points[i - 1]);
        }
        linePath.addLine(points[points.length - 1]);
        context.setStrokeColor(color);
        context.setLineWidth(1.6);
        context.addPath(linePath);
        context.strokePath();
    };
    drawSeries(getPoints(seriesList[0]), lineColor, 0.12);
    if (seriesList.length > 1) {
        drawSeries(getPoints(seriesList[1]), secondaryLineColor, 0.08);
    }
    return context.getImage();
}
async function renderAccessoryCircular(widget, product) {
    const productDetails = await loadProductDetailStandard(product.id.toString());
    const progress = productDetails.bwusage / productDetails.bwlimit;
    const chartImage = drawCircleProgress(progress);
    widget.addImage(chartImage);
    Script.setWidget(widget);
}
async function renderAccessoryInline(widget, product) {
    const productDetails = await loadProductDetailStandard(product.id.toString());
    const progress = productDetails.bwusage / productDetails.bwlimit;
    const chartImage = drawCircleProgress(progress);
    widget.addImage(chartImage);
    const label = widget.addText(`${product.code}|${Math.round(progress * 100)}%`);
    label.font = Font.semiboldSystemFont(12);
    applyPrimaryTextColor(label);
    Script.setWidget(widget);
}
async function renderAccessoryRectangular(widget, product) {
    const productDetails = await loadProductDetailStandard(product.id.toString());
    const line1Stack = widget.addStack();
    const ipText = line1Stack.addText(`IP: ${productDetails.dedicatedip}`);
    ipText.font = Font.semiboldSystemFont(12);
    applyPrimaryTextColor(ipText);
    line1Stack.addSpacer();
    const updateTimeText = line1Stack.addText(new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }));
    updateTimeText.font = Font.semiboldSystemFont(12);
    applySecondaryTextColor(updateTimeText);
    const line2Stack = widget.addStack();
    const trafficChart = createProgressBarImage(productDetails.bwlimit, productDetails.bwusage, 100, 12, new Color("#1F6FD4"), new Color("#E0E0E0"));
    line2Stack.addImage(trafficChart);
    const line3Stack = widget.addStack();
    const usedGb = (productDetails.bwusage / 1024).toFixed(2);
    const totalGb = (productDetails.bwlimit / 1024).toFixed(2);
    line3Stack.addSpacer();
    const trafficText = line3Stack.addText(`流量: ${usedGb}GB / ${totalGb}GB`);
    trafficText.font = Font.semiboldSystemFont(10);
    applySecondaryTextColor(trafficText);
    line3Stack.addSpacer();
    Script.setWidget(widget);
}
async function renderSmall(widget, product) {
    const productDetails = await loadProductDetailStandard(product.id.toString());
    const isDark = Device.isUsingDarkAppearance();
    const primary = Color.dynamic(Color.black(), Color.white());
    const secondary = Color.dynamic(new Color("#6C6C70"), new Color("#8E8E93"));
    const green = new Color("#30D158");
    const red = new Color("#FF3B30");
    const progressBg = isDark ? new Color("#3A3A3C") : new Color("#D1D1D6");
    widget.backgroundColor = Color.dynamic(new Color("#FFFFFF"), new Color("#1C1C1E"));
    widget.setPadding(12, 12, 12, 12);
    // 头部行:名称 + 状态
    const headerRow = widget.addStack();
    headerRow.centerAlignContent();
    const nameText = headerRow.addText(productDetails.hostname || product.name);
    nameText.font = Font.boldSystemFont(13);
    nameText.textColor = primary;
    nameText.lineLimit = 1;
    const codeText = widget.addText(product.code);
    codeText.font = Font.systemFont(9);
    codeText.textColor = secondary;
    widget.addSpacer(4);
    // 流量卡片
    const trafficCard = widget.addStack();
    trafficCard.layoutVertically();
    trafficCard.backgroundColor = Color.dynamic(new Color("#F2F2F7"), new Color("#2C2C2E"));
    trafficCard.cornerRadius = 10;
    trafficCard.setPadding(6, 10, 6, 10);
    // 标题行
    const trafficHeader = trafficCard.addStack();
    trafficHeader.centerAlignContent();
    const globeSym = SFSymbol.named("globe");
    globeSym.applyFont(Font.systemFont(9));
    const globeImg = trafficHeader.addImage(globeSym.image);
    globeImg.imageSize = new Size(10, 10);
    globeImg.tintColor = secondary;
    trafficHeader.addSpacer(3);
    const trafficLabel = trafficHeader.addText("Traffic");
    trafficLabel.font = Font.systemFont(9);
    trafficLabel.textColor = secondary;
    trafficHeader.addSpacer();
    const usedGB = (productDetails.bwusage / 1024).toFixed(1);
    const totalGB = (productDetails.bwlimit / 1024).toFixed(1);
    const usageLabel = trafficHeader.addText(`${usedGB}/${totalGB} GB`);
    usageLabel.font = Font.systemFont(9);
    usageLabel.textColor = secondary;
    trafficCard.addSpacer(2);
    // 百分比 + 剩余
    const usagePercent = Math.round(productDetails.bwusage / productDetails.bwlimit * 100);
    const pctRow = trafficCard.addStack();
    pctRow.centerAlignContent();
    const percentText = pctRow.addText(`${usagePercent}%`);
    percentText.font = Font.boldSystemFont(28);
    percentText.textColor = usagePercent >= 80 ? red : green;
    pctRow.addSpacer();
    const remainGB = ((productDetails.bwlimit - productDetails.bwusage) / 1024).toFixed(1);
    const remainText = pctRow.addText(`剩余 ${remainGB} GB`);
    remainText.font = Font.systemFont(9);
    remainText.textColor = secondary;
    trafficCard.addSpacer(2);
    // 进度条
    const progressBar = createProgressBarImage(
        productDetails.bwlimit, productDetails.bwusage,
        130, 6, green, progressBg
    );
    const barRow = trafficCard.addStack();
    barRow.size = new Size(0, 6);
    const progressImg = barRow.addImage(progressBar);
    progressImg.imageSize = new Size(130, 6);
    widget.addSpacer(4);
    // 底部信息行
    const bottomRow = widget.addStack();
    bottomRow.centerAlignContent();
    const regDate = new Date(productDetails.regdate);
    if (!isNaN(regDate.getTime())) {
        const now = new Date();
        const regDay = regDate.getDate();
        const lastReset = new Date(now.getFullYear(), now.getMonth(), regDay);
        if (lastReset > now) lastReset.setMonth(lastReset.getMonth() - 1);
        const nextReset = new Date(lastReset);
        nextReset.setMonth(nextReset.getMonth() + 1);
        const daysUntilReset = Math.ceil((nextReset - now) / 86400000);
        const resetText = bottomRow.addText(`${daysUntilReset}天后重置`);
        resetText.font = Font.systemFont(9);
        resetText.textColor = secondary;
    }
    bottomRow.addSpacer();
    const invoiceDateStr = productDetails.nextinvoicedate || "";
    const invoiceDate = new Date(invoiceDateStr);
    if (!isNaN(invoiceDate.getTime())) {
        const daysLeft = Math.ceil((invoiceDate.getTime() - Date.now()) / 86400000);
        const renewBadge = bottomRow.addStack();
        renewBadge.backgroundColor = new Color(green.hex, 0.2);
        renewBadge.cornerRadius = 4;
        renewBadge.setPadding(1, 4, 1, 4);
        const renewText = renewBadge.addText(`续费 ${daysLeft}d`);
        renewText.font = Font.boldSystemFont(9);
        renewText.textColor = daysLeft <= 30 ? red : green;
    }
    Script.setWidget(widget);
}
async function renderMedium(widget, product) {
    const productDetails = await loadProductDetailStandard(product.id.toString());
    const isDark = Device.isUsingDarkAppearance();
    const primary = Color.dynamic(Color.black(), Color.white());
    const secondary = Color.dynamic(new Color("#6C6C70"), new Color("#8E8E93"));
    const green = new Color("#30D158");
    const red = new Color("#FF3B30");
    const blue = new Color("#007AFF");
    const orange = new Color("#FF9F0A");
    const progressBg = isDark ? new Color("#3A3A3C") : new Color("#D1D1D6");
    widget.backgroundColor = Color.dynamic(new Color("#FFFFFF"), new Color("#1C1C1E"));
    widget.setPadding(12, 14, 12, 14);
    const nameText = widget.addText(productDetails.hostname || product.name);
    nameText.font = Font.boldSystemFont(14);
    nameText.textColor = primary;
    nameText.lineLimit = 1;
    const subRow = widget.addStack();
    subRow.spacing = 6;
    const codeText = subRow.addText(product.code);
    codeText.font = Font.systemFont(10);
    codeText.textColor = secondary;
    const ipText = subRow.addText(productDetails.dedicatedip || "");
    ipText.font = new Font("Menlo", 10);
    ipText.textColor = secondary;
    widget.addSpacer();
    // 流量卡片
    const trafficCard = widget.addStack();
    trafficCard.layoutVertically();
    trafficCard.backgroundColor = Color.dynamic(new Color("#F2F2F7"), new Color("#2C2C2E"));
    trafficCard.cornerRadius = 10;
    trafficCard.setPadding(8, 12, 8, 12);
    // 标题行
    const trafficHeader = trafficCard.addStack();
    trafficHeader.centerAlignContent();
    const globeSymbol = SFSymbol.named("globe");
    globeSymbol.applyFont(Font.systemFont(11));
    const globeImg = trafficHeader.addImage(globeSymbol.image);
    globeImg.imageSize = new Size(12, 12);
    globeImg.tintColor = secondary;
    trafficHeader.addSpacer(3);
    const trafficLabel = trafficHeader.addText("Traffic");
    trafficLabel.font = Font.systemFont(11);
    trafficLabel.textColor = secondary;
    trafficHeader.addSpacer();
    const usedGB = (productDetails.bwusage / 1024).toFixed(1);
    const totalGB = (productDetails.bwlimit / 1024).toFixed(1);
    const usageLabel = trafficHeader.addText(`${usedGB} / ${totalGB} GB`);
    usageLabel.font = Font.boldSystemFont(11);
    usageLabel.textColor = primary;
    trafficCard.addSpacer(4);
    // 百分比 + 剩余
    const usagePercent = Math.round(productDetails.bwusage / productDetails.bwlimit * 100);
    const pctRow = trafficCard.addStack();
    pctRow.centerAlignContent();
    const percentText = pctRow.addText(`${usagePercent}%`);
    percentText.font = Font.boldSystemFont(30);
    percentText.textColor = usagePercent >= 80 ? red : green;
    pctRow.addSpacer();
    const remainGB = ((productDetails.bwlimit - productDetails.bwusage) / 1024).toFixed(1);
    const remainLabel = pctRow.addText(`剩余 ${remainGB} GB`);
    remainLabel.font = Font.systemFont(9);
    remainLabel.textColor = secondary;
    trafficCard.addSpacer(4);
    // 进度条
    const progressBar = createProgressBarImage(
        productDetails.bwlimit, productDetails.bwusage,
        280, 8, green, progressBg
    );
    const barRow = trafficCard.addStack();
    barRow.size = new Size(0, 8);
    const progressImg = barRow.addImage(progressBar);
    progressImg.imageSize = new Size(280, 8);
    trafficCard.addSpacer(4);
    // 入站出站 + 日均 + 重置
    const inOutRow = trafficCard.addStack();
    inOutRow.centerAlignContent();
    const inArrow = inOutRow.addText("↙");
    inArrow.font = Font.systemFont(10);
    inArrow.textColor = blue;
    inOutRow.addSpacer(2);
    const inLabel = inOutRow.addText(`入站 ${formatMB(productDetails.bwusage_in || 0)}`);
    inLabel.font = Font.systemFont(10);
    inLabel.textColor = secondary;
    inOutRow.addSpacer(8);
    const outArrow = inOutRow.addText("↗");
    outArrow.font = Font.systemFont(10);
    outArrow.textColor = orange;
    inOutRow.addSpacer(2);
    const outLabel = inOutRow.addText(`出站 ${formatMB(productDetails.bwusage_out || 0)}`);
    outLabel.font = Font.systemFont(10);
    outLabel.textColor = secondary;
    trafficCard.addSpacer(2);
    const bottomRow = trafficCard.addStack();
    bottomRow.centerAlignContent();
    const regDate = new Date(productDetails.regdate);
    if (!isNaN(regDate.getTime())) {
        const now = new Date();
        const regDay = regDate.getDate();
        const lastReset = new Date(now.getFullYear(), now.getMonth(), regDay);
        if (lastReset > now) lastReset.setMonth(lastReset.getMonth() - 1);
        const daysSinceReset = Math.max(1, Math.floor((now - lastReset) / 86400000));
        const dailyAvgMB = productDetails.bwusage / daysSinceReset;
        const nextReset = new Date(lastReset);
        nextReset.setMonth(nextReset.getMonth() + 1);
        const daysUntilReset = Math.ceil((nextReset - now) / 86400000);
        const avgText = bottomRow.addText(`日均 ${formatMB(dailyAvgMB)}`);
        avgText.font = Font.systemFont(9);
        avgText.textColor = secondary;
        bottomRow.addSpacer(8);
        const resetText = bottomRow.addText(`${daysUntilReset}天后重置`);
        resetText.font = Font.systemFont(9);
        resetText.textColor = secondary;
    }
    bottomRow.addSpacer();
    const invoiceDateStr = productDetails.nextinvoicedate || "";
    const invoiceDate = new Date(invoiceDateStr);
    if (!isNaN(invoiceDate.getTime())) {
        const daysLeft = Math.ceil((invoiceDate.getTime() - Date.now()) / 86400000);
        const renewBadge = bottomRow.addStack();
        renewBadge.backgroundColor = new Color(green.hex, 0.2);
        renewBadge.cornerRadius = 4;
        renewBadge.setPadding(1, 4, 1, 4);
        const renewText = renewBadge.addText(`${daysLeft}d`);
        renewText.font = Font.boldSystemFont(9);
        renewText.textColor = daysLeft <= 30 ? red : green;
        bottomRow.addSpacer(4);
        const dateText = bottomRow.addText(invoiceDateStr.split(" ")[0]);
        dateText.font = Font.systemFont(9);
        dateText.textColor = secondary;
    }

    Script.setWidget(widget);

}
async function renderLarge(widget, product) {

    const pid = product.id.toString();
    const productDetails = await loadProductDetailStandard(pid);
    const vmDetails = await loadVMDetails(pid);
    const allGraphData = await loadAllGraphData(pid);

    const cpuData = allGraphData.cpu || { datasets: [] };
    const memData = allGraphData.mem || { datasets: [] };
    const netData = allGraphData.net || { datasets: [] };
    const isDark = Device.isUsingDarkAppearance();
    const theme = {
        bg: Color.dynamic(new Color("#FFFFFF"), new Color("#1C1C1E")),
        card: Color.dynamic(new Color("#F2F2F7"), new Color("#2C2C2E")),
        primary: Color.dynamic(Color.black(), Color.white()),
        secondary: Color.dynamic(new Color("#6C6C70"), new Color("#8E8E93")),
        progressBg: isDark ? new Color("#3A3A3C") : new Color("#D1D1D6"),
        green: new Color("#30D158"),
        blue: new Color("#007AFF"),
        orange: new Color("#FF9F0A"),
        red: new Color("#FF3B30"),
    };
    widget.backgroundColor = theme.bg;
    widget.setPadding(12, 14, 12, 14);

    // === ROW 1: HEADER ===
    const headerRow = widget.addStack();
    headerRow.centerAlignContent();
    const headerLeft = headerRow.addStack();
    headerLeft.layoutVertically();
    const nameText = headerLeft.addText(productDetails.hostname || product.name);
    nameText.font = Font.boldSystemFont(16);
    nameText.textColor = theme.primary;
    nameText.lineLimit = 1;
    const subInfo = headerLeft.addStack();
    subInfo.spacing = 6;
    const codeText = subInfo.addText(product.code);
    codeText.font = Font.systemFont(10);
    codeText.textColor = theme.secondary;
    const ipLabel = subInfo.addText(productDetails.dedicatedip || "");
    ipLabel.font = new Font("Menlo", 10);
    ipLabel.textColor = theme.secondary;
    headerRow.addSpacer();
    const isRunning = vmDetails && vmDetails.status === "Running";
    const statusBadge = headerRow.addStack();
    statusBadge.backgroundColor = new Color((isRunning ? theme.green : theme.red).hex, 0.15);
    statusBadge.cornerRadius = 6;
    statusBadge.setPadding(3, 8, 3, 8);
    statusBadge.centerAlignContent();
    const dotCtx = new DrawContext();
    dotCtx.size = new Size(8, 8);
    dotCtx.opaque = false;
    dotCtx.respectScreenScale = true;
    dotCtx.setFillColor(isRunning ? theme.green : theme.red);
    dotCtx.fillEllipse(new Rect(0, 0, 8, 8));
    const dotImg = statusBadge.addImage(dotCtx.getImage());
    dotImg.imageSize = new Size(8, 8);
    statusBadge.addSpacer(4);
    const statusText = statusBadge.addText(isRunning ? "在线" : "离线");
    statusText.font = Font.boldSystemFont(11);
    statusText.textColor = isRunning ? theme.green : theme.red;

    widget.addSpacer();

    // === ROW 2: [uptime card] + [renewal card] ===
    const row2 = widget.addStack();
    row2.spacing = 8;
    // -- Uptime Card --
    const uptimeCard = row2.addStack();
    uptimeCard.layoutVertically();
    uptimeCard.backgroundColor = theme.card;
    uptimeCard.cornerRadius = 12;
    uptimeCard.setPadding(8, 12, 8, 12);
    const uptimeHeader = uptimeCard.addStack();
    uptimeHeader.centerAlignContent();
    const serverSym = SFSymbol.named("server.rack");
    serverSym.applyFont(Font.systemFont(11));
    const serverIcon = uptimeHeader.addImage(serverSym.image);
    serverIcon.imageSize = new Size(13, 13);
    serverIcon.tintColor = theme.secondary;
    uptimeHeader.addSpacer(4);
    const serverLabel = uptimeHeader.addText("服务器");
    serverLabel.font = Font.systemFont(11);
    serverLabel.textColor = theme.secondary;
    uptimeHeader.addSpacer();
    uptimeCard.addSpacer();
    if (vmDetails && vmDetails.uptime) {
        const ut = formatUptime(vmDetails.uptime);
        const utDaysRow = uptimeCard.addStack();
        utDaysRow.centerAlignContent();
        const utDays = utDaysRow.addText(`${ut.days}`);
        utDays.font = Font.boldSystemFont(28);
        utDays.textColor = theme.primary;
        const utDayUnit = utDaysRow.addText(` 天`);
        utDayUnit.font = Font.systemFont(12);
        utDayUnit.textColor = theme.secondary;
        const utDetail = uptimeCard.addText(`${ut.hours}h ${ut.mins}m`);
        utDetail.font = Font.systemFont(10);
        utDetail.textColor = theme.secondary;
    } else {
        const naText = uptimeCard.addText("N/A");
        naText.font = Font.boldSystemFont(24);
        naText.textColor = theme.secondary;
    }
    uptimeCard.addSpacer();
    // -- Renewal Card --
    const renewCard = row2.addStack();
    renewCard.layoutVertically();
    renewCard.backgroundColor = theme.card;
    renewCard.cornerRadius = 12;
    renewCard.setPadding(8, 12, 8, 12);
    const renewHeader = renewCard.addStack();
    renewHeader.centerAlignContent();
    const calSym = SFSymbol.named("calendar");
    calSym.applyFont(Font.systemFont(11));
    const calIcon = renewHeader.addImage(calSym.image);
    calIcon.imageSize = new Size(13, 13);
    calIcon.tintColor = theme.secondary;
    renewHeader.addSpacer(4);
    const renewLabel = renewHeader.addText("续费");
    renewLabel.font = Font.systemFont(11);
    renewLabel.textColor = theme.secondary;
    renewHeader.addSpacer();
    renewCard.addSpacer();
    const invoiceDateStr = productDetails.nextinvoicedate || "";
    const invoiceDate = new Date(invoiceDateStr);
    if (!isNaN(invoiceDate.getTime())) {
        const daysLeft = Math.ceil((invoiceDate.getTime() - Date.now()) / 86400000);
        const daysColor = daysLeft <= 30 ? theme.red : theme.green;
        const daysRow = renewCard.addStack();
        daysRow.centerAlignContent();
        const daysVal = daysRow.addText(`${daysLeft}`);
        daysVal.font = Font.boldSystemFont(28);
        daysVal.textColor = daysColor;
        const daysUnit = daysRow.addText(` 天`);
        daysUnit.font = Font.systemFont(12);
        daysUnit.textColor = theme.secondary;
        const dateLabel = renewCard.addText(invoiceDateStr.split(" ")[0]);
        dateLabel.font = Font.systemFont(10);
        dateLabel.textColor = theme.secondary;
    } else {
        const naText = renewCard.addText("N/A");
        naText.font = Font.boldSystemFont(24);
        naText.textColor = theme.secondary;
    }
    renewCard.addSpacer();

    widget.addSpacer();

    // === ROW 3: TRAFFIC CARD (full width) ===
    const trafficCard = widget.addStack();
    trafficCard.layoutVertically();
    trafficCard.backgroundColor = theme.card;
    trafficCard.cornerRadius = 12;
    trafficCard.setPadding(10, 12, 10, 12);
    // traffic header
    const trafficHeader = trafficCard.addStack();
    trafficHeader.centerAlignContent();
    const globeSymbol = SFSymbol.named("globe");
    globeSymbol.applyFont(Font.systemFont(11));
    const globeImg = trafficHeader.addImage(globeSymbol.image);
    globeImg.imageSize = new Size(12, 12);
    globeImg.tintColor = theme.secondary;
    trafficHeader.addSpacer(3);
    const trafficLabel = trafficHeader.addText("Traffic");
    trafficLabel.font = Font.systemFont(11);
    trafficLabel.textColor = theme.secondary;
    trafficHeader.addSpacer();
    const usagePercent = Math.round(productDetails.bwusage / productDetails.bwlimit * 100);
    const usedGB = (productDetails.bwusage / 1024).toFixed(1);
    const totalGB = (productDetails.bwlimit / 1024).toFixed(1);
    const usageText = trafficHeader.addText(`${usedGB} / ${totalGB} GB`);
    usageText.font = Font.boldSystemFont(11);
    usageText.textColor = theme.primary;
    trafficCard.addSpacer(4);
    // percentage row
    const trafficMainRow = trafficCard.addStack();
    trafficMainRow.centerAlignContent();
    const percentText = trafficMainRow.addText(`${usagePercent}%`);
    percentText.font = Font.boldSystemFont(22);
    percentText.textColor = usagePercent >= 80 ? theme.red : theme.green;
    trafficMainRow.addSpacer();
    const remainGB = ((productDetails.bwlimit - productDetails.bwusage) / 1024).toFixed(1);
    const remainLabel = trafficMainRow.addText(`剩余 ${remainGB} GB`);
    remainLabel.font = Font.systemFont(9);
    remainLabel.textColor = theme.secondary;
    trafficCard.addSpacer(4);
    // progress bar (full width)
    const progressBar = createProgressBarImage(
        productDetails.bwlimit, productDetails.bwusage,
        300, 8, theme.green, theme.progressBg
    );
    const barRow = trafficCard.addStack();
    barRow.size = new Size(0, 8);
    const progressImg = barRow.addImage(progressBar);
    progressImg.imageSize = new Size(300, 8);
    trafficCard.addSpacer(4);
    // in/out row
    const inOutRow = trafficCard.addStack();
    inOutRow.centerAlignContent();
    const inArrow = inOutRow.addText("↙");
    inArrow.font = Font.systemFont(10);
    inArrow.textColor = theme.blue;
    inOutRow.addSpacer(2);
    const inLabel = inOutRow.addText(`入站 ${formatMB(productDetails.bwusage_in || 0)}`);
    inLabel.font = Font.systemFont(10);
    inLabel.textColor = theme.secondary;
    inOutRow.addSpacer();
    const outArrow = inOutRow.addText("↗");
    outArrow.font = Font.systemFont(10);
    outArrow.textColor = theme.orange;
    inOutRow.addSpacer(2);
    const outLabel = inOutRow.addText(`出站 ${formatMB(productDetails.bwusage_out || 0)}`);
    outLabel.font = Font.systemFont(10);
    outLabel.textColor = theme.secondary;
    // daily avg + reset
    const regDate = new Date(productDetails.regdate);
    if (!isNaN(regDate.getTime())) {
        trafficCard.addSpacer(3);
        const now = new Date();
        const regDay = regDate.getDate();
        const lastReset = new Date(now.getFullYear(), now.getMonth(), regDay);
        if (lastReset > now) lastReset.setMonth(lastReset.getMonth() - 1);
        const daysSinceReset = Math.max(1, Math.floor((now - lastReset) / 86400000));
        const dailyAvgMB = productDetails.bwusage / daysSinceReset;
        const nextReset = new Date(lastReset);
        nextReset.setMonth(nextReset.getMonth() + 1);
        const daysUntilReset = Math.ceil((nextReset - now) / 86400000);
        const statsRow = trafficCard.addStack();
        statsRow.centerAlignContent();
        const avgLabel = statsRow.addText(`日均 ${formatMB(dailyAvgMB)}`);
        avgLabel.font = Font.systemFont(10);
        avgLabel.textColor = theme.secondary;
        statsRow.addSpacer();
        const resetLabel = statsRow.addText(`${daysUntilReset} 天后重置`);
        resetLabel.font = Font.systemFont(10);
        resetLabel.textColor = theme.secondary;
    }

    widget.addSpacer();

    // === ROW 4: [CPU chart] [RAM chart] [NET chart] + [detail card] ===
    const row3 = widget.addStack();
    row3.spacing = 6;
    // -- 3 chart cards --
    const chartsCol = row3.addStack();
    chartsCol.layoutVertically();
    chartsCol.spacing = 6;
    // top row: CPU + RAM
    const chartRow1 = chartsCol.addStack();
    chartRow1.spacing = 6;
    function makeChartCard(parent, iconName, label, iconColor, datasets, minCount, chartColor, secondaryChartColor) {
        const card = parent.addStack();
        card.layoutVertically();
        card.backgroundColor = theme.card;
        card.cornerRadius = 10;
        card.setPadding(6, 6, 4, 6);
        const h = card.addStack();
        h.centerAlignContent();
        const sym = SFSymbol.named(iconName);
        sym.applyFont(Font.systemFont(9));
        const icon = h.addImage(sym.image);
        icon.imageSize = new Size(10, 10);
        icon.tintColor = iconColor;
        h.addSpacer(2);
        const t = h.addText(label);
        t.font = Font.systemFont(9);
        t.textColor = theme.secondary;
        card.addSpacer(2);
        if (datasets.length >= minCount) {
            const chartData = minCount >= 2
                ? [datasets[0].data, datasets[1].data]
                : datasets[0].data;
            const chart = createLineChartImage(chartData, 80, 30, chartColor, secondaryChartColor);
            card.addImage(chart);
        }
    }
    makeChartCard(chartRow1, "cpu", "CPU", theme.orange, cpuData.datasets, 1, theme.blue);
    makeChartCard(chartRow1, "memorychip", "RAM", theme.green, memData.datasets, 1, new Color("#1662a0"));
    // bottom: NET (wider)
    const chartRow2 = chartsCol.addStack();
    const netCard = chartRow2.addStack();
    netCard.layoutVertically();
    netCard.backgroundColor = theme.card;
    netCard.cornerRadius = 10;
    netCard.setPadding(6, 6, 4, 6);
    const netH = netCard.addStack();
    netH.centerAlignContent();
    const netSym = SFSymbol.named("antenna.radiowaves.left.and.right");
    netSym.applyFont(Font.systemFont(9));
    const netIcon = netH.addImage(netSym.image);
    netIcon.imageSize = new Size(10, 10);
    netIcon.tintColor = theme.orange;
    netH.addSpacer(2);
    const netLabel = netH.addText("NET");
    netLabel.font = Font.systemFont(9);
    netLabel.textColor = theme.secondary;
    netCard.addSpacer(2);
    if (netData.datasets && netData.datasets.length >= 2) {
        const netChart = createLineChartImage(
            [netData.datasets[0].data, netData.datasets[1].data],
            170, 30, new Color("#1F6FD4"), new Color("#16A085")
        );
        netCard.addImage(netChart);
    }
    // -- Right: detail card (spans full height) --
    const detailCard = row3.addStack();
    detailCard.layoutVertically();
    detailCard.backgroundColor = theme.card;
    detailCard.cornerRadius = 12;
    detailCard.setPadding(8, 10, 8, 10);
    function addDetailRow(parent, label, value, valColor) {
        const row = parent.addStack();
        row.centerAlignContent();
        const l = row.addText(label);
        l.font = Font.systemFont(9);
        l.textColor = theme.secondary;
        row.addSpacer();
        const v = row.addText(value);
        v.font = Font.boldSystemFont(9);
        v.textColor = valColor || theme.primary;
        v.lineLimit = 1;
        parent.addSpacer(3);
    }
    addDetailRow(detailCard, "注册", productDetails.regdate ? productDetails.regdate.split(" ")[0] : "N/A");
    if (!isNaN(invoiceDate.getTime())) {
        addDetailRow(detailCard, "到期", invoiceDateStr.split(" ")[0]);
    }
    addDetailRow(detailCard, "产品", product.name);
    // CPU value
    if (cpuData.datasets && cpuData.datasets.length > 0 && cpuData.datasets[0].data.length > 0) {
        const cpuVal = cpuData.datasets[0].data[cpuData.datasets[0].data.length - 1];
        addDetailRow(detailCard, "CPU", `${cpuVal.toFixed(1)}%`);
    }
    // Memory (bytes -> percentage using maxmem)
    if (vmDetails && vmDetails.maxmem && vmDetails.mem != null) {
        const memDisplay = `${(vmDetails.mem / vmDetails.maxmem * 100).toFixed(1)}%`;
        addDetailRow(detailCard, "内存", memDisplay);
    }
    // NET speed
    if (netData.datasets && netData.datasets.length >= 2 && netData.datasets[0].data.length > 0) {
        const fmtSpeed = (v) => {
            if (v >= 1024 * 1024) return `${(v / 1024 / 1024).toFixed(1)}M/s`;
            if (v >= 1024) return `${(v / 1024).toFixed(0)}K/s`;
            return `${v.toFixed(0)}B/s`;
        };
        const lastIn = netData.datasets[0].data[netData.datasets[0].data.length - 1];
        const lastOut = netData.datasets[1].data[netData.datasets[1].data.length - 1];
        addDetailRow(detailCard, "↙入站", fmtSpeed(lastIn));
        addDetailRow(detailCard, "↗出站", fmtSpeed(lastOut));
    }

    Script.setWidget(widget);
}
async function main() {
    if (config.runsInApp) {
        const hasCookie = getCookie();
        if (!hasCookie) {
            await handleSettingsMenu();
            Script.complete();
            return;
        }
        const menu = new Alert();
        menu.title = "DMIT 小组件";
        menu.addAction("预览大号组件");
        menu.addAction("预览中号组件");
        menu.addAction("预览小号组件");
        menu.addAction("设置");
        menu.addCancelAction("取消");
        const choice = await menu.present();
        if (choice === 3) {
            await handleSettingsMenu();
            Script.complete();
            return;
        }
        if (choice === -1) {
            Script.complete();
            return;
        }
        let products = getCachedProducts();
        if (!products) {
            products = await loadProducts();
        }
        if (products.length === 0) {
            const errAlert = new Alert();
            errAlert.title = "错误";
            errAlert.message = "未找到产品数据,请检查 Cookie 是否有效。";
            errAlert.addAction("重新设置 Cookie");
            errAlert.addCancelAction("取消");
            const errChoice = await errAlert.present();
            if (errChoice === 0) {
                await handleSettingsMenu();
            }
            Script.complete();
            return;
        }
        const product = pickProductWithFallback(products, productsIndex);
        if (!product) {
            const noProductAlert = new Alert();
            noProductAlert.title = "错误";
            noProductAlert.message = "未找到可用产品";
            noProductAlert.addAction("好的");
            await noProductAlert.present();
            Script.complete();
            return;
        }
        const widget = new ListWidget();
        if (choice === 0) {
            await renderLarge(widget, product);
            await widget.presentLarge();
        } else if (choice === 1) {
            await renderMedium(widget, product);
            await widget.presentMedium();
        } else {
            await renderSmall(widget, product);
            await widget.presentSmall();
        }
        Script.complete();
        return;
    }
    const widget = new ListWidget();
    const cookie = getCookie();
    if (!cookie) {
        showWidgetErrorAndFinish(widget, "请先设置 Cookie");
        return;
    }
    let products = getCachedProducts();
    if (!products) {
        products = await loadProducts();
    }
    if (products.length === 0) {
        showWidgetErrorAndFinish(widget, "未找到产品数据");
        return;
    }
    const product = pickProductWithFallback(products, productsIndex);
    if (!product) {
        showWidgetErrorAndFinish(widget, "未找到可用产品");
        return;
    }
    switch (config.widgetFamily) {
        case "accessoryCircular":
            await renderAccessoryCircular(widget, product);
            break;
        case "accessoryInline":
            await renderAccessoryInline(widget, product);
            break;
        case "accessoryRectangular":
            await renderAccessoryRectangular(widget, product);
            break;
        case "small":
            await renderSmall(widget, product);
            break;
        case "medium":
            await renderMedium(widget, product);
            break;
        case "large":
        case "extraLarge":
            await renderLarge(widget, product);
            break;
        default:
            await renderMedium(widget, product);
            break;
    }
    Script.complete();
}
void main();
123
123

你好啊,陌生人!

我的朋友,看起来你是新来的,如果想参与到讨论中,点击下面的按钮!

📈用户数目📈

目前论坛共有63814位seeker

🎉欢迎新用户🎉