YYPOST群发软件 发表于 前天 06:48

floatUI深色控制台风格悬浮窗日志

floatUI深色控制台风格悬浮窗日志






/**
* ╔══════════════════════════════════════════════════════════╗
* ║   floatUI深色控制台风格悬浮窗日志
* ║   知识点:floatUI 悬浮窗、彩色日志(Html)、            ║
* ║          进度条、自动滚屏、event.onStopBtnEvent、      ║
* ║          runTime.stopAllRuning()                         ║
* ╚══════════════════════════════════════════════════════════╝
*
* 目标:深色控制台风格悬浮窗(标题栏 + 统计栏 + 进度条 + 彩色日志 + 状态栏),
*       循环跑模拟任务实时打日志,点停止按钮时自动关闭并清理线程。
* 难度:★★☆☆☆ 进阶第二课
* 运行:直接在 AIWROK 里跑,不需要装任何 App
*
* 注意(Rhino ES5 规则):
* 1. 不支持 ES6 模板字符串,一律用 + 拼接
* 2. 函数必须先定义再调用
* 3. 全局变量统一在文件顶部声明,避免 ReferenceError
* 4. 日志用 printl,不要用 print.log()
* 5. Import / importClass 必须放在全局最外层,不能进代码块
*/

importClass(android.text.Html);

// ==================== 顶部:全局变量声明 ====================
var gWin = null;      // 悬浮窗对象(floatUI)
var gTvTitle = null;    // 标题栏计数控件
var gTvTask = null;   // 统计栏:任务数
var gTvOk = null;       // 统计栏:成功数
var gTvErr = null;      // 统计栏:失败数
var gTvTime = null;   // 统计栏:耗时
var gTvBar = null;      // 进度条控件
var gTvStatus = null;   // 底部状态栏
var gTvLog = null;      // 日志 TextView 控件
var gScroll = null;   // 日志 ScrollView(用于自动滚屏)
var gHtmlLines = [];    // 彩色日志历史(存 Html 字符串)
var gLogCount = 0;      // 日志总条数
var gMaxLines = 100;    // 最多保留 100 行
var gOkCount = 0;       // 成功计数
var gErrCount = 0;      // 失败计数
var gStartTs = 0;       // 启动时间戳
var gStopTick = false;// 计时线程退出标志

// ==================== 调色板(想换配色改这里)====================
var C_BG_OUTER = "#33000000";   // 外框(半透明黑,形成描边感)
var C_HEADER   = "#0EA5E9";   // 标题栏:天蓝
var C_PANEL    = "#0F172A";   // 面板底:深蓝黑
var C_BODY   = "#0B1220";   // 日志区底:更深
var C_TEXT   = "#E2E8F0";   // 正文
var C_DIM      = "#64748B";   // 时间戳(暗)
var C_OK       = "#4ADE80";   // 成功(绿)
var C_ERR      = "#F87171";   // 失败(红)
var C_WARN   = "#FBBF24";   // 提醒(黄)
var C_ACCENT   = "#38BDF8";   // 强调(亮蓝)

// ==================== 工具函数(先定义,后使用)====================

// 阻塞延时:Rhino 里用 Thread.sleep,统一封装成 doSleep
function doSleep(ms) {
    try { java.lang.Thread.sleep(ms); } catch (e) {}
}

// 取两位数时间戳,例如 09:05:03
function getTimeStr() {
    var d = new Date();
    var h = d.getHours();   if (h < 10) h = "0" + h;
    var m = d.getMinutes();   if (m < 10) m = "0" + m;
    var s = d.getSeconds();   if (s < 10) s = "0" + s;
    return h + ":" + m + ":" + s;
}

// 取 HH:MM:SS 耗时
function getElapsedStr() {
    var ms = new Date().getTime() - gStartTs;
    var mm = Math.floor(ms / 60000);
    var ss = Math.floor((ms % 60000) / 1000);
    return (mm < 10 ? "0" : "") + mm + ":" + (ss < 10 ? "0" : "") + ss;
}

