feat(大屏): 顶部加「📤 发布」按钮 —— 选设备直接注入视频/图片、注入剪贴板
用户要求:"大屏按钮右边加一个按钮叫发布,就是选择设备号注入视频或者图片,位置就和发布页面的一样, 然后还有在选择设备号注入剪贴板"。 大屏(`/wall`)顶栏「一键亮屏」右边加 **「📤 发布」**,点开是一块面板: · **设备**下拉(设备名 + 地址,离线会标注;每次打开现取最新列表) · **视频 / 图片**:选文件 → 「注入素材」→ 推到该机相册 `/sdcard/DCIM/Camera` · **剪贴板文字**:填一段 → 「注入剪贴板」→ 写进该机剪贴板(发文案用) · 面板下方是操作日志(✅/❌ + 原因),失败会写清为什么 后端(`web/tools_api.py`,都按 **设备位** 放行 —— 大屏上随手一推是设备操作,不该要管理员位): · `POST /api/tools/push_material`(form-data:file + serial): 保存上传 → `publish_flow.push_video` 推到相册(文件名带 `wall` 前缀、mtime 改成现在, 保证它在相册里排最前)→ `verify_pushed` 校验进没进相册索引 → 返回 `{ok,msg,remote,verify}` · **不占位、不写库**:与「发布计划 → 推送」是两回事(那条按计划走占位/去重/回写状态); 这条是"手上有一段素材,想马上在这台机器上发" · 扩展名按 `video_plan.MEDIA_EXTS` 校验(视频+图片),不在其中 → 400;设备不在线 → 400 · 临时目录用完即删(`finally` 里 rmtree) · `POST /api/tools/push_clipboard`:与 `/api/tools/clipboard/set` **同一个实现** (`_set_device_clipboard`),只是权限位不同 真机验过(A01):剪贴板注入 ✅;1×1 测试图推到相册 ✅(`verify=no_index` 的 ⚠ 是这张图太小、 媒体库没索引它,真视频/图片是 ✅);`.txt` 被 400 拦住 ✅;测试文件已从手机删掉、不留痕。 文档:doc/API.md 加两条接口(含请求/返回/错误口径)、README 实时看屏那行补上发布面板。
This commit is contained in:
@@ -55,7 +55,7 @@
|
||||
| **去重账本** | 解决"**同一个号被做两次、有的号还没做**":任务里用「条件判断→去重」+「记为已做」,把"这个身份做过了"记进一张**所有设备共享**的账本——多台手机、反复重跑都不会重复做同一个号。「任务 → 去重记录」页能看"谁做过了、还差谁",也能删记录让它重跑(见 [TASK_DEV.md](doc/TASK_DEV.md) §4.6) |
|
||||
| **视频发布计划** | 「发布计划」页:批量上传**视频/图片**(文件名 `名字_日期_编号`,**名字 = 抖音昵称**;视频和图片同一套规则)与标题 txt(每行 `标题 || 正文_名字_日期_编号`)→ 自动按 (名字,日期,编号) 配对 → **按日期的时间线**看每天谁要发、发到哪一步。**平台只负责把素材推到手机**(推到 `/sdcard/DCIM/Camera/`、触发相册刷新、把标题写进剪贴板),**抖音里怎么发由你自己在任务里写步骤**(步骤顺序:`读取本机账号名字` → `条件判断`校验 → `推送发布视频` → 你的发布步骤 → `标记发布结果`;页顶部有**「发布任务」**块可一键建好骨架 + 看下次运行/启停,`输入文字` 那一步可以选**取值来源=计划标题/正文**自动填文案并回读校验);发完可抓作品的**分享链接**存下来(平台不长期囤视频,链接才是长期资产,也方便后续铺评论:可一键复制、按日期/名字导出 CSV)。状态区分"**可重试**"(推送阶段失败,还没到抖音)与"**结果未知、需人工确认**"(推送之后出的岔子,绝不自动重发)(见 [DATA_MODEL.md](doc/DATA_MODEL.md) §2.11、[TASK_DEV.md](doc/TASK_DEV.md) §4.7) |
|
||||
| **元素抓取** | 拉取设备 UI 元素树 → 点选回填选择器;支持"点一下"与"测选择器"真机验证 |
|
||||
| **实时看屏** | MJPEG 实时流 + 点击/滑动/按键/文字输入;全屏监控大屏(`/wall`) |
|
||||
| **实时看屏** | MJPEG 实时流 + 点击/滑动/按键/文字输入;全屏监控大屏(`/wall`):每个设备卡片点开就能操作,顶部还有 **「📤 发布」面板** —— 选设备直接把视频/图片推到相册、或把文案写进剪贴板(临时手动推,不占发布计划、不写库) |
|
||||
| **应用管理** | APK 上传/解析/批量安装;设备已装应用与版本查询;剪贴板注入 |
|
||||
| **AI 控制台** | 用自然语言驱动 AI 操作指定设备(MCP 工具 + 截图),流式输出、Markdown 渲染、推理链折叠、token 统计;成功操作自动沉淀「经验库 / 动作库」并在相似任务中召回 |
|
||||
| **MCP 接入** | 20 个 `de_*` 工具,把手机控制开放给外部 AI;写操作有开关、设备忙时拒绝、全量审计 |
|
||||
|
||||
@@ -211,6 +211,8 @@
|
||||
| GET | `/api/adb/devices` | Admin | 维护终端设备列表(本机 adb + 设备池) |
|
||||
| POST | `/api/adb/cmd` | Admin | 执行 adb 命令(20s 超时;拦截 `kill-server`/`disconnect`) |
|
||||
| POST | `/api/tools/clipboard/set` | Admin | 剪贴板注入到多台设备 |
|
||||
| POST | `/api/tools/push_clipboard` | D | 同上(**同一个实现**),权限按设备位放行 —— 大屏「发布」面板用 |
|
||||
| POST | `/api/tools/push_material` | D | 大屏「发布」面板:把上传的视频/图片**直接推到指定设备**相册(不占发布计划) |
|
||||
| POST | `/api/tools/appver` | Admin | 查指定包名在所有在线设备的版本 |
|
||||
|
||||
### 2.8 tailscale(`web/tailscale_api.py`,全部 Admin)
|
||||
@@ -771,6 +773,8 @@
|
||||
| 接口 | 请求 | 说明 |
|
||||
|------|------|------|
|
||||
| `POST /api/tools/clipboard/set` | `{"serials":[…],"text":"…"}` | 设备端 Agent 通道写入并读回校验(旧版独立 ClipInject 自动兜底);serials 空或非列表 → 400 |
|
||||
| `POST /api/tools/push_clipboard` | `{"serial":"…"}`(或 `serials:[…]`)+ `{"text":"…"}` | 与上一条**同一个实现**(`_set_device_clipboard`),权限**设备位**即可 —— 大屏「发布」面板注入剪贴板用 |
|
||||
| `POST /api/tools/push_material` | form-data:`file`(视频/图片)+ `serial` + `album_dir`(可省) | 大屏「发布」面板:推文件到该设备相册(默认 `/sdcard/DCIM/Camera`,文件名带 `wall` 前缀、mtime 改成现在)。**不占位、不写库**;扩展名不在 `MEDIA_EXTS` 或设备不在线 → 400;推完做相册索引校验,返回 `{ok,msg,remote,verify,size}`(`verify=no_index` 时 `ok=true` 但文案带 ⚠) |
|
||||
| `POST /api/tools/appver` | `{"package":"com.xxx"}` | 并发查所有在线设备(≤10 并发);包名须匹配 `^[A-Za-z0-9_.]+$` |
|
||||
|
||||
### Tailscale(全部 Admin)
|
||||
|
||||
@@ -117,6 +117,23 @@ main{position:relative;z-index:2;height:calc(100vh - 88px);overflow-y:auto;paddi
|
||||
.ctrl-tools .text-row{display:flex;gap:6px;flex:1 0 100%;margin-top:2px}
|
||||
.ctrl-tools .text-row input{flex:1;background:#0d1117;border:1px solid var(--card-line);border-radius:8px;padding:6px 10px;font-size:12px;color:var(--text);outline:none;font-family:var(--body)}
|
||||
.ctrl-tools .text-row input:focus{border-color:var(--teal)}
|
||||
/* 大屏「发布」面板:选设备 → 推视频/图片到相册、写剪贴板 */
|
||||
.pub-label{display:block;font-size:12px;font-weight:600;color:var(--muted);margin-bottom:5px}
|
||||
.pub-input{width:100%;box-sizing:border-box;background:#0f1319;border:1px solid var(--card-line);
|
||||
color:#e5e9f0;border-radius:8px;padding:8px 10px;font-size:13px;font-family:var(--body)}
|
||||
select.pub-input{padding:7px 8px}
|
||||
.pub-btn{background:#1a2029;border:1px solid var(--card-line);color:var(--teal);border-radius:8px;
|
||||
padding:8px 14px;font-size:13px;font-weight:600;cursor:pointer;font-family:var(--body);transition:all .12s}
|
||||
.pub-btn:hover{border-color:var(--teal)}
|
||||
.pub-btn:disabled{opacity:.55;cursor:not-allowed}
|
||||
.pub-hint{font-size:11.5px;color:var(--faint);margin-top:5px;line-height:1.55}
|
||||
.pub-log{margin-top:2px;font-size:12px;color:var(--muted);max-height:132px;overflow:auto;
|
||||
background:#0f1319;border:1px solid var(--ink-line);border-radius:8px;padding:8px 10px;
|
||||
white-space:pre-wrap;word-break:break-all}
|
||||
.pub-log:empty{display:none}
|
||||
.pub-log .ok{color:var(--green)}
|
||||
.pub-log .err{color:#f87171}
|
||||
|
||||
/* 滚动条 */
|
||||
::-webkit-scrollbar{width:8px}
|
||||
::-webkit-scrollbar-thumb{background:#232936;border-radius:4px}
|
||||
@@ -131,6 +148,7 @@ main{position:relative;z-index:2;height:calc(100vh - 88px);overflow-y:auto;paddi
|
||||
<input type="checkbox" id="wall-filter-online" checked onchange="refreshStatus()"> 只看在线
|
||||
</label>
|
||||
<button class="wall-btn" id="btn-wake-all" onclick="wakeAll()">💡 一键亮屏</button>
|
||||
<button class="wall-btn" id="btn-publish" onclick="openPublish()" title="选设备 → 注入视频/图片到相册,或注入剪贴板">📤 发布</button>
|
||||
<div class="clock"><span id="wall-clock">--:--:--</span><div class="date" id="wall-date"></div></div>
|
||||
</header>
|
||||
<main id="wall-grid"></main>
|
||||
@@ -167,6 +185,38 @@ main{position:relative;z-index:2;height:calc(100vh - 88px);overflow-y:auto;paddi
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 发布面板(选设备 → 注入素材 / 剪贴板) -->
|
||||
<div class="ctrl-overlay" id="pub-overlay" onclick="if(event.target===this)closePublish()">
|
||||
<div class="ctrl-box" style="width:min(470px,94vw)">
|
||||
<div class="ctrl-head">
|
||||
<div class="info">
|
||||
<div class="name">📤 发布</div>
|
||||
<div class="sub" style="font-size:12px;color:var(--muted)">选设备 → 把视频/图片推到相册,或把文字写进剪贴板</div>
|
||||
</div>
|
||||
<button class="close" onclick="closePublish()">×</button>
|
||||
</div>
|
||||
<div style="padding:14px 16px;display:flex;flex-direction:column;gap:14px;overflow:auto">
|
||||
<div>
|
||||
<label class="pub-label">设备</label>
|
||||
<select class="pub-input" id="pub-serial"></select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="pub-label">视频 / 图片</label>
|
||||
<input type="file" class="pub-input" id="pub-file" accept="video/*,image/*">
|
||||
<div class="pub-hint">推到相册 /sdcard/DCIM/Camera,文件名带 <code>wall</code> 前缀
|
||||
(<b>不占发布计划</b>,不进队列、不改状态;推完在手机相册里就是最新那一个)</div>
|
||||
<button class="pub-btn" style="margin-top:8px" id="pub-push-btn" onclick="pubPushMaterial()">📤 注入素材</button>
|
||||
</div>
|
||||
<div>
|
||||
<label class="pub-label">剪贴板文字</label>
|
||||
<textarea class="pub-input" id="pub-text" rows="3" placeholder="要写进手机剪贴板的文字(发文案用)"></textarea>
|
||||
<button class="pub-btn" style="margin-top:8px" id="pub-clip-btn" onclick="pubPushClipboard()">📋 注入剪贴板</button>
|
||||
</div>
|
||||
<div class="pub-log" id="pub-log"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// ================== 监控大屏(深色控制室) ==================
|
||||
// 状态每 5s 刷新;缩略图每 2.5s 轮询(页面不可见时暂停)
|
||||
@@ -222,6 +272,68 @@ async function refreshStatus(){
|
||||
if(_ctrlSerial)updateCtrlHead();
|
||||
}
|
||||
|
||||
// ================== 发布面板:选设备 → 注入素材 / 剪贴板 ==================
|
||||
// 与「发布计划」页的区别:那里按**计划**推(占位/去重/回写状态、按账号名找设备);
|
||||
// 这里是**临时手动**推一个文件到某台手机,给"手上有一段素材,想马上在这台机器上发"用。
|
||||
function pubLog(msg, cls){
|
||||
const box=document.getElementById('pub-log');
|
||||
const line=document.createElement('div');
|
||||
if(cls)line.className=cls;
|
||||
line.textContent=msg;
|
||||
box.appendChild(line);
|
||||
box.scrollTop=box.scrollHeight;
|
||||
}
|
||||
function openPublish(){
|
||||
const sel=document.getElementById('pub-serial');
|
||||
const keep=sel.value;
|
||||
sel.innerHTML=_devices.map(function(d){
|
||||
const name=d.device_name||d.model||d.serial;
|
||||
return '<option value="'+d.serial+'"'+(d.serial===keep?' selected':'')+'>'
|
||||
+esc(name)+'('+shortIp(d.serial)+')'+(d.present?'':' · 离线')+'</option>';
|
||||
}).join('')||'<option value="">(设备池为空)</option>';
|
||||
document.getElementById('pub-overlay').classList.add('show');
|
||||
}
|
||||
function closePublish(){document.getElementById('pub-overlay').classList.remove('show');}
|
||||
function pubSerial(){return document.getElementById('pub-serial').value||'';}
|
||||
|
||||
async function pubPushMaterial(){
|
||||
const serial=pubSerial();
|
||||
const fi=document.getElementById('pub-file');
|
||||
const f=fi.files&&fi.files[0];
|
||||
if(!serial){pubLog('❌ 先选设备','err');return;}
|
||||
if(!f){pubLog('❌ 先选视频或图片','err');return;}
|
||||
const btn=document.getElementById('pub-push-btn');
|
||||
btn.disabled=true;
|
||||
pubLog('⏳ 正在推到 '+serial+' …('+f.name+' '+(f.size/1048576).toFixed(1)+'MB)');
|
||||
try{
|
||||
const fd=new FormData();
|
||||
fd.append('file',f);
|
||||
fd.append('serial',serial);
|
||||
const r=await fetch('/api/tools/push_material',{method:'POST',
|
||||
headers:{'X-CSRF-Token':_csrfToken},body:fd});
|
||||
const j=await r.json();
|
||||
if(j.ok){pubLog('✅ '+(j.msg||'已推送'),'ok');fi.value='';}
|
||||
else{pubLog('❌ '+(j.error||'推送失败'),'err');}
|
||||
}catch(e){pubLog('❌ 请求失败:'+e,'err');}
|
||||
btn.disabled=false;
|
||||
}
|
||||
|
||||
async function pubPushClipboard(){
|
||||
const serial=pubSerial();
|
||||
const text=(document.getElementById('pub-text').value||'').trim();
|
||||
if(!serial){pubLog('❌ 先选设备','err');return;}
|
||||
if(!text){pubLog('❌ 先填要注入的文字','err');return;}
|
||||
const btn=document.getElementById('pub-clip-btn');
|
||||
btn.disabled=true;
|
||||
pubLog('⏳ 正在注入剪贴板到 '+serial+' …');
|
||||
try{
|
||||
const j=await apiPost('/api/tools/push_clipboard',{serial:serial,text:text});
|
||||
if(j&&j.ok){pubLog('✅ '+(j.msg||'已注入剪贴板'),'ok');}
|
||||
else{pubLog('❌ '+((j&&(j.error||(j.results&&Object.values(j.results)[0]&&Object.values(j.results)[0].msg)))||'注入失败'),'err');}
|
||||
}catch(e){pubLog('❌ 请求失败:'+e,'err');}
|
||||
btn.disabled=false;
|
||||
}
|
||||
|
||||
// 「只看在线」勾选时过滤离线设备(默认勾选)
|
||||
function visibleDevs(devs){
|
||||
const onlyOnline=document.getElementById('wall-filter-online')
|
||||
|
||||
+101
-2
@@ -1,7 +1,11 @@
|
||||
"""工具域 API:adb 终端/剪贴板注入/应用版本。"""
|
||||
"""工具域 API:adb 终端/剪贴板注入/应用版本/大屏「发布」注入素材。"""
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import shlex
|
||||
import tempfile
|
||||
import uuid
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
|
||||
from flask import Blueprint, jsonify, request
|
||||
@@ -11,7 +15,7 @@ from core.adb_helper import adb_connect, adb_connect_light, _ADB_LOCK, _adb
|
||||
from config import ADB_PATH
|
||||
from core.logger import get_logger
|
||||
from web import context
|
||||
from web.auth import admin_required, perm_required
|
||||
from web.auth import admin_required, perm_required, PERM_DEVICES
|
||||
from web.common import _merged_device_list
|
||||
|
||||
_log = get_logger("web")
|
||||
@@ -99,6 +103,101 @@ def _set_device_clipboard(serial, text):
|
||||
return inject_clipboard(serial, text)
|
||||
|
||||
|
||||
@bp.route("/api/tools/push_clipboard", methods=["POST"])
|
||||
@perm_required(PERM_DEVICES)
|
||||
def api_tools_push_clipboard():
|
||||
"""大屏「发布」面板:往指定设备注入剪贴板文字。
|
||||
|
||||
与 `/api/tools/clipboard/set` 是**同一个实现**(`_set_device_clipboard`),
|
||||
只是权限按「设备」位放行 —— 大屏上随手把一段文案抄到手机上,是设备操作,
|
||||
不该要求管理员位。请求:`{"serial": "..."}` 或 `{"serials": ["..."]}`,外加 `text`。
|
||||
"""
|
||||
data = request.json or {}
|
||||
text = (data.get("text") or "").strip()
|
||||
serials = data.get("serials")
|
||||
if not isinstance(serials, list) or not serials:
|
||||
one = (data.get("serial") or "").strip()
|
||||
serials = [one] if one else []
|
||||
if 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"])
|
||||
return jsonify({"ok": ok_count == len(results), "results": results,
|
||||
"ok_count": ok_count, "fail_count": len(results) - ok_count,
|
||||
"msg": "已注入 %d 台" % ok_count if ok_count else "注入失败",
|
||||
"error": None if ok_count == len(results)
|
||||
else "%d 台注入失败" % (len(results) - ok_count)})
|
||||
|
||||
|
||||
@bp.route("/api/tools/push_material", methods=["POST"])
|
||||
@perm_required(PERM_DEVICES)
|
||||
def api_tools_push_material():
|
||||
"""大屏「发布」面板:把上传的**视频/图片直接推到指定那台设备**的相册。
|
||||
|
||||
form-data:`file`(视频/图片)、`serial`、`album_dir`(可省,默认 /sdcard/DCIM/Camera)
|
||||
返回 `{ok, msg, remote, verify}`
|
||||
|
||||
与「发布计划 → 推送」的区别:那条是**按计划**推(原子占位、去重、回写状态、
|
||||
按账号名找设备);这条是**临时手动推一个文件** —— 给"手上有一段素材,想马上
|
||||
在这台机器上发"用,所以**不占位、不写库**,手机相册里就多一个文件
|
||||
(名字带 `wall` 前缀,和计划推送的素材分得开)。
|
||||
|
||||
为什么还要 u2 连接:推完要 `touch` 改时间(相册/抖音按时间倒序,"第一个素材"
|
||||
才是刚推的这个)+ 校验进没进相册索引 —— 这些都走设备 shell,见 publish_flow。
|
||||
"""
|
||||
from core import video_plan as vp
|
||||
from tasks.generic.publish_flow import ALBUM_DIR, push_video, verify_pushed
|
||||
from core.account_probe import _connect
|
||||
|
||||
f = request.files.get("file")
|
||||
serial = (request.form.get("serial") or "").strip()
|
||||
album_dir = (request.form.get("album_dir") or "").strip() or ALBUM_DIR
|
||||
if not serial:
|
||||
return jsonify({"ok": False, "error": "未选择设备"}), 400
|
||||
if not f or not f.filename:
|
||||
return jsonify({"ok": False, "error": "未选择文件"}), 400
|
||||
ext = os.path.splitext(f.filename)[1].lower()
|
||||
if ext not in vp.MEDIA_EXTS:
|
||||
return jsonify({"ok": False, "error": "只支持视频/图片(%s),当前 %s"
|
||||
% ("、".join(vp.MEDIA_EXTS), ext or "无扩展名")}), 400
|
||||
try:
|
||||
online = set(device_pool.list_online())
|
||||
except Exception:
|
||||
online = set()
|
||||
if online and serial not in online:
|
||||
return jsonify({"ok": False, "error": "这台设备当前不在线"}), 400
|
||||
|
||||
tmpdir = tempfile.mkdtemp(prefix="wall_push_")
|
||||
local = os.path.join(tmpdir, vp.safe_name(f.filename))
|
||||
try:
|
||||
f.save(local)
|
||||
size = os.path.getsize(local)
|
||||
d = _connect(serial, timeout=25)
|
||||
remote, err = push_video(d, serial, local, "wall" + uuid.uuid4().hex[:8],
|
||||
album_dir=album_dir)
|
||||
if err:
|
||||
return jsonify({"ok": False, "error": err})
|
||||
verify, vmsg = verify_pushed(d, remote)
|
||||
if verify == "nofile":
|
||||
return jsonify({"ok": False, "error": "推送校验失败:%s" % vmsg})
|
||||
ok = verify == "ok"
|
||||
_log.info("大屏发布:%s ← %s(%d 字节)→ %s [%s]"
|
||||
% (serial, f.filename, size, remote, verify))
|
||||
return jsonify({"ok": True, "remote": remote, "verify": verify, "size": size,
|
||||
"msg": "已推到 %s" % remote if ok else "已推到 %s(⚠ %s)" % (remote, vmsg)})
|
||||
except Exception as e:
|
||||
_log.warning("大屏发布失败: %s: %s" % (type(e).__name__, e))
|
||||
return jsonify({"ok": False, "error": "推送异常: %s: %s"
|
||||
% (type(e).__name__, str(e)[:120])})
|
||||
finally:
|
||||
shutil.rmtree(tmpdir, ignore_errors=True)
|
||||
|
||||
|
||||
def _app_ver_on_device(serial, pkg):
|
||||
"""单设备包版本查询:pm path 检查安装 → dumpsys 取 versionName/versionCode。
|
||||
|
||||
|
||||
Reference in New Issue
Block a user