mirror of
https://github.com/aiclientproxy/proxycast.git
synced 2026-09-24 23:10:56 +08:00
release: bump version to v0.75.0
This commit is contained in:
+5
-5
@@ -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
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "proxycast",
|
||||
"private": true,
|
||||
"version": "0.74.0",
|
||||
"version": "0.75.0",
|
||||
"type": "module",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
Generated
+15
-15
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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<String, PluginConfig>,
|
||||
/// 管理器配置
|
||||
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<PluginTaskState>,
|
||||
limit: usize,
|
||||
) -> Vec<PluginTaskRecord> {
|
||||
self.task_tracker.list_tasks(plugin_id, state, limit)
|
||||
}
|
||||
|
||||
/// 获取插件任务详情
|
||||
pub fn get_task(&self, task_id: &str) -> Option<PluginTaskRecord> {
|
||||
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<PluginQueueStats> {
|
||||
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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<Self, Self::Err> {
|
||||
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<String>,
|
||||
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<Utc>,
|
||||
pub ended_at: Option<DateTime<Utc>>,
|
||||
pub duration_ms: Option<u64>,
|
||||
pub error: Option<PluginTaskError>,
|
||||
}
|
||||
|
||||
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<PluginTaskError>,
|
||||
}
|
||||
|
||||
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<QueueMetrics>,
|
||||
}
|
||||
|
||||
impl RunningGuard {
|
||||
fn new(metrics: Arc<QueueMetrics>) -> Self {
|
||||
Self { metrics }
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for RunningGuard {
|
||||
fn drop(&mut self) {
|
||||
self.metrics.running.fetch_sub(1, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
|
||||
/// 插件任务跟踪器
|
||||
pub struct PluginTaskTracker {
|
||||
tasks: DashMap<String, PluginTaskRecord>,
|
||||
semaphores: DashMap<String, Arc<Semaphore>>,
|
||||
queue_metrics: DashMap<String, Arc<QueueMetrics>>,
|
||||
cancel_flags: DashMap<String, Arc<AtomicBool>>,
|
||||
retention_limit: usize,
|
||||
emitter: Arc<RwLock<Option<DynEmitter>>>,
|
||||
}
|
||||
|
||||
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<PluginTaskRecord> {
|
||||
self.tasks.get(task_id).map(|entry| entry.value().clone())
|
||||
}
|
||||
|
||||
pub fn list_tasks(
|
||||
&self,
|
||||
plugin_id: Option<&str>,
|
||||
state: Option<PluginTaskState>,
|
||||
limit: usize,
|
||||
) -> Vec<PluginTaskRecord> {
|
||||
let mut records: Vec<PluginTaskRecord> = 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<PluginQueueStats> {
|
||||
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<T, F, Fut>(
|
||||
&self,
|
||||
plugin_id: &str,
|
||||
operation: &str,
|
||||
mut policy: PluginTaskPolicy,
|
||||
mut operation_fn: F,
|
||||
) -> Result<T, PluginTaskFailure>
|
||||
where
|
||||
T: Send + 'static,
|
||||
F: FnMut(u32) -> Fut + Send,
|
||||
Fut: Future<Output = Result<T, PluginError>> + 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<String> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<Option<BatchTask>> {
|
||||
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<Vec<BatchTask>> {
|
||||
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<bool> {
|
||||
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<chrono::DateTime<chrono::Utc>>,
|
||||
completed_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||
) -> 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<Option<TaskTemplate>> {
|
||||
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<Vec<TaskTemplate>> {
|
||||
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<bool> {
|
||||
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",
|
||||
|
||||
@@ -232,7 +232,7 @@ pub fn init_states(config: &Config) -> Result<AppStates, String> {
|
||||
|
||||
// 初始化默认技能仓库
|
||||
{
|
||||
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}"))?;
|
||||
}
|
||||
|
||||
@@ -240,6 +240,21 @@ pub fn run() {
|
||||
tracing::info!("[启动] MCP Manager 事件发射器已设置");
|
||||
}
|
||||
|
||||
// 设置 PluginManager 的任务事件发射器(用于发送 plugin-task-event)
|
||||
if let Some(plugin_manager) =
|
||||
app.try_state::<crate::commands::plugin_cmd::PluginManagerState>()
|
||||
{
|
||||
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,
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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<FeedbackStats, String> {
|
||||
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);
|
||||
|
||||
@@ -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<UnifiedMemory> = {
|
||||
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;
|
||||
|
||||
@@ -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<String>) -> Result<Option<PluginTaskState>, String> {
|
||||
let Some(state) = state else {
|
||||
return Ok(None);
|
||||
};
|
||||
state
|
||||
.parse::<PluginTaskState>()
|
||||
.map(Some)
|
||||
.map_err(|e| format!("解析任务状态失败: {e}"))
|
||||
}
|
||||
|
||||
/// 查询插件任务列表
|
||||
#[tauri::command]
|
||||
pub async fn list_plugin_tasks(
|
||||
state: tauri::State<'_, PluginManagerState>,
|
||||
plugin_id: Option<String>,
|
||||
task_state: Option<String>,
|
||||
limit: Option<usize>,
|
||||
) -> Result<Vec<PluginTaskRecord>, 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<Option<PluginTaskRecord>, 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<bool, String> {
|
||||
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<String>,
|
||||
) -> Result<Vec<PluginQueueStats>, String> {
|
||||
let manager = state.0.read().await;
|
||||
Ok(manager.get_queue_stats(plugin_id.as_deref()))
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 插件 UI 注册系统
|
||||
// ============================================================================
|
||||
|
||||
@@ -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<String, String
|
||||
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!("kiro_{}_{}_{}.json", &uuid[..8], timestamp, "kiro");
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "ProxyCast",
|
||||
"version": "0.74.0",
|
||||
"version": "0.75.0",
|
||||
"identifier": "com.proxycast.app",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
- 为已安装插件列表提供右键菜单
|
||||
- 支持启用/禁用插件
|
||||
- 支持打开插件目录
|
||||
|
||||
@@ -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<ComponentProps<typeof WorkbenchPage>> = {},
|
||||
): 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(<WorkbenchPage theme="social-media" {...props} />);
|
||||
function renderDefaultWorkspacePage(
|
||||
props: Partial<ComponentProps<typeof WorkbenchPage>> = {},
|
||||
) {
|
||||
return renderPage({
|
||||
...DEFAULT_WORKSPACE_PAGE_PROPS,
|
||||
...props,
|
||||
});
|
||||
|
||||
mountedRoots.push({ container, root });
|
||||
return { container, root };
|
||||
}
|
||||
|
||||
async function flushEffects(times = 3): Promise<void> {
|
||||
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<void> {
|
||||
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();
|
||||
|
||||
@@ -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<typeof WorkbenchCreateContentDialog>;
|
||||
|
||||
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> = {},
|
||||
): 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<ComponentProps<typeof WorkbenchCreateContentDialog>> = {},
|
||||
): RenderResult {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
<WorkbenchCreateContentDialog
|
||||
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}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
const rendered = { container, root };
|
||||
mountedRoots.push(rendered);
|
||||
return rendered;
|
||||
overrides: Partial<ContentDialogProps> = {},
|
||||
) {
|
||||
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);
|
||||
|
||||
@@ -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<typeof WorkbenchCreateProjectDialog>;
|
||||
|
||||
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> = {},
|
||||
): 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<ComponentProps<typeof WorkbenchCreateProjectDialog>> = {},
|
||||
): RenderResult {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
<WorkbenchCreateProjectDialog
|
||||
open={true}
|
||||
creatingProject={false}
|
||||
newProjectName="小说项目A"
|
||||
projectTypeLabel="小说创作"
|
||||
workspaceProjectsRoot="/tmp/workspace"
|
||||
resolvedProjectPath="/tmp/workspace/小说项目A"
|
||||
pathChecking={false}
|
||||
pathConflictMessage=""
|
||||
onOpenChange={() => {}}
|
||||
onProjectNameChange={() => {}}
|
||||
onCreateProject={() => {}}
|
||||
{...overrides}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
const rendered = { container, root };
|
||||
mountedRoots.push(rendered);
|
||||
return rendered;
|
||||
overrides: Partial<ProjectDialogProps> = {},
|
||||
) {
|
||||
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);
|
||||
|
||||
@@ -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<TProps> 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<TProps>(
|
||||
Component: ComponentType<TProps>,
|
||||
initialProps: TProps,
|
||||
mountedRoots: MountedRoot[],
|
||||
): MountedRenderResult<TProps> {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
|
||||
const rerender = (props: TProps) => {
|
||||
act(() => {
|
||||
root.render(
|
||||
createElement(
|
||||
Component as ComponentType<Record<string, unknown>>,
|
||||
props as Record<string, unknown>,
|
||||
),
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
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<Element>;
|
||||
};
|
||||
|
||||
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<void> {
|
||||
for (let i = 0; i < times; i += 1) {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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 (
|
||||
<div
|
||||
data-create-project-open={String(dialogs.createProjectDialogOpen)}
|
||||
data-create-content-open={String(dialogs.createContentDialogOpen)}
|
||||
data-create-content-step={dialogs.createContentDialogStep}
|
||||
data-new-project-name={dialogs.newProjectName}
|
||||
data-workspace-root={dialogs.workspaceProjectsRoot}
|
||||
data-resolved-project-path={dialogs.resolvedProjectPath}
|
||||
data-path-checking={String(dialogs.pathChecking)}
|
||||
data-path-conflict-message={dialogs.pathConflictMessage}
|
||||
data-creating-project={String(dialogs.creatingProject)}
|
||||
data-creating-content={String(dialogs.creatingContent)}
|
||||
data-creation-intent-error={dialogs.creationIntentError}
|
||||
data-current-intent-length={String(dialogs.currentIntentLength)}
|
||||
data-pending-prompts={JSON.stringify(dialogs.pendingInitialPromptsByContentId)}
|
||||
data-content-modes={JSON.stringify(dialogs.contentCreationModes)}
|
||||
>
|
||||
<button
|
||||
data-testid="open-project-dialog"
|
||||
onClick={dialogs.handleOpenCreateProjectDialog}
|
||||
/>
|
||||
<button
|
||||
data-testid="close-project-dialog"
|
||||
onClick={() => dialogs.setCreateProjectDialogOpen(false)}
|
||||
/>
|
||||
<button
|
||||
data-testid="set-project-name-new"
|
||||
onClick={() => dialogs.setNewProjectName("新项目A")}
|
||||
/>
|
||||
<button
|
||||
data-testid="set-project-name-conflict"
|
||||
onClick={() => dialogs.setNewProjectName("冲突项目")}
|
||||
/>
|
||||
<button
|
||||
data-testid="create-project"
|
||||
onClick={() => {
|
||||
void dialogs.handleCreateProject();
|
||||
}}
|
||||
/>
|
||||
|
||||
<button
|
||||
data-testid="open-content-dialog"
|
||||
onClick={dialogs.handleOpenCreateContentDialog}
|
||||
/>
|
||||
<button data-testid="goto-intent" onClick={dialogs.handleGoToIntentStep} />
|
||||
<button
|
||||
data-testid="fill-intent-topic"
|
||||
onClick={() =>
|
||||
dialogs.handleCreationIntentValueChange("topic", "这是一个足够详细的创作主题")
|
||||
}
|
||||
/>
|
||||
<button
|
||||
data-testid="create-content"
|
||||
onClick={() => {
|
||||
void dialogs.handleCreateContent();
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
data-testid="consume-prompt"
|
||||
onClick={() => dialogs.consumePendingInitialPrompt("content-new")}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const mountedRoots: MountedRoot[] = [];
|
||||
|
||||
function createHarnessProps(overrides: Partial<HarnessProps> = {}): 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<HarnessProps> = {}) {
|
||||
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<void> {
|
||||
click(container, "open-content-dialog");
|
||||
await flushEffects();
|
||||
click(container, "goto-intent");
|
||||
await flushEffects();
|
||||
}
|
||||
|
||||
function parseRootDatasetRecord(
|
||||
root: HTMLElement | null,
|
||||
key: string,
|
||||
): Record<string, string> {
|
||||
const value = root?.dataset[key] ?? "{}";
|
||||
return JSON.parse(value) as Record<string, string>;
|
||||
}
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown>).creationMode);
|
||||
}
|
||||
|
||||
function useWorkspaceProjectsRootLoader(
|
||||
setWorkspaceProjectsRoot: Dispatch<SetStateAction<string>>,
|
||||
): 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<SetStateAction<string>>;
|
||||
}
|
||||
|
||||
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<SetStateAction<boolean>>;
|
||||
setPathConflictMessage: Dispatch<SetStateAction<string>>;
|
||||
}
|
||||
|
||||
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<string, CreationMode>;
|
||||
setContentCreationModes: Dispatch<SetStateAction<Record<string, CreationMode>>>;
|
||||
}
|
||||
|
||||
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<string, CreationMode>
|
||||
>({});
|
||||
|
||||
const resetProjectPathState = useCallback(() => {
|
||||
setResolvedProjectPath("");
|
||||
setPathChecking(false);
|
||||
setPathConflictMessage("");
|
||||
}, []);
|
||||
|
||||
const creationIntentInput = useMemo<CreationIntentInput>(
|
||||
() => ({
|
||||
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<string, unknown>).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,
|
||||
|
||||
@@ -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<string, unknown> = {},
|
||||
): Record<string, unknown> {
|
||||
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 (
|
||||
<div
|
||||
data-current-content-title={controller.currentContentTitle ?? ""}
|
||||
data-project-type-label={controller.projectTypeLabel}
|
||||
>
|
||||
<button
|
||||
data-testid="quick-save"
|
||||
onClick={() => {
|
||||
void controller.handleQuickSaveCurrent();
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
data-testid="enter-workspace"
|
||||
onClick={() =>
|
||||
controller.handleEnterWorkspace("content-new", { showChatPanel: false })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const mountedRoots: MountedRoot[] = [];
|
||||
|
||||
function renderHarness(props: Partial<ControllerHarnessProps> = {}) {
|
||||
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<string, unknown>;
|
||||
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<typeof vi.fn>;
|
||||
const applyInitialNavigationState =
|
||||
navigation.applyInitialNavigationState as ReturnType<typeof vi.fn>;
|
||||
|
||||
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<string, unknown>;
|
||||
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<typeof vi.fn>;
|
||||
const setActiveWorkspaceView =
|
||||
navigation.setActiveWorkspaceView as ReturnType<typeof vi.fn>;
|
||||
const setShowChatPanel = navigation.setShowChatPanel as ReturnType<typeof vi.fn>;
|
||||
const setActiveRightDrawer =
|
||||
navigation.setActiveRightDrawer as ReturnType<typeof vi.fn>;
|
||||
const setLeftSidebarCollapsed =
|
||||
store.setLeftSidebarCollapsed as ReturnType<typeof vi.fn>;
|
||||
|
||||
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<string, unknown>;
|
||||
const loadContents = projectData.loadContents as ReturnType<typeof vi.fn>;
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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<void>;
|
||||
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<void>;
|
||||
projects: Array<unknown>;
|
||||
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 || "已选文稿"
|
||||
|
||||
@@ -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 (
|
||||
<div
|
||||
data-mode={navigation.workspaceMode}
|
||||
data-view={navigation.activeWorkspaceView}
|
||||
data-drawer={navigation.activeRightDrawer ?? "none"}
|
||||
data-show-workflow-rail={String(navigation.showWorkflowRail)}
|
||||
data-show-chat={String(navigation.showChatPanel)}
|
||||
data-should-render-left-sidebar={String(navigation.shouldRenderLeftSidebar)}
|
||||
data-is-create-view={String(navigation.isCreateWorkspaceView)}
|
||||
data-right-rail={String(navigation.shouldRenderWorkspaceRightRail)}
|
||||
data-view-label={navigation.activeWorkspaceViewLabel}
|
||||
>
|
||||
<button
|
||||
data-testid="apply-project-detail"
|
||||
onClick={() => navigation.applyInitialNavigationState("project-detail")}
|
||||
/>
|
||||
<button
|
||||
data-testid="open-workflow"
|
||||
onClick={navigation.handleOpenWorkflowView}
|
||||
/>
|
||||
<button
|
||||
data-testid="back-project-management"
|
||||
onClick={navigation.handleBackToProjectManagement}
|
||||
/>
|
||||
<button
|
||||
data-testid="enter-publish"
|
||||
onClick={() => navigation.handleEnterWorkspaceView("publish")}
|
||||
/>
|
||||
<button
|
||||
data-testid="switch-publish"
|
||||
onClick={() => navigation.handleSwitchWorkspaceView("publish")}
|
||||
/>
|
||||
<button
|
||||
data-testid="switch-create"
|
||||
onClick={() => navigation.handleSwitchWorkspaceView("create")}
|
||||
/>
|
||||
<button
|
||||
data-testid="prepare-tools-state"
|
||||
onClick={() => {
|
||||
navigation.setActiveRightDrawer("tools");
|
||||
navigation.setShowWorkflowRail(true);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const mountedRoots: MountedRoot[] = [];
|
||||
|
||||
function createHarnessProps(
|
||||
overrides: Partial<NavigationHarnessProps> = {},
|
||||
): 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<NavigationHarnessProps> = {}) {
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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 (
|
||||
<div
|
||||
data-selected-project-id={data.selectedProjectId ?? ""}
|
||||
data-selected-content-id={data.selectedContentId ?? ""}
|
||||
data-project-query={data.projectQuery}
|
||||
data-content-query={data.contentQuery}
|
||||
data-project-ids={data.projects.map((item) => 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(",")}
|
||||
>
|
||||
<button
|
||||
data-testid="load-projects"
|
||||
onClick={() => {
|
||||
void data.loadProjects();
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
data-testid="load-contents-project-a"
|
||||
onClick={() => {
|
||||
void data.loadContents("project-a");
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
data-testid="set-selected-project-manual"
|
||||
onClick={() => data.setSelectedProjectId("project-manual")}
|
||||
/>
|
||||
<button
|
||||
data-testid="set-selected-content-manual"
|
||||
onClick={() => data.setSelectedContentId("content-manual")}
|
||||
/>
|
||||
<button
|
||||
data-testid="set-project-query-manual"
|
||||
onClick={() => data.setProjectQuery("manual")}
|
||||
/>
|
||||
<button
|
||||
data-testid="set-content-query-manual"
|
||||
onClick={() => data.setContentQuery("manual")}
|
||||
/>
|
||||
<button
|
||||
data-testid="reset-queries"
|
||||
onClick={data.resetProjectAndContentQueries}
|
||||
/>
|
||||
<button
|
||||
data-testid="clear-contents-selection"
|
||||
onClick={data.clearContentsSelection}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const mountedRoots: MountedRoot[] = [];
|
||||
|
||||
function renderHarness(props: Partial<HarnessProps> = {}) {
|
||||
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("");
|
||||
});
|
||||
});
|
||||
@@ -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 (
|
||||
<div>
|
||||
<div data-testid="action-count">{nonCreateQuickActions.length}</div>
|
||||
<div data-testid="action-keys">
|
||||
{nonCreateQuickActions.map((action) => action.key).join(",")}
|
||||
</div>
|
||||
<div data-testid="action-labels">
|
||||
{nonCreateQuickActions.map((action) => action.label).join("|")}
|
||||
</div>
|
||||
{nonCreateQuickActions.map((action) => (
|
||||
<button key={action.key} data-key={action.key} onClick={action.onClick}>
|
||||
{action.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const mountedRoots: MountedRoot[] = [];
|
||||
|
||||
function createHarnessProps(
|
||||
overrides: Partial<QuickActionsHarnessProps> = {},
|
||||
): 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<QuickActionsHarnessProps> = {}) {
|
||||
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("快速保存当前文稿");
|
||||
});
|
||||
});
|
||||
@@ -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>,
|
||||
): Project {
|
||||
return {
|
||||
isDefault: false,
|
||||
createdAt: WORKSPACE_FIXTURE_TIMESTAMP,
|
||||
updatedAt: WORKSPACE_FIXTURE_TIMESTAMP,
|
||||
isFavorite: false,
|
||||
isArchived: false,
|
||||
tags: [],
|
||||
...data,
|
||||
};
|
||||
}
|
||||
|
||||
type ContentFixtureRequired = Pick<ContentListItem, "id" | "project_id" | "title">;
|
||||
|
||||
export function createWorkspaceContentFixture(
|
||||
data: ContentFixtureRequired & Partial<ContentListItem>,
|
||||
): ContentListItem {
|
||||
return {
|
||||
content_type: "post",
|
||||
status: "draft",
|
||||
order: 0,
|
||||
word_count: 0,
|
||||
created_at: WORKSPACE_FIXTURE_TIMESTAMP,
|
||||
updated_at: WORKSPACE_FIXTURE_TIMESTAMP,
|
||||
...data,
|
||||
};
|
||||
}
|
||||
@@ -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)
|
||||
// ============================================================================
|
||||
|
||||
@@ -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<SurfaceState[]>([]);
|
||||
const [taskEvents, setTaskEvents] = useState<PluginTaskEventPayload[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(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<PluginTaskEventPayload>(
|
||||
"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,
|
||||
|
||||
@@ -131,7 +131,8 @@ const defaultMocks: Record<string, any> = {
|
||||
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<string, any> = {
|
||||
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<string, any> = {
|
||||
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: () => [],
|
||||
|
||||
Reference in New Issue
Block a user