mirror of
https://github.com/aiclientproxy/proxycast.git
synced 2026-09-24 23:10:56 +08:00
feat: 统一内容创作系统 v0.56.0
- 项目选择器支持按主题类型筛选 - 修复 Select 组件点击外部不关闭的问题 - projectId 贯穿整个聊天生命周期 - 修复 lint 错误和 clippy 警告 - 新增人设、素材、模板管理功能
This commit is contained in:
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "proxycast",
|
||||
"private": true,
|
||||
"version": "0.55.0",
|
||||
"version": "0.56.0",
|
||||
"type": "module",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
Generated
+3
-3
@@ -6553,7 +6553,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proxycast"
|
||||
version = "0.55.0"
|
||||
version = "0.56.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"arboard",
|
||||
@@ -6635,7 +6635,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proxycast-core"
|
||||
version = "0.55.0"
|
||||
version = "0.56.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dirs 5.0.1",
|
||||
@@ -6651,7 +6651,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proxycast-infra"
|
||||
version = "0.55.0"
|
||||
version = "0.56.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dashmap 5.5.3",
|
||||
|
||||
@@ -3,7 +3,7 @@ members = ["crates/*"]
|
||||
resolver = "2"
|
||||
|
||||
[workspace.package]
|
||||
version = "0.55.0"
|
||||
version = "0.56.0"
|
||||
edition = "2021"
|
||||
authors = ["you"]
|
||||
repository = "https://github.com/aiclientproxy/proxycast"
|
||||
@@ -164,7 +164,7 @@ version = "2.4"
|
||||
|
||||
[package]
|
||||
name = "proxycast"
|
||||
version = "0.55.0"
|
||||
version = "0.56.0"
|
||||
description = "AI API Proxy Desktop App"
|
||||
authors = ["you"]
|
||||
edition = "2021"
|
||||
|
||||
@@ -15,6 +15,7 @@ Tauri 后端核心代码,处理系统级功能和 API 服务。
|
||||
- `converter/` - 协议转换(OpenAI ↔ CW/Claude/Antigravity)
|
||||
- `credential/` - 凭证池管理(负载均衡、健康检查)
|
||||
- `database/` - 数据库层(SQLite + DAO)
|
||||
- `errors/` - 错误类型定义(项目、人设、素材、模板、迁移错误)
|
||||
- `flow_monitor/` - LLM 流量监控(拦截、存储、查询)
|
||||
- `injection/` - 请求注入(系统提示词等)
|
||||
- `middleware/` - HTTP 中间件
|
||||
|
||||
@@ -28,6 +28,7 @@ use crate::agent::credential_bridge::{
|
||||
};
|
||||
use crate::database::DbConnection;
|
||||
use crate::services::aster_session_store::ProxyCastSessionStore;
|
||||
use crate::services::project_context_builder::ProjectContextBuilder;
|
||||
|
||||
/// Provider 配置信息
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -396,6 +397,57 @@ impl AsterAgentState {
|
||||
tokens.remove(session_id);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
// 项目上下文支持
|
||||
// ------------------------------------------------------------------------
|
||||
|
||||
/// 构建带项目上下文的 System Prompt
|
||||
///
|
||||
/// 加载项目的人设、素材、模板配置,构建完整的 AI 提示词。
|
||||
///
|
||||
/// # 参数
|
||||
/// - `db`: 数据库连接
|
||||
/// - `project_id`: 项目 ID
|
||||
///
|
||||
/// # 返回
|
||||
/// - 成功返回构建好的 System Prompt
|
||||
/// - 失败返回错误信息
|
||||
pub fn build_project_system_prompt(
|
||||
db: &DbConnection,
|
||||
project_id: &str,
|
||||
) -> Result<String, String> {
|
||||
let conn = db
|
||||
.lock()
|
||||
.map_err(|e| format!("获取数据库连接失败: {}", e))?;
|
||||
let context = ProjectContextBuilder::build_context(&conn, project_id)
|
||||
.map_err(|e| format!("构建项目上下文失败: {}", e))?;
|
||||
Ok(ProjectContextBuilder::build_system_prompt(&context))
|
||||
}
|
||||
|
||||
/// 创建带项目上下文的会话配置
|
||||
///
|
||||
/// 自动加载项目配置并构建 SessionConfig。
|
||||
///
|
||||
/// # 参数
|
||||
/// - `db`: 数据库连接
|
||||
/// - `session_id`: 会话 ID
|
||||
/// - `project_id`: 项目 ID(可选,如果为 None 则不注入项目上下文)
|
||||
///
|
||||
/// # 返回
|
||||
/// - 构建好的 SessionConfig
|
||||
pub fn create_session_config_with_project(
|
||||
db: &DbConnection,
|
||||
session_id: &str,
|
||||
project_id: Option<&str>,
|
||||
) -> SessionConfig {
|
||||
let system_prompt =
|
||||
project_id.and_then(|pid| Self::build_project_system_prompt(db, pid).ok());
|
||||
|
||||
SessionConfigBuilder::new(session_id)
|
||||
.system_prompt(system_prompt.unwrap_or_default())
|
||||
.build()
|
||||
}
|
||||
|
||||
/// 检查 Agent 是否已初始化
|
||||
pub async fn is_initialized(&self) -> bool {
|
||||
self.agent.read().await.is_some()
|
||||
|
||||
@@ -1247,6 +1247,35 @@ pub fn run() {
|
||||
commands::workspace_cmd::workspace_set_default,
|
||||
commands::workspace_cmd::workspace_get_default,
|
||||
commands::workspace_cmd::workspace_get_by_path,
|
||||
commands::workspace_cmd::get_or_create_default_project,
|
||||
commands::workspace_cmd::get_project_context,
|
||||
commands::workspace_cmd::build_project_system_prompt,
|
||||
// Persona commands
|
||||
commands::persona_cmd::create_persona,
|
||||
commands::persona_cmd::list_personas,
|
||||
commands::persona_cmd::get_persona,
|
||||
commands::persona_cmd::update_persona,
|
||||
commands::persona_cmd::delete_persona,
|
||||
commands::persona_cmd::set_default_persona,
|
||||
commands::persona_cmd::list_persona_templates,
|
||||
commands::persona_cmd::get_default_persona,
|
||||
// Material commands
|
||||
commands::material_cmd::upload_material,
|
||||
commands::material_cmd::list_materials,
|
||||
commands::material_cmd::get_material,
|
||||
commands::material_cmd::update_material,
|
||||
commands::material_cmd::delete_material,
|
||||
commands::material_cmd::get_material_content,
|
||||
commands::material_cmd::get_material_count,
|
||||
commands::material_cmd::get_materials_content,
|
||||
// Template commands
|
||||
commands::template_cmd::create_template,
|
||||
commands::template_cmd::list_templates,
|
||||
commands::template_cmd::get_template,
|
||||
commands::template_cmd::update_template,
|
||||
commands::template_cmd::delete_template,
|
||||
commands::template_cmd::set_default_template,
|
||||
commands::template_cmd::get_default_template,
|
||||
// Content commands
|
||||
commands::content_cmd::content_create,
|
||||
commands::content_cmd::content_get,
|
||||
|
||||
@@ -167,6 +167,9 @@ pub struct AsterChatRequest {
|
||||
/// Provider 配置(可选,如果未配置则使用当前配置)
|
||||
#[serde(default)]
|
||||
pub provider_config: Option<ConfigureProviderRequest>,
|
||||
/// 项目 ID(可选,用于注入项目上下文到 System Prompt)
|
||||
#[serde(default)]
|
||||
pub project_id: Option<String>,
|
||||
}
|
||||
|
||||
/// 图片输入
|
||||
@@ -214,30 +217,59 @@ pub async fn aster_agent_chat_stream(
|
||||
// 同时 get_session 也会自动创建不存在的 session
|
||||
let session_id = &request.session_id;
|
||||
|
||||
// 从 ProxyCast 数据库读取 session 的 system_prompt(如果存在)
|
||||
// 构建 system_prompt:优先使用项目上下文,其次使用 session 的 system_prompt
|
||||
let system_prompt = {
|
||||
let db_conn = db.lock().map_err(|e| format!("获取数据库连接失败: {e}"))?;
|
||||
match AgentDao::get_session(&db_conn, session_id) {
|
||||
Ok(Some(session)) => {
|
||||
tracing::debug!(
|
||||
"[AsterAgent] 找到 session,system_prompt: {:?}",
|
||||
session.system_prompt.as_ref().map(|s| s.len())
|
||||
);
|
||||
session.system_prompt
|
||||
|
||||
// 1. 如果提供了 project_id,构建项目上下文
|
||||
let project_prompt = if let Some(ref project_id) = request.project_id {
|
||||
match AsterAgentState::build_project_system_prompt(&db, project_id) {
|
||||
Ok(prompt) => {
|
||||
tracing::info!(
|
||||
"[AsterAgent] 已加载项目上下文: project_id={}, prompt_len={}",
|
||||
project_id,
|
||||
prompt.len()
|
||||
);
|
||||
Some(prompt)
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
"[AsterAgent] 加载项目上下文失败: {}, 继续使用 session prompt",
|
||||
e
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
Ok(None) => {
|
||||
tracing::debug!(
|
||||
"[AsterAgent] ProxyCast 数据库中未找到 session: {}",
|
||||
session_id
|
||||
);
|
||||
None
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
"[AsterAgent] 读取 session 失败: {}, 继续使用空 system_prompt",
|
||||
e
|
||||
);
|
||||
None
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// 2. 如果没有项目上下文,尝试从 session 读取
|
||||
if project_prompt.is_some() {
|
||||
project_prompt
|
||||
} else {
|
||||
match AgentDao::get_session(&db_conn, session_id) {
|
||||
Ok(Some(session)) => {
|
||||
tracing::debug!(
|
||||
"[AsterAgent] 找到 session,system_prompt: {:?}",
|
||||
session.system_prompt.as_ref().map(|s| s.len())
|
||||
);
|
||||
session.system_prompt
|
||||
}
|
||||
Ok(None) => {
|
||||
tracing::debug!(
|
||||
"[AsterAgent] ProxyCast 数据库中未找到 session: {}",
|
||||
session_id
|
||||
);
|
||||
None
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
"[AsterAgent] 读取 session 失败: {}, 继续使用空 system_prompt",
|
||||
e
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
//! 素材相关的 Tauri 命令
|
||||
//!
|
||||
//! 提供素材(Material)管理的前端 API,包括:
|
||||
//! - 上传、获取、列表、更新、删除素材
|
||||
//! - 获取素材内容(用于 AI 引用)
|
||||
//!
|
||||
//! ## 相关需求
|
||||
//! - Requirements 7.1: 素材列表显示
|
||||
//! - Requirements 7.2: 上传素材按钮
|
||||
//! - Requirements 7.3: 素材创建
|
||||
//! - Requirements 7.4: 素材搜索和筛选
|
||||
//! - Requirements 7.5: 素材预览
|
||||
//! - Requirements 7.6: 素材删除
|
||||
|
||||
use tauri::State;
|
||||
|
||||
use crate::database::DbConnection;
|
||||
use crate::models::project_model::{
|
||||
Material, MaterialFilter, MaterialUpdate, UploadMaterialRequest,
|
||||
};
|
||||
use crate::services::material_service::MaterialService;
|
||||
|
||||
// ============================================================================
|
||||
// Tauri 命令
|
||||
// ============================================================================
|
||||
|
||||
/// 上传素材
|
||||
///
|
||||
/// 在指定项目中上传新的素材。支持文档、图片、文本、数据文件等类型。
|
||||
///
|
||||
/// # 参数
|
||||
/// - `db`: 数据库连接状态
|
||||
/// - `req`: 上传素材请求,包含项目 ID、名称、类型、文件路径等信息
|
||||
///
|
||||
/// # 返回
|
||||
/// - 成功返回创建的素材
|
||||
/// - 失败返回错误信息
|
||||
///
|
||||
/// # 示例(前端调用)
|
||||
/// ```typescript
|
||||
/// const material = await invoke('upload_material', {
|
||||
/// req: {
|
||||
/// project_id: 'project-1',
|
||||
/// name: '参考文档.pdf',
|
||||
/// type: 'document',
|
||||
/// file_path: '/tmp/upload.pdf',
|
||||
/// tags: ['参考', '重要'],
|
||||
/// description: '项目参考文档',
|
||||
/// }
|
||||
/// });
|
||||
/// ```
|
||||
#[tauri::command]
|
||||
pub async fn upload_material(
|
||||
db: State<'_, DbConnection>,
|
||||
req: UploadMaterialRequest,
|
||||
) -> Result<Material, String> {
|
||||
let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?;
|
||||
MaterialService::upload_material(&conn, req).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 获取项目的素材列表
|
||||
///
|
||||
/// 获取指定项目下的所有素材,支持按类型、标签和关键词筛选。
|
||||
///
|
||||
/// # 参数
|
||||
/// - `db`: 数据库连接状态
|
||||
/// - `project_id`: 项目 ID
|
||||
/// - `filter`: 可选的筛选条件
|
||||
///
|
||||
/// # 返回
|
||||
/// - 成功返回素材列表
|
||||
/// - 失败返回错误信息
|
||||
///
|
||||
/// # 示例(前端调用)
|
||||
/// ```typescript
|
||||
/// // 获取所有素材
|
||||
/// const materials = await invoke('list_materials', {
|
||||
/// projectId: 'project-1'
|
||||
/// });
|
||||
///
|
||||
/// // 按类型筛选
|
||||
/// const documents = await invoke('list_materials', {
|
||||
/// projectId: 'project-1',
|
||||
/// filter: { type: 'document' }
|
||||
/// });
|
||||
///
|
||||
/// // 按标签筛选
|
||||
/// const important = await invoke('list_materials', {
|
||||
/// projectId: 'project-1',
|
||||
/// filter: { tags: ['重要'] }
|
||||
/// });
|
||||
/// ```
|
||||
#[tauri::command]
|
||||
pub async fn list_materials(
|
||||
db: State<'_, DbConnection>,
|
||||
project_id: String,
|
||||
filter: Option<MaterialFilter>,
|
||||
) -> Result<Vec<Material>, String> {
|
||||
let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?;
|
||||
MaterialService::list_materials(&conn, &project_id, filter).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 获取单个素材
|
||||
///
|
||||
/// 根据 ID 获取素材详情。
|
||||
///
|
||||
/// # 参数
|
||||
/// - `db`: 数据库连接状态
|
||||
/// - `id`: 素材 ID
|
||||
///
|
||||
/// # 返回
|
||||
/// - 成功返回 Option<Material>,不存在时返回 None
|
||||
/// - 失败返回错误信息
|
||||
///
|
||||
/// # 示例(前端调用)
|
||||
/// ```typescript
|
||||
/// const material = await invoke('get_material', {
|
||||
/// id: 'material-1'
|
||||
/// });
|
||||
/// ```
|
||||
#[tauri::command]
|
||||
pub async fn get_material(
|
||||
db: State<'_, DbConnection>,
|
||||
id: String,
|
||||
) -> Result<Option<Material>, String> {
|
||||
let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?;
|
||||
MaterialService::get_material(&conn, &id).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 更新素材元数据
|
||||
///
|
||||
/// 更新指定素材的元数据信息(名称、标签、描述)。
|
||||
/// 注意:不能更新文件内容,如需更新文件请删除后重新上传。
|
||||
///
|
||||
/// # 参数
|
||||
/// - `db`: 数据库连接状态
|
||||
/// - `id`: 素材 ID
|
||||
/// - `update`: 更新内容,只包含需要更新的字段
|
||||
///
|
||||
/// # 返回
|
||||
/// - 成功返回更新后的素材
|
||||
/// - 失败返回错误信息
|
||||
///
|
||||
/// # 示例(前端调用)
|
||||
/// ```typescript
|
||||
/// const material = await invoke('update_material', {
|
||||
/// id: 'material-1',
|
||||
/// update: {
|
||||
/// name: '新名称',
|
||||
/// tags: ['新标签'],
|
||||
/// description: '新描述',
|
||||
/// }
|
||||
/// });
|
||||
/// ```
|
||||
#[tauri::command]
|
||||
pub async fn update_material(
|
||||
db: State<'_, DbConnection>,
|
||||
id: String,
|
||||
update: MaterialUpdate,
|
||||
) -> Result<Material, String> {
|
||||
let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?;
|
||||
MaterialService::update_material(&conn, &id, update).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 删除素材
|
||||
///
|
||||
/// 删除指定的素材,同时删除数据库记录和文件系统中的文件。
|
||||
/// 此操作不可逆,请谨慎使用。
|
||||
///
|
||||
/// # 参数
|
||||
/// - `db`: 数据库连接状态
|
||||
/// - `id`: 素材 ID
|
||||
///
|
||||
/// # 返回
|
||||
/// - 成功返回 ()
|
||||
/// - 失败返回错误信息
|
||||
///
|
||||
/// # 示例(前端调用)
|
||||
/// ```typescript
|
||||
/// await invoke('delete_material', {
|
||||
/// id: 'material-1'
|
||||
/// });
|
||||
/// ```
|
||||
#[tauri::command]
|
||||
pub async fn delete_material(db: State<'_, DbConnection>, id: String) -> Result<(), String> {
|
||||
let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?;
|
||||
MaterialService::delete_material(&conn, &id).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 获取素材内容
|
||||
///
|
||||
/// 获取素材的文本内容,用于 AI 引用。
|
||||
/// 根据素材类型返回不同的内容:
|
||||
/// - text 类型:返回 content 字段或读取文件内容
|
||||
/// - document 类型:对于文本文件返回内容,其他返回描述信息
|
||||
/// - image/data/link 类型:返回描述信息
|
||||
///
|
||||
/// # 参数
|
||||
/// - `db`: 数据库连接状态
|
||||
/// - `id`: 素材 ID
|
||||
///
|
||||
/// # 返回
|
||||
/// - 成功返回素材内容字符串
|
||||
/// - 失败返回错误信息
|
||||
///
|
||||
/// # 示例(前端调用)
|
||||
/// ```typescript
|
||||
/// const content = await invoke('get_material_content', {
|
||||
/// id: 'material-1'
|
||||
/// });
|
||||
/// ```
|
||||
#[tauri::command]
|
||||
pub async fn get_material_content(
|
||||
db: State<'_, DbConnection>,
|
||||
id: String,
|
||||
) -> Result<String, String> {
|
||||
let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?;
|
||||
MaterialService::get_material_content(&conn, &id).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 获取项目的素材数量
|
||||
///
|
||||
/// 获取指定项目下的素材总数。
|
||||
///
|
||||
/// # 参数
|
||||
/// - `db`: 数据库连接状态
|
||||
/// - `project_id`: 项目 ID
|
||||
///
|
||||
/// # 返回
|
||||
/// - 成功返回素材数量
|
||||
/// - 失败返回错误信息
|
||||
///
|
||||
/// # 示例(前端调用)
|
||||
/// ```typescript
|
||||
/// const count = await invoke('get_material_count', {
|
||||
/// projectId: 'project-1'
|
||||
/// });
|
||||
/// ```
|
||||
#[tauri::command]
|
||||
pub async fn get_material_count(
|
||||
db: State<'_, DbConnection>,
|
||||
project_id: String,
|
||||
) -> Result<i64, String> {
|
||||
let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?;
|
||||
crate::database::dao::material_dao::MaterialDao::count(&conn, &project_id)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 批量获取素材内容
|
||||
///
|
||||
/// 获取项目下所有素材的内容,用于构建项目上下文。
|
||||
/// 返回素材名称和内容的列表。
|
||||
///
|
||||
/// # 参数
|
||||
/// - `db`: 数据库连接状态
|
||||
/// - `project_id`: 项目 ID
|
||||
///
|
||||
/// # 返回
|
||||
/// - 成功返回素材内容列表 [(name, content), ...]
|
||||
/// - 失败返回错误信息
|
||||
///
|
||||
/// # 示例(前端调用)
|
||||
/// ```typescript
|
||||
/// const contents = await invoke('get_materials_content', {
|
||||
/// projectId: 'project-1'
|
||||
/// });
|
||||
/// // contents: [['文档1', '内容1'], ['文档2', '内容2']]
|
||||
/// ```
|
||||
#[tauri::command]
|
||||
pub async fn get_materials_content(
|
||||
db: State<'_, DbConnection>,
|
||||
project_id: String,
|
||||
) -> Result<Vec<(String, String)>, String> {
|
||||
let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?;
|
||||
MaterialService::get_materials_content(&conn, &project_id).map_err(|e| e.to_string())
|
||||
}
|
||||
@@ -14,6 +14,7 @@ pub mod general_chat_cmd;
|
||||
pub mod injection_cmd;
|
||||
pub mod kiro_local;
|
||||
pub mod machine_id_cmd;
|
||||
pub mod material_cmd;
|
||||
pub mod mcp_cmd;
|
||||
pub mod memory_cmd;
|
||||
pub mod model_cmd;
|
||||
@@ -23,6 +24,7 @@ pub mod music_cmd;
|
||||
pub mod network_cmd;
|
||||
pub mod oauth_cmd;
|
||||
pub mod orchestrator_cmd;
|
||||
pub mod persona_cmd;
|
||||
pub mod plugin_cmd;
|
||||
pub mod plugin_install_cmd;
|
||||
pub mod plugin_rpc_cmd;
|
||||
@@ -35,6 +37,7 @@ pub mod session_files_cmd;
|
||||
pub mod skill_cmd;
|
||||
pub mod switch_cmd;
|
||||
pub mod telemetry_cmd;
|
||||
pub mod template_cmd;
|
||||
pub mod terminal_cmd;
|
||||
pub mod tool_hooks;
|
||||
pub mod tray_cmd;
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
//! 人设相关的 Tauri 命令
|
||||
//!
|
||||
//! 提供人设(Persona)管理的前端 API,包括:
|
||||
//! - 创建、获取、列表、更新、删除人设
|
||||
//! - 设置项目默认人设
|
||||
//! - 获取人设模板列表
|
||||
//!
|
||||
//! ## 相关需求
|
||||
//! - Requirements 6.1: 人设列表显示
|
||||
//! - Requirements 6.2: 创建人设按钮
|
||||
//! - Requirements 6.3: 人设创建表单
|
||||
//! - Requirements 6.4: 设置默认人设
|
||||
//! - Requirements 6.5: 人设模板
|
||||
//! - Requirements 6.6: 人设删除确认
|
||||
|
||||
use tauri::State;
|
||||
|
||||
use crate::database::DbConnection;
|
||||
use crate::models::project_model::{CreatePersonaRequest, Persona, PersonaTemplate, PersonaUpdate};
|
||||
use crate::services::persona_service::PersonaService;
|
||||
|
||||
// ============================================================================
|
||||
// Tauri 命令
|
||||
// ============================================================================
|
||||
|
||||
/// 创建人设
|
||||
///
|
||||
/// 在指定项目中创建新的人设配置。
|
||||
///
|
||||
/// # 参数
|
||||
/// - `db`: 数据库连接状态
|
||||
/// - `req`: 创建人设请求,包含项目 ID、名称、风格等信息
|
||||
///
|
||||
/// # 返回
|
||||
/// - 成功返回创建的人设
|
||||
/// - 失败返回错误信息
|
||||
///
|
||||
/// # 示例(前端调用)
|
||||
/// ```typescript
|
||||
/// const persona = await invoke('create_persona', {
|
||||
/// req: {
|
||||
/// project_id: 'project-1',
|
||||
/// name: '专业写手',
|
||||
/// style: '专业严谨',
|
||||
/// tone: '正式',
|
||||
/// }
|
||||
/// });
|
||||
/// ```
|
||||
#[tauri::command]
|
||||
pub async fn create_persona(
|
||||
db: State<'_, DbConnection>,
|
||||
req: CreatePersonaRequest,
|
||||
) -> Result<Persona, String> {
|
||||
let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?;
|
||||
PersonaService::create_persona(&conn, req).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 获取项目的人设列表
|
||||
///
|
||||
/// 获取指定项目下的所有人设配置。
|
||||
///
|
||||
/// # 参数
|
||||
/// - `db`: 数据库连接状态
|
||||
/// - `project_id`: 项目 ID
|
||||
///
|
||||
/// # 返回
|
||||
/// - 成功返回人设列表
|
||||
/// - 失败返回错误信息
|
||||
///
|
||||
/// # 示例(前端调用)
|
||||
/// ```typescript
|
||||
/// const personas = await invoke('list_personas', {
|
||||
/// projectId: 'project-1'
|
||||
/// });
|
||||
/// ```
|
||||
#[tauri::command]
|
||||
pub async fn list_personas(
|
||||
db: State<'_, DbConnection>,
|
||||
project_id: String,
|
||||
) -> Result<Vec<Persona>, String> {
|
||||
let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?;
|
||||
PersonaService::list_personas(&conn, &project_id).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 获取单个人设
|
||||
///
|
||||
/// 根据 ID 获取人设详情。
|
||||
///
|
||||
/// # 参数
|
||||
/// - `db`: 数据库连接状态
|
||||
/// - `id`: 人设 ID
|
||||
///
|
||||
/// # 返回
|
||||
/// - 成功返回 Option<Persona>,不存在时返回 None
|
||||
/// - 失败返回错误信息
|
||||
///
|
||||
/// # 示例(前端调用)
|
||||
/// ```typescript
|
||||
/// const persona = await invoke('get_persona', {
|
||||
/// id: 'persona-1'
|
||||
/// });
|
||||
/// ```
|
||||
#[tauri::command]
|
||||
pub async fn get_persona(
|
||||
db: State<'_, DbConnection>,
|
||||
id: String,
|
||||
) -> Result<Option<Persona>, String> {
|
||||
let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?;
|
||||
PersonaService::get_persona(&conn, &id).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 更新人设
|
||||
///
|
||||
/// 更新指定人设的配置信息。
|
||||
///
|
||||
/// # 参数
|
||||
/// - `db`: 数据库连接状态
|
||||
/// - `id`: 人设 ID
|
||||
/// - `update`: 更新内容,只包含需要更新的字段
|
||||
///
|
||||
/// # 返回
|
||||
/// - 成功返回更新后的人设
|
||||
/// - 失败返回错误信息
|
||||
///
|
||||
/// # 示例(前端调用)
|
||||
/// ```typescript
|
||||
/// const persona = await invoke('update_persona', {
|
||||
/// id: 'persona-1',
|
||||
/// update: {
|
||||
/// name: '新名称',
|
||||
/// style: '新风格',
|
||||
/// }
|
||||
/// });
|
||||
/// ```
|
||||
#[tauri::command]
|
||||
pub async fn update_persona(
|
||||
db: State<'_, DbConnection>,
|
||||
id: String,
|
||||
update: PersonaUpdate,
|
||||
) -> Result<Persona, String> {
|
||||
let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?;
|
||||
PersonaService::update_persona(&conn, &id, update).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 删除人设
|
||||
///
|
||||
/// 删除指定的人设配置。
|
||||
///
|
||||
/// # 参数
|
||||
/// - `db`: 数据库连接状态
|
||||
/// - `id`: 人设 ID
|
||||
///
|
||||
/// # 返回
|
||||
/// - 成功返回 ()
|
||||
/// - 失败返回错误信息
|
||||
///
|
||||
/// # 示例(前端调用)
|
||||
/// ```typescript
|
||||
/// await invoke('delete_persona', {
|
||||
/// id: 'persona-1'
|
||||
/// });
|
||||
/// ```
|
||||
#[tauri::command]
|
||||
pub async fn delete_persona(db: State<'_, DbConnection>, id: String) -> Result<(), String> {
|
||||
let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?;
|
||||
PersonaService::delete_persona(&conn, &id).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 设置项目默认人设
|
||||
///
|
||||
/// 将指定人设设为项目的默认人设。
|
||||
/// 同一项目只能有一个默认人设,设置新默认会自动取消原有默认。
|
||||
///
|
||||
/// # 参数
|
||||
/// - `db`: 数据库连接状态
|
||||
/// - `project_id`: 项目 ID
|
||||
/// - `persona_id`: 要设为默认的人设 ID
|
||||
///
|
||||
/// # 返回
|
||||
/// - 成功返回 ()
|
||||
/// - 失败返回错误信息
|
||||
///
|
||||
/// # 示例(前端调用)
|
||||
/// ```typescript
|
||||
/// await invoke('set_default_persona', {
|
||||
/// projectId: 'project-1',
|
||||
/// personaId: 'persona-1'
|
||||
/// });
|
||||
/// ```
|
||||
#[tauri::command]
|
||||
pub async fn set_default_persona(
|
||||
db: State<'_, DbConnection>,
|
||||
project_id: String,
|
||||
persona_id: String,
|
||||
) -> Result<(), String> {
|
||||
let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?;
|
||||
PersonaService::set_default_persona(&conn, &project_id, &persona_id).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 获取人设模板列表
|
||||
///
|
||||
/// 获取预定义的人设模板,用于快速创建人设。
|
||||
/// 模板包含常见的写作风格配置,如专业写手、生活博主等。
|
||||
///
|
||||
/// # 返回
|
||||
/// - 人设模板列表
|
||||
///
|
||||
/// # 示例(前端调用)
|
||||
/// ```typescript
|
||||
/// const templates = await invoke('list_persona_templates');
|
||||
/// ```
|
||||
#[tauri::command]
|
||||
pub async fn list_persona_templates() -> Result<Vec<PersonaTemplate>, String> {
|
||||
Ok(PersonaService::list_persona_templates())
|
||||
}
|
||||
|
||||
/// 获取项目的默认人设
|
||||
///
|
||||
/// 获取指定项目的默认人设配置。
|
||||
///
|
||||
/// # 参数
|
||||
/// - `db`: 数据库连接状态
|
||||
/// - `project_id`: 项目 ID
|
||||
///
|
||||
/// # 返回
|
||||
/// - 成功返回 Option<Persona>,没有默认人设时返回 None
|
||||
/// - 失败返回错误信息
|
||||
///
|
||||
/// # 示例(前端调用)
|
||||
/// ```typescript
|
||||
/// const defaultPersona = await invoke('get_default_persona', {
|
||||
/// projectId: 'project-1'
|
||||
/// });
|
||||
/// ```
|
||||
#[tauri::command]
|
||||
pub async fn get_default_persona(
|
||||
db: State<'_, DbConnection>,
|
||||
project_id: String,
|
||||
) -> Result<Option<Persona>, String> {
|
||||
let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?;
|
||||
PersonaService::get_default_persona(&conn, &project_id).map_err(|e| e.to_string())
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
//! 排版模板相关的 Tauri 命令
|
||||
//!
|
||||
//! 提供排版模板(Template)管理的前端 API,包括:
|
||||
//! - 创建、获取、列表、更新、删除模板
|
||||
//! - 设置项目默认模板
|
||||
//!
|
||||
//! ## 相关需求
|
||||
//! - Requirements 8.1: 模板列表显示
|
||||
//! - Requirements 8.2: 创建模板按钮
|
||||
//! - Requirements 8.3: 模板创建表单
|
||||
//! - Requirements 8.4: 设置默认模板
|
||||
//! - Requirements 8.5: 模板预览功能
|
||||
|
||||
use tauri::State;
|
||||
|
||||
use crate::database::DbConnection;
|
||||
use crate::models::project_model::{CreateTemplateRequest, Template, TemplateUpdate};
|
||||
use crate::services::template_service::TemplateService;
|
||||
|
||||
// ============================================================================
|
||||
// Tauri 命令
|
||||
// ============================================================================
|
||||
|
||||
/// 创建排版模板
|
||||
///
|
||||
/// 在指定项目中创建新的排版模板。
|
||||
///
|
||||
/// # 参数
|
||||
/// - `db`: 数据库连接状态
|
||||
/// - `req`: 创建模板请求,包含项目 ID、名称、平台、样式规则等信息
|
||||
///
|
||||
/// # 返回
|
||||
/// - 成功返回创建的模板
|
||||
/// - 失败返回错误信息
|
||||
///
|
||||
/// # 示例(前端调用)
|
||||
/// ```typescript
|
||||
/// const template = await invoke('create_template', {
|
||||
/// req: {
|
||||
/// project_id: 'project-1',
|
||||
/// name: '小红书模板',
|
||||
/// platform: 'xiaohongshu',
|
||||
/// title_style: '吸引眼球',
|
||||
/// emoji_usage: 'heavy',
|
||||
/// }
|
||||
/// });
|
||||
/// ```
|
||||
#[tauri::command]
|
||||
pub async fn create_template(
|
||||
db: State<'_, DbConnection>,
|
||||
req: CreateTemplateRequest,
|
||||
) -> Result<Template, String> {
|
||||
let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?;
|
||||
TemplateService::create_template(&conn, req).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 获取项目的模板列表
|
||||
///
|
||||
/// 获取指定项目下的所有排版模板。
|
||||
///
|
||||
/// # 参数
|
||||
/// - `db`: 数据库连接状态
|
||||
/// - `project_id`: 项目 ID
|
||||
///
|
||||
/// # 返回
|
||||
/// - 成功返回模板列表
|
||||
/// - 失败返回错误信息
|
||||
///
|
||||
/// # 示例(前端调用)
|
||||
/// ```typescript
|
||||
/// const templates = await invoke('list_templates', {
|
||||
/// projectId: 'project-1'
|
||||
/// });
|
||||
/// ```
|
||||
#[tauri::command]
|
||||
pub async fn list_templates(
|
||||
db: State<'_, DbConnection>,
|
||||
project_id: String,
|
||||
) -> Result<Vec<Template>, String> {
|
||||
let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?;
|
||||
TemplateService::list_templates(&conn, &project_id).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 获取单个模板
|
||||
///
|
||||
/// 根据 ID 获取模板详情。
|
||||
///
|
||||
/// # 参数
|
||||
/// - `db`: 数据库连接状态
|
||||
/// - `id`: 模板 ID
|
||||
///
|
||||
/// # 返回
|
||||
/// - 成功返回 Option<Template>,不存在时返回 None
|
||||
/// - 失败返回错误信息
|
||||
///
|
||||
/// # 示例(前端调用)
|
||||
/// ```typescript
|
||||
/// const template = await invoke('get_template', {
|
||||
/// id: 'template-1'
|
||||
/// });
|
||||
/// ```
|
||||
#[tauri::command]
|
||||
pub async fn get_template(
|
||||
db: State<'_, DbConnection>,
|
||||
id: String,
|
||||
) -> Result<Option<Template>, String> {
|
||||
let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?;
|
||||
TemplateService::get_template(&conn, &id).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 更新模板
|
||||
///
|
||||
/// 更新指定模板的配置信息。
|
||||
///
|
||||
/// # 参数
|
||||
/// - `db`: 数据库连接状态
|
||||
/// - `id`: 模板 ID
|
||||
/// - `update`: 更新内容,只包含需要更新的字段
|
||||
///
|
||||
/// # 返回
|
||||
/// - 成功返回更新后的模板
|
||||
/// - 失败返回错误信息
|
||||
///
|
||||
/// # 示例(前端调用)
|
||||
/// ```typescript
|
||||
/// const template = await invoke('update_template', {
|
||||
/// id: 'template-1',
|
||||
/// update: {
|
||||
/// name: '新名称',
|
||||
/// title_style: '新标题风格',
|
||||
/// emoji_usage: 'moderate',
|
||||
/// }
|
||||
/// });
|
||||
/// ```
|
||||
#[tauri::command]
|
||||
pub async fn update_template(
|
||||
db: State<'_, DbConnection>,
|
||||
id: String,
|
||||
update: TemplateUpdate,
|
||||
) -> Result<Template, String> {
|
||||
let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?;
|
||||
TemplateService::update_template(&conn, &id, update).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 删除模板
|
||||
///
|
||||
/// 删除指定的排版模板。
|
||||
///
|
||||
/// # 参数
|
||||
/// - `db`: 数据库连接状态
|
||||
/// - `id`: 模板 ID
|
||||
///
|
||||
/// # 返回
|
||||
/// - 成功返回 ()
|
||||
/// - 失败返回错误信息
|
||||
///
|
||||
/// # 示例(前端调用)
|
||||
/// ```typescript
|
||||
/// await invoke('delete_template', {
|
||||
/// id: 'template-1'
|
||||
/// });
|
||||
/// ```
|
||||
#[tauri::command]
|
||||
pub async fn delete_template(db: State<'_, DbConnection>, id: String) -> Result<(), String> {
|
||||
let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?;
|
||||
TemplateService::delete_template(&conn, &id).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 设置项目默认模板
|
||||
///
|
||||
/// 将指定模板设为项目的默认模板。
|
||||
/// 同一项目只能有一个默认模板,设置新默认会自动取消原有默认。
|
||||
///
|
||||
/// # 参数
|
||||
/// - `db`: 数据库连接状态
|
||||
/// - `project_id`: 项目 ID
|
||||
/// - `template_id`: 要设为默认的模板 ID
|
||||
///
|
||||
/// # 返回
|
||||
/// - 成功返回 ()
|
||||
/// - 失败返回错误信息
|
||||
///
|
||||
/// # 示例(前端调用)
|
||||
/// ```typescript
|
||||
/// await invoke('set_default_template', {
|
||||
/// projectId: 'project-1',
|
||||
/// templateId: 'template-1'
|
||||
/// });
|
||||
/// ```
|
||||
#[tauri::command]
|
||||
pub async fn set_default_template(
|
||||
db: State<'_, DbConnection>,
|
||||
project_id: String,
|
||||
template_id: String,
|
||||
) -> Result<(), String> {
|
||||
let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?;
|
||||
TemplateService::set_default_template(&conn, &project_id, &template_id)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 获取项目的默认模板
|
||||
///
|
||||
/// 获取指定项目的默认排版模板。
|
||||
///
|
||||
/// # 参数
|
||||
/// - `db`: 数据库连接状态
|
||||
/// - `project_id`: 项目 ID
|
||||
///
|
||||
/// # 返回
|
||||
/// - 成功返回 Option<Template>,没有默认模板时返回 None
|
||||
/// - 失败返回错误信息
|
||||
///
|
||||
/// # 示例(前端调用)
|
||||
/// ```typescript
|
||||
/// const defaultTemplate = await invoke('get_default_template', {
|
||||
/// projectId: 'project-1'
|
||||
/// });
|
||||
/// ```
|
||||
#[tauri::command]
|
||||
pub async fn get_default_template(
|
||||
db: State<'_, DbConnection>,
|
||||
project_id: String,
|
||||
) -> Result<Option<Template>, String> {
|
||||
let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?;
|
||||
TemplateService::get_default_template(&conn, &project_id).map_err(|e| e.to_string())
|
||||
}
|
||||
@@ -12,6 +12,8 @@
|
||||
//! - `workspace_get_default` - 获取默认 workspace
|
||||
|
||||
use crate::database::DbConnection;
|
||||
use crate::models::project_model::ProjectContext;
|
||||
use crate::services::project_context_builder::ProjectContextBuilder;
|
||||
use crate::workspace::{
|
||||
Workspace, WorkspaceManager, WorkspaceSettings, WorkspaceType, WorkspaceUpdate,
|
||||
};
|
||||
@@ -208,3 +210,81 @@ pub async fn workspace_get_by_path(
|
||||
let workspace = manager.get_by_path(&PathBuf::from(&root_path))?;
|
||||
Ok(workspace.map(|ws| ws.into()))
|
||||
}
|
||||
|
||||
// ==================== 项目上下文相关命令 ====================
|
||||
|
||||
/// 获取或创建默认项目
|
||||
///
|
||||
/// 如果默认项目不存在,则自动创建一个。
|
||||
/// 用于确保系统始终有一个默认项目可用。
|
||||
///
|
||||
/// # 返回
|
||||
/// - 成功返回默认项目
|
||||
/// - 失败返回错误信息
|
||||
#[tauri::command]
|
||||
pub async fn get_or_create_default_project(
|
||||
db: State<'_, DbConnection>,
|
||||
) -> Result<WorkspaceListItem, String> {
|
||||
let manager = WorkspaceManager::new(db.inner().clone());
|
||||
|
||||
// 先尝试获取默认项目
|
||||
if let Some(workspace) = manager.get_default()? {
|
||||
return Ok(workspace.into());
|
||||
}
|
||||
|
||||
// 不存在则创建默认项目
|
||||
let workspace = manager.create_with_type(
|
||||
"默认项目".to_string(),
|
||||
PathBuf::from("default"),
|
||||
WorkspaceType::Persistent,
|
||||
)?;
|
||||
|
||||
// 设置为默认
|
||||
manager.set_default(&workspace.id)?;
|
||||
|
||||
// 重新获取以确保 is_default 标志正确
|
||||
let workspace = manager.get(&workspace.id)?.ok_or("创建默认项目失败")?;
|
||||
Ok(workspace.into())
|
||||
}
|
||||
|
||||
/// 获取项目上下文
|
||||
///
|
||||
/// 加载项目的完整上下文,包括人设、素材、模板等配置。
|
||||
/// 用于在发送消息前构建 AI 的 System Prompt。
|
||||
///
|
||||
/// # 参数
|
||||
/// - `project_id`: 项目 ID
|
||||
///
|
||||
/// # 返回
|
||||
/// - 成功返回项目上下文
|
||||
/// - 失败返回错误信息
|
||||
#[tauri::command]
|
||||
pub async fn get_project_context(
|
||||
db: State<'_, DbConnection>,
|
||||
project_id: String,
|
||||
) -> Result<ProjectContext, String> {
|
||||
let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?;
|
||||
ProjectContextBuilder::build_context(&conn, &project_id).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 构建项目 System Prompt
|
||||
///
|
||||
/// 根据项目配置构建 AI 的 System Prompt。
|
||||
/// 包含人设信息、素材引用、排版规则等。
|
||||
///
|
||||
/// # 参数
|
||||
/// - `project_id`: 项目 ID
|
||||
///
|
||||
/// # 返回
|
||||
/// - 成功返回构建好的 System Prompt 字符串
|
||||
/// - 失败返回错误信息
|
||||
#[tauri::command]
|
||||
pub async fn build_project_system_prompt(
|
||||
db: State<'_, DbConnection>,
|
||||
project_id: String,
|
||||
) -> Result<String, String> {
|
||||
let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?;
|
||||
let context =
|
||||
ProjectContextBuilder::build_context(&conn, &project_id).map_err(|e| e.to_string())?;
|
||||
Ok(ProjectContextBuilder::build_system_prompt(&context))
|
||||
}
|
||||
|
||||
@@ -2494,11 +2494,12 @@ proptest! {
|
||||
use crate::ProviderType;
|
||||
|
||||
/// 生成有效的 Provider 类型字符串
|
||||
/// 注意:只包含往返一致的 Provider 类型(即 parse().to_string() == 原值)
|
||||
/// qwen 等第三方 Provider 会被映射到 openai,不满足往返一致性
|
||||
fn arb_valid_provider_type() -> impl Strategy<Value = String> {
|
||||
prop_oneof![
|
||||
Just("kiro".to_string()),
|
||||
Just("gemini".to_string()),
|
||||
Just("qwen".to_string()),
|
||||
Just("openai".to_string()),
|
||||
Just("claude".to_string()),
|
||||
Just("antigravity".to_string()),
|
||||
@@ -2506,26 +2507,22 @@ fn arb_valid_provider_type() -> impl Strategy<Value = String> {
|
||||
Just("gemini_api_key".to_string()),
|
||||
Just("codex".to_string()),
|
||||
Just("claude_oauth".to_string()),
|
||||
Just("anthropic".to_string()),
|
||||
Just("anthropic_compatible".to_string()),
|
||||
Just("azure_openai".to_string()),
|
||||
Just("aws_bedrock".to_string()),
|
||||
Just("ollama".to_string()),
|
||||
]
|
||||
}
|
||||
|
||||
/// 生成无效的 Provider 类型字符串
|
||||
fn arb_invalid_provider_type() -> impl Strategy<Value = String> {
|
||||
// 生成不在有效列表中的字符串
|
||||
// 注意:需要排除所有在 ProviderType::from_str 中有效的字符串
|
||||
"[a-z]{3,15}".prop_filter("排除有效的 Provider 类型", |s| {
|
||||
!matches!(
|
||||
s.as_str(),
|
||||
"kiro"
|
||||
| "gemini"
|
||||
| "qwen"
|
||||
| "openai"
|
||||
| "claude"
|
||||
| "antigravity"
|
||||
| "vertex"
|
||||
| "gemini_api_key"
|
||||
| "codex"
|
||||
| "claude_oauth"
|
||||
)
|
||||
// 排除所有在 ProviderType::from_str 中能成功解析的字符串
|
||||
use std::str::FromStr;
|
||||
crate::ProviderType::from_str(s).is_err()
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -2006,14 +2006,14 @@ mod unit_tests {
|
||||
fn test_screenshot_chat_config_default() {
|
||||
let config = ScreenshotChatConfig::default();
|
||||
assert!(!config.enabled);
|
||||
assert_eq!(config.shortcut, "CommandOrControl+Shift+S");
|
||||
assert_eq!(config.shortcut, "CommandOrControl+Alt+Q");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_experimental_features_default() {
|
||||
let config = ExperimentalFeatures::default();
|
||||
assert!(!config.screenshot_chat.enabled);
|
||||
assert_eq!(config.screenshot_chat.shortcut, "CommandOrControl+Shift+S");
|
||||
assert_eq!(config.screenshot_chat.shortcut, "CommandOrControl+Alt+Q");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -2040,7 +2040,7 @@ mod unit_tests {
|
||||
assert!(!config.experimental.screenshot_chat.enabled);
|
||||
assert_eq!(
|
||||
config.experimental.screenshot_chat.shortcut,
|
||||
"CommandOrControl+Shift+S"
|
||||
"CommandOrControl+Alt+Q"
|
||||
);
|
||||
// 语音输入测试
|
||||
assert!(!config.experimental.voice_input.enabled);
|
||||
|
||||
@@ -8,7 +8,8 @@
|
||||
|------|------|
|
||||
| `mod.rs` | 模块入口,数据库初始化 |
|
||||
| `schema.rs` | 表结构定义和创建 |
|
||||
| `migration.rs` | 数据迁移逻辑 |
|
||||
| `migration.rs` | 数据迁移逻辑(API Keys、Provider ID 等) |
|
||||
| `migration_v2.rs` | 统一内容系统迁移(默认项目、话题迁移) |
|
||||
| `system_providers.rs` | 系统预设 Provider 配置 |
|
||||
| `dao/` | 数据访问对象层 |
|
||||
|
||||
@@ -59,6 +60,17 @@
|
||||
- 标记来源为 `imported`
|
||||
- 迁移完成后设置 `migrated_api_keys_to_pool` 标记,避免重复迁移
|
||||
|
||||
### 统一内容系统迁移 (migration_v2)
|
||||
|
||||
`migrate_unified_content_system()` 函数实现统一内容系统的数据迁移:
|
||||
|
||||
- **创建默认项目**: 如果不存在 `is_default=true` 的项目,自动创建"默认项目"
|
||||
- **迁移话题**: 将所有 `project_id` 为 null 的内容迁移到默认项目
|
||||
- **事务保护**: 迁移过程使用事务,失败时自动回滚
|
||||
- **幂等性**: 迁移完成后设置标记,避免重复执行
|
||||
|
||||
_Requirements: 2.1, 2.2, 2.3, 2.4_
|
||||
|
||||
## 使用示例
|
||||
|
||||
```rust
|
||||
|
||||
@@ -0,0 +1,857 @@
|
||||
//! 素材数据访问层
|
||||
//!
|
||||
//! 提供素材(Material)的 CRUD 操作,包括:
|
||||
//! - 创建、获取、列表、更新、删除素材
|
||||
//! - 支持按类型和标签筛选
|
||||
//!
|
||||
//! ## 相关需求
|
||||
//! - Requirements 7.1: 素材列表显示
|
||||
//! - Requirements 7.3: 素材创建
|
||||
//! - Requirements 7.4: 素材搜索和筛选
|
||||
//! - Requirements 7.6: 素材删除
|
||||
|
||||
use rusqlite::{params, Connection};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::errors::project_error::MaterialError;
|
||||
use crate::models::project_model::{
|
||||
Material, MaterialFilter, MaterialUpdate, UploadMaterialRequest,
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// 数据访问对象
|
||||
// ============================================================================
|
||||
|
||||
/// 素材 DAO
|
||||
///
|
||||
/// 提供素材的数据库操作方法。
|
||||
pub struct MaterialDao;
|
||||
|
||||
impl MaterialDao {
|
||||
// ------------------------------------------------------------------------
|
||||
// 创建素材
|
||||
// ------------------------------------------------------------------------
|
||||
|
||||
/// 创建新素材
|
||||
///
|
||||
/// # 参数
|
||||
/// - `conn`: 数据库连接
|
||||
/// - `req`: 上传素材请求
|
||||
///
|
||||
/// # 返回
|
||||
/// - 成功返回创建的素材
|
||||
/// - 失败返回 MaterialError
|
||||
///
|
||||
/// # 注意
|
||||
/// 文件大小和 MIME 类型应由 Service 层计算后传入。
|
||||
/// DAO 层只负责数据库操作,不处理文件系统。
|
||||
pub fn create(
|
||||
conn: &Connection,
|
||||
req: &UploadMaterialRequest,
|
||||
) -> Result<Material, MaterialError> {
|
||||
Self::create_with_metadata(conn, req, None, None)
|
||||
}
|
||||
|
||||
/// 创建新素材(带文件元数据)
|
||||
///
|
||||
/// # 参数
|
||||
/// - `conn`: 数据库连接
|
||||
/// - `req`: 上传素材请求
|
||||
/// - `file_size`: 文件大小(字节)
|
||||
/// - `mime_type`: MIME 类型
|
||||
///
|
||||
/// # 返回
|
||||
/// - 成功返回创建的素材
|
||||
/// - 失败返回 MaterialError
|
||||
pub fn create_with_metadata(
|
||||
conn: &Connection,
|
||||
req: &UploadMaterialRequest,
|
||||
file_size: Option<i64>,
|
||||
mime_type: Option<String>,
|
||||
) -> Result<Material, MaterialError> {
|
||||
let id = Uuid::new_v4().to_string();
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
|
||||
// 序列化 tags JSON 字段
|
||||
let tags_json = serde_json::to_string(req.tags.as_ref().unwrap_or(&vec![]))
|
||||
.unwrap_or_else(|_| "[]".to_string());
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO materials (
|
||||
id, project_id, name, material_type, file_path, file_size,
|
||||
mime_type, content, tags_json, description, created_at
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)",
|
||||
params![
|
||||
id,
|
||||
req.project_id,
|
||||
req.name,
|
||||
req.material_type,
|
||||
req.file_path,
|
||||
file_size,
|
||||
mime_type,
|
||||
req.content,
|
||||
tags_json,
|
||||
req.description,
|
||||
now,
|
||||
],
|
||||
)?;
|
||||
|
||||
// 返回创建的素材
|
||||
Ok(Material {
|
||||
id,
|
||||
project_id: req.project_id.clone(),
|
||||
name: req.name.clone(),
|
||||
material_type: req.material_type.clone(),
|
||||
file_path: req.file_path.clone(),
|
||||
file_size,
|
||||
mime_type,
|
||||
content: req.content.clone(),
|
||||
tags: req.tags.clone().unwrap_or_default(),
|
||||
description: req.description.clone(),
|
||||
created_at: now,
|
||||
})
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
// 获取素材
|
||||
// ------------------------------------------------------------------------
|
||||
|
||||
/// 获取单个素材
|
||||
///
|
||||
/// # 参数
|
||||
/// - `conn`: 数据库连接
|
||||
/// - `id`: 素材 ID
|
||||
///
|
||||
/// # 返回
|
||||
/// - 成功返回 Option<Material>
|
||||
/// - 失败返回 MaterialError
|
||||
pub fn get(conn: &Connection, id: &str) -> Result<Option<Material>, MaterialError> {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, project_id, name, material_type, file_path, file_size,
|
||||
mime_type, content, tags_json, description, created_at
|
||||
FROM materials WHERE id = ?",
|
||||
)?;
|
||||
|
||||
let mut rows = stmt.query([id])?;
|
||||
|
||||
if let Some(row) = rows.next()? {
|
||||
Ok(Some(Self::map_row(row)?))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
// 列表素材
|
||||
// ------------------------------------------------------------------------
|
||||
|
||||
/// 获取项目的素材列表
|
||||
///
|
||||
/// # 参数
|
||||
/// - `conn`: 数据库连接
|
||||
/// - `project_id`: 项目 ID
|
||||
/// - `filter`: 可选的筛选条件
|
||||
///
|
||||
/// # 返回
|
||||
/// - 成功返回素材列表
|
||||
/// - 失败返回 MaterialError
|
||||
pub fn list(
|
||||
conn: &Connection,
|
||||
project_id: &str,
|
||||
filter: Option<&MaterialFilter>,
|
||||
) -> Result<Vec<Material>, MaterialError> {
|
||||
// 构建基础查询
|
||||
let mut sql = String::from(
|
||||
"SELECT id, project_id, name, material_type, file_path, file_size,
|
||||
mime_type, content, tags_json, description, created_at
|
||||
FROM materials WHERE project_id = ?",
|
||||
);
|
||||
let mut params_vec: Vec<Box<dyn rusqlite::ToSql>> = vec![Box::new(project_id.to_string())];
|
||||
|
||||
// 应用筛选条件
|
||||
if let Some(f) = filter {
|
||||
// 按类型筛选
|
||||
if let Some(ref material_type) = f.material_type {
|
||||
sql.push_str(" AND material_type = ?");
|
||||
params_vec.push(Box::new(material_type.clone()));
|
||||
}
|
||||
|
||||
// 按搜索关键词筛选(名称或描述)
|
||||
if let Some(ref query) = f.search_query {
|
||||
sql.push_str(" AND (name LIKE ? OR description LIKE ?)");
|
||||
let pattern = format!("%{}%", query);
|
||||
params_vec.push(Box::new(pattern.clone()));
|
||||
params_vec.push(Box::new(pattern));
|
||||
}
|
||||
|
||||
// 按标签筛选(使用 JSON 包含检查)
|
||||
if let Some(ref tags) = f.tags {
|
||||
for tag in tags {
|
||||
sql.push_str(" AND tags_json LIKE ?");
|
||||
params_vec.push(Box::new(format!("%\"{}%", tag)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sql.push_str(" ORDER BY created_at DESC");
|
||||
|
||||
let mut stmt = conn.prepare(&sql)?;
|
||||
|
||||
// 转换参数为引用切片
|
||||
let params_refs: Vec<&dyn rusqlite::ToSql> =
|
||||
params_vec.iter().map(|p| p.as_ref()).collect();
|
||||
|
||||
let materials: Vec<Material> = stmt
|
||||
.query_map(params_refs.as_slice(), |row| Self::map_row(row))?
|
||||
.filter_map(|r| r.ok())
|
||||
.collect();
|
||||
|
||||
Ok(materials)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
// 更新素材
|
||||
// ------------------------------------------------------------------------
|
||||
|
||||
/// 更新素材元数据
|
||||
///
|
||||
/// 注意:只能更新名称、标签和描述,不能更新文件内容。
|
||||
///
|
||||
/// # 参数
|
||||
/// - `conn`: 数据库连接
|
||||
/// - `id`: 素材 ID
|
||||
/// - `update`: 更新内容
|
||||
///
|
||||
/// # 返回
|
||||
/// - 成功返回更新后的素材
|
||||
/// - 失败返回 MaterialError
|
||||
pub fn update(
|
||||
conn: &Connection,
|
||||
id: &str,
|
||||
update: &MaterialUpdate,
|
||||
) -> Result<Material, MaterialError> {
|
||||
// 先获取现有素材
|
||||
let existing =
|
||||
Self::get(conn, id)?.ok_or_else(|| MaterialError::NotFound(id.to_string()))?;
|
||||
|
||||
// 构建更新后的值
|
||||
let name = update.name.as_ref().unwrap_or(&existing.name);
|
||||
let tags = update.tags.clone().unwrap_or(existing.tags);
|
||||
let description = update.description.clone().or(existing.description);
|
||||
|
||||
// 序列化 tags JSON 字段
|
||||
let tags_json = serde_json::to_string(&tags).unwrap_or_else(|_| "[]".to_string());
|
||||
|
||||
conn.execute(
|
||||
"UPDATE materials SET name = ?1, tags_json = ?2, description = ?3 WHERE id = ?4",
|
||||
params![name, tags_json, description, id],
|
||||
)?;
|
||||
|
||||
// 返回更新后的素材
|
||||
Self::get(conn, id)?.ok_or_else(|| MaterialError::NotFound(id.to_string()))
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
// 删除素材
|
||||
// ------------------------------------------------------------------------
|
||||
|
||||
/// 删除素材
|
||||
///
|
||||
/// 注意:此方法只删除数据库记录,不删除文件。
|
||||
/// 文件删除应由 Service 层处理。
|
||||
///
|
||||
/// # 参数
|
||||
/// - `conn`: 数据库连接
|
||||
/// - `id`: 素材 ID
|
||||
///
|
||||
/// # 返回
|
||||
/// - 成功返回被删除的素材(用于后续文件清理)
|
||||
/// - 失败返回 MaterialError
|
||||
pub fn delete(conn: &Connection, id: &str) -> Result<Material, MaterialError> {
|
||||
// 先获取素材信息(用于返回文件路径)
|
||||
let material =
|
||||
Self::get(conn, id)?.ok_or_else(|| MaterialError::NotFound(id.to_string()))?;
|
||||
|
||||
let rows = conn.execute("DELETE FROM materials WHERE id = ?", [id])?;
|
||||
|
||||
if rows == 0 {
|
||||
return Err(MaterialError::NotFound(id.to_string()));
|
||||
}
|
||||
|
||||
Ok(material)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
// 批量操作
|
||||
// ------------------------------------------------------------------------
|
||||
|
||||
/// 获取项目的素材数量
|
||||
///
|
||||
/// # 参数
|
||||
/// - `conn`: 数据库连接
|
||||
/// - `project_id`: 项目 ID
|
||||
///
|
||||
/// # 返回
|
||||
/// - 成功返回素材数量
|
||||
/// - 失败返回 MaterialError
|
||||
pub fn count(conn: &Connection, project_id: &str) -> Result<i64, MaterialError> {
|
||||
let count: i64 = conn.query_row(
|
||||
"SELECT COUNT(*) FROM materials WHERE project_id = ?",
|
||||
[project_id],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
/// 删除项目的所有素材
|
||||
///
|
||||
/// 注意:此方法只删除数据库记录,不删除文件。
|
||||
/// 文件删除应由 Service 层处理。
|
||||
///
|
||||
/// # 参数
|
||||
/// - `conn`: 数据库连接
|
||||
/// - `project_id`: 项目 ID
|
||||
///
|
||||
/// # 返回
|
||||
/// - 成功返回被删除的素材列表(用于后续文件清理)
|
||||
/// - 失败返回 MaterialError
|
||||
pub fn delete_by_project(
|
||||
conn: &Connection,
|
||||
project_id: &str,
|
||||
) -> Result<Vec<Material>, MaterialError> {
|
||||
// 先获取所有素材
|
||||
let materials = Self::list(conn, project_id, None)?;
|
||||
|
||||
// 删除所有素材
|
||||
conn.execute("DELETE FROM materials WHERE project_id = ?", [project_id])?;
|
||||
|
||||
Ok(materials)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
// 辅助方法
|
||||
// ------------------------------------------------------------------------
|
||||
|
||||
/// 映射数据库行到 Material 结构体
|
||||
fn map_row(row: &rusqlite::Row) -> Result<Material, rusqlite::Error> {
|
||||
let tags_json: String = row.get(8)?;
|
||||
|
||||
// 解析 tags JSON 字段
|
||||
let tags: Vec<String> = serde_json::from_str(&tags_json).unwrap_or_default();
|
||||
|
||||
Ok(Material {
|
||||
id: row.get(0)?,
|
||||
project_id: row.get(1)?,
|
||||
name: row.get(2)?,
|
||||
material_type: row.get(3)?,
|
||||
file_path: row.get(4)?,
|
||||
file_size: row.get(5)?,
|
||||
mime_type: row.get(6)?,
|
||||
content: row.get(7)?,
|
||||
tags,
|
||||
description: row.get(9)?,
|
||||
created_at: row.get(10)?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 测试
|
||||
// ============================================================================
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::database::schema::create_tables;
|
||||
|
||||
/// 创建测试数据库连接
|
||||
fn setup_test_db() -> Connection {
|
||||
let conn = Connection::open_in_memory().unwrap();
|
||||
create_tables(&conn).unwrap();
|
||||
conn
|
||||
}
|
||||
|
||||
/// 创建测试项目
|
||||
fn create_test_project(conn: &Connection, id: &str) {
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
conn.execute(
|
||||
"INSERT INTO workspaces (id, name, workspace_type, root_path, created_at, updated_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
||||
params![
|
||||
id,
|
||||
"测试项目",
|
||||
"persistent",
|
||||
format!("/test/{}", id),
|
||||
now,
|
||||
now
|
||||
],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_material() {
|
||||
let conn = setup_test_db();
|
||||
create_test_project(&conn, "project-1");
|
||||
|
||||
let req = UploadMaterialRequest {
|
||||
project_id: "project-1".to_string(),
|
||||
name: "测试文档.pdf".to_string(),
|
||||
material_type: "document".to_string(),
|
||||
file_path: None,
|
||||
content: Some("这是文档内容".to_string()),
|
||||
tags: Some(vec!["参考".to_string(), "重要".to_string()]),
|
||||
description: Some("测试描述".to_string()),
|
||||
};
|
||||
|
||||
let material = MaterialDao::create(&conn, &req).unwrap();
|
||||
|
||||
assert!(!material.id.is_empty());
|
||||
assert_eq!(material.project_id, "project-1");
|
||||
assert_eq!(material.name, "测试文档.pdf");
|
||||
assert_eq!(material.material_type, "document");
|
||||
assert_eq!(material.content, Some("这是文档内容".to_string()));
|
||||
assert_eq!(material.tags.len(), 2);
|
||||
assert!(material.tags.contains(&"参考".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_material_minimal() {
|
||||
let conn = setup_test_db();
|
||||
create_test_project(&conn, "project-1");
|
||||
|
||||
let req = UploadMaterialRequest {
|
||||
project_id: "project-1".to_string(),
|
||||
name: "简单素材".to_string(),
|
||||
material_type: "text".to_string(),
|
||||
file_path: None,
|
||||
content: None,
|
||||
tags: None,
|
||||
description: None,
|
||||
};
|
||||
|
||||
let material = MaterialDao::create(&conn, &req).unwrap();
|
||||
|
||||
assert!(!material.id.is_empty());
|
||||
assert_eq!(material.name, "简单素材");
|
||||
assert!(material.tags.is_empty());
|
||||
assert!(material.description.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_material() {
|
||||
let conn = setup_test_db();
|
||||
create_test_project(&conn, "project-1");
|
||||
|
||||
let req = UploadMaterialRequest {
|
||||
project_id: "project-1".to_string(),
|
||||
name: "测试素材".to_string(),
|
||||
material_type: "image".to_string(),
|
||||
file_path: None,
|
||||
content: None,
|
||||
tags: Some(vec!["图片".to_string()]),
|
||||
description: Some("一张图片".to_string()),
|
||||
};
|
||||
|
||||
let created = MaterialDao::create(&conn, &req).unwrap();
|
||||
let fetched = MaterialDao::get(&conn, &created.id).unwrap();
|
||||
|
||||
assert!(fetched.is_some());
|
||||
let fetched = fetched.unwrap();
|
||||
assert_eq!(fetched.id, created.id);
|
||||
assert_eq!(fetched.name, "测试素材");
|
||||
assert_eq!(fetched.material_type, "image");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_nonexistent_material() {
|
||||
let conn = setup_test_db();
|
||||
let result = MaterialDao::get(&conn, "nonexistent").unwrap();
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_list_materials() {
|
||||
let conn = setup_test_db();
|
||||
create_test_project(&conn, "project-1");
|
||||
create_test_project(&conn, "project-2");
|
||||
|
||||
// 为 project-1 创建三个素材
|
||||
for i in 1..=3 {
|
||||
let req = UploadMaterialRequest {
|
||||
project_id: "project-1".to_string(),
|
||||
name: format!("素材{}", i),
|
||||
material_type: "document".to_string(),
|
||||
file_path: None,
|
||||
content: None,
|
||||
tags: None,
|
||||
description: None,
|
||||
};
|
||||
MaterialDao::create(&conn, &req).unwrap();
|
||||
}
|
||||
|
||||
// 为 project-2 创建一个素材
|
||||
let req = UploadMaterialRequest {
|
||||
project_id: "project-2".to_string(),
|
||||
name: "素材4".to_string(),
|
||||
material_type: "image".to_string(),
|
||||
file_path: None,
|
||||
content: None,
|
||||
tags: None,
|
||||
description: None,
|
||||
};
|
||||
MaterialDao::create(&conn, &req).unwrap();
|
||||
|
||||
// 验证 project-1 有 3 个素材
|
||||
let materials = MaterialDao::list(&conn, "project-1", None).unwrap();
|
||||
assert_eq!(materials.len(), 3);
|
||||
|
||||
// 验证 project-2 有 1 个素材
|
||||
let materials = MaterialDao::list(&conn, "project-2", None).unwrap();
|
||||
assert_eq!(materials.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_list_materials_filter_by_type() {
|
||||
let conn = setup_test_db();
|
||||
create_test_project(&conn, "project-1");
|
||||
|
||||
// 创建不同类型的素材
|
||||
let types = vec!["document", "image", "document", "text"];
|
||||
for (i, t) in types.iter().enumerate() {
|
||||
let req = UploadMaterialRequest {
|
||||
project_id: "project-1".to_string(),
|
||||
name: format!("素材{}", i),
|
||||
material_type: t.to_string(),
|
||||
file_path: None,
|
||||
content: None,
|
||||
tags: None,
|
||||
description: None,
|
||||
};
|
||||
MaterialDao::create(&conn, &req).unwrap();
|
||||
}
|
||||
|
||||
// 筛选 document 类型
|
||||
let filter = MaterialFilter {
|
||||
material_type: Some("document".to_string()),
|
||||
tags: None,
|
||||
search_query: None,
|
||||
};
|
||||
let materials = MaterialDao::list(&conn, "project-1", Some(&filter)).unwrap();
|
||||
assert_eq!(materials.len(), 2);
|
||||
|
||||
// 筛选 image 类型
|
||||
let filter = MaterialFilter {
|
||||
material_type: Some("image".to_string()),
|
||||
tags: None,
|
||||
search_query: None,
|
||||
};
|
||||
let materials = MaterialDao::list(&conn, "project-1", Some(&filter)).unwrap();
|
||||
assert_eq!(materials.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_list_materials_filter_by_search() {
|
||||
let conn = setup_test_db();
|
||||
create_test_project(&conn, "project-1");
|
||||
|
||||
// 创建素材
|
||||
let names = vec!["重要文档", "普通文档", "重要图片"];
|
||||
for name in names {
|
||||
let req = UploadMaterialRequest {
|
||||
project_id: "project-1".to_string(),
|
||||
name: name.to_string(),
|
||||
material_type: "document".to_string(),
|
||||
file_path: None,
|
||||
content: None,
|
||||
tags: None,
|
||||
description: None,
|
||||
};
|
||||
MaterialDao::create(&conn, &req).unwrap();
|
||||
}
|
||||
|
||||
// 搜索 "重要"
|
||||
let filter = MaterialFilter {
|
||||
material_type: None,
|
||||
tags: None,
|
||||
search_query: Some("重要".to_string()),
|
||||
};
|
||||
let materials = MaterialDao::list(&conn, "project-1", Some(&filter)).unwrap();
|
||||
assert_eq!(materials.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_list_materials_filter_by_tags() {
|
||||
let conn = setup_test_db();
|
||||
create_test_project(&conn, "project-1");
|
||||
|
||||
// 创建带标签的素材
|
||||
let req1 = UploadMaterialRequest {
|
||||
project_id: "project-1".to_string(),
|
||||
name: "素材1".to_string(),
|
||||
material_type: "document".to_string(),
|
||||
file_path: None,
|
||||
content: None,
|
||||
tags: Some(vec!["重要".to_string(), "参考".to_string()]),
|
||||
description: None,
|
||||
};
|
||||
MaterialDao::create(&conn, &req1).unwrap();
|
||||
|
||||
let req2 = UploadMaterialRequest {
|
||||
project_id: "project-1".to_string(),
|
||||
name: "素材2".to_string(),
|
||||
material_type: "document".to_string(),
|
||||
file_path: None,
|
||||
content: None,
|
||||
tags: Some(vec!["普通".to_string()]),
|
||||
description: None,
|
||||
};
|
||||
MaterialDao::create(&conn, &req2).unwrap();
|
||||
|
||||
// 筛选包含 "重要" 标签的素材
|
||||
let filter = MaterialFilter {
|
||||
material_type: None,
|
||||
tags: Some(vec!["重要".to_string()]),
|
||||
search_query: None,
|
||||
};
|
||||
let materials = MaterialDao::list(&conn, "project-1", Some(&filter)).unwrap();
|
||||
assert_eq!(materials.len(), 1);
|
||||
assert_eq!(materials[0].name, "素材1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_material() {
|
||||
let conn = setup_test_db();
|
||||
create_test_project(&conn, "project-1");
|
||||
|
||||
let req = UploadMaterialRequest {
|
||||
project_id: "project-1".to_string(),
|
||||
name: "原始名称".to_string(),
|
||||
material_type: "document".to_string(),
|
||||
file_path: None,
|
||||
content: None,
|
||||
tags: Some(vec!["标签1".to_string()]),
|
||||
description: Some("原始描述".to_string()),
|
||||
};
|
||||
|
||||
let created = MaterialDao::create(&conn, &req).unwrap();
|
||||
|
||||
let update = MaterialUpdate {
|
||||
name: Some("更新后名称".to_string()),
|
||||
tags: Some(vec!["标签2".to_string(), "标签3".to_string()]),
|
||||
description: Some("更新后描述".to_string()),
|
||||
};
|
||||
|
||||
let updated = MaterialDao::update(&conn, &created.id, &update).unwrap();
|
||||
|
||||
assert_eq!(updated.name, "更新后名称");
|
||||
assert_eq!(updated.tags.len(), 2);
|
||||
assert!(updated.tags.contains(&"标签2".to_string()));
|
||||
assert_eq!(updated.description, Some("更新后描述".to_string()));
|
||||
// 验证其他字段未变
|
||||
assert_eq!(updated.material_type, "document");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_material_partial() {
|
||||
let conn = setup_test_db();
|
||||
create_test_project(&conn, "project-1");
|
||||
|
||||
let req = UploadMaterialRequest {
|
||||
project_id: "project-1".to_string(),
|
||||
name: "原始名称".to_string(),
|
||||
material_type: "document".to_string(),
|
||||
file_path: None,
|
||||
content: None,
|
||||
tags: Some(vec!["标签1".to_string()]),
|
||||
description: Some("原始描述".to_string()),
|
||||
};
|
||||
|
||||
let created = MaterialDao::create(&conn, &req).unwrap();
|
||||
|
||||
// 只更新名称
|
||||
let update = MaterialUpdate {
|
||||
name: Some("新名称".to_string()),
|
||||
tags: None,
|
||||
description: None,
|
||||
};
|
||||
|
||||
let updated = MaterialDao::update(&conn, &created.id, &update).unwrap();
|
||||
|
||||
assert_eq!(updated.name, "新名称");
|
||||
// 其他字段保持不变
|
||||
assert_eq!(updated.tags, vec!["标签1".to_string()]);
|
||||
assert_eq!(updated.description, Some("原始描述".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_nonexistent_material() {
|
||||
let conn = setup_test_db();
|
||||
let update = MaterialUpdate::default();
|
||||
let result = MaterialDao::update(&conn, "nonexistent", &update);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_delete_material() {
|
||||
let conn = setup_test_db();
|
||||
create_test_project(&conn, "project-1");
|
||||
|
||||
let req = UploadMaterialRequest {
|
||||
project_id: "project-1".to_string(),
|
||||
name: "待删除素材".to_string(),
|
||||
material_type: "document".to_string(),
|
||||
file_path: Some("/path/to/file.pdf".to_string()),
|
||||
content: None,
|
||||
tags: None,
|
||||
description: None,
|
||||
};
|
||||
|
||||
let created = MaterialDao::create(&conn, &req).unwrap();
|
||||
|
||||
// 验证素材存在
|
||||
assert!(MaterialDao::get(&conn, &created.id).unwrap().is_some());
|
||||
|
||||
// 删除素材
|
||||
let deleted = MaterialDao::delete(&conn, &created.id).unwrap();
|
||||
|
||||
// 验证返回的素材信息正确
|
||||
assert_eq!(deleted.id, created.id);
|
||||
assert_eq!(deleted.file_path, Some("/path/to/file.pdf".to_string()));
|
||||
|
||||
// 验证素材已删除
|
||||
assert!(MaterialDao::get(&conn, &created.id).unwrap().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_delete_nonexistent_material() {
|
||||
let conn = setup_test_db();
|
||||
let result = MaterialDao::delete(&conn, "nonexistent");
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_count_materials() {
|
||||
let conn = setup_test_db();
|
||||
create_test_project(&conn, "project-1");
|
||||
|
||||
// 初始数量为 0
|
||||
let count = MaterialDao::count(&conn, "project-1").unwrap();
|
||||
assert_eq!(count, 0);
|
||||
|
||||
// 创建 3 个素材
|
||||
for i in 1..=3 {
|
||||
let req = UploadMaterialRequest {
|
||||
project_id: "project-1".to_string(),
|
||||
name: format!("素材{}", i),
|
||||
material_type: "document".to_string(),
|
||||
file_path: None,
|
||||
content: None,
|
||||
tags: None,
|
||||
description: None,
|
||||
};
|
||||
MaterialDao::create(&conn, &req).unwrap();
|
||||
}
|
||||
|
||||
// 验证数量为 3
|
||||
let count = MaterialDao::count(&conn, "project-1").unwrap();
|
||||
assert_eq!(count, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_delete_by_project() {
|
||||
let conn = setup_test_db();
|
||||
create_test_project(&conn, "project-1");
|
||||
create_test_project(&conn, "project-2");
|
||||
|
||||
// 为 project-1 创建 2 个素材
|
||||
for i in 1..=2 {
|
||||
let req = UploadMaterialRequest {
|
||||
project_id: "project-1".to_string(),
|
||||
name: format!("素材{}", i),
|
||||
material_type: "document".to_string(),
|
||||
file_path: Some(format!("/path/to/file{}.pdf", i)),
|
||||
content: None,
|
||||
tags: None,
|
||||
description: None,
|
||||
};
|
||||
MaterialDao::create(&conn, &req).unwrap();
|
||||
}
|
||||
|
||||
// 为 project-2 创建 1 个素材
|
||||
let req = UploadMaterialRequest {
|
||||
project_id: "project-2".to_string(),
|
||||
name: "素材3".to_string(),
|
||||
material_type: "document".to_string(),
|
||||
file_path: None,
|
||||
content: None,
|
||||
tags: None,
|
||||
description: None,
|
||||
};
|
||||
MaterialDao::create(&conn, &req).unwrap();
|
||||
|
||||
// 删除 project-1 的所有素材
|
||||
let deleted = MaterialDao::delete_by_project(&conn, "project-1").unwrap();
|
||||
|
||||
// 验证返回了 2 个被删除的素材
|
||||
assert_eq!(deleted.len(), 2);
|
||||
|
||||
// 验证 project-1 没有素材了
|
||||
let materials = MaterialDao::list(&conn, "project-1", None).unwrap();
|
||||
assert_eq!(materials.len(), 0);
|
||||
|
||||
// 验证 project-2 的素材未受影响
|
||||
let materials = MaterialDao::list(&conn, "project-2", None).unwrap();
|
||||
assert_eq!(materials.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_project_scoped_query_correctness() {
|
||||
// Property 2: Project-Scoped Query Correctness
|
||||
// 验证按 project_id 筛选的查询只返回属于该项目的素材
|
||||
let conn = setup_test_db();
|
||||
create_test_project(&conn, "project-a");
|
||||
create_test_project(&conn, "project-b");
|
||||
|
||||
// 为两个项目创建素材
|
||||
for i in 1..=3 {
|
||||
let req = UploadMaterialRequest {
|
||||
project_id: "project-a".to_string(),
|
||||
name: format!("A素材{}", i),
|
||||
material_type: "document".to_string(),
|
||||
file_path: None,
|
||||
content: None,
|
||||
tags: None,
|
||||
description: None,
|
||||
};
|
||||
MaterialDao::create(&conn, &req).unwrap();
|
||||
}
|
||||
|
||||
for i in 1..=2 {
|
||||
let req = UploadMaterialRequest {
|
||||
project_id: "project-b".to_string(),
|
||||
name: format!("B素材{}", i),
|
||||
material_type: "image".to_string(),
|
||||
file_path: None,
|
||||
content: None,
|
||||
tags: None,
|
||||
description: None,
|
||||
};
|
||||
MaterialDao::create(&conn, &req).unwrap();
|
||||
}
|
||||
|
||||
// 查询 project-a 的素材
|
||||
let materials_a = MaterialDao::list(&conn, "project-a", None).unwrap();
|
||||
assert_eq!(materials_a.len(), 3);
|
||||
for m in &materials_a {
|
||||
assert_eq!(m.project_id, "project-a");
|
||||
}
|
||||
|
||||
// 查询 project-b 的素材
|
||||
let materials_b = MaterialDao::list(&conn, "project-b", None).unwrap();
|
||||
assert_eq!(materials_b.len(), 2);
|
||||
for m in &materials_b {
|
||||
assert_eq!(m.project_id, "project-b");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,9 +3,13 @@ pub mod api_key_provider;
|
||||
pub mod chat;
|
||||
pub mod general_chat;
|
||||
pub mod installed_plugins;
|
||||
pub mod material_dao;
|
||||
pub mod mcp;
|
||||
pub mod orchestrator;
|
||||
pub mod persona_dao;
|
||||
pub mod prompts;
|
||||
pub mod provider_pool;
|
||||
pub mod providers;
|
||||
pub mod publish_config_dao;
|
||||
pub mod skills;
|
||||
pub mod template_dao;
|
||||
|
||||
@@ -0,0 +1,699 @@
|
||||
//! 人设数据访问层
|
||||
//!
|
||||
//! 提供人设(Persona)的 CRUD 操作,包括:
|
||||
//! - 创建、获取、列表、更新、删除人设
|
||||
//! - 设置项目默认人设
|
||||
//!
|
||||
//! ## 相关需求
|
||||
//! - Requirements 6.1: 人设列表显示
|
||||
//! - Requirements 6.3: 人设创建
|
||||
//! - Requirements 6.4: 设置默认人设
|
||||
//! - Requirements 6.6: 人设删除
|
||||
|
||||
use rusqlite::{params, Connection};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::errors::project_error::PersonaError;
|
||||
use crate::models::project_model::{CreatePersonaRequest, Persona, PersonaUpdate};
|
||||
|
||||
// ============================================================================
|
||||
// 数据访问对象
|
||||
// ============================================================================
|
||||
|
||||
/// 人设 DAO
|
||||
///
|
||||
/// 提供人设的数据库操作方法。
|
||||
pub struct PersonaDao;
|
||||
|
||||
impl PersonaDao {
|
||||
// ------------------------------------------------------------------------
|
||||
// 创建人设
|
||||
// ------------------------------------------------------------------------
|
||||
|
||||
/// 创建新人设
|
||||
///
|
||||
/// # 参数
|
||||
/// - `conn`: 数据库连接
|
||||
/// - `req`: 创建人设请求
|
||||
///
|
||||
/// # 返回
|
||||
/// - 成功返回创建的人设
|
||||
/// - 失败返回 PersonaError
|
||||
pub fn create(conn: &Connection, req: &CreatePersonaRequest) -> Result<Persona, PersonaError> {
|
||||
let id = Uuid::new_v4().to_string();
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
|
||||
// 序列化 JSON 字段
|
||||
let forbidden_words_json =
|
||||
serde_json::to_string(req.forbidden_words.as_ref().unwrap_or(&vec![]))
|
||||
.unwrap_or_else(|_| "[]".to_string());
|
||||
|
||||
let preferred_words_json =
|
||||
serde_json::to_string(req.preferred_words.as_ref().unwrap_or(&vec![]))
|
||||
.unwrap_or_else(|_| "[]".to_string());
|
||||
|
||||
let platforms_json = serde_json::to_string(req.platforms.as_ref().unwrap_or(&vec![]))
|
||||
.unwrap_or_else(|_| "[]".to_string());
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO personas (
|
||||
id, project_id, name, description, style, tone, target_audience,
|
||||
forbidden_words_json, preferred_words_json, examples, platforms_json,
|
||||
is_default, created_at, updated_at
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)",
|
||||
params![
|
||||
id,
|
||||
req.project_id,
|
||||
req.name,
|
||||
req.description,
|
||||
req.style,
|
||||
req.tone,
|
||||
req.target_audience,
|
||||
forbidden_words_json,
|
||||
preferred_words_json,
|
||||
req.examples,
|
||||
platforms_json,
|
||||
0, // is_default
|
||||
now,
|
||||
now,
|
||||
],
|
||||
)?;
|
||||
|
||||
// 返回创建的人设
|
||||
Ok(Persona {
|
||||
id,
|
||||
project_id: req.project_id.clone(),
|
||||
name: req.name.clone(),
|
||||
description: req.description.clone(),
|
||||
style: req.style.clone(),
|
||||
tone: req.tone.clone(),
|
||||
target_audience: req.target_audience.clone(),
|
||||
forbidden_words: req.forbidden_words.clone().unwrap_or_default(),
|
||||
preferred_words: req.preferred_words.clone().unwrap_or_default(),
|
||||
examples: req.examples.clone(),
|
||||
platforms: req.platforms.clone().unwrap_or_default(),
|
||||
is_default: false,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
})
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
// 获取人设
|
||||
// ------------------------------------------------------------------------
|
||||
|
||||
/// 获取单个人设
|
||||
///
|
||||
/// # 参数
|
||||
/// - `conn`: 数据库连接
|
||||
/// - `id`: 人设 ID
|
||||
///
|
||||
/// # 返回
|
||||
/// - 成功返回 Option<Persona>
|
||||
/// - 失败返回 PersonaError
|
||||
pub fn get(conn: &Connection, id: &str) -> Result<Option<Persona>, PersonaError> {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, project_id, name, description, style, tone, target_audience,
|
||||
forbidden_words_json, preferred_words_json, examples, platforms_json,
|
||||
is_default, created_at, updated_at
|
||||
FROM personas WHERE id = ?",
|
||||
)?;
|
||||
|
||||
let mut rows = stmt.query([id])?;
|
||||
|
||||
if let Some(row) = rows.next()? {
|
||||
Ok(Some(Self::map_row(row)?))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
// 列表人设
|
||||
// ------------------------------------------------------------------------
|
||||
|
||||
/// 获取项目的人设列表
|
||||
///
|
||||
/// # 参数
|
||||
/// - `conn`: 数据库连接
|
||||
/// - `project_id`: 项目 ID
|
||||
///
|
||||
/// # 返回
|
||||
/// - 成功返回人设列表
|
||||
/// - 失败返回 PersonaError
|
||||
pub fn list(conn: &Connection, project_id: &str) -> Result<Vec<Persona>, PersonaError> {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, project_id, name, description, style, tone, target_audience,
|
||||
forbidden_words_json, preferred_words_json, examples, platforms_json,
|
||||
is_default, created_at, updated_at
|
||||
FROM personas WHERE project_id = ? ORDER BY created_at DESC",
|
||||
)?;
|
||||
|
||||
let personas: Vec<Persona> = stmt
|
||||
.query_map([project_id], |row| Self::map_row(row))?
|
||||
.filter_map(|r| r.ok())
|
||||
.collect();
|
||||
|
||||
Ok(personas)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
// 更新人设
|
||||
// ------------------------------------------------------------------------
|
||||
|
||||
/// 更新人设
|
||||
///
|
||||
/// # 参数
|
||||
/// - `conn`: 数据库连接
|
||||
/// - `id`: 人设 ID
|
||||
/// - `update`: 更新内容
|
||||
///
|
||||
/// # 返回
|
||||
/// - 成功返回更新后的人设
|
||||
/// - 失败返回 PersonaError
|
||||
pub fn update(
|
||||
conn: &Connection,
|
||||
id: &str,
|
||||
update: &PersonaUpdate,
|
||||
) -> Result<Persona, PersonaError> {
|
||||
// 先获取现有人设
|
||||
let existing =
|
||||
Self::get(conn, id)?.ok_or_else(|| PersonaError::NotFound(id.to_string()))?;
|
||||
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
|
||||
// 构建更新后的值
|
||||
let name = update.name.as_ref().unwrap_or(&existing.name);
|
||||
let description = update.description.clone().or(existing.description);
|
||||
let style = update.style.as_ref().unwrap_or(&existing.style);
|
||||
let tone = update.tone.clone().or(existing.tone);
|
||||
let target_audience = update.target_audience.clone().or(existing.target_audience);
|
||||
let forbidden_words = update
|
||||
.forbidden_words
|
||||
.clone()
|
||||
.unwrap_or(existing.forbidden_words);
|
||||
let preferred_words = update
|
||||
.preferred_words
|
||||
.clone()
|
||||
.unwrap_or(existing.preferred_words);
|
||||
let examples = update.examples.clone().or(existing.examples);
|
||||
let platforms = update.platforms.clone().unwrap_or(existing.platforms);
|
||||
|
||||
// 序列化 JSON 字段
|
||||
let forbidden_words_json =
|
||||
serde_json::to_string(&forbidden_words).unwrap_or_else(|_| "[]".to_string());
|
||||
let preferred_words_json =
|
||||
serde_json::to_string(&preferred_words).unwrap_or_else(|_| "[]".to_string());
|
||||
let platforms_json = serde_json::to_string(&platforms).unwrap_or_else(|_| "[]".to_string());
|
||||
|
||||
conn.execute(
|
||||
"UPDATE personas SET
|
||||
name = ?1, description = ?2, style = ?3, tone = ?4, target_audience = ?5,
|
||||
forbidden_words_json = ?6, preferred_words_json = ?7, examples = ?8,
|
||||
platforms_json = ?9, updated_at = ?10
|
||||
WHERE id = ?11",
|
||||
params![
|
||||
name,
|
||||
description,
|
||||
style,
|
||||
tone,
|
||||
target_audience,
|
||||
forbidden_words_json,
|
||||
preferred_words_json,
|
||||
examples,
|
||||
platforms_json,
|
||||
now,
|
||||
id,
|
||||
],
|
||||
)?;
|
||||
|
||||
// 返回更新后的人设
|
||||
Self::get(conn, id)?.ok_or_else(|| PersonaError::NotFound(id.to_string()))
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
// 删除人设
|
||||
// ------------------------------------------------------------------------
|
||||
|
||||
/// 删除人设
|
||||
///
|
||||
/// # 参数
|
||||
/// - `conn`: 数据库连接
|
||||
/// - `id`: 人设 ID
|
||||
///
|
||||
/// # 返回
|
||||
/// - 成功返回 ()
|
||||
/// - 失败返回 PersonaError
|
||||
pub fn delete(conn: &Connection, id: &str) -> Result<(), PersonaError> {
|
||||
let rows = conn.execute("DELETE FROM personas WHERE id = ?", [id])?;
|
||||
|
||||
if rows == 0 {
|
||||
return Err(PersonaError::NotFound(id.to_string()));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
// 设置默认人设
|
||||
// ------------------------------------------------------------------------
|
||||
|
||||
/// 设置项目的默认人设
|
||||
///
|
||||
/// 将指定人设设为默认,同时取消该项目其他人设的默认状态。
|
||||
/// 这确保每个项目最多只有一个默认人设。
|
||||
///
|
||||
/// # 参数
|
||||
/// - `conn`: 数据库连接
|
||||
/// - `project_id`: 项目 ID
|
||||
/// - `persona_id`: 要设为默认的人设 ID
|
||||
///
|
||||
/// # 返回
|
||||
/// - 成功返回 ()
|
||||
/// - 失败返回 PersonaError
|
||||
pub fn set_default(
|
||||
conn: &Connection,
|
||||
project_id: &str,
|
||||
persona_id: &str,
|
||||
) -> Result<(), PersonaError> {
|
||||
// 验证人设存在且属于该项目
|
||||
let persona = Self::get(conn, persona_id)?
|
||||
.ok_or_else(|| PersonaError::NotFound(persona_id.to_string()))?;
|
||||
|
||||
if persona.project_id != project_id {
|
||||
return Err(PersonaError::ProjectNotFound(project_id.to_string()));
|
||||
}
|
||||
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
|
||||
// 先取消该项目所有人设的默认状态
|
||||
conn.execute(
|
||||
"UPDATE personas SET is_default = 0, updated_at = ?1 WHERE project_id = ?2",
|
||||
params![now, project_id],
|
||||
)?;
|
||||
|
||||
// 设置指定人设为默认
|
||||
conn.execute(
|
||||
"UPDATE personas SET is_default = 1, updated_at = ?1 WHERE id = ?2",
|
||||
params![now, persona_id],
|
||||
)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 获取项目的默认人设
|
||||
///
|
||||
/// # 参数
|
||||
/// - `conn`: 数据库连接
|
||||
/// - `project_id`: 项目 ID
|
||||
///
|
||||
/// # 返回
|
||||
/// - 成功返回 Option<Persona>
|
||||
/// - 失败返回 PersonaError
|
||||
pub fn get_default(
|
||||
conn: &Connection,
|
||||
project_id: &str,
|
||||
) -> Result<Option<Persona>, PersonaError> {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, project_id, name, description, style, tone, target_audience,
|
||||
forbidden_words_json, preferred_words_json, examples, platforms_json,
|
||||
is_default, created_at, updated_at
|
||||
FROM personas WHERE project_id = ? AND is_default = 1",
|
||||
)?;
|
||||
|
||||
let mut rows = stmt.query([project_id])?;
|
||||
|
||||
if let Some(row) = rows.next()? {
|
||||
Ok(Some(Self::map_row(row)?))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
// 辅助方法
|
||||
// ------------------------------------------------------------------------
|
||||
|
||||
/// 映射数据库行到 Persona 结构体
|
||||
fn map_row(row: &rusqlite::Row) -> Result<Persona, rusqlite::Error> {
|
||||
let forbidden_words_json: String = row.get(7)?;
|
||||
let preferred_words_json: String = row.get(8)?;
|
||||
let platforms_json: String = row.get(10)?;
|
||||
|
||||
// 解析 JSON 字段
|
||||
let forbidden_words: Vec<String> =
|
||||
serde_json::from_str(&forbidden_words_json).unwrap_or_default();
|
||||
let preferred_words: Vec<String> =
|
||||
serde_json::from_str(&preferred_words_json).unwrap_or_default();
|
||||
let platforms: Vec<String> = serde_json::from_str(&platforms_json).unwrap_or_default();
|
||||
|
||||
Ok(Persona {
|
||||
id: row.get(0)?,
|
||||
project_id: row.get(1)?,
|
||||
name: row.get(2)?,
|
||||
description: row.get(3)?,
|
||||
style: row.get(4)?,
|
||||
tone: row.get(5)?,
|
||||
target_audience: row.get(6)?,
|
||||
forbidden_words,
|
||||
preferred_words,
|
||||
examples: row.get(9)?,
|
||||
platforms,
|
||||
is_default: row.get::<_, i32>(11)? != 0,
|
||||
created_at: row.get(12)?,
|
||||
updated_at: row.get(13)?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 测试
|
||||
// ============================================================================
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::database::schema::create_tables;
|
||||
|
||||
/// 创建测试数据库连接
|
||||
fn setup_test_db() -> Connection {
|
||||
let conn = Connection::open_in_memory().unwrap();
|
||||
create_tables(&conn).unwrap();
|
||||
conn
|
||||
}
|
||||
|
||||
/// 创建测试项目
|
||||
fn create_test_project(conn: &Connection, id: &str) {
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
conn.execute(
|
||||
"INSERT INTO workspaces (id, name, workspace_type, root_path, created_at, updated_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
||||
params![
|
||||
id,
|
||||
"测试项目",
|
||||
"persistent",
|
||||
format!("/test/{}", id),
|
||||
now,
|
||||
now
|
||||
],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_persona() {
|
||||
let conn = setup_test_db();
|
||||
create_test_project(&conn, "project-1");
|
||||
|
||||
let req = CreatePersonaRequest {
|
||||
project_id: "project-1".to_string(),
|
||||
name: "专业写手".to_string(),
|
||||
description: Some("专业的技术文章写手".to_string()),
|
||||
style: "专业".to_string(),
|
||||
tone: Some("正式".to_string()),
|
||||
target_audience: Some("技术人员".to_string()),
|
||||
forbidden_words: Some(vec!["禁词1".to_string()]),
|
||||
preferred_words: Some(vec!["偏好词1".to_string()]),
|
||||
examples: Some("示例文本".to_string()),
|
||||
platforms: Some(vec!["xiaohongshu".to_string()]),
|
||||
};
|
||||
|
||||
let persona = PersonaDao::create(&conn, &req).unwrap();
|
||||
|
||||
assert!(!persona.id.is_empty());
|
||||
assert_eq!(persona.project_id, "project-1");
|
||||
assert_eq!(persona.name, "专业写手");
|
||||
assert_eq!(persona.style, "专业");
|
||||
assert!(!persona.is_default);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_persona() {
|
||||
let conn = setup_test_db();
|
||||
create_test_project(&conn, "project-1");
|
||||
|
||||
let req = CreatePersonaRequest {
|
||||
project_id: "project-1".to_string(),
|
||||
name: "测试人设".to_string(),
|
||||
description: None,
|
||||
style: "轻松".to_string(),
|
||||
tone: None,
|
||||
target_audience: None,
|
||||
forbidden_words: None,
|
||||
preferred_words: None,
|
||||
examples: None,
|
||||
platforms: None,
|
||||
};
|
||||
|
||||
let created = PersonaDao::create(&conn, &req).unwrap();
|
||||
let fetched = PersonaDao::get(&conn, &created.id).unwrap();
|
||||
|
||||
assert!(fetched.is_some());
|
||||
let fetched = fetched.unwrap();
|
||||
assert_eq!(fetched.id, created.id);
|
||||
assert_eq!(fetched.name, "测试人设");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_nonexistent_persona() {
|
||||
let conn = setup_test_db();
|
||||
let result = PersonaDao::get(&conn, "nonexistent").unwrap();
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_list_personas() {
|
||||
let conn = setup_test_db();
|
||||
create_test_project(&conn, "project-1");
|
||||
create_test_project(&conn, "project-2");
|
||||
|
||||
// 为 project-1 创建两个人设
|
||||
for i in 1..=2 {
|
||||
let req = CreatePersonaRequest {
|
||||
project_id: "project-1".to_string(),
|
||||
name: format!("人设{}", i),
|
||||
description: None,
|
||||
style: "测试".to_string(),
|
||||
tone: None,
|
||||
target_audience: None,
|
||||
forbidden_words: None,
|
||||
preferred_words: None,
|
||||
examples: None,
|
||||
platforms: None,
|
||||
};
|
||||
PersonaDao::create(&conn, &req).unwrap();
|
||||
}
|
||||
|
||||
// 为 project-2 创建一个人设
|
||||
let req = CreatePersonaRequest {
|
||||
project_id: "project-2".to_string(),
|
||||
name: "人设3".to_string(),
|
||||
description: None,
|
||||
style: "测试".to_string(),
|
||||
tone: None,
|
||||
target_audience: None,
|
||||
forbidden_words: None,
|
||||
preferred_words: None,
|
||||
examples: None,
|
||||
platforms: None,
|
||||
};
|
||||
PersonaDao::create(&conn, &req).unwrap();
|
||||
|
||||
// 验证 project-1 有 2 个人设
|
||||
let personas = PersonaDao::list(&conn, "project-1").unwrap();
|
||||
assert_eq!(personas.len(), 2);
|
||||
|
||||
// 验证 project-2 有 1 个人设
|
||||
let personas = PersonaDao::list(&conn, "project-2").unwrap();
|
||||
assert_eq!(personas.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_persona() {
|
||||
let conn = setup_test_db();
|
||||
create_test_project(&conn, "project-1");
|
||||
|
||||
let req = CreatePersonaRequest {
|
||||
project_id: "project-1".to_string(),
|
||||
name: "原始名称".to_string(),
|
||||
description: Some("原始描述".to_string()),
|
||||
style: "原始风格".to_string(),
|
||||
tone: None,
|
||||
target_audience: None,
|
||||
forbidden_words: None,
|
||||
preferred_words: None,
|
||||
examples: None,
|
||||
platforms: None,
|
||||
};
|
||||
|
||||
let created = PersonaDao::create(&conn, &req).unwrap();
|
||||
|
||||
let update = PersonaUpdate {
|
||||
name: Some("更新后名称".to_string()),
|
||||
description: Some("更新后描述".to_string()),
|
||||
style: Some("更新后风格".to_string()),
|
||||
tone: Some("活泼".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let updated = PersonaDao::update(&conn, &created.id, &update).unwrap();
|
||||
|
||||
assert_eq!(updated.name, "更新后名称");
|
||||
assert_eq!(updated.description, Some("更新后描述".to_string()));
|
||||
assert_eq!(updated.style, "更新后风格");
|
||||
assert_eq!(updated.tone, Some("活泼".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_nonexistent_persona() {
|
||||
let conn = setup_test_db();
|
||||
let update = PersonaUpdate::default();
|
||||
let result = PersonaDao::update(&conn, "nonexistent", &update);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_delete_persona() {
|
||||
let conn = setup_test_db();
|
||||
create_test_project(&conn, "project-1");
|
||||
|
||||
let req = CreatePersonaRequest {
|
||||
project_id: "project-1".to_string(),
|
||||
name: "待删除人设".to_string(),
|
||||
description: None,
|
||||
style: "测试".to_string(),
|
||||
tone: None,
|
||||
target_audience: None,
|
||||
forbidden_words: None,
|
||||
preferred_words: None,
|
||||
examples: None,
|
||||
platforms: None,
|
||||
};
|
||||
|
||||
let created = PersonaDao::create(&conn, &req).unwrap();
|
||||
|
||||
// 验证人设存在
|
||||
assert!(PersonaDao::get(&conn, &created.id).unwrap().is_some());
|
||||
|
||||
// 删除人设
|
||||
PersonaDao::delete(&conn, &created.id).unwrap();
|
||||
|
||||
// 验证人设已删除
|
||||
assert!(PersonaDao::get(&conn, &created.id).unwrap().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_delete_nonexistent_persona() {
|
||||
let conn = setup_test_db();
|
||||
let result = PersonaDao::delete(&conn, "nonexistent");
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_set_default_persona() {
|
||||
let conn = setup_test_db();
|
||||
create_test_project(&conn, "project-1");
|
||||
|
||||
// 创建两个人设
|
||||
let req1 = CreatePersonaRequest {
|
||||
project_id: "project-1".to_string(),
|
||||
name: "人设1".to_string(),
|
||||
description: None,
|
||||
style: "测试".to_string(),
|
||||
tone: None,
|
||||
target_audience: None,
|
||||
forbidden_words: None,
|
||||
preferred_words: None,
|
||||
examples: None,
|
||||
platforms: None,
|
||||
};
|
||||
let persona1 = PersonaDao::create(&conn, &req1).unwrap();
|
||||
|
||||
let req2 = CreatePersonaRequest {
|
||||
project_id: "project-1".to_string(),
|
||||
name: "人设2".to_string(),
|
||||
description: None,
|
||||
style: "测试".to_string(),
|
||||
tone: None,
|
||||
target_audience: None,
|
||||
forbidden_words: None,
|
||||
preferred_words: None,
|
||||
examples: None,
|
||||
platforms: None,
|
||||
};
|
||||
let persona2 = PersonaDao::create(&conn, &req2).unwrap();
|
||||
|
||||
// 设置人设1为默认
|
||||
PersonaDao::set_default(&conn, "project-1", &persona1.id).unwrap();
|
||||
|
||||
let p1 = PersonaDao::get(&conn, &persona1.id).unwrap().unwrap();
|
||||
let p2 = PersonaDao::get(&conn, &persona2.id).unwrap().unwrap();
|
||||
assert!(p1.is_default);
|
||||
assert!(!p2.is_default);
|
||||
|
||||
// 设置人设2为默认,人设1应该不再是默认
|
||||
PersonaDao::set_default(&conn, "project-1", &persona2.id).unwrap();
|
||||
|
||||
let p1 = PersonaDao::get(&conn, &persona1.id).unwrap().unwrap();
|
||||
let p2 = PersonaDao::get(&conn, &persona2.id).unwrap().unwrap();
|
||||
assert!(!p1.is_default);
|
||||
assert!(p2.is_default);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_default_persona() {
|
||||
let conn = setup_test_db();
|
||||
create_test_project(&conn, "project-1");
|
||||
|
||||
// 初始没有默认人设
|
||||
let default = PersonaDao::get_default(&conn, "project-1").unwrap();
|
||||
assert!(default.is_none());
|
||||
|
||||
// 创建人设并设为默认
|
||||
let req = CreatePersonaRequest {
|
||||
project_id: "project-1".to_string(),
|
||||
name: "默认人设".to_string(),
|
||||
description: None,
|
||||
style: "测试".to_string(),
|
||||
tone: None,
|
||||
target_audience: None,
|
||||
forbidden_words: None,
|
||||
preferred_words: None,
|
||||
examples: None,
|
||||
platforms: None,
|
||||
};
|
||||
let persona = PersonaDao::create(&conn, &req).unwrap();
|
||||
PersonaDao::set_default(&conn, "project-1", &persona.id).unwrap();
|
||||
|
||||
// 验证可以获取默认人设
|
||||
let default = PersonaDao::get_default(&conn, "project-1").unwrap();
|
||||
assert!(default.is_some());
|
||||
assert_eq!(default.unwrap().id, persona.id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_set_default_wrong_project() {
|
||||
let conn = setup_test_db();
|
||||
create_test_project(&conn, "project-1");
|
||||
create_test_project(&conn, "project-2");
|
||||
|
||||
// 在 project-1 创建人设
|
||||
let req = CreatePersonaRequest {
|
||||
project_id: "project-1".to_string(),
|
||||
name: "人设".to_string(),
|
||||
description: None,
|
||||
style: "测试".to_string(),
|
||||
tone: None,
|
||||
target_audience: None,
|
||||
forbidden_words: None,
|
||||
preferred_words: None,
|
||||
examples: None,
|
||||
platforms: None,
|
||||
};
|
||||
let persona = PersonaDao::create(&conn, &req).unwrap();
|
||||
|
||||
// 尝试在 project-2 设置该人设为默认,应该失败
|
||||
let result = PersonaDao::set_default(&conn, "project-2", &persona.id);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,709 @@
|
||||
//! 发布配置数据访问层
|
||||
//!
|
||||
//! 提供发布配置(PublishConfig)的 CRUD 操作,包括:
|
||||
//! - 创建、获取、列表、更新发布配置
|
||||
//! - 按平台获取配置
|
||||
//! - 更新发布统计
|
||||
//!
|
||||
//! ## 相关需求
|
||||
//! - Requirements 9.1: 发布配置列表显示
|
||||
//! - Requirements 9.4: 凭证加密存储
|
||||
|
||||
use rusqlite::{params, Connection};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::errors::project_error::PublishConfigError;
|
||||
use crate::models::project_model::PublishConfig;
|
||||
|
||||
// ============================================================================
|
||||
// 数据访问对象
|
||||
// ============================================================================
|
||||
|
||||
/// 发布配置 DAO
|
||||
///
|
||||
/// 提供发布配置的数据库操作方法。
|
||||
pub struct PublishConfigDao;
|
||||
|
||||
impl PublishConfigDao {
|
||||
// ------------------------------------------------------------------------
|
||||
// 创建发布配置
|
||||
// ------------------------------------------------------------------------
|
||||
|
||||
/// 创建新的发布配置
|
||||
///
|
||||
/// # 参数
|
||||
/// - `conn`: 数据库连接
|
||||
/// - `project_id`: 项目 ID
|
||||
/// - `platform`: 平台名称
|
||||
///
|
||||
/// # 返回
|
||||
/// - 成功返回创建的发布配置
|
||||
/// - 失败返回 PublishConfigError
|
||||
///
|
||||
/// # 注意
|
||||
/// - 每个项目的每个平台只能有一个配置(UNIQUE 约束)
|
||||
pub fn create(
|
||||
conn: &Connection,
|
||||
project_id: &str,
|
||||
platform: &str,
|
||||
) -> Result<PublishConfig, PublishConfigError> {
|
||||
let id = Uuid::new_v4().to_string();
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO publish_configs (
|
||||
id, project_id, platform, is_configured, credentials_encrypted,
|
||||
last_published_at, publish_count, created_at, updated_at
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
|
||||
params![
|
||||
id,
|
||||
project_id,
|
||||
platform,
|
||||
0, // is_configured
|
||||
Option::<String>::None, // credentials_encrypted
|
||||
Option::<i64>::None, // last_published_at
|
||||
0, // publish_count
|
||||
now,
|
||||
now,
|
||||
],
|
||||
)?;
|
||||
|
||||
Ok(PublishConfig {
|
||||
id,
|
||||
project_id: project_id.to_string(),
|
||||
platform: platform.to_string(),
|
||||
is_configured: false,
|
||||
last_published_at: None,
|
||||
publish_count: 0,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
})
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
// 获取发布配置
|
||||
// ------------------------------------------------------------------------
|
||||
|
||||
/// 获取单个发布配置
|
||||
///
|
||||
/// # 参数
|
||||
/// - `conn`: 数据库连接
|
||||
/// - `id`: 配置 ID
|
||||
///
|
||||
/// # 返回
|
||||
/// - 成功返回 Option<PublishConfig>
|
||||
/// - 失败返回 PublishConfigError
|
||||
pub fn get(conn: &Connection, id: &str) -> Result<Option<PublishConfig>, PublishConfigError> {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, project_id, platform, is_configured, credentials_encrypted,
|
||||
last_published_at, publish_count, created_at, updated_at
|
||||
FROM publish_configs WHERE id = ?",
|
||||
)?;
|
||||
|
||||
let mut rows = stmt.query([id])?;
|
||||
|
||||
if let Some(row) = rows.next()? {
|
||||
Ok(Some(Self::map_row(row)?))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
/// 按项目和平台获取发布配置
|
||||
///
|
||||
/// # 参数
|
||||
/// - `conn`: 数据库连接
|
||||
/// - `project_id`: 项目 ID
|
||||
/// - `platform`: 平台名称
|
||||
///
|
||||
/// # 返回
|
||||
/// - 成功返回 Option<PublishConfig>
|
||||
/// - 失败返回 PublishConfigError
|
||||
pub fn get_by_platform(
|
||||
conn: &Connection,
|
||||
project_id: &str,
|
||||
platform: &str,
|
||||
) -> Result<Option<PublishConfig>, PublishConfigError> {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, project_id, platform, is_configured, credentials_encrypted,
|
||||
last_published_at, publish_count, created_at, updated_at
|
||||
FROM publish_configs WHERE project_id = ? AND platform = ?",
|
||||
)?;
|
||||
|
||||
let mut rows = stmt.query(params![project_id, platform])?;
|
||||
|
||||
if let Some(row) = rows.next()? {
|
||||
Ok(Some(Self::map_row(row)?))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
// 列表发布配置
|
||||
// ------------------------------------------------------------------------
|
||||
|
||||
/// 获取项目的所有发布配置
|
||||
///
|
||||
/// # 参数
|
||||
/// - `conn`: 数据库连接
|
||||
/// - `project_id`: 项目 ID
|
||||
///
|
||||
/// # 返回
|
||||
/// - 成功返回发布配置列表
|
||||
/// - 失败返回 PublishConfigError
|
||||
pub fn list(
|
||||
conn: &Connection,
|
||||
project_id: &str,
|
||||
) -> Result<Vec<PublishConfig>, PublishConfigError> {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, project_id, platform, is_configured, credentials_encrypted,
|
||||
last_published_at, publish_count, created_at, updated_at
|
||||
FROM publish_configs WHERE project_id = ? ORDER BY created_at DESC",
|
||||
)?;
|
||||
|
||||
let configs: Vec<PublishConfig> = stmt
|
||||
.query_map([project_id], |row| Self::map_row(row))?
|
||||
.filter_map(|r| r.ok())
|
||||
.collect();
|
||||
|
||||
Ok(configs)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
// 更新发布配置
|
||||
// ------------------------------------------------------------------------
|
||||
|
||||
/// 更新发布配置
|
||||
///
|
||||
/// # 参数
|
||||
/// - `conn`: 数据库连接
|
||||
/// - `id`: 配置 ID
|
||||
/// - `is_configured`: 是否已配置
|
||||
/// - `credentials_encrypted`: 加密后的凭证(可选)
|
||||
///
|
||||
/// # 返回
|
||||
/// - 成功返回更新后的发布配置
|
||||
/// - 失败返回 PublishConfigError
|
||||
pub fn update(
|
||||
conn: &Connection,
|
||||
id: &str,
|
||||
is_configured: bool,
|
||||
credentials_encrypted: Option<String>,
|
||||
) -> Result<PublishConfig, PublishConfigError> {
|
||||
// 先验证配置存在
|
||||
Self::get(conn, id)?.ok_or_else(|| PublishConfigError::NotFound(id.to_string()))?;
|
||||
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
|
||||
conn.execute(
|
||||
"UPDATE publish_configs SET
|
||||
is_configured = ?1, credentials_encrypted = ?2, updated_at = ?3
|
||||
WHERE id = ?4",
|
||||
params![is_configured, credentials_encrypted, now, id],
|
||||
)?;
|
||||
|
||||
Self::get(conn, id)?.ok_or_else(|| PublishConfigError::NotFound(id.to_string()))
|
||||
}
|
||||
|
||||
/// 记录发布操作
|
||||
///
|
||||
/// 更新最后发布时间和发布次数。
|
||||
///
|
||||
/// # 参数
|
||||
/// - `conn`: 数据库连接
|
||||
/// - `id`: 配置 ID
|
||||
///
|
||||
/// # 返回
|
||||
/// - 成功返回更新后的发布配置
|
||||
/// - 失败返回 PublishConfigError
|
||||
pub fn record_publish(
|
||||
conn: &Connection,
|
||||
id: &str,
|
||||
) -> Result<PublishConfig, PublishConfigError> {
|
||||
// 先验证配置存在
|
||||
Self::get(conn, id)?.ok_or_else(|| PublishConfigError::NotFound(id.to_string()))?;
|
||||
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
|
||||
conn.execute(
|
||||
"UPDATE publish_configs SET
|
||||
last_published_at = ?1, publish_count = publish_count + 1, updated_at = ?2
|
||||
WHERE id = ?3",
|
||||
params![now, now, id],
|
||||
)?;
|
||||
|
||||
Self::get(conn, id)?.ok_or_else(|| PublishConfigError::NotFound(id.to_string()))
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
// 删除发布配置
|
||||
// ------------------------------------------------------------------------
|
||||
|
||||
/// 删除发布配置
|
||||
///
|
||||
/// # 参数
|
||||
/// - `conn`: 数据库连接
|
||||
/// - `id`: 配置 ID
|
||||
///
|
||||
/// # 返回
|
||||
/// - 成功返回 ()
|
||||
/// - 失败返回 PublishConfigError
|
||||
pub fn delete(conn: &Connection, id: &str) -> Result<(), PublishConfigError> {
|
||||
let rows = conn.execute("DELETE FROM publish_configs WHERE id = ?", [id])?;
|
||||
|
||||
if rows == 0 {
|
||||
return Err(PublishConfigError::NotFound(id.to_string()));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 删除项目的所有发布配置
|
||||
///
|
||||
/// # 参数
|
||||
/// - `conn`: 数据库连接
|
||||
/// - `project_id`: 项目 ID
|
||||
///
|
||||
/// # 返回
|
||||
/// - 成功返回删除的数量
|
||||
/// - 失败返回 PublishConfigError
|
||||
pub fn delete_by_project(
|
||||
conn: &Connection,
|
||||
project_id: &str,
|
||||
) -> Result<usize, PublishConfigError> {
|
||||
let rows = conn.execute(
|
||||
"DELETE FROM publish_configs WHERE project_id = ?",
|
||||
[project_id],
|
||||
)?;
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
// 批量操作
|
||||
// ------------------------------------------------------------------------
|
||||
|
||||
/// 获取项目的发布配置数量
|
||||
///
|
||||
/// # 参数
|
||||
/// - `conn`: 数据库连接
|
||||
/// - `project_id`: 项目 ID
|
||||
///
|
||||
/// # 返回
|
||||
/// - 成功返回配置数量
|
||||
/// - 失败返回 PublishConfigError
|
||||
pub fn count(conn: &Connection, project_id: &str) -> Result<i64, PublishConfigError> {
|
||||
let count: i64 = conn.query_row(
|
||||
"SELECT COUNT(*) FROM publish_configs WHERE project_id = ?",
|
||||
[project_id],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
/// 获取或创建发布配置
|
||||
///
|
||||
/// 如果指定项目和平台的配置已存在,返回现有配置;
|
||||
/// 否则创建新配置。
|
||||
///
|
||||
/// # 参数
|
||||
/// - `conn`: 数据库连接
|
||||
/// - `project_id`: 项目 ID
|
||||
/// - `platform`: 平台名称
|
||||
///
|
||||
/// # 返回
|
||||
/// - 成功返回发布配置
|
||||
/// - 失败返回 PublishConfigError
|
||||
pub fn get_or_create(
|
||||
conn: &Connection,
|
||||
project_id: &str,
|
||||
platform: &str,
|
||||
) -> Result<PublishConfig, PublishConfigError> {
|
||||
if let Some(config) = Self::get_by_platform(conn, project_id, platform)? {
|
||||
return Ok(config);
|
||||
}
|
||||
Self::create(conn, project_id, platform)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
// 辅助方法
|
||||
// ------------------------------------------------------------------------
|
||||
|
||||
/// 映射数据库行到 PublishConfig 结构体
|
||||
fn map_row(row: &rusqlite::Row) -> Result<PublishConfig, rusqlite::Error> {
|
||||
Ok(PublishConfig {
|
||||
id: row.get(0)?,
|
||||
project_id: row.get(1)?,
|
||||
platform: row.get(2)?,
|
||||
is_configured: row.get::<_, i32>(3)? != 0,
|
||||
// credentials_encrypted 不返回给前端,保持安全
|
||||
// 这里我们跳过它,但在内部使用时可以获取
|
||||
last_published_at: row.get(5)?,
|
||||
publish_count: row.get(6)?,
|
||||
created_at: row.get(7)?,
|
||||
updated_at: row.get(8)?,
|
||||
})
|
||||
}
|
||||
|
||||
/// 获取加密凭证(内部使用)
|
||||
///
|
||||
/// # 参数
|
||||
/// - `conn`: 数据库连接
|
||||
/// - `id`: 配置 ID
|
||||
///
|
||||
/// # 返回
|
||||
/// - 成功返回加密凭证(如果有)
|
||||
/// - 失败返回 PublishConfigError
|
||||
pub fn get_credentials(
|
||||
conn: &Connection,
|
||||
id: &str,
|
||||
) -> Result<Option<String>, PublishConfigError> {
|
||||
let credentials: Option<String> = conn.query_row(
|
||||
"SELECT credentials_encrypted FROM publish_configs WHERE id = ?",
|
||||
[id],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
Ok(credentials)
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 测试
|
||||
// ============================================================================
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::database::schema::create_tables;
|
||||
|
||||
/// 创建测试数据库连接
|
||||
fn setup_test_db() -> Connection {
|
||||
let conn = Connection::open_in_memory().unwrap();
|
||||
create_tables(&conn).unwrap();
|
||||
conn
|
||||
}
|
||||
|
||||
/// 创建测试项目
|
||||
fn create_test_project(conn: &Connection, id: &str) {
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
conn.execute(
|
||||
"INSERT INTO workspaces (id, name, workspace_type, root_path, created_at, updated_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
||||
params![
|
||||
id,
|
||||
"测试项目",
|
||||
"persistent",
|
||||
format!("/test/{}", id),
|
||||
now,
|
||||
now
|
||||
],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_publish_config() {
|
||||
let conn = setup_test_db();
|
||||
create_test_project(&conn, "project-1");
|
||||
|
||||
let config = PublishConfigDao::create(&conn, "project-1", "xiaohongshu").unwrap();
|
||||
|
||||
assert!(!config.id.is_empty());
|
||||
assert_eq!(config.project_id, "project-1");
|
||||
assert_eq!(config.platform, "xiaohongshu");
|
||||
assert!(!config.is_configured);
|
||||
assert!(config.last_published_at.is_none());
|
||||
assert_eq!(config.publish_count, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_duplicate_platform() {
|
||||
let conn = setup_test_db();
|
||||
create_test_project(&conn, "project-1");
|
||||
|
||||
// 第一次创建成功
|
||||
PublishConfigDao::create(&conn, "project-1", "xiaohongshu").unwrap();
|
||||
|
||||
// 第二次创建同一平台应该失败(UNIQUE 约束)
|
||||
let result = PublishConfigDao::create(&conn, "project-1", "xiaohongshu");
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_publish_config() {
|
||||
let conn = setup_test_db();
|
||||
create_test_project(&conn, "project-1");
|
||||
|
||||
let created = PublishConfigDao::create(&conn, "project-1", "wechat").unwrap();
|
||||
let fetched = PublishConfigDao::get(&conn, &created.id).unwrap();
|
||||
|
||||
assert!(fetched.is_some());
|
||||
let fetched = fetched.unwrap();
|
||||
assert_eq!(fetched.id, created.id);
|
||||
assert_eq!(fetched.platform, "wechat");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_nonexistent_config() {
|
||||
let conn = setup_test_db();
|
||||
let result = PublishConfigDao::get(&conn, "nonexistent").unwrap();
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_by_platform() {
|
||||
let conn = setup_test_db();
|
||||
create_test_project(&conn, "project-1");
|
||||
|
||||
PublishConfigDao::create(&conn, "project-1", "xiaohongshu").unwrap();
|
||||
PublishConfigDao::create(&conn, "project-1", "wechat").unwrap();
|
||||
|
||||
// 获取小红书配置
|
||||
let config = PublishConfigDao::get_by_platform(&conn, "project-1", "xiaohongshu")
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(config.platform, "xiaohongshu");
|
||||
|
||||
// 获取微信配置
|
||||
let config = PublishConfigDao::get_by_platform(&conn, "project-1", "wechat")
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(config.platform, "wechat");
|
||||
|
||||
// 获取不存在的平台
|
||||
let config = PublishConfigDao::get_by_platform(&conn, "project-1", "zhihu").unwrap();
|
||||
assert!(config.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_list_publish_configs() {
|
||||
let conn = setup_test_db();
|
||||
create_test_project(&conn, "project-1");
|
||||
create_test_project(&conn, "project-2");
|
||||
|
||||
// 为 project-1 创建两个配置
|
||||
PublishConfigDao::create(&conn, "project-1", "xiaohongshu").unwrap();
|
||||
PublishConfigDao::create(&conn, "project-1", "wechat").unwrap();
|
||||
|
||||
// 为 project-2 创建一个配置
|
||||
PublishConfigDao::create(&conn, "project-2", "zhihu").unwrap();
|
||||
|
||||
// 验证 project-1 有 2 个配置
|
||||
let configs = PublishConfigDao::list(&conn, "project-1").unwrap();
|
||||
assert_eq!(configs.len(), 2);
|
||||
|
||||
// 验证 project-2 有 1 个配置
|
||||
let configs = PublishConfigDao::list(&conn, "project-2").unwrap();
|
||||
assert_eq!(configs.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_publish_config() {
|
||||
let conn = setup_test_db();
|
||||
create_test_project(&conn, "project-1");
|
||||
|
||||
let created = PublishConfigDao::create(&conn, "project-1", "xiaohongshu").unwrap();
|
||||
assert!(!created.is_configured);
|
||||
|
||||
// 更新配置
|
||||
let updated = PublishConfigDao::update(
|
||||
&conn,
|
||||
&created.id,
|
||||
true,
|
||||
Some("encrypted_credentials_here".to_string()),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(updated.is_configured);
|
||||
assert!(updated.updated_at >= created.updated_at);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_nonexistent_config() {
|
||||
let conn = setup_test_db();
|
||||
let result = PublishConfigDao::update(&conn, "nonexistent", true, None);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_record_publish() {
|
||||
let conn = setup_test_db();
|
||||
create_test_project(&conn, "project-1");
|
||||
|
||||
let created = PublishConfigDao::create(&conn, "project-1", "xiaohongshu").unwrap();
|
||||
assert_eq!(created.publish_count, 0);
|
||||
assert!(created.last_published_at.is_none());
|
||||
|
||||
// 记录第一次发布
|
||||
let updated = PublishConfigDao::record_publish(&conn, &created.id).unwrap();
|
||||
assert_eq!(updated.publish_count, 1);
|
||||
assert!(updated.last_published_at.is_some());
|
||||
|
||||
// 记录第二次发布
|
||||
let updated = PublishConfigDao::record_publish(&conn, &created.id).unwrap();
|
||||
assert_eq!(updated.publish_count, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_delete_publish_config() {
|
||||
let conn = setup_test_db();
|
||||
create_test_project(&conn, "project-1");
|
||||
|
||||
let created = PublishConfigDao::create(&conn, "project-1", "xiaohongshu").unwrap();
|
||||
|
||||
// 验证配置存在
|
||||
assert!(PublishConfigDao::get(&conn, &created.id).unwrap().is_some());
|
||||
|
||||
// 删除配置
|
||||
PublishConfigDao::delete(&conn, &created.id).unwrap();
|
||||
|
||||
// 验证配置已删除
|
||||
assert!(PublishConfigDao::get(&conn, &created.id).unwrap().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_delete_nonexistent_config() {
|
||||
let conn = setup_test_db();
|
||||
let result = PublishConfigDao::delete(&conn, "nonexistent");
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_delete_by_project() {
|
||||
let conn = setup_test_db();
|
||||
create_test_project(&conn, "project-1");
|
||||
create_test_project(&conn, "project-2");
|
||||
|
||||
// 为 project-1 创建 2 个配置
|
||||
PublishConfigDao::create(&conn, "project-1", "xiaohongshu").unwrap();
|
||||
PublishConfigDao::create(&conn, "project-1", "wechat").unwrap();
|
||||
|
||||
// 为 project-2 创建 1 个配置
|
||||
PublishConfigDao::create(&conn, "project-2", "zhihu").unwrap();
|
||||
|
||||
// 删除 project-1 的所有配置
|
||||
let deleted_count = PublishConfigDao::delete_by_project(&conn, "project-1").unwrap();
|
||||
|
||||
// 验证删除了 2 个配置
|
||||
assert_eq!(deleted_count, 2);
|
||||
|
||||
// 验证 project-1 没有配置了
|
||||
let configs = PublishConfigDao::list(&conn, "project-1").unwrap();
|
||||
assert_eq!(configs.len(), 0);
|
||||
|
||||
// 验证 project-2 的配置未受影响
|
||||
let configs = PublishConfigDao::list(&conn, "project-2").unwrap();
|
||||
assert_eq!(configs.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_count_publish_configs() {
|
||||
let conn = setup_test_db();
|
||||
create_test_project(&conn, "project-1");
|
||||
|
||||
// 初始数量为 0
|
||||
let count = PublishConfigDao::count(&conn, "project-1").unwrap();
|
||||
assert_eq!(count, 0);
|
||||
|
||||
// 创建 3 个配置
|
||||
PublishConfigDao::create(&conn, "project-1", "xiaohongshu").unwrap();
|
||||
PublishConfigDao::create(&conn, "project-1", "wechat").unwrap();
|
||||
PublishConfigDao::create(&conn, "project-1", "zhihu").unwrap();
|
||||
|
||||
// 验证数量为 3
|
||||
let count = PublishConfigDao::count(&conn, "project-1").unwrap();
|
||||
assert_eq!(count, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_or_create() {
|
||||
let conn = setup_test_db();
|
||||
create_test_project(&conn, "project-1");
|
||||
|
||||
// 第一次调用应该创建
|
||||
let config1 = PublishConfigDao::get_or_create(&conn, "project-1", "xiaohongshu").unwrap();
|
||||
assert_eq!(config1.platform, "xiaohongshu");
|
||||
|
||||
// 第二次调用应该返回现有配置
|
||||
let config2 = PublishConfigDao::get_or_create(&conn, "project-1", "xiaohongshu").unwrap();
|
||||
assert_eq!(config1.id, config2.id);
|
||||
|
||||
// 验证只有一个配置
|
||||
let count = PublishConfigDao::count(&conn, "project-1").unwrap();
|
||||
assert_eq!(count, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_credentials() {
|
||||
let conn = setup_test_db();
|
||||
create_test_project(&conn, "project-1");
|
||||
|
||||
let created = PublishConfigDao::create(&conn, "project-1", "xiaohongshu").unwrap();
|
||||
|
||||
// 初始没有凭证
|
||||
let credentials = PublishConfigDao::get_credentials(&conn, &created.id).unwrap();
|
||||
assert!(credentials.is_none());
|
||||
|
||||
// 更新凭证
|
||||
PublishConfigDao::update(
|
||||
&conn,
|
||||
&created.id,
|
||||
true,
|
||||
Some("encrypted_secret".to_string()),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// 获取凭证
|
||||
let credentials = PublishConfigDao::get_credentials(&conn, &created.id).unwrap();
|
||||
assert_eq!(credentials, Some("encrypted_secret".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_project_scoped_query_correctness() {
|
||||
// Property 2: Project-Scoped Query Correctness
|
||||
// 验证按 project_id 筛选的查询只返回属于该项目的配置
|
||||
let conn = setup_test_db();
|
||||
create_test_project(&conn, "project-a");
|
||||
create_test_project(&conn, "project-b");
|
||||
|
||||
// 为两个项目创建配置
|
||||
PublishConfigDao::create(&conn, "project-a", "xiaohongshu").unwrap();
|
||||
PublishConfigDao::create(&conn, "project-a", "wechat").unwrap();
|
||||
PublishConfigDao::create(&conn, "project-a", "zhihu").unwrap();
|
||||
|
||||
PublishConfigDao::create(&conn, "project-b", "weibo").unwrap();
|
||||
PublishConfigDao::create(&conn, "project-b", "douyin").unwrap();
|
||||
|
||||
// 查询 project-a 的配置
|
||||
let configs_a = PublishConfigDao::list(&conn, "project-a").unwrap();
|
||||
assert_eq!(configs_a.len(), 3);
|
||||
for c in &configs_a {
|
||||
assert_eq!(c.project_id, "project-a");
|
||||
}
|
||||
|
||||
// 查询 project-b 的配置
|
||||
let configs_b = PublishConfigDao::list(&conn, "project-b").unwrap();
|
||||
assert_eq!(configs_b.len(), 2);
|
||||
for c in &configs_b {
|
||||
assert_eq!(c.project_id, "project-b");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_platform_uniqueness_per_project() {
|
||||
// 验证每个项目的每个平台只能有一个配置
|
||||
let conn = setup_test_db();
|
||||
create_test_project(&conn, "project-1");
|
||||
create_test_project(&conn, "project-2");
|
||||
|
||||
// project-1 创建小红书配置
|
||||
PublishConfigDao::create(&conn, "project-1", "xiaohongshu").unwrap();
|
||||
|
||||
// project-2 也可以创建小红书配置(不同项目)
|
||||
PublishConfigDao::create(&conn, "project-2", "xiaohongshu").unwrap();
|
||||
|
||||
// project-1 再次创建小红书配置应该失败
|
||||
let result = PublishConfigDao::create(&conn, "project-1", "xiaohongshu");
|
||||
assert!(result.is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,940 @@
|
||||
//! 排版模板数据访问层
|
||||
//!
|
||||
//! 提供排版模板(Template)的 CRUD 操作,包括:
|
||||
//! - 创建、获取、列表、更新、删除模板
|
||||
//! - 设置项目默认模板
|
||||
//!
|
||||
//! ## 相关需求
|
||||
//! - Requirements 8.1: 模板列表显示
|
||||
//! - Requirements 8.3: 模板创建
|
||||
//! - Requirements 8.4: 设置默认模板
|
||||
|
||||
use rusqlite::{params, Connection};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::errors::project_error::TemplateError;
|
||||
use crate::models::project_model::{CreateTemplateRequest, Template, TemplateUpdate};
|
||||
|
||||
// ============================================================================
|
||||
// 数据访问对象
|
||||
// ============================================================================
|
||||
|
||||
/// 排版模板 DAO
|
||||
///
|
||||
/// 提供排版模板的数据库操作方法。
|
||||
pub struct TemplateDao;
|
||||
|
||||
impl TemplateDao {
|
||||
// ------------------------------------------------------------------------
|
||||
// 创建模板
|
||||
// ------------------------------------------------------------------------
|
||||
|
||||
/// 创建新模板
|
||||
///
|
||||
/// # 参数
|
||||
/// - `conn`: 数据库连接
|
||||
/// - `req`: 创建模板请求
|
||||
///
|
||||
/// # 返回
|
||||
/// - 成功返回创建的模板
|
||||
/// - 失败返回 TemplateError
|
||||
pub fn create(
|
||||
conn: &Connection,
|
||||
req: &CreateTemplateRequest,
|
||||
) -> Result<Template, TemplateError> {
|
||||
let id = Uuid::new_v4().to_string();
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
|
||||
// 使用默认值处理可选字段
|
||||
let emoji_usage = req.emoji_usage.as_deref().unwrap_or("moderate");
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO templates (
|
||||
id, project_id, name, platform, title_style, paragraph_style,
|
||||
ending_style, emoji_usage, hashtag_rules, image_rules,
|
||||
is_default, created_at, updated_at
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
|
||||
params![
|
||||
id,
|
||||
req.project_id,
|
||||
req.name,
|
||||
req.platform,
|
||||
req.title_style,
|
||||
req.paragraph_style,
|
||||
req.ending_style,
|
||||
emoji_usage,
|
||||
req.hashtag_rules,
|
||||
req.image_rules,
|
||||
0, // is_default
|
||||
now,
|
||||
now,
|
||||
],
|
||||
)?;
|
||||
|
||||
// 返回创建的模板
|
||||
Ok(Template {
|
||||
id,
|
||||
project_id: req.project_id.clone(),
|
||||
name: req.name.clone(),
|
||||
platform: req.platform.clone(),
|
||||
title_style: req.title_style.clone(),
|
||||
paragraph_style: req.paragraph_style.clone(),
|
||||
ending_style: req.ending_style.clone(),
|
||||
emoji_usage: emoji_usage.to_string(),
|
||||
hashtag_rules: req.hashtag_rules.clone(),
|
||||
image_rules: req.image_rules.clone(),
|
||||
is_default: false,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
})
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
// 获取模板
|
||||
// ------------------------------------------------------------------------
|
||||
|
||||
/// 获取单个模板
|
||||
///
|
||||
/// # 参数
|
||||
/// - `conn`: 数据库连接
|
||||
/// - `id`: 模板 ID
|
||||
///
|
||||
/// # 返回
|
||||
/// - 成功返回 Option<Template>
|
||||
/// - 失败返回 TemplateError
|
||||
pub fn get(conn: &Connection, id: &str) -> Result<Option<Template>, TemplateError> {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, project_id, name, platform, title_style, paragraph_style,
|
||||
ending_style, emoji_usage, hashtag_rules, image_rules,
|
||||
is_default, created_at, updated_at
|
||||
FROM templates WHERE id = ?",
|
||||
)?;
|
||||
|
||||
let mut rows = stmt.query([id])?;
|
||||
|
||||
if let Some(row) = rows.next()? {
|
||||
Ok(Some(Self::map_row(row)?))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
// 列表模板
|
||||
// ------------------------------------------------------------------------
|
||||
|
||||
/// 获取项目的模板列表
|
||||
///
|
||||
/// # 参数
|
||||
/// - `conn`: 数据库连接
|
||||
/// - `project_id`: 项目 ID
|
||||
///
|
||||
/// # 返回
|
||||
/// - 成功返回模板列表
|
||||
/// - 失败返回 TemplateError
|
||||
pub fn list(conn: &Connection, project_id: &str) -> Result<Vec<Template>, TemplateError> {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, project_id, name, platform, title_style, paragraph_style,
|
||||
ending_style, emoji_usage, hashtag_rules, image_rules,
|
||||
is_default, created_at, updated_at
|
||||
FROM templates WHERE project_id = ? ORDER BY created_at DESC",
|
||||
)?;
|
||||
|
||||
let templates: Vec<Template> = stmt
|
||||
.query_map([project_id], |row| Self::map_row(row))?
|
||||
.filter_map(|r| r.ok())
|
||||
.collect();
|
||||
|
||||
Ok(templates)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
// 更新模板
|
||||
// ------------------------------------------------------------------------
|
||||
|
||||
/// 更新模板
|
||||
///
|
||||
/// # 参数
|
||||
/// - `conn`: 数据库连接
|
||||
/// - `id`: 模板 ID
|
||||
/// - `update`: 更新内容
|
||||
///
|
||||
/// # 返回
|
||||
/// - 成功返回更新后的模板
|
||||
/// - 失败返回 TemplateError
|
||||
pub fn update(
|
||||
conn: &Connection,
|
||||
id: &str,
|
||||
update: &TemplateUpdate,
|
||||
) -> Result<Template, TemplateError> {
|
||||
// 先获取现有模板
|
||||
let existing =
|
||||
Self::get(conn, id)?.ok_or_else(|| TemplateError::NotFound(id.to_string()))?;
|
||||
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
|
||||
// 构建更新后的值
|
||||
let name = update.name.as_ref().unwrap_or(&existing.name);
|
||||
let title_style = update.title_style.clone().or(existing.title_style);
|
||||
let paragraph_style = update.paragraph_style.clone().or(existing.paragraph_style);
|
||||
let ending_style = update.ending_style.clone().or(existing.ending_style);
|
||||
let emoji_usage = update.emoji_usage.as_ref().unwrap_or(&existing.emoji_usage);
|
||||
let hashtag_rules = update.hashtag_rules.clone().or(existing.hashtag_rules);
|
||||
let image_rules = update.image_rules.clone().or(existing.image_rules);
|
||||
|
||||
conn.execute(
|
||||
"UPDATE templates SET
|
||||
name = ?1, title_style = ?2, paragraph_style = ?3, ending_style = ?4,
|
||||
emoji_usage = ?5, hashtag_rules = ?6, image_rules = ?7, updated_at = ?8
|
||||
WHERE id = ?9",
|
||||
params![
|
||||
name,
|
||||
title_style,
|
||||
paragraph_style,
|
||||
ending_style,
|
||||
emoji_usage,
|
||||
hashtag_rules,
|
||||
image_rules,
|
||||
now,
|
||||
id,
|
||||
],
|
||||
)?;
|
||||
|
||||
// 返回更新后的模板
|
||||
Self::get(conn, id)?.ok_or_else(|| TemplateError::NotFound(id.to_string()))
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
// 删除模板
|
||||
// ------------------------------------------------------------------------
|
||||
|
||||
/// 删除模板
|
||||
///
|
||||
/// # 参数
|
||||
/// - `conn`: 数据库连接
|
||||
/// - `id`: 模板 ID
|
||||
///
|
||||
/// # 返回
|
||||
/// - 成功返回 ()
|
||||
/// - 失败返回 TemplateError
|
||||
pub fn delete(conn: &Connection, id: &str) -> Result<(), TemplateError> {
|
||||
let rows = conn.execute("DELETE FROM templates WHERE id = ?", [id])?;
|
||||
|
||||
if rows == 0 {
|
||||
return Err(TemplateError::NotFound(id.to_string()));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
// 设置默认模板
|
||||
// ------------------------------------------------------------------------
|
||||
|
||||
/// 设置项目的默认模板
|
||||
///
|
||||
/// 将指定模板设为默认,同时取消该项目其他模板的默认状态。
|
||||
/// 这确保每个项目最多只有一个默认模板。
|
||||
///
|
||||
/// # 参数
|
||||
/// - `conn`: 数据库连接
|
||||
/// - `project_id`: 项目 ID
|
||||
/// - `template_id`: 要设为默认的模板 ID
|
||||
///
|
||||
/// # 返回
|
||||
/// - 成功返回 ()
|
||||
/// - 失败返回 TemplateError
|
||||
pub fn set_default(
|
||||
conn: &Connection,
|
||||
project_id: &str,
|
||||
template_id: &str,
|
||||
) -> Result<(), TemplateError> {
|
||||
// 验证模板存在且属于该项目
|
||||
let template = Self::get(conn, template_id)?
|
||||
.ok_or_else(|| TemplateError::NotFound(template_id.to_string()))?;
|
||||
|
||||
if template.project_id != project_id {
|
||||
return Err(TemplateError::ProjectNotFound(project_id.to_string()));
|
||||
}
|
||||
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
|
||||
// 先取消该项目所有模板的默认状态
|
||||
conn.execute(
|
||||
"UPDATE templates SET is_default = 0, updated_at = ?1 WHERE project_id = ?2",
|
||||
params![now, project_id],
|
||||
)?;
|
||||
|
||||
// 设置指定模板为默认
|
||||
conn.execute(
|
||||
"UPDATE templates SET is_default = 1, updated_at = ?1 WHERE id = ?2",
|
||||
params![now, template_id],
|
||||
)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 获取项目的默认模板
|
||||
///
|
||||
/// # 参数
|
||||
/// - `conn`: 数据库连接
|
||||
/// - `project_id`: 项目 ID
|
||||
///
|
||||
/// # 返回
|
||||
/// - 成功返回 Option<Template>
|
||||
/// - 失败返回 TemplateError
|
||||
pub fn get_default(
|
||||
conn: &Connection,
|
||||
project_id: &str,
|
||||
) -> Result<Option<Template>, TemplateError> {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, project_id, name, platform, title_style, paragraph_style,
|
||||
ending_style, emoji_usage, hashtag_rules, image_rules,
|
||||
is_default, created_at, updated_at
|
||||
FROM templates WHERE project_id = ? AND is_default = 1",
|
||||
)?;
|
||||
|
||||
let mut rows = stmt.query([project_id])?;
|
||||
|
||||
if let Some(row) = rows.next()? {
|
||||
Ok(Some(Self::map_row(row)?))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
// 批量操作
|
||||
// ------------------------------------------------------------------------
|
||||
|
||||
/// 获取项目的模板数量
|
||||
///
|
||||
/// # 参数
|
||||
/// - `conn`: 数据库连接
|
||||
/// - `project_id`: 项目 ID
|
||||
///
|
||||
/// # 返回
|
||||
/// - 成功返回模板数量
|
||||
/// - 失败返回 TemplateError
|
||||
pub fn count(conn: &Connection, project_id: &str) -> Result<i64, TemplateError> {
|
||||
let count: i64 = conn.query_row(
|
||||
"SELECT COUNT(*) FROM templates WHERE project_id = ?",
|
||||
[project_id],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
/// 删除项目的所有模板
|
||||
///
|
||||
/// # 参数
|
||||
/// - `conn`: 数据库连接
|
||||
/// - `project_id`: 项目 ID
|
||||
///
|
||||
/// # 返回
|
||||
/// - 成功返回删除的数量
|
||||
/// - 失败返回 TemplateError
|
||||
pub fn delete_by_project(conn: &Connection, project_id: &str) -> Result<usize, TemplateError> {
|
||||
let rows = conn.execute("DELETE FROM templates WHERE project_id = ?", [project_id])?;
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
// 辅助方法
|
||||
// ------------------------------------------------------------------------
|
||||
|
||||
/// 映射数据库行到 Template 结构体
|
||||
fn map_row(row: &rusqlite::Row) -> Result<Template, rusqlite::Error> {
|
||||
Ok(Template {
|
||||
id: row.get(0)?,
|
||||
project_id: row.get(1)?,
|
||||
name: row.get(2)?,
|
||||
platform: row.get(3)?,
|
||||
title_style: row.get(4)?,
|
||||
paragraph_style: row.get(5)?,
|
||||
ending_style: row.get(6)?,
|
||||
emoji_usage: row.get(7)?,
|
||||
hashtag_rules: row.get(8)?,
|
||||
image_rules: row.get(9)?,
|
||||
is_default: row.get::<_, i32>(10)? != 0,
|
||||
created_at: row.get(11)?,
|
||||
updated_at: row.get(12)?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 测试
|
||||
// ============================================================================
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::database::schema::create_tables;
|
||||
|
||||
/// 创建测试数据库连接
|
||||
fn setup_test_db() -> Connection {
|
||||
let conn = Connection::open_in_memory().unwrap();
|
||||
create_tables(&conn).unwrap();
|
||||
conn
|
||||
}
|
||||
|
||||
/// 创建测试项目
|
||||
fn create_test_project(conn: &Connection, id: &str) {
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
conn.execute(
|
||||
"INSERT INTO workspaces (id, name, workspace_type, root_path, created_at, updated_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
||||
params![
|
||||
id,
|
||||
"测试项目",
|
||||
"persistent",
|
||||
format!("/test/{}", id),
|
||||
now,
|
||||
now
|
||||
],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_template() {
|
||||
let conn = setup_test_db();
|
||||
create_test_project(&conn, "project-1");
|
||||
|
||||
let req = CreateTemplateRequest {
|
||||
project_id: "project-1".to_string(),
|
||||
name: "小红书模板".to_string(),
|
||||
platform: "xiaohongshu".to_string(),
|
||||
title_style: Some("吸引眼球".to_string()),
|
||||
paragraph_style: Some("简短有力".to_string()),
|
||||
ending_style: Some("引导互动".to_string()),
|
||||
emoji_usage: Some("heavy".to_string()),
|
||||
hashtag_rules: Some("3-5个相关话题".to_string()),
|
||||
image_rules: Some("配图要精美".to_string()),
|
||||
};
|
||||
|
||||
let template = TemplateDao::create(&conn, &req).unwrap();
|
||||
|
||||
assert!(!template.id.is_empty());
|
||||
assert_eq!(template.project_id, "project-1");
|
||||
assert_eq!(template.name, "小红书模板");
|
||||
assert_eq!(template.platform, "xiaohongshu");
|
||||
assert_eq!(template.emoji_usage, "heavy");
|
||||
assert!(!template.is_default);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_template_minimal() {
|
||||
let conn = setup_test_db();
|
||||
create_test_project(&conn, "project-1");
|
||||
|
||||
let req = CreateTemplateRequest {
|
||||
project_id: "project-1".to_string(),
|
||||
name: "简单模板".to_string(),
|
||||
platform: "markdown".to_string(),
|
||||
title_style: None,
|
||||
paragraph_style: None,
|
||||
ending_style: None,
|
||||
emoji_usage: None,
|
||||
hashtag_rules: None,
|
||||
image_rules: None,
|
||||
};
|
||||
|
||||
let template = TemplateDao::create(&conn, &req).unwrap();
|
||||
|
||||
assert!(!template.id.is_empty());
|
||||
assert_eq!(template.name, "简单模板");
|
||||
assert_eq!(template.platform, "markdown");
|
||||
// 默认值
|
||||
assert_eq!(template.emoji_usage, "moderate");
|
||||
assert!(template.title_style.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_template() {
|
||||
let conn = setup_test_db();
|
||||
create_test_project(&conn, "project-1");
|
||||
|
||||
let req = CreateTemplateRequest {
|
||||
project_id: "project-1".to_string(),
|
||||
name: "测试模板".to_string(),
|
||||
platform: "wechat".to_string(),
|
||||
title_style: Some("正式".to_string()),
|
||||
paragraph_style: None,
|
||||
ending_style: None,
|
||||
emoji_usage: Some("minimal".to_string()),
|
||||
hashtag_rules: None,
|
||||
image_rules: None,
|
||||
};
|
||||
|
||||
let created = TemplateDao::create(&conn, &req).unwrap();
|
||||
let fetched = TemplateDao::get(&conn, &created.id).unwrap();
|
||||
|
||||
assert!(fetched.is_some());
|
||||
let fetched = fetched.unwrap();
|
||||
assert_eq!(fetched.id, created.id);
|
||||
assert_eq!(fetched.name, "测试模板");
|
||||
assert_eq!(fetched.platform, "wechat");
|
||||
assert_eq!(fetched.emoji_usage, "minimal");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_nonexistent_template() {
|
||||
let conn = setup_test_db();
|
||||
let result = TemplateDao::get(&conn, "nonexistent").unwrap();
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_list_templates() {
|
||||
let conn = setup_test_db();
|
||||
create_test_project(&conn, "project-1");
|
||||
create_test_project(&conn, "project-2");
|
||||
|
||||
// 为 project-1 创建两个模板
|
||||
for i in 1..=2 {
|
||||
let req = CreateTemplateRequest {
|
||||
project_id: "project-1".to_string(),
|
||||
name: format!("模板{}", i),
|
||||
platform: "xiaohongshu".to_string(),
|
||||
title_style: None,
|
||||
paragraph_style: None,
|
||||
ending_style: None,
|
||||
emoji_usage: None,
|
||||
hashtag_rules: None,
|
||||
image_rules: None,
|
||||
};
|
||||
TemplateDao::create(&conn, &req).unwrap();
|
||||
}
|
||||
|
||||
// 为 project-2 创建一个模板
|
||||
let req = CreateTemplateRequest {
|
||||
project_id: "project-2".to_string(),
|
||||
name: "模板3".to_string(),
|
||||
platform: "wechat".to_string(),
|
||||
title_style: None,
|
||||
paragraph_style: None,
|
||||
ending_style: None,
|
||||
emoji_usage: None,
|
||||
hashtag_rules: None,
|
||||
image_rules: None,
|
||||
};
|
||||
TemplateDao::create(&conn, &req).unwrap();
|
||||
|
||||
// 验证 project-1 有 2 个模板
|
||||
let templates = TemplateDao::list(&conn, "project-1").unwrap();
|
||||
assert_eq!(templates.len(), 2);
|
||||
|
||||
// 验证 project-2 有 1 个模板
|
||||
let templates = TemplateDao::list(&conn, "project-2").unwrap();
|
||||
assert_eq!(templates.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_template() {
|
||||
let conn = setup_test_db();
|
||||
create_test_project(&conn, "project-1");
|
||||
|
||||
let req = CreateTemplateRequest {
|
||||
project_id: "project-1".to_string(),
|
||||
name: "原始名称".to_string(),
|
||||
platform: "xiaohongshu".to_string(),
|
||||
title_style: Some("原始标题风格".to_string()),
|
||||
paragraph_style: None,
|
||||
ending_style: None,
|
||||
emoji_usage: Some("moderate".to_string()),
|
||||
hashtag_rules: None,
|
||||
image_rules: None,
|
||||
};
|
||||
|
||||
let created = TemplateDao::create(&conn, &req).unwrap();
|
||||
|
||||
let update = TemplateUpdate {
|
||||
name: Some("更新后名称".to_string()),
|
||||
title_style: Some("更新后标题风格".to_string()),
|
||||
paragraph_style: Some("新段落风格".to_string()),
|
||||
ending_style: None,
|
||||
emoji_usage: Some("heavy".to_string()),
|
||||
hashtag_rules: Some("5个话题".to_string()),
|
||||
image_rules: None,
|
||||
};
|
||||
|
||||
let updated = TemplateDao::update(&conn, &created.id, &update).unwrap();
|
||||
|
||||
assert_eq!(updated.name, "更新后名称");
|
||||
assert_eq!(updated.title_style, Some("更新后标题风格".to_string()));
|
||||
assert_eq!(updated.paragraph_style, Some("新段落风格".to_string()));
|
||||
assert_eq!(updated.emoji_usage, "heavy");
|
||||
assert_eq!(updated.hashtag_rules, Some("5个话题".to_string()));
|
||||
// 验证平台未变
|
||||
assert_eq!(updated.platform, "xiaohongshu");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_template_partial() {
|
||||
let conn = setup_test_db();
|
||||
create_test_project(&conn, "project-1");
|
||||
|
||||
let req = CreateTemplateRequest {
|
||||
project_id: "project-1".to_string(),
|
||||
name: "原始名称".to_string(),
|
||||
platform: "wechat".to_string(),
|
||||
title_style: Some("原始标题".to_string()),
|
||||
paragraph_style: Some("原始段落".to_string()),
|
||||
ending_style: None,
|
||||
emoji_usage: Some("moderate".to_string()),
|
||||
hashtag_rules: None,
|
||||
image_rules: None,
|
||||
};
|
||||
|
||||
let created = TemplateDao::create(&conn, &req).unwrap();
|
||||
|
||||
// 只更新名称
|
||||
let update = TemplateUpdate {
|
||||
name: Some("新名称".to_string()),
|
||||
title_style: None,
|
||||
paragraph_style: None,
|
||||
ending_style: None,
|
||||
emoji_usage: None,
|
||||
hashtag_rules: None,
|
||||
image_rules: None,
|
||||
};
|
||||
|
||||
let updated = TemplateDao::update(&conn, &created.id, &update).unwrap();
|
||||
|
||||
assert_eq!(updated.name, "新名称");
|
||||
// 其他字段保持不变
|
||||
assert_eq!(updated.title_style, Some("原始标题".to_string()));
|
||||
assert_eq!(updated.paragraph_style, Some("原始段落".to_string()));
|
||||
assert_eq!(updated.emoji_usage, "moderate");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_nonexistent_template() {
|
||||
let conn = setup_test_db();
|
||||
let update = TemplateUpdate::default();
|
||||
let result = TemplateDao::update(&conn, "nonexistent", &update);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_delete_template() {
|
||||
let conn = setup_test_db();
|
||||
create_test_project(&conn, "project-1");
|
||||
|
||||
let req = CreateTemplateRequest {
|
||||
project_id: "project-1".to_string(),
|
||||
name: "待删除模板".to_string(),
|
||||
platform: "markdown".to_string(),
|
||||
title_style: None,
|
||||
paragraph_style: None,
|
||||
ending_style: None,
|
||||
emoji_usage: None,
|
||||
hashtag_rules: None,
|
||||
image_rules: None,
|
||||
};
|
||||
|
||||
let created = TemplateDao::create(&conn, &req).unwrap();
|
||||
|
||||
// 验证模板存在
|
||||
assert!(TemplateDao::get(&conn, &created.id).unwrap().is_some());
|
||||
|
||||
// 删除模板
|
||||
TemplateDao::delete(&conn, &created.id).unwrap();
|
||||
|
||||
// 验证模板已删除
|
||||
assert!(TemplateDao::get(&conn, &created.id).unwrap().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_delete_nonexistent_template() {
|
||||
let conn = setup_test_db();
|
||||
let result = TemplateDao::delete(&conn, "nonexistent");
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_set_default_template() {
|
||||
let conn = setup_test_db();
|
||||
create_test_project(&conn, "project-1");
|
||||
|
||||
// 创建两个模板
|
||||
let req1 = CreateTemplateRequest {
|
||||
project_id: "project-1".to_string(),
|
||||
name: "模板1".to_string(),
|
||||
platform: "xiaohongshu".to_string(),
|
||||
title_style: None,
|
||||
paragraph_style: None,
|
||||
ending_style: None,
|
||||
emoji_usage: None,
|
||||
hashtag_rules: None,
|
||||
image_rules: None,
|
||||
};
|
||||
let template1 = TemplateDao::create(&conn, &req1).unwrap();
|
||||
|
||||
let req2 = CreateTemplateRequest {
|
||||
project_id: "project-1".to_string(),
|
||||
name: "模板2".to_string(),
|
||||
platform: "wechat".to_string(),
|
||||
title_style: None,
|
||||
paragraph_style: None,
|
||||
ending_style: None,
|
||||
emoji_usage: None,
|
||||
hashtag_rules: None,
|
||||
image_rules: None,
|
||||
};
|
||||
let template2 = TemplateDao::create(&conn, &req2).unwrap();
|
||||
|
||||
// 设置模板1为默认
|
||||
TemplateDao::set_default(&conn, "project-1", &template1.id).unwrap();
|
||||
|
||||
let t1 = TemplateDao::get(&conn, &template1.id).unwrap().unwrap();
|
||||
let t2 = TemplateDao::get(&conn, &template2.id).unwrap().unwrap();
|
||||
assert!(t1.is_default);
|
||||
assert!(!t2.is_default);
|
||||
|
||||
// 设置模板2为默认,模板1应该不再是默认
|
||||
TemplateDao::set_default(&conn, "project-1", &template2.id).unwrap();
|
||||
|
||||
let t1 = TemplateDao::get(&conn, &template1.id).unwrap().unwrap();
|
||||
let t2 = TemplateDao::get(&conn, &template2.id).unwrap().unwrap();
|
||||
assert!(!t1.is_default);
|
||||
assert!(t2.is_default);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_default_template() {
|
||||
let conn = setup_test_db();
|
||||
create_test_project(&conn, "project-1");
|
||||
|
||||
// 初始没有默认模板
|
||||
let default = TemplateDao::get_default(&conn, "project-1").unwrap();
|
||||
assert!(default.is_none());
|
||||
|
||||
// 创建模板并设为默认
|
||||
let req = CreateTemplateRequest {
|
||||
project_id: "project-1".to_string(),
|
||||
name: "默认模板".to_string(),
|
||||
platform: "xiaohongshu".to_string(),
|
||||
title_style: None,
|
||||
paragraph_style: None,
|
||||
ending_style: None,
|
||||
emoji_usage: None,
|
||||
hashtag_rules: None,
|
||||
image_rules: None,
|
||||
};
|
||||
let template = TemplateDao::create(&conn, &req).unwrap();
|
||||
TemplateDao::set_default(&conn, "project-1", &template.id).unwrap();
|
||||
|
||||
// 验证可以获取默认模板
|
||||
let default = TemplateDao::get_default(&conn, "project-1").unwrap();
|
||||
assert!(default.is_some());
|
||||
assert_eq!(default.unwrap().id, template.id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_set_default_wrong_project() {
|
||||
let conn = setup_test_db();
|
||||
create_test_project(&conn, "project-1");
|
||||
create_test_project(&conn, "project-2");
|
||||
|
||||
// 在 project-1 创建模板
|
||||
let req = CreateTemplateRequest {
|
||||
project_id: "project-1".to_string(),
|
||||
name: "模板".to_string(),
|
||||
platform: "markdown".to_string(),
|
||||
title_style: None,
|
||||
paragraph_style: None,
|
||||
ending_style: None,
|
||||
emoji_usage: None,
|
||||
hashtag_rules: None,
|
||||
image_rules: None,
|
||||
};
|
||||
let template = TemplateDao::create(&conn, &req).unwrap();
|
||||
|
||||
// 尝试在 project-2 设置该模板为默认,应该失败
|
||||
let result = TemplateDao::set_default(&conn, "project-2", &template.id);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_count_templates() {
|
||||
let conn = setup_test_db();
|
||||
create_test_project(&conn, "project-1");
|
||||
|
||||
// 初始数量为 0
|
||||
let count = TemplateDao::count(&conn, "project-1").unwrap();
|
||||
assert_eq!(count, 0);
|
||||
|
||||
// 创建 3 个模板
|
||||
for i in 1..=3 {
|
||||
let req = CreateTemplateRequest {
|
||||
project_id: "project-1".to_string(),
|
||||
name: format!("模板{}", i),
|
||||
platform: "markdown".to_string(),
|
||||
title_style: None,
|
||||
paragraph_style: None,
|
||||
ending_style: None,
|
||||
emoji_usage: None,
|
||||
hashtag_rules: None,
|
||||
image_rules: None,
|
||||
};
|
||||
TemplateDao::create(&conn, &req).unwrap();
|
||||
}
|
||||
|
||||
// 验证数量为 3
|
||||
let count = TemplateDao::count(&conn, "project-1").unwrap();
|
||||
assert_eq!(count, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_delete_by_project() {
|
||||
let conn = setup_test_db();
|
||||
create_test_project(&conn, "project-1");
|
||||
create_test_project(&conn, "project-2");
|
||||
|
||||
// 为 project-1 创建 2 个模板
|
||||
for i in 1..=2 {
|
||||
let req = CreateTemplateRequest {
|
||||
project_id: "project-1".to_string(),
|
||||
name: format!("模板{}", i),
|
||||
platform: "xiaohongshu".to_string(),
|
||||
title_style: None,
|
||||
paragraph_style: None,
|
||||
ending_style: None,
|
||||
emoji_usage: None,
|
||||
hashtag_rules: None,
|
||||
image_rules: None,
|
||||
};
|
||||
TemplateDao::create(&conn, &req).unwrap();
|
||||
}
|
||||
|
||||
// 为 project-2 创建 1 个模板
|
||||
let req = CreateTemplateRequest {
|
||||
project_id: "project-2".to_string(),
|
||||
name: "模板3".to_string(),
|
||||
platform: "wechat".to_string(),
|
||||
title_style: None,
|
||||
paragraph_style: None,
|
||||
ending_style: None,
|
||||
emoji_usage: None,
|
||||
hashtag_rules: None,
|
||||
image_rules: None,
|
||||
};
|
||||
TemplateDao::create(&conn, &req).unwrap();
|
||||
|
||||
// 删除 project-1 的所有模板
|
||||
let deleted_count = TemplateDao::delete_by_project(&conn, "project-1").unwrap();
|
||||
|
||||
// 验证删除了 2 个模板
|
||||
assert_eq!(deleted_count, 2);
|
||||
|
||||
// 验证 project-1 没有模板了
|
||||
let templates = TemplateDao::list(&conn, "project-1").unwrap();
|
||||
assert_eq!(templates.len(), 0);
|
||||
|
||||
// 验证 project-2 的模板未受影响
|
||||
let templates = TemplateDao::list(&conn, "project-2").unwrap();
|
||||
assert_eq!(templates.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_project_scoped_query_correctness() {
|
||||
// Property 2: Project-Scoped Query Correctness
|
||||
// 验证按 project_id 筛选的查询只返回属于该项目的模板
|
||||
let conn = setup_test_db();
|
||||
create_test_project(&conn, "project-a");
|
||||
create_test_project(&conn, "project-b");
|
||||
|
||||
// 为两个项目创建模板
|
||||
for i in 1..=3 {
|
||||
let req = CreateTemplateRequest {
|
||||
project_id: "project-a".to_string(),
|
||||
name: format!("A模板{}", i),
|
||||
platform: "xiaohongshu".to_string(),
|
||||
title_style: None,
|
||||
paragraph_style: None,
|
||||
ending_style: None,
|
||||
emoji_usage: None,
|
||||
hashtag_rules: None,
|
||||
image_rules: None,
|
||||
};
|
||||
TemplateDao::create(&conn, &req).unwrap();
|
||||
}
|
||||
|
||||
for i in 1..=2 {
|
||||
let req = CreateTemplateRequest {
|
||||
project_id: "project-b".to_string(),
|
||||
name: format!("B模板{}", i),
|
||||
platform: "wechat".to_string(),
|
||||
title_style: None,
|
||||
paragraph_style: None,
|
||||
ending_style: None,
|
||||
emoji_usage: None,
|
||||
hashtag_rules: None,
|
||||
image_rules: None,
|
||||
};
|
||||
TemplateDao::create(&conn, &req).unwrap();
|
||||
}
|
||||
|
||||
// 查询 project-a 的模板
|
||||
let templates_a = TemplateDao::list(&conn, "project-a").unwrap();
|
||||
assert_eq!(templates_a.len(), 3);
|
||||
for t in &templates_a {
|
||||
assert_eq!(t.project_id, "project-a");
|
||||
}
|
||||
|
||||
// 查询 project-b 的模板
|
||||
let templates_b = TemplateDao::list(&conn, "project-b").unwrap();
|
||||
assert_eq!(templates_b.len(), 2);
|
||||
for t in &templates_b {
|
||||
assert_eq!(t.project_id, "project-b");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_default_uniqueness_constraint() {
|
||||
// Property 3: Default Uniqueness Constraint
|
||||
// 验证每个项目最多只有一个默认模板
|
||||
let conn = setup_test_db();
|
||||
create_test_project(&conn, "project-1");
|
||||
|
||||
// 创建三个模板
|
||||
let mut template_ids = Vec::new();
|
||||
for i in 1..=3 {
|
||||
let req = CreateTemplateRequest {
|
||||
project_id: "project-1".to_string(),
|
||||
name: format!("模板{}", i),
|
||||
platform: "markdown".to_string(),
|
||||
title_style: None,
|
||||
paragraph_style: None,
|
||||
ending_style: None,
|
||||
emoji_usage: None,
|
||||
hashtag_rules: None,
|
||||
image_rules: None,
|
||||
};
|
||||
let template = TemplateDao::create(&conn, &req).unwrap();
|
||||
template_ids.push(template.id);
|
||||
}
|
||||
|
||||
// 依次设置每个模板为默认,验证只有一个是默认的
|
||||
for (i, id) in template_ids.iter().enumerate() {
|
||||
TemplateDao::set_default(&conn, "project-1", id).unwrap();
|
||||
|
||||
// 验证只有当前模板是默认的
|
||||
let templates = TemplateDao::list(&conn, "project-1").unwrap();
|
||||
let default_count = templates.iter().filter(|t| t.is_default).count();
|
||||
assert_eq!(
|
||||
default_count,
|
||||
1,
|
||||
"设置第{}个模板为默认后,默认模板数量应为1",
|
||||
i + 1
|
||||
);
|
||||
|
||||
// 验证当前模板是默认的
|
||||
let current = TemplateDao::get(&conn, id).unwrap().unwrap();
|
||||
assert!(current.is_default, "当前设置的模板应该是默认的");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,500 @@
|
||||
//! 统一内容系统数据迁移服务
|
||||
//!
|
||||
//! 实现从旧版本到新版本的数据迁移,主要包括:
|
||||
//! - 创建默认项目
|
||||
//! - 将现有话题迁移到默认项目
|
||||
//!
|
||||
//! _Requirements: 2.1, 2.2, 2.3, 2.4_
|
||||
|
||||
use chrono::Utc;
|
||||
use rusqlite::{params, Connection};
|
||||
use uuid::Uuid;
|
||||
|
||||
/// 迁移设置键名
|
||||
const MIGRATION_KEY_UNIFIED_CONTENT: &str = "migrated_unified_content_system_v1";
|
||||
|
||||
/// 默认项目名称
|
||||
const DEFAULT_PROJECT_NAME: &str = "默认项目";
|
||||
|
||||
/// 默认项目图标
|
||||
const DEFAULT_PROJECT_ICON: &str = "📁";
|
||||
|
||||
/// 执行统一内容系统迁移
|
||||
///
|
||||
/// 迁移步骤:
|
||||
/// 1. 检查是否已迁移
|
||||
/// 2. 创建默认项目(如果不存在)
|
||||
/// 3. 将所有 project_id 为 null 的内容迁移到默认项目
|
||||
/// 4. 标记迁移完成
|
||||
///
|
||||
/// _Requirements: 2.1, 2.2, 2.3, 2.4_
|
||||
pub fn migrate_unified_content_system(conn: &Connection) -> Result<MigrationResult, String> {
|
||||
// 检查是否已经迁移过
|
||||
if is_migration_completed(conn, MIGRATION_KEY_UNIFIED_CONTENT) {
|
||||
tracing::debug!("[迁移] 统一内容系统已迁移过,跳过");
|
||||
return Ok(MigrationResult::skipped());
|
||||
}
|
||||
|
||||
tracing::info!("[迁移] 开始执行统一内容系统迁移");
|
||||
|
||||
// 开始事务
|
||||
conn.execute("BEGIN TRANSACTION", [])
|
||||
.map_err(|e| format!("开始事务失败: {e}"))?;
|
||||
|
||||
// 执行迁移
|
||||
let result = execute_migration(conn);
|
||||
|
||||
match result {
|
||||
Ok(stats) => {
|
||||
// 标记迁移完成
|
||||
mark_migration_completed(conn, MIGRATION_KEY_UNIFIED_CONTENT)?;
|
||||
|
||||
// 提交事务
|
||||
conn.execute("COMMIT", [])
|
||||
.map_err(|e| format!("提交事务失败: {e}"))?;
|
||||
|
||||
tracing::info!(
|
||||
"[迁移] 统一内容系统迁移完成: 默认项目={}, 迁移内容数={}",
|
||||
stats.default_project_id,
|
||||
stats.migrated_contents_count
|
||||
);
|
||||
|
||||
Ok(MigrationResult::success(stats))
|
||||
}
|
||||
Err(e) => {
|
||||
// 回滚事务
|
||||
// _Requirements: 2.4_
|
||||
let _ = conn.execute("ROLLBACK", []);
|
||||
tracing::error!("[迁移] 统一内容系统迁移失败,已回滚: {}", e);
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 执行迁移的核心逻辑
|
||||
fn execute_migration(conn: &Connection) -> Result<MigrationStats, String> {
|
||||
// 1. 获取或创建默认项目
|
||||
// _Requirements: 2.1_
|
||||
let default_project_id = get_or_create_default_project(conn)?;
|
||||
|
||||
// 2. 迁移所有 project_id 为 null 的内容到默认项目
|
||||
// _Requirements: 2.2_
|
||||
let migrated_count = migrate_null_project_contents(conn, &default_project_id)?;
|
||||
|
||||
// 3. 验证迁移结果
|
||||
verify_migration(conn)?;
|
||||
|
||||
Ok(MigrationStats {
|
||||
default_project_id,
|
||||
migrated_contents_count: migrated_count,
|
||||
})
|
||||
}
|
||||
|
||||
/// 获取或创建默认项目
|
||||
///
|
||||
/// 如果已存在 is_default=true 的项目,返回其 ID
|
||||
/// 否则创建新的默认项目
|
||||
///
|
||||
/// _Requirements: 2.1_
|
||||
fn get_or_create_default_project(conn: &Connection) -> Result<String, String> {
|
||||
// 检查是否已存在默认项目
|
||||
let existing_id: Option<String> = conn
|
||||
.query_row(
|
||||
"SELECT id FROM workspaces WHERE is_default = 1",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.ok();
|
||||
|
||||
if let Some(id) = existing_id {
|
||||
tracing::info!("[迁移] 找到现有默认项目: {}", id);
|
||||
return Ok(id);
|
||||
}
|
||||
|
||||
// 创建新的默认项目
|
||||
let project_id = Uuid::new_v4().to_string();
|
||||
let now = Utc::now().timestamp_millis();
|
||||
|
||||
// 使用应用数据目录作为默认项目的 root_path
|
||||
let root_path = get_default_project_path()?;
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO workspaces (
|
||||
id, name, workspace_type, root_path, is_default,
|
||||
settings_json, icon, color, is_favorite, is_archived,
|
||||
tags_json, created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
params![
|
||||
&project_id,
|
||||
DEFAULT_PROJECT_NAME,
|
||||
"general",
|
||||
&root_path,
|
||||
true, // is_default = true
|
||||
"{}",
|
||||
DEFAULT_PROJECT_ICON,
|
||||
Option::<String>::None,
|
||||
false,
|
||||
false,
|
||||
"[]",
|
||||
now,
|
||||
now,
|
||||
],
|
||||
)
|
||||
.map_err(|e| format!("创建默认项目失败: {e}"))?;
|
||||
|
||||
tracing::info!("[迁移] 创建默认项目: id={}, path={}", project_id, root_path);
|
||||
|
||||
Ok(project_id)
|
||||
}
|
||||
|
||||
/// 获取默认项目的存储路径
|
||||
fn get_default_project_path() -> Result<String, String> {
|
||||
let home = dirs::home_dir().ok_or_else(|| "无法获取主目录".to_string())?;
|
||||
let path = home.join(".proxycast").join("projects").join("default");
|
||||
|
||||
// 确保目录存在
|
||||
std::fs::create_dir_all(&path).map_err(|e| format!("创建默认项目目录失败: {e}"))?;
|
||||
|
||||
path.to_str()
|
||||
.map(|s| s.to_string())
|
||||
.ok_or_else(|| "无效的路径".to_string())
|
||||
}
|
||||
|
||||
/// 将所有 project_id 为 null 的内容迁移到默认项目
|
||||
///
|
||||
/// _Requirements: 2.2_
|
||||
fn migrate_null_project_contents(
|
||||
conn: &Connection,
|
||||
default_project_id: &str,
|
||||
) -> Result<usize, String> {
|
||||
let now = Utc::now().timestamp_millis();
|
||||
|
||||
// 查询需要迁移的内容数量
|
||||
let null_count: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM contents WHERE project_id IS NULL OR project_id = ''",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap_or(0);
|
||||
|
||||
if null_count == 0 {
|
||||
tracing::info!("[迁移] 没有需要迁移的内容");
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
tracing::info!("[迁移] 发现 {} 条需要迁移的内容", null_count);
|
||||
|
||||
// 更新所有 project_id 为 null 的内容
|
||||
let affected = conn
|
||||
.execute(
|
||||
"UPDATE contents
|
||||
SET project_id = ?, updated_at = ?
|
||||
WHERE project_id IS NULL OR project_id = ''",
|
||||
params![default_project_id, now],
|
||||
)
|
||||
.map_err(|e| format!("迁移内容失败: {e}"))?;
|
||||
|
||||
tracing::info!("[迁移] 已将 {} 条内容迁移到默认项目", affected);
|
||||
|
||||
Ok(affected)
|
||||
}
|
||||
|
||||
/// 验证迁移结果
|
||||
///
|
||||
/// 确保没有 project_id 为 null 的内容
|
||||
fn verify_migration(conn: &Connection) -> Result<(), String> {
|
||||
let null_count: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM contents WHERE project_id IS NULL OR project_id = ''",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap_or(0);
|
||||
|
||||
if null_count > 0 {
|
||||
return Err(format!(
|
||||
"迁移验证失败: 仍有 {} 条内容的 project_id 为空",
|
||||
null_count
|
||||
));
|
||||
}
|
||||
|
||||
// 验证默认项目存在
|
||||
let default_exists: bool = conn
|
||||
.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM workspaces WHERE is_default = 1)",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap_or(false);
|
||||
|
||||
if !default_exists {
|
||||
return Err("迁移验证失败: 默认项目不存在".to_string());
|
||||
}
|
||||
|
||||
tracing::info!("[迁移] 迁移验证通过");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 检查迁移是否已完成
|
||||
fn is_migration_completed(conn: &Connection, key: &str) -> bool {
|
||||
conn.query_row(
|
||||
"SELECT value FROM settings WHERE key = ?",
|
||||
params![key],
|
||||
|row| row.get::<_, String>(0),
|
||||
)
|
||||
.map(|v| v == "true")
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// 标记迁移完成
|
||||
fn mark_migration_completed(conn: &Connection, key: &str) -> Result<(), String> {
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO settings (key, value) VALUES (?, 'true')",
|
||||
params![key],
|
||||
)
|
||||
.map_err(|e| format!("标记迁移完成失败: {e}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 迁移结果类型
|
||||
// ============================================================================
|
||||
|
||||
/// 迁移统计信息
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MigrationStats {
|
||||
/// 默认项目 ID
|
||||
pub default_project_id: String,
|
||||
/// 迁移的内容数量
|
||||
pub migrated_contents_count: usize,
|
||||
}
|
||||
|
||||
/// 迁移结果
|
||||
#[derive(Debug)]
|
||||
pub struct MigrationResult {
|
||||
/// 是否执行了迁移
|
||||
pub executed: bool,
|
||||
/// 迁移统计(如果执行了迁移)
|
||||
pub stats: Option<MigrationStats>,
|
||||
}
|
||||
|
||||
impl MigrationResult {
|
||||
/// 创建跳过的结果
|
||||
fn skipped() -> Self {
|
||||
Self {
|
||||
executed: false,
|
||||
stats: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 创建成功的结果
|
||||
fn success(stats: MigrationStats) -> Self {
|
||||
Self {
|
||||
executed: true,
|
||||
stats: Some(stats),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 辅助函数
|
||||
// ============================================================================
|
||||
|
||||
/// 获取默认项目 ID
|
||||
///
|
||||
/// 如果默认项目不存在,返回 None
|
||||
pub fn get_default_project_id(conn: &Connection) -> Option<String> {
|
||||
conn.query_row(
|
||||
"SELECT id FROM workspaces WHERE is_default = 1",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.ok()
|
||||
}
|
||||
|
||||
/// 确保默认项目存在
|
||||
///
|
||||
/// 如果不存在则创建,返回默认项目 ID
|
||||
pub fn ensure_default_project(conn: &Connection) -> Result<String, String> {
|
||||
get_or_create_default_project(conn)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 单元测试
|
||||
// ============================================================================
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use rusqlite::Connection;
|
||||
|
||||
/// 创建测试数据库
|
||||
fn setup_test_db() -> Connection {
|
||||
let conn = Connection::open_in_memory().unwrap();
|
||||
|
||||
// 创建 settings 表
|
||||
conn.execute(
|
||||
"CREATE TABLE settings (key TEXT PRIMARY KEY, value TEXT NOT NULL)",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// 创建 workspaces 表
|
||||
conn.execute(
|
||||
"CREATE TABLE workspaces (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
workspace_type TEXT NOT NULL DEFAULT 'persistent',
|
||||
root_path TEXT NOT NULL UNIQUE,
|
||||
is_default INTEGER DEFAULT 0,
|
||||
settings_json TEXT DEFAULT '{}',
|
||||
icon TEXT,
|
||||
color TEXT,
|
||||
is_favorite INTEGER DEFAULT 0,
|
||||
is_archived INTEGER DEFAULT 0,
|
||||
tags_json TEXT DEFAULT '[]',
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
)",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// 创建 contents 表
|
||||
conn.execute(
|
||||
"CREATE TABLE contents (
|
||||
id TEXT PRIMARY KEY,
|
||||
project_id TEXT,
|
||||
title TEXT NOT NULL,
|
||||
content_type TEXT NOT NULL DEFAULT 'document',
|
||||
status TEXT NOT NULL DEFAULT 'draft',
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
body TEXT NOT NULL DEFAULT '',
|
||||
word_count INTEGER NOT NULL DEFAULT 0,
|
||||
metadata_json TEXT,
|
||||
session_id TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
)",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
conn
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_migration_creates_default_project() {
|
||||
let conn = setup_test_db();
|
||||
|
||||
// 执行迁移
|
||||
let result = migrate_unified_content_system(&conn).unwrap();
|
||||
|
||||
assert!(result.executed);
|
||||
assert!(result.stats.is_some());
|
||||
|
||||
// 验证默认项目存在
|
||||
let default_exists: bool = conn
|
||||
.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM workspaces WHERE is_default = 1)",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(default_exists);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_migration_migrates_null_project_contents() {
|
||||
let conn = setup_test_db();
|
||||
let now = Utc::now().timestamp_millis();
|
||||
|
||||
// 插入一些没有 project_id 的内容
|
||||
conn.execute(
|
||||
"INSERT INTO contents (id, project_id, title, created_at, updated_at)
|
||||
VALUES ('c1', NULL, '内容1', ?, ?)",
|
||||
params![now, now],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO contents (id, project_id, title, created_at, updated_at)
|
||||
VALUES ('c2', '', '内容2', ?, ?)",
|
||||
params![now, now],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// 执行迁移
|
||||
let result = migrate_unified_content_system(&conn).unwrap();
|
||||
|
||||
assert!(result.executed);
|
||||
let stats = result.stats.unwrap();
|
||||
assert_eq!(stats.migrated_contents_count, 2);
|
||||
|
||||
// 验证所有内容都有 project_id
|
||||
let null_count: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM contents WHERE project_id IS NULL OR project_id = ''",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(null_count, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_migration_skips_if_already_done() {
|
||||
let conn = setup_test_db();
|
||||
|
||||
// 第一次迁移
|
||||
let result1 = migrate_unified_content_system(&conn).unwrap();
|
||||
assert!(result1.executed);
|
||||
|
||||
// 第二次迁移应该跳过
|
||||
let result2 = migrate_unified_content_system(&conn).unwrap();
|
||||
assert!(!result2.executed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_migration_uses_existing_default_project() {
|
||||
let conn = setup_test_db();
|
||||
let now = Utc::now().timestamp_millis();
|
||||
|
||||
// 先创建一个默认项目
|
||||
conn.execute(
|
||||
"INSERT INTO workspaces (id, name, workspace_type, root_path, is_default, created_at, updated_at)
|
||||
VALUES ('existing-default', '已有默认项目', 'general', '/tmp/existing', 1, ?, ?)",
|
||||
params![now, now],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// 插入没有 project_id 的内容
|
||||
conn.execute(
|
||||
"INSERT INTO contents (id, project_id, title, created_at, updated_at)
|
||||
VALUES ('c1', NULL, '内容1', ?, ?)",
|
||||
params![now, now],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// 执行迁移
|
||||
let result = migrate_unified_content_system(&conn).unwrap();
|
||||
|
||||
assert!(result.executed);
|
||||
let stats = result.stats.unwrap();
|
||||
assert_eq!(stats.default_project_id, "existing-default");
|
||||
|
||||
// 验证内容被迁移到已有的默认项目
|
||||
let project_id: String = conn
|
||||
.query_row(
|
||||
"SELECT project_id FROM contents WHERE id = 'c1'",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(project_id, "existing-default");
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
pub mod dao;
|
||||
pub mod migration;
|
||||
pub mod migration_v2;
|
||||
pub mod schema;
|
||||
pub mod system_providers;
|
||||
|
||||
@@ -83,5 +84,24 @@ pub fn init_database() -> Result<DbConnection, String> {
|
||||
}
|
||||
}
|
||||
|
||||
// 执行统一内容系统迁移(创建默认项目,迁移话题)
|
||||
// _Requirements: 2.1, 2.2, 2.3, 2.4_
|
||||
match migration_v2::migrate_unified_content_system(&conn) {
|
||||
Ok(result) => {
|
||||
if result.executed {
|
||||
if let Some(stats) = result.stats {
|
||||
tracing::info!(
|
||||
"[数据库] 统一内容系统迁移完成: 默认项目={}, 迁移内容数={}",
|
||||
stats.default_project_id,
|
||||
stats.migrated_contents_count
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("[数据库] 统一内容系统迁移失败(非致命): {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Arc::new(Mutex::new(conn)))
|
||||
}
|
||||
|
||||
@@ -580,6 +580,19 @@ pub fn create_tables(conn: &Connection) -> Result<(), rusqlite::Error> {
|
||||
[],
|
||||
);
|
||||
|
||||
// Migration: 添加默认人设和模板引用字段到 workspaces 表
|
||||
// _Requirements: 11.2, 11.3_
|
||||
// 注意:SQLite 不支持 ALTER TABLE ADD COLUMN 带外键约束,
|
||||
// 外键约束通过应用层逻辑保证
|
||||
let _ = conn.execute(
|
||||
"ALTER TABLE workspaces ADD COLUMN default_persona_id TEXT",
|
||||
[],
|
||||
);
|
||||
let _ = conn.execute(
|
||||
"ALTER TABLE workspaces ADD COLUMN default_template_id TEXT",
|
||||
[],
|
||||
);
|
||||
|
||||
// Migration: 迁移旧的项目类型到新类型
|
||||
// drama -> video, social -> social-media
|
||||
let _ = conn.execute(
|
||||
@@ -697,6 +710,134 @@ pub fn create_tables(conn: &Connection) -> Result<(), rusqlite::Error> {
|
||||
[],
|
||||
)?;
|
||||
|
||||
// ============================================================================
|
||||
// 人设表 (Persona)
|
||||
// 存储项目级人设配置,用于 AI 内容生成时的风格控制
|
||||
// _Requirements: 6.3_
|
||||
// ============================================================================
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS personas (
|
||||
id TEXT PRIMARY KEY,
|
||||
project_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
style TEXT NOT NULL DEFAULT '',
|
||||
tone TEXT,
|
||||
target_audience TEXT,
|
||||
forbidden_words_json TEXT NOT NULL DEFAULT '[]',
|
||||
preferred_words_json TEXT NOT NULL DEFAULT '[]',
|
||||
examples TEXT,
|
||||
platforms_json TEXT NOT NULL DEFAULT '[]',
|
||||
is_default INTEGER DEFAULT 0,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
FOREIGN KEY (project_id) REFERENCES workspaces(id) ON DELETE CASCADE
|
||||
)",
|
||||
[],
|
||||
)?;
|
||||
|
||||
// 创建 personas 索引
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_personas_project_id ON personas(project_id)",
|
||||
[],
|
||||
)?;
|
||||
|
||||
// ============================================================================
|
||||
// 素材表 (Material)
|
||||
// 存储项目级素材,包括文档、图片、文本、数据文件等
|
||||
// _Requirements: 7.3_
|
||||
// ============================================================================
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS materials (
|
||||
id TEXT PRIMARY KEY,
|
||||
project_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
material_type TEXT NOT NULL DEFAULT 'document',
|
||||
file_path TEXT,
|
||||
file_size INTEGER,
|
||||
mime_type TEXT,
|
||||
content TEXT,
|
||||
tags_json TEXT NOT NULL DEFAULT '[]',
|
||||
description TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
FOREIGN KEY (project_id) REFERENCES workspaces(id) ON DELETE CASCADE
|
||||
)",
|
||||
[],
|
||||
)?;
|
||||
|
||||
// 创建 materials 索引
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_materials_project_id ON materials(project_id)",
|
||||
[],
|
||||
)?;
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_materials_type ON materials(material_type)",
|
||||
[],
|
||||
)?;
|
||||
|
||||
// ============================================================================
|
||||
// 排版模板表 (Template)
|
||||
// 存储项目级排版模板,用于控制 AI 输出内容的格式
|
||||
// _Requirements: 8.3_
|
||||
// ============================================================================
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS templates (
|
||||
id TEXT PRIMARY KEY,
|
||||
project_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
platform TEXT NOT NULL,
|
||||
title_style TEXT,
|
||||
paragraph_style TEXT,
|
||||
ending_style TEXT,
|
||||
emoji_usage TEXT NOT NULL DEFAULT 'moderate',
|
||||
hashtag_rules TEXT,
|
||||
image_rules TEXT,
|
||||
is_default INTEGER DEFAULT 0,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
FOREIGN KEY (project_id) REFERENCES workspaces(id) ON DELETE CASCADE
|
||||
)",
|
||||
[],
|
||||
)?;
|
||||
|
||||
// 创建 templates 索引
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_templates_project_id ON templates(project_id)",
|
||||
[],
|
||||
)?;
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_templates_platform ON templates(platform)",
|
||||
[],
|
||||
)?;
|
||||
|
||||
// ============================================================================
|
||||
// 发布配置表 (PublishConfig)
|
||||
// 存储项目级发布配置,包括平台凭证和发布历史
|
||||
// _Requirements: 9.4_
|
||||
// ============================================================================
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS publish_configs (
|
||||
id TEXT PRIMARY KEY,
|
||||
project_id TEXT NOT NULL,
|
||||
platform TEXT NOT NULL,
|
||||
is_configured INTEGER DEFAULT 0,
|
||||
credentials_encrypted TEXT,
|
||||
last_published_at INTEGER,
|
||||
publish_count INTEGER DEFAULT 0,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
FOREIGN KEY (project_id) REFERENCES workspaces(id) ON DELETE CASCADE,
|
||||
UNIQUE(project_id, platform)
|
||||
)",
|
||||
[],
|
||||
)?;
|
||||
|
||||
// 创建 publish_configs 索引
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_publish_configs_project_id ON publish_configs(project_id)",
|
||||
[],
|
||||
)?;
|
||||
|
||||
// 大纲节点表
|
||||
// 存储项目的大纲结构
|
||||
conn.execute(
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
# 错误类型模块
|
||||
|
||||
定义 ProxyCast 应用中的各种错误类型。
|
||||
|
||||
## 文件索引
|
||||
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| `mod.rs` | 模块入口,导出所有错误类型 |
|
||||
| `project_error.rs` | 项目相关错误类型 |
|
||||
|
||||
## 错误类型
|
||||
|
||||
### ProjectError
|
||||
项目操作错误,包括:
|
||||
- `NotFound` - 项目不存在
|
||||
- `CannotDeleteDefault` - 无法删除默认项目
|
||||
- `CannotArchiveDefault` - 无法归档默认项目
|
||||
- `NameAlreadyExists` - 项目名称已存在
|
||||
- `DatabaseError` - 数据库错误
|
||||
- `IoError` - IO 错误
|
||||
|
||||
### PersonaError
|
||||
人设操作错误,包括:
|
||||
- `NotFound` - 人设不存在
|
||||
- `ProjectNotFound` - 项目不存在
|
||||
- `NameAlreadyExists` - 人设名称已存在
|
||||
- `DatabaseError` - 数据库错误
|
||||
|
||||
### MaterialError
|
||||
素材操作错误,包括:
|
||||
- `NotFound` - 素材不存在
|
||||
- `ProjectNotFound` - 项目不存在
|
||||
- `UnsupportedFileType` - 不支持的文件类型
|
||||
- `FileTooLarge` - 文件过大
|
||||
- `FileReadError` - 文件读取失败
|
||||
- `DatabaseError` - 数据库错误
|
||||
- `IoError` - IO 错误
|
||||
|
||||
### TemplateError
|
||||
模板操作错误,包括:
|
||||
- `NotFound` - 模板不存在
|
||||
- `ProjectNotFound` - 项目不存在
|
||||
- `UnsupportedPlatform` - 不支持的平台
|
||||
- `DatabaseError` - 数据库错误
|
||||
|
||||
### MigrationError
|
||||
数据迁移错误,包括:
|
||||
- `MigrationFailed` - 迁移失败
|
||||
- `DatabaseError` - 数据库错误
|
||||
|
||||
## 使用示例
|
||||
|
||||
```rust
|
||||
use crate::errors::{ProjectError, PersonaError};
|
||||
|
||||
fn delete_project(id: &str, is_default: bool) -> Result<(), ProjectError> {
|
||||
if is_default {
|
||||
return Err(ProjectError::CannotDeleteDefault);
|
||||
}
|
||||
// ...
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
## 相关需求
|
||||
- Requirements 2.4: 迁移错误处理
|
||||
- Requirements 11.6: 默认项目保护
|
||||
@@ -0,0 +1,11 @@
|
||||
//! 错误类型模块
|
||||
//!
|
||||
//! 定义 ProxyCast 应用中的各种错误类型。
|
||||
//!
|
||||
//! ## 模块结构
|
||||
//! - `project_error`: 项目相关错误(ProjectError, PersonaError, MaterialError, TemplateError, MigrationError)
|
||||
|
||||
pub mod project_error;
|
||||
|
||||
// 重新导出常用错误类型
|
||||
pub use project_error::{MaterialError, MigrationError, PersonaError, ProjectError, TemplateError};
|
||||
@@ -0,0 +1,462 @@
|
||||
//! 项目相关错误类型
|
||||
//!
|
||||
//! 定义统一内容创作系统中的错误类型,包括:
|
||||
//! - ProjectError(项目错误)
|
||||
//! - PersonaError(人设错误)
|
||||
//! - MaterialError(素材错误)
|
||||
//! - TemplateError(模板错误)
|
||||
//! - MigrationError(迁移错误)
|
||||
//!
|
||||
//! ## 设计原则
|
||||
//! - 使用 thiserror 派生 Error trait
|
||||
//! - 支持 From 转换以便错误传播
|
||||
//! - 实现 Serialize 以支持 Tauri 命令返回
|
||||
//!
|
||||
//! ## 相关需求
|
||||
//! - Requirements 2.4: 迁移错误处理
|
||||
//! - Requirements 11.6: 默认项目保护
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
// ============================================================================
|
||||
// 项目错误
|
||||
// ============================================================================
|
||||
|
||||
/// 项目操作错误
|
||||
///
|
||||
/// 涵盖项目 CRUD 操作中可能出现的所有错误情况。
|
||||
#[derive(Error, Debug)]
|
||||
pub enum ProjectError {
|
||||
/// 项目不存在
|
||||
#[error("项目不存在: {0}")]
|
||||
NotFound(String),
|
||||
|
||||
/// 无法删除默认项目
|
||||
#[error("无法删除默认项目")]
|
||||
CannotDeleteDefault,
|
||||
|
||||
/// 无法归档默认项目
|
||||
#[error("无法归档默认项目")]
|
||||
CannotArchiveDefault,
|
||||
|
||||
/// 项目名称已存在
|
||||
#[error("项目名称已存在: {0}")]
|
||||
NameAlreadyExists(String),
|
||||
|
||||
/// 数据库错误
|
||||
#[error("数据库错误: {0}")]
|
||||
DatabaseError(#[from] rusqlite::Error),
|
||||
|
||||
/// IO 错误
|
||||
#[error("IO 错误: {0}")]
|
||||
IoError(#[from] std::io::Error),
|
||||
}
|
||||
|
||||
impl From<ProjectError> for String {
|
||||
fn from(err: ProjectError) -> Self {
|
||||
err.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
impl serde::Serialize for ProjectError {
|
||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: serde::Serializer,
|
||||
{
|
||||
serializer.serialize_str(&self.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 人设错误
|
||||
// ============================================================================
|
||||
|
||||
/// 人设操作错误
|
||||
///
|
||||
/// 涵盖人设 CRUD 操作中可能出现的所有错误情况。
|
||||
#[derive(Error, Debug)]
|
||||
pub enum PersonaError {
|
||||
/// 人设不存在
|
||||
#[error("人设不存在: {0}")]
|
||||
NotFound(String),
|
||||
|
||||
/// 项目不存在
|
||||
#[error("项目不存在: {0}")]
|
||||
ProjectNotFound(String),
|
||||
|
||||
/// 人设名称已存在
|
||||
#[error("人设名称已存在: {0}")]
|
||||
NameAlreadyExists(String),
|
||||
|
||||
/// 数据库错误
|
||||
#[error("数据库错误: {0}")]
|
||||
DatabaseError(#[from] rusqlite::Error),
|
||||
}
|
||||
|
||||
impl From<PersonaError> for String {
|
||||
fn from(err: PersonaError) -> Self {
|
||||
err.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
impl serde::Serialize for PersonaError {
|
||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: serde::Serializer,
|
||||
{
|
||||
serializer.serialize_str(&self.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 素材错误
|
||||
// ============================================================================
|
||||
|
||||
/// 素材操作错误
|
||||
///
|
||||
/// 涵盖素材上传、存储、删除等操作中可能出现的所有错误情况。
|
||||
#[derive(Error, Debug)]
|
||||
pub enum MaterialError {
|
||||
/// 素材不存在
|
||||
#[error("素材不存在: {0}")]
|
||||
NotFound(String),
|
||||
|
||||
/// 项目不存在
|
||||
#[error("项目不存在: {0}")]
|
||||
ProjectNotFound(String),
|
||||
|
||||
/// 不支持的文件类型
|
||||
#[error("不支持的文件类型: {0}")]
|
||||
UnsupportedFileType(String),
|
||||
|
||||
/// 文件过大
|
||||
#[error("文件过大: {0} bytes (最大 {1} bytes)")]
|
||||
FileTooLarge(u64, u64),
|
||||
|
||||
/// 文件读取失败
|
||||
#[error("文件读取失败: {0}")]
|
||||
FileReadError(String),
|
||||
|
||||
/// 数据库错误
|
||||
#[error("数据库错误: {0}")]
|
||||
DatabaseError(#[from] rusqlite::Error),
|
||||
|
||||
/// IO 错误
|
||||
#[error("IO 错误: {0}")]
|
||||
IoError(#[from] std::io::Error),
|
||||
}
|
||||
|
||||
impl From<MaterialError> for String {
|
||||
fn from(err: MaterialError) -> Self {
|
||||
err.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
impl serde::Serialize for MaterialError {
|
||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: serde::Serializer,
|
||||
{
|
||||
serializer.serialize_str(&self.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 模板错误
|
||||
// ============================================================================
|
||||
|
||||
/// 模板操作错误
|
||||
///
|
||||
/// 涵盖排版模板 CRUD 操作中可能出现的所有错误情况。
|
||||
#[derive(Error, Debug)]
|
||||
pub enum TemplateError {
|
||||
/// 模板不存在
|
||||
#[error("模板不存在: {0}")]
|
||||
NotFound(String),
|
||||
|
||||
/// 项目不存在
|
||||
#[error("项目不存在: {0}")]
|
||||
ProjectNotFound(String),
|
||||
|
||||
/// 不支持的平台
|
||||
#[error("不支持的平台: {0}")]
|
||||
UnsupportedPlatform(String),
|
||||
|
||||
/// 数据库错误
|
||||
#[error("数据库错误: {0}")]
|
||||
DatabaseError(#[from] rusqlite::Error),
|
||||
}
|
||||
|
||||
impl From<TemplateError> for String {
|
||||
fn from(err: TemplateError) -> Self {
|
||||
err.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
impl serde::Serialize for TemplateError {
|
||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: serde::Serializer,
|
||||
{
|
||||
serializer.serialize_str(&self.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 发布配置错误
|
||||
// ============================================================================
|
||||
|
||||
/// 发布配置操作错误
|
||||
///
|
||||
/// 涵盖发布配置 CRUD 操作中可能出现的所有错误情况。
|
||||
#[derive(Error, Debug)]
|
||||
pub enum PublishConfigError {
|
||||
/// 发布配置不存在
|
||||
#[error("发布配置不存在: {0}")]
|
||||
NotFound(String),
|
||||
|
||||
/// 项目不存在
|
||||
#[error("项目不存在: {0}")]
|
||||
ProjectNotFound(String),
|
||||
|
||||
/// 平台配置已存在
|
||||
#[error("平台配置已存在: 项目 {0} 平台 {1}")]
|
||||
PlatformAlreadyExists(String, String),
|
||||
|
||||
/// 不支持的平台
|
||||
#[error("不支持的平台: {0}")]
|
||||
UnsupportedPlatform(String),
|
||||
|
||||
/// 数据库错误
|
||||
#[error("数据库错误: {0}")]
|
||||
DatabaseError(#[from] rusqlite::Error),
|
||||
}
|
||||
|
||||
impl From<PublishConfigError> for String {
|
||||
fn from(err: PublishConfigError) -> Self {
|
||||
err.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
impl serde::Serialize for PublishConfigError {
|
||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: serde::Serializer,
|
||||
{
|
||||
serializer.serialize_str(&self.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 迁移错误
|
||||
// ============================================================================
|
||||
|
||||
/// 数据迁移错误
|
||||
///
|
||||
/// 涵盖数据库迁移过程中可能出现的所有错误情况。
|
||||
/// 主要用于现有话题迁移到默认项目的场景。
|
||||
#[derive(Error, Debug)]
|
||||
pub enum MigrationError {
|
||||
/// 迁移失败
|
||||
#[error("迁移失败: {0}")]
|
||||
MigrationFailed(String),
|
||||
|
||||
/// 数据库错误
|
||||
#[error("数据库错误: {0}")]
|
||||
DatabaseError(#[from] rusqlite::Error),
|
||||
}
|
||||
|
||||
impl From<MigrationError> for String {
|
||||
fn from(err: MigrationError) -> Self {
|
||||
err.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
impl serde::Serialize for MigrationError {
|
||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: serde::Serializer,
|
||||
{
|
||||
serializer.serialize_str(&self.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 测试
|
||||
// ============================================================================
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_project_error_display() {
|
||||
let err = ProjectError::NotFound("test-project".to_string());
|
||||
assert_eq!(err.to_string(), "项目不存在: test-project");
|
||||
|
||||
let err = ProjectError::CannotDeleteDefault;
|
||||
assert_eq!(err.to_string(), "无法删除默认项目");
|
||||
|
||||
let err = ProjectError::CannotArchiveDefault;
|
||||
assert_eq!(err.to_string(), "无法归档默认项目");
|
||||
|
||||
let err = ProjectError::NameAlreadyExists("我的项目".to_string());
|
||||
assert_eq!(err.to_string(), "项目名称已存在: 我的项目");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_persona_error_display() {
|
||||
let err = PersonaError::NotFound("persona-1".to_string());
|
||||
assert_eq!(err.to_string(), "人设不存在: persona-1");
|
||||
|
||||
let err = PersonaError::ProjectNotFound("project-1".to_string());
|
||||
assert_eq!(err.to_string(), "项目不存在: project-1");
|
||||
|
||||
let err = PersonaError::NameAlreadyExists("专业写手".to_string());
|
||||
assert_eq!(err.to_string(), "人设名称已存在: 专业写手");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_material_error_display() {
|
||||
let err = MaterialError::NotFound("mat-1".to_string());
|
||||
assert_eq!(err.to_string(), "素材不存在: mat-1");
|
||||
|
||||
let err = MaterialError::ProjectNotFound("project-1".to_string());
|
||||
assert_eq!(err.to_string(), "项目不存在: project-1");
|
||||
|
||||
let err = MaterialError::UnsupportedFileType(".exe".to_string());
|
||||
assert_eq!(err.to_string(), "不支持的文件类型: .exe");
|
||||
|
||||
let err = MaterialError::FileTooLarge(10_000_000, 5_000_000);
|
||||
assert_eq!(
|
||||
err.to_string(),
|
||||
"文件过大: 10000000 bytes (最大 5000000 bytes)"
|
||||
);
|
||||
|
||||
let err = MaterialError::FileReadError("权限不足".to_string());
|
||||
assert_eq!(err.to_string(), "文件读取失败: 权限不足");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_template_error_display() {
|
||||
let err = TemplateError::NotFound("tpl-1".to_string());
|
||||
assert_eq!(err.to_string(), "模板不存在: tpl-1");
|
||||
|
||||
let err = TemplateError::ProjectNotFound("project-1".to_string());
|
||||
assert_eq!(err.to_string(), "项目不存在: project-1");
|
||||
|
||||
let err = TemplateError::UnsupportedPlatform("unknown".to_string());
|
||||
assert_eq!(err.to_string(), "不支持的平台: unknown");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_migration_error_display() {
|
||||
let err = MigrationError::MigrationFailed("表不存在".to_string());
|
||||
assert_eq!(err.to_string(), "迁移失败: 表不存在");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_project_error_to_string() {
|
||||
let err = ProjectError::NotFound("test".to_string());
|
||||
let s: String = err.into();
|
||||
assert_eq!(s, "项目不存在: test");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_persona_error_to_string() {
|
||||
let err = PersonaError::NotFound("test".to_string());
|
||||
let s: String = err.into();
|
||||
assert_eq!(s, "人设不存在: test");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_material_error_to_string() {
|
||||
let err = MaterialError::NotFound("test".to_string());
|
||||
let s: String = err.into();
|
||||
assert_eq!(s, "素材不存在: test");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_template_error_to_string() {
|
||||
let err = TemplateError::NotFound("test".to_string());
|
||||
let s: String = err.into();
|
||||
assert_eq!(s, "模板不存在: test");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_migration_error_to_string() {
|
||||
let err = MigrationError::MigrationFailed("test".to_string());
|
||||
let s: String = err.into();
|
||||
assert_eq!(s, "迁移失败: test");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_project_error_serialize() {
|
||||
let err = ProjectError::CannotDeleteDefault;
|
||||
let json = serde_json::to_string(&err).unwrap();
|
||||
assert_eq!(json, "\"无法删除默认项目\"");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_persona_error_serialize() {
|
||||
let err = PersonaError::NotFound("test".to_string());
|
||||
let json = serde_json::to_string(&err).unwrap();
|
||||
assert_eq!(json, "\"人设不存在: test\"");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_material_error_serialize() {
|
||||
let err = MaterialError::FileTooLarge(100, 50);
|
||||
let json = serde_json::to_string(&err).unwrap();
|
||||
assert_eq!(json, "\"文件过大: 100 bytes (最大 50 bytes)\"");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_template_error_serialize() {
|
||||
let err = TemplateError::UnsupportedPlatform("test".to_string());
|
||||
let json = serde_json::to_string(&err).unwrap();
|
||||
assert_eq!(json, "\"不支持的平台: test\"");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_migration_error_serialize() {
|
||||
let err = MigrationError::MigrationFailed("test".to_string());
|
||||
let json = serde_json::to_string(&err).unwrap();
|
||||
assert_eq!(json, "\"迁移失败: test\"");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_publish_config_error_display() {
|
||||
let err = PublishConfigError::NotFound("config-1".to_string());
|
||||
assert_eq!(err.to_string(), "发布配置不存在: config-1");
|
||||
|
||||
let err = PublishConfigError::ProjectNotFound("project-1".to_string());
|
||||
assert_eq!(err.to_string(), "项目不存在: project-1");
|
||||
|
||||
let err = PublishConfigError::PlatformAlreadyExists(
|
||||
"project-1".to_string(),
|
||||
"xiaohongshu".to_string(),
|
||||
);
|
||||
assert_eq!(
|
||||
err.to_string(),
|
||||
"平台配置已存在: 项目 project-1 平台 xiaohongshu"
|
||||
);
|
||||
|
||||
let err = PublishConfigError::UnsupportedPlatform("unknown".to_string());
|
||||
assert_eq!(err.to_string(), "不支持的平台: unknown");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_publish_config_error_to_string() {
|
||||
let err = PublishConfigError::NotFound("test".to_string());
|
||||
let s: String = err.into();
|
||||
assert_eq!(s, "发布配置不存在: test");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_publish_config_error_serialize() {
|
||||
let err = PublishConfigError::PlatformAlreadyExists("p1".to_string(), "wechat".to_string());
|
||||
let json = serde_json::to_string(&err).unwrap();
|
||||
assert_eq!(json, "\"平台配置已存在: 项目 p1 平台 wechat\"");
|
||||
}
|
||||
}
|
||||
@@ -57,6 +57,7 @@ mod converter;
|
||||
mod data;
|
||||
#[cfg(debug_assertions)]
|
||||
mod dev_bridge;
|
||||
mod errors;
|
||||
mod logger;
|
||||
mod models;
|
||||
mod providers;
|
||||
|
||||
@@ -6,6 +6,7 @@ pub mod machine_id;
|
||||
pub mod mcp_model;
|
||||
pub mod model_registry;
|
||||
pub mod openai;
|
||||
pub mod project_model;
|
||||
pub mod prompt_model;
|
||||
pub mod provider_model;
|
||||
pub mod provider_pool_model;
|
||||
@@ -20,6 +21,8 @@ pub use codewhisperer::*;
|
||||
pub use mcp_model::McpServer;
|
||||
#[allow(unused_imports)]
|
||||
pub use openai::*;
|
||||
#[allow(unused_imports)]
|
||||
pub use project_model::*;
|
||||
pub use prompt_model::Prompt;
|
||||
pub use provider_model::Provider;
|
||||
#[allow(unused_imports)]
|
||||
|
||||
@@ -586,6 +586,24 @@ fn infer_model_tier(model_id: &str, model_name: &str) -> ModelTier {
|
||||
let id_lower = model_id.to_lowercase();
|
||||
let name_lower = model_name.to_lowercase();
|
||||
|
||||
// Mini 等级模型(优先检查,因为 gpt-4o-mini 包含 gpt-4o)
|
||||
let mini_patterns = [
|
||||
"mini",
|
||||
"nano",
|
||||
"lite",
|
||||
"flash",
|
||||
"haiku",
|
||||
"gpt-4o-mini",
|
||||
"gemini-flash",
|
||||
"qwen-turbo",
|
||||
"glm-4-flash",
|
||||
];
|
||||
for pattern in mini_patterns {
|
||||
if id_lower.contains(pattern) || name_lower.contains(pattern) {
|
||||
return ModelTier::Mini;
|
||||
}
|
||||
}
|
||||
|
||||
// Max 等级模型
|
||||
let max_patterns = [
|
||||
"opus",
|
||||
@@ -604,24 +622,6 @@ fn infer_model_tier(model_id: &str, model_name: &str) -> ModelTier {
|
||||
}
|
||||
}
|
||||
|
||||
// Mini 等级模型
|
||||
let mini_patterns = [
|
||||
"mini",
|
||||
"nano",
|
||||
"lite",
|
||||
"flash",
|
||||
"haiku",
|
||||
"gpt-4o-mini",
|
||||
"gemini-flash",
|
||||
"qwen-turbo",
|
||||
"glm-4-flash",
|
||||
];
|
||||
for pattern in mini_patterns {
|
||||
if id_lower.contains(pattern) || name_lower.contains(pattern) {
|
||||
return ModelTier::Mini;
|
||||
}
|
||||
}
|
||||
|
||||
// 默认为 Pro 等级
|
||||
ModelTier::Pro
|
||||
}
|
||||
|
||||
@@ -0,0 +1,706 @@
|
||||
//! 项目相关模型定义
|
||||
//!
|
||||
//! 定义统一内容创作系统中的项目相关数据结构,包括:
|
||||
//! - Persona(人设)
|
||||
//! - Material(素材)
|
||||
//! - Template(排版模板)
|
||||
//! - PublishConfig(发布配置)
|
||||
//! - ProjectContext(项目上下文)
|
||||
//!
|
||||
//! 以及相关的请求类型。
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
// ============================================================================
|
||||
// 人设相关类型
|
||||
// ============================================================================
|
||||
|
||||
/// 人设配置
|
||||
///
|
||||
/// 存储项目级人设配置,包含写作风格、语气、目标读者等信息。
|
||||
/// 用于 AI 生成内容时的风格指导。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Persona {
|
||||
/// 唯一标识
|
||||
pub id: String,
|
||||
/// 所属项目 ID
|
||||
pub project_id: String,
|
||||
/// 人设名称
|
||||
pub name: String,
|
||||
/// 人设描述
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
/// 写作风格(如:专业、轻松、幽默等)
|
||||
pub style: String,
|
||||
/// 语气(如:正式、亲切、活泼等)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tone: Option<String>,
|
||||
/// 目标读者群体
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub target_audience: Option<String>,
|
||||
/// 禁用词列表
|
||||
#[serde(default)]
|
||||
pub forbidden_words: Vec<String>,
|
||||
/// 偏好词列表
|
||||
#[serde(default)]
|
||||
pub preferred_words: Vec<String>,
|
||||
/// 示例文本
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub examples: Option<String>,
|
||||
/// 适用平台列表
|
||||
#[serde(default)]
|
||||
pub platforms: Vec<String>,
|
||||
/// 是否为项目默认人设
|
||||
#[serde(default)]
|
||||
pub is_default: bool,
|
||||
/// 创建时间(Unix 时间戳)
|
||||
pub created_at: i64,
|
||||
/// 更新时间(Unix 时间戳)
|
||||
pub updated_at: i64,
|
||||
}
|
||||
|
||||
/// 创建人设请求
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CreatePersonaRequest {
|
||||
/// 所属项目 ID
|
||||
pub project_id: String,
|
||||
/// 人设名称
|
||||
pub name: String,
|
||||
/// 人设描述
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
/// 写作风格
|
||||
pub style: String,
|
||||
/// 语气
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tone: Option<String>,
|
||||
/// 目标读者群体
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub target_audience: Option<String>,
|
||||
/// 禁用词列表
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub forbidden_words: Option<Vec<String>>,
|
||||
/// 偏好词列表
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub preferred_words: Option<Vec<String>>,
|
||||
/// 示例文本
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub examples: Option<String>,
|
||||
/// 适用平台列表
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub platforms: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
/// 更新人设请求
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct PersonaUpdate {
|
||||
/// 人设名称
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub name: Option<String>,
|
||||
/// 人设描述
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
/// 写作风格
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub style: Option<String>,
|
||||
/// 语气
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tone: Option<String>,
|
||||
/// 目标读者群体
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub target_audience: Option<String>,
|
||||
/// 禁用词列表
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub forbidden_words: Option<Vec<String>>,
|
||||
/// 偏好词列表
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub preferred_words: Option<Vec<String>>,
|
||||
/// 示例文本
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub examples: Option<String>,
|
||||
/// 适用平台列表
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub platforms: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
/// 人设模板
|
||||
///
|
||||
/// 预定义的人设模板,用于快速创建人设。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PersonaTemplate {
|
||||
/// 模板 ID
|
||||
pub id: String,
|
||||
/// 模板名称
|
||||
pub name: String,
|
||||
/// 模板描述
|
||||
pub description: String,
|
||||
/// 写作风格
|
||||
pub style: String,
|
||||
/// 语气
|
||||
pub tone: String,
|
||||
/// 目标读者群体
|
||||
pub target_audience: String,
|
||||
/// 适用平台列表
|
||||
#[serde(default)]
|
||||
pub platforms: Vec<String>,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 素材相关类型
|
||||
// ============================================================================
|
||||
|
||||
/// 素材类型
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum MaterialType {
|
||||
/// 文档(PDF、Word 等)
|
||||
Document,
|
||||
/// 图片
|
||||
Image,
|
||||
/// 纯文本
|
||||
Text,
|
||||
/// 数据文件(CSV、JSON 等)
|
||||
Data,
|
||||
/// 链接
|
||||
Link,
|
||||
}
|
||||
|
||||
impl Default for MaterialType {
|
||||
fn default() -> Self {
|
||||
Self::Document
|
||||
}
|
||||
}
|
||||
|
||||
impl MaterialType {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
MaterialType::Document => "document",
|
||||
MaterialType::Image => "image",
|
||||
MaterialType::Text => "text",
|
||||
MaterialType::Data => "data",
|
||||
MaterialType::Link => "link",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_str(s: &str) -> Self {
|
||||
match s.to_lowercase().as_str() {
|
||||
"document" => MaterialType::Document,
|
||||
"image" => MaterialType::Image,
|
||||
"text" => MaterialType::Text,
|
||||
"data" => MaterialType::Data,
|
||||
"link" => MaterialType::Link,
|
||||
_ => MaterialType::Document,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 素材
|
||||
///
|
||||
/// 存储项目级素材,包含文档、图片、文本等参考资料。
|
||||
/// 用于 AI 创作时的引用。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Material {
|
||||
/// 唯一标识
|
||||
pub id: String,
|
||||
/// 所属项目 ID
|
||||
pub project_id: String,
|
||||
/// 素材名称
|
||||
pub name: String,
|
||||
/// 素材类型
|
||||
#[serde(rename = "type")]
|
||||
pub material_type: String,
|
||||
/// 文件路径(本地存储路径)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub file_path: Option<String>,
|
||||
/// 文件大小(字节)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub file_size: Option<i64>,
|
||||
/// MIME 类型
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub mime_type: Option<String>,
|
||||
/// 文本内容(用于 text 类型或提取的内容)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub content: Option<String>,
|
||||
/// 标签列表
|
||||
#[serde(default)]
|
||||
pub tags: Vec<String>,
|
||||
/// 素材描述
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
/// 创建时间(Unix 时间戳)
|
||||
pub created_at: i64,
|
||||
}
|
||||
|
||||
/// 上传素材请求
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct UploadMaterialRequest {
|
||||
/// 所属项目 ID
|
||||
pub project_id: String,
|
||||
/// 素材名称
|
||||
pub name: String,
|
||||
/// 素材类型
|
||||
#[serde(rename = "type")]
|
||||
pub material_type: String,
|
||||
/// 文件路径(上传的临时文件路径)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub file_path: Option<String>,
|
||||
/// 文本内容(用于 text 类型)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub content: Option<String>,
|
||||
/// 标签列表
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tags: Option<Vec<String>>,
|
||||
/// 素材描述
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
/// 更新素材请求
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct MaterialUpdate {
|
||||
/// 素材名称
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub name: Option<String>,
|
||||
/// 标签列表
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tags: Option<Vec<String>>,
|
||||
/// 素材描述
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
/// 素材筛选条件
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct MaterialFilter {
|
||||
/// 按类型筛选
|
||||
#[serde(rename = "type", skip_serializing_if = "Option::is_none")]
|
||||
pub material_type: Option<String>,
|
||||
/// 按标签筛选
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tags: Option<Vec<String>>,
|
||||
/// 搜索关键词
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub search_query: Option<String>,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 排版模板相关类型
|
||||
// ============================================================================
|
||||
|
||||
/// 平台类型
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum Platform {
|
||||
/// 小红书
|
||||
Xiaohongshu,
|
||||
/// 微信公众号
|
||||
Wechat,
|
||||
/// 知乎
|
||||
Zhihu,
|
||||
/// 微博
|
||||
Weibo,
|
||||
/// 抖音
|
||||
Douyin,
|
||||
/// Markdown 通用格式
|
||||
Markdown,
|
||||
}
|
||||
|
||||
impl Default for Platform {
|
||||
fn default() -> Self {
|
||||
Self::Markdown
|
||||
}
|
||||
}
|
||||
|
||||
impl Platform {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Platform::Xiaohongshu => "xiaohongshu",
|
||||
Platform::Wechat => "wechat",
|
||||
Platform::Zhihu => "zhihu",
|
||||
Platform::Weibo => "weibo",
|
||||
Platform::Douyin => "douyin",
|
||||
Platform::Markdown => "markdown",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_str(s: &str) -> Self {
|
||||
match s.to_lowercase().as_str() {
|
||||
"xiaohongshu" => Platform::Xiaohongshu,
|
||||
"wechat" => Platform::Wechat,
|
||||
"zhihu" => Platform::Zhihu,
|
||||
"weibo" => Platform::Weibo,
|
||||
"douyin" => Platform::Douyin,
|
||||
"markdown" => Platform::Markdown,
|
||||
_ => Platform::Markdown,
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取平台显示名称
|
||||
pub fn display_name(&self) -> &'static str {
|
||||
match self {
|
||||
Platform::Xiaohongshu => "小红书",
|
||||
Platform::Wechat => "微信公众号",
|
||||
Platform::Zhihu => "知乎",
|
||||
Platform::Weibo => "微博",
|
||||
Platform::Douyin => "抖音",
|
||||
Platform::Markdown => "Markdown",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Emoji 使用程度
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum EmojiUsage {
|
||||
/// 大量使用
|
||||
Heavy,
|
||||
/// 适度使用
|
||||
Moderate,
|
||||
/// 少量使用
|
||||
Minimal,
|
||||
}
|
||||
|
||||
impl Default for EmojiUsage {
|
||||
fn default() -> Self {
|
||||
Self::Moderate
|
||||
}
|
||||
}
|
||||
|
||||
impl EmojiUsage {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
EmojiUsage::Heavy => "heavy",
|
||||
EmojiUsage::Moderate => "moderate",
|
||||
EmojiUsage::Minimal => "minimal",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_str(s: &str) -> Self {
|
||||
match s.to_lowercase().as_str() {
|
||||
"heavy" => EmojiUsage::Heavy,
|
||||
"moderate" => EmojiUsage::Moderate,
|
||||
"minimal" => EmojiUsage::Minimal,
|
||||
_ => EmojiUsage::Moderate,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 排版模板
|
||||
///
|
||||
/// 存储项目级排版模板,定义输出内容的格式规则。
|
||||
/// 用于 AI 生成内容时的格式指导。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Template {
|
||||
/// 唯一标识
|
||||
pub id: String,
|
||||
/// 所属项目 ID
|
||||
pub project_id: String,
|
||||
/// 模板名称
|
||||
pub name: String,
|
||||
/// 目标平台
|
||||
pub platform: String,
|
||||
/// 标题风格
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub title_style: Option<String>,
|
||||
/// 段落风格
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub paragraph_style: Option<String>,
|
||||
/// 结尾风格
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub ending_style: Option<String>,
|
||||
/// Emoji 使用程度
|
||||
pub emoji_usage: String,
|
||||
/// 话题标签规则
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub hashtag_rules: Option<String>,
|
||||
/// 图片规则
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub image_rules: Option<String>,
|
||||
/// 是否为项目默认模板
|
||||
#[serde(default)]
|
||||
pub is_default: bool,
|
||||
/// 创建时间(Unix 时间戳)
|
||||
pub created_at: i64,
|
||||
/// 更新时间(Unix 时间戳)
|
||||
pub updated_at: i64,
|
||||
}
|
||||
|
||||
/// 创建模板请求
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CreateTemplateRequest {
|
||||
/// 所属项目 ID
|
||||
pub project_id: String,
|
||||
/// 模板名称
|
||||
pub name: String,
|
||||
/// 目标平台
|
||||
pub platform: String,
|
||||
/// 标题风格
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub title_style: Option<String>,
|
||||
/// 段落风格
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub paragraph_style: Option<String>,
|
||||
/// 结尾风格
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub ending_style: Option<String>,
|
||||
/// Emoji 使用程度
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub emoji_usage: Option<String>,
|
||||
/// 话题标签规则
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub hashtag_rules: Option<String>,
|
||||
/// 图片规则
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub image_rules: Option<String>,
|
||||
}
|
||||
|
||||
/// 更新模板请求
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct TemplateUpdate {
|
||||
/// 模板名称
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub name: Option<String>,
|
||||
/// 标题风格
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub title_style: Option<String>,
|
||||
/// 段落风格
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub paragraph_style: Option<String>,
|
||||
/// 结尾风格
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub ending_style: Option<String>,
|
||||
/// Emoji 使用程度
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub emoji_usage: Option<String>,
|
||||
/// 话题标签规则
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub hashtag_rules: Option<String>,
|
||||
/// 图片规则
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub image_rules: Option<String>,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 发布配置相关类型
|
||||
// ============================================================================
|
||||
|
||||
/// 发布配置
|
||||
///
|
||||
/// 存储项目级发布配置,包含平台认证信息和发布历史。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PublishConfig {
|
||||
/// 唯一标识
|
||||
pub id: String,
|
||||
/// 所属项目 ID
|
||||
pub project_id: String,
|
||||
/// 目标平台
|
||||
pub platform: String,
|
||||
/// 是否已配置
|
||||
#[serde(default)]
|
||||
pub is_configured: bool,
|
||||
/// 最后发布时间(Unix 时间戳)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub last_published_at: Option<i64>,
|
||||
/// 发布次数
|
||||
#[serde(default)]
|
||||
pub publish_count: i64,
|
||||
/// 创建时间(Unix 时间戳)
|
||||
pub created_at: i64,
|
||||
/// 更新时间(Unix 时间戳)
|
||||
pub updated_at: i64,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 项目上下文相关类型
|
||||
// ============================================================================
|
||||
|
||||
/// 项目上下文
|
||||
///
|
||||
/// 聚合项目的所有配置信息,用于注入到 AI System Prompt。
|
||||
/// 包含项目基本信息、默认人设、素材列表和默认模板。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ProjectContext {
|
||||
/// 项目信息
|
||||
pub project: crate::workspace::Workspace,
|
||||
/// 默认人设(如果有)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub persona: Option<Persona>,
|
||||
/// 素材列表
|
||||
#[serde(default)]
|
||||
pub materials: Vec<Material>,
|
||||
/// 默认模板(如果有)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub template: Option<Template>,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 测试
|
||||
// ============================================================================
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_material_type_conversion() {
|
||||
assert_eq!(MaterialType::Document.as_str(), "document");
|
||||
assert_eq!(MaterialType::Image.as_str(), "image");
|
||||
assert_eq!(MaterialType::Text.as_str(), "text");
|
||||
assert_eq!(MaterialType::Data.as_str(), "data");
|
||||
assert_eq!(MaterialType::Link.as_str(), "link");
|
||||
|
||||
assert_eq!(MaterialType::from_str("document"), MaterialType::Document);
|
||||
assert_eq!(MaterialType::from_str("IMAGE"), MaterialType::Image);
|
||||
assert_eq!(MaterialType::from_str("unknown"), MaterialType::Document);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_platform_conversion() {
|
||||
assert_eq!(Platform::Xiaohongshu.as_str(), "xiaohongshu");
|
||||
assert_eq!(Platform::Wechat.as_str(), "wechat");
|
||||
assert_eq!(Platform::Markdown.as_str(), "markdown");
|
||||
|
||||
assert_eq!(Platform::from_str("xiaohongshu"), Platform::Xiaohongshu);
|
||||
assert_eq!(Platform::from_str("WECHAT"), Platform::Wechat);
|
||||
assert_eq!(Platform::from_str("unknown"), Platform::Markdown);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_platform_display_name() {
|
||||
assert_eq!(Platform::Xiaohongshu.display_name(), "小红书");
|
||||
assert_eq!(Platform::Wechat.display_name(), "微信公众号");
|
||||
assert_eq!(Platform::Markdown.display_name(), "Markdown");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_emoji_usage_conversion() {
|
||||
assert_eq!(EmojiUsage::Heavy.as_str(), "heavy");
|
||||
assert_eq!(EmojiUsage::Moderate.as_str(), "moderate");
|
||||
assert_eq!(EmojiUsage::Minimal.as_str(), "minimal");
|
||||
|
||||
assert_eq!(EmojiUsage::from_str("heavy"), EmojiUsage::Heavy);
|
||||
assert_eq!(EmojiUsage::from_str("MODERATE"), EmojiUsage::Moderate);
|
||||
assert_eq!(EmojiUsage::from_str("unknown"), EmojiUsage::Moderate);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_persona_serialization() {
|
||||
let persona = Persona {
|
||||
id: "test-id".to_string(),
|
||||
project_id: "project-1".to_string(),
|
||||
name: "测试人设".to_string(),
|
||||
description: Some("这是一个测试人设".to_string()),
|
||||
style: "专业".to_string(),
|
||||
tone: Some("正式".to_string()),
|
||||
target_audience: Some("技术人员".to_string()),
|
||||
forbidden_words: vec!["禁词1".to_string()],
|
||||
preferred_words: vec!["偏好词1".to_string()],
|
||||
examples: None,
|
||||
platforms: vec!["xiaohongshu".to_string()],
|
||||
is_default: false,
|
||||
created_at: 1234567890,
|
||||
updated_at: 1234567890,
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&persona).unwrap();
|
||||
let parsed: Persona = serde_json::from_str(&json).unwrap();
|
||||
|
||||
assert_eq!(parsed.id, persona.id);
|
||||
assert_eq!(parsed.name, persona.name);
|
||||
assert_eq!(parsed.style, persona.style);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_material_serialization() {
|
||||
let material = Material {
|
||||
id: "mat-1".to_string(),
|
||||
project_id: "project-1".to_string(),
|
||||
name: "测试素材.pdf".to_string(),
|
||||
material_type: "document".to_string(),
|
||||
file_path: Some("/path/to/file.pdf".to_string()),
|
||||
file_size: Some(1024),
|
||||
mime_type: Some("application/pdf".to_string()),
|
||||
content: None,
|
||||
tags: vec!["标签1".to_string()],
|
||||
description: Some("测试描述".to_string()),
|
||||
created_at: 1234567890,
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&material).unwrap();
|
||||
assert!(json.contains("\"type\":\"document\""));
|
||||
|
||||
let parsed: Material = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(parsed.material_type, "document");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_template_serialization() {
|
||||
let template = Template {
|
||||
id: "tpl-1".to_string(),
|
||||
project_id: "project-1".to_string(),
|
||||
name: "小红书模板".to_string(),
|
||||
platform: "xiaohongshu".to_string(),
|
||||
title_style: Some("吸引眼球".to_string()),
|
||||
paragraph_style: Some("简短有力".to_string()),
|
||||
ending_style: Some("引导互动".to_string()),
|
||||
emoji_usage: "heavy".to_string(),
|
||||
hashtag_rules: Some("3-5个相关话题".to_string()),
|
||||
image_rules: Some("配图要精美".to_string()),
|
||||
is_default: true,
|
||||
created_at: 1234567890,
|
||||
updated_at: 1234567890,
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&template).unwrap();
|
||||
let parsed: Template = serde_json::from_str(&json).unwrap();
|
||||
|
||||
assert_eq!(parsed.platform, "xiaohongshu");
|
||||
assert_eq!(parsed.emoji_usage, "heavy");
|
||||
assert!(parsed.is_default);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_persona_request() {
|
||||
let req = CreatePersonaRequest {
|
||||
project_id: "project-1".to_string(),
|
||||
name: "新人设".to_string(),
|
||||
description: None,
|
||||
style: "轻松".to_string(),
|
||||
tone: Some("活泼".to_string()),
|
||||
target_audience: None,
|
||||
forbidden_words: Some(vec!["禁词".to_string()]),
|
||||
preferred_words: None,
|
||||
examples: None,
|
||||
platforms: Some(vec!["wechat".to_string()]),
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&req).unwrap();
|
||||
let parsed: CreatePersonaRequest = serde_json::from_str(&json).unwrap();
|
||||
|
||||
assert_eq!(parsed.project_id, "project-1");
|
||||
assert_eq!(parsed.style, "轻松");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_upload_material_request() {
|
||||
let req = UploadMaterialRequest {
|
||||
project_id: "project-1".to_string(),
|
||||
name: "文档.pdf".to_string(),
|
||||
material_type: "document".to_string(),
|
||||
file_path: Some("/tmp/upload.pdf".to_string()),
|
||||
content: None,
|
||||
tags: Some(vec!["参考".to_string()]),
|
||||
description: Some("参考文档".to_string()),
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&req).unwrap();
|
||||
assert!(json.contains("\"type\":\"document\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_default_values() {
|
||||
assert_eq!(MaterialType::default(), MaterialType::Document);
|
||||
assert_eq!(Platform::default(), Platform::Markdown);
|
||||
assert_eq!(EmojiUsage::default(), EmojiUsage::Moderate);
|
||||
}
|
||||
}
|
||||
@@ -30,8 +30,8 @@ async fn test_request_processor_components() {
|
||||
// 验证路由器可以正常使用
|
||||
{
|
||||
let router = processor.router.read().await;
|
||||
// 默认使用 Kiro
|
||||
assert_eq!(router.default_provider(), Some(ProviderType::Kiro));
|
||||
// 默认路由器为空,等待从配置加载
|
||||
assert_eq!(router.default_provider(), None);
|
||||
}
|
||||
|
||||
// 验证映射器可以正常使用
|
||||
@@ -117,13 +117,13 @@ async fn test_route_model_returns_default() {
|
||||
let pool_service = Arc::new(ProviderPoolService::new());
|
||||
let processor = RequestProcessor::with_defaults(pool_service);
|
||||
|
||||
// 所有模型都应返回默认 Provider
|
||||
// 默认路由器为空,所有模型都应返回 None
|
||||
let (provider, is_default) = processor.route_model("gemini-2.5-flash").await;
|
||||
assert_eq!(provider, Some(ProviderType::Kiro));
|
||||
assert_eq!(provider, None);
|
||||
assert!(is_default);
|
||||
|
||||
let (provider, is_default) = processor.route_model("claude-sonnet-4-5").await;
|
||||
assert_eq!(provider, Some(ProviderType::Kiro));
|
||||
assert_eq!(provider, None);
|
||||
assert!(is_default);
|
||||
}
|
||||
|
||||
@@ -136,11 +136,11 @@ async fn test_route_for_context() {
|
||||
let mut ctx = RequestContext::new("gemini-2.5-flash".to_string());
|
||||
ctx.set_resolved_model("gemini-2.5-flash".to_string());
|
||||
|
||||
// 路由并更新上下文
|
||||
// 路由并更新上下文(默认路由器为空,返回 None)
|
||||
let provider = processor.route_for_context(&mut ctx).await;
|
||||
|
||||
assert_eq!(provider, Some(ProviderType::Kiro));
|
||||
assert_eq!(ctx.provider, Some(ProviderType::Kiro));
|
||||
assert_eq!(provider, None);
|
||||
assert_eq!(ctx.provider, None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -158,11 +158,11 @@ async fn test_resolve_and_route() {
|
||||
let mut ctx = RequestContext::new("gpt-4".to_string());
|
||||
let provider = processor.resolve_and_route(&mut ctx).await;
|
||||
|
||||
// gpt-4 -> claude-sonnet-4-5 -> Kiro (默认)
|
||||
// gpt-4 -> claude-sonnet-4-5 -> None (默认路由器为空)
|
||||
assert_eq!(ctx.original_model, "gpt-4");
|
||||
assert_eq!(ctx.resolved_model, "claude-sonnet-4-5");
|
||||
assert_eq!(provider, Some(ProviderType::Kiro));
|
||||
assert_eq!(ctx.provider, Some(ProviderType::Kiro));
|
||||
assert_eq!(provider, None);
|
||||
assert_eq!(ctx.provider, None);
|
||||
}
|
||||
|
||||
// ========== 属性测试 (Property-Based Tests) ==========
|
||||
|
||||
@@ -1512,6 +1512,17 @@ fn transform_to_codex_format(
|
||||
let instructions = get_codex_instructions_for_model(model);
|
||||
codex_request["instructions"] = serde_json::json!(instructions);
|
||||
|
||||
// 处理可选参数:temperature, max_tokens (-> max_output_tokens), top_p
|
||||
if let Some(temp) = request.get("temperature") {
|
||||
codex_request["temperature"] = temp.clone();
|
||||
}
|
||||
if let Some(max_tokens) = request.get("max_tokens") {
|
||||
codex_request["max_output_tokens"] = max_tokens.clone();
|
||||
}
|
||||
if let Some(top_p) = request.get("top_p") {
|
||||
codex_request["top_p"] = top_p.clone();
|
||||
}
|
||||
|
||||
// Build tools array if present
|
||||
if let Some(tools) = request["tools"].as_array() {
|
||||
let codex_tools: Vec<serde_json::Value> = tools
|
||||
|
||||
@@ -127,7 +127,7 @@ mod tests {
|
||||
#[test]
|
||||
fn test_default_shortcut() {
|
||||
let config = ScreenshotChatConfig::default();
|
||||
assert_eq!(config.shortcut, "CommandOrControl+Shift+S");
|
||||
assert_eq!(config.shortcut, "CommandOrControl+Alt+Q");
|
||||
assert!(!config.enabled);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,9 @@
|
||||
- `kiro_event_service.rs` - Kiro 事件服务
|
||||
- `machine_id_service.rs` - 机器 ID 服务
|
||||
- `model_registry_service.rs` - 模型注册表服务
|
||||
- `persona_service.rs` - 人设服务(创建、列表、更新、删除、设置默认、模板)
|
||||
- `material_service.rs` - 素材服务(上传、存储、删除、内容读取)
|
||||
- `template_service.rs` - 排版模板服务(创建、列表、更新、删除、设置默认)
|
||||
- `update_check_service.rs` - 自动更新检查服务(每日检查、系统通知)
|
||||
- `update_window.rs` - 更新提醒独立窗口管理
|
||||
|
||||
|
||||
@@ -134,7 +134,7 @@ mod tests {
|
||||
let test_cases = [
|
||||
json!({}),
|
||||
json!({"simple": "value"}),
|
||||
json!({"number": 42, "float": 3.14, "bool": true, "null": null}),
|
||||
json!({"number": 42, "float": 3.15, "bool": true, "null": null}),
|
||||
json!({"nested": {"deep": {"value": "here"}}}),
|
||||
json!({"array": [1, 2, 3, "four", {"five": 5}]}),
|
||||
json!({"unicode": "你好世界 🌍", "special": "\"quotes\" and \\backslash"}),
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,10 +7,13 @@ pub mod general_chat;
|
||||
pub mod kiro_event_service;
|
||||
pub mod live_sync;
|
||||
pub mod machine_id_service;
|
||||
pub mod material_service;
|
||||
pub mod mcp_service;
|
||||
pub mod mcp_sync;
|
||||
pub mod model_registry_service;
|
||||
pub mod model_service;
|
||||
pub mod persona_service;
|
||||
pub mod project_context_builder;
|
||||
pub mod prompt_service;
|
||||
pub mod prompt_sync;
|
||||
pub mod provider_pool_service;
|
||||
@@ -18,6 +21,7 @@ pub mod session_context_service;
|
||||
pub mod skill_service;
|
||||
pub mod switch;
|
||||
pub mod sysinfo_service;
|
||||
pub mod template_service;
|
||||
pub mod token_cache_service;
|
||||
pub mod tool_hooks_service;
|
||||
pub mod update_check_service;
|
||||
|
||||
@@ -0,0 +1,575 @@
|
||||
//! 人设服务层
|
||||
//!
|
||||
//! 提供人设(Persona)的业务逻辑,包括:
|
||||
//! - 创建、获取、列表、更新、删除人设
|
||||
//! - 设置项目默认人设
|
||||
//! - 获取人设模板列表
|
||||
//!
|
||||
//! ## 相关需求
|
||||
//! - Requirements 6.1: 人设列表显示
|
||||
//! - Requirements 6.2: 创建人设按钮
|
||||
//! - Requirements 6.3: 人设创建表单
|
||||
//! - Requirements 6.4: 设置默认人设
|
||||
//! - Requirements 6.5: 人设模板
|
||||
//! - Requirements 6.6: 人设删除确认
|
||||
|
||||
use rusqlite::Connection;
|
||||
|
||||
use crate::database::dao::persona_dao::PersonaDao;
|
||||
use crate::errors::project_error::PersonaError;
|
||||
use crate::models::project_model::{CreatePersonaRequest, Persona, PersonaTemplate, PersonaUpdate};
|
||||
|
||||
// ============================================================================
|
||||
// 人设服务
|
||||
// ============================================================================
|
||||
|
||||
/// 人设服务
|
||||
///
|
||||
/// 封装人设的业务逻辑,调用 PersonaDao 进行数据操作。
|
||||
pub struct PersonaService;
|
||||
|
||||
impl PersonaService {
|
||||
// ------------------------------------------------------------------------
|
||||
// 创建人设
|
||||
// ------------------------------------------------------------------------
|
||||
|
||||
/// 创建新人设
|
||||
///
|
||||
/// # 参数
|
||||
/// - `conn`: 数据库连接
|
||||
/// - `req`: 创建人设请求
|
||||
///
|
||||
/// # 返回
|
||||
/// - 成功返回创建的人设
|
||||
/// - 失败返回 PersonaError
|
||||
///
|
||||
/// # 示例
|
||||
/// ```ignore
|
||||
/// let req = CreatePersonaRequest {
|
||||
/// project_id: "project-1".to_string(),
|
||||
/// name: "专业写手".to_string(),
|
||||
/// style: "专业".to_string(),
|
||||
/// ..Default::default()
|
||||
/// };
|
||||
/// let persona = PersonaService::create_persona(&conn, req)?;
|
||||
/// ```
|
||||
pub fn create_persona(
|
||||
conn: &Connection,
|
||||
req: CreatePersonaRequest,
|
||||
) -> Result<Persona, PersonaError> {
|
||||
// 验证项目存在
|
||||
Self::validate_project_exists(conn, &req.project_id)?;
|
||||
|
||||
// 调用 DAO 创建人设
|
||||
PersonaDao::create(conn, &req)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
// 获取人设列表
|
||||
// ------------------------------------------------------------------------
|
||||
|
||||
/// 获取项目的人设列表
|
||||
///
|
||||
/// # 参数
|
||||
/// - `conn`: 数据库连接
|
||||
/// - `project_id`: 项目 ID
|
||||
///
|
||||
/// # 返回
|
||||
/// - 成功返回人设列表
|
||||
/// - 失败返回 PersonaError
|
||||
pub fn list_personas(
|
||||
conn: &Connection,
|
||||
project_id: &str,
|
||||
) -> Result<Vec<Persona>, PersonaError> {
|
||||
PersonaDao::list(conn, project_id)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
// 获取单个人设
|
||||
// ------------------------------------------------------------------------
|
||||
|
||||
/// 获取单个人设
|
||||
///
|
||||
/// # 参数
|
||||
/// - `conn`: 数据库连接
|
||||
/// - `id`: 人设 ID
|
||||
///
|
||||
/// # 返回
|
||||
/// - 成功返回 Option<Persona>
|
||||
/// - 失败返回 PersonaError
|
||||
pub fn get_persona(conn: &Connection, id: &str) -> Result<Option<Persona>, PersonaError> {
|
||||
PersonaDao::get(conn, id)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
// 更新人设
|
||||
// ------------------------------------------------------------------------
|
||||
|
||||
/// 更新人设
|
||||
///
|
||||
/// # 参数
|
||||
/// - `conn`: 数据库连接
|
||||
/// - `id`: 人设 ID
|
||||
/// - `update`: 更新内容
|
||||
///
|
||||
/// # 返回
|
||||
/// - 成功返回更新后的人设
|
||||
/// - 失败返回 PersonaError
|
||||
pub fn update_persona(
|
||||
conn: &Connection,
|
||||
id: &str,
|
||||
update: PersonaUpdate,
|
||||
) -> Result<Persona, PersonaError> {
|
||||
PersonaDao::update(conn, id, &update)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
// 删除人设
|
||||
// ------------------------------------------------------------------------
|
||||
|
||||
/// 删除人设
|
||||
///
|
||||
/// # 参数
|
||||
/// - `conn`: 数据库连接
|
||||
/// - `id`: 人设 ID
|
||||
///
|
||||
/// # 返回
|
||||
/// - 成功返回 ()
|
||||
/// - 失败返回 PersonaError
|
||||
pub fn delete_persona(conn: &Connection, id: &str) -> Result<(), PersonaError> {
|
||||
PersonaDao::delete(conn, id)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
// 设置默认人设
|
||||
// ------------------------------------------------------------------------
|
||||
|
||||
/// 设置项目的默认人设
|
||||
///
|
||||
/// 将指定人设设为默认,同时取消该项目其他人设的默认状态。
|
||||
///
|
||||
/// # 参数
|
||||
/// - `conn`: 数据库连接
|
||||
/// - `project_id`: 项目 ID
|
||||
/// - `persona_id`: 要设为默认的人设 ID
|
||||
///
|
||||
/// # 返回
|
||||
/// - 成功返回 ()
|
||||
/// - 失败返回 PersonaError
|
||||
pub fn set_default_persona(
|
||||
conn: &Connection,
|
||||
project_id: &str,
|
||||
persona_id: &str,
|
||||
) -> Result<(), PersonaError> {
|
||||
PersonaDao::set_default(conn, project_id, persona_id)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
// 获取默认人设
|
||||
// ------------------------------------------------------------------------
|
||||
|
||||
/// 获取项目的默认人设
|
||||
///
|
||||
/// # 参数
|
||||
/// - `conn`: 数据库连接
|
||||
/// - `project_id`: 项目 ID
|
||||
///
|
||||
/// # 返回
|
||||
/// - 成功返回 Option<Persona>
|
||||
/// - 失败返回 PersonaError
|
||||
pub fn get_default_persona(
|
||||
conn: &Connection,
|
||||
project_id: &str,
|
||||
) -> Result<Option<Persona>, PersonaError> {
|
||||
PersonaDao::get_default(conn, project_id)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
// 人设模板
|
||||
// ------------------------------------------------------------------------
|
||||
|
||||
/// 获取人设模板列表
|
||||
///
|
||||
/// 返回预定义的人设模板,用于快速创建人设。
|
||||
/// 模板包含常见的写作风格配置。
|
||||
///
|
||||
/// # 返回
|
||||
/// - 人设模板列表
|
||||
pub fn list_persona_templates() -> Vec<PersonaTemplate> {
|
||||
vec![
|
||||
PersonaTemplate {
|
||||
id: "professional-writer".to_string(),
|
||||
name: "专业写手".to_string(),
|
||||
description: "适合撰写专业技术文章、行业分析报告".to_string(),
|
||||
style: "专业严谨".to_string(),
|
||||
tone: "正式".to_string(),
|
||||
target_audience: "专业人士、行业从业者".to_string(),
|
||||
platforms: vec!["zhihu".to_string(), "wechat".to_string()],
|
||||
},
|
||||
PersonaTemplate {
|
||||
id: "lifestyle-blogger".to_string(),
|
||||
name: "生活博主".to_string(),
|
||||
description: "适合分享生活日常、好物推荐、美食探店".to_string(),
|
||||
style: "轻松活泼".to_string(),
|
||||
tone: "亲切".to_string(),
|
||||
target_audience: "年轻女性、生活爱好者".to_string(),
|
||||
platforms: vec!["xiaohongshu".to_string(), "douyin".to_string()],
|
||||
},
|
||||
PersonaTemplate {
|
||||
id: "tech-enthusiast".to_string(),
|
||||
name: "科技达人".to_string(),
|
||||
description: "适合数码产品评测、科技资讯分享".to_string(),
|
||||
style: "客观理性".to_string(),
|
||||
tone: "专业但易懂".to_string(),
|
||||
target_audience: "科技爱好者、数码发烧友".to_string(),
|
||||
platforms: vec!["zhihu".to_string(), "weibo".to_string()],
|
||||
},
|
||||
PersonaTemplate {
|
||||
id: "emotional-writer".to_string(),
|
||||
name: "情感作者".to_string(),
|
||||
description: "适合情感故事、心灵鸡汤、人生感悟".to_string(),
|
||||
style: "温暖细腻".to_string(),
|
||||
tone: "感性".to_string(),
|
||||
target_audience: "追求情感共鸣的读者".to_string(),
|
||||
platforms: vec!["wechat".to_string(), "xiaohongshu".to_string()],
|
||||
},
|
||||
PersonaTemplate {
|
||||
id: "humor-creator".to_string(),
|
||||
name: "幽默段子手".to_string(),
|
||||
description: "适合搞笑内容、段子创作、娱乐吐槽".to_string(),
|
||||
style: "幽默诙谐".to_string(),
|
||||
tone: "轻松搞笑".to_string(),
|
||||
target_audience: "追求娱乐放松的用户".to_string(),
|
||||
platforms: vec!["weibo".to_string(), "douyin".to_string()],
|
||||
},
|
||||
PersonaTemplate {
|
||||
id: "knowledge-sharer".to_string(),
|
||||
name: "知识分享者".to_string(),
|
||||
description: "适合知识科普、学习方法、技能教程".to_string(),
|
||||
style: "清晰易懂".to_string(),
|
||||
tone: "耐心友好".to_string(),
|
||||
target_audience: "学习者、求知者".to_string(),
|
||||
platforms: vec![
|
||||
"zhihu".to_string(),
|
||||
"wechat".to_string(),
|
||||
"douyin".to_string(),
|
||||
],
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
// 辅助方法
|
||||
// ------------------------------------------------------------------------
|
||||
|
||||
/// 验证项目是否存在
|
||||
fn validate_project_exists(conn: &Connection, project_id: &str) -> Result<(), PersonaError> {
|
||||
let mut stmt = conn
|
||||
.prepare("SELECT 1 FROM workspaces WHERE id = ?")
|
||||
.map_err(PersonaError::DatabaseError)?;
|
||||
|
||||
let exists = stmt
|
||||
.exists([project_id])
|
||||
.map_err(PersonaError::DatabaseError)?;
|
||||
|
||||
if !exists {
|
||||
return Err(PersonaError::ProjectNotFound(project_id.to_string()));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 测试
|
||||
// ============================================================================
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::database::schema::create_tables;
|
||||
|
||||
/// 创建测试数据库连接
|
||||
fn setup_test_db() -> Connection {
|
||||
let conn = Connection::open_in_memory().unwrap();
|
||||
create_tables(&conn).unwrap();
|
||||
conn
|
||||
}
|
||||
|
||||
/// 创建测试项目
|
||||
fn create_test_project(conn: &Connection, id: &str) {
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
conn.execute(
|
||||
"INSERT INTO workspaces (id, name, workspace_type, root_path, created_at, updated_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
||||
rusqlite::params![
|
||||
id,
|
||||
"测试项目",
|
||||
"persistent",
|
||||
format!("/test/{}", id),
|
||||
now,
|
||||
now
|
||||
],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_persona_success() {
|
||||
let conn = setup_test_db();
|
||||
create_test_project(&conn, "project-1");
|
||||
|
||||
let req = CreatePersonaRequest {
|
||||
project_id: "project-1".to_string(),
|
||||
name: "测试人设".to_string(),
|
||||
description: Some("测试描述".to_string()),
|
||||
style: "专业".to_string(),
|
||||
tone: Some("正式".to_string()),
|
||||
target_audience: None,
|
||||
forbidden_words: None,
|
||||
preferred_words: None,
|
||||
examples: None,
|
||||
platforms: None,
|
||||
};
|
||||
|
||||
let persona = PersonaService::create_persona(&conn, req).unwrap();
|
||||
|
||||
assert!(!persona.id.is_empty());
|
||||
assert_eq!(persona.project_id, "project-1");
|
||||
assert_eq!(persona.name, "测试人设");
|
||||
assert_eq!(persona.style, "专业");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_persona_project_not_found() {
|
||||
let conn = setup_test_db();
|
||||
|
||||
let req = CreatePersonaRequest {
|
||||
project_id: "nonexistent".to_string(),
|
||||
name: "测试人设".to_string(),
|
||||
description: None,
|
||||
style: "专业".to_string(),
|
||||
tone: None,
|
||||
target_audience: None,
|
||||
forbidden_words: None,
|
||||
preferred_words: None,
|
||||
examples: None,
|
||||
platforms: None,
|
||||
};
|
||||
|
||||
let result = PersonaService::create_persona(&conn, req);
|
||||
assert!(result.is_err());
|
||||
|
||||
match result.unwrap_err() {
|
||||
PersonaError::ProjectNotFound(id) => assert_eq!(id, "nonexistent"),
|
||||
_ => panic!("期望 ProjectNotFound 错误"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_list_personas() {
|
||||
let conn = setup_test_db();
|
||||
create_test_project(&conn, "project-1");
|
||||
|
||||
// 创建两个人设
|
||||
for i in 1..=2 {
|
||||
let req = CreatePersonaRequest {
|
||||
project_id: "project-1".to_string(),
|
||||
name: format!("人设{}", i),
|
||||
description: None,
|
||||
style: "测试".to_string(),
|
||||
tone: None,
|
||||
target_audience: None,
|
||||
forbidden_words: None,
|
||||
preferred_words: None,
|
||||
examples: None,
|
||||
platforms: None,
|
||||
};
|
||||
PersonaService::create_persona(&conn, req).unwrap();
|
||||
}
|
||||
|
||||
let personas = PersonaService::list_personas(&conn, "project-1").unwrap();
|
||||
assert_eq!(personas.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_persona() {
|
||||
let conn = setup_test_db();
|
||||
create_test_project(&conn, "project-1");
|
||||
|
||||
let req = CreatePersonaRequest {
|
||||
project_id: "project-1".to_string(),
|
||||
name: "测试人设".to_string(),
|
||||
description: None,
|
||||
style: "测试".to_string(),
|
||||
tone: None,
|
||||
target_audience: None,
|
||||
forbidden_words: None,
|
||||
preferred_words: None,
|
||||
examples: None,
|
||||
platforms: None,
|
||||
};
|
||||
|
||||
let created = PersonaService::create_persona(&conn, req).unwrap();
|
||||
let fetched = PersonaService::get_persona(&conn, &created.id).unwrap();
|
||||
|
||||
assert!(fetched.is_some());
|
||||
assert_eq!(fetched.unwrap().id, created.id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_persona() {
|
||||
let conn = setup_test_db();
|
||||
create_test_project(&conn, "project-1");
|
||||
|
||||
let req = CreatePersonaRequest {
|
||||
project_id: "project-1".to_string(),
|
||||
name: "原始名称".to_string(),
|
||||
description: None,
|
||||
style: "原始风格".to_string(),
|
||||
tone: None,
|
||||
target_audience: None,
|
||||
forbidden_words: None,
|
||||
preferred_words: None,
|
||||
examples: None,
|
||||
platforms: None,
|
||||
};
|
||||
|
||||
let created = PersonaService::create_persona(&conn, req).unwrap();
|
||||
|
||||
let update = PersonaUpdate {
|
||||
name: Some("更新后名称".to_string()),
|
||||
style: Some("更新后风格".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let updated = PersonaService::update_persona(&conn, &created.id, update).unwrap();
|
||||
|
||||
assert_eq!(updated.name, "更新后名称");
|
||||
assert_eq!(updated.style, "更新后风格");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_delete_persona() {
|
||||
let conn = setup_test_db();
|
||||
create_test_project(&conn, "project-1");
|
||||
|
||||
let req = CreatePersonaRequest {
|
||||
project_id: "project-1".to_string(),
|
||||
name: "待删除人设".to_string(),
|
||||
description: None,
|
||||
style: "测试".to_string(),
|
||||
tone: None,
|
||||
target_audience: None,
|
||||
forbidden_words: None,
|
||||
preferred_words: None,
|
||||
examples: None,
|
||||
platforms: None,
|
||||
};
|
||||
|
||||
let created = PersonaService::create_persona(&conn, req).unwrap();
|
||||
|
||||
// 验证人设存在
|
||||
assert!(PersonaService::get_persona(&conn, &created.id)
|
||||
.unwrap()
|
||||
.is_some());
|
||||
|
||||
// 删除人设
|
||||
PersonaService::delete_persona(&conn, &created.id).unwrap();
|
||||
|
||||
// 验证人设已删除
|
||||
assert!(PersonaService::get_persona(&conn, &created.id)
|
||||
.unwrap()
|
||||
.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_set_default_persona() {
|
||||
let conn = setup_test_db();
|
||||
create_test_project(&conn, "project-1");
|
||||
|
||||
// 创建两个人设
|
||||
let req1 = CreatePersonaRequest {
|
||||
project_id: "project-1".to_string(),
|
||||
name: "人设1".to_string(),
|
||||
description: None,
|
||||
style: "测试".to_string(),
|
||||
tone: None,
|
||||
target_audience: None,
|
||||
forbidden_words: None,
|
||||
preferred_words: None,
|
||||
examples: None,
|
||||
platforms: None,
|
||||
};
|
||||
let persona1 = PersonaService::create_persona(&conn, req1).unwrap();
|
||||
|
||||
let req2 = CreatePersonaRequest {
|
||||
project_id: "project-1".to_string(),
|
||||
name: "人设2".to_string(),
|
||||
description: None,
|
||||
style: "测试".to_string(),
|
||||
tone: None,
|
||||
target_audience: None,
|
||||
forbidden_words: None,
|
||||
preferred_words: None,
|
||||
examples: None,
|
||||
platforms: None,
|
||||
};
|
||||
let persona2 = PersonaService::create_persona(&conn, req2).unwrap();
|
||||
|
||||
// 设置人设1为默认
|
||||
PersonaService::set_default_persona(&conn, "project-1", &persona1.id).unwrap();
|
||||
|
||||
let default = PersonaService::get_default_persona(&conn, "project-1").unwrap();
|
||||
assert!(default.is_some());
|
||||
assert_eq!(default.unwrap().id, persona1.id);
|
||||
|
||||
// 设置人设2为默认,人设1应该不再是默认
|
||||
PersonaService::set_default_persona(&conn, "project-1", &persona2.id).unwrap();
|
||||
|
||||
let default = PersonaService::get_default_persona(&conn, "project-1").unwrap();
|
||||
assert!(default.is_some());
|
||||
assert_eq!(default.unwrap().id, persona2.id);
|
||||
|
||||
// 验证只有一个默认人设
|
||||
let personas = PersonaService::list_personas(&conn, "project-1").unwrap();
|
||||
let default_count = personas.iter().filter(|p| p.is_default).count();
|
||||
assert_eq!(default_count, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_list_persona_templates() {
|
||||
let templates = PersonaService::list_persona_templates();
|
||||
|
||||
// 验证模板数量
|
||||
assert!(!templates.is_empty());
|
||||
assert!(templates.len() >= 6);
|
||||
|
||||
// 验证模板内容
|
||||
let professional = templates.iter().find(|t| t.id == "professional-writer");
|
||||
assert!(professional.is_some());
|
||||
let professional = professional.unwrap();
|
||||
assert_eq!(professional.name, "专业写手");
|
||||
assert!(!professional.platforms.is_empty());
|
||||
|
||||
// 验证所有模板都有必要字段
|
||||
for template in &templates {
|
||||
assert!(!template.id.is_empty());
|
||||
assert!(!template.name.is_empty());
|
||||
assert!(!template.description.is_empty());
|
||||
assert!(!template.style.is_empty());
|
||||
assert!(!template.tone.is_empty());
|
||||
assert!(!template.target_audience.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_default_persona_none() {
|
||||
let conn = setup_test_db();
|
||||
create_test_project(&conn, "project-1");
|
||||
|
||||
// 没有设置默认人设时应返回 None
|
||||
let default = PersonaService::get_default_persona(&conn, "project-1").unwrap();
|
||||
assert!(default.is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,757 @@
|
||||
//! 项目上下文构建器
|
||||
//!
|
||||
//! 提供项目上下文的构建功能,包括:
|
||||
//! - 加载项目配置(人设、素材、模板)
|
||||
//! - 构建 AI System Prompt
|
||||
//! - 条件性包含各个 section
|
||||
//!
|
||||
//! ## 相关需求
|
||||
//! - Requirements 10.1: 加载项目上下文
|
||||
//! - Requirements 10.2: 构建 system_prompt
|
||||
//! - Requirements 10.3: 通过 SessionConfig 传递
|
||||
//! - Requirements 10.4: 无人设时省略 persona section
|
||||
//! - Requirements 10.5: 无素材时省略 materials section
|
||||
//! - Requirements 10.6: 无模板时省略 template section
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use chrono::Utc;
|
||||
use rusqlite::Connection;
|
||||
use tracing::{debug, warn};
|
||||
|
||||
use crate::errors::project_error::ProjectError;
|
||||
use crate::models::project_model::{Material, Persona, ProjectContext, Template};
|
||||
use crate::services::material_service::MaterialService;
|
||||
use crate::services::persona_service::PersonaService;
|
||||
use crate::services::template_service::TemplateService;
|
||||
use crate::workspace::{Workspace, WorkspaceSettings, WorkspaceType};
|
||||
|
||||
// ============================================================================
|
||||
// 项目上下文构建器
|
||||
// ============================================================================
|
||||
|
||||
/// 项目上下文构建器
|
||||
///
|
||||
/// 负责加载项目的完整上下文(人设、素材、模板),
|
||||
/// 并将其转换为 AI 可理解的 System Prompt。
|
||||
pub struct ProjectContextBuilder;
|
||||
|
||||
impl ProjectContextBuilder {
|
||||
// ------------------------------------------------------------------------
|
||||
// 构建项目上下文
|
||||
// ------------------------------------------------------------------------
|
||||
|
||||
/// 构建完整的项目上下文
|
||||
///
|
||||
/// 加载项目的所有配置信息,包括:
|
||||
/// - 项目基本信息
|
||||
/// - 默认人设(如果有)
|
||||
/// - 素材列表
|
||||
/// - 默认模板(如果有)
|
||||
///
|
||||
/// # 参数
|
||||
/// - `conn`: 数据库连接
|
||||
/// - `project_id`: 项目 ID
|
||||
///
|
||||
/// # 返回
|
||||
/// - 成功返回 ProjectContext
|
||||
/// - 失败返回 ProjectError
|
||||
///
|
||||
/// # 示例
|
||||
/// ```ignore
|
||||
/// let context = ProjectContextBuilder::build_context(&conn, "project-1")?;
|
||||
/// let prompt = ProjectContextBuilder::build_system_prompt(&context);
|
||||
/// ```
|
||||
pub fn build_context(
|
||||
conn: &Connection,
|
||||
project_id: &str,
|
||||
) -> Result<ProjectContext, ProjectError> {
|
||||
debug!(project_id = %project_id, "开始构建项目上下文");
|
||||
|
||||
// 1. 加载项目基本信息
|
||||
let project = Self::load_project(conn, project_id)?;
|
||||
|
||||
// 2. 加载默认人设(可选)
|
||||
let persona = Self::load_default_persona(conn, project_id);
|
||||
|
||||
// 3. 加载素材列表
|
||||
let materials = Self::load_materials(conn, project_id);
|
||||
|
||||
// 4. 加载默认模板(可选)
|
||||
let template = Self::load_default_template(conn, project_id);
|
||||
|
||||
debug!(
|
||||
project_id = %project_id,
|
||||
has_persona = persona.is_some(),
|
||||
material_count = materials.len(),
|
||||
has_template = template.is_some(),
|
||||
"项目上下文构建完成"
|
||||
);
|
||||
|
||||
Ok(ProjectContext {
|
||||
project,
|
||||
persona,
|
||||
materials,
|
||||
template,
|
||||
})
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
// 构建 System Prompt
|
||||
// ------------------------------------------------------------------------
|
||||
|
||||
/// 将项目上下文转换为 System Prompt
|
||||
///
|
||||
/// 根据项目配置构建结构化的 AI 提示词,包含:
|
||||
/// - 人设信息(如果有)
|
||||
/// - 素材引用(如果有)
|
||||
/// - 排版规则(如果有)
|
||||
///
|
||||
/// # 参数
|
||||
/// - `context`: 项目上下文
|
||||
///
|
||||
/// # 返回
|
||||
/// - 构建好的 System Prompt 字符串
|
||||
///
|
||||
/// # 注意
|
||||
/// 各 section 根据数据是否存在条件性包含,
|
||||
/// 避免生成空的或无意义的提示词部分。
|
||||
pub fn build_system_prompt(context: &ProjectContext) -> String {
|
||||
let mut sections: Vec<String> = Vec::new();
|
||||
|
||||
// 添加项目基本信息
|
||||
sections.push(Self::format_project_header(&context.project));
|
||||
|
||||
// 条件性添加人设 section
|
||||
if let Some(ref persona) = context.persona {
|
||||
sections.push(Self::format_persona(persona));
|
||||
}
|
||||
|
||||
// 条件性添加素材 section
|
||||
if !context.materials.is_empty() {
|
||||
sections.push(Self::format_materials(&context.materials));
|
||||
}
|
||||
|
||||
// 条件性添加模板 section
|
||||
if let Some(ref template) = context.template {
|
||||
sections.push(Self::format_template(template));
|
||||
}
|
||||
|
||||
sections.join("\n\n")
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
// 辅助方法 - 数据加载
|
||||
// ------------------------------------------------------------------------
|
||||
|
||||
/// 加载项目基本信息
|
||||
fn load_project(conn: &Connection, project_id: &str) -> Result<Workspace, ProjectError> {
|
||||
let result = conn.query_row(
|
||||
"SELECT id, name, workspace_type, root_path, is_default, settings_json,
|
||||
created_at, updated_at, icon, color, is_favorite, is_archived, tags_json
|
||||
FROM workspaces WHERE id = ?",
|
||||
rusqlite::params![project_id],
|
||||
|row| Self::row_to_workspace(row),
|
||||
);
|
||||
|
||||
match result {
|
||||
Ok(workspace) => Ok(workspace),
|
||||
Err(rusqlite::Error::QueryReturnedNoRows) => {
|
||||
Err(ProjectError::NotFound(project_id.to_string()))
|
||||
}
|
||||
Err(e) => Err(ProjectError::DatabaseError(e)),
|
||||
}
|
||||
}
|
||||
|
||||
/// 从数据库行解析 Workspace
|
||||
fn row_to_workspace(row: &rusqlite::Row) -> Result<Workspace, rusqlite::Error> {
|
||||
let id: String = row.get(0)?;
|
||||
let name: String = row.get(1)?;
|
||||
let workspace_type_str: String = row.get(2)?;
|
||||
let root_path_str: String = row.get(3)?;
|
||||
let is_default: bool = row.get(4)?;
|
||||
let settings_json: String = row.get(5)?;
|
||||
let created_at_ms: i64 = row.get(6)?;
|
||||
let updated_at_ms: i64 = row.get(7)?;
|
||||
let icon: Option<String> = row.get(8)?;
|
||||
let color: Option<String> = row.get(9)?;
|
||||
let is_favorite: bool = row.get::<_, Option<bool>>(10)?.unwrap_or(false);
|
||||
let is_archived: bool = row.get::<_, Option<bool>>(11)?.unwrap_or(false);
|
||||
let tags_json: Option<String> = row.get(12)?;
|
||||
|
||||
let settings: WorkspaceSettings = serde_json::from_str(&settings_json).unwrap_or_default();
|
||||
let tags: Vec<String> = tags_json
|
||||
.and_then(|s| serde_json::from_str(&s).ok())
|
||||
.unwrap_or_default();
|
||||
|
||||
Ok(Workspace {
|
||||
id,
|
||||
name,
|
||||
workspace_type: WorkspaceType::from_str(&workspace_type_str),
|
||||
root_path: PathBuf::from(root_path_str),
|
||||
is_default,
|
||||
created_at: chrono::DateTime::from_timestamp_millis(created_at_ms)
|
||||
.unwrap_or_else(Utc::now),
|
||||
updated_at: chrono::DateTime::from_timestamp_millis(updated_at_ms)
|
||||
.unwrap_or_else(Utc::now),
|
||||
settings,
|
||||
icon,
|
||||
color,
|
||||
is_favorite,
|
||||
is_archived,
|
||||
tags,
|
||||
stats: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// 加载默认人设
|
||||
fn load_default_persona(conn: &Connection, project_id: &str) -> Option<Persona> {
|
||||
match PersonaService::get_default_persona(conn, project_id) {
|
||||
Ok(persona) => persona,
|
||||
Err(e) => {
|
||||
warn!(
|
||||
project_id = %project_id,
|
||||
error = %e,
|
||||
"加载默认人设失败"
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 加载素材列表
|
||||
fn load_materials(conn: &Connection, project_id: &str) -> Vec<Material> {
|
||||
match MaterialService::list_materials(conn, project_id, None) {
|
||||
Ok(materials) => materials,
|
||||
Err(e) => {
|
||||
warn!(
|
||||
project_id = %project_id,
|
||||
error = %e,
|
||||
"加载素材列表失败"
|
||||
);
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 加载默认模板
|
||||
fn load_default_template(conn: &Connection, project_id: &str) -> Option<Template> {
|
||||
match TemplateService::get_default_template(conn, project_id) {
|
||||
Ok(template) => template,
|
||||
Err(e) => {
|
||||
warn!(
|
||||
project_id = %project_id,
|
||||
error = %e,
|
||||
"加载默认模板失败"
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
// 辅助方法 - 格式化
|
||||
// ------------------------------------------------------------------------
|
||||
|
||||
/// 格式化项目头部信息
|
||||
fn format_project_header(project: &Workspace) -> String {
|
||||
format!(
|
||||
"# 项目: {}\n\n你正在为「{}」项目创作内容。",
|
||||
project.name, project.name
|
||||
)
|
||||
}
|
||||
|
||||
/// 格式化人设信息
|
||||
///
|
||||
/// 将人设配置转换为 AI 可理解的提示词格式。
|
||||
fn format_persona(persona: &Persona) -> String {
|
||||
let mut lines = vec![
|
||||
"## 你的身份".to_string(),
|
||||
String::new(),
|
||||
format!("你是「{}」。", persona.name),
|
||||
];
|
||||
|
||||
// 添加描述
|
||||
if let Some(ref desc) = persona.description {
|
||||
lines.push(format!("描述: {}", desc));
|
||||
}
|
||||
|
||||
// 添加写作风格
|
||||
lines.push(format!("写作风格: {}", persona.style));
|
||||
|
||||
// 添加语气
|
||||
if let Some(ref tone) = persona.tone {
|
||||
lines.push(format!("语气: {}", tone));
|
||||
}
|
||||
|
||||
// 添加目标读者
|
||||
if let Some(ref audience) = persona.target_audience {
|
||||
lines.push(format!("目标读者: {}", audience));
|
||||
}
|
||||
|
||||
// 添加禁用词
|
||||
if !persona.forbidden_words.is_empty() {
|
||||
lines.push(format!(
|
||||
"禁止使用的词汇: {}",
|
||||
persona.forbidden_words.join("、")
|
||||
));
|
||||
}
|
||||
|
||||
// 添加偏好词
|
||||
if !persona.preferred_words.is_empty() {
|
||||
lines.push(format!(
|
||||
"推荐使用的词汇: {}",
|
||||
persona.preferred_words.join("、")
|
||||
));
|
||||
}
|
||||
|
||||
// 添加示例
|
||||
if let Some(ref examples) = persona.examples {
|
||||
lines.push(String::new());
|
||||
lines.push("### 写作示例".to_string());
|
||||
lines.push(examples.clone());
|
||||
}
|
||||
|
||||
lines.join("\n")
|
||||
}
|
||||
|
||||
/// 格式化素材摘要
|
||||
///
|
||||
/// 将素材列表转换为 AI 可引用的格式。
|
||||
/// 对于文本类素材,包含内容摘要;
|
||||
/// 对于其他类型,包含描述信息。
|
||||
fn format_materials(materials: &[Material]) -> String {
|
||||
let mut lines = vec![
|
||||
"## 可引用素材".to_string(),
|
||||
String::new(),
|
||||
"以下是项目中的参考素材,你可以在创作时引用:".to_string(),
|
||||
String::new(),
|
||||
];
|
||||
|
||||
for (i, material) in materials.iter().enumerate() {
|
||||
lines.push(format!("### {}. {}", i + 1, material.name));
|
||||
|
||||
// 添加类型
|
||||
lines.push(format!(
|
||||
"类型: {}",
|
||||
Self::format_material_type(&material.material_type)
|
||||
));
|
||||
|
||||
// 添加描述
|
||||
if let Some(ref desc) = material.description {
|
||||
lines.push(format!("描述: {}", desc));
|
||||
}
|
||||
|
||||
// 添加标签
|
||||
if !material.tags.is_empty() {
|
||||
lines.push(format!("标签: {}", material.tags.join("、")));
|
||||
}
|
||||
|
||||
// 添加内容摘要(仅文本类型)
|
||||
if let Some(ref content) = material.content {
|
||||
let summary = Self::truncate_content(content, 500);
|
||||
lines.push(format!("内容:\n{}", summary));
|
||||
}
|
||||
|
||||
lines.push(String::new());
|
||||
}
|
||||
|
||||
lines.join("\n")
|
||||
}
|
||||
|
||||
/// 格式化素材类型显示名称
|
||||
fn format_material_type(material_type: &str) -> &'static str {
|
||||
match material_type {
|
||||
"document" => "文档",
|
||||
"image" => "图片",
|
||||
"text" => "文本",
|
||||
"data" => "数据",
|
||||
"link" => "链接",
|
||||
_ => "其他",
|
||||
}
|
||||
}
|
||||
|
||||
/// 截断内容到指定长度
|
||||
fn truncate_content(content: &str, max_len: usize) -> String {
|
||||
if content.chars().count() <= max_len {
|
||||
content.to_string()
|
||||
} else {
|
||||
let truncated: String = content.chars().take(max_len).collect();
|
||||
format!("{}...", truncated)
|
||||
}
|
||||
}
|
||||
|
||||
/// 格式化排版规则
|
||||
///
|
||||
/// 将排版模板转换为 AI 可遵循的格式规则。
|
||||
fn format_template(template: &Template) -> String {
|
||||
let mut lines = vec![
|
||||
"## 排版规则".to_string(),
|
||||
String::new(),
|
||||
format!(
|
||||
"请按照以下「{}」平台的排版规则输出内容:",
|
||||
Self::format_platform(&template.platform)
|
||||
),
|
||||
String::new(),
|
||||
];
|
||||
|
||||
// 添加标题风格
|
||||
if let Some(ref title_style) = template.title_style {
|
||||
lines.push(format!("**标题风格**: {}", title_style));
|
||||
}
|
||||
|
||||
// 添加段落风格
|
||||
if let Some(ref paragraph_style) = template.paragraph_style {
|
||||
lines.push(format!("**段落风格**: {}", paragraph_style));
|
||||
}
|
||||
|
||||
// 添加结尾风格
|
||||
if let Some(ref ending_style) = template.ending_style {
|
||||
lines.push(format!("**结尾风格**: {}", ending_style));
|
||||
}
|
||||
|
||||
// 添加 Emoji 使用规则
|
||||
let emoji_desc = match template.emoji_usage.as_str() {
|
||||
"heavy" => "大量使用 emoji 表情,增加趣味性",
|
||||
"moderate" => "适度使用 emoji 表情,点缀内容",
|
||||
"minimal" => "少量或不使用 emoji 表情,保持简洁",
|
||||
_ => "适度使用 emoji 表情",
|
||||
};
|
||||
lines.push(format!("**Emoji 使用**: {}", emoji_desc));
|
||||
|
||||
// 添加话题标签规则
|
||||
if let Some(ref hashtag_rules) = template.hashtag_rules {
|
||||
lines.push(format!("**话题标签**: {}", hashtag_rules));
|
||||
}
|
||||
|
||||
// 添加图片规则
|
||||
if let Some(ref image_rules) = template.image_rules {
|
||||
lines.push(format!("**配图建议**: {}", image_rules));
|
||||
}
|
||||
|
||||
lines.join("\n")
|
||||
}
|
||||
|
||||
/// 格式化平台显示名称
|
||||
fn format_platform(platform: &str) -> &'static str {
|
||||
match platform {
|
||||
"xiaohongshu" => "小红书",
|
||||
"wechat" => "微信公众号",
|
||||
"zhihu" => "知乎",
|
||||
"weibo" => "微博",
|
||||
"douyin" => "抖音",
|
||||
"markdown" => "Markdown",
|
||||
_ => "通用",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 测试
|
||||
// ============================================================================
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::database::schema::create_tables;
|
||||
use crate::models::project_model::CreatePersonaRequest;
|
||||
|
||||
/// 创建测试数据库连接
|
||||
fn setup_test_db() -> Connection {
|
||||
let conn = Connection::open_in_memory().unwrap();
|
||||
create_tables(&conn).unwrap();
|
||||
conn
|
||||
}
|
||||
|
||||
/// 创建测试项目
|
||||
fn create_test_project(conn: &Connection, id: &str, name: &str) {
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
conn.execute(
|
||||
"INSERT INTO workspaces (id, name, workspace_type, root_path, created_at, updated_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
||||
rusqlite::params![id, name, "persistent", format!("/test/{}", id), now, now],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_context_project_not_found() {
|
||||
let conn = setup_test_db();
|
||||
let result = ProjectContextBuilder::build_context(&conn, "nonexistent");
|
||||
assert!(result.is_err());
|
||||
|
||||
match result.unwrap_err() {
|
||||
ProjectError::NotFound(id) => assert_eq!(id, "nonexistent"),
|
||||
_ => panic!("期望 NotFound 错误"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_context_empty_project() {
|
||||
let conn = setup_test_db();
|
||||
create_test_project(&conn, "project-1", "测试项目");
|
||||
|
||||
let context = ProjectContextBuilder::build_context(&conn, "project-1").unwrap();
|
||||
|
||||
assert_eq!(context.project.name, "测试项目");
|
||||
assert!(context.persona.is_none());
|
||||
assert!(context.materials.is_empty());
|
||||
assert!(context.template.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_context_with_persona() {
|
||||
let conn = setup_test_db();
|
||||
create_test_project(&conn, "project-1", "测试项目");
|
||||
|
||||
// 创建人设并设为默认
|
||||
let req = CreatePersonaRequest {
|
||||
project_id: "project-1".to_string(),
|
||||
name: "专业写手".to_string(),
|
||||
description: Some("专业技术文章写手".to_string()),
|
||||
style: "专业严谨".to_string(),
|
||||
tone: Some("正式".to_string()),
|
||||
target_audience: Some("技术人员".to_string()),
|
||||
forbidden_words: None,
|
||||
preferred_words: None,
|
||||
examples: None,
|
||||
platforms: None,
|
||||
};
|
||||
let persona = PersonaService::create_persona(&conn, req).unwrap();
|
||||
PersonaService::set_default_persona(&conn, "project-1", &persona.id).unwrap();
|
||||
|
||||
let context = ProjectContextBuilder::build_context(&conn, "project-1").unwrap();
|
||||
|
||||
assert!(context.persona.is_some());
|
||||
let p = context.persona.unwrap();
|
||||
assert_eq!(p.name, "专业写手");
|
||||
assert_eq!(p.style, "专业严谨");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_system_prompt_empty() {
|
||||
let conn = setup_test_db();
|
||||
create_test_project(&conn, "project-1", "测试项目");
|
||||
|
||||
let context = ProjectContextBuilder::build_context(&conn, "project-1").unwrap();
|
||||
let prompt = ProjectContextBuilder::build_system_prompt(&context);
|
||||
|
||||
// 应该只包含项目头部
|
||||
assert!(prompt.contains("# 项目: 测试项目"));
|
||||
assert!(!prompt.contains("## 你的身份"));
|
||||
assert!(!prompt.contains("## 可引用素材"));
|
||||
assert!(!prompt.contains("## 排版规则"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_system_prompt_with_persona() {
|
||||
let conn = setup_test_db();
|
||||
create_test_project(&conn, "project-1", "测试项目");
|
||||
|
||||
// 创建人设
|
||||
let req = CreatePersonaRequest {
|
||||
project_id: "project-1".to_string(),
|
||||
name: "生活博主".to_string(),
|
||||
description: Some("分享生活日常".to_string()),
|
||||
style: "轻松活泼".to_string(),
|
||||
tone: Some("亲切".to_string()),
|
||||
target_audience: Some("年轻女性".to_string()),
|
||||
forbidden_words: Some(vec!["禁词1".to_string(), "禁词2".to_string()]),
|
||||
preferred_words: Some(vec!["推荐词".to_string()]),
|
||||
examples: Some("这是一个示例文本".to_string()),
|
||||
platforms: None,
|
||||
};
|
||||
let persona = PersonaService::create_persona(&conn, req).unwrap();
|
||||
PersonaService::set_default_persona(&conn, "project-1", &persona.id).unwrap();
|
||||
|
||||
let context = ProjectContextBuilder::build_context(&conn, "project-1").unwrap();
|
||||
let prompt = ProjectContextBuilder::build_system_prompt(&context);
|
||||
|
||||
// 验证人设 section
|
||||
assert!(prompt.contains("## 你的身份"));
|
||||
assert!(prompt.contains("你是「生活博主」"));
|
||||
assert!(prompt.contains("写作风格: 轻松活泼"));
|
||||
assert!(prompt.contains("语气: 亲切"));
|
||||
assert!(prompt.contains("目标读者: 年轻女性"));
|
||||
assert!(prompt.contains("禁止使用的词汇: 禁词1、禁词2"));
|
||||
assert!(prompt.contains("推荐使用的词汇: 推荐词"));
|
||||
assert!(prompt.contains("### 写作示例"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_system_prompt_with_materials() {
|
||||
let conn = setup_test_db();
|
||||
create_test_project(&conn, "project-1", "测试项目");
|
||||
|
||||
// 创建素材
|
||||
use crate::models::project_model::UploadMaterialRequest;
|
||||
let req = UploadMaterialRequest {
|
||||
project_id: "project-1".to_string(),
|
||||
name: "参考文档".to_string(),
|
||||
material_type: "text".to_string(),
|
||||
file_path: None,
|
||||
content: Some("这是参考文档的内容".to_string()),
|
||||
tags: Some(vec!["参考".to_string(), "重要".to_string()]),
|
||||
description: Some("重要的参考资料".to_string()),
|
||||
};
|
||||
MaterialService::upload_material(&conn, req).unwrap();
|
||||
|
||||
let context = ProjectContextBuilder::build_context(&conn, "project-1").unwrap();
|
||||
let prompt = ProjectContextBuilder::build_system_prompt(&context);
|
||||
|
||||
// 验证素材 section
|
||||
assert!(prompt.contains("## 可引用素材"));
|
||||
assert!(prompt.contains("### 1. 参考文档"));
|
||||
assert!(prompt.contains("类型: 文本"));
|
||||
assert!(prompt.contains("描述: 重要的参考资料"));
|
||||
assert!(prompt.contains("标签: 参考、重要"));
|
||||
assert!(prompt.contains("这是参考文档的内容"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_system_prompt_with_template() {
|
||||
let conn = setup_test_db();
|
||||
create_test_project(&conn, "project-1", "测试项目");
|
||||
|
||||
// 创建模板
|
||||
use crate::models::project_model::CreateTemplateRequest;
|
||||
let req = CreateTemplateRequest {
|
||||
project_id: "project-1".to_string(),
|
||||
name: "小红书模板".to_string(),
|
||||
platform: "xiaohongshu".to_string(),
|
||||
title_style: Some("吸引眼球".to_string()),
|
||||
paragraph_style: Some("简短有力".to_string()),
|
||||
ending_style: Some("引导互动".to_string()),
|
||||
emoji_usage: Some("heavy".to_string()),
|
||||
hashtag_rules: Some("3-5个相关话题".to_string()),
|
||||
image_rules: Some("配图要精美".to_string()),
|
||||
};
|
||||
let template = TemplateService::create_template(&conn, req).unwrap();
|
||||
TemplateService::set_default_template(&conn, "project-1", &template.id).unwrap();
|
||||
|
||||
let context = ProjectContextBuilder::build_context(&conn, "project-1").unwrap();
|
||||
let prompt = ProjectContextBuilder::build_system_prompt(&context);
|
||||
|
||||
// 验证模板 section
|
||||
assert!(prompt.contains("## 排版规则"));
|
||||
assert!(prompt.contains("小红书"));
|
||||
assert!(prompt.contains("**标题风格**: 吸引眼球"));
|
||||
assert!(prompt.contains("**段落风格**: 简短有力"));
|
||||
assert!(prompt.contains("**结尾风格**: 引导互动"));
|
||||
assert!(prompt.contains("大量使用 emoji"));
|
||||
assert!(prompt.contains("**话题标签**: 3-5个相关话题"));
|
||||
assert!(prompt.contains("**配图建议**: 配图要精美"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_system_prompt_full_context() {
|
||||
let conn = setup_test_db();
|
||||
create_test_project(&conn, "project-1", "完整项目");
|
||||
|
||||
// 创建人设
|
||||
let persona_req = CreatePersonaRequest {
|
||||
project_id: "project-1".to_string(),
|
||||
name: "测试人设".to_string(),
|
||||
description: None,
|
||||
style: "专业".to_string(),
|
||||
tone: None,
|
||||
target_audience: None,
|
||||
forbidden_words: None,
|
||||
preferred_words: None,
|
||||
examples: None,
|
||||
platforms: None,
|
||||
};
|
||||
let persona = PersonaService::create_persona(&conn, persona_req).unwrap();
|
||||
PersonaService::set_default_persona(&conn, "project-1", &persona.id).unwrap();
|
||||
|
||||
// 创建素材
|
||||
use crate::models::project_model::UploadMaterialRequest;
|
||||
let material_req = UploadMaterialRequest {
|
||||
project_id: "project-1".to_string(),
|
||||
name: "素材1".to_string(),
|
||||
material_type: "text".to_string(),
|
||||
file_path: None,
|
||||
content: Some("素材内容".to_string()),
|
||||
tags: None,
|
||||
description: None,
|
||||
};
|
||||
MaterialService::upload_material(&conn, material_req).unwrap();
|
||||
|
||||
// 创建模板
|
||||
use crate::models::project_model::CreateTemplateRequest;
|
||||
let template_req = CreateTemplateRequest {
|
||||
project_id: "project-1".to_string(),
|
||||
name: "测试模板".to_string(),
|
||||
platform: "markdown".to_string(),
|
||||
title_style: None,
|
||||
paragraph_style: None,
|
||||
ending_style: None,
|
||||
emoji_usage: Some("minimal".to_string()),
|
||||
hashtag_rules: None,
|
||||
image_rules: None,
|
||||
};
|
||||
let template = TemplateService::create_template(&conn, template_req).unwrap();
|
||||
TemplateService::set_default_template(&conn, "project-1", &template.id).unwrap();
|
||||
|
||||
let context = ProjectContextBuilder::build_context(&conn, "project-1").unwrap();
|
||||
let prompt = ProjectContextBuilder::build_system_prompt(&context);
|
||||
|
||||
// 验证所有 section 都存在
|
||||
assert!(prompt.contains("# 项目: 完整项目"));
|
||||
assert!(prompt.contains("## 你的身份"));
|
||||
assert!(prompt.contains("## 可引用素材"));
|
||||
assert!(prompt.contains("## 排版规则"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_truncate_content() {
|
||||
// 短内容不截断
|
||||
let short = "短内容";
|
||||
assert_eq!(
|
||||
ProjectContextBuilder::truncate_content(short, 100),
|
||||
"短内容"
|
||||
);
|
||||
|
||||
// 长内容截断
|
||||
let long = "这是一段很长的内容,需要被截断处理";
|
||||
let truncated = ProjectContextBuilder::truncate_content(long, 10);
|
||||
assert!(truncated.ends_with("..."));
|
||||
assert!(truncated.chars().count() <= 13); // 10 + "..."
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_material_type() {
|
||||
assert_eq!(
|
||||
ProjectContextBuilder::format_material_type("document"),
|
||||
"文档"
|
||||
);
|
||||
assert_eq!(ProjectContextBuilder::format_material_type("image"), "图片");
|
||||
assert_eq!(ProjectContextBuilder::format_material_type("text"), "文本");
|
||||
assert_eq!(ProjectContextBuilder::format_material_type("data"), "数据");
|
||||
assert_eq!(ProjectContextBuilder::format_material_type("link"), "链接");
|
||||
assert_eq!(
|
||||
ProjectContextBuilder::format_material_type("unknown"),
|
||||
"其他"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_platform() {
|
||||
assert_eq!(
|
||||
ProjectContextBuilder::format_platform("xiaohongshu"),
|
||||
"小红书"
|
||||
);
|
||||
assert_eq!(
|
||||
ProjectContextBuilder::format_platform("wechat"),
|
||||
"微信公众号"
|
||||
);
|
||||
assert_eq!(ProjectContextBuilder::format_platform("zhihu"), "知乎");
|
||||
assert_eq!(ProjectContextBuilder::format_platform("weibo"), "微博");
|
||||
assert_eq!(ProjectContextBuilder::format_platform("douyin"), "抖音");
|
||||
assert_eq!(
|
||||
ProjectContextBuilder::format_platform("markdown"),
|
||||
"Markdown"
|
||||
);
|
||||
assert_eq!(ProjectContextBuilder::format_platform("unknown"), "通用");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,499 @@
|
||||
//! 排版模板服务层
|
||||
//!
|
||||
//! 提供排版模板(Template)的业务逻辑,包括:
|
||||
//! - 创建、获取、列表、更新、删除模板
|
||||
//! - 设置项目默认模板
|
||||
//!
|
||||
//! ## 相关需求
|
||||
//! - Requirements 8.1: 模板列表显示
|
||||
//! - Requirements 8.2: 创建模板按钮
|
||||
//! - Requirements 8.3: 模板创建表单
|
||||
//! - Requirements 8.4: 设置默认模板
|
||||
//! - Requirements 8.5: 模板预览功能
|
||||
|
||||
use rusqlite::Connection;
|
||||
|
||||
use crate::database::dao::template_dao::TemplateDao;
|
||||
use crate::errors::project_error::TemplateError;
|
||||
use crate::models::project_model::{CreateTemplateRequest, Template, TemplateUpdate};
|
||||
|
||||
// ============================================================================
|
||||
// 排版模板服务
|
||||
// ============================================================================
|
||||
|
||||
/// 排版模板服务
|
||||
///
|
||||
/// 封装排版模板的业务逻辑,调用 TemplateDao 进行数据操作。
|
||||
pub struct TemplateService;
|
||||
|
||||
impl TemplateService {
|
||||
// ------------------------------------------------------------------------
|
||||
// 创建模板
|
||||
// ------------------------------------------------------------------------
|
||||
|
||||
/// 创建新模板
|
||||
///
|
||||
/// # 参数
|
||||
/// - `conn`: 数据库连接
|
||||
/// - `req`: 创建模板请求
|
||||
///
|
||||
/// # 返回
|
||||
/// - 成功返回创建的模板
|
||||
/// - 失败返回 TemplateError
|
||||
///
|
||||
/// # 示例
|
||||
/// ```ignore
|
||||
/// let req = CreateTemplateRequest {
|
||||
/// project_id: "project-1".to_string(),
|
||||
/// name: "小红书模板".to_string(),
|
||||
/// platform: "xiaohongshu".to_string(),
|
||||
/// ..Default::default()
|
||||
/// };
|
||||
/// let template = TemplateService::create_template(&conn, req)?;
|
||||
/// ```
|
||||
pub fn create_template(
|
||||
conn: &Connection,
|
||||
req: CreateTemplateRequest,
|
||||
) -> Result<Template, TemplateError> {
|
||||
// 验证项目存在
|
||||
Self::validate_project_exists(conn, &req.project_id)?;
|
||||
|
||||
// 调用 DAO 创建模板
|
||||
TemplateDao::create(conn, &req)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
// 获取模板列表
|
||||
// ------------------------------------------------------------------------
|
||||
|
||||
/// 获取项目的模板列表
|
||||
///
|
||||
/// # 参数
|
||||
/// - `conn`: 数据库连接
|
||||
/// - `project_id`: 项目 ID
|
||||
///
|
||||
/// # 返回
|
||||
/// - 成功返回模板列表
|
||||
/// - 失败返回 TemplateError
|
||||
pub fn list_templates(
|
||||
conn: &Connection,
|
||||
project_id: &str,
|
||||
) -> Result<Vec<Template>, TemplateError> {
|
||||
TemplateDao::list(conn, project_id)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
// 获取单个模板
|
||||
// ------------------------------------------------------------------------
|
||||
|
||||
/// 获取单个模板
|
||||
///
|
||||
/// # 参数
|
||||
/// - `conn`: 数据库连接
|
||||
/// - `id`: 模板 ID
|
||||
///
|
||||
/// # 返回
|
||||
/// - 成功返回 Option<Template>
|
||||
/// - 失败返回 TemplateError
|
||||
pub fn get_template(conn: &Connection, id: &str) -> Result<Option<Template>, TemplateError> {
|
||||
TemplateDao::get(conn, id)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
// 更新模板
|
||||
// ------------------------------------------------------------------------
|
||||
|
||||
/// 更新模板
|
||||
///
|
||||
/// # 参数
|
||||
/// - `conn`: 数据库连接
|
||||
/// - `id`: 模板 ID
|
||||
/// - `update`: 更新内容
|
||||
///
|
||||
/// # 返回
|
||||
/// - 成功返回更新后的模板
|
||||
/// - 失败返回 TemplateError
|
||||
pub fn update_template(
|
||||
conn: &Connection,
|
||||
id: &str,
|
||||
update: TemplateUpdate,
|
||||
) -> Result<Template, TemplateError> {
|
||||
TemplateDao::update(conn, id, &update)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
// 删除模板
|
||||
// ------------------------------------------------------------------------
|
||||
|
||||
/// 删除模板
|
||||
///
|
||||
/// # 参数
|
||||
/// - `conn`: 数据库连接
|
||||
/// - `id`: 模板 ID
|
||||
///
|
||||
/// # 返回
|
||||
/// - 成功返回 ()
|
||||
/// - 失败返回 TemplateError
|
||||
pub fn delete_template(conn: &Connection, id: &str) -> Result<(), TemplateError> {
|
||||
TemplateDao::delete(conn, id)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
// 设置默认模板
|
||||
// ------------------------------------------------------------------------
|
||||
|
||||
/// 设置项目的默认模板
|
||||
///
|
||||
/// 将指定模板设为默认,同时取消该项目其他模板的默认状态。
|
||||
///
|
||||
/// # 参数
|
||||
/// - `conn`: 数据库连接
|
||||
/// - `project_id`: 项目 ID
|
||||
/// - `template_id`: 要设为默认的模板 ID
|
||||
///
|
||||
/// # 返回
|
||||
/// - 成功返回 ()
|
||||
/// - 失败返回 TemplateError
|
||||
pub fn set_default_template(
|
||||
conn: &Connection,
|
||||
project_id: &str,
|
||||
template_id: &str,
|
||||
) -> Result<(), TemplateError> {
|
||||
TemplateDao::set_default(conn, project_id, template_id)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
// 获取默认模板
|
||||
// ------------------------------------------------------------------------
|
||||
|
||||
/// 获取项目的默认模板
|
||||
///
|
||||
/// # 参数
|
||||
/// - `conn`: 数据库连接
|
||||
/// - `project_id`: 项目 ID
|
||||
///
|
||||
/// # 返回
|
||||
/// - 成功返回 Option<Template>
|
||||
/// - 失败返回 TemplateError
|
||||
pub fn get_default_template(
|
||||
conn: &Connection,
|
||||
project_id: &str,
|
||||
) -> Result<Option<Template>, TemplateError> {
|
||||
TemplateDao::get_default(conn, project_id)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
// 辅助方法
|
||||
// ------------------------------------------------------------------------
|
||||
|
||||
/// 验证项目是否存在
|
||||
fn validate_project_exists(conn: &Connection, project_id: &str) -> Result<(), TemplateError> {
|
||||
let mut stmt = conn
|
||||
.prepare("SELECT 1 FROM workspaces WHERE id = ?")
|
||||
.map_err(TemplateError::DatabaseError)?;
|
||||
|
||||
let exists = stmt
|
||||
.exists([project_id])
|
||||
.map_err(TemplateError::DatabaseError)?;
|
||||
|
||||
if !exists {
|
||||
return Err(TemplateError::ProjectNotFound(project_id.to_string()));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 测试
|
||||
// ============================================================================
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::database::schema::create_tables;
|
||||
|
||||
/// 创建测试数据库连接
|
||||
fn setup_test_db() -> Connection {
|
||||
let conn = Connection::open_in_memory().unwrap();
|
||||
create_tables(&conn).unwrap();
|
||||
conn
|
||||
}
|
||||
|
||||
/// 创建测试项目
|
||||
fn create_test_project(conn: &Connection, id: &str) {
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
conn.execute(
|
||||
"INSERT INTO workspaces (id, name, workspace_type, root_path, created_at, updated_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
||||
rusqlite::params![
|
||||
id,
|
||||
"测试项目",
|
||||
"persistent",
|
||||
format!("/test/{}", id),
|
||||
now,
|
||||
now
|
||||
],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_template_success() {
|
||||
let conn = setup_test_db();
|
||||
create_test_project(&conn, "project-1");
|
||||
|
||||
let req = CreateTemplateRequest {
|
||||
project_id: "project-1".to_string(),
|
||||
name: "小红书模板".to_string(),
|
||||
platform: "xiaohongshu".to_string(),
|
||||
title_style: Some("吸引眼球".to_string()),
|
||||
paragraph_style: Some("简短有力".to_string()),
|
||||
ending_style: Some("引导互动".to_string()),
|
||||
emoji_usage: Some("heavy".to_string()),
|
||||
hashtag_rules: Some("3-5个相关话题".to_string()),
|
||||
image_rules: Some("配图要精美".to_string()),
|
||||
};
|
||||
|
||||
let template = TemplateService::create_template(&conn, req).unwrap();
|
||||
|
||||
assert!(!template.id.is_empty());
|
||||
assert_eq!(template.project_id, "project-1");
|
||||
assert_eq!(template.name, "小红书模板");
|
||||
assert_eq!(template.platform, "xiaohongshu");
|
||||
assert_eq!(template.emoji_usage, "heavy");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_template_project_not_found() {
|
||||
let conn = setup_test_db();
|
||||
|
||||
let req = CreateTemplateRequest {
|
||||
project_id: "nonexistent".to_string(),
|
||||
name: "测试模板".to_string(),
|
||||
platform: "markdown".to_string(),
|
||||
title_style: None,
|
||||
paragraph_style: None,
|
||||
ending_style: None,
|
||||
emoji_usage: None,
|
||||
hashtag_rules: None,
|
||||
image_rules: None,
|
||||
};
|
||||
|
||||
let result = TemplateService::create_template(&conn, req);
|
||||
assert!(result.is_err());
|
||||
|
||||
match result.unwrap_err() {
|
||||
TemplateError::ProjectNotFound(id) => assert_eq!(id, "nonexistent"),
|
||||
_ => panic!("期望 ProjectNotFound 错误"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_list_templates() {
|
||||
let conn = setup_test_db();
|
||||
create_test_project(&conn, "project-1");
|
||||
|
||||
// 创建两个模板
|
||||
for i in 1..=2 {
|
||||
let req = CreateTemplateRequest {
|
||||
project_id: "project-1".to_string(),
|
||||
name: format!("模板{}", i),
|
||||
platform: "xiaohongshu".to_string(),
|
||||
title_style: None,
|
||||
paragraph_style: None,
|
||||
ending_style: None,
|
||||
emoji_usage: None,
|
||||
hashtag_rules: None,
|
||||
image_rules: None,
|
||||
};
|
||||
TemplateService::create_template(&conn, req).unwrap();
|
||||
}
|
||||
|
||||
let templates = TemplateService::list_templates(&conn, "project-1").unwrap();
|
||||
assert_eq!(templates.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_template() {
|
||||
let conn = setup_test_db();
|
||||
create_test_project(&conn, "project-1");
|
||||
|
||||
let req = CreateTemplateRequest {
|
||||
project_id: "project-1".to_string(),
|
||||
name: "测试模板".to_string(),
|
||||
platform: "wechat".to_string(),
|
||||
title_style: Some("正式".to_string()),
|
||||
paragraph_style: None,
|
||||
ending_style: None,
|
||||
emoji_usage: Some("minimal".to_string()),
|
||||
hashtag_rules: None,
|
||||
image_rules: None,
|
||||
};
|
||||
|
||||
let created = TemplateService::create_template(&conn, req).unwrap();
|
||||
let fetched = TemplateService::get_template(&conn, &created.id).unwrap();
|
||||
|
||||
assert!(fetched.is_some());
|
||||
assert_eq!(fetched.unwrap().id, created.id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_template() {
|
||||
let conn = setup_test_db();
|
||||
create_test_project(&conn, "project-1");
|
||||
|
||||
let req = CreateTemplateRequest {
|
||||
project_id: "project-1".to_string(),
|
||||
name: "原始名称".to_string(),
|
||||
platform: "xiaohongshu".to_string(),
|
||||
title_style: Some("原始标题风格".to_string()),
|
||||
paragraph_style: None,
|
||||
ending_style: None,
|
||||
emoji_usage: Some("moderate".to_string()),
|
||||
hashtag_rules: None,
|
||||
image_rules: None,
|
||||
};
|
||||
|
||||
let created = TemplateService::create_template(&conn, req).unwrap();
|
||||
|
||||
let update = TemplateUpdate {
|
||||
name: Some("更新后名称".to_string()),
|
||||
title_style: Some("更新后标题风格".to_string()),
|
||||
paragraph_style: Some("新段落风格".to_string()),
|
||||
ending_style: None,
|
||||
emoji_usage: Some("heavy".to_string()),
|
||||
hashtag_rules: Some("5个话题".to_string()),
|
||||
image_rules: None,
|
||||
};
|
||||
|
||||
let updated = TemplateService::update_template(&conn, &created.id, update).unwrap();
|
||||
|
||||
assert_eq!(updated.name, "更新后名称");
|
||||
assert_eq!(updated.title_style, Some("更新后标题风格".to_string()));
|
||||
assert_eq!(updated.paragraph_style, Some("新段落风格".to_string()));
|
||||
assert_eq!(updated.emoji_usage, "heavy");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_delete_template() {
|
||||
let conn = setup_test_db();
|
||||
create_test_project(&conn, "project-1");
|
||||
|
||||
let req = CreateTemplateRequest {
|
||||
project_id: "project-1".to_string(),
|
||||
name: "待删除模板".to_string(),
|
||||
platform: "markdown".to_string(),
|
||||
title_style: None,
|
||||
paragraph_style: None,
|
||||
ending_style: None,
|
||||
emoji_usage: None,
|
||||
hashtag_rules: None,
|
||||
image_rules: None,
|
||||
};
|
||||
|
||||
let created = TemplateService::create_template(&conn, req).unwrap();
|
||||
|
||||
// 验证模板存在
|
||||
assert!(TemplateService::get_template(&conn, &created.id)
|
||||
.unwrap()
|
||||
.is_some());
|
||||
|
||||
// 删除模板
|
||||
TemplateService::delete_template(&conn, &created.id).unwrap();
|
||||
|
||||
// 验证模板已删除
|
||||
assert!(TemplateService::get_template(&conn, &created.id)
|
||||
.unwrap()
|
||||
.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_set_default_template() {
|
||||
let conn = setup_test_db();
|
||||
create_test_project(&conn, "project-1");
|
||||
|
||||
// 创建两个模板
|
||||
let req1 = CreateTemplateRequest {
|
||||
project_id: "project-1".to_string(),
|
||||
name: "模板1".to_string(),
|
||||
platform: "xiaohongshu".to_string(),
|
||||
title_style: None,
|
||||
paragraph_style: None,
|
||||
ending_style: None,
|
||||
emoji_usage: None,
|
||||
hashtag_rules: None,
|
||||
image_rules: None,
|
||||
};
|
||||
let template1 = TemplateService::create_template(&conn, req1).unwrap();
|
||||
|
||||
let req2 = CreateTemplateRequest {
|
||||
project_id: "project-1".to_string(),
|
||||
name: "模板2".to_string(),
|
||||
platform: "wechat".to_string(),
|
||||
title_style: None,
|
||||
paragraph_style: None,
|
||||
ending_style: None,
|
||||
emoji_usage: None,
|
||||
hashtag_rules: None,
|
||||
image_rules: None,
|
||||
};
|
||||
let template2 = TemplateService::create_template(&conn, req2).unwrap();
|
||||
|
||||
// 设置模板1为默认
|
||||
TemplateService::set_default_template(&conn, "project-1", &template1.id).unwrap();
|
||||
|
||||
let default = TemplateService::get_default_template(&conn, "project-1").unwrap();
|
||||
assert!(default.is_some());
|
||||
assert_eq!(default.unwrap().id, template1.id);
|
||||
|
||||
// 设置模板2为默认,模板1应该不再是默认
|
||||
TemplateService::set_default_template(&conn, "project-1", &template2.id).unwrap();
|
||||
|
||||
let default = TemplateService::get_default_template(&conn, "project-1").unwrap();
|
||||
assert!(default.is_some());
|
||||
assert_eq!(default.unwrap().id, template2.id);
|
||||
|
||||
// 验证只有一个默认模板
|
||||
let templates = TemplateService::list_templates(&conn, "project-1").unwrap();
|
||||
let default_count = templates.iter().filter(|t| t.is_default).count();
|
||||
assert_eq!(default_count, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_default_template_none() {
|
||||
let conn = setup_test_db();
|
||||
create_test_project(&conn, "project-1");
|
||||
|
||||
// 没有设置默认模板时应返回 None
|
||||
let default = TemplateService::get_default_template(&conn, "project-1").unwrap();
|
||||
assert!(default.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_template_minimal() {
|
||||
let conn = setup_test_db();
|
||||
create_test_project(&conn, "project-1");
|
||||
|
||||
let req = CreateTemplateRequest {
|
||||
project_id: "project-1".to_string(),
|
||||
name: "简单模板".to_string(),
|
||||
platform: "markdown".to_string(),
|
||||
title_style: None,
|
||||
paragraph_style: None,
|
||||
ending_style: None,
|
||||
emoji_usage: None,
|
||||
hashtag_rules: None,
|
||||
image_rules: None,
|
||||
};
|
||||
|
||||
let template = TemplateService::create_template(&conn, req).unwrap();
|
||||
|
||||
assert!(!template.id.is_empty());
|
||||
assert_eq!(template.name, "简单模板");
|
||||
assert_eq!(template.platform, "markdown");
|
||||
// 默认值
|
||||
assert_eq!(template.emoji_usage, "moderate");
|
||||
assert!(template.title_style.is_none());
|
||||
}
|
||||
}
|
||||
+25
-1
@@ -21,6 +21,7 @@ import { AgentChatPage } from "./components/agent";
|
||||
import { PluginsPage } from "./components/plugins/PluginsPage";
|
||||
import { ImageGenPage } from "./components/image-gen";
|
||||
import { ProjectsPage } from "./components/projects";
|
||||
import { ProjectDetailPage } from "./components/projects/ProjectDetailPage";
|
||||
import { CreateProjectDialog } from "./components/projects/CreateProjectDialog";
|
||||
import { ProjectType } from "./lib/api/project";
|
||||
|
||||
@@ -39,7 +40,12 @@ import { useRelayRegistry } from "./hooks/useRelayRegistry";
|
||||
import { ComponentDebugProvider } from "./contexts/ComponentDebugContext";
|
||||
import { SoundProvider } from "./contexts/SoundProvider";
|
||||
import { ComponentDebugOverlay } from "./components/dev";
|
||||
import { Page, PageParams, AgentPageParams } from "./types/page";
|
||||
import {
|
||||
Page,
|
||||
PageParams,
|
||||
AgentPageParams,
|
||||
ProjectDetailPageParams,
|
||||
} from "./types/page";
|
||||
import { open } from "@tauri-apps/plugin-dialog";
|
||||
import { createProject, createContent } from "./lib/api/project";
|
||||
import { toast } from "sonner";
|
||||
@@ -291,6 +297,24 @@ function AppContent() {
|
||||
<ProjectsPage onNavigate={handleNavigate} />
|
||||
</PageWrapper>
|
||||
|
||||
{/* 项目详情页 */}
|
||||
{currentPage === "project-detail" &&
|
||||
(pageParams as ProjectDetailPageParams).projectId && (
|
||||
<PageWrapper $isActive={true}>
|
||||
<ProjectDetailPage
|
||||
projectId={(pageParams as ProjectDetailPageParams).projectId}
|
||||
onBack={() => handleNavigate("projects")}
|
||||
onNavigateToChat={(contentId) => {
|
||||
handleNavigate("agent", {
|
||||
projectId: (pageParams as ProjectDetailPageParams)
|
||||
.projectId,
|
||||
contentId,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</PageWrapper>
|
||||
)}
|
||||
|
||||
{/* 终端工作区 - 使用 div 包装以支持显示/隐藏 */}
|
||||
<div
|
||||
style={{
|
||||
|
||||
@@ -30,6 +30,8 @@ import {
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { ProjectSelector } from "@/components/projects/ProjectSelector";
|
||||
import { useProjects } from "@/hooks/useProjects";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
// Import Assets
|
||||
@@ -297,6 +299,10 @@ interface EmptyStateProps {
|
||||
onThemeChange?: (theme: string) => void;
|
||||
/** 推荐标签点击回调 */
|
||||
onRecommendationClick?: (shortLabel: string, fullPrompt: string) => void;
|
||||
/** 当前选中的项目 ID */
|
||||
projectId?: string | null;
|
||||
/** 项目变更回调 */
|
||||
onProjectChange?: (projectId: string) => void;
|
||||
}
|
||||
|
||||
// Scenarios Configuration - 与 ProjectType 统一
|
||||
@@ -495,7 +501,56 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
|
||||
activeTheme = "general",
|
||||
onThemeChange,
|
||||
onRecommendationClick,
|
||||
projectId: externalProjectId,
|
||||
onProjectChange,
|
||||
}) => {
|
||||
// 项目管理 - 内部状态(当外部未提供时使用)
|
||||
const { defaultProject, getOrCreateDefault } = useProjects();
|
||||
const [internalProjectId, setInternalProjectId] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
// 使用外部或内部的 projectId
|
||||
const projectId = externalProjectId ?? internalProjectId;
|
||||
|
||||
// 初始化默认项目
|
||||
useEffect(() => {
|
||||
if (!externalProjectId && !internalProjectId) {
|
||||
if (defaultProject) {
|
||||
// 通知父组件
|
||||
if (onProjectChange) {
|
||||
onProjectChange(defaultProject.id);
|
||||
} else {
|
||||
setInternalProjectId(defaultProject.id);
|
||||
}
|
||||
} else {
|
||||
getOrCreateDefault().then((p) => {
|
||||
// 通知父组件
|
||||
if (onProjectChange) {
|
||||
onProjectChange(p.id);
|
||||
} else {
|
||||
setInternalProjectId(p.id);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [
|
||||
externalProjectId,
|
||||
internalProjectId,
|
||||
defaultProject,
|
||||
getOrCreateDefault,
|
||||
onProjectChange,
|
||||
]);
|
||||
|
||||
// 处理项目变更
|
||||
const handleProjectChange = (newProjectId: string) => {
|
||||
if (onProjectChange) {
|
||||
onProjectChange(newProjectId);
|
||||
} else {
|
||||
setInternalProjectId(newProjectId);
|
||||
}
|
||||
};
|
||||
|
||||
// 从配置中读取启用的主题
|
||||
const [enabledThemes, setEnabledThemes] = useState<string[]>(
|
||||
DEFAULT_ENABLED_THEMES,
|
||||
@@ -664,6 +719,15 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
|
||||
|
||||
<Toolbar>
|
||||
<ToolLoginLeft>
|
||||
{/* 项目选择器 - PRD 4.2:始终显示,默认选中「默认项目」,按主题筛选 */}
|
||||
<ProjectSelector
|
||||
value={projectId}
|
||||
onChange={handleProjectChange}
|
||||
workspaceType={activeTheme}
|
||||
placeholder="选择项目"
|
||||
className="h-8 text-xs min-w-[120px]"
|
||||
/>
|
||||
|
||||
{activeTheme === "social-media" && (
|
||||
<>
|
||||
<Select
|
||||
|
||||
@@ -154,7 +154,7 @@ function isCanvasStateEmpty(state: CanvasStateUnion | null): boolean {
|
||||
|
||||
export function AgentChatPage({
|
||||
onNavigate: _onNavigate,
|
||||
projectId,
|
||||
projectId: externalProjectId,
|
||||
contentId,
|
||||
onRecommendationClick: _onRecommendationClick,
|
||||
}: {
|
||||
@@ -172,6 +172,14 @@ export function AgentChatPage({
|
||||
const [creationMode, setCreationMode] = useState<CreationMode>("guided");
|
||||
const [layoutMode, setLayoutMode] = useState<LayoutMode>("chat");
|
||||
|
||||
// 内部 projectId 状态(当外部未提供时使用)
|
||||
const [internalProjectId, setInternalProjectId] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
// 使用外部或内部的 projectId
|
||||
const projectId = externalProjectId ?? internalProjectId ?? undefined;
|
||||
|
||||
// 画布状态(支持多种画布类型)
|
||||
const [canvasState, setCanvasState] = useState<CanvasStateUnion | null>(null);
|
||||
|
||||
@@ -367,9 +375,23 @@ export function AgentChatPage({
|
||||
content = JSON.stringify(canvasState);
|
||||
}
|
||||
|
||||
// 如果有 contentId,直接同步
|
||||
// 如果有 contentId,先验证内容存在再同步
|
||||
if (contentId && content) {
|
||||
syncContent(contentId, content);
|
||||
// 先检查内容是否存在,避免同步到不存在的记录
|
||||
getContent(contentId)
|
||||
.then((existingContent) => {
|
||||
if (existingContent) {
|
||||
syncContent(contentId, content);
|
||||
} else {
|
||||
console.warn(
|
||||
"[AgentChatPage] contentId 对应的内容不存在,跳过同步:",
|
||||
contentId,
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error("[AgentChatPage] 检查内容存在性失败:", err);
|
||||
});
|
||||
}
|
||||
// 如果没有 contentId 但有 projectId,自动创建 Content
|
||||
else if (!contentId && projectId && content && project) {
|
||||
@@ -789,13 +811,26 @@ export function AgentChatPage({
|
||||
console.error("[AgentChatPage] 持久化文件失败:", err);
|
||||
});
|
||||
|
||||
// 同步内容到项目(如果有 contentId)
|
||||
// 同步内容到项目(如果有 contentId,先验证存在性)
|
||||
if (contentId) {
|
||||
updateContent(contentId, {
|
||||
body: content,
|
||||
}).catch((err) => {
|
||||
console.error("[AgentChatPage] 同步内容到项目失败:", err);
|
||||
});
|
||||
getContent(contentId)
|
||||
.then((existingContent) => {
|
||||
if (existingContent) {
|
||||
updateContent(contentId, {
|
||||
body: content,
|
||||
}).catch((err) => {
|
||||
console.error("[AgentChatPage] 同步内容到项目失败:", err);
|
||||
});
|
||||
} else {
|
||||
console.warn(
|
||||
"[AgentChatPage] contentId 对应的内容不存在,跳过同步:",
|
||||
contentId,
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error("[AgentChatPage] 检查内容存在性失败:", err);
|
||||
});
|
||||
}
|
||||
|
||||
// 根据文件名推进工作流步骤(使用动态映射)
|
||||
@@ -1211,6 +1246,8 @@ export function AgentChatPage({
|
||||
onCreationModeChange={setCreationMode}
|
||||
activeTheme={activeTheme}
|
||||
onThemeChange={setActiveTheme}
|
||||
projectId={projectId}
|
||||
onProjectChange={(newProjectId) => setInternalProjectId(newProjectId)}
|
||||
onRecommendationClick={(shortLabel, fullPrompt) => {
|
||||
// 直接将推荐提示词放入输入框,不创建项目
|
||||
setInput(fullPrompt);
|
||||
|
||||
@@ -2,9 +2,10 @@
|
||||
* @file 空状态组件
|
||||
* @description 无消息时显示的欢迎界面
|
||||
* @module components/chat/components/EmptyState
|
||||
* @requirements 4.1, 4.4
|
||||
*/
|
||||
|
||||
import React, { memo } from "react";
|
||||
import React, { memo, useState } from "react";
|
||||
import styled from "styled-components";
|
||||
import {
|
||||
MessageSquare,
|
||||
@@ -13,6 +14,7 @@ import {
|
||||
Languages,
|
||||
Lightbulb,
|
||||
} from "lucide-react";
|
||||
import { ProjectSelector } from "@/components/projects/ProjectSelector";
|
||||
|
||||
const Container = styled.div`
|
||||
flex: 1;
|
||||
@@ -108,6 +110,12 @@ const SuggestionDesc = styled.div`
|
||||
white-space: nowrap;
|
||||
`;
|
||||
|
||||
const ProjectSelectorWrapper = styled.div`
|
||||
margin-bottom: 24px;
|
||||
width: 100%;
|
||||
max-width: 280px;
|
||||
`;
|
||||
|
||||
const suggestions = [
|
||||
{
|
||||
icon: Code,
|
||||
@@ -138,15 +146,28 @@ const suggestions = [
|
||||
interface EmptyStateProps {
|
||||
/** 点击建议时的回调 */
|
||||
onSuggestionClick?: (prompt: string) => void;
|
||||
/** 当前选中的项目 ID */
|
||||
selectedProjectId?: string | null;
|
||||
/** 项目选择变化回调 */
|
||||
onProjectChange?: (projectId: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 空状态组件
|
||||
*
|
||||
* 显示欢迎信息和快捷建议
|
||||
* 显示欢迎信息、项目选择器和快捷建议
|
||||
*/
|
||||
export const EmptyState: React.FC<EmptyStateProps> = memo(
|
||||
({ onSuggestionClick }) => {
|
||||
({ onSuggestionClick, selectedProjectId, onProjectChange }) => {
|
||||
const [localProjectId, setLocalProjectId] = useState<string | null>(
|
||||
selectedProjectId || null,
|
||||
);
|
||||
|
||||
const handleProjectChange = (projectId: string) => {
|
||||
setLocalProjectId(projectId);
|
||||
onProjectChange?.(projectId);
|
||||
};
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<IconWrapper>
|
||||
@@ -158,6 +179,14 @@ export const EmptyState: React.FC<EmptyStateProps> = memo(
|
||||
我是你的 AI 助手,可以帮你解答问题、编写代码、翻译文本、头脑风暴等
|
||||
</Subtitle>
|
||||
|
||||
<ProjectSelectorWrapper>
|
||||
<ProjectSelector
|
||||
value={localProjectId}
|
||||
onChange={handleProjectChange}
|
||||
placeholder="选择项目"
|
||||
/>
|
||||
</ProjectSelectorWrapper>
|
||||
|
||||
<SuggestionsGrid>
|
||||
{suggestions.map((item) => (
|
||||
<SuggestionCard
|
||||
|
||||
@@ -17,6 +17,14 @@ interface StreamChunkEvent {
|
||||
done: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 流式对话选项
|
||||
*/
|
||||
interface StreamChatOptions {
|
||||
/** 项目 ID(可选,用于注入项目上下文) */
|
||||
projectId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 流式响应处理 Hook
|
||||
*
|
||||
@@ -31,12 +39,14 @@ export function useStreaming() {
|
||||
* @param messages - 消息历史
|
||||
* @param onChunk - 收到数据块时的回调
|
||||
* @param signal - AbortSignal 用于取消请求
|
||||
* @param options - 可选配置(包含 projectId)
|
||||
*/
|
||||
const streamChat = useCallback(
|
||||
async (
|
||||
messages: Message[],
|
||||
onChunk: (chunk: string) => void,
|
||||
signal?: AbortSignal,
|
||||
options?: StreamChatOptions,
|
||||
): Promise<void> => {
|
||||
let unlisten: UnlistenFn | null = null;
|
||||
let isAborted = false;
|
||||
@@ -69,6 +79,7 @@ export function useStreaming() {
|
||||
role: m.role,
|
||||
content: m.content,
|
||||
})),
|
||||
projectId: options?.projectId,
|
||||
});
|
||||
} catch (err) {
|
||||
if (!isAborted) {
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* @file ProjectDetailPage.tsx
|
||||
* @description 项目详情页组件,Tab 化展示各功能模块
|
||||
* @module components/projects/ProjectDetailPage
|
||||
* @requirements 5.1, 5.2
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { useProject } from "@/hooks/useProject";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ArrowLeftIcon, FolderIcon } from "lucide-react";
|
||||
import {
|
||||
ContentTab,
|
||||
PersonaTab,
|
||||
MaterialTab,
|
||||
TemplateTab,
|
||||
PublishTab,
|
||||
SettingsTab,
|
||||
} from "./tabs";
|
||||
|
||||
export type ProjectTab =
|
||||
| "content"
|
||||
| "persona"
|
||||
| "material"
|
||||
| "template"
|
||||
| "publish"
|
||||
| "settings";
|
||||
|
||||
export interface ProjectDetailPageProps {
|
||||
/** 项目 ID */
|
||||
projectId: string;
|
||||
/** 返回回调 */
|
||||
onBack?: () => void;
|
||||
/** 导航到聊天页面 */
|
||||
onNavigateToChat?: (projectId: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 项目详情页组件
|
||||
*
|
||||
* 显示项目的各个功能模块,通过 Tab 切换。
|
||||
*/
|
||||
export function ProjectDetailPage({
|
||||
projectId,
|
||||
onBack,
|
||||
onNavigateToChat,
|
||||
}: ProjectDetailPageProps) {
|
||||
const { project, loading, error } = useProject(projectId);
|
||||
const [activeTab, setActiveTab] = useState<ProjectTab>("content");
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-muted-foreground">加载中...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !project) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full gap-4">
|
||||
<div className="text-destructive">{error || "项目不存在"}</div>
|
||||
<Button variant="outline" onClick={onBack}>
|
||||
返回
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* 头部 */}
|
||||
<div className="flex items-center gap-4 p-4 border-b">
|
||||
<Button variant="ghost" size="icon" onClick={onBack}>
|
||||
<ArrowLeftIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
{project.icon ? (
|
||||
<span className="text-2xl">{project.icon}</span>
|
||||
) : (
|
||||
<FolderIcon className="h-6 w-6 text-muted-foreground" />
|
||||
)}
|
||||
<h1 className="text-xl font-semibold">{project.name}</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tab 内容 */}
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={(v) => setActiveTab(v as ProjectTab)}
|
||||
className="flex-1 flex flex-col"
|
||||
>
|
||||
<TabsList className="mx-4 mt-4 justify-start">
|
||||
<TabsTrigger value="content">内容</TabsTrigger>
|
||||
<TabsTrigger value="persona">人设</TabsTrigger>
|
||||
<TabsTrigger value="material">素材</TabsTrigger>
|
||||
<TabsTrigger value="template">排版</TabsTrigger>
|
||||
<TabsTrigger value="publish">发布</TabsTrigger>
|
||||
<TabsTrigger value="settings">设置</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<div className="flex-1 overflow-auto">
|
||||
<TabsContent value="content" className="h-full m-0">
|
||||
<ContentTab
|
||||
projectId={projectId}
|
||||
onNewTopic={() => onNavigateToChat?.(projectId)}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="persona" className="h-full m-0">
|
||||
<PersonaTab projectId={projectId} />
|
||||
</TabsContent>
|
||||
<TabsContent value="material" className="h-full m-0">
|
||||
<MaterialTab projectId={projectId} />
|
||||
</TabsContent>
|
||||
<TabsContent value="template" className="h-full m-0">
|
||||
<TemplateTab projectId={projectId} />
|
||||
</TabsContent>
|
||||
<TabsContent value="publish" className="h-full m-0">
|
||||
<PublishTab projectId={projectId} />
|
||||
</TabsContent>
|
||||
<TabsContent value="settings" className="h-full m-0">
|
||||
<SettingsTab projectId={projectId} />
|
||||
</TabsContent>
|
||||
</div>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ProjectDetailPage;
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* @file ProjectFilter.tsx
|
||||
* @description 项目筛选组件,用于侧边栏筛选话题
|
||||
* @module components/projects/ProjectFilter
|
||||
* @requirements 3.1
|
||||
*/
|
||||
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { useProjects } from "@/hooks/useProjects";
|
||||
import { FolderIcon, LayersIcon } from "lucide-react";
|
||||
|
||||
export interface ProjectFilterProps {
|
||||
/** 当前选中的项目 ID,null 表示全部项目 */
|
||||
value: string | null;
|
||||
/** 选择变化回调 */
|
||||
onChange: (projectId: string | null) => void;
|
||||
/** 自定义类名 */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 项目筛选组件
|
||||
*
|
||||
* 显示项目筛选下拉框,支持选择"全部项目"或特定项目。
|
||||
*/
|
||||
export function ProjectFilter({
|
||||
value,
|
||||
onChange,
|
||||
className,
|
||||
}: ProjectFilterProps) {
|
||||
const { projects, loading } = useProjects();
|
||||
|
||||
// 过滤掉已归档的项目
|
||||
const availableProjects = projects.filter((p) => !p.isArchived);
|
||||
|
||||
const handleChange = (val: string) => {
|
||||
onChange(val === "all" ? null : val);
|
||||
};
|
||||
|
||||
return (
|
||||
<Select
|
||||
value={value || "all"}
|
||||
onValueChange={handleChange}
|
||||
disabled={loading}
|
||||
>
|
||||
<SelectTrigger className={className}>
|
||||
<SelectValue placeholder="全部项目" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">
|
||||
<div className="flex items-center gap-2">
|
||||
<LayersIcon className="h-4 w-4 text-muted-foreground" />
|
||||
<span>全部项目</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
{availableProjects.map((project) => (
|
||||
<SelectItem key={project.id} value={project.id}>
|
||||
<div className="flex items-center gap-2">
|
||||
{project.icon ? (
|
||||
<span className="text-base">{project.icon}</span>
|
||||
) : (
|
||||
<FolderIcon className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
<span>{project.name}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
||||
export default ProjectFilter;
|
||||
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* @file ProjectSelector.tsx
|
||||
* @description 项目选择器组件,用于在聊天入口和侧边栏选择项目
|
||||
* @module components/projects/ProjectSelector
|
||||
* @requirements 4.1, 4.2, 4.3, 4.5
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { useProjects } from "@/hooks/useProjects";
|
||||
import { FolderIcon, StarIcon } from "lucide-react";
|
||||
|
||||
export interface ProjectSelectorProps {
|
||||
/** 当前选中的项目 ID */
|
||||
value: string | null;
|
||||
/** 选择变化回调 */
|
||||
onChange: (projectId: string) => void;
|
||||
/** 按主题类型筛选(可选,不传则显示所有项目) */
|
||||
workspaceType?: string;
|
||||
/** 占位符文本 */
|
||||
placeholder?: string;
|
||||
/** 是否禁用 */
|
||||
disabled?: boolean;
|
||||
/** 自定义类名 */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 项目选择器组件
|
||||
*
|
||||
* 显示项目下拉选择器,默认选中"默认项目"。
|
||||
*/
|
||||
export function ProjectSelector({
|
||||
value,
|
||||
onChange,
|
||||
workspaceType,
|
||||
placeholder = "选择项目",
|
||||
disabled = false,
|
||||
className,
|
||||
}: ProjectSelectorProps) {
|
||||
const { projects, defaultProject, loading, getOrCreateDefault } =
|
||||
useProjects();
|
||||
const [initialized, setInitialized] = useState(false);
|
||||
|
||||
// 过滤项目:排除归档 + 按主题类型筛选
|
||||
const availableProjects = useMemo(() => {
|
||||
let filtered = projects.filter((p) => !p.isArchived);
|
||||
|
||||
// 按主题类型筛选(默认项目始终显示)
|
||||
if (workspaceType && workspaceType !== "general") {
|
||||
filtered = filtered.filter(
|
||||
(p) => p.isDefault || p.workspaceType === workspaceType,
|
||||
);
|
||||
}
|
||||
|
||||
// 默认项目排在最前面
|
||||
return filtered.sort((a, b) => {
|
||||
if (a.isDefault && !b.isDefault) return -1;
|
||||
if (!a.isDefault && b.isDefault) return 1;
|
||||
return 0;
|
||||
});
|
||||
}, [projects, workspaceType]);
|
||||
|
||||
// 查找当前选中的项目
|
||||
const selectedProject = useMemo(
|
||||
() => availableProjects.find((p) => p.id === value),
|
||||
[availableProjects, value],
|
||||
);
|
||||
|
||||
// 初始化时确保有默认项目
|
||||
useEffect(() => {
|
||||
if (!initialized && !loading) {
|
||||
if (!value && defaultProject) {
|
||||
onChange(defaultProject.id);
|
||||
} else if (!value && !defaultProject) {
|
||||
// 创建默认项目
|
||||
getOrCreateDefault().then((project) => {
|
||||
onChange(project.id);
|
||||
});
|
||||
}
|
||||
setInitialized(true);
|
||||
}
|
||||
}, [
|
||||
initialized,
|
||||
loading,
|
||||
value,
|
||||
defaultProject,
|
||||
onChange,
|
||||
getOrCreateDefault,
|
||||
]);
|
||||
|
||||
return (
|
||||
<Select
|
||||
value={value || undefined}
|
||||
onValueChange={onChange}
|
||||
disabled={disabled || loading}
|
||||
>
|
||||
<SelectTrigger className={className}>
|
||||
{/* 自定义显示选中项 */}
|
||||
{selectedProject ? (
|
||||
<div className="flex items-center gap-2">
|
||||
{selectedProject.icon ? (
|
||||
<span className="text-base">{selectedProject.icon}</span>
|
||||
) : (
|
||||
<FolderIcon className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
<span className="truncate">{selectedProject.name}</span>
|
||||
{selectedProject.isDefault && (
|
||||
<StarIcon className="h-3 w-3 text-yellow-500 fill-yellow-500 flex-shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<SelectValue placeholder={placeholder} />
|
||||
)}
|
||||
</SelectTrigger>
|
||||
<SelectContent side="top" className="min-w-[280px] max-w-[400px]">
|
||||
{availableProjects.map((project) => (
|
||||
<SelectItem key={project.id} value={project.id}>
|
||||
<div className="flex items-center gap-2 w-full">
|
||||
{project.icon ? (
|
||||
<span className="text-base flex-shrink-0">{project.icon}</span>
|
||||
) : (
|
||||
<FolderIcon className="h-4 w-4 text-muted-foreground flex-shrink-0" />
|
||||
)}
|
||||
<span className="truncate">{project.name}</span>
|
||||
{project.isDefault && (
|
||||
<StarIcon className="h-3 w-3 text-yellow-500 fill-yellow-500 flex-shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
||||
export default ProjectSelector;
|
||||
@@ -284,7 +284,13 @@ export function ProjectsPage({ onNavigate }: ProjectsPageProps) {
|
||||
|
||||
// 点击项目
|
||||
const handleProjectClick = (project: Project) => {
|
||||
setSelectedProject(project);
|
||||
// 如果提供了 onNavigate,导航到项目详情页
|
||||
if (onNavigate) {
|
||||
onNavigate("project-detail", { projectId: project.id });
|
||||
} else {
|
||||
// 否则使用内部状态显示内容列表
|
||||
setSelectedProject(project);
|
||||
}
|
||||
};
|
||||
|
||||
// 点击内容时跳转到创作界面
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* @file TopicCard.tsx
|
||||
* @description 话题卡片组件,显示话题信息和项目标签
|
||||
* @module components/projects/TopicCard
|
||||
* @requirements 3.4, 3.5
|
||||
*/
|
||||
|
||||
import { memo } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { MessageSquareIcon, FolderIcon } from "lucide-react";
|
||||
|
||||
export interface TopicCardProps {
|
||||
/** 话题 ID */
|
||||
id: string;
|
||||
/** 话题标题 */
|
||||
title: string;
|
||||
/** 最后更新时间 */
|
||||
updatedAt: number;
|
||||
/** 消息数量 */
|
||||
messageCount?: number;
|
||||
/** 项目名称 */
|
||||
projectName?: string;
|
||||
/** 项目图标 */
|
||||
projectIcon?: string;
|
||||
/** 是否为默认项目 */
|
||||
isDefaultProject?: boolean;
|
||||
/** 是否选中 */
|
||||
isActive?: boolean;
|
||||
/** 点击回调 */
|
||||
onClick?: () => void;
|
||||
/** 自定义类名 */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化时间显示
|
||||
*/
|
||||
function formatTime(timestamp: number): string {
|
||||
const date = new Date(timestamp);
|
||||
const now = new Date();
|
||||
const diff = now.getTime() - date.getTime();
|
||||
|
||||
// 今天
|
||||
if (diff < 24 * 60 * 60 * 1000 && date.getDate() === now.getDate()) {
|
||||
return date.toLocaleTimeString("zh-CN", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
// 昨天
|
||||
const yesterday = new Date(now);
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
if (date.getDate() === yesterday.getDate()) {
|
||||
return "昨天";
|
||||
}
|
||||
|
||||
// 本周
|
||||
if (diff < 7 * 24 * 60 * 60 * 1000) {
|
||||
const days = ["周日", "周一", "周二", "周三", "周四", "周五", "周六"];
|
||||
return days[date.getDay()];
|
||||
}
|
||||
|
||||
// 更早
|
||||
return date.toLocaleDateString("zh-CN", {
|
||||
month: "numeric",
|
||||
day: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 话题卡片组件
|
||||
*
|
||||
* 显示话题标题、更新时间、消息数量和项目标签。
|
||||
*/
|
||||
export const TopicCard = memo(function TopicCard({
|
||||
title,
|
||||
updatedAt,
|
||||
messageCount,
|
||||
projectName,
|
||||
projectIcon,
|
||||
isDefaultProject,
|
||||
isActive,
|
||||
onClick,
|
||||
className,
|
||||
}: TopicCardProps) {
|
||||
const displayProjectName = isDefaultProject ? "默认项目" : projectName;
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"w-full text-left p-3 rounded-lg border transition-colors",
|
||||
"hover:bg-muted/50",
|
||||
isActive ? "bg-primary/10 border-primary/30" : "bg-card border-border",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{/* 标题 */}
|
||||
<div className="font-medium text-sm text-foreground truncate mb-1">
|
||||
{title || "新话题"}
|
||||
</div>
|
||||
|
||||
{/* 底部信息 */}
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||
{/* 项目标签 */}
|
||||
<div className="flex items-center gap-1 truncate">
|
||||
{projectIcon ? (
|
||||
<span className="text-sm">{projectIcon}</span>
|
||||
) : (
|
||||
<FolderIcon className="h-3 w-3" />
|
||||
)}
|
||||
<span className="truncate">{displayProjectName || "未分类"}</span>
|
||||
</div>
|
||||
|
||||
{/* 时间和消息数 */}
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
{messageCount !== undefined && messageCount > 0 && (
|
||||
<div className="flex items-center gap-0.5">
|
||||
<MessageSquareIcon className="h-3 w-3" />
|
||||
<span>{messageCount}</span>
|
||||
</div>
|
||||
)}
|
||||
<span>{formatTime(updatedAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
});
|
||||
|
||||
export default TopicCard;
|
||||
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* @file TopicListSidebar.tsx
|
||||
* @description 话题列表侧边栏组件,包含项目筛选和话题列表
|
||||
* @module components/projects/TopicListSidebar
|
||||
* @requirements 3.1, 3.2, 3.3
|
||||
*/
|
||||
|
||||
import { useState, useMemo } from "react";
|
||||
import { ProjectFilter } from "./ProjectFilter";
|
||||
import { TopicCard } from "./TopicCard";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { PlusIcon, SearchIcon } from "lucide-react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
||||
export interface Topic {
|
||||
id: string;
|
||||
title: string;
|
||||
projectId: string;
|
||||
projectName?: string;
|
||||
projectIcon?: string;
|
||||
isDefaultProject?: boolean;
|
||||
updatedAt: number;
|
||||
messageCount?: number;
|
||||
}
|
||||
|
||||
export interface TopicListSidebarProps {
|
||||
/** 话题列表 */
|
||||
topics: Topic[];
|
||||
/** 当前选中的话题 ID */
|
||||
activeTopicId?: string | null;
|
||||
/** 话题点击回调 */
|
||||
onTopicClick?: (topicId: string) => void;
|
||||
/** 新建话题回调 */
|
||||
onNewTopic?: (projectId: string | null) => void;
|
||||
/** 加载状态 */
|
||||
loading?: boolean;
|
||||
/** 自定义类名 */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 话题列表侧边栏组件
|
||||
*
|
||||
* 包含项目筛选下拉框和话题列表。
|
||||
*/
|
||||
export function TopicListSidebar({
|
||||
topics,
|
||||
activeTopicId,
|
||||
onTopicClick,
|
||||
onNewTopic,
|
||||
loading,
|
||||
className,
|
||||
}: TopicListSidebarProps) {
|
||||
const [filterProjectId, setFilterProjectId] = useState<string | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
|
||||
// 筛选后的话题列表
|
||||
const filteredTopics = useMemo(() => {
|
||||
let result = topics;
|
||||
|
||||
// 按项目筛选
|
||||
if (filterProjectId) {
|
||||
result = result.filter((t) => t.projectId === filterProjectId);
|
||||
}
|
||||
|
||||
// 按搜索关键词筛选
|
||||
if (searchQuery) {
|
||||
const query = searchQuery.toLowerCase();
|
||||
result = result.filter((t) => t.title.toLowerCase().includes(query));
|
||||
}
|
||||
|
||||
// 按更新时间排序
|
||||
return result.sort((a, b) => b.updatedAt - a.updatedAt);
|
||||
}, [topics, filterProjectId, searchQuery]);
|
||||
|
||||
return (
|
||||
<div className={`flex flex-col h-full ${className || ""}`}>
|
||||
{/* 头部 */}
|
||||
<div className="p-3 border-b space-y-3">
|
||||
{/* 项目筛选 */}
|
||||
<ProjectFilter
|
||||
value={filterProjectId}
|
||||
onChange={setFilterProjectId}
|
||||
className="w-full"
|
||||
/>
|
||||
|
||||
{/* 搜索框 */}
|
||||
<div className="relative">
|
||||
<SearchIcon className="absolute left-2.5 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="搜索话题..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-8 h-8"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 新建话题按钮 */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full"
|
||||
onClick={() => onNewTopic?.(filterProjectId)}
|
||||
>
|
||||
<PlusIcon className="h-4 w-4 mr-1" />
|
||||
新建话题
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 话题列表 */}
|
||||
<div className="flex-1 overflow-y-auto p-2 space-y-1">
|
||||
{loading ? (
|
||||
<div className="text-center text-muted-foreground py-8">
|
||||
加载中...
|
||||
</div>
|
||||
) : filteredTopics.length === 0 ? (
|
||||
<div className="text-center text-muted-foreground py-8">
|
||||
{searchQuery || filterProjectId ? "没有找到话题" : "暂无话题"}
|
||||
</div>
|
||||
) : (
|
||||
filteredTopics.map((topic) => (
|
||||
<TopicCard
|
||||
key={topic.id}
|
||||
id={topic.id}
|
||||
title={topic.title}
|
||||
updatedAt={topic.updatedAt}
|
||||
messageCount={topic.messageCount}
|
||||
projectName={topic.projectName}
|
||||
projectIcon={topic.projectIcon}
|
||||
isDefaultProject={topic.isDefaultProject}
|
||||
isActive={topic.id === activeTopicId}
|
||||
onClick={() => onTopicClick?.(topic.id)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TopicListSidebar;
|
||||
@@ -0,0 +1,258 @@
|
||||
/**
|
||||
* @file MaterialUploadDialog.tsx
|
||||
* @description 素材上传对话框组件
|
||||
* @module components/projects/dialogs/MaterialUploadDialog
|
||||
* @requirements 7.1, 7.2
|
||||
*/
|
||||
|
||||
import React, { useState, useRef } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
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 {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { UploadIcon, FileIcon, Loader2Icon, XIcon } from "lucide-react";
|
||||
import type { MaterialType, UploadMaterialRequest } from "@/types/material";
|
||||
|
||||
export interface MaterialUploadDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
projectId: string;
|
||||
onUpload: (data: UploadMaterialRequest, file?: File) => Promise<void>;
|
||||
}
|
||||
|
||||
const TYPE_OPTIONS: { value: MaterialType; label: string }[] = [
|
||||
{ value: "document", label: "文档" },
|
||||
{ value: "image", label: "图片" },
|
||||
{ value: "text", label: "文本" },
|
||||
{ value: "data", label: "数据" },
|
||||
{ value: "link", label: "链接" },
|
||||
];
|
||||
|
||||
export function MaterialUploadDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
projectId,
|
||||
onUpload,
|
||||
}: MaterialUploadDialogProps) {
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [name, setName] = useState("");
|
||||
const [type, setType] = useState<MaterialType>("document");
|
||||
const [description, setDescription] = useState("");
|
||||
const [tags, setTags] = useState("");
|
||||
const [content, setContent] = useState("");
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const resetForm = () => {
|
||||
setName("");
|
||||
setType("document");
|
||||
setDescription("");
|
||||
setTags("");
|
||||
setContent("");
|
||||
setSelectedFile(null);
|
||||
};
|
||||
|
||||
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
setSelectedFile(file);
|
||||
if (!name) setName(file.name);
|
||||
// 根据文件类型自动设置素材类型
|
||||
if (file.type.startsWith("image/")) setType("image");
|
||||
else if (file.type.includes("json") || file.type.includes("csv"))
|
||||
setType("data");
|
||||
else setType("document");
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpload = async () => {
|
||||
if (!name.trim()) return;
|
||||
setUploading(true);
|
||||
try {
|
||||
await onUpload(
|
||||
{
|
||||
projectId,
|
||||
name: name.trim(),
|
||||
type,
|
||||
description: description.trim() || undefined,
|
||||
tags: tags
|
||||
? tags
|
||||
.split(/[,,、]/)
|
||||
.map((t) => t.trim())
|
||||
.filter(Boolean)
|
||||
: [],
|
||||
content:
|
||||
type === "text" || type === "link" ? content.trim() : undefined,
|
||||
},
|
||||
selectedFile || undefined,
|
||||
);
|
||||
resetForm();
|
||||
onOpenChange(false);
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(v) => {
|
||||
if (!v) resetForm();
|
||||
onOpenChange(v);
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>上传素材</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
{/* 文件选择区域 */}
|
||||
<div
|
||||
className="border-2 border-dashed rounded-lg p-6 text-center cursor-pointer hover:border-primary/50 transition-colors"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
className="hidden"
|
||||
onChange={handleFileSelect}
|
||||
accept="image/*,.pdf,.doc,.docx,.txt,.md,.json,.csv"
|
||||
/>
|
||||
{selectedFile ? (
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<FileIcon className="h-8 w-8 text-primary" />
|
||||
<div className="text-left">
|
||||
<p className="font-medium">{selectedFile.name}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{(selectedFile.size / 1024).toFixed(1)} KB
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setSelectedFile(null);
|
||||
}}
|
||||
>
|
||||
<XIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<UploadIcon className="h-10 w-10 mx-auto mb-2 text-muted-foreground" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
点击或拖拽文件到此处
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
支持图片、文档、数据文件
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 素材名称 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="material-name">素材名称 *</Label>
|
||||
<Input
|
||||
id="material-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="输入素材名称"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 素材类型 */}
|
||||
<div className="space-y-2">
|
||||
<Label>素材类型</Label>
|
||||
<Select
|
||||
value={type}
|
||||
onValueChange={(v) => setType(v as MaterialType)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{TYPE_OPTIONS.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 文本/链接内容 */}
|
||||
{(type === "text" || type === "link") && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="material-content">
|
||||
{type === "link" ? "链接地址" : "文本内容"}
|
||||
</Label>
|
||||
<Textarea
|
||||
id="material-content"
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
placeholder={type === "link" ? "https://..." : "输入文本内容"}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 描述 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="material-desc">描述</Label>
|
||||
<Input
|
||||
id="material-desc"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="简要描述素材内容"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 标签 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="material-tags">标签</Label>
|
||||
<Input
|
||||
id="material-tags"
|
||||
value={tags}
|
||||
onChange={(e) => setTags(e.target.value)}
|
||||
placeholder="用逗号分隔,例如:参考、数据、图片"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={handleUpload} disabled={uploading || !name.trim()}>
|
||||
{uploading ? (
|
||||
<Loader2Icon className="h-4 w-4 mr-1 animate-spin" />
|
||||
) : (
|
||||
<UploadIcon className="h-4 w-4 mr-1" />
|
||||
)}
|
||||
{uploading ? "上传中..." : "上传"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default MaterialUploadDialog;
|
||||
@@ -0,0 +1,220 @@
|
||||
/**
|
||||
* @file PersonaDialog.tsx
|
||||
* @description 人设编辑对话框组件
|
||||
* @module components/projects/dialogs/PersonaDialog
|
||||
* @requirements 6.1, 6.2, 6.3, 6.4
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
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 { SaveIcon, Loader2Icon } from "lucide-react";
|
||||
import type { Persona, CreatePersonaRequest } from "@/types/persona";
|
||||
|
||||
export interface PersonaDialogProps {
|
||||
/** 是否打开 */
|
||||
open: boolean;
|
||||
/** 关闭回调 */
|
||||
onOpenChange: (open: boolean) => void;
|
||||
/** 项目 ID */
|
||||
projectId: string;
|
||||
/** 编辑的人设(新建时为 null) */
|
||||
persona: Persona | null;
|
||||
/** 保存回调 */
|
||||
onSave: (data: CreatePersonaRequest) => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 人设编辑对话框
|
||||
*/
|
||||
export function PersonaDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
projectId,
|
||||
persona,
|
||||
onSave,
|
||||
}: PersonaDialogProps) {
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [name, setName] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [style, setStyle] = useState("");
|
||||
const [tone, setTone] = useState("");
|
||||
const [targetAudience, setTargetAudience] = useState("");
|
||||
const [forbiddenWords, setForbiddenWords] = useState("");
|
||||
const [preferredWords, setPreferredWords] = useState("");
|
||||
|
||||
const isEditing = !!persona?.id;
|
||||
|
||||
// 同步编辑数据
|
||||
useEffect(() => {
|
||||
if (persona) {
|
||||
setName(persona.name || "");
|
||||
setDescription(persona.description || "");
|
||||
setStyle(persona.style || "");
|
||||
setTone(persona.tone || "");
|
||||
setTargetAudience(persona.targetAudience || "");
|
||||
setForbiddenWords(persona.forbiddenWords?.join("、") || "");
|
||||
setPreferredWords(persona.preferredWords?.join("、") || "");
|
||||
} else {
|
||||
// 重置表单
|
||||
setName("");
|
||||
setDescription("");
|
||||
setStyle("");
|
||||
setTone("");
|
||||
setTargetAudience("");
|
||||
setForbiddenWords("");
|
||||
setPreferredWords("");
|
||||
}
|
||||
}, [persona, open]);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!name.trim()) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
await onSave({
|
||||
projectId,
|
||||
name: name.trim(),
|
||||
description: description.trim() || undefined,
|
||||
style: style.trim() || "专业、清晰",
|
||||
tone: tone.trim() || undefined,
|
||||
targetAudience: targetAudience.trim() || undefined,
|
||||
forbiddenWords: forbiddenWords
|
||||
? forbiddenWords
|
||||
.split(/[,,、]/)
|
||||
.map((w) => w.trim())
|
||||
.filter(Boolean)
|
||||
: [],
|
||||
preferredWords: preferredWords
|
||||
? preferredWords
|
||||
.split(/[,,、]/)
|
||||
.map((w) => w.trim())
|
||||
.filter(Boolean)
|
||||
: [],
|
||||
});
|
||||
onOpenChange(false);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-lg max-h-[85vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{isEditing ? "编辑人设" : "创建人设"}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
{/* 名称 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="persona-name">人设名称 *</Label>
|
||||
<Input
|
||||
id="persona-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="例如:科技博主小王"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 描述 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="persona-desc">描述</Label>
|
||||
<Textarea
|
||||
id="persona-desc"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="简要描述这个人设的特点"
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 写作风格 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="persona-style">写作风格 *</Label>
|
||||
<Input
|
||||
id="persona-style"
|
||||
value={style}
|
||||
onChange={(e) => setStyle(e.target.value)}
|
||||
placeholder="例如:轻松幽默、专业严谨、温暖亲切"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 语气 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="persona-tone">语气</Label>
|
||||
<Input
|
||||
id="persona-tone"
|
||||
value={tone}
|
||||
onChange={(e) => setTone(e.target.value)}
|
||||
placeholder="例如:友好、正式、活泼"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 目标受众 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="persona-audience">目标受众</Label>
|
||||
<Input
|
||||
id="persona-audience"
|
||||
value={targetAudience}
|
||||
onChange={(e) => setTargetAudience(e.target.value)}
|
||||
placeholder="例如:年轻女性、科技爱好者、职场新人"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 禁用词 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="persona-forbidden">禁用词</Label>
|
||||
<Input
|
||||
id="persona-forbidden"
|
||||
value={forbiddenWords}
|
||||
onChange={(e) => setForbiddenWords(e.target.value)}
|
||||
placeholder="用逗号分隔,例如:绝对、一定、必须"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
AI 创作时会避免使用这些词
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 偏好词 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="persona-preferred">偏好词</Label>
|
||||
<Input
|
||||
id="persona-preferred"
|
||||
value={preferredWords}
|
||||
onChange={(e) => setPreferredWords(e.target.value)}
|
||||
placeholder="用逗号分隔,例如:宝子、姐妹、干货"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
AI 创作时会优先使用这些词
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={saving || !name.trim()}>
|
||||
{saving ? (
|
||||
<Loader2Icon className="h-4 w-4 mr-1 animate-spin" />
|
||||
) : (
|
||||
<SaveIcon className="h-4 w-4 mr-1" />
|
||||
)}
|
||||
{saving ? "保存中..." : "保存"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default PersonaDialog;
|
||||
@@ -0,0 +1,234 @@
|
||||
/**
|
||||
* @file TemplateDialog.tsx
|
||||
* @description 排版模板编辑对话框组件
|
||||
* @module components/projects/dialogs/TemplateDialog
|
||||
* @requirements 8.1, 8.2, 8.3, 8.4
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
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 {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { SaveIcon, Loader2Icon } from "lucide-react";
|
||||
import type {
|
||||
Template,
|
||||
Platform,
|
||||
EmojiUsage,
|
||||
CreateTemplateRequest,
|
||||
} from "@/types/template";
|
||||
|
||||
export interface TemplateDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
projectId: string;
|
||||
template: Template | null;
|
||||
onSave: (data: CreateTemplateRequest) => Promise<void>;
|
||||
}
|
||||
|
||||
const PLATFORM_OPTIONS: { value: Platform; label: string }[] = [
|
||||
{ value: "xiaohongshu", label: "小红书" },
|
||||
{ value: "wechat", label: "微信公众号" },
|
||||
{ value: "zhihu", label: "知乎" },
|
||||
{ value: "weibo", label: "微博" },
|
||||
{ value: "douyin", label: "抖音" },
|
||||
{ value: "markdown", label: "Markdown" },
|
||||
];
|
||||
|
||||
const EMOJI_OPTIONS: { value: EmojiUsage; label: string }[] = [
|
||||
{ value: "heavy", label: "大量使用" },
|
||||
{ value: "moderate", label: "适度使用" },
|
||||
{ value: "minimal", label: "少量使用" },
|
||||
];
|
||||
|
||||
export function TemplateDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
projectId,
|
||||
template,
|
||||
onSave,
|
||||
}: TemplateDialogProps) {
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [name, setName] = useState("");
|
||||
const [platform, setPlatform] = useState<Platform>("xiaohongshu");
|
||||
const [emojiUsage, setEmojiUsage] = useState<EmojiUsage>("moderate");
|
||||
const [titleStyle, setTitleStyle] = useState("");
|
||||
const [paragraphStyle, setParagraphStyle] = useState("");
|
||||
const [endingStyle, setEndingStyle] = useState("");
|
||||
const [hashtagRules, setHashtagRules] = useState("");
|
||||
|
||||
const isEditing = !!template?.id;
|
||||
|
||||
useEffect(() => {
|
||||
if (template) {
|
||||
setName(template.name || "");
|
||||
setPlatform(template.platform || "xiaohongshu");
|
||||
setEmojiUsage(template.emojiUsage || "moderate");
|
||||
setTitleStyle(template.titleStyle || "");
|
||||
setParagraphStyle(template.paragraphStyle || "");
|
||||
setEndingStyle(template.endingStyle || "");
|
||||
setHashtagRules(template.hashtagRules || "");
|
||||
} else {
|
||||
setName("");
|
||||
setPlatform("xiaohongshu");
|
||||
setEmojiUsage("moderate");
|
||||
setTitleStyle("");
|
||||
setParagraphStyle("");
|
||||
setEndingStyle("");
|
||||
setHashtagRules("");
|
||||
}
|
||||
}, [template, open]);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!name.trim()) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
await onSave({
|
||||
projectId,
|
||||
name: name.trim(),
|
||||
platform,
|
||||
emojiUsage,
|
||||
titleStyle: titleStyle.trim() || undefined,
|
||||
paragraphStyle: paragraphStyle.trim() || undefined,
|
||||
endingStyle: endingStyle.trim() || undefined,
|
||||
hashtagRules: hashtagRules.trim() || undefined,
|
||||
});
|
||||
onOpenChange(false);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-lg max-h-[85vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{isEditing ? "编辑模板" : "创建模板"}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="template-name">模板名称 *</Label>
|
||||
<Input
|
||||
id="template-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="例如:小红书清新风"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>目标平台</Label>
|
||||
<Select
|
||||
value={platform}
|
||||
onValueChange={(v) => setPlatform(v as Platform)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{PLATFORM_OPTIONS.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Emoji 使用</Label>
|
||||
<Select
|
||||
value={emojiUsage}
|
||||
onValueChange={(v) => setEmojiUsage(v as EmojiUsage)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{EMOJI_OPTIONS.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="template-title">标题风格</Label>
|
||||
<Textarea
|
||||
id="template-title"
|
||||
value={titleStyle}
|
||||
onChange={(e) => setTitleStyle(e.target.value)}
|
||||
placeholder="例如:使用数字开头,加入 emoji,控制在 20 字以内"
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="template-paragraph">段落风格</Label>
|
||||
<Textarea
|
||||
id="template-paragraph"
|
||||
value={paragraphStyle}
|
||||
onChange={(e) => setParagraphStyle(e.target.value)}
|
||||
placeholder="例如:每段 2-3 句话,使用短句,多用分点"
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="template-ending">结尾风格</Label>
|
||||
<Input
|
||||
id="template-ending"
|
||||
value={endingStyle}
|
||||
onChange={(e) => setEndingStyle(e.target.value)}
|
||||
placeholder="例如:引导互动,提问式结尾"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="template-hashtag">话题标签规则</Label>
|
||||
<Input
|
||||
id="template-hashtag"
|
||||
value={hashtagRules}
|
||||
onChange={(e) => setHashtagRules(e.target.value)}
|
||||
placeholder="例如:5-10 个标签,包含热门话题"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={saving || !name.trim()}>
|
||||
{saving ? (
|
||||
<Loader2Icon className="h-4 w-4 mr-1 animate-spin" />
|
||||
) : (
|
||||
<SaveIcon className="h-4 w-4 mr-1" />
|
||||
)}
|
||||
{saving ? "保存中..." : "保存"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default TemplateDialog;
|
||||
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* @file index.ts
|
||||
* @description 项目对话框组件导出
|
||||
* @module components/projects/dialogs
|
||||
*/
|
||||
|
||||
export { PersonaDialog } from "./PersonaDialog";
|
||||
export { TemplateDialog } from "./TemplateDialog";
|
||||
export { MaterialUploadDialog } from "./MaterialUploadDialog";
|
||||
@@ -3,9 +3,19 @@
|
||||
*/
|
||||
|
||||
export { ProjectsPage } from "./ProjectsPage";
|
||||
export { ProjectDetailPage } from "./ProjectDetailPage";
|
||||
export { ProjectCard } from "./ProjectCard";
|
||||
export { NewProjectCard } from "./NewProjectCard";
|
||||
export { ProjectCategories } from "./ProjectCategories";
|
||||
export { CreateProjectDialog } from "./CreateProjectDialog";
|
||||
export { ContentListPage } from "./ContentListPage";
|
||||
export type { ProjectFilter } from "./ProjectCategories";
|
||||
export { ProjectSelector } from "./ProjectSelector";
|
||||
export { ProjectFilter } from "./ProjectFilter";
|
||||
export { TopicCard } from "./TopicCard";
|
||||
export { TopicListSidebar } from "./TopicListSidebar";
|
||||
export type { ProjectFilter as ProjectFilterType } from "./ProjectCategories";
|
||||
export type { ProjectSelectorProps } from "./ProjectSelector";
|
||||
export type { ProjectFilterProps } from "./ProjectFilter";
|
||||
export type { TopicCardProps } from "./TopicCard";
|
||||
export type { TopicListSidebarProps, Topic } from "./TopicListSidebar";
|
||||
export type { ProjectDetailPageProps } from "./ProjectDetailPage";
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* @file ContentTab.tsx
|
||||
* @description 内容 Tab 组件,显示项目话题列表
|
||||
* @module components/projects/tabs/ContentTab
|
||||
* @requirements 5.3, 5.4, 5.5
|
||||
*/
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { PlusIcon, MessageSquareIcon } from "lucide-react";
|
||||
|
||||
export interface ContentTabProps {
|
||||
/** 项目 ID */
|
||||
projectId: string;
|
||||
/** 新建话题回调 */
|
||||
onNewTopic?: () => void;
|
||||
/** 话题点击回调 */
|
||||
onTopicClick?: (topicId: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 内容 Tab 组件
|
||||
*
|
||||
* 显示项目下的话题列表,提供新建话题入口。
|
||||
*/
|
||||
export function ContentTab({
|
||||
projectId: _projectId,
|
||||
onNewTopic,
|
||||
onTopicClick,
|
||||
}: ContentTabProps) {
|
||||
// TODO: 从后端获取项目话题列表
|
||||
const topics: Array<{
|
||||
id: string;
|
||||
title: string;
|
||||
updatedAt: number;
|
||||
messageCount: number;
|
||||
}> = [];
|
||||
|
||||
return (
|
||||
<div className="p-4 space-y-4">
|
||||
{/* 头部操作栏 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-medium">话题列表</h2>
|
||||
<Button onClick={onNewTopic}>
|
||||
<PlusIcon className="h-4 w-4 mr-1" />
|
||||
新建话题
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 话题列表 */}
|
||||
{topics.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-muted-foreground">
|
||||
<MessageSquareIcon className="h-12 w-12 mb-4 opacity-50" />
|
||||
<p className="text-lg mb-2">暂无话题</p>
|
||||
<p className="text-sm mb-4">点击上方按钮创建第一个话题</p>
|
||||
<Button variant="outline" onClick={onNewTopic}>
|
||||
<PlusIcon className="h-4 w-4 mr-1" />
|
||||
新建话题
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{topics.map((topic) => (
|
||||
<button
|
||||
key={topic.id}
|
||||
onClick={() => onTopicClick?.(topic.id)}
|
||||
className="w-full text-left p-4 rounded-lg border bg-card hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
<div className="font-medium mb-1">{topic.title}</div>
|
||||
<div className="flex items-center gap-4 text-sm text-muted-foreground">
|
||||
<span>{topic.messageCount} 条消息</span>
|
||||
<span>
|
||||
{new Date(topic.updatedAt).toLocaleDateString("zh-CN")}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ContentTab;
|
||||
@@ -0,0 +1,224 @@
|
||||
/**
|
||||
* @file MaterialTab.tsx
|
||||
* @description 素材 Tab 组件,管理项目素材
|
||||
* @module components/projects/tabs/MaterialTab
|
||||
* @requirements 7.1, 7.2, 7.3, 7.4, 7.5, 7.6
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { useMaterials } from "@/hooks/useMaterials";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
PlusIcon,
|
||||
SearchIcon,
|
||||
FileIcon,
|
||||
ImageIcon,
|
||||
FileTextIcon,
|
||||
DatabaseIcon,
|
||||
LinkIcon,
|
||||
TrashIcon,
|
||||
EyeIcon,
|
||||
} from "lucide-react";
|
||||
import type { MaterialType, UploadMaterialRequest } from "@/types/material";
|
||||
import { MaterialTypeLabels } from "@/types/material";
|
||||
import { MaterialUploadDialog } from "../dialogs";
|
||||
|
||||
export interface MaterialTabProps {
|
||||
/** 项目 ID */
|
||||
projectId: string;
|
||||
}
|
||||
|
||||
const MaterialTypeIcons: Record<MaterialType, typeof FileIcon> = {
|
||||
document: FileIcon,
|
||||
image: ImageIcon,
|
||||
text: FileTextIcon,
|
||||
data: DatabaseIcon,
|
||||
link: LinkIcon,
|
||||
};
|
||||
|
||||
/**
|
||||
* 素材 Tab 组件
|
||||
*
|
||||
* 显示素材网格,支持上传、预览、删除和筛选。
|
||||
*/
|
||||
export function MaterialTab({ projectId }: MaterialTabProps) {
|
||||
const {
|
||||
filteredMaterials,
|
||||
count,
|
||||
loading,
|
||||
filter,
|
||||
setFilter,
|
||||
upload,
|
||||
remove,
|
||||
} = useMaterials(projectId);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [uploadDialogOpen, setUploadDialogOpen] = useState(false);
|
||||
|
||||
const handleSearch = (query: string) => {
|
||||
setSearchQuery(query);
|
||||
setFilter({ ...filter, searchQuery: query });
|
||||
};
|
||||
|
||||
const handleUpload = async (data: UploadMaterialRequest, _file?: File) => {
|
||||
// TODO: 文件上传需要使用 Tauri 文件对话框获取路径
|
||||
// 目前仅支持文本/链接类型的素材
|
||||
await upload(data);
|
||||
};
|
||||
|
||||
const handleTypeFilter = (type: MaterialType | null) => {
|
||||
setFilter({ ...filter, type: type || undefined });
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (confirm("确定要删除这个素材吗?此操作不可恢复。")) {
|
||||
await remove(id);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-muted-foreground">加载中...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-4 space-y-4">
|
||||
{/* 头部操作栏 */}
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-2 flex-1">
|
||||
<div className="relative flex-1 max-w-sm">
|
||||
<SearchIcon className="absolute left-2.5 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="搜索素材..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => handleSearch(e.target.value)}
|
||||
className="pl-8"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
variant={!filter.type ? "secondary" : "ghost"}
|
||||
size="sm"
|
||||
onClick={() => handleTypeFilter(null)}
|
||||
>
|
||||
全部
|
||||
</Button>
|
||||
{(Object.keys(MaterialTypeLabels) as MaterialType[]).map((type) => (
|
||||
<Button
|
||||
key={type}
|
||||
variant={filter.type === type ? "secondary" : "ghost"}
|
||||
size="sm"
|
||||
onClick={() => handleTypeFilter(type)}
|
||||
>
|
||||
{MaterialTypeLabels[type]}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<Button onClick={() => setUploadDialogOpen(true)}>
|
||||
<PlusIcon className="h-4 w-4 mr-1" />
|
||||
上传素材
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 素材统计 */}
|
||||
<div className="text-sm text-muted-foreground">
|
||||
共 {count} 个素材
|
||||
{filter.type && `,筛选: ${MaterialTypeLabels[filter.type]}`}
|
||||
{searchQuery && `,搜索: "${searchQuery}"`}
|
||||
</div>
|
||||
|
||||
{/* 素材网格 */}
|
||||
{filteredMaterials.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-muted-foreground">
|
||||
<FileIcon className="h-12 w-12 mb-4 opacity-50" />
|
||||
<p className="text-lg mb-2">暂无素材</p>
|
||||
<p className="text-sm mb-4">上传素材供 AI 创作时引用</p>
|
||||
<Button variant="outline" onClick={() => setUploadDialogOpen(true)}>
|
||||
<PlusIcon className="h-4 w-4 mr-1" />
|
||||
上传素材
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
|
||||
{filteredMaterials.map((material) => {
|
||||
const Icon =
|
||||
MaterialTypeIcons[material.type as MaterialType] || FileIcon;
|
||||
return (
|
||||
<div
|
||||
key={material.id}
|
||||
className="p-4 rounded-lg border bg-card space-y-3"
|
||||
>
|
||||
{/* 图标和类型 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon className="h-5 w-5 text-muted-foreground" />
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{MaterialTypeLabels[material.type as MaterialType] ||
|
||||
material.type}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7">
|
||||
<EyeIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-destructive"
|
||||
onClick={() => handleDelete(material.id)}
|
||||
>
|
||||
<TrashIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 名称 */}
|
||||
<div className="font-medium truncate" title={material.name}>
|
||||
{material.name}
|
||||
</div>
|
||||
|
||||
{/* 描述 */}
|
||||
{material.description && (
|
||||
<p className="text-sm text-muted-foreground line-clamp-2">
|
||||
{material.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* 标签 */}
|
||||
{material.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{material.tags.slice(0, 3).map((tag) => (
|
||||
<Badge key={tag} variant="secondary" className="text-xs">
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
{material.tags.length > 3 && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
+{material.tags.length - 3}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 上传对话框 */}
|
||||
<MaterialUploadDialog
|
||||
open={uploadDialogOpen}
|
||||
onOpenChange={setUploadDialogOpen}
|
||||
projectId={projectId}
|
||||
onUpload={handleUpload}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default MaterialTab;
|
||||
@@ -0,0 +1,191 @@
|
||||
/**
|
||||
* @file PersonaTab.tsx
|
||||
* @description 人设 Tab 组件,管理项目人设
|
||||
* @module components/projects/tabs/PersonaTab
|
||||
* @requirements 6.1, 6.2, 6.3, 6.4, 6.5, 6.6
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { usePersonas } from "@/hooks/usePersonas";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
PlusIcon,
|
||||
UserIcon,
|
||||
StarIcon,
|
||||
PencilIcon,
|
||||
TrashIcon,
|
||||
} from "lucide-react";
|
||||
import type { Persona, CreatePersonaRequest } from "@/types/persona";
|
||||
import { PersonaDialog } from "../dialogs";
|
||||
|
||||
export interface PersonaTabProps {
|
||||
/** 项目 ID */
|
||||
projectId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 人设 Tab 组件
|
||||
*
|
||||
* 显示人设列表,支持创建、编辑、删除和设置默认人设。
|
||||
*/
|
||||
export function PersonaTab({ projectId }: PersonaTabProps) {
|
||||
const {
|
||||
personas,
|
||||
defaultPersona: _defaultPersona,
|
||||
loading,
|
||||
create,
|
||||
update,
|
||||
remove,
|
||||
setDefault,
|
||||
} = usePersonas(projectId);
|
||||
const [editingPersona, setEditingPersona] = useState<Persona | null>(null);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (confirm("确定要删除这个人设吗?")) {
|
||||
await remove(id);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSetDefault = async (id: string) => {
|
||||
await setDefault(id);
|
||||
};
|
||||
|
||||
const handleOpenCreate = () => {
|
||||
setEditingPersona(null);
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleOpenEdit = (persona: Persona) => {
|
||||
setEditingPersona(persona);
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleSave = async (data: CreatePersonaRequest) => {
|
||||
if (editingPersona?.id) {
|
||||
await update(editingPersona.id, data);
|
||||
} else {
|
||||
await create(data);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-muted-foreground">加载中...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-4 space-y-4">
|
||||
{/* 头部操作栏 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-medium">人设管理</h2>
|
||||
<Button onClick={handleOpenCreate}>
|
||||
<PlusIcon className="h-4 w-4 mr-1" />
|
||||
创建人设
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 人设列表 */}
|
||||
{personas.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-muted-foreground">
|
||||
<UserIcon className="h-12 w-12 mb-4 opacity-50" />
|
||||
<p className="text-lg mb-2">暂无人设</p>
|
||||
<p className="text-sm mb-4">创建人设来定义 AI 的写作风格</p>
|
||||
<Button variant="outline" onClick={handleOpenCreate}>
|
||||
<PlusIcon className="h-4 w-4 mr-1" />
|
||||
创建人设
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{personas.map((persona) => (
|
||||
<div
|
||||
key={persona.id}
|
||||
className="p-4 rounded-lg border bg-card space-y-3"
|
||||
>
|
||||
{/* 头部 */}
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<UserIcon className="h-5 w-5 text-muted-foreground" />
|
||||
<span className="font-medium">{persona.name}</span>
|
||||
{persona.isDefault && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
<StarIcon className="h-3 w-3 mr-1 fill-yellow-500 text-yellow-500" />
|
||||
默认
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => handleOpenEdit(persona)}
|
||||
>
|
||||
<PencilIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-destructive"
|
||||
onClick={() => handleDelete(persona.id)}
|
||||
>
|
||||
<TrashIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 描述 */}
|
||||
{persona.description && (
|
||||
<p className="text-sm text-muted-foreground line-clamp-2">
|
||||
{persona.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* 标签 */}
|
||||
<div className="flex flex-wrap gap-2 text-xs">
|
||||
<Badge variant="outline">风格: {persona.style}</Badge>
|
||||
{persona.tone && (
|
||||
<Badge variant="outline">语气: {persona.tone}</Badge>
|
||||
)}
|
||||
{persona.targetAudience && (
|
||||
<Badge variant="outline">
|
||||
受众: {persona.targetAudience}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 操作 */}
|
||||
{!persona.isDefault && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full"
|
||||
onClick={() => handleSetDefault(persona.id)}
|
||||
>
|
||||
<StarIcon className="h-4 w-4 mr-1" />
|
||||
设为默认
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 人设编辑对话框 */}
|
||||
<PersonaDialog
|
||||
open={dialogOpen}
|
||||
onOpenChange={setDialogOpen}
|
||||
projectId={projectId}
|
||||
persona={editingPersona}
|
||||
onSave={handleSave}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default PersonaTab;
|
||||
@@ -0,0 +1,190 @@
|
||||
/**
|
||||
* @file PublishTab.tsx
|
||||
* @description 发布配置 Tab 组件,管理项目发布设置
|
||||
* @module components/projects/tabs/PublishTab
|
||||
* @requirements 9.1, 9.2, 9.3
|
||||
*/
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
SendIcon,
|
||||
CheckCircleIcon,
|
||||
XCircleIcon,
|
||||
SettingsIcon,
|
||||
HistoryIcon,
|
||||
} from "lucide-react";
|
||||
import type { Platform } from "@/types/template";
|
||||
|
||||
export interface PublishTabProps {
|
||||
/** 项目 ID */
|
||||
projectId: string;
|
||||
}
|
||||
|
||||
/** 平台配置信息 */
|
||||
interface PlatformConfig {
|
||||
platform: Platform;
|
||||
name: string;
|
||||
icon: string;
|
||||
isConfigured: boolean;
|
||||
lastPublishedAt?: number;
|
||||
publishCount: number;
|
||||
}
|
||||
|
||||
/** 平台显示名称映射 */
|
||||
const _PLATFORM_LABELS: Record<Platform, string> = {
|
||||
xiaohongshu: "小红书",
|
||||
wechat: "微信公众号",
|
||||
zhihu: "知乎",
|
||||
weibo: "微博",
|
||||
douyin: "抖音",
|
||||
markdown: "Markdown",
|
||||
};
|
||||
|
||||
/** 模拟平台配置数据 - 实际应从 hook 获取 */
|
||||
const MOCK_PLATFORMS: PlatformConfig[] = [
|
||||
{
|
||||
platform: "xiaohongshu",
|
||||
name: "小红书",
|
||||
icon: "📕",
|
||||
isConfigured: false,
|
||||
publishCount: 0,
|
||||
},
|
||||
{
|
||||
platform: "wechat",
|
||||
name: "微信公众号",
|
||||
icon: "💬",
|
||||
isConfigured: false,
|
||||
publishCount: 0,
|
||||
},
|
||||
{
|
||||
platform: "zhihu",
|
||||
name: "知乎",
|
||||
icon: "📘",
|
||||
isConfigured: false,
|
||||
publishCount: 0,
|
||||
},
|
||||
{
|
||||
platform: "weibo",
|
||||
name: "微博",
|
||||
icon: "🔴",
|
||||
isConfigured: false,
|
||||
publishCount: 0,
|
||||
},
|
||||
{
|
||||
platform: "douyin",
|
||||
name: "抖音",
|
||||
icon: "🎵",
|
||||
isConfigured: false,
|
||||
publishCount: 0,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* 发布配置 Tab 组件
|
||||
*
|
||||
* 显示平台配置状态和发布历史。
|
||||
*/
|
||||
export function PublishTab({ projectId: _projectId }: PublishTabProps) {
|
||||
// TODO: 使用 usePublishConfigs hook 获取实际数据
|
||||
const platforms = MOCK_PLATFORMS;
|
||||
const loading = false;
|
||||
|
||||
const _formatDate = (timestamp?: number) => {
|
||||
if (!timestamp) return "从未发布";
|
||||
return new Date(timestamp).toLocaleDateString("zh-CN", {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-muted-foreground">加载中...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-4 space-y-6">
|
||||
{/* 头部 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-medium">发布配置</h2>
|
||||
</div>
|
||||
|
||||
{/* 平台配置列表 */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-medium text-muted-foreground">平台账号</h3>
|
||||
<div className="grid gap-3">
|
||||
{platforms.map((config) => (
|
||||
<div
|
||||
key={config.platform}
|
||||
className="flex items-center justify-between p-4 rounded-lg border bg-card"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-2xl">{config.icon}</span>
|
||||
<div>
|
||||
<p className="font-medium">{config.name}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{config.isConfigured
|
||||
? `已发布 ${config.publishCount} 篇`
|
||||
: "未配置"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{config.isConfigured ? (
|
||||
<Badge variant="secondary" className="gap-1">
|
||||
<CheckCircleIcon className="h-3 w-3 text-green-500" />
|
||||
已连接
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="outline" className="gap-1">
|
||||
<XCircleIcon className="h-3 w-3 text-muted-foreground" />
|
||||
未连接
|
||||
</Badge>
|
||||
)}
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8">
|
||||
<SettingsIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 发布历史 */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-medium text-muted-foreground">
|
||||
发布历史
|
||||
</h3>
|
||||
<Button variant="ghost" size="sm">
|
||||
<HistoryIcon className="h-4 w-4 mr-1" />
|
||||
查看全部
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground border rounded-lg">
|
||||
<SendIcon className="h-10 w-10 mb-3 opacity-50" />
|
||||
<p className="text-sm">暂无发布记录</p>
|
||||
<p className="text-xs mt-1">配置平台账号后即可发布内容</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 提示信息 */}
|
||||
<div className="p-4 rounded-lg bg-muted/50 text-sm text-muted-foreground">
|
||||
<p className="font-medium mb-1">💡 提示</p>
|
||||
<p>
|
||||
发布功能正在开发中。配置平台账号后,您可以一键将内容发布到多个平台。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default PublishTab;
|
||||
@@ -0,0 +1,219 @@
|
||||
/**
|
||||
* @file SettingsTab.tsx
|
||||
* @description 项目设置 Tab 组件,管理项目基本设置
|
||||
* @module components/projects/tabs/SettingsTab
|
||||
* @requirements 11.1, 11.2, 11.3, 11.4, 11.5, 11.6
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useProject } from "@/hooks/useProject";
|
||||
import { usePersonas } from "@/hooks/usePersonas";
|
||||
import { useTemplates } from "@/hooks/useTemplates";
|
||||
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 { SaveIcon, ArchiveIcon, AlertTriangleIcon } from "lucide-react";
|
||||
|
||||
export interface SettingsTabProps {
|
||||
/** 项目 ID */
|
||||
projectId: string;
|
||||
}
|
||||
|
||||
/** 项目图标选项 */
|
||||
const ICON_OPTIONS = [
|
||||
{ value: "📝", label: "📝 笔记" },
|
||||
{ value: "📚", label: "📚 书籍" },
|
||||
{ value: "💡", label: "💡 创意" },
|
||||
{ value: "🎯", label: "🎯 目标" },
|
||||
{ value: "🚀", label: "🚀 项目" },
|
||||
{ value: "🎨", label: "🎨 设计" },
|
||||
{ value: "📱", label: "📱 应用" },
|
||||
{ value: "🌟", label: "🌟 精选" },
|
||||
];
|
||||
|
||||
/**
|
||||
* 项目设置 Tab 组件
|
||||
*
|
||||
* 管理项目基本信息、默认人设/模板、归档。
|
||||
*/
|
||||
export function SettingsTab({ projectId }: SettingsTabProps) {
|
||||
const { project, loading, update, archive } = useProject(projectId);
|
||||
const { personas } = usePersonas(projectId);
|
||||
const { templates } = useTemplates(projectId);
|
||||
|
||||
const [name, setName] = useState("");
|
||||
const [icon, setIcon] = useState("📝");
|
||||
const [defaultPersonaId, setDefaultPersonaId] = useState("");
|
||||
const [defaultTemplateId, setDefaultTemplateId] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
// 同步项目数据到表单
|
||||
useEffect(() => {
|
||||
if (project) {
|
||||
setName(project.name);
|
||||
setIcon(project.icon || "📝");
|
||||
setDefaultPersonaId(project.defaultPersonaId || "");
|
||||
setDefaultTemplateId(project.defaultTemplateId || "");
|
||||
}
|
||||
}, [project]);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!project) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
await update({
|
||||
name,
|
||||
icon,
|
||||
defaultPersonaId: defaultPersonaId || undefined,
|
||||
defaultTemplateId: defaultTemplateId || undefined,
|
||||
});
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleArchive = async () => {
|
||||
if (!project) return;
|
||||
if (confirm("确认归档项目?归档后项目将从列表中隐藏。")) {
|
||||
await archive();
|
||||
}
|
||||
};
|
||||
|
||||
if (loading || !project) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-muted-foreground">加载中...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isDefault = project.isDefault;
|
||||
|
||||
return (
|
||||
<div className="p-4 space-y-6 max-w-2xl">
|
||||
{/* 基本信息 */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-medium text-muted-foreground">基本信息</h3>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="project-name">项目名称</Label>
|
||||
<Input
|
||||
id="project-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="输入项目名称"
|
||||
disabled={isDefault}
|
||||
/>
|
||||
{isDefault && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
默认项目名称不可修改
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>项目图标</Label>
|
||||
<Select value={icon} onValueChange={setIcon}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="选择图标" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ICON_OPTIONS.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 默认配置 */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-medium text-muted-foreground">默认配置</h3>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>默认人设</Label>
|
||||
<Select value={defaultPersonaId} onValueChange={setDefaultPersonaId}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="选择默认人设" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="">无</SelectItem>
|
||||
{personas.map((persona) => (
|
||||
<SelectItem key={persona.id} value={persona.id}>
|
||||
{persona.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
新建话题时自动使用的人设
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>默认排版模板</Label>
|
||||
<Select
|
||||
value={defaultTemplateId}
|
||||
onValueChange={setDefaultTemplateId}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="选择默认模板" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="">无</SelectItem>
|
||||
{templates.map((template) => (
|
||||
<SelectItem key={template.id} value={template.id}>
|
||||
{template.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
新建话题时自动使用的排版模板
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 保存按钮 */}
|
||||
<Button onClick={handleSave} disabled={saving}>
|
||||
<SaveIcon className="h-4 w-4 mr-1" />
|
||||
{saving ? "保存中..." : "保存设置"}
|
||||
</Button>
|
||||
|
||||
{/* 危险操作区域 */}
|
||||
{!isDefault && (
|
||||
<div className="space-y-4 pt-6 border-t">
|
||||
<h3 className="text-sm font-medium text-destructive flex items-center gap-2">
|
||||
<AlertTriangleIcon className="h-4 w-4" />
|
||||
危险操作
|
||||
</h3>
|
||||
<Button variant="outline" onClick={handleArchive}>
|
||||
<ArchiveIcon className="h-4 w-4 mr-1" />
|
||||
归档项目
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 默认项目提示 */}
|
||||
{isDefault && (
|
||||
<div className="p-4 rounded-lg bg-muted/50 text-sm text-muted-foreground">
|
||||
<p className="font-medium mb-1">💡 默认项目</p>
|
||||
<p>
|
||||
默认项目不可删除或归档。所有未分配项目的话题都会归属到默认项目。
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SettingsTab;
|
||||
@@ -0,0 +1,210 @@
|
||||
/**
|
||||
* @file TemplateTab.tsx
|
||||
* @description 排版模板 Tab 组件,管理项目排版模板
|
||||
* @module components/projects/tabs/TemplateTab
|
||||
* @requirements 8.1, 8.2, 8.3, 8.4, 8.5
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { useTemplates } from "@/hooks/useTemplates";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
PlusIcon,
|
||||
LayoutTemplateIcon,
|
||||
StarIcon,
|
||||
PencilIcon,
|
||||
TrashIcon,
|
||||
EyeIcon as _EyeIcon,
|
||||
} from "lucide-react";
|
||||
import type {
|
||||
Template,
|
||||
Platform,
|
||||
EmojiUsage,
|
||||
CreateTemplateRequest,
|
||||
} from "@/types/template";
|
||||
import { TemplateDialog } from "../dialogs";
|
||||
|
||||
export interface TemplateTabProps {
|
||||
/** 项目 ID */
|
||||
projectId: string;
|
||||
}
|
||||
|
||||
/** 平台显示名称映射 */
|
||||
const PLATFORM_LABELS: Record<Platform, string> = {
|
||||
xiaohongshu: "小红书",
|
||||
wechat: "微信公众号",
|
||||
zhihu: "知乎",
|
||||
weibo: "微博",
|
||||
douyin: "抖音",
|
||||
markdown: "Markdown",
|
||||
};
|
||||
|
||||
/** Emoji 使用程度显示名称 */
|
||||
const EMOJI_LABELS: Record<EmojiUsage, string> = {
|
||||
heavy: "大量使用",
|
||||
moderate: "适度使用",
|
||||
minimal: "少量使用",
|
||||
};
|
||||
|
||||
/**
|
||||
* 排版模板 Tab 组件
|
||||
*
|
||||
* 显示模板列表,支持创建、编辑、删除和设置默认模板。
|
||||
*/
|
||||
export function TemplateTab({ projectId }: TemplateTabProps) {
|
||||
const { templates, loading, create, update, remove, setDefault } =
|
||||
useTemplates(projectId);
|
||||
const [editingTemplate, setEditingTemplate] = useState<Template | null>(null);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (confirm("确定要删除这个模板吗?")) {
|
||||
await remove(id);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSetDefault = async (id: string) => {
|
||||
await setDefault(id);
|
||||
};
|
||||
|
||||
const handleOpenCreate = () => {
|
||||
setEditingTemplate(null);
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleOpenEdit = (template: Template) => {
|
||||
setEditingTemplate(template);
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleSave = async (data: CreateTemplateRequest) => {
|
||||
if (editingTemplate?.id) {
|
||||
await update(editingTemplate.id, data);
|
||||
} else {
|
||||
await create(data);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-muted-foreground">加载中...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-4 space-y-4">
|
||||
{/* 头部操作栏 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-medium">排版模板</h2>
|
||||
<Button onClick={handleOpenCreate}>
|
||||
<PlusIcon className="h-4 w-4 mr-1" />
|
||||
创建模板
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 模板列表 */}
|
||||
{templates.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-muted-foreground">
|
||||
<LayoutTemplateIcon className="h-12 w-12 mb-4 opacity-50" />
|
||||
<p className="text-lg mb-2">暂无排版模板</p>
|
||||
<p className="text-sm mb-4">创建模板来定义内容的排版风格</p>
|
||||
<Button variant="outline" onClick={handleOpenCreate}>
|
||||
<PlusIcon className="h-4 w-4 mr-1" />
|
||||
创建模板
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{templates.map((template) => (
|
||||
<div
|
||||
key={template.id}
|
||||
className="p-4 rounded-lg border bg-card space-y-3"
|
||||
>
|
||||
{/* 头部 */}
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<LayoutTemplateIcon className="h-5 w-5 text-muted-foreground" />
|
||||
<span className="font-medium">{template.name}</span>
|
||||
{template.isDefault && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
<StarIcon className="h-3 w-3 mr-1 fill-yellow-500 text-yellow-500" />
|
||||
默认
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => handleOpenEdit(template)}
|
||||
>
|
||||
<PencilIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-destructive"
|
||||
onClick={() => handleDelete(template.id)}
|
||||
>
|
||||
<TrashIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 平台和 Emoji 设置 */}
|
||||
<div className="flex flex-wrap gap-2 text-xs">
|
||||
<Badge variant="outline">
|
||||
{PLATFORM_LABELS[template.platform] || template.platform}
|
||||
</Badge>
|
||||
<Badge variant="outline">
|
||||
Emoji:{" "}
|
||||
{EMOJI_LABELS[template.emojiUsage] || template.emojiUsage}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* 样式预览 */}
|
||||
<div className="text-sm text-muted-foreground space-y-1">
|
||||
{template.titleStyle && (
|
||||
<p className="line-clamp-1">标题: {template.titleStyle}</p>
|
||||
)}
|
||||
{template.paragraphStyle && (
|
||||
<p className="line-clamp-1">
|
||||
段落: {template.paragraphStyle}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 操作 */}
|
||||
{!template.isDefault && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full"
|
||||
onClick={() => handleSetDefault(template.id)}
|
||||
>
|
||||
<StarIcon className="h-4 w-4 mr-1" />
|
||||
设为默认
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 模板编辑对话框 */}
|
||||
<TemplateDialog
|
||||
open={dialogOpen}
|
||||
onOpenChange={setDialogOpen}
|
||||
projectId={projectId}
|
||||
template={editingTemplate}
|
||||
onSave={handleSave}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TemplateTab;
|
||||
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* 项目详情页 Tab 组件导出
|
||||
*/
|
||||
|
||||
export { ContentTab } from "./ContentTab";
|
||||
export { PersonaTab } from "./PersonaTab";
|
||||
export { MaterialTab } from "./MaterialTab";
|
||||
export { TemplateTab } from "./TemplateTab";
|
||||
export { PublishTab } from "./PublishTab";
|
||||
export { SettingsTab } from "./SettingsTab";
|
||||
@@ -1,4 +1,10 @@
|
||||
import React, { createContext, useContext, useState } from "react";
|
||||
import React, {
|
||||
createContext,
|
||||
useContext,
|
||||
useState,
|
||||
useEffect,
|
||||
useRef,
|
||||
} from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
|
||||
@@ -31,10 +37,35 @@ const Select: React.FC<SelectProps> = ({
|
||||
}) => {
|
||||
const [internalValue, setInternalValue] = useState(defaultValue || "");
|
||||
const [open, setOpen] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const currentValue = value !== undefined ? value : internalValue;
|
||||
const handleValueChange = onValueChange || setInternalValue;
|
||||
|
||||
// 点击外部关闭下拉菜单
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (
|
||||
containerRef.current &&
|
||||
!containerRef.current.contains(event.target as Node)
|
||||
) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 延迟添加监听器,避免立即触发
|
||||
const timer = setTimeout(() => {
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
}, 0);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
document.removeEventListener("mousedown", handleClickOutside);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<SelectContext.Provider
|
||||
value={{
|
||||
@@ -46,6 +77,7 @@ const Select: React.FC<SelectProps> = ({
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="relative"
|
||||
onMouseLeave={closeOnMouseLeave ? () => setOpen(false) : undefined}
|
||||
>
|
||||
|
||||
@@ -4,6 +4,12 @@ export { useDeepLink } from "./useDeepLink";
|
||||
export { useModelRegistry } from "./useModelRegistry";
|
||||
export { useSound } from "./useSound";
|
||||
export { useWorkspace } from "./useWorkspace";
|
||||
export { useProjects } from "./useProjects";
|
||||
export { useProject } from "./useProject";
|
||||
export { usePersonas } from "./usePersonas";
|
||||
export { useMaterials } from "./useMaterials";
|
||||
export { useTemplates } from "./useTemplates";
|
||||
export { useProjectContext } from "./useProjectContext";
|
||||
export type { UseSoundReturn } from "./useSound";
|
||||
export type {
|
||||
Workspace,
|
||||
@@ -12,6 +18,12 @@ export type {
|
||||
UpdateWorkspaceRequest,
|
||||
UseWorkspaceReturn,
|
||||
} from "./useWorkspace";
|
||||
export type { UseProjectsReturn } from "./useProjects";
|
||||
export type { UseProjectReturn } from "./useProject";
|
||||
export type { UsePersonasReturn } from "./usePersonas";
|
||||
export type { UseMaterialsReturn } from "./useMaterials";
|
||||
export type { UseTemplatesReturn } from "./useTemplates";
|
||||
export type { UseProjectContextReturn } from "./useProjectContext";
|
||||
export type {
|
||||
ConnectPayload,
|
||||
RelayInfo,
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* @file useMaterials.ts
|
||||
* @description 素材管理 Hook,提供素材列表获取、上传、更新、删除、预览功能
|
||||
* @module hooks/useMaterials
|
||||
* @requirements 7.1, 7.2, 7.3, 7.4, 7.5, 7.6
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback, useMemo } from "react";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import type {
|
||||
Material,
|
||||
UploadMaterialRequest,
|
||||
MaterialUpdate,
|
||||
MaterialFilter,
|
||||
} from "@/types/material";
|
||||
|
||||
/** Hook 返回类型 */
|
||||
export interface UseMaterialsReturn {
|
||||
/** 素材列表 */
|
||||
materials: Material[];
|
||||
/** 筛选后的素材列表 */
|
||||
filteredMaterials: Material[];
|
||||
/** 素材数量 */
|
||||
count: number;
|
||||
/** 加载状态 */
|
||||
loading: boolean;
|
||||
/** 错误信息 */
|
||||
error: string | null;
|
||||
/** 当前筛选条件 */
|
||||
filter: MaterialFilter;
|
||||
/** 设置筛选条件 */
|
||||
setFilter: (filter: MaterialFilter) => void;
|
||||
/** 刷新列表 */
|
||||
refresh: () => Promise<void>;
|
||||
/** 上传素材 */
|
||||
upload: (request: UploadMaterialRequest) => Promise<Material>;
|
||||
/** 更新素材 */
|
||||
update: (id: string, update: MaterialUpdate) => Promise<Material>;
|
||||
/** 删除素材 */
|
||||
remove: (id: string) => Promise<void>;
|
||||
/** 获取素材内容 */
|
||||
getContent: (id: string) => Promise<string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 素材管理 Hook
|
||||
*
|
||||
* @param projectId - 项目 ID
|
||||
*/
|
||||
export function useMaterials(projectId: string | null): UseMaterialsReturn {
|
||||
const [materials, setMaterials] = useState<Material[]>([]);
|
||||
const [count, setCount] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [filter, setFilter] = useState<MaterialFilter>({});
|
||||
|
||||
/** 刷新素材列表 */
|
||||
const refresh = useCallback(async () => {
|
||||
if (!projectId) {
|
||||
setMaterials([]);
|
||||
setCount(0);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const [list, total] = await Promise.all([
|
||||
invoke<Material[]>("list_materials", { projectId, filter: null }),
|
||||
invoke<number>("get_material_count", { projectId }),
|
||||
]);
|
||||
|
||||
setMaterials(list);
|
||||
setCount(total);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [projectId]);
|
||||
|
||||
/** 筛选后的素材列表 */
|
||||
const filteredMaterials = useMemo(() => {
|
||||
let result = materials;
|
||||
|
||||
// 按类型筛选
|
||||
if (filter.type) {
|
||||
result = result.filter((m) => m.type === filter.type);
|
||||
}
|
||||
|
||||
// 按标签筛选
|
||||
if (filter.tags && filter.tags.length > 0) {
|
||||
result = result.filter((m) =>
|
||||
filter.tags!.some((tag) => m.tags.includes(tag)),
|
||||
);
|
||||
}
|
||||
|
||||
// 按搜索关键词筛选
|
||||
if (filter.searchQuery) {
|
||||
const query = filter.searchQuery.toLowerCase();
|
||||
result = result.filter(
|
||||
(m) =>
|
||||
m.name.toLowerCase().includes(query) ||
|
||||
m.description?.toLowerCase().includes(query) ||
|
||||
m.tags.some((tag) => tag.toLowerCase().includes(query)),
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [materials, filter]);
|
||||
|
||||
/** 上传素材 */
|
||||
const upload = useCallback(
|
||||
async (request: UploadMaterialRequest): Promise<Material> => {
|
||||
const material = await invoke<Material>("upload_material", {
|
||||
req: request,
|
||||
});
|
||||
await refresh();
|
||||
return material;
|
||||
},
|
||||
[refresh],
|
||||
);
|
||||
|
||||
/** 更新素材 */
|
||||
const update = useCallback(
|
||||
async (id: string, updateData: MaterialUpdate): Promise<Material> => {
|
||||
const material = await invoke<Material>("update_material", {
|
||||
id,
|
||||
update: updateData,
|
||||
});
|
||||
await refresh();
|
||||
return material;
|
||||
},
|
||||
[refresh],
|
||||
);
|
||||
|
||||
/** 删除素材 */
|
||||
const remove = useCallback(
|
||||
async (id: string): Promise<void> => {
|
||||
await invoke("delete_material", { id });
|
||||
await refresh();
|
||||
},
|
||||
[refresh],
|
||||
);
|
||||
|
||||
/** 获取素材内容 */
|
||||
const getContent = useCallback(async (id: string): Promise<string> => {
|
||||
return invoke<string>("get_material_content", { id });
|
||||
}, []);
|
||||
|
||||
// 初始加载
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
}, [refresh]);
|
||||
|
||||
return {
|
||||
materials,
|
||||
filteredMaterials,
|
||||
count,
|
||||
loading,
|
||||
error,
|
||||
filter,
|
||||
setFilter,
|
||||
refresh,
|
||||
upload,
|
||||
update,
|
||||
remove,
|
||||
getContent,
|
||||
};
|
||||
}
|
||||
|
||||
export default useMaterials;
|
||||
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* @file usePersonas.ts
|
||||
* @description 人设管理 Hook,提供人设列表获取、创建、更新、删除、设置默认功能
|
||||
* @module hooks/usePersonas
|
||||
* @requirements 6.1, 6.2, 6.3, 6.4, 6.5, 6.6
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import type {
|
||||
Persona,
|
||||
CreatePersonaRequest,
|
||||
PersonaUpdate,
|
||||
PersonaTemplate,
|
||||
} from "@/types/persona";
|
||||
|
||||
/** Hook 返回类型 */
|
||||
export interface UsePersonasReturn {
|
||||
/** 人设列表 */
|
||||
personas: Persona[];
|
||||
/** 默认人设 */
|
||||
defaultPersona: Persona | null;
|
||||
/** 人设模板列表 */
|
||||
templates: PersonaTemplate[];
|
||||
/** 加载状态 */
|
||||
loading: boolean;
|
||||
/** 错误信息 */
|
||||
error: string | null;
|
||||
/** 刷新列表 */
|
||||
refresh: () => Promise<void>;
|
||||
/** 创建人设 */
|
||||
create: (request: CreatePersonaRequest) => Promise<Persona>;
|
||||
/** 更新人设 */
|
||||
update: (id: string, update: PersonaUpdate) => Promise<Persona>;
|
||||
/** 删除人设 */
|
||||
remove: (id: string) => Promise<void>;
|
||||
/** 设置默认人设 */
|
||||
setDefault: (personaId: string) => Promise<void>;
|
||||
/** 加载人设模板 */
|
||||
loadTemplates: () => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 人设管理 Hook
|
||||
*
|
||||
* @param projectId - 项目 ID
|
||||
*/
|
||||
export function usePersonas(projectId: string | null): UsePersonasReturn {
|
||||
const [personas, setPersonas] = useState<Persona[]>([]);
|
||||
const [defaultPersona, setDefaultPersona] = useState<Persona | null>(null);
|
||||
const [templates, setTemplates] = useState<PersonaTemplate[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
/** 刷新人设列表 */
|
||||
const refresh = useCallback(async () => {
|
||||
if (!projectId) {
|
||||
setPersonas([]);
|
||||
setDefaultPersona(null);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const [list, defaultP] = await Promise.all([
|
||||
invoke<Persona[]>("list_personas", { projectId }),
|
||||
invoke<Persona | null>("get_default_persona", { projectId }),
|
||||
]);
|
||||
|
||||
setPersonas(list);
|
||||
setDefaultPersona(defaultP);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [projectId]);
|
||||
|
||||
/** 创建人设 */
|
||||
const create = useCallback(
|
||||
async (request: CreatePersonaRequest): Promise<Persona> => {
|
||||
const persona = await invoke<Persona>("create_persona", { req: request });
|
||||
await refresh();
|
||||
return persona;
|
||||
},
|
||||
[refresh],
|
||||
);
|
||||
|
||||
/** 更新人设 */
|
||||
const update = useCallback(
|
||||
async (id: string, updateData: PersonaUpdate): Promise<Persona> => {
|
||||
const persona = await invoke<Persona>("update_persona", {
|
||||
id,
|
||||
update: updateData,
|
||||
});
|
||||
await refresh();
|
||||
return persona;
|
||||
},
|
||||
[refresh],
|
||||
);
|
||||
|
||||
/** 删除人设 */
|
||||
const remove = useCallback(
|
||||
async (id: string): Promise<void> => {
|
||||
await invoke("delete_persona", { id });
|
||||
await refresh();
|
||||
},
|
||||
[refresh],
|
||||
);
|
||||
|
||||
/** 设置默认人设 */
|
||||
const setDefault = useCallback(
|
||||
async (personaId: string): Promise<void> => {
|
||||
if (!projectId) return;
|
||||
await invoke("set_default_persona", { projectId, personaId });
|
||||
await refresh();
|
||||
},
|
||||
[projectId, refresh],
|
||||
);
|
||||
|
||||
/** 加载人设模板 */
|
||||
const loadTemplates = useCallback(async () => {
|
||||
try {
|
||||
const list = await invoke<PersonaTemplate[]>("list_persona_templates");
|
||||
setTemplates(list);
|
||||
} catch (err) {
|
||||
console.error("加载人设模板失败:", err);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 初始加载
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
}, [refresh]);
|
||||
|
||||
return {
|
||||
personas,
|
||||
defaultPersona,
|
||||
templates,
|
||||
loading,
|
||||
error,
|
||||
refresh,
|
||||
create,
|
||||
update,
|
||||
remove,
|
||||
setDefault,
|
||||
loadTemplates,
|
||||
};
|
||||
}
|
||||
|
||||
export default usePersonas;
|
||||
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* @file useProject.ts
|
||||
* @description 单个项目管理 Hook,提供项目获取、更新功能
|
||||
* @module hooks/useProject
|
||||
* @requirements 5.1, 11.1, 11.4, 11.5
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import type { Project, ProjectUpdate } from "@/types/project";
|
||||
|
||||
/** Hook 返回类型 */
|
||||
export interface UseProjectReturn {
|
||||
/** 项目数据 */
|
||||
project: Project | null;
|
||||
/** 加载状态 */
|
||||
loading: boolean;
|
||||
/** 错误信息 */
|
||||
error: string | null;
|
||||
/** 刷新项目 */
|
||||
refresh: () => Promise<void>;
|
||||
/** 更新项目 */
|
||||
update: (updateData: ProjectUpdate) => Promise<Project>;
|
||||
/** 归档项目 */
|
||||
archive: () => Promise<void>;
|
||||
/** 取消归档 */
|
||||
unarchive: () => Promise<void>;
|
||||
/** 切换收藏状态 */
|
||||
toggleFavorite: () => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 单个项目管理 Hook
|
||||
*
|
||||
* @param projectId - 项目 ID
|
||||
*/
|
||||
export function useProject(projectId: string | null): UseProjectReturn {
|
||||
const [project, setProject] = useState<Project | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
/** 刷新项目 */
|
||||
const refresh = useCallback(async () => {
|
||||
if (!projectId) {
|
||||
setProject(null);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const result = await invoke<Project | null>("workspace_get", {
|
||||
id: projectId,
|
||||
});
|
||||
|
||||
setProject(result);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [projectId]);
|
||||
|
||||
/** 更新项目 */
|
||||
const update = useCallback(
|
||||
async (updateData: ProjectUpdate): Promise<Project> => {
|
||||
if (!projectId) {
|
||||
throw new Error("项目 ID 不能为空");
|
||||
}
|
||||
|
||||
const result = await invoke<Project>("workspace_update", {
|
||||
id: projectId,
|
||||
request: updateData,
|
||||
});
|
||||
|
||||
setProject(result);
|
||||
return result;
|
||||
},
|
||||
[projectId],
|
||||
);
|
||||
|
||||
/** 归档项目 */
|
||||
const archive = useCallback(async () => {
|
||||
if (!projectId) return;
|
||||
await update({ isArchived: true });
|
||||
}, [projectId, update]);
|
||||
|
||||
/** 取消归档 */
|
||||
const unarchive = useCallback(async () => {
|
||||
if (!projectId) return;
|
||||
await update({ isArchived: false });
|
||||
}, [projectId, update]);
|
||||
|
||||
/** 切换收藏状态 */
|
||||
const toggleFavorite = useCallback(async () => {
|
||||
if (!projectId || !project) return;
|
||||
await update({ isFavorite: !project.isFavorite });
|
||||
}, [projectId, project, update]);
|
||||
|
||||
// 初始加载和 projectId 变化时刷新
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
}, [refresh]);
|
||||
|
||||
return {
|
||||
project,
|
||||
loading,
|
||||
error,
|
||||
refresh,
|
||||
update,
|
||||
archive,
|
||||
unarchive,
|
||||
toggleFavorite,
|
||||
};
|
||||
}
|
||||
|
||||
export default useProject;
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* @file useProjectContext.ts
|
||||
* @description 项目上下文 Hook,提供项目上下文加载和 system_prompt 构建功能
|
||||
* @module hooks/useProjectContext
|
||||
* @requirements 10.1, 10.2, 10.3
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import type { ProjectContext } from "@/types/context";
|
||||
|
||||
/** Hook 返回类型 */
|
||||
export interface UseProjectContextReturn {
|
||||
/** 项目上下文 */
|
||||
context: ProjectContext | null;
|
||||
/** System Prompt */
|
||||
systemPrompt: string | null;
|
||||
/** 加载状态 */
|
||||
loading: boolean;
|
||||
/** 错误信息 */
|
||||
error: string | null;
|
||||
/** 刷新上下文 */
|
||||
refresh: () => Promise<void>;
|
||||
/** 构建 System Prompt */
|
||||
buildSystemPrompt: () => Promise<string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 项目上下文 Hook
|
||||
*
|
||||
* @param projectId - 项目 ID
|
||||
*/
|
||||
export function useProjectContext(
|
||||
projectId: string | null,
|
||||
): UseProjectContextReturn {
|
||||
const [context, setContext] = useState<ProjectContext | null>(null);
|
||||
const [systemPrompt, setSystemPrompt] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
/** 刷新项目上下文 */
|
||||
const refresh = useCallback(async () => {
|
||||
if (!projectId) {
|
||||
setContext(null);
|
||||
setSystemPrompt(null);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const [ctx, prompt] = await Promise.all([
|
||||
invoke<ProjectContext>("get_project_context", { projectId }),
|
||||
invoke<string>("build_project_system_prompt", { projectId }),
|
||||
]);
|
||||
|
||||
setContext(ctx);
|
||||
setSystemPrompt(prompt);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [projectId]);
|
||||
|
||||
/** 构建 System Prompt */
|
||||
const buildSystemPrompt = useCallback(async (): Promise<string> => {
|
||||
if (!projectId) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const prompt = await invoke<string>("build_project_system_prompt", {
|
||||
projectId,
|
||||
});
|
||||
setSystemPrompt(prompt);
|
||||
return prompt;
|
||||
}, [projectId]);
|
||||
|
||||
// 初始加载
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
}, [refresh]);
|
||||
|
||||
return {
|
||||
context,
|
||||
systemPrompt,
|
||||
loading,
|
||||
error,
|
||||
refresh,
|
||||
buildSystemPrompt,
|
||||
};
|
||||
}
|
||||
|
||||
export default useProjectContext;
|
||||
@@ -0,0 +1,176 @@
|
||||
/**
|
||||
* @file useProjects.ts
|
||||
* @description 项目管理 Hook,提供项目列表获取、创建、更新、删除、筛选功能
|
||||
* @module hooks/useProjects
|
||||
* @requirements 12.1, 12.2, 12.3, 12.4
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback, useMemo } from "react";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import type {
|
||||
Project,
|
||||
CreateProjectRequest,
|
||||
ProjectUpdate,
|
||||
ProjectFilter,
|
||||
} from "@/types/project";
|
||||
// WorkspaceType 用于类型定义,暂未使用
|
||||
// import type { WorkspaceType } from '@/types/workspace';
|
||||
|
||||
/** Hook 返回类型 */
|
||||
export interface UseProjectsReturn {
|
||||
/** 项目列表 */
|
||||
projects: Project[];
|
||||
/** 筛选后的项目列表 */
|
||||
filteredProjects: Project[];
|
||||
/** 默认项目 */
|
||||
defaultProject: Project | null;
|
||||
/** 加载状态 */
|
||||
loading: boolean;
|
||||
/** 错误信息 */
|
||||
error: string | null;
|
||||
/** 当前筛选条件 */
|
||||
filter: ProjectFilter;
|
||||
/** 设置筛选条件 */
|
||||
setFilter: (filter: ProjectFilter) => void;
|
||||
/** 刷新列表 */
|
||||
refresh: () => Promise<void>;
|
||||
/** 创建项目 */
|
||||
create: (request: CreateProjectRequest) => Promise<Project>;
|
||||
/** 更新项目 */
|
||||
update: (id: string, update: ProjectUpdate) => Promise<Project>;
|
||||
/** 删除项目 */
|
||||
remove: (id: string) => Promise<boolean>;
|
||||
/** 获取或创建默认项目 */
|
||||
getOrCreateDefault: () => Promise<Project>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 项目管理 Hook
|
||||
*/
|
||||
export function useProjects(): UseProjectsReturn {
|
||||
const [projects, setProjects] = useState<Project[]>([]);
|
||||
const [defaultProject, setDefaultProject] = useState<Project | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [filter, setFilter] = useState<ProjectFilter>({});
|
||||
|
||||
/** 刷新项目列表 */
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const [list, defaultProj] = await Promise.all([
|
||||
invoke<Project[]>("workspace_list"),
|
||||
invoke<Project | null>("workspace_get_default"),
|
||||
]);
|
||||
|
||||
setProjects(list);
|
||||
setDefaultProject(defaultProj);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
/** 筛选后的项目列表 */
|
||||
const filteredProjects = useMemo(() => {
|
||||
let result = projects;
|
||||
|
||||
// 按 workspaceType 筛选
|
||||
if (filter.workspaceType) {
|
||||
result = result.filter((p) => p.workspaceType === filter.workspaceType);
|
||||
}
|
||||
|
||||
// 按归档状态筛选
|
||||
if (filter.isArchived !== undefined) {
|
||||
result = result.filter((p) => p.isArchived === filter.isArchived);
|
||||
}
|
||||
|
||||
// 按收藏状态筛选
|
||||
if (filter.isFavorite !== undefined) {
|
||||
result = result.filter((p) => p.isFavorite === filter.isFavorite);
|
||||
}
|
||||
|
||||
// 按搜索关键词筛选
|
||||
if (filter.searchQuery) {
|
||||
const query = filter.searchQuery.toLowerCase();
|
||||
result = result.filter(
|
||||
(p) =>
|
||||
p.name.toLowerCase().includes(query) ||
|
||||
p.tags.some((tag) => tag.toLowerCase().includes(query)),
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [projects, filter]);
|
||||
|
||||
/** 创建项目 */
|
||||
const create = useCallback(
|
||||
async (request: CreateProjectRequest): Promise<Project> => {
|
||||
const project = await invoke<Project>("workspace_create", {
|
||||
request: {
|
||||
name: request.name,
|
||||
rootPath: request.name, // 使用名称作为路径
|
||||
workspaceType: request.workspaceType,
|
||||
},
|
||||
});
|
||||
await refresh();
|
||||
return project;
|
||||
},
|
||||
[refresh],
|
||||
);
|
||||
|
||||
/** 更新项目 */
|
||||
const update = useCallback(
|
||||
async (id: string, updateData: ProjectUpdate): Promise<Project> => {
|
||||
const project = await invoke<Project>("workspace_update", {
|
||||
id,
|
||||
request: updateData,
|
||||
});
|
||||
await refresh();
|
||||
return project;
|
||||
},
|
||||
[refresh],
|
||||
);
|
||||
|
||||
/** 删除项目 */
|
||||
const remove = useCallback(
|
||||
async (id: string): Promise<boolean> => {
|
||||
const result = await invoke<boolean>("workspace_delete", { id });
|
||||
await refresh();
|
||||
return result;
|
||||
},
|
||||
[refresh],
|
||||
);
|
||||
|
||||
/** 获取或创建默认项目 */
|
||||
const getOrCreateDefault = useCallback(async (): Promise<Project> => {
|
||||
const project = await invoke<Project>("get_or_create_default_project");
|
||||
await refresh();
|
||||
return project;
|
||||
}, [refresh]);
|
||||
|
||||
// 初始加载
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
}, [refresh]);
|
||||
|
||||
return {
|
||||
projects,
|
||||
filteredProjects,
|
||||
defaultProject,
|
||||
loading,
|
||||
error,
|
||||
filter,
|
||||
setFilter,
|
||||
refresh,
|
||||
create,
|
||||
update,
|
||||
remove,
|
||||
getOrCreateDefault,
|
||||
};
|
||||
}
|
||||
|
||||
export default useProjects;
|
||||
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* @file useTemplates.ts
|
||||
* @description 模板管理 Hook,提供模板列表获取、创建、更新、删除、设置默认功能
|
||||
* @module hooks/useTemplates
|
||||
* @requirements 8.1, 8.2, 8.3, 8.4, 8.5
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import type {
|
||||
Template,
|
||||
CreateTemplateRequest,
|
||||
TemplateUpdate,
|
||||
} from "@/types/template";
|
||||
|
||||
/** Hook 返回类型 */
|
||||
export interface UseTemplatesReturn {
|
||||
/** 模板列表 */
|
||||
templates: Template[];
|
||||
/** 默认模板 */
|
||||
defaultTemplate: Template | null;
|
||||
/** 加载状态 */
|
||||
loading: boolean;
|
||||
/** 错误信息 */
|
||||
error: string | null;
|
||||
/** 刷新列表 */
|
||||
refresh: () => Promise<void>;
|
||||
/** 创建模板 */
|
||||
create: (request: CreateTemplateRequest) => Promise<Template>;
|
||||
/** 更新模板 */
|
||||
update: (id: string, update: TemplateUpdate) => Promise<Template>;
|
||||
/** 删除模板 */
|
||||
remove: (id: string) => Promise<void>;
|
||||
/** 设置默认模板 */
|
||||
setDefault: (templateId: string) => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 模板管理 Hook
|
||||
*
|
||||
* @param projectId - 项目 ID
|
||||
*/
|
||||
export function useTemplates(projectId: string | null): UseTemplatesReturn {
|
||||
const [templates, setTemplates] = useState<Template[]>([]);
|
||||
const [defaultTemplate, setDefaultTemplate] = useState<Template | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
/** 刷新模板列表 */
|
||||
const refresh = useCallback(async () => {
|
||||
if (!projectId) {
|
||||
setTemplates([]);
|
||||
setDefaultTemplate(null);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const [list, defaultT] = await Promise.all([
|
||||
invoke<Template[]>("list_templates", { projectId }),
|
||||
invoke<Template | null>("get_default_template", { projectId }),
|
||||
]);
|
||||
|
||||
setTemplates(list);
|
||||
setDefaultTemplate(defaultT);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [projectId]);
|
||||
|
||||
/** 创建模板 */
|
||||
const create = useCallback(
|
||||
async (request: CreateTemplateRequest): Promise<Template> => {
|
||||
const template = await invoke<Template>("create_template", {
|
||||
req: request,
|
||||
});
|
||||
await refresh();
|
||||
return template;
|
||||
},
|
||||
[refresh],
|
||||
);
|
||||
|
||||
/** 更新模板 */
|
||||
const update = useCallback(
|
||||
async (id: string, updateData: TemplateUpdate): Promise<Template> => {
|
||||
const template = await invoke<Template>("update_template", {
|
||||
id,
|
||||
update: updateData,
|
||||
});
|
||||
await refresh();
|
||||
return template;
|
||||
},
|
||||
[refresh],
|
||||
);
|
||||
|
||||
/** 删除模板 */
|
||||
const remove = useCallback(
|
||||
async (id: string): Promise<void> => {
|
||||
await invoke("delete_template", { id });
|
||||
await refresh();
|
||||
},
|
||||
[refresh],
|
||||
);
|
||||
|
||||
/** 设置默认模板 */
|
||||
const setDefault = useCallback(
|
||||
async (templateId: string): Promise<void> => {
|
||||
if (!projectId) return;
|
||||
await invoke("set_default_template", { projectId, templateId });
|
||||
await refresh();
|
||||
},
|
||||
[projectId, refresh],
|
||||
);
|
||||
|
||||
// 初始加载
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
}, [refresh]);
|
||||
|
||||
return {
|
||||
templates,
|
||||
defaultTemplate,
|
||||
loading,
|
||||
error,
|
||||
refresh,
|
||||
create,
|
||||
update,
|
||||
remove,
|
||||
setDefault,
|
||||
};
|
||||
}
|
||||
|
||||
export default useTemplates;
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* 项目上下文相关类型定义
|
||||
*
|
||||
* 定义项目上下文(ProjectContext)和发布配置相关的 TypeScript 类型。
|
||||
*
|
||||
* @module types/context
|
||||
* @requirements 10.1
|
||||
*/
|
||||
|
||||
import type { Project } from "./project";
|
||||
import type { Persona } from "./persona";
|
||||
import type { Material } from "./material";
|
||||
import type { Template, Platform } from "./template";
|
||||
|
||||
// ============================================================================
|
||||
// 上下文类型
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 项目上下文
|
||||
*
|
||||
* 包含项目的完整配置信息,用于构建 AI System Prompt。
|
||||
*/
|
||||
export interface ProjectContext {
|
||||
project: Project;
|
||||
persona?: Persona;
|
||||
materials: Material[];
|
||||
template?: Template;
|
||||
}
|
||||
|
||||
/**
|
||||
* 发布配置
|
||||
*/
|
||||
export interface PublishConfig {
|
||||
id: string;
|
||||
projectId: string;
|
||||
platform: Platform;
|
||||
isConfigured: boolean;
|
||||
lastPublishedAt?: number;
|
||||
publishCount: number;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* 类型定义导出
|
||||
*
|
||||
* @module types
|
||||
*/
|
||||
|
||||
// Workspace 类型
|
||||
export * from "./workspace";
|
||||
|
||||
// 项目类型
|
||||
export * from "./project";
|
||||
|
||||
// 人设类型
|
||||
export * from "./persona";
|
||||
|
||||
// 素材类型
|
||||
export * from "./material";
|
||||
|
||||
// 模板类型
|
||||
export * from "./template";
|
||||
|
||||
// 上下文类型
|
||||
export * from "./context";
|
||||
|
||||
// 聊天类型
|
||||
export * from "./chat";
|
||||
|
||||
// 页面类型
|
||||
export * from "./page";
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* 素材相关类型定义
|
||||
*
|
||||
* 定义素材(Material)相关的 TypeScript 类型。
|
||||
*
|
||||
* @module types/material
|
||||
* @requirements 7.3
|
||||
*/
|
||||
|
||||
// ============================================================================
|
||||
// 素材类型
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 素材类型枚举
|
||||
*/
|
||||
export type MaterialType = "document" | "image" | "text" | "data" | "link";
|
||||
|
||||
/**
|
||||
* 素材类型显示名称映射
|
||||
*/
|
||||
export const MaterialTypeLabels: Record<MaterialType, string> = {
|
||||
document: "文档",
|
||||
image: "图片",
|
||||
text: "文本",
|
||||
data: "数据",
|
||||
link: "链接",
|
||||
};
|
||||
|
||||
/**
|
||||
* 素材
|
||||
*/
|
||||
export interface Material {
|
||||
id: string;
|
||||
projectId: string;
|
||||
name: string;
|
||||
type: MaterialType;
|
||||
filePath?: string;
|
||||
fileSize?: number;
|
||||
mimeType?: string;
|
||||
content?: string;
|
||||
tags: string[];
|
||||
description?: string;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传素材请求
|
||||
*/
|
||||
export interface UploadMaterialRequest {
|
||||
projectId: string;
|
||||
name: string;
|
||||
type: MaterialType;
|
||||
filePath?: string;
|
||||
content?: string;
|
||||
tags?: string[];
|
||||
description?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新素材请求
|
||||
*/
|
||||
export interface MaterialUpdate {
|
||||
name?: string;
|
||||
tags?: string[];
|
||||
description?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 素材筛选条件
|
||||
*/
|
||||
export interface MaterialFilter {
|
||||
type?: MaterialType;
|
||||
tags?: string[];
|
||||
searchQuery?: string;
|
||||
}
|
||||
+12
-1
@@ -22,6 +22,7 @@ export type Page =
|
||||
| "web"
|
||||
| "image-analysis"
|
||||
| "projects"
|
||||
| "project-detail"
|
||||
| `plugin:${string}`;
|
||||
|
||||
/**
|
||||
@@ -33,7 +34,17 @@ export interface AgentPageParams {
|
||||
contentId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 项目详情页参数
|
||||
*/
|
||||
export interface ProjectDetailPageParams {
|
||||
projectId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 页面参数联合类型
|
||||
*/
|
||||
export type PageParams = AgentPageParams | Record<string, unknown>;
|
||||
export type PageParams =
|
||||
| AgentPageParams
|
||||
| ProjectDetailPageParams
|
||||
| Record<string, unknown>;
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* 人设相关类型定义
|
||||
*
|
||||
* 定义人设(Persona)相关的 TypeScript 类型。
|
||||
*
|
||||
* @module types/persona
|
||||
* @requirements 6.3
|
||||
*/
|
||||
|
||||
import type { Platform } from "./template";
|
||||
|
||||
// ============================================================================
|
||||
// 人设类型
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 人设
|
||||
*/
|
||||
export interface Persona {
|
||||
id: string;
|
||||
projectId: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
style: string;
|
||||
tone?: string;
|
||||
targetAudience?: string;
|
||||
forbiddenWords: string[];
|
||||
preferredWords: string[];
|
||||
examples?: string;
|
||||
platforms: Platform[];
|
||||
isDefault: boolean;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建人设请求
|
||||
*/
|
||||
export interface CreatePersonaRequest {
|
||||
projectId: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
style: string;
|
||||
tone?: string;
|
||||
targetAudience?: string;
|
||||
forbiddenWords?: string[];
|
||||
preferredWords?: string[];
|
||||
examples?: string;
|
||||
platforms?: Platform[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新人设请求
|
||||
*/
|
||||
export interface PersonaUpdate {
|
||||
name?: string;
|
||||
description?: string;
|
||||
style?: string;
|
||||
tone?: string;
|
||||
targetAudience?: string;
|
||||
forbiddenWords?: string[];
|
||||
preferredWords?: string[];
|
||||
examples?: string;
|
||||
platforms?: Platform[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 人设模板(用于快速创建)
|
||||
*/
|
||||
export interface PersonaTemplate {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
style: string;
|
||||
tone: string;
|
||||
targetAudience: string;
|
||||
platforms: Platform[];
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* 项目相关类型定义
|
||||
*
|
||||
* 定义项目(Project/Workspace)相关的 TypeScript 类型。
|
||||
*
|
||||
* @module types/project
|
||||
* @requirements 1.5, 12.6
|
||||
*/
|
||||
|
||||
import type { WorkspaceType } from "./workspace";
|
||||
|
||||
// ============================================================================
|
||||
// 项目类型
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 项目(Workspace)
|
||||
*/
|
||||
export interface Project {
|
||||
id: string;
|
||||
name: string;
|
||||
workspaceType: WorkspaceType;
|
||||
rootPath: string;
|
||||
isDefault: boolean;
|
||||
icon?: string;
|
||||
color?: string;
|
||||
isFavorite: boolean;
|
||||
isArchived: boolean;
|
||||
tags: string[];
|
||||
defaultPersonaId?: string;
|
||||
defaultTemplateId?: string;
|
||||
stats?: ProjectStats;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 项目统计信息
|
||||
*/
|
||||
export interface ProjectStats {
|
||||
contentCount: number;
|
||||
totalWords: number;
|
||||
completedCount: number;
|
||||
lastAccessed?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建项目请求
|
||||
*/
|
||||
export interface CreateProjectRequest {
|
||||
name: string;
|
||||
workspaceType: WorkspaceType;
|
||||
icon?: string;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新项目请求
|
||||
*/
|
||||
export interface ProjectUpdate {
|
||||
name?: string;
|
||||
icon?: string;
|
||||
color?: string;
|
||||
isFavorite?: boolean;
|
||||
isArchived?: boolean;
|
||||
tags?: string[];
|
||||
defaultPersonaId?: string;
|
||||
defaultTemplateId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 项目筛选条件
|
||||
*/
|
||||
export interface ProjectFilter {
|
||||
workspaceType?: WorkspaceType;
|
||||
isArchived?: boolean;
|
||||
isFavorite?: boolean;
|
||||
searchQuery?: string;
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* 排版模板相关类型定义
|
||||
*
|
||||
* 定义排版模板(Template)相关的 TypeScript 类型。
|
||||
*
|
||||
* @module types/template
|
||||
* @requirements 8.3
|
||||
*/
|
||||
|
||||
// ============================================================================
|
||||
// 模板类型
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 平台类型枚举
|
||||
*/
|
||||
export type Platform =
|
||||
| "xiaohongshu" // 小红书
|
||||
| "wechat" // 微信公众号
|
||||
| "zhihu" // 知乎
|
||||
| "weibo" // 微博
|
||||
| "douyin" // 抖音
|
||||
| "markdown"; // Markdown
|
||||
|
||||
/**
|
||||
* 平台显示名称映射
|
||||
*/
|
||||
export const PlatformLabels: Record<Platform, string> = {
|
||||
xiaohongshu: "小红书",
|
||||
wechat: "微信公众号",
|
||||
zhihu: "知乎",
|
||||
weibo: "微博",
|
||||
douyin: "抖音",
|
||||
markdown: "Markdown",
|
||||
};
|
||||
|
||||
/**
|
||||
* Emoji 使用程度枚举
|
||||
*/
|
||||
export type EmojiUsage = "heavy" | "moderate" | "minimal";
|
||||
|
||||
/**
|
||||
* Emoji 使用程度显示名称映射
|
||||
*/
|
||||
export const EmojiUsageLabels: Record<EmojiUsage, string> = {
|
||||
heavy: "大量使用",
|
||||
moderate: "适度使用",
|
||||
minimal: "少量使用",
|
||||
};
|
||||
|
||||
/**
|
||||
* 排版模板
|
||||
*/
|
||||
export interface Template {
|
||||
id: string;
|
||||
projectId: string;
|
||||
name: string;
|
||||
platform: Platform;
|
||||
titleStyle?: string;
|
||||
paragraphStyle?: string;
|
||||
endingStyle?: string;
|
||||
emojiUsage: EmojiUsage;
|
||||
hashtagRules?: string;
|
||||
imageRules?: string;
|
||||
isDefault: boolean;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建模板请求
|
||||
*/
|
||||
export interface CreateTemplateRequest {
|
||||
projectId: string;
|
||||
name: string;
|
||||
platform: Platform;
|
||||
titleStyle?: string;
|
||||
paragraphStyle?: string;
|
||||
endingStyle?: string;
|
||||
emojiUsage?: EmojiUsage;
|
||||
hashtagRules?: string;
|
||||
imageRules?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新模板请求
|
||||
*/
|
||||
export interface TemplateUpdate {
|
||||
name?: string;
|
||||
titleStyle?: string;
|
||||
paragraphStyle?: string;
|
||||
endingStyle?: string;
|
||||
emojiUsage?: EmojiUsage;
|
||||
hashtagRules?: string;
|
||||
imageRules?: string;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Workspace 相关类型定义
|
||||
*
|
||||
* @module types/workspace
|
||||
*/
|
||||
|
||||
// ============================================================================
|
||||
// Workspace 类型
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Workspace 类型枚举
|
||||
*/
|
||||
export type WorkspaceType =
|
||||
| "persistent" // 持久化项目
|
||||
| "social-media" // 社交媒体
|
||||
| "blog" // 博客
|
||||
| "novel" // 小说
|
||||
| "general"; // 通用
|
||||
|
||||
/**
|
||||
* Workspace 类型显示名称映射
|
||||
*/
|
||||
export const WorkspaceTypeLabels: Record<WorkspaceType, string> = {
|
||||
persistent: "持久化",
|
||||
"social-media": "社交媒体",
|
||||
blog: "博客",
|
||||
novel: "小说",
|
||||
general: "通用",
|
||||
};
|
||||
Reference in New Issue
Block a user