diff --git a/static/admin/monitor.js b/static/admin/monitor.js index 7ffe374..1bce159 100644 --- a/static/admin/monitor.js +++ b/static/admin/monitor.js @@ -281,7 +281,7 @@ function showTab(name){ document.querySelectorAll('.tab-panel').forEach(p=>p.classList.remove('active')); document.querySelectorAll('.navbar .tab').forEach(t=>t.classList.remove('active')); document.getElementById('tab-'+name).classList.add('active'); - document.querySelectorAll('.navbar .tab')[['monitor','tasks','apps','groups','logs','users','maintenance'].indexOf(name)].classList.add('active'); + document.querySelectorAll('.navbar .tab')[['monitor','tasks','apps','groups','logs','users','maintenance','tools'].indexOf(name)].classList.add('active'); // 停止所有定时器 if(_monitorTimer){clearInterval(_monitorTimer);_monitorTimer=null;} if(_logTimer){clearInterval(_logTimer);_logTimer=null;} @@ -290,14 +290,15 @@ function showTab(name){ if(name==='tasks'){showSubTab('tasks',_activeSubs.tasks);loadTasks();loadCustomActions();} if(name==='apps'){showSubTab('apps',_activeSubs.apps);loadApks();} if(name==='maintenance'){showSubTab('maintenance',_activeSubs.maintenance);loadAdbDevices();loadTailscaleDevices();} + if(name==='tools'){showSubTab('tools',_activeSubs.tools);loadToolsDevices();} if(name==='groups')loadGroups(); if(name==='logs'){loadLogs();if(document.getElementById('log-auto').checked)_logTimer=setInterval(loadLogs,3000);} if(name==='users')loadUsers(); } -// ================== 页内子分栏(任务/应用/维护 通用) ================== +// ================== 页内子分栏(任务/应用/维护/工具 通用) ================== // 每个带子分栏的 Tab 记住上次选中的子分栏,切走再切回来保持原位 -let _activeSubs = {tasks: 'plan', apps: 'apks', maintenance: 'stf'}; +let _activeSubs = {tasks: 'plan', apps: 'apks', maintenance: 'stf', tools: 'clipboard'}; function showSubTab(tabId, name){ _activeSubs[tabId] = name; @@ -306,6 +307,60 @@ function showSubTab(tabId, name){ tab.querySelectorAll('.sub-panel').forEach(p=>p.classList.toggle('active', p.id===tabId+'-sub-'+name)); } +// ================== 工具:剪贴板注入 ================== +let _clipDevices = []; // [{serial, state}],来源 /api/adb/devices(本地 adb + STF 池) +let _clipSelected = new Set(); + +function _escHtml(s){ + return String(s==null?'':s).replace(/[&<>"']/g, c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); +} + +async function loadToolsDevices(){ + const r = await apiGet('/api/adb/devices'); + const status = document.getElementById('clip-status'); + if(!r||!r.ok){status.textContent='加载失败';showToast('加载设备失败: '+(r&&r.error||''),'error');return;} + _clipDevices = r.devices||[]; + const wrap = document.getElementById('clip-devices'); + if(!_clipDevices.length){ + wrap.innerHTML='
无可用设备(本地 adb 或 STF 池均无)
'; + status.textContent=''; + return; + } + wrap.innerHTML = _clipDevices.map(d=>{ + const st = d.state==='stf' ? 'STF池' : (d.state==='device' ? '已连接' : d.state); + const stCls = (d.state==='device'||d.state==='stf') ? 'label-success' : 'label-warning'; + return ''; + }).join(''); + status.textContent = '共 '+_clipDevices.length+' 台设备'; +} + +function toggleClipDevice(cb){ + if(cb.checked)_clipSelected.add(cb.value); + else _clipSelected.delete(cb.value); +} + +async function injectClipboard(){ + const text = document.getElementById('clip-text').value.trim(); + if(!text){showToast('请输入要注入的文字','error');return;} + if(!_clipSelected.size){showToast('请先勾选目标设备','error');return;} + const btn = document.querySelector('#tools-sub-clipboard .btn-primary'); + btn.disabled = true; const old = btn.textContent; btn.textContent = '注入中...'; + try{ + const r = await apiPost('/api/tools/clipboard/set', {serials:[..._clipSelected], text}); + if(!r||!r.ok){showToast('注入失败: '+(r&&r.error||''),'error');return;} + const rows = Object.entries(r.results).map(([s,v])=> + ''+_escHtml(s)+''+(v.ok?'成功':'失败 '+_escHtml(v.msg))+'').join(''); + document.getElementById('clip-result').innerHTML = rows; + document.getElementById('clip-result-wrap').style.display = 'block'; + showToast('注入完成:成功 '+r.ok_count+' 台,失败 '+r.fail_count+' 台', r.fail_count?'error':'success'); + } finally { + btn.disabled = false; btn.textContent = old; + } +} + // ================== 通用列表:搜索 + 分页 ================== // 用法: // setListPager(key,{data:()=>array, filter:(item,kw)=>bool, render:(item)=>html, diff --git a/templates/admin/monitor.html b/templates/admin/monitor.html index cdcccde..e63d41d 100644 --- a/templates/admin/monitor.html +++ b/templates/admin/monitor.html @@ -282,6 +282,7 @@ select.form-control{cursor:pointer} +
... @@ -572,6 +573,53 @@ select.form-control{cursor:pointer}
+ +
+
工具
+
管理员专用:常用设备工具集合
+ +
+ +
+ + +
+
+ + +
+
+
+
+ + +
+
+ + 注入后需在目标 App 的输入框长按选择"粘贴" +
+
实现:u2 setClipboard(jsonrpc 直连 atx-agent),实测支持中文、引号、换行;USB 设备首次连接会自动推送 atx-agent。
+
+
+
+ +
+
点击"刷新设备"加载
+
+
+
+
+ +
+
+ diff --git a/web_server.py b/web_server.py index 928f5e9..a8f2679 100644 --- a/web_server.py +++ b/web_server.py @@ -23,6 +23,7 @@ import shlex import re import subprocess from datetime import datetime +from concurrent.futures import ThreadPoolExecutor, TimeoutError as FuturesTimeout from flask import (Flask, jsonify, request, redirect, url_for, render_template, Response, make_response, session) @@ -35,7 +36,8 @@ from core.device_worker import clear_worker_error, clear_all_worker_errors from core.logger import get_logger, _LOG_DIR, _MODULE_FILES from core.models import db, init_db, User, DeviceGroup, TaskJob, CustomAction from core.apk_manager import ApkManager -from core.adb_helper import screenshot, list_installed_apps, adb_connect_light, _ADB_LOCK, _adb +from core.adb_helper import (screenshot, list_installed_apps, adb_connect_light, + adb_connect, _ADB_LOCK, _adb) from core import uiauto_helper, tailscale_client from core.tailscale_client import TailscaleError from config import ADB_PATH, STF_SSH_TARGET, STF_DOCKER_CONTAINER @@ -937,6 +939,59 @@ def api_adb_cmd(): return jsonify({"ok": False, "error": f"执行失败: {e}"}), 500 +@app.route("/api/tools/clipboard/set", methods=["POST"]) +@admin_required +def api_tools_clipboard_set(): + """工具-剪贴板注入:把指定文字写入一台或多台设备的剪贴板。 + + 请求: {"serials": ["100.100.10.11:5555", ...], "text": "要注入的文字"} + 设备来源与维护终端一致:本地 adb 已连接设备(含 USB)+ STF 在线池。 + 实现:u2 jsonrpc.setClipboard(实测 cmd clipboard 在 MIUI 上不存在)。 + """ + data = request.json or {} + serials = data.get("serials") or [] + text = (data.get("text") or "").strip() + if not isinstance(serials, list) or not serials: + return jsonify({"ok": False, "error": "未选择设备"}), 400 + if not text: + return jsonify({"ok": False, "error": "注入内容不能为空"}), 400 + results = {} + for serial in serials: + ok, msg = _set_device_clipboard(serial, text) + results[serial] = {"ok": ok, "msg": msg} + ok_count = sum(1 for v in results.values() if v["ok"]) + fail_count = len(results) - ok_count + return jsonify({"ok": True, "results": results, + "ok_count": ok_count, "fail_count": fail_count, + "error": None if ok_count == len(results) else f"{fail_count} 台设备注入失败"}) + + +def _set_device_clipboard(serial, text): + """向单台设备注入剪贴板文字(u2 jsonrpc.setClipboard)。 + + - IP:port 设备:先 adb connect(已连接自动跳过;绝不 disconnect,红线) + - USB 设备(无冒号):u2 按 serial 直连,首次会自动推送 atx-agent + - 连接/注入都带超时保护,避免 atx-agent 无响应时挂住请求 + """ + import uiautomator2 as u2 + if ":" in serial: + try: + adb_connect(serial) + except Exception as e: + return False, f"adb 连接失败: {e}" + d = None + try: + with ThreadPoolExecutor(max_workers=1) as pool: + d = pool.submit(u2.connect, serial).result(timeout=30) + with ThreadPoolExecutor(max_workers=1) as pool: + pool.submit(d.set_clipboard, text).result(timeout=15) + return True, "已注入" + except FuturesTimeout: + return False, "连接/注入超时(atx-agent 可能无响应)" + except Exception as e: + return False, f"{type(e).__name__}: {str(e)[:120]}" + + @app.route("/api/stf/restart", methods=["POST"]) @admin_required def api_stf_restart():