PicoBot/src/command/handlers/rename_topic.rs
oudecheng 2a5a0277c0 feat(web): 添加话题重命名功能
新增 RenameTopic 命令与 TopicRenamed 协议消息,复用存储层已有的
update_topic_title 方法。后端响应携带刷新后的完整 topic 列表,
前端零额外往返即可同步侧边栏。

前端 TopicList 侧边栏增加内联编辑入口:悬停显示铅笔图标,点击进入
编辑模式,Enter 提交 / Esc 取消 / blur 取消;通过 onMouseDown
preventDefault 防止按钮点击时 input 提前失焦。

包含 5 个单元测试覆盖成功、空标题、不存在、标题未变跳过写入、
can_handle 等场景。
2026-08-03 22:10:25 +08:00

269 lines
8.9 KiB
Rust

use crate::command::context::CommandContext;
use crate::command::handler::{CommandHandler, CommandMetadata};
use crate::command::handlers::list_topics::TopicSummary;
use crate::command::response::{CommandError, CommandResponse, MessageKind};
use crate::command::Command;
use crate::storage::SessionStore;
use async_trait::async_trait;
use std::sync::Arc;
/// 重命名话题命令处理器
pub struct RenameTopicCommandHandler {
store: Arc<SessionStore>,
}
impl RenameTopicCommandHandler {
pub fn new(store: Arc<SessionStore>) -> Self {
Self { store }
}
}
#[async_trait]
impl CommandHandler for RenameTopicCommandHandler {
fn can_handle(&self, cmd: &Command) -> bool {
matches!(cmd, Command::RenameTopic { .. })
}
fn metadata(&self) -> Option<CommandMetadata> {
Some(CommandMetadata {
name: "rename",
description: "重命名指定话题",
usage: "/rename <topic_id> <new_title>",
})
}
async fn handle(
&self,
cmd: Command,
ctx: CommandContext,
) -> Result<CommandResponse, CommandError> {
match cmd {
Command::RenameTopic { topic_id, title } => {
handle_rename_topic(self, topic_id, title, ctx).await
}
_ => unreachable!(),
}
}
}
async fn handle_rename_topic(
handler: &RenameTopicCommandHandler,
topic_id: String,
title: String,
ctx: CommandContext,
) -> Result<CommandResponse, CommandError> {
let session_id = ctx
.session_id
.as_deref()
.ok_or_else(|| CommandError::new("NO_SESSION", "No active session"))?;
// 校验新标题非空
let trimmed_title = title.trim();
if trimmed_title.is_empty() {
return Err(CommandError::new(
"INVALID_TITLE",
"Topic title must not be empty",
));
}
// 验证话题存在
let topic = handler
.store
.get_topic(&topic_id)
.map_err(|e| CommandError::new("GET_TOPIC_ERROR", e.to_string()))?
.ok_or_else(|| {
CommandError::new("TOPIC_NOT_FOUND", format!("Topic not found: {}", topic_id))
})?;
let old_title = topic.title.clone();
// 标题未变化时直接返回当前列表,避免无意义写入
if old_title == trimmed_title {
let topics = handler
.store
.list_topics(session_id)
.map_err(|e| CommandError::new("LIST_TOPICS_ERROR", e.to_string()))?;
let topic_summaries = serialize_summaries(&topics);
return Ok(CommandResponse::success(ctx.request_id)
.with_message(MessageKind::Notification, &format!("✓ 话题标题未变化: {}", trimmed_title))
.with_metadata("topics", &topic_summaries)
.with_metadata("topic_id", &topic_id)
.with_metadata("title", trimmed_title)
.with_metadata("session_id", session_id));
}
// 执行重命名(存储层方法已存在)
handler
.store
.update_topic_title(&topic_id, trimmed_title)
.map_err(|e| CommandError::new("RENAME_TOPIC_ERROR", e.to_string()))?;
// 查询更新后的话题列表,返回给前端刷新侧边栏
let topics = handler
.store
.list_topics(session_id)
.map_err(|e| CommandError::new("LIST_TOPICS_ERROR", e.to_string()))?;
let topic_summaries = serialize_summaries(&topics);
let message = format!("✓ 已重命名话题: {}{}", old_title, trimmed_title);
Ok(CommandResponse::success(ctx.request_id)
.with_message(MessageKind::Notification, &message)
.with_metadata("topics", &topic_summaries)
.with_metadata("topic_id", &topic_id)
.with_metadata("title", trimmed_title)
.with_metadata("session_id", session_id))
}
fn serialize_summaries(topics: &[crate::storage::TopicRecord]) -> String {
let summaries: Vec<TopicSummary> = topics
.iter()
.map(|t| TopicSummary {
topic_id: t.id.clone(),
session_id: t.session_id.clone(),
title: t.title.clone(),
description: t.description.clone().filter(|d| !d.is_empty()),
message_count: t.message_count,
created_at: t.created_at,
last_active_at: t.last_active_at,
})
.collect();
serde_json::to_string(&summaries).unwrap_or_else(|_| "[]".to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::storage::SessionStore;
fn create_test_handler() -> RenameTopicCommandHandler {
let store = Arc::new(SessionStore::in_memory().unwrap());
RenameTopicCommandHandler::new(store)
}
#[tokio::test]
async fn test_rename_topic_success() {
let handler = create_test_handler();
let store = handler.store.clone();
let session = store.create_session("test_channel", Some("test")).unwrap();
let topic = store
.create_topic(&session.id, "old title", None)
.unwrap();
let ctx = CommandContext::new("test", "test_channel")
.with_session_id(&session.id)
.with_chat_id(&session.id);
let cmd = Command::RenameTopic {
topic_id: topic.id.clone(),
title: "new title".to_string(),
};
let result = handler.handle(cmd, ctx).await;
assert!(result.is_ok());
let resp = result.unwrap();
assert!(resp.success);
assert_eq!(resp.metadata.get("title").map(String::as_str), Some("new title"));
assert_eq!(resp.metadata.get("topic_id").map(String::as_str), Some(topic.id.as_str()));
assert!(resp.metadata.contains_key("topics"));
// 验证存储层已更新
let updated = store.get_topic(&topic.id).unwrap().unwrap();
assert_eq!(updated.title, "new title");
}
#[tokio::test]
async fn test_rename_topic_empty_title() {
let handler = create_test_handler();
let store = handler.store.clone();
let session = store.create_session("test_channel", Some("test")).unwrap();
let topic = store
.create_topic(&session.id, "old title", None)
.unwrap();
let ctx = CommandContext::new("test", "test_channel")
.with_session_id(&session.id)
.with_chat_id(&session.id);
let cmd = Command::RenameTopic {
topic_id: topic.id.clone(),
title: " ".to_string(),
};
let result = handler.handle(cmd, ctx).await;
assert!(result.is_err());
let err = result.unwrap_err();
assert_eq!(err.code, "INVALID_TITLE");
// 标题未被修改
let unchanged = store.get_topic(&topic.id).unwrap().unwrap();
assert_eq!(unchanged.title, "old title");
}
#[tokio::test]
async fn test_rename_nonexistent_topic() {
let handler = create_test_handler();
let store = handler.store.clone();
let session = store.create_session("test_channel", Some("test")).unwrap();
let ctx = CommandContext::new("test", "test_channel")
.with_session_id(&session.id)
.with_chat_id(&session.id);
let cmd = Command::RenameTopic {
topic_id: "nonexistent".to_string(),
title: "new title".to_string(),
};
let result = handler.handle(cmd, ctx).await;
assert!(result.is_err());
let err = result.unwrap_err();
assert_eq!(err.code, "TOPIC_NOT_FOUND");
}
#[tokio::test]
async fn test_rename_topic_same_title_skips_write() {
let handler = create_test_handler();
let store = handler.store.clone();
let session = store.create_session("test_channel", Some("test")).unwrap();
let topic = store
.create_topic(&session.id, "same title", None)
.unwrap();
let original_updated_at = store.get_topic(&topic.id).unwrap().unwrap().updated_at;
// 等待一秒确保 updated_at 会变化(如果真的写入)
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
let ctx = CommandContext::new("test", "test_channel")
.with_session_id(&session.id)
.with_chat_id(&session.id);
let cmd = Command::RenameTopic {
topic_id: topic.id.clone(),
title: "same title".to_string(),
};
let result = handler.handle(cmd, ctx).await;
assert!(result.is_ok());
// updated_at 未变化说明未触发写入
let after = store.get_topic(&topic.id).unwrap().unwrap();
assert_eq!(after.updated_at, original_updated_at);
}
#[test]
fn test_can_handle() {
let handler = create_test_handler();
assert!(handler.can_handle(&Command::RenameTopic {
topic_id: "test".to_string(),
title: "test".to_string(),
}));
assert!(!handler.can_handle(&Command::Help));
assert!(!handler.can_handle(&Command::DeleteTopic {
topic_id: "test".to_string(),
}));
}
}