mirror of
https://github.com/aiclientproxy/proxycast.git
synced 2026-09-24 23:10:56 +08:00
feat: 添加渠道管理功能并修复 workspace_mismatch 兼容性问题
- 新增渠道管理页面(AI 模型提供商 + 消息通知渠道) - 支持飞书、Telegram、Discord 通知渠道配置 - 新增 migration_v4:启动时自动修复 [object Promise] 路径污染 - 新增 migration_v4:自动统一旧会话 working_dir 到默认 workspace - 修复老用户升级后出现的 workspace_mismatch 错误 Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
54611cd3ab
commit
8fec0f1088
@@ -0,0 +1,232 @@
|
|||||||
|
//! 修复 [object Promise] 路径污染问题 + 统一会话工作目录
|
||||||
|
//!
|
||||||
|
//! 历史 bug:
|
||||||
|
//! 1. 前端代码未正确 await Promise,导致 root_path 和 working_dir
|
||||||
|
//! 被存储为 "[object Promise]/Project-xxx" 格式的字符串。
|
||||||
|
//! 2. 旧会话的 working_dir 指向不同的项目目录,与默认 workspace 不匹配。
|
||||||
|
//!
|
||||||
|
//! 本迁移自动检测并修复这些问题。
|
||||||
|
|
||||||
|
use rusqlite::{params, Connection};
|
||||||
|
|
||||||
|
/// 迁移设置键名
|
||||||
|
const MIGRATION_KEY_FIX_PROMISE_PATHS: &str = "migrated_fix_promise_paths_v1";
|
||||||
|
const MIGRATION_KEY_UNIFY_SESSION_DIRS: &str = "migrated_unify_session_dirs_v1";
|
||||||
|
|
||||||
|
/// 默认项目根目录(用于替换损坏的路径)
|
||||||
|
const DEFAULT_PROJECTS_DIR: &str = ".proxycast/projects";
|
||||||
|
|
||||||
|
/// 迁移结果
|
||||||
|
pub struct MigrationResult {
|
||||||
|
/// 是否执行了迁移
|
||||||
|
pub executed: bool,
|
||||||
|
/// 修复的 workspace 数量
|
||||||
|
pub fixed_workspaces: usize,
|
||||||
|
/// 修复的 agent_session 数量
|
||||||
|
pub fixed_sessions: usize,
|
||||||
|
/// 统一到默认 workspace 的会话数量
|
||||||
|
pub unified_sessions: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 执行 [object Promise] 路径修复迁移
|
||||||
|
pub fn migrate_fix_promise_paths(conn: &Connection) -> Result<MigrationResult, String> {
|
||||||
|
// 检查是否已经迁移过
|
||||||
|
let promise_done = is_migration_completed(conn, MIGRATION_KEY_FIX_PROMISE_PATHS);
|
||||||
|
let unify_done = is_migration_completed(conn, MIGRATION_KEY_UNIFY_SESSION_DIRS);
|
||||||
|
|
||||||
|
if promise_done && unify_done {
|
||||||
|
tracing::debug!("[迁移] Promise 路径修复和会话目录统一已执行过,跳过");
|
||||||
|
return Ok(MigrationResult {
|
||||||
|
executed: false,
|
||||||
|
fixed_workspaces: 0,
|
||||||
|
fixed_sessions: 0,
|
||||||
|
unified_sessions: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取用户主目录
|
||||||
|
let home = dirs::home_dir()
|
||||||
|
.map(|p| p.to_string_lossy().to_string())
|
||||||
|
.unwrap_or_else(|| "/Users/unknown".to_string());
|
||||||
|
let default_path = format!("{}/{}", home, DEFAULT_PROJECTS_DIR);
|
||||||
|
|
||||||
|
// 开始事务
|
||||||
|
conn.execute("BEGIN TRANSACTION", [])
|
||||||
|
.map_err(|e| format!("开始事务失败: {e}"))?;
|
||||||
|
|
||||||
|
let result = execute_migration(conn, &default_path, promise_done, unify_done);
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok((fixed_ws, fixed_sess, unified_sess)) => {
|
||||||
|
if !promise_done {
|
||||||
|
mark_migration_completed(conn, MIGRATION_KEY_FIX_PROMISE_PATHS)?;
|
||||||
|
}
|
||||||
|
if !unify_done {
|
||||||
|
mark_migration_completed(conn, MIGRATION_KEY_UNIFY_SESSION_DIRS)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
conn.execute("COMMIT", [])
|
||||||
|
.map_err(|e| format!("提交事务失败: {e}"))?;
|
||||||
|
|
||||||
|
if fixed_ws > 0 || fixed_sess > 0 {
|
||||||
|
tracing::info!(
|
||||||
|
"[迁移] Promise 路径修复完成: 修复 workspaces={}, sessions={}",
|
||||||
|
fixed_ws,
|
||||||
|
fixed_sess
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if unified_sess > 0 {
|
||||||
|
tracing::info!(
|
||||||
|
"[迁移] 会话目录统一完成: 统一 {} 个会话到默认 workspace",
|
||||||
|
unified_sess
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(MigrationResult {
|
||||||
|
executed: fixed_ws > 0 || fixed_sess > 0 || unified_sess > 0,
|
||||||
|
fixed_workspaces: fixed_ws,
|
||||||
|
fixed_sessions: fixed_sess,
|
||||||
|
unified_sessions: unified_sess,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
let _ = conn.execute("ROLLBACK", []);
|
||||||
|
tracing::error!("[迁移] 路径修复和会话统一失败,已回滚: {}", e);
|
||||||
|
Err(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn execute_migration(
|
||||||
|
conn: &Connection,
|
||||||
|
default_path: &str,
|
||||||
|
promise_done: bool,
|
||||||
|
unify_done: bool,
|
||||||
|
) -> Result<(usize, usize, usize), String> {
|
||||||
|
let mut fixed_ws = 0;
|
||||||
|
let mut fixed_sess = 0;
|
||||||
|
let mut unified_sess = 0;
|
||||||
|
|
||||||
|
// 步骤 1: 修复 [object Promise] 路径污染(如果未完成)
|
||||||
|
if !promise_done {
|
||||||
|
let workspace_count = count_corrupted_workspaces(conn);
|
||||||
|
let session_count = count_corrupted_sessions(conn);
|
||||||
|
|
||||||
|
if workspace_count > 0 || session_count > 0 {
|
||||||
|
tracing::info!(
|
||||||
|
"[迁移] 发现 Promise 路径污染数据: workspaces={}, sessions={},开始修复",
|
||||||
|
workspace_count,
|
||||||
|
session_count
|
||||||
|
);
|
||||||
|
|
||||||
|
// 修复 workspaces 表的 root_path
|
||||||
|
fixed_ws = conn
|
||||||
|
.execute(
|
||||||
|
"UPDATE workspaces SET root_path = REPLACE(root_path, '[object Promise]', ?1) \
|
||||||
|
WHERE root_path LIKE '%[object Promise]%'",
|
||||||
|
params![default_path],
|
||||||
|
)
|
||||||
|
.map_err(|e| format!("修复 workspaces.root_path 失败: {e}"))?;
|
||||||
|
|
||||||
|
// 修复 agent_sessions 表的 working_dir
|
||||||
|
fixed_sess = conn
|
||||||
|
.execute(
|
||||||
|
"UPDATE agent_sessions SET working_dir = REPLACE(working_dir, '[object Promise]', ?1) \
|
||||||
|
WHERE working_dir LIKE '%[object Promise]%'",
|
||||||
|
params![default_path],
|
||||||
|
)
|
||||||
|
.map_err(|e| format!("修复 agent_sessions.working_dir 失败: {e}"))?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 步骤 2: 统一所有会话的 working_dir 到默认 workspace(如果未完成)
|
||||||
|
if !unify_done {
|
||||||
|
// 获取默认 workspace 的 root_path
|
||||||
|
let default_workspace_path = get_default_workspace_path(conn)?;
|
||||||
|
|
||||||
|
if let Some(default_ws_path) = default_workspace_path {
|
||||||
|
// 统计需要更新的会话数量
|
||||||
|
let mismatched_count: i64 = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT COUNT(*) FROM agent_sessions \
|
||||||
|
WHERE working_dir IS NOT NULL \
|
||||||
|
AND working_dir != '' \
|
||||||
|
AND working_dir != ?1",
|
||||||
|
params![&default_ws_path],
|
||||||
|
|row| row.get(0),
|
||||||
|
)
|
||||||
|
.unwrap_or(0);
|
||||||
|
|
||||||
|
if mismatched_count > 0 {
|
||||||
|
tracing::info!(
|
||||||
|
"[迁移] 发现 {} 个会话的 working_dir 与默认 workspace 不匹配,开始统一",
|
||||||
|
mismatched_count
|
||||||
|
);
|
||||||
|
|
||||||
|
// 将所有会话的 working_dir 统一到默认 workspace
|
||||||
|
unified_sess = conn
|
||||||
|
.execute(
|
||||||
|
"UPDATE agent_sessions SET working_dir = ?1 \
|
||||||
|
WHERE working_dir IS NOT NULL \
|
||||||
|
AND working_dir != '' \
|
||||||
|
AND working_dir != ?1",
|
||||||
|
params![&default_ws_path],
|
||||||
|
)
|
||||||
|
.map_err(|e| format!("统一会话 working_dir 失败: {e}"))?;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
tracing::warn!("[迁移] 未找到默认 workspace,跳过会话目录统一");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok((fixed_ws, fixed_sess, unified_sess))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取默认 workspace 的 root_path
|
||||||
|
fn get_default_workspace_path(conn: &Connection) -> Result<Option<String>, String> {
|
||||||
|
let result = conn.query_row(
|
||||||
|
"SELECT root_path FROM workspaces WHERE is_default = 1 LIMIT 1",
|
||||||
|
[],
|
||||||
|
|row| row.get::<_, String>(0),
|
||||||
|
);
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok(path) => Ok(Some(path)),
|
||||||
|
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
|
||||||
|
Err(e) => Err(format!("查询默认 workspace 失败: {e}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn count_corrupted_workspaces(conn: &Connection) -> i64 {
|
||||||
|
conn.query_row(
|
||||||
|
"SELECT COUNT(*) FROM workspaces WHERE root_path LIKE '%[object Promise]%'",
|
||||||
|
[],
|
||||||
|
|row| row.get::<_, i64>(0),
|
||||||
|
)
|
||||||
|
.unwrap_or(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn count_corrupted_sessions(conn: &Connection) -> i64 {
|
||||||
|
conn.query_row(
|
||||||
|
"SELECT COUNT(*) FROM agent_sessions WHERE working_dir LIKE '%[object Promise]%'",
|
||||||
|
[],
|
||||||
|
|row| row.get::<_, i64>(0),
|
||||||
|
)
|
||||||
|
.unwrap_or(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_migration_completed(conn: &Connection, key: &str) -> bool {
|
||||||
|
conn.query_row("SELECT value FROM settings WHERE key = ?1", [key], |row| {
|
||||||
|
row.get::<_, String>(0)
|
||||||
|
})
|
||||||
|
.is_ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mark_migration_completed(conn: &Connection, key: &str) -> Result<(), String> {
|
||||||
|
conn.execute(
|
||||||
|
"INSERT OR REPLACE INTO settings (key, value) VALUES (?1, ?2)",
|
||||||
|
[key, "1"],
|
||||||
|
)
|
||||||
|
.map_err(|e| format!("标记迁移完成失败: {e}"))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ pub mod dao;
|
|||||||
pub mod migration;
|
pub mod migration;
|
||||||
pub mod migration_v2;
|
pub mod migration_v2;
|
||||||
pub mod migration_v3;
|
pub mod migration_v3;
|
||||||
|
pub mod migration_v4;
|
||||||
pub mod schema;
|
pub mod schema;
|
||||||
pub mod system_providers;
|
pub mod system_providers;
|
||||||
|
|
||||||
@@ -144,5 +145,22 @@ pub fn init_database() -> Result<DbConnection, String> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 修复 [object Promise] 路径污染问题(历史 bug 遗留数据)
|
||||||
|
match migration_v4::migrate_fix_promise_paths(&conn) {
|
||||||
|
Ok(result) => {
|
||||||
|
if result.executed {
|
||||||
|
tracing::info!(
|
||||||
|
"[数据库] 路径修复和会话统一完成: workspaces={}, sessions={}, unified={}",
|
||||||
|
result.fixed_workspaces,
|
||||||
|
result.fixed_sessions,
|
||||||
|
result.unified_sessions
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!("[数据库] 路径修复和会话统一失败(非致命): {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Ok(Arc::new(Mutex::new(conn)))
|
Ok(Arc::new(Mutex::new(conn)))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -806,6 +806,19 @@ pub fn run() {
|
|||||||
// MCP 资源管理命令
|
// MCP 资源管理命令
|
||||||
commands::mcp_cmd::mcp_list_resources,
|
commands::mcp_cmd::mcp_list_resources,
|
||||||
commands::mcp_cmd::mcp_read_resource,
|
commands::mcp_cmd::mcp_read_resource,
|
||||||
|
// Channel commands
|
||||||
|
commands::channels_cmd::get_ai_channels,
|
||||||
|
commands::channels_cmd::get_ai_channel,
|
||||||
|
commands::channels_cmd::create_ai_channel,
|
||||||
|
commands::channels_cmd::update_ai_channel,
|
||||||
|
commands::channels_cmd::delete_ai_channel,
|
||||||
|
commands::channels_cmd::test_ai_channel,
|
||||||
|
commands::channels_cmd::get_notification_channels,
|
||||||
|
commands::channels_cmd::get_notification_channel,
|
||||||
|
commands::channels_cmd::create_notification_channel,
|
||||||
|
commands::channels_cmd::update_notification_channel,
|
||||||
|
commands::channels_cmd::delete_notification_channel,
|
||||||
|
commands::channels_cmd::test_notification_channel,
|
||||||
// Prompt commands
|
// Prompt commands
|
||||||
commands::prompt_cmd::get_prompts,
|
commands::prompt_cmd::get_prompts,
|
||||||
commands::prompt_cmd::upsert_prompt,
|
commands::prompt_cmd::upsert_prompt,
|
||||||
|
|||||||
@@ -0,0 +1,289 @@
|
|||||||
|
//! 渠道管理命令
|
||||||
|
//!
|
||||||
|
//! 提供渠道管理相关的 Tauri 命令,包括 AI 渠道和通知渠道的 CRUD 操作。
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use tauri::State;
|
||||||
|
|
||||||
|
use crate::AppState;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// 类型定义
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// AI 渠道配置
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct AIChannelConfig {
|
||||||
|
pub name: String,
|
||||||
|
pub engine: AIProviderEngine,
|
||||||
|
pub display_name: String,
|
||||||
|
pub description: Option<String>,
|
||||||
|
pub api_key_env: String,
|
||||||
|
pub base_url: String,
|
||||||
|
pub models: Vec<ModelInfo>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub headers: Option<serde_json::Value>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub timeout_seconds: Option<u32>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub supports_streaming: Option<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// AI 提供商引擎类型
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum AIProviderEngine {
|
||||||
|
OpenAI,
|
||||||
|
Ollama,
|
||||||
|
Anthropic,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 模型信息
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ModelInfo {
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub description: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// AI 渠道
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct AIChannel {
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub engine: AIProviderEngine,
|
||||||
|
pub display_name: String,
|
||||||
|
pub description: Option<String>,
|
||||||
|
pub api_key_env: String,
|
||||||
|
pub base_url: String,
|
||||||
|
pub models: Vec<ModelInfo>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub headers: Option<serde_json::Value>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub timeout_seconds: Option<u32>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub supports_streaming: Option<bool>,
|
||||||
|
pub enabled: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 通知渠道类型
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum NotificationChannelType {
|
||||||
|
Feishu,
|
||||||
|
Telegram,
|
||||||
|
Discord,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 通知渠道配置
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct NotificationChannelConfig {
|
||||||
|
pub name: String,
|
||||||
|
pub channel_type: NotificationChannelType,
|
||||||
|
pub config: NotificationChannelSpecificConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 通知渠道特定配置
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(tag = "type", rename_all = "lowercase")]
|
||||||
|
pub enum NotificationChannelSpecificConfig {
|
||||||
|
Feishu(FeishuConfig),
|
||||||
|
Telegram(TelegramConfig),
|
||||||
|
Discord(DiscordConfig),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 飞书配置
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct FeishuConfig {
|
||||||
|
pub webhook_url: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub secret: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Telegram 配置
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct TelegramConfig {
|
||||||
|
pub bot_token: String,
|
||||||
|
pub chat_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Discord 配置
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct DiscordConfig {
|
||||||
|
pub webhook_url: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub username: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 通知渠道
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct NotificationChannel {
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub channel_type: NotificationChannelType,
|
||||||
|
pub config: NotificationChannelSpecificConfig,
|
||||||
|
pub enabled: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 连接测试结果
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ConnectionTestResult {
|
||||||
|
pub success: bool,
|
||||||
|
pub message: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub details: Option<serde_json::Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 测试消息结果
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct TestMessageResult {
|
||||||
|
pub success: bool,
|
||||||
|
pub message: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// AI 渠道命令
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// 获取所有 AI 渠道
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_ai_channels(
|
||||||
|
_state: State<'_, AppState>,
|
||||||
|
) -> Result<Vec<AIChannel>, String> {
|
||||||
|
// TODO: 实现 AI 渠道获取逻辑
|
||||||
|
// 需要从 aster-rust 获取 DeclarativeProviderConfig 列表
|
||||||
|
tracing::info!("[渠道] 获取 AI 渠道列表");
|
||||||
|
Ok(vec![])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取单个 AI 渠道
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_ai_channel(
|
||||||
|
id: String,
|
||||||
|
_state: State<'_, AppState>,
|
||||||
|
) -> Result<AIChannel, String> {
|
||||||
|
tracing::info!("[渠道] 获取 AI 渠道: {}", id);
|
||||||
|
Err("暂未实现".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 创建 AI 渠道
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn create_ai_channel(
|
||||||
|
_config: AIChannelConfig,
|
||||||
|
_state: State<'_, AppState>,
|
||||||
|
) -> Result<AIChannel, String> {
|
||||||
|
tracing::info!("[渠道] 创建 AI 渠道: {}", _config.name);
|
||||||
|
// TODO: 实现创建逻辑
|
||||||
|
Err("暂未实现".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 更新 AI 渠道
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn update_ai_channel(
|
||||||
|
id: String,
|
||||||
|
_config: AIChannelConfig,
|
||||||
|
_state: State<'_, AppState>,
|
||||||
|
) -> Result<AIChannel, String> {
|
||||||
|
tracing::info!("[渠道] 更新 AI 渠道: {}", id);
|
||||||
|
// TODO: 实现更新逻辑
|
||||||
|
Err("暂未实现".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 删除 AI 渠道
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn delete_ai_channel(
|
||||||
|
id: String,
|
||||||
|
_state: State<'_, AppState>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
tracing::info!("[渠道] 删除 AI 渠道: {}", id);
|
||||||
|
// TODO: 实现删除逻辑
|
||||||
|
Err("暂未实现".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 测试 AI 渠道连接
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn test_ai_channel(
|
||||||
|
id: String,
|
||||||
|
_state: State<'_, AppState>,
|
||||||
|
) -> Result<ConnectionTestResult, String> {
|
||||||
|
tracing::info!("[渠道] 测试 AI 渠道连接: {}", id);
|
||||||
|
// TODO: 实现测试连接逻辑
|
||||||
|
Ok(ConnectionTestResult {
|
||||||
|
success: false,
|
||||||
|
message: "暂未实现".to_string(),
|
||||||
|
details: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// 通知渠道命令
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// 获取所有通知渠道
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_notification_channels(
|
||||||
|
_state: State<'_, AppState>,
|
||||||
|
) -> Result<Vec<NotificationChannel>, String> {
|
||||||
|
// TODO: 实现通知渠道获取逻辑
|
||||||
|
tracing::info!("[渠道] 获取通知渠道列表");
|
||||||
|
Ok(vec![])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取单个通知渠道
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_notification_channel(
|
||||||
|
id: String,
|
||||||
|
_state: State<'_, AppState>,
|
||||||
|
) -> Result<NotificationChannel, String> {
|
||||||
|
tracing::info!("[渠道] 获取通知渠道: {}", id);
|
||||||
|
Err("暂未实现".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 创建通知渠道
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn create_notification_channel(
|
||||||
|
_config: NotificationChannelConfig,
|
||||||
|
_state: State<'_, AppState>,
|
||||||
|
) -> Result<NotificationChannel, String> {
|
||||||
|
tracing::info!("[渠道] 创建通知渠道: {}", _config.name);
|
||||||
|
// TODO: 实现创建逻辑
|
||||||
|
Err("暂未实现".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 更新通知渠道
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn update_notification_channel(
|
||||||
|
id: String,
|
||||||
|
_config: NotificationChannelConfig,
|
||||||
|
_state: State<'_, AppState>,
|
||||||
|
) -> Result<NotificationChannel, String> {
|
||||||
|
tracing::info!("[渠道] 更新通知渠道: {}", id);
|
||||||
|
// TODO: 实现更新逻辑
|
||||||
|
Err("暂未实现".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 删除通知渠道
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn delete_notification_channel(
|
||||||
|
id: String,
|
||||||
|
_state: State<'_, AppState>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
tracing::info!("[渠道] 删除通知渠道: {}", id);
|
||||||
|
// TODO: 实现删除逻辑
|
||||||
|
Err("暂未实现".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 发送测试消息到通知渠道
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn test_notification_channel(
|
||||||
|
id: String,
|
||||||
|
message: String,
|
||||||
|
_state: State<'_, AppState>,
|
||||||
|
) -> Result<TestMessageResult, String> {
|
||||||
|
tracing::info!("[渠道] 测试通知渠道: {}, 消息: {}", id, message);
|
||||||
|
// TODO: 实现测试消息逻辑
|
||||||
|
Ok(TestMessageResult {
|
||||||
|
success: false,
|
||||||
|
message: "暂未实现".to_string(),
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
pub mod a2ui_form_cmd;
|
pub mod a2ui_form_cmd;
|
||||||
pub mod agent_cmd;
|
pub mod agent_cmd;
|
||||||
|
pub mod channels_cmd;
|
||||||
pub mod api_key_provider_cmd;
|
pub mod api_key_provider_cmd;
|
||||||
pub mod asr_cmd;
|
pub mod asr_cmd;
|
||||||
pub mod aster_agent_cmd;
|
pub mod aster_agent_cmd;
|
||||||
|
|||||||
@@ -132,6 +132,14 @@ pub async fn workspace_create(
|
|||||||
db: State<'_, DbConnection>,
|
db: State<'_, DbConnection>,
|
||||||
request: CreateWorkspaceRequest,
|
request: CreateWorkspaceRequest,
|
||||||
) -> Result<WorkspaceListItem, String> {
|
) -> Result<WorkspaceListItem, String> {
|
||||||
|
// 验证 root_path 不是 Promise 对象
|
||||||
|
if request.root_path.contains("[object Promise]") {
|
||||||
|
return Err(format!(
|
||||||
|
"无效的 root_path: {}。请确保前端正确 await 了 Promise。",
|
||||||
|
request.root_path
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
let manager = WorkspaceManager::new(db.inner().clone());
|
let manager = WorkspaceManager::new(db.inner().clone());
|
||||||
|
|
||||||
let workspace_type = request
|
let workspace_type = request
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ import { ProfileSettings } from "../account/profile";
|
|||||||
import { ProviderPoolPage } from "@/components/provider-pool";
|
import { ProviderPoolPage } from "@/components/provider-pool";
|
||||||
import { ApiServerPage } from "@/components/api-server/ApiServerPage";
|
import { ApiServerPage } from "@/components/api-server/ApiServerPage";
|
||||||
import { McpPanel } from "@/components/mcp";
|
import { McpPanel } from "@/components/mcp";
|
||||||
|
import { ChannelsSettings } from "../system/channels";
|
||||||
|
|
||||||
import { SettingHeader } from "../features/SettingHeader";
|
import { SettingHeader } from "../features/SettingHeader";
|
||||||
|
|
||||||
@@ -237,6 +238,14 @@ function renderSettingsContent(tab: SettingsTabs): ReactNode {
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
case SettingsTabs.Channels:
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SettingHeader title="渠道管理" />
|
||||||
|
<ChannelsSettings />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
case SettingsTabs.Proxy:
|
case SettingsTabs.Proxy:
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -321,6 +330,7 @@ const WIDE_CONTENT_TABS = new Set<SettingsTabs>([
|
|||||||
SettingsTabs.Providers,
|
SettingsTabs.Providers,
|
||||||
SettingsTabs.ApiServer,
|
SettingsTabs.ApiServer,
|
||||||
SettingsTabs.McpServer,
|
SettingsTabs.McpServer,
|
||||||
|
SettingsTabs.Channels,
|
||||||
SettingsTabs.ExecutionTracker,
|
SettingsTabs.ExecutionTracker,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
Mic,
|
Mic,
|
||||||
Server,
|
Server,
|
||||||
Plug,
|
Plug,
|
||||||
|
Route,
|
||||||
Globe,
|
Globe,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
HeartPulse,
|
HeartPulse,
|
||||||
@@ -151,6 +152,11 @@ export function useSettingsCategory(): CategoryGroup[] {
|
|||||||
label: t("settings.tab.mcpServer", "MCP 服务器"),
|
label: t("settings.tab.mcpServer", "MCP 服务器"),
|
||||||
icon: Plug,
|
icon: Plug,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: SettingsTabs.Channels,
|
||||||
|
label: t("settings.tab.channels", "渠道管理"),
|
||||||
|
icon: Route,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: SettingsTabs.Proxy,
|
key: SettingsTabs.Proxy,
|
||||||
label: t("settings.tab.proxy", "网络代理"),
|
label: t("settings.tab.proxy", "网络代理"),
|
||||||
|
|||||||
@@ -0,0 +1,416 @@
|
|||||||
|
/**
|
||||||
|
* AI 渠道表单模态框组件
|
||||||
|
*
|
||||||
|
* 用于添加或编辑 AI 渠道配置
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState, useCallback, useEffect } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Modal, ModalHeader, ModalBody, ModalFooter } from "@/components/Modal";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import {
|
||||||
|
type AIChannel,
|
||||||
|
type AIChannelConfig,
|
||||||
|
type ModelInfo,
|
||||||
|
AIProviderEngine,
|
||||||
|
} from "@/lib/api/channels";
|
||||||
|
|
||||||
|
export interface AIChannelFormModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onSubmit: (config: AIChannelConfig) => Promise<void>;
|
||||||
|
initialData?: AIChannel;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FormState {
|
||||||
|
name: string;
|
||||||
|
engine: AIProviderEngine;
|
||||||
|
display_name: string;
|
||||||
|
description: string;
|
||||||
|
api_key_env: string;
|
||||||
|
base_url: string;
|
||||||
|
models_json: string; // JSON 字符串格式存储模型列表
|
||||||
|
headers_json: string; // JSON 字符串格式存储请求头
|
||||||
|
timeout_seconds: string;
|
||||||
|
supports_streaming: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const INITIAL_FORM_STATE: FormState = {
|
||||||
|
name: "",
|
||||||
|
engine: AIProviderEngine.OPENAI,
|
||||||
|
display_name: "",
|
||||||
|
description: "",
|
||||||
|
api_key_env: "",
|
||||||
|
base_url: "",
|
||||||
|
models_json: "[]",
|
||||||
|
headers_json: "{}",
|
||||||
|
timeout_seconds: "60",
|
||||||
|
supports_streaming: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AIChannelFormModal({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
onSubmit,
|
||||||
|
initialData,
|
||||||
|
}: AIChannelFormModalProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [formState, setFormState] = useState<FormState>(INITIAL_FORM_STATE);
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
|
// 重置表单或填充初始数据
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
if (initialData) {
|
||||||
|
setFormState({
|
||||||
|
name: initialData.name,
|
||||||
|
engine: initialData.engine,
|
||||||
|
display_name: initialData.display_name,
|
||||||
|
description: initialData.description ?? "",
|
||||||
|
api_key_env: initialData.api_key_env,
|
||||||
|
base_url: initialData.base_url,
|
||||||
|
models_json: JSON.stringify(initialData.models, null, 2),
|
||||||
|
headers_json: JSON.stringify(initialData.headers ?? {}, null, 2),
|
||||||
|
timeout_seconds: String(initialData.timeout_seconds ?? 60),
|
||||||
|
supports_streaming: initialData.supports_streaming ?? true,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setFormState(INITIAL_FORM_STATE);
|
||||||
|
}
|
||||||
|
setErrors({});
|
||||||
|
}
|
||||||
|
}, [isOpen, initialData]);
|
||||||
|
|
||||||
|
const updateField = useCallback(
|
||||||
|
<K extends keyof FormState>(field: K, value: FormState[K]) => {
|
||||||
|
setFormState((prev) => ({ ...prev, [field]: value }));
|
||||||
|
// 清除该字段的错误
|
||||||
|
if (errors[field]) {
|
||||||
|
setErrors((prev) => {
|
||||||
|
const newErrors = { ...prev };
|
||||||
|
delete newErrors[field];
|
||||||
|
return newErrors;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[errors],
|
||||||
|
);
|
||||||
|
|
||||||
|
const validateForm = useCallback((): boolean => {
|
||||||
|
const newErrors: Record<string, string> = {};
|
||||||
|
|
||||||
|
if (!formState.name.trim()) {
|
||||||
|
newErrors.name = t("名称不能为空", "名称不能为空");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!formState.display_name.trim()) {
|
||||||
|
newErrors.display_name = t("显示名称不能为空", "显示名称不能为空");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!formState.api_key_env.trim()) {
|
||||||
|
newErrors.api_key_env = t("API Key 环境变量名不能为空", "API Key 环境变量名不能为空");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!formState.base_url.trim()) {
|
||||||
|
newErrors.base_url = t("API 地址不能为空", "API 地址不能为空");
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
new URL(formState.base_url.trim());
|
||||||
|
} catch {
|
||||||
|
newErrors.base_url = t("请输入有效的 URL", "请输入有效的 URL");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证 models_json
|
||||||
|
try {
|
||||||
|
const models = JSON.parse(formState.models_json);
|
||||||
|
if (!Array.isArray(models)) {
|
||||||
|
newErrors.models_json = t("模型列表必须是数组格式", "模型列表必须是数组格式");
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
newErrors.models_json = t("无效的 JSON 格式", "无效的 JSON 格式");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证 headers_json
|
||||||
|
try {
|
||||||
|
JSON.parse(formState.headers_json);
|
||||||
|
} catch {
|
||||||
|
newErrors.headers_json = t("无效的 JSON 格式", "无效的 JSON 格式");
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeout = Number(formState.timeout_seconds);
|
||||||
|
if (isNaN(timeout) || timeout <= 0) {
|
||||||
|
newErrors.timeout_seconds = t("超时时间必须是正整数", "超时时间必须是正整数");
|
||||||
|
}
|
||||||
|
|
||||||
|
setErrors(newErrors);
|
||||||
|
return Object.keys(newErrors).length === 0;
|
||||||
|
}, [formState, t]);
|
||||||
|
|
||||||
|
const handleSubmit = useCallback(async () => {
|
||||||
|
if (!validateForm()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSubmitting(true);
|
||||||
|
try {
|
||||||
|
const models: ModelInfo[] = JSON.parse(formState.models_json);
|
||||||
|
const headers: Record<string, string> = JSON.parse(formState.headers_json);
|
||||||
|
|
||||||
|
const config: AIChannelConfig = {
|
||||||
|
name: formState.name.trim(),
|
||||||
|
engine: formState.engine,
|
||||||
|
display_name: formState.display_name.trim(),
|
||||||
|
description: formState.description.trim() || undefined,
|
||||||
|
api_key_env: formState.api_key_env.trim(),
|
||||||
|
base_url: formState.base_url.trim(),
|
||||||
|
models,
|
||||||
|
headers: Object.keys(headers).length > 0 ? headers : undefined,
|
||||||
|
timeout_seconds: Number(formState.timeout_seconds),
|
||||||
|
supports_streaming: formState.supports_streaming,
|
||||||
|
};
|
||||||
|
|
||||||
|
await onSubmit(config);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("提交失败:", e);
|
||||||
|
// 错误已经在调用处处理
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
}, [formState, onSubmit, validateForm]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal isOpen={isOpen} onClose={onClose} maxWidth="max-w-2xl">
|
||||||
|
<ModalHeader>
|
||||||
|
{initialData
|
||||||
|
? t("编辑 AI 渠道", "编辑 AI 渠道")
|
||||||
|
: t("添加 AI 渠道", "添加 AI 渠道")}
|
||||||
|
</ModalHeader>
|
||||||
|
|
||||||
|
<ModalBody className="space-y-4 max-h-[70vh] overflow-y-auto">
|
||||||
|
{/* 名称 */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="name">
|
||||||
|
名称 <span className="text-red-500">*</span>
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="name"
|
||||||
|
type="text"
|
||||||
|
value={formState.name}
|
||||||
|
onChange={(e) => updateField("name", e.target.value)}
|
||||||
|
placeholder="my-openai-channel"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
className={errors.name ? "border-red-500" : ""}
|
||||||
|
/>
|
||||||
|
{errors.name && (
|
||||||
|
<p className="text-xs text-red-500">{errors.name}</p>
|
||||||
|
)}
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t("唯一标识符,只能包含小写字母、数字和连字符", "唯一标识符,只能包含小写字母、数字和连字符")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 显示名称 */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="display_name">
|
||||||
|
{t("显示名称", "显示名称")} <span className="text-red-500">*</span>
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="display_name"
|
||||||
|
type="text"
|
||||||
|
value={formState.display_name}
|
||||||
|
onChange={(e) => updateField("display_name", e.target.value)}
|
||||||
|
placeholder="我的 OpenAI 渠道"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
className={errors.display_name ? "border-red-500" : ""}
|
||||||
|
/>
|
||||||
|
{errors.display_name && (
|
||||||
|
<p className="text-xs text-red-500">{errors.display_name}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 引擎类型 */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="engine">{t("引擎类型", "引擎类型")}</Label>
|
||||||
|
<Select
|
||||||
|
value={formState.engine}
|
||||||
|
onValueChange={(value) => updateField("engine", value as AIProviderEngine)}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value={AIProviderEngine.OPENAI}>OpenAI</SelectItem>
|
||||||
|
<SelectItem value={AIProviderEngine.OLLAMA}>Ollama</SelectItem>
|
||||||
|
<SelectItem value={AIProviderEngine.ANTHROPIC}>
|
||||||
|
Anthropic
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 描述 */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="description">{t("描述", "描述")}</Label>
|
||||||
|
<Textarea
|
||||||
|
id="description"
|
||||||
|
value={formState.description}
|
||||||
|
onChange={(e) => updateField("description", e.target.value)}
|
||||||
|
placeholder={t("可选的渠道描述", "可选的渠道描述")}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
rows={2}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* API Key 环境变量名 */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="api_key_env">
|
||||||
|
API Key {t("环境变量名", "环境变量名")} <span className="text-red-500">*</span>
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="api_key_env"
|
||||||
|
type="text"
|
||||||
|
value={formState.api_key_env}
|
||||||
|
onChange={(e) => updateField("api_key_env", e.target.value)}
|
||||||
|
placeholder="OPENAI_API_KEY"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
className={errors.api_key_env ? "border-red-500" : ""}
|
||||||
|
/>
|
||||||
|
{errors.api_key_env && (
|
||||||
|
<p className="text-xs text-red-500">{errors.api_key_env}</p>
|
||||||
|
)}
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t("存储 API Key 的环境变量名称", "存储 API Key 的环境变量名称")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* API 地址 */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="base_url">
|
||||||
|
API {t("地址", "地址")} <span className="text-red-500">*</span>
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="base_url"
|
||||||
|
type="text"
|
||||||
|
value={formState.base_url}
|
||||||
|
onChange={(e) => updateField("base_url", e.target.value)}
|
||||||
|
placeholder="https://api.openai.com/v1"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
className={errors.base_url ? "border-red-500" : ""}
|
||||||
|
/>
|
||||||
|
{errors.base_url && (
|
||||||
|
<p className="text-xs text-red-500">{errors.base_url}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 模型列表 */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="models_json">{t("模型列表", "模型列表")}</Label>
|
||||||
|
<Textarea
|
||||||
|
id="models_json"
|
||||||
|
value={formState.models_json}
|
||||||
|
onChange={(e) => updateField("models_json", e.target.value)}
|
||||||
|
placeholder={`[
|
||||||
|
{
|
||||||
|
"id": "gpt-4",
|
||||||
|
"name": "GPT-4",
|
||||||
|
"description": "最强大的模型"
|
||||||
|
}
|
||||||
|
]`}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
rows={6}
|
||||||
|
className={errors.models_json ? "border-red-500" : "font-mono text-xs"}
|
||||||
|
/>
|
||||||
|
{errors.models_json && (
|
||||||
|
<p className="text-xs text-red-500">{errors.models_json}</p>
|
||||||
|
)}
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t("JSON 格式的模型列表", "JSON 格式的模型列表")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 自定义请求头 */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="headers_json">{t("自定义请求头", "自定义请求头")}</Label>
|
||||||
|
<Textarea
|
||||||
|
id="headers_json"
|
||||||
|
value={formState.headers_json}
|
||||||
|
onChange={(e) => updateField("headers_json", e.target.value)}
|
||||||
|
placeholder={`{
|
||||||
|
"X-Custom-Header": "value"
|
||||||
|
}`}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
rows={3}
|
||||||
|
className={errors.headers_json ? "border-red-500" : "font-mono text-xs"}
|
||||||
|
/>
|
||||||
|
{errors.headers_json && (
|
||||||
|
<p className="text-xs text-red-500">{errors.headers_json}</p>
|
||||||
|
)}
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t("JSON 格式的自定义请求头(可选)", "JSON 格式的自定义请求头(可选)")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 超时时间 */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="timeout_seconds">{t("超时时间", "超时时间")}(秒)</Label>
|
||||||
|
<Input
|
||||||
|
id="timeout_seconds"
|
||||||
|
type="number"
|
||||||
|
value={formState.timeout_seconds}
|
||||||
|
onChange={(e) => updateField("timeout_seconds", e.target.value)}
|
||||||
|
min="1"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
className={errors.timeout_seconds ? "border-red-500" : ""}
|
||||||
|
/>
|
||||||
|
{errors.timeout_seconds && (
|
||||||
|
<p className="text-xs text-red-500">{errors.timeout_seconds}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 支持流式 */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<Label>{t("支持流式输出", "支持流式输出")}</Label>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t("该渠道是否支持 SSE 流式响应", "该渠道是否支持 SSE 流式响应")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={formState.supports_streaming}
|
||||||
|
onCheckedChange={(checked) => updateField("supports_streaming", checked)}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</ModalBody>
|
||||||
|
|
||||||
|
<ModalFooter>
|
||||||
|
<Button variant="outline" onClick={onClose} disabled={isSubmitting}>
|
||||||
|
{t("取消", "取消")}
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSubmit} disabled={isSubmitting}>
|
||||||
|
{isSubmitting
|
||||||
|
? t("保存中...", "保存中...")
|
||||||
|
: initialData
|
||||||
|
? t("保存", "保存")
|
||||||
|
: t("添加", "添加")}
|
||||||
|
</Button>
|
||||||
|
</ModalFooter>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AIChannelFormModal;
|
||||||
@@ -0,0 +1,255 @@
|
|||||||
|
/**
|
||||||
|
* AI 渠道列表组件
|
||||||
|
*
|
||||||
|
* 显示 AI 模型提供商渠道列表,支持添加、编辑、删除、启用/禁用和连接测试
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState, useEffect, useCallback } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Plus, Pencil, Trash2, Power } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableHeader,
|
||||||
|
TableBody,
|
||||||
|
TableHead,
|
||||||
|
TableRow,
|
||||||
|
TableCell,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import {
|
||||||
|
aiChannelsApi,
|
||||||
|
type AIChannel,
|
||||||
|
type AIChannelConfig,
|
||||||
|
AIProviderEngine,
|
||||||
|
} from "@/lib/api/channels";
|
||||||
|
import { AIChannelFormModal } from "./AIChannelFormModal";
|
||||||
|
import { ConnectionTestButton } from "./ConnectionTestButton";
|
||||||
|
import { DeleteChannelDialog } from "./DeleteChannelDialog";
|
||||||
|
|
||||||
|
export function AIChannelsList() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [channels, setChannels] = useState<AIChannel[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [showAddModal, setShowAddModal] = useState(false);
|
||||||
|
const [editingChannel, setEditingChannel] = useState<AIChannel | null>(null);
|
||||||
|
const [deletingChannel, setDeletingChannel] = useState<AIChannel | null>(null);
|
||||||
|
|
||||||
|
// 加载渠道列表
|
||||||
|
const loadChannels = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const data = await aiChannelsApi.getChannels();
|
||||||
|
setChannels(data);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("加载 AI 渠道失败:", e);
|
||||||
|
toast.error(`加载失败: ${e instanceof Error ? e.message : e}`);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadChannels();
|
||||||
|
}, [loadChannels]);
|
||||||
|
|
||||||
|
// 添加渠道
|
||||||
|
const handleAdd = useCallback(
|
||||||
|
async (config: AIChannelConfig) => {
|
||||||
|
try {
|
||||||
|
await aiChannelsApi.createChannel(config);
|
||||||
|
toast.success("AI 渠道已创建");
|
||||||
|
await loadChannels();
|
||||||
|
setShowAddModal(false);
|
||||||
|
} catch (e) {
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[loadChannels],
|
||||||
|
);
|
||||||
|
|
||||||
|
// 更新渠道
|
||||||
|
const handleUpdate = useCallback(
|
||||||
|
async (id: string, config: AIChannelConfig) => {
|
||||||
|
try {
|
||||||
|
await aiChannelsApi.updateChannel(id, config);
|
||||||
|
toast.success("AI 渠道已更新");
|
||||||
|
await loadChannels();
|
||||||
|
setEditingChannel(null);
|
||||||
|
} catch (e) {
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[loadChannels],
|
||||||
|
);
|
||||||
|
|
||||||
|
// 删除渠道
|
||||||
|
const handleDelete = useCallback(async () => {
|
||||||
|
if (!deletingChannel) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await aiChannelsApi.deleteChannel(deletingChannel.id);
|
||||||
|
toast.success("AI 渠道已删除");
|
||||||
|
await loadChannels();
|
||||||
|
setDeletingChannel(null);
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(`删除失败: ${e instanceof Error ? e.message : e}`);
|
||||||
|
}
|
||||||
|
}, [deletingChannel, loadChannels]);
|
||||||
|
|
||||||
|
// 切换启用状态
|
||||||
|
const handleToggle = useCallback(
|
||||||
|
async (channel: AIChannel, enabled: boolean) => {
|
||||||
|
try {
|
||||||
|
await aiChannelsApi.updateChannel(channel.id, {
|
||||||
|
...channel,
|
||||||
|
enabled,
|
||||||
|
} as AIChannelConfig);
|
||||||
|
toast.success(enabled ? "AI 渠道已启用" : "AI 渠道已禁用");
|
||||||
|
await loadChannels();
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(`操作失败: ${e instanceof Error ? e.message : e}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[loadChannels],
|
||||||
|
);
|
||||||
|
|
||||||
|
// 获取引擎名称
|
||||||
|
const getEngineName = (engine: AIProviderEngine): string => {
|
||||||
|
const names: Record<AIProviderEngine, string> = {
|
||||||
|
[AIProviderEngine.OPENAI]: "OpenAI",
|
||||||
|
[AIProviderEngine.OLLAMA]: "Ollama",
|
||||||
|
[AIProviderEngine.ANTHROPIC]: "Anthropic",
|
||||||
|
};
|
||||||
|
return names[engine] || engine;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-32">
|
||||||
|
<div className="animate-spin h-6 w-6 border-2 border-primary border-t-transparent rounded-full" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* 头部操作栏 */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-medium">
|
||||||
|
{t("AI 模型提供商", "AI 模型提供商")}
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t("管理 OpenAI、Ollama、Anthropic 等 AI 服务提供商", "管理 OpenAI、Ollama、Anthropic 等 AI 服务提供商")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button size="sm" onClick={() => setShowAddModal(true)}>
|
||||||
|
<Plus className="h-4 w-4 mr-1" />
|
||||||
|
{t("添加 AI 渠道", "添加 AI 渠道")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 渠道列表 */}
|
||||||
|
{channels.length === 0 ? (
|
||||||
|
<div className="text-center py-8 text-muted-foreground text-sm">
|
||||||
|
<p>{t("暂无 AI 渠道", "暂无 AI 渠道")}</p>
|
||||||
|
<p className="text-xs mt-1">
|
||||||
|
{t("点击上方按钮添加您的第一个 AI 渠道", "点击上方按钮添加您的第一个 AI 渠道")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="border rounded-lg overflow-hidden">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>{t("名称", "名称")}</TableHead>
|
||||||
|
<TableHead>{t("引擎", "引擎")}</TableHead>
|
||||||
|
<TableHead>{t("API 地址", "API 地址")}</TableHead>
|
||||||
|
<TableHead>{t("模型数", "模型数")}</TableHead>
|
||||||
|
<TableHead>{t("状态", "状态")}</TableHead>
|
||||||
|
<TableHead className="text-right">
|
||||||
|
{t("操作", "操作")}
|
||||||
|
</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{channels.map((channel) => (
|
||||||
|
<TableRow key={channel.id}>
|
||||||
|
<TableCell className="font-medium">
|
||||||
|
{channel.display_name}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{getEngineName(channel.engine)}</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground text-xs">
|
||||||
|
{channel.base_url}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{channel.models.length}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Switch
|
||||||
|
checked={channel.enabled}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
handleToggle(channel, checked)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<div className="flex items-center justify-end gap-1">
|
||||||
|
<ConnectionTestButton
|
||||||
|
channelId={channel.id}
|
||||||
|
channelName={channel.display_name}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8"
|
||||||
|
onClick={() => setEditingChannel(channel)}
|
||||||
|
>
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8"
|
||||||
|
onClick={() => setDeletingChannel(channel)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4 text-destructive" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 添加模态框 */}
|
||||||
|
<AIChannelFormModal
|
||||||
|
isOpen={showAddModal}
|
||||||
|
onClose={() => setShowAddModal(false)}
|
||||||
|
onSubmit={handleAdd}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 编辑模态框 */}
|
||||||
|
{editingChannel && (
|
||||||
|
<AIChannelFormModal
|
||||||
|
isOpen={!!editingChannel}
|
||||||
|
onClose={() => setEditingChannel(null)}
|
||||||
|
onSubmit={(config) => handleUpdate(editingChannel.id, config)}
|
||||||
|
initialData={editingChannel}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 删除确认对话框 */}
|
||||||
|
<DeleteChannelDialog
|
||||||
|
isOpen={!!deletingChannel}
|
||||||
|
channelName={deletingChannel?.display_name ?? ""}
|
||||||
|
onClose={() => setDeletingChannel(null)}
|
||||||
|
onConfirm={handleDelete}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AIChannelsList;
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
/**
|
||||||
|
* 连接测试按钮组件
|
||||||
|
*
|
||||||
|
* 用于测试 AI 渠道的连接状态
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState, useCallback } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Plug, Loader2 } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { aiChannelsApi } from "@/lib/api/channels";
|
||||||
|
|
||||||
|
export interface ConnectionTestButtonProps {
|
||||||
|
channelId: string;
|
||||||
|
channelName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ConnectionTestButton({
|
||||||
|
channelId,
|
||||||
|
channelName,
|
||||||
|
}: ConnectionTestButtonProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [testing, setTesting] = useState(false);
|
||||||
|
|
||||||
|
const handleTest = useCallback(async () => {
|
||||||
|
setTesting(true);
|
||||||
|
try {
|
||||||
|
const result = await aiChannelsApi.testChannel(channelId);
|
||||||
|
if (result.success) {
|
||||||
|
toast.success(t("连接成功", "连接成功") + ": " + result.message);
|
||||||
|
} else {
|
||||||
|
toast.error(t("连接失败", "连接失败") + ": " + result.message);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(
|
||||||
|
t("测试失败", "测试失败") + ": " + (e instanceof Error ? e.message : e),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setTesting(false);
|
||||||
|
}
|
||||||
|
}, [channelId, t]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8"
|
||||||
|
onClick={handleTest}
|
||||||
|
disabled={testing}
|
||||||
|
title={t("测试连接", "测试连接")}
|
||||||
|
>
|
||||||
|
{testing ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Plug className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ConnectionTestButton;
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
/**
|
||||||
|
* 删除渠道确认对话框组件
|
||||||
|
*
|
||||||
|
* 用于确认删除操作
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
|
export interface DeleteChannelDialogProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
channelName: string;
|
||||||
|
onClose: () => void;
|
||||||
|
onConfirm: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DeleteChannelDialog({
|
||||||
|
isOpen,
|
||||||
|
channelName,
|
||||||
|
onClose,
|
||||||
|
onConfirm,
|
||||||
|
}: DeleteChannelDialogProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
{t("删除确认", "删除确认")}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{t(
|
||||||
|
"确定要删除渠道 \"{name}\" 吗?此操作无法撤销。",
|
||||||
|
"确定要删除渠道 \"{name}\" 吗?此操作无法撤销。",
|
||||||
|
{ name: channelName }
|
||||||
|
)}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={onClose}>
|
||||||
|
{t("取消", "取消")}
|
||||||
|
</Button>
|
||||||
|
<Button variant="destructive" onClick={onConfirm}>
|
||||||
|
{t("删除", "删除")}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DeleteChannelDialog;
|
||||||
@@ -0,0 +1,453 @@
|
|||||||
|
/**
|
||||||
|
* 通知渠道表单模态框组件
|
||||||
|
*
|
||||||
|
* 用于添加或编辑消息通知渠道配置(飞书、Telegram、Discord)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState, useCallback, useEffect } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Modal, ModalHeader, ModalBody, ModalFooter } from "@/components/Modal";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import {
|
||||||
|
type NotificationChannel,
|
||||||
|
type NotificationChannelConfig,
|
||||||
|
NotificationChannelType,
|
||||||
|
} from "@/lib/api/channels";
|
||||||
|
|
||||||
|
export interface NotificationChannelFormModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onSubmit: (config: NotificationChannelConfig) => Promise<void>;
|
||||||
|
initialData?: NotificationChannel;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FormState {
|
||||||
|
channel_type: NotificationChannelType;
|
||||||
|
name: string;
|
||||||
|
// 飞书配置
|
||||||
|
feishu_webhook_url: string;
|
||||||
|
feishu_secret: string;
|
||||||
|
// Telegram 配置
|
||||||
|
telegram_bot_token: string;
|
||||||
|
telegram_chat_id: string;
|
||||||
|
// Discord 配置
|
||||||
|
discord_webhook_url: string;
|
||||||
|
discord_username: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const INITIAL_FORM_STATE: FormState = {
|
||||||
|
channel_type: NotificationChannelType.FEISHU,
|
||||||
|
name: "",
|
||||||
|
feishu_webhook_url: "",
|
||||||
|
feishu_secret: "",
|
||||||
|
telegram_bot_token: "",
|
||||||
|
telegram_chat_id: "",
|
||||||
|
discord_webhook_url: "",
|
||||||
|
discord_username: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function NotificationChannelFormModal({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
onSubmit,
|
||||||
|
initialData,
|
||||||
|
}: NotificationChannelFormModalProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [formState, setFormState] = useState<FormState>(INITIAL_FORM_STATE);
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
|
// 重置表单或填充初始数据
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
if (initialData) {
|
||||||
|
const newState = { ...INITIAL_FORM_STATE };
|
||||||
|
newState.channel_type = initialData.channel_type;
|
||||||
|
newState.name = initialData.name;
|
||||||
|
|
||||||
|
// 根据渠道类型填充特定配置
|
||||||
|
switch (initialData.channel_type) {
|
||||||
|
case NotificationChannelType.FEISHU: {
|
||||||
|
const cfg = initialData.config as { type: NotificationChannelType.FEISHU; webhook_url: string; secret?: string };
|
||||||
|
newState.feishu_webhook_url = cfg.webhook_url ?? "";
|
||||||
|
newState.feishu_secret = cfg.secret ?? "";
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case NotificationChannelType.TELEGRAM: {
|
||||||
|
const cfg = initialData.config as { type: NotificationChannelType.TELEGRAM; bot_token: string; chat_id: string };
|
||||||
|
newState.telegram_bot_token = cfg.bot_token;
|
||||||
|
newState.telegram_chat_id = cfg.chat_id;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case NotificationChannelType.DISCORD: {
|
||||||
|
const cfg = initialData.config as { type: NotificationChannelType.DISCORD; webhook_url: string; username?: string };
|
||||||
|
newState.discord_webhook_url = cfg.webhook_url;
|
||||||
|
newState.discord_username = cfg.username ?? "";
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setFormState(newState);
|
||||||
|
} else {
|
||||||
|
setFormState(INITIAL_FORM_STATE);
|
||||||
|
}
|
||||||
|
setErrors({});
|
||||||
|
}
|
||||||
|
}, [isOpen, initialData]);
|
||||||
|
|
||||||
|
const updateField = useCallback(
|
||||||
|
<K extends keyof FormState>(field: K, value: FormState[K]) => {
|
||||||
|
setFormState((prev) => ({ ...prev, [field]: value }));
|
||||||
|
// 清除该字段的错误
|
||||||
|
const fieldKey = field as string;
|
||||||
|
if (errors[fieldKey]) {
|
||||||
|
setErrors((prev) => {
|
||||||
|
const newErrors = { ...prev };
|
||||||
|
delete newErrors[fieldKey];
|
||||||
|
return newErrors;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[errors],
|
||||||
|
);
|
||||||
|
|
||||||
|
const validateForm = useCallback((): boolean => {
|
||||||
|
const newErrors: Record<string, string> = {};
|
||||||
|
|
||||||
|
if (!formState.name.trim()) {
|
||||||
|
newErrors.name = t("名称不能为空", "名称不能为空");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 根据渠道类型验证特定字段
|
||||||
|
switch (formState.channel_type) {
|
||||||
|
case NotificationChannelType.FEISHU:
|
||||||
|
if (!formState.feishu_webhook_url.trim()) {
|
||||||
|
newErrors.feishu_webhook_url = t("Webhook 地址不能为空", "Webhook 地址不能为空");
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
new URL(formState.feishu_webhook_url.trim());
|
||||||
|
} catch {
|
||||||
|
newErrors.feishu_webhook_url = t("请输入有效的 URL", "请输入有效的 URL");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case NotificationChannelType.TELEGRAM:
|
||||||
|
if (!formState.telegram_bot_token.trim()) {
|
||||||
|
newErrors.telegram_bot_token = t("Bot Token 不能为空", "Bot Token 不能为空");
|
||||||
|
}
|
||||||
|
if (!formState.telegram_chat_id.trim()) {
|
||||||
|
newErrors.telegram_chat_id = t("聊天 ID 不能为空", "聊天 ID 不能为空");
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case NotificationChannelType.DISCORD:
|
||||||
|
if (!formState.discord_webhook_url.trim()) {
|
||||||
|
newErrors.discord_webhook_url = t("Webhook URL 不能为空", "Webhook URL 不能为空");
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
new URL(formState.discord_webhook_url.trim());
|
||||||
|
} catch {
|
||||||
|
newErrors.discord_webhook_url = t("请输入有效的 URL", "请输入有效的 URL");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
setErrors(newErrors);
|
||||||
|
return Object.keys(newErrors).length === 0;
|
||||||
|
}, [formState, t]);
|
||||||
|
|
||||||
|
const handleSubmit = useCallback(async () => {
|
||||||
|
if (!validateForm()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSubmitting(true);
|
||||||
|
try {
|
||||||
|
let config: NotificationChannelConfig;
|
||||||
|
|
||||||
|
switch (formState.channel_type) {
|
||||||
|
case NotificationChannelType.FEISHU:
|
||||||
|
config = {
|
||||||
|
name: formState.name.trim(),
|
||||||
|
channel_type: formState.channel_type,
|
||||||
|
config: {
|
||||||
|
type: NotificationChannelType.FEISHU,
|
||||||
|
webhook_url: formState.feishu_webhook_url.trim(),
|
||||||
|
secret: formState.feishu_secret.trim() || undefined,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
break;
|
||||||
|
|
||||||
|
case NotificationChannelType.TELEGRAM:
|
||||||
|
config = {
|
||||||
|
name: formState.name.trim(),
|
||||||
|
channel_type: formState.channel_type,
|
||||||
|
config: {
|
||||||
|
type: NotificationChannelType.TELEGRAM,
|
||||||
|
bot_token: formState.telegram_bot_token.trim(),
|
||||||
|
chat_id: formState.telegram_chat_id.trim(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
break;
|
||||||
|
|
||||||
|
case NotificationChannelType.DISCORD:
|
||||||
|
config = {
|
||||||
|
name: formState.name.trim(),
|
||||||
|
channel_type: formState.channel_type,
|
||||||
|
config: {
|
||||||
|
type: NotificationChannelType.DISCORD,
|
||||||
|
webhook_url: formState.discord_webhook_url.trim(),
|
||||||
|
username: formState.discord_username.trim() || undefined,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
throw new Error("未知的通知渠道类型");
|
||||||
|
}
|
||||||
|
|
||||||
|
await onSubmit(config);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("提交失败:", e);
|
||||||
|
// 错误已经在调用处处理
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
}, [formState, onSubmit, validateForm]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal isOpen={isOpen} onClose={onClose} maxWidth="max-w-md">
|
||||||
|
<ModalHeader>
|
||||||
|
{initialData
|
||||||
|
? t("编辑通知渠道", "编辑通知渠道")
|
||||||
|
: t("添加通知渠道", "添加通知渠道")}
|
||||||
|
</ModalHeader>
|
||||||
|
|
||||||
|
<ModalBody className="space-y-4">
|
||||||
|
{/* 渠道类型 */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="channel_type">
|
||||||
|
{t("渠道类型", "渠道类型")} <span className="text-red-500">*</span>
|
||||||
|
</Label>
|
||||||
|
<Select
|
||||||
|
value={formState.channel_type}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
updateField("channel_type", value as NotificationChannelType)
|
||||||
|
}
|
||||||
|
disabled={isSubmitting || !!initialData}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value={NotificationChannelType.FEISHU}>
|
||||||
|
{t("飞书", "飞书")}
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value={NotificationChannelType.TELEGRAM}>
|
||||||
|
Telegram
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value={NotificationChannelType.DISCORD}>
|
||||||
|
Discord
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t("选择消息通知渠道类型", "选择消息通知渠道类型")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 名称 */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="name">
|
||||||
|
{t("名称", "名称")} <span className="text-red-500">*</span>
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="name"
|
||||||
|
type="text"
|
||||||
|
value={formState.name}
|
||||||
|
onChange={(e) => updateField("name", e.target.value)}
|
||||||
|
placeholder={t("我的飞书通知", "我的飞书通知")}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
className={errors.name ? "border-red-500" : ""}
|
||||||
|
/>
|
||||||
|
{errors.name && <p className="text-xs text-red-500">{errors.name}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 飞书配置 */}
|
||||||
|
{formState.channel_type === NotificationChannelType.FEISHU && (
|
||||||
|
<>
|
||||||
|
<div className="border-t pt-4">
|
||||||
|
<h4 className="text-sm font-medium mb-3">
|
||||||
|
{t("飞书配置", "飞书配置")}
|
||||||
|
</h4>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="feishu_webhook_url">
|
||||||
|
Webhook {t("地址", "地址")} <span className="text-red-500">*</span>
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="feishu_webhook_url"
|
||||||
|
type="text"
|
||||||
|
value={formState.feishu_webhook_url}
|
||||||
|
onChange={(e) => updateField("feishu_webhook_url", e.target.value)}
|
||||||
|
placeholder="https://open.feishu.cn/open-apis/bot/v2/hook/..."
|
||||||
|
disabled={isSubmitting}
|
||||||
|
className={errors.feishu_webhook_url ? "border-red-500" : ""}
|
||||||
|
/>
|
||||||
|
{errors.feishu_webhook_url && (
|
||||||
|
<p className="text-xs text-red-500">{errors.feishu_webhook_url}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="feishu_secret">
|
||||||
|
{t("签名密钥", "签名密钥")} ({t("可选", "可选")})
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="feishu_secret"
|
||||||
|
type="password"
|
||||||
|
value={formState.feishu_secret}
|
||||||
|
onChange={(e) => updateField("feishu_secret", e.target.value)}
|
||||||
|
placeholder="********"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t(
|
||||||
|
"用于验证 Webhook 请求的签名密钥",
|
||||||
|
"用于验证 Webhook 请求的签名密钥",
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Telegram 配置 */}
|
||||||
|
{formState.channel_type === NotificationChannelType.TELEGRAM && (
|
||||||
|
<>
|
||||||
|
<div className="border-t pt-4">
|
||||||
|
<h4 className="text-sm font-medium mb-3">
|
||||||
|
Telegram {t("配置", "配置")}
|
||||||
|
</h4>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="telegram_bot_token">
|
||||||
|
Bot Token <span className="text-red-500">*</span>
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="telegram_bot_token"
|
||||||
|
type="password"
|
||||||
|
value={formState.telegram_bot_token}
|
||||||
|
onChange={(e) => updateField("telegram_bot_token", e.target.value)}
|
||||||
|
placeholder="123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
className={errors.telegram_bot_token ? "border-red-500" : ""}
|
||||||
|
/>
|
||||||
|
{errors.telegram_bot_token && (
|
||||||
|
<p className="text-xs text-red-500">{errors.telegram_bot_token}</p>
|
||||||
|
)}
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t("从 @BotFather 获取", "从 @BotFather 获取")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="telegram_chat_id">
|
||||||
|
Chat ID <span className="text-red-500">*</span>
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="telegram_chat_id"
|
||||||
|
type="text"
|
||||||
|
value={formState.telegram_chat_id}
|
||||||
|
onChange={(e) => updateField("telegram_chat_id", e.target.value)}
|
||||||
|
placeholder="-1001234567890"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
className={errors.telegram_chat_id ? "border-red-500" : ""}
|
||||||
|
/>
|
||||||
|
{errors.telegram_chat_id && (
|
||||||
|
<p className="text-xs text-red-500">{errors.telegram_chat_id}</p>
|
||||||
|
)}
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t("目标聊天或频道的 ID", "目标聊天或频道的 ID")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Discord 配置 */}
|
||||||
|
{formState.channel_type === NotificationChannelType.DISCORD && (
|
||||||
|
<>
|
||||||
|
<div className="border-t pt-4">
|
||||||
|
<h4 className="text-sm font-medium mb-3">
|
||||||
|
Discord {t("配置", "配置")}
|
||||||
|
</h4>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="discord_webhook_url">
|
||||||
|
Webhook URL <span className="text-red-500">*</span>
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="discord_webhook_url"
|
||||||
|
type="text"
|
||||||
|
value={formState.discord_webhook_url}
|
||||||
|
onChange={(e) => updateField("discord_webhook_url", e.target.value)}
|
||||||
|
placeholder="https://discord.com/api/webhooks/..."
|
||||||
|
disabled={isSubmitting}
|
||||||
|
className={errors.discord_webhook_url ? "border-red-500" : ""}
|
||||||
|
/>
|
||||||
|
{errors.discord_webhook_url && (
|
||||||
|
<p className="text-xs text-red-500">{errors.discord_webhook_url}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="discord_username">
|
||||||
|
{t("自定义用户名", "自定义用户名")} ({t("可选", "可选")})
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="discord_username"
|
||||||
|
type="text"
|
||||||
|
value={formState.discord_username}
|
||||||
|
onChange={(e) => updateField("discord_username", e.target.value)}
|
||||||
|
placeholder="ProxyCast Bot"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t("覆盖机器人的默认用户名", "覆盖机器人的默认用户名")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</ModalBody>
|
||||||
|
|
||||||
|
<ModalFooter>
|
||||||
|
<Button variant="outline" onClick={onClose} disabled={isSubmitting}>
|
||||||
|
{t("取消", "取消")}
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSubmit} disabled={isSubmitting}>
|
||||||
|
{isSubmitting
|
||||||
|
? t("保存中...", "保存中...")
|
||||||
|
: initialData
|
||||||
|
? t("保存", "保存")
|
||||||
|
: t("添加", "添加")}
|
||||||
|
</Button>
|
||||||
|
</ModalFooter>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default NotificationChannelFormModal;
|
||||||
@@ -0,0 +1,272 @@
|
|||||||
|
/**
|
||||||
|
* 通知渠道列表组件
|
||||||
|
*
|
||||||
|
* 显示消息通知渠道列表(飞书、Telegram、Discord),支持添加、编辑、删除、启用/禁用和发送测试消息
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState, useEffect, useCallback } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Plus, Pencil, Trash2, Send } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableHeader,
|
||||||
|
TableBody,
|
||||||
|
TableHead,
|
||||||
|
TableRow,
|
||||||
|
TableCell,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import {
|
||||||
|
notificationChannelsApi,
|
||||||
|
type NotificationChannel,
|
||||||
|
type NotificationChannelConfig,
|
||||||
|
NotificationChannelType,
|
||||||
|
} from "@/lib/api/channels";
|
||||||
|
import { NotificationChannelFormModal } from "./NotificationChannelFormModal";
|
||||||
|
import { SendTestMessageButton } from "./SendTestMessageButton";
|
||||||
|
import { DeleteChannelDialog } from "./DeleteChannelDialog";
|
||||||
|
|
||||||
|
export function NotificationChannelsList() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [channels, setChannels] = useState<NotificationChannel[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [showAddModal, setShowAddModal] = useState(false);
|
||||||
|
const [editingChannel, setEditingChannel] = useState<NotificationChannel | null>(null);
|
||||||
|
const [deletingChannel, setDeletingChannel] = useState<NotificationChannel | null>(null);
|
||||||
|
|
||||||
|
// 加载渠道列表
|
||||||
|
const loadChannels = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const data = await notificationChannelsApi.getChannels();
|
||||||
|
setChannels(data);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("加载通知渠道失败:", e);
|
||||||
|
toast.error(`加载失败: ${e instanceof Error ? e.message : e}`);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadChannels();
|
||||||
|
}, [loadChannels]);
|
||||||
|
|
||||||
|
// 添加渠道
|
||||||
|
const handleAdd = useCallback(
|
||||||
|
async (config: NotificationChannelConfig) => {
|
||||||
|
try {
|
||||||
|
await notificationChannelsApi.createChannel(config);
|
||||||
|
toast.success("通知渠道已创建");
|
||||||
|
await loadChannels();
|
||||||
|
setShowAddModal(false);
|
||||||
|
} catch (e) {
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[loadChannels],
|
||||||
|
);
|
||||||
|
|
||||||
|
// 更新渠道
|
||||||
|
const handleUpdate = useCallback(
|
||||||
|
async (id: string, config: NotificationChannelConfig) => {
|
||||||
|
try {
|
||||||
|
await notificationChannelsApi.updateChannel(id, config);
|
||||||
|
toast.success("通知渠道已更新");
|
||||||
|
await loadChannels();
|
||||||
|
setEditingChannel(null);
|
||||||
|
} catch (e) {
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[loadChannels],
|
||||||
|
);
|
||||||
|
|
||||||
|
// 删除渠道
|
||||||
|
const handleDelete = useCallback(async () => {
|
||||||
|
if (!deletingChannel) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await notificationChannelsApi.deleteChannel(deletingChannel.id);
|
||||||
|
toast.success("通知渠道已删除");
|
||||||
|
await loadChannels();
|
||||||
|
setDeletingChannel(null);
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(`删除失败: ${e instanceof Error ? e.message : e}`);
|
||||||
|
}
|
||||||
|
}, [deletingChannel, loadChannels]);
|
||||||
|
|
||||||
|
// 切换启用状态
|
||||||
|
const handleToggle = useCallback(
|
||||||
|
async (channel: NotificationChannel, enabled: boolean) => {
|
||||||
|
try {
|
||||||
|
await notificationChannelsApi.updateChannel(channel.id, {
|
||||||
|
...channel,
|
||||||
|
enabled,
|
||||||
|
} as NotificationChannelConfig);
|
||||||
|
toast.success(enabled ? "通知渠道已启用" : "通知渠道已禁用");
|
||||||
|
await loadChannels();
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(`操作失败: ${e instanceof Error ? e.message : e}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[loadChannels],
|
||||||
|
);
|
||||||
|
|
||||||
|
// 获取渠道类型名称
|
||||||
|
const getChannelTypeName = (type: NotificationChannelType): string => {
|
||||||
|
const names: Record<NotificationChannelType, string> = {
|
||||||
|
[NotificationChannelType.FEISHU]: t("飞书", "飞书"),
|
||||||
|
[NotificationChannelType.TELEGRAM]: t("Telegram", "Telegram"),
|
||||||
|
[NotificationChannelType.DISCORD]: t("Discord", "Discord"),
|
||||||
|
};
|
||||||
|
return names[type] || type;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 获取渠道配置摘要
|
||||||
|
const getConfigSummary = (channel: NotificationChannel): string => {
|
||||||
|
switch (channel.channel_type) {
|
||||||
|
case NotificationChannelType.FEISHU:
|
||||||
|
return t("Webhook", "Webhook");
|
||||||
|
case NotificationChannelType.TELEGRAM:
|
||||||
|
return t("Bot", "Bot");
|
||||||
|
case NotificationChannelType.DISCORD:
|
||||||
|
return t("Webhook", "Webhook");
|
||||||
|
default:
|
||||||
|
return "-";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-32">
|
||||||
|
<div className="animate-spin h-6 w-6 border-2 border-primary border-t-transparent rounded-full" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* 头部操作栏 */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-medium">
|
||||||
|
{t("消息通知渠道", "消息通知渠道")}
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t("管理飞书、Telegram、Discord 等消息通知渠道", "管理飞书、Telegram、Discord 等消息通知渠道")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button size="sm" onClick={() => setShowAddModal(true)}>
|
||||||
|
<Plus className="h-4 w-4 mr-1" />
|
||||||
|
{t("添加通知渠道", "添加通知渠道")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 渠道列表 */}
|
||||||
|
{channels.length === 0 ? (
|
||||||
|
<div className="text-center py-8 text-muted-foreground text-sm">
|
||||||
|
<p>{t("暂无通知渠道", "暂无通知渠道")}</p>
|
||||||
|
<p className="text-xs mt-1">
|
||||||
|
{t("点击上方按钮添加您的第一个通知渠道", "点击上方按钮添加您的第一个通知渠道")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="border rounded-lg overflow-hidden">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>{t("名称", "名称")}</TableHead>
|
||||||
|
<TableHead>{t("类型", "类型")}</TableHead>
|
||||||
|
<TableHead>{t("配置", "配置")}</TableHead>
|
||||||
|
<TableHead>{t("状态", "状态")}</TableHead>
|
||||||
|
<TableHead className="text-right">
|
||||||
|
{t("操作", "操作")}
|
||||||
|
</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{channels.map((channel) => (
|
||||||
|
<TableRow key={channel.id}>
|
||||||
|
<TableCell className="font-medium">
|
||||||
|
{channel.name}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<span className="inline-flex items-center px-2 py-1 rounded-md text-xs font-medium bg-primary/10 text-primary">
|
||||||
|
{getChannelTypeName(channel.channel_type)}
|
||||||
|
</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground text-xs">
|
||||||
|
{getConfigSummary(channel)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Switch
|
||||||
|
checked={channel.enabled}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
handleToggle(channel, checked)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<div className="flex items-center justify-end gap-1">
|
||||||
|
<SendTestMessageButton
|
||||||
|
channelId={channel.id}
|
||||||
|
channelName={channel.name}
|
||||||
|
channelType={channel.channel_type}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8"
|
||||||
|
onClick={() => setEditingChannel(channel)}
|
||||||
|
>
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8"
|
||||||
|
onClick={() => setDeletingChannel(channel)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4 text-destructive" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 添加模态框 */}
|
||||||
|
<NotificationChannelFormModal
|
||||||
|
isOpen={showAddModal}
|
||||||
|
onClose={() => setShowAddModal(false)}
|
||||||
|
onSubmit={handleAdd}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 编辑模态框 */}
|
||||||
|
{editingChannel && (
|
||||||
|
<NotificationChannelFormModal
|
||||||
|
isOpen={!!editingChannel}
|
||||||
|
onClose={() => setEditingChannel(null)}
|
||||||
|
onSubmit={(config) => handleUpdate(editingChannel.id, config)}
|
||||||
|
initialData={editingChannel}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 删除确认对话框 */}
|
||||||
|
<DeleteChannelDialog
|
||||||
|
isOpen={!!deletingChannel}
|
||||||
|
channelName={deletingChannel?.name ?? ""}
|
||||||
|
onClose={() => setDeletingChannel(null)}
|
||||||
|
onConfirm={handleDelete}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default NotificationChannelsList;
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
/**
|
||||||
|
* 发送测试消息按钮组件
|
||||||
|
*
|
||||||
|
* 用于向通知渠道发送测试消息
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState, useCallback } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Send, Loader2 } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import { notificationChannelsApi, NotificationChannelType } from "@/lib/api/channels";
|
||||||
|
|
||||||
|
export interface SendTestMessageButtonProps {
|
||||||
|
channelId: string;
|
||||||
|
channelName: string;
|
||||||
|
channelType: NotificationChannelType;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_TEST_MESSAGES = {
|
||||||
|
[NotificationChannelType.FEISHU]: "这是一条来自 ProxyCast 的测试消息 📱",
|
||||||
|
[NotificationChannelType.TELEGRAM]: "This is a test message from ProxyCast 🚀",
|
||||||
|
[NotificationChannelType.DISCORD]: "🎉 Test message from ProxyCast",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function SendTestMessageButton({
|
||||||
|
channelId,
|
||||||
|
channelName,
|
||||||
|
channelType,
|
||||||
|
}: SendTestMessageButtonProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [sending, setSending] = useState(false);
|
||||||
|
|
||||||
|
const handleSend = useCallback(
|
||||||
|
async (message?: string) => {
|
||||||
|
setSending(true);
|
||||||
|
try {
|
||||||
|
const testMessage =
|
||||||
|
message ||
|
||||||
|
DEFAULT_TEST_MESSAGES[channelType] ||
|
||||||
|
t("测试消息", "这是一条测试消息");
|
||||||
|
|
||||||
|
const result = await notificationChannelsApi.testChannel(
|
||||||
|
channelId,
|
||||||
|
testMessage
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
toast.success(
|
||||||
|
t("发送成功", "发送成功") + ": " + result.message
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
toast.error(
|
||||||
|
t("发送失败", "发送失败") + ": " + result.message
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(
|
||||||
|
t("发送失败", "发送失败") + ": " + (e instanceof Error ? e.message : e)
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setSending(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[channelId, channelType, t]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8"
|
||||||
|
disabled={sending}
|
||||||
|
title={t("发送测试消息", "发送测试消息")}
|
||||||
|
>
|
||||||
|
{sending ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Send className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem onClick={() => handleSend()}>
|
||||||
|
{t("发送默认测试消息", "发送默认测试消息")}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => {
|
||||||
|
const customMessage = prompt(
|
||||||
|
t("请输入测试消息内容", "请输入测试消息内容"),
|
||||||
|
DEFAULT_TEST_MESSAGES[channelType]
|
||||||
|
);
|
||||||
|
if (customMessage && customMessage.trim()) {
|
||||||
|
handleSend(customMessage.trim());
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("发送自定义消息...", "发送自定义消息...")}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SendTestMessageButton;
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
/**
|
||||||
|
* 渠道管理设置页面
|
||||||
|
*
|
||||||
|
* 提供两类渠道的管理:
|
||||||
|
* - AI 模型提供商渠道(OpenAI、Ollama、Anthropic 等)
|
||||||
|
* - 消息通知渠道(飞书、Telegram、Discord 等)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import { SettingHeader } from "@/components/settings-v2/features/SettingHeader";
|
||||||
|
import { AIChannelsList } from "./AIChannelsList";
|
||||||
|
import { NotificationChannelsList } from "./NotificationChannelsList";
|
||||||
|
|
||||||
|
export interface ChannelsSettingsProps {
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChannelsSettings({ className }: ChannelsSettingsProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [activeTab, setActiveTab] = useState<"ai" | "notification">("ai");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={className}>
|
||||||
|
<SettingHeader title={t("渠道管理", "渠道管理")} />
|
||||||
|
|
||||||
|
<Tabs
|
||||||
|
value={activeTab}
|
||||||
|
onValueChange={(value) => setActiveTab(value as "ai" | "notification")}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
<TabsList className="grid w-full max-w-md grid-cols-2">
|
||||||
|
<TabsTrigger value="ai">
|
||||||
|
{t("AI 模型提供商", "AI 模型提供商")}
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="notification">
|
||||||
|
{t("消息通知渠道", "消息通知渠道")}
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="ai" className="mt-6">
|
||||||
|
<AIChannelsList />
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="notification" className="mt-6">
|
||||||
|
<NotificationChannelsList />
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ChannelsSettings;
|
||||||
@@ -3743,4 +3743,103 @@
|
|||||||
"在下方输入详细的提示词以开始创作。": "Enter a detailed prompt below to begin your creative journey.",
|
"在下方输入详细的提示词以开始创作。": "Enter a detailed prompt below to begin your creative journey.",
|
||||||
"描述你想要生成的图片(例如:'赛博朋克风格的未来城市,日落时分,飞车穿梭,高细节')...": "Describe the image you want to generate (e.g., 'A futuristic city with flying cars at sunset, cyberpunk style')...",
|
"描述你想要生成的图片(例如:'赛博朋克风格的未来城市,日落时分,飞车穿梭,高细节')...": "Describe the image you want to generate (e.g., 'A futuristic city with flying cars at sunset, cyberpunk style')...",
|
||||||
"新建图片": "New Image"
|
"新建图片": "New Image"
|
||||||
|
,
|
||||||
|
"// === Channels Settings (src/components/settings-v2/system/channels/) ===": "",
|
||||||
|
"渠道管理": "Channel Management",
|
||||||
|
"AI 模型提供商": "AI Model Providers",
|
||||||
|
"消息通知渠道": "Notification Channels",
|
||||||
|
"添加 AI 渠道": "Add AI Channel",
|
||||||
|
"添加通知渠道": "Add Notification Channel",
|
||||||
|
"编辑 AI 渠道": "Edit AI Channel",
|
||||||
|
"编辑通知渠道": "Edit Notification Channel",
|
||||||
|
"飞书": "Feishu",
|
||||||
|
"Telegram": "Telegram",
|
||||||
|
"Discord": "Discord",
|
||||||
|
"名称": "Name",
|
||||||
|
"显示名称": "Display Name",
|
||||||
|
"引擎": "Engine",
|
||||||
|
"引擎类型": "Engine Type",
|
||||||
|
"描述": "Description",
|
||||||
|
"API 地址": "API URL",
|
||||||
|
"地址": "URL",
|
||||||
|
"Webhook 地址": "Webhook URL",
|
||||||
|
"Webhook URL": "Webhook URL",
|
||||||
|
"签名密钥": "Secret",
|
||||||
|
"Bot Token": "Bot Token",
|
||||||
|
"聊天 ID": "Chat ID",
|
||||||
|
"Chat ID": "Chat ID",
|
||||||
|
"自定义用户名": "Custom Username",
|
||||||
|
"模型数": "Models",
|
||||||
|
"模型列表": "Model List",
|
||||||
|
"API Key 环境变量名": "API Key Env Name",
|
||||||
|
"环境变量名": "Environment Variable Name",
|
||||||
|
"自定义请求头": "Custom Headers",
|
||||||
|
"超时时间": "Timeout",
|
||||||
|
"支持流式输出": "Support Streaming",
|
||||||
|
"状态": "Status",
|
||||||
|
"操作": "Actions",
|
||||||
|
"测试连接": "Test Connection",
|
||||||
|
"发送测试消息": "Send Test Message",
|
||||||
|
"测试消息": "Test Message",
|
||||||
|
"连接成功": "Connection Successful",
|
||||||
|
"连接失败": "Connection Failed",
|
||||||
|
"发送成功": "Sent Successfully",
|
||||||
|
"发送失败": "Send Failed",
|
||||||
|
"测试失败": "Test Failed",
|
||||||
|
"发送默认测试消息": "Send Default Test Message",
|
||||||
|
"发送自定义消息...": "Send Custom Message...",
|
||||||
|
"请输入测试消息内容": "Enter test message content",
|
||||||
|
"类型": "Type",
|
||||||
|
"配置": "Config",
|
||||||
|
"Webhook": "Webhook",
|
||||||
|
"Bot": "Bot",
|
||||||
|
"暂无 AI 渠道": "No AI Channels",
|
||||||
|
"暂无通知渠道": "No Notification Channels",
|
||||||
|
"点击上方按钮添加您的第一个 AI 渠道": "Click the button above to add your first AI channel",
|
||||||
|
"点击上方按钮添加您的第一个通知渠道": "Click the button above to add your first notification channel",
|
||||||
|
"选择消息通知渠道类型": "Select notification channel type",
|
||||||
|
"渠道类型": "Channel Type",
|
||||||
|
"我的飞书通知": "My Feishu Notification",
|
||||||
|
"用于验证 Webhook 请求的签名密钥": "Secret key for verifying webhook requests",
|
||||||
|
"从 @BotFather 获取": "Get from @BotFather",
|
||||||
|
"目标聊天或频道的 ID": "Target chat or channel ID",
|
||||||
|
"覆盖机器人的默认用户名": "Override the default bot username",
|
||||||
|
"唯一标识符,只能包含小写字母、数字和连字符": "Unique identifier, can only contain lowercase letters, numbers, and hyphens",
|
||||||
|
"可选的渠道描述": "Optional channel description",
|
||||||
|
"存储 API Key 的环境变量名称": "Environment variable name for storing API Key",
|
||||||
|
"JSON 格式的模型列表": "Model list in JSON format",
|
||||||
|
"JSON 格式的自定义请求头(可选)": "Custom headers in JSON format (optional)",
|
||||||
|
"该渠道是否支持 SSE 流式响应": "Whether this channel supports SSE streaming responses",
|
||||||
|
"保存中...": "Saving...",
|
||||||
|
"添加": "Add",
|
||||||
|
"保存": "Save",
|
||||||
|
"取消": "Cancel",
|
||||||
|
"删除": "Delete",
|
||||||
|
"确定要删除渠道 \"{name}\" 吗?此操作无法撤销。": "Are you sure you want to delete channel \"{name}\"? This action cannot be undone.",
|
||||||
|
"管理 OpenAI、Ollama、Anthropic 等 AI 服务提供商": "Manage AI service providers like OpenAI, Ollama, Anthropic, etc.",
|
||||||
|
"管理飞书、Telegram、Discord 等消息通知渠道": "Manage notification channels like Feishu, Telegram, Discord, etc.",
|
||||||
|
"这是一条来自 ProxyCast 的测试消息 📱": "This is a test message from ProxyCast 📱",
|
||||||
|
"AI 渠道已创建": "AI channel created",
|
||||||
|
"AI 渠道已更新": "AI channel updated",
|
||||||
|
"AI 渠道已删除": "AI channel deleted",
|
||||||
|
"AI 渠道已启用": "AI channel enabled",
|
||||||
|
"AI 渠道已禁用": "AI channel disabled",
|
||||||
|
"通知渠道已创建": "Notification channel created",
|
||||||
|
"通知渠道已更新": "Notification channel updated",
|
||||||
|
"通知渠道已删除": "Notification channel deleted",
|
||||||
|
"通知渠道已启用": "Notification channel enabled",
|
||||||
|
"通知渠道已禁用": "Notification channel disabled",
|
||||||
|
"无效的 JSON 格式": "Invalid JSON format",
|
||||||
|
"模型列表必须是数组格式": "Model list must be an array",
|
||||||
|
"超时时间必须是正整数": "Timeout must be a positive integer",
|
||||||
|
"请输入有效的 URL": "Please enter a valid URL",
|
||||||
|
"名称不能为空": "Name cannot be empty",
|
||||||
|
"显示名称不能为空": "Display name cannot be empty",
|
||||||
|
"API Key 环境变量名不能为空": "API Key env name cannot be empty",
|
||||||
|
"API 地址不能为空": "API URL cannot be empty",
|
||||||
|
"Webhook 地址不能为空": "Webhook URL cannot be empty",
|
||||||
|
"Webhook URL 不能为空": "Webhook URL cannot be empty",
|
||||||
|
"Bot Token 不能为空": "Bot token cannot be empty",
|
||||||
|
"聊天 ID 不能为空": "Chat ID cannot be empty",
|
||||||
|
"加载失败": "Load failed"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3723,5 +3723,102 @@
|
|||||||
"准备就绪": "准备就绪",
|
"准备就绪": "准备就绪",
|
||||||
"在下方输入详细的提示词以开始创作。": "在下方输入详细的提示词以开始创作。",
|
"在下方输入详细的提示词以开始创作。": "在下方输入详细的提示词以开始创作。",
|
||||||
"描述你想要生成的图片(例如:'赛博朋克风格的未来城市,日落时分,飞车穿梭,高细节')...": "描述你想要生成的图片(例如:'赛博朋克风格的未来城市,日落时分,飞车穿梭,高细节')...",
|
"描述你想要生成的图片(例如:'赛博朋克风格的未来城市,日落时分,飞车穿梭,高细节')...": "描述你想要生成的图片(例如:'赛博朋克风格的未来城市,日落时分,飞车穿梭,高细节')...",
|
||||||
"新建图片": "新建图片"
|
"// === Channels Settings (src/components/settings-v2/system/channels/) ===": "",
|
||||||
|
"渠道管理": "渠道管理",
|
||||||
|
"AI 模型提供商": "AI 模型提供商",
|
||||||
|
"消息通知渠道": "消息通知渠道",
|
||||||
|
"添加 AI 渠道": "添加 AI 渠道",
|
||||||
|
"添加通知渠道": "添加通知渠道",
|
||||||
|
"编辑 AI 渠道": "编辑 AI 渠道",
|
||||||
|
"编辑通知渠道": "编辑通知渠道",
|
||||||
|
"飞书": "飞书",
|
||||||
|
"Telegram": "Telegram",
|
||||||
|
"Discord": "Discord",
|
||||||
|
"名称": "名称",
|
||||||
|
"显示名称": "显示名称",
|
||||||
|
"引擎": "引擎",
|
||||||
|
"引擎类型": "引擎类型",
|
||||||
|
"描述": "描述",
|
||||||
|
"API 地址": "API 地址",
|
||||||
|
"地址": "地址",
|
||||||
|
"Webhook 地址": "Webhook 地址",
|
||||||
|
"Webhook URL": "Webhook URL",
|
||||||
|
"签名密钥": "签名密钥",
|
||||||
|
"Bot Token": "Bot Token",
|
||||||
|
"聊天 ID": "聊天 ID",
|
||||||
|
"Chat ID": "Chat ID",
|
||||||
|
"自定义用户名": "自定义用户名",
|
||||||
|
"模型数": "模型数",
|
||||||
|
"模型列表": "模型列表",
|
||||||
|
"API Key 环境变量名": "API Key 环境变量名",
|
||||||
|
"环境变量名": "环境变量名",
|
||||||
|
"自定义请求头": "自定义请求头",
|
||||||
|
"超时时间": "超时时间",
|
||||||
|
"支持流式输出": "支持流式输出",
|
||||||
|
"状态": "状态",
|
||||||
|
"操作": "操作",
|
||||||
|
"测试连接": "测试连接",
|
||||||
|
"发送测试消息": "发送测试消息",
|
||||||
|
"测试消息": "测试消息",
|
||||||
|
"连接成功": "连接成功",
|
||||||
|
"连接失败": "连接失败",
|
||||||
|
"发送成功": "发送成功",
|
||||||
|
"发送失败": "发送失败",
|
||||||
|
"测试失败": "测试失败",
|
||||||
|
"发送默认测试消息": "发送默认测试消息",
|
||||||
|
"发送自定义消息...": "发送自定义消息...",
|
||||||
|
"请输入测试消息内容": "请输入测试消息内容",
|
||||||
|
"类型": "类型",
|
||||||
|
"配置": "配置",
|
||||||
|
"Webhook": "Webhook",
|
||||||
|
"Bot": "Bot",
|
||||||
|
"暂无 AI 渠道": "暂无 AI 渠道",
|
||||||
|
"暂无通知渠道": "暂无通知渠道",
|
||||||
|
"点击上方按钮添加您的第一个 AI 渠道": "点击上方按钮添加您的第一个 AI 渠道",
|
||||||
|
"点击上方按钮添加您的第一个通知渠道": "点击上方按钮添加您的第一个通知渠道",
|
||||||
|
"选择消息通知渠道类型": "选择消息通知渠道类型",
|
||||||
|
"渠道类型": "渠道类型",
|
||||||
|
"我的飞书通知": "我的飞书通知",
|
||||||
|
"用于验证 Webhook 请求的签名密钥": "用于验证 Webhook 请求的签名密钥",
|
||||||
|
"从 @BotFather 获取": "从 @BotFather 获取",
|
||||||
|
"目标聊天或频道的 ID": "目标聊天或频道的 ID",
|
||||||
|
"覆盖机器人的默认用户名": "覆盖机器人的默认用户名",
|
||||||
|
"唯一标识符,只能包含小写字母、数字和连字符": "唯一标识符,只能包含小写字母、数字和连字符",
|
||||||
|
"可选的渠道描述": "可选的渠道描述",
|
||||||
|
"存储 API Key 的环境变量名称": "存储 API Key 的环境变量名称",
|
||||||
|
"JSON 格式的模型列表": "JSON 格式的模型列表",
|
||||||
|
"JSON 格式的自定义请求头(可选)": "JSON 格式的自定义请求头(可选)",
|
||||||
|
"该渠道是否支持 SSE 流式响应": "该渠道是否支持 SSE 流式响应",
|
||||||
|
"保存中...": "保存中...",
|
||||||
|
"添加": "添加",
|
||||||
|
"保存": "保存",
|
||||||
|
"取消": "取消",
|
||||||
|
"删除": "删除",
|
||||||
|
"确定要删除渠道 \"{name}\" 吗?此操作无法撤销。": "确定要删除渠道 \"{name}\" 吗?此操作无法撤销。",
|
||||||
|
"管理 OpenAI、Ollama、Anthropic 等 AI 服务提供商": "管理 OpenAI、Ollama、Anthropic 等 AI 服务提供商",
|
||||||
|
"管理飞书、Telegram、Discord 等消息通知渠道": "管理飞书、Telegram、Discord 等消息通知渠道",
|
||||||
|
"这是一条来自 ProxyCast 的测试消息 📱": "这是一条来自 ProxyCast 的测试消息 📱",
|
||||||
|
"AI 渠道已创建": "AI 渠道已创建",
|
||||||
|
"AI 渠道已更新": "AI 渠道已更新",
|
||||||
|
"AI 渠道已删除": "AI 渠道已删除",
|
||||||
|
"AI 渠道已启用": "AI 渠道已启用",
|
||||||
|
"AI 渠道已禁用": "AI 渠道已禁用",
|
||||||
|
"通知渠道已创建": "通知渠道已创建",
|
||||||
|
"通知渠道已更新": "通知渠道已更新",
|
||||||
|
"通知渠道已删除": "通知渠道已删除",
|
||||||
|
"通知渠道已启用": "通知渠道已启用",
|
||||||
|
"通知渠道已禁用": "通知渠道已禁用",
|
||||||
|
"无效的 JSON 格式": "无效的 JSON 格式",
|
||||||
|
"模型列表必须是数组格式": "模型列表必须是数组格式",
|
||||||
|
"超时时间必须是正整数": "超时时间必须是正整数",
|
||||||
|
"请输入有效的 URL": "请输入有效的 URL",
|
||||||
|
"名称不能为空": "名称不能为空",
|
||||||
|
"显示名称不能为空": "显示名称不能为空",
|
||||||
|
"API Key 环境变量名不能为空": "API Key 环境变量名不能为空",
|
||||||
|
"API 地址不能为空": "API 地址不能为空",
|
||||||
|
"Webhook 地址不能为空": "Webhook 地址不能为空",
|
||||||
|
"Webhook URL 不能为空": "Webhook URL 不能为空",
|
||||||
|
"Bot Token 不能为空": "Bot Token 不能为空",
|
||||||
|
"聊天 ID 不能为空": "聊天 ID 不能为空",
|
||||||
|
"加载失败": "加载失败"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,394 @@
|
|||||||
|
/**
|
||||||
|
* 渠道管理 API
|
||||||
|
*
|
||||||
|
* 封装所有渠道管理的 API 调用,包括 AI 渠道和通知渠道
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { safeInvoke } from "@/lib/dev-bridge";
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// 类型定义
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AI 提供商引擎类型
|
||||||
|
*/
|
||||||
|
export enum AIProviderEngine {
|
||||||
|
OPENAI = "openai",
|
||||||
|
OLLAMA = "ollama",
|
||||||
|
ANTHROPIC = "anthropic",
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 模型信息
|
||||||
|
*/
|
||||||
|
export interface ModelInfo {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AI 渠道配置
|
||||||
|
*/
|
||||||
|
export interface AIChannelConfig {
|
||||||
|
name: string;
|
||||||
|
engine: AIProviderEngine;
|
||||||
|
display_name: string;
|
||||||
|
description?: string;
|
||||||
|
api_key_env: string;
|
||||||
|
base_url: string;
|
||||||
|
models: ModelInfo[];
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
timeout_seconds?: number;
|
||||||
|
supports_streaming?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AI 渠道
|
||||||
|
*/
|
||||||
|
export interface AIChannel {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
engine: AIProviderEngine;
|
||||||
|
display_name: string;
|
||||||
|
description?: string;
|
||||||
|
api_key_env: string;
|
||||||
|
base_url: string;
|
||||||
|
models: ModelInfo[];
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
timeout_seconds?: number;
|
||||||
|
supports_streaming?: boolean;
|
||||||
|
enabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 连接测试结果
|
||||||
|
*/
|
||||||
|
export interface ConnectionTestResult {
|
||||||
|
success: boolean;
|
||||||
|
message: string;
|
||||||
|
details?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 通知渠道类型
|
||||||
|
*/
|
||||||
|
export enum NotificationChannelType {
|
||||||
|
FEISHU = "feishu",
|
||||||
|
TELEGRAM = "telegram",
|
||||||
|
DISCORD = "discord",
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 飞书配置
|
||||||
|
*/
|
||||||
|
export interface FeishuConfig {
|
||||||
|
webhook_url: string;
|
||||||
|
secret?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Telegram 配置
|
||||||
|
*/
|
||||||
|
export interface TelegramConfig {
|
||||||
|
bot_token: string;
|
||||||
|
chat_id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Discord 配置
|
||||||
|
*/
|
||||||
|
export interface DiscordConfig {
|
||||||
|
webhook_url: string;
|
||||||
|
username?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 通知渠道特定配置
|
||||||
|
*/
|
||||||
|
export type NotificationChannelSpecificConfig =
|
||||||
|
| { type: NotificationChannelType.FEISHU } & FeishuConfig
|
||||||
|
| { type: NotificationChannelType.TELEGRAM } & TelegramConfig
|
||||||
|
| { type: NotificationChannelType.DISCORD } & DiscordConfig;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 通知渠道配置
|
||||||
|
*/
|
||||||
|
export interface NotificationChannelConfig {
|
||||||
|
name: string;
|
||||||
|
channel_type: NotificationChannelType;
|
||||||
|
config: NotificationChannelSpecificConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 通知渠道
|
||||||
|
*/
|
||||||
|
export interface NotificationChannel {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
channel_type: NotificationChannelType;
|
||||||
|
config: NotificationChannelSpecificConfig;
|
||||||
|
enabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 测试消息结果
|
||||||
|
*/
|
||||||
|
export interface TestMessageResult {
|
||||||
|
success: boolean;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// AI 渠道 API
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AI 渠道 API
|
||||||
|
*/
|
||||||
|
export const aiChannelsApi = {
|
||||||
|
/**
|
||||||
|
* 获取所有 AI 渠道
|
||||||
|
*/
|
||||||
|
async getChannels(): Promise<AIChannel[]> {
|
||||||
|
return safeInvoke<Array<unknown>>("get_ai_channels").then((channels) =>
|
||||||
|
channels.map(mapAIChannelFromBackend),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取单个 AI 渠道
|
||||||
|
*/
|
||||||
|
async getChannel(id: string): Promise<AIChannel> {
|
||||||
|
const channel = await safeInvoke<AIChannel>("get_ai_channel", { id });
|
||||||
|
return mapAIChannelFromBackend(channel);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建 AI 渠道
|
||||||
|
*/
|
||||||
|
async createChannel(config: AIChannelConfig): Promise<AIChannel> {
|
||||||
|
const channel = await safeInvoke<AIChannel>("create_ai_channel", {
|
||||||
|
config: mapAIChannelConfigToBackend(config),
|
||||||
|
});
|
||||||
|
return mapAIChannelFromBackend(channel);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新 AI 渠道
|
||||||
|
*/
|
||||||
|
async updateChannel(
|
||||||
|
id: string,
|
||||||
|
config: Partial<AIChannelConfig>,
|
||||||
|
): Promise<AIChannel> {
|
||||||
|
const channel = await safeInvoke<AIChannel>("update_ai_channel", {
|
||||||
|
id,
|
||||||
|
config: mapAIChannelConfigToBackend(
|
||||||
|
config as AIChannelConfig,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
return mapAIChannelFromBackend(channel);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除 AI 渠道
|
||||||
|
*/
|
||||||
|
async deleteChannel(id: string): Promise<void> {
|
||||||
|
await safeInvoke<void>("delete_ai_channel", { id });
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 测试 AI 渠道连接
|
||||||
|
*/
|
||||||
|
async testChannel(id: string): Promise<ConnectionTestResult> {
|
||||||
|
return safeInvoke<ConnectionTestResult>("test_ai_channel", { id });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// 通知渠道 API
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 通知渠道 API
|
||||||
|
*/
|
||||||
|
export const notificationChannelsApi = {
|
||||||
|
/**
|
||||||
|
* 获取所有通知渠道
|
||||||
|
*/
|
||||||
|
async getChannels(): Promise<NotificationChannel[]> {
|
||||||
|
return safeInvoke<Array<unknown>>("get_notification_channels").then(
|
||||||
|
(channels) => channels.map(mapNotificationChannelFromBackend),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取单个通知渠道
|
||||||
|
*/
|
||||||
|
async getChannel(id: string): Promise<NotificationChannel> {
|
||||||
|
const channel = await safeInvoke<NotificationChannel>(
|
||||||
|
"get_notification_channel",
|
||||||
|
{ id },
|
||||||
|
);
|
||||||
|
return mapNotificationChannelFromBackend(channel);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建通知渠道
|
||||||
|
*/
|
||||||
|
async createChannel(
|
||||||
|
config: NotificationChannelConfig,
|
||||||
|
): Promise<NotificationChannel> {
|
||||||
|
const channel = await safeInvoke<NotificationChannel>(
|
||||||
|
"create_notification_channel",
|
||||||
|
{ config: mapNotificationChannelConfigToBackend(config) },
|
||||||
|
);
|
||||||
|
return mapNotificationChannelFromBackend(channel);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新通知渠道
|
||||||
|
*/
|
||||||
|
async updateChannel(
|
||||||
|
id: string,
|
||||||
|
config: Partial<NotificationChannelConfig>,
|
||||||
|
): Promise<NotificationChannel> {
|
||||||
|
const channel = await safeInvoke<NotificationChannel>(
|
||||||
|
"update_notification_channel",
|
||||||
|
{
|
||||||
|
id,
|
||||||
|
config: mapNotificationChannelConfigToBackend(
|
||||||
|
config as NotificationChannelConfig,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return mapNotificationChannelFromBackend(channel);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除通知渠道
|
||||||
|
*/
|
||||||
|
async deleteChannel(id: string): Promise<void> {
|
||||||
|
await safeInvoke<void>("delete_notification_channel", { id });
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发送测试消息到通知渠道
|
||||||
|
*/
|
||||||
|
async testChannel(id: string, message: string): Promise<TestMessageResult> {
|
||||||
|
return safeInvoke<TestMessageResult>("test_notification_channel", {
|
||||||
|
id,
|
||||||
|
message,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// 数据映射函数
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将后端 AI 渠道数据映射到前端格式
|
||||||
|
*/
|
||||||
|
function mapAIChannelFromBackend(channel: unknown): AIChannel {
|
||||||
|
const data = channel as Record<string, unknown>;
|
||||||
|
return {
|
||||||
|
id: data.id as string,
|
||||||
|
name: data.name as string,
|
||||||
|
engine: data.engine as AIProviderEngine,
|
||||||
|
display_name: data.display_name as string,
|
||||||
|
description: data.description as string | undefined,
|
||||||
|
api_key_env: data.api_key_env as string,
|
||||||
|
base_url: data.base_url as string,
|
||||||
|
models: (data.models as Array<Record<string, unknown>> | undefined)?.map(
|
||||||
|
(m) => ({
|
||||||
|
id: m.id as string,
|
||||||
|
name: m.name as string,
|
||||||
|
description: m.description as string | undefined,
|
||||||
|
}),
|
||||||
|
) ?? [],
|
||||||
|
headers: data.headers as Record<string, string> | undefined,
|
||||||
|
timeout_seconds: data.timeout_seconds as number | undefined,
|
||||||
|
supports_streaming: data.supports_streaming as boolean | undefined,
|
||||||
|
enabled: data.enabled as boolean,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将前端 AI 渠道配置映射到后端格式
|
||||||
|
*/
|
||||||
|
function mapAIChannelConfigToBackend(config: AIChannelConfig): Record<string, unknown> {
|
||||||
|
return {
|
||||||
|
name: config.name,
|
||||||
|
engine: config.engine,
|
||||||
|
display_name: config.display_name,
|
||||||
|
description: config.description,
|
||||||
|
api_key_env: config.api_key_env,
|
||||||
|
base_url: config.base_url,
|
||||||
|
models: config.models,
|
||||||
|
headers: config.headers,
|
||||||
|
timeout_seconds: config.timeout_seconds,
|
||||||
|
supports_streaming: config.supports_streaming,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将后端通知渠道数据映射到前端格式
|
||||||
|
*/
|
||||||
|
function mapNotificationChannelFromBackend(channel: unknown): NotificationChannel {
|
||||||
|
const data = channel as Record<string, unknown>;
|
||||||
|
const baseConfig = {
|
||||||
|
id: data.id as string,
|
||||||
|
name: data.name as string,
|
||||||
|
channel_type: data.channel_type as NotificationChannelType,
|
||||||
|
enabled: data.enabled as boolean,
|
||||||
|
};
|
||||||
|
|
||||||
|
const config = data.config as Record<string, unknown>;
|
||||||
|
|
||||||
|
switch (baseConfig.channel_type) {
|
||||||
|
case NotificationChannelType.FEISHU:
|
||||||
|
return {
|
||||||
|
...baseConfig,
|
||||||
|
config: {
|
||||||
|
type: NotificationChannelType.FEISHU,
|
||||||
|
webhook_url: config.webhook_url as string,
|
||||||
|
secret: config.secret as string | undefined,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
case NotificationChannelType.TELEGRAM:
|
||||||
|
return {
|
||||||
|
...baseConfig,
|
||||||
|
config: {
|
||||||
|
type: NotificationChannelType.TELEGRAM,
|
||||||
|
bot_token: config.bot_token as string,
|
||||||
|
chat_id: config.chat_id as string,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
case NotificationChannelType.DISCORD:
|
||||||
|
return {
|
||||||
|
...baseConfig,
|
||||||
|
config: {
|
||||||
|
type: NotificationChannelType.DISCORD,
|
||||||
|
webhook_url: config.webhook_url as string,
|
||||||
|
username: config.username as string | undefined,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
default:
|
||||||
|
throw new Error(`未知的通知渠道类型: ${baseConfig.channel_type}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将前端通知渠道配置映射到后端格式
|
||||||
|
*/
|
||||||
|
function mapNotificationChannelConfigToBackend(
|
||||||
|
config: NotificationChannelConfig,
|
||||||
|
): Record<string, unknown> {
|
||||||
|
return {
|
||||||
|
name: config.name,
|
||||||
|
channel_type: config.channel_type,
|
||||||
|
config: config.config,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -37,6 +37,7 @@ export enum SettingsTabs {
|
|||||||
// 系统
|
// 系统
|
||||||
ApiServer = "api-server",
|
ApiServer = "api-server",
|
||||||
McpServer = "mcp-server",
|
McpServer = "mcp-server",
|
||||||
|
Channels = "channels",
|
||||||
Proxy = "proxy",
|
Proxy = "proxy",
|
||||||
SecurityPerformance = "security-performance",
|
SecurityPerformance = "security-performance",
|
||||||
Heartbeat = "heartbeat",
|
Heartbeat = "heartbeat",
|
||||||
@@ -85,6 +86,7 @@ export const SETTINGS_GROUPS: Record<SettingsGroupKey, SettingsTabs[]> = {
|
|||||||
[SettingsGroupKey.System]: [
|
[SettingsGroupKey.System]: [
|
||||||
SettingsTabs.ApiServer,
|
SettingsTabs.ApiServer,
|
||||||
SettingsTabs.McpServer,
|
SettingsTabs.McpServer,
|
||||||
|
SettingsTabs.Channels,
|
||||||
SettingsTabs.Proxy,
|
SettingsTabs.Proxy,
|
||||||
SettingsTabs.SecurityPerformance,
|
SettingsTabs.SecurityPerformance,
|
||||||
SettingsTabs.Heartbeat,
|
SettingsTabs.Heartbeat,
|
||||||
|
|||||||
Reference in New Issue
Block a user