// 拼一行彩色日志:<font> 包时间戳 / 图标 / 正文
function buildHtmlLine(ts, icon, iconColor, msg, msgColor) {
    return "<font color='" + C_DIM + "'>" + ts + "</font>" +
         "<font color='" + iconColor + "'>" + icon + "</font>" +
         "<font color='" + msgColor + "'>" + msg + "</font>";
}

// ==================== 第 1 步:创建悬浮窗 ====================
// floatUI 是 AIWROK 的悬浮窗控件,loadXML 用 XML 描述布局
// 规则:尺寸用 "fill_parent" 而非 "*";padding 用单值;没有 <View> 组件
function initFloatWindow() {
    var sw = screen.getScreenWidth();
    var sh = screen.getScreenHeight();
    printl("[屏幕] " + sw + "x" + sh);

    // 按屏幕百分比算尺寸,换任何手机都不会遮挡界面
    var w = Math.floor(sw * 0.52);   // 宽 52%
    var h = Math.floor(sh * 0.30);   // 高 30%
    var x = 8;
    var y = Math.floor(sh * 0.08);   // 距顶部 8%

    // 布局结构:外框(描边感) → 标题栏 → 统计栏 → 进度条 → 日志区 → 状态栏
    var xml =
      '<LinearLayout orientation="vertical" w="' + w + '" h="' + h +
      '" background="' + C_BG_OUTER + '" padding="3">' +

      // ── 标题栏(标题不能用 fill_parent,否则右侧计数被挤出屏幕)──
      '<LinearLayout orientation="horizontal" w="fill_parent" h="wrap_content" ' +
      'background="' + C_HEADER + '" padding="8">' +
      '<TextView text="&#128640; 运行日志" textColor="#FFFFFF" textSize="13" ' +
      'textStyle="bold"/>' +
      '<TextView id="hdrCount" text="0 条" textColor="#E0F2FE" textSize="11" ' +
      'w="fill_parent" gravity="right"/>' +
      '</LinearLayout>' +

      // ── 统计栏 ──
      '<LinearLayout orientation="horizontal" w="fill_parent" h="wrap_content" ' +
      'background="' + C_PANEL + '" padding="6">' +
      '<TextView id="stTask" text="任务 0" textColor="#94A3B8" textSize="11"/>' +
      '<TextView id="stOk"   text="✓ 0" textColor="' + C_OK + '" textSize="11"/>' +
      '<TextView id="stErr"text="✗ 0" textColor="' + C_ERR + '" textSize="11"/>' +
      '<TextView id="stTime" text="⏱ 00:00" textColor="' + C_WARN + '" textSize="11" ' +
      'w="fill_parent" gravity="right"/>' +
      '</LinearLayout>' +

      // ── 进度条(用 ▰▱ 字符画,任何版本都支持)──
      '<LinearLayout orientation="horizontal" w="fill_parent" h="wrap_content" ' +
      'background="' + C_PANEL + '" padding="4">' +
      '<TextView id="progBar" text="▰▰▰▰▰▰▰▰▰▰▰▰ 0%" ' +
      'textColor="' + C_ACCENT + '" textSize="11" w="fill_parent"/>' +
      '</LinearLayout>' +

      // ── 日志区 ──
      '<ScrollView id="logScroll" w="fill_parent" h="fill_parent" background="' + C_BODY + '">' +
      '<TextView id="logBox" w="fill_parent" h="wrap_content" text="" ' +
      'textColor="' + C_TEXT + '" textSize="11" padding="6"/>' +
      '</ScrollView>' +

      // ── 状态栏 ──
      '<TextView id="statusBar" text="● 正在运行..." textColor="' + C_OK + '" ' +
      'textSize="11" background="' + C_PANEL + '" padding="6" gravity="center"/>' +

      '</LinearLayout>';

    try {
      gWin = new floatUI();
      gWin.loadXML(xml);
      gWin.setPosition(x, y);   // 必须在 loadXML 成功后再调用,否则会空指针
      gTvTitle= gWin.findViewById("hdrCount");
      gTvTask   = gWin.findViewById("stTask");
      gTvOk   = gWin.findViewById("stOk");
      gTvErr    = gWin.findViewById("stErr");
      gTvTime   = gWin.findViewById("stTime");
      gTvBar    = gWin.findViewById("progBar");
      gTvStatus = gWin.findViewById("statusBar");
      gTvLog    = gWin.findViewById("logBox");
      gScroll   = gWin.findViewById("logScroll");
      printl("[悬浮窗] 创建成功 pos=(" + x + "," + y + ") size=" + w + "x" + h);
      return true;
    } catch (e) {
      printl("[悬浮窗] 创建失败: " + e);
      return false;
    }
}

