Files
auto_control/templates/admin/monitor.html
T
butubb b98e6deac1 feat(发布计划): 视频发布计划(批量上传配对 → 时间线 → 推送到手机 → 发布任务 → 分享链接)
一、平台侧(账号 → 发布计划页)
- 新表 video_plan(schema v9→v10):账号×发布日期×编号 → 素材 + 标题 + 发布状态 + 分享链接;
  状态机 pending/ready/pushing/publishing/done/failed/unknown/skipped(**failed 与 unknown 必须分开**:
  推送阶段的失败可安全重试;碰过抖音之后的岔子只能算"结果未知",绝不自动重发)
- 素材上传:文件名 `手机号_日期_编号`(编号可省)解析配对;标题 txt `标题内容_手机号_日期_编号`;
  内容寻址落盘 data/videos/YYYY-MM/(sha1 分块算,同名不存两份),**不进整库备份**但进 manifest 反查
- 新蓝图 web/video_plan_api.py:上传/时间线/统计/单条增删改/推送到手机/标记结果/裁决/链接导出 CSV/
  任务列表与一键新建、**就地编辑**(GET/PUT /tasks/<id>)、**一键推送**(POST /push_all,按设备分组、设备内串行)
- 账号页拆子分栏(台账 / 发布计划)+ static/admin/release.js;清理 job(04:41 僵尸回收+过期行、04:47 素材文件)
- 上传体积:MAX_CONTENT_LENGTH(默认 2GiB)+ 413 JSON + nginx client_max_body_size(修现有 APK 上传隐患)

二、任务侧(平台推素材,抖音流程你自己写)
- 新步骤 push_release「推送发布视频」:原子占位 → adb push → **touch 改成"现在"** → 清旧目录同名副本 →
  触发扫描并**按路径**校验相册索引 → 标题写进剪贴板;默认目录 /sdcard/DCIM/Camera
- 新步骤 mark_release「标记发布结果」:回写 done/failed/unknown,成功时抓作品分享链接、删手机素材
- input_text 支持 text_source=release_title(自动取计划标题 + 回读校验);
  if_el 的候选值来源新增 release(**本机当前发布计划**的抖音号/昵称,发布前校验"登的是不是要发的号")
- build_release_steps 骨架 15 步:⓪ 亮屏 → ① 打开抖音(等首页) → ② 点「我」→ ③ 等抖音号出现 →
  ④ 条件判断(账号) → then ⑤ 推送 ⑥⑦⑧⑨⑩⑪⑫ 抖音点击/填标题 → ⑬ 标记 / else 发通知跳过

三、修(推送这一路的检测机制)
- **uiautomator2 3.x 的 d.shell() 返回 ShellResponse(tuple 子类)不是 str**:`'x' in resp` 恒 False、
  `.strip()` 不存在 → "推上去的文件大小不对"每次都判失败(文件其实推上去了)、相册校验永远报没进、
  删除确认永远判没删掉。新增 publish_flow._sh() 统一取 .output;大小改成解析 ls -l 的大小列
- **adb push 保留本地 mtime** → 推 3 天前上传的素材在按时间排序的相册里排不到最前,
  "点第一个 = 刚推的那个"不成立 → 推完 touch
- 相册校验**按路径**比(MediaStore 的 _data 会把目录小写、/storage/emulated/0 ≡ /sdcard),
  只比文件名会被老目录的同名残留骗过去
- 屏幕没亮就启动抖音会永远停在启动页(UI 树为空)→ 后面"点我/等抖音号"必然 miss,
  最后报成误导人的"账号不符" → 骨架第一步固定加「亮屏」,open_app 等「首页」出现

四、其它
- core/ledger.serial_of():设备名 → 当前地址(设备换 IP 后快照是错的)
- 通知事件 task.video.published / task.video.failed;备份清单加 video_plan 与素材统计
- 文档同步:DATA_MODEL §2.11 + schema v10、API(新接口与语义)、TASK_DEV §4.7 专章、
  ARCHITECTURE(账号页子分栏/release.js/两个 job)、DEPLOY(表数/nginx)、NOTIFY、DEVELOPMENT、README
2026-09-28 15:59:09 +08:00

