import { Component, inject, OnInit, signal, output } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { HttpClient } from '@angular/common/http'; import { finalize, timeout } from 'rxjs'; import { UI_READ_TIMEOUT_MS } from '../../core/http-timeouts'; interface Capabilities { recording?: { codec: string; max_long_edge: number; fps: number; h265_kbps: number; h264_kbps: number; computer_scope: string; minimum_disk_mb: number; hevc_playback: boolean }; adb_auto_connect: boolean; adb_discovery_interval_seconds: number; adb_discovery_timeout_seconds: number; video_enabled: boolean; recording_enabled: boolean; outputter_enabled: boolean; flash_final_check_enabled: boolean; step_summarizer_enabled: boolean; screenshot_limit_enabled: boolean; screenshot_max_long_edge: number; task_timeout_seconds: number; observation_timeout_seconds: number; observation_max_attempts: number; device_command_timeout_seconds: number; action_failure_attempts: number; worker_cleanup_timeout_seconds: number; } @Component({ selector: 'app-capability-settings', standalone: true, imports: [FormsModule], template: `

功能开关

Flash 最终 AI 判定切换后自动保存;其余设置点击底部按钮保存。保存后对尚未开始的任务生效,不影响运行中的任务。

@if (config.recording; as recording) {
录像留档设置

默认低码率留档,不影响模型观察截图。仅录实际使用的来源;屏幕模式包含该屏幕其他可见内容。

}
执行保护

限制故障等待与反复失败;保存后对新任务生效,任务 JSON 可单独覆盖。

@if (message()) {

{{ message() }}

} @if (error()) { }
`, styles: [`section { margin-top: 24px; padding-top: 20px; border-top: 1px solid rgb(var(--app-border-control-rgb, 100 116 139) / 0.3333); } h3 { margin: 0 0 8px; } p, small { color: var(--app-text-muted, #64748b); line-height: 1.6; } label { display: flex; gap: 12px; align-items: flex-start; padding: 14px 0; cursor: pointer; } input { width: 20px; height: 20px; accent-color: var(--app-info-text, #0284c7); flex-shrink: 0; } small { display: block; margin-top: 4px; } button { padding: 10px 18px; border-radius: 10px; border: 1px solid #38bdf877; background: #0284c7; color: white; font: inherit; cursor: pointer; } button:disabled { opacity: .5; cursor: default; } .error { color: var(--app-error-text, #be123c); } .execution-settings { margin: 12px 0 20px; padding: 12px 14px; border: 1px solid var(--app-border-control, #cbd5e1); border-radius: 10px; background: var(--app-surface-soft, #f8fafc); } summary { cursor: pointer; font-weight: 600; color: var(--app-text-control, #334155); } .number-fields { display: grid; grid-template-columns: 1fr 1fr; gap: 12px 16px; } .number-fields label { display: block; padding: 0; color: var(--app-text-control, #334155); } .number-fields input, .number-fields select { box-sizing: border-box; display: block; width: 100%; height: auto; margin-top: 8px; padding: 9px; border: 1px solid var(--app-border-control, #cbd5e1); border-radius: 8px; font: inherit; } section {font-size:13px;border-color:var(--app-border, #e2e8f0)}h3 {font-size:15px;color:var(--app-text-control, #334155)} section > label {align-items:center;flex-direction:row-reverse;justify-content:space-between;border-bottom:1px solid var(--app-border, #f1f5f9);gap:20px}section > label > span {flex:1}small {font-size:12px} input[role=switch] {appearance:none;position:relative;width:36px;height:22px;border:1px solid var(--app-border-control, #cbd5e1);border-radius:20px;background:var(--app-surface-raised, #e2e8f0);cursor:pointer;transition:background .15s} input[role=switch]::after {content:'';position:absolute;top:2px;left:2px;width:16px;height:16px;border-radius:50%;background:var(--app-surface, #fff);box-shadow:0 1px 3px #0f172a25;transition:transform .15s} input[role=switch]:checked {background:#6366f1;border-color:#6366f1}input[role=switch]:checked::after {transform:translateX(14px)}input[role=switch]:focus-visible {outline:2px solid var(--app-primary-border, #a5b4fc);outline-offset:3px}input:disabled {opacity:.5;cursor:default} button {background:#4f46e5;border-color:#4f46e5;border-radius:12px;font-weight:600}.execution-settings {border-color:var(--app-border, #e2e8f0);border-radius:14px;padding:14px 16px}.number-fields input,.number-fields select {background:var(--app-surface, #fff);border-color:var(--app-border, #dbe3ec)} @media(max-width: 480px) { .number-fields { grid-template-columns: 1fr; } }`] }) export class CapabilitySettingsComponent implements OnInit { changed = output(); private http = inject(HttpClient); private savedConfig: Capabilities | null = null; config: Capabilities = { adb_auto_connect: true, adb_discovery_interval_seconds: 10, adb_discovery_timeout_seconds: 5, video_enabled: false, recording_enabled: false, outputter_enabled: true, flash_final_check_enabled: false, step_summarizer_enabled: true, screenshot_limit_enabled: true, screenshot_max_long_edge: 2048, task_timeout_seconds: 1800, observation_timeout_seconds: 60, observation_max_attempts: 3, device_command_timeout_seconds: 10, action_failure_attempts: 3, worker_cleanup_timeout_seconds: 30 }; busy = signal(false); loading = signal(true); loaded = signal(false); message = signal(''); error = signal(''); ngOnInit(): void { this.http.get('/api/system/capability-settings').pipe(timeout(UI_READ_TIMEOUT_MS), finalize(() => this.loading.set(false))).subscribe({ next: config => { this.savedConfig = structuredClone(config); this.config = config; this.loaded.set(true); this.changed.emit(config); }, error: () => this.error.set('功能配置加载失败,请刷新页面重试。') }); } saveFlashFinalCheck(enabled: boolean): void { if (this.busy() || !this.loaded() || !this.savedConfig) return; const previous = this.savedConfig.flash_final_check_enabled; this.config.flash_final_check_enabled = enabled; this.busy.set(true); this.message.set('正在保存 Flash 判定设置…'); this.error.set(''); // Preserve other settings that are still being edited, including invalid drafts. const update = { ...this.savedConfig, flash_final_check_enabled: enabled }; this.http.post('/api/system/capability-settings', update).pipe(finalize(() => this.busy.set(false))).subscribe({ next: config => { this.savedConfig = structuredClone(config); this.config.flash_final_check_enabled = config.flash_final_check_enabled; this.changed.emit(config); this.message.set(`Flash 最终 AI 判定已${config.flash_final_check_enabled ? '开启' : '关闭'},尚未开始的任务将使用此设置,无需重启服务。`); }, error: () => { this.config.flash_final_check_enabled = previous; this.message.set(''); this.error.set('Flash 判定设置保存失败,开关已恢复。请检查连接后重新切换。'); } }); } save(): void { if (this.busy() || this.loading() || !this.loaded() || !this.valid()) return; if (this.config.recording) { this.config.recording.hevc_playback = !!document.createElement('video').canPlayType('video/mp4; codecs="hvc1"'); } this.busy.set(true); this.message.set(''); this.error.set(''); this.http.post('/api/system/capability-settings', this.config).pipe(finalize(() => this.busy.set(false))).subscribe({ next: config => { this.savedConfig = structuredClone(config); this.config = config; this.changed.emit(config); this.message.set('功能设置已保存,新任务将使用此配置。'); }, error: () => this.error.set('保存失败,请检查服务器连接和 .env 文件权限。') }); } valid(): boolean { const c = this.config; const r = c.recording; if (r && (!['auto', 'h264', 'h265'].includes(r.codec) || !['screen', 'window'].includes(r.computer_scope) || !Number.isInteger(r.max_long_edge) || r.max_long_edge < 320 || r.max_long_edge > 4096 || !Number.isInteger(r.fps) || r.fps < 5 || r.fps > 60 || !Number.isInteger(r.h265_kbps) || r.h265_kbps < 128 || r.h265_kbps > 10000 || !Number.isInteger(r.h264_kbps) || r.h264_kbps < 128 || r.h264_kbps > 20000)) return false; return Number.isInteger(c.task_timeout_seconds) && c.task_timeout_seconds >= 30 && c.task_timeout_seconds <= 86400 && c.observation_timeout_seconds > 0 && c.observation_timeout_seconds <= 300 && Number.isInteger(c.observation_max_attempts) && c.observation_max_attempts >= 1 && c.observation_max_attempts <= 5 && c.device_command_timeout_seconds > 0 && c.device_command_timeout_seconds <= 60 && Number.isInteger(c.action_failure_attempts) && c.action_failure_attempts >= 1 && c.action_failure_attempts <= 10 && c.worker_cleanup_timeout_seconds > 0 && c.worker_cleanup_timeout_seconds <= 300 && c.adb_discovery_interval_seconds >= 1 && c.adb_discovery_interval_seconds <= 300 && c.adb_discovery_timeout_seconds > 0 && c.adb_discovery_timeout_seconds <= 30; } }