diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index b4b6989fe..753a1c539 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,4 +1,4 @@ -# Release v0.74.0 +# Release v0.75.0 ## 📊 变更统计 @@ -176,9 +176,9 @@ ### 版本号 已自动同步到: -- `package.json`: 0.74.0 -- `src-tauri/Cargo.toml`: 0.74.0 -- `src-tauri/tauri.conf.json`: 0.74.0 +- `package.json`: 0.75.0 +- `src-tauri/Cargo.toml`: 0.75.0 +- `src-tauri/tauri.conf.json`: 0.75.0 ### 新功能使用 @@ -213,4 +213,4 @@ AI:[自动打开天气网站并读取内容] 今天晴天,20-25°C... --- -**完整变更日志**:https://github.com/aiclientproxy/proxycast/compare/v0.73.0...v0.74.0 +**完整变更日志**:https://github.com/aiclientproxy/proxycast/compare/v0.74.0...v0.75.0 diff --git a/package.json b/package.json index 73f081ab7..459650cf5 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "proxycast", "private": true, - "version": "0.74.0", + "version": "0.75.0", "type": "module", "repository": { "type": "git", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 246532d26..d2a6c79a5 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -6685,7 +6685,7 @@ dependencies = [ [[package]] name = "proxycast" -version = "0.74.0" +version = "0.75.0" dependencies = [ "anyhow", "arboard", @@ -6785,7 +6785,7 @@ dependencies = [ [[package]] name = "proxycast-agent" -version = "0.74.0" +version = "0.75.0" dependencies = [ "aster-core", "async-trait", @@ -6809,7 +6809,7 @@ dependencies = [ [[package]] name = "proxycast-config" -version = "0.74.0" +version = "0.75.0" dependencies = [ "async-trait", "parking_lot", @@ -6825,7 +6825,7 @@ dependencies = [ [[package]] name = "proxycast-core" -version = "0.74.0" +version = "0.75.0" dependencies = [ "aster-models", "async-trait", @@ -6865,7 +6865,7 @@ dependencies = [ [[package]] name = "proxycast-credential" -version = "0.74.0" +version = "0.75.0" dependencies = [ "axum 0.7.9", "base64 0.22.1", @@ -6900,7 +6900,7 @@ dependencies = [ [[package]] name = "proxycast-infra" -version = "0.74.0" +version = "0.75.0" dependencies = [ "chrono", "dashmap 5.5.3", @@ -6920,7 +6920,7 @@ dependencies = [ [[package]] name = "proxycast-mcp" -version = "0.74.0" +version = "0.75.0" dependencies = [ "async-trait", "glob", @@ -6951,7 +6951,7 @@ dependencies = [ [[package]] name = "proxycast-processor" -version = "0.74.0" +version = "0.75.0" dependencies = [ "async-trait", "parking_lot", @@ -6970,7 +6970,7 @@ dependencies = [ [[package]] name = "proxycast-providers" -version = "0.74.0" +version = "0.75.0" dependencies = [ "anyhow", "async-stream", @@ -7022,7 +7022,7 @@ dependencies = [ [[package]] name = "proxycast-server" -version = "0.74.0" +version = "0.75.0" dependencies = [ "async-stream", "axum 0.7.9", @@ -7065,7 +7065,7 @@ dependencies = [ [[package]] name = "proxycast-server-utils" -version = "0.74.0" +version = "0.75.0" dependencies = [ "axum 0.7.9", "futures", @@ -7080,7 +7080,7 @@ dependencies = [ [[package]] name = "proxycast-services" -version = "0.74.0" +version = "0.75.0" dependencies = [ "anyhow", "aster-core", @@ -7121,7 +7121,7 @@ dependencies = [ [[package]] name = "proxycast-skills" -version = "0.74.0" +version = "0.75.0" dependencies = [ "async-trait", "dirs 5.0.1", @@ -7137,7 +7137,7 @@ dependencies = [ [[package]] name = "proxycast-terminal" -version = "0.74.0" +version = "0.75.0" dependencies = [ "async-trait", "base64 0.22.1", @@ -7164,7 +7164,7 @@ dependencies = [ [[package]] name = "proxycast-websocket" -version = "0.74.0" +version = "0.75.0" dependencies = [ "axum 0.7.9", "chrono", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index cad58a12c..581e27f5d 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -3,7 +3,7 @@ members = ["crates/*"] resolver = "2" [workspace.package] -version = "0.74.0" +version = "0.75.0" edition = "2021" authors = ["coso"] repository = "https://github.com/aiclientproxy/proxycast" @@ -189,7 +189,7 @@ version = "2.4" [package] name = "proxycast" -version = "0.74.0" +version = "0.75.0" description = "AI API Proxy Desktop App" authors = ["you"] edition = "2021" diff --git a/src-tauri/crates/core/src/plugin/manager.rs b/src-tauri/crates/core/src/plugin/manager.rs index 7afe1f7c1..8709330a0 100644 --- a/src-tauri/crates/core/src/plugin/manager.rs +++ b/src-tauri/crates/core/src/plugin/manager.rs @@ -5,22 +5,34 @@ use std::collections::HashMap; use std::path::{Path, PathBuf}; use std::sync::Arc; -use std::time::Duration; use dashmap::DashMap; use tokio::sync::RwLock; -use tokio::time::timeout; use super::loader::PluginLoader; +use super::task::{ + PluginQueueStats, PluginTaskPolicy, PluginTaskRecord, PluginTaskState, PluginTaskTracker, +}; use super::types::{ HookResult, PluginConfig, PluginContext, PluginError, PluginInfo, PluginInstance, PluginStatus, }; +use crate::DynEmitter; /// 插件管理器配置 #[derive(Debug, Clone)] pub struct PluginManagerConfig { /// 默认超时时间 (毫秒) pub default_timeout_ms: u64, + /// 默认重试次数 + pub default_max_retries: u32, + /// 默认重试退避基数 (毫秒) + pub default_retry_backoff_ms: u64, + /// 插件级并发上限 + pub default_max_concurrency_per_plugin: usize, + /// 插件级队列长度上限 + pub default_queue_limit_per_plugin: usize, + /// 任务记录保留数量 + pub task_retention_limit: usize, /// 是否启用插件系统 pub enabled: bool, /// 最大并发插件数 @@ -31,6 +43,11 @@ impl Default for PluginManagerConfig { fn default() -> Self { Self { default_timeout_ms: 5000, + default_max_retries: 2, + default_retry_backoff_ms: 300, + default_max_concurrency_per_plugin: 4, + default_queue_limit_per_plugin: 100, + task_retention_limit: 2000, enabled: true, max_plugins: 50, } @@ -47,6 +64,8 @@ pub struct PluginManager { configs: DashMap, /// 管理器配置 config: PluginManagerConfig, + /// 插件任务治理与跟踪 + task_tracker: PluginTaskTracker, } impl PluginManager { @@ -56,6 +75,7 @@ impl PluginManager { loader: PluginLoader::new(plugins_dir), plugins: DashMap::new(), configs: DashMap::new(), + task_tracker: PluginTaskTracker::new(config.task_retention_limit), config, } } @@ -248,6 +268,46 @@ impl PluginManager { infos } + /// 设置插件任务事件发射器 + pub async fn set_task_emitter(&self, emitter: DynEmitter) { + self.task_tracker.set_emitter(emitter).await; + } + + /// 列出插件任务 + pub fn list_tasks( + &self, + plugin_id: Option<&str>, + state: Option, + limit: usize, + ) -> Vec { + self.task_tracker.list_tasks(plugin_id, state, limit) + } + + /// 获取插件任务详情 + pub fn get_task(&self, task_id: &str) -> Option { + self.task_tracker.get_task(task_id) + } + + /// 取消插件任务 + pub fn cancel_task(&self, task_id: &str) -> bool { + self.task_tracker.cancel_task(task_id) + } + + /// 获取插件队列统计 + pub fn get_queue_stats(&self, plugin_id: Option<&str>) -> Vec { + self.task_tracker.queue_stats(plugin_id) + } + + fn build_policy(&self, timeout_ms: u64) -> PluginTaskPolicy { + PluginTaskPolicy { + timeout_ms, + max_retries: self.config.default_max_retries, + retry_backoff_ms: self.config.default_retry_backoff_ms, + max_concurrency_per_plugin: self.config.default_max_concurrency_per_plugin, + queue_limit_per_plugin: self.config.default_queue_limit_per_plugin, + } + } + /// 执行请求前钩子 (带隔离) pub async fn run_on_request( &self, @@ -267,24 +327,41 @@ impl PluginManager { } let timeout_ms = instance.config.timeout_ms; + let policy = self.build_policy(timeout_ms); let plugin = instance.plugin.clone(); let plugin_name = plugin.name().to_string(); + let base_ctx = ctx.clone(); + let base_request = request.clone(); - // 带超时执行 - let result = match timeout( - Duration::from_millis(timeout_ms), - plugin.on_request(ctx, request), - ) - .await + let result = match self + .task_tracker + .execute(&plugin_name, "on_request", policy, move |_attempt| { + let plugin = plugin.clone(); + let mut attempt_ctx = base_ctx.clone(); + let mut attempt_request = base_request.clone(); + async move { + let hook_result = plugin + .on_request(&mut attempt_ctx, &mut attempt_request) + .await?; + Ok((hook_result, attempt_ctx, attempt_request)) + } + }) + .await { - Ok(Ok(result)) => result, - Ok(Err(e)) => { - tracing::warn!("插件 {} on_request 执行失败: {}", plugin_name, e); - HookResult::failure(e.to_string(), timeout_ms) + Ok((hook_result, next_ctx, next_request)) => { + *ctx = next_ctx; + *request = next_request; + hook_result } - Err(_) => { - tracing::warn!("插件 {} on_request 执行超时", plugin_name); - HookResult::failure(format!("执行超时 ({timeout_ms}ms)"), timeout_ms) + Err(failure) => { + tracing::warn!( + "插件 {} on_request 执行失败: {} (state={:?}, attempts={})", + plugin_name, + failure.message, + failure.state, + failure.attempts + ); + HookResult::failure(failure.message, timeout_ms) } }; @@ -321,24 +398,41 @@ impl PluginManager { } let timeout_ms = instance.config.timeout_ms; + let policy = self.build_policy(timeout_ms); let plugin = instance.plugin.clone(); let plugin_name = plugin.name().to_string(); + let base_ctx = ctx.clone(); + let base_response = response.clone(); - // 带超时执行 - let result = match timeout( - Duration::from_millis(timeout_ms), - plugin.on_response(ctx, response), - ) - .await + let result = match self + .task_tracker + .execute(&plugin_name, "on_response", policy, move |_attempt| { + let plugin = plugin.clone(); + let mut attempt_ctx = base_ctx.clone(); + let mut attempt_response = base_response.clone(); + async move { + let hook_result = plugin + .on_response(&mut attempt_ctx, &mut attempt_response) + .await?; + Ok((hook_result, attempt_ctx, attempt_response)) + } + }) + .await { - Ok(Ok(result)) => result, - Ok(Err(e)) => { - tracing::warn!("插件 {} on_response 执行失败: {}", plugin_name, e); - HookResult::failure(e.to_string(), timeout_ms) + Ok((hook_result, next_ctx, next_response)) => { + *ctx = next_ctx; + *response = next_response; + hook_result } - Err(_) => { - tracing::warn!("插件 {} on_response 执行超时", plugin_name); - HookResult::failure(format!("执行超时 ({timeout_ms}ms)"), timeout_ms) + Err(failure) => { + tracing::warn!( + "插件 {} on_response 执行失败: {} (state={:?}, attempts={})", + plugin_name, + failure.message, + failure.state, + failure.attempts + ); + HookResult::failure(failure.message, timeout_ms) } }; @@ -371,24 +465,38 @@ impl PluginManager { } let timeout_ms = instance.config.timeout_ms; + let policy = self.build_policy(timeout_ms); let plugin = instance.plugin.clone(); let plugin_name = plugin.name().to_string(); + let base_ctx = ctx.clone(); + let error_text = error.to_string(); - // 带超时执行 - let result = match timeout( - Duration::from_millis(timeout_ms), - plugin.on_error(ctx, error), - ) - .await + let result = match self + .task_tracker + .execute(&plugin_name, "on_error", policy, move |_attempt| { + let plugin = plugin.clone(); + let mut attempt_ctx = base_ctx.clone(); + let error_text = error_text.clone(); + async move { + let hook_result = plugin.on_error(&mut attempt_ctx, &error_text).await?; + Ok((hook_result, attempt_ctx)) + } + }) + .await { - Ok(Ok(result)) => result, - Ok(Err(e)) => { - tracing::warn!("插件 {} on_error 执行失败: {}", plugin_name, e); - HookResult::failure(e.to_string(), timeout_ms) + Ok((hook_result, next_ctx)) => { + *ctx = next_ctx; + hook_result } - Err(_) => { - tracing::warn!("插件 {} on_error 执行超时", plugin_name); - HookResult::failure(format!("执行超时 ({timeout_ms}ms)"), timeout_ms) + Err(failure) => { + tracing::warn!( + "插件 {} on_error 执行失败: {} (state={:?}, attempts={})", + plugin_name, + failure.message, + failure.state, + failure.attempts + ); + HookResult::failure(failure.message, timeout_ms) } }; @@ -452,9 +560,16 @@ impl PluginManager { .get(plugin_id) .ok_or_else(|| PluginError::NotFound(plugin_id.to_string()))?; - // TODO: 检查插件是否实现了 PluginUI trait - // 目前返回空列表 - Ok(Vec::new()) + let policy = self.build_policy(self.config.default_timeout_ms); + self.task_tracker + .execute(plugin_id, "get_plugin_surfaces", policy, |_attempt| async { + Ok::<_, PluginError>(Vec::new()) + }) + .await + .map_err(|failure| PluginError::ExecutionError { + plugin_name: plugin_id.to_string(), + message: failure.message, + }) } /// 处理插件 UI 操作 @@ -468,16 +583,29 @@ impl PluginManager { .get(plugin_id) .ok_or_else(|| PluginError::NotFound(plugin_id.to_string()))?; - // TODO: 将操作转发给插件的 handle_action 方法 - // 目前返回空列表 - tracing::debug!( - "收到插件 {} 的 UI 操作: {} (surface: {})", - plugin_id, - action.name, - action.surface_id - ); + let action_name = action.name.clone(); + let surface_id = action.surface_id.clone(); + let policy = self.build_policy(self.config.default_timeout_ms); - Ok(Vec::new()) + self.task_tracker + .execute(plugin_id, "handle_plugin_action", policy, move |_attempt| { + let action_name = action_name.clone(); + let surface_id = surface_id.clone(); + async move { + tracing::debug!( + "收到插件 {} 的 UI 操作: {} (surface: {})", + plugin_id, + action_name, + surface_id + ); + Ok::<_, PluginError>(Vec::new()) + } + }) + .await + .map_err(|failure| PluginError::ExecutionError { + plugin_name: plugin_id.to_string(), + message: failure.message, + }) } } diff --git a/src-tauri/crates/core/src/plugin/mod.rs b/src-tauri/crates/core/src/plugin/mod.rs index 1c877fc81..7a45d4f1f 100644 --- a/src-tauri/crates/core/src/plugin/mod.rs +++ b/src-tauri/crates/core/src/plugin/mod.rs @@ -14,6 +14,7 @@ pub mod examples; pub mod installer; mod loader; mod manager; +mod task; mod types; pub mod ui_builder; pub mod ui_trait; @@ -22,6 +23,10 @@ pub mod ui_types; pub use binary_downloader::BinaryDownloader; pub use loader::PluginLoader; pub use manager::PluginManager; +pub use task::{ + PluginQueueStats, PluginTaskError, PluginTaskEventPayload, PluginTaskFailure, PluginTaskPolicy, + PluginTaskRecord, PluginTaskState, PluginTaskTracker, +}; pub use types::{ BinaryComponentStatus, BinaryManifest, HookResult, PlatformBinaries, Plugin, PluginConfig, PluginContext, PluginError, PluginInfo, PluginManifest, PluginState, PluginStatus, PluginType, diff --git a/src-tauri/crates/core/src/plugin/task.rs b/src-tauri/crates/core/src/plugin/task.rs new file mode 100644 index 000000000..9b8d395e7 --- /dev/null +++ b/src-tauri/crates/core/src/plugin/task.rs @@ -0,0 +1,855 @@ +//! 插件任务执行治理模型 +//! +//! 提供统一的任务状态、重试、超时、并发和队列治理能力。 + +use chrono::{DateTime, Utc}; +use dashmap::DashMap; +use serde::{Deserialize, Serialize}; +use std::future::Future; +use std::str::FromStr; +use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering}; +use std::sync::Arc; +use std::time::{Duration, Instant}; +use tokio::sync::{RwLock, Semaphore}; +use tokio::time::{sleep, timeout}; +use uuid::Uuid; + +use crate::event_emit::DynEmitter; + +use super::types::PluginError; + +/// 插件任务状态 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PluginTaskState { + Queued, + Running, + Retrying, + Succeeded, + Failed, + Cancelled, + TimedOut, +} + +impl PluginTaskState { + pub fn is_terminal(self) -> bool { + matches!( + self, + PluginTaskState::Succeeded + | PluginTaskState::Failed + | PluginTaskState::Cancelled + | PluginTaskState::TimedOut + ) + } +} + +impl FromStr for PluginTaskState { + type Err = String; + + fn from_str(s: &str) -> Result { + match s { + "queued" => Ok(Self::Queued), + "running" => Ok(Self::Running), + "retrying" => Ok(Self::Retrying), + "succeeded" => Ok(Self::Succeeded), + "failed" => Ok(Self::Failed), + "cancelled" => Ok(Self::Cancelled), + "timed_out" => Ok(Self::TimedOut), + _ => Err(format!("未知任务状态: {s}")), + } + } +} + +/// 任务错误详情 +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PluginTaskError { + pub code: Option, + pub message: String, + pub retryable: bool, +} + +/// 插件任务执行策略 +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PluginTaskPolicy { + pub timeout_ms: u64, + pub max_retries: u32, + pub retry_backoff_ms: u64, + pub max_concurrency_per_plugin: usize, + pub queue_limit_per_plugin: usize, +} + +impl Default for PluginTaskPolicy { + fn default() -> Self { + Self { + timeout_ms: 30_000, + max_retries: 2, + retry_backoff_ms: 300, + max_concurrency_per_plugin: 4, + queue_limit_per_plugin: 100, + } + } +} + +/// 插件任务记录 +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PluginTaskRecord { + pub task_id: String, + pub plugin_id: String, + pub operation: String, + pub state: PluginTaskState, + pub attempt: u32, + pub max_retries: u32, + pub started_at: DateTime, + pub ended_at: Option>, + pub duration_ms: Option, + pub error: Option, +} + +impl PluginTaskRecord { + fn new(task_id: String, plugin_id: String, operation: String, max_retries: u32) -> Self { + Self { + task_id, + plugin_id, + operation, + state: PluginTaskState::Queued, + attempt: 0, + max_retries, + started_at: Utc::now(), + ended_at: None, + duration_ms: None, + error: None, + } + } + + fn finish_with_success(&mut self, attempt: u32, started: Instant) { + self.state = PluginTaskState::Succeeded; + self.attempt = attempt; + self.ended_at = Some(Utc::now()); + self.duration_ms = Some(started.elapsed().as_millis() as u64); + self.error = None; + } + + fn finish_with_failure( + &mut self, + state: PluginTaskState, + attempt: u32, + started: Instant, + error: PluginTaskError, + ) { + self.state = state; + self.attempt = attempt; + self.ended_at = Some(Utc::now()); + self.duration_ms = Some(started.elapsed().as_millis() as u64); + self.error = Some(error); + } +} + +/// 前端消费的任务事件载荷 +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PluginTaskEventPayload { + pub plugin_id: String, + pub task_id: String, + pub operation: String, + pub state: PluginTaskState, + pub attempt: u32, + pub timestamp: String, + pub error: Option, +} + +impl PluginTaskEventPayload { + fn from_record(record: &PluginTaskRecord) -> Self { + Self { + plugin_id: record.plugin_id.clone(), + task_id: record.task_id.clone(), + operation: record.operation.clone(), + state: record.state, + attempt: record.attempt, + timestamp: Utc::now().to_rfc3339(), + error: record.error.clone(), + } + } +} + +/// 任务失败返回 +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PluginTaskFailure { + pub task_id: String, + pub state: PluginTaskState, + pub attempts: u32, + pub message: String, + pub retryable: bool, +} + +impl PluginTaskFailure { + fn new( + task_id: String, + state: PluginTaskState, + attempts: u32, + message: String, + retryable: bool, + ) -> Self { + Self { + task_id, + state, + attempts, + message, + retryable, + } + } +} + +/// 插件队列统计信息 +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PluginQueueStats { + pub plugin_id: String, + pub running: usize, + pub waiting: usize, + pub rejected: u64, + pub completed: u64, + pub failed: u64, + pub cancelled: u64, + pub timed_out: u64, +} + +#[derive(Default)] +struct QueueMetrics { + running: AtomicUsize, + waiting: AtomicUsize, + rejected: AtomicU64, + completed: AtomicU64, + failed: AtomicU64, + cancelled: AtomicU64, + timed_out: AtomicU64, +} + +impl QueueMetrics { + fn snapshot(&self, plugin_id: String) -> PluginQueueStats { + PluginQueueStats { + plugin_id, + running: self.running.load(Ordering::SeqCst), + waiting: self.waiting.load(Ordering::SeqCst), + rejected: self.rejected.load(Ordering::SeqCst), + completed: self.completed.load(Ordering::SeqCst), + failed: self.failed.load(Ordering::SeqCst), + cancelled: self.cancelled.load(Ordering::SeqCst), + timed_out: self.timed_out.load(Ordering::SeqCst), + } + } +} + +struct RunningGuard { + metrics: Arc, +} + +impl RunningGuard { + fn new(metrics: Arc) -> Self { + Self { metrics } + } +} + +impl Drop for RunningGuard { + fn drop(&mut self) { + self.metrics.running.fetch_sub(1, Ordering::SeqCst); + } +} + +/// 插件任务跟踪器 +pub struct PluginTaskTracker { + tasks: DashMap, + semaphores: DashMap>, + queue_metrics: DashMap>, + cancel_flags: DashMap>, + retention_limit: usize, + emitter: Arc>>, +} + +impl Default for PluginTaskTracker { + fn default() -> Self { + Self::new(2_000) + } +} + +impl PluginTaskTracker { + pub fn new(retention_limit: usize) -> Self { + Self { + tasks: DashMap::new(), + semaphores: DashMap::new(), + queue_metrics: DashMap::new(), + cancel_flags: DashMap::new(), + retention_limit: retention_limit.max(100), + emitter: Arc::new(RwLock::new(None)), + } + } + + pub async fn set_emitter(&self, emitter: DynEmitter) { + let mut guard = self.emitter.write().await; + *guard = Some(emitter); + } + + pub async fn clear_emitter(&self) { + let mut guard = self.emitter.write().await; + *guard = None; + } + + pub fn get_task(&self, task_id: &str) -> Option { + self.tasks.get(task_id).map(|entry| entry.value().clone()) + } + + pub fn list_tasks( + &self, + plugin_id: Option<&str>, + state: Option, + limit: usize, + ) -> Vec { + let mut records: Vec = self + .tasks + .iter() + .filter_map(|entry| { + let record = entry.value(); + if let Some(plugin_id_filter) = plugin_id { + if record.plugin_id != plugin_id_filter { + return None; + } + } + if let Some(state_filter) = state { + if record.state != state_filter { + return None; + } + } + Some(record.clone()) + }) + .collect(); + + records.sort_by(|a, b| b.started_at.cmp(&a.started_at)); + records.truncate(limit.max(1)); + records + } + + pub fn cancel_task(&self, task_id: &str) -> bool { + let Some(flag) = self.cancel_flags.get(task_id) else { + return false; + }; + flag.store(true, Ordering::SeqCst); + true + } + + pub fn queue_stats(&self, plugin_id: Option<&str>) -> Vec { + let mut items = Vec::new(); + for entry in &self.queue_metrics { + if let Some(plugin_filter) = plugin_id { + if entry.key() != plugin_filter { + continue; + } + } + items.push(entry.value().snapshot(entry.key().clone())); + } + items.sort_by(|a, b| a.plugin_id.cmp(&b.plugin_id)); + items + } + + pub async fn execute( + &self, + plugin_id: &str, + operation: &str, + mut policy: PluginTaskPolicy, + mut operation_fn: F, + ) -> Result + where + T: Send + 'static, + F: FnMut(u32) -> Fut + Send, + Fut: Future> + Send, + { + if policy.max_concurrency_per_plugin == 0 { + policy.max_concurrency_per_plugin = 1; + } + if policy.queue_limit_per_plugin == 0 { + policy.queue_limit_per_plugin = 1; + } + if policy.timeout_ms == 0 { + policy.timeout_ms = 1; + } + + let task_id = Uuid::new_v4().to_string(); + let total_started = Instant::now(); + let mut record = PluginTaskRecord::new( + task_id.clone(), + plugin_id.to_string(), + operation.to_string(), + policy.max_retries, + ); + self.upsert_task(record.clone()); + self.emit_task_event(&record).await; + + let cancel_flag = Arc::new(AtomicBool::new(false)); + self.cancel_flags + .insert(task_id.clone(), Arc::clone(&cancel_flag)); + + let semaphore = self + .semaphores + .entry(plugin_id.to_string()) + .or_insert_with(|| Arc::new(Semaphore::new(policy.max_concurrency_per_plugin))) + .clone(); + let metrics = self + .queue_metrics + .entry(plugin_id.to_string()) + .or_insert_with(|| Arc::new(QueueMetrics::default())) + .clone(); + + let waiting_now = metrics.waiting.fetch_add(1, Ordering::SeqCst) + 1; + if waiting_now > policy.queue_limit_per_plugin { + metrics.waiting.fetch_sub(1, Ordering::SeqCst); + metrics.rejected.fetch_add(1, Ordering::SeqCst); + + let error = PluginTaskError { + code: Some("QUEUE_LIMIT_EXCEEDED".to_string()), + message: format!( + "插件 {plugin_id} 队列已满 (limit={})", + policy.queue_limit_per_plugin + ), + retryable: false, + }; + record.finish_with_failure(PluginTaskState::Failed, 0, total_started, error.clone()); + self.upsert_task(record.clone()); + self.cancel_flags.remove(&task_id); + self.emit_task_event(&record).await; + return Err(PluginTaskFailure::new( + task_id, + PluginTaskState::Failed, + 0, + error.message, + false, + )); + } + + let permit = match semaphore.acquire_owned().await { + Ok(permit) => permit, + Err(err) => { + metrics.waiting.fetch_sub(1, Ordering::SeqCst); + let error = PluginTaskError { + code: Some("SEMAPHORE_CLOSED".to_string()), + message: format!("无法获取插件执行许可: {err}"), + retryable: true, + }; + record.finish_with_failure( + PluginTaskState::Failed, + 0, + total_started, + error.clone(), + ); + self.upsert_task(record.clone()); + self.cancel_flags.remove(&task_id); + self.emit_task_event(&record).await; + return Err(PluginTaskFailure::new( + task_id, + PluginTaskState::Failed, + 0, + error.message, + true, + )); + } + }; + metrics.waiting.fetch_sub(1, Ordering::SeqCst); + metrics.running.fetch_add(1, Ordering::SeqCst); + let running_guard = RunningGuard::new(Arc::clone(&metrics)); + + if cancel_flag.load(Ordering::SeqCst) { + let error = PluginTaskError { + code: Some("TASK_CANCELLED".to_string()), + message: "任务已取消".to_string(), + retryable: false, + }; + record.finish_with_failure(PluginTaskState::Cancelled, 0, total_started, error.clone()); + metrics.cancelled.fetch_add(1, Ordering::SeqCst); + self.upsert_task(record.clone()); + self.cancel_flags.remove(&task_id); + self.emit_task_event(&record).await; + drop(permit); + drop(running_guard); + return Err(PluginTaskFailure::new( + task_id, + PluginTaskState::Cancelled, + 0, + error.message, + false, + )); + } + + let mut attempt: u32 = 0; + loop { + attempt += 1; + record.state = if attempt == 1 { + PluginTaskState::Running + } else { + PluginTaskState::Retrying + }; + record.attempt = attempt; + record.error = None; + self.upsert_task(record.clone()); + self.emit_task_event(&record).await; + + let timed_result = timeout( + Duration::from_millis(policy.timeout_ms), + operation_fn(attempt), + ) + .await; + + match timed_result { + Ok(Ok(value)) => { + record.finish_with_success(attempt, total_started); + metrics.completed.fetch_add(1, Ordering::SeqCst); + self.upsert_task(record.clone()); + self.cancel_flags.remove(&task_id); + self.emit_task_event(&record).await; + drop(permit); + drop(running_guard); + return Ok(value); + } + Ok(Err(err)) => { + let retryable = is_retryable_error(&err); + let can_retry = retryable + && attempt <= policy.max_retries + && !cancel_flag.load(Ordering::SeqCst); + + if can_retry { + let backoff = backoff_duration(policy.retry_backoff_ms, attempt); + sleep(backoff).await; + continue; + } + + let state = if cancel_flag.load(Ordering::SeqCst) { + PluginTaskState::Cancelled + } else { + PluginTaskState::Failed + }; + let error = PluginTaskError { + code: classify_error_code(&err), + message: err.to_string(), + retryable, + }; + record.finish_with_failure(state, attempt, total_started, error.clone()); + match state { + PluginTaskState::Cancelled => { + metrics.cancelled.fetch_add(1, Ordering::SeqCst); + } + PluginTaskState::Failed => { + metrics.failed.fetch_add(1, Ordering::SeqCst); + } + _ => {} + } + self.upsert_task(record.clone()); + self.cancel_flags.remove(&task_id); + self.emit_task_event(&record).await; + drop(permit); + drop(running_guard); + return Err(PluginTaskFailure::new( + task_id, + state, + attempt, + error.message, + retryable, + )); + } + Err(_) => { + let can_retry = + attempt <= policy.max_retries && !cancel_flag.load(Ordering::SeqCst); + if can_retry { + let backoff = backoff_duration(policy.retry_backoff_ms, attempt); + sleep(backoff).await; + continue; + } + let state = if cancel_flag.load(Ordering::SeqCst) { + PluginTaskState::Cancelled + } else { + PluginTaskState::TimedOut + }; + let error = PluginTaskError { + code: Some(if state == PluginTaskState::TimedOut { + "TIMEOUT".to_string() + } else { + "TASK_CANCELLED".to_string() + }), + message: if state == PluginTaskState::TimedOut { + format!("执行超时: {}ms", policy.timeout_ms) + } else { + "任务已取消".to_string() + }, + retryable: state == PluginTaskState::TimedOut, + }; + record.finish_with_failure(state, attempt, total_started, error.clone()); + match state { + PluginTaskState::TimedOut => { + metrics.timed_out.fetch_add(1, Ordering::SeqCst); + } + PluginTaskState::Cancelled => { + metrics.cancelled.fetch_add(1, Ordering::SeqCst); + } + _ => {} + } + self.upsert_task(record.clone()); + self.cancel_flags.remove(&task_id); + self.emit_task_event(&record).await; + drop(permit); + drop(running_guard); + return Err(PluginTaskFailure::new( + task_id, + state, + attempt, + error.message, + state == PluginTaskState::TimedOut, + )); + } + } + } + } + + fn upsert_task(&self, record: PluginTaskRecord) { + self.tasks.insert(record.task_id.clone(), record); + self.trim_retention(); + } + + fn trim_retention(&self) { + if self.tasks.len() <= self.retention_limit { + return; + } + + while self.tasks.len() > self.retention_limit { + let oldest_id = self + .tasks + .iter() + .min_by_key(|entry| entry.value().started_at) + .map(|entry| entry.key().clone()); + let Some(oldest_id) = oldest_id else { + break; + }; + self.tasks.remove(&oldest_id); + self.cancel_flags.remove(&oldest_id); + } + } + + async fn emit_task_event(&self, record: &PluginTaskRecord) { + let payload = PluginTaskEventPayload::from_record(record); + let Ok(value) = serde_json::to_value(payload) else { + return; + }; + + let emitter = self.emitter.read().await.clone(); + if let Some(emitter) = emitter { + let _ = emitter.emit_event("plugin-task-event", &value); + } + } +} + +fn backoff_duration(base_ms: u64, attempt: u32) -> Duration { + let factor = 2_u64.saturating_pow(attempt.saturating_sub(1)); + Duration::from_millis(base_ms.max(1).saturating_mul(factor)) +} + +fn classify_error_code(err: &PluginError) -> Option { + match err { + PluginError::Timeout { .. } => Some("TIMEOUT".to_string()), + PluginError::Disabled(_) => Some("PLUGIN_DISABLED".to_string()), + PluginError::NotFound(_) => Some("PLUGIN_NOT_FOUND".to_string()), + PluginError::ConfigError(_) => Some("CONFIG_ERROR".to_string()), + PluginError::LoadError(_) => Some("LOAD_ERROR".to_string()), + PluginError::InitError(_) => Some("INIT_ERROR".to_string()), + PluginError::ExecutionError { message, .. } => { + if message.contains("401") || message.contains("403") { + Some("AUTH_ERROR".to_string()) + } else if message.contains("429") { + Some("RATE_LIMIT".to_string()) + } else if message.contains("500") + || message.contains("502") + || message.contains("503") + || message.contains("504") + { + Some("UPSTREAM_5XX".to_string()) + } else { + Some("EXECUTION_ERROR".to_string()) + } + } + _ => Some("UNKNOWN".to_string()), + } +} + +fn is_retryable_error(err: &PluginError) -> bool { + match err { + PluginError::Timeout { .. } => true, + PluginError::ExecutionError { message, .. } => { + let lower = message.to_lowercase(); + message.contains("429") + || message.contains("500") + || message.contains("502") + || message.contains("503") + || message.contains("504") + || lower.contains("timeout") + || lower.contains("temporar") + || lower.contains("connection") + || lower.contains("network") + } + _ => false, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tokio::sync::Mutex; + + #[tokio::test] + async fn test_execute_success_and_record_terminal_state() { + let tracker = PluginTaskTracker::new(100); + let policy = PluginTaskPolicy::default(); + + let result = tracker + .execute("demo-plugin", "on_request", policy, |_attempt| async move { + Ok::<_, PluginError>("ok".to_string()) + }) + .await + .expect("执行应成功"); + + assert_eq!(result, "ok"); + let tasks = tracker.list_tasks(Some("demo-plugin"), None, 10); + assert_eq!(tasks.len(), 1); + assert_eq!(tasks[0].state, PluginTaskState::Succeeded); + assert_eq!(tasks[0].attempt, 1); + } + + #[tokio::test] + async fn test_retry_then_success() { + let tracker = PluginTaskTracker::new(100); + let policy = PluginTaskPolicy { + max_retries: 2, + retry_backoff_ms: 1, + ..PluginTaskPolicy::default() + }; + let counter = Arc::new(Mutex::new(0_u32)); + + let result = tracker + .execute("retry-plugin", "on_response", policy, { + let counter = Arc::clone(&counter); + move |_attempt| { + let counter = Arc::clone(&counter); + async move { + let mut lock = counter.lock().await; + *lock += 1; + if *lock < 2 { + Err(PluginError::ExecutionError { + plugin_name: "retry-plugin".to_string(), + message: "503 upstream unavailable".to_string(), + }) + } else { + Ok::<_, PluginError>("recovered".to_string()) + } + } + } + }) + .await + .expect("应在重试后成功"); + + assert_eq!(result, "recovered"); + let tasks = tracker.list_tasks(Some("retry-plugin"), None, 10); + assert_eq!(tasks[0].state, PluginTaskState::Succeeded); + assert_eq!(tasks[0].attempt, 2); + } + + #[tokio::test] + async fn test_timeout_to_terminal_state() { + let tracker = PluginTaskTracker::new(100); + let policy = PluginTaskPolicy { + timeout_ms: 30, + max_retries: 0, + ..PluginTaskPolicy::default() + }; + + let result = tracker + .execute( + "timeout-plugin", + "on_error", + policy, + |_attempt| async move { + sleep(Duration::from_millis(80)).await; + Ok::<_, PluginError>("late".to_string()) + }, + ) + .await; + + assert!(result.is_err()); + let err = result.expect_err("应超时失败"); + assert_eq!(err.state, PluginTaskState::TimedOut); + let tasks = tracker.list_tasks(Some("timeout-plugin"), None, 10); + assert_eq!(tasks[0].state, PluginTaskState::TimedOut); + } + + #[tokio::test] + async fn test_queue_limit_rejection() { + let tracker = Arc::new(PluginTaskTracker::new(100)); + let policy = PluginTaskPolicy { + max_concurrency_per_plugin: 1, + queue_limit_per_plugin: 1, + timeout_ms: 500, + max_retries: 0, + ..PluginTaskPolicy::default() + }; + + let tracker_a = Arc::clone(&tracker); + let policy_a = policy.clone(); + let t1 = tokio::spawn(async move { + tracker_a + .execute( + "queue-plugin", + "on_request", + policy_a, + |_attempt| async move { + sleep(Duration::from_millis(150)).await; + Ok::<_, PluginError>("t1".to_string()) + }, + ) + .await + }); + + sleep(Duration::from_millis(20)).await; + + let tracker_b = Arc::clone(&tracker); + let policy_b = policy.clone(); + let t2 = tokio::spawn(async move { + tracker_b + .execute( + "queue-plugin", + "on_request", + policy_b, + |_attempt| async move { + sleep(Duration::from_millis(80)).await; + Ok::<_, PluginError>("t2".to_string()) + }, + ) + .await + }); + + sleep(Duration::from_millis(20)).await; + + let t3 = tracker + .execute( + "queue-plugin", + "on_request", + policy, + |_attempt| async move { Ok::<_, PluginError>("t3".to_string()) }, + ) + .await; + + let r1 = t1.await.expect("join t1"); + let r2 = t2.await.expect("join t2"); + assert!(r1.is_ok()); + assert!(r2.is_ok()); + assert!(t3.is_err()); + + let stats = tracker.queue_stats(Some("queue-plugin")); + assert_eq!(stats.len(), 1); + assert!(stats[0].rejected >= 1); + } +} diff --git a/src-tauri/crates/core/src/plugin/tests.rs b/src-tauri/crates/core/src/plugin/tests.rs index 2da514879..077b4ab2f 100644 --- a/src-tauri/crates/core/src/plugin/tests.rs +++ b/src-tauri/crates/core/src/plugin/tests.rs @@ -266,6 +266,7 @@ mod property_tests { default_timeout_ms: 1000, enabled: true, max_plugins: 10, + ..PluginManagerConfig::default() }; let manager = PluginManager::new(temp_dir.path().to_path_buf(), config); @@ -297,6 +298,7 @@ mod property_tests { default_timeout_ms: 1000, enabled: false, // 禁用插件系统 max_plugins: 10, + ..PluginManagerConfig::default() }; let manager = PluginManager::new(temp_dir.path().to_path_buf(), config); diff --git a/src-tauri/crates/providers/src/providers/codex.rs b/src-tauri/crates/providers/src/providers/codex.rs index e788bfde6..f7ae12831 100644 --- a/src-tauri/crates/providers/src/providers/codex.rs +++ b/src-tauri/crates/providers/src/providers/codex.rs @@ -744,7 +744,10 @@ impl CodexProvider { } // 3. OAuth 刷新流程(标准流程) - let refresh_token = self.credentials.refresh_token.as_ref().unwrap(); + let refresh_token = + self.credentials.refresh_token.as_ref().ok_or_else(|| { + create_config_error("OAuth 刷新令牌不可用 (refresh_token is None)") + })?; tracing::info!("[CODEX] 正在刷新 access token"); @@ -2351,7 +2354,7 @@ pub async fn start_codex_oauth_server_and_get_url() -> Result< let uuid = Uuid::new_v4().to_string(); let timestamp = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) - .unwrap() + .unwrap_or_default() .as_secs(); let filename = format!("codex_{}_{}.json", &uuid[..8], timestamp); let creds_file_path = creds_dir.join(&filename); diff --git a/src-tauri/crates/scheduler/src/batch_dao.rs b/src-tauri/crates/scheduler/src/batch_dao.rs index d1b40eff8..003dbf035 100644 --- a/src-tauri/crates/scheduler/src/batch_dao.rs +++ b/src-tauri/crates/scheduler/src/batch_dao.rs @@ -5,7 +5,7 @@ use super::batch::{BatchTask, BatchTaskStatus}; use super::template::TaskTemplate; use anyhow::{Context, Result}; -use proxycast_core::database::DbConnection; +use proxycast_core::database::{lock_db, DbConnection}; use rusqlite::{params, OptionalExtension}; use uuid::Uuid; @@ -15,7 +15,7 @@ pub struct BatchTaskDao; impl BatchTaskDao { /// 初始化数据库表 pub fn init_tables(db: &DbConnection) -> Result<()> { - let conn = db.lock().unwrap(); + let conn = lock_db(db).map_err(|e| anyhow::anyhow!(e))?; // 创建批量任务表 conn.execute( @@ -69,7 +69,7 @@ impl BatchTaskDao { /// 保存批量任务 pub fn save(db: &DbConnection, batch_task: &BatchTask) -> Result<()> { - let conn = db.lock().unwrap(); + let conn = lock_db(db).map_err(|e| anyhow::anyhow!(e))?; let options_json = serde_json::to_string(&batch_task.options)?; let tasks_json = serde_json::to_string(&batch_task.tasks)?; @@ -104,7 +104,7 @@ impl BatchTaskDao { /// 根据 ID 查询批量任务 pub fn get_by_id(db: &DbConnection, id: &Uuid) -> Result> { - let conn = db.lock().unwrap(); + let conn = lock_db(db).map_err(|e| anyhow::anyhow!(e))?; let mut stmt = conn.prepare( "SELECT id, name, template_id, status, options_json, tasks_json, results_json, @@ -185,7 +185,7 @@ impl BatchTaskDao { /// 查询所有批量任务 pub fn list_all(db: &DbConnection, limit: usize) -> Result> { - let conn = db.lock().unwrap(); + let conn = lock_db(db).map_err(|e| anyhow::anyhow!(e))?; let mut stmt = conn.prepare( "SELECT id, name, template_id, status, options_json, tasks_json, results_json, @@ -268,7 +268,7 @@ impl BatchTaskDao { /// 删除批量任务 pub fn delete(db: &DbConnection, id: &Uuid) -> Result { - let conn = db.lock().unwrap(); + let conn = lock_db(db).map_err(|e| anyhow::anyhow!(e))?; let affected = conn.execute( "DELETE FROM batch_tasks WHERE id = ?1", @@ -280,7 +280,7 @@ impl BatchTaskDao { /// 更新批量任务状态 pub fn update_status(db: &DbConnection, id: &Uuid, status: BatchTaskStatus) -> Result<()> { - let conn = db.lock().unwrap(); + let conn = lock_db(db).map_err(|e| anyhow::anyhow!(e))?; conn.execute( "UPDATE batch_tasks SET status = ?1 WHERE id = ?2", @@ -301,7 +301,7 @@ impl BatchTaskDao { started_at: Option>, completed_at: Option>, ) -> Result<()> { - let conn = db.lock().unwrap(); + let conn = lock_db(db).map_err(|e| anyhow::anyhow!(e))?; let results_json = if results.is_empty() { None @@ -330,7 +330,7 @@ pub struct TemplateDao; impl TemplateDao { /// 保存模板 pub fn save(db: &DbConnection, template: &TaskTemplate) -> Result<()> { - let conn = db.lock().unwrap(); + let conn = lock_db(db).map_err(|e| anyhow::anyhow!(e))?; conn.execute( "INSERT OR REPLACE INTO batch_templates @@ -357,7 +357,7 @@ impl TemplateDao { /// 根据 ID 查询模板 pub fn get_by_id(db: &DbConnection, id: &Uuid) -> Result> { - let conn = db.lock().unwrap(); + let conn = lock_db(db).map_err(|e| anyhow::anyhow!(e))?; let mut stmt = conn.prepare( "SELECT id, name, description, model, system_prompt, user_message_template, @@ -391,7 +391,7 @@ impl TemplateDao { /// 查询所有模板 pub fn list_all(db: &DbConnection) -> Result> { - let conn = db.lock().unwrap(); + let conn = lock_db(db).map_err(|e| anyhow::anyhow!(e))?; let mut stmt = conn.prepare( "SELECT id, name, description, model, system_prompt, user_message_template, @@ -429,7 +429,7 @@ impl TemplateDao { /// 删除模板 pub fn delete(db: &DbConnection, id: &Uuid) -> Result { - let conn = db.lock().unwrap(); + let conn = lock_db(db).map_err(|e| anyhow::anyhow!(e))?; let affected = conn.execute( "DELETE FROM batch_templates WHERE id = ?1", diff --git a/src-tauri/src/app/bootstrap.rs b/src-tauri/src/app/bootstrap.rs index 147c96849..d844dec39 100644 --- a/src-tauri/src/app/bootstrap.rs +++ b/src-tauri/src/app/bootstrap.rs @@ -232,7 +232,7 @@ pub fn init_states(config: &Config) -> Result { // 初始化默认技能仓库 { - let conn = db.lock().expect("Failed to lock database"); + let conn = database::lock_db(&db).map_err(|e| format!("Failed to lock database: {e}"))?; database::dao::skills::SkillDao::init_default_skill_repos(&conn) .map_err(|e| format!("初始化默认技能仓库失败: {e}"))?; } diff --git a/src-tauri/src/app/runner.rs b/src-tauri/src/app/runner.rs index c3ba829a2..572f6fde4 100644 --- a/src-tauri/src/app/runner.rs +++ b/src-tauri/src/app/runner.rs @@ -240,6 +240,21 @@ pub fn run() { tracing::info!("[启动] MCP Manager 事件发射器已设置"); } + // 设置 PluginManager 的任务事件发射器(用于发送 plugin-task-event) + if let Some(plugin_manager) = + app.try_state::() + { + let app_handle = app.handle().clone(); + let emitter = proxycast_core::DynEmitter::new( + crate::app::TauriEventEmitter(app_handle), + ); + tauri::async_runtime::block_on(async { + let manager = plugin_manager.0.read().await; + manager.set_task_emitter(emitter).await; + }); + tracing::info!("[启动] PluginManager 任务事件发射器已设置"); + } + // 初始化截图对话模块 // _Requirements: 7.3_ { @@ -990,6 +1005,10 @@ pub fn run() { commands::plugin_cmd::reload_plugins, commands::plugin_cmd::unload_plugin, commands::plugin_cmd::get_plugins_dir, + commands::plugin_cmd::list_plugin_tasks, + commands::plugin_cmd::get_plugin_task, + commands::plugin_cmd::cancel_plugin_task, + commands::plugin_cmd::get_plugin_queue_stats, // Plugin Install commands commands::plugin_install_cmd::install_plugin_from_file, commands::plugin_install_cmd::install_plugin_from_url, diff --git a/src-tauri/src/app/setup.rs b/src-tauri/src/app/setup.rs index 3edc97efa..a645a0825 100644 --- a/src-tauri/src/app/setup.rs +++ b/src-tauri/src/app/setup.rs @@ -75,7 +75,7 @@ pub fn setup_app( // 初始化默认 skill repos { - let conn = db.lock().expect("Failed to lock database"); + let conn = proxycast_core::database::lock_db(&db)?; database::dao::skills::SkillDao::init_default_skill_repos(&conn) .expect("Failed to initialize default skill repos"); } diff --git a/src-tauri/src/commands/memory_feedback_cmd.rs b/src-tauri/src/commands/memory_feedback_cmd.rs index ab7a43d89..8c02ff652 100644 --- a/src-tauri/src/commands/memory_feedback_cmd.rs +++ b/src-tauri/src/commands/memory_feedback_cmd.rs @@ -1,6 +1,7 @@ //! Memory feedback commands use crate::database::DbConnection; +use proxycast_core::database::lock_db; use proxycast_memory::feedback::{ calculate_approval_rate, current_timestamp, generate_feedback_id, get_recent_feedbacks, record_feedback, FeedbackAction, UserFeedback, @@ -28,7 +29,7 @@ pub async fn unified_memory_feedback( created_at: current_timestamp(), }; - let conn = db.lock().unwrap(); + let conn = lock_db(&db)?; record_feedback(&conn, &feedback)?; Ok(()) @@ -39,7 +40,7 @@ pub async fn get_memory_feedback_stats( db: State<'_, DbConnection>, session_id: String, ) -> Result { - let conn = db.lock().unwrap(); + let conn = lock_db(&db)?; let feedbacks = get_recent_feedbacks(&conn, &session_id, 50)?; let approval_rate = calculate_approval_rate(&feedbacks); diff --git a/src-tauri/src/commands/memory_search_cmd.rs b/src-tauri/src/commands/memory_search_cmd.rs index cca503421..ceca90aab 100644 --- a/src-tauri/src/commands/memory_search_cmd.rs +++ b/src-tauri/src/commands/memory_search_cmd.rs @@ -3,6 +3,7 @@ //! Provides Tauri commands for semantic and hybrid search use crate::database::DbConnection; +use proxycast_core::database::lock_db; use proxycast_memory::models::{ MemoryCategory, MemoryMetadata, MemorySource, MemoryType, UnifiedMemory, }; @@ -161,7 +162,7 @@ pub async fn unified_memory_semantic_search( .map_err(|e| format!("Failed to get embedding: {e}"))?; let results = { - let conn = db.lock().unwrap(); + let conn = lock_db(&db)?; search::semantic_search( &conn, &query_embedding, @@ -246,7 +247,7 @@ pub async fn unified_memory_hybrid_search( // Execute semantic search let semantic_results = { - let conn = db.lock().unwrap(); + let conn = lock_db(&db)?; search::semantic_search( &conn, &query_embedding, @@ -263,7 +264,7 @@ pub async fn unified_memory_hybrid_search( // Execute keyword search let keyword_results: Vec = { - let conn = db.lock().unwrap(); + let conn = lock_db(&db)?; let query_clean = options.query.replace('%', "\\%").replace('_', "\\_"); let search_pattern = format!("%{query_clean}%"); let limit = options.limit.unwrap_or(50) as i64; diff --git a/src-tauri/src/commands/plugin_cmd.rs b/src-tauri/src/commands/plugin_cmd.rs index 371573b5f..b1aa2a96c 100644 --- a/src-tauri/src/commands/plugin_cmd.rs +++ b/src-tauri/src/commands/plugin_cmd.rs @@ -11,7 +11,10 @@ #![allow(dead_code)] -use proxycast_core::plugin::{PluginConfig, PluginInfo, PluginManager, PluginManifest, PluginType}; +use proxycast_core::plugin::{ + PluginConfig, PluginInfo, PluginManager, PluginManifest, PluginQueueStats, PluginTaskRecord, + PluginTaskState, PluginType, +}; use serde::{Deserialize, Serialize}; use std::path::Path; use std::sync::Arc; @@ -155,6 +158,59 @@ pub async fn get_plugins_dir( Ok(dir) } +fn parse_task_state(state: Option) -> Result, String> { + let Some(state) = state else { + return Ok(None); + }; + state + .parse::() + .map(Some) + .map_err(|e| format!("解析任务状态失败: {e}")) +} + +/// 查询插件任务列表 +#[tauri::command] +pub async fn list_plugin_tasks( + state: tauri::State<'_, PluginManagerState>, + plugin_id: Option, + task_state: Option, + limit: Option, +) -> Result, String> { + let manager = state.0.read().await; + let parsed_state = parse_task_state(task_state)?; + Ok(manager.list_tasks(plugin_id.as_deref(), parsed_state, limit.unwrap_or(100))) +} + +/// 获取单个插件任务 +#[tauri::command] +pub async fn get_plugin_task( + state: tauri::State<'_, PluginManagerState>, + task_id: String, +) -> Result, String> { + let manager = state.0.read().await; + Ok(manager.get_task(&task_id)) +} + +/// 取消插件任务 +#[tauri::command] +pub async fn cancel_plugin_task( + state: tauri::State<'_, PluginManagerState>, + task_id: String, +) -> Result { + let manager = state.0.read().await; + Ok(manager.cancel_task(&task_id)) +} + +/// 获取插件队列统计 +#[tauri::command] +pub async fn get_plugin_queue_stats( + state: tauri::State<'_, PluginManagerState>, + plugin_id: Option, +) -> Result, String> { + let manager = state.0.read().await; + Ok(manager.get_queue_stats(plugin_id.as_deref())) +} + // ============================================================================ // 插件 UI 注册系统 // ============================================================================ diff --git a/src-tauri/src/commands/provider_pool_cmd.rs b/src-tauri/src/commands/provider_pool_cmd.rs index bf9d46b86..1f114374b 100644 --- a/src-tauri/src/commands/provider_pool_cmd.rs +++ b/src-tauri/src/commands/provider_pool_cmd.rs @@ -67,7 +67,7 @@ fn copy_and_rename_credential_file( let uuid = Uuid::new_v4().to_string(); let timestamp = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) - .unwrap() + .unwrap_or_default() .as_secs(); let new_filename = format!( @@ -672,7 +672,7 @@ fn create_kiro_credential_from_json(json_content: &str) -> Result ({ + mockSafeInvoke: vi.fn(), + mockSafeListen: vi.fn(async () => () => {}), + mockToast: { + success: vi.fn(), + warning: vi.fn(), + error: vi.fn(), + }, +})); + +vi.mock("@/lib/dev-bridge", () => ({ + safeInvoke: (...args: unknown[]) => mockSafeInvoke(...args), + safeListen: mockSafeListen, +})); + +vi.mock("sonner", () => ({ + toast: mockToast, +})); + +vi.mock("./PluginInstallDialog", () => ({ + PluginInstallDialog: () => null, +})); + +vi.mock("./PluginUninstallDialog", () => ({ + PluginUninstallDialog: () => null, +})); + +vi.mock("./PluginItemContextMenu", () => ({ + PluginItemContextMenu: ({ children }: { children: React.ReactNode }) => ( + <>{children} + ), +})); + +import { PluginManager } from "./PluginManager"; + +const mountedRoots: MountedRoot[] = []; +const runtimeFilterStorageKey = "proxycast.pluginDiagnostics.filters.v1"; +let consoleWarnSpy: ReturnType; +let clipboardWriteTextMock: ReturnType; +let originalClipboard: Clipboard | undefined; + +function changeSelectValue(element: HTMLSelectElement | null, value: string) { + act(() => { + if (!element) { + return; + } + const setter = Object.getOwnPropertyDescriptor( + window.HTMLSelectElement.prototype, + "value", + )?.set; + setter?.call(element, value); + element.dispatchEvent(new Event("change", { bubbles: true })); + }); +} + +function changeDateTimeInputValue( + element: HTMLInputElement | null, + value: string, +) { + act(() => { + if (!element) { + return; + } + const setter = Object.getOwnPropertyDescriptor( + window.HTMLInputElement.prototype, + "value", + )?.set; + setter?.call(element, value); + element.dispatchEvent(new Event("input", { bubbles: true })); + element.dispatchEvent(new Event("change", { bubbles: true })); + }); +} + +function toDateTimeLocalValue(value: Date): string { + const pad = (num: number) => String(num).padStart(2, "0"); + return `${value.getFullYear()}-${pad(value.getMonth() + 1)}-${pad(value.getDate())}T${pad( + value.getHours(), + )}:${pad(value.getMinutes())}`; +} + +function buildMockTasks() { + const now = Date.now(); + return Array.from({ length: 12 }).map((_, index) => { + const id = index + 1; + let startedAtOffset = id * 60 * 1000; + if (id === 11) { + startedAtOffset = 2 * 60 * 60 * 1000; + } + if (id === 12) { + startedAtOffset = 8 * 24 * 60 * 60 * 1000; + } + return { + taskId: `task-${id}`, + pluginId: "demo-plugin", + operation: id === 9 ? "special-op" : "handle_plugin_action", + state: id % 3 === 0 ? "succeeded" : "running", + attempt: 1, + maxRetries: 2, + startedAt: new Date(now - startedAtOffset).toISOString(), + durationMs: 100 + id, + error: + id === 7 + ? { + code: "UPSTREAM_5XX", + message: "upstream error", + retryable: true, + } + : null, + }; + }); +} + +describe("PluginManager 任务可观测", () => { + beforeEach(() => { + setupReactActEnvironment(); + vi.clearAllMocks(); + consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + originalClipboard = navigator.clipboard; + clipboardWriteTextMock = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: { + writeText: clipboardWriteTextMock, + }, + }); + window.localStorage.clear(); + const mockTasks = buildMockTasks(); + + mockSafeInvoke.mockImplementation( + async (command: string, args?: Record) => { + switch (command) { + case "get_plugin_status": + return { + enabled: true, + plugin_count: 1, + plugins_dir: "/tmp/plugins", + }; + case "get_plugins": + return [ + { + name: "demo-plugin", + version: "1.0.0", + description: "Demo plugin", + author: "tester", + status: "enabled", + path: "/tmp/plugins/demo-plugin", + hooks: ["on_request"], + config_schema: null, + config: { + enabled: true, + timeout_ms: 5000, + settings: {}, + }, + state: { + name: "demo-plugin", + status: "enabled", + loaded_at: new Date().toISOString(), + last_executed: null, + execution_count: 12, + error_count: 1, + last_error: null, + }, + }, + ]; + case "list_installed_plugins": + return []; + case "list_plugin_tasks": + if (args?.taskState && args.taskState !== "all") { + return mockTasks.filter((task) => task.state === args.taskState); + } + return mockTasks; + case "get_plugin_queue_stats": + return [ + { + pluginId: "demo-plugin", + running: 1, + waiting: 2, + rejected: 0, + completed: 10, + failed: 1, + cancelled: 0, + timedOut: 0, + }, + ]; + case "cancel_plugin_task": + expect(args).toEqual({ taskId: "task-1" }); + return true; + case "get_plugin_task": + expect(args).toEqual({ taskId: "task-1" }); + return { + taskId: "task-1", + pluginId: "demo-plugin", + operation: "handle_plugin_action", + state: "running", + attempt: 1, + maxRetries: 2, + startedAt: new Date().toISOString(), + durationMs: 120, + error: null, + }; + default: + return []; + } + }, + ); + }); + + afterEach(() => { + cleanupMountedRoots(mountedRoots); + consoleWarnSpy.mockRestore(); + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: originalClipboard, + }); + }); + + it("展示插件任务状态和队列统计", async () => { + const { container } = mountHarness(PluginManager, {}, mountedRoots); + await flushEffects(6); + + const expandButton = container.querySelector( + "button[data-testid='plugin-expand-demo-plugin']", + ); + expect(expandButton).not.toBeNull(); + clickElement(expandButton); + await flushEffects(3); + + expect(container.textContent).toContain("最近任务"); + expect(container.textContent).toContain("handle_plugin_action"); + expect(container.textContent).toContain("执行队列"); + expect(container.textContent).toContain("运行中"); + }); + + it("支持取消运行中的任务", async () => { + const { container } = mountHarness(PluginManager, {}, mountedRoots); + await flushEffects(6); + + const expandButton = container.querySelector( + "button[data-testid='plugin-expand-demo-plugin']", + ); + clickElement(expandButton); + await flushEffects(3); + + const cancelButton = container.querySelector( + "button[data-testid='plugin-cancel-task-task-1']", + ); + expect(cancelButton).not.toBeNull(); + clickElement(cancelButton); + await flushEffects(4); + + expect(mockSafeInvoke).toHaveBeenCalledWith("cancel_plugin_task", { + taskId: "task-1", + }); + expect(mockToast.success).toHaveBeenCalled(); + }); + + it("支持查看单任务详情", async () => { + const { container } = mountHarness(PluginManager, {}, mountedRoots); + await flushEffects(6); + + const detailButton = container.querySelector( + "button[data-testid='plugin-task-detail-task-1']", + ); + expect(detailButton).not.toBeNull(); + clickElement(detailButton); + await flushEffects(4); + + expect(mockSafeInvoke).toHaveBeenCalledWith("get_plugin_task", { + taskId: "task-1", + }); + expect( + container.querySelector("[data-testid='plugin-task-detail-panel']"), + ).not.toBeNull(); + expect(container.textContent).toContain("任务详情"); + }); + + it("支持诊断面板搜索和分页", async () => { + const { container } = mountHarness(PluginManager, {}, mountedRoots); + await flushEffects(6); + + expect( + container.querySelector("[data-testid='plugin-runtime-row-task-1']"), + ).not.toBeNull(); + expect( + container.querySelector("[data-testid='plugin-runtime-row-task-11']"), + ).toBeNull(); + + const nextPageBtn = container.querySelector( + "button[data-testid='plugin-runtime-next-page']", + ); + expect(nextPageBtn).not.toBeNull(); + clickElement(nextPageBtn); + await flushEffects(3); + expect( + container.querySelector("[data-testid='plugin-runtime-row-task-11']"), + ).not.toBeNull(); + + const searchInput = container.querySelector( + "input[data-testid='plugin-runtime-search-input']", + ); + expect(searchInput).not.toBeNull(); + if (searchInput instanceof HTMLInputElement) { + fillTextInput(searchInput, "special-op"); + await flushEffects(3); + expect( + container.querySelector("[data-testid='plugin-runtime-row-task-9']"), + ).not.toBeNull(); + expect( + container.querySelector("[data-testid='plugin-runtime-row-task-1']"), + ).toBeNull(); + } + }); + + it("支持按时间范围筛选任务", async () => { + const { container } = mountHarness(PluginManager, {}, mountedRoots); + await flushEffects(6); + + const timeRangeFilter = container.querySelector( + "select[data-testid='plugin-runtime-time-range-filter']", + ) as HTMLSelectElement | null; + expect(timeRangeFilter).not.toBeNull(); + + changeSelectValue(timeRangeFilter, "1h"); + await flushEffects(3); + + expect( + container.querySelector("[data-testid='plugin-runtime-row-task-1']"), + ).not.toBeNull(); + expect( + container.querySelector("[data-testid='plugin-runtime-row-task-11']"), + ).toBeNull(); + expect( + container.querySelector("[data-testid='plugin-runtime-row-task-12']"), + ).toBeNull(); + }); + + it("支持自定义时间区间筛选并持久化", async () => { + const now = Date.now(); + const customStart = toDateTimeLocalValue( + new Date(now - 3 * 60 * 60 * 1000), + ); + const customEnd = toDateTimeLocalValue(new Date(now - 30 * 60 * 1000)); + const { container } = mountHarness(PluginManager, {}, mountedRoots); + await flushEffects(6); + + const timeRangeFilter = container.querySelector( + "select[data-testid='plugin-runtime-time-range-filter']", + ) as HTMLSelectElement | null; + expect(timeRangeFilter).not.toBeNull(); + changeSelectValue(timeRangeFilter, "custom"); + await flushEffects(2); + + const customStartInput = container.querySelector( + "input[data-testid='plugin-runtime-custom-start-input']", + ) as HTMLInputElement | null; + const customEndInput = container.querySelector( + "input[data-testid='plugin-runtime-custom-end-input']", + ) as HTMLInputElement | null; + expect(customStartInput).not.toBeNull(); + expect(customEndInput).not.toBeNull(); + + changeDateTimeInputValue(customStartInput, customStart); + changeDateTimeInputValue(customEndInput, customEnd); + await flushEffects(3); + + expect( + container.querySelector("[data-testid='plugin-runtime-row-task-1']"), + ).toBeNull(); + expect( + container.querySelector("[data-testid='plugin-runtime-row-task-11']"), + ).not.toBeNull(); + expect( + container.querySelector("[data-testid='plugin-runtime-row-task-12']"), + ).toBeNull(); + + const stored = JSON.parse( + window.localStorage.getItem(runtimeFilterStorageKey) ?? "{}", + ) as { + timeRangeFilter?: string; + customStartTime?: string; + customEndTime?: string; + }; + expect(stored.timeRangeFilter).toBe("custom"); + expect(stored.customStartTime).toBe(customStart); + expect(stored.customEndTime).toBe(customEnd); + }); + + it("支持快捷时间按钮快速设定自定义区间", async () => { + const { container } = mountHarness(PluginManager, {}, mountedRoots); + await flushEffects(6); + + const timeRangeFilter = container.querySelector( + "select[data-testid='plugin-runtime-time-range-filter']", + ) as HTMLSelectElement | null; + changeSelectValue(timeRangeFilter, "custom"); + await flushEffects(2); + + const quickRangeButton = container.querySelector( + "button[data-testid='plugin-runtime-quick-range-15m']", + ); + expect(quickRangeButton).not.toBeNull(); + clickElement(quickRangeButton); + await flushEffects(2); + + const customStartInput = container.querySelector( + "input[data-testid='plugin-runtime-custom-start-input']", + ) as HTMLInputElement | null; + const customEndInput = container.querySelector( + "input[data-testid='plugin-runtime-custom-end-input']", + ) as HTMLInputElement | null; + expect(customStartInput?.value).not.toBe(""); + expect(customEndInput?.value).not.toBe(""); + expect( + container.querySelector("[data-testid='plugin-runtime-row-task-1']"), + ).not.toBeNull(); + expect( + container.querySelector("[data-testid='plugin-runtime-row-task-11']"), + ).toBeNull(); + + const stored = JSON.parse( + window.localStorage.getItem(runtimeFilterStorageKey) ?? "{}", + ) as { + timeRangeFilter?: string; + customStartTime?: string; + customEndTime?: string; + }; + expect(stored.timeRangeFilter).toBe("custom"); + expect(stored.customStartTime).toBeTruthy(); + expect(stored.customEndTime).toBeTruthy(); + }); + + it("自定义区间开始晚于结束时展示自动处理提示", async () => { + const now = Date.now(); + const lateStart = toDateTimeLocalValue(new Date(now - 20 * 60 * 1000)); + const earlyEnd = toDateTimeLocalValue(new Date(now - 50 * 60 * 1000)); + + const { container } = mountHarness(PluginManager, {}, mountedRoots); + await flushEffects(6); + + const timeRangeFilter = container.querySelector( + "select[data-testid='plugin-runtime-time-range-filter']", + ) as HTMLSelectElement | null; + changeSelectValue(timeRangeFilter, "custom"); + await flushEffects(2); + + const customStartInput = container.querySelector( + "input[data-testid='plugin-runtime-custom-start-input']", + ) as HTMLInputElement | null; + const customEndInput = container.querySelector( + "input[data-testid='plugin-runtime-custom-end-input']", + ) as HTMLInputElement | null; + changeDateTimeInputValue(customStartInput, lateStart); + changeDateTimeInputValue(customEndInput, earlyEnd); + await flushEffects(2); + + const hint = container.querySelector( + "[data-testid='plugin-runtime-custom-range-hint']", + ); + expect(hint?.textContent).toContain("自动按时间先后处理"); + }); + + it("支持清空自定义区间并恢复提示文案", async () => { + const { container } = mountHarness(PluginManager, {}, mountedRoots); + await flushEffects(6); + + const timeRangeFilter = container.querySelector( + "select[data-testid='plugin-runtime-time-range-filter']", + ) as HTMLSelectElement | null; + changeSelectValue(timeRangeFilter, "custom"); + await flushEffects(2); + + const quickRangeButton = container.querySelector( + "button[data-testid='plugin-runtime-quick-range-30m']", + ); + clickElement(quickRangeButton); + await flushEffects(2); + + const clearRangeButton = container.querySelector( + "button[data-testid='plugin-runtime-custom-clear-range']", + ); + expect(clearRangeButton).not.toBeNull(); + clickElement(clearRangeButton); + await flushEffects(2); + + const customStartInput = container.querySelector( + "input[data-testid='plugin-runtime-custom-start-input']", + ) as HTMLInputElement | null; + const customEndInput = container.querySelector( + "input[data-testid='plugin-runtime-custom-end-input']", + ) as HTMLInputElement | null; + expect(customStartInput?.value).toBe(""); + expect(customEndInput?.value).toBe(""); + + const hint = container.querySelector( + "[data-testid='plugin-runtime-custom-range-hint']", + ); + expect(hint?.textContent).toContain("展示全部任务"); + }); + + it("支持记忆并一键应用上次自定义区间", async () => { + const now = Date.now(); + const customStart = toDateTimeLocalValue(new Date(now - 70 * 60 * 1000)); + const customEnd = toDateTimeLocalValue(new Date(now - 20 * 60 * 1000)); + + const { container } = mountHarness(PluginManager, {}, mountedRoots); + await flushEffects(6); + + const timeRangeFilter = container.querySelector( + "select[data-testid='plugin-runtime-time-range-filter']", + ) as HTMLSelectElement | null; + changeSelectValue(timeRangeFilter, "custom"); + await flushEffects(2); + + const customStartInput = container.querySelector( + "input[data-testid='plugin-runtime-custom-start-input']", + ) as HTMLInputElement | null; + const customEndInput = container.querySelector( + "input[data-testid='plugin-runtime-custom-end-input']", + ) as HTMLInputElement | null; + changeDateTimeInputValue(customStartInput, customStart); + changeDateTimeInputValue(customEndInput, customEnd); + await flushEffects(2); + + const clearRangeButton = container.querySelector( + "button[data-testid='plugin-runtime-custom-clear-range']", + ); + clickElement(clearRangeButton); + await flushEffects(2); + expect(customStartInput?.value).toBe(""); + expect(customEndInput?.value).toBe(""); + + const applyLastRangeButton = container.querySelector( + "button[data-testid='plugin-runtime-custom-apply-last-range']", + ); + expect(applyLastRangeButton).not.toBeNull(); + clickElement(applyLastRangeButton); + await flushEffects(2); + + expect(customStartInput?.value).toBe(customStart); + expect(customEndInput?.value).toBe(customEnd); + + const lastRangeText = container.querySelector( + "[data-testid='plugin-runtime-custom-last-range']", + ); + expect(lastRangeText?.textContent).not.toContain("暂无"); + + const stored = JSON.parse( + window.localStorage.getItem(runtimeFilterStorageKey) ?? "{}", + ) as { + lastAppliedCustomStartTime?: string; + lastAppliedCustomEndTime?: string; + }; + expect(stored.lastAppliedCustomStartTime).toBe(customStart); + expect(stored.lastAppliedCustomEndTime).toBe(customEnd); + }); + + it("支持复制上次区间并展示最近使用时间", async () => { + const now = Date.now(); + const customStart = toDateTimeLocalValue(new Date(now - 80 * 60 * 1000)); + const customEnd = toDateTimeLocalValue(new Date(now - 10 * 60 * 1000)); + + const { container } = mountHarness(PluginManager, {}, mountedRoots); + await flushEffects(6); + + const timeRangeFilter = container.querySelector( + "select[data-testid='plugin-runtime-time-range-filter']", + ) as HTMLSelectElement | null; + changeSelectValue(timeRangeFilter, "custom"); + await flushEffects(2); + + const customStartInput = container.querySelector( + "input[data-testid='plugin-runtime-custom-start-input']", + ) as HTMLInputElement | null; + const customEndInput = container.querySelector( + "input[data-testid='plugin-runtime-custom-end-input']", + ) as HTMLInputElement | null; + changeDateTimeInputValue(customStartInput, customStart); + changeDateTimeInputValue(customEndInput, customEnd); + await flushEffects(2); + + const copyButton = container.querySelector( + "button[data-testid='plugin-runtime-custom-copy-last-range']", + ); + expect(copyButton).not.toBeNull(); + clickElement(copyButton); + await flushEffects(2); + + expect(clipboardWriteTextMock).toHaveBeenCalledWith( + `上次区间: ${customStart} ~ ${customEnd}`, + ); + expect(mockToast.success).toHaveBeenCalledWith("已复制上次区间"); + + const updatedAtLabel = container.querySelector( + "[data-testid='plugin-runtime-custom-last-range-updated-at']", + ); + expect(updatedAtLabel?.textContent).not.toContain("暂无"); + + const stored = JSON.parse( + window.localStorage.getItem(runtimeFilterStorageKey) ?? "{}", + ) as { + lastAppliedCustomUpdatedAt?: string; + }; + expect(stored.lastAppliedCustomUpdatedAt).toBeTruthy(); + }); + + it("支持复制结构化区间 JSON", async () => { + const now = Date.now(); + const customStart = toDateTimeLocalValue(new Date(now - 95 * 60 * 1000)); + const customEnd = toDateTimeLocalValue(new Date(now - 25 * 60 * 1000)); + + const { container } = mountHarness(PluginManager, {}, mountedRoots); + await flushEffects(6); + + const timeRangeFilter = container.querySelector( + "select[data-testid='plugin-runtime-time-range-filter']", + ) as HTMLSelectElement | null; + changeSelectValue(timeRangeFilter, "custom"); + await flushEffects(2); + + const customStartInput = container.querySelector( + "input[data-testid='plugin-runtime-custom-start-input']", + ) as HTMLInputElement | null; + const customEndInput = container.querySelector( + "input[data-testid='plugin-runtime-custom-end-input']", + ) as HTMLInputElement | null; + changeDateTimeInputValue(customStartInput, customStart); + changeDateTimeInputValue(customEndInput, customEnd); + await flushEffects(2); + + const copyJsonButton = container.querySelector( + "button[data-testid='plugin-runtime-custom-copy-last-range-json']", + ); + expect(copyJsonButton).not.toBeNull(); + clickElement(copyJsonButton); + await flushEffects(2); + + expect(clipboardWriteTextMock).toHaveBeenCalled(); + const copiedValue = clipboardWriteTextMock.mock.calls[ + clipboardWriteTextMock.mock.calls.length - 1 + ]?.[0] as string | undefined; + expect(copiedValue).toBeTruthy(); + const parsed = JSON.parse(copiedValue ?? "{}") as { + start?: string; + end?: string; + updatedAt?: string | null; + }; + expect(parsed.start).toBe(customStart); + expect(parsed.end).toBe(customEnd); + expect(parsed.updatedAt).toBeTruthy(); + expect(mockToast.success).toHaveBeenCalledWith("已复制区间 JSON"); + }); + + it("支持保存并应用最近区间历史(最多5条)", async () => { + const now = Date.now(); + const ranges = Array.from({ length: 6 }).map((_, index) => { + const start = toDateTimeLocalValue( + new Date(now - (index + 7) * 60 * 60 * 1000), + ); + const end = toDateTimeLocalValue( + new Date(now - (index + 6) * 60 * 60 * 1000), + ); + return { start, end }; + }); + + const { container } = mountHarness(PluginManager, {}, mountedRoots); + await flushEffects(6); + + const timeRangeFilter = container.querySelector( + "select[data-testid='plugin-runtime-time-range-filter']", + ) as HTMLSelectElement | null; + changeSelectValue(timeRangeFilter, "custom"); + await flushEffects(2); + + const customStartInput = container.querySelector( + "input[data-testid='plugin-runtime-custom-start-input']", + ) as HTMLInputElement | null; + const customEndInput = container.querySelector( + "input[data-testid='plugin-runtime-custom-end-input']", + ) as HTMLInputElement | null; + const historySelect = container.querySelector( + "select[data-testid='plugin-runtime-custom-history-select']", + ) as HTMLSelectElement | null; + + for (const range of ranges) { + changeDateTimeInputValue(customStartInput, range.start); + changeDateTimeInputValue(customEndInput, range.end); + await flushEffects(2); + } + + const stored = JSON.parse( + window.localStorage.getItem(runtimeFilterStorageKey) ?? "{}", + ) as { + customRangeHistory?: Array<{ + id: string; + start: string; + end: string; + }>; + }; + + expect(stored.customRangeHistory).toBeTruthy(); + expect(stored.customRangeHistory?.length).toBe(5); + const latestRange = ranges[ranges.length - 1]; + expect(stored.customRangeHistory?.[0]?.start).toBe(latestRange.start); + expect(stored.customRangeHistory?.[0]?.end).toBe(latestRange.end); + + const oldestInHistory = stored.customRangeHistory?.[4]; + expect(oldestInHistory).toBeTruthy(); + changeSelectValue(historySelect, oldestInHistory?.id ?? ""); + await flushEffects(2); + + expect(customStartInput?.value).toBe(oldestInHistory?.start); + expect(customEndInput?.value).toBe(oldestInHistory?.end); + expect(container.textContent).toContain("共 5 条"); + }); + + it("支持为历史区间命名标签并持久化", async () => { + const now = Date.now(); + const customStart = toDateTimeLocalValue( + new Date(now - 3 * 60 * 60 * 1000), + ); + const customEnd = toDateTimeLocalValue(new Date(now - 2 * 60 * 60 * 1000)); + const originalPrompt = window.prompt; + const promptMock = vi.fn(() => "早高峰"); + Object.defineProperty(window, "prompt", { + configurable: true, + value: promptMock, + }); + + try { + const { container } = mountHarness(PluginManager, {}, mountedRoots); + await flushEffects(6); + + const timeRangeFilter = container.querySelector( + "select[data-testid='plugin-runtime-time-range-filter']", + ) as HTMLSelectElement | null; + changeSelectValue(timeRangeFilter, "custom"); + await flushEffects(2); + + const customStartInput = container.querySelector( + "input[data-testid='plugin-runtime-custom-start-input']", + ) as HTMLInputElement | null; + const customEndInput = container.querySelector( + "input[data-testid='plugin-runtime-custom-end-input']", + ) as HTMLInputElement | null; + changeDateTimeInputValue(customStartInput, customStart); + changeDateTimeInputValue(customEndInput, customEnd); + await flushEffects(2); + + const renameButton = container.querySelector( + "button[data-testid='plugin-runtime-custom-history-rename-0']", + ); + expect(renameButton).not.toBeNull(); + clickElement(renameButton); + await flushEffects(2); + + expect(promptMock).toHaveBeenCalled(); + expect(container.textContent).toContain("早高峰"); + + const stored = JSON.parse( + window.localStorage.getItem(runtimeFilterStorageKey) ?? "{}", + ) as { + customRangeHistory?: Array<{ + label?: string; + }>; + }; + expect(stored.customRangeHistory?.[0]?.label).toBe("早高峰"); + } finally { + Object.defineProperty(window, "prompt", { + configurable: true, + value: originalPrompt, + }); + } + }); + + it("支持历史区间按标签排序与搜索过滤", async () => { + const now = Date.now(); + const history = [ + { + start: toDateTimeLocalValue(new Date(now - 6 * 60 * 60 * 1000)), + end: toDateTimeLocalValue(new Date(now - 5 * 60 * 60 * 1000)), + updatedAt: new Date(now - 6 * 60 * 60 * 1000).toISOString(), + label: "C标签", + }, + { + start: toDateTimeLocalValue(new Date(now - 5 * 60 * 60 * 1000)), + end: toDateTimeLocalValue(new Date(now - 4 * 60 * 60 * 1000)), + updatedAt: new Date(now - 5 * 60 * 60 * 1000).toISOString(), + label: "A标签", + }, + { + start: toDateTimeLocalValue(new Date(now - 4 * 60 * 60 * 1000)), + end: toDateTimeLocalValue(new Date(now - 3 * 60 * 60 * 1000)), + updatedAt: new Date(now - 4 * 60 * 60 * 1000).toISOString(), + label: "B标签", + }, + ]; + + window.localStorage.setItem( + runtimeFilterStorageKey, + JSON.stringify({ + timeRangeFilter: "custom", + customRangeHistory: history, + }), + ); + + const { container } = mountHarness(PluginManager, {}, mountedRoots); + await flushEffects(6); + + const historySort = container.querySelector( + "select[data-testid='plugin-runtime-custom-history-sort']", + ) as HTMLSelectElement | null; + changeSelectValue(historySort, "label"); + await flushEffects(2); + + const firstHistoryItem = container.querySelector( + "[data-testid='plugin-runtime-custom-history-item-0']", + ); + expect(firstHistoryItem?.textContent).toContain("A标签"); + + const historySearch = container.querySelector( + "input[data-testid='plugin-runtime-custom-history-search']", + ) as HTMLInputElement | null; + fillTextInput(historySearch, "B标签"); + await flushEffects(2); + + expect(container.textContent).toContain("匹配 1 条"); + const filteredFirstItem = container.querySelector( + "[data-testid='plugin-runtime-custom-history-item-0']", + ); + expect(filteredFirstItem?.textContent).toContain("B标签"); + expect( + container.querySelector( + "[data-testid='plugin-runtime-custom-history-item-1']", + ), + ).toBeNull(); + }); + + it("支持收藏历史区间并置顶展示", async () => { + const now = Date.now(); + const history = [ + { + start: toDateTimeLocalValue(new Date(now - 6 * 60 * 60 * 1000)), + end: toDateTimeLocalValue(new Date(now - 5 * 60 * 60 * 1000)), + updatedAt: new Date(now - 6 * 60 * 60 * 1000).toISOString(), + label: "C标签", + }, + { + start: toDateTimeLocalValue(new Date(now - 5 * 60 * 60 * 1000)), + end: toDateTimeLocalValue(new Date(now - 4 * 60 * 60 * 1000)), + updatedAt: new Date(now - 5 * 60 * 60 * 1000).toISOString(), + label: "B标签", + }, + { + start: toDateTimeLocalValue(new Date(now - 4 * 60 * 60 * 1000)), + end: toDateTimeLocalValue(new Date(now - 3 * 60 * 60 * 1000)), + updatedAt: new Date(now - 4 * 60 * 60 * 1000).toISOString(), + label: "A标签", + }, + ]; + + window.localStorage.setItem( + runtimeFilterStorageKey, + JSON.stringify({ + timeRangeFilter: "custom", + customRangeHistory: history, + }), + ); + + const { container } = mountHarness(PluginManager, {}, mountedRoots); + await flushEffects(6); + + const firstBeforePin = container.querySelector( + "[data-testid='plugin-runtime-custom-history-item-0']", + ); + expect(firstBeforePin?.textContent).toContain("A标签"); + expect(container.textContent).toContain("收藏 0 条"); + + const pinButton = container.querySelector( + "button[data-testid='plugin-runtime-custom-history-pin-2']", + ); + expect(pinButton).not.toBeNull(); + clickElement(pinButton); + await flushEffects(2); + + const firstAfterPin = container.querySelector( + "[data-testid='plugin-runtime-custom-history-item-0']", + ); + expect(firstAfterPin?.textContent).toContain("C标签"); + expect(container.textContent).toContain("收藏 1 条"); + + const stored = JSON.parse( + window.localStorage.getItem(runtimeFilterStorageKey) ?? "{}", + ) as { + customRangeHistory?: Array<{ label?: string; pinned?: boolean }>; + }; + const pinnedItem = stored.customRangeHistory?.find( + (item) => item.label === "C标签", + ); + expect(pinnedItem?.pinned).toBe(true); + }); + + it("支持仅看收藏历史区间并持久化筛选状态", async () => { + const now = Date.now(); + window.localStorage.setItem( + runtimeFilterStorageKey, + JSON.stringify({ + timeRangeFilter: "custom", + customRangeHistory: [ + { + start: toDateTimeLocalValue(new Date(now - 5 * 60 * 60 * 1000)), + end: toDateTimeLocalValue(new Date(now - 4 * 60 * 60 * 1000)), + updatedAt: new Date(now - 5 * 60 * 60 * 1000).toISOString(), + label: "未收藏", + pinned: false, + }, + { + start: toDateTimeLocalValue(new Date(now - 4 * 60 * 60 * 1000)), + end: toDateTimeLocalValue(new Date(now - 3 * 60 * 60 * 1000)), + updatedAt: new Date(now - 4 * 60 * 60 * 1000).toISOString(), + label: "已收藏", + pinned: true, + }, + ], + }), + ); + + const { container } = mountHarness(PluginManager, {}, mountedRoots); + await flushEffects(6); + + expect(container.textContent).toContain("匹配 2 条"); + + const viewModeSelect = container.querySelector( + "select[data-testid='plugin-runtime-custom-history-view-mode']", + ) as HTMLSelectElement | null; + expect(viewModeSelect).not.toBeNull(); + changeSelectValue(viewModeSelect, "only_pinned"); + await flushEffects(2); + + expect(container.textContent).toContain("匹配 1 条"); + expect(container.textContent).toContain("仅收藏匹配 1 条"); + const firstFilteredItem = container.querySelector( + "[data-testid='plugin-runtime-custom-history-item-0']", + ); + expect(firstFilteredItem?.textContent).toContain("已收藏"); + expect( + container.querySelector( + "[data-testid='plugin-runtime-custom-history-item-1']", + ), + ).toBeNull(); + + let stored = JSON.parse( + window.localStorage.getItem(runtimeFilterStorageKey) ?? "{}", + ) as { + customRangeHistoryOnlyPinned?: boolean; + }; + expect(stored.customRangeHistoryOnlyPinned).toBe(true); + + changeSelectValue(viewModeSelect, "default"); + await flushEffects(2); + expect(container.textContent).toContain("匹配 2 条"); + + stored = JSON.parse( + window.localStorage.getItem(runtimeFilterStorageKey) ?? "{}", + ) as { + customRangeHistoryOnlyPinned?: boolean; + }; + expect(stored.customRangeHistoryOnlyPinned).toBe(false); + }); + + it("仅收藏模式下无收藏区间时展示专属空态提示", async () => { + const now = Date.now(); + window.localStorage.setItem( + runtimeFilterStorageKey, + JSON.stringify({ + timeRangeFilter: "custom", + customRangeHistory: [ + { + start: toDateTimeLocalValue(new Date(now - 5 * 60 * 60 * 1000)), + end: toDateTimeLocalValue(new Date(now - 4 * 60 * 60 * 1000)), + updatedAt: new Date(now - 5 * 60 * 60 * 1000).toISOString(), + label: "普通区间", + pinned: false, + }, + ], + }), + ); + + const { container } = mountHarness(PluginManager, {}, mountedRoots); + await flushEffects(6); + + const viewModeSelect = container.querySelector( + "select[data-testid='plugin-runtime-custom-history-view-mode']", + ) as HTMLSelectElement | null; + expect(viewModeSelect).not.toBeNull(); + changeSelectValue(viewModeSelect, "only_pinned"); + await flushEffects(2); + + const emptyHint = container.querySelector( + "[data-testid='plugin-runtime-custom-history-empty']", + ); + expect(emptyHint?.textContent).toContain("当前仅收藏模式下暂无历史区间"); + expect(container.textContent).toContain("匹配 0 条"); + expect(container.textContent).toContain("仅收藏匹配 0 条"); + expect(container.textContent).toContain("收藏 0 条"); + }); + + it("支持关闭收藏置顶后按当前排序规则展示", async () => { + const now = Date.now(); + window.localStorage.setItem( + runtimeFilterStorageKey, + JSON.stringify({ + timeRangeFilter: "custom", + customRangeHistory: [ + { + start: toDateTimeLocalValue(new Date(now - 6 * 60 * 60 * 1000)), + end: toDateTimeLocalValue(new Date(now - 5 * 60 * 60 * 1000)), + updatedAt: new Date(now - 6 * 60 * 60 * 1000).toISOString(), + label: "C标签", + pinned: true, + }, + { + start: toDateTimeLocalValue(new Date(now - 5 * 60 * 60 * 1000)), + end: toDateTimeLocalValue(new Date(now - 4 * 60 * 60 * 1000)), + updatedAt: new Date(now - 5 * 60 * 60 * 1000).toISOString(), + label: "A标签", + pinned: false, + }, + { + start: toDateTimeLocalValue(new Date(now - 4 * 60 * 60 * 1000)), + end: toDateTimeLocalValue(new Date(now - 3 * 60 * 60 * 1000)), + updatedAt: new Date(now - 4 * 60 * 60 * 1000).toISOString(), + label: "B标签", + pinned: false, + }, + ], + }), + ); + + const { container } = mountHarness(PluginManager, {}, mountedRoots); + await flushEffects(6); + + const historySort = container.querySelector( + "select[data-testid='plugin-runtime-custom-history-sort']", + ) as HTMLSelectElement | null; + changeSelectValue(historySort, "label"); + await flushEffects(2); + + const firstWithPinnedFirst = container.querySelector( + "[data-testid='plugin-runtime-custom-history-item-0']", + ); + expect(firstWithPinnedFirst?.textContent).toContain("C标签"); + + const viewModeSelect = container.querySelector( + "select[data-testid='plugin-runtime-custom-history-view-mode']", + ) as HTMLSelectElement | null; + expect(viewModeSelect).not.toBeNull(); + changeSelectValue(viewModeSelect, "flat"); + await flushEffects(2); + + const firstWithPureSort = container.querySelector( + "[data-testid='plugin-runtime-custom-history-item-0']", + ); + expect(firstWithPureSort?.textContent).toContain("A标签"); + + let stored = JSON.parse( + window.localStorage.getItem(runtimeFilterStorageKey) ?? "{}", + ) as { + customRangeHistoryPinnedFirst?: boolean; + }; + expect(stored.customRangeHistoryPinnedFirst).toBe(false); + + changeSelectValue(viewModeSelect, "default"); + await flushEffects(2); + + const firstPinnedAgain = container.querySelector( + "[data-testid='plugin-runtime-custom-history-item-0']", + ); + expect(firstPinnedAgain?.textContent).toContain("C标签"); + + stored = JSON.parse( + window.localStorage.getItem(runtimeFilterStorageKey) ?? "{}", + ) as { + customRangeHistoryPinnedFirst?: boolean; + }; + expect(stored.customRangeHistoryPinnedFirst).toBe(true); + }); + + it("支持导出历史区间 JSON", async () => { + const now = Date.now(); + window.localStorage.setItem( + runtimeFilterStorageKey, + JSON.stringify({ + timeRangeFilter: "custom", + customRangeHistory: [ + { + start: toDateTimeLocalValue(new Date(now - 2 * 60 * 60 * 1000)), + end: toDateTimeLocalValue(new Date(now - 90 * 60 * 1000)), + updatedAt: new Date(now - 2 * 60 * 60 * 1000).toISOString(), + label: "午高峰", + }, + ], + }), + ); + + const { container } = mountHarness(PluginManager, {}, mountedRoots); + await flushEffects(6); + + const exportButton = container.querySelector( + "button[data-testid='plugin-runtime-custom-history-export-json']", + ); + expect(exportButton).not.toBeNull(); + clickElement(exportButton); + await flushEffects(2); + + expect(clipboardWriteTextMock).toHaveBeenCalled(); + const copied = clipboardWriteTextMock.mock.calls[ + clipboardWriteTextMock.mock.calls.length - 1 + ]?.[0] as string | undefined; + const parsed = JSON.parse(copied ?? "{}") as { + version?: number; + customRangeHistory?: Array<{ label?: string }>; + }; + expect(parsed.version).toBe(1); + expect(parsed.customRangeHistory?.[0]?.label).toBe("午高峰"); + expect(mockToast.success).toHaveBeenCalledWith("已复制历史区间 JSON"); + }); + + it("支持导入历史区间 JSON", async () => { + const now = Date.now(); + const originalPrompt = window.prompt; + Object.defineProperty(window, "prompt", { + configurable: true, + value: vi.fn(() => + JSON.stringify({ + customRangeHistory: [ + { + start: toDateTimeLocalValue(new Date(now - 7 * 60 * 60 * 1000)), + end: toDateTimeLocalValue(new Date(now - 6 * 60 * 60 * 1000)), + updatedAt: new Date(now - 7 * 60 * 60 * 1000).toISOString(), + label: "晨间", + }, + { + start: toDateTimeLocalValue(new Date(now - 5 * 60 * 60 * 1000)), + end: toDateTimeLocalValue(new Date(now - 4 * 60 * 60 * 1000)), + updatedAt: new Date(now - 5 * 60 * 60 * 1000).toISOString(), + label: "午后", + }, + ], + }), + ), + }); + + try { + const { container } = mountHarness(PluginManager, {}, mountedRoots); + await flushEffects(6); + + const timeRangeFilter = container.querySelector( + "select[data-testid='plugin-runtime-time-range-filter']", + ) as HTMLSelectElement | null; + changeSelectValue(timeRangeFilter, "custom"); + await flushEffects(2); + + const importButton = container.querySelector( + "button[data-testid='plugin-runtime-custom-history-import-json']", + ); + expect(importButton).not.toBeNull(); + clickElement(importButton); + await flushEffects(3); + + expect(container.textContent).toContain("共 2 条,匹配 2 条"); + expect(container.textContent).toContain("晨间"); + expect(container.textContent).toContain("午后"); + + const stored = JSON.parse( + window.localStorage.getItem(runtimeFilterStorageKey) ?? "{}", + ) as { + customRangeHistory?: Array<{ label?: string }>; + }; + expect(stored.customRangeHistory?.length).toBe(2); + expect(mockToast.success).toHaveBeenCalledWith("已导入 2 条历史区间"); + } finally { + Object.defineProperty(window, "prompt", { + configurable: true, + value: originalPrompt, + }); + } + }); + + it("导入历史区间 JSON 格式错误时提示失败", async () => { + const originalPrompt = window.prompt; + Object.defineProperty(window, "prompt", { + configurable: true, + value: vi.fn(() => "{bad-json"), + }); + + try { + const { container } = mountHarness(PluginManager, {}, mountedRoots); + await flushEffects(6); + + const timeRangeFilter = container.querySelector( + "select[data-testid='plugin-runtime-time-range-filter']", + ) as HTMLSelectElement | null; + changeSelectValue(timeRangeFilter, "custom"); + await flushEffects(2); + + const importButton = container.querySelector( + "button[data-testid='plugin-runtime-custom-history-import-json']", + ); + clickElement(importButton); + await flushEffects(2); + + expect(mockToast.error).toHaveBeenCalled(); + const latestError = + mockToast.error.mock.calls[mockToast.error.mock.calls.length - 1]?.[0]; + expect(String(latestError)).toContain("导入历史 JSON 失败"); + } finally { + Object.defineProperty(window, "prompt", { + configurable: true, + value: originalPrompt, + }); + } + }); + + it("支持删除单条与清空历史区间", async () => { + const now = Date.now(); + const ranges = Array.from({ length: 3 }).map((_, index) => { + const start = toDateTimeLocalValue( + new Date(now - (index + 4) * 60 * 60 * 1000), + ); + const end = toDateTimeLocalValue( + new Date(now - (index + 3) * 60 * 60 * 1000), + ); + return { start, end }; + }); + + const { container } = mountHarness(PluginManager, {}, mountedRoots); + await flushEffects(6); + + const timeRangeFilter = container.querySelector( + "select[data-testid='plugin-runtime-time-range-filter']", + ) as HTMLSelectElement | null; + changeSelectValue(timeRangeFilter, "custom"); + await flushEffects(2); + + const customStartInput = container.querySelector( + "input[data-testid='plugin-runtime-custom-start-input']", + ) as HTMLInputElement | null; + const customEndInput = container.querySelector( + "input[data-testid='plugin-runtime-custom-end-input']", + ) as HTMLInputElement | null; + for (const range of ranges) { + changeDateTimeInputValue(customStartInput, range.start); + changeDateTimeInputValue(customEndInput, range.end); + await flushEffects(2); + } + + let stored = JSON.parse( + window.localStorage.getItem(runtimeFilterStorageKey) ?? "{}", + ) as { + customRangeHistory?: Array<{ + id: string; + }>; + }; + const historyLengthBeforeDelete = stored.customRangeHistory?.length ?? 0; + expect(historyLengthBeforeDelete).toBeGreaterThan(0); + expect(container.textContent).toContain( + `共 ${historyLengthBeforeDelete} 条`, + ); + + const deleteFirstHistoryButton = container.querySelector( + "button[data-testid='plugin-runtime-custom-history-delete-0']", + ); + expect(deleteFirstHistoryButton).not.toBeNull(); + clickElement(deleteFirstHistoryButton); + await flushEffects(2); + + stored = JSON.parse( + window.localStorage.getItem(runtimeFilterStorageKey) ?? "{}", + ) as { + customRangeHistory?: Array<{ + id: string; + }>; + }; + const historyLengthAfterDelete = stored.customRangeHistory?.length ?? 0; + expect(historyLengthAfterDelete).toBe(historyLengthBeforeDelete - 1); + expect(container.textContent).toContain( + `共 ${historyLengthAfterDelete} 条`, + ); + + const clearHistoryButton = container.querySelector( + "button[data-testid='plugin-runtime-custom-history-clear-all']", + ); + expect(clearHistoryButton).not.toBeNull(); + clickElement(clearHistoryButton); + await flushEffects(2); + + expect(container.textContent).toContain("共 0 条"); + stored = JSON.parse( + window.localStorage.getItem(runtimeFilterStorageKey) ?? "{}", + ) as { + customRangeHistory?: Array<{ + id: string; + }>; + }; + expect(stored.customRangeHistory).toEqual([]); + }); + + it("Clipboard API 不可用时回退 execCommand 复制", async () => { + const now = Date.now(); + const customStart = toDateTimeLocalValue(new Date(now - 65 * 60 * 1000)); + const customEnd = toDateTimeLocalValue(new Date(now - 5 * 60 * 1000)); + const originalExecCommand = document.execCommand; + const execCommandMock = vi.fn(() => true); + + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: undefined, + }); + Object.defineProperty(document, "execCommand", { + configurable: true, + value: execCommandMock, + }); + + try { + const { container } = mountHarness(PluginManager, {}, mountedRoots); + await flushEffects(6); + + const timeRangeFilter = container.querySelector( + "select[data-testid='plugin-runtime-time-range-filter']", + ) as HTMLSelectElement | null; + changeSelectValue(timeRangeFilter, "custom"); + await flushEffects(2); + + const customStartInput = container.querySelector( + "input[data-testid='plugin-runtime-custom-start-input']", + ) as HTMLInputElement | null; + const customEndInput = container.querySelector( + "input[data-testid='plugin-runtime-custom-end-input']", + ) as HTMLInputElement | null; + changeDateTimeInputValue(customStartInput, customStart); + changeDateTimeInputValue(customEndInput, customEnd); + await flushEffects(2); + + const copyButton = container.querySelector( + "button[data-testid='plugin-runtime-custom-copy-last-range']", + ); + clickElement(copyButton); + await flushEffects(2); + + expect(execCommandMock).toHaveBeenCalledWith("copy"); + expect(mockToast.success).toHaveBeenCalledWith("已复制上次区间"); + expect(clipboardWriteTextMock).not.toHaveBeenCalled(); + } finally { + Object.defineProperty(document, "execCommand", { + configurable: true, + value: originalExecCommand, + }); + } + }); + + it("降级复制失败时展示错误提示", async () => { + const now = Date.now(); + const customStart = toDateTimeLocalValue(new Date(now - 50 * 60 * 1000)); + const customEnd = toDateTimeLocalValue(new Date(now - 15 * 60 * 1000)); + const originalExecCommand = document.execCommand; + const execCommandMock = vi.fn(() => false); + + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: undefined, + }); + Object.defineProperty(document, "execCommand", { + configurable: true, + value: execCommandMock, + }); + + try { + const { container } = mountHarness(PluginManager, {}, mountedRoots); + await flushEffects(6); + + const timeRangeFilter = container.querySelector( + "select[data-testid='plugin-runtime-time-range-filter']", + ) as HTMLSelectElement | null; + changeSelectValue(timeRangeFilter, "custom"); + await flushEffects(2); + + const customStartInput = container.querySelector( + "input[data-testid='plugin-runtime-custom-start-input']", + ) as HTMLInputElement | null; + const customEndInput = container.querySelector( + "input[data-testid='plugin-runtime-custom-end-input']", + ) as HTMLInputElement | null; + changeDateTimeInputValue(customStartInput, customStart); + changeDateTimeInputValue(customEndInput, customEnd); + await flushEffects(2); + + const copyButton = container.querySelector( + "button[data-testid='plugin-runtime-custom-copy-last-range']", + ); + clickElement(copyButton); + await flushEffects(2); + + expect(execCommandMock).toHaveBeenCalledWith("copy"); + expect(mockToast.error).toHaveBeenCalled(); + const latestError = + mockToast.error.mock.calls[mockToast.error.mock.calls.length - 1]?.[0]; + expect(String(latestError)).toContain("浏览器不支持复制"); + } finally { + Object.defineProperty(document, "execCommand", { + configurable: true, + value: originalExecCommand, + }); + } + }); + + it("支持恢复并持久化诊断筛选条件", async () => { + window.localStorage.setItem( + runtimeFilterStorageKey, + JSON.stringify({ + taskFilter: "all", + globalPluginFilter: "demo-plugin", + taskSearchKeyword: "special-op", + taskPageSize: 20, + timeRangeFilter: "24h", + }), + ); + + const { container } = mountHarness(PluginManager, {}, mountedRoots); + await flushEffects(6); + + const pluginFilter = container.querySelector( + "select[data-testid='plugin-runtime-plugin-filter']", + ) as HTMLSelectElement | null; + const searchInput = container.querySelector( + "input[data-testid='plugin-runtime-search-input']", + ) as HTMLInputElement | null; + const pageSize = container.querySelector( + "select[data-testid='plugin-runtime-page-size']", + ) as HTMLSelectElement | null; + const timeRangeFilter = container.querySelector( + "select[data-testid='plugin-runtime-time-range-filter']", + ) as HTMLSelectElement | null; + + expect(pluginFilter?.value).toBe("demo-plugin"); + expect(searchInput?.value).toBe("special-op"); + expect(pageSize?.value).toBe("20"); + expect(timeRangeFilter?.value).toBe("24h"); + expect( + container.querySelector("[data-testid='plugin-runtime-row-task-9']"), + ).not.toBeNull(); + + changeSelectValue(timeRangeFilter, "1h"); + await flushEffects(2); + + const stored = JSON.parse( + window.localStorage.getItem(runtimeFilterStorageKey) ?? "{}", + ) as { + timeRangeFilter?: string; + taskPageSize?: number; + taskSearchKeyword?: string; + }; + expect(stored.timeRangeFilter).toBe("1h"); + expect(stored.taskPageSize).toBe(20); + expect(stored.taskSearchKeyword).toBe("special-op"); + }); + + it("支持恢复自定义时间区间配置", async () => { + const now = Date.now(); + const customStart = toDateTimeLocalValue( + new Date(now - 4 * 60 * 60 * 1000), + ); + const customEnd = toDateTimeLocalValue(new Date(now - 90 * 60 * 1000)); + + window.localStorage.setItem( + runtimeFilterStorageKey, + JSON.stringify({ + taskFilter: "all", + globalPluginFilter: "all", + taskSearchKeyword: "", + taskPageSize: 10, + timeRangeFilter: "custom", + customStartTime: customStart, + customEndTime: customEnd, + }), + ); + + const { container } = mountHarness(PluginManager, {}, mountedRoots); + await flushEffects(6); + + const timeRangeFilter = container.querySelector( + "select[data-testid='plugin-runtime-time-range-filter']", + ) as HTMLSelectElement | null; + const customStartInput = container.querySelector( + "input[data-testid='plugin-runtime-custom-start-input']", + ) as HTMLInputElement | null; + const customEndInput = container.querySelector( + "input[data-testid='plugin-runtime-custom-end-input']", + ) as HTMLInputElement | null; + + expect(timeRangeFilter?.value).toBe("custom"); + expect(customStartInput?.value).toBe(customStart); + expect(customEndInput?.value).toBe(customEnd); + expect( + container.querySelector("[data-testid='plugin-runtime-row-task-1']"), + ).toBeNull(); + expect( + container.querySelector("[data-testid='plugin-runtime-row-task-11']"), + ).not.toBeNull(); + }); + + it("支持重置筛选并回到默认分页", async () => { + const { container } = mountHarness(PluginManager, {}, mountedRoots); + await flushEffects(6); + + const nextPageBtn = container.querySelector( + "button[data-testid='plugin-runtime-next-page']", + ); + expect(nextPageBtn).not.toBeNull(); + clickElement(nextPageBtn); + await flushEffects(2); + expect( + container.querySelector("[data-testid='plugin-runtime-row-task-11']"), + ).not.toBeNull(); + + const searchInput = container.querySelector( + "input[data-testid='plugin-runtime-search-input']", + ) as HTMLInputElement | null; + const pageSize = container.querySelector( + "select[data-testid='plugin-runtime-page-size']", + ) as HTMLSelectElement | null; + const timeRangeFilter = container.querySelector( + "select[data-testid='plugin-runtime-time-range-filter']", + ) as HTMLSelectElement | null; + const resetButton = container.querySelector( + "button[data-testid='plugin-runtime-reset-filters']", + ); + + fillTextInput(searchInput, "special-op"); + changeSelectValue(pageSize, "20"); + changeSelectValue(timeRangeFilter, "24h"); + await flushEffects(2); + + clickElement(resetButton); + await flushEffects(3); + + expect(searchInput?.value).toBe(""); + expect(pageSize?.value).toBe("10"); + expect(timeRangeFilter?.value).toBe("all"); + expect( + container.querySelector("[data-testid='plugin-runtime-row-task-1']"), + ).not.toBeNull(); + expect( + container.querySelector("[data-testid='plugin-runtime-row-task-11']"), + ).toBeNull(); + + const stored = JSON.parse( + window.localStorage.getItem(runtimeFilterStorageKey) ?? "{}", + ) as { + taskSearchKeyword?: string; + taskPageSize?: number; + timeRangeFilter?: string; + globalPluginFilter?: string; + customStartTime?: string; + customEndTime?: string; + }; + expect(stored.taskSearchKeyword).toBe(""); + expect(stored.taskPageSize).toBe(10); + expect(stored.timeRangeFilter).toBe("all"); + expect(stored.globalPluginFilter).toBe("all"); + expect(stored.customStartTime).toBe(""); + expect(stored.customEndTime).toBe(""); + }); + + it("持久化筛选中的无效插件会自动回退到全部", async () => { + window.localStorage.setItem( + runtimeFilterStorageKey, + JSON.stringify({ + taskFilter: "all", + globalPluginFilter: "missing-plugin", + taskSearchKeyword: "", + taskPageSize: 10, + timeRangeFilter: "all", + }), + ); + + const { container } = mountHarness(PluginManager, {}, mountedRoots); + await flushEffects(6); + + const pluginFilter = container.querySelector( + "select[data-testid='plugin-runtime-plugin-filter']", + ) as HTMLSelectElement | null; + expect(pluginFilter?.value).toBe("all"); + + const stored = JSON.parse( + window.localStorage.getItem(runtimeFilterStorageKey) ?? "{}", + ) as { + globalPluginFilter?: string; + }; + expect(stored.globalPluginFilter).toBe("all"); + }); + + it("持久化配置损坏时会回退默认筛选", async () => { + window.localStorage.setItem(runtimeFilterStorageKey, "{bad-json"); + + const { container } = mountHarness(PluginManager, {}, mountedRoots); + await flushEffects(6); + + const pluginFilter = container.querySelector( + "select[data-testid='plugin-runtime-plugin-filter']", + ) as HTMLSelectElement | null; + const stateFilter = container.querySelector( + "select[data-testid='plugin-runtime-state-filter']", + ) as HTMLSelectElement | null; + const pageSize = container.querySelector( + "select[data-testid='plugin-runtime-page-size']", + ) as HTMLSelectElement | null; + const timeRangeFilter = container.querySelector( + "select[data-testid='plugin-runtime-time-range-filter']", + ) as HTMLSelectElement | null; + const searchInput = container.querySelector( + "input[data-testid='plugin-runtime-search-input']", + ) as HTMLInputElement | null; + + expect(pluginFilter?.value).toBe("all"); + expect(stateFilter?.value).toBe("all"); + expect(pageSize?.value).toBe("10"); + expect(timeRangeFilter?.value).toBe("all"); + expect(searchInput?.value).toBe(""); + expect(consoleWarnSpy).toHaveBeenCalled(); + }); + + it("支持导出CSV", async () => { + const createObjectURL = vi.fn(() => "blob:mock-url"); + const revokeObjectURL = vi.fn(); + const originCreate = URL.createObjectURL; + const originRevoke = URL.revokeObjectURL; + const originAnchorClick = HTMLAnchorElement.prototype.click; + URL.createObjectURL = createObjectURL; + URL.revokeObjectURL = revokeObjectURL; + HTMLAnchorElement.prototype.click = vi.fn(); + + try { + const { container } = mountHarness(PluginManager, {}, mountedRoots); + await flushEffects(6); + + const exportButton = container.querySelector( + "button[data-testid='plugin-runtime-export-csv']", + ); + expect(exportButton).not.toBeNull(); + clickElement(exportButton); + await flushEffects(2); + + expect(createObjectURL).toHaveBeenCalled(); + expect(revokeObjectURL).toHaveBeenCalled(); + expect(mockToast.success).toHaveBeenCalled(); + } finally { + URL.createObjectURL = originCreate; + URL.revokeObjectURL = originRevoke; + HTMLAnchorElement.prototype.click = originAnchorClick; + } + }); +}); diff --git a/src/components/plugins/PluginManager.tsx b/src/components/plugins/PluginManager.tsx index 1e55478be..3e8e889b5 100644 --- a/src/components/plugins/PluginManager.tsx +++ b/src/components/plugins/PluginManager.tsx @@ -1,5 +1,5 @@ -import React, { useEffect, useState, useCallback } from "react"; -import { safeInvoke } from "@/lib/dev-bridge"; +import React, { useEffect, useState, useCallback, useMemo } from "react"; +import { safeInvoke, safeListen } from "@/lib/dev-bridge"; import { Puzzle, RefreshCw, @@ -14,7 +14,9 @@ import { ChevronUp, Plus, Package, + Ban, } from "lucide-react"; +import type { UnlistenFn } from "@tauri-apps/api/event"; import { PluginInstallDialog } from "./PluginInstallDialog"; import { PluginUninstallDialog } from "./PluginUninstallDialog"; import { PluginItemContextMenu } from "./PluginItemContextMenu"; @@ -57,6 +59,424 @@ interface PluginServiceStatus { plugins_dir: string; } +type PluginTaskState = + | "queued" + | "running" + | "retrying" + | "succeeded" + | "failed" + | "cancelled" + | "timed_out"; + +type PluginTaskFilter = PluginTaskState | "all"; +type PluginTaskTimeRangeFilter = "all" | "1h" | "24h" | "7d" | "custom"; +type CustomRangeHistorySort = "recent" | "label"; +type CustomRangeHistoryViewMode = "default" | "flat" | "only_pinned"; + +interface CustomRangeHistoryItem { + id: string; + start: string; + end: string; + updatedAt: string; + label: string; + pinned: boolean; +} + +interface PersistedRuntimeFilters { + taskFilter: PluginTaskFilter; + globalPluginFilter: string; + taskSearchKeyword: string; + taskPageSize: number; + timeRangeFilter: PluginTaskTimeRangeFilter; + customStartTime: string; + customEndTime: string; + lastAppliedCustomStartTime: string; + lastAppliedCustomEndTime: string; + lastAppliedCustomUpdatedAt: string; + customRangeHistory: CustomRangeHistoryItem[]; + customRangeHistorySearchKeyword: string; + customRangeHistorySort: CustomRangeHistorySort; + customRangeHistoryOnlyPinned: boolean; + customRangeHistoryPinnedFirst: boolean; +} + +interface EffectiveTaskTimeBounds { + effectiveStartMs: number | null; + effectiveEndMs: number | null; + isCustomRangeSwapped: boolean; + isCustomRangeEmpty: boolean; +} + +interface PluginTaskError { + code?: string; + message: string; + retryable: boolean; +} + +interface PluginTaskRecord { + taskId: string; + pluginId: string; + operation: string; + state: PluginTaskState; + attempt: number; + maxRetries: number; + startedAt: string; + endedAt?: string; + durationMs?: number; + error?: PluginTaskError; +} + +interface PluginQueueStats { + pluginId: string; + running: number; + waiting: number; + rejected: number; + completed: number; + failed: number; + cancelled: number; + timedOut: number; +} + +const getTaskStateLabel = (state: PluginTaskState): string => { + switch (state) { + case "queued": + return "排队中"; + case "running": + return "执行中"; + case "retrying": + return "重试中"; + case "succeeded": + return "成功"; + case "failed": + return "失败"; + case "cancelled": + return "已取消"; + case "timed_out": + return "超时"; + default: + return state; + } +}; + +const getTaskStateClassName = (state: PluginTaskState): string => { + switch (state) { + case "succeeded": + return "bg-green-100 text-green-700"; + case "running": + case "retrying": + return "bg-blue-100 text-blue-700"; + case "queued": + return "bg-yellow-100 text-yellow-700"; + case "failed": + case "timed_out": + return "bg-red-100 text-red-700"; + case "cancelled": + return "bg-gray-200 text-gray-700"; + default: + return "bg-muted text-muted-foreground"; + } +}; + +const canCancelTask = (task: PluginTaskRecord) => + task.state === "queued" || + task.state === "running" || + task.state === "retrying"; + +const formatTime = (value?: string) => { + if (!value) { + return "-"; + } + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + return value; + } + return date.toLocaleString(); +}; + +const escapeCsvValue = ( + value: string | number | boolean | undefined | null, +) => { + const text = String(value ?? ""); + const escaped = text.replace(/"/g, '""'); + return `"${escaped}"`; +}; + +const RUNTIME_FILTER_STORAGE_KEY = "proxycast.pluginDiagnostics.filters.v1"; +const DEFAULT_TASK_PAGE_SIZE = 10; +const TASK_PAGE_SIZE_OPTIONS = [10, 20, 50] as const; +const TASK_TIME_RANGE_TO_MS: Record< + Exclude, + number +> = { + "1h": 60 * 60 * 1000, + "24h": 24 * 60 * 60 * 1000, + "7d": 7 * 24 * 60 * 60 * 1000, +}; +const QUICK_CUSTOM_RANGE_MINUTES = [15, 30, 60] as const; +const MAX_CUSTOM_RANGE_HISTORY = 5; + +const toDateTimeLocalValue = (value: Date): string => { + const pad = (num: number) => String(num).padStart(2, "0"); + return `${value.getFullYear()}-${pad(value.getMonth() + 1)}-${pad(value.getDate())}T${pad( + value.getHours(), + )}:${pad(value.getMinutes())}`; +}; + +const buildCustomRangeHistoryId = (start: string, end: string): string => + `${start}|${end}`; + +const fallbackCopyText = (text: string): boolean => { + const textarea = document.createElement("textarea"); + textarea.value = text; + textarea.setAttribute("readonly", "true"); + textarea.style.position = "fixed"; + textarea.style.opacity = "0"; + textarea.style.pointerEvents = "none"; + document.body.appendChild(textarea); + textarea.focus(); + textarea.select(); + textarea.setSelectionRange(0, text.length); + + try { + return document.execCommand("copy"); + } catch { + return false; + } finally { + textarea.remove(); + } +}; + +const writeTextToClipboard = async (text: string): Promise => { + if (typeof navigator !== "undefined" && navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(text); + return; + } + + if (!fallbackCopyText(text)) { + throw new Error("浏览器不支持复制"); + } +}; + +const isPluginTaskFilter = (value: unknown): value is PluginTaskFilter => { + const validFilters: PluginTaskFilter[] = [ + "all", + "queued", + "running", + "retrying", + "succeeded", + "failed", + "cancelled", + "timed_out", + ]; + return ( + typeof value === "string" && + validFilters.includes(value as PluginTaskFilter) + ); +}; + +const isPluginTaskTimeRangeFilter = ( + value: unknown, +): value is PluginTaskTimeRangeFilter => { + const validRanges: PluginTaskTimeRangeFilter[] = [ + "all", + "1h", + "24h", + "7d", + "custom", + ]; + return ( + typeof value === "string" && + validRanges.includes(value as PluginTaskTimeRangeFilter) + ); +}; + +const toValidDateTimeLocalInput = (value: unknown): string => { + if (typeof value !== "string") { + return ""; + } + if (!value) { + return ""; + } + return Number.isNaN(Date.parse(value)) ? "" : value; +}; + +const toValidIsoDateTimeInput = (value: unknown): string => { + if (typeof value !== "string") { + return ""; + } + if (!value) { + return ""; + } + const parsed = Date.parse(value); + if (Number.isNaN(parsed)) { + return ""; + } + return new Date(parsed).toISOString(); +}; + +const isCustomRangeHistorySort = ( + value: unknown, +): value is CustomRangeHistorySort => value === "recent" || value === "label"; + +const toValidTaskPageSize = (value: unknown): number => { + if (typeof value !== "number") { + return DEFAULT_TASK_PAGE_SIZE; + } + return TASK_PAGE_SIZE_OPTIONS.includes( + value as (typeof TASK_PAGE_SIZE_OPTIONS)[number], + ) + ? value + : DEFAULT_TASK_PAGE_SIZE; +}; + +const toValidCustomRangeHistory = ( + value: unknown, +): CustomRangeHistoryItem[] => { + if (!Array.isArray(value)) { + return []; + } + + const normalized = value + .map((item) => { + if (!item || typeof item !== "object") { + return null; + } + const record = item as Partial; + const start = toValidDateTimeLocalInput(record.start); + const end = toValidDateTimeLocalInput(record.end); + const updatedAt = toValidIsoDateTimeInput(record.updatedAt); + const label = typeof record.label === "string" ? record.label.trim() : ""; + const pinned = Boolean(record.pinned); + if (!start || !end || !updatedAt) { + return null; + } + return { + id: buildCustomRangeHistoryId(start, end), + start, + end, + updatedAt, + label, + pinned, + }; + }) + .filter((item): item is CustomRangeHistoryItem => item !== null) + .sort( + (left, right) => Date.parse(right.updatedAt) - Date.parse(left.updatedAt), + ); + + const uniqueById = normalized.reduce( + (acc, current) => { + if (acc.some((item) => item.id === current.id)) { + return acc; + } + acc.push(current); + return acc; + }, + [], + ); + + return uniqueById.slice(0, MAX_CUSTOM_RANGE_HISTORY); +}; + +const parseCustomRangeHistoryImport = ( + raw: unknown, +): CustomRangeHistoryItem[] => { + if (Array.isArray(raw)) { + return toValidCustomRangeHistory(raw); + } + if (raw && typeof raw === "object") { + const payload = raw as { + customRangeHistory?: unknown; + history?: unknown; + }; + return toValidCustomRangeHistory( + payload.customRangeHistory ?? payload.history, + ); + } + return []; +}; + +const readPersistedRuntimeFilters = (): PersistedRuntimeFilters => { + const fallback: PersistedRuntimeFilters = { + taskFilter: "all", + globalPluginFilter: "all", + taskSearchKeyword: "", + taskPageSize: DEFAULT_TASK_PAGE_SIZE, + timeRangeFilter: "all", + customStartTime: "", + customEndTime: "", + lastAppliedCustomStartTime: "", + lastAppliedCustomEndTime: "", + lastAppliedCustomUpdatedAt: "", + customRangeHistory: [], + customRangeHistorySearchKeyword: "", + customRangeHistorySort: "recent", + customRangeHistoryOnlyPinned: false, + customRangeHistoryPinnedFirst: true, + }; + + if (typeof window === "undefined") { + return fallback; + } + + try { + const raw = window.localStorage.getItem(RUNTIME_FILTER_STORAGE_KEY); + if (!raw) { + return fallback; + } + const parsed = JSON.parse(raw) as Partial; + return { + taskFilter: isPluginTaskFilter(parsed.taskFilter) + ? parsed.taskFilter + : fallback.taskFilter, + globalPluginFilter: + typeof parsed.globalPluginFilter === "string" + ? parsed.globalPluginFilter + : fallback.globalPluginFilter, + taskSearchKeyword: + typeof parsed.taskSearchKeyword === "string" + ? parsed.taskSearchKeyword + : fallback.taskSearchKeyword, + taskPageSize: toValidTaskPageSize(parsed.taskPageSize), + timeRangeFilter: isPluginTaskTimeRangeFilter(parsed.timeRangeFilter) + ? parsed.timeRangeFilter + : fallback.timeRangeFilter, + customStartTime: toValidDateTimeLocalInput(parsed.customStartTime), + customEndTime: toValidDateTimeLocalInput(parsed.customEndTime), + lastAppliedCustomStartTime: toValidDateTimeLocalInput( + parsed.lastAppliedCustomStartTime, + ), + lastAppliedCustomEndTime: toValidDateTimeLocalInput( + parsed.lastAppliedCustomEndTime, + ), + lastAppliedCustomUpdatedAt: toValidIsoDateTimeInput( + parsed.lastAppliedCustomUpdatedAt, + ), + customRangeHistory: toValidCustomRangeHistory(parsed.customRangeHistory), + customRangeHistorySearchKeyword: + typeof parsed.customRangeHistorySearchKeyword === "string" + ? parsed.customRangeHistorySearchKeyword + : fallback.customRangeHistorySearchKeyword, + customRangeHistorySort: isCustomRangeHistorySort( + parsed.customRangeHistorySort, + ) + ? parsed.customRangeHistorySort + : fallback.customRangeHistorySort, + customRangeHistoryOnlyPinned: + typeof parsed.customRangeHistoryOnlyPinned === "boolean" + ? parsed.customRangeHistoryOnlyPinned + : fallback.customRangeHistoryOnlyPinned, + customRangeHistoryPinnedFirst: + typeof parsed.customRangeHistoryPinnedFirst === "boolean" + ? parsed.customRangeHistoryPinnedFirst + : fallback.customRangeHistoryPinnedFirst, + }; + } catch (error) { + console.warn("[PluginManager] 读取诊断筛选配置失败:", error); + return fallback; + } +}; + /** 安装来源 */ interface InstallSource { type: "local" | "url" | "github"; @@ -81,6 +501,10 @@ interface InstalledPlugin { } export function PluginManager() { + const initialRuntimeFilters = useMemo( + () => readPersistedRuntimeFilters(), + [], + ); const [status, setStatus] = useState(null); const [plugins, setPlugins] = useState([]); const [installedPlugins, setInstalledPlugins] = useState( @@ -89,12 +513,104 @@ export function PluginManager() { const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [expandedPlugin, setExpandedPlugin] = useState(null); + const [taskFilter, setTaskFilter] = useState( + initialRuntimeFilters.taskFilter, + ); + const [globalPluginFilter, setGlobalPluginFilter] = useState( + initialRuntimeFilters.globalPluginFilter, + ); + const [taskSearchKeyword, setTaskSearchKeyword] = useState( + initialRuntimeFilters.taskSearchKeyword, + ); + const [taskPage, setTaskPage] = useState(1); + const [taskPageSize, setTaskPageSize] = useState( + initialRuntimeFilters.taskPageSize, + ); + const [timeRangeFilter, setTimeRangeFilter] = + useState(initialRuntimeFilters.timeRangeFilter); + const [customStartTime, setCustomStartTime] = useState( + initialRuntimeFilters.customStartTime, + ); + const [customEndTime, setCustomEndTime] = useState( + initialRuntimeFilters.customEndTime, + ); + const [lastAppliedCustomStartTime, setLastAppliedCustomStartTime] = + useState(initialRuntimeFilters.lastAppliedCustomStartTime); + const [lastAppliedCustomEndTime, setLastAppliedCustomEndTime] = + useState(initialRuntimeFilters.lastAppliedCustomEndTime); + const [lastAppliedCustomUpdatedAt, setLastAppliedCustomUpdatedAt] = + useState(initialRuntimeFilters.lastAppliedCustomUpdatedAt); + const [customRangeHistory, setCustomRangeHistory] = useState< + CustomRangeHistoryItem[] + >(initialRuntimeFilters.customRangeHistory); + const [selectedCustomRangeHistoryId, setSelectedCustomRangeHistoryId] = + useState(""); + const [customRangeHistorySearchKeyword, setCustomRangeHistorySearchKeyword] = + useState(initialRuntimeFilters.customRangeHistorySearchKeyword); + const [customRangeHistorySort, setCustomRangeHistorySort] = + useState( + initialRuntimeFilters.customRangeHistorySort, + ); + const [customRangeHistoryOnlyPinned, setCustomRangeHistoryOnlyPinned] = + useState(initialRuntimeFilters.customRangeHistoryOnlyPinned); + const [customRangeHistoryPinnedFirst, setCustomRangeHistoryPinnedFirst] = + useState(initialRuntimeFilters.customRangeHistoryPinnedFirst); + const [pluginTasks, setPluginTasks] = useState< + Record + >({}); + const [allTasks, setAllTasks] = useState([]); + const [queueStats, setQueueStats] = useState< + Record + >({}); + const [cancellingTaskId, setCancellingTaskId] = useState(null); + const [detailLoadingTaskId, setDetailLoadingTaskId] = useState( + null, + ); + const [selectedTaskDetail, setSelectedTaskDetail] = + useState(null); // 对话框状态 const [showInstallDialog, setShowInstallDialog] = useState(false); const [pluginToUninstall, setPluginToUninstall] = useState(null); + const fetchRuntimeData = useCallback(async () => { + try { + const [taskList, queueStatsList] = await Promise.all([ + safeInvoke("list_plugin_tasks", { + taskState: taskFilter === "all" ? null : taskFilter, + limit: 300, + }).catch(() => []), + safeInvoke("get_plugin_queue_stats").catch( + () => [], + ), + ]); + + const groupedTasks = taskList.reduce>( + (acc, task) => { + if (!acc[task.pluginId]) { + acc[task.pluginId] = []; + } + acc[task.pluginId].push(task); + return acc; + }, + {}, + ); + setPluginTasks(groupedTasks); + setAllTasks(taskList); + + const queueStatsMap = queueStatsList.reduce< + Record + >((acc, stats) => { + acc[stats.pluginId] = stats; + return acc; + }, {}); + setQueueStats(queueStatsMap); + } catch (err) { + console.warn("[PluginManager] 加载插件运行态数据失败:", err); + } + }, [taskFilter]); + const fetchData = useCallback(async () => { try { setLoading(true); @@ -106,18 +622,115 @@ export function PluginManager() { setStatus(serviceStatus); setPlugins(pluginList); setInstalledPlugins(installedList); + await fetchRuntimeData(); setError(null); } catch (err) { setError(err instanceof Error ? err.message : String(err)); } finally { setLoading(false); } - }, []); + }, [fetchRuntimeData]); useEffect(() => { fetchData(); }, [fetchData]); + useEffect(() => { + fetchRuntimeData(); + }, [fetchRuntimeData]); + + useEffect(() => { + const timer = window.setInterval(() => { + fetchRuntimeData(); + }, 5000); + return () => window.clearInterval(timer); + }, [fetchRuntimeData]); + + useEffect(() => { + let unlisten: UnlistenFn | null = null; + let delayedRefreshTimer: number | null = null; + + const setupTaskEventListener = async () => { + try { + unlisten = await safeListen("plugin-task-event", () => { + if (delayedRefreshTimer !== null) { + window.clearTimeout(delayedRefreshTimer); + } + delayedRefreshTimer = window.setTimeout(() => { + void fetchRuntimeData(); + }, 200); + }); + } catch (err) { + console.warn("[PluginManager] 监听 plugin-task-event 失败:", err); + } + }; + + void setupTaskEventListener(); + + return () => { + if (delayedRefreshTimer !== null) { + window.clearTimeout(delayedRefreshTimer); + } + if (unlisten) { + void unlisten(); + } + }; + }, [fetchRuntimeData]); + + useEffect(() => { + try { + const persisted: PersistedRuntimeFilters = { + taskFilter, + globalPluginFilter, + taskSearchKeyword, + taskPageSize, + timeRangeFilter, + customStartTime, + customEndTime, + lastAppliedCustomStartTime, + lastAppliedCustomEndTime, + lastAppliedCustomUpdatedAt, + customRangeHistory, + customRangeHistorySearchKeyword, + customRangeHistorySort, + customRangeHistoryOnlyPinned, + customRangeHistoryPinnedFirst, + }; + window.localStorage.setItem( + RUNTIME_FILTER_STORAGE_KEY, + JSON.stringify(persisted), + ); + } catch (error) { + console.warn("[PluginManager] 持久化诊断筛选配置失败:", error); + } + }, [ + taskFilter, + globalPluginFilter, + taskSearchKeyword, + taskPageSize, + timeRangeFilter, + customStartTime, + customEndTime, + lastAppliedCustomStartTime, + lastAppliedCustomEndTime, + lastAppliedCustomUpdatedAt, + customRangeHistory, + customRangeHistorySearchKeyword, + customRangeHistorySort, + customRangeHistoryOnlyPinned, + customRangeHistoryPinnedFirst, + ]); + + useEffect(() => { + if (loading || globalPluginFilter === "all") { + return; + } + const exists = plugins.some((plugin) => plugin.name === globalPluginFilter); + if (!exists) { + setGlobalPluginFilter("all"); + } + }, [loading, plugins, globalPluginFilter]); + // 处理安装成功 const handleInstallSuccess = useCallback(() => { fetchData(); @@ -148,6 +761,98 @@ export function PluginManager() { } }; + const handleCancelTask = async (taskId: string) => { + try { + setCancellingTaskId(taskId); + const cancelled = await safeInvoke("cancel_plugin_task", { + taskId, + }); + if (cancelled) { + toast.success("任务取消请求已发送"); + } else { + toast.warning("任务可能已结束,无法取消"); + } + await fetchRuntimeData(); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + toast.error(`取消任务失败: ${message}`); + } finally { + setCancellingTaskId(null); + } + }; + + const handleLoadTaskDetail = async (taskId: string) => { + try { + setDetailLoadingTaskId(taskId); + const detail = await safeInvoke( + "get_plugin_task", + { + taskId, + }, + ); + if (!detail) { + toast.warning("任务详情不存在,可能已被清理"); + return; + } + setSelectedTaskDetail(detail); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + toast.error(`加载任务详情失败: ${message}`); + } finally { + setDetailLoadingTaskId(null); + } + }; + + const exportDiagnosticTasks = () => { + try { + const headers = [ + "taskId", + "pluginId", + "operation", + "state", + "attempt", + "maxRetries", + "startedAt", + "endedAt", + "durationMs", + "errorCode", + "errorMessage", + "retryable", + ]; + const rows = diagnosticTasks.map((task) => [ + escapeCsvValue(task.taskId), + escapeCsvValue(task.pluginId), + escapeCsvValue(task.operation), + escapeCsvValue(getTaskStateLabel(task.state)), + escapeCsvValue(task.attempt), + escapeCsvValue(task.maxRetries), + escapeCsvValue(task.startedAt), + escapeCsvValue(task.endedAt), + escapeCsvValue(task.durationMs), + escapeCsvValue(task.error?.code), + escapeCsvValue(task.error?.message), + escapeCsvValue(task.error?.retryable), + ]); + const csv = [headers.join(","), ...rows.map((row) => row.join(","))] + .join("\n") + .trim(); + + const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" }); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = `plugin-diagnostics-${Date.now()}.csv`; + document.body.appendChild(link); + link.click(); + link.remove(); + URL.revokeObjectURL(url); + toast.success("任务记录已导出为 CSV"); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + toast.error(`导出 CSV 失败: ${message}`); + } + }; + const handleUnloadPlugin = async (name: string) => { try { await safeInvoke("unload_plugin", { name }); @@ -185,6 +890,515 @@ export function PluginManager() { } }; + const resetDiagnosticFilters = useCallback(() => { + setTaskFilter("all"); + setGlobalPluginFilter("all"); + setTaskSearchKeyword(""); + setTaskPageSize(DEFAULT_TASK_PAGE_SIZE); + setTimeRangeFilter("all"); + setCustomStartTime(""); + setCustomEndTime(""); + setSelectedCustomRangeHistoryId(""); + setCustomRangeHistorySearchKeyword(""); + setCustomRangeHistorySort("recent"); + setCustomRangeHistoryOnlyPinned(false); + setCustomRangeHistoryPinnedFirst(true); + setTaskPage(1); + }, []); + + const applyQuickCustomTimeRange = useCallback((minutes: number) => { + const now = new Date(); + const start = new Date(now.getTime() - minutes * 60 * 1000); + setTimeRangeFilter("custom"); + setCustomStartTime(toDateTimeLocalValue(start)); + setCustomEndTime(toDateTimeLocalValue(now)); + setSelectedCustomRangeHistoryId(""); + }, []); + + const clearCustomTimeRange = useCallback(() => { + setCustomStartTime(""); + setCustomEndTime(""); + setSelectedCustomRangeHistoryId(""); + }, []); + + const applyLastCustomTimeRange = useCallback(() => { + if (!lastAppliedCustomStartTime || !lastAppliedCustomEndTime) { + return; + } + setTimeRangeFilter("custom"); + setCustomStartTime(lastAppliedCustomStartTime); + setCustomEndTime(lastAppliedCustomEndTime); + setSelectedCustomRangeHistoryId(""); + }, [lastAppliedCustomStartTime, lastAppliedCustomEndTime]); + + const applyCustomRangeHistoryItem = useCallback( + (historyId: string) => { + setSelectedCustomRangeHistoryId(historyId); + const target = customRangeHistory.find((item) => item.id === historyId); + if (!target) { + return; + } + setTimeRangeFilter("custom"); + setCustomStartTime(target.start); + setCustomEndTime(target.end); + setSelectedCustomRangeHistoryId(""); + }, + [customRangeHistory], + ); + + const removeCustomRangeHistoryItem = useCallback((historyId: string) => { + setCustomRangeHistory((previous) => + previous.filter((item) => item.id !== historyId), + ); + setSelectedCustomRangeHistoryId(""); + }, []); + + const clearCustomRangeHistory = useCallback(() => { + setCustomRangeHistory([]); + setSelectedCustomRangeHistoryId(""); + }, []); + + const togglePinCustomRangeHistoryItem = useCallback((historyId: string) => { + setCustomRangeHistory((previous) => + previous.map((item) => + item.id === historyId ? { ...item, pinned: !item.pinned } : item, + ), + ); + }, []); + + const exportCustomRangeHistoryAsJson = useCallback(async () => { + if (customRangeHistory.length === 0) { + toast.warning("暂无可导出的历史区间"); + return; + } + const payload = { + version: 1, + exportedAt: new Date().toISOString(), + customRangeHistory, + }; + try { + await writeTextToClipboard(JSON.stringify(payload, null, 2)); + toast.success("已复制历史区间 JSON"); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + toast.error(`导出历史 JSON 失败: ${message}`); + } + }, [customRangeHistory]); + + const importCustomRangeHistoryFromJson = useCallback(() => { + const text = window.prompt("请粘贴历史区间 JSON"); + if (text === null) { + return; + } + const normalizedText = text.trim(); + if (!normalizedText) { + toast.warning("未输入任何 JSON 内容"); + return; + } + + try { + const parsed = JSON.parse(normalizedText) as unknown; + const importedHistory = parseCustomRangeHistoryImport(parsed); + if (importedHistory.length === 0) { + toast.warning("JSON 中未包含有效历史区间"); + return; + } + setCustomRangeHistory(importedHistory); + setSelectedCustomRangeHistoryId(""); + setCustomRangeHistorySearchKeyword(""); + setCustomRangeHistorySort("recent"); + toast.success(`已导入 ${importedHistory.length} 条历史区间`); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + toast.error(`导入历史 JSON 失败: ${message}`); + } + }, []); + + const filteredCustomRangeHistory = useMemo(() => { + const keyword = customRangeHistorySearchKeyword.trim().toLowerCase(); + const sorted = [...customRangeHistory].sort((left, right) => { + if (customRangeHistoryPinnedFirst && left.pinned !== right.pinned) { + return left.pinned ? -1 : 1; + } + if (customRangeHistorySort === "label") { + const leftKey = left.label || `${left.start}-${left.end}`; + const rightKey = right.label || `${right.start}-${right.end}`; + return leftKey.localeCompare(rightKey, "zh-Hans-CN"); + } + return Date.parse(right.updatedAt) - Date.parse(left.updatedAt); + }); + + if (!keyword) { + return customRangeHistoryOnlyPinned + ? sorted.filter((item) => item.pinned) + : sorted; + } + return sorted.filter((item) => { + if (customRangeHistoryOnlyPinned && !item.pinned) { + return false; + } + const searchable = [item.label, item.start, item.end] + .join(" ") + .toLowerCase(); + return searchable.includes(keyword); + }); + }, [ + customRangeHistory, + customRangeHistorySearchKeyword, + customRangeHistorySort, + customRangeHistoryOnlyPinned, + customRangeHistoryPinnedFirst, + ]); + + const pinnedCustomRangeHistoryCount = useMemo( + () => customRangeHistory.filter((item) => item.pinned).length, + [customRangeHistory], + ); + + const customRangeHistoryEmptyHintText = useMemo(() => { + const hasSearchKeyword = customRangeHistorySearchKeyword.trim().length > 0; + if (customRangeHistoryOnlyPinned) { + if (pinnedCustomRangeHistoryCount === 0) { + return "当前仅收藏模式下暂无历史区间"; + } + return hasSearchKeyword + ? "当前仅收藏模式与关键词下无匹配历史区间" + : "当前仅收藏模式下无匹配历史区间"; + } + return hasSearchKeyword ? "当前关键词下无匹配历史区间" : "暂无历史区间"; + }, [ + customRangeHistoryOnlyPinned, + customRangeHistorySearchKeyword, + pinnedCustomRangeHistoryCount, + ]); + + const customRangeHistoryCountText = useMemo(() => { + if (customRangeHistoryOnlyPinned) { + return `总计 ${customRangeHistory.length} 条,收藏 ${pinnedCustomRangeHistoryCount} 条,仅收藏匹配 ${filteredCustomRangeHistory.length} 条`; + } + return `共 ${customRangeHistory.length} 条,匹配 ${filteredCustomRangeHistory.length} 条,收藏 ${pinnedCustomRangeHistoryCount} 条`; + }, [ + customRangeHistory.length, + customRangeHistoryOnlyPinned, + filteredCustomRangeHistory.length, + pinnedCustomRangeHistoryCount, + ]); + + const customRangeHistoryViewMode = useMemo(() => { + if (customRangeHistoryOnlyPinned) { + return "only_pinned"; + } + return customRangeHistoryPinnedFirst ? "default" : "flat"; + }, [customRangeHistoryOnlyPinned, customRangeHistoryPinnedFirst]); + + const handleCustomRangeHistoryViewModeChange = useCallback( + (mode: CustomRangeHistoryViewMode) => { + if (mode === "only_pinned") { + setCustomRangeHistoryOnlyPinned(true); + setCustomRangeHistoryPinnedFirst(true); + return; + } + if (mode === "flat") { + setCustomRangeHistoryOnlyPinned(false); + setCustomRangeHistoryPinnedFirst(false); + return; + } + setCustomRangeHistoryOnlyPinned(false); + setCustomRangeHistoryPinnedFirst(true); + }, + [], + ); + + const renameCustomRangeHistoryItem = useCallback( + (historyId: string) => { + const target = customRangeHistory.find((item) => item.id === historyId); + if (!target) { + return; + } + const nextLabel = window.prompt("请输入区间标签", target.label); + if (nextLabel === null) { + return; + } + const normalizedLabel = nextLabel.trim(); + setCustomRangeHistory((previous) => + previous.map((item) => + item.id === historyId ? { ...item, label: normalizedLabel } : item, + ), + ); + }, + [customRangeHistory], + ); + + const copyLastCustomTimeRange = useCallback(async () => { + if (!lastAppliedCustomStartTime || !lastAppliedCustomEndTime) { + toast.warning("暂无可复制的上次区间"); + return; + } + + const rangeText = `上次区间: ${lastAppliedCustomStartTime} ~ ${lastAppliedCustomEndTime}`; + try { + await writeTextToClipboard(rangeText); + toast.success("已复制上次区间"); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + toast.error(`复制区间失败: ${message}`); + } + }, [lastAppliedCustomStartTime, lastAppliedCustomEndTime]); + + const copyLastCustomTimeRangeAsJson = useCallback(async () => { + if (!lastAppliedCustomStartTime || !lastAppliedCustomEndTime) { + toast.warning("暂无可复制的上次区间"); + return; + } + + const payload = { + start: lastAppliedCustomStartTime, + end: lastAppliedCustomEndTime, + updatedAt: lastAppliedCustomUpdatedAt || null, + }; + + try { + await writeTextToClipboard(JSON.stringify(payload, null, 2)); + toast.success("已复制区间 JSON"); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + toast.error(`复制 JSON 失败: ${message}`); + } + }, [ + lastAppliedCustomStartTime, + lastAppliedCustomEndTime, + lastAppliedCustomUpdatedAt, + ]); + + const effectiveTimeBounds = useMemo(() => { + if (timeRangeFilter === "all") { + return { + effectiveStartMs: null, + effectiveEndMs: null, + isCustomRangeSwapped: false, + isCustomRangeEmpty: false, + }; + } + + if (timeRangeFilter !== "custom") { + return { + effectiveStartMs: Date.now() - TASK_TIME_RANGE_TO_MS[timeRangeFilter], + effectiveEndMs: null, + isCustomRangeSwapped: false, + isCustomRangeEmpty: false, + }; + } + + const parsedStartMs = Date.parse(customStartTime); + const parsedEndMs = Date.parse(customEndTime); + const hasStart = !Number.isNaN(parsedStartMs); + const hasEnd = !Number.isNaN(parsedEndMs); + const isCustomRangeEmpty = !hasStart && !hasEnd; + + let effectiveStartMs = hasStart ? parsedStartMs : null; + let effectiveEndMs = hasEnd ? parsedEndMs : null; + let isCustomRangeSwapped = false; + + if ( + effectiveStartMs !== null && + effectiveEndMs !== null && + effectiveStartMs > effectiveEndMs + ) { + [effectiveStartMs, effectiveEndMs] = [effectiveEndMs, effectiveStartMs]; + isCustomRangeSwapped = true; + } + + return { + effectiveStartMs, + effectiveEndMs, + isCustomRangeSwapped, + isCustomRangeEmpty, + }; + }, [timeRangeFilter, customStartTime, customEndTime]); + + const customRangeHintText = useMemo(() => { + if (timeRangeFilter !== "custom") { + return ""; + } + if (effectiveTimeBounds.isCustomRangeEmpty) { + return "未设置开始/结束时间,当前展示全部任务。"; + } + if (effectiveTimeBounds.isCustomRangeSwapped) { + return "开始时间晚于结束时间,系统已自动按时间先后处理。"; + } + if ( + effectiveTimeBounds.effectiveStartMs !== null && + effectiveTimeBounds.effectiveEndMs !== null + ) { + return "按开始与结束时间区间过滤。"; + } + if (effectiveTimeBounds.effectiveStartMs !== null) { + return "按开始时间向后过滤。"; + } + return "按结束时间向前过滤。"; + }, [timeRangeFilter, effectiveTimeBounds]); + + useEffect(() => { + if (timeRangeFilter !== "custom") { + return; + } + const { effectiveStartMs, effectiveEndMs } = effectiveTimeBounds; + if (effectiveStartMs === null || effectiveEndMs === null) { + return; + } + const normalizedStart = toDateTimeLocalValue(new Date(effectiveStartMs)); + const normalizedEnd = toDateTimeLocalValue(new Date(effectiveEndMs)); + if ( + normalizedStart === lastAppliedCustomStartTime && + normalizedEnd === lastAppliedCustomEndTime + ) { + return; + } + const nowIso = new Date().toISOString(); + setLastAppliedCustomStartTime(normalizedStart); + setLastAppliedCustomEndTime(normalizedEnd); + setLastAppliedCustomUpdatedAt(nowIso); + setCustomRangeHistory((previous) => { + const historyId = buildCustomRangeHistoryId( + normalizedStart, + normalizedEnd, + ); + const existed = previous.find((item) => item.id === historyId); + const nextItem: CustomRangeHistoryItem = { + id: historyId, + start: normalizedStart, + end: normalizedEnd, + updatedAt: nowIso, + label: existed?.label ?? "", + pinned: existed?.pinned ?? false, + }; + const filtered = previous.filter((item) => item.id !== nextItem.id); + return [nextItem, ...filtered].slice(0, MAX_CUSTOM_RANGE_HISTORY); + }); + }, [ + timeRangeFilter, + effectiveTimeBounds, + lastAppliedCustomStartTime, + lastAppliedCustomEndTime, + ]); + + const diagnosticTasks = useMemo(() => { + const keyword = taskSearchKeyword.trim().toLowerCase(); + + return allTasks.filter((task) => { + if ( + globalPluginFilter !== "all" && + task.pluginId !== globalPluginFilter + ) { + return false; + } + + if ( + effectiveTimeBounds.effectiveStartMs !== null || + effectiveTimeBounds.effectiveEndMs !== null + ) { + const startedAtMs = Date.parse(task.startedAt); + if (Number.isNaN(startedAtMs)) { + return false; + } + if ( + effectiveTimeBounds.effectiveStartMs !== null && + startedAtMs < effectiveTimeBounds.effectiveStartMs + ) { + return false; + } + if ( + effectiveTimeBounds.effectiveEndMs !== null && + startedAtMs > effectiveTimeBounds.effectiveEndMs + ) { + return false; + } + } + + if (!keyword) { + return true; + } + + const searchableText = [ + task.taskId, + task.pluginId, + task.operation, + getTaskStateLabel(task.state), + task.error?.code ?? "", + task.error?.message ?? "", + ] + .join(" ") + .toLowerCase(); + return searchableText.includes(keyword); + }); + }, [allTasks, globalPluginFilter, taskSearchKeyword, effectiveTimeBounds]); + + const totalTaskPages = Math.max( + 1, + Math.ceil(diagnosticTasks.length / taskPageSize), + ); + + const pagedDiagnosticTasks = useMemo(() => { + const safePage = Math.min(Math.max(taskPage, 1), totalTaskPages); + const offset = (safePage - 1) * taskPageSize; + return diagnosticTasks.slice(offset, offset + taskPageSize); + }, [diagnosticTasks, taskPage, taskPageSize, totalTaskPages]); + + const taskRangeStart = + diagnosticTasks.length === 0 ? 0 : (taskPage - 1) * taskPageSize + 1; + const taskRangeEnd = Math.min( + taskPage * taskPageSize, + diagnosticTasks.length, + ); + + useEffect(() => { + setTaskPage(1); + }, [ + taskFilter, + globalPluginFilter, + taskSearchKeyword, + taskPageSize, + timeRangeFilter, + customStartTime, + customEndTime, + ]); + + useEffect(() => { + if (taskPage > totalTaskPages) { + setTaskPage(totalTaskPages); + } + }, [taskPage, totalTaskPages]); + + useEffect(() => { + if (!selectedTaskDetail) { + return; + } + const exists = diagnosticTasks.some( + (task) => task.taskId === selectedTaskDetail.taskId, + ); + if (!exists) { + setSelectedTaskDetail(null); + } + }, [diagnosticTasks, selectedTaskDetail]); + + const goPrevPage = () => { + setTaskPage((previous) => Math.max(1, previous - 1)); + }; + + const goNextPage = () => { + setTaskPage((previous) => Math.min(totalTaskPages, previous + 1)); + }; + + const visiblePages = useMemo(() => { + const pages: number[] = []; + const start = Math.max(1, taskPage - 1); + const end = Math.min(totalTaskPages, taskPage + 1); + for (let current = start; current <= end; current += 1) { + pages.push(current); + } + return pages; + }, [taskPage, totalTaskPages]); + if (loading) { return (
@@ -326,6 +1540,10 @@ export function PluginManager() { handleTogglePlugin(plugin.name, plugin.config.enabled) } onUnload={() => handleUnloadPlugin(plugin.name)} + tasks={pluginTasks[plugin.name] || []} + queueStats={queueStats[plugin.name]} + onCancelTask={handleCancelTask} + cancellingTaskId={cancellingTaskId} getStatusIcon={getStatusIcon} getStatusText={getStatusText} /> @@ -334,6 +1552,546 @@ export function PluginManager() { )}
+ {/* 运行诊断 */} +
+
+

