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

902 lines
66 KiB
HTML
Raw 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="claimed">单趟执行 · 已领取</option>
<option value="selecting">单趟执行 · 选择规格</option>
<option value="gate1">单趟执行 · 闸门一</option>
<option value="quantity_gate2">单趟执行 · 数量与闸门二</option>
<option value="confirm_gate3">单趟执行 · 确认页与闸门三</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="gate1-price">—</dd></div>
<div><dt>闸门二面板总额</dt><dd id="gate2-total">—</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 件', gate1Price: 'CNY 32.50', gate2Total: 'CNY 65.00', 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:'ORDERING', spec:'透明 | L | 2 件', cap:'CNY 60.00', submission:'未建立',
note:'管理员已开始采购,当前单趟已完成规格与闸门一,正在设置并复核数量。', evidence:['商品页截图(内联假证据)','规格面板原始截图(内联假证据)'],
timeline:['12:22:31 领取一次性授权','12:23:04 精确匹配透明 / L','12:23:38 闸门一读取 CNY 24.00','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 Gate1 单价、Gate2 面板总额与 Gate3 确认金额通过','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 58.00 > 上限 CNY 55.00', resultNote:'没有建立围栏、创建订单或付款。'
}
].sort((a, b) => b.collectedAt.localeCompare(a.collectedAt));
const recordStatus = {
RECONCILIATION_REQUIRED:{label:'结果待核查',tone:'critical'},
ORDERING:{label:'采购执行中',tone:'info'},
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: [],
single: ['核对一次性授权并打开商品', '精确选择颜色分类与尺码', '闸门一:读取 SKU 单价并校验上限', '设置数量、读回并读取闸门二面板总额', '确认页执行闸门三同额与控件唯一校验', '申请服务端提交围栏', '首次许可后持久化并内部点击一次', '调和同一提交结果']
};
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 },
claimed: { tone:'info', title:'已领取管理员授权的采购任务', text:'任务字段与最高总价已锁定;当前只准备打开目标商品,尚未建立提交围栏。', web:'ok', device:'ok', version:'ok', leg:'单趟 · 已领取', legTone:'info', action:'真机步骤执行中', enabled:false, reason:'当前任务已开始,停止轮询只应阻止下一次领取,不能硬取消本任务。', session:'运行中', step:'核对授权与商品', kind:'single', active:0, countdown:'暂停', fail:'0 / 3', lease:'04:48' },
selecting: { tone:'info', title:'正在精确选择颜色与尺码', text:'只在已取证维度容器内精确唯一匹配并读回;缺失、重复或版本变化立即停止。', web:'ok', device:'ok', version:'ok', leg:'单趟 · 选择规格', legTone:'info', action:'真机步骤执行中', enabled:false, reason:'当前受控步骤不能被硬取消。', session:'运行中', step:'精确选择规格', kind:'single', active:1, countdown:'暂停', fail:'0 / 3', lease:'04:18' },
gate1: { tone:'info', title:'闸门一:读取 SKU 单价', text:'当前规格单价必须唯一可读,单价乘数量不得超过管理员授权的最高总价。', web:'ok', device:'ok', version:'ok', leg:'单趟 · 闸门一', legTone:'info', action:'安全校验进行中', enabled:false, reason:'价格不可读或超上限时立即停止。', session:'运行中', step:'闸门一:读取 SKU 单价', kind:'single', active:2, countdown:'暂停', fail:'0 / 3', lease:'03:58' },
quantity_gate2: { tone:'info', title:'数量复核与闸门二', text:'数量必须读回为 2;目标规格不能漂移,面板总额 CNY 65.00 必须唯一可读且不超过 CNY 80.00。', web:'ok', device:'ok', version:'ok', leg:'单趟 · 闸门二', legTone:'info', action:'安全校验进行中', enabled:false, reason:'数量、规格、总额歧义或超限都停止。', session:'运行中', step:'数量读回与闸门二', kind:'single', active:3, countdown:'暂停', fail:'0 / 3', lease:'03:31' },
confirm_gate3: { tone:'caution', title:'确认页与闸门三', text:'正在核对规格、数量、应付总额严格等于闸门二面板总额,并确认提交控件唯一;此时没有点击许可。', web:'ok', device:'ok', version:'ok', leg:'单趟 · 闸门三', legTone:'caution', action:'安全校验进行中', enabled:false, reason:'没有服务端围栏许可,绝不能点击提交订单。', session:'运行中', step:'闸门三:核对应付总额', kind:'single', active:4, countdown:'暂停', fail:'0 / 3', lease:'02:58' },
fencing: { tone:'caution', title:'正在申请服务端提交围栏', text:'等待首次明确的 click_permitted。超时、冲突、网络失败或响应不明都不会点击。', web:'ok', device:'ok', version:'ok', leg:'单趟 · 申请围栏', legTone:'caution', action:'等待围栏响应', enabled:false, reason:'申请中禁止取消、关闭或换 key 重复申请。', session:'运行中', step:'申请唯一提交围栏', kind:'single', active:5, countdown:'暂停', fail:'0 / 3', lease:'02:31' },
fence_failed: { tone:'critical', title:'未获得首次明确点击许可', text:'围栏失败或响应不明:没有点击提交订单。只核查同一幂等键,不能直接重试。', web:'bad', device:'ok', version:'ok', leg:'单趟 · 未获许可', legTone:'critical', action:'打开围栏核查说明', enabled:true, safeAction:'review', reason:'只核查围栏状态,不触发提交。', session:'已停止', step:'核查幂等键', kind:'single', active:5, countdown:'—', fail:'1 / 3', submission:'围栏状态未知 · 幂等键 DEMO-FENCE-042' },
fenced: { tone:'caution', title:'围栏已建立,只核对同一提交', text:'submission_id 已固定。内部流程至多点击一次;界面不提供提交、重领、放弃或重试。', web:'ok', device:'ok', version:'ok', leg:'单趟 · 已围栏', legTone:'caution', action:'打开同一提交核查说明', enabled:true, safeAction:'review', reason:'只能查看与调和同一提交记录。', session:'受控执行', step:'调和订单结果', kind:'single', active:7, 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:'single', active:8, 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:'single', active:7, 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:'single', active:7, 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:'single', active:7, countdown:'—', fail:'0 / 3', submission:'SUB-DEMO-0042 · HANDED_OFF' },
manual: { tone:'caution', title:'任务已转人工:闸门二总额超限', text:'目标数量面板总额 CNY 85.00 超过最高总价 CNY 80.00。围栏未建立,没有创建订单;需管理员核查后重置。', web:'ok', device:'ok', version:'ok', leg:'NEEDS_MANUAL', legTone:'caution', action:'导出假诊断摘要', enabled:true, safeAction:'export', reason:'导出只生成本地假文本,不含完整 XML、页面全文或敏感字段。', session:'继续轮询其他任务', step:'当前任务已移交', kind:'single', active:3, 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:'停止后不再领取新任务。'},
claimed:{label:'停止轮询',enabled:false,reason:'当前任务已领取;停止只应阻止下次领取,不能硬取消本任务。'},
selecting:{label:'停止轮询',enabled:false,reason:'受控真机步骤执行中,禁止硬停止。'},
gate1:{label:'停止轮询',enabled:false,reason:'价格闸门执行中,禁止硬停止。'},
quantity_gate2:{label:'停止轮询',enabled:false,reason:'数量与闸门二执行中,禁止硬停止。'},
confirm_gate3:{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 : '—';
$('gate1-price').textContent = hasTask && ['gate1','quantity_gate2','confirm_gate3','fencing','fence_failed','fenced','waiting_payment','uncertain','security','payment_handoff','manual'].includes($('scenario').value) ? task.gate1Price : '—';
$('gate2-total').textContent = hasTask && ['quantity_gate2','confirm_gate3','fencing','fence_failed','fenced','waiting_payment','uncertain','security','payment_handoff'].includes($('scenario').value) ? task.gate2Total : '—';
$('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 = ['claimed','selecting','gate1','quantity_gate2','confirm_gate3','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>