177 lines
12 KiB
TypeScript
177 lines
12 KiB
TypeScript
import { Component, DestroyRef, computed, effect, inject, output, signal } from '@angular/core';
|
|
import { FavoriteButtonComponent } from '../favorite-button/favorite-button.component';
|
|
import { ReportMarkdownComponent } from '../report-markdown/report-markdown.component';
|
|
import { CommonModule } from '@angular/common';
|
|
import { FormsModule } from '@angular/forms';
|
|
import { HttpClient } from '@angular/common/http';
|
|
import { firstValueFrom } from 'rxjs';
|
|
import { AgentService } from '../../services/agent.service';
|
|
import { SystemService } from '../../services/system.service';
|
|
import { TestResultBadgeComponent } from '../test-result-badge/test-result-badge.component';
|
|
import { TestResultCardComponent } from '../test-result-badge/test-result-card.component';
|
|
import { CaseReviewResponse } from '../../core/models/session.model';
|
|
import { TaskPluginsComponent, TaskPluginOptions } from '../task-plugins/task-plugins.component';
|
|
|
|
interface ArchiveProgress {
|
|
status: 'idle' | 'preparing' | 'ready' | 'failed';
|
|
stage: string;
|
|
completed: number;
|
|
total: number;
|
|
elapsed_seconds: number;
|
|
eta_seconds: number | null;
|
|
error: string | null;
|
|
}
|
|
|
|
@Component({
|
|
selector: 'app-automation-panel', standalone: true, imports: [FavoriteButtonComponent, ReportMarkdownComponent, CommonModule, FormsModule, TestResultBadgeComponent, TestResultCardComponent, TaskPluginsComponent],
|
|
templateUrl: './automation-panel.component.html', styleUrl: './automation-panel.component.scss'
|
|
})
|
|
export class AutomationPanelComponent {
|
|
taskPlugins: TaskPluginOptions = {};
|
|
queueRequested = output<void>();
|
|
agent = inject(AgentService); system = inject(SystemService); http = inject(HttpClient);
|
|
tab = signal('reports'); error = signal(''); notice = signal(''); busy = signal(false);
|
|
batches = signal<any[]>([]); preview = signal<any>(null);
|
|
selectedRows = signal<Set<number>>(new Set());
|
|
selectedCount = computed(() => this.selectedRows().size);
|
|
batch = signal<any>(null); report = signal<any>(null);
|
|
archiveProgress = signal<ArchiveProgress | null>(null);
|
|
archiveBatchId = signal(''); archiveBusy = signal(false);
|
|
guidance = '';
|
|
taskStatus = computed(() => {
|
|
const sid = this.agent.currentSessionId();
|
|
if (!sid) return '';
|
|
return this.agent.sessions().find(s => s.session_id === sid)?.status
|
|
|| (this.agent.runningSessionId() === sid ? this.agent.agentStatus() : '') || '';
|
|
});
|
|
liveTask = computed(() => ['running', 'paused', 'task_paused', 'resuming'].includes(this.taskStatus()));
|
|
word = true; excel = true; name = ''; device = ''; profile = 'flash'; stopOnFailure = false; timeout = 1800;
|
|
repeatCount = 1; maxExecutions = 10000;
|
|
private destroyed = false;
|
|
constructor() {
|
|
const destroy = inject(DestroyRef);
|
|
this.http.get<any>('/api/automation/preferences').subscribe(p => { this.word = p.formats.includes('docx'); this.excel = p.formats.includes('xlsx'); this.timeout = p.batch_timeout_seconds || 1800; this.maxExecutions = p.batch_max_executions || 10000; });
|
|
void this.refresh();
|
|
const timer = setInterval(() => void this.refresh(), 5000);
|
|
destroy.onDestroy(() => { this.destroyed = true; clearInterval(timer); });
|
|
effect(() => {
|
|
this.agent.resultRevision();
|
|
const sid = this.agent.currentSessionId(); this.report.set(null); this.guidance = '';
|
|
if (sid) this.http.get<any>(`/api/automation/reports/${encodeURIComponent(sid)}`).subscribe({ next: r => { if (this.agent.currentSessionId() === sid) this.report.set(r); }, error: () => {} });
|
|
});
|
|
}
|
|
formats() { return [ ...(this.word ? ['docx'] : []), ...(this.excel ? ['xlsx'] : []) ]; }
|
|
label(s: string) { return ({draft:'待开始',running:'执行中',paused:'已暂停',task_paused:'等待指导',resuming:'等待设备恢复',completed:'执行完成',failed:'失败',cancelled:'已取消',pending:'待执行',queued:'排队中',interrupted:'中断',skipped:'跳过',finalizing:'正在生成报告',deleting:'删除中,可重试清理'} as Record<string,string>)[s] || s; }
|
|
reviewSaved(review: CaseReviewResponse) { this.agent.applyCaseReview(review); if (this.report()?.id === review.id) this.report.update(value => ({...value, ...review})); }
|
|
openBatchTask(id: string) { this.agent.selectSession(id, true); }
|
|
showBatchQueue(id: string) { this.agent.batchFilter.set(id); this.agent.activeTab.set('tasks'); this.queueRequested.emit(); }
|
|
duration(seconds: number) { const minutes = Math.floor(seconds / 60); return minutes ? `${minutes} 分 ${seconds % 60} 秒` : `${seconds} 秒`; }
|
|
downloadBatchArchive(id: string) { void this.prepareBatchArchive(id); }
|
|
private async prepareBatchArchive(id: string) {
|
|
if (this.archiveBusy()) return;
|
|
this.archiveBusy.set(true); this.archiveBatchId.set(id);
|
|
this.archiveProgress.set({ status: 'preparing', stage: '正在请求打包', completed: 0, total: 0, elapsed_seconds: 0, eta_seconds: null, error: null });
|
|
const base = `/api/automation/batches/${encodeURIComponent(id)}/archive`;
|
|
try {
|
|
let progress = await firstValueFrom(this.http.post<ArchiveProgress>(`${base}/prepare`, {}));
|
|
while (!this.destroyed && progress.status === 'preparing') {
|
|
this.archiveProgress.set(progress);
|
|
await new Promise<void>(resolve => setTimeout(resolve, 1000));
|
|
if (this.destroyed) return;
|
|
progress = await firstValueFrom(this.http.get<ArchiveProgress>(`${base}/status`));
|
|
}
|
|
if (this.destroyed) return;
|
|
this.archiveProgress.set(progress);
|
|
if (progress.status === 'failed') throw Error(progress.error || '批次报告打包失败');
|
|
if (progress.status !== 'ready') throw Error('打包任务已中断,请重试');
|
|
const link = document.createElement('a');
|
|
link.href = base;
|
|
link.download = `批次报告-${id.slice(0, 8)}.zip`;
|
|
document.body.appendChild(link); link.click(); link.remove();
|
|
this.notice.set('报告包已就绪,正在开始下载');
|
|
} catch (e: any) {
|
|
const detail = e.error?.detail;
|
|
this.archiveProgress.set({
|
|
...(this.archiveProgress() || { completed: 0, total: 0, elapsed_seconds: 0, eta_seconds: null }),
|
|
status: 'failed', stage: '打包失败', error: typeof detail === 'string' ? detail : e.message || '下载失败'
|
|
});
|
|
} finally { this.archiveBusy.set(false); }
|
|
}
|
|
async action(fn: () => Promise<void>) {
|
|
if (this.busy()) return;
|
|
this.busy.set(true); this.error.set(''); this.notice.set('');
|
|
try { await fn(); } catch (e: any) { const d = e.error?.detail; this.error.set(typeof d === 'string' ? d : d ? JSON.stringify(d) : e.message || '操作失败'); }
|
|
finally { this.busy.set(false); }
|
|
}
|
|
async refresh() {
|
|
try {
|
|
const b = await firstValueFrom(this.http.get<any[]>('/api/automation/batches'));
|
|
if (this.destroyed) return;
|
|
this.batches.set(b);
|
|
const sid = this.agent.currentSessionId();
|
|
if (sid && this.tab() === 'reports') { const r = await firstValueFrom(this.http.get<any>(`/api/automation/reports/${encodeURIComponent(sid)}`)); if (this.agent.currentSessionId() === sid) this.report.set(r); }
|
|
const id = this.batch()?.id;
|
|
if (id) { const item = await firstValueFrom(this.http.get<any>(`/api/automation/batches/${id}`)); if (this.batch()?.id === id) this.batch.set(item); }
|
|
} catch { /* User initiated actions expose errors; polling retries on the next interval. */ }
|
|
}
|
|
savePreferences() { void this.action(async () => { if (!this.formats().length) throw Error('请至少选择一种报告格式'); await firstValueFrom(this.http.put('/api/automation/preferences',{formats:this.formats()})); this.notice.set('已保存,后续任务和批次使用此默认格式'); }); }
|
|
upload(event: Event) {
|
|
const file = (event.target as HTMLInputElement).files?.[0]; if (!file) return;
|
|
void this.action(async () => { this.preview.set(null); this.selectedRows.set(new Set()); const data = new FormData(); data.append('file',file); const preview: any = await firstValueFrom(this.http.post('/api/automation/import/preview',data)); this.preview.set(preview); this.selectedRows.set(new Set<number>(preview.tasks.map((t: any) => t.row))); this.repeatCount = 1; this.name = file.name.replace(/\.xlsx$/i,''); });
|
|
}
|
|
toggleRow(row: number) {
|
|
const selected = new Set(this.selectedRows());
|
|
if (selected.has(row)) selected.delete(row); else selected.add(row);
|
|
this.selectedRows.set(selected);
|
|
}
|
|
toggleAllRows() {
|
|
const rows: number[] = (this.preview()?.tasks || []).map((t: any) => t.row);
|
|
this.selectedRows.set(this.selectedCount() === rows.length ? new Set() : new Set(rows));
|
|
}
|
|
validRepetitions() {
|
|
return Number.isInteger(this.repeatCount) && this.repeatCount >= 1
|
|
&& this.selectedCount() * this.repeatCount <= this.maxExecutions;
|
|
}
|
|
caseConfig(config: Record<string, unknown> | undefined) {
|
|
const overrides = Object.fromEntries(Object.entries(config || {}).filter(([, value]) => value !== null && value !== undefined));
|
|
return Object.keys(overrides).length ? JSON.stringify(overrides, null, 2) : '';
|
|
}
|
|
startBatch() { void this.action(async () => {
|
|
if (!this.preview()?.valid) throw Error('请先导入并修正所有错误');
|
|
if (!this.selectedCount()) throw Error('请至少勾选一个用例');
|
|
if (!this.validRepetitions()) throw Error(`循环次数必须为正整数,执行总数不能超过 ${this.maxExecutions}`);
|
|
const cases = this.preview().tasks.filter((t: any) => this.selectedRows().has(t.row)).map(({row, ...c}: any) => ({...c, task_config: {...this.taskPlugins, ...Object.fromEntries(Object.entries(c.task_config || {}).filter(([,v]) => v !== null && v !== undefined))}}));
|
|
if (!this.device && cases.some((c: any) => c.task_config.execution_target !== 'computer' && !c.task_config.device_serial)) throw Error('请选择执行设备');
|
|
const created: any = await firstValueFrom(this.http.post('/api/automation/batches',{name:this.name.trim(),cases,repeat_count:this.repeatCount,device_serial:this.device || null,profile:this.profile,stop_on_failure:this.stopOnFailure,timeout_seconds:this.timeout,formats:this.formats()}));
|
|
this.batch.set(created);
|
|
await firstValueFrom(this.http.post(`/api/automation/batches/${created.id}/resume`,{}));
|
|
this.preview.set(null); this.notice.set('批次已开始,可暂停或查看每项结果'); await this.refresh();
|
|
}); }
|
|
selectBatch(id: string) { void this.action(async () => { this.batch.set(await firstValueFrom(this.http.get(`/api/automation/batches/${id}`))); }); }
|
|
deleteBatch(item: { id: string; name: string }) {
|
|
if (!window.confirm(`删除批次“${item.name}”及其全部任务记录、报告和文件?此操作无法撤销。`)) return;
|
|
void this.action(async () => {
|
|
await firstValueFrom(this.http.delete(`/api/automation/batches/${encodeURIComponent(item.id)}`));
|
|
if (this.batch()?.id === item.id) this.batch.set(null);
|
|
await this.refresh();
|
|
this.agent.fetchSessions();
|
|
this.notice.set('批次及关联任务、报告和文件已删除');
|
|
});
|
|
}
|
|
batchAction(action: string) { void this.action(async () => { this.batch.set(await firstValueFrom(this.http.post(`/api/automation/batches/${this.batch().id}/${action}`,{}))); await this.refresh(); }); }
|
|
pauseTask(id: string) { void this.action(async () => { await firstValueFrom(this.http.post(`/api/tasks/${encodeURIComponent(id)}/pause`,{})); this.notice.set('已请求暂停:当前步骤结束后释放设备'); await this.refresh(); }); }
|
|
guideTask(id: string) { void this.action(async () => {
|
|
if (!this.guidance.trim()) throw Error('请先填写指导内容');
|
|
const status = this.taskStatus();
|
|
await firstValueFrom(this.http.post(`/api/tasks/${encodeURIComponent(id)}/guide`,{instruction:this.guidance.trim()}));
|
|
this.guidance = '';
|
|
this.agent.fetchGuidance(id);
|
|
if (status === 'paused') this.notice.set('模型请求暂停,请在执行日志中恢复模型调用');
|
|
await this.refresh();
|
|
}); }
|
|
continueTask(id: string) { void this.action(async () => {
|
|
await firstValueFrom(this.http.post(`/api/tasks/${encodeURIComponent(id)}/continue`,{}));
|
|
this.notice.set('任务已申请恢复;设备忙时等待排队'); await this.refresh();
|
|
}); }
|
|
}
|