|
|
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="🚀 运行日志" 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("🧹 清理资源,关闭悬浮窗", 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("🎬 演示开始(约 15 秒)", C_WARN);
- appendLog("═══════ 任务开始 ═══════", C_WARN);
- startTickerThread();
- refreshBar(0, tasks.length);
- for (var i = 0; i < tasks.length; i++) {
- var name = tasks[i][0];
- var ok = tasks[i][1];
- 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();
复制代码
|
|