feat: add favorite task templates and optimize evidence exports
This commit is contained in:
1 parent
e1ea099592
commit
a4a5430922
46 files changed
+1714
-188
No files matched your search
+3
-2
@@ -14,7 +14,7 @@
|
||||
<section><h3>当前任务报告</h3>
|
||||
@if (report(); as r) {
|
||||
<div class="report-overview">
|
||||
<strong class="report-title">{{r.name}}</strong>
|
||||
<div class="report-heading"><app-favorite-button [sourceId]="r.id" /><strong class="report-title">{{r.name}}</strong></div>
|
||||
<div class="report-badges"><span [class.failed]="r.status === 'failed'">{{label(r.status)}}</span><app-test-result-badge [result]="r.test_result" [executionStatus]="r.status" /></div>
|
||||
@if(r.test_result; as result){<app-test-result-card [result]="result" [sessionId]="r.id" [executionStatus]="r.status" (reviewSaved)="reviewSaved($event)" />}
|
||||
@if (r.error) { <div class="report-error" role="status"><strong>异常原因</strong><p>{{r.error}}</p></div> }
|
||||
@@ -76,6 +76,7 @@
|
||||
<section><h3>批次记录</h3>
|
||||
@for(b of batches(); track b.id){
|
||||
<div class="batch-record">
|
||||
<app-favorite-button sourceKind="batch" [sourceId]="b.id" />
|
||||
<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>
|
||||
@@ -106,6 +107,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" [class.selected]="agent.currentSessionId() === j.session_id"><button (click)="openBatchTask(j.session_id)" [title]="j.case.goal" [attr.aria-pressed]="agent.currentSessionId() === j.session_id">{{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><app-test-result-badge [result]="j.test_result" [executionStatus]="j.status" [compact]="true" />@if(j.error){<small>{{j.error}}</small>}</div>}
|
||||
@for(j of b.jobs; track j.session_id){<div class="job" [class.selected]="agent.currentSessionId() === j.session_id"><app-favorite-button [sourceId]="j.session_id" /><button (click)="openBatchTask(j.session_id)" [title]="j.case.goal" [attr.aria-pressed]="agent.currentSessionId() === j.session_id">{{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><app-test-result-badge [result]="j.test_result" [executionStatus]="j.status" [compact]="true" />@if(j.error){<small>{{j.error}}</small>}</div>}
|
||||
</section>}
|
||||
}
|
||||
+2
-1
@@ -26,7 +26,7 @@ input[type=checkbox]{width:15px;height:15px;accent-color:#2563eb;vertical-align:
|
||||
.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;}
|
||||
.batch-record{position:relative;display:flex;gap:8px;align-items:stretch;margin-top:8px;}.batch-record>app-favorite-button{position:absolute;left:9px;top:9px;z-index:1}.batch-record .record{padding-left:44px;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;}
|
||||
@@ -41,6 +41,7 @@ input[type=checkbox]{width:15px;height:15px;accent-color:#2563eb;vertical-align:
|
||||
.task-guidance{margin-top:16px;padding:14px;border:1px solid #bfdbfe;border-radius:10px;background:#f8fbff;}
|
||||
.task-guidance label{margin:8px 0 4px;}
|
||||
.report-overview{padding:14px;border:1px solid #dbeafe;border-radius:10px;background:#f8fbff;}
|
||||
.report-heading{display:flex;align-items:flex-start;gap:8px;}.report-heading .report-title{flex:1;min-width:0;}
|
||||
.report-title{display:block;font-size:14px;line-height:1.5;color:#0f172a;overflow-wrap:anywhere;}
|
||||
.report-badges{display:flex;gap:7px;flex-wrap:wrap;margin-top:9px;span{padding:3px 8px;border:1px solid #bfdbfe;border-radius:20px;background:#eff6ff;color:#1e40af;font-size:12px;&.failed{background:#fff1f2;border-color:#fecdd3;color:#be123c;}}}
|
||||
.report-error{margin-top:11px;padding:10px 12px;background:#fff1f2;border-left:3px solid #e11d48;border-radius:6px;color:#9f1239;strong{font-size:12px;}p{margin:4px 0 0;color:#9f1239;white-space:pre-wrap;}}
|
||||
|
||||
+1
-1
@@ -129,7 +129,7 @@ describe('AutomationPanelComponent batch deletion', () => {
|
||||
{session_id:'task-b',status:'completed',case:{name:'用例 B',goal:'观察主页'}}
|
||||
]});
|
||||
fixture.detectChanges();
|
||||
const jobs: HTMLButtonElement[] = [...fixture.nativeElement.querySelectorAll('.job button')];
|
||||
const jobs: HTMLButtonElement[] = [...fixture.nativeElement.querySelectorAll('.job > button')];
|
||||
jobs[0].click(); jobs[1].click();
|
||||
expect(agent.selectSession.calls.allArgs()).toEqual([['task-a',true],['task-b',true]]);
|
||||
expect(component.tab()).toBe('batches');
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
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';
|
||||
@@ -22,7 +23,7 @@ interface ArchiveProgress {
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-automation-panel', standalone: true, imports: [ReportMarkdownComponent, CommonModule, FormsModule, TestResultBadgeComponent, TestResultCardComponent, TaskPluginsComponent],
|
||||
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 {
|
||||
|
||||
@@ -125,8 +125,11 @@ limitations under the License.
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="task-goal" title="{{ session.initial_goal }}">
|
||||
{{ session.display_name || session.initial_goal || '未命名任务' }}
|
||||
<div class="task-title-row">
|
||||
<app-favorite-button [sourceId]="session.session_id" />
|
||||
<div class="task-goal" title="{{ session.initial_goal }}">
|
||||
{{ session.display_name || session.initial_goal || '未命名任务' }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="task-card-footer">
|
||||
<app-task-id-copy [taskId]="session.session_id" />
|
||||
|
||||
@@ -559,6 +559,16 @@ $text-muted: #a1a1aa;
|
||||
}
|
||||
}
|
||||
|
||||
.task-title-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 5px;
|
||||
min-width: 0;
|
||||
|
||||
app-favorite-button { margin-top: -5px; }
|
||||
.task-goal { flex: 1; min-width: 0; }
|
||||
}
|
||||
|
||||
.task-goal {
|
||||
font-size: 12.5px;
|
||||
font-weight: 500;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { TaskIdCopyComponent } from '../task-id-copy/task-id-copy.component';
|
||||
import { FavoriteButtonComponent } from '../favorite-button/favorite-button.component';
|
||||
import { AutomationPanelComponent } from '../automation-panel/automation-panel.component';
|
||||
import { TaskHistoryComponent } from '../task-history/task-history.component';
|
||||
import { UiLabelPipe } from '../../utils/ui-label.pipe';
|
||||
@@ -34,7 +35,7 @@ export type { MarkdownSegment, MarkdownLine, NoteMilestone, ParsedNote };
|
||||
@Component({
|
||||
selector: 'app-chat-interface',
|
||||
standalone: true,
|
||||
imports: [TaskIdCopyComponent, UiLabelPipe, CommonModule, FormsModule, AutomationPanelComponent, TaskHistoryComponent, TestResultBadgeComponent, TaskModeBadgeComponent, TaskBatchFilterComponent],
|
||||
imports: [FavoriteButtonComponent, TaskIdCopyComponent, UiLabelPipe, CommonModule, FormsModule, AutomationPanelComponent, TaskHistoryComponent, TestResultBadgeComponent, TaskModeBadgeComponent, TaskBatchFilterComponent],
|
||||
templateUrl: './chat-interface.component.html',
|
||||
styleUrl: './chat-interface.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
|
||||
+2
-2
@@ -2,7 +2,7 @@
|
||||
<app-agent-stream [conversationMessages]="messages()" [demonstrations]="s.demonstrations" [pendingQuestion]="s.pending_question || ''" (useRecommendation)="text = $event; send()">
|
||||
<section task-summary class="session-summary">
|
||||
<div class="row"><strong><span class="material-symbols-outlined">explore</span> {{activeValidation() ? '用例复跑中' : label(s.status)}}</strong><small>{{s.config.model}} / {{s.config.reasoning_effort}} · 续接 {{s.continuation_count}} 次</small></div>
|
||||
<p>{{s.goal}}</p>
|
||||
<div class="exploration-goal"><app-favorite-button sourceKind="exploration" [sourceId]="s.id" label="收藏为重新探索" /><p>{{s.goal}}</p></div>
|
||||
@if(s.pause_reason === 'execution_budget'){<p class="feedback">本段执行到时,进度已保存。可继续探索,不计为测试失败。</p>}
|
||||
@if(s.pause_reason === 'device_observation'){<p class="feedback" role="status">设备画面暂时不可用,探索已暂停。指导已保留,请确认设备恢复响应后点击“继续探索”。</p>}
|
||||
<details class="execution-history"><summary>执行记录({{s.runs.length}}) · 当前展示所选轮次的操作步骤</summary>@for(run of s.runs; track run.id){<div class="run"><button class="run-link" (click)="agent.selectSession(run.id,true)">{{run.kind === 'validation' ? '用例 v' + run.case_version + ' 复跑' : '探索执行'}} · {{label(run.status)}}{{agent.currentSessionId() === run.id ? ' · 当前查看' : ''}}</button><app-test-result-badge [result]="run.test_result" [executionStatus]="run.status" [compact]="true" /></div>}</details>
|
||||
@@ -12,7 +12,7 @@
|
||||
<section class="case-editor" aria-label="可复用用例"><div class="row"><strong class="case-title"><span class="material-symbols-outlined">task_alt</span>{{s.case ? '探索成果 · 可复用用例' : '本轮探索已结束'}}</strong><button (click)="generate()" [disabled]="busy() || !!s.active_run_id || generating() || dirty">{{generating() ? '正在提炼…' : s.case ? '重新提炼' : '提炼用例'}}</button></div>
|
||||
@if(!s.case){<p class="muted">探索验收通过后自动生成;也可以先编辑并保存草稿。</p><button (click)="editorOpen.set(true)">编辑草稿</button>}
|
||||
@if(s.generations[0]?.status === 'failed'){<p class="error">上次提炼未完成,执行结果保留。可重试提炼。</p>}
|
||||
@if(s.case){<div class="case-compact"><h3>{{s.case.name}} <small>v{{s.case.version}}</small></h3><p class="muted">已整理为一个可复用用例。你可以查看修改,或交给 Flash / Pro 独立复跑。</p><div class="actions"><button class="primary" (click)="copyCase()"><span class="material-symbols-outlined">content_copy</span>复制任务提示词</button><button (click)="editorOpen.set(!editorOpen())">{{editorOpen() ? '收起编辑' : '查看 / 编辑'}}</button><button (click)="rerunOpen.set(!rerunOpen())">复跑 / 批次</button></div></div>}
|
||||
@if(s.case){<div class="case-compact"><h3><app-favorite-button sourceKind="exploration_case" [sourceId]="s.id" [version]="s.case.version" label="收藏此版本用例" /><span>{{s.case.name}} <small>v{{s.case.version}}</small></span></h3><p class="muted">已整理为一个可复用用例。你可以查看修改,或交给 Flash / Pro 独立复跑。</p><div class="actions"><button class="primary" (click)="copyCase()"><span class="material-symbols-outlined">content_copy</span>复制任务提示词</button><button (click)="editorOpen.set(!editorOpen())">{{editorOpen() ? '收起编辑' : '查看 / 编辑'}}</button><button (click)="rerunOpen.set(!rerunOpen())">复跑 / 批次</button></div></div>}
|
||||
@if(editorOpen()) {
|
||||
@if(s.case){<label>查看版本<select [ngModel]="editVersion" (ngModelChange)="selectVersion($event)" [disabled]="dirty">@for(version of versions(); track version){<option [value]="version">v{{version}}</option>}</select></label>}
|
||||
@if(dirty && editVersion !== s.case_version){<p class="error">已有新版本;当前编辑仍保留,请先复制修改内容再加载最新版本。</p>}
|
||||
|
||||
+1
-1
@@ -73,5 +73,5 @@ app-agent-stream {flex:1;min-height:0;display:block}
|
||||
.case-editor {border-color:#c7d2fe;background:linear-gradient(135deg,#f5f7ff,#fff);padding:20px;border-radius:18px}
|
||||
.case-title {display:flex;align-items:center;gap:8px;color:#4f46e5;font-size:14px}.case-title .material-symbols-outlined {font-size:22px}
|
||||
.case-compact {display:block}.case-compact h3 {font-size:18px;line-height:1.5;color:#1e293b;margin:14px 0 6px}.case-compact small {font-size:11px;font-weight:400;background:#e0e7ff;color:#4338ca;padding:3px 6px;border-radius:5px;white-space:nowrap}.case-compact .actions {margin:16px 0 0}.case-compact .primary {background:#4f46e5;border-color:#4f46e5}
|
||||
.session-summary {padding:12px 16px;border-radius:14px}.session-summary p {color:#64748b;font-size:12px}
|
||||
.session-summary {padding:12px 16px;border-radius:14px}.exploration-goal{display:flex;align-items:flex-start;gap:6px}.session-summary .exploration-goal p{flex:1;min-width:0;margin:4px 0}.case-compact h3{display:flex;align-items:flex-start;gap:6px}.case-compact h3>span{min-width:0;overflow-wrap:anywhere}.session-summary p {color:#64748b;font-size:12px}
|
||||
@media(max-width:600px) {.composer {margin:0 12px 12px;padding:12px}.send-hint {display:none}.send-button {margin-left:auto}.question-banner {max-height:160px;overflow:auto}.case-editor {padding:14px}}
|
||||
+2
-1
@@ -1,4 +1,5 @@
|
||||
import { Component, ChangeDetectionStrategy, DestroyRef, effect, inject, signal, untracked, input } from '@angular/core';
|
||||
import { FavoriteButtonComponent } from '../favorite-button/favorite-button.component';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { AgentStreamComponent } from '../agent-stream/agent-stream.component';
|
||||
import { ExplorationService, ExplorationSession, ExplorationMessage, Demonstration } from '../../services/exploration.service';
|
||||
@@ -7,7 +8,7 @@ import { TestResultBadgeComponent } from '../test-result-badge/test-result-badge
|
||||
|
||||
@Component({
|
||||
selector: 'app-exploration-panel', standalone: true,
|
||||
imports: [FormsModule, AgentStreamComponent, TestResultBadgeComponent],
|
||||
imports: [FormsModule, AgentStreamComponent, TestResultBadgeComponent, FavoriteButtonComponent],
|
||||
templateUrl: './exploration-panel.component.html', styleUrl: './exploration-panel.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { Component, ChangeDetectionStrategy, computed, inject, input, signal } from '@angular/core';
|
||||
import { FavoriteKind, FavoritesService } from '../../services/favorites.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-favorite-button', standalone: true, changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `<button type="button" [class.saved]="!!favorite()" [disabled]="busy() || !api.loaded() || !sourceId()"
|
||||
[attr.aria-pressed]="!!favorite()" [attr.aria-label]="favorite() ? '取消收藏' : label()" [title]="favorite() ? '取消收藏' : label()"
|
||||
(click)="toggle($event)"><span class="material-symbols-outlined" aria-hidden="true">{{favorite() ? 'star' : 'star_outline'}}</span></button>
|
||||
@if(error()){<span class="feedback" role="alert">{{error()}}</span>}
|
||||
@if(api.error() && !api.loaded()){<button type="button" class="retry" (click)="$event.stopPropagation(); api.load(true)" title="重试加载收藏">重试</button>}`,
|
||||
styles: [`:host{display:inline-flex;align-items:center;position:relative;flex-shrink:0}button{display:inline-flex;align-items:center;justify-content:center;width:28px;height:28px;border:0;border-radius:7px;background:transparent;color:#64748b;cursor:pointer}button:hover{background:#fef3c7;color:#b45309}button.saved{color:#c47b0b}button.saved span{font-variation-settings:'FILL' 1}button:focus-visible{outline:2px solid #818cf8;outline-offset:2px}button:disabled{opacity:.45;cursor:default}.material-symbols-outlined{font-size:19px}.feedback{position:absolute;left:0;top:30px;min-width:170px;max-width:260px;padding:8px 10px;z-index:20;border:1px solid #fecaca;border-radius:8px;background:#fff7f7;color:#b91c1c;font-size:12px;line-height:1.5;box-shadow:0 4px 16px #0f172a12}.retry{font-size:10px;width:auto}`]
|
||||
})
|
||||
export class FavoriteButtonComponent {
|
||||
api = inject(FavoritesService);
|
||||
sourceId = input.required<string>();
|
||||
sourceKind = input<FavoriteKind>('task');
|
||||
version = input<number>();
|
||||
label = input('收藏到精选任务');
|
||||
busy = signal(false); error = signal('');
|
||||
favorite = computed(() => this.api.items().find(i => i.source_kind === this.sourceKind() && i.source_id === this.sourceId() && (!this.version() || i.case_version === this.version())));
|
||||
constructor() { void this.api.load(); }
|
||||
async toggle(event: Event) {
|
||||
event.stopPropagation(); if (this.busy()) return;
|
||||
this.busy.set(true); this.error.set('');
|
||||
try {
|
||||
const saved = this.favorite();
|
||||
if (saved) await this.api.remove(saved.id);
|
||||
else await this.api.create(this.sourceKind(), this.sourceId(), this.version());
|
||||
} catch(e) { this.error.set((e as any)?.error?.detail || '收藏操作失败,请重试'); }
|
||||
finally { this.busy.set(false); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<section class="featured" aria-label="精选任务">
|
||||
<div class="heading"><div><h2><span class="material-symbols-outlined" aria-hidden="true">star</span>精选任务</h2><p>收藏常用流程,沿用原配置一键复跑。</p></div><input type="search" aria-label="搜索精选任务" placeholder="搜索收藏…" [ngModel]="query()" (ngModelChange)="query.set($event)"></div>
|
||||
<div class="filters" aria-label="精选任务类型">@for(tab of filters; track tab.id){<button type="button" [class.selected]="filter() === tab.id" [attr.aria-pressed]="filter() === tab.id" (click)="filter.set(tab.id)">{{tab.label}}</button>}</div>
|
||||
@if(error() || api.error()){<p class="error" role="alert">{{error() || api.error()}} @if(api.error()){<button (click)="api.load(true)">重试</button>}</p>}
|
||||
@if(notice()){<p class="notice" role="status">{{notice()}}</p>}
|
||||
@if(api.loading() && !api.loaded()){<p class="empty" role="status">正在读取精选任务…</p>}
|
||||
@else if(!api.items().length && !api.error()){<div class="empty"><span class="material-symbols-outlined" aria-hidden="true">star_outline</span><strong>把常用测试留在这里</strong><p>在任务队列、历史记录或批次旁点击星标,即可收藏。删除原记录不会影响收藏。</p></div>}
|
||||
@else if(!filtered().length){<p class="empty">没有匹配的精选任务。</p>}
|
||||
<div class="cards">@for(item of filtered(); track item.id){
|
||||
<article class="card" [class.expanded]="expanded() === item.id">
|
||||
<div class="card-heading"><span class="mode" [class.pro]="options(item)['profile'] === 'pro'" [class.explore]="item.kind === 'exploration'">{{mode(item)}}</span></div>
|
||||
<div class="card-title"><button class="star" type="button" (click)="remove(item)" [disabled]="!!busy()" aria-label="取消收藏" title="取消收藏"><span class="material-symbols-outlined" aria-hidden="true">star</span></button><h3>{{item.name}}</h3></div><p class="model-summary">{{summary(item)}}</p>
|
||||
@if(item.case_version){<p class="hint">固定用例 v{{item.case_version}}</p>}
|
||||
@if(item.inherited_fields.length){<p class="hint inherited" title="部分历史配置未记录,收藏时已采用当前默认值:{{item.inherited_fields.join('、')}}">{{item.inherited_fields[0].startsWith('首次') ? item.inherited_fields[0] : '旧任务部分配置缺失,已采用收藏时的默认值'}}</p>}
|
||||
<div class="card-actions"><button type="button" class="configure" (click)="toggle(item)" [attr.aria-expanded]="expanded() === item.id"><span class="material-symbols-outlined" aria-hidden="true">tune</span>配置<span class="material-symbols-outlined" aria-hidden="true">{{expanded() === item.id ? 'expand_less' : 'expand_more'}}</span></button><button type="button" class="primary" (click)="run(item)" [disabled]="!!busy()"><span class="material-symbols-outlined" aria-hidden="true">play_arrow</span>{{busy() === item.id ? '处理中…' : item.kind === 'exploration' ? '重新探索' : '运行'}}</button></div>
|
||||
@if(expanded() === item.id){@let d = draft(item);
|
||||
<div class="configuration"><label>收藏名称<input [(ngModel)]="d.name" maxlength="200" aria-label="收藏名称"></label>
|
||||
@if(item.kind === 'batch'){
|
||||
<label class="checkbox"><input type="checkbox" [(ngModel)]="d.uniform">本次统一模型和思考强度</label>
|
||||
@if(d.uniform){<label>整批模型<input [(ngModel)]="d.model" list="favorite-models" placeholder="输入模型名称"></label><app-setting-slider label="整批思考强度" [(value)]="d.reasoning_effort" [values]="efforts" [labels]="effortLabels" />}
|
||||
@for(c of item.snapshot.cases; track $index; let i = $index){<details><summary>{{i + 1}}. {{c.name}}</summary>
|
||||
<label>执行模式<select [(ngModel)]="d.cases[i].profile"><option value="flash">Flash</option><option value="pro">Pro</option></select></label>
|
||||
<label>模型<input [(ngModel)]="d.cases[i].model" list="favorite-models" [disabled]="d.uniform"></label>
|
||||
<app-setting-slider label="思考强度" [(value)]="d.cases[i].reasoning_effort" [values]="efforts" [labels]="effortLabels" [disabled]="d.uniform" />
|
||||
@if(!computer(c.task_config)){<label>执行设备<select [(ngModel)]="d.cases[i].device_serial"><option value="">请选择在线设备</option>@if(d.cases[i].device_serial){<option [value]="d.cases[i].device_serial">原选择:{{d.cases[i].device_serial}}</option>}@for(device of system.connectedDevices(); track device.serial){<option [value]="device.serial">{{device.serial}}</option>}</select></label>}
|
||||
</details>}
|
||||
}@else{
|
||||
@if(item.kind !== 'exploration'){<label>执行模式<select [(ngModel)]="d.profile"><option value="flash">Flash</option><option value="pro">Pro</option></select></label>}
|
||||
<label>模型<input [(ngModel)]="d.model" list="favorite-models" aria-label="复跑模型"></label>
|
||||
<app-setting-slider label="思考强度" [(value)]="d.reasoning_effort" [values]="efforts" [labels]="effortLabels" />
|
||||
@if(!computer(options(item))){<label>执行设备<select [(ngModel)]="d.device_serial" aria-label="复跑设备"><option value="">请选择在线设备</option>@if(d.device_serial){<option [value]="d.device_serial">原选择:{{d.device_serial}}</option>}@for(device of system.connectedDevices(); track device.serial){<option [value]="device.serial">{{device.serial}}</option>}</select></label>}
|
||||
}
|
||||
<p class="hint">调整仅用于本次运行;保存后才会改变收藏的默认配置。</p><button class="save" (click)="save(item)" [disabled]="!!busy() || !d.name.trim() || (d.uniform && !d.model.trim())">保存为默认配置</button>
|
||||
</div>
|
||||
}
|
||||
</article>
|
||||
}</div>
|
||||
<datalist id="favorite-models">@for(model of models; track model){<option [value]="model"></option>}</datalist>
|
||||
</section>
|
||||
@@ -0,0 +1 @@
|
||||
:host{display:block;width:100%;color:#334155}.featured{margin-top:26px}.heading{display:flex;align-items:center;justify-content:space-between;gap:16px}h2{display:flex;align-items:center;gap:7px;margin:0;font-size:16px;font-weight:650;color:#1e293b}h2 span{font-size:20px;color:#d29a32}.heading p{font-size:12px;color:#64748b;margin:6px 0 0}.heading>input{max-width:210px;width:40%;border-radius:20px;background:#fff}.filters{display:flex;gap:5px;flex-wrap:wrap;margin:17px 0}.filters button{border:0;padding:6px 13px;border-radius:18px;font-size:12px;background:transparent;color:#64748b}.filters button.selected{background:#e0e7ff;color:#4338ca;font-weight:600}.cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,260px),1fr));gap:14px;align-items:start}.card{padding:16px;border:1px solid #e2e8f0;border-radius:16px;background:#fff;min-width:0;transition:border-color .15s,box-shadow .15s}.card:hover{border-color:#c7d2fe;box-shadow:0 4px 16px #33415508}.card.expanded{border-color:#a5b4fc}.card-heading{display:flex;align-items:center;justify-content:space-between}.mode{padding:3px 8px;background:#f1f5f9;color:#475569;border-radius:6px;font-size:11px;line-height:16px}.mode.pro{background:#ede9fe;color:#6d28d9}.mode.explore{background:#e0f2fe;color:#0369a1}.star{display:flex;padding:3px;border:0;background:transparent;color:#c58b23}.star span{font-size:19px;font-variation-settings:'FILL' 1}h3{font-size:14px;line-height:1.5;margin:13px 0 7px;overflow-wrap:anywhere;color:#1e293b}.model-summary{font-size:12px;color:#64748b;margin:0;overflow-wrap:anywhere}.card-title{display:flex;align-items:flex-start;gap:6px;margin:13px 0 7px}.card-title h3{flex:1;min-width:0;margin:0}.card-title .star{flex-shrink:0;width:24px;height:24px;align-items:center;justify-content:center;padding:0}.card-actions{display:flex;align-items:center;justify-content:space-between;margin-top:18px;gap:10px}.card-actions button{display:flex;align-items:center;gap:4px;padding:7px 10px;border-radius:8px;font-size:12px;white-space:nowrap}.card-actions span{font-size:16px}.configure{color:#64748b;border:0;background:transparent}.primary{border:1px solid #6366f1;background:#6366f1;color:white}.primary:hover{background:#4f46e5}.configuration{border-top:1px solid #e2e8f0;margin-top:14px;padding-top:14px;display:grid;gap:12px}.configuration label{display:grid;gap:6px;font-size:12px;color:#475569}.configuration .checkbox{display:flex;align-items:center}.checkbox input{width:auto}input,select{box-sizing:border-box;min-width:0;width:100%;padding:8px 10px;border:1px solid #cbd5e1;border-radius:8px;color:#334155;background:white;font:inherit;font-size:12px}option{color:#334155;background:#fff}.hint{font-size:11px;color:#64748b;line-height:1.5;margin:7px 0 0}.inherited{color:#927044}.save{justify-self:start;padding:7px 10px;border:1px solid #cbd5e1;background:#fff;color:#475569;border-radius:8px;font-size:12px}details{border:1px solid #e2e8f0;padding:10px;border-radius:9px}details summary{font-size:12px;cursor:pointer;overflow-wrap:anywhere}details[open]>label,details[open]>app-setting-slider{margin-top:12px;display:block}button{font-family:inherit;cursor:pointer}button:disabled{opacity:.5;cursor:default}button:focus-visible,input:focus-visible,select:focus-visible,summary:focus-visible{outline:2px solid #818cf8;outline-offset:2px}.empty{display:flex;flex-direction:column;align-items:center;text-align:center;padding:28px 18px;border:1px dashed #cbd5e1;border-radius:16px;color:#64748b;font-size:12px;line-height:1.7}.empty>span{font-size:28px;color:#c5a365;margin-bottom:9px}.empty strong{font-size:14px;color:#475569}.empty p{max-width:440px;margin:8px 0 0}.error,.notice{padding:10px 12px;border-radius:8px;font-size:12px;line-height:1.6}.error{background:#fff1f2;color:#b91c1c}.notice{background:#f0fdf4;color:#166534}@media(max-width:560px){.heading{align-items:flex-start;flex-direction:column}.heading>input{width:100%;max-width:none}.cards{grid-template-columns:1fr}}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { TestBed, fakeAsync, flushMicrotasks } from '@angular/core/testing';
|
||||
import { signal } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import { FeaturedTasksComponent } from './featured-tasks.component';
|
||||
import { FavoritesService, Favorite } from '../../services/favorites.service';
|
||||
import { AgentService } from '../../services/agent.service';
|
||||
import { SystemService } from '../../services/system.service';
|
||||
|
||||
describe('Featured tasks',()=>{
|
||||
let api:any, agent:any, system:any, router:any;
|
||||
const favorite:Favorite={id:'favorite',name:'检查页面',kind:'task',source_kind:'task',source_id:'source',inherited_fields:[],snapshot:{goal:'检查主页',runtime_overrides:{profile:'pro',model:'original-model',reasoning_effort:'high',device_serial:'device'}}};
|
||||
beforeEach(()=>{
|
||||
api={items:signal([structuredClone(favorite)]),loaded:signal(true),loading:signal(false),error:signal(''),load:jasmine.createSpy().and.resolveTo(),run:jasmine.createSpy().and.resolveTo({kind:'task',session_id:'new'}),update:jasmine.createSpy().and.resolveTo(favorite),remove:jasmine.createSpy().and.resolveTo()};
|
||||
agent={fetchSessions:jasmine.createSpy(),selectSession:jasmine.createSpy(),batchFilter:signal(''),activeTab:signal('tasks')};system={connectedDevices:signal([{serial:'device',state:'device'}])};router={navigate:jasmine.createSpy().and.resolveTo(true)};
|
||||
TestBed.configureTestingModule({providers:[{provide:FavoritesService,useValue:api},{provide:AgentService,useValue:agent},{provide:SystemService,useValue:system},{provide:Router,useValue:router}]});
|
||||
});
|
||||
it('runs a card with original settings and keeps configuration folded initially',fakeAsync(()=>{
|
||||
const fixture=TestBed.createComponent(FeaturedTasksComponent);fixture.detectChanges();
|
||||
expect(fixture.nativeElement.querySelector('.configuration')).toBeNull();
|
||||
fixture.nativeElement.querySelector('.primary').click();flushMicrotasks();
|
||||
expect(api.run).toHaveBeenCalledWith('favorite',{});expect(api.update).not.toHaveBeenCalled();expect(agent.selectSession).toHaveBeenCalledWith('new',true);expect(router.navigate).toHaveBeenCalledWith(['/workspace']);
|
||||
}));
|
||||
it('blocks unavailable original devices and opens a device chooser without submitting',fakeAsync(()=>{
|
||||
system.connectedDevices.set([{serial:'other',state:'device'}]);const fixture=TestBed.createComponent(FeaturedTasksComponent);fixture.detectChanges();fixture.nativeElement.querySelector('.primary').click();flushMicrotasks();fixture.detectChanges();
|
||||
expect(api.run).not.toHaveBeenCalled();expect(fixture.nativeElement.querySelector('[aria-label="复跑设备"]')).not.toBeNull();expect(fixture.nativeElement.querySelector('[role="alert"]').textContent).toContain('原设备不在线');
|
||||
}));
|
||||
it('applies model changes only to this run unless defaults are explicitly saved',fakeAsync(()=>{
|
||||
const fixture=TestBed.createComponent(FeaturedTasksComponent);const c=fixture.componentInstance;c.draft(favorite).model='temporary';void c.run(favorite);flushMicrotasks();
|
||||
expect(api.run).toHaveBeenCalledWith('favorite',{task_config:{model:'temporary'}});expect(api.update).not.toHaveBeenCalled();expect(favorite.snapshot.runtime_overrides!['model']).toBe('original-model');
|
||||
void c.save(favorite);flushMicrotasks();expect(api.update).toHaveBeenCalledWith('favorite',{task_config:{model:'temporary'},name:'检查页面'});
|
||||
}));
|
||||
it('computer-only favorites need no Android device and can be filtered by name',fakeAsync(()=>{
|
||||
const pc:Favorite={...structuredClone(favorite),kind:'exploration_case',case_version:1,snapshot:{goal:'检查电脑',runtime_overrides:{profile:'flash',model:'original-model',reasoning_effort:'high',execution_target:'computer'}}};api.items.set([pc]);system.connectedDevices.set([]);
|
||||
const fixture=TestBed.createComponent(FeaturedTasksComponent),c=fixture.componentInstance;c.query.set('不存在');expect(c.filtered()).toEqual([]);c.query.set('页面');expect(c.filtered().length).toBe(1);void c.run(pc);flushMicrotasks();expect(api.run).toHaveBeenCalled();
|
||||
}));
|
||||
it('batch defaults keep each case and repetition count, with explicit uniform override',()=>{
|
||||
const batch:Favorite={...structuredClone(favorite),kind:'batch',snapshot:{repeat_count:3,cases:[{name:'甲',goal:'检查甲',task_config:{profile:'flash',model:'a',reasoning_effort:'high',device_serial:'device'}},{name:'乙',goal:'检查乙',task_config:{profile:'pro',model:'b',reasoning_effort:'max',device_serial:'device'}}]}};
|
||||
const fixture=TestBed.createComponent(FeaturedTasksComponent),c=fixture.componentInstance;expect(c.payload(batch)).toEqual({});const d=c.draft(batch);expect(c.payload(batch)).toEqual({case_configs:{}});d.uniform=true;d.model='uniform';d.reasoning_effort='high';expect(c.payload(batch)['task_config']).toEqual({model:'uniform',reasoning_effort:'high'});expect(batch.snapshot.repeat_count).toBe(3);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
import { Component, ChangeDetectionStrategy, computed, inject, signal } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { Router } from '@angular/router';
|
||||
import { FavoritesService, Favorite } from '../../services/favorites.service';
|
||||
import { AgentService } from '../../services/agent.service';
|
||||
import { SystemService } from '../../services/system.service';
|
||||
import { SettingSliderComponent, REASONING_VALUES, REASONING_LABELS } from '../setting-slider/setting-slider.component';
|
||||
|
||||
interface ModelDraft {profile: string; model: string; reasoning_effort: string; device_serial: string;}
|
||||
interface Draft extends ModelDraft {name: string; uniform: boolean; cases: ModelDraft[];}
|
||||
|
||||
@Component({
|
||||
selector: 'app-featured-tasks', standalone: true, imports: [FormsModule, SettingSliderComponent],
|
||||
templateUrl: './featured-tasks.component.html', styleUrl: './featured-tasks.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class FeaturedTasksComponent {
|
||||
api = inject(FavoritesService); system = inject(SystemService); private agent = inject(AgentService); private router = inject(Router);
|
||||
query = signal(''); filter = signal('all'); expanded = signal<string | null>(null);
|
||||
busy = signal<string | null>(null); error = signal(''); notice = signal('');
|
||||
drafts = new Map<string, Draft>();
|
||||
readonly efforts = REASONING_VALUES; readonly effortLabels = REASONING_LABELS;
|
||||
readonly models = ['gpt-6.1-sol','gpt-6-astra','gpt-6-luna','mimo-v2.6-pro','mimo-v2.6-flash'];
|
||||
readonly filters = [{id:'all',label:'全部'},{id:'flash',label:'Flash'},{id:'pro',label:'Pro'},{id:'exploration',label:'探索'},{id:'batch',label:'批量'}];
|
||||
filtered = computed(() => this.api.items().filter(item => {
|
||||
const filter = this.filter(), query = this.query().trim().toLowerCase();
|
||||
return (filter === 'all' || (filter === 'batch' ? item.kind === 'batch' : filter === 'exploration' ? ['exploration','exploration_case'].includes(item.kind) : item.kind !== 'batch' && this.options(item)['profile'] === filter)) && (!query || `${item.name} ${item.snapshot.goal || ''}`.toLowerCase().includes(query));
|
||||
}));
|
||||
constructor() { void this.api.load(); }
|
||||
options(item: Favorite): Record<string, any> { return item.kind === 'exploration' ? {...item.snapshot.task_config,...item.snapshot.config,profile:'exploration'} : item.snapshot.runtime_overrides || {}; }
|
||||
mode(item: Favorite) { return item.kind === 'batch' ? '批量' : item.kind === 'exploration' ? '重新探索' : (this.options(item)['profile'] === 'pro' ? 'Pro' : 'Flash') + (item.kind === 'exploration_case' ? ' · 用例复跑' : ''); }
|
||||
summary(item: Favorite) { return item.kind === 'batch' ? `${item.snapshot.cases?.length || 0} 个用例 · ${item.snapshot.repeat_count || 1} 轮 · 保留各项配置` : `${this.options(item)['model'] || '当前默认'} / ${this.options(item)['reasoning_effort'] || '当前默认'}`; }
|
||||
computer(options: Record<string, any>) { return options['execution_target'] === 'computer'; }
|
||||
modelDraft(options: Record<string, any>): ModelDraft { return {profile:options['profile'] || 'flash', model:options['model'] || '', reasoning_effort:options['reasoning_effort'] || 'default', device_serial:this.computer(options) ? '' : options['device_serial'] || ''}; }
|
||||
draft(item: Favorite): Draft {
|
||||
let draft = this.drafts.get(item.id);
|
||||
if (!draft) { draft = {...this.modelDraft(item.kind === 'batch' ? item.snapshot.cases?.[0]?.task_config || {} : this.options(item)), name:item.name, uniform:false, cases:(item.snapshot.cases || []).map(c => this.modelDraft(c.task_config))}; this.drafts.set(item.id,draft); }
|
||||
return draft;
|
||||
}
|
||||
toggle(item: Favorite) { this.expanded.set(this.expanded() === item.id ? null : item.id); }
|
||||
private changes(current: ModelDraft, original: Record<string, any>) {
|
||||
const baseline = this.modelDraft(original), result: Record<string, unknown> = {};
|
||||
for (const key of ['profile','model','reasoning_effort','device_serial'] as const) {
|
||||
if (current[key] !== baseline[key] && current[key]) result[key] = current[key];
|
||||
}
|
||||
return result;
|
||||
}
|
||||
payload(item: Favorite): Record<string, unknown> {
|
||||
const draft = this.drafts.get(item.id); if (!draft) return {};
|
||||
if (item.kind === 'batch') {
|
||||
const case_configs: Record<number, unknown> = {};
|
||||
draft.cases.forEach((c,i) => { const changes = this.changes(c,item.snapshot.cases![i].task_config); if(Object.keys(changes).length)case_configs[i]=changes; });
|
||||
return {case_configs, ...(draft.uniform ? {task_config:{model:draft.model,reasoning_effort:draft.reasoning_effort}} : {})};
|
||||
}
|
||||
return {task_config:this.changes(draft,this.options(item))};
|
||||
}
|
||||
private deviceMissing(item: Favorite) {
|
||||
const devices = this.system.connectedDevices();
|
||||
const cases = item.kind === 'batch' ? item.snapshot.cases!.map((c,i) => ({...c.task_config,...(this.payload(item)['case_configs'] as any)?.[i]})) : [{...this.options(item),...(this.payload(item)['task_config'] as any)}];
|
||||
return cases.some(options => !this.computer(options) && (!options['device_serial'] || !devices.some(d => d.serial === options['device_serial'] && d.state === 'device')));
|
||||
}
|
||||
async save(item: Favorite) {
|
||||
if(!this.validModels(item)){this.error.set('模型名称不能为空');return;}
|
||||
if(this.busy())return;this.busy.set(item.id);this.error.set('');this.notice.set('');
|
||||
try { const saved = await this.api.update(item.id,{...this.payload(item),name:this.draft(item).name});this.drafts.delete(item.id);this.draft(saved);this.notice.set('已保存收藏的默认配置'); }
|
||||
catch(e){this.error.set((e as any)?.error?.detail || '保存失败,请重试');}finally{this.busy.set(null);}
|
||||
}
|
||||
async remove(item: Favorite) {
|
||||
if(this.busy())return;this.busy.set(item.id);this.error.set('');
|
||||
try{await this.api.remove(item.id);this.drafts.delete(item.id);}catch(e){this.error.set((e as any)?.error?.detail || '取消收藏失败');}finally{this.busy.set(null);}
|
||||
}
|
||||
async run(item: Favorite) {
|
||||
if(this.busy())return;this.error.set('');this.notice.set('');
|
||||
if(!this.validModels(item)){this.error.set('模型名称不能为空');return;}
|
||||
if(this.deviceMissing(item)){this.expanded.set(item.id);this.error.set('原设备不在线,请在配置中选择可用设备后运行。');return;}
|
||||
const draft=this.drafts.get(item.id);if(draft?.uniform && !draft.model.trim()){this.error.set('请填写统一使用的模型');return;}
|
||||
this.busy.set(item.id);
|
||||
try{
|
||||
const result=await this.api.run(item.id,this.payload(item));
|
||||
this.agent.fetchSessions();
|
||||
if(result.batch_id){this.agent.batchFilter.set(result.batch_id);this.agent.activeTab.set('tasks');}
|
||||
else{const id=result.session_id || result.admission?.run?.id;if(id)this.agent.selectSession(id,true);}
|
||||
await this.router.navigate(['/workspace']);
|
||||
}catch(e){this.error.set((e as any)?.error?.detail || '运行请求失败;重试将沿用相同请求编号,避免重复执行。');}
|
||||
finally{this.busy.set(null);}
|
||||
}
|
||||
private validModels(item: Favorite) {
|
||||
const draft=this.drafts.get(item.id);if(!draft)return true;
|
||||
return item.kind === 'batch' ? draft.uniform ? !!draft.model.trim() : draft.cases.every(c => !!c.model.trim()) : !!draft.model.trim();
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { provideHttpClient } from '@angular/common/http';
|
||||
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
|
||||
import { AgentService } from '../../services/agent.service';
|
||||
import { TaskHistoryComponent } from './task-history.component';
|
||||
import { FavoritesService } from '../../services/favorites.service';
|
||||
|
||||
describe('历史任务检索', () => {
|
||||
let http: HttpTestingController;
|
||||
@@ -13,6 +14,7 @@ describe('历史任务检索', () => {
|
||||
TestBed.configureTestingModule({providers: [provideHttpClient(), provideHttpClientTesting(),
|
||||
{provide: AgentService, useValue: {currentSessionId: signal(null), batchFilter: signal(''), resultRevision: signal(0), selectSession: jasmine.createSpy(), deleteSession: jasmine.createSpy().and.returnValue(of({})), canDeleteSession: AgentService.prototype.canDeleteSession, deletionPrompt: (_id: string, kind: string) => kind === 'exploration' ? '删除探索及全部探索记录,复跑与批次保留' : '删除历史任务'}}]});
|
||||
http = TestBed.inject(HttpTestingController);
|
||||
TestBed.inject(FavoritesService).loaded.set(true);
|
||||
});
|
||||
afterEach(() => http.verify());
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { TaskIdCopyComponent } from '../task-id-copy/task-id-copy.component';
|
||||
import { FavoriteButtonComponent } from '../favorite-button/favorite-button.component';
|
||||
import { TaskModeBadgeComponent } from '../task-mode-badge/task-mode-badge.component';
|
||||
import { TestResultBadgeComponent } from '../test-result-badge/test-result-badge.component';
|
||||
import { Component, DestroyRef, effect, inject, signal, untracked } from '@angular/core';
|
||||
@@ -8,18 +9,18 @@ import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
import { AgentService } from '../../services/agent.service';
|
||||
|
||||
@Component({
|
||||
selector:'app-task-history', standalone:true, imports:[CommonModule,FormsModule,TaskIdCopyComponent,TestResultBadgeComponent,TaskModeBadgeComponent],
|
||||
selector:'app-task-history', standalone:true, imports:[CommonModule,FormsModule,TaskIdCopyComponent,TestResultBadgeComponent,TaskModeBadgeComponent,FavoriteButtonComponent],
|
||||
template:`<section aria-label="历史任务"><h3>历史任务 <small>{{total()}} 条</small><small class="query-status" role="status">{{loading() ? '正在查询…' : ''}}</small></h3>
|
||||
<input aria-label="搜索历史任务" placeholder="搜索名称、目标、任务编号或标签" [(ngModel)]="query" (ngModelChange)="search()">
|
||||
<div class="filters"><label>起始日期<input type="date" [(ngModel)]="start" (ngModelChange)="search()"></label><label>结束日期<input type="date" [(ngModel)]="end" (ngModelChange)="search()"></label><label>状态<select [(ngModel)]="status" (ngModelChange)="search()"><option value="">全部状态</option><option value="completed">执行完成</option><option value="failed">失败</option><option value="cancelled">已取消</option><option value="interrupted">中断</option><option value="skipped">跳过</option></select></label></div>
|
||||
@if(error()){<p role="alert">{{error()}}</p>}
|
||||
@for(group of groups(); track group.key){<details [open]="!collapsed.has(group.key)" (toggle)="toggle(group.key,$event)"><summary>{{group.title}} <small>本页 {{group.rows.length}} 条</small></summary>
|
||||
@for(row of group.rows; track row.session_id){<div class="history-item" [class]="'history-item status-'+row.status"><button class="row" [class.selected]="agent.currentSessionId() === row.session_id" (click)="agent.selectSession(row.session_id,true)" [title]="row.initial_goal || ''"><strong>{{row.name || row.initial_goal}}</strong><span class="history-meta"><app-task-mode-badge [session]="row" />@if(row.exploration_kind === "validation"){<span>用例复跑 · v{{row.case_version}}</span>}<b class="history-status">{{label(row.status)}}</b><app-test-result-badge [result]="row.test_result" [executionStatus]="row.status" [compact]="true" /> <span>{{row.end_time ? '完成于 '+(row.end_time*1000 | date:'MM-dd HH:mm:ss') : '开始于 '+(row.start_time*1000 | date:'MM-dd HH:mm:ss')}}</span><span>总耗时 {{duration(row.start_time,row.end_time)}}</span></span>@if(row.error){<span class="history-error" [title]="row.error">{{row.error}}</span>}</button><app-task-id-copy [taskId]="row.session_id" />@if(agent.canDeleteSession(row)){<button class="delete-history" (click)="remove(row.session_id, row.exploration_kind)" aria-label="删除此历史任务"><span class="material-symbols-outlined">delete</span></button>}</div>}
|
||||
@for(row of group.rows; track row.session_id){<div class="history-item" [class]="'history-item status-'+row.status"><div class="history-heading"><app-favorite-button [sourceId]="row.session_id" /><button class="row" [class.selected]="agent.currentSessionId() === row.session_id" (click)="agent.selectSession(row.session_id,true)" [title]="row.initial_goal || ''"><strong>{{row.name || row.initial_goal}}</strong><span class="history-meta"><app-task-mode-badge [session]="row" />@if(row.exploration_kind === "validation"){<span>用例复跑 · v{{row.case_version}}</span>}<b class="history-status">{{label(row.status)}}</b><app-test-result-badge [result]="row.test_result" [executionStatus]="row.status" [compact]="true" /> <span>{{row.end_time ? '完成于 '+(row.end_time*1000 | date:'MM-dd HH:mm:ss') : '开始于 '+(row.start_time*1000 | date:'MM-dd HH:mm:ss')}}</span><span>总耗时 {{duration(row.start_time,row.end_time)}}</span></span>@if(row.error){<span class="history-error" [title]="row.error">{{row.error}}</span>}</button></div><app-task-id-copy [taskId]="row.session_id" />@if(agent.canDeleteSession(row)){<button class="delete-history" (click)="remove(row.session_id, row.exploration_kind)" aria-label="删除此历史任务"><span class="material-symbols-outlined">delete</span></button>}</div>}
|
||||
</details>}
|
||||
@if(!loading() && !total()){<p>没有符合条件的历史任务。</p>}
|
||||
<div class="pager"><button [disabled]="page === 1 || loading()" (click)="page=page-1;load()">上一页</button><span>{{page}} / {{pages()}}</span><button [disabled]="page >= pages() || loading()" (click)="page=page+1;load()">下一页</button></div>
|
||||
</section>`,
|
||||
styles:[`:host{display:block;font-size:12px;color:inherit}h3{display:flex;align-items:center;gap:6px;font-size:14px;margin:14px 0 8px}.query-status{margin-left:auto;font-size:11px;font-weight:400;white-space:nowrap}small{opacity:.6}input,select,button{font:inherit;color:inherit;border:1px solid #64748b55;border-radius:6px;padding:8px;background:transparent;box-sizing:border-box}input{width:100%}select option{background:white;color:#1e293b}.filters{display:flex;gap:6px;flex-wrap:wrap;margin:8px 0}.filters label{flex:1;min-width:110px}.filters input,.filters select{display:block;width:100%}summary{padding:10px 0;cursor:pointer}.row{display:flex;flex-direction:column;gap:6px;width:100%;text-align:left;cursor:pointer;margin:5px 0;overflow-wrap:anywhere}.row strong{font-weight:600}.row.selected{border-color:#60a5fa;background:#3b82f615}.history-item{position:relative;border:1px solid #cbd5e1;border-left:3px solid #94a3b8;border-radius:8px;margin:6px 0;padding:8px;background:#fff}.history-item.status-completed,.history-item.status-success{border-left-color:#16a34a}.history-item.status-failed{border-left-color:#dc2626;background:#fffafa}.history-item.status-cancelled,.history-item.status-interrupted{border-left-color:#d97706;background:#fffdf8}.history-item.status-skipped{border-left-color:#64748b}.history-item:has(.row.selected){border-color:#60a5fa;background:#eff6ff}.history-item .row{border:0;padding:0;margin:0 0 4px}.history-item .row.selected{background:transparent}.history-item .row{padding-right:35px}.history-meta{display:flex;gap:5px;align-items:center;flex-wrap:wrap;color:#475569;font-size:11px}.history-status{display:inline-flex;align-items:center;box-sizing:border-box;height:20px;line-height:14px;border-radius:5px;padding:0 6px;background:#f1f5f9;color:#334155}.status-completed .history-status,.status-success .history-status{background:#dcfce7;color:#166534}.status-failed .history-status{background:#fee2e2;color:#991b1b}.status-cancelled .history-status,.status-interrupted .history-status{background:#fef3c7;color:#92400e}.history-error{font-size:11px;color:#b91c1c;line-height:1.4}.delete-history{position:absolute;right:5px;top:8px;border:0;padding:3px;color:#64748b;cursor:pointer;font-size:12px}.delete-history .material-symbols-outlined{font-size:16px}.delete-history:hover{color:#b91c1c}.pager{display:flex;align-items:center;justify-content:space-between;gap:8px;margin:12px 0}button:disabled{opacity:.4}p{opacity:.7}`]
|
||||
styles:[`:host{display:block;font-size:12px;color:inherit}h3{display:flex;align-items:center;gap:6px;font-size:14px;margin:14px 0 8px}.query-status{margin-left:auto;font-size:11px;font-weight:400;white-space:nowrap}small{opacity:.6}input,select,button{font:inherit;color:inherit;border:1px solid #64748b55;border-radius:6px;padding:8px;background:transparent;box-sizing:border-box}input{width:100%}select option{background:white;color:#1e293b}.filters{display:flex;gap:6px;flex-wrap:wrap;margin:8px 0}.filters label{flex:1;min-width:110px}.filters input,.filters select{display:block;width:100%}summary{padding:10px 0;cursor:pointer}.row{display:flex;flex-direction:column;gap:6px;width:100%;text-align:left;cursor:pointer;margin:5px 0;overflow-wrap:anywhere}.row strong{font-weight:600}.row.selected{border-color:#60a5fa;background:#3b82f615}.history-item{position:relative;border:1px solid #cbd5e1;border-left:3px solid #94a3b8;border-radius:8px;margin:6px 0;padding:8px;background:#fff}.history-item.status-completed,.history-item.status-success{border-left-color:#16a34a}.history-item.status-failed{border-left-color:#dc2626;background:#fffafa}.history-item.status-cancelled,.history-item.status-interrupted{border-left-color:#d97706;background:#fffdf8}.history-item.status-skipped{border-left-color:#64748b}.history-item:has(.row.selected){border-color:#60a5fa;background:#eff6ff}.history-item .row{border:0;padding:0;margin:0 0 4px}.history-item .row.selected{background:transparent}.history-item .row{padding-right:35px}.history-heading{display:flex;align-items:flex-start;gap:5px;min-width:0}.history-heading>app-favorite-button{margin-top:-5px}.history-heading>.row{flex:1;min-width:0;width:auto}.history-meta{display:flex;gap:5px;align-items:center;flex-wrap:wrap;color:#475569;font-size:11px}.history-status{display:inline-flex;align-items:center;box-sizing:border-box;height:20px;line-height:14px;border-radius:5px;padding:0 6px;background:#f1f5f9;color:#334155}.status-completed .history-status,.status-success .history-status{background:#dcfce7;color:#166534}.status-failed .history-status{background:#fee2e2;color:#991b1b}.status-cancelled .history-status,.status-interrupted .history-status{background:#fef3c7;color:#92400e}.history-error{font-size:11px;color:#b91c1c;line-height:1.4}.delete-history{position:absolute;right:5px;top:8px;border:0;padding:3px;color:#64748b;cursor:pointer;font-size:12px}.delete-history .material-symbols-outlined{font-size:16px}.delete-history:hover{color:#b91c1c}.pager{display:flex;align-items:center;justify-content:space-between;gap:8px;margin:12px 0}button:disabled{opacity:.4}p{opacity:.7}`]
|
||||
})
|
||||
export class TaskHistoryComponent {
|
||||
agent=inject(AgentService); private http=inject(HttpClient); query='';start='';end='';status='';page=1;
|
||||
|
||||
Reference in new issue
Block a user