01 · 选择设备
先选择一台手机。档案未验证时,任务会停在采样门禁。
未选择"""NextGen 本地控制台:设备、采样、导入和任务调度。""" from __future__ import annotations import base64 import binascii import cgi import json import re import shutil import threading import urllib.parse from datetime import datetime from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path from . import device, ledger, profiles, recorder, runner, sampling from .paths import CONFIG, TASKS, WORK from .platform import scrcpy_binary RECORDING: dict[str, object] = {} RECORDING_LOCK = threading.Lock() ENQUEUE_LOCK = threading.Lock() MAX_UPLOAD_BYTES = 12 * 1024 * 1024 def settings() -> dict: base = {"planner": {"enabled": False}, "safety": {"allow_submit": False, "max_loop_steps": 30}} if CONFIG.exists(): base.update(json.loads(CONFIG.read_text(encoding="utf-8"))) return base def public_settings() -> dict: data = settings() return {"planner_enabled": bool(data.get("planner", {}).get("enabled")), "allow_submit": bool(data.get("safety", {}).get("allow_submit")), "max_loop_steps": data.get("safety", {}).get("max_loop_steps", 30)} def device_payload(serial: str) -> dict: selected = device.selected_device(serial) fingerprint = device.fingerprint(selected.serial) return {"serial": selected.serial, "model": selected.model, "fingerprint": fingerprint, "profile": profiles.compatibility(profiles.load(selected.serial), fingerprint)} def list_tasks() -> list[dict]: rows = [] for path in sorted(TASKS.glob("*.json"), key=lambda item: item.stat().st_mtime, reverse=True)[:20] if TASKS.exists() else []: try: task = json.loads(path.read_text(encoding="utf-8")) rows.append({"id": task.get("id"), "status": task.get("status"), "device_serial": task.get("device_serial"), "updated_at": task.get("updated_at"), "block_reason": task.get("block_reason", ""), "history": task.get("history", [])[-5:]}) except (OSError, json.JSONDecodeError): continue return rows def roster_status() -> dict: try: rows = ledger.read_roster() return {"ready": True, "rows": len(rows), "path": str(ledger.ROSTER)} except RuntimeError as exc: return {"ready": False, "rows": 0, "reason": str(exc)} def latest_recording() -> str: replay = WORK / "replay" if not replay.is_dir(): return "" recordings = [path for path in replay.iterdir() if path.is_dir() and (path / "session.json").is_file() and (path / "timeline.json").is_file()] return str(max(recordings, key=lambda path: path.stat().st_mtime)) if recordings else "" def save_qr_image(data_url: str) -> dict: """保存浏览器剪贴板图片;仅接受常见图像格式,拒绝任意文件写入。""" if not isinstance(data_url, str) or not data_url.startswith("data:image/") or "," not in data_url: raise RuntimeError("未收到有效二维码图片") _header, encoded = data_url.split(",", 1) try: image = base64.b64decode(encoded, validate=True) except (binascii.Error, ValueError) as exc: raise RuntimeError("二维码图片数据损坏") from exc if not image or len(image) > MAX_UPLOAD_BYTES: raise RuntimeError("二维码图片不能为空且不能超过 12MB") extensions = ((b"\x89PNG\r\n\x1a\n", ".png"), (b"\xff\xd8\xff", ".jpg"), (b"GIF87a", ".gif"), (b"GIF89a", ".gif"), (b"RIFF", ".webp")) extension = next((suffix for signature, suffix in extensions if image.startswith(signature)), "") if not extension: raise RuntimeError("仅支持 PNG、JPG、GIF 或 WEBP 图片") destination = WORK / "inputs" / "qr" / f"qr_{datetime.now().strftime('%Y%m%d-%H%M%S-%f')}{extension}" destination.parent.mkdir(parents=True, exist_ok=True) destination.write_bytes(image) return {"saved": True, "path": str(destination), "size": len(image)} PAGE = """
设备 → 采样 → 档案 → 任务。页面只调度,业务状态机始终在后端执行。
先选择一台手机。档案未验证时,任务会停在采样门禁。
未选择录制完整流程后,导入采样并确认设备档案。
沿用旧 UI 的文件交互:拖入名单(含 XML),直接粘贴二维码截图。原文件与截图都只保存在 NextGen 工作目录。
任务按设备加锁。可刷新查看排队、阻塞与最近动作。
就绪
没有已授权设备
';document.querySelectorAll('[data-serial]').forEach(el=>el.addEventListener('click',()=>{selectDevice(el.dataset.serial);devices()}))}catch(e){tell('读取设备失败:'+e.message,'error');out(e.message)}}async function tasks(){try{const rows=await api('/api/tasks');q('tasks').innerHTML=rows.map(t=>`暂无任务
';return rows}catch(e){out(e.message);throw e}}async function roster(){try{const r=await api('/api/roster');rosterReady=!!r.ready;q('roster-status').textContent=r.ready?`已导入 ${r.rows} 人(NextGen 独立名单)`:(r.reason||'尚未导入名单')}catch(e){q('roster-status').textContent=e.message}}async function recording(){try{const r=await api('/api/sampling');q('recording').textContent=r.active?(r.stopping?'正在整理录制… ':'录制中:')+r.output:'尚未开始';if(r.output)q('sample-path').value=r.output;q('record-start').disabled=!!r.active;q('record-stop').disabled=!r.active}catch(e){out(e.message)}}async function all(){try{const s=await api('/api/settings');canSubmit=!!s.allow_submit;q('safety').textContent=canSubmit?'提交权限已开启':'观察模式(提交权限关闭)';await Promise.all([devices(),tasks(),recording(),roster()]);tell(serial?'设备已就绪,可创建任务':'请先选择设备')}catch(e){out(e.message)}}async function uploadRoster(file){if(!file)return;const form=new FormData();form.append('file',file,file.name);q('roster-status').textContent=`正在导入:${file.name}`;try{const r=await api('/api/roster/upload',{method:'POST',body:form});rosterReady=true;q('roster-status').textContent=`已导入 ${r.rows} 人:${file.name}`;tell(`名单导入成功:${r.rows} 人`,'ok');out(r)}catch(e){rosterReady=false;q('roster-status').textContent='导入失败:'+e.message;tell('名单导入失败:'+e.message,'error');out(e.message)}}async function saveQr(file){if(!file)return;const reader=new FileReader();reader.onload=async()=>{const zone=q('qr-paste'),hint=q('qr-hint'),preview=q('qr-preview');preview.src=reader.result;preview.style.display='block';hint.textContent='正在保存二维码截图…';try{const r=await api('/api/qr/paste',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({image:reader.result})});qrPath=r.path;zone.classList.remove('bad');zone.classList.add('ok');hint.textContent='二维码已保存,创建任务时会推送到手机相册';tell('二维码已保存,可创建任务','ok');out(r)}catch(e){qrPath='';zone.classList.remove('ok');zone.classList.add('bad');hint.textContent='保存失败:'+e.message;tell('二维码保存失败:'+e.message,'error');out(e.message)}};reader.readAsDataURL(file)}async function create(allow){if(!serial)return tell('请先在上方选择手机','error');if(allow&&!canSubmit)return tell('当前为观察模式;安全策略未开启,不能执行开票任务','error');if(allow&&!rosterReady)return tell('请先拖入乘车人名单','error');if(allow&&!qrPath)return tell('请先粘贴二维码截图','error');try{const t=await api('/api/tasks',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({device_serial:serial,allow_submit:allow,success_action:q('action').value,qr_image:qrPath})});out(t);tell(allow?`任务已创建,正在启动:${t.id.slice(0,8)}`:`观察任务已创建:${t.id.slice(0,8)}`,'ok');if(allow){const queued=await api('/api/tasks/'+t.id+'/run',{method:'POST'});out(queued);tell(`任务已进入队列:${t.id.slice(0,8)}`,'ok')}await tasks()}catch(e){tell('创建任务失败:'+e.message,'error');out(e.message)}}const drop=q('roster-drop'),file=q('roster-file');file.addEventListener('change',()=>uploadRoster(file.files[0]));['dragenter','dragover'].forEach(evt=>drop.addEventListener(evt,e=>{e.preventDefault();drop.classList.add('dragover')}));['dragleave','drop'].forEach(evt=>drop.addEventListener(evt,e=>{e.preventDefault();drop.classList.remove('dragover')}));drop.addEventListener('drop',e=>uploadRoster(e.dataTransfer?.files?.[0]));const paste=q('qr-paste');paste.addEventListener('click',()=>paste.focus());paste.addEventListener('paste',e=>{e.preventDefault();const item=[...(e.clipboardData?.items||[])].find(v=>v.type.startsWith('image/'));if(!item){q('qr-hint').textContent='剪贴板中没有图片,请先复制二维码截图';paste.classList.add('bad');tell('剪贴板中没有二维码图片','error');return}saveQr(item.getAsFile())});document.addEventListener('click',async e=>{const action=e.target.closest('[data-action]')?.dataset.action;if(!action)return;try{if(action==='refresh')return all();if(action==='tasks'){const rows=await tasks();return tell(`任务已刷新:${rows.length} 个`,'ok')}if(action==='observe')return create(false);if(action==='execute')return create(true);if(action==='record-start'){if(!serial)return tell('请先选择设备','error');await api('/api/sampling/start',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({serial})});return recording()}if(action==='record-stop'){await api('/api/sampling/stop',{method:'POST'});return recording()}if(action==='sample-import'){if(!serial)return tell('请先选择设备','error');out(await api('/api/sampling/import',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({serial,path:q('sample-path').value.trim()})}));return devices()}if(action==='profile-verify'){if(!serial)return tell('请先选择设备','error');out(await api('/api/profiles/'+encodeURIComponent(serial)+'/verify',{method:'POST'}));return devices()}}catch(err){tell(err.message,'error');out(err.message)}});all();setInterval(tasks,3000);setInterval(recording,3000)})();""".encode() APP_JS = APP_JS.replace( "function tell(text,tone=''){const el=q('task-message');el.textContent=text;el.className='task-message '+tone}".encode(), "function tell(text,tone=''){const el=q('task-message');el.textContent=text;el.className='task-message '+tone}function tellTasks(text,tone=''){const el=q('tasks-message');el.textContent=text;el.className='task-message '+tone}".encode(), ).replace( "return tell(`任务已刷新:${rows.length} 个`,'ok')".encode(), "return tellTasks(`任务已刷新:${rows.length} 个`,'ok')".encode(), ) APP_JS = APP_JS.replace( "function tellTasks(text,tone=''){const el=q('tasks-message');el.textContent=text;el.className='task-message '+tone}".encode(), "function tellTasks(text,tone=''){const el=q('tasks-message');el.textContent=text;el.className='task-message '+tone}function tellSample(text,tone=''){const el=q('sample-message');el.textContent=text;el.className='task-message '+tone}".encode(), ).replace( "if(r.output)q('sample-path').value=r.output;".encode(), "if(r.output)q('sample-path').value=r.output;if(r.latest&&!q('sample-path').value)q('sample-path').value=r.latest;".encode(), ).replace( "if(action==='sample-import'){if(!serial)return tell('请先选择设备','error');out(await api('/api/sampling/import',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({serial,path:q('sample-path').value.trim()})}));return devices()}".encode(), "if(action==='sample-import'){if(!serial)return tellSample('请先选择设备','error');try{const result=await api('/api/sampling/import',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({serial,path:q('sample-path').value.trim()})});out(result);tellSample('采样已导入,请点击确认档案','ok');return devices()}catch(error){tellSample('导入采样失败:'+error.message,'error');throw error}}".encode(), ).replace( "if(action==='profile-verify'){if(!serial)return tell('请先选择设备','error');out(await api('/api/profiles/'+encodeURIComponent(serial)+'/verify',{method:'POST'}));return devices()}".encode(), "if(action==='profile-verify'){if(!serial)return tellSample('请先选择设备','error');try{const result=await api('/api/profiles/'+encodeURIComponent(serial)+'/verify',{method:'POST'});out(result);tellSample('设备档案已确认,可执行任务','ok');return devices()}catch(error){tellSample('确认档案失败:'+error.message,'error');throw error}}".encode(), ) class Handler(BaseHTTPRequestHandler): def send(self, payload: bytes, content_type: str, status: int = 200) -> None: self.send_response(status); self.send_header("Content-Type", content_type); self.send_header("Content-Length", str(len(payload))); self.end_headers(); self.wfile.write(payload) def send_json(self, data: object, status: int = 200) -> None: self.send(json.dumps(data, ensure_ascii=False).encode(), "application/json; charset=utf-8", status) def read_json(self) -> dict: length = int(self.headers.get("Content-Length", "0")) if length > MAX_UPLOAD_BYTES: raise RuntimeError("请求不能超过 12MB") return json.loads(self.rfile.read(length) or b"{}") def read_roster_upload(self) -> Path: length = int(self.headers.get("Content-Length", "0")) if not 0 < length <= MAX_UPLOAD_BYTES: raise RuntimeError("名单文件不能为空且不能超过 12MB") form = cgi.FieldStorage(fp=self.rfile, headers=self.headers, environ={"REQUEST_METHOD": "POST", "CONTENT_TYPE": self.headers.get("Content-Type", ""), "CONTENT_LENGTH": str(length)}) item = form["file"] if "file" in form else None if item is None or isinstance(item, list) or not item.filename: raise RuntimeError("未收到名单文件") name = Path(item.filename).name if Path(name).suffix.lower() not in {".csv", ".xml", ".xlsx", ".xlsm"}: raise RuntimeError("名单只支持 .csv、.xml、.xlsx、.xlsm") target = WORK / "uploads" / f"{datetime.now().strftime('%Y%m%d-%H%M%S-%f')}_{re.sub(r'[^A-Za-z0-9_.-]+', '_', name)}" target.parent.mkdir(parents=True, exist_ok=True) with target.open("wb") as handle: shutil.copyfileobj(item.file, handle) return target def do_GET(self) -> None: path = urllib.parse.urlparse(self.path).path try: if path == "/": self.send(PAGE, "text/html; charset=utf-8"); return if path == "/app.css": self.send(CSS, "text/css; charset=utf-8"); return if path == "/app.js": self.send(APP_JS, "application/javascript; charset=utf-8"); return if path == "/api/settings": self.send_json(public_settings()); return if path == "/api/devices": self.send_json([device_payload(item.serial) for item in device.devices() if item.state == "device"]); return if path == "/api/tasks": self.send_json(list_tasks()); return if path == "/api/roster": self.send_json(roster_status()); return if path == "/api/sampling": with RECORDING_LOCK: if RECORDING and not RECORDING["thread"].is_alive(): RECORDING.clear() self.send_json({"active": bool(RECORDING), "stopping": bool(RECORDING.get("stopping")), "output": RECORDING.get("output", ""), "latest": latest_recording()}); return if path.startswith("/api/tasks/"): self.send_json(runner.load(path.rsplit("/", 1)[-1])); return except Exception as exc: self.send_json({"error": str(exc)}, 400); return self.send_json({"error": "not found"}, 404) def do_POST(self) -> None: path = urllib.parse.urlparse(self.path).path try: if path == "/api/roster/upload": source = self.read_roster_upload(); canonical = ledger.import_roster(source) self.send_json({"imported": True, "source": source.name, "roster": str(canonical), "rows": len(ledger.read_roster())}, 201); return if path == "/api/qr/paste": self.send_json(save_qr_image(str(self.read_json().get("image", ""))), 201); return if path == "/api/tasks": body = self.read_json(); allowed = body.get("allow_submit") is True and bool(settings().get("safety", {}).get("allow_submit")) self.send_json(runner.create(str(body.get("device_serial", "")), allow_submit=allowed, roster_path=str(body.get("roster_path", "")), success_action=str(body.get("success_action", "download")), qr_image=str(body.get("qr_image", ""))), 201); return if path.startswith("/api/tasks/") and path.endswith("/run"): with ENQUEUE_LOCK: task = runner.load(path.split("/")[3]) if task.get("status") in {"queued", "running"}: self.send_json({"error": "任务已经在执行"}, 409); return task["status"] = "queued"; runner.save(task); threading.Thread(target=runner.run, args=(task, settings()), daemon=True).start(); self.send_json({"id": task["id"], "status": "queued"}, 202); return if path == "/api/sampling/start": serial = str(self.read_json().get("serial", "")); device.selected_device(serial) with RECORDING_LOCK: if RECORDING: raise RuntimeError("已有采样录制正在进行") output = WORK / "replay" / f"{re.sub(r'[^A-Za-z0-9_.-]+', '_', serial)}_{datetime.now().strftime('%Y%m%d-%H%M%S')}"; stop = threading.Event(); thread = threading.Thread(target=recorder.record, args=(output, serial, 0.75, stop), daemon=True); RECORDING.update({"output": str(output), "stop": stop, "thread": thread}); thread.start() try: __import__("subprocess").Popen([scrcpy_binary(), "-s", serial, "--window-title", "Autotrain Next 手机屏幕控制"]) except FileNotFoundError: pass self.send_json({"started": True, "output": str(output)}, 202); return if path == "/api/sampling/stop": with RECORDING_LOCK: if not RECORDING: raise RuntimeError("没有正在录制的采样") RECORDING["stop"].set(); RECORDING["thread"].join(timeout=2) if RECORDING["thread"].is_alive(): RECORDING["stopping"] = True; self.send_json({"stopping": True, "output": RECORDING["output"]}, 202); return output = RECORDING["output"]; RECORDING.clear() self.send_json({"stopped": True, "output": output}); return if path == "/api/sampling/import": body = self.read_json(); serial = str(body.get("serial", "")); selected = device.selected_device(serial); self.send_json(sampling.import_recording(Path(str(body.get("path", ""))), device.fingerprint(selected.serial)), 201); return if path.startswith("/api/profiles/") and path.endswith("/verify"): serial = urllib.parse.unquote(path.split("/")[3]); profile = profiles.load(serial) if not profile: raise RuntimeError("该设备尚未导入采样") missing = sorted(profiles.REQUIRED_SAMPLE_PAGES - set(profile.get("sampling", {}).get("samples", {}))) if missing: raise RuntimeError(f"采样不完整,缺少页面:{', '.join(missing)}") profile["status"] = "verified"; profiles.save(profile); self.send_json(profile); return except Exception as exc: self.send_json({"error": str(exc)}, 400); return self.send_json({"error": "not found"}, 404) def log_message(self, _format: str, *_args: object) -> None: return def serve(port: int = 8766) -> None: print(f"Autotrain Next: http://127.0.0.1:{port}") ThreadingHTTPServer(("127.0.0.1", port), Handler).serve_forever()