// ==================== UI 线程调度(本例最关键的修复)====================
// 从脚本线程直接 setText 只会触发"重绘",不会触发"重新测量布局":
// - wrap_content 的日志区高度永远卡在第 1 行 → 后面的日志全被裁掉(只显示一行)
// - 文本变长的控件("任务 10")新内容被裁 → 数字显示不出来
// 解决:用 view.post() 把更新调度到 UI 线程执行,布局才能正常重算。
// (参考:floatUI功能/安卓可移动悬浮窗功能.js 的方案2)
function uiPost(view, fn) {
    if (view && view.post) {
      try { view.post(fn); return; } catch (e) {}
    }
    try { fn(); } catch (e2) {}
}

// ==================== 第 2 步:写日志(彩色 + 自动滚屏)====================
// 关键:用数组存历史 Html 行,setText 时 join 全部输出(防覆盖)。
// Html.fromHtml 让每行的时间戳/图标/正文各显各的颜色。
function appendLog(msg, color) {
    gLogCount++;
    var msgColor = color || C_TEXT;
    if (msg.length > 200) msg = msg.substring(0, 200) + "...";

    // 不同类型给不同图标和颜色
    var icon = "•", iconColor = C_ACCENT;
    if (color == C_OK)   { icon = "✓"; iconColor = C_OK; }
    if (color == C_ERR){ icon = "✗"; iconColor = C_ERR; }
    if (color == C_WARN) { icon = "➤"; iconColor = C_WARN; }

    gHtmlLines.push(buildHtmlLine(getTimeStr(), icon, iconColor, msg, msgColor));
    if (gHtmlLines.length > gMaxLines) gHtmlLines.shift();// 超出上限丢掉最旧的一行

    // 在 UI 线程里更新日志 + 计数 + 滚屏,布局才会重新测量
    uiPost(gTvLog, function () {
      try {
            var html = gHtmlLines.join("<br/>");
            gTvLog.setText(Html.fromHtml(html));
            gTvTitle.setText(gLogCount + " 条");
            // 自动滚到最新一行(FOCUS_DOWN = 130)
            if (gScroll) gScroll.fullScroll(130);
      } catch (e) {}
    });

    // 同步打印到 AIWROK 控制台,方便调试
    printl(msg);
}

// ==================== 第 3 步:统计栏 / 进度条刷新 ====================
function refreshStats(taskName) {
    var taskTotal = gOkCount + gErrCount;
    var ok = gOkCount, err = gErrCount, elapsed = getElapsedStr();
    uiPost(gTvTask, function () {
      try {
            gTvTask.setText("任务 " + taskTotal);
            gTvOk.setText("✓ " + ok);
            gTvErr.setText("✗ " + err);
            gTvTime.setText("⏱ " + elapsed);
      } catch (e) {}
    });
}

// 进度条:12 格方块,按完成比例填充
function refreshBar(done, total) {
    var blocks = 12;
    var filled = Math.round(done * blocks / total);
    if (filled > blocks) filled = blocks;
    var bar = "";
    for (var i = 0; i < blocks; i++) bar += (i < filled) ? "▰" : "▱";
    var pct = Math.round(done * 100 / total);
    var text = bar + " " + pct + "%";
    uiPost(gTvBar, function () {
      try { gTvBar.setText(text); } catch (e) {}
    });
}

