Files
cmbuyer/docs/design/desk-execution.html

901 lines
66 KiB
HTML
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.
<!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>采购工具 · 采购执行原型</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) minmax(180px, .55fr) 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)) minmax(260px, .9fr);
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:nth-child(3) { border-right: 1px solid var(--divider); }
.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; }
.session-control { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 4px 12px; align-items: center; padding: 10px 12px; }
.session-copy { min-width: 0; }
.session-copy span { display: block; color: var(--text-secondary); font-size: 11px; }
.session-copy strong { display: block; overflow-wrap: anywhere; }
.session-control .primary { min-width: 112px; }
.disabled-reason { grid-column: 1 / -1; color: var(--text-secondary); font-size: 11px; }
.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 minmax(0, 1fr) auto;
gap: 10px;
align-items: center;
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); }
.banner button[hidden] { display: none; }
.workspace { display: grid; grid-template-columns: minmax(0, 1.85fr) minmax(320px, 1fr); gap: 12px; margin-top: 12px; align-items: stretch; }
.left-pane { min-width: 0; min-height: 0; }
.stack { display: grid; grid-template-rows: auto minmax(240px, 1fr); gap: 12px; min-width: 0; }
.stack[hidden], .detail-workspace[hidden] { display: none; }
.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-layout { display: grid; grid-template-columns: minmax(0, 2fr) minmax(180px, 1fr); gap: 16px; align-items: start; }
.task-copy { min-width: 0; }
.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; }
.product-preview { margin: 0; min-width: 0; border: 1px solid var(--divider); border-radius: 6px; overflow: hidden; background: var(--surface-muted); }
.preview-stage { aspect-ratio: 4 / 3; min-height: 148px; display: grid; place-items: center; padding: 12px; border-bottom: 1px solid var(--divider); }
.preview-stage svg { width: 100%; height: 100%; max-height: 210px; }
.preview-empty { color: var(--text-secondary); text-align: center; }
.preview-empty strong { display: block; color: var(--text-primary); margin-bottom: 4px; }
.product-preview figcaption { padding: 9px 10px; color: var(--text-secondary); font-size: 11px; }
.product-preview figcaption span, .product-preview figcaption time { display: block; }
.log-card { display: flex; min-height: 240px; flex-direction: column; }
.log-tools { display: flex; align-items: center; justify-content: flex-end; gap: 10px; flex-wrap: wrap; }
.inline-metrics { display: flex; gap: 10px; flex-wrap: wrap; }
.inline-metrics span { color: var(--text-secondary); font-size: 11px; white-space: nowrap; }
.inline-metrics strong { color: var(--text-primary); font-variant-numeric: tabular-nums; }
.log-list { list-style: none; padding: 0; margin: 0; min-height: 210px; max-height: 360px; 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; }
.record-card { min-width: 0; min-height: 0; display: flex; flex-direction: column; }
.record-actions { display: flex; align-items: center; gap: 8px; }
.table-wrap { min-height: 420px; max-height: 720px; overflow: auto; flex: 1; }
.record-table { width: 100%; border-collapse: separate; border-spacing: 0; table-layout: fixed; }
.record-table th { position: sticky; top: 0; z-index: 1; padding: 9px 12px; color: var(--text-secondary); background: var(--surface-muted); border-bottom: 1px solid var(--stroke); text-align: left; font-size: 12px; }
.record-table th:last-child { width: 112px; }
.record-table td { padding: 10px 12px; border-bottom: 1px solid var(--divider); vertical-align: top; }
.record-table tbody tr { cursor: default; }
.record-table tbody tr:hover { background: var(--surface-muted); }
.record-table tbody tr[aria-selected="true"] { background: var(--accent-soft); }
.record-table tbody tr:focus-visible { position: relative; outline-offset: -3px; }
.record-title { display: block; font-weight: 650; overflow-wrap: anywhere; }
.record-meta { display: block; margin-top: 3px; color: var(--text-secondary); font-size: 11px; font-variant-numeric: tabular-nums; }
.record-status { display: inline-flex; padding: 2px 7px; border: 1px solid currentColor; border-radius: 999px; font-size: 11px; font-weight: 650; }
.record-status[data-tone="success"] { color: var(--success); }
.record-status[data-tone="info"] { color: var(--info); }
.record-status[data-tone="caution"] { color: var(--caution); }
.record-status[data-tone="critical"] { color: var(--critical); }
.table-empty { padding: 28px 16px; color: var(--text-secondary); text-align: center; }
.table-help { margin: 0; padding: 9px 12px; color: var(--text-secondary); border-top: 1px solid var(--divider); font-size: 11px; }
.context-menu { position: fixed; z-index: 20; min-width: 150px; padding: 4px; border: 1px solid var(--stroke); border-radius: 6px; background: var(--surface-raised); box-shadow: var(--shadow); }
.context-menu[hidden] { display: none; }
.context-menu button { width: 100%; justify-content: flex-start; border-color: transparent; }
dialog { width: min(560px, calc(100vw - 32px)); max-height: calc(100vh - 32px); overflow: auto; 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); }
.detail-workspace { min-height: 540px; height: 100%; display: flex; flex-direction: column; }
.detail-header { align-items: center; }
.detail-header-actions { display: flex; align-items: center; justify-content: flex-end; gap: 8px; flex-wrap: wrap; }
.shortcut { margin-left: 5px; padding: 1px 5px; border: 1px solid var(--stroke); border-bottom-width: 2px; border-radius: 3px; color: var(--text-secondary); background: var(--surface-muted); font-size: 11px; }
.detail-scroll { min-height: 0; overflow: auto; flex: 1; }
.record-detail-grid { display: grid; grid-template-columns: minmax(0, 3fr) minmax(240px, 2fr); gap: 16px; padding: 16px 20px; }
.detail-feedback { margin: 16px 20px 0; padding: 16px; border: 1px solid var(--stroke); border-radius: 6px; background: var(--surface-muted); }
.detail-feedback[hidden] { display: none; }
.detail-feedback strong, .detail-feedback span { display: block; }
.detail-feedback span { margin-top: 4px; color: var(--text-secondary); }
.detail-feedback button { margin-top: 12px; }
.detail-panel { min-width: 0; }
.detail-panel h3, .record-result h3 { margin: 0 0 9px; font-size: 14px; }
.detail-list { display: grid; grid-template-columns: 104px minmax(0, 1fr); gap: 7px 12px; margin: 0; }
.detail-list dt { color: var(--text-secondary); }
.detail-list dd { margin: 0; overflow-wrap: anywhere; }
.timeline { margin: 14px 0 0; padding-left: 20px; color: var(--text-secondary); }
.timeline li + li { margin-top: 5px; }
.evidence-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; }
.evidence-card { margin: 0; border: 1px solid var(--divider); border-radius: 6px; overflow: hidden; background: var(--surface-muted); }
.evidence-card:only-child { grid-column: 1 / -1; }
.evidence-card svg { display: block; width: 100%; aspect-ratio: 4 / 3; }
.evidence-card figcaption { padding: 8px 10px; color: var(--text-secondary); font-size: 11px; }
.record-result { padding: 14px 20px 18px; border-top: 1px solid var(--divider); }
.result-value { margin: 0; padding: 12px; border: 1px solid var(--divider); border-radius: 6px; background: var(--surface-muted); font-weight: 650; overflow-wrap: anywhere; }
.result-note { margin: 8px 0 0; color: var(--text-secondary); font-size: 12px; }
.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; }
.stack { grid-template-rows: auto auto; }
.record-card { min-height: 480px; }
.workspace[data-mode="record"] .record-card { display: none; }
.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:nth-child(3) { border-right: 0; }
.session-control { grid-template-columns: 1fr; }
.session-control .primary { width: 100%; min-height: 40px; }
.banner { grid-template-columns: auto minmax(0, 1fr); }
.banner button { grid-column: 1 / -1; width: 100%; }
.task-layout { grid-template-columns: 1fr; }
.task-grid { grid-template-columns: 1fr; }
.task-grid .span-2 { grid-column: auto; }
.preview-stage { aspect-ratio: 16 / 9; }
.card-header { align-items: stretch; flex-direction: column; }
.log-tools, .record-actions { justify-content: flex-start; }
.log-list li { grid-template-columns: 58px 1fr; }
.log-list li span:last-child { grid-column: 1 / -1; }
.record-detail-grid { grid-template-columns: 1fr; }
.detail-header-actions { align-items: stretch; justify-content: flex-start; }
.detail-header-actions .badge { white-space: normal; }
.evidence-grid { grid-template-columns: 1fr; }
.detail-list { grid-template-columns: 1fr; gap: 2px; }
.detail-list dd { margin-bottom: 7px; }
}
@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, .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">采购工具<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>
<label for="detail-state">记录详情状态(仅演示读取反馈)
<select id="detail-state">
<option value="normal" selected>正常</option>
<option value="loading">加载中</option>
<option value="error">加载失败</option>
<option value="missing">记录不存在</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>
<div class="session-control" aria-label="轮询会话控制">
<div class="session-copy"><span>会话状态</span><strong id="session-state">未启动</strong></div>
<button type="button" class="primary" id="primary-action">开始轮询</button>
<div class="session-copy"><span>当前步骤</span><strong id="current-step">等待开始</strong></div>
<div class="disabled-reason" id="action-reason">web、设备和已取证 App 版本均就绪后才能开始。</div>
</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>
<button type="button" id="context-action" hidden>查看说明</button>
</section>
<div class="workspace" id="workspace" data-mode="live">
<div class="left-pane">
<div class="stack" id="live-workspace">
<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">
<div class="task-layout">
<div class="task-copy">
<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>
<figure class="product-preview" aria-labelledby="preview-caption">
<div class="preview-stage">
<div class="preview-empty" id="preview-empty"><strong>暂无商品预览</strong>只有取得可信图片证据后才展示</div>
<svg id="product-preview-image" viewBox="0 0 320 240" role="img" aria-label="原型假商品预览,不是真实拼多多图片" hidden>
<rect width="320" height="240" fill="#e7f3ff"/>
<rect x="28" y="24" width="264" height="192" rx="12" fill="#ffffff" stroke="#9ac8ee" stroke-width="2"/>
<path d="M114 72l30-18h32l30 18 33 12-18 43-23-10v61h-76v-61l-23 10-18-43 33-12z" fill="#f5f5f5" stroke="#65788a" stroke-width="3"/>
<path d="M144 55c3 20 29 20 32 0" fill="none" stroke="#65788a" stroke-width="3"/>
<text x="160" y="208" text-anchor="middle" fill="#3a5268" font-size="13" font-family="Segoe UI, sans-serif">内联假证据 · 不访问拼多多</text>
</svg>
</div>
<figcaption id="preview-caption"><span id="preview-source">来源:尚无可信图片</span><time id="preview-time">采集时间:—</time></figcaption>
</figure>
</div>
</div>
</section>
<section class="card log-card" aria-labelledby="log-title">
<div class="card-header">
<div><h2 id="log-title">滚动日志</h2><p>最新事件在上;不含凭据、地址、手机号或页面全文</p></div>
<div class="log-tools">
<div class="inline-metrics" aria-label="会话指标"><span>下次轮询 <strong id="countdown">—</strong></span><span>连续失败 <strong id="failure-count">0 / 3</strong></span><span>本次完成 <strong id="completed-count">0</strong></span></div>
<button type="button" id="export-log">导出假诊断摘要</button>
</div>
</div>
<ul class="log-list" id="logs" aria-live="polite" aria-relevant="additions"></ul>
</section>
</div>
<section class="card detail-workspace" id="record-detail-pane" aria-labelledby="record-detail-title" aria-busy="false" hidden>
<div class="card-header detail-header">
<div><h1 id="record-detail-title">执行记录详情</h1><p id="record-detail-meta">选择右侧记录查看历史快照</p></div>
<div class="detail-header-actions">
<span class="badge" id="live-task-state" data-tone="success" aria-live="polite">实时:未启动</span>
<button type="button" id="return-live">返回当前任务 <span class="shortcut" aria-hidden="true">Esc</span></button>
</div>
</div>
<div class="detail-feedback" id="detail-feedback" role="status" aria-live="polite" hidden>
<strong id="detail-feedback-title"></strong>
<span id="detail-feedback-text"></span>
<button type="button" id="retry-detail" hidden>重新读取记录</button>
</div>
<div class="detail-scroll" id="detail-content">
<div class="record-detail-grid">
<section class="detail-panel" aria-labelledby="original-copy-title">
<h3 id="original-copy-title">原始文字</h3>
<dl class="detail-list">
<dt>任务编号</dt><dd id="detail-task-id"></dd>
<dt>商品标题</dt><dd id="detail-product"></dd>
<dt>目标规格</dt><dd id="detail-spec"></dd>
<dt>金额边界</dt><dd id="detail-cap"></dd>
<dt>提交围栏</dt><dd id="detail-submission"></dd>
<dt>原始说明</dt><dd id="detail-note"></dd>
</dl>
<ol class="timeline" id="detail-timeline" aria-label="执行时间线"></ol>
</section>
<section class="detail-panel" aria-labelledby="evidence-title">
<h3 id="evidence-title">图片证据</h3>
<div class="evidence-grid" id="detail-evidence"></div>
</section>
</div>
<section class="record-result" aria-labelledby="result-title">
<h3 id="result-title">采购结果</h3>
<p class="result-value" id="detail-result"></p>
<p class="result-note" id="detail-result-note"></p>
</section>
</div>
</section>
</div>
<section class="card record-card" aria-labelledby="record-title">
<div class="card-header">
<div><h2 id="record-title">执行记录</h2><p><span id="record-count">0</span> 条假记录 · 按采集时间倒序</p></div>
<div class="record-actions"><button type="button" id="view-record" disabled>查看所选记录</button></div>
</div>
<div class="table-wrap">
<table class="record-table" aria-describedby="record-help">
<thead><tr><th scope="col">标题</th><th scope="col">状态</th></tr></thead>
<tbody id="record-rows"></tbody>
</table>
<div class="table-empty" id="record-empty" hidden>暂无执行记录。开始轮询并产生结果后会显示在这里。</div>
</div>
<p class="table-help" id="record-help">单击选择;双击行、按 Enter 或使用“查看所选记录”在左侧打开详情。详情态可连续选择其他记录。</p>
</section>
</div>
</main>
<div class="context-menu" id="record-menu" role="menu" hidden>
<button type="button" id="record-menu-open" role="menuitem">查看记录详情</button>
</div>
<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 records = [
{
id:'REC-DEMO-0042', taskId:'CB-20260803-0042', title:'纯棉圆领短袖 T 恤(原型假数据)', collectedAt:'2026-08-03T13:52:18+08:00',
status:'RECONCILIATION_REQUIRED', spec:'白色 | XL | 2 件', cap:'CNY 80.00', submission:'SUB-DEMO-0042 · 结果不明确',
note:'围栏后网络中断,订单可能已创建。只允许核查同一提交记录,不能重试。', evidence:['商品页截图裁剪(内联假证据)','规格面板截图(内联假证据)'],
timeline:['13:51:04 重新选择同一规格','13:51:41 三道价格闸门通过(假数据)','13:51:58 围栏已建立','13:52:18 点击结果不明确,转人工核查'],
result:'下单待核查:白色 | XL | CNY 32.50 | 2 件', resultNote:'订单可能已创建、支付未完成。系统不会再次提交,也不会付款。'
},
{
id:'REC-DEMO-0041', taskId:'CB-20260803-0041', title:'办公室桌面收纳盒(原型假数据)', collectedAt:'2026-08-03T12:24:09+08:00',
status:'WAITING_CONFIRMATION', spec:'透明 | L | 2 件', cap:'CNY 60.00', submission:'未建立',
note:'第一趟试选完成,规格面板证据已回传,等待管理员确认。', evidence:['商品页截图裁剪(内联假证据)','规格面板截图(内联假证据)'],
timeline:['12:22:31 打开商品','12:23:04 匹配透明 / L','12:23:38 读取规格面板单价','12:24:09 截图并退出商品页'],
result:'试选:透明 | L | CNY 24.00 | 2 件', resultNote:'这只是试选结果,不代表已下单。'
},
{
id:'REC-DEMO-0038', taskId:'CB-20260803-0038', title:'标签打印纸补货(原型假数据)', collectedAt:'2026-08-03T10:06:42+08:00',
status:'WAITING_PAYMENT', spec:'白色 | 50×30 | 1 件', cap:'CNY 50.00', submission:'SUB-DEMO-0038 · SUBMITTED',
note:'已明确创建待付款订单,自动化已停止,等待人核对后自行付款。', evidence:['订单确认页非敏感截图(内联假证据)'],
timeline:['10:04:22 重新选择同一规格','10:05:10 价格与数量复核通过','10:05:36 获得唯一围栏许可','10:06:42 明确创建待付款订单'],
result:'已创建待付款订单:白色 | 50×30 | CNY 46.00 | 1 件', resultNote:'系统只创建待付款订单,付款始终由人完成。'
},
{
id:'REC-DEMO-0032', taskId:'CB-20260803-0032', title:'仓库打包胶带(原型假数据)', collectedAt:'2026-08-03T09:18:05+08:00',
status:'NEEDS_MANUAL', spec:'透明 | 45mm | 4 件', cap:'CNY 55.00', submission:'未建立',
note:'商品页面未取得可信图片,授权价与当前规格面板价格不一致,已转人工。', evidence:[],
timeline:['09:16:40 打开商品','09:17:22 匹配透明 / 45mm','09:18:05 价格闸门二不通过,停止自动流程'],
result:'未采购:授权价 CNY 12.00 | 当前价 CNY 13.50', resultNote:'没有创建订单,也没有付款。'
}
].sort((a, b) => b.collectedAt.localeCompare(a.collectedAt));
const recordStatus = {
RECONCILIATION_REQUIRED:{label:'结果待核查',tone:'critical'},
WAITING_CONFIRMATION:{label:'等待确认',tone:'caution'},
WAITING_PAYMENT:{label:'待付款',tone:'success'},
NEEDS_MANUAL:{label:'转人工',tone:'caution'}
};
let currentRecordId = records[0]?.id || null;
let recordFocusReturn = null;
let workspaceMode = 'live';
let detailRequestId = 0;
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:'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 }
};
const sessionControls = {
not_ready:{label:'开始轮询',enabled:false,reason:'web 服务未就绪,不能领取任务。'},
version_mismatch:{label:'开始轮询',enabled:false,reason:'App 版本失配,必须先完成本项目真机取证。'},
idle:{label:'开始轮询',enabled:true,reason:'只启动普通轮询,不会直接执行下单。'},
polling:{label:'停止轮询',enabled:true,reason:'停止后不再领取新任务。'},
trial:{label:'停止轮询',enabled:false,reason:'真机步骤执行中,禁止硬停止。'},
trial_done:{label:'停止轮询',enabled:true,reason:'手机已退出商品页,可以安全停止轮询。'},
dry_run:{label:'停止轮询',enabled:false,reason:'只读演练执行中,禁止硬停止。'},
dry_run_ready:{label:'停止轮询',enabled:true,reason:'演练已退出确认页,可以安全停止轮询。'},
order_prefence:{label:'停止轮询',enabled:false,reason:'真实第二趟校验中,禁止硬停止。'},
fencing:{label:'停止轮询',enabled:false,reason:'围栏申请中,禁止关闭、取消或重复申请。'},
fence_failed:{label:'开始轮询',enabled:false,reason:'先核查围栏状态;不能用重新轮询绕过不明确结果。'},
fenced:{label:'停止轮询',enabled:false,reason:'已建立围栏,只能调和同一提交。'},
waiting_payment:{label:'开始轮询',enabled:false,reason:'先由人核对待付款订单并完成收口。'},
uncertain:{label:'开始轮询',enabled:false,reason:'订单结果不明确,只能核查同一提交。'},
security:{label:'开始轮询',enabled:false,reason:'安全校验已转人工,不允许自动继续。'},
payment_handoff:{label:'开始轮询',enabled:false,reason:'外部支付交接中,自动化保持停止。'},
manual:{label:'停止轮询',enabled:true,reason:'当前任务已移交,仍可安全停止普通轮询。'},
failed_stop:{label:'开始轮询',enabled:false,reason:'先到配置页修复连接;不会自动恢复轮询。'}
};
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 formatRecordTime(value) {
return value.slice(5, 16).replace('T', ' ');
}
function selectRecord(id, focus = false) {
currentRecordId = id;
let selectedRow = null;
for (const row of $('record-rows').rows) {
const selected = row.dataset.recordId === id;
row.setAttribute('aria-selected', String(selected));
row.tabIndex = selected ? 0 : -1;
if (selected) selectedRow = row;
if (selected && focus) row.focus();
}
$('view-record').disabled = !records.some((record) => record.id === id);
if (workspaceMode === 'record') {
recordFocusReturn = selectedRow || recordFocusReturn;
renderRecordDetail(id);
}
}
function renderRecords() {
const rows = records.map((record) => {
const status = recordStatus[record.status] || {label:record.status,tone:'info'};
const row = document.createElement('tr');
row.dataset.recordId = record.id;
row.setAttribute('aria-selected', 'false');
const titleCell = document.createElement('td');
const title = document.createElement('span'); title.className = 'record-title'; title.textContent = record.title;
const meta = document.createElement('span'); meta.className = 'record-meta'; meta.textContent = `${record.taskId} · ${formatRecordTime(record.collectedAt)}`;
titleCell.append(title, meta);
const statusCell = document.createElement('td');
const badge = document.createElement('span'); badge.className = 'record-status'; badge.dataset.tone = status.tone; badge.textContent = status.label;
statusCell.append(badge); row.append(titleCell, statusCell);
row.addEventListener('click', () => selectRecord(record.id));
row.addEventListener('dblclick', (event) => { if (event.target.closest('button,a,input,select')) return; selectRecord(record.id); openRecord(record.id, row); });
row.addEventListener('keydown', (event) => {
if (event.key === 'Enter') { event.preventDefault(); openRecord(record.id, row); return; }
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
event.preventDefault();
const rows = [...$('record-rows').rows]; const index = rows.indexOf(row); const delta = event.key === 'ArrowDown' ? 1 : -1;
const next = rows[Math.max(0, Math.min(rows.length - 1, index + delta))]; if (next) selectRecord(next.dataset.recordId, true);
}
if (event.key === 'F10' && event.shiftKey) { event.preventDefault(); showRecordMenu(record.id, row.getBoundingClientRect().left + 16, row.getBoundingClientRect().top + 24); }
});
row.addEventListener('contextmenu', (event) => { event.preventDefault(); selectRecord(record.id); showRecordMenu(record.id, event.clientX, event.clientY); });
return row;
});
$('record-rows').replaceChildren(...rows);
$('record-count').textContent = String(records.length);
$('record-empty').hidden = records.length > 0;
if (records.length) selectRecord(records.some((record) => record.id === currentRecordId) ? currentRecordId : records[0].id);
}
function showRecordMenu(id, x, y) {
currentRecordId = id;
const menu = $('record-menu'); menu.hidden = false;
menu.style.left = `${Math.min(x, innerWidth - 170)}px`; menu.style.top = `${Math.min(y, innerHeight - 54)}px`;
$('record-menu-open').focus();
}
function hideRecordMenu() { $('record-menu').hidden = true; }
function createEvidence(label) {
const figure = document.createElement('figure'); figure.className = 'evidence-card';
const svg = $('product-preview-image').cloneNode(true); svg.hidden = false; svg.removeAttribute('id'); svg.setAttribute('aria-label', `${label},原型内联假证据`);
const caption = document.createElement('figcaption'); caption.textContent = `${label} · 不访问真实拼多多`;
figure.append(svg, caption); return figure;
}
function showDetailFeedback(title, text, retry = false) {
$('detail-feedback-title').textContent = title;
$('detail-feedback-text').textContent = text;
$('retry-detail').hidden = !retry;
$('detail-feedback').hidden = false;
$('detail-content').hidden = true;
}
async function renderRecordDetail(id) {
const requestId = ++detailRequestId;
const prototypeState = $('detail-state').value;
const record = records.find((item) => item.id === id);
$('record-detail-pane').setAttribute('aria-busy', String(prototypeState === 'loading' || prototypeState === 'normal'));
if (prototypeState === 'loading') {
showDetailFeedback('正在读取执行记录', `正在按稳定记录 ID ${id} 读取文字、图片索引和采购结果。`);
return;
}
if (prototypeState === 'error') {
showDetailFeedback('执行记录加载失败', '当前任务与右侧列表不受影响。可安全重试读取同一记录,不会重新执行采购。', true);
$('record-detail-pane').setAttribute('aria-busy', 'false');
return;
}
if (prototypeState === 'missing' || !record) {
showDetailFeedback('执行记录已不存在', '记录可能已归档或当前账号无权查看。请返回当前任务;不会用其他记录补齐内容。');
$('record-detail-pane').setAttribute('aria-busy', 'false');
return;
}
showDetailFeedback('正在读取执行记录', `正在加载 ${record.taskId} 的已保存证据。`);
await Promise.resolve();
if (requestId !== detailRequestId || workspaceMode !== 'record' || currentRecordId !== id) return;
const status = recordStatus[record.status] || {label:record.status};
$('record-detail-title').textContent = record.title;
$('record-detail-meta').textContent = `${record.taskId} · ${formatRecordTime(record.collectedAt)} · ${status.label}`;
$('detail-task-id').textContent = record.taskId; $('detail-product').textContent = record.title; $('detail-spec').textContent = record.spec;
$('detail-cap').textContent = record.cap; $('detail-submission').textContent = record.submission; $('detail-note').textContent = record.note;
$('detail-timeline').replaceChildren(...record.timeline.map((value) => { const li = document.createElement('li'); li.textContent = value; return li; }));
if (record.evidence.length) $('detail-evidence').replaceChildren(...record.evidence.map(createEvidence));
else { const empty = document.createElement('div'); empty.className = 'preview-empty'; const strong = document.createElement('strong'); strong.textContent = '暂无可信图片'; empty.append(strong, '记录保留原始文字,不用其他图片凑合。'); $('detail-evidence').replaceChildren(empty); }
$('detail-result').textContent = record.result; $('detail-result-note').textContent = record.resultNote;
$('detail-feedback').hidden = true; $('detail-content').hidden = false;
$('record-detail-pane').setAttribute('aria-busy', 'false');
}
function openRecord(id, source) {
const record = records.find((item) => item.id === id); if (!record) return;
hideRecordMenu(); currentRecordId = id;
recordFocusReturn = source || [...$('record-rows').rows].find((row) => row.dataset.recordId === id) || $('view-record');
workspaceMode = 'record'; $('workspace').dataset.mode = 'record';
$('live-workspace').hidden = true; $('record-detail-pane').hidden = false;
renderRecordDetail(id);
$('return-live').focus();
$('announcer').textContent = `已在左侧打开 ${record.title} 的执行记录详情。按 Escape 或返回当前任务按钮返回。`;
}
function showLiveWorkspace() {
if (workspaceMode !== 'record') return;
detailRequestId += 1; workspaceMode = 'live'; $('workspace').dataset.mode = 'live';
$('record-detail-pane').hidden = true; $('live-workspace').hidden = false;
const currentRow = [...$('record-rows').rows].find((row) => row.dataset.recordId === currentRecordId);
const focusTarget = currentRow || (recordFocusReturn?.isConnected ? recordFocusReturn : $('view-record'));
focusTarget?.focus();
$('announcer').textContent = '已返回当前任务;执行记录选择和列表位置已保留。';
}
function render(key, announce = true) {
const s = scenarios[key];
const sessionControl = sessionControls[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 || '未建立';
$('preview-empty').hidden = hasTask; $('product-preview-image').hidden = !hasTask;
$('preview-source').textContent = hasTask ? '来源:商品页截图裁剪(内联假证据)' : '来源:尚无可信图片';
$('preview-time').textContent = hasTask ? '采集时间:2026-08-03 13:51(假数据)' : '采集时间:—';
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;
$('live-task-state').textContent = `实时:${s.session} · ${s.step}`; $('live-task-state').dataset.tone = s.tone;
$('primary-action').textContent = sessionControl.label; $('primary-action').disabled = !sessionControl.enabled;
$('action-reason').textContent = sessionControl.reason;
$('context-action').hidden = !s.safeAction; $('context-action').textContent = s.safeAction ? s.action : '';
$('context-action').dataset.safeAction = s.safeAction || '';
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(['采购工具原型假诊断摘要\n无真实任务、无凭据、无页面全文。\n'], {type:'text/plain;charset=utf-8'});
const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = 'purchase-tool-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', () => { showLiveWorkspace(); $('scenario').value = 'idle'; $('detail-state').value = 'normal'; $('logs').replaceChildren(); currentRecordId = records[0]?.id || null; renderRecords(); render('idle'); appendLog('状态', '假数据已复位。'); });
$('primary-action').addEventListener('click', () => {
const next = $('scenario').value === 'idle' ? 'polling' : 'idle'; $('scenario').value = next; render(next);
});
$('context-action').addEventListener('click', () => {
const action = $('context-action').dataset.safeAction;
if (action === 'settings') { location.href = 'desk-device-settings.html'; return; }
if (action === 'export') { exportDemo(); return; }
if (action === 'paymentInfo') { showDialog('人工付款边界', '自动化已经停止。本系统不会点击支付、免密支付、先用后付或任何扣款控件;订单状态需要人核查。'); return; }
showDialog('只核查同一提交记录', '围栏后只能围绕 SUB-DEMO-0042 调和。这里没有重新领取、放弃授权、重新申请围栏或再次提交的动作。');
});
$('view-record').addEventListener('click', () => openRecord(currentRecordId, [...$('record-rows').rows].find((row) => row.dataset.recordId === currentRecordId)));
$('record-menu-open').addEventListener('click', () => openRecord(currentRecordId, [...$('record-rows').rows].find((row) => row.dataset.recordId === currentRecordId)));
$('return-live').addEventListener('click', showLiveWorkspace);
$('detail-state').addEventListener('change', () => { if (workspaceMode === 'record') renderRecordDetail(currentRecordId); });
$('retry-detail').addEventListener('click', () => { $('detail-state').value = 'normal'; renderRecordDetail(currentRecordId); });
document.addEventListener('pointerdown', (event) => { if (!$('record-menu').hidden && !$('record-menu').contains(event.target)) hideRecordMenu(); });
document.addEventListener('keydown', (event) => {
if (event.key !== 'Escape') return;
if (!$('record-menu').hidden) { event.preventDefault(); hideRecordMenu(); return; }
if ($('info-dialog').open) return;
if (workspaceMode === 'record') { event.preventDefault(); showLiveWorkspace(); }
});
window.addEventListener('resize', hideRecordMenu);
$('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(); });
renderRecords(); render('idle', false); appendLog('状态', '原型已加载;未连接任何服务或设备。');
})();
</script>
</body>
</html>