autotrain/nextgen/autotrain_next/web.py
2026-07-24 10:37:32 +08:00

224 lines
26 KiB
Python
Raw Permalink 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.

"""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 = """<!doctype html><html lang='zh-CN'><meta charset='utf-8'><meta name='viewport' content='width=device-width,initial-scale=1'><title>Autotrain Next 控制台</title><link rel='stylesheet' href='/app.css'><main>
<header><div><small>AUTOMATION CONTROL ROOM</small><h1>Autotrain Next</h1><p>设备 → 采样 → 档案 → 任务。页面只调度,业务状态机始终在后端执行。</p></div><b id='safety'>读取安全策略…</b></header>
<section class='grid'><article><h2>01 · 选择设备</h2><p>先选择一台手机。档案未验证时,任务会停在采样门禁。</p><button data-action='refresh'>刷新设备</button><span id='selected'>未选择</span><div id='devices'></div></article>
<article><h2>02 · 新手机采样</h2><p>录制完整流程后,导入采样并确认设备档案。</p><button id='record-start' data-action='record-start'>开始录制</button><button id='record-stop' data-action='record-stop' disabled>结束录制</button><input id='sample-path' placeholder='录制目录'><button data-action='sample-import'>导入采样</button><button data-action='profile-verify'>确认档案</button><div id='recording'>尚未开始</div><div class='task-message' id='sample-message' role='status'>可导入已有录制目录</div></article>
<article class='wide'><h2>03 · 创建任务</h2><p>沿用旧 UI 的文件交互:拖入名单(含 XML直接粘贴二维码截图。原文件与截图都只保存在 NextGen 工作目录。</p><div class='task-inputs'><div><h3>① 乘车人名单</h3><label class='dropzone' id='roster-drop'><input id='roster-file' type='file' accept='.csv,.xml,.xlsx,.xlsm'><strong>拖入名单文件</strong><br><span>或点击选择 .csv / .xml / .xlsx / .xlsm</span></label><div class='input-status' id='roster-status'>尚未导入名单</div></div><div><h3>② 二维码截图</h3><div class='pastezone' id='qr-paste' tabindex='0'><strong>⌘V 粘贴二维码截图</strong><br><span id='qr-hint'>点击此区域后,从剪贴板粘贴图片</span><img id='qr-preview' alt='二维码预览'></div></div></div><div class='row'><select id='action'><option value='download'>下载并归档</option><option value='email'>发送至邮箱</option></select><button data-action='observe'>创建观察任务</button><button class='primary' data-action='execute'>创建并执行开票任务</button></div><div class='task-message' id='task-message' role='status'>请选择设备后创建任务</div></article>
<article class='wide'><h2>04 · 任务与接管</h2><p>任务按设备加锁。可刷新查看排队、阻塞与最近动作。</p><button data-action='tasks'>刷新任务</button><div class='task-message' id='tasks-message' role='status'>任务列表已加载</div><div id='tasks'></div></article><article class='wide'><h2>诊断输出</h2><pre id='out'>就绪</pre></article></section></main><script src='/app.js' defer></script></html>""".encode()
CSS = """*{box-sizing:border-box}body{margin:0;background:radial-gradient(circle at 80% 0,#17365c,transparent 32%),#07111f;color:#eaf1fa;font:14px -apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif}main{max-width:1280px;margin:auto;padding:32px 24px 56px}header{display:flex;justify-content:space-between;gap:16px;align-items:flex-start;margin-bottom:26px}small{color:#56d4be;letter-spacing:.12em;font-weight:700}h1{font-size:32px;margin:5px 0}h2{font-size:16px;margin:0 0 5px}h3{font-size:14px;margin:0 0 8px}p,#recording,.dropzone span,.pastezone span{color:#8ea4bd;line-height:1.5}header b,#selected{padding:8px 11px;border:1px solid #294561;border-radius:99px;color:#b9c8d9;font-size:12px}.grid{display:grid;grid-template-columns:1fr 1fr;gap:16px}article{background:linear-gradient(145deg,#102239,#0b182a);border:1px solid #20334b;border-radius:16px;padding:18px}.wide{grid-column:1/-1}.row,.task-inputs{display:flex;gap:14px;flex-wrap:wrap}.task-inputs>div{flex:1 1 320px}input,select,button{background:#081524;color:#eaf1fa;border:1px solid #315477;border-radius:9px;padding:10px;margin:4px 3px 4px 0}input{min-width:220px;flex:1}button{background:#173552;font-weight:650;cursor:pointer}button.primary{background:#56d4be;border-color:#56d4be;color:#04231d}button:disabled{opacity:.45;cursor:not-allowed}.device,.task{border:1px solid #20334b;border-radius:10px;padding:11px;margin-top:9px;cursor:pointer}.device.active{border-color:#56d4be;background:#0d2b36}.tag{font-size:12px;color:#8ea4bd;margin-left:8px}.reason{color:#f7b75b}pre{max-height:250px;overflow:auto;background:#06101c;border:1px solid #20334b;border-radius:10px;padding:12px}.dropzone,.pastezone{display:block;min-height:130px;padding:22px;border:2px dashed #315477;border-radius:12px;background:#081524;cursor:pointer;text-align:center;transition:.15s}.dropzone input{display:none}.dropzone.dragover,.pastezone:focus,.pastezone.ok{border-color:#56d4be;background:#0d2b36;outline:0}.pastezone.bad{border-color:#f08a87}.pastezone img{display:none;max-height:150px;max-width:220px;margin:10px auto 0;border-radius:8px;border:1px solid #315477}.input-status,.task-message{margin-top:7px;color:#8ea4bd;font-size:12px}.task-message.ok{color:#56d4be}.task-message.error{color:#f7b75b}@media(max-width:820px){main{padding:20px 14px}.grid{grid-template-columns:1fr}.wide{grid-column:auto}header{display:block}header b{display:inline-block;margin-top:8px}}""".encode()
APP_JS = r"""(()=>{const q=id=>document.getElementById(id);let serial=localStorage.getItem('autotrain-next-device')||'',qrPath='',rosterReady=false,canSubmit=false;const esc=v=>String(v??'').replace(/[&<>'"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;',"'":'&#39;','"':'&quot;'}[c]));const out=v=>q('out').textContent=typeof v==='string'?v:JSON.stringify(v,null,2);function tell(text,tone=''){const el=q('task-message');el.textContent=text;el.className='task-message '+tone}function selectDevice(value){serial=value||'';if(serial)localStorage.setItem('autotrain-next-device',serial);else localStorage.removeItem('autotrain-next-device');q('selected').textContent=serial?'已选 '+serial:'未选择'}async function api(url,opt={}){const res=await fetch(url,opt);const body=await res.json();if(!res.ok)throw Error(body.error||res.statusText);return body}async function devices(){try{const rows=await api('/api/devices');if(!rows.some(d=>d.serial===serial))selectDevice(rows.length===1?rows[0].serial:'');q('devices').innerHTML=rows.map(d=>`<div class='device ${d.serial===serial?'active':''}' data-serial='${esc(d.serial)}'><b>${esc(d.model||'Android')}</b> · ${esc(d.serial)}<span class='tag'>${esc(d.fingerprint.screen)} / ${esc(d.profile.status)}</span><div class='reason'>${esc((d.profile.reasons||[]).join(''))}</div></div>`).join('')||'<p>没有已授权设备</p>';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=>`<div class='task'><b>${esc(t.id)}</b><span class='tag'>${esc(t.status)}</span><br>${esc(t.device_serial)} · ${esc(t.updated_at)}<div class='reason'>${esc(t.block_reason)}</div><small>${esc((t.history||[]).map(x=>x.tool||x.error||'').join(''))}</small></div>`).join('')||'<p>暂无任务</p>';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()