优化: 所有剩余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
+91 -45
View File
@@ -17,7 +17,7 @@ use df_project::scan::{
collect_sample, detect_stack, discover_projects, extract_description,
normalize_path, DiscoveredProject,
};
use df_storage::crud::ProjectQuery;
use df_storage::crud::{ProjectActivityRecord, ProjectQuery};
use df_storage::models::{ProjectEventRecord, ProjectRecord};
use crate::state::AppState;
@@ -80,22 +80,49 @@ pub struct CreateProjectInput {
///
/// F-260621-02:吃可选 query(关键词/排序/分页),向后兼容——不传(或 None)走 list_active
/// 全量(deleted_at IS NULL + created_at DESC),零破坏;传 query 走 list_by_query 动态 WHERE。
///
/// 问题3(项目最近活跃排序):默认无 query 路径改走 list_active_with_activity,返回
/// 带 `last_active_at` 字段的 `ProjectActivityRecord`(COALESCE project_events 最新事件,
/// projects.updated_at 回退),按业务活跃排序而非元信息修改时间。query 路径(关键词/分页)
/// 同样补 last_active_at 字段(查 map 填充,无事件回退 updated_at),保持前端契约统一。
#[tauri::command]
pub async fn list_projects(
state: State<'_, AppState>,
query: Option<ProjectQuery>,
) -> Result<Vec<ProjectRecord>, String> {
) -> Result<Vec<ProjectActivityRecord>, String> {
match query {
// None 或全空 query(trim 后 keyword 空 + 无 order_by/limit/offset)→ 走 list_active,
// 与历史行为完全等价(列表上 list_by_query 空也等价,但保留 list_active 分支明示向后兼容契约)。
// None 或全空 query(trim 后 keyword 空 + 无 order_by/limit/offset)→ 走 list_active_with_activity,
// 按最近活跃排序(问题3)。list_active 分支语义已被 list_active_with_activity 覆盖
// (后者亦返 deleted_at IS NULL 的全部项目,仅多了 last_active_at 字段 + 排序键)。
Some(q) if q.keyword.as_deref().map(str::trim).is_some_and(|k| !k.is_empty())
|| q.order_by.is_some()
|| q.limit.is_some()
|| q.offset.is_some() =>
{
state.projects.list_by_query(q).await.map_err(err_str)
// query 路径:list_by_query 返 ProjectRecord,补 last_active_at 字段。
// 单次拉全项目最新事件 map,逐条 COALESCE 填充,无事件回退 updated_at。
let records = state.projects.list_by_query(q).await.map_err(err_str)?;
let activity = state
.project_events
.latest_activity_per_project()
.await
.map_err(err_str)?;
let result = records
.into_iter()
.map(|r| {
let last_active_at = activity
.get(&r.id)
.cloned()
.unwrap_or_else(|| r.updated_at.clone());
ProjectActivityRecord {
record: r,
last_active_at,
}
})
.collect();
Ok(result)
}
_ => state.projects.list_active().await.map_err(err_str),
_ => state.projects.list_active_with_activity().await.map_err(err_str),
}
}
@@ -522,7 +549,7 @@ pub struct ImportBatchResult {
}
/// 单条批量导入入参
#[derive(Debug, Deserialize)]
#[derive(Debug, Clone, Deserialize)]
pub struct ImportBatchItemInput {
pub path: String,
#[serde(default)]
@@ -538,6 +565,10 @@ pub struct ImportBatchItemInput {
///
/// 限流:llm_concurrency 双层 permit(global + per_conv)防止批量扫描打满 provider。
/// 默认 planning 状态(对齐 create_project),不关联 idea。
///
/// L16:并发上限分块(CHUNK_SIZE=4)——LLM 调用经双层 permit 限流,但 create_with_binding 内
/// 非 LLM IO(detect_stack/canonicalize/DB 查询/insert/reload_allowed_dirs 读全表)无全局限流,
/// 全量并发会产生调度/DB 锁竞争。分块串行处理 chunk、chunk 内并发,结果等价仅削峰。
#[tauri::command]
pub async fn import_projects_batch(
state: State<'_, AppState>,
@@ -568,48 +599,63 @@ pub async fn import_projects_batch(
// 每项独立 future,并发 join。失败逐项记录不影响其它。
// 注:provider 通过 Arc clone 在各 future 间共享(零拷贝,引用计数)。
let futures: Vec<_> = items
.into_iter()
.map(|item| {
let state_ref = state.inner();
let provider = provider.clone();
let pc = pc.clone();
async move {
let path = item.path.trim().to_string();
if path.is_empty() {
return ImportBatchItemResult {
path,
name: None,
error: Some("路径为空".to_string()),
//
// L16:批量上限分块(chunk)。原 join_all 全量并发,虽 LLM 调用经 llm_concurrency 双层 permit
// 限流(global + per_conv),但 create_with_binding 内仍有重 IO(spawn_blocking detect_stack /
// normalize_path canonicalize / find_binding_conflict DB 查询 / insert project+module /
// reload_allowed_dirs 读全表 projects.path)。勾选数十项时全量并发会让这些非 LLM 操作同时
// 入队,产生 tokio 任务调度压力 + DB 锁竞争排队(reload_allowed_dirs 读全量 projects 表 × N)。
// 改分块串行处理各 chunk、chunk 内并发:结果与全量并发等价(每项独立无依赖,顺序不影响结果),
// 仅削平调度/DB 压力峰值。CHUNK_SIZE=4(对齐常见 4 核,与 LLM 限流槽位数同量级)。
const IMPORT_BATCH_CHUNK_SIZE: usize = 4;
let mut results: Vec<ImportBatchItemResult> = Vec::with_capacity(items.len());
// 分块:chunk 内并发 join,chunk 间串行 await,结果按原顺序聚合(与全量 join_all 等价顺序)。
for chunk in items.chunks(IMPORT_BATCH_CHUNK_SIZE) {
let chunk_futures: Vec<_> = chunk
.iter()
.cloned()
.map(|item| {
let state_ref = state.inner();
let provider = provider.clone();
let pc = pc.clone();
async move {
let path = item.path.trim().to_string();
if path.is_empty() {
return ImportBatchItemResult {
path,
name: None,
error: Some("路径为空".to_string()),
};
}
// 走 scan_project_with_ai 同款「探测+采样+LLM 抽 description」(轻量子代理)
let desc = match extract_description_via_llm(state_ref, &provider, &pc, &path).await {
Ok(d) => d,
Err(e) => {
// LLM 失败/降级:description 留空,但仍入库(用户手填)。记录原因。
tracing::warn!("批量导入 LLM 抽 description 失败 path={path} err={e}");
String::new()
}
};
}
// 走 scan_project_with_ai 同款「探测+采样+LLM 抽 description」(轻量子代理)
let desc = match extract_description_via_llm(state_ref, &provider, &pc, &path).await {
Ok(d) => d,
Err(e) => {
// LLM 失败/降级:description 留空,但仍入库(用户手填)。记录原因。
tracing::warn!("批量导入 LLM 抽 description 失败 path={path} err={e}");
String::new()
let want_name = item.name.as_deref().map(str::trim).filter(|s| !s.is_empty()).map(String::from);
match create_with_binding(state_ref, resolve_name(&path, want_name), desc, None, Some(path.clone()), None).await {
Ok(rec) => ImportBatchItemResult {
path,
name: Some(rec.name),
error: None,
},
Err(e) => ImportBatchItemResult {
path,
name: None,
error: Some(e),
},
}
};
let want_name = item.name.as_deref().map(str::trim).filter(|s| !s.is_empty()).map(String::from);
match create_with_binding(state_ref, resolve_name(&path, want_name), desc, None, Some(path.clone()), None).await {
Ok(rec) => ImportBatchItemResult {
path,
name: Some(rec.name),
error: None,
},
Err(e) => ImportBatchItemResult {
path,
name: None,
error: Some(e),
},
}
}
})
.collect();
})
.collect();
let chunk_results = futures::future::join_all(chunk_futures).await;
results.extend(chunk_results);
}
let results = futures::future::join_all(futures).await;
let imported = results.iter().filter(|r| r.name.is_some()).count();
let skipped = results.len() - imported;
Ok(ImportBatchResult {