feat: 新增工具Tab - 剪贴板注入(多选本地adb/STF设备批量注入文字,u2 setClipboard 实现,实测支持中文/引号/换行)

This commit is contained in:
2026-08-11 09:53:32 +08:00
parent cff0fc6a79
commit 2f1003bf1f
3 changed files with 162 additions and 4 deletions
+58 -3
View File
@@ -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=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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,
+48
View File
@@ -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
View File
@@ -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():