feat(prototype): add MVP desktop interaction flows
This commit is contained in:
@@ -0,0 +1,357 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN" data-theme="system">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="icon" href="data:,">
|
||||
<title>cmbuyer 桌面端原型 · 设备与参数</title>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
--surface-base: #f3f3f3;
|
||||
--surface-layer: rgba(255,255,255,.86);
|
||||
--surface-raised: #fff;
|
||||
--surface-muted: #f8f8f8;
|
||||
--text-primary: #1b1b1b;
|
||||
--text-secondary: #5d5d5d;
|
||||
--text-disabled: #8a8a8a;
|
||||
--accent: #005fb8;
|
||||
--accent-hover: #00549f;
|
||||
--accent-soft: #e7f3ff;
|
||||
--success: #0f6b38;
|
||||
--success-soft: #e7f5ec;
|
||||
--caution: #8a5600;
|
||||
--caution-soft: #fff4ce;
|
||||
--critical: #b10e1e;
|
||||
--critical-soft: #fde7e9;
|
||||
--stroke: #d0d0d0;
|
||||
--divider: #e5e5e5;
|
||||
--focus: #000;
|
||||
--shadow: 0 8px 24px rgba(0,0,0,.08);
|
||||
--radius-control: 4px;
|
||||
--radius-card: 8px;
|
||||
}
|
||||
html[data-theme="dark"] {
|
||||
--surface-base:#202020;--surface-layer:rgba(44,44,44,.94);--surface-raised:#2b2b2b;--surface-muted:#262626;
|
||||
--text-primary:#f5f5f5;--text-secondary:#c7c7c7;--text-disabled:#8f8f8f;--accent:#60aef2;--accent-hover:#79bcf5;
|
||||
--accent-soft:#12324b;--success:#6ccb8e;--success-soft:#173d27;--caution:#f3c45b;--caution-soft:#493711;
|
||||
--critical:#ff99a4;--critical-soft:#4e1d22;--stroke:#5c5c5c;--divider:#3c3c3c;--focus:#fff;--shadow:0 8px 24px rgba(0,0,0,.32);
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
html[data-theme="system"] {
|
||||
--surface-base:#202020;--surface-layer:rgba(44,44,44,.94);--surface-raised:#2b2b2b;--surface-muted:#262626;
|
||||
--text-primary:#f5f5f5;--text-secondary:#c7c7c7;--text-disabled:#8f8f8f;--accent:#60aef2;--accent-hover:#79bcf5;
|
||||
--accent-soft:#12324b;--success:#6ccb8e;--success-soft:#173d27;--caution:#f3c45b;--caution-soft:#493711;
|
||||
--critical:#ff99a4;--critical-soft:#4e1d22;--stroke:#5c5c5c;--divider:#3c3c3c;--focus:#fff;--shadow:0 8px 24px rgba(0,0,0,.32);
|
||||
}
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body { margin:0; min-width:320px; min-height:100vh; color:var(--text-primary); background:var(--surface-base); font:14px/1.45 "Segoe UI Variable","Segoe UI","Microsoft YaHei UI",sans-serif; }
|
||||
button,input,select { font:inherit; }
|
||||
:focus-visible { outline:2px solid var(--focus); outline-offset:2px; }
|
||||
button:focus-visible,input:focus-visible,select:focus-visible,a:focus-visible { box-shadow:0 0 0 4px var(--accent-soft); }
|
||||
button,.button-link { min-height:32px; border:1px solid var(--stroke); border-radius:var(--radius-control); padding:5px 14px; color:var(--text-primary); background:var(--surface-raised); cursor:pointer; text-decoration:none; display:inline-flex; align-items:center; justify-content:center; gap:8px; }
|
||||
button:hover:not(:disabled),.button-link:hover { background:var(--surface-muted); }
|
||||
button:disabled,input:disabled,select:disabled { color:var(--text-disabled); background:var(--surface-muted); cursor:not-allowed; }
|
||||
.primary { color:#fff; border-color:transparent; background:var(--accent); }
|
||||
.primary:hover:not(:disabled) { background:var(--accent-hover); }
|
||||
.prototype-banner { padding:6px 16px; color:#1b1b1b; background:#f7cf46; border-bottom:1px solid #b08b00; font-weight:650; text-align:center; }
|
||||
.app-bar { min-height:52px; display:flex; align-items:center; gap:16px; padding:8px 16px; border-bottom:1px solid var(--divider); background:var(--surface-layer); }
|
||||
.brand { font-size:16px; font-weight:650; white-space:nowrap; }
|
||||
.brand small { display:block; color:var(--text-secondary); font-size:11px; font-weight:400; }
|
||||
.tabs { display:flex; align-self:stretch; align-items:stretch; gap:2px; }
|
||||
.tabs a { min-height:40px; padding:9px 16px 7px; display:flex; align-items:center; border-bottom:3px solid transparent; color:var(--text-secondary); text-decoration:none; font-weight:600; }
|
||||
.tabs a[aria-current="page"] { color:var(--text-primary); border-bottom-color:var(--accent); }
|
||||
.app-actions { margin-left:auto; display:flex; align-items:center; gap:8px; }
|
||||
.app-actions label { color:var(--text-secondary); font-size:12px; }
|
||||
.app-actions select,.prototype-tools select { min-height:32px; border:1px solid var(--stroke); border-radius:4px; color:var(--text-primary); background:var(--surface-raised); padding:4px 28px 4px 8px; }
|
||||
main { max-width:1120px; margin:0 auto; padding:16px; }
|
||||
.prototype-tools { display:grid; grid-template-columns:minmax(240px,1fr) auto; align-items:end; gap:12px; margin-bottom:12px; padding:12px; border:1px dashed var(--stroke); border-radius:var(--radius-card); background:var(--surface-layer); }
|
||||
.prototype-tools label { display:grid; gap:5px; color:var(--text-secondary); font-size:12px; }
|
||||
.prototype-tools select { width:100%; }
|
||||
.tool-buttons { display:flex; flex-wrap:wrap; gap:8px; }
|
||||
.banner { display:grid; grid-template-columns:auto 1fr; gap:10px; margin-bottom:12px; padding:12px 14px; border:1px solid currentColor; border-radius:var(--radius-card); color:var(--accent); background:var(--accent-soft); }
|
||||
.banner[data-tone="success"] { color:var(--success); background:var(--success-soft); }
|
||||
.banner[data-tone="caution"] { color:var(--caution); background:var(--caution-soft); }
|
||||
.banner[data-tone="critical"] { color:var(--critical); background:var(--critical-soft); }
|
||||
.banner strong { display:block; margin-bottom:2px; }
|
||||
.banner p { margin:0; color:var(--text-primary); }
|
||||
.banner-symbol { font-weight:800; }
|
||||
.layout { display:grid; grid-template-columns:minmax(0,1fr) 280px; gap:12px; align-items:start; }
|
||||
.form-stack,.side-stack { display:grid; gap:12px; }
|
||||
.card { border:1px solid var(--stroke); border-radius:var(--radius-card); background:var(--surface-raised); box-shadow:0 2px 8px rgba(0,0,0,.04); overflow:hidden; }
|
||||
.card-header { padding:14px 16px 10px; border-bottom:1px solid var(--divider); }
|
||||
.card-header h1,.card-header h2 { margin:0; font-size:17px; }
|
||||
.card-header p { margin:3px 0 0; color:var(--text-secondary); font-size:12px; }
|
||||
.card-body { padding:14px 16px 16px; }
|
||||
.fields { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:14px 20px; }
|
||||
.field { min-width:0; display:grid; align-content:start; gap:5px; }
|
||||
.field.full { grid-column:1/-1; }
|
||||
.field label,.group-label { font-weight:600; }
|
||||
.required { color:var(--critical); }
|
||||
.field input,.field select { width:100%; min-height:34px; padding:5px 9px; color:var(--text-primary); background:var(--surface-raised); border:1px solid var(--stroke); border-radius:var(--radius-control); }
|
||||
.field input[readonly] { background:var(--surface-muted); color:var(--text-secondary); }
|
||||
.field input[aria-invalid="true"],.field select[aria-invalid="true"] { border-color:var(--critical); box-shadow:0 0 0 1px var(--critical); }
|
||||
.help { color:var(--text-secondary); font-size:12px; }
|
||||
.error { min-height:18px; color:var(--critical); font-size:12px; }
|
||||
.radio-row { display:flex; flex-wrap:wrap; gap:8px 20px; min-height:34px; align-items:center; }
|
||||
.radio-row label { display:flex; align-items:center; gap:7px; font-weight:400; }
|
||||
input[type="radio"] { width:16px; min-height:16px; accent-color:var(--accent); }
|
||||
.summary-list { margin:0; display:grid; gap:12px; }
|
||||
.summary-list div { display:grid; gap:2px; }
|
||||
.summary-list dt { color:var(--text-secondary); font-size:12px; }
|
||||
.summary-list dd { margin:0; overflow-wrap:anywhere; }
|
||||
.status { display:inline-flex; align-items:center; gap:7px; font-weight:650; }
|
||||
.status::before { content:""; width:9px; height:9px; border-radius:50%; background:currentColor; }
|
||||
.status.ok { color:var(--success); }
|
||||
.status.warn { color:var(--caution); }
|
||||
.status.bad { color:var(--critical); }
|
||||
.check-list { margin:0; padding-left:20px; display:grid; gap:8px; }
|
||||
.check-list li::marker { color:var(--success); }
|
||||
.form-actions { margin-top:12px; display:grid; grid-template-columns:1fr auto; gap:12px; align-items:center; padding:12px 16px; border:1px solid var(--stroke); border-radius:var(--radius-card); background:var(--surface-raised); box-shadow:var(--shadow); }
|
||||
.action-text { color:var(--text-secondary); font-size:12px; }
|
||||
.action-buttons { display:flex; flex-wrap:wrap; justify-content:flex-end; gap:8px; }
|
||||
dialog { width:min(560px,calc(100vw - 32px)); border:1px solid var(--stroke); border-radius:8px; padding:0; color:var(--text-primary); background:var(--surface-raised); box-shadow:0 24px 80px rgba(0,0,0,.3); }
|
||||
dialog::backdrop { background:rgba(0,0,0,.42); }
|
||||
.dialog-body { padding:20px; }
|
||||
.dialog-body h2 { margin:0 0 8px; font-size:18px; }
|
||||
.dialog-body p { margin:0; }
|
||||
.dialog-actions { display:flex; justify-content:flex-end; gap:8px; padding:12px 20px; border-top:1px solid var(--divider); }
|
||||
.sr-only { position:absolute; width:1px; height:1px; padding:0; margin:-1px; overflow:hidden; clip:rect(0,0,0,0); white-space:nowrap; border:0; }
|
||||
@media (max-width:800px) { .layout { grid-template-columns:1fr; } .side-stack { grid-template-columns:repeat(2,minmax(0,1fr)); } }
|
||||
@media (max-width:640px) {
|
||||
.app-bar { align-items:stretch; flex-wrap:wrap; gap:4px 12px; }
|
||||
.tabs { order:3; width:100%; } .tabs a { flex:1; justify-content:center; padding-inline:8px; }
|
||||
.app-actions label { position:absolute; clip:rect(0,0,0,0); }
|
||||
main { padding:8px; } .prototype-tools { grid-template-columns:1fr; }
|
||||
.fields,.side-stack { grid-template-columns:1fr; } .field.full { grid-column:auto; }
|
||||
.form-actions { grid-template-columns:1fr; }
|
||||
.action-buttons { justify-content:stretch; } .action-buttons button { flex:1; min-height:40px; }
|
||||
}
|
||||
@media (prefers-reduced-motion:reduce) { *,*::before,*::after { scroll-behavior:auto!important; transition-duration:.01ms!important; animation-duration:.01ms!important; animation-iteration-count:1!important; } }
|
||||
@media (forced-colors:active) { .prototype-banner,.banner,.primary,.status::before { forced-color-adjust:auto; } .card,.form-actions,.prototype-tools { box-shadow:none; } .status::before { border:1px solid ButtonText; } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="prototype-banner" role="note">PROTOTYPE - 仅供枚举交互,非实现依据;全部是假数据,不执行连接检查或保存真实配置</div>
|
||||
<header class="app-bar">
|
||||
<div class="brand">cmbuyer 执行器<small>Windows / PySide6 前置原型</small></div>
|
||||
<nav class="tabs" aria-label="主要页面">
|
||||
<a href="desk-execution.html" id="execution-link">采购执行</a>
|
||||
<a href="desk-device-settings.html" aria-current="page">设备与参数</a>
|
||||
</nav>
|
||||
<div class="app-actions"><label for="theme">主题</label><select id="theme" aria-label="原型主题"><option value="system">跟随系统</option><option value="light">浅色</option><option value="dark">深色</option></select></div>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<section class="prototype-tools" aria-labelledby="prototype-tools-title">
|
||||
<label id="prototype-tools-title" for="scenario">原型状态切换器(仅改变本页假数据)
|
||||
<select id="scenario">
|
||||
<option value="default">默认 · 可编辑</option>
|
||||
<option value="invalid">校验失败 · 保留输入</option>
|
||||
<option value="checking">连接检查中</option>
|
||||
<option value="connected">连接检查通过</option>
|
||||
<option value="web_error">web 服务不可达</option>
|
||||
<option value="mismatch">App 版本失配</option>
|
||||
<option value="saved">保存成功</option>
|
||||
<option value="running">轮询运行中 · 配置冻结</option>
|
||||
</select>
|
||||
</label>
|
||||
<div class="tool-buttons"><button type="button" id="reset">复位假数据</button></div>
|
||||
</section>
|
||||
|
||||
<section class="banner" id="status-banner" data-tone="caution" aria-live="polite" aria-atomic="true">
|
||||
<span class="banner-symbol" aria-hidden="true">i</span>
|
||||
<div><strong id="banner-title">尚未检查连接</strong><p id="banner-text">先确认显式 serial、ADB 和 web 地址,再运行不会打开 App 的模拟检查。</p></div>
|
||||
</section>
|
||||
|
||||
<form id="settings-form" novalidate>
|
||||
<div class="layout">
|
||||
<div class="form-stack">
|
||||
<section class="card" aria-labelledby="device-title">
|
||||
<div class="card-header"><h1 id="device-title">设备档案</h1><p>必须显式指定 serial;不自动选择设备</p></div>
|
||||
<div class="card-body fields">
|
||||
<div class="field">
|
||||
<label for="profile">档案名称 <span class="required" aria-hidden="true">*</span></label>
|
||||
<input id="profile" name="profile" value="采购手机(假数据)" required aria-describedby="profile-help profile-error">
|
||||
<span class="help" id="profile-help">用于本机识别,不上传真实个人信息。</span><span class="error" id="profile-error"></span>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="serial">ADB serial <span class="required" aria-hidden="true">*</span></label>
|
||||
<input id="serial" name="serial" value="FAKE-DEVICE-01" required spellcheck="false" aria-describedby="serial-help serial-error">
|
||||
<span class="help" id="serial-help">USB / WiFi 同时在线时仍只认这里的精确值。</span><span class="error" id="serial-error"></span>
|
||||
</div>
|
||||
<fieldset class="field full" style="border:0;padding:0;margin:0">
|
||||
<legend class="group-label">连接方式</legend>
|
||||
<div class="radio-row"><label><input type="radio" name="transport" value="usb" checked>USB ADB</label><label><input type="radio" name="transport" value="wifi">WiFi ADB</label></div>
|
||||
<span class="help">这是展示标签,不改变 serial 的唯一身份规则。</span>
|
||||
</fieldset>
|
||||
<div class="field full">
|
||||
<label for="adb-path">ADB 可执行文件 <span class="required" aria-hidden="true">*</span></label>
|
||||
<input id="adb-path" name="adb_path" value="C:\\Android\\platform-tools\\adb.exe" required spellcheck="false" aria-describedby="adb-help adb-error">
|
||||
<span class="help" id="adb-help">原生实现使用文件选择器;此 HTML 不访问本地文件系统。</span><span class="error" id="adb-error"></span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card" aria-labelledby="service-title">
|
||||
<div class="card-header"><h2 id="service-title">web 服务与设备身份</h2><p>设备凭据与管理员会话隔离</p></div>
|
||||
<div class="card-body fields">
|
||||
<div class="field full">
|
||||
<label for="web-url">web 服务地址 <span class="required" aria-hidden="true">*</span></label>
|
||||
<input id="web-url" name="web_url" type="url" value="http://127.0.0.1:8080" required spellcheck="false" aria-describedby="url-help url-error">
|
||||
<span class="help" id="url-help">仅为原型假地址;本页不会向该地址发请求。</span><span class="error" id="url-error"></span>
|
||||
</div>
|
||||
<div class="field full">
|
||||
<label for="device-token">设备凭据 <span class="required" aria-hidden="true">*</span></label>
|
||||
<input id="device-token" name="device_token" type="password" value="prototype-fake-token" required autocomplete="off" aria-describedby="token-help token-error">
|
||||
<span class="help" id="token-help">仅为假值。生产实现不得把凭据写入日志、URL 或普通错误信息。</span><span class="error" id="token-error"></span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card" aria-labelledby="poll-title">
|
||||
<div class="card-header"><h2 id="poll-title">轮询与超时</h2><p>以下均是原型示例值,最终默认值仍待实现和真机观察确认</p></div>
|
||||
<div class="card-body fields">
|
||||
<div class="field">
|
||||
<label for="poll-interval">轮询间隔(秒) <span class="required" aria-hidden="true">*</span></label>
|
||||
<input id="poll-interval" name="poll_interval" type="number" min="5" max="300" step="1" value="15" required aria-describedby="poll-help poll-error">
|
||||
<span class="help" id="poll-help">示例范围 5–300;生产值需结合风控风险确认。</span><span class="error" id="poll-error"></span>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="failure-threshold">连续失败停止阈值 <span class="required" aria-hidden="true">*</span></label>
|
||||
<input id="failure-threshold" name="failure_threshold" type="number" min="1" max="10" step="1" value="3" required aria-describedby="threshold-help threshold-error">
|
||||
<span class="help" id="threshold-help">达到阈值后停止,不做无限重试。</span><span class="error" id="threshold-error"></span>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="request-timeout">普通请求超时(秒) <span class="required" aria-hidden="true">*</span></label>
|
||||
<input id="request-timeout" name="request_timeout" type="number" min="1" max="120" step="1" value="10" required aria-describedby="request-help request-error">
|
||||
<span class="help" id="request-help">只用于可安全重放的普通请求。</span><span class="error" id="request-error"></span>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="step-timeout">真机步骤超时(秒) <span class="required" aria-hidden="true">*</span></label>
|
||||
<input id="step-timeout" name="step_timeout" type="number" min="5" max="300" step="1" value="45" required aria-describedby="step-help step-error">
|
||||
<span class="help" id="step-help">提交围栏前后采用不同恢复协议,不以超时直接重试提交。</span><span class="error" id="step-error"></span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<aside class="side-stack" aria-label="连接与安全摘要">
|
||||
<section class="card" aria-labelledby="connection-title">
|
||||
<div class="card-header"><h2 id="connection-title">连接摘要</h2><p>当前仅为原型模拟</p></div>
|
||||
<div class="card-body">
|
||||
<dl class="summary-list">
|
||||
<div><dt>web 服务</dt><dd><span class="status warn" id="web-status">未检查</span></dd></div>
|
||||
<div><dt>ADB 设备</dt><dd><span class="status warn" id="adb-status">未检查</span></dd></div>
|
||||
<div><dt>拼多多安装</dt><dd><span class="status warn" id="app-status">未检查</span></dd></div>
|
||||
<div><dt>实际 App 版本</dt><dd id="actual-version">待真机取证</dd></div>
|
||||
<div><dt>已取证版本</dt><dd id="verified-version">待 T-103 记录</dd></div>
|
||||
</dl>
|
||||
</div>
|
||||
</section>
|
||||
<section class="card" aria-labelledby="boundary-title">
|
||||
<div class="card-header"><h2 id="boundary-title">检查边界</h2><p>连接检查不进入业务流程</p></div>
|
||||
<div class="card-body">
|
||||
<ul class="check-list"><li>只检查 web 可达性</li><li>只确认指定 serial 可见</li><li>只确认拼多多已安装</li><li>不打开商品或选择规格</li><li>不创建订单,更不付款</li></ul>
|
||||
</div>
|
||||
</section>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<section class="form-actions" aria-label="配置操作">
|
||||
<div class="action-text" id="action-text">尚无未保存更改。连接检查不会打开拼多多。</div>
|
||||
<div class="action-buttons"><button type="button" id="check-connection">模拟连接检查</button><button type="submit" class="primary" id="save">保存假配置 <span aria-hidden="true">Ctrl+S</span></button></div>
|
||||
</section>
|
||||
</form>
|
||||
</main>
|
||||
|
||||
<dialog id="discard-dialog" aria-labelledby="discard-title">
|
||||
<div class="dialog-body"><h2 id="discard-title">放弃未保存的假配置?</h2><p>只会丢弃本页原型中的编辑,不影响任何真实设备或服务。</p></div>
|
||||
<div class="dialog-actions"><button type="button" id="stay">继续编辑</button><button type="button" class="primary" id="discard">放弃并前往采购执行</button></div>
|
||||
</dialog>
|
||||
<div class="sr-only" id="announcer" aria-live="assertive" aria-atomic="true"></div>
|
||||
|
||||
<script>
|
||||
(() => {
|
||||
'use strict';
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const form = $('settings-form');
|
||||
let dirty = false;
|
||||
const editable = [...form.querySelectorAll('input, select, button')];
|
||||
const initial = Object.fromEntries([...new FormData(form).entries()]);
|
||||
const statusCopy = {
|
||||
default:{tone:'caution',title:'尚未检查连接',text:'先确认显式 serial、ADB 和 web 地址,再运行不会打开 App 的模拟检查。'},
|
||||
invalid:{tone:'critical',title:'有 3 项需要修正',text:'输入已保留。错误位于对应字段旁;修正后再保存或检查连接。'},
|
||||
checking:{tone:'info',title:'正在模拟连接检查',text:'原型仅演示异步状态,不会执行 ADB、HTTP 或打开拼多多。'},
|
||||
connected:{tone:'success',title:'连接检查通过(模拟)',text:'显式 serial 可见、web 可达且拼多多已安装;版本匹配也仅为演示,真实值仍须 T-103 取证。'},
|
||||
web_error:{tone:'critical',title:'web 服务不可达',text:'假配置没有丢失。检查地址与网络后可重新做普通连接检查;这不代表可以重试订单提交。'},
|
||||
mismatch:{tone:'critical',title:'拼多多 App 版本失配',text:'已 fail closed:生产实现必须停止领取真机任务,由人重新取证并记录版本。'},
|
||||
saved:{tone:'success',title:'假配置已保存',text:'仅更新了浏览器内的原型快照,没有写文件、调用 API 或变更真实设备。'},
|
||||
running:{tone:'caution',title:'轮询运行中,配置已冻结',text:'先到采购执行页安全停止普通轮询。若已建立提交围栏,则必须继续调和同一 submission。'}
|
||||
};
|
||||
function setBanner(key) { const s=statusCopy[key]; $('status-banner').dataset.tone=s.tone; $('banner-title').textContent=s.title; $('banner-text').textContent=s.text; $('announcer').textContent=`${s.title}。${s.text}`; }
|
||||
function setStatus(id,text,tone) { const el=$(id); el.textContent=text; el.className=`status ${tone}`; }
|
||||
function clearErrors() { form.querySelectorAll('[aria-invalid="true"]').forEach(el=>el.removeAttribute('aria-invalid')); form.querySelectorAll('.error').forEach(el=>el.textContent=''); }
|
||||
function error(id,message) { const input=$(id), output=$(`${id.replace('failure-threshold','threshold').replace('poll-interval','poll').replace('request-timeout','request').replace('step-timeout','step').replace('web-url','url').replace('device-token','token').replace('adb-path','adb')}-error`); input.setAttribute('aria-invalid','true'); if(output) output.textContent=message; }
|
||||
function setFrozen(frozen) { editable.forEach(el=>el.disabled=frozen); $('scenario').disabled=false; $('reset').disabled=false; $('theme').disabled=false; }
|
||||
function showScenario(key) {
|
||||
if(key!=='invalid' && $('serial').value==='' && $('web-url').value==='not-a-url' && $('poll-interval').value==='1') {
|
||||
$('serial').value=initial.serial; $('web-url').value=initial.web_url; $('poll-interval').value=initial.poll_interval; dirty=false;
|
||||
}
|
||||
clearErrors(); setFrozen(false); setBanner(key);
|
||||
$('check-connection').textContent='模拟连接检查'; $('action-text').textContent=dirty?'有未保存的假配置。':'尚无未保存更改。连接检查不会打开拼多多。';
|
||||
setStatus('web-status','未检查','warn'); setStatus('adb-status','未检查','warn'); setStatus('app-status','未检查','warn');
|
||||
$('actual-version').textContent='待真机取证'; $('verified-version').textContent='待 T-103 记录';
|
||||
if(key==='invalid') {
|
||||
$('serial').value=''; $('web-url').value='not-a-url'; $('poll-interval').value='1';
|
||||
error('serial','必须显式填写精确 serial,不能留空自动选择。'); error('web-url','请输入完整的 http:// 或 https:// 地址。'); error('poll-interval','原型示例范围为 5–300 秒。');
|
||||
dirty=true; $('action-text').textContent='输入已保留;请修正 3 项错误。';
|
||||
} else if(key==='checking') {
|
||||
setFrozen(true); $('scenario').disabled=false; $('reset').disabled=false; $('theme').disabled=false; $('check-connection').textContent='检查中…'; $('action-text').textContent='等待模拟结果;没有网络请求。';
|
||||
setStatus('web-status','检查中','warn'); setStatus('adb-status','排队中','warn'); setStatus('app-status','排队中','warn');
|
||||
} else if(key==='connected') {
|
||||
setStatus('web-status','已连接(模拟)','ok'); setStatus('adb-status','指定设备可见(模拟)','ok'); setStatus('app-status','已安装;版本模拟匹配','ok');
|
||||
$('actual-version').textContent='模拟匹配;真实值待取证'; $('verified-version').textContent='待 T-103 记录真实值'; $('action-text').textContent=dirty?'连接模拟通过;仍有未保存的假配置。':'连接模拟通过;没有发出网络或 ADB 请求。';
|
||||
} else if(key==='web_error') {
|
||||
setStatus('web-status','不可达(模拟)','bad'); setStatus('adb-status','未继续检查','warn'); setStatus('app-status','未继续检查','warn');
|
||||
} else if(key==='mismatch') {
|
||||
setStatus('web-status','已连接(模拟)','ok'); setStatus('adb-status','指定设备可见(模拟)','ok'); setStatus('app-status','已安装,但版本失配','bad');
|
||||
$('actual-version').textContent='模拟:与证据版本不同'; $('verified-version').textContent='模拟:当前证据版本';
|
||||
} else if(key==='saved') {
|
||||
setStatus('web-status','已连接(模拟)','ok'); setStatus('adb-status','指定设备可见(模拟)','ok'); setStatus('app-status','已安装;版本仍待真机取证','warn'); dirty=false; $('action-text').textContent='假配置已保存。按 Ctrl+S 可再次演示保存。';
|
||||
} else if(key==='running') {
|
||||
setStatus('web-status','已连接(模拟)','ok'); setStatus('adb-status','使用中(模拟)','ok'); setStatus('app-status','运行中','warn'); setFrozen(true); $('scenario').disabled=false; $('reset').disabled=false; $('theme').disabled=false; $('action-text').textContent='运行中不能切换设备或保存参数。';
|
||||
}
|
||||
}
|
||||
function restoreInitial() {
|
||||
for(const [name,value] of Object.entries(initial)) { const controls=form.elements.namedItem(name); if(!controls) continue; if(controls instanceof RadioNodeList) controls.value=value; else controls.value=value; }
|
||||
dirty=false; clearErrors(); $('scenario').value='default'; showScenario('default'); $('announcer').textContent='假配置已复位。';
|
||||
}
|
||||
function validate() {
|
||||
clearErrors(); const problems=[];
|
||||
if(!$('profile').value.trim()) { error('profile','请填写档案名称。'); problems.push($('profile')); }
|
||||
if(!$('serial').value.trim()) { error('serial','必须显式填写精确 serial,不能留空自动选择。'); problems.push($('serial')); }
|
||||
if(!$('adb-path').value.trim()) { error('adb-path','请选择 ADB 可执行文件。'); problems.push($('adb-path')); }
|
||||
try { const url=new URL($('web-url').value); if(!['http:','https:'].includes(url.protocol)) throw new Error(); } catch { error('web-url','请输入完整的 http:// 或 https:// 地址。'); problems.push($('web-url')); }
|
||||
if(!$('device-token').value) { error('device-token','请输入设备凭据;不要使用管理员会话凭据。'); problems.push($('device-token')); }
|
||||
const ranges=[['poll-interval',5,300,'原型示例范围为 5–300 秒。'],['failure-threshold',1,10,'原型示例范围为 1–10 次。'],['request-timeout',1,120,'原型示例范围为 1–120 秒。'],['step-timeout',5,300,'原型示例范围为 5–300 秒。']];
|
||||
for(const [id,min,max,msg] of ranges) { const n=Number($(id).value); if(!Number.isInteger(n)||n<min||n>max) { error(id,msg); problems.push($(id)); } }
|
||||
if(problems.length) { $('scenario').value='invalid'; setBanner('invalid'); $('banner-title').textContent=`有 ${problems.length} 项需要修正`; $('action-text').textContent=`输入已保留;请修正 ${problems.length} 项错误。`; problems[0].focus(); return false; }
|
||||
return true;
|
||||
}
|
||||
form.addEventListener('input', (e) => { if(e.target.matches('input,select')) { dirty=true; $('action-text').textContent='有未保存的假配置。'; if(e.target.hasAttribute('aria-invalid')) { e.target.removeAttribute('aria-invalid'); const err=e.target.closest('.field')?.querySelector('.error'); if(err) err.textContent=''; } } });
|
||||
form.addEventListener('submit', (e) => { e.preventDefault(); if(validate()) { $('scenario').value='saved'; showScenario('saved'); } });
|
||||
$('check-connection').addEventListener('click', () => { if(!validate()) return; $('scenario').value='checking'; showScenario('checking'); window.setTimeout(()=>{ $('scenario').value='connected'; showScenario('connected'); },650); });
|
||||
$('scenario').addEventListener('change', (e) => showScenario(e.target.value));
|
||||
$('reset').addEventListener('click', restoreInitial);
|
||||
$('theme').addEventListener('change', (e) => { document.documentElement.dataset.theme=e.target.value; $('announcer').textContent=`主题已切换为${e.target.options[e.target.selectedIndex].text}`; });
|
||||
$('execution-link').addEventListener('click', (e) => { if(!dirty) return; e.preventDefault(); $('discard-dialog').showModal(); });
|
||||
$('stay').addEventListener('click', () => $('discard-dialog').close());
|
||||
$('discard').addEventListener('click', () => { dirty=false; location.href='desk-execution.html'; });
|
||||
window.addEventListener('keydown', (e) => { if(e.ctrlKey && e.key.toLowerCase()==='s') { e.preventDefault(); form.requestSubmit(); } });
|
||||
showScenario('default');
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,534 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN" data-theme="system">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="icon" href="data:,">
|
||||
<title>cmbuyer 桌面端原型 · 采购执行</title>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
--surface-base: #f3f3f3;
|
||||
--surface-layer: rgba(255, 255, 255, .86);
|
||||
--surface-raised: #ffffff;
|
||||
--surface-muted: #f8f8f8;
|
||||
--text-primary: #1b1b1b;
|
||||
--text-secondary: #5d5d5d;
|
||||
--text-disabled: #8a8a8a;
|
||||
--accent: #005fb8;
|
||||
--accent-hover: #00549f;
|
||||
--accent-pressed: #004578;
|
||||
--accent-soft: #e7f3ff;
|
||||
--success: #0f6b38;
|
||||
--success-soft: #e7f5ec;
|
||||
--caution: #8a5600;
|
||||
--caution-soft: #fff4ce;
|
||||
--critical: #b10e1e;
|
||||
--critical-soft: #fde7e9;
|
||||
--info: #005fb8;
|
||||
--info-soft: #e7f3ff;
|
||||
--stroke: #d0d0d0;
|
||||
--divider: #e5e5e5;
|
||||
--focus: #000000;
|
||||
--shadow: 0 8px 24px rgba(0, 0, 0, .08);
|
||||
--space-1: 4px;
|
||||
--space-2: 8px;
|
||||
--space-3: 12px;
|
||||
--space-4: 16px;
|
||||
--space-5: 20px;
|
||||
--space-6: 24px;
|
||||
--radius-control: 4px;
|
||||
--radius-card: 8px;
|
||||
--motion-fast: 120ms;
|
||||
}
|
||||
|
||||
html[data-theme="dark"] {
|
||||
--surface-base: #202020;
|
||||
--surface-layer: rgba(44, 44, 44, .94);
|
||||
--surface-raised: #2b2b2b;
|
||||
--surface-muted: #262626;
|
||||
--text-primary: #f5f5f5;
|
||||
--text-secondary: #c7c7c7;
|
||||
--text-disabled: #8f8f8f;
|
||||
--accent: #60aef2;
|
||||
--accent-hover: #79bcf5;
|
||||
--accent-pressed: #4798de;
|
||||
--accent-soft: #12324b;
|
||||
--success: #6ccb8e;
|
||||
--success-soft: #173d27;
|
||||
--caution: #f3c45b;
|
||||
--caution-soft: #493711;
|
||||
--critical: #ff99a4;
|
||||
--critical-soft: #4e1d22;
|
||||
--info: #75b9f3;
|
||||
--info-soft: #12324b;
|
||||
--stroke: #5c5c5c;
|
||||
--divider: #3c3c3c;
|
||||
--focus: #ffffff;
|
||||
--shadow: 0 8px 24px rgba(0, 0, 0, .32);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
html[data-theme="system"] {
|
||||
--surface-base: #202020;
|
||||
--surface-layer: rgba(44, 44, 44, .94);
|
||||
--surface-raised: #2b2b2b;
|
||||
--surface-muted: #262626;
|
||||
--text-primary: #f5f5f5;
|
||||
--text-secondary: #c7c7c7;
|
||||
--text-disabled: #8f8f8f;
|
||||
--accent: #60aef2;
|
||||
--accent-hover: #79bcf5;
|
||||
--accent-pressed: #4798de;
|
||||
--accent-soft: #12324b;
|
||||
--success: #6ccb8e;
|
||||
--success-soft: #173d27;
|
||||
--caution: #f3c45b;
|
||||
--caution-soft: #493711;
|
||||
--critical: #ff99a4;
|
||||
--critical-soft: #4e1d22;
|
||||
--info: #75b9f3;
|
||||
--info-soft: #12324b;
|
||||
--stroke: #5c5c5c;
|
||||
--divider: #3c3c3c;
|
||||
--focus: #ffffff;
|
||||
--shadow: 0 8px 24px rgba(0, 0, 0, .32);
|
||||
}
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
color: var(--text-primary);
|
||||
background: var(--surface-base);
|
||||
font: 14px/1.45 "Segoe UI Variable", "Segoe UI", "Microsoft YaHei UI", sans-serif;
|
||||
}
|
||||
button, select, input { font: inherit; }
|
||||
a { color: inherit; }
|
||||
button, select, a { -webkit-tap-highlight-color: transparent; }
|
||||
:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; }
|
||||
button:focus-visible, select:focus-visible, a:focus-visible { box-shadow: 0 0 0 4px var(--accent-soft); }
|
||||
button, .button-link {
|
||||
min-height: 32px;
|
||||
border: 1px solid var(--stroke);
|
||||
border-radius: var(--radius-control);
|
||||
padding: 5px 14px;
|
||||
color: var(--text-primary);
|
||||
background: var(--surface-raised);
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
}
|
||||
button:hover:not(:disabled), .button-link:hover { background: var(--surface-muted); }
|
||||
button:active:not(:disabled), .button-link:active { transform: translateY(1px); }
|
||||
button:disabled { color: var(--text-disabled); background: var(--surface-muted); cursor: not-allowed; }
|
||||
.primary { color: white; border-color: transparent; background: var(--accent); }
|
||||
.primary:hover:not(:disabled) { background: var(--accent-hover); }
|
||||
.primary:active:not(:disabled) { background: var(--accent-pressed); }
|
||||
.danger { color: var(--critical); border-color: var(--critical); background: transparent; }
|
||||
.prototype-banner {
|
||||
padding: 6px 16px;
|
||||
color: #1b1b1b;
|
||||
background: #f7cf46;
|
||||
border-bottom: 1px solid #b08b00;
|
||||
font-weight: 650;
|
||||
text-align: center;
|
||||
}
|
||||
.app-bar {
|
||||
min-height: 52px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 8px 16px;
|
||||
border-bottom: 1px solid var(--divider);
|
||||
background: var(--surface-layer);
|
||||
}
|
||||
.brand { font-size: 16px; font-weight: 650; white-space: nowrap; }
|
||||
.brand small { display: block; color: var(--text-secondary); font-size: 11px; font-weight: 400; }
|
||||
.tabs { display: flex; align-self: stretch; align-items: stretch; gap: 2px; }
|
||||
.tabs a {
|
||||
min-height: 40px;
|
||||
padding: 9px 16px 7px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-bottom: 3px solid transparent;
|
||||
color: var(--text-secondary);
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
}
|
||||
.tabs a[aria-current="page"] { color: var(--text-primary); border-bottom-color: var(--accent); }
|
||||
.app-actions { margin-left: auto; display: flex; align-items: center; gap: 8px; }
|
||||
.app-actions label { color: var(--text-secondary); font-size: 12px; }
|
||||
.app-actions select { min-height: 32px; border: 1px solid var(--stroke); border-radius: 4px; background: var(--surface-raised); color: var(--text-primary); padding: 4px 28px 4px 8px; }
|
||||
main { max-width: 1480px; margin: 0 auto; padding: 16px; }
|
||||
.prototype-tools {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(240px, 1fr) auto;
|
||||
gap: 12px;
|
||||
align-items: end;
|
||||
margin-bottom: 12px;
|
||||
padding: 12px;
|
||||
border: 1px dashed var(--stroke);
|
||||
border-radius: var(--radius-card);
|
||||
background: var(--surface-layer);
|
||||
}
|
||||
.prototype-tools label { display: grid; gap: 5px; color: var(--text-secondary); font-size: 12px; }
|
||||
.prototype-tools select { min-height: 34px; width: 100%; border: 1px solid var(--stroke); border-radius: 4px; color: var(--text-primary); background: var(--surface-raised); padding: 5px 8px; }
|
||||
.tool-buttons { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
.status-strip {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
border: 1px solid var(--stroke);
|
||||
border-radius: var(--radius-card);
|
||||
background: var(--surface-raised);
|
||||
overflow: hidden;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.health { display: grid; grid-template-columns: auto 1fr; gap: 4px 9px; padding: 12px 16px; border-right: 1px solid var(--divider); }
|
||||
.health:last-child { border-right: 0; }
|
||||
.health-mark { width: 10px; height: 10px; border-radius: 50%; background: currentColor; margin-top: 5px; }
|
||||
.health strong { font-weight: 650; }
|
||||
.health span:last-child { grid-column: 2; color: var(--text-secondary); font-size: 12px; }
|
||||
.tone-success { color: var(--success); }
|
||||
.tone-info { color: var(--info); }
|
||||
.tone-caution { color: var(--caution); }
|
||||
.tone-critical { color: var(--critical); }
|
||||
.banner {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 10px;
|
||||
margin-top: 12px;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid currentColor;
|
||||
border-radius: var(--radius-card);
|
||||
background: var(--info-soft);
|
||||
color: var(--info);
|
||||
}
|
||||
.banner[data-tone="success"] { background: var(--success-soft); color: var(--success); }
|
||||
.banner[data-tone="caution"] { background: var(--caution-soft); color: var(--caution); }
|
||||
.banner[data-tone="critical"] { background: var(--critical-soft); color: var(--critical); }
|
||||
.banner-symbol { font-weight: 800; }
|
||||
.banner strong { display: block; margin-bottom: 2px; }
|
||||
.banner p { margin: 0; color: var(--text-primary); }
|
||||
.workspace { display: grid; grid-template-columns: minmax(0, 1.3fr) minmax(320px, .7fr); gap: 12px; margin-top: 12px; }
|
||||
.stack { display: grid; gap: 12px; align-content: start; }
|
||||
.card { border: 1px solid var(--stroke); border-radius: var(--radius-card); background: var(--surface-raised); box-shadow: 0 2px 8px rgba(0,0,0,.04); overflow: hidden; }
|
||||
.card-header { display: flex; align-items: start; justify-content: space-between; gap: 12px; padding: 14px 16px 10px; border-bottom: 1px solid var(--divider); }
|
||||
.card-header h1, .card-header h2 { margin: 0; font-size: 17px; line-height: 1.35; }
|
||||
.card-header p { margin: 3px 0 0; color: var(--text-secondary); font-size: 12px; }
|
||||
.badge { display: inline-flex; align-items: center; gap: 6px; min-height: 24px; padding: 2px 8px; border: 1px solid currentColor; border-radius: 999px; font-size: 12px; font-weight: 650; white-space: nowrap; }
|
||||
.badge[data-tone="info"] { color: var(--info); background: var(--info-soft); }
|
||||
.badge[data-tone="success"] { color: var(--success); background: var(--success-soft); }
|
||||
.badge[data-tone="caution"] { color: var(--caution); background: var(--caution-soft); }
|
||||
.badge[data-tone="critical"] { color: var(--critical); background: var(--critical-soft); }
|
||||
.card-body { padding: 14px 16px 16px; }
|
||||
.task-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px 20px; margin: 0; }
|
||||
.task-grid div { min-width: 0; }
|
||||
.task-grid dt { color: var(--text-secondary); font-size: 12px; }
|
||||
.task-grid dd { margin: 2px 0 0; overflow-wrap: anywhere; font-variant-numeric: tabular-nums; }
|
||||
.task-grid .span-2 { grid-column: 1 / -1; }
|
||||
.steps { list-style: none; margin: 14px 0 0; padding: 0; display: grid; gap: 7px; }
|
||||
.steps li { display: grid; grid-template-columns: 20px 1fr auto; gap: 8px; align-items: start; color: var(--text-secondary); }
|
||||
.step-mark { width: 18px; height: 18px; border: 1px solid var(--stroke); border-radius: 50%; display: grid; place-items: center; font-size: 11px; }
|
||||
.steps li.done { color: var(--success); }
|
||||
.steps li.active { color: var(--text-primary); font-weight: 650; }
|
||||
.steps li.active .step-mark { border-color: var(--accent); box-shadow: inset 0 0 0 4px var(--accent-soft); }
|
||||
.steps small { color: var(--text-secondary); font-weight: 400; }
|
||||
.metric-row { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; }
|
||||
.metric { padding: 10px; border: 1px solid var(--divider); border-radius: 6px; background: var(--surface-muted); }
|
||||
.metric span { display: block; color: var(--text-secondary); font-size: 11px; }
|
||||
.metric strong { display: block; margin-top: 2px; font-size: 16px; font-variant-numeric: tabular-nums; }
|
||||
.log-list { list-style: none; padding: 0; margin: 0; max-height: 290px; overflow: auto; }
|
||||
.log-list li { display: grid; grid-template-columns: 64px 78px 1fr; gap: 8px; padding: 8px 16px; border-bottom: 1px solid var(--divider); }
|
||||
.log-list li:last-child { border-bottom: 0; }
|
||||
.log-list time { color: var(--text-secondary); font-variant-numeric: tabular-nums; }
|
||||
.log-level { font-size: 12px; font-weight: 650; }
|
||||
.poll-bar { margin-top: 12px; padding: 12px 16px; display: grid; grid-template-columns: minmax(160px, auto) 1fr auto; gap: 16px; align-items: center; border: 1px solid var(--stroke); border-radius: var(--radius-card); background: var(--surface-raised); box-shadow: var(--shadow); }
|
||||
.poll-summary { display: flex; gap: 20px; flex-wrap: wrap; }
|
||||
.poll-summary span { color: var(--text-secondary); font-size: 12px; }
|
||||
.poll-summary strong { color: var(--text-primary); font-variant-numeric: tabular-nums; }
|
||||
.disabled-reason { color: var(--text-secondary); font-size: 12px; max-width: 320px; }
|
||||
dialog { width: min(560px, calc(100vw - 32px)); border: 1px solid var(--stroke); border-radius: 8px; padding: 0; color: var(--text-primary); background: var(--surface-raised); box-shadow: 0 24px 80px rgba(0,0,0,.3); }
|
||||
dialog::backdrop { background: rgba(0,0,0,.42); }
|
||||
.dialog-body { padding: 20px; }
|
||||
.dialog-body h2 { margin: 0 0 8px; font-size: 18px; }
|
||||
.dialog-body p { margin: 0 0 10px; }
|
||||
.dialog-body code { overflow-wrap: anywhere; }
|
||||
.dialog-actions { display: flex; justify-content: flex-end; gap: 8px; padding: 12px 20px; border-top: 1px solid var(--divider); }
|
||||
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border: 0; }
|
||||
|
||||
@media (max-width: 1007px) {
|
||||
.workspace { grid-template-columns: 1fr; }
|
||||
.log-list { max-height: 220px; }
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.app-bar { align-items: stretch; flex-wrap: wrap; gap: 4px 12px; }
|
||||
.tabs { order: 3; width: 100%; }
|
||||
.tabs a { flex: 1; justify-content: center; padding-inline: 8px; }
|
||||
.app-actions label { position: absolute; clip: rect(0,0,0,0); }
|
||||
main { padding: 8px; }
|
||||
.prototype-tools { grid-template-columns: 1fr; }
|
||||
.status-strip { grid-template-columns: 1fr; }
|
||||
.health { border-right: 0; border-bottom: 1px solid var(--divider); }
|
||||
.health:last-child { border-bottom: 0; }
|
||||
.task-grid { grid-template-columns: 1fr; }
|
||||
.task-grid .span-2 { grid-column: auto; }
|
||||
.metric-row { grid-template-columns: 1fr; }
|
||||
.log-list li { grid-template-columns: 58px 1fr; }
|
||||
.log-list li span:last-child { grid-column: 1 / -1; }
|
||||
.poll-bar { grid-template-columns: 1fr; gap: 8px; }
|
||||
.poll-bar button { width: 100%; min-height: 40px; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after { scroll-behavior: auto !important; transition-duration: .01ms !important; animation-duration: .01ms !important; animation-iteration-count: 1 !important; }
|
||||
}
|
||||
@media (forced-colors: active) {
|
||||
.prototype-banner, .banner, .badge, .primary, .health-mark { forced-color-adjust: auto; }
|
||||
.card, .status-strip, .poll-bar, .prototype-tools { box-shadow: none; }
|
||||
.health-mark { border: 1px solid ButtonText; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="prototype-banner" role="note">PROTOTYPE - 仅供枚举交互,非实现依据;全部是假数据,不连接 ADB、web 服务或拼多多</div>
|
||||
<header class="app-bar">
|
||||
<div class="brand">cmbuyer 执行器<small>Windows / PySide6 前置原型</small></div>
|
||||
<nav class="tabs" aria-label="主要页面">
|
||||
<a href="desk-execution.html" aria-current="page">采购执行</a>
|
||||
<a href="desk-device-settings.html">设备与参数</a>
|
||||
</nav>
|
||||
<div class="app-actions">
|
||||
<label for="theme">主题</label>
|
||||
<select id="theme" aria-label="原型主题">
|
||||
<option value="system">跟随系统</option>
|
||||
<option value="light">浅色</option>
|
||||
<option value="dark">深色</option>
|
||||
</select>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<section class="prototype-tools" aria-labelledby="prototype-tools-title">
|
||||
<label id="prototype-tools-title" for="scenario">原型状态切换器(仅改变本页假数据)
|
||||
<select id="scenario">
|
||||
<option value="not_ready">未就绪 · web 不可达</option>
|
||||
<option value="version_mismatch">未就绪 · 拼多多版本失配</option>
|
||||
<option value="idle" selected>已就绪 · 尚未轮询</option>
|
||||
<option value="polling">轮询中 · 暂无任务</option>
|
||||
<option value="trial">执行中 · 第一趟试选</option>
|
||||
<option value="trial_done">第一趟完成 · 已释放手机</option>
|
||||
<option value="dry_run">执行中 · 下单只读演练</option>
|
||||
<option value="dry_run_ready">演练完成 · 等待真实第二趟</option>
|
||||
<option value="order_prefence">真实第二趟 · 围栏前校验</option>
|
||||
<option value="fencing">真实第二趟 · 申请围栏中</option>
|
||||
<option value="fence_failed">围栏失败或响应不明 · 不点击</option>
|
||||
<option value="fenced">围栏后 · 核对同一提交</option>
|
||||
<option value="waiting_payment">明确已创建 · 等人付款</option>
|
||||
<option value="uncertain">点击结果不明 · 可能已创建</option>
|
||||
<option value="security">安全校验 · 已停止</option>
|
||||
<option value="payment_handoff">外部支付交接 · 已停止</option>
|
||||
<option value="manual">围栏前异常 · 待人工</option>
|
||||
<option value="failed_stop">连续失败 · 轮询已停止</option>
|
||||
</select>
|
||||
</label>
|
||||
<div class="tool-buttons">
|
||||
<button type="button" id="reset">复位假数据</button>
|
||||
<button type="button" id="close-demo">演示关闭窗口</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="status-strip" aria-label="就绪状态">
|
||||
<div class="health" id="web-health">
|
||||
<span class="health-mark" aria-hidden="true"></span><strong>web 服务</strong><span>已连接 · 假环境</span>
|
||||
</div>
|
||||
<div class="health" id="device-health">
|
||||
<span class="health-mark" aria-hidden="true"></span><strong>Android 设备</strong><span>FAKE-DEVICE-01 · 已解锁(模拟)</span>
|
||||
</div>
|
||||
<div class="health" id="version-health">
|
||||
<span class="health-mark" aria-hidden="true"></span><strong>拼多多版本</strong><span>实际 / 已取证:均待 T-103 真机取证</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="banner" id="state-banner" data-tone="success" aria-live="polite" aria-atomic="true">
|
||||
<span class="banner-symbol" aria-hidden="true">i</span>
|
||||
<div><strong id="banner-title">设备已就绪</strong><p id="banner-text">可以开始定时轮询。当前页面不会连接真实设备。</p></div>
|
||||
</section>
|
||||
|
||||
<div class="workspace">
|
||||
<div class="stack">
|
||||
<section class="card" aria-labelledby="task-title">
|
||||
<div class="card-header">
|
||||
<div><h1 id="task-title">当前任务</h1><p id="task-subtitle">尚未领取任务</p></div>
|
||||
<span class="badge" id="leg-badge" data-tone="success">空闲</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<dl class="task-grid">
|
||||
<div><dt>任务编号</dt><dd id="task-id">—</dd></div>
|
||||
<div><dt>剩余租约</dt><dd id="lease">—</dd></div>
|
||||
<div class="span-2"><dt>商品</dt><dd id="product">暂无商品;轮询会在安全就绪后领取一条任务</dd></div>
|
||||
<div><dt>颜色分类</dt><dd id="color">—</dd></div>
|
||||
<div><dt>尺码 / 数量</dt><dd id="size-qty">—</dd></div>
|
||||
<div><dt>授权单价</dt><dd id="auth-price">—</dd></div>
|
||||
<div><dt>金额上限</dt><dd id="price-cap">—</dd></div>
|
||||
<div class="span-2"><dt>提交围栏</dt><dd id="submission">未建立</dd></div>
|
||||
</dl>
|
||||
<ol class="steps" id="steps" aria-label="执行步骤"></ol>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card" aria-labelledby="run-title">
|
||||
<div class="card-header"><div><h2 id="run-title">会话概览</h2><p>只显示当前前台会话,不代表后台进程</p></div></div>
|
||||
<div class="card-body metric-row">
|
||||
<div class="metric"><span>下次轮询</span><strong id="countdown">—</strong></div>
|
||||
<div class="metric"><span>连续失败</span><strong id="failure-count">0 / 3</strong></div>
|
||||
<div class="metric"><span>本次完成</span><strong id="completed-count">0</strong></div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section class="card" aria-labelledby="log-title">
|
||||
<div class="card-header"><div><h2 id="log-title">事件日志</h2><p>假日志;不含凭据、地址、手机号或页面全文</p></div><button type="button" id="export-log">导出假诊断摘要</button></div>
|
||||
<ul class="log-list" id="logs" aria-live="polite" aria-relevant="additions"></ul>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section class="poll-bar" aria-label="轮询控制">
|
||||
<button type="button" class="primary" id="primary-action">开始轮询</button>
|
||||
<div class="poll-summary"><span>会话:<strong id="session-state">未启动</strong></span><span>当前步骤:<strong id="current-step">等待开始</strong></span></div>
|
||||
<div class="disabled-reason" id="action-reason">web、设备和已取证 App 版本均就绪后才能开始。</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<dialog id="info-dialog" aria-labelledby="dialog-title">
|
||||
<div class="dialog-body">
|
||||
<h2 id="dialog-title">安全说明</h2>
|
||||
<p id="dialog-text"></p>
|
||||
<p><strong>原型边界:</strong>本对话框不会调用 API、ADB 或任何平台动作。</p>
|
||||
</div>
|
||||
<div class="dialog-actions"><button type="button" class="primary" id="dialog-close">知道了</button></div>
|
||||
</dialog>
|
||||
<div id="announcer" class="sr-only" aria-live="assertive" aria-atomic="true"></div>
|
||||
|
||||
<script>
|
||||
(() => {
|
||||
'use strict';
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const task = {
|
||||
id: 'CB-20260803-0042',
|
||||
product: '纯棉圆领短袖 T 恤(原型假数据)',
|
||||
color: '白色', sizeQty: 'XL / 2 件', authPrice: 'CNY 32.50', cap: 'CNY 80.00'
|
||||
};
|
||||
const baseSteps = {
|
||||
idle: [],
|
||||
trial: ['打开商品(待真机判据)', '精确匹配颜色分类与尺码', '从规格面板读取单价', '截图并退出商品页'],
|
||||
dry: ['重新打开商品(待真机判据)', '精确选择与数量复核', '读取订单确认页非敏感摘要', '验证提交控件唯一后退出'],
|
||||
order: ['重新选择同一规格', '闸门二:单价必须等于授权价', '数量复核与闸门三', '申请服务端提交围栏', '围栏许可后内部仅点击一次', '调和同一提交结果']
|
||||
};
|
||||
const scenarios = {
|
||||
not_ready: { tone:'critical', title:'web 服务不可达', text:'未开始轮询。检查本地网络和服务地址;已完成的本地证据会保留待补传。', web:'bad', device:'ok', version:'ok', leg:'未就绪', legTone:'critical', action:'开始轮询', enabled:false, reason:'web 服务未就绪,不能领取任务。', session:'未启动', step:'连接检查', kind:'idle', active:-1, countdown:'—', fail:'1 / 3' },
|
||||
version_mismatch: { tone:'critical', title:'拼多多版本与证据不一致', text:'已 fail closed:停止领取真机任务。下一步是由人重新取证并记录 App 版本,不能沿用旧判据。', web:'ok', device:'ok', version:'bad', leg:'版本失配', legTone:'critical', action:'开始轮询', enabled:false, reason:'App 版本失配;必须先完成本项目真机取证。', session:'已停止', step:'等待重新取证', kind:'idle', active:-1, countdown:'—', fail:'0 / 3' },
|
||||
idle: { tone:'success', title:'设备已就绪(模拟)', text:'三项就绪条件在本场景中均为模拟通过,可以演示定时轮询;不会连接真实设备。', web:'ok', device:'ok', version:'ok', leg:'空闲', legTone:'success', action:'开始轮询', enabled:true, reason:'点击只切换原型状态,不发起网络请求。', session:'未启动', step:'等待开始', kind:'idle', active:-1, countdown:'—', fail:'0 / 3' },
|
||||
polling: { tone:'info', title:'正在轮询,暂无待领任务', text:'无任务是正常空状态,不计入失败;将按配置间隔继续检查。', web:'ok', device:'ok', version:'ok', leg:'轮询中', legTone:'info', action:'停止轮询', enabled:true, reason:'停止后不再领取新任务。', session:'运行中', step:'等待下一轮', kind:'idle', active:-1, countdown:'00:12', fail:'0 / 3', count:2 },
|
||||
trial: { tone:'info', title:'第一趟试选正在执行', text:'只勾选规格、读价和截图;不会进入订单确认流程,完成后必须退出商品页释放手机。', web:'ok', device:'ok', version:'ok', leg:'TRIAL · 第一趟试选', legTone:'info', action:'真机步骤执行中', enabled:false, reason:'真机步骤期间禁用硬取消和关闭,避免留下无法判定的中间态。', session:'运行中', step:'读取规格面板单价', kind:'trial', active:2, countdown:'暂停', fail:'0 / 3', lease:'04:18' },
|
||||
trial_done: { tone:'success', title:'试选已回传,手机已释放', text:'规格与价格证据已保存(模拟);任务等待管理员确认,本设备继续下一轮。', web:'ok', device:'ok', version:'ok', leg:'TRIAL · 已完成', legTone:'success', action:'停止轮询', enabled:true, reason:'手机已退出商品页,可以安全停止轮询。', session:'运行中', step:'等待下一轮', kind:'trial', active:4, countdown:'00:09', fail:'0 / 3', count:3 },
|
||||
dry_run: { tone:'info', title:'DRY-RUN · 只读演练', text:'只到订单确认页读取非敏感摘要并验证控件唯一;不会提交订单,也不会消费授权。', web:'ok', device:'ok', version:'ok', leg:'DRY-RUN · 不会提交', legTone:'info', action:'只读演练进行中', enabled:false, reason:'演练中不提供提交动作;真机步骤期间受控关闭。', session:'运行中', step:'读取订单确认页', kind:'dry', active:2, countdown:'暂停', fail:'0 / 3', lease:'03:42' },
|
||||
dry_run_ready: { tone:'success', title:'只读演练已完成', text:'演练证据已回传(模拟)并退出确认页。旧演练不能替代真实第二趟的当前页面事实。', web:'ok', device:'ok', version:'ok', leg:'DRY-RUN · READY', legTone:'success', action:'停止轮询', enabled:true, reason:'等待服务端安排真实第二趟;不会由用户直接触发提交。', session:'运行中', step:'等待真实第二趟', kind:'dry', active:4, countdown:'00:16', fail:'0 / 3', count:4 },
|
||||
order_prefence: { tone:'caution', title:'真实第二趟 · 围栏前校验', text:'正在重新验证授权、闸门二、数量、闸门三和控件唯一。此时尚无点击许可。', web:'ok', device:'ok', version:'ok', leg:'ORDER · 围栏前', legTone:'caution', action:'安全校验进行中', enabled:false, reason:'没有服务端围栏许可,绝不能点击提交订单。', session:'运行中', step:'闸门三:核对实付款', kind:'order', active:2, countdown:'暂停', fail:'0 / 3', lease:'02:58' },
|
||||
fencing: { tone:'caution', title:'正在申请服务端提交围栏', text:'等待明确的 click_permitted。超时、冲突、网络失败或响应不明都不会点击。', web:'ok', device:'ok', version:'ok', leg:'ORDER · 申请围栏', legTone:'caution', action:'等待围栏响应', enabled:false, reason:'申请中禁止取消、关闭或重复申请。', session:'运行中', step:'申请唯一提交围栏', kind:'order', active:3, countdown:'暂停', fail:'0 / 3', lease:'02:31' },
|
||||
fence_failed: { tone:'critical', title:'未获得点击许可', text:'围栏申请失败或响应不明:没有点击提交订单。请按幂等键核查服务端记录,不能直接重试。', web:'bad', device:'ok', version:'ok', leg:'ORDER · 未获许可', legTone:'critical', action:'打开围栏核查说明', enabled:true, safeAction:'review', reason:'只核查围栏状态,不会触发提交。', session:'已停止', step:'核查幂等键', kind:'order', active:3, countdown:'—', fail:'1 / 3', submission:'围栏状态未知 · 幂等键 DEMO-FENCE-042' },
|
||||
fenced: { tone:'caution', title:'围栏已建立,只核对同一提交', text:'submission_id 已固定。内部流程至多点击一次;用户界面不提供提交、重领、放弃或重试入口。', web:'ok', device:'ok', version:'ok', leg:'ORDER · 已围栏', legTone:'caution', action:'打开同一提交核查说明', enabled:true, safeAction:'review', reason:'只能查看与调和同一提交记录。', session:'受控执行', step:'调和订单结果', kind:'order', active:5, countdown:'暂停', fail:'0 / 3', lease:'01:49', submission:'SUB-DEMO-0042 · FENCED' },
|
||||
waiting_payment: { tone:'success', title:'已明确创建待付款订单', text:'自动化已停止。系统不会付款;请人在拼多多核对后自行付款,再到 web 端标记。', web:'ok', device:'ok', version:'ok', leg:'WAITING_PAYMENT', legTone:'success', action:'查看待付款说明', enabled:true, safeAction:'paymentInfo', reason:'仅显示人工收口说明,不执行支付。', session:'已停止', step:'等待人工付款', kind:'order', active:6, countdown:'—', fail:'0 / 3', count:5, submission:'SUB-DEMO-0042 · SUBMITTED' },
|
||||
uncertain: { tone:'critical', title:'订单可能已创建,结果不明确', text:'保持金额预留和永久围栏,只核查 SUB-DEMO-0042;禁止重新领取、放弃授权或再次点击。', web:'ok', device:'ok', version:'ok', leg:'RECONCILIATION_REQUIRED', legTone:'critical', action:'打开人工核查说明', enabled:true, safeAction:'review', reason:'核查不会发起新的下单。可另行导出假证据摘要。', session:'已停止', step:'人工核查同一提交', kind:'order', active:5, countdown:'—', fail:'0 / 3', submission:'SUB-DEMO-0042 · 结果不明确' },
|
||||
security: { tone:'critical', title:'检测到安全校验,已停止', text:'保留现场并转人工;不尝试绕过验证码、风控、人脸或短信校验。若已围栏,只调和同一提交。', web:'ok', device:'ok', version:'ok', leg:'待人工 · 安全校验', legTone:'critical', action:'打开人工核查说明', enabled:true, safeAction:'review', reason:'只显示核查说明,不提供绕过或继续入口。', session:'已停止', step:'等待人工处理', kind:'order', active:5, countdown:'—', fail:'1 / 3', submission:'SUB-DEMO-0042 · RECONCILIATION_REQUIRED' },
|
||||
payment_handoff: { tone:'critical', title:'进入外部支付交接,已停止', text:'订单可能已创建、支付未完成。不会读取或输入支付凭据,也没有“继续付款”按钮。', web:'ok', device:'ok', version:'ok', leg:'待人工 · 外部支付', legTone:'critical', action:'打开人工核查说明', enabled:true, safeAction:'paymentInfo', reason:'只显示人工核查与付款边界。', session:'已停止', step:'人工核查同一提交', kind:'order', active:5, countdown:'—', fail:'0 / 3', submission:'SUB-DEMO-0042 · HANDED_OFF' },
|
||||
manual: { tone:'caution', title:'任务已转人工:授权价与现价不一致', text:'授权 CNY 32.50,规格面板现价 CNY 35.00。没有下单,也不会自动放弃;请联系管理员处理。', web:'ok', device:'ok', version:'ok', leg:'NEEDS_MANUAL', legTone:'caution', action:'导出假诊断摘要', enabled:true, safeAction:'export', reason:'导出仅生成本地假文本,不包含页面全文或敏感信息。', session:'继续轮询其他任务', step:'当前任务已移交', kind:'order', active:1, countdown:'00:11', fail:'0 / 3', submission:'未建立' },
|
||||
failed_stop: { tone:'critical', title:'连续失败达到阈值,轮询已停止', text:'最近三次均为服务连接失败。检查设置和网络;修复后重新开始普通轮询,不会重试任何提交点击。', web:'bad', device:'ok', version:'ok', leg:'自动停止', legTone:'critical', action:'前往设备与参数', enabled:true, safeAction:'settings', reason:'先修复连接;不会自动恢复轮询。', session:'已停止', step:'等待修复', kind:'idle', active:-1, countdown:'—', fail:'3 / 3', count:4 }
|
||||
};
|
||||
|
||||
function setHealth(id, state, detail) {
|
||||
const el = $(id);
|
||||
el.className = `health tone-${state === 'ok' ? 'success' : state === 'bad' ? 'critical' : 'caution'}`;
|
||||
if (detail) el.querySelector('span:last-child').textContent = detail;
|
||||
}
|
||||
function renderSteps(kind, active) {
|
||||
const items = baseSteps[kind] || [];
|
||||
$('steps').replaceChildren(...items.map((label, index) => {
|
||||
const li = document.createElement('li');
|
||||
if (active >= items.length || index < active) li.className = 'done';
|
||||
else if (index === active) li.className = 'active';
|
||||
const mark = document.createElement('span'); mark.className = 'step-mark'; mark.textContent = index < active || active >= items.length ? '✓' : String(index + 1);
|
||||
const text = document.createElement('span'); text.textContent = label;
|
||||
const state = document.createElement('small'); state.textContent = index < active || active >= items.length ? '已完成' : index === active ? '当前' : '等待';
|
||||
li.append(mark, text, state); return li;
|
||||
}));
|
||||
}
|
||||
function appendLog(level, message) {
|
||||
const li = document.createElement('li');
|
||||
const now = new Date();
|
||||
const time = document.createElement('time'); time.textContent = now.toLocaleTimeString('zh-CN', {hour12:false});
|
||||
const badge = document.createElement('span'); badge.className = `log-level tone-${level === '停止' ? 'critical' : level === '注意' ? 'caution' : level === '完成' ? 'success' : 'info'}`; badge.textContent = level;
|
||||
const text = document.createElement('span'); text.textContent = message;
|
||||
li.append(time, badge, text); $('logs').prepend(li);
|
||||
while ($('logs').children.length > 8) $('logs').lastElementChild.remove();
|
||||
}
|
||||
function render(key, announce = true) {
|
||||
const s = scenarios[key];
|
||||
$('state-banner').dataset.tone = s.tone;
|
||||
$('banner-title').textContent = s.title; $('banner-text').textContent = s.text;
|
||||
setHealth('web-health', s.web, s.web === 'bad' ? '不可达(模拟)· 已停止领取' : '已连接 · 假环境');
|
||||
setHealth('device-health', s.device, 'FAKE-DEVICE-01 · 已解锁(模拟)');
|
||||
setHealth('version-health', s.version, s.version === 'bad' ? '模拟失配 · 必须重新取证' : '模拟匹配 · 真实版本值待 T-103 取证');
|
||||
$('leg-badge').textContent = s.leg; $('leg-badge').dataset.tone = s.legTone;
|
||||
const hasTask = !['not_ready','version_mismatch','idle','polling','failed_stop'].includes(key);
|
||||
$('task-subtitle').textContent = hasTask ? '本页所有任务字段均为假数据' : '尚未领取任务';
|
||||
$('task-id').textContent = hasTask ? task.id : '—'; $('lease').textContent = s.lease || '—';
|
||||
$('product').textContent = hasTask ? task.product : '暂无商品;轮询会在安全就绪后领取一条任务';
|
||||
$('color').textContent = hasTask ? task.color : '—'; $('size-qty').textContent = hasTask ? task.sizeQty : '—';
|
||||
$('auth-price').textContent = hasTask && s.kind !== 'trial' ? task.authPrice : '—'; $('price-cap').textContent = hasTask ? task.cap : '—';
|
||||
$('submission').textContent = s.submission || '未建立';
|
||||
renderSteps(s.kind, s.active);
|
||||
$('countdown').textContent = s.countdown; $('failure-count').textContent = s.fail; $('completed-count').textContent = String(s.count || 0);
|
||||
$('session-state').textContent = s.session; $('current-step').textContent = s.step;
|
||||
$('primary-action').textContent = s.action; $('primary-action').disabled = !s.enabled; $('primary-action').dataset.safeAction = s.safeAction || '';
|
||||
$('action-reason').textContent = s.reason;
|
||||
if (announce) { $('announcer').textContent = `${s.title}。${s.text}`; appendLog(s.tone === 'critical' ? '停止' : s.tone === 'caution' ? '注意' : s.tone === 'success' ? '完成' : '状态', `切换原型状态:${s.title}`); }
|
||||
}
|
||||
function showDialog(title, text) {
|
||||
$('dialog-title').textContent = title; $('dialog-text').textContent = text; $('info-dialog').showModal();
|
||||
}
|
||||
function exportDemo() {
|
||||
const blob = new Blob(['cmbuyer 原型假诊断摘要\n无真实任务、无凭据、无页面全文。\n'], {type:'text/plain;charset=utf-8'});
|
||||
const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = 'cmbuyer-prototype-diagnostic.txt'; a.click(); URL.revokeObjectURL(url);
|
||||
appendLog('完成', '已生成本地假诊断摘要;没有外部网络请求。');
|
||||
}
|
||||
|
||||
$('scenario').addEventListener('change', (e) => render(e.target.value));
|
||||
$('theme').addEventListener('change', (e) => { document.documentElement.dataset.theme = e.target.value; $('announcer').textContent = `主题已切换为${e.target.options[e.target.selectedIndex].text}`; });
|
||||
$('reset').addEventListener('click', () => { $('scenario').value = 'idle'; $('logs').replaceChildren(); render('idle'); appendLog('状态', '假数据已复位。'); });
|
||||
$('primary-action').addEventListener('click', () => {
|
||||
const action = $('primary-action').dataset.safeAction;
|
||||
if (!action) {
|
||||
const next = $('scenario').value === 'idle' ? 'polling' : 'idle'; $('scenario').value = next; render(next); return;
|
||||
}
|
||||
if (action === 'settings') { location.href = 'desk-device-settings.html'; return; }
|
||||
if (action === 'export') { exportDemo(); return; }
|
||||
if (action === 'paymentInfo') { showDialog('人工付款边界', '自动化已经停止。本系统不会点击支付、免密支付、先用后付或任何扣款控件;订单状态需要人核查。'); return; }
|
||||
showDialog('只核查同一提交记录', '围栏后只能围绕 SUB-DEMO-0042 调和。这里没有重新领取、放弃授权、重新申请围栏或再次提交的动作。');
|
||||
});
|
||||
$('export-log').addEventListener('click', exportDemo);
|
||||
$('close-demo').addEventListener('click', () => {
|
||||
const unsafe = ['trial','dry_run','order_prefence','fencing','fenced'].includes($('scenario').value);
|
||||
showDialog(unsafe ? '当前不能直接关闭' : '关闭窗口协议', unsafe ? '真机或围栏流程处于受控阶段。原生实现需先完成安全停靠或保持同一 submission 调和,不能让关闭看起来等于“可以重来”。' : '原生实现关闭窗口后立即停止普通轮询,不留后台进程。此 HTML 只演示规则,不会关闭浏览器。');
|
||||
});
|
||||
$('dialog-close').addEventListener('click', () => $('info-dialog').close());
|
||||
$('info-dialog').addEventListener('click', (e) => { if (e.target === $('info-dialog')) $('info-dialog').close(); });
|
||||
render('idle', false); appendLog('状态', '原型已加载;未连接任何服务或设备。');
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
+74
-3
@@ -3,7 +3,7 @@ id: T-006
|
||||
title: 桌面端 MVP 交互原型
|
||||
phase: 0
|
||||
deps: []
|
||||
status: TODO
|
||||
status: DOING
|
||||
created: 2026-08-03
|
||||
context_ref: dfe3d56
|
||||
work_branch: task/t-006-desk-prototype
|
||||
@@ -97,5 +97,76 @@ desk 端将使用 PySide6 / Qt Widgets,但当前没有可确认的桌面应用
|
||||
|
||||
## 执行记录
|
||||
|
||||
开工后记录修改文件、浏览器检查尺寸、控制台 / 网络 / 可访问性结果、HTML → PySide6 映射、
|
||||
未验证范围和人工确认项。
|
||||
### 2026-08-03 · 原型待人工确认
|
||||
|
||||
状态保持 `DOING`。本轮只完成 Phase 0 浏览器原型,不连接 web、ADB 或拼多多,也不把浏览器
|
||||
行为视为 Qt / 真机验收。
|
||||
|
||||
**已修改**
|
||||
|
||||
- `docs/design/desk-execution.html`:固定两页签桌面壳、三项就绪状态、当前任务与步骤、会话
|
||||
指标、事件日志和唯一主动作。显式状态切换器覆盖 18 个状态:未连接、版本失配、空闲、
|
||||
空轮询、试选、试选完成并释放手机、dry-run、dry-run 完成、真实第二趟围栏前、申请围栏、
|
||||
围栏失败 / 响应不明、围栏后调和、待付款、结果不明、安全校验、外部支付、围栏前转人工、
|
||||
连续失败停止。
|
||||
- `docs/design/desk-device-settings.html`:显式 serial、USB / WiFi 标识、ADB 路径、web 地址、
|
||||
假设备凭据、轮询 / 请求 / 真机步骤超时和连续失败阈值。覆盖默认、字段校验、检查中、
|
||||
检查通过、web 失败、版本失配、保存成功、运行中冻结 8 个状态,以及未保存离开协议。
|
||||
- 两页均使用 `Segoe UI Variable` / `Segoe UI` 回退、集中语义 token、浅色 / 深色 / 高对比
|
||||
回退、`prefers-reduced-motion`、可见键盘焦点、`aria-live`、本地可复位假数据和内联 CSS / JS。
|
||||
页面间只有本地相对链接;没有外部资源、生产地址请求或真实动作。
|
||||
|
||||
**HTML → PySide6 / Qt Widgets 交接映射**
|
||||
|
||||
| 原型项 | 核心交互契约 | 推荐 Qt Widgets 映射 | 信号 / 状态来源 |
|
||||
| --- | --- | --- | --- |
|
||||
| 应用壳与两个固定页签 | 固定同级页面;切换保留各页状态 | `QMainWindow` + 不可关闭的 `QTabWidget` | `currentChanged(int)`;稳定 page id,不用标题作业务身份 |
|
||||
| web / 设备 / App 版本状态 | 三项全就绪才允许普通轮询;版本失配 fail closed | `QWidget` + 3 组 `QLabel`,状态色由语义 palette / 动态属性驱动 | `ReadinessViewModel.changed`;实际版本与证据版本由服务层提供 |
|
||||
| 页面信息条 | 连接、失败、待人工和围栏状态占稳定位置,不抢焦点 | 语义 Banner `QWidget`(图形 + 标题 + 说明) | `state_changed` 经 GUI 线程队列连接;重要变化触发无障碍事件 |
|
||||
| 唯一主动作 | 每个状态只暴露一个安全主动作;禁用旁边必须有原因 | 共享 `QAction` + `QPushButton` | `QAction.triggered`;enabled / text 由状态机单向派生 |
|
||||
| 当前任务摘要 | 只读展示任务、规格、金额、租约和 submission | `QFormLayout` + 可复制的只读 `QLabel` / `QLineEdit` | 当前 execution 快照;金额保持十进制字符串 |
|
||||
| 步骤列表 | 已完成 / 当前 / 等待不只靠颜色;稳定步骤 id | `QListView` + `QAbstractListModel` + `QStyledItemDelegate` | execution step event;模型更新必须在 GUI 线程 |
|
||||
| 会话指标与事件日志 | 空任务不算失败;日志不含凭据、地址、手机号或页面全文 | 指标 `QLabel`;日志 `QTableView` + `QAbstractTableModel` | poll/session event;高频更新节流,记录使用稳定 event id |
|
||||
| 设置表单 | 显式提交;字段错误就近显示,失败保留输入 | `QFormLayout`、`QLineEdit`、`QButtonGroup` + `QRadioButton`、`QSpinBox` | `textChanged` / `valueChanged` 只改草稿;`QValidator` 仅做格式层校验 |
|
||||
| 保存与连接检查 | 保存是唯一主动作;检查只验证连接和安装,不打开 App | `QAction` + `QPushButton`;后台 worker + 页面进度 | `triggered` → worker;结果带 request id,过期结果忽略 |
|
||||
| 运行中冻结 | 轮询中禁止换设备和保存参数 | 表单容器 / 相关 `QAction.setEnabled(False)` | session state;禁用原因用可见 `QLabel` 与 accessible description |
|
||||
| 对话框与关闭协议 | Escape / 关闭使用同一取消路径;围栏后关闭不等于可以重来 | `QDialog` + `QDialogButtonBox` | `accepted` / `rejected`;关闭后焦点返回调用者 |
|
||||
| 焦点与快捷键 | 自然 Tab 顺序、可见焦点、`Ctrl+S` 保存 | 标签 buddy、`QAction` + `QKeySequence.Save` | Qt 标准焦点链;原生实现重新用 Narrator / Inspect 验证 |
|
||||
|
||||
Qt 实现时不复制 DOM、CSS 或 JavaScript 状态对象。业务状态必须来自 web / execution 服务,
|
||||
View 只渲染;I/O 离开 GUI 线程,通过 PySide6 `Signal` / `Slot` 回到 GUI 线程。PySide6 / Qt 的
|
||||
精确版本要在 T-002 初始化后核对,版本敏感 API 在此任务中未作事实声明。
|
||||
|
||||
**验证证据**
|
||||
|
||||
- `python scripts/validate_agent_context.py`:通过(6 个任务路由,18 个有效仓库路径)。
|
||||
- `git diff --check`:通过。
|
||||
- Python 标准库 HTML 解析:两页可解析;`desk-execution.html` 39 个 id、
|
||||
`desk-device-settings.html` 54 个 id,均无重复;除 `data:,` favicon 外无资源引用。
|
||||
- 静态危险面扫描:未发现 `fetch`、`XMLHttpRequest`、WebSocket、外部脚本 / 图片 / iframe,
|
||||
也未发现文案为「提交订单」「继续付款」「免密支付」「先用后付」「重试提交」的按钮。
|
||||
- Chromium 本地逐页检查:最终控制台 0 error / 0 warning;网络面板只有当前本地 HTML 文档。
|
||||
- 执行页 18 个状态、设置页 8 个状态逐项切换;围栏失败时主动作是「打开围栏核查说明」,
|
||||
围栏后和结果不明时只显示同一 `submission_id` 的核查入口,危险按钮集合为空。
|
||||
- 375 / 768 / 1024 / 1440 px 均无横向溢出;表单操作区与内容区在四个宽度下无覆盖。
|
||||
1440 px 两页均做 full-page 视觉检查。
|
||||
- 键盘路径检查到页面链接、主题、状态切换、按钮和表单字段,焦点轮廓可见;Enter 打开核查
|
||||
对话框、Escape 关闭并把焦点还给调用按钮;表单提交聚焦第一个错误且保留输入,模拟异步
|
||||
检查期间禁用保存,完成后恢复;未保存离开会打开确认对话框。
|
||||
- 浏览器 accessibility snapshot 可识别 navigation、main、region、表单控件标签、按钮和状态
|
||||
文本;深色主题已交互检查。`forced-colors` 与减少动画分支已静态检查,但不替代原生测试。
|
||||
|
||||
**未验证 / 待人工确认**
|
||||
|
||||
- 请人工确认:两个固定页签是否足够;执行页当前任务 / 日志的宽屏比例和紧凑页密度;
|
||||
「开始 / 停止轮询」作为普通会话唯一主动作是否清楚;待人工、版本失配、围栏失败、围栏后、
|
||||
结果不明与外部支付的严重度和文案是否足够明确。
|
||||
- 请人工确认:设置页字段集合,以及轮询间隔、普通请求超时、真机步骤超时、连续失败阈值
|
||||
是否应该允许用户配置。原型中的数字仅是假数据,不是产品默认值。
|
||||
- T-103 真机取证后,必须回修 App 实际 / 已取证版本展示、可读字段和文案;本原型没有定义
|
||||
任何拼多多页面判据、选择器、节点或已验证版本。
|
||||
- 未验证原生标题栏、系统菜单、Snap Layouts、DPI / 多显示器、Qt palette / QSS、Windows
|
||||
高对比、输入法、Narrator / UI Automation、线程、性能、打包与关闭真实后台任务。这些不能
|
||||
由 HTML 证明,需在 T-002 及后续原生实现任务中重新验收。
|
||||
- 本轮没有真机证据,也没有真实 web / ADB / API / 文件保存。人工确认前任务继续保持
|
||||
`DOING`,不得自动改为 `DONE`。
|
||||
|
||||
Reference in New Issue
Block a user