feat: support batch repetition and selective case import
This commit is contained in:
1 parent
df66897ff4
commit
4d6560dbd4
16 files changed
+296
-37
No files matched your search
@@ -30,6 +30,7 @@ class BatchRequest(ReportPreferences):
|
||||
device_serial: str = Field(min_length=1, max_length=200)
|
||||
profile: Literal["flash", "pro"] = "flash"
|
||||
stop_on_failure: bool = False
|
||||
repeat_count: int = Field(default=1, ge=1, strict=True)
|
||||
timeout_seconds: int = Field(
|
||||
default_factory=lambda: settings.ARTEMIS_TASK_TIMEOUT_SECONDS,
|
||||
ge=30,
|
||||
@@ -52,6 +53,7 @@ def preferences():
|
||||
return {
|
||||
**store.preferences(),
|
||||
"batch_timeout_seconds": settings.ARTEMIS_TASK_TIMEOUT_SECONDS,
|
||||
"batch_max_executions": settings.ARTEMIS_BATCH_MAX_EXECUTIONS,
|
||||
}
|
||||
|
||||
|
||||
@@ -256,7 +258,12 @@ def history(
|
||||
[*args, size, (page - 1) * size],
|
||||
).fetchall()
|
||||
items = [dict(row) for row in rows]
|
||||
jobs = store.jobs_for_sessions([item["session_id"] for item in items])
|
||||
for item in items:
|
||||
if item["case_id"]:
|
||||
if job := jobs.get(item["session_id"]):
|
||||
item["name"] = task_import.execution_title(job)
|
||||
item["iteration"] = job.get("iteration", 1)
|
||||
item["repeat_count"] = job.get("repeat_count", 1)
|
||||
elif item["case_id"]:
|
||||
item["name"] = task_import.case_title(item)
|
||||
return {"items": items, "total": total, "page": page, "size": size}
|
||||
@@ -28,7 +28,7 @@ try:
|
||||
from admin_console.services.media_service import media_service
|
||||
from admin_console.services.model_service import model_service
|
||||
from admin_console.services.automation_store import store
|
||||
from admin_console.services.task_import import case_title
|
||||
from admin_console.services.task_import import execution_title
|
||||
except ImportError:
|
||||
from apps.admin_console.core.state import state
|
||||
from apps.admin_console.database.repositories.session_repository import session_repo
|
||||
@@ -36,7 +36,7 @@ except ImportError:
|
||||
from apps.admin_console.services.media_service import media_service
|
||||
from apps.admin_console.services.model_service import model_service
|
||||
from apps.admin_console.services.automation_store import store
|
||||
from apps.admin_console.services.task_import import case_title
|
||||
from apps.admin_console.services.task_import import execution_title
|
||||
|
||||
|
||||
router = APIRouter(tags=["sessions"])
|
||||
@@ -83,7 +83,9 @@ def _list_sessions_sync():
|
||||
if job:
|
||||
case = job.get("case") or {}
|
||||
row_dict["case_id"] = case.get("case_id") or ""
|
||||
row_dict["display_name"] = case_title(case) or row_dict.get("initial_goal")
|
||||
row_dict["display_name"] = execution_title(job) or row_dict.get("initial_goal")
|
||||
row_dict["iteration"] = job.get("iteration", 1)
|
||||
row_dict["repeat_count"] = job.get("repeat_count", 1)
|
||||
row_dict["error"] = job.get("error") or ""
|
||||
if job.get("status") in {"completed", "failed", "cancelled", "interrupted", "skipped"}:
|
||||
row_dict["status"] = job["status"]
|
||||
|
||||
@@ -9,7 +9,7 @@ from uuid import UUID
|
||||
|
||||
from apps.admin_console.core.state import state
|
||||
from apps.admin_console.services.automation_store import RECOVERY_ERRORS, store, new_id
|
||||
from apps.admin_console.services.task_import import case_goal, case_title
|
||||
from apps.admin_console.services.task_import import case_goal, execution_title
|
||||
from artemis.config.settings import settings
|
||||
from artemis.runtime.task_limits import task_timeout
|
||||
|
||||
@@ -297,7 +297,7 @@ async def tick_batches():
|
||||
runtime_overrides=options,
|
||||
session_id=job["session_id"],
|
||||
ingress="batch",
|
||||
display_name=case_title(job["case"]),
|
||||
display_name=execution_title(job),
|
||||
)
|
||||
if response.get("status") == "rejected" or not response.get("tasks"):
|
||||
raise ValueError(response.get("error") or "队列拒绝接收任务")
|
||||
@@ -330,6 +330,15 @@ def has_legacy_replay(batch_id: str) -> bool:
|
||||
|
||||
|
||||
def create_batch(payload: dict) -> dict:
|
||||
repeat_count = payload.get("repeat_count", 1)
|
||||
if type(repeat_count) is not int or repeat_count < 1:
|
||||
raise ValueError("循环次数必须为正整数")
|
||||
cases = payload["cases"]
|
||||
total = len(cases) * repeat_count
|
||||
if not cases or total > settings.ARTEMIS_BATCH_MAX_EXECUTIONS:
|
||||
raise ValueError(
|
||||
f"请选择用例;用例数 × 循环次数不能超过 {settings.ARTEMIS_BATCH_MAX_EXECUTIONS} 次执行"
|
||||
)
|
||||
bid = new_id()
|
||||
batch = {
|
||||
"id": bid,
|
||||
@@ -342,21 +351,28 @@ def create_batch(payload: dict) -> dict:
|
||||
"timeout_seconds": payload.get("timeout_seconds") or settings.ARTEMIS_TASK_TIMEOUT_SECONDS,
|
||||
"stop_on_failure": payload.get("stop_on_failure", False),
|
||||
"request_id": payload.get("request_id"),
|
||||
"repeat_count": repeat_count,
|
||||
"case_count": len(cases),
|
||||
"total_executions": total,
|
||||
}
|
||||
jobs = []
|
||||
for number, case in enumerate(payload["cases"], 1):
|
||||
jobs.append(
|
||||
{
|
||||
"session_id": new_id(),
|
||||
"batch_id": bid,
|
||||
"position": number,
|
||||
"case": case,
|
||||
"status": "pending",
|
||||
"formats": batch["formats"],
|
||||
"created_at": time.time(),
|
||||
"device_serial": batch["device_serial"],
|
||||
}
|
||||
)
|
||||
for iteration in range(1, repeat_count + 1):
|
||||
for number, case in enumerate(cases, 1):
|
||||
jobs.append(
|
||||
{
|
||||
"session_id": new_id(),
|
||||
"batch_id": bid,
|
||||
"position": len(jobs) + 1,
|
||||
"iteration": iteration,
|
||||
"repeat_count": repeat_count,
|
||||
"case_position": number,
|
||||
"case": case,
|
||||
"status": "pending",
|
||||
"formats": batch["formats"],
|
||||
"created_at": batch["created_at"],
|
||||
"device_serial": batch["device_serial"],
|
||||
}
|
||||
)
|
||||
return store.create_batch(batch, jobs, payload)
|
||||
|
||||
|
||||
@@ -412,7 +428,13 @@ def batch_action(batch_id: str, action: str) -> dict:
|
||||
if not failed:
|
||||
raise ValueError("没有可重跑的失败或中断任务")
|
||||
revised = create_batch(
|
||||
{**batch, "name": batch["name"] + " · 失败项重跑", "cases": [j["case"] for j in failed]}
|
||||
{
|
||||
**batch,
|
||||
"name": batch["name"] + " · 失败项重跑",
|
||||
"cases": [j["case"] for j in failed],
|
||||
"repeat_count": 1,
|
||||
"request_id": None,
|
||||
}
|
||||
)
|
||||
return revised
|
||||
else:
|
||||
|
||||
@@ -22,7 +22,7 @@ from artemis.config import IMAGES_DIR, TRACES_PATH, WORKSPACE_ROOT
|
||||
from apps.admin_console.database.repositories.session_repository import SessionRepository
|
||||
from apps.admin_console.database.repositories.step_repository import StepRepository
|
||||
from apps.admin_console.services.automation_store import store, identifier
|
||||
from apps.admin_console.services.task_import import case_title
|
||||
from apps.admin_console.services.task_import import execution_title
|
||||
|
||||
EXPORT_ROOT = WORKSPACE_ROOT / "artifacts" / "reports"
|
||||
EXPORT_LOCK = threading.RLock()
|
||||
@@ -190,7 +190,9 @@ def session_report(session_id: str, db_path=None) -> dict:
|
||||
)
|
||||
return {
|
||||
"id": session_id,
|
||||
"name": case_title(case) or session.get("initial_goal", session_id)[:120],
|
||||
"name": execution_title(meta) or session.get("initial_goal", session_id)[:120],
|
||||
"iteration": meta.get("iteration", 1),
|
||||
"repeat_count": meta.get("repeat_count", 1),
|
||||
"goal": case.get("goal") or session.get("initial_goal", ""),
|
||||
"expected": case.get("expected", ""),
|
||||
"status": status,
|
||||
|
||||
@@ -189,3 +189,11 @@ def case_title(case: dict) -> str:
|
||||
case_id = str(case.get("case_id") or "").strip()
|
||||
name = str(case.get("name") or "").strip()
|
||||
return " ".join(part for part in (case_id, name) if part)
|
||||
|
||||
|
||||
def execution_title(job: dict) -> str:
|
||||
"""Distinguish repeated executions without altering the original case or prompt."""
|
||||
title = case_title(job.get("case") or {})
|
||||
if job.get("repeat_count", 1) > 1:
|
||||
title += f" · 第 {job['iteration']} / {job['repeat_count']} 轮"
|
||||
return title
|
||||
+20
-5
@@ -47,26 +47,41 @@
|
||||
<section><h3>导入任务</h3><p>每行一个用例,仅「Artemis提示词」作为任务正文;「任务配置(JSON)」可设置每项模型、模式、超时和录屏。「前置约束提取」仅供查看与搜索。示例在模板的「填写说明」页。</p>
|
||||
<div class="actions"><a href="/api/automation/template">下载 Excel 模板</a><label class="upload">导入 Excel<input type="file" accept=".xlsx" [disabled]="busy()" (change)="upload($event)"></label></div>
|
||||
@if(preview(); as p) {
|
||||
<p>有效任务 {{p.tasks.length}} 条 · 错误 {{p.errors.length}} 条</p>
|
||||
<div class="selection-toolbar"><span>已选 <strong>{{selectedCount()}}</strong> / {{p.tasks.length}} 个用例 · 错误 {{p.errors.length}} 条</span><button type="button" [disabled]="busy() || !p.tasks.length" (click)="toggleAllRows()">{{selectedCount() === p.tasks.length ? '全不选' : '全选'}}</button></div>
|
||||
@for(e of p.errors; track e.row){<p class="error">第 {{e.row}} 行:{{e.message}}</p>}
|
||||
<div class="table-wrap"><table><thead><tr><th>行</th><th>用例</th><th>提示词</th><th>任务配置</th></tr></thead><tbody>@for(t of p.tasks; track t.row){<tr><td>{{t.row}}</td><td>{{t.name}}</td><td>{{t.goal}}</td><td>{{t.task_config | json}}</td></tr>}</tbody></table></div>
|
||||
<div class="case-preview-list" aria-label="选择导入用例">
|
||||
@for(t of p.tasks; track t.row){
|
||||
<div class="case-preview" [class.unselected]="!selectedRows().has(t.row)">
|
||||
<span class="selection-column"><input type="checkbox" [checked]="selectedRows().has(t.row)" [disabled]="busy()" (change)="toggleRow(t.row)" [attr.aria-label]="'导入用例:' + (t.case_id ? t.case_id + ' ' : '') + t.name"></span>
|
||||
<div class="case-preview-content"><strong>{{t.case_id ? t.case_id + ' ' : ''}}{{t.name}}</strong><small>第 {{t.row}} 行</small>
|
||||
<p class="case-goal" [title]="t.goal">{{t.goal}}</p>
|
||||
@if(caseConfig(t.task_config); as config){<details><summary>自定义参数</summary><pre>{{config}}</pre></details>}@else{<small>使用批次 / 全局参数</small>}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<label>批次名称<input [(ngModel)]="name" maxlength="200"></label>
|
||||
<div class="fields"><label>执行模式<select [(ngModel)]="profile"><option value="flash">Flash</option><option value="pro">Pro</option></select></label><label>单任务时限(秒)<input type="number" min="30" max="86400" [(ngModel)]="timeout"></label></div>
|
||||
<label for="batch-repeat-count">整批循环次数<input id="batch-repeat-count" type="number" min="1" [max]="maxExecutions" step="1" [(ngModel)]="repeatCount"></label>
|
||||
<p class="execution-summary">{{selectedCount()}} 个用例 × {{repeatCount || 0}} 轮,共 {{selectedCount() * (repeatCount || 0)}} 次执行</p>
|
||||
<p class="muted">每轮按表格顺序执行所选用例,分别保存结果。未勾选用例不会导入。</p>
|
||||
@if(!validRepetitions()){<p class="error">循环次数须为正整数,执行总数不超过 {{maxExecutions}}。</p>}
|
||||
<label><input type="checkbox" [(ngModel)]="stopOnFailure"> 失败即停止后续任务(默认继续)</label>
|
||||
<label><input type="checkbox" [(ngModel)]="word"> Word</label><label><input type="checkbox" [(ngModel)]="excel"> Excel</label>
|
||||
<button class="primary" [disabled]="busy() || !p.valid || !device || !name.trim() || !formats().length" (click)="startBatch()">确认导入并开始执行</button>
|
||||
<button class="primary" [disabled]="busy() || !p.valid || !selectedCount() || !validRepetitions() || !device || !name.trim() || !formats().length" (click)="startBatch()">确认导入并开始执行</button>
|
||||
}
|
||||
</section>
|
||||
<section><h3>批次记录</h3>
|
||||
@for(b of batches(); track b.id){
|
||||
<div class="batch-record">
|
||||
<button class="record" [class.selected]="batch()?.id === b.id" (click)="selectBatch(b.id)"><strong>{{b.name}}</strong><span>{{label(b.status)}} · {{b.created_at*1000 | date:'MM-dd HH:mm'}}</span></button>
|
||||
<button class="record" [class.selected]="batch()?.id === b.id" (click)="selectBatch(b.id)"><strong>{{b.name}}</strong><span>{{label(b.status)}} · {{b.created_at*1000 | date:'MM-dd HH:mm'}} · {{b.repeat_count || 1}} 轮@if(b.total_executions){ · {{b.total_executions}} 次执行}</span></button>
|
||||
<button class="delete-batch" type="button" [disabled]="busy() || b.status === 'running'" (click)="deleteBatch(b)" [attr.aria-label]="'删除批次:' + b.name" [title]="b.status === 'running' ? '执行中的批次不能删除' : '删除批次及其任务、报告和文件'"><span class="material-symbols-outlined">delete_outline</span></button>
|
||||
</div>
|
||||
}
|
||||
@if(!batches().length){<p>暂无批次。</p>}
|
||||
</section>
|
||||
@if(batch(); as b){<section><h3>{{b.name}}</h3><p>{{label(b.status)}} · {{b.reason}}</p>
|
||||
<p class="execution-summary">{{b.case_count || b.jobs?.length || 0}} 个用例 · {{b.repeat_count || 1}} 轮 · 共 {{b.total_executions || b.jobs?.length || 0}} 次执行</p>
|
||||
<div class="actions">
|
||||
@if(b.status === 'running'){<button [disabled]="busy()" (click)="batchAction('pause')">暂停后续任务</button>}
|
||||
@if(b.status === 'draft' || b.status === 'paused'){<button [disabled]="busy()" (click)="batchAction('resume')">{{b.status === 'draft' ? '开始执行' : '继续未开始任务'}}</button>}
|
||||
@@ -87,6 +102,6 @@
|
||||
</div>
|
||||
}
|
||||
@if(b.report_error){<p class="error">报告生成失败:{{b.report_error}}</p>}
|
||||
@for(j of b.jobs; track j.session_id){<div class="job"><button (click)="agent.selectSession(j.session_id,true); tab.set('reports')" [title]="j.case.goal">{{j.case.case_id ? j.case.case_id + ' ' : ''}}{{j.case.name}}</button><span>{{label(j.status)}}</span>@if(j.error){<small>{{j.error}}</small>}</div>}
|
||||
@for(j of b.jobs; track j.session_id){<div class="job"><button (click)="agent.selectSession(j.session_id,true); tab.set('reports')" [title]="j.case.goal">{{j.case.case_id ? j.case.case_id + ' ' : ''}}{{j.case.name}}@if(j.repeat_count > 1){ · 第 {{j.iteration}} / {{j.repeat_count}} 轮}</button><span>{{label(j.status)}}</span>@if(j.error){<small>{{j.error}}</small>}</div>}
|
||||
</section>}
|
||||
}
|
||||
@@ -16,6 +16,13 @@ input:not([type=checkbox]):not([type=file]),select,textarea{box-sizing:border-bo
|
||||
input[type=checkbox]{width:15px;height:15px;accent-color:#2563eb;vertical-align:-3px;margin-right:6px;}
|
||||
.upload{position:relative;margin:0;color:#fff;background:#2563eb;border-color:#2563eb;}.upload:hover{background:#1d4ed8;border-color:#1d4ed8;}.upload input{position:absolute;inset:0;width:100%;height:100%;opacity:0;cursor:pointer;}
|
||||
.device{background:#f8fafc;padding:12px 16px;}.device label{margin:0;}
|
||||
.selection-toolbar{display:flex;align-items:center;justify-content:space-between;gap:8px;margin:12px 0;color:#475569;}
|
||||
.selection-column{flex:0 0 18px;text-align:center;}.selection-column input{margin:0;}
|
||||
.case-preview-list{max-height:320px;overflow:auto;border:1px solid #e2e8f0;border-radius:8px;margin-bottom:14px;}
|
||||
.case-preview{display:flex;align-items:flex-start;gap:10px;padding:12px;border-bottom:1px solid #e2e8f0;&:last-child{border-bottom:0;}&.unselected{background:#f8fafc;color:#64748b;}}
|
||||
.case-preview-content{min-width:0;flex:1;strong{display:block;overflow-wrap:anywhere;}small{display:block;}details{margin:5px 0 0;}summary{padding:0;font-size:12px;color:#2563eb;}pre{max-height:160px;overflow:auto;white-space:pre-wrap;overflow-wrap:anywhere;background:#f8fafc;border-radius:6px;padding:8px;font-size:11px;}}
|
||||
.case-goal{display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2;overflow:hidden;white-space:pre-wrap;margin:5px 0;font-size:12px;}
|
||||
.execution-summary{padding:9px 12px;border-radius:8px;background:#eff6ff;color:#1d4ed8;font-weight:500;}
|
||||
.record{display:flex;width:100%;text-align:left;align-items:flex-start;flex-direction:column;gap:4px;margin:8px 0 0;padding:12px;overflow-wrap:anywhere;}.record span{color:#64748b;font-size:12px;font-weight:400;}.record.selected{border-color:#93c5fd;background:#eff6ff;}.record.selected strong{color:#1d4ed8;}
|
||||
.batch-record{display:flex;gap:8px;align-items:stretch;margin-top:8px;}.batch-record .record{flex:1;min-width:0;width:auto;margin:0;}.batch-record .delete-batch{flex:0 0 40px;min-width:40px;padding:0;color:#b91c1c;}.batch-record .delete-batch:hover:not(:disabled){background:#fff1f2;border-color:#fda4af;}.batch-record .delete-batch .material-symbols-outlined{font-size:19px;}
|
||||
.archive-progress{margin:12px 0;padding:11px 13px;border:1px solid #bfdbfe;border-radius:10px;background:#f8fbff;color:#1e40af;}
|
||||
|
||||
+55
@@ -22,6 +22,7 @@ describe('AutomationPanelComponent batch deletion', () => {
|
||||
http.delete.and.returnValue(of({ status: 'deleted' }));
|
||||
http.post.and.returnValue(of({ status: 'ready', stage: '报告包已就绪', completed: 0, total: 0, elapsed_seconds: 0, eta_seconds: null, error: null }));
|
||||
http.delete.calls.reset();
|
||||
http.post.calls.reset();
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [AutomationPanelComponent],
|
||||
providers: [
|
||||
@@ -32,6 +33,60 @@ describe('AutomationPanelComponent batch deletion', () => {
|
||||
}).compileComponents();
|
||||
});
|
||||
|
||||
it('selects all imported cases by default, toggles all and submits only checked cases with rounds', async () => {
|
||||
const tasks = [
|
||||
{ row: 2, case_id: '1.1', name: '打开设置', goal: '观察设置', task_config: { model: 'mimo-v2.6-pro' } },
|
||||
{ row: 3, case_id: '1.2', name: '观察首页', goal: '观察首页', task_config: {} }
|
||||
];
|
||||
(http.post.and as any).callFake((url: string) => of(url.endsWith('/preview')
|
||||
? { valid: true, tasks, errors: [] } : url.endsWith('/batches')
|
||||
? { id: 'selected-batch', status: 'draft' } : { id: 'selected-batch', status: 'running' }));
|
||||
const fixture = TestBed.createComponent(AutomationPanelComponent);
|
||||
const component = fixture.componentInstance;
|
||||
component.tab.set('batches');
|
||||
component.upload({ target: { files: [new File(['test'], '用例.xlsx')] } } as unknown as Event);
|
||||
await new Promise<void>(resolve => setTimeout(resolve, 0));
|
||||
fixture.detectChanges();
|
||||
const checkboxes = () => [...fixture.nativeElement.querySelectorAll('.selection-column input')] as HTMLInputElement[];
|
||||
expect(checkboxes().every(c => c.checked)).toBeTrue();
|
||||
const toggle = fixture.nativeElement.querySelector('.selection-toolbar button') as HTMLButtonElement;
|
||||
expect(toggle.textContent).toContain('全不选');
|
||||
toggle.click(); fixture.detectChanges();
|
||||
expect(component.selectedCount()).toBe(0);
|
||||
expect((fixture.nativeElement.querySelector('.primary') as HTMLButtonElement).disabled).toBeTrue();
|
||||
expect(toggle.textContent).toContain('全选');
|
||||
toggle.click(); fixture.detectChanges();
|
||||
expect(component.selectedCount()).toBe(2);
|
||||
checkboxes()[1].click();
|
||||
component.repeatCount = 3; component.device = 'test-device';
|
||||
fixture.detectChanges();
|
||||
expect(fixture.nativeElement.querySelector('.execution-summary').textContent).toContain('共 3 次执行');
|
||||
component.startBatch();
|
||||
await new Promise<void>(resolve => setTimeout(resolve, 0));
|
||||
const request = http.post.calls.allArgs().find(args => args[0] === '/api/automation/batches');
|
||||
expect(request?.[1]).toEqual(jasmine.objectContaining({
|
||||
cases: [{ case_id: '1.1', name: '打开设置', goal: '观察设置', task_config: { model: 'mimo-v2.6-pro' } }],
|
||||
repeat_count: 3, device_serial: 'test-device'
|
||||
}));
|
||||
});
|
||||
|
||||
it('rejects empty selections and invalid or excessive loop counts without creating a batch', async () => {
|
||||
const fixture = TestBed.createComponent(AutomationPanelComponent);
|
||||
const component = fixture.componentInstance;
|
||||
component.preview.set({ valid: true, tasks: [{ row: 2, name: '检查', goal: '观察' }], errors: [] });
|
||||
component.device = 'test-device';
|
||||
component.startBatch();
|
||||
await new Promise<void>(resolve => setTimeout(resolve, 0));
|
||||
expect(component.error()).toContain('至少勾选');
|
||||
component.selectedRows.set(new Set([2]));
|
||||
for (const count of [0, 1.5, 10001]) {
|
||||
component.repeatCount = count; component.startBatch();
|
||||
await new Promise<void>(resolve => setTimeout(resolve, 0));
|
||||
expect(component.error()).toContain('循环次数');
|
||||
}
|
||||
expect(http.post).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('confirms deletion and removes the batch from the visible list', async () => {
|
||||
const fixture = TestBed.createComponent(AutomationPanelComponent);
|
||||
fixture.componentInstance.tab.set('batches');
|
||||
|
||||
@@ -25,6 +25,8 @@ export class AutomationPanelComponent {
|
||||
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);
|
||||
@@ -37,10 +39,11 @@ export class AutomationPanelComponent {
|
||||
});
|
||||
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.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); });
|
||||
@@ -104,13 +107,32 @@ export class AutomationPanelComponent {
|
||||
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); const data = new FormData(); data.append('file',file); this.preview.set(await firstValueFrom(this.http.post('/api/automation/import/preview',data))); this.name = file.name.replace(/\.xlsx$/i,''); });
|
||||
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}`);
|
||||
if (!this.device) throw Error('请选择执行设备');
|
||||
const cases = this.preview().tasks.map(({row, ...c}: any) => c);
|
||||
const created: any = await firstValueFrom(this.http.post('/api/automation/batches',{name:this.name.trim(),cases,device_serial:this.device,profile:this.profile,stop_on_failure:this.stopOnFailure,timeout_seconds:this.timeout,formats:this.formats()}));
|
||||
const cases = this.preview().tasks.filter((t: any) => this.selectedRows().has(t.row)).map(({row, ...c}: any) => c);
|
||||
const created: any = await firstValueFrom(this.http.post('/api/automation/batches',{name:this.name.trim(),cases,repeat_count:this.repeatCount,device_serial:this.device,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();
|
||||
|
||||
Reference in new issue
Block a user