feat: 统一内容创作系统 v0.56.0

- 项目选择器支持按主题类型筛选
- 修复 Select 组件点击外部不关闭的问题
- projectId 贯穿整个聊天生命周期
- 修复 lint 错误和 clippy 警告
- 新增人设、素材、模板管理功能
This commit is contained in:
coso
2026-02-04 00:00:01 +08:00
parent 08ad7a332e
commit 322b2e07f8
79 changed files with 13081 additions and 93 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "proxycast",
"private": true,
"version": "0.55.0",
"version": "0.56.0",
"type": "module",
"repository": {
"type": "git",
+3 -3
View File
@@ -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",
+2 -2
View File
@@ -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"
+1
View File
@@ -15,6 +15,7 @@ Tauri 后端核心代码,处理系统级功能和 API 服务。
- `converter/` - 协议转换(OpenAI ↔ CW/Claude/Antigravity)
- `credential/` - 凭证池管理(负载均衡、健康检查)
- `database/` - 数据库层(SQLite + DAO)
- `errors/` - 错误类型定义(项目、人设、素材、模板、迁移错误)
- `flow_monitor/` - LLM 流量监控(拦截、存储、查询)
- `injection/` - 请求注入(系统提示词等)
- `middleware/` - HTTP 中间件
+52
View File
@@ -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()
+29
View File
@@ -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,
+53 -21
View File
@@ -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
}
}
}
};
+276
View File
@@ -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())
}
+3
View File
@@ -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;
+242
View File
@@ -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())
}
+226
View File
@@ -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())
}
+80
View File
@@ -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))
}
+11 -14
View File
@@ -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()
})
}
+3 -3
View File
@@ -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);
+13 -1
View File
@@ -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
+857
View File
@@ -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");
}
}
}
+4
View File
@@ -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;
+699
View File
@@ -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());
}
}
+940
View File
@@ -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, "当前设置的模板应该是默认的");
}
}
}
+500
View File
@@ -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");
}
}
+20
View File
@@ -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)))
}
+141
View File
@@ -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(
+68
View File
@@ -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: 默认项目保护
+11
View File
@@ -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};
+462
View File
@@ -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\"");
}
}
+1
View File
@@ -57,6 +57,7 @@ mod converter;
mod data;
#[cfg(debug_assertions)]
mod dev_bridge;
mod errors;
mod logger;
mod models;
mod providers;
+3
View File
@@ -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)]
+18 -18
View File
@@ -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
}
+706
View File
@@ -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);
}
}
+11 -11
View File
@@ -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) ==========
+11
View File
@@ -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
+1 -1
View File
@@ -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);
}
}
+3
View File
@@ -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` - 更新提醒独立窗口管理
+1 -1
View File
@@ -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
+4
View File
@@ -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;
+575
View File
@@ -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"), "通用");
}
}
+499
View File
@@ -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
View File
@@ -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
+46 -9
View File
@@ -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);
+32 -3
View File
@@ -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
+11
View File
@@ -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;
+79
View File
@@ -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;
+143
View File
@@ -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;
+7 -1
View File
@@ -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);
}
};
// 点击内容时跳转到创作界面
+131
View File
@@ -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;
+9
View File
@@ -0,0 +1,9 @@
/**
* @file index.ts
* @description 项目对话框组件导出
* @module components/projects/dialogs
*/
export { PersonaDialog } from "./PersonaDialog";
export { TemplateDialog } from "./TemplateDialog";
export { MaterialUploadDialog } from "./MaterialUploadDialog";
+11 -1
View File
@@ -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;
+191
View File
@@ -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;
+190
View File
@@ -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;
+10
View File
@@ -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";
+33 -1
View File
@@ -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}
>
+12
View File
@@ -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,
+174
View File
@@ -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;
+154
View File
@@ -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;
+119
View File
@@ -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;
+96
View File
@@ -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;
+176
View File
@@ -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;
+138
View File
@@ -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;
+43
View File
@@ -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;
}
+29
View File
@@ -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";
+76
View File
@@ -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
View File
@@ -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>;
+78
View File
@@ -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[];
}
+79
View File
@@ -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;
}
+96
View File
@@ -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;
}
+30
View File
@@ -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: "通用",
};