Files
webtest-agent/static/js/app.js
T

222 lines
8.3 KiB
JavaScript
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.
/* webtest-agent 前端逻辑 */
const $ = s => document.querySelector(s);
const API = '';
async function refreshHealth() {
try {
const r = await fetch(API + '/health');
const d = await r.json();
$('#health').textContent = `服务正常 | 模型: ${d.llm_model} | 运行中任务: ${d.concurrent}`;
$('#health').classList.add('ok');
} catch (e) {
$('#health').textContent = '服务异常';
$('#health').classList.remove('ok');
}
}
const STATUS_MAP = {
queued: '排队中', running: '测试中', finished: '已完成', stopped: '已停止'
};
const RESULT_MAP = {
pending: '待定', pass: '✅ 通过', fail: '❌ 失败', error: '⚠️ 错误', stopped: '⏹️ 停止'
};
function esc(s) {
return String(s ?? '').replace(/[&<>"']/g, c => (
{'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
}
async function loadTasks() {
try {
const r = await fetch(API + '/api/tasks');
const d = await r.json();
const tb = $('#task-table tbody');
if (!d.tasks.length) {
tb.innerHTML = '<tr><td colspan="8" class="empty">暂无任务,先发起一个吧</td></tr>';
return;
}
tb.innerHTML = d.tasks.map(t => {
const ops = t.status === 'running' || t.status === 'queued'
? `<button class="btn small" onclick="stopTask('${t.id}')">停止</button> `
: '';
const report = t.status === 'finished'
? `<a class="report-link" href="#" onclick="openReport('${t.id}');return false;">报告</a>`
: '';
return `<tr>
<td>${esc(t.id)}</td>
<td>${esc(t.url)}</td>
<td><div class="goal-cell" title="${esc(t.goal)}">${esc(t.goal)}</div></td>
<td><span class="status ${esc(t.status)}">${STATUS_MAP[t.status] || esc(t.status)}</span></td>
<td><span class="result ${esc(t.result)}">${RESULT_MAP[t.result] || esc(t.result)}</span></td>
<td>${t.steps || 0}</td>
<td>${esc(t.created)}</td>
<td><button class="btn small" onclick="openDetail('${t.id}')">详情</button> ${ops}${report}</td>
</tr>`;
}).join('');
} catch (e) {
$('#task-table tbody').innerHTML = '<tr><td colspan="8" class="empty">加载失败</td></tr>';
}
}
async function stopTask(tid) {
if (!confirm('确定停止该任务?')) return;
await fetch(API + `/api/tasks/${tid}/stop`, { method: 'POST' });
setTimeout(loadTasks, 500);
}
function openReport(tid) {
$('#report-frame').src = API + `/api/tasks/${tid}/report`;
$('#modal-title').textContent = `测试报告 - ${tid}`;
$('#report-modal').classList.remove('hidden');
}
/* ========== 任务详情 ========== */
let detailTid = null;
let detailTimer = null;
function openDetail(tid) {
detailTid = tid;
$('#detail-title').textContent = `任务详情 - ${tid}`;
$('#detail-steps').innerHTML = '<div class="empty">加载中...</div>';
$('#detail-modal').classList.remove('hidden');
refreshDetail();
detailTimer = setInterval(refreshDetail, 2500);
}
function closeDetail() {
detailTid = null;
if (detailTimer) { clearInterval(detailTimer); detailTimer = null; }
$('#detail-modal').classList.add('hidden');
}
const ACTION_LABEL = { click: '点击', fill: '填表', select: '选择', press: '按键',
wait: '等待', scroll: '滚动', assert: '断言', screenshot: '截图', done: '完成', fail: '失败' };
async function refreshDetail() {
if (!detailTid) return;
let t;
try {
const r = await fetch(API + `/api/tasks/${detailTid}`);
t = await r.json();
} catch (e) { return; }
// 元信息
const done = t.status === 'finished' || t.status === 'stopped';
$('#detail-meta').innerHTML = `
<div class="m-item"><b>目标网址</b><span>${esc(t.url)}</span></div>
<div class="m-item"><b>测试目标</b><span>${esc(t.goal)}</span></div>
<div class="m-item"><b>状态</b><span><span class="status ${esc(t.status)}">${STATUS_MAP[t.status] || esc(t.status)}</span> / <span class="result ${esc(t.result)}">${RESULT_MAP[t.result] || esc(t.result)}</span></span></div>
<div class="m-item"><b>步骤</b><span>${t.steps || 0} / ${t.max_steps}</span></div>
<div class="m-item"><b>创建时间</b><span>${esc(t.created)}</span></div>
${t.finished ? `<div class="m-item"><b>结束时间</b><span>${esc(t.finished)}</span></div>` : ''}
`;
// 实时状态条
const cur = t.current;
const liveBar = $('#live-bar');
if (!done && cur) {
liveBar.classList.remove('hidden');
$('#live-phase').textContent = `步骤 ${cur.step || '-'}: ${cur.phase || ''}` +
(cur.elapsed ? `(已运行 ${cur.elapsed}s` : '');
let d = cur.detail || '';
if (cur.reason) d += `<div class="live-reason">💡 ${esc(cur.reason)}</div>`;
$('#live-detail').innerHTML = esc(d);
} else {
liveBar.classList.add('hidden');
}
// 按钮
$('#detail-report-btn').classList.toggle('hidden', !done || t.result === 'error');
$('#detail-stop-btn').classList.toggle('hidden', done);
// 步骤列表
const steps = t.steps_log || [];
if (!steps.length) {
$('#detail-steps').innerHTML = '<div class="empty">还没有步骤记录,AI 正在准备...</div>';
} else {
$('#detail-steps').innerHTML = steps.map(s => {
const act = s.action || '';
const actCls = ['assert','click','fill','fail'].includes(act) ? act : '';
const shot = s.screenshot
? `<div class="step-shot"><img src="${API}/api/tasks/${detailTid}/screenshot/${esc(s.screenshot)}" loading="lazy" onclick="window.open(this.src)"></div>`
: '';
const resCls = s.result === 'ok' ? 'ok' : (s.result === 'fail' || s.result === 'error' ? 'bad' : '');
return `<div class="step-card">
<div class="step-top">
<span class="step-num">#${s.n}</span>
<span class="step-action ${actCls}">${ACTION_LABEL[act] || act}</span>
${s.target ? `<span class="step-target">${esc(s.target)}${s.value ? ' ' + esc(s.value) : ''}</span>` : ''}
<span class="step-result ${resCls}">${esc(s.result)}</span>
<span class="step-time">${esc(s.ts || '')}</span>
</div>
${s.reason ? `<div class="step-reason">💡 ${esc(s.reason)}</div>` : ''}
${s.detail ? `<div class="step-detail">${esc(s.detail)}</div>` : ''}
${shot}
</div>`;
}).join('');
}
// 结束后停止轮询
if (done) { if (detailTimer) { clearInterval(detailTimer); detailTimer = null; } }
}
$('#detail-close').onclick = closeDetail;
$('#detail-modal').onclick = e => { if (e.target === $('#detail-modal')) closeDetail(); };
$('#detail-stop-btn').onclick = async () => {
if (!detailTid || !confirm('确定停止该任务?')) return;
await fetch(API + `/api/tasks/${detailTid}/stop`, { method: 'POST' });
refreshDetail(); loadTasks();
};
$('#detail-report-btn').onclick = () => { if (detailTid) openReport(detailTid); };
$('#modal-close').onclick = () => $('#report-modal').classList.add('hidden');
$('#report-modal').onclick = e => { if (e.target === $('#report-modal')) $('#report-modal').classList.add('hidden'); };
$('#submit').onclick = async () => {
const url = $('#url').value.trim();
const goal = $('#goal').value.trim();
if (!url || !goal) { alert('请填写网址和测试目标'); return; }
const btn = $('#submit');
btn.disabled = true; btn.textContent = '提交中...';
try {
const r = await fetch(API + '/api/tasks', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
url, goal,
max_steps: parseInt($('#max_steps').value) || 30,
timeout: parseInt($('#timeout').value) || 600
})
});
const d = await r.json();
if (d.error) { alert('提交失败: ' + d.error); }
else {
$('#goal').value = '';
loadTasks();
// 轮询直到该任务结束
pollTask(d.task_id);
}
} catch (e) { alert('提交失败: ' + e); }
finally { btn.disabled = false; btn.textContent = '🚀 开始测试'; }
};
function pollTask(tid, count = 0) {
if (count > 400) return;
setTimeout(async () => {
try {
const r = await fetch(API + `/api/tasks/${tid}`);
const t = await r.json();
loadTasks();
if (t.status === 'finished' || t.status === 'stopped') return;
pollTask(tid, count + 1);
} catch (e) { /* ignore */ }
}, 3000);
}
$('#refresh').onclick = loadTasks;
refreshHealth();
loadTasks();
setInterval(refreshHealth, 30000);
setInterval(loadTasks, 5000);