import { TaskOutcomeComponent } from '../task-outcome/task-outcome.component'; 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'; import { EvidenceSourcesComponent } from '../evidence-sources/evidence-sources.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: [TaskOutcomeComponent,FavoriteButtonComponent, ReportMarkdownComponent, CommonModule, FormsModule, TestResultBadgeComponent, TestResultCardComponent, TaskPluginsComponent, EvidenceSourcesComponent], templateUrl: './automation-panel.component.html', styleUrl: './automation-panel.component.scss' }) export class AutomationPanelComponent { taskPlugins: TaskPluginOptions = {}; queueRequested = output(); agent = inject(AgentService); system = inject(SystemService); http = inject(HttpClient); tab = signal('reports'); error = signal(''); notice = signal(''); busy = signal(false); batches = signal([]); preview = signal(null); selectedRows = signal>(new Set()); selectedCount = computed(() => this.selectedRows().size); batch = signal(null); report = signal(null); archiveProgress = signal(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('/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(`/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)[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(`${base}/prepare`, {})); while (!this.destroyed && progress.status === 'preparing') { this.archiveProgress.set(progress); await new Promise(resolve => setTimeout(resolve, 1000)); if (this.destroyed) return; progress = await firstValueFrom(this.http.get(`${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) { 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('/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(`/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(`/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(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 | 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(); }); } }