优化: 所有剩余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:
lxy
2026-08-02 13:11:06 +08:00
parent caaabf0c15
commit f736f435bc
70 changed files with 2645 additions and 576 deletions
@@ -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 子句 +