Add dark theme, richer Markdown and readable plugin results. Improve Android input evidence, response integrity and diagnostics. Unify lifecycle, guidance receipts and evidence sources across clients. Keep direct action dispatch without an added AI quota approval layer. Align version metadata at 2.2.0 and bundle Helper 1.2.2. Validation: 3063 backend tests before the version bump; 44 after. Frontend: 317 tests and production build passed.
79 lines
7.5 KiB
TypeScript
79 lines
7.5 KiB
TypeScript
import { Component, computed, effect, inject, input, output, signal } from '@angular/core';
|
|
import { DatePipe } from '@angular/common';
|
|
import { FormsModule } from '@angular/forms';
|
|
import { HttpClient } from '@angular/common/http';
|
|
import { CaseReviewResponse, CaseTestResult } from '../../core/models/session.model';
|
|
import { ReportMarkdownComponent } from '../report-markdown/report-markdown.component';
|
|
import { TestResultBadgeComponent, testResultSourceLabel } from './test-result-badge.component';
|
|
|
|
@Component({
|
|
selector: 'app-test-result-card',
|
|
standalone: true,
|
|
imports: [ReportMarkdownComponent, TestResultBadgeComponent, DatePipe, FormsModule],
|
|
template: `<section [class]="'test-result-card ' + result().status" aria-label="用例测试结果" role="status">
|
|
<header><span class="caption">用例测试结果 <small>{{sourceLabel()}}</small></span><app-test-result-badge [result]="result()" [prominent]="true" /></header>
|
|
<app-report-markdown class="reason" [content]="result().reason" />
|
|
@if(result().reviewed_at){<p class="review-time">复验于 {{result().reviewed_at! * 1000 | date:'yyyy-MM-dd HH:mm:ss'}}</p>}
|
|
@if(result().ai_result; as original){<details><summary>{{original.verification_skipped ? '原始结果 · 未做 AI 判定' : '原 AI 结论 · ' + (original.status === 'passed' ? '测试通过' : original.status === 'failed' ? '测试不通过' : original.status === 'blocked' ? '测试阻塞' : '未验证')}}</summary><app-report-markdown class="reason" [content]="original.reason" />@if(original.evidence.length){<ol>@for(item of original.evidence; track $index){<li><app-report-markdown [content]="item" /></li>}</ol>}</details>}
|
|
@if(result().evidence.length){<details><summary>查看验收证据 · {{result().evidence.length}} 条</summary><ol>@for(item of result().evidence; track $index){<li><app-report-markdown [content]="item" /></li>}</ol></details>}
|
|
@if(canReview()){
|
|
<div class="review-toolbar"><button type="button" [disabled]="saving()" (click)="toggleReview()" [attr.aria-expanded]="reviewing()">{{result().source === 'human' ? '修改人工复验' : '人工复验'}}</button>@if(notice()){<span role="status">{{notice()}}</span>}</div>
|
|
@if(reviewing()){
|
|
<div class="review-form" aria-label="人工复验">
|
|
<label>复验备注(选填)<textarea [(ngModel)]="note" [disabled]="saving()" rows="2" maxlength="2000" placeholder="填写人工检查依据或发现的问题"></textarea></label>
|
|
<div class="review-actions"><button type="button" class="confirm-pass" [disabled]="saving()" (click)="saveReview('passed')">确认通过</button><button type="button" class="confirm-fail" [disabled]="saving()" (click)="saveReview('failed')">确认不通过</button><button type="button" [disabled]="saving()" (click)="reviewing.set(false)">取消</button>@if(saving()){<span role="status">正在保存…</span>}</div>
|
|
@if(error()){<p class="review-error" role="alert">{{error()}}</p>}
|
|
</div>
|
|
}
|
|
}
|
|
</section>`,
|
|
styles: [`
|
|
:host{display:block;margin:8px 0}
|
|
.test-result-card{border:1px solid var(--app-border-control, #cbd5e1);border-left:3px solid var(--app-border-control, #64748b);border-radius:10px;padding:12px 16px;background:var(--app-surface-soft, #f8fafc);color:var(--app-text-control, #334155)}
|
|
.passed{border-color:var(--app-success-border, #bbf7d0);border-left-color:#15803d;background:var(--app-success-soft, #f0fdf4)}
|
|
.failed{border-color:var(--app-error-border, #fecaca);border-left-color:#b91c1c;background:var(--app-error-soft, #fef2f2)}
|
|
.inconclusive,.blocked{border-color:var(--app-warning-border, #fde68a);border-left-color:#b45309;background:var(--app-warning-soft, #fffbeb)}
|
|
header{display:flex;align-items:center;justify-content:space-between;gap:8px;flex-wrap:wrap}
|
|
.caption{font-size:12px;font-weight:600;color:var(--app-text-secondary, #475569)}
|
|
.caption small{font-size:11px;font-weight:400;margin-left:7px;color:var(--app-text-muted, #64748b)}
|
|
.reason{font-size:13px;line-height:1.7;margin:8px 0 0;white-space:pre-wrap;overflow-wrap:anywhere;color:var(--app-text-control, #1e293b)}
|
|
details{margin-top:6px;font-size:12px;line-height:1.7;color:var(--app-text-secondary, #475569)}
|
|
summary{cursor:pointer;width:fit-content;padding:2px 0;font-weight:600}
|
|
ol{padding-left:22px;margin:8px 0 0}li{padding:3px 0;white-space:pre-wrap;overflow-wrap:anywhere}
|
|
.review-time{margin:5px 0 0;color:var(--app-text-muted, #64748b);font-size:11px}
|
|
.review-toolbar,.review-actions{display:flex;align-items:center;flex-wrap:wrap;gap:8px;margin-top:8px;font-size:12px}
|
|
button{border:1px solid var(--app-border-control, #cbd5e1);border-radius:6px;padding:4px 9px;background:var(--app-surface, #fff);color:var(--app-text-secondary, #475569);font:inherit;cursor:pointer;line-height:1.5}
|
|
button:hover{border-color:var(--app-border-control, #94a3b8);background:var(--app-surface-soft, #f8fafc)}button:disabled{opacity:.55;cursor:wait}button:focus-visible{outline:2px solid #3b82f6;outline-offset:2px}
|
|
.review-toolbar span{color:var(--app-success-text, #15803d)}.review-form{border-top:1px solid var(--app-border-control, #cbd5e1);margin-top:10px;padding-top:10px;font-size:12px}
|
|
label{display:block;color:var(--app-text-secondary, #475569)}textarea{display:block;box-sizing:border-box;width:100%;resize:vertical;border:1px solid var(--app-border-control, #cbd5e1);border-radius:6px;margin-top:5px;padding:7px 9px;color:var(--app-text-control, #1e293b);background:var(--app-surface, #fff);font:inherit;line-height:1.6}
|
|
.confirm-pass{color:var(--app-success-text, #166534);border-color:var(--app-success-border, #86efac);background:var(--app-success-soft, #f0fdf4)}.confirm-fail{color:var(--app-error-text, #991b1b);border-color:var(--app-error-border, #fca5a5);background:var(--app-error-soft, #fef2f2)}.review-error{color:var(--app-error-text, #b91c1c);margin:8px 0 0}
|
|
@media(max-width:480px){.test-result-card{padding:10px 12px}}
|
|
`]
|
|
})
|
|
export class TestResultCardComponent {
|
|
private http = inject(HttpClient);
|
|
result = input.required<CaseTestResult>();
|
|
sourceLabel = computed(() => testResultSourceLabel(this.result()));
|
|
sessionId = input<string | null>(null);
|
|
executionStatus = input('');
|
|
reviewSaved = output<CaseReviewResponse>();
|
|
reviewing = signal(false); saving = signal(false); error = signal(''); notice = signal(''); note = '';
|
|
canReview = computed(() => !!this.sessionId() && ['completed', 'success', 'failed', 'cancelled', 'interrupted', 'skipped'].includes(this.executionStatus()));
|
|
constructor() {
|
|
effect(() => { this.sessionId(); this.reviewing.set(false); this.error.set(''); this.notice.set(''); this.note = ''; });
|
|
}
|
|
toggleReview() { this.reviewing.update(value => !value); this.error.set(''); this.notice.set(''); this.note = this.result().source === 'human' ? this.result().reason : ''; }
|
|
saveReview(status: 'passed' | 'failed') {
|
|
const id = this.sessionId();
|
|
if (!id || !this.canReview() || this.saving()) return;
|
|
this.saving.set(true); this.error.set('');
|
|
this.http.put<CaseReviewResponse>(`/api/automation/reports/${encodeURIComponent(id)}/review`, {status, reason: this.note.trim()}).subscribe({
|
|
next: response => {
|
|
this.saving.set(false); this.reviewSaved.emit(response);
|
|
if (this.sessionId() === id) { this.reviewing.set(false); this.note = ''; this.notice.set('人工复验已保存,报告已更新'); }
|
|
},
|
|
error: error => { this.saving.set(false); if (this.sessionId() === id) this.error.set(error.error?.detail || '复验保存失败,请重试'); }
|
|
});
|
|
}
|
|
}
|