优化: 所有剩余UI/UX待办一批完成(持久化+AuditLog+解耦+total+原12大改+P2)
持久化(P1-c):新建 usePersistedRef composable,Tasks/AuditLog/ProjectDetail 等接入 localStorage
AuditLog(P1-d):后端 list_tool_executions 加 WHERE 筛选+返 {items,total,has_more},前端对接+长列折叠+筛选持久化
数据源解耦(P1-g):ProjectDetail projectTasks 按 project_id 独立加载 + ChatInput @项目联想独立加载(不读 store.tasks 当前页)
GitChanges(12a):后端 get_module_commits 加 git rev-list --count 返 total,前端显真实总数
原12大改:Dashboard 统计卡压底行(1)/Projects 列表卡片视图(2)/project_event 埋点排序(3)/TaskDetail 重设计(4)/IdeaDetail 重设计(5)/KnowledgeDetail 重设计(6)/界面持久化+侧栏Ctrl+B+审批数字键(7)/ProjectDetail 三栏改两栏(10)
P2打磨:文件浏览器(FileTree去重/FilePreview行号.md Diff/selectedFilePath归位)/settings反馈(假保存/端口校验)/AI会话(try-catch/scrollIntoView)/后端计数(move_queue事件/timeline total/workflow分页/import_batch分块)/杂项(TopBar/ConfirmDialog键盘/CIStatus i18n/ToolResultBody/ModuleNode/ApprovalDialog全选)
This commit is contained in:
@@ -184,6 +184,32 @@ impl_repo!(
|
||||
}
|
||||
);
|
||||
|
||||
// ============================================================
|
||||
// AuditQuery — 审批历史多条件查询入参(status / risk / 工具名关键词)
|
||||
// ============================================================
|
||||
|
||||
/// 审批历史多条件查询入参(对标 [`IdeaQuery`] 的可选字段 struct 设计)。
|
||||
///
|
||||
/// 所有字段可选;全 None → 等价 `list_recent`(向后兼容)。设计对齐 `查询能力补全方案`:
|
||||
/// 可选字段 struct 而非逐个加 IPC 参数,复用 [`IdeaRepo::list_by_query`] 的动态 WHERE 拼接
|
||||
/// 模式(if-let 分支拼 SQL + 分支化参数绑定)。
|
||||
///
|
||||
/// - `status`:状态精确匹配(pending/approved/rejected/executing/completed/failed/interrupted)
|
||||
/// - `risk_level`:风险等级精确匹配(low/medium/high)
|
||||
/// - `tool_keyword`:`tool_name LIKE %kw%`(对齐 idea_repo 关键词 LIKE 检索,不上 FTS5)
|
||||
/// - `limit`/`offset`:钳制上限 200(对齐 [`AiToolExecutionRepo::list_recent`])
|
||||
///
|
||||
/// `Deserialize`:Tauri IPC 从前端 JSON 反序列化为命令参数。
|
||||
/// `Default`:命令层兼容旧全量调用(`AuditQuery::default()` 等价无条件)。
|
||||
#[derive(Debug, Clone, Default, serde::Deserialize)]
|
||||
pub struct AuditQuery {
|
||||
pub status: Option<String>,
|
||||
pub risk_level: Option<String>,
|
||||
pub tool_keyword: Option<String>,
|
||||
pub limit: Option<u32>,
|
||||
pub offset: Option<u32>,
|
||||
}
|
||||
|
||||
impl_repo!(
|
||||
/// AI 工具执行审计表 CRUD
|
||||
AiToolExecutionRepo,
|
||||
@@ -318,6 +344,125 @@ impl AiToolExecutionRepo {
|
||||
.await
|
||||
.map_err(storage_err)?
|
||||
}
|
||||
|
||||
/// 多条件查询:动态 WHERE 拼接(status / risk_level / 工具名关键词) + 分页。
|
||||
///
|
||||
/// 复用 [`IdeaRepo::list_by_query`] 的动态 WHERE 模式:if-let 分支按可选条件拼 SQL 片段,
|
||||
/// 各分支化参数绑定到 `?N` 占位符。limit 钳制上限 200(对齐 [`Self::list_recent`])。
|
||||
///
|
||||
/// **向后兼容**:空 query(全 None)→ 无 WHERE 子句,等价 `list_recent`。
|
||||
/// 与 list_pending/list_recent 同理走专用 SELECT,绕过通用 query 宏(后者硬编码
|
||||
/// ORDER BY created_at,本表无该列)。
|
||||
pub async fn list_by_query(&self, q: &AuditQuery) -> Result<Vec<AiToolExecutionRecord>> {
|
||||
let conn = self.conn.clone();
|
||||
let status = q.status.clone();
|
||||
let risk = q.risk_level.clone();
|
||||
let kw = q.tool_keyword.clone();
|
||||
let limit_i: i64 = q.limit.unwrap_or(50).min(200) as i64;
|
||||
let offset_i: i64 = q.offset.unwrap_or(0) as i64;
|
||||
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let guard = conn.blocking_lock();
|
||||
|
||||
let mut where_clauses: Vec<String> = Vec::new();
|
||||
let mut params_vec: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
|
||||
|
||||
if let Some(s) = &status {
|
||||
where_clauses.push(format!("status = ?{}", params_vec.len() + 1));
|
||||
params_vec.push(Box::new(s.clone()));
|
||||
}
|
||||
if let Some(r) = &risk {
|
||||
where_clauses.push(format!("risk_level = ?{}", params_vec.len() + 1));
|
||||
params_vec.push(Box::new(r.clone()));
|
||||
}
|
||||
if let Some(k) = &kw {
|
||||
let escaped = k.replace('%', "\\%").replace('_', "\\_");
|
||||
let pat = format!("%{escaped}%");
|
||||
where_clauses.push(format!("tool_name LIKE ?{} ESCAPE '\\'", params_vec.len() + 1));
|
||||
params_vec.push(Box::new(pat));
|
||||
}
|
||||
|
||||
let where_sql = if where_clauses.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(" WHERE {}", where_clauses.join(" AND "))
|
||||
};
|
||||
|
||||
let where_param_count = params_vec.len();
|
||||
let sql = format!(
|
||||
"SELECT * FROM ai_tool_executions{where_sql} \
|
||||
ORDER BY requested_at DESC LIMIT ?{lim} OFFSET ?{off}",
|
||||
lim = where_param_count + 1,
|
||||
off = where_param_count + 2,
|
||||
);
|
||||
|
||||
let mut stmt = guard.prepare(&sql).map_err(storage_err)?;
|
||||
params_vec.push(Box::new(limit_i));
|
||||
params_vec.push(Box::new(offset_i));
|
||||
let param_refs: Vec<&dyn rusqlite::ToSql> =
|
||||
params_vec.iter().map(|p| p.as_ref()).collect();
|
||||
let rows = stmt
|
||||
.query_map(param_refs.as_slice(), |row| ai_tool_execution_from_row(row))
|
||||
.map_err(storage_err)?;
|
||||
let mut results = Vec::new();
|
||||
for r in rows {
|
||||
results.push(r.map_err(storage_err)?);
|
||||
}
|
||||
Ok(results)
|
||||
})
|
||||
.await
|
||||
.map_err(storage_err)?
|
||||
}
|
||||
|
||||
/// 按 [`AuditQuery`] 条件计数(不含 limit/offset,用于分页 total)。
|
||||
///
|
||||
/// 复用 [`Self::list_by_query`] 的 WHERE 构造逻辑(仅 WHERE,无 ORDER BY/LIMIT),
|
||||
/// 返回满足条件的总行数(忽略分页裁剪)。对标 [`TaskRepo::count_by_query`]。
|
||||
pub async fn count_by_query(&self, q: &AuditQuery) -> Result<i64> {
|
||||
let conn = self.conn.clone();
|
||||
let status = q.status.clone();
|
||||
let risk = q.risk_level.clone();
|
||||
let kw = q.tool_keyword.clone();
|
||||
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let guard = conn.blocking_lock();
|
||||
|
||||
let mut where_clauses: Vec<String> = Vec::new();
|
||||
let mut params_vec: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
|
||||
|
||||
if let Some(s) = &status {
|
||||
where_clauses.push(format!("status = ?{}", params_vec.len() + 1));
|
||||
params_vec.push(Box::new(s.clone()));
|
||||
}
|
||||
if let Some(r) = &risk {
|
||||
where_clauses.push(format!("risk_level = ?{}", params_vec.len() + 1));
|
||||
params_vec.push(Box::new(r.clone()));
|
||||
}
|
||||
if let Some(k) = &kw {
|
||||
let escaped = k.replace('%', "\\%").replace('_', "\\_");
|
||||
let pat = format!("%{escaped}%");
|
||||
where_clauses.push(format!("tool_name LIKE ?{} ESCAPE '\\'", params_vec.len() + 1));
|
||||
params_vec.push(Box::new(pat));
|
||||
}
|
||||
|
||||
let sql = if where_clauses.is_empty() {
|
||||
"SELECT COUNT(*) FROM ai_tool_executions".to_string()
|
||||
} else {
|
||||
format!(
|
||||
"SELECT COUNT(*) FROM ai_tool_executions WHERE {}",
|
||||
where_clauses.join(" AND ")
|
||||
)
|
||||
};
|
||||
let param_refs: Vec<&dyn rusqlite::ToSql> =
|
||||
params_vec.iter().map(|p| p.as_ref()).collect();
|
||||
let count: i64 = guard
|
||||
.query_row(&sql, param_refs.as_slice(), |row| row.get(0))
|
||||
.map_err(storage_err)?;
|
||||
Ok(count)
|
||||
})
|
||||
.await
|
||||
.map_err(storage_err)?
|
||||
}
|
||||
}
|
||||
|
||||
// AiConversationRepo 的整体更新已由 impl_repo! 宏统一生成的 update_full 提供。
|
||||
|
||||
@@ -179,6 +179,47 @@ impl ProjectEventRepo {
|
||||
.map_err(storage_err)?
|
||||
}
|
||||
|
||||
/// 全表取每个项目的最新事件时间(`SELECT project_id, MAX(created_at) GROUP BY project_id`)。
|
||||
///
|
||||
/// 供「项目列表最近活跃排序」使用:不改 projects 表 schema,通过 project_events 统一事件流
|
||||
/// 推导各项目真实活跃时间(任务/灵感/状态推进等业务事件),而非 projects.updated_at(后者随
|
||||
/// 元信息修改如改 description 也会刷新,不反映真实业务活跃)。
|
||||
///
|
||||
/// 返回 `HashMap<project_id, latest_created_at>`。无事件的项目不在 map 中(调用方用
|
||||
/// COALESCE 回退 projects.updated_at)。命中 idx_project_events_project 的 project_id 维度,
|
||||
/// 单用户桌面应用事件量小无压力。
|
||||
///
|
||||
/// 注:`MAX(created_at)` 在 SQLite 中对 TEXT(毫秒时间戳字符串)做字典序比较等价数值序
|
||||
/// (定长毫秒字符串),语义正确。
|
||||
pub async fn latest_activity_per_project(&self) -> Result<std::collections::HashMap<String, String>> {
|
||||
let conn = self.conn.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let guard = conn.blocking_lock();
|
||||
let mut stmt = guard
|
||||
.prepare(
|
||||
"SELECT project_id, MAX(created_at) AS latest \
|
||||
FROM project_events GROUP BY project_id",
|
||||
)
|
||||
.map_err(storage_err)?;
|
||||
let rows = stmt
|
||||
.query_map([], |row| {
|
||||
Ok((
|
||||
row.get::<_, String>(0)?,
|
||||
row.get::<_, String>(1)?,
|
||||
))
|
||||
})
|
||||
.map_err(storage_err)?;
|
||||
let mut map = std::collections::HashMap::new();
|
||||
for r in rows {
|
||||
let (pid, latest) = r.map_err(storage_err)?;
|
||||
map.insert(pid, latest);
|
||||
}
|
||||
Ok(map)
|
||||
})
|
||||
.await
|
||||
.map_err(storage_err)?
|
||||
}
|
||||
|
||||
/// 跨项目列最近 N 条事件(全表 created_at DESC,id DESC 兜底,top-N,命中
|
||||
/// idx_project_events_project 的 created_at 维度)。
|
||||
///
|
||||
@@ -370,6 +411,54 @@ mod tests {
|
||||
assert_eq!(got[0].id, "e2");
|
||||
}
|
||||
|
||||
/// latest_activity_per_project:每项目取 MAX(created_at),无事件项目不在 map 中。
|
||||
#[tokio::test]
|
||||
async fn latest_activity_per_project_groups() {
|
||||
let (db, repo) = setup().await;
|
||||
// 补一个 proj-2 占位 project(FK 要求)
|
||||
let project_repo = ProjectRepo::new(&db);
|
||||
project_repo
|
||||
.insert(ProjectRecord {
|
||||
id: "proj-2".to_string(),
|
||||
name: "proj-2".to_string(),
|
||||
description: String::new(),
|
||||
status: ProjectStatus::Planning,
|
||||
idea_id: None,
|
||||
path: None,
|
||||
stack: None,
|
||||
created_at: "1700000000000".to_string(),
|
||||
updated_at: "1700000000000".to_string(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// proj-1: 插 2 条(created_at 由存储层覆盖,第 2 条更晚)
|
||||
repo.insert(erec("e1", "proj-1", "1")).await.unwrap();
|
||||
repo.insert(erec("e2", "proj-1", "1")).await.unwrap();
|
||||
// proj-2: 插 1 条
|
||||
repo.insert(erec("e3", "proj-2", "1")).await.unwrap();
|
||||
|
||||
let map = repo.latest_activity_per_project().await.unwrap();
|
||||
// 两项目都在 map 中
|
||||
assert_eq!(map.len(), 2);
|
||||
// proj-1 的 latest = 最后插入的 e2 的 created_at(存储层覆盖的当前毫秒)
|
||||
let proj1_latest = map.get("proj-1").expect("proj-1 应在 map 中");
|
||||
let proj2_latest = map.get("proj-2").expect("proj-2 应在 map 中");
|
||||
// proj-1 最后插入(e2)晚于 proj-2(e3)的插入顺序:e3 在 e2 之前?
|
||||
// 插入顺序:e1(proj-1), e2(proj-1), e3(proj-2) → e3 的 created_at 最大。
|
||||
// 故 proj-2 的 latest 应 ≥ proj-1 的 latest。
|
||||
assert!(
|
||||
proj2_latest >= proj1_latest,
|
||||
"proj-2(e3 最后插)created_at 应 ≥ proj-1: proj2={} proj1={}",
|
||||
proj2_latest,
|
||||
proj1_latest
|
||||
);
|
||||
|
||||
// 无事件项目不在 map(清空重建场景)
|
||||
let empty_map_empty = repo.latest_activity_per_project().await.unwrap();
|
||||
assert!(!empty_map_empty.contains_key("no-such"));
|
||||
}
|
||||
|
||||
/// entity_type/entity_id 为 None(纯决策日志无明确实体)也能正常插入与查询。
|
||||
#[tokio::test]
|
||||
async fn insert_with_null_entity_fields() {
|
||||
|
||||
@@ -72,6 +72,22 @@ fn build_order_clause(order_by: Option<&str>) -> Result<String> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 带「最近活跃时间」的项目记录(list_active_with_activity 专用返回结构)。
|
||||
///
|
||||
/// `last_active_at` = COALESCE(project_events 最新事件 created_at, projects.updated_at):
|
||||
/// 反映业务活跃(任务/灵感/状态推进等事件),无事件项目回退 updated_at。供前端「最近活跃排序」
|
||||
/// 展示,语义比 projects.updated_at(随元信息修改如改 description 也会刷新)更准确。
|
||||
///
|
||||
/// Serialize 供 IPC 层直接序列化回前端(serde 字段名 snake_case,对齐 ProjectRecord)。
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
pub struct ProjectActivityRecord {
|
||||
/// 项目完整记录(嵌套序列化:record.id / record.name ...)
|
||||
#[serde(flatten)]
|
||||
pub record: ProjectRecord,
|
||||
/// 最近活跃时间(毫秒字符串)。前端独立消费,不混入 record 的 updated_at。
|
||||
pub last_active_at: String,
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// from_row 辅助函数
|
||||
// ============================================================
|
||||
@@ -204,6 +220,51 @@ impl ProjectRepo {
|
||||
.map_err(storage_err)?
|
||||
}
|
||||
|
||||
/// 列出未删除项目,**按「最近活跃」排序**(LEFT JOIN project_events 取最新事件时间,
|
||||
/// COALESCE 回退 updated_at)。
|
||||
///
|
||||
/// 业务语义:项目「最近活跃」应反映业务事件(任务/灵感/状态推进),而非 projects.updated_at
|
||||
/// (后者随元信息修改如改 description/path 也会刷新,不反映真实业务活跃)。本方法通过子查询
|
||||
/// 取每个项目 project_events 最新 created_at,无事件项目回退 updated_at,排序列统一可比。
|
||||
///
|
||||
/// 子查询而非 JOIN:project_events 每项目可能 0..N 条,JOIN 会展开需 DISTINCT,子查询
|
||||
/// `(SELECT MAX(created_at) FROM project_events WHERE project_id = projects.id)` 每行一次
|
||||
/// 聚合,语义清晰无笛卡尔积风险。单用户桌面应用项目数小,无性能压力。
|
||||
///
|
||||
/// 返回 `ProjectActivityRecord`(ProjectRecord + last_active_at 字段),供前端排序展示。
|
||||
pub async fn list_active_with_activity(&self) -> Result<Vec<ProjectActivityRecord>> {
|
||||
let conn = self.conn.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let guard = conn.blocking_lock();
|
||||
// COALESCE(子查询最新事件, projects.updated_at):无事件项目回退 updated_at,
|
||||
// 保证所有项目有可比活跃时间。ORDER BY 该列 DESC,同时间 created_at DESC 兜底稳定。
|
||||
let mut stmt = guard
|
||||
.prepare(
|
||||
"SELECT id, name, description, status, idea_id, path, stack, created_at, updated_at, \
|
||||
COALESCE((SELECT MAX(created_at) FROM project_events WHERE project_id = projects.id), \
|
||||
updated_at) AS last_active_at \
|
||||
FROM projects WHERE deleted_at IS NULL \
|
||||
ORDER BY last_active_at DESC, created_at DESC",
|
||||
)
|
||||
.map_err(storage_err)?;
|
||||
let rows = stmt
|
||||
.query_map([], |row| {
|
||||
Ok(ProjectActivityRecord {
|
||||
record: project_from_row(row)?,
|
||||
last_active_at: row.get("last_active_at")?,
|
||||
})
|
||||
})
|
||||
.map_err(storage_err)?;
|
||||
let mut results = Vec::new();
|
||||
for r in rows {
|
||||
results.push(r.map_err(storage_err)?);
|
||||
}
|
||||
Ok(results)
|
||||
})
|
||||
.await
|
||||
.map_err(storage_err)?
|
||||
}
|
||||
|
||||
/// 按条件查询未删除项目(P2/P3:关键词搜索 + 排序 + 分页)。
|
||||
///
|
||||
/// 复用 `KnowledgeRepo::search` 动态 WHERE 拼接模式:按可选字段 if-let 拼 SQL 子句 +
|
||||
|
||||
Reference in New Issue
Block a user