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
+112
View File
@@ -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')