运行诊断

+
+ + +
+
+
+
+ + + + + +
+ +
+
+ {timeRangeFilter === "custom" && ( +
+
+ {QUICK_CUSTOM_RANGE_MINUTES.map((minutes) => ( + + ))} + + + + +
+
+ {lastAppliedCustomStartTime && lastAppliedCustomEndTime + ? `上次区间:${formatTime(lastAppliedCustomStartTime)} - ${formatTime(lastAppliedCustomEndTime)}` + : "上次区间:暂无"} +
+
+ 最近使用: + {lastAppliedCustomUpdatedAt + ? formatTime(lastAppliedCustomUpdatedAt) + : "暂无"} +
+ + {filteredCustomRangeHistory.length > 0 && ( +
+ {filteredCustomRangeHistory.map((item, index) => ( +
+ + {(item.pinned ? "★ " : "") + + (item.label + ? `${item.label}:${formatTime(item.start)} ~ ${formatTime(item.end)}` + : `${formatTime(item.start)} ~ ${formatTime(item.end)}`)} + +
+ + + +
+
+ ))} +
+ )} + {filteredCustomRangeHistory.length === 0 && + customRangeHistory.length > 0 && ( +
+ {customRangeHistoryEmptyHintText} +
+ )} +
+ + +
+
+ {customRangeHintText} +
+
+ )} + + {diagnosticTasks.length === 0 ? ( +
+ 当前筛选下暂无任务记录 +
+ ) : ( +
+
+ + + + + + + + + + + + + + {pagedDiagnosticTasks.map((task) => ( + + + + + + + + + + ))} + +
插件操作状态尝试开始时间耗时操作
{task.pluginId}{task.operation} + + {getTaskStateLabel(task.state)} + + + {task.attempt}/{task.maxRetries + 1} + + {formatTime(task.startedAt)} + {task.durationMs ?? 0}ms +
+ + {canCancelTask(task) && ( + + )} +
+
+
+
+
+ 显示 {taskRangeStart}-{taskRangeEnd} /{" "} + {diagnosticTasks.length} +
+
+ + {visiblePages.map((page) => ( + + ))} + +
+
+
+ )} + + {selectedTaskDetail && ( +
+
+
任务详情
+ +
+
+
任务ID: {selectedTaskDetail.taskId}
+
插件: {selectedTaskDetail.pluginId}
+
操作: {selectedTaskDetail.operation}
+
状态: {getTaskStateLabel(selectedTaskDetail.state)}
+
+ 尝试: {selectedTaskDetail.attempt}/ + {selectedTaskDetail.maxRetries + 1} +
+
耗时: {selectedTaskDetail.durationMs ?? 0}ms
+
开始: {formatTime(selectedTaskDetail.startedAt)}
+
结束: {formatTime(selectedTaskDetail.endedAt)}
+
+ {selectedTaskDetail.error?.message && ( +
+ 错误: {selectedTaskDetail.error.message} +
+ )} +
+ )} +
+
+ {/* 安装对话框 */} void; onToggleEnabled: () => void; onUnload: () => void; + tasks: PluginTaskRecord[]; + queueStats?: PluginQueueStats; + onCancelTask: (taskId: string) => Promise; + cancellingTaskId: string | null; getStatusIcon: (status: string) => React.ReactNode; getStatusText: (status: string) => string; } @@ -370,6 +2132,10 @@ function PluginItem({ onToggleExpand, onToggleEnabled, onUnload, + tasks, + queueStats, + onCancelTask, + cancellingTaskId, getStatusIcon, getStatusText, }: PluginItemProps) { @@ -380,6 +2146,7 @@ function PluginItem({ + + )} + + ))} + + )} + )} diff --git a/src/components/plugins/README.md b/src/components/plugins/README.md index 24c26e149..90b39d9a8 100644 --- a/src/components/plugins/README.md +++ b/src/components/plugins/README.md @@ -4,42 +4,67 @@ ## 文件索引 -| 文件 | 说明 | -|------|------| -| `PluginsPage.tsx` | 插件中心页面,独立的导航栏入口 | -| `PluginManager.tsx` | 插件管理主组件,显示插件列表和状态 | -| `PluginInstallDialog.tsx` | 插件安装对话框,支持本地文件和 URL 安装 | -| `PluginUninstallDialog.tsx` | 插件卸载确认对话框 | -| `PluginUIRenderer.tsx` | 插件 UI 渲染器,根据 pluginId 渲染对应的插件 UI | +| 文件 | 说明 | +| --------------------------- | --------------------------------------------------- | +| `PluginsPage.tsx` | 插件中心页面,独立的导航栏入口 | +| `PluginManager.tsx` | 插件管理主组件,显示插件列表和状态 | +| `PluginInstallDialog.tsx` | 插件安装对话框,支持本地文件和 URL 安装 | +| `PluginUninstallDialog.tsx` | 插件卸载确认对话框 | +| `PluginUIRenderer.tsx` | 插件 UI 渲染器,根据 pluginId 渲染对应的插件 UI | | `PluginItemContextMenu.tsx` | 插件项右键菜单,支持启用/禁用、打开目录、卸载等操作 | -| `index.ts` | 模块导出 | +| `index.ts` | 模块导出 | ## 功能说明 ### PluginManager + - 显示插件系统状态概览 - 列出已加载的插件和已安装的插件包 - 提供安装/卸载入口 - 支持启用/禁用插件 +- 提供运行诊断面板(任务筛选、搜索、分页、详情、取消) +- 支持时间范围筛选(1h / 24h / 7d / 自定义区间)与一键重置筛选 +- 自定义区间支持快捷按钮(近 15/30/60 分钟)与区间提示文案 +- 自定义区间支持记忆最近一次有效区间并一键应用 +- 支持显示上次区间最近使用时间,并可一键复制区间 +- 复制区间具备降级兜底(Clipboard API 不可用时自动回退) +- 支持一键复制结构化区间 JSON(start/end/updatedAt) +- 支持保存最近 5 条自定义区间历史并快速回填 +- 支持删除单条历史区间与一键清空历史 +- 支持为历史区间命名标签,便于快速识别与复用 +- 支持收藏历史区间并在列表/下拉中置顶展示 +- 历史区间支持按标签搜索与排序(最近使用/标签) +- 支持“仅收藏”快速筛选,便于聚焦常用区间 +- 支持“收藏置顶”开关,允许切换为纯时间/标签排序 +- 支持历史区间“视图模式”菜单(默认置顶/纯排序/仅收藏) +- 仅收藏模式下提供专属空态提示,提升筛选反馈清晰度 +- 仅收藏模式下统计文案会突出“仅收藏匹配数” +- 支持历史区间 JSON 导入/导出(剪贴板) +- 支持将当前筛选任务导出为 CSV +- 自动持久化运行诊断筛选条件(localStorage) ### PluginInstallDialog + - 支持从本地文件安装(.zip, .tar.gz) - 支持从 URL 下载安装(GitHub Releases 等) - 显示安装进度(下载、验证、解压、安装、注册) - 显示安装结果 ### PluginUninstallDialog + - 显示插件信息确认 - 调用后端卸载命令 - 刷新插件列表 ### PluginUIRenderer + - 根据 pluginId 渲染对应的插件 UI 组件 - 支持内置插件组件映射 (machine-id-tool -> MachineIdTool) - 显示友好的错误提示(插件未找到、加载失败) - 导出 Page 类型定义,支持动态插件路由 ### PluginItemContextMenu + - 为已安装插件列表提供右键菜单 - 支持启用/禁用插件 - 支持打开插件目录 diff --git a/src/components/workspace/WorkbenchPage.test.tsx b/src/components/workspace/WorkbenchPage.test.tsx index 41058517c..f4a3024f2 100644 --- a/src/components/workspace/WorkbenchPage.test.tsx +++ b/src/components/workspace/WorkbenchPage.test.tsx @@ -1,7 +1,28 @@ -import { act, type ComponentProps } from "react"; -import { createRoot, type Root } from "react-dom/client"; +import type { ComponentProps } from "react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { useWorkbenchStore } from "@/stores/useWorkbenchStore"; +import { + clickButtonByText, + clickButtonByTitle, + clickElement, + cleanupMountedRoots, + findAsideByClassFragment, + findButtonByText, + findButtonByTitle, + findInputById, + findInputByPlaceholder, + fillTextInput, + flushEffects as flushAsyncEffects, + mountHarness, + setupReactActEnvironment, + triggerKeyboardShortcut, + type MountedRoot, +} from "./hooks/testUtils"; +import { + createWorkspaceContentFixture, + createWorkspaceProjectFixture, + DEFAULT_WORKSPACE_PAGE_PROPS, +} from "./testFixtures"; const { mockListProjects, @@ -72,81 +93,90 @@ vi.mock("@/lib/api/project", () => ({ import { WorkbenchPage } from "./WorkbenchPage"; -interface RenderResult { - container: HTMLDivElement; - root: Root; -} - -const mountedRoots: Array<{ container: HTMLDivElement; root: Root }> = []; +const mountedRoots: MountedRoot[] = []; function renderPage( props: Partial> = {}, -): RenderResult { - const container = document.createElement("div"); - document.body.appendChild(container); - const root = createRoot(container); +) { + return mountHarness( + WorkbenchPage, + { theme: "social-media", ...props }, + mountedRoots, + ); +} - act(() => { - root.render(); +function renderDefaultWorkspacePage( + props: Partial> = {}, +) { + return renderPage({ + ...DEFAULT_WORKSPACE_PAGE_PROPS, + ...props, }); - - mountedRoots.push({ container, root }); - return { container, root }; } async function flushEffects(times = 3): Promise { - for (let i = 0; i < times; i += 1) { - await act(async () => { - await Promise.resolve(); - }); - } + await flushAsyncEffects(times); } -function getLeftSidebar(container: HTMLElement): HTMLElement | null { - const matched = Array.from(container.querySelectorAll("aside")).find((aside) => - aside.className.includes("bg-muted/20"), - ); - return (matched as HTMLElement | undefined) ?? null; +async function enterDefaultWorkspace(options?: { + expandSidebar?: boolean; +}): Promise<{ container: HTMLDivElement }> { + const rendered = renderDefaultWorkspacePage(); + await flushEffects(); + + if (options?.expandSidebar) { + triggerKeyboardShortcut(window, "b", { ctrlKey: true }); + await flushEffects(); + } + + return { container: rendered.container }; +} + +function expectAgentWorkspaceVisible(container: HTMLElement): void { + expect(container.querySelector("[data-testid='agent-chat-page']")).not.toBeNull(); +} + +function expectWorkspaceNavigationVisible(container: HTMLElement): void { + expect(container.textContent).toContain("创作"); + expect(container.textContent).toContain("发布"); +} + +async function enterProjectManagementFromWorkspace( + container: HTMLElement, +): Promise { + const managementButton = findButtonByText(container, "项目管理"); + expect(managementButton).toBeDefined(); + clickButtonByText(container, "项目管理"); + await flushEffects(); +} + +function expectProjectManagementLandingVisible(container: HTMLElement): void { + expect(container.textContent).toContain("统一创作工作区"); + expect(container.textContent).toContain("进入创作"); } beforeEach(() => { - ( - globalThis as typeof globalThis & { - IS_REACT_ACT_ENVIRONMENT?: boolean; - } - ).IS_REACT_ACT_ENVIRONMENT = true; + setupReactActEnvironment(); localStorage.clear(); vi.clearAllMocks(); useWorkbenchStore.getState().setLeftSidebarCollapsed(true); mockListProjects.mockResolvedValue([ - { + createWorkspaceProjectFixture({ id: "project-1", name: "社媒项目A", workspaceType: "social-media", rootPath: "/tmp/workspace/project-1", - isDefault: false, - createdAt: Date.now(), - updatedAt: Date.now(), - isFavorite: false, - isArchived: false, - tags: [], - }, + }), ]); mockListContents.mockResolvedValue([ - { + createWorkspaceContentFixture({ id: "content-1", project_id: "project-1", title: "文稿A", - content_type: "post", - status: "draft", - order: 0, - word_count: 0, - created_at: Date.now(), - updated_at: Date.now(), - }, + }), ]); mockGetContent.mockResolvedValue({ @@ -156,16 +186,7 @@ beforeEach(() => { }); afterEach(() => { - while (mountedRoots.length > 0) { - const mounted = mountedRoots.pop(); - if (!mounted) { - break; - } - act(() => { - mounted.root.unmount(); - }); - mounted.container.remove(); - } + cleanupMountedRoots(mountedRoots); localStorage.clear(); }); @@ -174,7 +195,7 @@ describe("WorkbenchPage 左侧栏模式行为", () => { const { container } = renderPage({ viewMode: "project-management" }); await flushEffects(); - const leftSidebar = getLeftSidebar(container); + const leftSidebar = findAsideByClassFragment(container, "bg-muted/20"); expect(leftSidebar).not.toBeNull(); expect(leftSidebar?.className).toContain("w-[260px]"); expect(container.textContent).toContain("主题项目管理"); @@ -184,118 +205,54 @@ describe("WorkbenchPage 左侧栏模式行为", () => { const { container } = renderPage({ viewMode: "project-management" }); await flushEffects(); - const projectButton = Array.from(container.querySelectorAll("button")).find( - (button) => button.textContent?.includes("社媒项目A"), - ); + const projectButton = findButtonByText(container, "社媒项目A"); expect(projectButton).toBeDefined(); - - act(() => { - projectButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); - }); + clickButtonByText(container, "社媒项目A"); await flushEffects(); - expect(container.querySelector("[data-testid='agent-chat-page']")).not.toBeNull(); - expect(container.textContent).toContain("创作"); - expect(container.textContent).toContain("发布"); + expectAgentWorkspaceVisible(container); + expectWorkspaceNavigationVisible(container); }); it("作业模式默认收起左侧栏", async () => { - const { container } = renderPage({ - viewMode: "workspace", - projectId: "project-1", - contentId: "content-1", - }); - await flushEffects(); + const { container } = await enterDefaultWorkspace(); - expect(getLeftSidebar(container)).toBeNull(); + expect(findAsideByClassFragment(container, "bg-muted/20")).toBeNull(); expect(container.textContent).not.toContain("主题项目管理"); }); it("作业模式展开侧栏后点击项目保持在统一工作区", async () => { - const { container } = renderPage({ - viewMode: "workspace", - projectId: "project-1", - contentId: "content-1", - }); - await flushEffects(); + const { container } = await enterDefaultWorkspace({ expandSidebar: true }); - act(() => { - window.dispatchEvent( - new KeyboardEvent("keydown", { - key: "b", - ctrlKey: true, - bubbles: true, - }), - ); - }); - await flushEffects(); - - const projectButton = Array.from(container.querySelectorAll("button")).find( - (button) => button.textContent?.includes("社媒项目A"), - ); + const projectButton = findButtonByText(container, "社媒项目A"); expect(projectButton).toBeDefined(); - - act(() => { - projectButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); - }); + clickButtonByText(container, "社媒项目A"); await flushEffects(); - expect(container.querySelector("[data-testid='agent-chat-page']")).not.toBeNull(); + expectAgentWorkspaceVisible(container); }); it("工作区点击项目管理后回到项目管理态", async () => { - const { container } = renderPage({ - viewMode: "workspace", - projectId: "project-1", - contentId: "content-1", - }); - await flushEffects(); + const { container } = await enterDefaultWorkspace(); - const managementButton = Array.from(container.querySelectorAll("button")).find( - (button) => button.textContent?.includes("项目管理"), - ); - expect(managementButton).toBeDefined(); - - act(() => { - managementButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); - }); - await flushEffects(); - - expect(container.textContent).toContain("统一创作工作区"); - expect(container.textContent).toContain("进入创作"); + await enterProjectManagementFromWorkspace(container); + expectProjectManagementLandingVisible(container); }); it("工作区点击项目管理后自动展开左侧栏", async () => { - const { container } = renderPage({ - viewMode: "workspace", - projectId: "project-1", - contentId: "content-1", - }); - await flushEffects(); + const { container } = await enterDefaultWorkspace(); - const managementButton = Array.from(container.querySelectorAll("button")).find( - (button) => button.textContent?.includes("项目管理"), - ); - expect(managementButton).not.toBeUndefined(); + await enterProjectManagementFromWorkspace(container); - act(() => { - managementButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); - }); - await flushEffects(); - - const leftSidebar = getLeftSidebar(container); + const leftSidebar = findAsideByClassFragment(container, "bg-muted/20"); expect(leftSidebar).not.toBeNull(); expect(leftSidebar?.className).toContain("w-[260px]"); + expectProjectManagementLandingVisible(container); expect(container.textContent).toContain("主题项目管理"); }); it("统一工作区中的聊天页隐藏内部顶部栏,避免双导航", async () => { - const { container } = renderPage({ - viewMode: "workspace", - projectId: "project-1", - contentId: "content-1", - }); - await flushEffects(); + const { container } = await enterDefaultWorkspace(); const chat = container.querySelector("[data-testid='agent-chat-page']"); expect(chat).not.toBeNull(); @@ -304,18 +261,12 @@ describe("WorkbenchPage 左侧栏模式行为", () => { it("视频主题在作业模式渲染主题工作区而非对话工作区", async () => { mockListProjects.mockResolvedValueOnce([ - { + createWorkspaceProjectFixture({ id: "video-project-1", name: "视频项目A", workspaceType: "video", rootPath: "/tmp/workspace/video-project-1", - isDefault: false, - createdAt: Date.now(), - updatedAt: Date.now(), - isFavorite: false, - isArchived: false, - tags: [], - }, + }), ]); const { container } = renderPage({ @@ -333,193 +284,91 @@ describe("WorkbenchPage 左侧栏模式行为", () => { }); it("切换到非创作视图时左侧显示紧凑提示并可返回创作视图", async () => { - const { container } = renderPage({ - viewMode: "workspace", - projectId: "project-1", - contentId: "content-1", - }); - await flushEffects(); + const { container } = await enterDefaultWorkspace({ expandSidebar: true }); - act(() => { - window.dispatchEvent( - new KeyboardEvent("keydown", { - key: "b", - ctrlKey: true, - bubbles: true, - }), - ); - }); - await flushEffects(); - - const publishButton = Array.from(container.querySelectorAll("button")).find( - (button) => button.textContent?.trim() === "发布", - ); + const publishButton = findButtonByText(container, "发布", { exact: true }); expect(publishButton).toBeDefined(); - - act(() => { - publishButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); - }); + clickButtonByText(container, "发布", { exact: true }); await flushEffects(); expect(container.textContent).toContain("当前处于「发布」视图"); expect(container.textContent).toContain("当前文稿:文稿A"); expect(container.textContent).toContain("返回创作视图"); - expect(container.querySelector("input[placeholder='搜索文稿...']")).toBeNull(); + expect(findInputByPlaceholder(container, "搜索文稿...")).toBeNull(); - const openViewActionsButton = container.querySelector( - "button[title='展开视图动作']", - ); + const openViewActionsButton = findButtonByTitle(container, "展开视图动作"); expect(openViewActionsButton).not.toBeNull(); - act(() => { - openViewActionsButton?.dispatchEvent( - new MouseEvent("click", { bubbles: true }), - ); - }); + clickElement(openViewActionsButton); await flushEffects(); expect(container.textContent).toContain("视图动作"); expect(container.textContent).toContain("前往设置视图"); - const backToCreateButton = Array.from(container.querySelectorAll("button")).find( - (button) => button.textContent?.trim() === "返回创作视图", - ); - expect(backToCreateButton).toBeDefined(); - - act(() => { - backToCreateButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + const backToCreateButton = findButtonByText(container, "返回创作视图", { + exact: true, }); + expect(backToCreateButton).toBeDefined(); + clickButtonByText(container, "返回创作视图", { exact: true }); await flushEffects(); - expect(container.querySelector("input[placeholder='搜索文稿...']")).not.toBeNull(); + expect(findInputByPlaceholder(container, "搜索文稿...")).not.toBeNull(); }); it("创建项目后保持选中新项目且重置项目搜索", async () => { - mockListProjects - .mockResolvedValueOnce([ - { - id: "project-1", - name: "社媒项目A", - workspaceType: "social-media", - rootPath: "/tmp/workspace/project-1", - isDefault: false, - createdAt: Date.now(), - updatedAt: Date.now(), - isFavorite: false, - isArchived: false, - tags: [], - }, - ]) - .mockResolvedValueOnce([ - { - id: "project-1", - name: "社媒项目A", - workspaceType: "social-media", - rootPath: "/tmp/workspace/project-1", - isDefault: false, - createdAt: Date.now(), - updatedAt: Date.now(), - isFavorite: false, - isArchived: false, - tags: [], - }, - { - id: "project-2", - name: "新项目B", - workspaceType: "social-media", - rootPath: "/tmp/workspace/新项目B", - isDefault: false, - createdAt: Date.now(), - updatedAt: Date.now(), - isFavorite: false, - isArchived: false, - tags: [], - }, - ]); - mockCreateProject.mockResolvedValue({ + const baseProject = createWorkspaceProjectFixture({ + id: "project-1", + name: "社媒项目A", + workspaceType: "social-media", + rootPath: "/tmp/workspace/project-1", + }); + const createdProject = createWorkspaceProjectFixture({ id: "project-2", name: "新项目B", workspaceType: "social-media", rootPath: "/tmp/workspace/新项目B", - isDefault: false, - createdAt: Date.now(), - updatedAt: Date.now(), - isFavorite: false, - isArchived: false, - tags: [], }); - const { container } = renderPage({ - viewMode: "workspace", - projectId: "project-1", - contentId: "content-1", - }); - await flushEffects(); + mockListProjects + .mockResolvedValueOnce([baseProject]) + .mockResolvedValueOnce([baseProject, createdProject]); + mockCreateProject.mockResolvedValue(createdProject); - act(() => { - window.dispatchEvent( - new KeyboardEvent("keydown", { - key: "b", - ctrlKey: true, - bubbles: true, - }), - ); - }); - await flushEffects(); + const { container } = await enterDefaultWorkspace({ expandSidebar: true }); - const projectSearchInput = container.querySelector( - "input[placeholder='搜索项目...']", + const projectSearchInput = findInputByPlaceholder( + container, + "搜索项目...", ) as HTMLInputElement | null; expect(projectSearchInput).not.toBeNull(); - act(() => { - if (!projectSearchInput) { - return; - } - projectSearchInput.value = "关键字"; - projectSearchInput.dispatchEvent(new Event("input", { bubbles: true })); - }); + fillTextInput(projectSearchInput, "关键字"); await flushEffects(); expect(projectSearchInput?.value).toBe("关键字"); - const createProjectButton = container.querySelector("button[title='新建项目']"); + const createProjectButton = findButtonByTitle(container, "新建项目"); expect(createProjectButton).not.toBeNull(); - act(() => { - createProjectButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); - }); + clickButtonByTitle(container, "新建项目"); await flushEffects(); - const projectNameInput = document.querySelector( - "#workspace-project-name", + const projectNameInput = findInputById( + document, + "workspace-project-name", ) as HTMLInputElement | null; expect(projectNameInput).not.toBeNull(); - act(() => { - if (!projectNameInput) { - return; - } - projectNameInput.value = "新项目B"; - projectNameInput.dispatchEvent(new Event("input", { bubbles: true })); - }); + fillTextInput(projectNameInput, "新项目B"); await flushEffects(); - const createButton = Array.from(document.querySelectorAll("button")).find( - (button) => button.textContent?.trim() === "创建项目", - ); + const createButton = findButtonByText(document, "创建项目", { exact: true }); expect(createButton).toBeDefined(); - act(() => { - createButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); - }); + clickButtonByText(document, "创建项目", { exact: true }); await flushEffects(5); expect(mockCreateProject).toHaveBeenCalled(); expect(mockListContents).toHaveBeenCalledWith("project-2"); expect(projectSearchInput?.value).toBe(""); - const newProjectEntry = Array.from(container.querySelectorAll("button")).find( - (button) => button.textContent?.includes("新项目B"), - ); - const oldProjectEntry = Array.from(container.querySelectorAll("button")).find( - (button) => button.textContent?.includes("社媒项目A"), - ); + const newProjectEntry = findButtonByText(container, "新项目B"); + const oldProjectEntry = findButtonByText(container, "社媒项目A"); expect(newProjectEntry).toBeDefined(); expect(newProjectEntry?.className).toContain("bg-accent text-accent-foreground"); expect(oldProjectEntry).toBeDefined(); diff --git a/src/components/workspace/dialogs/WorkbenchCreateContentDialog.test.tsx b/src/components/workspace/dialogs/WorkbenchCreateContentDialog.test.tsx index 10ec15cb7..9b18c866a 100644 --- a/src/components/workspace/dialogs/WorkbenchCreateContentDialog.test.tsx +++ b/src/components/workspace/dialogs/WorkbenchCreateContentDialog.test.tsx @@ -1,101 +1,83 @@ -import { act, type ComponentProps } from "react"; -import { createRoot, type Root } from "react-dom/client"; +import type { ComponentProps } from "react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { WorkbenchCreateContentDialog } from "./WorkbenchCreateContentDialog"; +import { + clickButtonByText, + findButtonByText, + cleanupMountedRoots, + findInputById, + fillTextInput, + mountHarness, + setupReactActEnvironment, + type MountedRoot, +} from "../hooks/testUtils"; -interface RenderResult { - container: HTMLDivElement; - root: Root; -} +const mountedRoots: MountedRoot[] = []; -const mountedRoots: RenderResult[] = []; +type ContentDialogProps = ComponentProps; -function setInputValue(input: HTMLInputElement, value: string): void { - const setter = Object.getOwnPropertyDescriptor( - window.HTMLInputElement.prototype, - "value", - )?.set; - setter?.call(input, value); - input.dispatchEvent(new Event("input", { bubbles: true })); +function createDialogProps( + overrides: Partial = {}, +): ContentDialogProps { + return { + open: true, + creatingContent: false, + step: "mode", + selectedProjectId: "project-1", + creationModeOptions: [ + { value: "guided", label: "引导模式", description: "分步骤提问" }, + { value: "fast", label: "快速模式", description: "快速起稿" }, + ], + selectedCreationMode: "guided", + onCreationModeChange: () => {}, + currentCreationIntentFields: [ + { + key: "topic", + label: "创作主题", + placeholder: "请输入主题", + }, + ], + creationIntentValues: { + topic: "", + targetAudience: "", + goal: "", + constraints: "", + contentType: "", + length: "", + corePoints: "", + tone: "", + outline: "", + mustInclude: "", + extraRequirements: "", + }, + onCreationIntentValueChange: () => {}, + currentIntentLength: 0, + minCreationIntentLength: 10, + creationIntentError: "", + onOpenChange: () => {}, + onBackOrCancel: () => {}, + onGoToIntentStep: () => {}, + onCreateContent: () => {}, + ...overrides, + }; } function renderDialog( - overrides: Partial> = {}, -): RenderResult { - const container = document.createElement("div"); - document.body.appendChild(container); - const root = createRoot(container); - - act(() => { - root.render( - {}} - currentCreationIntentFields={[ - { - key: "topic", - label: "创作主题", - placeholder: "请输入主题", - }, - ]} - creationIntentValues={{ - topic: "", - targetAudience: "", - goal: "", - constraints: "", - contentType: "", - length: "", - corePoints: "", - tone: "", - outline: "", - mustInclude: "", - extraRequirements: "", - }} - onCreationIntentValueChange={() => {}} - currentIntentLength={0} - minCreationIntentLength={10} - creationIntentError="" - onOpenChange={() => {}} - onBackOrCancel={() => {}} - onGoToIntentStep={() => {}} - onCreateContent={() => {}} - {...overrides} - />, - ); - }); - - const rendered = { container, root }; - mountedRoots.push(rendered); - return rendered; + overrides: Partial = {}, +) { + return mountHarness( + WorkbenchCreateContentDialog, + createDialogProps(overrides), + mountedRoots, + ); } beforeEach(() => { - ( - globalThis as typeof globalThis & { - IS_REACT_ACT_ENVIRONMENT?: boolean; - } - ).IS_REACT_ACT_ENVIRONMENT = true; + setupReactActEnvironment(); }); afterEach(() => { - while (mountedRoots.length > 0) { - const mounted = mountedRoots.pop(); - if (!mounted) { - break; - } - act(() => { - mounted.root.unmount(); - }); - mounted.container.remove(); - } + cleanupMountedRoots(mountedRoots); }); describe("WorkbenchCreateContentDialog", () => { @@ -107,21 +89,13 @@ describe("WorkbenchCreateContentDialog", () => { expect(document.body.textContent).toContain("步骤 1/2"); expect(document.body.textContent).toContain("引导模式"); - const fastModeButton = Array.from(document.body.querySelectorAll("button")).find( - (button) => button.textContent?.includes("快速模式"), - ); - const nextButton = Array.from(document.body.querySelectorAll("button")).find( - (button) => button.textContent?.trim() === "下一步", - ); + const fastModeButton = findButtonByText(document.body, "快速模式"); + const nextButton = findButtonByText(document.body, "下一步", { exact: true }); expect(fastModeButton).toBeDefined(); expect(nextButton).toBeDefined(); - act(() => { - fastModeButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); - }); - act(() => { - nextButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); - }); + clickButtonByText(document.body, "快速模式"); + clickButtonByText(document.body, "下一步", { exact: true }); expect(onCreationModeChange).toHaveBeenCalledWith("fast"); expect(onGoToIntentStep).toHaveBeenCalledTimes(1); @@ -139,9 +113,9 @@ describe("WorkbenchCreateContentDialog", () => { expect(document.body.textContent).toContain("创作意图字数:6/10"); expect(document.body.textContent).toContain("创作意图至少需要 10 个字"); - const createButton = Array.from(document.body.querySelectorAll("button")).find( - (button) => button.textContent?.trim() === "创建并进入作业", - ); + const createButton = findButtonByText(document.body, "创建并进入作业", { + exact: true, + }); expect(createButton).toBeDefined(); expect(createButton).toHaveProperty("disabled", true); }); @@ -158,33 +132,22 @@ describe("WorkbenchCreateContentDialog", () => { onCreateContent, }); - const topicInput = document.body.querySelector( - "input#creation-intent-topic", + const topicInput = findInputById( + document.body, + "creation-intent-topic", ) as HTMLInputElement | null; expect(topicInput).not.toBeNull(); + fillTextInput(topicInput, "新的主题"); - act(() => { - if (!topicInput) { - return; - } - setInputValue(topicInput, "新的主题"); + const backButton = findButtonByText(document.body, "上一步", { exact: true }); + const createButton = findButtonByText(document.body, "创建并进入作业", { + exact: true, }); - - const backButton = Array.from(document.body.querySelectorAll("button")).find( - (button) => button.textContent?.trim() === "上一步", - ); - const createButton = Array.from(document.body.querySelectorAll("button")).find( - (button) => button.textContent?.trim() === "创建并进入作业", - ); expect(backButton).toBeDefined(); expect(createButton).toBeDefined(); - act(() => { - backButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); - }); - act(() => { - createButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); - }); + clickButtonByText(document.body, "上一步", { exact: true }); + clickButtonByText(document.body, "创建并进入作业", { exact: true }); expect(onCreationIntentValueChange).toHaveBeenCalledWith("topic", "新的主题"); expect(onBackOrCancel).toHaveBeenCalledTimes(1); diff --git a/src/components/workspace/dialogs/WorkbenchCreateProjectDialog.test.tsx b/src/components/workspace/dialogs/WorkbenchCreateProjectDialog.test.tsx index c8b9be848..1fe961531 100644 --- a/src/components/workspace/dialogs/WorkbenchCreateProjectDialog.test.tsx +++ b/src/components/workspace/dialogs/WorkbenchCreateProjectDialog.test.tsx @@ -1,74 +1,56 @@ -import { act, type ComponentProps } from "react"; -import { createRoot, type Root } from "react-dom/client"; +import type { ComponentProps } from "react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { WorkbenchCreateProjectDialog } from "./WorkbenchCreateProjectDialog"; +import { + clickButtonByText, + findButtonByText, + cleanupMountedRoots, + findInputById, + fillTextInput, + mountHarness, + setupReactActEnvironment, + type MountedRoot, +} from "../hooks/testUtils"; -interface RenderResult { - container: HTMLDivElement; - root: Root; -} +const mountedRoots: MountedRoot[] = []; -const mountedRoots: RenderResult[] = []; +type ProjectDialogProps = ComponentProps; -function setInputValue(input: HTMLInputElement, value: string): void { - const setter = Object.getOwnPropertyDescriptor( - window.HTMLInputElement.prototype, - "value", - )?.set; - setter?.call(input, value); - input.dispatchEvent(new Event("input", { bubbles: true })); +function createDialogProps( + overrides: Partial = {}, +): ProjectDialogProps { + return { + open: true, + creatingProject: false, + newProjectName: "小说项目A", + projectTypeLabel: "小说创作", + workspaceProjectsRoot: "/tmp/workspace", + resolvedProjectPath: "/tmp/workspace/小说项目A", + pathChecking: false, + pathConflictMessage: "", + onOpenChange: () => {}, + onProjectNameChange: () => {}, + onCreateProject: () => {}, + ...overrides, + }; } function renderDialog( - overrides: Partial> = {}, -): RenderResult { - const container = document.createElement("div"); - document.body.appendChild(container); - const root = createRoot(container); - - act(() => { - root.render( - {}} - onProjectNameChange={() => {}} - onCreateProject={() => {}} - {...overrides} - />, - ); - }); - - const rendered = { container, root }; - mountedRoots.push(rendered); - return rendered; + overrides: Partial = {}, +) { + return mountHarness( + WorkbenchCreateProjectDialog, + createDialogProps(overrides), + mountedRoots, + ); } beforeEach(() => { - ( - globalThis as typeof globalThis & { - IS_REACT_ACT_ENVIRONMENT?: boolean; - } - ).IS_REACT_ACT_ENVIRONMENT = true; + setupReactActEnvironment(); }); afterEach(() => { - while (mountedRoots.length > 0) { - const mounted = mountedRoots.pop(); - if (!mounted) { - break; - } - act(() => { - mounted.root.unmount(); - }); - mounted.container.remove(); - } + cleanupMountedRoots(mountedRoots); }); describe("WorkbenchCreateProjectDialog", () => { @@ -79,22 +61,18 @@ describe("WorkbenchCreateProjectDialog", () => { expect(document.body.textContent).toContain("新建项目"); expect(document.body.textContent).toContain("/tmp/workspace/小说项目A"); - const projectTypeInput = document.body.querySelector( - "input#workspace-project-type", + const projectTypeInput = findInputById( + document.body, + "workspace-project-type", ) as HTMLInputElement | null; expect(projectTypeInput?.value).toBe("小说创作"); - const projectNameInput = document.body.querySelector( - "input#workspace-project-name", + const projectNameInput = findInputById( + document.body, + "workspace-project-name", ) as HTMLInputElement | null; expect(projectNameInput).not.toBeNull(); - - act(() => { - if (!projectNameInput) { - return; - } - setInputValue(projectNameInput, "小说项目B"); - }); + fillTextInput(projectNameInput, "小说项目B"); expect(onProjectNameChange).toHaveBeenCalledWith("小说项目B"); }); @@ -102,9 +80,9 @@ describe("WorkbenchCreateProjectDialog", () => { it("路径冲突时禁用创建按钮", () => { renderDialog({ pathConflictMessage: "路径已存在项目:冲突项目" }); - const createButton = Array.from(document.body.querySelectorAll("button")).find( - (button) => button.textContent?.trim() === "创建项目", - ); + const createButton = findButtonByText(document.body, "创建项目", { + exact: true, + }); expect(createButton).toBeDefined(); expect(createButton).toHaveProperty("disabled", true); expect(document.body.textContent).toContain("路径已存在项目:冲突项目"); @@ -115,21 +93,15 @@ describe("WorkbenchCreateProjectDialog", () => { const onCreateProject = vi.fn(); renderDialog({ onOpenChange, onCreateProject }); - const cancelButton = Array.from(document.body.querySelectorAll("button")).find( - (button) => button.textContent?.trim() === "取消", - ); - const createButton = Array.from(document.body.querySelectorAll("button")).find( - (button) => button.textContent?.trim() === "创建项目", - ); + const cancelButton = findButtonByText(document.body, "取消", { exact: true }); + const createButton = findButtonByText(document.body, "创建项目", { + exact: true, + }); expect(cancelButton).toBeDefined(); expect(createButton).toBeDefined(); - act(() => { - cancelButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); - }); - act(() => { - createButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); - }); + clickButtonByText(document.body, "取消", { exact: true }); + clickButtonByText(document.body, "创建项目", { exact: true }); expect(onOpenChange).toHaveBeenCalledWith(false); expect(onCreateProject).toHaveBeenCalledTimes(1); diff --git a/src/components/workspace/hooks/testUtils.tsx b/src/components/workspace/hooks/testUtils.tsx new file mode 100644 index 000000000..38e511f2a --- /dev/null +++ b/src/components/workspace/hooks/testUtils.tsx @@ -0,0 +1,253 @@ +import { act, createElement, type ComponentType } from "react"; +import { createRoot, type Root } from "react-dom/client"; + +export interface MountedRoot { + container: HTMLDivElement; + root: Root; +} + +export interface MountedRenderResult extends MountedRoot { + rerender: (props: TProps) => void; +} + +export function setupReactActEnvironment(): void { + ( + globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; + } + ).IS_REACT_ACT_ENVIRONMENT = true; +} + +export function mountHarness( + Component: ComponentType, + initialProps: TProps, + mountedRoots: MountedRoot[], +): MountedRenderResult { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + const rerender = (props: TProps) => { + act(() => { + root.render( + createElement( + Component as ComponentType>, + props as Record, + ), + ); + }); + }; + + rerender(initialProps); + mountedRoots.push({ container, root }); + + return { + container, + root, + rerender, + }; +} + +export function cleanupMountedRoots(mountedRoots: MountedRoot[]): void { + while (mountedRoots.length > 0) { + const mounted = mountedRoots.pop(); + if (!mounted) { + break; + } + act(() => { + mounted.root.unmount(); + }); + mounted.container.remove(); + } +} + +export function clickElement(element: Element | null): void { + act(() => { + element?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); +} + +type QueryScope = { + querySelectorAll: (selectors: string) => ArrayLike; +}; + +export function findButtonByText( + scope: QueryScope, + text: string, + options?: { + exact?: boolean; + }, +): HTMLButtonElement | undefined { + const exact = options?.exact ?? false; + return Array.from(scope.querySelectorAll("button")).find((button) => { + const content = button.textContent?.trim() ?? ""; + return exact ? content === text : content.includes(text); + }) as HTMLButtonElement | undefined; +} + +export function clickButtonByText( + scope: QueryScope, + text: string, + options?: { + exact?: boolean; + }, +): HTMLButtonElement | undefined { + const button = findButtonByText(scope, text, options); + clickElement(button ?? null); + return button; +} + +export function clickByTestId( + container: HTMLElement, + testId: string, +): HTMLButtonElement | null { + const button = container.querySelector( + `button[data-testid='${testId}']`, + ) as HTMLButtonElement | null; + clickElement(button); + return button; +} + +export function getRootElement(container: HTMLElement): HTMLElement | null { + return container.firstElementChild as HTMLElement | null; +} + +export function findInputByPlaceholder( + scope: QueryScope, + placeholder: string, +): HTMLInputElement | HTMLTextAreaElement | null { + const field = Array.from(scope.querySelectorAll("input,textarea")).find( + (element) => + element instanceof HTMLInputElement || + element instanceof HTMLTextAreaElement + ? element.placeholder === placeholder + : false, + ); + if ( + field instanceof HTMLInputElement || + field instanceof HTMLTextAreaElement + ) { + return field; + } + return null; +} + +export function findInputById( + scope: { + querySelector: (selectors: string) => Element | null; + }, + id: string, +): HTMLInputElement | HTMLTextAreaElement | null { + const element = scope.querySelector(`#${id}`); + if ( + element instanceof HTMLInputElement || + element instanceof HTMLTextAreaElement + ) { + return element; + } + return null; +} + +export function findButtonByTitle( + scope: { + querySelector: (selectors: string) => Element | null; + }, + title: string, +): HTMLButtonElement | null { + const element = scope.querySelector(`button[title='${title}']`); + if (element instanceof HTMLButtonElement) { + return element; + } + return null; +} + +export function clickButtonByTitle( + scope: { + querySelector: (selectors: string) => Element | null; + }, + title: string, +): HTMLButtonElement | null { + const button = findButtonByTitle(scope, title); + clickElement(button); + return button; +} + +export function findAsideByClassFragment( + scope: QueryScope, + classFragment: string, +): HTMLElement | null { + const matched = Array.from(scope.querySelectorAll("aside")).find((aside) => + aside.className.includes(classFragment), + ); + if (matched instanceof HTMLElement) { + return matched; + } + return null; +} + +export function setTextInputValue( + element: HTMLInputElement | HTMLTextAreaElement, + value: string, +): void { + const prototype = + element instanceof HTMLTextAreaElement + ? window.HTMLTextAreaElement.prototype + : window.HTMLInputElement.prototype; + const setter = Object.getOwnPropertyDescriptor(prototype, "value")?.set; + setter?.call(element, value); + element.dispatchEvent(new Event("input", { bubbles: true })); +} + +export function fillTextInput( + element: HTMLInputElement | HTMLTextAreaElement | null, + value: string, +): void { + act(() => { + if (!element) { + return; + } + setTextInputValue(element, value); + }); +} + +export function triggerKeyboardShortcut( + target: EventTarget, + key: string, + options?: { + ctrlKey?: boolean; + metaKey?: boolean; + altKey?: boolean; + shiftKey?: boolean; + type?: "keydown" | "keyup"; + bubbles?: boolean; + }, +): void { + const { + type = "keydown", + bubbles = true, + ctrlKey, + metaKey, + altKey, + shiftKey, + } = options ?? {}; + act(() => { + target.dispatchEvent( + new KeyboardEvent(type, { + key, + bubbles, + ctrlKey, + metaKey, + altKey, + shiftKey, + }), + ); + }); +} + +export async function flushEffects(times = 4): Promise { + for (let i = 0; i < times; i += 1) { + await act(async () => { + await Promise.resolve(); + }); + } +} diff --git a/src/components/workspace/hooks/useCreationDialogs.test.tsx b/src/components/workspace/hooks/useCreationDialogs.test.tsx new file mode 100644 index 000000000..150c4dc1e --- /dev/null +++ b/src/components/workspace/hooks/useCreationDialogs.test.tsx @@ -0,0 +1,375 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + type UseCreationDialogsParams, + useCreationDialogs, +} from "./useCreationDialogs"; +import { + cleanupMountedRoots, + clickByTestId, + flushEffects, + getRootElement, + mountHarness, + setupReactActEnvironment, + type MountedRoot, +} from "./testUtils"; + +const { + mockCreateContent, + mockCreateProject, + mockExtractErrorMessage, + mockGetContent, + mockGetContentTypeLabel, + mockGetCreateProjectErrorMessage, + mockGetDefaultContentTypeForProject, + mockGetProjectByRootPath, + mockGetProjectTypeLabel, + mockGetWorkspaceProjectsRoot, + mockResolveProjectRootPath, + mockToastError, + mockToastSuccess, +} = vi.hoisted(() => ({ + mockCreateContent: vi.fn(), + mockCreateProject: vi.fn(), + mockExtractErrorMessage: vi.fn(), + mockGetContent: vi.fn(), + mockGetContentTypeLabel: vi.fn(), + mockGetCreateProjectErrorMessage: vi.fn(), + mockGetDefaultContentTypeForProject: vi.fn(), + mockGetProjectByRootPath: vi.fn(), + mockGetProjectTypeLabel: vi.fn(), + mockGetWorkspaceProjectsRoot: vi.fn(), + mockResolveProjectRootPath: vi.fn(), + mockToastError: vi.fn(), + mockToastSuccess: vi.fn(), +})); + +vi.mock("sonner", () => ({ + toast: { + success: mockToastSuccess, + error: mockToastError, + }, +})); + +vi.mock("@/lib/api/project", () => ({ + createContent: mockCreateContent, + createProject: mockCreateProject, + extractErrorMessage: mockExtractErrorMessage, + getContent: mockGetContent, + getContentTypeLabel: mockGetContentTypeLabel, + getCreateProjectErrorMessage: mockGetCreateProjectErrorMessage, + getDefaultContentTypeForProject: mockGetDefaultContentTypeForProject, + getProjectByRootPath: mockGetProjectByRootPath, + getProjectTypeLabel: mockGetProjectTypeLabel, + getWorkspaceProjectsRoot: mockGetWorkspaceProjectsRoot, + resolveProjectRootPath: mockResolveProjectRootPath, +})); + +type HarnessProps = UseCreationDialogsParams; + +function CreationDialogsHarness(props: HarnessProps) { + const dialogs = useCreationDialogs(props); + + return ( +
+
+ ); +} + +const mountedRoots: MountedRoot[] = []; + +function createHarnessProps(overrides: Partial = {}): HarnessProps { + return { + theme: "social-media", + selectedProjectId: null, + selectedContentId: null, + loadProjects: vi.fn(async () => undefined), + loadContents: vi.fn(async () => undefined), + onEnterWorkspace: vi.fn(), + onProjectCreated: vi.fn(), + defaultCreationMode: "guided", + minCreationIntentLength: 10, + ...overrides, + }; +} + +function renderHarness(props: Partial = {}) { + return mountHarness( + CreationDialogsHarness, + createHarnessProps(props), + mountedRoots, + ); +} + +function click(container: HTMLElement, testId: string): void { + const button = clickByTestId(container, testId); + expect(button).not.toBeNull(); +} + +async function openContentIntentStep(container: HTMLElement): Promise { + click(container, "open-content-dialog"); + await flushEffects(); + click(container, "goto-intent"); + await flushEffects(); +} + +function parseRootDatasetRecord( + root: HTMLElement | null, + key: string, +): Record { + const value = root?.dataset[key] ?? "{}"; + return JSON.parse(value) as Record; +} + +beforeEach(() => { + setupReactActEnvironment(); + + vi.clearAllMocks(); + + mockGetWorkspaceProjectsRoot.mockResolvedValue("/tmp/workspace"); + mockGetProjectTypeLabel.mockReturnValue("社媒内容"); + mockResolveProjectRootPath.mockImplementation( + async (name: string) => `/tmp/workspace/${name}`, + ); + mockGetProjectByRootPath.mockResolvedValue(null); + mockCreateProject.mockResolvedValue({ + id: "project-new", + name: "新项目A", + }); + mockExtractErrorMessage.mockReturnValue("mock-error"); + mockGetCreateProjectErrorMessage.mockReturnValue("mock-friendly-error"); + + mockGetDefaultContentTypeForProject.mockReturnValue("post"); + mockGetContentTypeLabel.mockReturnValue("文稿"); + mockCreateContent.mockResolvedValue({ + id: "content-new", + }); + mockGetContent.mockResolvedValue(null); +}); + +afterEach(() => { + cleanupMountedRoots(mountedRoots); +}); + +describe("useCreationDialogs", () => { + it("创建项目成功后应关闭弹窗并触发回调", async () => { + const loadProjects = vi.fn(async () => undefined); + const onProjectCreated = vi.fn(); + + const { container } = renderHarness({ + loadProjects, + onProjectCreated, + }); + await flushEffects(); + + click(container, "open-project-dialog"); + await flushEffects(); + click(container, "set-project-name-new"); + await flushEffects(); + click(container, "create-project"); + await flushEffects(6); + + expect(mockCreateProject).toHaveBeenCalledWith({ + name: "新项目A", + rootPath: "/tmp/workspace/新项目A", + workspaceType: "social-media", + }); + expect(onProjectCreated).toHaveBeenCalledWith("project-new"); + expect(loadProjects).toHaveBeenCalledTimes(1); + expect(mockToastSuccess).toHaveBeenCalledWith("已创建新项目"); + + const root = getRootElement(container); + expect(root?.dataset.createProjectOpen).toBe("false"); + expect(root?.dataset.creatingProject).toBe("false"); + }); + + it("打开项目弹窗后应检测路径冲突", async () => { + mockGetProjectByRootPath.mockImplementation(async (rootPath: string) => { + if (rootPath.endsWith("/冲突项目")) { + return { id: "project-existing", name: "历史项目" }; + } + return null; + }); + + const { container } = renderHarness(); + await flushEffects(); + + click(container, "open-project-dialog"); + await flushEffects(); + click(container, "set-project-name-conflict"); + await flushEffects(6); + + const root = getRootElement(container); + expect(root?.dataset.resolvedProjectPath).toBe("/tmp/workspace/冲突项目"); + expect(root?.dataset.pathChecking).toBe("false"); + expect(root?.dataset.pathConflictMessage).toBe("路径已存在项目:历史项目"); + expect(mockGetProjectByRootPath).toHaveBeenCalledWith("/tmp/workspace/冲突项目"); + }); + + it("关闭项目弹窗后应重置路径状态", async () => { + mockGetProjectByRootPath.mockImplementation(async (rootPath: string) => { + if (rootPath.endsWith("/冲突项目")) { + return { id: "project-existing", name: "历史项目" }; + } + return null; + }); + + const { container } = renderHarness(); + await flushEffects(); + + click(container, "open-project-dialog"); + await flushEffects(); + click(container, "set-project-name-conflict"); + await flushEffects(6); + + let root = getRootElement(container); + expect(root?.dataset.pathConflictMessage).toBe("路径已存在项目:历史项目"); + expect(root?.dataset.resolvedProjectPath).toBe("/tmp/workspace/冲突项目"); + + click(container, "close-project-dialog"); + await flushEffects(); + + root = getRootElement(container); + expect(root?.dataset.createProjectOpen).toBe("false"); + expect(root?.dataset.pathConflictMessage).toBe(""); + expect(root?.dataset.resolvedProjectPath).toBe(""); + expect(root?.dataset.pathChecking).toBe("false"); + }); + + it("选中文稿且 metadata 含 creationMode 时应回填模式缓存", async () => { + mockGetContent.mockResolvedValueOnce({ + id: "content-existing", + metadata: { + creationMode: "framework", + }, + }); + + const { container } = renderHarness({ + selectedContentId: "content-existing", + }); + await flushEffects(5); + + expect(mockGetContent).toHaveBeenCalledWith("content-existing"); + const root = getRootElement(container); + const contentModes = parseRootDatasetRecord(root, "contentModes"); + expect(contentModes["content-existing"]).toBe("framework"); + }); + + it("创作意图不足时创建文稿应被阻止并提示错误", async () => { + const { container } = renderHarness({ + selectedProjectId: "project-1", + }); + await flushEffects(); + + await openContentIntentStep(container); + click(container, "create-content"); + await flushEffects(); + + const root = getRootElement(container); + expect(root?.dataset.createContentOpen).toBe("true"); + expect(root?.dataset.createContentStep).toBe("intent"); + expect(root?.dataset.creationIntentError).toContain("创作意图至少需要 10 个字"); + expect(mockCreateContent).not.toHaveBeenCalled(); + }); + + it("创作意图通过后创建文稿应写入待发送提示并进入工作区", async () => { + const loadContents = vi.fn(async () => undefined); + const onEnterWorkspace = vi.fn(); + + const { container } = renderHarness({ + selectedProjectId: "project-1", + loadContents, + onEnterWorkspace, + }); + await flushEffects(); + + await openContentIntentStep(container); + click(container, "fill-intent-topic"); + await flushEffects(); + click(container, "create-content"); + await flushEffects(6); + + expect(mockCreateContent).toHaveBeenCalledTimes(1); + expect(loadContents).toHaveBeenCalledWith("project-1"); + expect(onEnterWorkspace).toHaveBeenCalledWith("content-new", { + showChatPanel: true, + }); + expect(mockToastSuccess).toHaveBeenCalledWith("已创建新文稿"); + + const root = getRootElement(container); + expect(root?.dataset.createContentOpen).toBe("false"); + expect(root?.dataset.createContentStep).toBe("mode"); + + const pendingPrompts = parseRootDatasetRecord(root, "pendingPrompts"); + const contentModes = parseRootDatasetRecord(root, "contentModes"); + expect(Object.keys(pendingPrompts)).toContain("content-new"); + expect(contentModes["content-new"]).toBe("guided"); + + click(container, "consume-prompt"); + await flushEffects(); + const rootAfterConsume = getRootElement(container); + const pendingPromptsAfterConsume = parseRootDatasetRecord( + rootAfterConsume, + "pendingPrompts", + ); + expect(pendingPromptsAfterConsume["content-new"]).toBeUndefined(); + }); +}); diff --git a/src/components/workspace/hooks/useCreationDialogs.ts b/src/components/workspace/hooks/useCreationDialogs.ts index 849a6fd2e..58e0c8506 100644 --- a/src/components/workspace/hooks/useCreationDialogs.ts +++ b/src/components/workspace/hooks/useCreationDialogs.ts @@ -1,4 +1,11 @@ -import { useCallback, useEffect, useMemo, useState } from "react"; +import { + useCallback, + useEffect, + useMemo, + useState, + type Dispatch, + type SetStateAction, +} from "react"; import { toast } from "sonner"; import { createContent, @@ -41,6 +48,191 @@ function parseCreationMode(value: unknown): CreationMode | null { return null; } +function parseCreationModeFromMetadata(metadata: unknown): CreationMode | null { + if (!metadata || typeof metadata !== "object") { + return null; + } + return parseCreationMode((metadata as Record).creationMode); +} + +function useWorkspaceProjectsRootLoader( + setWorkspaceProjectsRoot: Dispatch>, +): void { + useEffect(() => { + let mounted = true; + + const loadWorkspaceRoot = async () => { + try { + const root = await getWorkspaceProjectsRoot(); + if (mounted) { + setWorkspaceProjectsRoot(root); + } + } catch (error) { + console.error("加载 workspace 目录失败:", error); + } + }; + + void loadWorkspaceRoot(); + + return () => { + mounted = false; + }; + }, [setWorkspaceProjectsRoot]); +} + +interface UseProjectPathResolverParams { + createProjectDialogOpen: boolean; + newProjectName: string; + resetProjectPathState: () => void; + setResolvedProjectPath: Dispatch>; +} + +function useProjectPathResolver({ + createProjectDialogOpen, + newProjectName, + resetProjectPathState, + setResolvedProjectPath, +}: UseProjectPathResolverParams): void { + useEffect(() => { + if (!createProjectDialogOpen) { + resetProjectPathState(); + return; + } + + const projectName = newProjectName.trim(); + if (!projectName) { + resetProjectPathState(); + return; + } + + let mounted = true; + const resolvePath = async () => { + try { + const path = await resolveProjectRootPath(projectName); + if (mounted) { + setResolvedProjectPath(path); + } + } catch (error) { + console.error("解析项目目录失败:", error); + if (mounted) { + resetProjectPathState(); + } + } + }; + + void resolvePath(); + + return () => { + mounted = false; + }; + }, [ + createProjectDialogOpen, + newProjectName, + resetProjectPathState, + setResolvedProjectPath, + ]); +} + +interface UseProjectPathConflictCheckerParams { + createProjectDialogOpen: boolean; + resolvedProjectPath: string; + setPathChecking: Dispatch>; + setPathConflictMessage: Dispatch>; +} + +function useProjectPathConflictChecker({ + createProjectDialogOpen, + resolvedProjectPath, + setPathChecking, + setPathConflictMessage, +}: UseProjectPathConflictCheckerParams): void { + useEffect(() => { + if (!createProjectDialogOpen || !resolvedProjectPath) { + setPathChecking(false); + setPathConflictMessage(""); + return; + } + + let mounted = true; + setPathChecking(true); + + const checkPathConflict = async () => { + try { + const existingProject = await getProjectByRootPath(resolvedProjectPath); + if (!mounted) { + return; + } + if (existingProject) { + setPathConflictMessage(`路径已存在项目:${existingProject.name}`); + } else { + setPathConflictMessage(""); + } + } catch (error) { + console.error("检查项目路径冲突失败:", error); + if (mounted) { + setPathConflictMessage(""); + } + } finally { + if (mounted) { + setPathChecking(false); + } + } + }; + + void checkPathConflict(); + + return () => { + mounted = false; + }; + }, [ + createProjectDialogOpen, + resolvedProjectPath, + setPathChecking, + setPathConflictMessage, + ]); +} + +interface UseContentCreationModeLoaderParams { + selectedContentId: string | null; + contentCreationModes: Record; + setContentCreationModes: Dispatch>>; +} + +function useContentCreationModeLoader({ + selectedContentId, + contentCreationModes, + setContentCreationModes, +}: UseContentCreationModeLoaderParams): void { + useEffect(() => { + if (!selectedContentId || contentCreationModes[selectedContentId]) { + return; + } + + let mounted = true; + const loadCreationMode = async () => { + try { + const content = await getContent(selectedContentId); + const mode = parseCreationModeFromMetadata(content?.metadata); + + if (mounted && mode) { + setContentCreationModes((previous) => ({ + ...previous, + [selectedContentId]: mode, + })); + } + } catch (error) { + console.error("读取文稿创作模式失败:", error); + } + }; + + void loadCreationMode(); + + return () => { + mounted = false; + }; + }, [contentCreationModes, selectedContentId, setContentCreationModes]); +} + export interface UseCreationDialogsParams { theme: WorkspaceTheme; selectedProjectId: string | null; @@ -91,6 +283,12 @@ export function useCreationDialogs({ Record >({}); + const resetProjectPathState = useCallback(() => { + setResolvedProjectPath(""); + setPathChecking(false); + setPathConflictMessage(""); + }, []); + const creationIntentInput = useMemo( () => ({ creationMode: selectedCreationMode, @@ -119,11 +317,9 @@ export function useCreationDialogs({ const handleOpenCreateProjectDialog = useCallback(() => { setNewProjectName(`${getProjectTypeLabel(theme as ProjectType)}项目`); - setResolvedProjectPath(""); - setPathConflictMessage(""); - setPathChecking(false); + resetProjectPathState(); setCreateProjectDialogOpen(true); - }, [theme]); + }, [resetProjectPathState, theme]); const handleCreateProject = useCallback(async () => { const name = newProjectName.trim(); @@ -253,140 +449,24 @@ export function useCreationDialogs({ }); }, []); - useEffect(() => { - let mounted = true; - - const loadWorkspaceRoot = async () => { - try { - const root = await getWorkspaceProjectsRoot(); - if (mounted) { - setWorkspaceProjectsRoot(root); - } - } catch (error) { - console.error("加载 workspace 目录失败:", error); - } - }; - - void loadWorkspaceRoot(); - - return () => { - mounted = false; - }; - }, []); - - useEffect(() => { - if (!createProjectDialogOpen) { - setResolvedProjectPath(""); - setPathChecking(false); - setPathConflictMessage(""); - return; - } - - const projectName = newProjectName.trim(); - if (!projectName) { - setResolvedProjectPath(""); - setPathChecking(false); - setPathConflictMessage(""); - return; - } - - let mounted = true; - const resolvePath = async () => { - try { - const path = await resolveProjectRootPath(projectName); - if (mounted) { - setResolvedProjectPath(path); - } - } catch (error) { - console.error("解析项目目录失败:", error); - if (mounted) { - setResolvedProjectPath(""); - setPathConflictMessage(""); - setPathChecking(false); - } - } - }; - - void resolvePath(); - - return () => { - mounted = false; - }; - }, [createProjectDialogOpen, newProjectName]); - - useEffect(() => { - if (!createProjectDialogOpen || !resolvedProjectPath) { - setPathChecking(false); - setPathConflictMessage(""); - return; - } - - let mounted = true; - setPathChecking(true); - - const checkPathConflict = async () => { - try { - const existingProject = await getProjectByRootPath(resolvedProjectPath); - if (!mounted) { - return; - } - if (existingProject) { - setPathConflictMessage(`路径已存在项目:${existingProject.name}`); - } else { - setPathConflictMessage(""); - } - } catch (error) { - console.error("检查项目路径冲突失败:", error); - if (mounted) { - setPathConflictMessage(""); - } - } finally { - if (mounted) { - setPathChecking(false); - } - } - }; - - void checkPathConflict(); - - return () => { - mounted = false; - }; - }, [createProjectDialogOpen, resolvedProjectPath]); - - useEffect(() => { - if (!selectedContentId || contentCreationModes[selectedContentId]) { - return; - } - - let mounted = true; - const loadCreationMode = async () => { - try { - const content = await getContent(selectedContentId); - const metadata = content?.metadata; - const mode = parseCreationMode( - metadata && typeof metadata === "object" - ? (metadata as Record).creationMode - : null, - ); - - if (mounted && mode) { - setContentCreationModes((previous) => ({ - ...previous, - [selectedContentId]: mode, - })); - } - } catch (error) { - console.error("读取文稿创作模式失败:", error); - } - }; - - void loadCreationMode(); - - return () => { - mounted = false; - }; - }, [contentCreationModes, selectedContentId]); + useWorkspaceProjectsRootLoader(setWorkspaceProjectsRoot); + useProjectPathResolver({ + createProjectDialogOpen, + newProjectName, + resetProjectPathState, + setResolvedProjectPath, + }); + useProjectPathConflictChecker({ + createProjectDialogOpen, + resolvedProjectPath, + setPathChecking, + setPathConflictMessage, + }); + useContentCreationModeLoader({ + selectedContentId, + contentCreationModes, + setContentCreationModes, + }); return { createProjectDialogOpen, diff --git a/src/components/workspace/hooks/useWorkbenchController.test.tsx b/src/components/workspace/hooks/useWorkbenchController.test.tsx new file mode 100644 index 000000000..32d88713b --- /dev/null +++ b/src/components/workspace/hooks/useWorkbenchController.test.tsx @@ -0,0 +1,413 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + cleanupMountedRoots, + clickByTestId, + flushEffects, + mountHarness, + setupReactActEnvironment, + type MountedRoot, +} from "./testUtils"; +import { + type UseWorkbenchControllerParams, + useWorkbenchController, +} from "./useWorkbenchController"; + +const { + mockGetProjectTypeLabel, + mockGetThemeModule, + mockToastError, + mockToastSuccess, + mockUpdateContent, + mockUseCreationDialogs, + mockUseWorkbenchNavigation, + mockUseWorkbenchPanelRenderer, + mockUseWorkbenchProjectData, + mockUseWorkbenchQuickActions, + mockUseWorkbenchStore, +} = vi.hoisted(() => ({ + mockGetProjectTypeLabel: vi.fn(), + mockGetThemeModule: vi.fn(), + mockToastError: vi.fn(), + mockToastSuccess: vi.fn(), + mockUpdateContent: vi.fn(), + mockUseCreationDialogs: vi.fn(), + mockUseWorkbenchNavigation: vi.fn(), + mockUseWorkbenchPanelRenderer: vi.fn(), + mockUseWorkbenchProjectData: vi.fn(), + mockUseWorkbenchQuickActions: vi.fn(), + mockUseWorkbenchStore: vi.fn(), +})); + +vi.mock("sonner", () => ({ + toast: { + success: mockToastSuccess, + error: mockToastError, + }, +})); + +vi.mock("@/stores/useWorkbenchStore", () => ({ + useWorkbenchStore: mockUseWorkbenchStore, +})); + +vi.mock("@/lib/api/project", () => ({ + updateContent: mockUpdateContent, + getProjectTypeLabel: mockGetProjectTypeLabel, +})); + +vi.mock("@/features/themes", () => ({ + getThemeModule: mockGetThemeModule, +})); + +vi.mock("./useWorkbenchProjectData", () => ({ + useWorkbenchProjectData: mockUseWorkbenchProjectData, +})); + +vi.mock("./useWorkbenchNavigation", () => ({ + useWorkbenchNavigation: mockUseWorkbenchNavigation, +})); + +vi.mock("./useCreationDialogs", () => ({ + useCreationDialogs: mockUseCreationDialogs, +})); + +vi.mock("./useWorkbenchPanelRenderer", () => ({ + useWorkbenchPanelRenderer: mockUseWorkbenchPanelRenderer, +})); + +vi.mock("./useWorkbenchQuickActions", () => ({ + useWorkbenchQuickActions: mockUseWorkbenchQuickActions, +})); + +type ControllerHarnessProps = UseWorkbenchControllerParams; + +function createProjectDataHookValue( + overrides: Record = {}, +): Record { + return { + projects: [{ id: "project-1", name: "项目A" }], + projectsLoading: false, + selectedProjectId: "project-1", + setSelectedProjectId: vi.fn(), + contents: [{ id: "content-1", title: "文稿A" }], + contentsLoading: false, + selectedContentId: "content-1", + setSelectedContentId: vi.fn(), + projectQuery: "", + setProjectQuery: vi.fn(), + contentQuery: "", + setContentQuery: vi.fn(), + selectedProject: { id: "project-1", name: "项目A" }, + filteredProjects: [{ id: "project-1", name: "项目A" }], + filteredContents: [{ id: "content-1", title: "文稿A" }], + loadProjects: vi.fn(async () => undefined), + loadContents: vi.fn(async () => undefined), + resetProjectAndContentQueries: vi.fn(), + clearContentsSelection: vi.fn(), + ...overrides, + }; +} + +function WorkbenchControllerHarness(props: ControllerHarnessProps) { + const controller = useWorkbenchController(props); + + return ( +
+
+ ); +} + +const mountedRoots: MountedRoot[] = []; + +function renderHarness(props: Partial = {}) { + return mountHarness( + WorkbenchControllerHarness, + { + theme: "social-media", + initialProjectId: "project-1", + initialContentId: "content-1", + initialViewMode: "workspace", + ...props, + }, + mountedRoots, + ); +} + +function click(container: HTMLElement, testId: string): void { + const button = clickByTestId(container, testId); + expect(button).not.toBeNull(); +} + +beforeEach(() => { + setupReactActEnvironment(); + vi.clearAllMocks(); + + mockUseWorkbenchStore.mockReturnValue({ + leftSidebarCollapsed: true, + toggleLeftSidebar: vi.fn(), + setLeftSidebarCollapsed: vi.fn(), + }); + + mockGetThemeModule.mockReturnValue({ + navigation: { + defaultView: "create", + items: [{ key: "create", label: "创作" }], + }, + capabilities: { + workspaceKind: "agent-chat", + }, + panelRenderers: {}, + workspaceRenderer: () => null, + primaryWorkspaceRenderer: undefined, + }); + + mockGetProjectTypeLabel.mockReturnValue("社媒内容"); + + mockUseWorkbenchProjectData.mockReturnValue(createProjectDataHookValue()); + + mockUseWorkbenchNavigation.mockReturnValue({ + activeRightDrawer: null, + setActiveRightDrawer: vi.fn(), + showChatPanel: true, + setShowChatPanel: vi.fn(), + workflowProgress: null, + setWorkflowProgress: vi.fn(), + showWorkflowRail: false, + setShowWorkflowRail: vi.fn(), + workspaceMode: "workspace", + setWorkspaceMode: vi.fn(), + activeWorkspaceView: "create", + setActiveWorkspaceView: vi.fn(), + shouldRenderLeftSidebar: false, + isCreateWorkspaceView: true, + shouldRenderWorkspaceRightRail: true, + activeWorkspaceViewLabel: "创作", + hasWorkflowWorkspaceView: true, + hasPublishWorkspaceView: true, + hasSettingsWorkspaceView: true, + applyInitialNavigationState: vi.fn(), + handleOpenWorkflowView: vi.fn(), + handleBackToProjectManagement: vi.fn(), + handleEnterWorkspaceView: vi.fn(), + handleSwitchWorkspaceView: vi.fn(), + }); + + mockUseCreationDialogs.mockReturnValue({ + createProjectDialogOpen: false, + setCreateProjectDialogOpen: vi.fn(), + createContentDialogOpen: false, + setCreateContentDialogOpen: vi.fn(), + createContentDialogStep: "mode", + setCreateContentDialogStep: vi.fn(), + newProjectName: "", + setNewProjectName: vi.fn(), + workspaceProjectsRoot: "/tmp/workspace", + creatingProject: false, + creatingContent: false, + selectedCreationMode: "guided", + setSelectedCreationMode: vi.fn(), + creationIntentValues: {}, + creationIntentError: "", + setCreationIntentError: vi.fn(), + currentCreationIntentFields: [], + currentIntentLength: 0, + pendingInitialPromptsByContentId: {}, + contentCreationModes: {}, + resolvedProjectPath: "", + pathChecking: false, + pathConflictMessage: "", + resetCreateContentDialogState: vi.fn(), + handleOpenCreateProjectDialog: vi.fn(), + handleCreateProject: vi.fn(), + handleOpenCreateContentDialog: vi.fn(), + handleCreationIntentValueChange: vi.fn(), + handleGoToIntentStep: vi.fn(), + handleCreateContent: vi.fn(), + consumePendingInitialPrompt: vi.fn(), + }); + + mockUseWorkbenchPanelRenderer.mockReturnValue({ + activePanelRenderer: null, + }); + + mockUseWorkbenchQuickActions.mockReturnValue({ + nonCreateQuickActions: [], + }); +}); + +afterEach(() => { + cleanupMountedRoots(mountedRoots); +}); + +describe("useWorkbenchController", () => { + it("应在初始化时触发项目加载与导航初始化", async () => { + renderHarness(); + await flushEffects(5); + + const projectData = mockUseWorkbenchProjectData.mock.results[0] + .value as Record; + const navigation = mockUseWorkbenchNavigation.mock.results[0].value as Record< + string, + unknown + >; + + const resetQueries = projectData.resetProjectAndContentQueries as ReturnType< + typeof vi.fn + >; + const setSelectedProjectId = projectData.setSelectedProjectId as ReturnType< + typeof vi.fn + >; + const setSelectedContentId = projectData.setSelectedContentId as ReturnType< + typeof vi.fn + >; + const loadProjects = projectData.loadProjects as ReturnType; + const applyInitialNavigationState = + navigation.applyInitialNavigationState as ReturnType; + + expect(resetQueries).toHaveBeenCalledTimes(1); + expect(setSelectedProjectId).toHaveBeenCalledWith("project-1"); + expect(setSelectedContentId).toHaveBeenCalledWith("content-1"); + expect(applyInitialNavigationState).toHaveBeenCalledWith("workspace", "content-1"); + expect(loadProjects).toHaveBeenCalledTimes(1); + }); + + it("handleEnterWorkspace 应同步更新工作区关键状态", async () => { + const { container } = renderHarness(); + await flushEffects(); + + click(container, "enter-workspace"); + await flushEffects(); + + const projectData = mockUseWorkbenchProjectData.mock.results[0] + .value as Record; + const navigation = mockUseWorkbenchNavigation.mock.results[0].value as Record< + string, + unknown + >; + const store = mockUseWorkbenchStore.mock.results[0].value as Record< + string, + unknown + >; + + const setSelectedContentId = projectData.setSelectedContentId as ReturnType< + typeof vi.fn + >; + const setWorkspaceMode = navigation.setWorkspaceMode as ReturnType; + const setActiveWorkspaceView = + navigation.setActiveWorkspaceView as ReturnType; + const setShowChatPanel = navigation.setShowChatPanel as ReturnType; + const setActiveRightDrawer = + navigation.setActiveRightDrawer as ReturnType; + const setLeftSidebarCollapsed = + store.setLeftSidebarCollapsed as ReturnType; + + expect(setSelectedContentId).toHaveBeenCalledWith("content-new"); + expect(setWorkspaceMode).toHaveBeenCalledWith("workspace"); + expect(setActiveWorkspaceView).toHaveBeenCalledWith("create"); + expect(setShowChatPanel).toHaveBeenCalledWith(false); + expect(setActiveRightDrawer).toHaveBeenCalledWith(null); + expect(setLeftSidebarCollapsed).toHaveBeenCalledWith(true); + }); + + it("handleQuickSaveCurrent 成功时应保存并刷新文稿列表", async () => { + mockUpdateContent.mockResolvedValueOnce(undefined); + + const { container } = renderHarness(); + await flushEffects(5); + + const projectData = mockUseWorkbenchProjectData.mock.results[0] + .value as Record; + const loadContents = projectData.loadContents as ReturnType; + const callsBeforeSave = loadContents.mock.calls.length; + + click(container, "quick-save"); + await flushEffects(5); + + expect(mockUpdateContent).toHaveBeenCalledWith("content-1", { + metadata: { + saved_from: "theme-workspace", + saved_at: expect.any(Number), + }, + }); + expect(loadContents.mock.calls.length).toBe(callsBeforeSave + 1); + expect(loadContents).toHaveBeenLastCalledWith("project-1"); + expect(mockToastSuccess).toHaveBeenCalledWith("已保存当前文稿"); + }); + + it("handleQuickSaveCurrent 在未选中项目或文稿时应直接返回", async () => { + mockUseWorkbenchProjectData.mockReturnValueOnce( + createProjectDataHookValue({ + selectedProjectId: null, + selectedContentId: null, + }), + ); + + const { container } = renderHarness(); + await flushEffects(3); + + click(container, "quick-save"); + await flushEffects(3); + + expect(mockUpdateContent).not.toHaveBeenCalled(); + expect(mockToastSuccess).not.toHaveBeenCalled(); + expect(mockToastError).not.toHaveBeenCalled(); + }); + + it("handleQuickSaveCurrent 失败时应提示错误", async () => { + mockUpdateContent.mockRejectedValueOnce(new Error("save-failed")); + const consoleErrorSpy = vi + .spyOn(console, "error") + .mockImplementation(() => undefined); + + try { + const { container } = renderHarness(); + await flushEffects(5); + + click(container, "quick-save"); + await flushEffects(5); + + expect(mockUpdateContent).toHaveBeenCalledTimes(1); + expect(mockToastError).toHaveBeenCalledWith("保存失败"); + expect(consoleErrorSpy).toHaveBeenCalled(); + } finally { + consoleErrorSpy.mockRestore(); + } + }); + + it("快捷键 Ctrl/Cmd+B 应触发左侧栏切换", async () => { + const toggleLeftSidebar = vi.fn(); + mockUseWorkbenchStore.mockReturnValueOnce({ + leftSidebarCollapsed: true, + toggleLeftSidebar, + setLeftSidebarCollapsed: vi.fn(), + }); + + renderHarness(); + await flushEffects(); + + window.dispatchEvent( + new KeyboardEvent("keydown", { + key: "b", + ctrlKey: true, + bubbles: true, + }), + ); + await flushEffects(); + + expect(toggleLeftSidebar).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/components/workspace/hooks/useWorkbenchController.ts b/src/components/workspace/hooks/useWorkbenchController.ts index d0f1dbf1d..880bab610 100644 --- a/src/components/workspace/hooks/useWorkbenchController.ts +++ b/src/components/workspace/hooks/useWorkbenchController.ts @@ -78,6 +78,96 @@ export interface UseWorkbenchControllerParams { resetAt?: number; } +interface UseWorkbenchBootstrapParams { + applyInitialNavigationState: ( + initialViewMode: WorkspaceViewMode | undefined, + initialContentId: string | undefined, + ) => void; + clearContentsSelection: () => void; + initialContentId?: string; + initialProjectId?: string; + initialViewMode?: WorkspaceViewMode; + loadProjects: () => Promise; + resetProjectAndContentQueries: () => void; + resetAt?: number; + setSelectedContentId: (contentId: string | null) => void; + setSelectedProjectId: (projectId: string | null) => void; + theme: WorkspaceTheme; +} + +function useWorkbenchBootstrap({ + applyInitialNavigationState, + clearContentsSelection, + initialContentId, + initialProjectId, + initialViewMode, + loadProjects, + resetProjectAndContentQueries, + resetAt, + setSelectedContentId, + setSelectedProjectId, + theme, +}: UseWorkbenchBootstrapParams): void { + useEffect(() => { + resetProjectAndContentQueries(); + setSelectedProjectId(initialProjectId ?? null); + setSelectedContentId(initialContentId ?? null); + applyInitialNavigationState(initialViewMode, initialContentId); + clearContentsSelection(); + void loadProjects(); + }, [ + applyInitialNavigationState, + clearContentsSelection, + initialContentId, + initialProjectId, + initialViewMode, + loadProjects, + resetProjectAndContentQueries, + resetAt, + setSelectedContentId, + setSelectedProjectId, + theme, + ]); +} + +interface UseSelectedProjectContentsLoaderParams { + clearContentsSelection: () => void; + loadContents: (projectId: string) => Promise; + projects: Array; + selectedProjectId: string | null; +} + +function useSelectedProjectContentsLoader({ + clearContentsSelection, + loadContents, + projects, + selectedProjectId, +}: UseSelectedProjectContentsLoaderParams): void { + useEffect(() => { + if (!selectedProjectId) { + clearContentsSelection(); + return; + } + void loadContents(selectedProjectId); + }, [clearContentsSelection, loadContents, selectedProjectId, projects]); +} + +function useSidebarToggleHotkey(toggleLeftSidebar: () => void): void { + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if ((event.metaKey || event.ctrlKey) && event.key === "b") { + event.preventDefault(); + toggleLeftSidebar(); + } + }; + + window.addEventListener("keydown", handleKeyDown); + return () => { + window.removeEventListener("keydown", handleKeyDown); + }; + }, [toggleLeftSidebar]); +} + export function useWorkbenchController({ onNavigate, initialProjectId, @@ -268,14 +358,7 @@ export function useWorkbenchController({ } }, [loadContents, selectedContentId, selectedProjectId]); - useEffect(() => { - resetProjectAndContentQueries(); - setSelectedProjectId(initialProjectId ?? null); - setSelectedContentId(initialContentId ?? null); - applyInitialNavigationState(initialViewMode, initialContentId); - clearContentsSelection(); - void loadProjects(); - }, [ + useWorkbenchBootstrap({ applyInitialNavigationState, clearContentsSelection, initialContentId, @@ -287,33 +370,19 @@ export function useWorkbenchController({ setSelectedContentId, setSelectedProjectId, theme, - ]); - - useEffect(() => { - if (!selectedProjectId) { - clearContentsSelection(); - return; - } - void loadContents(selectedProjectId); - }, [clearContentsSelection, loadContents, selectedProjectId, projects]); + }); + useSelectedProjectContentsLoader({ + clearContentsSelection, + loadContents, + projects, + selectedProjectId, + }); const handleBackHome = useCallback(() => { onNavigate?.("agent", buildHomeAgentParams()); }, [onNavigate]); - useEffect(() => { - const handleKeyDown = (event: KeyboardEvent) => { - if ((event.metaKey || event.ctrlKey) && event.key === "b") { - event.preventDefault(); - toggleLeftSidebar(); - } - }; - - window.addEventListener("keydown", handleKeyDown); - return () => { - window.removeEventListener("keydown", handleKeyDown); - }; - }, [toggleLeftSidebar]); + useSidebarToggleHotkey(toggleLeftSidebar); const currentContentTitle = selectedContentId ? contents.find((item) => item.id === selectedContentId)?.title || "已选文稿" diff --git a/src/components/workspace/hooks/useWorkbenchNavigation.test.tsx b/src/components/workspace/hooks/useWorkbenchNavigation.test.tsx new file mode 100644 index 000000000..164357c48 --- /dev/null +++ b/src/components/workspace/hooks/useWorkbenchNavigation.test.tsx @@ -0,0 +1,209 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + type UseWorkbenchNavigationParams, + useWorkbenchNavigation, +} from "./useWorkbenchNavigation"; +import { + cleanupMountedRoots, + clickElement, + mountHarness, + setupReactActEnvironment, + type MountedRoot, +} from "./testUtils"; + +type NavigationHarnessProps = UseWorkbenchNavigationParams; + +function NavigationHarness(props: NavigationHarnessProps) { + const navigation = useWorkbenchNavigation(props); + + return ( +
+
+ ); +} + +const mountedRoots: MountedRoot[] = []; + +function createHarnessProps( + overrides: Partial = {}, +): NavigationHarnessProps { + return { + initialViewMode: "workspace", + initialContentId: "content-1", + defaultWorkspaceView: "create", + navigationItems: [ + { key: "create", label: "创作" }, + { key: "workflow", label: "流程" }, + { key: "settings", label: "设置" }, + ], + leftSidebarCollapsed: true, + setLeftSidebarCollapsed: vi.fn(), + isAgentChatWorkspace: true, + hasPrimaryWorkspaceRenderer: false, + ...overrides, + }; +} + +function renderHarness(initialProps: Partial = {}) { + return mountHarness( + NavigationHarness, + createHarnessProps(initialProps), + mountedRoots, + ); +} + +afterEach(() => { + cleanupMountedRoots(mountedRoots); +}); + +beforeEach(() => { + setupReactActEnvironment(); +}); + +describe("useWorkbenchNavigation", () => { + it("可按 project-detail 规则应用初始化导航状态", () => { + const setLeftSidebarCollapsed = vi.fn(); + + const { container } = renderHarness({ + initialViewMode: "project-management", + initialContentId: undefined, + leftSidebarCollapsed: false, + setLeftSidebarCollapsed, + }); + + const applyButton = container.querySelector( + "button[data-testid='apply-project-detail']", + ); + expect(applyButton).not.toBeNull(); + + clickElement(applyButton); + + const root = container.firstElementChild as HTMLElement | null; + expect(root?.dataset.mode).toBe("workspace"); + expect(root?.dataset.view).toBe("workflow"); + expect(setLeftSidebarCollapsed).toHaveBeenLastCalledWith(true); + }); + + it("无 workflow 导航时打开流程动作会回退到 settings", () => { + const setLeftSidebarCollapsed = vi.fn(); + + const { container } = renderHarness({ + navigationItems: [ + { key: "create", label: "创作" }, + { key: "publish", label: "发布" }, + { key: "settings", label: "设置" }, + ], + setLeftSidebarCollapsed, + }); + + const openWorkflowButton = container.querySelector( + "button[data-testid='open-workflow']", + ); + expect(openWorkflowButton).not.toBeNull(); + + clickElement(openWorkflowButton); + + const root = container.firstElementChild as HTMLElement | null; + expect(root?.dataset.view).toBe("settings"); + expect(root?.dataset.viewLabel).toBe("设置"); + }); + + it("切换到非 create 视图时收起工具抽屉与流程轨", () => { + const setLeftSidebarCollapsed = vi.fn(); + + const { container } = renderHarness({ + navigationItems: [ + { key: "create", label: "创作" }, + { key: "publish", label: "发布" }, + { key: "settings", label: "设置" }, + ], + setLeftSidebarCollapsed, + }); + + const prepareButton = container.querySelector( + "button[data-testid='prepare-tools-state']", + ); + expect(prepareButton).not.toBeNull(); + clickElement(prepareButton); + + let root = container.firstElementChild as HTMLElement | null; + expect(root?.dataset.drawer).toBe("tools"); + expect(root?.dataset.showWorkflowRail).toBe("true"); + + const switchPublishButton = container.querySelector( + "button[data-testid='switch-publish']", + ); + expect(switchPublishButton).not.toBeNull(); + clickElement(switchPublishButton); + + root = container.firstElementChild as HTMLElement | null; + expect(root?.dataset.drawer).toBe("none"); + expect(root?.dataset.showWorkflowRail).toBe("false"); + }); + + it("返回项目管理时恢复项目管理模式并展开左栏", () => { + const setLeftSidebarCollapsed = vi.fn(); + + const { container } = renderHarness({ + navigationItems: [ + { key: "create", label: "创作" }, + { key: "workflow", label: "流程" }, + ], + setLeftSidebarCollapsed, + }); + + const backButton = container.querySelector( + "button[data-testid='back-project-management']", + ); + expect(backButton).not.toBeNull(); + + clickElement(backButton); + + const root = container.firstElementChild as HTMLElement | null; + expect(root?.dataset.mode).toBe("project-management"); + expect(root?.dataset.showChat).toBe("true"); + expect(root?.dataset.shouldRenderLeftSidebar).toBe("true"); + expect(setLeftSidebarCollapsed).toHaveBeenLastCalledWith(false); + }); +}); diff --git a/src/components/workspace/hooks/useWorkbenchProjectData.test.tsx b/src/components/workspace/hooks/useWorkbenchProjectData.test.tsx new file mode 100644 index 000000000..898aa12af --- /dev/null +++ b/src/components/workspace/hooks/useWorkbenchProjectData.test.tsx @@ -0,0 +1,271 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + type UseWorkbenchProjectDataParams, + useWorkbenchProjectData, +} from "./useWorkbenchProjectData"; +import { + cleanupMountedRoots, + clickByTestId, + flushEffects, + getRootElement, + mountHarness, + setupReactActEnvironment, + type MountedRoot, +} from "./testUtils"; +import { + createWorkspaceContentFixture, + createWorkspaceProjectFixture, +} from "../testFixtures"; + +const { mockListContents, mockListProjects, mockToastError } = vi.hoisted(() => ({ + mockListContents: vi.fn(), + mockListProjects: vi.fn(), + mockToastError: vi.fn(), +})); + +vi.mock("sonner", () => ({ + toast: { + error: mockToastError, + }, +})); + +vi.mock("@/lib/api/project", () => ({ + listProjects: mockListProjects, + listContents: mockListContents, +})); + +type HarnessProps = UseWorkbenchProjectDataParams; + +function WorkbenchProjectDataHarness(props: HarnessProps) { + const data = useWorkbenchProjectData(props); + + return ( +
item.id).join(",")} + data-filtered-project-ids={data.filteredProjects.map((item) => item.id).join(",")} + data-content-ids={data.contents.map((item) => item.id).join(",")} + data-filtered-content-ids={data.filteredContents.map((item) => item.id).join(",")} + > +
+ ); +} + +const mountedRoots: MountedRoot[] = []; + +function renderHarness(props: Partial = {}) { + const baseProps: HarnessProps = { + theme: "social-media", + initialProjectId: undefined, + initialContentId: undefined, + }; + + return mountHarness( + WorkbenchProjectDataHarness, + { ...baseProps, ...props }, + mountedRoots, + ); +} + +function click(container: HTMLElement, testId: string): void { + const button = clickByTestId(container, testId); + expect(button).not.toBeNull(); +} + +beforeEach(() => { + setupReactActEnvironment(); + + vi.clearAllMocks(); +}); + +afterEach(() => { + cleanupMountedRoots(mountedRoots); +}); + +describe("useWorkbenchProjectData", () => { + it("loadProjects 仅保留当前主题且未归档项目,并使用 initialProjectId", async () => { + mockListProjects.mockResolvedValue([ + createWorkspaceProjectFixture({ + id: "project-init", + name: "初始化项目", + workspaceType: "social-media", + rootPath: "/tmp/workspace/project-init", + isArchived: false, + tags: [], + }), + createWorkspaceProjectFixture({ + id: "project-other-theme", + name: "视频项目", + workspaceType: "video", + rootPath: "/tmp/workspace/project-other-theme", + isArchived: false, + tags: [], + }), + createWorkspaceProjectFixture({ + id: "project-archived", + name: "归档项目", + workspaceType: "social-media", + rootPath: "/tmp/workspace/project-archived", + isArchived: true, + tags: [], + }), + createWorkspaceProjectFixture({ + id: "project-manual", + name: "Manual 标签项目", + workspaceType: "social-media", + rootPath: "/tmp/workspace/project-manual", + isArchived: false, + tags: ["manual"], + }), + ]); + + const { container } = renderHarness({ + initialProjectId: "project-init", + }); + + click(container, "load-projects"); + await flushEffects(); + + const root = getRootElement(container); + expect(root?.dataset.projectIds).toBe("project-init,project-manual"); + expect(root?.dataset.selectedProjectId).toBe("project-init"); + }); + + it("项目选择优先级应为 previousId > initialProjectId", async () => { + mockListProjects.mockResolvedValue([ + createWorkspaceProjectFixture({ + id: "project-init", + name: "初始化项目", + workspaceType: "social-media", + rootPath: "/tmp/workspace/project-init", + isArchived: false, + tags: [], + }), + createWorkspaceProjectFixture({ + id: "project-manual", + name: "Manual 标签项目", + workspaceType: "social-media", + rootPath: "/tmp/workspace/project-manual", + isArchived: false, + tags: ["manual"], + }), + ]); + + const { container } = renderHarness({ + initialProjectId: "project-init", + }); + + click(container, "load-projects"); + await flushEffects(); + click(container, "set-selected-project-manual"); + await flushEffects(); + click(container, "load-projects"); + await flushEffects(); + click(container, "set-project-query-manual"); + await flushEffects(); + + const root = getRootElement(container); + expect(root?.dataset.selectedProjectId).toBe("project-manual"); + expect(root?.dataset.filteredProjectIds).toBe("project-manual"); + }); + + it("文稿选择优先级应为 previousId > initialContentId,且支持筛选", async () => { + mockListContents.mockResolvedValue([ + createWorkspaceContentFixture({ + id: "content-init", + project_id: "project-a", + title: "初始化文稿", + }), + createWorkspaceContentFixture({ + id: "content-manual", + project_id: "project-a", + title: "manual 文稿", + }), + ]); + + const { container } = renderHarness({ + initialContentId: "content-init", + }); + + click(container, "load-contents-project-a"); + await flushEffects(); + click(container, "set-selected-content-manual"); + await flushEffects(); + click(container, "load-contents-project-a"); + await flushEffects(); + click(container, "set-content-query-manual"); + await flushEffects(); + + const root = getRootElement(container); + expect(root?.dataset.selectedContentId).toBe("content-manual"); + expect(root?.dataset.filteredContentIds).toBe("content-manual"); + }); + + it("应支持重置查询与清空文稿选择", async () => { + mockListContents.mockResolvedValue([ + createWorkspaceContentFixture({ + id: "content-manual", + project_id: "project-a", + title: "manual 文稿", + }), + ]); + + const { container } = renderHarness(); + + click(container, "load-contents-project-a"); + await flushEffects(); + click(container, "set-selected-content-manual"); + click(container, "set-project-query-manual"); + click(container, "set-content-query-manual"); + await flushEffects(); + + click(container, "reset-queries"); + click(container, "clear-contents-selection"); + await flushEffects(); + + const root = getRootElement(container); + expect(root?.dataset.projectQuery).toBe(""); + expect(root?.dataset.contentQuery).toBe(""); + expect(root?.dataset.selectedContentId).toBe(""); + expect(root?.dataset.contentIds).toBe(""); + }); +}); diff --git a/src/components/workspace/hooks/useWorkbenchQuickActions.test.tsx b/src/components/workspace/hooks/useWorkbenchQuickActions.test.tsx new file mode 100644 index 000000000..68f2305da --- /dev/null +++ b/src/components/workspace/hooks/useWorkbenchQuickActions.test.tsx @@ -0,0 +1,146 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + type UseWorkbenchQuickActionsParams, + useWorkbenchQuickActions, +} from "./useWorkbenchQuickActions"; +import { + cleanupMountedRoots, + clickElement, + mountHarness, + setupReactActEnvironment, + type MountedRoot, +} from "./testUtils"; + +type QuickActionsHarnessProps = UseWorkbenchQuickActionsParams; + +function QuickActionsHarness(props: QuickActionsHarnessProps) { + const { nonCreateQuickActions } = useWorkbenchQuickActions(props); + + return ( +
+
{nonCreateQuickActions.length}
+
+ {nonCreateQuickActions.map((action) => action.key).join(",")} +
+
+ {nonCreateQuickActions.map((action) => action.label).join("|")} +
+ {nonCreateQuickActions.map((action) => ( + + ))} +
+ ); +} + +const mountedRoots: MountedRoot[] = []; + +function createHarnessProps( + overrides: Partial = {}, +): QuickActionsHarnessProps { + return { + workspaceMode: "workspace", + activeWorkspaceView: "publish", + hasWorkflowWorkspaceView: true, + hasPublishWorkspaceView: true, + hasSettingsWorkspaceView: true, + selectedContentId: "content-1", + onSwitchWorkspaceView: vi.fn(), + onQuickSaveCurrent: vi.fn(), + ...overrides, + }; +} + +function renderHarness(initialProps: Partial = {}) { + return mountHarness( + QuickActionsHarness, + createHarnessProps(initialProps), + mountedRoots, + ); +} + +afterEach(() => { + cleanupMountedRoots(mountedRoots); +}); + +beforeEach(() => { + setupReactActEnvironment(); +}); + +describe("useWorkbenchQuickActions", () => { + it("非工作区或创作视图时不返回动作", () => { + const onSwitchWorkspaceView = vi.fn(); + const onQuickSaveCurrent = vi.fn(); + + const { container, rerender } = renderHarness({ + workspaceMode: "project-management", + onSwitchWorkspaceView, + onQuickSaveCurrent, + }); + + expect( + container.querySelector("[data-testid='action-count']")?.textContent, + ).toBe("0"); + + rerender( + createHarnessProps({ + workspaceMode: "workspace", + activeWorkspaceView: "create", + onSwitchWorkspaceView, + onQuickSaveCurrent, + }), + ); + + expect( + container.querySelector("[data-testid='action-count']")?.textContent, + ).toBe("0"); + }); + + it("发布视图返回正确动作,并可触发回调", () => { + const onSwitchWorkspaceView = vi.fn(); + const onQuickSaveCurrent = vi.fn(); + + const { container } = renderHarness({ + onSwitchWorkspaceView, + onQuickSaveCurrent, + }); + + const labels = + container.querySelector("[data-testid='action-labels']")?.textContent ?? ""; + expect(labels).toContain("返回创作视图"); + expect(labels).toContain("前往流程视图"); + expect(labels).toContain("前往设置视图"); + expect(labels).toContain("快速保存当前文稿"); + expect(labels).not.toContain("前往发布视图"); + + const workflowButton = container.querySelector( + "button[data-key='to-workflow']", + ); + expect(workflowButton).not.toBeNull(); + clickElement(workflowButton); + expect(onSwitchWorkspaceView).toHaveBeenCalledWith("workflow"); + + const saveButton = container.querySelector("button[data-key='quick-save']"); + expect(saveButton).not.toBeNull(); + clickElement(saveButton); + expect(onQuickSaveCurrent).toHaveBeenCalledTimes(1); + }); + + it("在流程视图时包含前往发布动作", () => { + const onSwitchWorkspaceView = vi.fn(); + const onQuickSaveCurrent = vi.fn(); + + const { container } = renderHarness({ + activeWorkspaceView: "workflow", + selectedContentId: null, + onSwitchWorkspaceView, + onQuickSaveCurrent, + }); + + const labels = + container.querySelector("[data-testid='action-labels']")?.textContent ?? ""; + expect(labels).toContain("前往发布视图"); + expect(labels).not.toContain("快速保存当前文稿"); + }); +}); diff --git a/src/components/workspace/testFixtures.ts b/src/components/workspace/testFixtures.ts new file mode 100644 index 000000000..3bee3d4f6 --- /dev/null +++ b/src/components/workspace/testFixtures.ts @@ -0,0 +1,44 @@ +import type { ContentListItem, Project } from "@/lib/api/project"; + +export const WORKSPACE_FIXTURE_TIMESTAMP = 1_700_000_000_000; + +export const DEFAULT_WORKSPACE_PAGE_PROPS = { + viewMode: "workspace", + projectId: "project-1", + contentId: "content-1", +} as const; + +type ProjectFixtureRequired = Pick< + Project, + "id" | "name" | "workspaceType" | "rootPath" +>; + +export function createWorkspaceProjectFixture( + data: ProjectFixtureRequired & Partial, +): Project { + return { + isDefault: false, + createdAt: WORKSPACE_FIXTURE_TIMESTAMP, + updatedAt: WORKSPACE_FIXTURE_TIMESTAMP, + isFavorite: false, + isArchived: false, + tags: [], + ...data, + }; +} + +type ContentFixtureRequired = Pick; + +export function createWorkspaceContentFixture( + data: ContentFixtureRequired & Partial, +): ContentListItem { + return { + content_type: "post", + status: "draft", + order: 0, + word_count: 0, + created_at: WORKSPACE_FIXTURE_TIMESTAMP, + updated_at: WORKSPACE_FIXTURE_TIMESTAMP, + ...data, + }; +} diff --git a/src/lib/plugin-ui/types.ts b/src/lib/plugin-ui/types.ts index 2e9294660..07958febf 100644 --- a/src/lib/plugin-ui/types.ts +++ b/src/lib/plugin-ui/types.ts @@ -421,6 +421,48 @@ export type ServerMessage = | { beginRendering: BeginRendering } | { deleteSurface: DeleteSurface }; +/** 插件任务状态 */ +export type PluginTaskState = + | "queued" + | "running" + | "retrying" + | "succeeded" + | "failed" + | "cancelled" + | "timed_out"; + +/** 插件任务错误 */ +export interface PluginTaskError { + code?: string; + message: string; + retryable: boolean; +} + +/** 插件任务事件 */ +export interface PluginTaskEventPayload { + pluginId: PluginId; + taskId: string; + operation: string; + state: PluginTaskState; + attempt: number; + timestamp: string; + error?: PluginTaskError; +} + +/** 插件任务记录 */ +export interface PluginTaskRecord { + taskId: string; + pluginId: PluginId; + operation: string; + state: PluginTaskState; + attempt: number; + maxRetries: number; + startedAt: string; + endedAt?: string; + durationMs?: number; + error?: PluginTaskError; +} + // ============================================================================ // 消息类型 (Client → Server) // ============================================================================ diff --git a/src/lib/plugin-ui/usePluginUI.ts b/src/lib/plugin-ui/usePluginUI.ts index 4772e7e2d..aa6b490b2 100644 --- a/src/lib/plugin-ui/usePluginUI.ts +++ b/src/lib/plugin-ui/usePluginUI.ts @@ -11,6 +11,7 @@ import { surfaceManager, SurfaceManager } from "./SurfaceManager"; import { initPluginUI } from "./index"; import type { PluginId, + PluginTaskEventPayload, SurfaceId, SurfaceState, ServerMessage, @@ -31,6 +32,8 @@ interface UsePluginUIOptions { interface UsePluginUIResult { /** 插件的所有 Surface */ surfaces: SurfaceState[]; + /** 插件任务事件(用于状态可观测) */ + taskEvents: PluginTaskEventPayload[]; /** 是否正在加载 */ loading: boolean; /** 错误信息 */ @@ -49,6 +52,7 @@ export function usePluginUI(options: UsePluginUIOptions): UsePluginUIResult { const { pluginId, autoInit = true, manager = surfaceManager } = options; const [surfaces, setSurfaces] = useState([]); + const [taskEvents, setTaskEvents] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const initializedRef = useRef(false); @@ -73,6 +77,41 @@ export function usePluginUI(options: UsePluginUIOptions): UsePluginUIResult { return unsubscribe; }, [pluginId, manager]); + // 监听插件任务事件 + useEffect(() => { + let unlisten: UnlistenFn | null = null; + + const setupTaskListener = async () => { + try { + unlisten = await safeListen( + "plugin-task-event", + (event) => { + if (event.payload.pluginId !== pluginId) { + return; + } + setTaskEvents((prev) => { + const next = [...prev, event.payload]; + if (next.length > 100) { + return next.slice(next.length - 100); + } + return next; + }); + }, + ); + } catch (err) { + console.error("[usePluginUI] 监听任务事件失败:", err); + } + }; + + setupTaskListener(); + + return () => { + if (unlisten) { + unlisten(); + } + }; + }, [pluginId]); + // 监听来自 Rust 的 UI 消息 useEffect(() => { let unlisten: UnlistenFn | null = null; @@ -178,6 +217,7 @@ export function usePluginUI(options: UsePluginUIOptions): UsePluginUIResult { return { surfaces, + taskEvents, loading, error, handleAction, diff --git a/src/lib/tauri-mock/core.ts b/src/lib/tauri-mock/core.ts index dd524893b..f8e6776fd 100644 --- a/src/lib/tauri-mock/core.ts +++ b/src/lib/tauri-mock/core.ts @@ -131,7 +131,8 @@ const defaultMocks: Record = { success: true, reused: false, browser_source: "system", - browser_path: "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", + browser_path: + "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", profile_dir: "/tmp/proxycast/chrome_profiles/search_google", remote_debugging_port: 13001, pid: 12345, @@ -172,12 +173,11 @@ const defaultMocks: Record = { auto_fallback: true, }), set_browser_backend_policy: (args: any) => ({ - priority: - args?.policy?.priority ?? [ - "aster_compat", - "proxycast_extension_bridge", - "cdp_direct", - ], + priority: args?.policy?.priority ?? [ + "aster_compat", + "proxycast_extension_bridge", + "cdp_direct", + ], auto_fallback: args?.policy?.auto_fallback ?? true, }), get_browser_backends_status: () => ({ @@ -321,6 +321,10 @@ const defaultMocks: Record = { unload_plugin: () => ({ success: true }), uninstall_plugin: () => ({ success: true }), launch_plugin_ui: () => ({}), + list_plugin_tasks: () => [], + get_plugin_task: () => null, + cancel_plugin_task: () => true, + get_plugin_queue_stats: () => [], // 凭证池相关 get_relay_providers: () => [],