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:
2026-10-12 14:57:01 +08:00
parent f4641c3e2d
commit 3bd7f636a0
4 changed files with 218 additions and 3 deletions
+101 -2
View File
@@ -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。