1617 lines
108 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% if db_env and not db_env.is_prod %}{{ db_env.env }}-{% endif %}设备自动化后台</title>
<style>
/* ========== 仪表控制台主题(Instrument Console) ==========
自托管字体:Bricolage Grotesque(展示/数字)+ IBM Plex Mono(数据/串号)
配色:暖纸底 + 墨色文字 + 深青主色;状态色 = 绿/红/琥珀/靛 */
@font-face{font-family:'Bricolage Grotesque';font-style:normal;font-weight:500;font-display:swap;src:url('/static/fonts/bricolage-500.woff2') format('woff2')}
@font-face{font-family:'Bricolage Grotesque';font-style:normal;font-weight:700;font-display:swap;src:url('/static/fonts/bricolage-700.woff2') format('woff2')}
@font-face{font-family:'Bricolage Grotesque';font-style:normal;font-weight:800;font-display:swap;src:url('/static/fonts/bricolage-800.woff2') format('woff2')}
@font-face{font-family:'IBM Plex Mono';font-style:normal;font-weight:400;font-display:swap;src:url('/static/fonts/plexmono-400.woff2') format('woff2')}
@font-face{font-family:'IBM Plex Mono';font-style:normal;font-weight:500;font-display:swap;src:url('/static/fonts/plexmono-500.woff2') format('woff2')}
@font-face{font-family:'IBM Plex Mono';font-style:normal;font-weight:600;font-display:swap;src:url('/static/fonts/plexmono-600.woff2') format('woff2')}
:root{
--primary:#0f766e; --primary-dark:#115e59; --primary-soft:#e6f4f1;
--success:#16a34a; --success-soft:#e8f7ee;
--danger:#dc2626; --danger-soft:#fdecec;
--warning:#d97706; --warning-soft:#fdf3e3;
--info:#4f46e5; --info-soft:#ecebfb;
--bg:#f5f4ef; --card:#ffffff; --border:#e6e3da; --border-strong:#d8d4c8;
--text:#1b1e24; --text-muted:#69707c; --text-light:#9aa1ab;
--ink:#14161a; --ink-2:#1e2127; --ink-line:#2a2e37;
--display:'Bricolage Grotesque','PingFang SC','Microsoft YaHei',sans-serif;
--mono:'IBM Plex Mono','SF Mono',Menlo,Consolas,monospace;
--body:-apple-system,'PingFang SC','Microsoft YaHei','Segoe UI',sans-serif;
--shadow-sm:0 1px 2px rgba(27,30,36,.05),0 1px 3px rgba(27,30,36,.06);
--shadow-md:0 4px 14px rgba(27,30,36,.08),0 2px 4px rgba(27,30,36,.05);
--shadow-lg:0 20px 50px rgba(20,22,26,.18);
--radius:10px;
}
*{box-sizing:border-box;margin:0;padding:0}
html{-webkit-font-smoothing:antialiased}
body{background:var(--bg);font-family:var(--body);color:var(--text);font-size:14px;line-height:1.5}
::-webkit-scrollbar{width:9px;height:9px}
::-webkit-scrollbar-thumb{background:#c9c5b9;border-radius:5px;border:2px solid var(--bg)}
::-webkit-scrollbar-thumb:hover{background:#b0ab9d}
::-webkit-scrollbar-track{background:transparent}
/* 顶部导航:墨色 + 顶部青线 + 品牌脉冲点 */
.navbar{background:linear-gradient(180deg,#1a1d23 0%,#14161a 100%);border-top:3px solid var(--primary);padding:0 28px;display:flex;align-items:center;height:58px;box-shadow:0 2px 10px rgba(0,0,0,.2);position:sticky;top:0;z-index:500}
.navbar .brand{color:#f3f4f6;font-family:var(--display);font-weight:700;font-size:16px;letter-spacing:.3px;margin-right:34px;white-space:nowrap;display:flex;align-items:center;gap:9px}
.brand-dot{width:9px;height:9px;border-radius:50%;background:var(--primary);box-shadow:0 0 0 0 rgba(15,118,110,.6);animation:brandPulse 2.4s infinite}
@keyframes brandPulse{0%{box-shadow:0 0 0 0 rgba(15,118,110,.45)}70%{box-shadow:0 0 0 8px rgba(15,118,110,0)}100%{box-shadow:0 0 0 0 rgba(15,118,110,0)}}
.navbar .tabs{display:flex;gap:2px;flex:1;align-self:stretch}
.env-badge{margin-right:18px;padding:3px 10px;border-radius:10px;font-size:11px;font-weight:700;letter-spacing:.5px;background:#2b3038;color:#9aa3af;border:1px solid #3a4048;white-space:nowrap}
.env-badge-prod{background:#4a1519;color:#ff9b9b;border-color:#8a2b31}
.navbar .tab{position:relative;padding:0 18px;color:#9aa3af;font-size:14px;font-weight:500;cursor:pointer;border:none;background:none;transition:color .15s;letter-spacing:.2px}
.navbar .tab::after{content:"";position:absolute;left:14px;right:14px;bottom:0;height:2px;border-radius:2px;background:var(--primary);transform:scaleX(0);transition:transform .18s}
.navbar .tab:hover{color:#e5e7eb}
.navbar .tab.active{color:#fff}
.navbar .tab.active::after{transform:scaleX(1)}
.navbar .user-info{color:#8a93a0;font-size:13px;display:flex;align-items:center;gap:14px}
.navbar .user-info a{color:#c2c9d3;text-decoration:none;font-size:13px;transition:color .15s}
.navbar .user-info a:hover{color:#fff}
/* 内容区 */
.content{max-width:1600px;margin:0 auto;padding:26px 30px 60px}
.tab-panel{display:none}
.tab-panel.active{display:block;animation:panelIn .22s ease-out}
@keyframes panelIn{from{opacity:0;transform:translateY(6px)}to{opacity:1;transform:none}}
.page-title{font-family:var(--display);font-size:21px;font-weight:800;letter-spacing:.2px;color:var(--text);margin:0 0 4px}
.page-sub{font-size:13px;color:var(--text-light);margin-bottom:22px}
/* 统计卡片:左侧色条 + 等宽数字 */
.stats-row{display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:14px;margin-bottom:26px}
.stat-card{background:var(--card);border-radius:var(--radius);padding:15px 18px;box-shadow:var(--shadow-sm);border:1px solid var(--border);position:relative;overflow:hidden;transition:transform .15s,box-shadow .15s}
.stat-card:hover{transform:translateY(-2px);box-shadow:var(--shadow-md)}
.stat-card::before{content:"";position:absolute;left:0;top:0;bottom:0;width:4px;background:var(--accent,#0f766e);border-radius:0 3px 3px 0}
.stat-card .stat-num{font-family:var(--mono);font-size:25px;font-weight:600;line-height:1.1;margin-bottom:5px;color:var(--accent,#0f766e);font-variant-numeric:tabular-nums}
.stat-card .stat-lbl{font-size:12px;color:var(--text-muted);font-weight:500;letter-spacing:.2px}
/* 工具栏 */
.toolbar{display:flex;gap:10px;margin-bottom:16px;align-items:center;flex-wrap:wrap}
.toolbar .hint{color:var(--text-light);font-size:12px;margin-left:auto}
/* 按钮 */
.btn{border-radius:8px;font-weight:500;border:1px solid var(--border-strong);padding:7px 16px;font-size:13px;transition:all .15s;cursor:pointer;background:var(--card);color:var(--text);box-shadow:var(--shadow-sm)}
.btn:hover{background:#faf9f5;border-color:#c4bfb2;transform:translateY(-1px)}
.btn:active{transform:none}
.btn:disabled{opacity:.5;cursor:not-allowed;transform:none}
.btn-primary{background:var(--primary);border-color:var(--primary);color:#fff;box-shadow:0 1px 4px rgba(15,118,110,.35)}
.btn-primary:hover{background:var(--primary-dark);border-color:var(--primary-dark)}
.btn-danger{background:var(--danger);border-color:var(--danger);color:#fff;box-shadow:0 1px 4px rgba(220,38,38,.3)}
.btn-danger:hover{background:#b91c1c;border-color:#b91c1c}
.btn-success{background:var(--success);border-color:var(--success);color:#fff}
.btn-success:hover{background:#15803d;border-color:#15803d}
.btn-sm{padding:4px 12px;font-size:12px}
.btn-xs{padding:3px 10px;font-size:12px;border-radius:6px}
/* 表单 */
.form-control{background:#fff;border:1px solid var(--border-strong);border-radius:8px;padding:7px 12px;font-size:13px;color:var(--text);transition:border-color .15s,box-shadow .15s;outline:none;font-family:var(--body)}
.form-control:focus{border-color:var(--primary);box-shadow:0 0 0 3px rgba(15,118,110,.12)}
.form-control::placeholder{color:var(--text-light)}
.form-group{margin-bottom:12px}
.form-group label{display:block;font-size:12px;font-weight:600;color:var(--text-muted);margin-bottom:5px}
.form-row{display:grid;grid-template-columns:1fr 1fr;gap:12px}
.form-check{display:flex;align-items:center;gap:7px;cursor:pointer;font-size:13px}
.form-check input{width:15px;height:15px;accent-color:var(--primary);cursor:pointer}
/* 通用列表分页栏 */
.list-pager{display:flex;align-items:center;gap:6px;flex-wrap:wrap;margin:10px 0 22px}
.list-pager .lp-total{font-size:12px;color:var(--text-light);margin-right:auto}
.list-search{width:240px}
/* 控制台/日志(墨色终端) */
.adb-console{background:var(--ink);color:#d5dae1;font-family:var(--mono);font-size:12.5px;padding:14px;border-radius:10px;min-height:200px;max-height:420px;overflow:auto;white-space:pre-wrap;word-break:break-all;box-shadow:inset 0 1px 6px rgba(0,0,0,.4);border:1px solid var(--ink-line)}
.log-content{background:var(--ink);color:#d5dae1;border-radius:10px;padding:16px;font-family:var(--mono);font-size:12.5px;line-height:1.65;max-height:600px;overflow-y:auto;white-space:pre-wrap;word-break:break-all;border:1px solid var(--ink-line);box-shadow:inset 0 1px 6px rgba(0,0,0,.4)}
.log-content .log-err{color:#f87171}
.log-content .log-warn{color:#fbbf24}
.log-content .log-info{color:#67e8f9}
.log-row{white-space:pre-wrap}
.log-cont{color:#8a94a6;padding-left:2.2em} /* 续行(traceback 缩进行):淡色缩进 */
.log-hit{background:#fbbf2433} /* 关键字命中高亮 */
/* 「公共巡检」配置块(任务编辑器里,独立于步骤画布) */
.patrol-box{margin-top:14px;border:1px solid var(--border-strong);border-radius:10px;padding:12px}
.patrol-head{display:flex;align-items:center;justify-content:space-between;margin-bottom:8px}
.patrol-head>span:first-child{font-weight:600;font-size:13px}
.patrol-card{border:1px solid var(--border);border-radius:8px;padding:10px;margin-bottom:8px;background:var(--surface-2,#fafafa)}
.patrol-row{display:flex;gap:10px;align-items:flex-end;margin-bottom:8px;flex-wrap:wrap}
.patrol-row:last-child{margin-bottom:0}
.patrol-row .pg{display:flex;flex-direction:column;gap:2px;min-width:110px}
.patrol-row .pg label{font-size:11px;color:var(--text-light)}
.patrol-row .form-check{margin-bottom:6px;display:flex;align-items:center;gap:4px;font-size:12px}
.patrol-cond{background:rgba(59,130,246,.05);border-radius:6px;padding:6px 8px;margin:0 -2px 8px}
/* 「步骤明细」表格的结果列配色(.log-* 那套作用域在 .log-content 里,表格用这套) */
.res-error{color:#dc2626;font-weight:600} /* 异常 / 未知步骤类型 */
.res-miss{color:#d97706} /* 未命中 / 超时 / 已达上限 */
.res-skip{color:var(--text-light)} /* 概率未触发 */
.res-ok{color:#059669}
/* 工具栏:控件较多,允许换行;控件本身不参与收缩(否则会被挤成竖排文字) */
.log-toolbar{display:flex;flex-wrap:wrap;gap:8px 10px;margin-bottom:12px;align-items:center}
.log-toolbar>*{flex:0 0 auto}
.log-toolbar .hint{flex:1 1 200px;min-width:160px}
/* 区块标题 */
.section-title{font-family:var(--display);font-size:15px;font-weight:700;color:var(--text);margin:0 0 12px;padding-bottom:8px;border-bottom:2px solid var(--primary);display:inline-block;letter-spacing:.2px}
/* 页内子分栏 */
.sub-tabs{display:flex;gap:2px;margin-bottom:18px;border-bottom:1px solid var(--border-strong)}
.sub-tab{padding:10px 16px;font-size:13px;font-weight:600;color:var(--text-muted);background:none;border:none;cursor:pointer;border-radius:8px 8px 0 0;transition:all .15s;position:relative}
.sub-tab:hover{color:var(--text);background:#efede6}
.sub-tab.active{color:var(--primary)}
.sub-tab.active::after{content:"";position:absolute;left:10px;right:10px;bottom:-1px;height:2px;background:var(--primary);border-radius:2px}
.sub-panel{display:none}
.sub-panel.active{display:block;animation:panelIn .2s ease-out}
/* 表格 */
.table{background:var(--card);border-radius:var(--radius);overflow:hidden;box-shadow:var(--shadow-sm);border:1px solid var(--border);width:100%;border-collapse:collapse;margin-bottom:26px}
.table thead th{background:#faf9f5;border-bottom:1px solid var(--border-strong);font-weight:600;color:var(--text-muted);font-size:12px;letter-spacing:.4px;padding:12px 14px;text-align:left;white-space:nowrap}
.table tbody td{padding:11px 14px;border-bottom:1px solid var(--border);vertical-align:middle;font-size:13px}
.table tbody tr:last-child td{border-bottom:none}
.table tbody tr{transition:background .1s}
.table tbody tr:hover td{background:#faf9f4}
.table thead th.sortable{cursor:pointer;user-select:none}
.table thead th.sortable:hover{background:#f3f1e9}
.table thead th.sortable::after{content:" ⇅";color:#c6c1b4;font-weight:400;font-size:11px}
.table thead th.sortable.sort-asc::after{content:" ↑";color:var(--primary);font-weight:700}
.table thead th.sortable.sort-desc::after{content:" ↓";color:var(--primary);font-weight:700}
.table tbody tr.row-checked td{background:var(--primary-soft)}
.table .col-cb{width:38px;text-align:center}
.table .col-cb input,.form-check input[type=checkbox],.device-list input{width:15px;height:15px;accent-color:var(--primary);cursor:pointer}
/* 徽章(柔和底 + 深色字) */
.label{padding:3px 10px;border-radius:999px;font-size:11px;font-weight:600;display:inline-block;line-height:1.5;letter-spacing:.2px}
.label-success{background:var(--success-soft);color:#15803d}
.label-default{background:#f1efe8;color:var(--text-muted)}
.label-warning{background:var(--warning-soft);color:#b45309}
.label-primary{background:var(--primary-soft);color:#0f766e}
.label-info{background:var(--info-soft);color:#4338ca}
.label-danger{background:var(--danger-soft);color:#b91c1c}
/* 进度条 */
.progress{background:#e9e6dd;border-radius:99px;height:6px;overflow:hidden;display:inline-block;vertical-align:middle;width:70px}
.progress-bar{background:var(--primary);height:100%;border-radius:99px;transition:width .3s}
/* 圆点 */
.dot{display:inline-block;width:8px;height:8px;border-radius:50%;margin-right:5px}
.dot-on{background:var(--success);box-shadow:0 0 6px rgba(22,163,74,.55)}
.dot-off{background:var(--danger);opacity:.55}
/* 任务卡片 */
.jobs-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(350px,1fr));gap:14px}
.job-card{background:var(--card);border:1px solid var(--border);border-radius:var(--radius);padding:15px 16px;box-shadow:var(--shadow-sm);transition:all .18s;position:relative}
.job-card:hover{box-shadow:var(--shadow-md);transform:translateY(-2px)}
.job-card.running{border-color:rgba(22,163,74,.55);box-shadow:0 0 0 1px rgba(22,163,74,.35),var(--shadow-sm)}
.job-card .job-head{display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:9px}
.job-card .job-name{font-size:14px;font-weight:600;color:var(--text)}
.job-card .job-type{font-size:11px;color:var(--text-muted);background:#f1efe8;padding:2px 8px;border-radius:99px;margin-top:3px;display:inline-block;font-weight:500}
.job-card .job-body{font-size:12px;color:var(--text-muted);margin-bottom:11px;line-height:1.65}
.job-card .job-body .kv{display:inline-block;background:#faf9f5;border:1px solid var(--border);border-radius:6px;padding:1px 7px;margin:2px 3px 0 0;font-family:var(--mono);font-size:11px}
.job-card .job-actions{display:flex;gap:6px;flex-wrap:wrap}
/* 任务覆盖的设备(监控页任务概况) */
.job-cov{margin-top:6px}
.cov-chips{display:flex;flex-wrap:wrap;gap:4px;margin-top:4px}
.cov-chip{font-family:var(--mono);font-size:11px;padding:1px 7px;border-radius:9px;border:1px solid;white-space:nowrap;cursor:default}
.cov-chip.online{color:#0e7490;background:#e0f2fe;border-color:#bae6fd}
.cov-chip.running{color:#b45309;background:#fef3c7;border-color:#fde68a}
.cov-chip.offline{color:#9ca3af;background:#f3f4f6;border-color:#e5e7eb;text-decoration:line-through}
.cov-note{font-size:11px;color:#94a3b8;margin-left:4px}
/* 空状态 / 辅助文本 */
.empty{text-align:center;padding:42px;color:var(--text-light);font-size:13px}
.text-muted{color:var(--text-light)!important}
.err-text{color:var(--danger);font-size:12px;max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.pulse{animation:pulse 2s infinite}
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.45}}
.help{font-size:12px;color:var(--text-light);line-height:1.7;background:#faf9f4;border:1px dashed var(--border-strong);border-radius:8px;padding:10px 12px;margin:10px 0}
.help code{font-family:var(--mono);background:#f1efe8;padding:1px 5px;border-radius:4px;font-size:11px;color:var(--primary-dark)}
/* 模态框 */
.modal-overlay{display:none;position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(20,22,26,.5);backdrop-filter:blur(2px);z-index:1000;justify-content:center;align-items:flex-start;padding:40px 20px;overflow-y:auto}
.modal-overlay.show{display:flex}
.modal{background:var(--card);border-radius:14px;box-shadow:var(--shadow-lg);width:100%;max-width:640px;margin-bottom:40px;border:1px solid var(--border);animation:modalIn .18s ease-out}
@keyframes modalIn{from{opacity:0;transform:translateY(10px) scale(.985)}to{opacity:1;transform:none}}
.modal-header{padding:18px 24px;border-bottom:1px solid var(--border);display:flex;justify-content:space-between;align-items:center}
.modal-header h3{font-size:17px;font-weight:700;color:var(--text);font-family:var(--display)}
.modal-header .btn{background:none;border:none;font-size:20px;color:var(--text-light);padding:0 6px;box-shadow:none}
.modal-header .btn:hover{color:var(--text)}
.modal-body{padding:20px 24px}
.modal-footer{padding:14px 24px;border-top:1px solid var(--border);display:flex;justify-content:flex-end;gap:8px}
.shot-modal{max-width:none;width:min(440px,95vw)}
.wide-modal{max-width:1000px}
.shot-modal .modal-body{text-align:center;padding:14px}
.shot-img{max-width:100%;max-height:calc(100vh - 240px);width:auto;height:auto;border-radius:8px;box-shadow:0 2px 10px rgba(0,0,0,.2);background:var(--ink);min-height:200px;display:block;margin:0 auto}
.shot-loading{color:var(--text-light);padding:60px 0;font-size:13px}
.shot-err{color:var(--danger);padding:40px 0;font-size:13px;white-space:pre-wrap;word-break:break-all}
.shot-info{font-size:12px;color:var(--text-muted);margin-top:10px}
.shot-toolbar{display:flex;gap:8px;justify-content:center;align-items:center;margin-top:12px;flex-wrap:wrap}
.shot-toolbar .auto-label{font-size:12px;color:var(--text-muted);display:flex;align-items:center;gap:4px}
/* Toast */
.toast{position:fixed;top:20px;right:20px;z-index:2000;padding:12px 20px;border-radius:10px;font-size:14px;font-weight:500;box-shadow:var(--shadow-lg);display:none;max-width:400px;border-left:4px solid;animation:toastIn .18s ease-out}
.toast.show{display:block}
@keyframes toastIn{from{opacity:0;transform:translateX(14px)}to{opacity:1;transform:none}}
.toast-success{background:#f0faf4;color:#166534;border-color:var(--success)}
.toast-error{background:#fef2f2;color:#991b1b;border-color:var(--danger)}
/* 设备多选列表 */
.device-list{max-height:200px;overflow-y:auto;border:1px solid var(--border-strong);border-radius:8px;padding:8px;background:#fff}
.device-list label{display:flex;align-items:center;gap:6px;padding:4px 8px;font-size:13px;font-weight:400;cursor:pointer;border-radius:6px}
.device-list label:hover{background:#f5f3ec}
/* 步骤编辑器 */
.step-editor{display:none;border:1px solid var(--border);border-radius:12px;overflow:hidden;margin-top:8px;background:#fff;box-shadow:var(--shadow-sm)}
.step-editor.show{display:block}
.step-editor .se-head{background:#faf9f5;padding:10px 14px;font-weight:600;font-size:13px;display:flex;justify-content:space-between;align-items:center;border-bottom:1px solid var(--border)}
.step-editor .se-body{display:grid;grid-template-columns:200px 1fr;min-height:280px}
.step-editor .se-lib{border-right:1px solid var(--border);padding:8px;background:#faf9f4;overflow-y:auto;max-height:520px}
.step-editor .se-lib .lib-title{font-size:11px;color:var(--text-light);font-weight:600;margin-bottom:6px;text-transform:uppercase;letter-spacing:.6px}
.step-editor .se-lib .lib-item{padding:6px 8px;border-radius:6px;cursor:grab;font-size:12px;color:var(--text);display:flex;align-items:center;gap:6px;margin-bottom:2px;transition:background .1s;white-space:nowrap;overflow:hidden}
.step-editor .se-lib .lib-item span:last-child{overflow:hidden;text-overflow:ellipsis}
.step-editor .se-lib .lib-item:hover{background:#ece9e0}
.step-editor .se-lib .lib-item:active{cursor:grabbing}
.step-editor .se-lib .lib-item .ico{width:18px;height:18px;display:inline-flex;align-items:center;justify-content:center;background:var(--primary);color:#fff;border-radius:5px;font-size:11px;flex-shrink:0}
.step-editor .se-lib .lib-section{margin-top:8px;padding-top:6px;border-top:1px solid var(--border)}
.step-editor .se-lib .lib-item.custom .ico{background:#7c3aed}
.step-editor .se-lib .lib-item.custom .del-btn{margin-left:auto;background:none;border:none;color:var(--text-light);cursor:pointer;font-size:13px;padding:0 2px}
.step-editor .se-lib .lib-item.custom .del-btn:hover{color:var(--danger)}
.step-editor .se-canvas{padding:8px;overflow-y:auto;max-height:480px}
.step-editor .se-empty{text-align:center;color:var(--text-light);padding:40px 20px;font-size:13px}
.step-editor .se-empty .hint{font-size:11px;margin-top:4px}
.se-toolbar{display:flex;gap:6px;padding:6px 8px;border-bottom:1px solid var(--border);background:#faf9f5}
.se-toolbar .btn{padding:4px 10px;font-size:12px}
.step-card{background:#fff;border:1px solid var(--border);border-radius:8px;margin-bottom:6px;transition:box-shadow .15s}
.step-card.drag-over{border-color:var(--primary);box-shadow:0 0 0 2px rgba(15,118,110,.18)}
.step-card.dragging{opacity:.5}
.step-card .sc-head{display:flex;align-items:center;gap:8px;padding:7px 10px;cursor:grab}
.step-card .sc-head:active{cursor:grabbing}
.step-card .sc-ico{width:22px;height:22px;display:inline-flex;align-items:center;justify-content:center;background:var(--primary);color:#fff;border-radius:6px;font-size:12px;flex-shrink:0}
.step-card .sc-label{flex:1;font-size:13px;font-weight:500;color:var(--text)}
.step-card .sc-type{font-size:11px;color:var(--text-light);background:#f1efe8;padding:1px 6px;border-radius:4px}
.step-card .sc-actions{display:flex;gap:2px}
.step-card .sc-actions button{background:none;border:none;color:var(--text-light);cursor:pointer;padding:2px 5px;font-size:14px;border-radius:4px;line-height:1}
.step-card .sc-actions button:hover{background:#f1efe8;color:var(--text)}
.step-card .sc-body{display:none;padding:8px 10px;border-top:1px solid var(--border);background:#faf9f4}
.step-card.expanded .sc-body{display:block}
.step-card .sc-body .form-row{grid-template-columns:1fr 1fr 1fr;gap:8px}
.step-card .sc-body .form-group{margin-bottom:8px}
.step-card .sc-body .form-group label{font-size:11px;margin-bottom:2px}
.step-card .sc-body .form-control{padding:5px 8px;font-size:12px}
.step-card.loop-block{border-left:3px solid var(--success)}
.step-card.loop-block .sc-ico{background:var(--success)}
.step-card.group-block{border-left:3px solid #7c3aed}
.step-card.group-block .sc-ico{background:#7c3aed}
.step-card.if-block{border-left:3px solid var(--warning)}
.step-card.if-block .sc-ico{background:var(--warning)}
.step-card.selected{border-color:var(--primary);box-shadow:0 0 0 2px rgba(15,118,110,.22)}
.step-children{margin-left:20px;margin-top:6px;border-left:2px dashed var(--border-strong);padding-left:8px}
.if-branch{margin-top:6px}
.if-branch .branch-title{font-size:11px;font-weight:600;padding:3px 10px;border-radius:4px;margin-bottom:4px;display:inline-block}
.if-branch .branch-title.then{color:#15803d;background:var(--success-soft)}
.if-branch .branch-title.else{color:#b91c1c;background:var(--danger-soft)}
.step-children.drag-over{border-left-color:var(--primary);background:rgba(15,118,110,.06);border-radius:6px}
#se-canvas.drag-over{outline:2px dashed var(--primary);outline-offset:2px;border-radius:8px}
.sel-picker{display:flex;gap:4px;align-items:center}
.sel-picker input{flex:1}
.sel-picker .btn-pick{padding:5px 8px;font-size:11px;white-space:nowrap}
/* 元素抓取弹窗 */
.el-device-grid{padding:16px;overflow-y:auto;flex:1}
.el-device-grid .egd-title{font-size:14px;font-weight:600;margin-bottom:12px;color:var(--text)}
.el-device-card{background:var(--card);border:1px solid var(--border);border-radius:10px;padding:14px;cursor:pointer;transition:all .15s;display:flex;align-items:center;gap:12px;margin-bottom:8px;box-shadow:var(--shadow-sm)}
.el-device-card:hover{border-color:var(--primary);box-shadow:var(--shadow-md);transform:translateY(-1px)}
.el-device-card .edc-icon{width:36px;height:36px;background:#f1efe8;border-radius:8px;display:flex;align-items:center;justify-content:center;font-size:18px;flex-shrink:0}
.el-device-card .edc-info{flex:1}
.el-device-card .edc-name{font-size:13.5px;font-weight:700;color:var(--text)}
.el-device-card .edc-serial{font-size:12px;color:var(--text-light);margin-top:2px;font-family:var(--mono)}
.el-device-card .edc-model{font-size:12px;color:var(--text-light);margin-top:2px}
.el-device-card .edc-status{font-size:11px;padding:2px 10px;border-radius:99px;background:var(--success-soft);color:#15803d;flex-shrink:0;font-weight:600}
.el-picker-main{display:flex;flex:1;overflow:hidden}
.el-shot-wrap{flex:1 1 auto;min-width:0;border-right:1px solid var(--border);background:var(--ink);display:flex;flex-direction:column}
.el-shot-toolbar{padding:6px 10px;background:var(--ink-2);display:flex;gap:6px;align-items:center;border-bottom:1px solid var(--ink-line)}
.el-shot-toolbar .btn{padding:3px 10px;font-size:12px;background:#39414d;color:#e5e7eb;border:none;box-shadow:none}
.el-shot-toolbar .btn:hover{background:#4b5563}
.el-shot-toolbar .el-shot-info{color:#9aa3af;font-size:11px;flex:1}
.el-shot-container{flex:1;overflow:auto;display:flex;align-items:flex-start;justify-content:center;padding:8px;position:relative}
.el-shot-container img{max-width:100%;height:auto;border-radius:4px;display:block}
.el-shot-container .el-shot-loading{color:#8a93a0;font-size:13px;padding:40px}
.el-shot-overlay{position:absolute;border:1px solid rgba(103,232,249,.45);background:transparent;cursor:pointer;border-radius:2px;transition:all .1s}
.el-shot-hover{position:absolute;outline:2px solid rgba(248,113,113,.9);outline-offset:-1px;background:rgba(248,113,113,.15);pointer-events:none;border-radius:2px;z-index:5}
.el-shot-warn{background:#fff3cd;border-bottom:1px solid #ffda6a;color:#7a5b00;font-size:12px;padding:6px 10px}
.el-shot-overlay:hover{border-color:#67e8f9;background:rgba(103,232,249,.18)}
.el-shot-overlay.active{outline:2px solid #f87171;outline-offset:-1px;background:rgba(248,113,113,.22)}
.el-list-wrap{flex:0 0 460px;display:flex;flex-direction:column;overflow:hidden;border-left:1px solid var(--border)}
.el-tabs{display:flex;border-bottom:1px solid var(--border);background:var(--ink-2);flex:none}
.el-tab{padding:7px 14px;font-size:12.5px;color:var(--text-light);cursor:pointer;border:none;background:none;border-bottom:2px solid transparent}
.el-tab.active{color:var(--primary);border-bottom-color:var(--primary);font-weight:600}
.el-pane{flex:1;overflow:auto;display:none}
.el-pane.active{display:block}
.el-attr{display:flex;gap:8px;padding:5px 12px;border-bottom:1px solid var(--border);font-size:12px}
.el-attr b{color:var(--text-light);font-weight:500;flex:0 0 96px;font-family:var(--mono)}
.el-attr span{color:var(--text);word-break:break-all;flex:1}
.el-color-row{display:flex;align-items:center;gap:8px;padding:6px 12px;font-size:12px;border-bottom:1px solid var(--border)}
.el-swatch{width:22px;height:22px;border-radius:4px;border:1px solid var(--border);flex:none}
.el-picker-search{width:100%;padding:8px 12px;border:none;border-bottom:1px solid var(--border);font-size:13px;outline:none;font-family:var(--body)}
.el-picker-list{flex:1;overflow-y:auto}
.el-picker-item{padding:8px 12px;border-bottom:1px solid var(--border);cursor:pointer;font-size:12px;display:flex;gap:8px;align-items:flex-start}
.el-picker-item:hover{background:var(--primary-soft)}
.el-picker-item.active{background:var(--primary-soft);box-shadow:inset 3px 0 0 var(--primary)}
.el-picker-item:last-child{border-bottom:none}
.el-picker-item .ep-depth{color:var(--text-light);font-family:var(--mono);flex-shrink:0;width:28px}
.el-picker-item .ep-info{flex:1;min-width:0}
.el-picker-item .ep-info .ep-name{color:var(--text);font-weight:500;word-break:break-all}
.el-picker-item .ep-info .ep-attrs{color:var(--text-light);font-size:11px;margin-top:2px;word-break:break-all}
.el-picker-item .ep-info .ep-attrs span{margin-right:8px}
/* 文字/content-desc 是最稳的定位依据,加粗上色让它一眼可见(id 退为补充信息) */
.el-picker-item .ep-info .ep-attrs .ep-text b{color:#0f766e;font-weight:600}
.el-picker-item .ep-info .ep-attrs .ep-desc b{color:#7c3aed;font-weight:600}
.el-picker-item .ep-info .ep-attrs .ep-id{font-family:var(--mono);opacity:.8}
.el-picker-item .ep-sel{font-size:10px;color:#fff;background:var(--primary);padding:1px 7px;border-radius:99px;flex-shrink:0;font-weight:600}
/* ===== AI 控制台(聊天界面,浅色内容区——DeepSeek 风格,图片文字清晰) ===== */
.agent-body{display:flex;flex:1;min-height:0}
.agent-col-sessions{width:236px;border-right:1px solid #e5e7eb;background:#f6f7f9;display:flex;flex-direction:column;min-width:0}
.agent-sess-head{padding:10px;border-bottom:1px solid #e5e7eb}
.agent-sess-list{flex:1;overflow-y:auto;padding:6px}
.agent-sess-item{padding:8px 10px;border-radius:9px;cursor:pointer;margin-bottom:2px;border:1px solid transparent;position:relative}
.agent-sess-item:hover{background:#eceff3}
.agent-sess-item.active{background:#e0f2fe;border-color:#bae6fd}
.agent-sess-item .t{font-size:12.5px;color:#1f2937;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;padding-right:20px}
.agent-sess-item .m{font-size:10.5px;color:#9ca3af;margin-top:2px}
.agent-sess-del{position:absolute;right:6px;top:6px;visibility:hidden;background:transparent;border:none;color:#9ca3af;cursor:pointer;font-size:12px;padding:2px 5px;border-radius:5px}
.agent-sess-item:hover .agent-sess-del{visibility:visible}
.agent-sess-del:hover{background:#fecaca;color:#b91c1c}
.agent-col-chat{flex:1;display:flex;flex-direction:column;min-width:0}
.agent-col-live{width:300px;border-left:1px solid #e5e7eb;background:#fff;display:flex;flex-direction:column;padding:10px}
.agent-live-head{font-size:12.5px;font-weight:600;color:#374151;display:flex;align-items:center;justify-content:space-between;margin-bottom:8px}
.agent-live-serial{font-family:var(--mono);font-size:10.5px;color:#6b7280}
.agent-live-body{flex:1;background:#0b0f14;border-radius:10px;display:flex;align-items:center;justify-content:center;overflow:hidden;min-height:200px}
.agent-live-body img{width:100%;height:100%;object-fit:contain}
.agent-live-body .agent-empty{color:#4b5563;padding:20px;font-size:12px;line-height:1.8}
.agent-shell{display:flex;flex-direction:column;height:calc(100vh - 150px);min-height:520px;border:1px solid #e5e7eb;border-radius:14px;overflow:hidden;background:#fff}
.agent-shell{display:flex;flex-direction:column;height:calc(100vh - 150px);min-height:480px;border:1px solid #e5e7eb;border-radius:14px;overflow:hidden;background:#fff}
/* AI 控制台里的子分栏占了一行,聊天区高度相应扣掉,避免整页出现滚动条 */
#agent-sub-chat .agent-shell{height:calc(100vh - 205px)}
/* ===== AI 建任务(子面板 2) ===== */
.tg-wrap{display:flex;gap:14px;height:calc(100vh - 205px);min-height:500px}
.tg-left{width:360px;flex:none;display:flex;flex-direction:column;gap:10px;overflow-y:auto;padding-right:2px}
.tg-right{flex:1;min-width:0;display:flex;flex-direction:column;gap:10px}
.tg-card{border:1px solid #e5e7eb;border-radius:12px;padding:12px;background:#fff}
.tg-title{font-size:12.5px;font-weight:700;color:#374151;margin-bottom:8px}
.tg-fold:hover{color:var(--primary)}
.tg-row{display:flex;align-items:center;gap:8px;margin-top:8px}
.tg-hint{font-size:11px;color:var(--text-light);line-height:1.5}
.tg-actions{display:flex;gap:8px}
.tg-actions .btn{flex:1}
.tg-check{display:flex;align-items:flex-start;gap:6px;font-size:11.5px;color:var(--text-light);cursor:pointer;line-height:1.5}
.tg-check input{margin-top:2px;flex:none}
.tg-check b{color:var(--text)}
.tg-status{font-size:11.5px;color:var(--text-light);min-height:16px;line-height:1.5}
.tg-right-head{display:flex;justify-content:space-between;align-items:center;font-size:12.5px;font-weight:700;color:#374151}
.tg-stream{flex:1;min-height:120px;overflow-y:auto;border:1px solid #e5e7eb;border-radius:12px;background:#fff;padding:10px;display:flex;flex-direction:column;gap:6px}
.tg-stream .agent-toolcard{border-left:2px solid var(--teal);padding-left:8px;font-size:12px}
.tg-stream .agent-toolcard img{max-width:180px;border-radius:6px;display:block;margin-top:6px}
.tg-stream .agent-toolcard .dot{width:6px;height:6px;border-radius:99px;background:var(--teal);display:inline-block;margin-right:6px}
.tg-stream .tg-err{color:#b91c1c;font-size:11.5px;margin-top:4px;font-family:var(--mono);word-break:break-all}
.tg-draft-wrap{max-height:46%;overflow-y:auto}
.tg-draft{border:1px solid #e5e7eb;border-radius:12px;background:#fff;padding:12px}
.tg-draft h4{margin:0 0 8px;font-size:13px}
.tg-step{font-family:var(--mono);font-size:11.5px;padding:3px 0;border-bottom:1px dashed #eef0f3;white-space:pre-wrap;word-break:break-all}
.tg-step:last-child{border-bottom:none}
.tg-step .t{color:#0f766e;font-weight:600}
.tg-step .p{color:#b45309}
.tg-note{background:#fffbeb;border:1px solid #fde68a;border-radius:8px;padding:8px 10px;font-size:11.5px;color:#92400e;margin-top:8px}
.tg-warn{background:#fef2f2;border:1px solid #fecaca;border-radius:8px;padding:8px 10px;font-size:11.5px;color:#b91c1c;margin-top:8px}
.tg-evidence{margin-top:8px;font-size:11px;color:var(--text-light)}
.tg-evidence summary{cursor:pointer;font-weight:600}
.tg-evidence div{padding:3px 0;border-bottom:1px dashed #eef0f3}
.tg-btns{display:flex;gap:8px;margin-top:12px;flex-wrap:wrap}
.agent-topbar{display:flex;align-items:center;justify-content:space-between;padding:10px 16px;border-bottom:1px solid #e5e7eb;background:#f9fafb}
.agent-title{font-size:14px;font-weight:700;color:#111827}
.agent-model-tag{margin-left:8px;font-size:11px;color:#0e7490;font-family:var(--mono);background:#e0f2fe;padding:2px 8px;border-radius:10px}
.agent-running-tag{color:#dc2626;font-size:12px;margin-right:8px}
.agent-top-actions{display:flex;align-items:center;gap:6px}
.agent-top-actions .btn{background:#fff;border:1px solid #d1d5db;color:#374151}
.agent-top-actions .btn:hover{border-color:#0e7490;color:#0e7490}
.agent-chat{flex:1;overflow-y:auto;padding:20px 26px;display:flex;flex-direction:column;gap:16px;background:#f7f8fa}
.agent-msg{max-width:80%;padding:11px 15px;border-radius:13px;font-size:13.5px;line-height:1.7;white-space:pre-wrap;word-break:break-word}
.agent-msg.user{align-self:flex-end;background:#0e7490;color:#fff;border-bottom-right-radius:3px}
.agent-msg.assistant{align-self:flex-start;background:#fff;border:1px solid #e5e7eb;border-bottom-left-radius:3px;color:#1f2937;box-shadow:0 1px 2px rgba(0,0,0,.04)}
.agent-msg.assistant .reasoning{color:#6b7280;font-size:12.5px;border-left:3px solid #d1d5db;padding-left:10px;margin:8px 0}
/* 推理链(可折叠) */
.agent-msg.assistant details.reasoning{margin:6px 0 10px;background:#f9fafb;border:1px solid #eef0f3;border-radius:9px;padding:6px 10px}
.agent-msg.assistant details.reasoning>summary{cursor:pointer;font-size:12px;color:#6b7280;font-weight:600;user-select:none;outline:none}
.agent-msg.assistant details.reasoning>summary:hover{color:#0e7490}
.agent-msg.assistant .reasoning-text{margin-top:6px;color:#6b7280;font-size:12.5px;line-height:1.7;white-space:pre-wrap;word-break:break-word;max-height:320px;overflow-y:auto}
/* token 用量脚注 */
.agent-usage{margin-top:8px;padding-top:7px;border-top:1px dashed #e5e7eb;font-size:11px;color:#9ca3af;font-family:var(--mono)}
.agent-token-total{margin-left:8px;font-size:11px;color:#6b7280;font-family:var(--mono);background:#f3f4f6;padding:2px 8px;border-radius:10px}
/* 正文 Markdown(.agent-msg 默认 pre-wrap,正文改由标签排版) */
.agent-msg.assistant .agent-text{white-space:normal}
.agent-text p{margin:0 0 8px}
.agent-text p:last-child{margin-bottom:0}
.agent-text .md-h{margin:12px 0 6px;font-weight:700;line-height:1.4}
.agent-text h1.md-h{font-size:17px}.agent-text h2.md-h{font-size:16px}.agent-text h3.md-h{font-size:15px}
.agent-text h4.md-h,.agent-text h5.md-h,.agent-text h6.md-h{font-size:13.5px}
.agent-text .md-list{margin:0 0 8px;padding-left:22px}
.agent-text .md-list li{margin:2px 0}
.agent-text .md-code{background:#0f172a;color:#e2e8f0;border-radius:8px;padding:9px 12px;overflow-x:auto;font-size:12px;font-family:var(--mono);margin:8px 0;white-space:pre}
.agent-text code{background:#f1f5f9;border:1px solid #e2e8f0;border-radius:4px;padding:0 4px;font-size:12px;font-family:var(--mono);color:#0f766e}
.agent-text .md-code code{background:none;border:none;color:inherit;padding:0}
.agent-text .md-quote{margin:8px 0;padding:2px 0 2px 11px;border-left:3px solid #d1d5db;color:#4b5563}
.agent-text .md-table{border-collapse:collapse;margin:8px 0;font-size:12.5px;display:block;overflow-x:auto;max-width:100%}
.agent-text .md-table th,.agent-text .md-table td{border:1px solid #e5e7eb;padding:5px 10px;text-align:left;white-space:nowrap}
.agent-text .md-table th{background:#f3f4f6;font-weight:600}
.agent-text hr{border:none;border-top:1px solid #e5e7eb;margin:12px 0}
.agent-text a{color:#0e7490;text-decoration:underline;word-break:break-all}
.agent-toolcards{display:flex;flex-direction:column;gap:5px;margin-bottom:9px}
.agent-toolcard{display:flex;align-items:flex-start;gap:8px;background:#f3f4f6;border:1px solid #e5e7eb;border-radius:9px;padding:6px 11px;font-size:12px;font-family:var(--mono);color:#374151;flex-wrap:wrap}
.agent-toolcard .dot{width:7px;height:7px;border-radius:50%;background:#10b981;flex:none;margin-top:5px}
.agent-toolcard code{color:#0e7490}
.agent-toolcard .text-muted{color:#6b7280}
.agent-toolcard img{max-width:190px;border-radius:7px;margin-top:4px;border:1px solid #e5e7eb;display:block;cursor:zoom-in}
.agent-inputbar{display:flex;gap:10px;padding:12px 14px;border-top:1px solid #e5e7eb;background:#fff}
.agent-inputbar textarea{flex:1;resize:none;background:#f9fafb;border:1px solid #d1d5db;border-radius:10px;padding:9px 12px;color:#111827;font-size:13.5px;outline:none;line-height:1.5}
.agent-inputbar textarea:focus{border-color:#0e7490;box-shadow:0 0 0 2px rgba(14,116,144,.12)}
.agent-inputbar .btn-primary{background:#0e7490;border:none}
.agent-inputbar .btn-primary:hover{background:#155e75}
.agent-inputbar .btn-primary:disabled{background:#9ca3af}
.agent-empty{color:#6b7280;font-size:13px;text-align:center;padding:44px 0}
/* ===== 动作配置:步骤默认值表单 ===== */
.cfg-card{border:1px solid var(--border-strong);border-radius:10px;padding:12px;margin-bottom:10px}
.cfg-card>h4{margin:0 0 8px;font-size:13px}
.cfg-row{display:flex;gap:12px;flex-wrap:wrap}
.cfg-row .pg{display:flex;flex-direction:column;gap:2px;min-width:120px}
.cfg-row .pg label{font-size:11px;color:var(--text-light)}
/* ===== 动作录制器 ===== */
.rec-box{width:min(1180px,96vw);max-height:92vh;display:flex;flex-direction:column}
.rec-head{display:flex;align-items:center;gap:10px;padding:12px 16px;border-bottom:1px solid var(--border)}
.rec-head b{font-size:14px}
.rec-body{display:flex;gap:14px;padding:14px 16px;overflow:hidden;flex:1;min-height:0}
.rec-stage{flex:1;min-width:0;position:relative;background:var(--ink);border-radius:10px;
display:flex;align-items:center;justify-content:center;overflow:hidden}
.rec-stage img{max-width:100%;max-height:74vh;display:block;cursor:crosshair;user-select:none}
.rec-hint{color:#8a94a6;font-size:13px;padding:40px}
.rec-warn{position:absolute;left:10px;top:10px;background:#fbbf24;color:#3b2f00;
font-size:11px;padding:2px 8px;border-radius:6px}
.rec-line{position:absolute;border:2px dashed #f97316;border-radius:4px;pointer-events:none}
.rec-side{width:330px;flex:0 0 330px;display:flex;flex-direction:column;gap:8px;min-height:0}
.rec-ops{display:flex;gap:6px;flex-wrap:wrap}
.rec-row{display:flex;gap:6px}
.rec-list-title{font-size:12px;color:var(--text-light);margin-top:4px}
.rec-list{flex:1;overflow-y:auto;border:1px solid var(--border);border-radius:8px;padding:6px;min-height:120px}
.rec-item{display:flex;align-items:center;gap:6px;padding:3px 4px;border-bottom:1px dashed var(--border)}
.rec-item:last-child{border-bottom:0}
.rec-idx{width:18px;color:var(--text-light);font-size:11px;text-align:right}
.rec-label{flex:1;font-size:12px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.rec-ops2{display:flex;gap:2px}
.rec-foot{border-top:1px solid var(--border);padding-top:8px}
</style>
</head>
<body>
<!-- 顶部导航 -->
<nav class="navbar">
<div class="brand"><span class="brand-dot"></span>设备自动化后台</div>
{% if db_env %}
<span class="env-badge{% if db_env.is_prod %} env-badge-prod{% endif %}"
title="数据库目标:{{ db_env.target }}({{ db_env.backend }})">
{% if db_env.is_prod %}PROD · {{ db_env.target }}{% else %}{{ db_env.env|upper }}{% endif %}
</span>
{% endif %}
<div class="tabs">
<button class="tab active" data-tab="monitor" onclick="showTab('monitor')">监控</button>
<button class="tab" data-tab="account" onclick="showTab('account')" data-perm="devices">账号</button>
<button class="tab" data-tab="tasks" onclick="showTab('tasks')">任务</button>
<button class="tab" data-tab="logs" onclick="showTab('logs')" data-perm="logs">日志</button>
<button class="tab" data-tab="users" onclick="showTab('users')" data-perm="admin">用户</button>
<button class="tab" data-tab="tools" onclick="showTab('tools')" data-perm="admin">工具</button>
<button class="tab" data-tab="agent" onclick="showTab('agent')" data-perm="admin">AI 控制台</button>
<button class="tab" data-tab="system" onclick="showTab('system')" data-perm="admin">系统</button>
</div>
<div class="user-info">
<span id="me-username">...</span>
<a href="/wall" target="_blank" title="监控大屏(全屏,挂墙/电视用)">📺 大屏</a>
<a href="/logout">退出</a>
</div>
</nav>
<div class="content">
<!-- ========== Tab 1: 监控 ========== -->
<div id="tab-monitor" class="tab-panel active">
<div class="page-title">设备状态监控</div>
<div class="page-sub">实时展示所有设备状态、当前执行任务及进度</div>
<div id="stats" class="stats-row"></div>
<div class="section-title">设备列表</div>
<div class="toolbar">
<button class="btn btn-danger" onclick="apiAction('/api/stop_all')" data-perm="devices">停止全部</button>
<button class="btn btn-danger" id="btn-stop-selected" onclick="stopSelectedDevices()" title="停止所有勾选的运行中设备" data-perm="devices">停止选中</button>
<button class="btn btn-danger" onclick="clearAllErrors()" title="清除所有异常/失败设备的错误状态(运行中/等待重试的跳过)" data-perm="devices">清除全部异常</button>
<button class="btn btn-primary" id="btn-scan-fg" onclick="scanForeground()" data-perm="devices">扫描前台App</button>
<button class="btn" onclick="screenAllDevices('on')" data-perm="devices" title="所有在线设备亮屏并解锁">一键亮屏</button>
<button class="btn" onclick="screenAllDevices('off')" data-perm="devices" title="所有在线设备息屏(运行中任务会中断)">一键息屏</button>
<button class="btn" onclick="loadMonitor()">刷新</button>
<label style="display:inline-flex;align-items:center;gap:4px;margin:0 6px">
<input type="checkbox" id="monitor-filter-online" checked onchange="loadMonitor()"> 只看在线
</label>
<input class="form-control list-search" id="search-devices" placeholder="搜索设备/型号/App/任务...">
<span class="hint"><span id="fg-status"></span> · 每 5 秒自动刷新 · <span id="srvTime"></span> · <span id="sel-count">未选中</span></span>
</div>
<table class="table">
<thead><tr>
<th class="col-cb"><input type="checkbox" id="cb-all" onclick="toggleAllDevices(this.checked)" title="全选/取消全选"></th>
<th data-sort="serial" onclick="_lpSort('devices','serial')">设备</th>
<th data-sort="model" onclick="_lpSort('devices','model')">型号</th>
<th data-sort="battery" onclick="_lpSort('devices','battery')" title="后台每分钟采集一次(dumpsys battery);颜色按告警档位上色,阈值在「工具 → 设备发现 → 电量监控」里配">电量</th>
<th data-sort="worker_status" onclick="_lpSort('devices','worker_status')">任务状态</th>
<th data-sort="foreground_app" onclick="_lpSort('devices','foreground_app')">前台App</th>
<th data-sort="progress" onclick="_lpSort('devices','progress')">进度</th>
<th data-sort="task_job" onclick="_lpSort('devices','task_job')">当前任务</th>
<th data-sort="current_action" onclick="_lpSort('devices','current_action')">当前动作</th>
<th data-sort="last_error" onclick="_lpSort('devices','last_error')">最近错误</th>
<th>操作</th>
</tr></thead>
<tbody id="tb-devices"></tbody>
</table>
<div class="list-pager" id="pager-devices"></div>
<!-- 异常设备汇总 -->
<div class="section-title" style="margin-top:8px">异常汇总 <span id="summary-badge" style="font-size:12px;font-weight:400;color:#94a3b8"></span></div>
<div id="summary-panel" style="display:none;margin-bottom:12px">
<table class="table">
<thead><tr><th>设备</th><th>状态</th><th>任务</th><th>重试</th><th>错误信息</th></tr></thead>
<tbody id="tb-summary"></tbody>
</table>
</div>
<div id="summary-empty" style="display:none" class="text-muted">无异常设备</div>
<div class="section-title" style="margin-top:8px">任务运行概况</div>
<div id="jobs-grid" class="jobs-grid"></div>
</div>
<!-- ========== Tab: 账号(台账) ========== -->
<div id="tab-account" class="tab-panel">
<div class="page-title">账号</div>
<div class="page-sub">每台设备上登录的抖音账号(谁在哪台机器上、卡在不在机内、能不能发视频)</div>
<div class="sub-tabs">
<button class="sub-tab active" data-sub="ledger" onclick="showSubTab('account','ledger')">账号台账</button>
<button class="sub-tab" data-sub="plan" onclick="showSubTab('account','plan')">发布计划</button>
</div>
<!-- 子分栏 1:账号台账 -->
<div class="sub-panel active" id="account-sub-ledger">
<div class="toolbar">
<button class="btn btn-primary" onclick="openLedgerModal('')" data-perm="devices">新增账号</button>
<button class="btn" onclick="openLedgerImport()" data-perm="devices">粘贴导入</button>
<button class="btn" onclick="loadLedger()">刷新</button>
<select class="form-control" id="ledger-device" style="width:140px" onchange="ledgerDeviceChanged()">
<option value="">全部设备</option>
</select>
<input class="form-control list-search" id="search-ledger" placeholder="搜索设备号/账号名/抖音号/手机号...">
<span class="text-muted" id="ledger-stats"></span>
</div>
<table class="table">
<thead><tr>
<th data-sort="device_name" onclick="_lpSort('ledger','device_name')">设备号</th>
<th data-sort="nickname" onclick="_lpSort('ledger','nickname')">账号名称</th>
<th data-sort="douyin_id" onclick="_lpSort('ledger','douyin_id')">抖音号</th>
<th>手机号</th>
<th>注册时间</th>
<th>卡在机内</th>
<th>可发视频</th>
<th>简介</th>
<th>备注</th>
<th>操作</th>
</tr></thead>
<tbody id="tb-ledger"></tbody>
</table>
<div class="list-pager" id="pager-ledger"></div>
<div class="help">
这张台账有三个用处:① 人在机器旁时对照(手机端 Agent 的「身份大字页」也会显示本机账号);
② 任务的「条件判断」可以直接从这里取号(编辑器里把<b>候选值来源</b>选成「本机台账」,
就不用把几十个号手写进去);③ 设备池里能看到每台登记了几个号。<br>
⚠ 台账里的抖音号是<b>纯号</b>(如 <code>35377983067</code>),只当"比对用的候选值"。
<b>不要</b>拿它去填「去重」的身份元素 —— 身份比对的是元素原文
(<code>抖音号:35377983067</code>),格式不同会让去重<b>静默失效</b>。
</div>
</div><!-- /account-sub-ledger -->
<!-- 子分栏 2:视频发布计划 -->
<div class="sub-panel" id="account-sub-plan">
<div class="toolbar" style="flex-wrap:wrap">
<button class="btn btn-primary" onclick="document.getElementById('rp-video-input').click()"
data-perm="devices">上传视频</button>
<button class="btn" onclick="document.getElementById('rp-title-input').click()"
data-perm="devices">上传标题</button>
<button class="btn btn-primary" onclick="rpPushAll()" data-perm="devices"
title="把今天所有「待发布/失败」的计划按设备推到手机上(每台设备串行推)">一键推送(今天)</button>
<button class="btn" onclick="rpExportLinks()">导出链接 CSV</button>
<button class="btn" onclick="loadReleasePlan()">刷新</button>
<select class="form-control" id="rp-range" style="width:130px" onchange="rpRangeChanged()">
<option value="today">今天</option>
<option value="week">未来 7 天</option>
<option value="all">全部</option>
</select>
<select class="form-control" id="rp-status" style="width:150px" onchange="rpRangeChanged()">
<option value="">全部状态</option>
<option value="pending">待标题</option>
<option value="ready">待发布</option>
<option value="done">已发布</option>
<option value="failed,unknown">失败/未知</option>
<option value="skipped">已跳过</option>
</select>
<input class="form-control list-search" id="search-release" placeholder="搜索账号/抖音号/标题/文件名...">
<span class="text-muted" id="rp-stats"></span>
</div>
<!-- 发布任务:到点自动"推送 + 发视频"。素材(上面)与执行(这里)在同一页 -->
<div style="border:1px solid var(--border);border-radius:6px;padding:8px 10px;margin-bottom:10px">
<div style="display:flex;justify-content:space-between;align-items:center;gap:8px;flex-wrap:wrap">
<div><b>发布任务</b>
<span class="text-muted" style="font-size:12px">
到点自动跑:推送素材 → 你的抖音步骤 → 标记结果(抖音那几步的选择器要你自己抓一次)
</span></div>
<div style="white-space:nowrap">
<button class="btn btn-xs btn-primary" onclick="rpNewTask()" data-perm="tasks">一键新建发布任务</button>
<button class="btn btn-xs" onclick="loadReleaseTasks()">刷新</button>
</div>
</div>
<div id="rp-tasks" style="margin-top:6px"></div>
</div>
<div id="rp-timeline"></div>
<div class="list-pager" id="pager-release"></div>
<details style="margin-top:12px">
<summary style="cursor:pointer;color:var(--text-light)">明细表(与时间线同一份数据)</summary>
<table class="table table-sm" style="margin-top:8px">
<thead><tr>
<th>发布日期</th><th>设备号</th><th>账号</th><th>编号</th><th>视频文件</th>
<th>标题</th><th>状态</th><th>分享链接</th><th>操作</th>
</tr></thead>
<tbody id="tb-release"></tbody>
</table>
</details>
<div class="help">
视频文件名规则 <code>手机号_日期_编号</code>(编号可省略,日期如 <code>20260912</code> 就是发布日期);
标题 txt 每行 <code>标题内容_手机号_日期_编号</code>(标题里可以有下划线,<code>#</code> 开头的行忽略)。
<b>手机号必须在「账号台账」里</b>,否则拒收。<br>
上传后自动按 (手机号, 日期, 编号) 配对:有编号按编号精确对,没编号按顺序配 ——
<b>没传标题的视频不会被推送</b>(先在计划页改标题或补传标题)。<br>
<b>平台只负责把视频推到手机</b>(推到 <code>/sdcard/DCIM/Camera/</code>、把文件时间改成"现在"好排在相册最前、触发相册刷新并<b>按路径</b>校验进了索引、
把标题写进手机剪贴板);**抖音里怎么发由你在任务里自己写步骤** ——
步骤顺序是:<b>「推送发布视频」→ 你自己的发布步骤(打开抖音→点+→相册→选视频→下一步→粘贴标题→发布)→ 最后放一个「标记发布结果」</b>。<br>
计划页的「推送到手机」是同一件事的人工入口(推完你在手机上发,发完点「标记已发布」)。<br>
⚠ 发布结果以<b>每行的状态</b>为准:<b>失败(可重试)</b>说明推送都没成功、可以再推;
<b>结果未知(需确认)</b>说明推送之后那一段出了岔子、<b>可能已经发出去了</b>,
要先到抖音里看一眼,再用「已发出 / 未发出」裁决(平台不会自动重发)。
</div>
<input type="file" id="rp-video-input" multiple accept="video/*" style="display:none" onchange="rpPickVideos(this)">
<input type="file" id="rp-title-input" accept=".txt" style="display:none" onchange="rpPickTitles(this)">
</div><!-- /account-sub-plan -->
</div>
<!-- ========== Tab 2: 任务 ========== -->
<div id="tab-tasks" class="tab-panel">
<div class="page-title">任务</div>
<div class="page-sub">任务计划定时调度与自定义动作复用管理</div>
<div class="sub-tabs">
<button class="sub-tab active" data-sub="plan" onclick="showSubTab('tasks','plan')">任务计划</button>
<button class="sub-tab" data-sub="actions" onclick="showSubTab('tasks','actions')">自定义动作</button>
<button class="sub-tab" data-sub="actioncfg" onclick="showSubTab('tasks','actioncfg')">动作配置</button>
<button class="sub-tab" data-sub="dedup" onclick="showSubTab('tasks','dedup')">去重记录</button>
</div>
<!-- 子分栏 1:任务计划 -->
<div class="sub-panel active" id="tasks-sub-plan">
<div class="toolbar">
<button class="btn btn-primary" onclick="openTaskModal()" data-perm="tasks">新建任务</button>
<button class="btn" onclick="loadTasks()">刷新</button>
<input class="form-control list-search" id="search-tasks" placeholder="搜索任务名/类型/ID...">
</div>
<table class="table">
<thead><tr>
<th data-sort="id" onclick="_lpSort('tasks','id')">ID</th><th data-sort="name" onclick="_lpSort('tasks','name')">任务名</th><th data-sort="task_type" onclick="_lpSort('tasks','task_type')">类型</th><th data-sort="enabled" onclick="_lpSort('tasks','enabled')">启用</th><th>目标</th><th>调度</th><th data-sort="next_run" onclick="_lpSort('tasks','next_run')">下次运行</th><th>重试</th><th>操作</th>
</tr></thead>
<tbody id="tb-tasks"></tbody>
</table>
<div class="list-pager" id="pager-tasks"></div>
</div>
<!-- 子分栏 2:自定义动作 -->
<div class="sub-panel" id="tasks-sub-actions">
<div class="toolbar">
<button class="btn btn-primary" onclick="openActionModal()" data-perm="tasks">新建动作</button>
<button class="btn" onclick="loadCustomActions()">刷新</button>
<input class="form-control list-search" id="search-actions" placeholder="搜索动作名...">
</div>
<div class="help">把常用步骤打包成可复用的动作,拖入任务步骤编辑器使用;也可在任务编辑器里"☑选中步骤→打包"生成</div>
<table class="table">
<thead><tr><th>图标</th><th data-sort="name" onclick="_lpSort('actions','name')">动作名</th><th data-sort="steps" onclick="_lpSort('actions','steps')">步骤数</th><th data-sort="created_at" onclick="_lpSort('actions','created_at')">创建时间</th><th>操作</th></tr></thead>
<tbody id="tb-actions"></tbody>
</table>
<div class="list-pager" id="pager-actions"></div>
</div>
<!-- 子分栏 3:动作配置(步骤默认值 + 录制动作) -->
<div class="sub-panel" id="tasks-sub-actioncfg">
<div class="toolbar">
<button class="btn btn-primary" onclick="saveStepDefaults()" data-perm="tasks">保存默认值</button>
<button class="btn" onclick="resetStepDefaults()">恢复出厂</button>
<button class="btn" onclick="openRecorder({mode:'action'})" data-perm="devices">● 录制动作</button>
<button class="btn" onclick="loadActionConfig()">刷新</button>
<span class="hint" id="cfg-hint"></span>
</div>
<div class="help">
<b>步骤默认值</b>:新建步骤时预填这套参数(<b>只影响之后新建的步骤</b>,已有步骤不动;
单步想改就打开那个任务改那一步)。<br>
<b>录制动作</b>:在设备画面上点/划/按键,自动翻译成步骤 —— 既能存成可复用的「自定义动作」,
也能只取一个手势回填到滑动步骤上(步骤编辑器里滑动那一步有「录制手势」按钮)。
</div>
<div id="cfg-forms"><div class="text-muted" style="padding:10px">加载中…</div></div>
</div>
</div>
<!-- ========== Tab 3: 分组 ========== -->
<!-- ========== Tab: AI 控制台(DeepSeek 风格聊天 + 实时 MCP 步骤) ========== -->
<div id="tab-agent" class="tab-panel">
<div class="sub-tabs">
<button class="sub-tab active" data-sub="chat" onclick="showSubTab('agent','chat')">💬 聊天</button>
<button class="sub-tab" data-sub="taskgen" onclick="showSubTab('agent','taskgen')">🧭 AI 建任务</button>
</div>
<!-- ===== 子面板 1:聊天(原有 AI 控制台,原样不动) ===== -->
<div class="sub-panel active" id="agent-sub-chat">
<div class="agent-shell">
<div class="agent-topbar">
<div class="agent-title">🤖 AI 控制台<span id="agent-model-tag" class="agent-model-tag"></span><span id="agent-token-total" class="agent-token-total" title=""></span></div>
<div class="agent-top-actions">
<span id="agent-running-tag" class="agent-running-tag" style="display:none">● 运行中</span>
<button class="btn btn-xs" onclick="openExpLib()" title="查看/管理自进化经验库(每日 AI 巡检建议,删除需确认)">🧠 经验库</button>
<button class="btn btn-xs" onclick="openActLib()" title="查看/管理可复用动作(命名动作 + 元素定位步骤,AI 自动沉淀,可编辑删除)">🎬 动作库</button>
<button class="btn btn-xs" onclick="openAgentConfig()">⚙ 配置</button>
</div>
</div>
<div class="agent-body">
<div class="agent-col-sessions">
<div class="agent-sess-head">
<button class="btn btn-primary" style="width:100%" onclick="createAgentSession()">+ 新建会话</button>
</div>
<div id="agent-sess-list" class="agent-sess-list"></div>
</div>
<div class="agent-col-chat">
<div id="agent-chat" class="agent-chat"></div>
</div>
<div class="agent-col-live">
<div class="agent-live-head">📺 实时画面<span id="agent-live-serial" class="agent-live-serial"></span></div>
<div class="agent-live-body" id="agent-live-body">
<img id="agent-live-img" alt="" style="display:none;width:100%">
<div class="agent-empty" id="agent-live-empty">运行任务时自动跟随操作的设备;<br>也可手动选择设备观看</div>
</div>
<select id="agent-live-select" class="form-control" style="margin-top:8px"
onchange="watchDevice(this.value)"><option value="">选择设备观看…</option></select>
</div>
</div>
<div class="agent-inputbar" style="align-items:flex-end;flex-direction:column">
<div style="display:flex;width:100%;gap:8px;align-items:center;margin-bottom:8px">
<label style="font-size:12.5px;color:#374151;font-weight:600;white-space:nowrap">🎯 目标设备</label>
<select id="agent-target-select" class="form-control" style="width:320px;font-family:monospace"
title="AI 只操作你选定的设备;有任务运行中的设备不可选">
<option value="">请选择设备(AI 只操作选定设备)…</option>
</select>
<span class="text-muted" id="agent-target-hint" style="font-size:11.5px"></span>
<span style="flex:1"></span>
<button class="btn btn-xs btn-danger" id="btn-agent-stop" style="display:none" onclick="stopAgent()">■ 停止</button>
</div>
<div style="display:flex;width:100%;gap:10px">
<textarea id="agent-input" rows="2"
placeholder="给 AI 下达指令,例如:打开抖音搜索奚学东,把第一个视频标题告诉我…(Enter 发送,Shift+Enter 换行)"></textarea>
<button class="btn btn-primary" id="btn-agent-send" onclick="sendAgentMsg()">发送</button>
</div>
</div>
</div>
</div><!-- /agent-sub-chat -->
<!-- ===== 子面板 2:AI 建任务 =====
AI 自己在真机上探索(看屏/读元素树/点按验证),把路径写成**任务步骤**,
经服务端校验后交人工确认(打开步骤编辑器 → 保存才入库)。
见 doc/AI_TASK_GEN.md -->
<div class="sub-panel" id="agent-sub-taskgen">
<div class="tg-wrap">
<div class="tg-left">
<div class="tg-card">
<div class="tg-title">1️⃣ 说清楚要做什么</div>
<textarea id="tg-prompt" class="form-control" rows="4"
placeholder="例如:建一个跑 2 小时的任务,自动刷抖音、随机点赞;拿不准的先别真发"></textarea>
<div class="tg-row">
<button class="btn btn-xs" onclick="tgExample()">用个例子</button>
<span class="tg-hint">AI 会自己在设备上探索,把走通的路径写成任务步骤</span>
</div>
</div>
<div class="tg-card">
<div class="tg-title">2️⃣ 选一台空闲设备</div>
<select id="tg-serial" class="form-control">
<option value="">加载设备…</option>
</select>
<div class="tg-hint" id="tg-serial-hint">有任务在跑的设备不可选(AI 不与任务抢设备)</div>
</div>
<div class="tg-card">
<div class="tg-title tg-fold" onclick="tgToggleAdv()">3️⃣ 任务设置(可选,留空由 AI 判断)
<span style="float:right" id="tg-adv-arrow">▸</span></div>
<div id="tg-adv" style="display:none">
<div class="form-group"><label>任务名</label>
<input id="tg-name" class="form-control" placeholder="留空=AI 自动起名"></div>
<div class="form-group"><label>目标设备</label>
<select id="tg-target" class="form-control" onchange="tgToggleTarget()">
<option value="">跟随探索设备</option>
<option value="all">全部空闲设备</option>
<option value="group">按分组</option>
<option value="serial">指定设备</option>
</select>
</div>
<div class="form-group" id="tg-group-wrap" style="display:none">
<label>分组</label><select id="tg-group" class="form-control"></select>
</div>
<div class="form-group"><label>调度</label>
<select id="tg-sched-mode" class="form-control" onchange="tgToggleSched()">
<option value="">跟随 AI 判断</option>
<option value="once">手动(不自动启动)</option>
<option value="daily">每天定时</option>
</select>
</div>
<div class="form-row" id="tg-sched-wrap" style="display:none">
<div class="form-group"><label>启动</label>
<input type="time" id="tg-sched-start" class="form-control" value="20:00"></div>
<div class="form-group"><label>停止(可留空)</label>
<input type="time" id="tg-sched-stop" class="form-control"></div>
</div>
<div class="form-group"><label>运行时长上限(秒)</label>
<input id="tg-maxdur" type="number" class="form-control"
placeholder="如 7200 = 2 小时;0/留空 = 不限"></div>
</div>
</div>
<div class="tg-actions">
<button class="btn btn-primary" id="tg-start" onclick="startExplore()">🚀 开始探索</button>
<button class="btn btn-danger" id="tg-stop" style="display:none" onclick="stopExplore()">■ 停止</button>
</div>
<label class="tg-check" title="勾上后:探索出草稿就直接建成任务(不再等你点确认)。草稿入库前仍会再校验一次;任务建好后可在任务页编辑/停用">
<input type="checkbox" id="tg-auto-create">
<span>探索完<b>直接创建任务</b>(跳过人工核对)</span>
</label>
<div class="tg-status" id="tg-status"></div>
<div class="tg-card" id="tg-live-card" style="display:none">
<div class="tg-title">📺 跟随画面 <span class="tg-hint" id="tg-live-serial"></span></div>
<img id="tg-live-img" alt="" style="width:100%;border-radius:8px;display:none">
</div>
</div>
<div class="tg-right">
<div class="tg-right-head">
<span>探索回放</span>
<span class="tg-hint" id="tg-token"></span>
</div>
<div class="tg-stream" id="tg-stream">
<div class="agent-empty">AI 在设备上的每一步(看屏 / 读元素树 / 点按验证)会实时显示在这里。</div>
</div>
<div class="tg-draft-wrap" id="tg-draft"></div>
</div>
</div>
</div><!-- /agent-sub-taskgen -->
</div>
<!-- ========== 配置模态框 ========== -->
<div id="agent-cfg-overlay" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,.6);z-index:1300;align-items:center;justify-content:center">
<div style="background:#11151c;border:1px solid var(--card-line);border-radius:14px;padding:22px;width:460px;max-width:92vw">
<div style="font-size:15px;font-weight:700;margin-bottom:14px">模型配置(OpenAI 兼容 API)</div>
<div class="form-group"><label>API Base</label>
<input type="text" id="acfg-base" class="form-control" placeholder="https://api.deepseek.com" style="font-family:monospace"></div>
<div class="form-group"><label>模型名</label>
<input type="text" id="acfg-model" class="form-control" placeholder="deepseek-v4-flash-vision-exp"></div>
<div class="form-group"><label>API Key <span class="text-muted" id="acfg-key-hint"></span></label>
<input type="password" id="acfg-key" class="form-control" placeholder="留空则不修改"></div>
<div class="form-group"><label>默认设备 serial(可选)</label>
<input type="text" id="acfg-serial" class="form-control" placeholder="192.168.20.66:5555" style="font-family:monospace"></div>
<div class="form-group"><label>最大执行步骤(1-200,默认 40)</label>
<input type="number" id="acfg-steps" class="form-control" min="1" max="200" placeholder="40"
title="单轮任务允许的最大工具调用轮数;超限会收尾总结(防死循环)"></div>
<div style="display:flex;justify-content:flex-end;gap:8px;margin-top:6px">
<button class="btn" onclick="closeAgentConfig()">取消</button>
<button class="btn btn-primary" onclick="saveAgentConfig()">保存</button>
</div>
</div>
</div>
<!-- ========== 经验库模态框(自进化记忆管理 + 每日 AI 巡检建议) ========== -->
<div id="agent-exp-overlay" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,.6);z-index:1320;align-items:center;justify-content:center">
<div style="background:#11151c;border:1px solid var(--card-line);border-radius:14px;padding:20px 22px;width:760px;max-width:94vw;max-height:86vh;display:flex;flex-direction:column">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:4px">
<div style="font-size:15px;font-weight:700">🧠 经验库<span class="text-muted" style="font-size:11.5px;font-weight:400;margin-left:8px" id="exp-lib-hint"></span></div>
<div style="display:flex;gap:6px;align-items:center">
<button class="btn btn-xs" onclick="triggerExpAudit()">🔍 立即巡检</button>
<button class="btn btn-xs" onclick="closeExpLib()">✕ 关闭</button>
</div>
</div>
<div class="text-muted" style="font-size:11.5px;padding:0 2px 8px;border-bottom:1px solid var(--card-line);margin-bottom:10px">
每次任务成功后会提炼「操作配方」存入经验库,下次相似任务自动参考;每日 03:47 AI 巡检给出疑似问题建议(删除需人工确认)。
<span id="exp-lib-last" class="text-muted"></span>
</div>
<div id="exp-lib-list" style="flex:1;overflow-y:auto;min-height:200px;display:flex;flex-direction:column;gap:8px"></div>
</div>
</div>
<!-- ========== 动作库模态框(可复用动作:命名动作 + 元素定位步骤) ========== -->
<div id="agent-act-overlay" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,.6);z-index:1320;align-items:center;justify-content:center">
<div style="background:#11151c;border:1px solid var(--card-line);border-radius:14px;padding:20px 22px;width:860px;max-width:94vw;max-height:86vh;display:flex;flex-direction:column">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:4px">
<div style="font-size:15px;font-weight:700">🎬 动作库<span class="text-muted" style="font-size:11.5px;font-weight:400;margin-left:8px" id="act-lib-hint"></span></div>
<div style="display:flex;gap:6px;align-items:center">
<button class="btn btn-xs" onclick="actNew()">+ 新建动作</button>
<button class="btn btn-xs" onclick="loadActLib()">刷新</button>
<button class="btn btn-xs" onclick="closeActLib()">✕ 关闭</button>
</div>
</div>
<div class="text-muted" style="font-size:11.5px;padding:0 2px 8px;border-bottom:1px solid var(--card-line);margin-bottom:10px">
任务成功后自动把<b>成功步骤</b>沉淀为可复用动作(命名动作 + 编辑器 schema 步骤 + 元素定位,<b>不含坐标</b>);执行前按动作名/别名召回注入,AI 可直接复用、跳过重新探索。可在此查看、编辑、删除或手动新建。
</div>
<div id="act-lib-list" style="flex:1;overflow-y:auto;min-height:200px;display:flex;flex-direction:column;gap:8px"></div>
</div>
<!-- 子分栏 4:去重记录(done_mark 表 = 跨设备"已做过"账本) -->
<div class="sub-panel" id="tasks-sub-dedup">
<div class="help">
<b>去重账本</b>:任务里用「条件判断 → 去重」+「记为已做」两个步骤,把"这个身份做过了"记在这里,
<b>所有设备共享一份</b>——所以多个手机跑同一个任务、反复重跑,也不会把同一个号做两次。<br>
<span class="text-muted">想让它重跑:删掉对应那条(单个号/单台设备)或「清空本任务记录」。
有效期在任务编辑器的<b>「去重有效期」</b>里设(每天一次 / 只做一次 / 按小时);「只做一次」的记录不会被自动清理。</span>
</div>
<div class="toolbar" style="gap:10px;flex-wrap:wrap">
<select class="form-control" id="dedup-job" style="width:280px" onchange="loadDedup()">
<option value="">全部任务</option>
</select>
<button class="btn" onclick="loadDedup()">刷新</button>
<button class="btn btn-danger" onclick="clearJobDedup()" data-perm="tasks">清空本任务记录</button>
<span class="text-muted" id="dedup-stats"></span>
</div>
<table class="table">
<thead><tr>
<th style="width:160px">时间</th><th style="width:90px">设备</th>
<th style="width:150px">身份值</th><th style="width:110px">有效期</th>
<th>任务</th><th style="width:120px">操作</th>
</tr></thead>
<tbody id="tb-dedup"><tr><td colspan="6" class="empty">暂无记录</td></tr></tbody>
</table>
</div>
</div>
<!-- ========== Tab 4: 日志 ========== -->
<div id="tab-logs" class="tab-panel">
<div class="page-title">日志查看</div>
<div class="page-sub">原始文本日志 + 任务步骤明细(结构化,可按设备/任务/时间过滤)</div>
<div class="sub-tabs">
<button class="sub-tab active" data-sub="files" onclick="showSubTab('logs','files')">文件日志</button>
<button class="sub-tab" data-sub="steps" onclick="showSubTab('logs','steps')">步骤明细</button>
</div>
<!-- 子分栏 1:文件日志 -->
<div class="sub-panel active" id="logs-sub-files">
<div class="log-toolbar">
<!-- 文件下拉由 /api/logs 返回的清单动态渲染(含 .1/.2 滚动历史与 notify.log) -->
<select id="log-file" class="form-control" style="width:auto" onchange="loadLogs()"></select>
<input id="log-q" class="form-control" style="width:190px" placeholder="关键字(设备号/包名/报错…)"
onkeydown="if(event.key==='Enter')loadLogs()">
<select id="log-level" class="form-control" style="width:auto" onchange="loadLogs()">
<option value="">全部级别</option>
<option value="ERROR">仅 ERROR</option>
<option value="WARNING">WARNING 及以上</option>
<option value="INFO">INFO 及以上</option>
</select>
<input id="log-since" type="datetime-local" class="form-control" style="width:auto"
title="起始时间" onchange="loadLogs()">
<span class="text-muted">~</span>
<input id="log-until" type="datetime-local" class="form-control" style="width:auto"
title="结束时间" onchange="loadLogs()">
<select id="log-lines" class="form-control" style="width:auto" onchange="loadLogs()">
<option value="100">最近 100 行</option>
<option value="300" selected>最近 300 行</option>
<option value="500">最近 500 行</option>
<option value="1000">最近 1000 行</option>
<option value="5000">最近 5000 行</option>
</select>
<label class="form-check"><input type="checkbox" id="log-auto" checked onchange="toggleLogAuto()"> 自动刷新</label>
<button class="btn" onclick="loadLogs()">刷新</button>
<button class="btn" onclick="downloadLogs()" title="下载当前筛选结果(无筛选即整个文件)">下载</button>
<button class="btn" onclick="resetLogFilters()">重置</button>
<span class="hint" id="log-hint"></span>
</div>
<div id="log-content" class="log-content"></div>
</div><!-- /logs-sub-files -->
<!-- 子分栏 2:步骤明细(task_step_log 表,结构化,可按设备/任务/时间/结果过滤) -->
<div class="sub-panel" id="logs-sub-steps">
<div class="log-toolbar">
<select id="sl-device" class="form-control" style="width:auto" onchange="loadStepLogs(true)"></select>
<select id="sl-job" class="form-control" style="width:auto" onchange="loadStepLogs(true)"></select>
<select id="sl-result" class="form-control" style="width:auto" onchange="loadStepLogs(true)">
<option value="">全部结果</option>
<option value="error">异常 error</option>
<option value="miss">未命中 miss</option>
<option value="unknown">未知类型 unknown</option>
<option value="skip">概率跳过 skip</option>
<option value="ok">正常 ok</option>
<option value="cap">已截断 cap</option>
</select>
<input id="sl-q" class="form-control" style="width:170px" placeholder="关键字(步骤名/选择器/详情)"
onkeydown="if(event.key==='Enter')loadStepLogs(true)">
<input id="sl-since" type="datetime-local" class="form-control" style="width:auto" onchange="loadStepLogs(true)">
<span class="text-muted">~</span>
<input id="sl-until" type="datetime-local" class="form-control" style="width:auto" onchange="loadStepLogs(true)">
<select id="sl-limit" class="form-control" style="width:auto" onchange="loadStepLogs(true)">
<option value="100">每页 100</option>
<option value="200" selected>每页 200</option>
<option value="500">每页 500</option>
</select>
<button class="btn" onclick="loadStepLogs(true)">刷新</button>
<button class="btn" onclick="downloadStepLogs()" title="导出当前筛选结果(CSV)">导出 CSV</button>
<button class="btn" onclick="resetStepFilters()">重置</button>
<span class="hint" id="sl-hint"></span>
</div>
<div class="hint" id="sl-note"></div>
<details id="sl-runs-box" style="margin:10px 0">
<summary style="cursor:pointer;font-size:13px" id="sl-runs-summary">最近运行概览</summary>
<table class="table" style="margin-top:8px">
<thead><tr>
<th style="width:150px">开始</th><th style="width:110px">设备</th><th>任务</th>
<th style="width:70px">步骤数</th><th style="width:80px">失败</th><th style="width:70px"></th>
</tr></thead>
<tbody id="sl-runs-body"></tbody>
</table>
</details>
<table class="table">
<thead><tr>
<th style="width:145px">时间</th><th style="width:105px">设备</th><th style="width:150px">任务</th>
<th style="width:60px">步骤</th><th>标签</th><th style="width:95px">类型</th>
<th style="width:75px">结果</th><th style="width:65px">耗时</th><th>详情</th>
</tr></thead>
<tbody id="sl-body"></tbody>
</table>
<div class="toolbar" style="margin-top:10px">
<button class="btn" id="sl-prev" onclick="stepLogPage(-1)">上一页</button>
<span class="hint" id="sl-page"></span>
<button class="btn" id="sl-next" onclick="stepLogPage(1)">下一页</button>
</div>
</div><!-- /logs-sub-steps -->
</div>
<!-- ========== Tab 5: 用户 ========== -->
<div id="tab-users" class="tab-panel">
<div class="page-title">用户管理</div>
<div class="page-sub">管理后台登录用户</div>
<div class="toolbar">
<button class="btn btn-primary" onclick="openUserModal()">新建用户</button>
<button class="btn" onclick="loadUsers()">刷新</button>
<input class="form-control list-search" id="search-users" placeholder="搜索用户名...">
</div>
<table class="table">
<thead><tr>
<th data-sort="id" onclick="_lpSort('users','id')">ID</th><th data-sort="username" onclick="_lpSort('users','username')">用户名</th><th data-sort="is_admin" onclick="_lpSort('users','is_admin')">管理员</th><th>权限</th><th>操作</th>
</tr></thead>
<tbody id="tb-users"></tbody>
</table>
<div class="list-pager" id="pager-users"></div>
</div>
<!-- ========== Tab 7: 维护(仅管理员) ========== -->
<!-- ========== 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>
<button class="sub-tab" data-sub="adb" onclick="showSubTab('tools','adb')">adb 远程终端</button>
<button class="sub-tab" data-sub="ts" onclick="showSubTab('tools','ts')">Tailscale 管理</button>
<button class="sub-tab" data-sub="apks" onclick="showSubTab('tools','apks')">应用管理</button>
<button class="sub-tab" data-sub="appver" onclick="showSubTab('tools','appver')">应用版本管理</button>
<button class="sub-tab" data-sub="devapps" onclick="showSubTab('tools','devapps')">设备已装应用</button>
<button class="sub-tab" data-sub="devpool" onclick="showSubTab('tools','devpool')">设备池管理</button>
<button class="sub-tab" data-sub="groups" onclick="showSubTab('tools','groups')">设备分组</button>
</div>
<!-- 子分栏 1:剪贴板注入 -->
<div class="sub-panel active" id="tools-sub-clipboard">
<div class="toolbar">
<button class="btn" onclick="loadToolsDevices(true)">刷新设备</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">实现:ClipInject APK 透明 Activity 写入剪贴板(Android 10+ 可用,u2 setClipboard 在 Android 12/13 会被系统丢弃);设备需先安装 com.example.clipinject,注入后自动读回比对验证。</div>
</div>
<div style="flex:1;min-width:280px">
<div class="form-group">
<label>目标设备(本地 adb 含 USB + 设备池,可多选)</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>
<!-- 子分栏 2:adb 远程终端 -->
<div class="sub-panel" id="tools-sub-adb">
<div class="help">选中目标设备后,命令里的 <code>-s &lt;设备&gt;</code> 自动附加,直接写
<code>shell ls /sdcard</code> 即可。离线设备也可选,用"重连设备"恢复。
<b>禁止 kill-server / disconnect</b>(会中断共享的 adb transport,导致设备连接全部重建)。</div>
<div style="display:flex;gap:8px;margin:8px 0;align-items:center">
<label style="font-size:12px;color:var(--text-light)">目标设备</label>
<select id="adb-device" class="form-control" style="flex:1;max-width:340px;font-family:monospace"></select>
<button class="btn btn-xs" onclick="loadAdbDevices(true)" title="刷新设备列表">刷新</button>
</div>
<div style="display:flex;gap:8px;margin:8px 0">
<input type="text" id="adb-cmd-input" class="form-control" style="flex:1;font-family:monospace"
placeholder="shell ls /sdcard(选中设备后 -s 自动附加)"
onkeydown="if(event.key==='Enter')runAdbCmd()">
<button class="btn btn-primary" onclick="runAdbCmd()">执行</button>
</div>
<div style="display:flex;gap:6px;margin-bottom:8px;flex-wrap:wrap">
<button class="btn btn-xs" onclick="fillAdbCmd('adb devices')">adb devices</button>
<button class="btn btn-xs" onclick="fillAdbCmd('shell dumpsys window | grep mCurrentFocus')">前台App</button>
<button class="btn btn-xs" onclick="fillAdbCmd('shell ls /sdcard')">列文件</button>
<button class="btn btn-xs" onclick="fillAdbCmd('shell screencap -p /sdcard/shot.png')">截屏</button>
<button class="btn btn-xs" onclick="fillAdbCmd('shell pm list packages -3')">已装应用</button>
<button class="btn btn-xs" onclick="fillAdbCmd('tcpip 5555')" title="USB 设备开启网络调试(重启设备后失效,需重新执行)">tcpip 5555 转网络调试</button>
</div>
<div style="display:flex;gap:6px;margin-bottom:8px;flex-wrap:wrap">
<button class="btn btn-xs" onclick="reconnectAdbDevice()" title="重新 adb connect 该设备">重连设备</button>
</div>
<pre id="adb-output" class="adb-console">(执行命令后输出显示在这里)</pre>
</div>
<!-- 子分栏 3:Tailscale 管理 -->
<div class="sub-panel" id="tools-sub-ts">
<div class="help" id="ts-hint"></div>
<div class="toolbar" style="margin:8px 0">
<button class="btn btn-primary" onclick="loadTailscaleDevices()">刷新设备</button>
<button class="btn" onclick="openTailscaleAuthKey()">生成接入 Auth Key</button>
<input class="form-control list-search" id="search-tailscale" placeholder="搜索主机名/名称/IP...">
<span id="ts-status" class="text-muted" style="margin-left:10px"></span>
</div>
<table class="table">
<thead><tr>
<th data-sort="hostname" onclick="_lpSort('tailscale','hostname')">主机名</th><th data-sort="name" onclick="_lpSort('tailscale','name')">显示名</th><th data-sort="ip" onclick="_lpSort('tailscale','ip')">IP</th><th data-sort="os" onclick="_lpSort('tailscale','os')">系统</th><th data-sort="online" onclick="_lpSort('tailscale','online')">在线</th><th data-sort="authorized" onclick="_lpSort('tailscale','authorized')">授权</th><th data-sort="key_expiry" onclick="_lpSort('tailscale','key_expiry')">密钥过期</th><th>操作</th>
</tr></thead>
<tbody id="tb-tailscale"><tr><td colspan="8" class="empty">点击"刷新设备"加载</td></tr></tbody>
</table>
<div class="list-pager" id="pager-tailscale"></div>
<div class="help">"设置IP"可修改设备 IPv4(会断开当前连接,平台 serial 随之变化,需同步设备池);"密钥不过期"= 关闭设备密钥验证,设备不会被定期踢下线;auth key 生成后只显示一次,请立即复制。</div>
</div>
<!-- 子分栏 4:应用管理 -->
<div class="sub-panel" id="tools-sub-apks">
<div class="toolbar">
<label class="btn btn-primary" style="cursor:pointer;margin-bottom:0">
上传APK
<input type="file" accept=".apk" style="display:none" onchange="uploadApk(this)">
</label>
<button class="btn" onclick="loadApks()">刷新</button>
<input class="form-control list-search" id="search-apks" placeholder="搜索应用名/包名...">
<span class="hint" id="apk-hint"></span>
</div>
<table class="table">
<thead><tr>
<th data-sort="name" onclick="_lpSort('apks','name')">应用名</th><th data-sort="package_name" onclick="_lpSort('apks','package_name')">包名</th><th data-sort="version_name" onclick="_lpSort('apks','version_name')">版本</th><th data-sort="size" onclick="_lpSort('apks','size')">大小</th><th data-sort="upload_time" onclick="_lpSort('apks','upload_time')">上传时间</th><th>操作</th>
</tr></thead>
<tbody id="tb-apks"></tbody>
</table>
<div class="list-pager" id="pager-apks"></div>
<!-- 设备端应用商店:设备上的 Agent 自己拉清单/下载/安装(绕开 MIUI 的 USB 安装拦截) -->
<div class="section-title" style="margin-top:16px">📱 设备端应用商店(Agent 直连安装)</div>
<div class="help">
设备上装的 <b>Agent</b> 自己来拉这份应用清单 → 在手机上选一个 → 自己下载 → 自己调系统安装器安装。
因为安装由设备上的 App 发起,走的是<b>普通应用安装流程</b>,
<b>不会触发 MIUI 针对 adb 安装的「USB 安装」拦截</b>(那正是 <code>adb install</code> 报 USER_RESTRICTED 的原因)。
适合"人就在机器旁边"的场景(本来就要点一次安装确认)。平台批量推送仍用上面的「安装」按钮。
<br><span class="text-muted">接口契约见 <code>doc/DEVICE_AGENT.md</code>;令牌属于凭据,别外传。</span>
</div>
<div class="toolbar" style="gap:10px;flex-wrap:wrap">
<label style="display:inline-flex;align-items:center;gap:4px;margin:0">
<input type="checkbox" id="store-enabled" onchange="toggleAgentStore(this.checked)"> 启用设备端商店
</label>
<input class="form-control" id="store-url" readonly style="width:290px;font-family:monospace" title="Agent 里要填的平台地址">
<input class="form-control" id="store-token" readonly style="width:300px;font-family:monospace" title="Agent 里要填的设备令牌">
<button class="btn" onclick="copyStoreToken()">复制令牌</button>
<button class="btn" onclick="regenStoreToken()">重置令牌</button>
<button class="btn" onclick="loadAgentStore()">刷新记录</button>
<span class="text-muted" id="store-status"></span>
</div>
<table class="table">
<thead><tr>
<th style="width:150px">时间</th><th style="width:90px">设备</th>
<th style="width:200px">应用</th><th style="width:110px">动作</th><th>说明</th>
</tr></thead>
<tbody id="tb-store-logs"><tr><td colspan="5" class="empty">暂无记录(设备还没从商店装过东西)</td></tr></tbody>
</table>
</div>
<!-- 子分栏 5:应用版本管理 -->
<div class="sub-panel" id="tools-sub-appver">
<div class="toolbar">
<input type="text" id="appver-pkg" class="form-control" style="width:340px;font-family:monospace"
placeholder="包名,如 com.ss.android.ugc.aweme" onkeydown="if(event.key==='Enter')queryAppVers()">
<button class="btn btn-primary" onclick="queryAppVers()">查询版本</button>
<input class="form-control list-search" id="search-appver" placeholder="搜索设备/版本...">
<span id="appver-status" class="text-muted"></span>
</div>
<table class="table">
<thead><tr>
<th data-sort="serial" onclick="_lpSort('appver','serial')">设备</th>
<th data-sort="state" onclick="_lpSort('appver','state')">状态</th>
<th data-sort="version" onclick="_lpSort('appver','version')">版本号</th>
<th data-sort="code" onclick="_lpSort('appver','code')">versionCode</th>
</tr></thead>
<tbody id="tb-appver"><tr><td colspan="4" class="empty">输入包名后点击"查询版本"</td></tr></tbody>
</table>
<div class="list-pager" id="pager-appver"></div>
<div class="help">查询所有设备(本地 adb 含 USB + 设备池)上该包名的安装情况与版本号,用于对比各设备 App 版本是否一致;未连接的设备会自动连接(绝不断开,不影响任务)。</div>
</div>
<!-- 子分栏 6:设备已装应用 -->
<div class="sub-panel" id="tools-sub-devapps">
<div class="toolbar">
<select id="apps-device" class="form-control" style="width:280px;display:inline-block">
<option value="">选择设备...</option>
</select>
<button class="btn btn-primary" onclick="loadDeviceApps()">查看应用</button>
<input class="form-control list-search" id="search-devapps" placeholder="搜索包名/路径...">
<span class="hint" id="apps-hint"></span>
</div>
<div style="max-height:420px;overflow:auto;border:1px solid var(--border);border-radius:8px">
<table class="table" style="margin:0">
<thead><tr>
<th data-sort="label" onclick="_lpSort('devapps','label')">应用</th><th data-sort="version_name" onclick="_lpSort('devapps','version_name')">版本号</th><th data-sort="version_code" onclick="_lpSort('devapps','version_code')">versionCode</th><th>APK 路径</th>
</tr></thead>
<tbody id="tb-device-apps">
<tr><td colspan="4" class="empty">选择设备后点击"查看应用"</td></tr>
</tbody>
</table>
</div>
<div class="list-pager" id="pager-devapps"></div>
</div>
<!-- 子分栏 6:设备池管理(本地 SQLite 清单,任务调度的数据源) -->
<div class="sub-panel" id="tools-sub-devpool">
<!-- 自动发现:扫描网段 → 待连接池,确认后才加入正式池 -->
<div style="border-bottom:1px solid var(--border);margin-bottom:10px;padding-bottom:10px">
<div class="toolbar" style="flex-wrap:wrap">
<label style="display:inline-flex;align-items:center;gap:4px">
<input type="checkbox" id="discovery-enabled" onchange="toggleDiscovery(this.checked)"> 定时扫描
</label>
<span class="text-muted" style="margin-left:8px">周期</span>
<input type="number" id="discovery-interval" style="width:70px" min="10">
<span class="text-muted">秒 · 网段</span>
<input type="text" id="discovery-subnets" class="form-control" style="width:300px;font-family:monospace"
placeholder="192.168.20.0/24, 100.100.10.0/24" title="逗号分隔的 CIDR 网段">
<label style="display:inline-flex;align-items:center;gap:4px;margin-left:10px"
title="开启后:扫描发现某台设备的指纹与设备池中已有记录一致(= 同一台设备换了 IP)时,自动把它认领到新地址——迁移原记录并同步分组/任务里的引用,不再需要人工确认。默认关:认领会改写分组与任务的引用,交人工点一下更稳妥。">
<input type="checkbox" id="discovery-autoclaim" onchange="toggleAutoClaim(this.checked)"> 指纹匹配自动认领
</label>
<button class="btn" onclick="saveDiscoverySettings()">保存设置</button>
<button class="btn btn-primary" onclick="runDiscoveryScan()">立即扫描</button>
<span id="discovery-status" class="text-muted"></span>
</div>
<table class="table table-hover table-sm" style="margin-top:8px">
<thead><tr><th>serial</th><th>识别</th><th>来源</th><th>首次发现</th><th>最近发现</th><th>在线</th><th>操作</th></tr></thead>
<tbody id="tb-discovery"><tr><td colspan="7" class="empty">尚未扫描</td></tr></tbody>
</table>
<div class="help">扫描局域网/Tailscale 网段中开放 adb 5555 的设备进入<b>待连接池</b>;
<b>确认后才加入正式设备池</b>(不自动连接、不参与任务调度)。未授权设备(手机未接受 RSA 弹窗)不会出现。<br>
<b>「识别」列</b>:平台按<b>设备指纹</b>认出这台其实就是池中某台设备换了 IP —— 确认后自动<b>认领</b>:
迁移原记录(名称/型号/备注全保留),并把分组与任务里的旧地址一并换成新地址。新设备则必须填名称。</div>
<!-- 正式池断联设备:仍在设备池(不删除、不进待连接池),自动重连中 -->
<div style="margin-top:10px">
<div style="font-size:12.5px;font-weight:600;margin-bottom:6px">设备池断联设备
<span class="text-muted" style="font-weight:400">(仍在设备池内,扫描线程每轮自动重连;也可手动立即重连)</span></div>
<table class="table table-hover table-sm">
<thead><tr><th>serial</th><th>名称</th><th>型号</th><th>状态</th><th>操作</th></tr></thead>
<tbody id="tb-pool-offline"><tr><td colspan="5" class="empty">全部在线</td></tr></tbody>
</table>
</div>
</div>
<!-- 电量监控:定时采集设备电量(大屏 + 监控页设备列表展示),低于阈值推 webhook 通知 -->
<div style="border-bottom:1px solid var(--border);margin-bottom:10px;padding-bottom:10px">
<div class="toolbar" style="flex-wrap:wrap">
<label style="display:inline-flex;align-items:center;gap:4px">
<input type="checkbox" id="battery-enabled" onchange="saveBatterySettings()"> 电量监控
</label>
<span class="text-muted" style="margin-left:8px">低电量</span>
<input type="number" id="battery-low" style="width:70px" min="1" max="100"
title="电量低于这个值就告警">
<span class="text-muted">% · 严重</span>
<input type="number" id="battery-critical" style="width:70px" min="1" max="100"
title="低于这个值时通知等级升到「严重」(error)。不能高于低电量阈值。">
<span class="text-muted">%</span>
<label style="display:inline-flex;align-items:center;gap:4px;margin-left:10px"
title="充电中(status=充电中/已充满)不告警——插着充电器说明正在补电。注意「插着却没充电」(劣质线/温控/满电停充)仍会告警,那正是最该知道的情况。">
<input type="checkbox" id="battery-skip-charging"> 充电中不告警
</label>
<span class="text-muted" style="margin-left:8px">采集间隔</span>
<input type="number" id="battery-interval" style="width:70px" min="30" max="3600">
<span class="text-muted">秒</span>
<button class="btn" onclick="saveBatterySettings()">保存设置</button>
<button class="btn" onclick="scanBatteryNow()">立即采集</button>
<span id="battery-status" class="text-muted"></span>
</div>
<div class="help">定时用 <code>dumpsys battery</code>(只读)采集池内在线设备的电量,显示在
<b>「监控 → 设备列表」的电量列</b>与<b>大屏卡片</b>上。<br>
低于阈值时推 webhook 通知(事件 <code>device.battery.low</code>,要在「系统 → 通知」里勾选订阅才发):
<b>掉到低电量报一次、掉到严重再报一次、恢复再报一次</b>,不做重复刷屏。<br>
电量只放内存、<b>不落库</b>(重启后重新采一轮),所以不占数据库、不影响备份。
设备离线时显示最后一次读数(置灰),不会变成空白。</div>
</div>
<div class="toolbar">
<input type="text" id="devpool-serial" class="form-control" style="width:220px;font-family:monospace"
placeholder="serial,如 100.100.10.20:5555" onkeydown="if(event.key==='Enter')addPoolDev()">
<input type="text" id="devpool-name" class="form-control" style="width:170px"
placeholder="设备名称(必填·唯一)" title="设备在平台里的标识;分组、任务目标、日志都用它认设备"
onkeydown="if(event.key==='Enter')addPoolDev()">
<button class="btn btn-primary" onclick="addPoolDev()">添加设备</button>
<button class="btn" onclick="reconnectPoolDevices()" title="并发 adb connect 池内全部设备">一键重连</button>
<button class="btn" onclick="refreshPoolModels()" title="采集池内在线设备的型号(getprop)">采集型号</button>
<button class="btn" onclick="loadDevPool()">刷新</button>
<label style="display:inline-flex;align-items:center;gap:4px;margin:0 6px">
<input type="checkbox" id="devpool-filter-online" checked onchange="loadDevPool()"> 只看在线
</label>
<span id="devpool-status" class="text-muted"></span>
</div>
<table class="table table-hover table-sm" style="margin-top:8px">
<thead><tr>
<th>serial</th><th>名称</th><th>型号</th><th>在线</th><th>状态</th>
<th title="设备指纹(ro.serialno):采集到之后,设备换 IP 才能被自动认领">指纹</th>
<th title="这台设备在「账号」页登记了几个抖音号(点数字看明细)">账号</th>
<th>添加时间</th><th>操作</th>
</tr></thead>
<tbody id="tb-devpool"></tbody>
</table>
<div class="help">
新增设备流程:手机开 USB 调试 → 连上后「adb 终端」执行 <code>tcpip 5555</code> 转网络调试 →
Tailscale 上线(同一账号)→ 在此添加 <code>IP:5555</code>(自动尝试连接)→
任务运行时 u2 自动推送 atx-agent。<b>停用 = 不参与任务调度</b>,删除 = 从池中移除(不影响其他系统)。<br>
<b>设备名称必填且唯一</b>:它是设备在平台里的身份(分组/任务/日志都按名称认设备);
设备换了 IP 时用「换地址」把记录迁过去,分组与任务的引用会自动同步,不用重新配。
</div>
</div>
<!-- 子分栏:设备分组(原顶级 tab,移到工具页) -->
<div class="sub-panel" id="tools-sub-groups">
<div class="toolbar">
<button class="btn btn-primary" onclick="openGroupModal()" data-perm="tasks">新建分组</button>
<button class="btn" onclick="loadGroups()">刷新</button>
<input class="form-control list-search" id="search-groups" placeholder="搜索分组名/描述...">
<span id="groups-status" class="text-muted"></span>
</div>
<table class="table table-hover table-sm" style="margin-top:8px">
<thead><tr>
<th data-sort="name" onclick="_lpSort('groups','name')">分组名</th><th>描述</th><th data-sort="count" onclick="_lpSort('groups','count')">设备数</th><th>设备列表</th><th>操作</th>
</tr></thead>
<tbody id="tb-groups"></tbody>
</table>
<div class="list-pager" id="pager-groups"></div>
<div class="help">管理设备分组,用于任务按分组执行。分组内的设备需先在<b>设备池管理</b>中确认(不在池内的设备不参与调度)。</div>
</div>
</div>
<!-- ========== Tab 9: 系统(仅管理员) ========== -->
<div id="tab-system" class="tab-panel">
<div class="page-title">系统</div>
<div class="page-sub">系统数据备份导出/导入(仅管理员)</div>
<div class="sub-tabs">
<button class="sub-tab active" data-sub="backup" onclick="showSubTab('system','backup')">数据备份</button>
<button class="sub-tab" data-sub="restore" onclick="showSubTab('system','restore')">导入恢复</button>
<button class="sub-tab" data-sub="notify" onclick="showSubTab('system','notify')">通知 / Webhook</button>
</div>
<!-- 子分栏 1:数据备份 -->
<div class="sub-panel active" id="system-sub-backup">
<div class="help">
<b>导出</b>当前系统数据为 zip:把数据库(MySQL 或回退模式的 SQLite)整库一致快照成
<code>users.db</code> 归档(<b>全部业务表</b>:用户 / 任务计划 / 设备分组 /
自定义动作 / APK 记录 / 设备池 / 待连接设备 / AI 会话 / 经验库 / 经验巡检 / <b>动作库</b> / 系统配置)+ 可选的
<code>apks/</code> 应用文件。恢复用:换机 / 整库迁移 / 出问题前留底。
<br><span class="text-muted">清单见预览的「表数据行数」(由模型元数据自动派生,新增表不会漏);备份里记录了来源环境,跨环境导入默认拒绝。</span>
</div>
<div class="toolbar" style="gap:8px">
<label style="display:inline-flex;align-items:center;gap:4px;margin:0 6px">
<input type="checkbox" id="backup-include-apk" checked> 包含 APK 文件(体积较大,~100MB 级)
</label>
<button class="btn btn-primary" onclick="doExportBackup()">导出备份</button>
<span id="backup-status" class="text-muted"></span>
</div>
<div style="background:#fff8e1;border:1px solid #ffe082;color:#8a6d00;padding:8px 12px;border-radius:6px;margin:8px 0">
⚠️ 备份为<b>全量快照</b>:含用户口令哈希、AI 配置里的 API Key 等敏感信息,请妥善保管,勿外传、勿提交到 git。
</div>
</div>
<!-- 子分栏 2:导入恢复 -->
<div class="sub-panel" id="system-sub-restore">
<div class="help">
<b>导入恢复</b>:上传平台导出的 <code>.zip</code> 或 <code>.db</code> → 先<b>校验预览</b> →
确认后系统会<b>自动备份当前库</b>(<code>data/backups/pre_restore_*.zip</code>,可直接再导入回来)并把恢复任务挂起,
<b>重启 web_server 后生效</b>(导入将覆盖现有全部数据;数据替换在单个事务里完成,失败会自动回滚)。
<br><span class="text-muted">备份来自另一个环境(dev↔prod)时默认拒绝导入,需在预览页显式勾选「允许跨环境导入」。</span>
</div>
<div class="toolbar" style="gap:8px">
<input type="file" id="restore-file" accept=".zip,.db">
<button class="btn" onclick="previewRestore()">上传并预览</button>
<button class="btn btn-danger" id="btn-apply-restore" onclick="applyRestore()" style="display:none">确认应用导入</button>
<span id="restore-status" class="text-muted"></span>
</div>
<div id="restore-preview" style="margin-top:8px"></div>
</div>
<!-- 子分栏 3:通知 / Webhook -->
<div class="sub-panel" id="system-sub-notify">
<div class="help">
<b>通知</b>:把平台事件(任务失败/成功、设备上下线、安装完成、备份恢复…)推到 webhook,
例如企业微信群机器人。每条 webhook <b>自己勾选关心哪些事件</b>——不勾就不推。
<br><span class="text-muted">为防刷屏:同一条 webhook 默认每 30 秒合并一次同类事件、每分钟最多 18 条
(企业微信硬限 20),超出的会压成一条「被限流折叠」摘要而不是丢掉。详见 <code>doc/NOTIFY.md</code>。</span>
</div>
<div class="toolbar" style="gap:10px;flex-wrap:wrap">
<button class="btn btn-primary" onclick="openNotifyModal(null)" data-perm="admin">+ 新建 Webhook</button>
<button class="btn" onclick="loadNotifyPanel()">刷新</button>
<label style="display:flex;align-items:center;gap:6px;font-size:12.5px;cursor:pointer"
title="关掉后所有 webhook 暂停推送(配置保留)">
<input type="checkbox" id="notify-global" onchange="toggleNotifyGlobal(this)"> 启用通知
</label>
<span class="text-muted" style="font-size:11.5px">默认聚合
<input type="number" id="notify-agg" min="0" max="600" class="form-control"
style="width:64px;display:inline-block"> 秒 ·
限流 <input type="number" id="notify-rate" min="0" max="60" class="form-control"
style="width:64px;display:inline-block"> 条/分
<button class="btn btn-xs" onclick="saveNotifyGlobal()">保存默认</button>
</span>
</div>
<table class="table">
<thead><tr><th>名称</th><th>格式</th><th>URL</th><th>订阅</th><th>状态</th><th>操作</th></tr></thead>
<tbody id="tb-notify-hooks"></tbody>
</table>
<div style="margin-top:18px;display:flex;align-items:center;gap:10px">
<b>发送记录</b>
<span class="text-muted" style="font-size:11.5px">仅本次运行期间(重启清空);完整历史见 <code>logs/notify.log</code></span>
<button class="btn btn-xs" onclick="loadNotifyPanel()">刷新记录</button>
</div>
<table class="table">
<thead><tr><th style="width:160px">时间</th><th style="width:120px">Webhook</th><th style="width:180px">事件</th><th style="width:70px">结果</th><th>详情</th></tr></thead>
<tbody id="tb-notify-logs"></tbody>
</table>
</div>
</div>
<!-- 通知:编辑弹窗(内容由 static/admin/notify.js 渲染) -->
<div id="notify-modal-overlay" onclick="if(event.target===this)closeNotifyModal()"
style="display:none;position:fixed;inset:0;background:rgba(0,0,0,.6);z-index:1340;align-items:flex-start;justify-content:center;overflow-y:auto;padding:30px 0">
<div id="notify-modal-box" style="background:#fff;border-radius:14px;padding:20px;width:760px;max-width:94vw;margin:auto"></div>
</div>
</div><!-- /.content -->
<!-- ========== 测试此步骤弹窗 ========== -->
<div id="test-overlay" class="modal-overlay" style="z-index:1200" onclick="if(event.target===this)closeTestModal()">
<div class="modal" id="test-modal-box">
<div class="modal-header">
<h3 id="test-modal-title">测试步骤</h3>
<button class="close" onclick="closeTestModal()">&times;</button>
</div>
<div class="modal-body" id="test-modal-body"></div>
<div class="modal-footer"><button class="btn" onclick="closeTestModal()">关闭</button></div>
</div>
</div>
<!-- ========== 模态框 ========== -->
<div id="modal-overlay" class="modal-overlay" onclick="if(event.target===this)closeModal()">
<div class="modal" id="modal-box">
<div class="modal-header">
<h3 id="modal-title">标题</h3>
<button class="close" onclick="closeModal()">&times;</button>
</div>
<div class="modal-body" id="modal-body"></div>
<div class="modal-footer" id="modal-footer"></div>
</div>
</div>
<!-- ========== 自定义动作编辑模态框 ========== -->
<div id="action-modal-overlay" class="modal-overlay" onclick="if(event.target===this)closeActionModal()">
<div class="modal" id="action-modal-box" style="max-width:1000px">
<div class="modal-header">
<h3 id="action-modal-title">新建动作</h3>
<button class="close" onclick="closeActionModal()">&times;</button>
</div>
<div class="modal-body" id="action-modal-body" style="max-height:78vh;overflow:auto"></div>
<div class="modal-footer" id="action-modal-footer"></div>
</div>
</div>
<!-- ========== 元素抓取模态框(独立第二层,不覆盖任务编辑窗口)========== -->
<div id="el-picker-overlay" class="modal-overlay" style="z-index:1100" onclick="if(event.target===this)closeElPicker()">
<div class="modal" id="el-picker-box" style="max-width:1800px;width:98vw">
<div class="modal-header">
<h3 id="el-picker-title">抓取元素</h3>
<button class="close" onclick="closeElPicker()">&times;</button>
</div>
<div class="modal-body" id="el-picker-body" style="height:88vh;padding:0;overflow:hidden;display:flex;flex-direction:column"></div>
<div class="modal-footer" id="el-picker-footer"></div>
</div>
</div>
<!-- ========== Toast ========== -->
<div id="toast" class="toast"></div>
<!-- 前端模块(按依赖顺序加载:base → list → 页面模块 → admin(含初始化)) -->
<script src="/static/admin/base.js"></script>
<script src="/static/admin/markdown.js"></script>
<script src="/static/admin/list.js"></script>
<script src="/static/admin/monitor.js"></script>
<script src="/static/admin/editor.js"></script>
<script src="/static/admin/tasks.js"></script>
<script src="/static/admin/tools.js"></script>
<script src="/static/admin/apps.js"></script>
<script src="/static/admin/admin.js"></script>
<script src="/static/admin/agent.js"></script>
<script src="/static/admin/taskgen.js"></script>
<script src="/static/admin/system.js"></script>
<script src="/static/admin/notify.js"></script>
<script src="/static/admin/steplog.js"></script>
<script src="/static/admin/recorder.js"></script>
<script src="/static/admin/dedup.js"></script>
<script src="/static/admin/ledger.js"></script>
<script src="/static/admin/release.js"></script>
</body>
</html>