function setStatus(text, color) {
    var shown = "● " + text;
    uiPost(gTvStatus, function () {
      try { gTvStatus.setTextColor(android.graphics.Color.parseColor(color)); } catch (e) {}
      try { gTvStatus.setText(shown); } catch (e2) {}
    });
}

// ==================== 第 4 步:后台计时线程 ====================
// new thread().runJsCode(fn, 名称) → 在独立线程里跑一段 JS 代码
// 主线程结束不会自动杀它,所以必须靠 gStopTick 标志让它自己退出
function startTickerThread() {
    new thread().runJsCode(function () {
      while (!gStopTick) {
            doSleep(1000);
            if (gStopTick) break;
            refreshStats();
      }
    }, "计时线程");
    printl("[线程] 计时线程已启动");
}

// ==================== 第 5 步:优雅停止 ====================
// event.onStopBtnEvent → 点停止按钮【之前】触发,适合做清理
// 注意:runTime.stopAllRuning() 必须放在清理动作的【最后】,
// 否则它会中断还没执行完的清理代码。
function cleanup() {
    gStopTick = true;               // 先让计时线程自己退出
    setStatus("正在清理资源...", C_WARN);
    appendLog("&#129529; 清理资源,关闭悬浮窗", C_WARN);
    doSleep(400);
    try { if (gWin) { gWin.close(); gWin = null; } } catch (e) {}   // floatUI 用 .close(),没有 .destroy()
    printl("[清理] 悬浮窗已关闭,线程标志已置位");
    runTime.stopAllRuning();          // 最后才停掉所有线程
    stop();
}

event.onStopBtnEvent(function () {
    cleanup();
});

// ==================== 第 6 步:主流程 ====================
function runMain() {
    printl("╔══════════════════════════════════════════╗");
    printl("║案例6:悬浮窗日志 + 优雅停止(美化版)║");
    printl("╚══════════════════════════════════════════╝");

    if (!initFloatWindow()) {
      printl("悬浮窗创建失败,脚本退出");
      return;
    }

    gStartTs = new Date().getTime();

    // 模拟任务列表:每条 [任务名, 是否成功]
    var tasks = [
      ["检测设备权限", true],
      ["连接远程服务器", true],
      ["拉取配置文件", true],
      ["OCR 图像识别", false],   // 故意失败,演示重试
      ["重试 OCR", true],
      ["解析识别结果", true],
      ["写入本地数据库", true],
      ["校验数据完整性", false],
      ["自动修复", true],
      ["生成运行报告", true]
    ];

    setStatus("正在运行...", C_OK);
    appendLog("&#127916; 演示开始(约 15 秒)", C_WARN);
    appendLog("═══════ 任务开始 ═══════", C_WARN);
    startTickerThread();
    refreshBar(0, tasks.length);

    for (var i = 0; i < tasks.length; i++) {
      var name = tasks;
      var ok = tasks;

      if (ok) {
            gOkCount++;
            appendLog("[ " + (i + 1) + "/" + tasks.length + " ] " + name, C_OK);
      } else {
            gErrCount++;
            appendLog("[ " + (i + 1) + "/" + tasks.length + " ] " + name + " → 重试中", C_ERR);
            refreshStats(name);
            doSleep(800);
            gErrCount--;            // 重试成功,失败数减回去
            gOkCount++;
            appendLog("↳ 重试成功", C_OK);
      }
      refreshStats(name);
      refreshBar(i + 1, tasks.length);
      doSleep(800);   // 真人节奏,别刷太快
    }

    // 收尾:让观众看清统计
    appendLog("═══════ 任务全部结束 ═══════", C_WARN);
    appendLog("共 " + gOkCount + " 成功 / " + gErrCount + " 失败,3 秒后自动关闭", C_OK);
    setStatus("全部完成 ✓", C_OK);
    refreshBar(tasks.length, tasks.length);
    doSleep(3000);

    // 正常跑完也要清理,否则悬浮窗会残留在屏幕上
    cleanup();
}

runMain();



页: [1]
查看完整版本: floatUI深色控制台风格悬浮窗日志