224 lines
26 KiB
Python
224 lines
26 KiB
Python
"""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=>({'&':'&','<':'<','>':'>',"'":''','"':'"'}[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()
|