B2B网络软件

标题: 悬浮窗控制台floatUI 可拖动悬浮窗 + 按钮控制 [打印本页]

作者: YYPOST群发软件    时间: 1 小时前
标题: 悬浮窗控制台floatUI 可拖动悬浮窗 + 按钮控制
悬浮窗控制台floatUI 可拖动悬浮窗 + 按钮控制
悬浮窗控制台floatUI 可拖动悬浮窗 + 按钮控制 B2B网络软件

悬浮窗控制台floatUI 可拖动悬浮窗 + 按钮控制 B2B网络软件

悬浮窗控制台floatUI 可拖动悬浮窗 + 按钮控制 B2B网络软件

悬浮窗控制台floatUI 可拖动悬浮窗 + 按钮控制 B2B网络软件

  1. /* =============================================================================
  2.    AIWROK 技巧项目 04 —— 悬浮窗控制台(floatUI 可拖动悬浮窗 + 按钮控制)

  3.    作用
  4.    ────
  5.    一个能拖动、能点按钮的悬浮小窗,飘在屏幕最上层:
  6.    ▶启动 / ⏸暂停 / ▶恢复 / ⏹停止 / 收起展开 / ✖关闭,
  7.    后台异步跑多线程任务,窗口实时显示状态。

  8.    这个项目演示的特殊技巧
  9.    ──────────────────────
  10.    1. floatUI 悬浮窗:loadXML 加载布局、findViewById 拿控件、setPosition 定位
  11.    2. 拖动手势:给标题栏挂 OnTouchListener,用 MotionEvent 的 ACTION_DOWN/MOVE/UP
  12.       + getRawX/getRawY 算位移
  13.       ⚠ 真机实测坑:直接写 new View.OnTouchListener(...) 会打印"当前环境不支持拖拽",
  14.         因为短类名 View / MotionEvent 并不是全局可用的。
  15.         正确写法:先 importClass(android.view.View) / importClass(android.view.MotionEvent),
  16.         或用全类名 android.view.View.OnTouchListener、android.view.MotionEvent.ACTION_MOVE。
  17.         本脚本两者都做了(resolveView / resolveMotionEvent),拖不动时还能"点标题栏换位"兜底。
  18.    3. 按钮回调:控件.setOnClickListener(function(){...})
  19.    4. 折叠:View.GONE / View.VISIBLE 控制主体显隐(环境不支持时会自动降级)
  20.    5. 关闭:ui.close(),同时把线程停掉,不留后台野线程
  21.    6. 点击去重:真机上一次点击可能被派发多次(日志里刷 3 条"⏹ 已停止"就是这个原因),
  22.       所以命令要 ① 300ms 同命令去重 ② 池内方法自身幂等,双保险。

  23.    注意
  24.    ────
  25.    本项目的悬浮窗就是"控制台"本身,不是自动弹出来刷屏的日志窗。
  26.    脚本里没有额外弹日志窗,全部日志走 printl 输出到 IDE。
  27.    ============================================================================= */
  28. new floatUI().hideSystemFloat();//隐藏aiwork系统自带的悬浮图标
  29. var CFG = {
  30.     workerCount: 3,
  31.     taskCount: 15,           // 每轮任务数(重启时自动重新装填)
  32.     posX: 40, posY: 220,     // 悬浮窗初始位置
  33.     cmdDebounce: 300         // 同一命令 300ms 内的重复点击直接忽略
  34. };

  35. // ============================== Java 类导入(拖拽必需) ==============================
  36. // 坑:直接写 new View.OnTouchListener(...) 会报"不支持拖拽",
  37. // 因为短类名 View / MotionEvent 并不是全局可用的,必须先 importClass。
  38. try { importClass(android.view.View); } catch (e) {}
  39. try { importClass(android.view.MotionEvent); } catch (e) {}

  40. /** 取 View 类:短名 → 全类名 → null */
  41. function resolveView() {
  42.     if (typeof View !== 'undefined' && View) return View;
  43.     try { if (android && android.view && android.view.View) return android.view.View; } catch (e) {}
  44.     return null;
  45. }

  46. /** 取 MotionEvent 类:短名 → 全类名 → null */
  47. function resolveMotionEvent() {
  48.     if (typeof MotionEvent !== 'undefined' && MotionEvent) return MotionEvent;
  49.     try { if (android && android.view && android.view.MotionEvent) return android.view.MotionEvent; } catch (e) {}
  50.     return null;
  51. }

  52. var ui = null;               // floatUI 实例
  53. var ctrls = {};              // 控件引用
  54. var screenW = 1080, screenH = 1920;
  55. var dragLastX = 0, dragLastY = 0, dragging = false;
  56. var expanded = true;
  57. var winX = CFG.posX, winY = CFG.posY;
  58. var dragEnabled = false;     // 拖拽是否真的绑上了
  59. var lastCmd = { name: '', t: 0 };

  60. // ============================== 工具 ==============================

  61. function log(m) { try { printl(m); } catch (e) {} }
  62. function sleepMs(ms) { try { java.lang.Thread.sleep(ms); } catch (e) {} }
  63. function nowMs() { return java.lang.System.currentTimeMillis(); }

  64. function getScreenSize() {
  65.     try {
  66.         var m = context.getResources().getDisplayMetrics();
  67.         if (m && m.widthPixels) { screenW = m.widthPixels; screenH = m.heightPixels; }
  68.     } catch (e) {
  69.         try { screenW = screen.getScreenWidth(); screenH = screen.getScreenHeight(); } catch (e2) {}
  70.     }
  71.     log('屏幕尺寸: ' + screenW + ' x ' + screenH);
  72. }

  73. // ============================== 后台任务池(精简版) ==============================

  74. function MiniPool(workerCount) {
  75.     var workerN = workerCount || 3;
  76.     var workers = [], queue = [], lock = false;
  77.     var running = false, paused = false, accepting = true;
  78.     var active = 0, total = 0, done = 0, failed = 0;
  79.     var exited = 0;            // 已自然退出的线程数

  80.     function section(fn) {
  81.         var g = 0;
  82.         while (lock && g < 3000) { sleepMs(2); g++; }
  83.         lock = true;
  84.         try { return fn(); } finally { lock = false; }
  85.     }

  86.     function take() {
  87.         var t = null;
  88.         section(function () { if (queue.length > 0) { t = queue.shift(); active++; } });
  89.         return t;
  90.     }

  91.     function loop(idx) {
  92.         while (running) {
  93.             if (paused) { sleepMs(120); continue; }
  94.             var task = take();
  95.             if (task == null) {
  96.                 if (!accepting && queue.length === 0) break;
  97.                 sleepMs(60);
  98.                 continue;
  99.             }
  100.             var ok = true, err = '';
  101.             try { task.fn('T' + idx); } catch (e) { ok = false; err = String(e); }
  102.             section(function () { active--; if (ok) done++; else failed++; });
  103.             consoleLog((ok ? '✔ ' : '✘ ') + 'T' + idx + ' ' + task.name + (ok ? '' : ' ' + err));
  104.         }
  105.         // 关键:线程自然跑完退出时,要把池子状态归位;
  106.         // 否则 running 一直是 true,任务跑完后再点【启动】会被当成"已在运行"而空转。
  107.         section(function () {
  108.             exited++;
  109.             if (exited >= workerN) { running = false; paused = false; accepting = true; }
  110.         });
  111.     }

  112.     this.add = function (name, fn) { section(function () { queue.push({ name: name, fn: fn }); total++; }); return this; };

  113.     /** 本轮是否已跑完 */
  114.     this.isFinished = function () { return total > 0 && (done + failed) >= total; };

  115.     /** 清空队列与计数(只能在没有线程运行时调用) */
  116.     this.reset = function () {
  117.         if (running || active > 0) return false;
  118.         section(function () {
  119.             queue = []; total = 0; done = 0; failed = 0; exited = 0; accepting = true; paused = false;
  120.         });
  121.         return true;
  122.     };

  123.     // 所有控制方法都要幂等:重复点击只提示、不重复执行、不重复打日志
  124.     this.start = function () {
  125.         if (running) { consoleLog('· 已经在运行了'); return false; }
  126.         if (queue.length === 0) { consoleLog('· 队列是空的,先装任务'); return false; }
  127.         running = true; paused = false; accepting = true; exited = 0;
  128.         for (var i = 0; i < workerN; i++) {
  129.             var th = new thread();
  130.             workers.push(th);
  131.             (function (n) { th.runJsCode(function () { loop(n); }, '悬浮台线程' + n); })(i + 1);
  132.             sleepMs(140);
  133.         }
  134.         consoleLog('▶ 已启动 ' + workerN + ' 个线程');
  135.         return true;
  136.     };

  137.     this.pause = function () {
  138.         if (!running) { consoleLog('· 没在运行,不用暂停'); return false; }
  139.         if (paused) { consoleLog('· 已经是暂停状态'); return false; }
  140.         paused = true;
  141.         consoleLog('⏸ 已暂停');
  142.         return true;
  143.     };

  144.     this.resume = function () {
  145.         if (!paused) { consoleLog('· 当前不是暂停状态'); return false; }
  146.         paused = false;
  147.         consoleLog('▶ 已恢复');
  148.         return true;
  149.     };

  150.     this.stop = function () {
  151.         // 幂等:确实没东西在跑就什么都不做("⏹ 已停止"刷 3 条就是这么来的)
  152.         var inf = this.info();
  153.         if (!inf.running && inf.alive === 0 && inf.active === 0) {
  154.             consoleLog('· 已经停止了');
  155.             return false;
  156.         }
  157.         accepting = false; running = false; paused = false;
  158.         for (var i = 0; i < workers.length; i++) {
  159.             try { if (workers[i].isAlive()) workers[i].stop(); } catch (e) {}
  160.         }
  161.         exited = 0;
  162.         consoleLog('⏹ 已停止');
  163.         return true;
  164.     };

  165.     this.info = function () {
  166.         var a = 0;
  167.         for (var i = 0; i < workers.length; i++) { try { if (workers[i].isAlive()) a++; } catch (e) {} }
  168.         return { total: total, done: done, failed: failed, queue: queue.length, active: active, alive: a, paused: paused, running: running };
  169.     };
  170. }

  171. var pool = null;

  172. // ============================== 窗口内的状态显示 ==============================

  173. var consoleLines = [];

  174. function consoleLog(text) {
  175.     var n = new Date();
  176.     var t = ('0' + n.getHours()).slice(-2) + ':' + ('0' + n.getMinutes()).slice(-2) + ':' + ('0' + n.getSeconds()).slice(-2);
  177.     consoleLines.unshift('[' + t + '] ' + text);
  178.     if (consoleLines.length > 4) consoleLines = consoleLines.slice(0, 4);
  179.     log(text);
  180.     // 注意:这里不直接刷控件。控件更新统一放到主线程心跳里做,
  181.     // 避免工作线程直接操作 UI 触发 Android 的 "only the original thread can touch its views" 异常。
  182. }

  183. function renderConsole() {
  184.     try {
  185.         if (ctrls.log) ctrls.log.setText(consoleLines.join('\n'));
  186.     } catch (e) {}
  187. }

  188. function refreshStatus() {
  189.     if (!pool) return;
  190.     var i = pool.info();
  191.     var pct = i.total > 0 ? Math.floor((i.done + i.failed) * 100 / i.total) : 0;
  192.     var txt = '进度 ' + pct + '%   ✔' + i.done + '  ✘' + i.failed +
  193.               '  队列' + i.queue + '  线程' + i.alive;
  194.     try { if (ctrls.status) ctrls.status.setText(txt); } catch (e) {}
  195. }

  196. // ============================== 浮动窗口 ==============================

  197. function buildXml() {
  198.     return '' +
  199.     '<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" ' +
  200.     '  android:id="box" android:layout_width="230dp" android:layout_height="wrap_content" ' +
  201.     '  android:orientation="vertical" android:background="#E6111A2B" android:padding="6dp">' +

  202.     '  <LinearLayout android:id="title" android:layout_width="match_parent" ' +
  203.     '    android:layout_height="34dp" android:orientation="horizontal" android:gravity="center_vertical" ' +
  204.     '    android:background="#1F2A44">' +
  205.     '    <TextView android:layout_width="wrap_content" android:layout_height="wrap_content" ' +
  206.     '      android:text="✥" android:textColor="#8FA3C8" android:textSize="15sp" android:paddingLeft="6dp" android:paddingRight="6dp"/>' +
  207.     '    <TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" ' +
  208.     '      android:text="AIWROK 控制台" android:textColor="#FFFFFF" android:textSize="13sp"/>' +
  209.     '    <Button android:id="btnFold" android:layout_width="34dp" android:layout_height="28dp" ' +
  210.     '      android:text="-" android:textColor="#FFFFFF" android:textSize="12sp" android:background="#334155" android:padding="0dp"/>' +
  211.     '    <Button android:id="btnClose" android:layout_width="34dp" android:layout_height="28dp" ' +
  212.     '      android:text="✕" android:textColor="#FFFFFF" android:textSize="12sp" android:background="#EF4444" android:padding="0dp"/>' +
  213.     '  </LinearLayout>' +

  214.     '  <LinearLayout android:id="body" android:layout_width="match_parent" ' +
  215.     '    android:layout_height="wrap_content" android:orientation="vertical" android:paddingTop="6dp">' +

  216.     '    <TextView android:id="status" android:layout_width="match_parent" android:layout_height="wrap_content" ' +
  217.     '      android:text="进度 0%   ✔0  ✘0  队列0  线程0" android:textColor="#60A5FA" android:textSize="11sp" android:paddingBottom="6dp"/>' +

  218.     '    <LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" ' +
  219.     '      android:orientation="horizontal">' +
  220.     '      <Button android:id="btnStart" android:layout_width="0dp" android:layout_weight="1" ' +
  221.     '        android:layout_height="36dp" android:text="▶ 启动" android:textColor="#FFFFFF" android:textSize="12sp" android:background="#16A34A" android:padding="0dp"/>' +
  222.     '      <Button android:id="btnPause" android:layout_width="0dp" android:layout_weight="1" ' +
  223.     '        android:layout_height="36dp" android:text="⏸ 暂停" android:textColor="#FFFFFF" android:textSize="12sp" android:background="#D97706" android:padding="0dp"/>' +
  224.     '    </LinearLayout>' +

  225.     '    <LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" ' +
  226.     '      android:orientation="horizontal" android:paddingTop="4dp">' +
  227.     '      <Button android:id="btnResume" android:layout_width="0dp" android:layout_weight="1" ' +
  228.     '        android:layout_height="36dp" android:text="▶ 恢复" android:textColor="#FFFFFF" android:textSize="12sp" android:background="#2563EB" android:padding="0dp"/>' +
  229.     '      <Button android:id="btnStop" android:layout_width="0dp" android:layout_weight="1" ' +
  230.     '        android:layout_height="36dp" android:text="⏹ 停止" android:textColor="#FFFFFF" android:textSize="12sp" android:background="#B91C1C" android:padding="0dp"/>' +
  231.     '    </LinearLayout>' +

  232.     '    <TextView android:id="log" android:layout_width="match_parent" android:layout_height="wrap_content" ' +
  233.     '      android:text="" android:textColor="#86EFAC" android:textSize="10sp" android:paddingTop="6dp"/>' +
  234.     '  </LinearLayout>' +
  235.     '</LinearLayout>';
  236. }

  237. function moveTo(x, y) {
  238.     if (x < 0) x = 0;
  239.     if (y < 0) y = 0;
  240.     if (x > screenW - 120) x = screenW - 120;
  241.     if (y > screenH - 80) y = screenH - 80;
  242.     winX = x; winY = y;
  243.     try { ui.setPosition(x, y); } catch (e) {}
  244. }

  245. /** 绑定拖拽。设备实测:短类名 View/MotionEvent 不存在 → 必须用 importClass 后的类 */
  246. function setupDrag() {
  247.     var V = resolveView();
  248.     var M = resolveMotionEvent();
  249.     if (!V || !M || !ctrls.title || !ctrls.title.setOnTouchListener) {
  250.         log('⚠ 拖拽不可用(缺 ' + (V ? '' : 'View ') + (M ? '' : 'MotionEvent') + '),改用【点标题栏换位】');
  251.         bindTitleTapToMove();
  252.         return false;
  253.     }
  254.     try {
  255.         ctrls.title.setOnTouchListener(new V.OnTouchListener({
  256.             onTouch: function (view, event) {
  257.                 try {
  258.                     switch (event.getAction()) {
  259.                         case M.ACTION_DOWN:
  260.                             dragging = true;
  261.                             dragLastX = event.getRawX();
  262.                             dragLastY = event.getRawY();
  263.                             break;
  264.                         case M.ACTION_MOVE:
  265.                             if (dragging) {
  266.                                 var dx = event.getRawX() - dragLastX;
  267.                                 var dy = event.getRawY() - dragLastY;
  268.                                 moveTo(winX + dx, winY + dy);
  269.                                 dragLastX = event.getRawX();
  270.                                 dragLastY = event.getRawY();
  271.                             }
  272.                             break;
  273.                         case M.ACTION_UP:
  274.                             dragging = false;
  275.                             break;
  276.                     }
  277.                     return true;
  278.                 } catch (e2) { return false; }
  279.             }
  280.         }));
  281.         dragEnabled = true;
  282.         log('✔ 拖拽已绑定:按住标题栏可移动窗口');
  283.         return true;
  284.     } catch (e) {
  285.         log('⚠ 拖拽绑定失败: ' + e);
  286.         bindTitleTapToMove();
  287.         return false;
  288.     }
  289. }

  290. /** 拖拽不可用时的兜底:点一下标题栏,在几个预设位置间循环 */
  291. var presetIdx = -1;
  292. function bindTitleTapToMove() {
  293.     if (!ctrls.title || !ctrls.title.setOnClickListener) return;
  294.     try {
  295.         ctrls.title.setOnClickListener(function () {
  296.             var presets = [
  297.                 [20, 160], [Math.floor(screenW * 0.5) - 115, 160],
  298.                 [screenW - 250, 160], [screenW - 250, Math.floor(screenH * 0.5)],
  299.                 [20, Math.floor(screenH * 0.6)]
  300.             ];
  301.             presetIdx = (presetIdx + 1) % presets.length;
  302.             moveTo(presets[presetIdx][0], presets[presetIdx][1]);
  303.             log('↔ 窗口已换位到第 ' + (presetIdx + 1) + ' 个位置');
  304.         });
  305.     } catch (e) {}
  306. }

  307. function toggleFold() {
  308.     expanded = !expanded;
  309.     var V = resolveView();
  310.     try {
  311.         if (V && V.GONE !== undefined && ctrls.body && ctrls.body.setVisibility) {
  312.             ctrls.body.setVisibility(expanded ? V.VISIBLE : V.GONE);
  313.             log(expanded ? '&#128316; 已展开' : '&#128317; 已收起');
  314.             return true;
  315.         }
  316.     } catch (e) {}
  317.     expanded = true;
  318.     log('⚠ 当前环境不支持折叠,窗口保持展开');
  319.     return false;
  320. }

  321. function closeConsole() {
  322.     log('✖ 关闭控制台');
  323.     try { pool.stop(); } catch (e) {}
  324.     try { ui.close(); } catch (e) { log('关闭悬浮窗失败: ' + e); }
  325.     alive = false;
  326. }

  327. // ============================== 命令分发(去重 + 立即刷界面) ==============================
  328. // 实测发现:一次点击可能被派发多次(3 条一模一样的"已停止"就是这么来的)。
  329. // 双保险:① 同一命令 300ms 内只认第一次;② MiniPool 内部各方法本身幂等。

  330. function onceCmd(name) {
  331.     var t = nowMs();
  332.     if (name === lastCmd.name && t - lastCmd.t < CFG.cmdDebounce) return false;
  333.     lastCmd = { name: name, t: t };
  334.     return true;
  335. }

  336. /** 按钮回调跑在 UI 线程,这里直接刷控件是安全的,点完立刻能看到变化 */
  337. function afterCmd() {
  338.     refreshStatus();
  339.     renderConsole();
  340. }

  341. function cmdStart() {
  342.     if (!onceCmd('start')) return;
  343.     if (pool.isFinished()) {                 // 上一轮已跑完 → 自动重新装填,避免"重启空跑"
  344.         pool.reset();
  345.         refillTasks();
  346.         consoleLog('&#128260; 已重新装填 ' + CFG.taskCount + ' 个任务');
  347.     }
  348.     pool.start();
  349.     afterCmd();
  350. }

  351. function cmdPause()  { if (!onceCmd('pause'))  return; pool.pause();  afterCmd(); }
  352. function cmdResume() { if (!onceCmd('resume')) return; pool.resume(); afterCmd(); }
  353. function cmdStop()   { if (!onceCmd('stop'))   return; pool.stop();   afterCmd(); }
  354. function cmdFold()   { if (!onceCmd('fold'))   return; toggleFold();  afterCmd(); }
  355. function cmdClose()  { if (!onceCmd('close'))  return; closeConsole(); }

  356. // ============================== 界面装配 ==============================

  357. var alive = true;

  358. function createConsole() {
  359.     getScreenSize();
  360.     ui = new floatUI();
  361.     ui.loadXML(buildXml());

  362.     ctrls.title     = ui.findViewById('title');
  363.     ctrls.body      = ui.findViewById('body');
  364.     ctrls.status    = ui.findViewById('status');
  365.     ctrls.log       = ui.findViewById('log');
  366.     ctrls.btnStart  = ui.findViewById('btnStart');
  367.     ctrls.btnPause  = ui.findViewById('btnPause');
  368.     ctrls.btnResume = ui.findViewById('btnResume');
  369.     ctrls.btnStop   = ui.findViewById('btnStop');
  370.     ctrls.btnFold   = ui.findViewById('btnFold');
  371.     ctrls.btnClose  = ui.findViewById('btnClose');

  372.     if (!ctrls.btnStart) { log('✘ 悬浮窗控件未取到,设备可能不支持 floatUI'); return false; }

  373.     // 按钮事件(统一走 cmd* 分发器:带去重 + 幂等 + 立即刷界面)
  374.     ctrls.btnStart.setOnClickListener(function () { try { cmdStart(); } catch (e) { log('启动失败: ' + e); } });
  375.     ctrls.btnPause.setOnClickListener(function () { try { cmdPause(); } catch (e) {} });
  376.     ctrls.btnResume.setOnClickListener(function () { try { cmdResume(); } catch (e) {} });
  377.     ctrls.btnStop.setOnClickListener(function () { try { cmdStop(); } catch (e) {} });
  378.     ctrls.btnFold.setOnClickListener(function () { try { cmdFold(); } catch (e) {} });
  379.     ctrls.btnClose.setOnClickListener(function () { try { cmdClose(); } catch (e) {} });

  380.     setupDrag();
  381.     moveTo(CFG.posX, CFG.posY);
  382.     consoleLog('控制台就绪,点【▶ 启动】跑任务');
  383.     return true;
  384. }

  385. // ============================== 任务与心跳 ==============================

  386. function buildTask(idx) {
  387.     return function (tag) {
  388.         sleepMs(rand.randNumber(400, 1200));
  389.         if (idx % 8 === 0) throw new Error('第' + idx + '个任务异常');
  390.     };
  391. }

  392. /** 装填一轮任务 */
  393. function refillTasks() {
  394.     for (var i = 1; i <= CFG.taskCount; i++) pool.add('任务' + i, buildTask(i));
  395. }

  396. /** 心跳(跑在主线程):刷新窗口状态 + 给主线程保活 */
  397. function heartbeat() {
  398.     if (!alive) { log('心跳结束,主线程退出'); return; }
  399.     refreshStatus();
  400.     renderConsole();
  401.     runTime.setTimeout(heartbeat, 1000);
  402. }

  403. // ============================== 入口 ==============================

  404. function main() {
  405.     log('════════════════════════════════════════════');
  406.     log('  悬浮窗控制台(floatUI)');
  407.     log('════════════════════════════════════════════');

  408.     pool = new MiniPool(CFG.workerCount);
  409.     refillTasks();

  410.     if (!createConsole()) return;

  411.     heartbeat();
  412.     log(dragEnabled ? '&#128073; 按住标题栏拖动窗口' : '&#128073; 点标题栏可循环换位');
  413.     log('   【▶ 启动】跑任务  【⏸ 暂停】/【▶ 恢复】  【⏹ 停止】  【-】收起  【✕】关闭');
  414.     log('   任务跑完后再点【▶ 启动】会自动重新装填一轮');
  415. }

  416. main();
复制代码







欢迎光临 B2B网络软件 (http://bbs.niubt.cn/) Powered by Discuz! X3.2