1406 lines
43 KiB
HTML
1406 lines
43 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="zh-CN">
|
||
|
||
<head>
|
||
<meta charset="UTF-8" />
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||
<title>AutoFlow | 自动化平台</title>
|
||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||
<link
|
||
href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap"
|
||
rel="stylesheet" />
|
||
<style>
|
||
/* ── Design Tokens ──────────────────────────────────────── */
|
||
:root {
|
||
--bg: #0d0f14;
|
||
--surface: #141720;
|
||
--surface2: #1c2030;
|
||
--surface3: #252a3a;
|
||
--border: #2a3048;
|
||
--accent: #6c72ff;
|
||
--accent2: #38e0b0;
|
||
--danger: #ff5c6c;
|
||
--warn: #ffb547;
|
||
--success: #38e0b0;
|
||
--text: #e6e9f4;
|
||
--text-muted: #7a82a0;
|
||
--sidebar-w: 240px;
|
||
--radius: 12px;
|
||
}
|
||
|
||
* {
|
||
box-sizing: border-box;
|
||
margin: 0;
|
||
padding: 0;
|
||
}
|
||
|
||
body {
|
||
font-family: 'Inter', sans-serif;
|
||
background: var(--bg);
|
||
color: var(--text);
|
||
min-height: 100vh;
|
||
display: flex;
|
||
overflow: hidden;
|
||
}
|
||
|
||
/* ── Sidebar ────────────────────────────────────────────── */
|
||
#sidebar {
|
||
width: var(--sidebar-w);
|
||
min-height: 100vh;
|
||
background: var(--surface);
|
||
border-right: 1px solid var(--border);
|
||
display: flex;
|
||
flex-direction: column;
|
||
padding: 20px 0;
|
||
position: fixed;
|
||
left: 0;
|
||
top: 0;
|
||
bottom: 0;
|
||
z-index: 100;
|
||
}
|
||
|
||
.logo {
|
||
padding: 0 20px 24px;
|
||
border-bottom: 1px solid var(--border);
|
||
}
|
||
|
||
.logo h1 {
|
||
font-size: 18px;
|
||
font-weight: 700;
|
||
background: linear-gradient(135deg, var(--accent), var(--accent2));
|
||
-webkit-background-clip: text;
|
||
-webkit-text-fill-color: transparent;
|
||
}
|
||
|
||
.logo span {
|
||
font-size: 11px;
|
||
color: var(--text-muted);
|
||
letter-spacing: 1px;
|
||
text-transform: uppercase;
|
||
}
|
||
|
||
.nav-section {
|
||
padding: 20px 12px 8px;
|
||
}
|
||
|
||
.nav-section-title {
|
||
font-size: 10px;
|
||
color: var(--text-muted);
|
||
text-transform: uppercase;
|
||
letter-spacing: 1.5px;
|
||
padding: 0 8px 8px;
|
||
}
|
||
|
||
.nav-item {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
padding: 10px 12px;
|
||
border-radius: 8px;
|
||
cursor: pointer;
|
||
font-size: 13.5px;
|
||
font-weight: 500;
|
||
color: var(--text-muted);
|
||
transition: all 0.2s;
|
||
margin-bottom: 2px;
|
||
user-select: none;
|
||
}
|
||
|
||
.nav-item:hover {
|
||
background: var(--surface3);
|
||
color: var(--text);
|
||
}
|
||
|
||
.nav-item.active {
|
||
background: rgba(108, 114, 255, 0.12);
|
||
color: var(--accent);
|
||
}
|
||
|
||
.nav-item .icon {
|
||
font-size: 16px;
|
||
width: 20px;
|
||
text-align: center;
|
||
}
|
||
|
||
.nav-badge {
|
||
margin-left: auto;
|
||
background: var(--accent);
|
||
color: #fff;
|
||
border-radius: 10px;
|
||
font-size: 10px;
|
||
padding: 1px 7px;
|
||
min-width: 18px;
|
||
text-align: center;
|
||
}
|
||
|
||
.sidebar-footer {
|
||
margin-top: auto;
|
||
padding: 20px 12px;
|
||
border-top: 1px solid var(--border);
|
||
}
|
||
|
||
.status-dot {
|
||
width: 8px;
|
||
height: 8px;
|
||
border-radius: 50%;
|
||
background: var(--accent2);
|
||
box-shadow: 0 0 6px var(--accent2);
|
||
display: inline-block;
|
||
margin-right: 8px;
|
||
animation: pulse 2s infinite;
|
||
}
|
||
|
||
@keyframes pulse {
|
||
|
||
0%,
|
||
100% {
|
||
opacity: 1
|
||
}
|
||
|
||
50% {
|
||
opacity: .4
|
||
}
|
||
}
|
||
|
||
/* ── Main Layout ────────────────────────────────────────── */
|
||
#main {
|
||
margin-left: var(--sidebar-w);
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
height: 100vh;
|
||
overflow: hidden;
|
||
}
|
||
|
||
#topbar {
|
||
padding: 20px 28px;
|
||
border-bottom: 1px solid var(--border);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
background: var(--surface);
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
#topbar h2 {
|
||
font-size: 18px;
|
||
font-weight: 600;
|
||
}
|
||
|
||
#topbar .sub {
|
||
font-size: 12px;
|
||
color: var(--text-muted);
|
||
margin-top: 2px;
|
||
}
|
||
|
||
.btn {
|
||
padding: 9px 18px;
|
||
border-radius: 8px;
|
||
border: none;
|
||
cursor: pointer;
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
transition: all 0.2s;
|
||
font-family: 'Inter', sans-serif;
|
||
}
|
||
|
||
.btn-primary {
|
||
background: linear-gradient(135deg, var(--accent), #4f56e8);
|
||
color: #fff;
|
||
box-shadow: 0 4px 15px rgba(108, 114, 255, 0.3);
|
||
}
|
||
|
||
.btn-primary:hover {
|
||
transform: translateY(-1px);
|
||
box-shadow: 0 6px 20px rgba(108, 114, 255, 0.45);
|
||
}
|
||
|
||
.btn-ghost {
|
||
background: var(--surface3);
|
||
color: var(--text-muted);
|
||
border: 1px solid var(--border);
|
||
}
|
||
|
||
.btn-ghost:hover {
|
||
color: var(--text);
|
||
border-color: var(--accent);
|
||
}
|
||
|
||
.btn-danger {
|
||
background: rgba(255, 92, 108, 0.12);
|
||
color: var(--danger);
|
||
border: 1px solid rgba(255, 92, 108, 0.3);
|
||
}
|
||
|
||
#content {
|
||
flex: 1;
|
||
overflow-y: auto;
|
||
padding: 24px 28px;
|
||
}
|
||
|
||
/* ── Cards / Grid ───────────────────────────────────────── */
|
||
.stats-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(4, 1fr);
|
||
gap: 16px;
|
||
margin-bottom: 24px;
|
||
}
|
||
|
||
.stat-card {
|
||
background: var(--surface);
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius);
|
||
padding: 20px;
|
||
position: relative;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.stat-card::before {
|
||
content: '';
|
||
position: absolute;
|
||
top: 0;
|
||
left: 0;
|
||
right: 0;
|
||
height: 2px;
|
||
background: var(--accent-grad, linear-gradient(90deg, var(--accent), var(--accent2)));
|
||
}
|
||
|
||
.stat-label {
|
||
font-size: 11px;
|
||
color: var(--text-muted);
|
||
text-transform: uppercase;
|
||
letter-spacing: 1px;
|
||
}
|
||
|
||
.stat-value {
|
||
font-size: 32px;
|
||
font-weight: 700;
|
||
margin: 8px 0 4px;
|
||
}
|
||
|
||
.stat-meta {
|
||
font-size: 12px;
|
||
color: var(--text-muted);
|
||
}
|
||
|
||
/* ── Task Table ─────────────────────────────────────────── */
|
||
.panel {
|
||
background: var(--surface);
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius);
|
||
overflow: hidden;
|
||
}
|
||
|
||
.panel-header {
|
||
padding: 16px 20px;
|
||
border-bottom: 1px solid var(--border);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
}
|
||
|
||
.panel-title {
|
||
font-size: 14px;
|
||
font-weight: 600;
|
||
}
|
||
|
||
table {
|
||
width: 100%;
|
||
border-collapse: collapse;
|
||
}
|
||
|
||
th {
|
||
text-align: left;
|
||
padding: 12px 20px;
|
||
font-size: 11px;
|
||
color: var(--text-muted);
|
||
text-transform: uppercase;
|
||
letter-spacing: 1px;
|
||
border-bottom: 1px solid var(--border);
|
||
font-weight: 500;
|
||
}
|
||
|
||
td {
|
||
padding: 14px 20px;
|
||
font-size: 13px;
|
||
border-bottom: 1px solid rgba(42, 48, 72, 0.5);
|
||
vertical-align: middle;
|
||
}
|
||
|
||
tr:last-child td {
|
||
border-bottom: none;
|
||
}
|
||
|
||
tr:hover td {
|
||
background: rgba(255, 255, 255, 0.02);
|
||
}
|
||
|
||
.status-badge {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 5px;
|
||
padding: 3px 10px;
|
||
border-radius: 20px;
|
||
font-size: 11px;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.status-badge.running {
|
||
background: rgba(108, 114, 255, 0.15);
|
||
color: var(--accent);
|
||
}
|
||
|
||
.status-badge.pass {
|
||
background: rgba(56, 224, 176, 0.12);
|
||
color: var(--accent2);
|
||
}
|
||
|
||
.status-badge.fail {
|
||
background: rgba(255, 92, 108, 0.12);
|
||
color: var(--danger);
|
||
}
|
||
|
||
.status-badge.pending {
|
||
background: rgba(122, 130, 160, 0.12);
|
||
color: var(--text-muted);
|
||
}
|
||
|
||
.spin {
|
||
display: inline-block;
|
||
width: 8px;
|
||
height: 8px;
|
||
border: 2px solid transparent;
|
||
border-top-color: currentColor;
|
||
border-radius: 50%;
|
||
animation: spin 0.8s linear infinite;
|
||
}
|
||
|
||
@keyframes spin {
|
||
to {
|
||
transform: rotate(360deg);
|
||
}
|
||
}
|
||
|
||
.action-btn {
|
||
padding: 5px 12px;
|
||
border-radius: 6px;
|
||
border: 1px solid var(--border);
|
||
background: transparent;
|
||
color: var(--text-muted);
|
||
font-size: 12px;
|
||
cursor: pointer;
|
||
transition: all .2s;
|
||
}
|
||
|
||
.action-btn:hover {
|
||
border-color: var(--accent);
|
||
color: var(--accent);
|
||
}
|
||
|
||
/* ── Modal ──────────────────────────────────────────────── */
|
||
.modal-overlay {
|
||
position: fixed;
|
||
inset: 0;
|
||
background: rgba(0, 0, 0, 0.65);
|
||
backdrop-filter: blur(4px);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
z-index: 999;
|
||
opacity: 0;
|
||
pointer-events: none;
|
||
transition: opacity .25s;
|
||
}
|
||
|
||
.modal-overlay.open {
|
||
opacity: 1;
|
||
pointer-events: all;
|
||
}
|
||
|
||
.modal {
|
||
background: var(--surface2);
|
||
border: 1px solid var(--border);
|
||
border-radius: 16px;
|
||
width: 520px;
|
||
max-h: 90vh;
|
||
overflow-y: auto;
|
||
box-shadow: 0 24px 60px rgba(0, 0, 0, .5);
|
||
transform: translateY(20px);
|
||
transition: transform .25s;
|
||
}
|
||
|
||
.modal-overlay.open .modal {
|
||
transform: translateY(0);
|
||
}
|
||
|
||
.modal-header {
|
||
padding: 24px 24px 0;
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: flex-start;
|
||
}
|
||
|
||
.modal-header h3 {
|
||
font-size: 18px;
|
||
font-weight: 700;
|
||
}
|
||
|
||
.modal-header p {
|
||
font-size: 12px;
|
||
color: var(--text-muted);
|
||
margin-top: 4px;
|
||
}
|
||
|
||
.modal-close {
|
||
background: var(--surface3);
|
||
border: none;
|
||
color: var(--text-muted);
|
||
width: 30px;
|
||
height: 30px;
|
||
border-radius: 8px;
|
||
cursor: pointer;
|
||
font-size: 16px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
transition: all .2s;
|
||
}
|
||
|
||
.modal-close:hover {
|
||
color: var(--text);
|
||
background: var(--border);
|
||
}
|
||
|
||
.modal-body {
|
||
padding: 24px;
|
||
}
|
||
|
||
.form-group {
|
||
margin-bottom: 18px;
|
||
}
|
||
|
||
.form-label {
|
||
font-size: 12px;
|
||
color: var(--text-muted);
|
||
font-weight: 500;
|
||
margin-bottom: 8px;
|
||
display: block;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.5px;
|
||
}
|
||
|
||
.form-input {
|
||
width: 100%;
|
||
background: var(--surface3);
|
||
border: 1px solid var(--border);
|
||
border-radius: 8px;
|
||
color: var(--text);
|
||
padding: 10px 14px;
|
||
font-size: 13.5px;
|
||
font-family: 'Inter', sans-serif;
|
||
outline: none;
|
||
transition: border .2s;
|
||
}
|
||
|
||
.form-input:focus {
|
||
border-color: var(--accent);
|
||
}
|
||
|
||
.form-input[type=password] {
|
||
font-family: 'JetBrains Mono', monospace;
|
||
letter-spacing: 2px;
|
||
}
|
||
|
||
.product-grid {
|
||
display: grid;
|
||
grid-template-columns: 1fr 1fr;
|
||
gap: 10px;
|
||
}
|
||
|
||
.product-card {
|
||
border: 2px solid var(--border);
|
||
border-radius: 10px;
|
||
padding: 14px;
|
||
cursor: pointer;
|
||
transition: all .2s;
|
||
background: var(--surface3);
|
||
}
|
||
|
||
.product-card:hover {
|
||
border-color: var(--accent);
|
||
}
|
||
|
||
.product-card.selected {
|
||
border-color: var(--accent);
|
||
background: rgba(108, 114, 255, 0.1);
|
||
}
|
||
|
||
.product-card .p-icon {
|
||
font-size: 24px;
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
.product-card .p-name {
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.product-card .p-desc {
|
||
font-size: 11px;
|
||
color: var(--text-muted);
|
||
margin-top: 4px;
|
||
line-height: 1.4;
|
||
}
|
||
|
||
.form-row {
|
||
display: grid;
|
||
grid-template-columns: 1fr 1fr;
|
||
gap: 14px;
|
||
}
|
||
|
||
.toggle {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
background: var(--surface3);
|
||
border: 1px solid var(--border);
|
||
border-radius: 8px;
|
||
padding: 12px 14px;
|
||
}
|
||
|
||
.toggle-label {
|
||
font-size: 13px;
|
||
}
|
||
|
||
.toggle-sub {
|
||
font-size: 11px;
|
||
color: var(--text-muted);
|
||
}
|
||
|
||
.switch {
|
||
position: relative;
|
||
width: 40px;
|
||
height: 22px;
|
||
}
|
||
|
||
.switch input {
|
||
opacity: 0;
|
||
width: 0;
|
||
height: 0;
|
||
}
|
||
|
||
.slider {
|
||
position: absolute;
|
||
inset: 0;
|
||
background: var(--surface2);
|
||
border-radius: 22px;
|
||
cursor: pointer;
|
||
transition: .3s;
|
||
border: 1px solid var(--border);
|
||
}
|
||
|
||
.slider:before {
|
||
content: '';
|
||
position: absolute;
|
||
width: 16px;
|
||
height: 16px;
|
||
border-radius: 50%;
|
||
background: var(--text-muted);
|
||
left: 2px;
|
||
top: 2px;
|
||
transition: .3s;
|
||
}
|
||
|
||
input:checked+.slider {
|
||
background: rgba(108, 114, 255, 0.3);
|
||
border-color: var(--accent);
|
||
}
|
||
|
||
input:checked+.slider:before {
|
||
transform: translateX(18px);
|
||
background: var(--accent);
|
||
}
|
||
|
||
.modal-footer {
|
||
padding: 0 24px 24px;
|
||
display: flex;
|
||
gap: 10px;
|
||
justify-content: flex-end;
|
||
}
|
||
|
||
.pwd-toggle {
|
||
position: absolute;
|
||
right: 12px;
|
||
top: 34px;
|
||
cursor: pointer;
|
||
font-size: 16px;
|
||
opacity: 0.5;
|
||
transition: opacity .2s;
|
||
user-select: none;
|
||
}
|
||
|
||
.pwd-toggle:hover {
|
||
opacity: 1;
|
||
}
|
||
|
||
/* ── Log Viewer ─────────────────────────────────────────── */
|
||
.log-viewer-modal .modal {
|
||
width: 800px;
|
||
}
|
||
|
||
.log-box {
|
||
background: #0a0c10;
|
||
border: 1px solid var(--border);
|
||
border-radius: 8px;
|
||
padding: 16px;
|
||
height: 360px;
|
||
overflow-y: auto;
|
||
font-family: 'JetBrains Mono', monospace;
|
||
font-size: 12px;
|
||
line-height: 1.7;
|
||
}
|
||
|
||
.log-line {
|
||
padding: 1px 0;
|
||
}
|
||
|
||
.log-line .ts {
|
||
color: #4a5270;
|
||
margin-right: 8px;
|
||
}
|
||
|
||
.log-line.INFO .level {
|
||
color: #6c72ff;
|
||
}
|
||
|
||
.log-line.SUCCESS .level {
|
||
color: #38e0b0;
|
||
}
|
||
|
||
.log-line.WARN .level {
|
||
color: #ffb547;
|
||
}
|
||
|
||
.log-line.ERROR .level {
|
||
color: #ff5c6c;
|
||
}
|
||
|
||
.log-line.INFO .msg {
|
||
color: #b4bcd8;
|
||
}
|
||
|
||
.log-line.SUCCESS .msg {
|
||
color: #38e0b0;
|
||
}
|
||
|
||
.log-line.WARN .msg {
|
||
color: #ffb547;
|
||
}
|
||
|
||
.log-line.ERROR .msg {
|
||
color: #ff5c6c;
|
||
}
|
||
|
||
.log-status {
|
||
padding: 12px 20px;
|
||
border-top: 1px solid var(--border);
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
background: var(--surface2);
|
||
}
|
||
|
||
/* ── Report ─────────────────────────────────────────────── */
|
||
.report-card {
|
||
background: var(--surface);
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius);
|
||
padding: 20px;
|
||
margin-bottom: 12px;
|
||
display: grid;
|
||
grid-template-columns: 1fr auto;
|
||
gap: 16px;
|
||
align-items: center;
|
||
}
|
||
|
||
.report-result {
|
||
font-size: 28px;
|
||
font-weight: 700;
|
||
}
|
||
|
||
.report-result.PASS {
|
||
color: var(--accent2);
|
||
}
|
||
|
||
.report-result.FAIL {
|
||
color: var(--danger);
|
||
}
|
||
|
||
.report-meta {
|
||
font-size: 12px;
|
||
color: var(--text-muted);
|
||
display: flex;
|
||
gap: 16px;
|
||
flex-wrap: wrap;
|
||
margin-top: 8px;
|
||
}
|
||
|
||
.report-meta span {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
}
|
||
|
||
.progress-bar {
|
||
height: 4px;
|
||
background: var(--surface3);
|
||
border-radius: 4px;
|
||
overflow: hidden;
|
||
margin-top: 12px;
|
||
}
|
||
|
||
.progress-fill {
|
||
height: 100%;
|
||
border-radius: 4px;
|
||
transition: width .5s;
|
||
}
|
||
|
||
/* ── Products Page ──────────────────────────────────────── */
|
||
.product-list {
|
||
display: grid;
|
||
grid-template-columns: repeat(2, 1fr);
|
||
gap: 16px;
|
||
}
|
||
|
||
.product-item {
|
||
background: var(--surface);
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius);
|
||
padding: 24px;
|
||
cursor: pointer;
|
||
transition: all .2s;
|
||
}
|
||
|
||
.product-item:hover {
|
||
border-color: var(--accent);
|
||
transform: translateY(-2px);
|
||
box-shadow: 0 8px 24px rgba(108, 114, 255, .15);
|
||
}
|
||
|
||
.product-item .big-icon {
|
||
font-size: 40px;
|
||
margin-bottom: 12px;
|
||
}
|
||
|
||
.product-item h3 {
|
||
font-size: 16px;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.product-item p {
|
||
font-size: 13px;
|
||
color: var(--text-muted);
|
||
margin-top: 6px;
|
||
line-height: 1.5;
|
||
}
|
||
|
||
/* ── Empty State ────────────────────────────────────────── */
|
||
.empty {
|
||
text-align: center;
|
||
padding: 60px 20px;
|
||
color: var(--text-muted);
|
||
}
|
||
|
||
.empty .e-icon {
|
||
font-size: 48px;
|
||
margin-bottom: 16px;
|
||
opacity: .4;
|
||
}
|
||
|
||
.empty h3 {
|
||
font-size: 16px;
|
||
color: var(--text);
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
.empty p {
|
||
font-size: 13px;
|
||
}
|
||
|
||
/* scrollbar */
|
||
::-webkit-scrollbar {
|
||
width: 6px;
|
||
height: 6px;
|
||
}
|
||
|
||
::-webkit-scrollbar-track {
|
||
background: transparent;
|
||
}
|
||
|
||
::-webkit-scrollbar-thumb {
|
||
background: var(--border);
|
||
border-radius: 6px;
|
||
}
|
||
</style>
|
||
</head>
|
||
|
||
<body>
|
||
|
||
<!-- ── Sidebar ─────────────────────────────────────────────────────────────── -->
|
||
<nav id="sidebar">
|
||
<div class="logo">
|
||
<h1>AutoFlow</h1>
|
||
<span>自动化平台</span>
|
||
</div>
|
||
|
||
<div class="nav-section">
|
||
<div class="nav-section-title">核心功能</div>
|
||
<div class="nav-item active" data-page="tasks" onclick="nav(this,'tasks')">
|
||
<span class="icon">⚡</span> 自动化任务
|
||
<span class="nav-badge" id="badge-tasks">0</span>
|
||
</div>
|
||
<div class="nav-item" data-page="reports" onclick="nav(this,'reports')">
|
||
<span class="icon">📊</span> 测试报告
|
||
<span class="nav-badge" id="badge-reports" style="background:var(--accent2)">0</span>
|
||
</div>
|
||
<div class="nav-item" data-page="stats" onclick="nav(this,'stats')">
|
||
<span class="icon">📈</span> 数据看板
|
||
</div>
|
||
</div>
|
||
|
||
<div class="nav-section">
|
||
<div class="nav-section-title">产品</div>
|
||
<div class="nav-item" data-page="products" onclick="nav(this,'products')">
|
||
<span class="icon">🤖</span> Robogo
|
||
</div>
|
||
<div class="nav-item" data-page="dataloop" onclick="nav(this,'dataloop')">
|
||
<span class="icon">🔄</span> 数据闭环
|
||
</div>
|
||
</div>
|
||
|
||
<div class="sidebar-footer">
|
||
<div style="font-size:12px;color:var(--text-muted);display:flex;align-items:center">
|
||
<span class="status-dot"></span> 平台运行中
|
||
</div>
|
||
</div>
|
||
</nav>
|
||
|
||
<!-- ── Main ────────────────────────────────────────────────────────────────── -->
|
||
<div id="main">
|
||
<div id="topbar">
|
||
<div>
|
||
<h2 id="page-title">自动化任务</h2>
|
||
<div class="sub" id="page-sub">管理并执行所有自动化测试任务</div>
|
||
</div>
|
||
<button class="btn btn-primary" id="create-btn" onclick="openCreateModal()">+ 创建任务</button>
|
||
</div>
|
||
<div id="content"></div>
|
||
</div>
|
||
|
||
<!-- ── Create Task Modal ──────────────────────────────────────────────────── -->
|
||
<div class="modal-overlay" id="create-modal">
|
||
<div class="modal">
|
||
<div class="modal-header">
|
||
<div>
|
||
<h3>创建自动化任务</h3>
|
||
<p>配置并启动一次自动化测试</p>
|
||
</div>
|
||
<button class="modal-close" onclick="closeModal('create-modal')">✕</button>
|
||
</div>
|
||
<div class="modal-body">
|
||
<!-- 任务名称 -->
|
||
<div class="form-group">
|
||
<label class="form-label">任务名称</label>
|
||
<input type="text" id="f-name" class="form-input" placeholder="例: Robogo PROD 巡检 #1" />
|
||
</div>
|
||
<!-- 账号 -->
|
||
<div class="form-row">
|
||
<div class="form-group">
|
||
<label class="form-label">账号</label>
|
||
<input type="text" id="f-account" class="form-input" placeholder="登录账号" />
|
||
</div>
|
||
<div class="form-group" style="position:relative">
|
||
<label class="form-label">密码</label>
|
||
<input type="password" id="f-password" class="form-input" placeholder="登录密码" style="padding-right:40px" />
|
||
<span class="pwd-toggle" onclick="togglePwd()">👁️</span>
|
||
</div>
|
||
</div>
|
||
<!-- 选择产品 -->
|
||
<div class="form-group">
|
||
<label class="form-label">选择产品</label>
|
||
<div class="product-grid" id="product-grid"></div>
|
||
</div>
|
||
<!-- 运行次数 / 重跑 -->
|
||
<div class="form-row">
|
||
<div class="form-group">
|
||
<label class="form-label">运行次数</label>
|
||
<input type="number" id="f-runs" class="form-input" value="1" min="1" max="10" />
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">定时执行</label>
|
||
<input type="datetime-local" id="f-schedule" class="form-input" onchange="updateSubmitBtn()" />
|
||
</div>
|
||
</div>
|
||
<!-- 失败重跑 -->
|
||
<div class="form-group">
|
||
<div class="toggle">
|
||
<div>
|
||
<div class="toggle-label">失败自动重跑</div>
|
||
<div class="toggle-sub">失败后额外追加一次运行尝试</div>
|
||
</div>
|
||
<label class="switch">
|
||
<input type="checkbox" id="f-retry" />
|
||
<span class="slider"></span>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<button class="btn btn-ghost" onclick="closeModal('create-modal')">取消</button>
|
||
<button class="btn btn-primary" id="submit-btn" onclick="submitTask()">🚀 立即运行</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ── Log Viewer Modal ───────────────────────────────────────────────────── -->
|
||
<div class="modal-overlay log-viewer-modal" id="log-modal">
|
||
<div class="modal">
|
||
<div class="modal-header">
|
||
<div>
|
||
<h3 id="log-modal-title">任务日志</h3>
|
||
<p id="log-modal-sub">实时执行输出</p>
|
||
</div>
|
||
<button class="modal-close" onclick="closeModal('log-modal')">✕</button>
|
||
</div>
|
||
<div class="modal-body" style="padding-bottom:0">
|
||
<div class="log-box" id="log-box"></div>
|
||
</div>
|
||
<div class="log-status">
|
||
<div id="log-status-dot" class="spin"
|
||
style="width:10px;height:10px;border:2px solid var(--accent);border-top-color:transparent;border-radius:50%;">
|
||
</div>
|
||
<span id="log-status-text" style="font-size:12px;color:var(--text-muted)">运行中...</span>
|
||
<button class="btn btn-ghost" style="margin-left:auto;padding:5px 12px;font-size:12px"
|
||
onclick="closeModal('log-modal')">关闭</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
// ── State ──────────────────────────────────────────────────────────────────
|
||
const API = '';
|
||
let currentPage = 'tasks';
|
||
let selectedProduct = 'robogo';
|
||
let products = {};
|
||
|
||
// ── Init ───────────────────────────────────────────────────────────────────
|
||
async function init() {
|
||
products = await fetch(`${API}/api/products`).then(r => r.json());
|
||
renderProductGrid();
|
||
renderPage('tasks');
|
||
setInterval(refreshBadges, 3000);
|
||
}
|
||
|
||
// ── Navigation ─────────────────────────────────────────────────────────────
|
||
const pages = {
|
||
tasks: { title: '自动化任务', sub: '管理并执行所有自动化测试任务' },
|
||
reports: { title: '测试报告', sub: '查看历史运行报告与结果分析' },
|
||
stats: { title: '数据看板', sub: '全量巡检任务健康度与趋势分析' },
|
||
products: { title: 'Robogo', sub: 'Robogo PROD 环境全链路巡检' },
|
||
dataloop: { title: '数据闭环', sub: '数据闭环平台端到端验证' },
|
||
};
|
||
|
||
async function nav(el, page) {
|
||
document.querySelectorAll('.nav-item').forEach(i => i.classList.remove('active'));
|
||
if (el) el.classList.add('active');
|
||
currentPage = page;
|
||
const p = pages[page] || pages.tasks;
|
||
document.getElementById('page-title').textContent = p.title;
|
||
document.getElementById('page-sub').textContent = p.sub;
|
||
const btn = document.getElementById('create-btn');
|
||
btn.style.display = page === 'tasks' ? '' : 'none';
|
||
await renderPage(page);
|
||
}
|
||
|
||
// ── Render Pages ───────────────────────────────────────────────────────────
|
||
async function renderPage(page) {
|
||
const c = document.getElementById('content');
|
||
if (page === 'tasks') await renderTasks(c);
|
||
else if (page === 'reports') await renderReports(c);
|
||
else if (page === 'stats') await renderDashboard(c);
|
||
else if (page === 'products') renderProducts(c, 'robogo');
|
||
else if (page === 'dataloop') renderProducts(c, 'data_loop');
|
||
}
|
||
|
||
async function renderTasks(c) {
|
||
const tasks = await fetch(`${API}/api/tasks`).then(r => r.json());
|
||
const running = tasks.filter(t => t.status === 'running').length;
|
||
const passed = tasks.filter(t => t.status === 'pass').length;
|
||
const failed = tasks.filter(t => t.status === 'fail').length;
|
||
|
||
c.innerHTML = `
|
||
<div class="stats-grid">
|
||
${stat('总任务数', tasks.length, '累计创建', 'linear-gradient(90deg,#6c72ff,#a78bfa)')}
|
||
${stat('运行中', running, '当前活跃', 'linear-gradient(90deg,#6c72ff,#38e0b0)', running > 0 ? 'var(--accent)' : '')}
|
||
${stat('通过', passed, '历史成功次数', 'linear-gradient(90deg,#38e0b0,#22c55e)', 'var(--accent2)')}
|
||
${stat('失败', failed, '需要关注', 'linear-gradient(90deg,#ff5c6c,#f97316)', failed > 0 ? 'var(--danger)' : '')}
|
||
</div>
|
||
<div class="panel">
|
||
<div class="panel-header">
|
||
<span class="panel-title">任务列表</span>
|
||
<span style="font-size:12px;color:var(--text-muted)">${tasks.length} 个任务</span>
|
||
</div>
|
||
${tasks.length === 0 ? emptyState('📋', '暂无任务', '点击右上角 + 创建第一个自动化任务') : `
|
||
<table>
|
||
<thead><tr>
|
||
<th>任务名称</th><th>产品</th><th>状态</th><th>运行次数</th><th>创建时间</th><th>操作</th>
|
||
</tr></thead>
|
||
<tbody>
|
||
${tasks.sort((a, b) => new Date(b.created_at) - new Date(a.created_at)).map(t => `
|
||
<tr>
|
||
<td><strong>${t.name}</strong></td>
|
||
<td>${productTag(t.product)}</td>
|
||
<td>${statusBadge(t.status)}</td>
|
||
<td><span style="color:var(--text-muted)">${t.run_count} 次</span></td>
|
||
<td><span style="color:var(--text-muted);font-size:12px">${fmtDate(t.created_at)}</span></td>
|
||
<td>
|
||
<button class="action-btn" onclick="openLog('${t.id}','${t.name}','${t.status}')">日志</button>
|
||
${t.report_id ? `<button class="action-btn" style="margin-left:6px" onclick="viewReport('${t.id}')">报告</button>` : ''}
|
||
${t.status === 'running' ? `<button class="action-btn btn-danger" style="margin-left:6px" onclick="stopTask('${t.id}')">停止</button>` : ''}
|
||
<button class="action-btn" style="margin-left:6px;color:var(--danger)" onclick="deleteTask('${t.id}')">删除</button>
|
||
</td>
|
||
</tr>
|
||
`).join('')}
|
||
</tbody>
|
||
</table>`}
|
||
</div>`;
|
||
}
|
||
|
||
async function renderReports(c) {
|
||
const reports = await fetch(`${API}/api/reports`).then(r => r.json());
|
||
if (reports.length === 0) {
|
||
c.innerHTML = emptyState('📊', '暂无报告', '完成一次任务后报告将在此出现');
|
||
return;
|
||
}
|
||
c.innerHTML = (await Promise.all(reports.sort((a, b) => new Date(b.finished_at) - new Date(a.finished_at)).map(async r => {
|
||
const pct = r.total_runs > 0 ? Math.round(r.pass / r.total_runs * 100) : 0;
|
||
const dur = r.started_at && r.finished_at ? Math.round((new Date(r.finished_at) - new Date(r.started_at)) / 1000) : '?';
|
||
|
||
// 尝试获取截图
|
||
const detail = await fetch(`${API}/api/reports/${r.task_id}`).then(res => res.json()).catch(() => ({}));
|
||
const screenshots = detail.screenshots || [];
|
||
const shotHtml = screenshots.length > 0 ? `
|
||
<div style="margin-top:16px; border-top:1px dashed var(--border); padding-top:12px">
|
||
<div style="font-size:11px;color:var(--text-muted);margin-bottom:8px">🖼️ 报错截图存档:</div>
|
||
<div style="display:flex;gap:10px;overflow-x:auto;padding-bottom:10px">
|
||
${screenshots.map(s => `<img src="/artifacts/screenshots/${s}" style="height:80px;border-radius:6px;border:1px solid var(--border);cursor:zoom-in" onclick="window.open('/artifacts/screenshots/${s}')"/>`).join('')}
|
||
</div>
|
||
</div>
|
||
` : '';
|
||
|
||
return `
|
||
<div class="report-card" style="display:block">
|
||
<div style="display:flex; justify-content:space-between; align-items:center">
|
||
<div style="display:flex;align-items:center;gap:12px">
|
||
<div class="report-result ${r.result}">${r.result === 'PASS' ? '✅' : '❌'} ${r.result}</div>
|
||
<div>
|
||
<div style="font-size:15px;font-weight:600">${r.task_name}</div>
|
||
<div class="report-meta">
|
||
<span>🤖 ${r.product}</span>
|
||
<span>🔄 ${r.total_runs}次</span>
|
||
<span>✅ ${r.pass}通过</span>
|
||
<span>❌ ${r.fail}失败</span>
|
||
<span>⏱ ${dur}s</span>
|
||
<span>🕐 ${fmtDate(r.finished_at)}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<button class="btn btn-ghost" style="font-size:12px;padding:8px 14px" onclick="openLog('${r.task_id}','${r.task_name}','pass')">查看日志</button>
|
||
</div>
|
||
<div class="progress-bar">
|
||
<div class="progress-fill" style="width:${pct}%;background:${r.result === 'PASS' ? 'var(--accent2)' : 'var(--danger)'}"></div>
|
||
</div>
|
||
${shotHtml}
|
||
</div>`;
|
||
}))).join('');
|
||
}
|
||
|
||
function renderProducts(c, key) {
|
||
const p = products[key];
|
||
if (!p) { c.innerHTML = emptyState('🔧', '该产品暂未接入', '敬请期待'); return; }
|
||
c.innerHTML = `
|
||
<div class="panel" style="padding:28px">
|
||
<div style="font-size:36px;margin-bottom:16px">${p.icon}</div>
|
||
<h2 style="font-size:22px;font-weight:700;margin-bottom:8px">${p.name}</h2>
|
||
<p style="color:var(--text-muted);font-size:14px;margin-bottom:24px">${p.desc}</p>
|
||
${p.entry ? `<button class="btn btn-primary" onclick="quickRun('${key}')">⚡ 快速创建任务并运行</button>` : `<span style="color:var(--text-muted);font-size:13px">🚧 即将接入...</span>`}
|
||
</div>`;
|
||
}
|
||
|
||
// ── UI Helpers ─────────────────────────────────────────────────────────────
|
||
function stat(label, val, meta, grad, color = 'var(--text)') {
|
||
return `<div class="stat-card" style="--accent-grad:${grad}">
|
||
<div class="stat-label">${label}</div>
|
||
<div class="stat-value" style="color:${color}">${val}</div>
|
||
<div class="stat-meta">${meta}</div>
|
||
</div>`;
|
||
}
|
||
|
||
function statusBadge(s) {
|
||
const map = { running: '<span class="spin"></span> 运行中', pass: '✓ 通过', fail: '✗ 失败', pending: '⏰ 等待中' };
|
||
return `<span class="status-badge ${s}">${map[s] || s}</span>`;
|
||
}
|
||
|
||
function productTag(key) {
|
||
const p = products[key];
|
||
return `<span style="font-size:12px;color:var(--text-muted)">${p ? p.icon + ' ' + p.name : key}</span>`;
|
||
}
|
||
|
||
function emptyState(icon, h, p) {
|
||
return `<div class="empty"><div class="e-icon">${icon}</div><h3>${h}</h3><p>${p}</p></div>`;
|
||
}
|
||
|
||
function fmtDate(iso) {
|
||
if (!iso) return '—';
|
||
const d = new Date(iso);
|
||
return `${d.getMonth() + 1}/${d.getDate()} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
|
||
}
|
||
|
||
// ── Product Grid ───────────────────────────────────────────────────────────
|
||
function renderProductGrid() {
|
||
const grid = document.getElementById('product-grid');
|
||
grid.innerHTML = Object.entries(products).map(([k, p]) => `
|
||
<div class="product-card ${k === selectedProduct ? 'selected' : ''}" onclick="selectProduct('${k}')">
|
||
<div class="p-icon">${p.icon}</div>
|
||
<div class="p-name">${p.name}</div>
|
||
<div class="p-desc">${p.desc}</div>
|
||
</div>`).join('');
|
||
}
|
||
|
||
function selectProduct(k) {
|
||
selectedProduct = k;
|
||
renderProductGrid();
|
||
}
|
||
|
||
// ── Modal ──────────────────────────────────────────────────────────────────
|
||
function openCreateModal() {
|
||
document.getElementById('f-name').value = `巡检任务_${new Date().toLocaleTimeString('zh')}`;
|
||
document.getElementById('create-modal').classList.add('open');
|
||
}
|
||
|
||
function closeModal(id) {
|
||
document.getElementById(id).classList.remove('open');
|
||
}
|
||
|
||
function updateSubmitBtn() {
|
||
const sched = document.getElementById('f-schedule').value;
|
||
const btn = document.getElementById('submit-btn');
|
||
if (sched) {
|
||
btn.textContent = '⏰ 定时执行';
|
||
btn.style.background = 'linear-gradient(135deg, #ffb547, #f97316)';
|
||
} else {
|
||
btn.textContent = '🚀 立即运行';
|
||
btn.style.background = '';
|
||
}
|
||
}
|
||
|
||
function togglePwd() {
|
||
const inp = document.getElementById('f-password');
|
||
const tog = document.querySelector('.pwd-toggle');
|
||
if (inp.type === 'password') {
|
||
inp.type = 'text';
|
||
tog.textContent = '🙈';
|
||
} else {
|
||
inp.type = 'password';
|
||
tog.textContent = '👁️';
|
||
}
|
||
}
|
||
|
||
async function submitTask() {
|
||
const name = document.getElementById('f-name').value.trim() || '未命名任务';
|
||
const account = document.getElementById('f-account').value.trim();
|
||
const password = document.getElementById('f-password').value;
|
||
const runs = parseInt(document.getElementById('f-runs').value) || 1;
|
||
const retry = document.getElementById('f-retry').checked;
|
||
const sched = document.getElementById('f-schedule').value;
|
||
|
||
if (!account || !password) { alert('请填写账号和密码'); return; }
|
||
|
||
const resp = await fetch(`${API}/api/tasks`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ name, product: selectedProduct, account, password, run_count: runs, retry_on_fail: retry, scheduled_at: sched })
|
||
});
|
||
const task = await resp.json();
|
||
closeModal('create-modal');
|
||
await renderPage('tasks');
|
||
openLog(task.id, task.name, 'running');
|
||
}
|
||
|
||
async function quickRun(productKey) {
|
||
try {
|
||
const account = prompt('请输入账号:');
|
||
if (account === null) return;
|
||
const password = prompt('请输入密码:');
|
||
if (password === null) return;
|
||
|
||
if (!account || !password) {
|
||
alert('账号和密码不能为空');
|
||
return;
|
||
}
|
||
|
||
const resp = await fetch(`${API}/api/tasks`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
name: `快速运行_${productKey}_${new Date().toLocaleTimeString('zh')}`,
|
||
product: productKey,
|
||
account,
|
||
password,
|
||
run_count: 1
|
||
})
|
||
});
|
||
|
||
if (!resp.ok) {
|
||
const err = await resp.json();
|
||
throw new Error(err.error || '创建任务失败');
|
||
}
|
||
|
||
const task = await resp.json();
|
||
// 确保导航完成后再打开日志
|
||
await nav(document.querySelector('[data-page="tasks"]'), 'tasks');
|
||
openLog(task.id, task.name, 'running');
|
||
} catch (e) {
|
||
console.error('QuickRun Error:', e);
|
||
alert('快速运行出错: ' + e.message);
|
||
}
|
||
}
|
||
|
||
// ── Log Viewer ─────────────────────────────────────────────────────────────
|
||
let activeSSE = null;
|
||
|
||
async function openLog(taskId, taskName, status) {
|
||
document.getElementById('log-modal-title').textContent = taskName;
|
||
document.getElementById('log-modal').classList.add('open');
|
||
const box = document.getElementById('log-box');
|
||
box.innerHTML = '';
|
||
|
||
if (activeSSE) { activeSSE.close(); activeSSE = null; }
|
||
|
||
// If already finished, fetch stored logs
|
||
if (status !== 'running') {
|
||
const rept = await fetch(`${API}/api/reports/${taskId}`).catch(() => null);
|
||
if (rept && rept.ok) {
|
||
const data = await rept.json();
|
||
data.logs.forEach(l => appendLog(box, l));
|
||
setLogDone(data.result === 'PASS');
|
||
return;
|
||
}
|
||
}
|
||
|
||
// Stream via SSE
|
||
setLogRunning();
|
||
const src = new EventSource(`${API}/api/tasks/${taskId}/logs`);
|
||
activeSSE = src;
|
||
src.onmessage = e => {
|
||
const data = JSON.parse(e.data);
|
||
if (data.level === 'PING') return;
|
||
if (data.level === 'DONE') {
|
||
src.close(); activeSSE = null;
|
||
renderPage(currentPage);
|
||
refreshBadges();
|
||
return;
|
||
}
|
||
appendLog(box, data);
|
||
};
|
||
src.onerror = () => { src.close(); setLogDone(false); };
|
||
}
|
||
|
||
function appendLog(box, data) {
|
||
const div = document.createElement('div');
|
||
div.className = `log-line ${data.level}`;
|
||
div.innerHTML = `<span class="ts">${data.ts}</span><span class="level">[${data.level}]</span> <span class="msg">${escHtml(data.msg)}</span>`;
|
||
box.appendChild(div);
|
||
box.scrollTop = box.scrollHeight;
|
||
}
|
||
|
||
function setLogRunning() {
|
||
document.getElementById('log-status-dot').style.display = '';
|
||
document.getElementById('log-status-text').textContent = '运行中...';
|
||
}
|
||
|
||
function setLogDone(pass) {
|
||
document.getElementById('log-status-dot').style.display = 'none';
|
||
const t = document.getElementById('log-status-text');
|
||
t.textContent = pass ? '✅ 测试通过' : '❌ 测试失败';
|
||
t.style.color = pass ? 'var(--accent2)' : 'var(--danger)';
|
||
}
|
||
|
||
async function viewReport(taskId) {
|
||
nav(document.querySelector('[data-page="reports"]'), 'reports');
|
||
}
|
||
|
||
// ── Badges ─────────────────────────────────────────────────────────────────
|
||
async function refreshBadges() {
|
||
const tasks = await fetch(`${API}/api/tasks`).then(r => r.json()).catch(() => []);
|
||
const reports = await fetch(`${API}/api/reports`).then(r => r.json()).catch(() => []);
|
||
document.getElementById('badge-tasks').textContent = tasks.length;
|
||
document.getElementById('badge-reports').textContent = reports.length;
|
||
}
|
||
|
||
// ── Utils ───────────────────────────────────────────────────────────────────
|
||
function escHtml(t) {
|
||
return String(t).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||
}
|
||
|
||
// ── 治理功能 ──
|
||
async function deleteTask(id) {
|
||
if (!confirm('确定删除该任务及其所有日志、截图吗?')) return;
|
||
await fetch(`${API}/api/tasks/${id}`, { method: 'DELETE' });
|
||
renderPage(currentPage);
|
||
}
|
||
|
||
async function stopTask(id) {
|
||
if (!confirm('确定强行停止当前运行的任务吗?')) return;
|
||
await fetch(`${API}/api/tasks/${id}/stop`, { method: 'POST' });
|
||
renderPage(currentPage);
|
||
}
|
||
|
||
// ── 看板渲染 ──
|
||
async function renderDashboard(c) {
|
||
const stats = await fetch(`${API}/api/dashboard/stats`).then(r => r.json());
|
||
const brands = Object.entries(stats.products).map(([name, data]) => {
|
||
const total = data.pass + data.fail;
|
||
const rate = total > 0 ? Math.round(data.pass / total * 100) : 0;
|
||
return `
|
||
<div class="stat-card">
|
||
<div class="stat-label">${name.toUpperCase()} 健康度</div>
|
||
<div class="stat-value" style="color:${rate > 90 ? 'var(--accent2)' : 'var(--danger)'}">${rate}%</div>
|
||
<div class="stat-meta">累计巡检 ${total} 次</div>
|
||
</div>
|
||
`;
|
||
}).join('');
|
||
|
||
c.innerHTML = `
|
||
<div class="stats-grid">
|
||
${stat('全域平均通过率', stats.pass_rate + '%', `基于 ${stats.total_reports} 份报告`, 'linear-gradient(90deg, #6c72ff, #38e0b0)', stats.pass_rate >= 95 ? 'var(--accent2)' : '')}
|
||
${stat('累计失败任务', stats.fail_count, '亟待维护项', 'linear-gradient(90deg, var(--danger), #f97316)', stats.fail_count > 0 ? 'var(--danger)' : '')}
|
||
</div>
|
||
<div style="display:grid; grid-template-columns: repeat(2,1fr); gap:16px; margin-top:16px">
|
||
${brands}
|
||
</div>
|
||
<div class="panel" style="margin-top:24px; padding:24px; text-align:center; color:var(--text-muted)">
|
||
📊 数据已在 ${stats.ts} 完成聚合,系统运行稳定。
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
// Close modal on overlay click
|
||
document.querySelectorAll('.modal-overlay').forEach(el => {
|
||
el.addEventListener('click', e => { if (e.target === el) closeModal(el.id); });
|
||
});
|
||
|
||
init();
|
||
</script>
|
||
</body>
|
||
|
||
</html> |