test/platform/index.html

1406 lines
43 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">
<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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
// ── 治理功能 ──
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>