feat: show progress while preparing batch archives
This commit is contained in:
1 parent
868f611c36
commit
8dbe054a8c
8 files changed
+333
-7
No files matched your search
@@ -10,6 +10,7 @@ from pydantic import BaseModel, Field
|
||||
|
||||
from apps.admin_console.services.automation_store import identifier, store
|
||||
from apps.admin_console.services import automation_service as service
|
||||
from apps.admin_console.services import batch_archive as archive_service
|
||||
from apps.admin_console.services import report_export, task_import
|
||||
|
||||
router = APIRouter(prefix="/api/automation", tags=["automation"])
|
||||
@@ -128,10 +129,13 @@ def delete_batch(batch_id: str):
|
||||
except ValueError as exc:
|
||||
raise HTTPException(400, str(exc)) from exc
|
||||
try:
|
||||
if archive_service.status(key, require("batch", key))["status"] == "preparing":
|
||||
raise HTTPException(409, "报告正在打包,请完成后再删除批次")
|
||||
if not service.delete_batch(key):
|
||||
raise HTTPException(404, "批次不存在")
|
||||
except ValueError as exc:
|
||||
raise HTTPException(409, str(exc)) from exc
|
||||
archive_service.forget(key)
|
||||
return {"batch_id": key, "status": "deleted"}
|
||||
|
||||
|
||||
@@ -144,10 +148,30 @@ def batch_action(batch_id: str, action: Literal["pause", "resume", "retry_failed
|
||||
raise HTTPException(409, str(exc)) from exc
|
||||
|
||||
|
||||
@router.post("/batches/{batch_id}/archive/prepare")
|
||||
def prepare_batch_archive(batch_id: str):
|
||||
batch = require("batch", batch_id)
|
||||
return archive_service.prepare(batch_id, batch)
|
||||
|
||||
|
||||
@router.get("/batches/{batch_id}/archive/status")
|
||||
def batch_archive_status(batch_id: str):
|
||||
batch = require("batch", batch_id)
|
||||
return archive_service.status(batch_id, batch)
|
||||
|
||||
|
||||
@router.get("/batches/{batch_id}/archive")
|
||||
async def batch_archive(batch_id: str):
|
||||
require("batch", batch_id)
|
||||
path = await asyncio.to_thread(report_export.export_batch, batch_id)
|
||||
batch = require("batch", batch_id)
|
||||
if archive_service.status(batch_id, batch)["status"] == "preparing":
|
||||
raise HTTPException(409, "报告正在打包,请等待准备完成")
|
||||
path = archive_service.archive_path(batch_id)
|
||||
if (
|
||||
not path.is_file()
|
||||
or not batch.get("report_ready")
|
||||
and archive_service.status(batch_id, batch)["status"] != "ready"
|
||||
):
|
||||
path = await asyncio.to_thread(report_export.export_batch, batch_id)
|
||||
return FileResponse(path, filename=f"批次报告-{batch_id[:8]}.zip")
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
"""Background preparation and observable progress for batch report downloads."""
|
||||
|
||||
import threading
|
||||
import time
|
||||
|
||||
from apps.admin_console.services import report_export
|
||||
|
||||
_lock = threading.Lock()
|
||||
_states: dict[str, dict] = {}
|
||||
|
||||
|
||||
def archive_path(batch_id: str):
|
||||
return report_export.EXPORT_ROOT / "batches" / batch_id / "批次报告.zip"
|
||||
|
||||
|
||||
def _snapshot(state: dict) -> dict:
|
||||
elapsed = max(0, (state.get("finished_at") or time.monotonic()) - state["started_at"])
|
||||
completed, total = state["completed"], state["total"]
|
||||
eta = (
|
||||
max(0, round(elapsed / completed * (total - completed)))
|
||||
if completed and total > completed
|
||||
else None
|
||||
)
|
||||
return {
|
||||
"status": state["status"],
|
||||
"stage": state["stage"],
|
||||
"completed": completed,
|
||||
"total": total,
|
||||
"elapsed_seconds": round(elapsed),
|
||||
"eta_seconds": eta,
|
||||
"error": state.get("error"),
|
||||
}
|
||||
|
||||
|
||||
def status(batch_id: str, batch: dict) -> dict:
|
||||
with _lock:
|
||||
state = _states.get(batch_id)
|
||||
if state:
|
||||
return _snapshot(state)
|
||||
if batch.get("report_ready") and archive_path(batch_id).is_file():
|
||||
return {
|
||||
"status": "ready",
|
||||
"stage": "报告包已就绪",
|
||||
"completed": 0,
|
||||
"total": 0,
|
||||
"elapsed_seconds": 0,
|
||||
"eta_seconds": None,
|
||||
"error": None,
|
||||
}
|
||||
return {
|
||||
"status": "idle",
|
||||
"stage": "尚未开始打包",
|
||||
"completed": 0,
|
||||
"total": 0,
|
||||
"elapsed_seconds": 0,
|
||||
"eta_seconds": None,
|
||||
"error": None,
|
||||
}
|
||||
|
||||
|
||||
def prepare(batch_id: str, batch: dict) -> dict:
|
||||
with _lock:
|
||||
current = _states.get(batch_id)
|
||||
if current and current["status"] == "preparing":
|
||||
return _snapshot(current)
|
||||
if batch.get("report_ready") and archive_path(batch_id).is_file():
|
||||
_states.pop(batch_id, None)
|
||||
return status_from_cache()
|
||||
state = {
|
||||
"status": "preparing",
|
||||
"stage": "等待打包",
|
||||
"completed": 0,
|
||||
"total": len(report_export.store.jobs(batch_id)),
|
||||
"started_at": time.monotonic(),
|
||||
}
|
||||
_states[batch_id] = state
|
||||
|
||||
def update(completed: int, total: int, stage: str) -> None:
|
||||
with _lock:
|
||||
state.update(completed=completed, total=total, stage=stage)
|
||||
|
||||
def run() -> None:
|
||||
try:
|
||||
with report_export.EXPORT_LOCK:
|
||||
latest = report_export.store.get("batch", batch_id)
|
||||
if not (latest and latest.get("report_ready") and archive_path(batch_id).is_file()):
|
||||
report_export.export_batch(batch_id, progress=update)
|
||||
with _lock:
|
||||
state.update(status="ready", stage="报告包已就绪", finished_at=time.monotonic())
|
||||
except Exception as exc:
|
||||
with _lock:
|
||||
state.update(
|
||||
status="failed",
|
||||
stage="打包失败",
|
||||
error=str(exc)[:500],
|
||||
finished_at=time.monotonic(),
|
||||
)
|
||||
|
||||
threading.Thread(target=run, name=f"batch-archive-{batch_id[:8]}", daemon=True).start()
|
||||
return status(batch_id, batch)
|
||||
|
||||
|
||||
def status_from_cache() -> dict:
|
||||
return {
|
||||
"status": "ready",
|
||||
"stage": "报告包已就绪",
|
||||
"completed": 0,
|
||||
"total": 0,
|
||||
"elapsed_seconds": 0,
|
||||
"eta_seconds": None,
|
||||
"error": None,
|
||||
}
|
||||
|
||||
|
||||
def forget(batch_id: str) -> None:
|
||||
with _lock:
|
||||
_states.pop(batch_id, None)
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Evidence-backed Word/Excel artifacts, independent of the language model runtime."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import cast
|
||||
from typing import Callable, cast
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
@@ -695,7 +695,7 @@ def export_session_media(session_id: str, kind: str) -> Path:
|
||||
return target
|
||||
|
||||
|
||||
def export_batch(batch_id: str) -> Path:
|
||||
def export_batch(batch_id: str, progress: Callable[[int, int, str], None] | None = None) -> Path:
|
||||
with EXPORT_LOCK:
|
||||
batch = store.get("batch", identifier(batch_id))
|
||||
if not batch:
|
||||
@@ -704,6 +704,8 @@ def export_batch(batch_id: str) -> Path:
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
formats = batch["formats"]
|
||||
records = [session_report(job["session_id"]) for job in store.jobs(batch_id)]
|
||||
if progress:
|
||||
progress(0, len(records), "正在生成批次汇总")
|
||||
rows = [("任务编号", "任务名称", "执行状态", "验证结论", "异常原因")]
|
||||
rows += [
|
||||
(r["id"], r["name"], STATUS.get(r["status"], r["status"]), r["verdict"], r["error"])
|
||||
@@ -734,6 +736,8 @@ def export_batch(batch_id: str) -> Path:
|
||||
for path in outputs:
|
||||
archive.write(path, path.name)
|
||||
for number, report in enumerate(records, 1):
|
||||
if progress:
|
||||
progress(number - 1, len(records), f"正在打包任务 {number}/{len(records)}")
|
||||
prefix = f"任务报告/{number:04d}_{archive_case_name(report)}"
|
||||
for path in export_session(report["id"], formats):
|
||||
archive.write(path, f"{prefix}/{path.name}")
|
||||
@@ -741,5 +745,9 @@ def export_batch(batch_id: str) -> Path:
|
||||
archive.write(source, f"{prefix}/步骤截图/{name}")
|
||||
for source, name in session_videos(report):
|
||||
archive.write(source, f"{prefix}/任务录屏/{name}")
|
||||
if progress:
|
||||
progress(number, len(records), f"已打包任务 {number}/{len(records)}")
|
||||
if progress:
|
||||
progress(len(records), len(records), "正在完成压缩包")
|
||||
temporary.replace(target)
|
||||
return target
|
||||
+15
-2
@@ -71,8 +71,21 @@
|
||||
@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>}
|
||||
@if(b.status !== 'running' && b.status !== 'deleting'){<button [disabled]="busy()" (click)="batchAction('retry_failed')">复制失败项为新批次</button>}
|
||||
<a [href]="'/api/automation/batches/'+b.id+'/archive'">下载批次报告 ZIP</a>
|
||||
</div><p class="muted">服务重启后等待手动继续。失败项不会自动重试;暂停不打断当前任务。</p>
|
||||
<button type="button" [disabled]="archiveBusy()" (click)="downloadBatchArchive(b.id)">{{archiveBusy() && archiveBatchId() === b.id ? '正在准备报告包…' : '下载批次报告 ZIP'}}</button>
|
||||
</div><p class="muted">服务重启后等待手动继续。失败项不会自动重试;暂停不打断当前任务。</p>
|
||||
@if(archiveBatchId() === b.id && archiveProgress(); as progress){
|
||||
<div class="archive-progress" role="status" aria-live="polite" [class.archive-failed]="progress.status === 'failed'">
|
||||
<div class="archive-progress-heading">
|
||||
@if(progress.status === 'preparing'){<span class="archive-spinner" aria-hidden="true"></span>}
|
||||
<strong>{{progress.stage}}</strong>
|
||||
</div>
|
||||
@if(progress.status === 'preparing'){
|
||||
<div class="archive-track" role="progressbar" [attr.aria-valuenow]="progress.total ? progress.completed : null" [attr.aria-valuemin]="progress.total ? 0 : null" [attr.aria-valuemax]="progress.total || null" [attr.aria-label]="'批次报告打包进度:' + progress.stage"><span [style.width.%]="progress.total ? progress.completed / progress.total * 100 : 8"></span></div>
|
||||
<p>已处理 {{progress.completed}} / {{progress.total}} 个任务 · 已用 {{duration(progress.elapsed_seconds)}}@if(progress.eta_seconds !== null){ · 预计还需约 {{duration(progress.eta_seconds)}}}</p>
|
||||
} @else if(progress.status === 'failed'){<p>{{progress.error}}。可点击下载按钮重试。</p>}
|
||||
@else if(progress.status === 'ready'){<p>打包完成,下载已开始。</p>}
|
||||
</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>}
|
||||
</section>}
|
||||
|
||||
@@ -18,6 +18,14 @@ input[type=checkbox]{width:15px;height:15px;accent-color:#2563eb;vertical-align:
|
||||
.device{background:#f8fafc;padding:12px 16px;}.device label{margin:0;}
|
||||
.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;}
|
||||
.archive-progress-heading{display:flex;align-items:center;gap:8px;}
|
||||
.archive-progress p{margin:7px 0 0;font-size:12px;color:#475569;}
|
||||
.archive-progress.archive-failed{border-color:#fecdd3;background:#fff1f2;color:#be123c;}
|
||||
.archive-track{height:7px;margin-top:10px;border-radius:999px;background:#dbeafe;overflow:hidden;}
|
||||
.archive-track span{display:block;height:100%;border-radius:inherit;background:#3b82f6;transition:width .3s ease;}
|
||||
.archive-spinner{width:13px;height:13px;border:2px solid #bfdbfe;border-top-color:#2563eb;border-radius:50%;animation:archive-spin .8s linear infinite;}
|
||||
@keyframes archive-spin{to{transform:rotate(360deg);}}
|
||||
.code{font-family:Consolas,monospace;font-size:12px;line-height:1.6;resize:vertical;background:#f8fafc;}.table-wrap{max-height:260px;overflow:auto;border:1px solid #e2e8f0;border-radius:8px;margin-bottom:14px;}table{width:100%;border-collapse:collapse;}th,td{padding:9px 10px;border-bottom:1px solid #e2e8f0;text-align:left;overflow-wrap:anywhere;}th{background:#f8fafc;color:#475569;font-weight:500;}
|
||||
.preview{white-space:pre-wrap;max-height:400px;overflow:auto;font:inherit;color:#334155;background:#f8fafc;border-radius:8px;padding:12px;border:1px solid #e2e8f0;}
|
||||
.job{display:flex;flex-wrap:wrap;gap:8px;align-items:center;padding:10px 0;border-bottom:1px solid #f1f5f9;}.job:last-child{border-bottom:0;}.job button{flex:1;justify-content:flex-start;text-align:left;min-width:150px;}.job span{font-size:12px;background:#f1f5f9;color:#475569;border-radius:5px;padding:3px 7px;}.job small{width:100%;}details{margin:14px 0;}summary{color:#475569;cursor:pointer;padding:8px 0;}
|
||||
|
||||
+55
-1
@@ -14,12 +14,13 @@ describe('AutomationPanelComponent batch deletion', () => {
|
||||
agentStatus: signal('idle'),
|
||||
sessions: signal<any[]>([])
|
||||
};
|
||||
const http = jasmine.createSpyObj<HttpClient>('HttpClient', ['get', 'delete']);
|
||||
const http = jasmine.createSpyObj<HttpClient>('HttpClient', ['get', 'post', 'delete']);
|
||||
|
||||
beforeEach(async () => {
|
||||
(http.get.and as any).callFake((url: string) => of(url.endsWith('/preferences')
|
||||
? { formats: ['docx', 'xlsx'], batch_timeout_seconds: 1800 } : []));
|
||||
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();
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [AutomationPanelComponent],
|
||||
@@ -77,4 +78,57 @@ describe('AutomationPanelComponent batch deletion', () => {
|
||||
expect(root.querySelector('.report-body')?.textContent).toContain('第二行');
|
||||
expect(root.querySelector('.report-body table td')?.textContent).toBe('设置');
|
||||
});
|
||||
|
||||
it('prepares a batch archive before starting the browser download', async () => {
|
||||
const fixture = TestBed.createComponent(AutomationPanelComponent);
|
||||
const component = fixture.componentInstance;
|
||||
component.tab.set('batches');
|
||||
await new Promise<void>(resolve => setTimeout(resolve, 0));
|
||||
component.batch.set({ id: 'batch-3', name: '批量测试', status: 'completed', jobs: [] });
|
||||
const click = spyOn(HTMLAnchorElement.prototype, 'click');
|
||||
fixture.detectChanges();
|
||||
(fixture.nativeElement.querySelector('.actions button:last-child') as HTMLButtonElement).click();
|
||||
await new Promise<void>(resolve => setTimeout(resolve, 0));
|
||||
fixture.detectChanges();
|
||||
expect(http.post).toHaveBeenCalledWith('/api/automation/batches/batch-3/archive/prepare', {});
|
||||
expect(click).toHaveBeenCalled();
|
||||
expect(fixture.nativeElement.querySelector('.archive-progress')?.textContent).toContain('下载已开始');
|
||||
});
|
||||
|
||||
it('shows archive errors and lets the user retry', async () => {
|
||||
http.post.and.returnValue(of({ status: 'failed', stage: '打包失败', completed: 1, total: 2, elapsed_seconds: 3, eta_seconds: null, error: '截图文件无法读取' }));
|
||||
const fixture = TestBed.createComponent(AutomationPanelComponent);
|
||||
const component = fixture.componentInstance;
|
||||
component.tab.set('batches');
|
||||
await new Promise<void>(resolve => setTimeout(resolve, 0));
|
||||
component.batch.set({ id: 'batch-4', name: '批量测试', status: 'completed', jobs: [] });
|
||||
fixture.detectChanges();
|
||||
(fixture.nativeElement.querySelector('.actions button:last-child') as HTMLButtonElement).click();
|
||||
await new Promise<void>(resolve => setTimeout(resolve, 0));
|
||||
fixture.detectChanges();
|
||||
expect(fixture.nativeElement.querySelector('.archive-progress')?.textContent).toContain('截图文件无法读取');
|
||||
expect((fixture.nativeElement.querySelector('.actions button:last-child') as HTMLButtonElement).disabled).toBeFalse();
|
||||
});
|
||||
|
||||
it('shows live archive progress and estimated remaining time while preparing', async () => {
|
||||
http.post.and.returnValue(of({ status: 'preparing', stage: '正在打包任务 2/4', completed: 1, total: 4, elapsed_seconds: 12, eta_seconds: 36, error: null }));
|
||||
(http.get.and as any).callFake((url: string) => of(url.endsWith('/status')
|
||||
? { status: 'ready', stage: '报告包已就绪', completed: 4, total: 4, elapsed_seconds: 13, eta_seconds: null, error: null }
|
||||
: url.endsWith('/preferences') ? { formats: ['docx', 'xlsx'], batch_timeout_seconds: 1800 } : []));
|
||||
const fixture = TestBed.createComponent(AutomationPanelComponent);
|
||||
const component = fixture.componentInstance;
|
||||
component.tab.set('batches');
|
||||
await new Promise<void>(resolve => setTimeout(resolve, 0));
|
||||
component.batch.set({ id: 'batch-5', name: '批量测试', status: 'completed', jobs: [] });
|
||||
spyOn(HTMLAnchorElement.prototype, 'click');
|
||||
fixture.detectChanges();
|
||||
(fixture.nativeElement.querySelector('.actions button:last-child') as HTMLButtonElement).click();
|
||||
await new Promise<void>(resolve => setTimeout(resolve, 0));
|
||||
fixture.detectChanges();
|
||||
const progress = fixture.nativeElement.querySelector('.archive-progress') as HTMLElement;
|
||||
expect(progress.textContent).toContain('已处理 1 / 4 个任务');
|
||||
expect(progress.textContent).toContain('预计还需约 36 秒');
|
||||
expect(progress.querySelector('.archive-spinner')).not.toBeNull();
|
||||
fixture.destroy();
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,16 @@ import { AgentService } from '../../services/agent.service';
|
||||
import { SystemService } from '../../services/system.service';
|
||||
import { renderMarkdownToHtml } from '../../utils/markdown-parser.util';
|
||||
|
||||
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: [CommonModule, FormsModule],
|
||||
templateUrl: './automation-panel.component.html', styleUrl: './automation-panel.component.scss'
|
||||
@@ -16,6 +26,8 @@ export class AutomationPanelComponent {
|
||||
tab = signal('reports'); error = signal(''); notice = signal(''); busy = signal(false);
|
||||
batches = signal<any[]>([]); preview = signal<any>(null);
|
||||
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();
|
||||
@@ -40,6 +52,38 @@ export class AutomationPanelComponent {
|
||||
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; }
|
||||
renderReport(content: string) { return renderMarkdownToHtml(content || ''); }
|
||||
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('');
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from io import BytesIO
|
||||
import json
|
||||
import sqlite3
|
||||
import threading
|
||||
import time
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
from uuid import UUID
|
||||
@@ -108,6 +109,63 @@ def test_invalid_archive_and_empty_template():
|
||||
assert not task_import.parse_workbook(task_import.template())["valid"]
|
||||
|
||||
|
||||
def test_batch_archive_preparation_reports_progress_and_reuses_ready_file(
|
||||
client, isolated, monkeypatch
|
||||
):
|
||||
batch = service.create_batch(payload())
|
||||
batch_id = batch["id"]
|
||||
started = threading.Event()
|
||||
release = threading.Event()
|
||||
calls = []
|
||||
|
||||
def slow_export(identifier, progress=None):
|
||||
calls.append(identifier)
|
||||
progress(0, 2, "正在打包任务 1/2")
|
||||
started.set()
|
||||
assert release.wait(5)
|
||||
progress(2, 2, "正在完成压缩包")
|
||||
path = reports.EXPORT_ROOT / "batches" / identifier / "批次报告.zip"
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_bytes(b"prepared archive")
|
||||
return path
|
||||
|
||||
monkeypatch.setattr(reports, "export_batch", slow_export)
|
||||
base = f"/api/automation/batches/{batch_id}/archive"
|
||||
try:
|
||||
response = client.post(base + "/prepare")
|
||||
assert response.status_code == 200
|
||||
assert response.json()["status"] == "preparing"
|
||||
assert started.wait(2)
|
||||
progress = client.get(base + "/status").json()
|
||||
assert progress["total"] == 2
|
||||
assert progress["stage"] == "正在打包任务 1/2"
|
||||
assert client.get(base).status_code == 409
|
||||
finally:
|
||||
release.set()
|
||||
for _ in range(50):
|
||||
if client.get(base + "/status").json()["status"] == "ready":
|
||||
break
|
||||
time.sleep(0.02)
|
||||
assert client.get(base + "/status").json()["status"] == "ready"
|
||||
assert client.get(base).content == b"prepared archive"
|
||||
assert calls == [batch_id]
|
||||
|
||||
|
||||
def test_completed_batch_reuses_existing_archive(client, isolated, monkeypatch):
|
||||
batch = service.create_batch(payload())
|
||||
batch_id = batch["id"]
|
||||
isolated.put("batch", batch_id, {**batch, "status": "completed", "report_ready": True})
|
||||
path = reports.EXPORT_ROOT / "batches" / batch_id / "批次报告.zip"
|
||||
path.parent.mkdir(parents=True)
|
||||
path.write_bytes(b"existing archive")
|
||||
exporter = Mock(side_effect=AssertionError("archive must not be rebuilt"))
|
||||
monkeypatch.setattr(reports, "export_batch", exporter)
|
||||
base = f"/api/automation/batches/{batch_id}/archive"
|
||||
assert client.post(base + "/prepare").json()["status"] == "ready"
|
||||
assert client.get(base).content == b"existing archive"
|
||||
exporter.assert_not_called()
|
||||
|
||||
|
||||
def test_preview_does_not_enqueue(client, isolated):
|
||||
response = client.post(
|
||||
"/api/automation/import/preview",
|
||||
|
||||
Reference in new issue
Block a user