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}
+
+
+
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():