feat: 新增工具Tab - 剪贴板注入(多选本地adb/STF设备批量注入文字,u2 setClipboard 实现,实测支持中文/引号/换行)
This commit is contained in:
+58
-3
@@ -281,7 +281,7 @@ function showTab(name){
|
|||||||
document.querySelectorAll('.tab-panel').forEach(p=>p.classList.remove('active'));
|
document.querySelectorAll('.tab-panel').forEach(p=>p.classList.remove('active'));
|
||||||
document.querySelectorAll('.navbar .tab').forEach(t=>t.classList.remove('active'));
|
document.querySelectorAll('.navbar .tab').forEach(t=>t.classList.remove('active'));
|
||||||
document.getElementById('tab-'+name).classList.add('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(_monitorTimer){clearInterval(_monitorTimer);_monitorTimer=null;}
|
||||||
if(_logTimer){clearInterval(_logTimer);_logTimer=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==='tasks'){showSubTab('tasks',_activeSubs.tasks);loadTasks();loadCustomActions();}
|
||||||
if(name==='apps'){showSubTab('apps',_activeSubs.apps);loadApks();}
|
if(name==='apps'){showSubTab('apps',_activeSubs.apps);loadApks();}
|
||||||
if(name==='maintenance'){showSubTab('maintenance',_activeSubs.maintenance);loadAdbDevices();loadTailscaleDevices();}
|
if(name==='maintenance'){showSubTab('maintenance',_activeSubs.maintenance);loadAdbDevices();loadTailscaleDevices();}
|
||||||
|
if(name==='tools'){showSubTab('tools',_activeSubs.tools);loadToolsDevices();}
|
||||||
if(name==='groups')loadGroups();
|
if(name==='groups')loadGroups();
|
||||||
if(name==='logs'){loadLogs();if(document.getElementById('log-auto').checked)_logTimer=setInterval(loadLogs,3000);}
|
if(name==='logs'){loadLogs();if(document.getElementById('log-auto').checked)_logTimer=setInterval(loadLogs,3000);}
|
||||||
if(name==='users')loadUsers();
|
if(name==='users')loadUsers();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ================== 页内子分栏(任务/应用/维护 通用) ==================
|
// ================== 页内子分栏(任务/应用/维护/工具 通用) ==================
|
||||||
// 每个带子分栏的 Tab 记住上次选中的子分栏,切走再切回来保持原位
|
// 每个带子分栏的 Tab 记住上次选中的子分栏,切走再切回来保持原位
|
||||||
let _activeSubs = {tasks: 'plan', apps: 'apks', maintenance: 'stf'};
|
let _activeSubs = {tasks: 'plan', apps: 'apks', maintenance: 'stf', tools: 'clipboard'};
|
||||||
|
|
||||||
function showSubTab(tabId, name){
|
function showSubTab(tabId, name){
|
||||||
_activeSubs[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));
|
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='<div class="empty" style="padding:20px">无可用设备(本地 adb 或 STF 池均无)</div>';
|
||||||
|
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 '<label data-serial="'+_escHtml(d.serial)+'">'
|
||||||
|
+'<input type="checkbox" value="'+_escHtml(d.serial)+'"'+( _clipSelected.has(d.serial)?' checked':'')+' onchange="toggleClipDevice(this)">'
|
||||||
|
+'<span style="font-family:monospace;flex:1;overflow:hidden;text-overflow:ellipsis">'+_escHtml(d.serial)+'</span>'
|
||||||
|
+'<span class="label '+stCls+'">'+st+'</span></label>';
|
||||||
|
}).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])=>
|
||||||
|
'<tr><td style="font-family:monospace">'+_escHtml(s)+'</td><td>'+(v.ok?'<span class="label label-success">成功</span>':'<span class="label label-danger">失败</span> '+_escHtml(v.msg))+'</td></tr>').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,
|
// setListPager(key,{data:()=>array, filter:(item,kw)=>bool, render:(item)=>html,
|
||||||
|
|||||||
@@ -282,6 +282,7 @@ select.form-control{cursor:pointer}
|
|||||||
<button class="tab" onclick="showTab('logs')" data-perm="logs">日志</button>
|
<button class="tab" onclick="showTab('logs')" data-perm="logs">日志</button>
|
||||||
<button class="tab" onclick="showTab('users')" data-perm="admin">用户</button>
|
<button class="tab" onclick="showTab('users')" data-perm="admin">用户</button>
|
||||||
<button class="tab" onclick="showTab('maintenance')" data-perm="admin">维护</button>
|
<button class="tab" onclick="showTab('maintenance')" data-perm="admin">维护</button>
|
||||||
|
<button class="tab" onclick="showTab('tools')" data-perm="admin">工具</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="user-info">
|
<div class="user-info">
|
||||||
<span id="me-username">...</span>
|
<span id="me-username">...</span>
|
||||||
@@ -572,6 +573,53 @@ select.form-control{cursor:pointer}
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- ========== Tab 8: 工具(仅管理员) ========== -->
|
||||||
|
<div id="tab-tools" class="tab-panel">
|
||||||
|
<div class="page-title">工具</div>
|
||||||
|
<div class="page-sub">管理员专用:常用设备工具集合</div>
|
||||||
|
|
||||||
|
<div class="sub-tabs">
|
||||||
|
<button class="sub-tab active" data-sub="clipboard" onclick="showSubTab('tools','clipboard')">剪贴板注入</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 子分栏 1:剪贴板注入 -->
|
||||||
|
<div class="sub-panel active" id="tools-sub-clipboard">
|
||||||
|
<div class="toolbar">
|
||||||
|
<button class="btn" onclick="loadToolsDevices()">刷新设备</button>
|
||||||
|
<span id="clip-status" class="text-muted"></span>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;gap:20px;flex-wrap:wrap">
|
||||||
|
<div style="flex:1;min-width:340px">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>注入内容</label>
|
||||||
|
<textarea id="clip-text" class="form-control" rows="6"
|
||||||
|
placeholder="要注入到设备剪贴板的文字(支持中文/引号/换行)"></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="toolbar" style="margin-bottom:8px">
|
||||||
|
<button class="btn btn-primary" onclick="injectClipboard()">注入剪贴板</button>
|
||||||
|
<span class="hint">注入后需在目标 App 的输入框长按选择"粘贴"</span>
|
||||||
|
</div>
|
||||||
|
<div class="help">实现:u2 setClipboard(jsonrpc 直连 atx-agent),实测支持中文、引号、换行;USB 设备首次连接会自动推送 atx-agent。</div>
|
||||||
|
</div>
|
||||||
|
<div style="flex:1;min-width:280px">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>目标设备(本地 adb 含 USB + STF 池,可多选)</label>
|
||||||
|
<div class="device-list" id="clip-devices" style="max-height:300px">
|
||||||
|
<div class="empty" style="padding:20px">点击"刷新设备"加载</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="clip-result-wrap" style="display:none;margin-top:16px">
|
||||||
|
<div class="section-title">注入结果</div>
|
||||||
|
<table class="table">
|
||||||
|
<thead><tr><th>设备</th><th>结果</th></tr></thead>
|
||||||
|
<tbody id="clip-result"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
</div><!-- /.content -->
|
</div><!-- /.content -->
|
||||||
|
|
||||||
<!-- ========== 测试此步骤弹窗 ========== -->
|
<!-- ========== 测试此步骤弹窗 ========== -->
|
||||||
|
|||||||
+56
-1
@@ -23,6 +23,7 @@ import shlex
|
|||||||
import re
|
import re
|
||||||
import subprocess
|
import subprocess
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from concurrent.futures import ThreadPoolExecutor, TimeoutError as FuturesTimeout
|
||||||
|
|
||||||
from flask import (Flask, jsonify, request, redirect, url_for, render_template,
|
from flask import (Flask, jsonify, request, redirect, url_for, render_template,
|
||||||
Response, make_response, session)
|
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.logger import get_logger, _LOG_DIR, _MODULE_FILES
|
||||||
from core.models import db, init_db, User, DeviceGroup, TaskJob, CustomAction
|
from core.models import db, init_db, User, DeviceGroup, TaskJob, CustomAction
|
||||||
from core.apk_manager import ApkManager
|
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 import uiauto_helper, tailscale_client
|
||||||
from core.tailscale_client import TailscaleError
|
from core.tailscale_client import TailscaleError
|
||||||
from config import ADB_PATH, STF_SSH_TARGET, STF_DOCKER_CONTAINER
|
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
|
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"])
|
@app.route("/api/stf/restart", methods=["POST"])
|
||||||
@admin_required
|
@admin_required
|
||||||
def api_stf_restart():
|
def api_stf_restart():
|
||||||
|
|||||||
Reference in New Issue
Block a user