优化: 所有剩余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:
@@ -42,7 +42,7 @@ pub mod record;
|
||||
#[allow(unused_imports)]
|
||||
pub(crate) use record::{audit_tool_call, query_audit_history, record_audit};
|
||||
#[allow(unused_imports)]
|
||||
pub use record::{list_tool_executions, ToolExecutionDto};
|
||||
pub use record::{list_tool_executions, ToolExecutionDto, ToolExecutionPage, ToolExecQuery};
|
||||
|
||||
// reason 拼装(resolve_project_label / resolve_task_label / build_approval_reason)
|
||||
// 拆至子模块 audit/reason.rs(第一批 helper 抽离,行为零变更)。
|
||||
|
||||
@@ -9,11 +9,11 @@
|
||||
//!
|
||||
//! 依赖 audit/utils.rs 的 `truncate_chars` 做参数/结果截断,通过 `super::truncate_chars` 引用。
|
||||
|
||||
use serde::Serialize;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tauri::State;
|
||||
|
||||
use df_ai::ai_tools::RiskLevel;
|
||||
use df_storage::crud::AiToolExecutionRepo;
|
||||
use df_storage::crud::{AiToolExecutionRepo, AuditQuery};
|
||||
use df_storage::models::AiToolExecutionRecord;
|
||||
use df_types::types::new_id;
|
||||
|
||||
@@ -128,24 +128,78 @@ pub struct ToolExecutionDto {
|
||||
pub decided_by: Option<String>,
|
||||
}
|
||||
|
||||
/// 审批历史查询入参(前端透传,空值=不过滤)。
|
||||
///
|
||||
/// 复用 [`AuditQuery`](`df_storage::crud::AuditQuery`) 的字段语义:status/risk_level 精确匹配,
|
||||
/// tool_keyword 走 tool_name LIKE。limit/offset 默认 50/0,storage 层钳制 limit ≤ 200。
|
||||
///
|
||||
/// `Deserialize`:Tauri IPC 从前端 JSON 反序列化。
|
||||
#[derive(Debug, Clone, Default, Deserialize)]
|
||||
pub struct ToolExecQuery {
|
||||
/// 状态精确匹配(pending/approved/rejected/executing/completed/failed/interrupted)
|
||||
pub status: Option<String>,
|
||||
/// 风险等级精确匹配(low/medium/high)
|
||||
pub risk_level: Option<String>,
|
||||
/// 工具名关键词(tool_name LIKE %kw%)
|
||||
pub tool_keyword: Option<String>,
|
||||
pub limit: Option<u32>,
|
||||
pub offset: Option<u32>,
|
||||
}
|
||||
|
||||
impl From<ToolExecQuery> for AuditQuery {
|
||||
fn from(q: ToolExecQuery) -> Self {
|
||||
AuditQuery {
|
||||
status: q.status,
|
||||
risk_level: q.risk_level,
|
||||
tool_keyword: q.tool_keyword,
|
||||
limit: q.limit,
|
||||
offset: q.offset,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 审批历史分页结果(对标项目通用 `{items,total,has_more}` 结构)。
|
||||
///
|
||||
/// - `items`:当前页审计 DTO 列表
|
||||
/// - `total`:满足筛选条件的总行数(忽略分页裁剪,前端用于"第 N 页 / 共 M 条"展示)
|
||||
/// - `has_more`:基于 `loaded < total` 推断,而非"本页是否满 limit"启发式
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct ToolExecutionPage {
|
||||
pub items: Vec<ToolExecutionDto>,
|
||||
pub total: i64,
|
||||
pub has_more: bool,
|
||||
}
|
||||
|
||||
/// 审批历史面板查询:按 requested_at 倒序(最新在前)分页返回工具调用审计记录。
|
||||
///
|
||||
/// 支持 status / risk_level / 工具名关键词筛选(WHERE 在后端收口,非前端 filter 当前页)。
|
||||
/// 默认 limit=50 / offset=0(第一页)。limit 在 storage 层钳制 ≤200 防滥用。
|
||||
/// 敏感字段(arguments/result)截断成摘要返回,完整原值仍留库。
|
||||
///
|
||||
/// 返回 `{items,total,has_more}`:total 为满足筛选条件的真实总数(独立 COUNT 查询),
|
||||
/// has_more 基于 `offset + items.len() < total` 推断。
|
||||
#[tauri::command]
|
||||
pub async fn list_tool_executions(
|
||||
state: State<'_, AppState>,
|
||||
limit: Option<u32>,
|
||||
offset: Option<u32>,
|
||||
) -> Result<Vec<ToolExecutionDto>, String> {
|
||||
let limit = limit.unwrap_or(50);
|
||||
let offset = offset.unwrap_or(0);
|
||||
query: Option<ToolExecQuery>,
|
||||
) -> Result<ToolExecutionPage, String> {
|
||||
let q = query.unwrap_or_default();
|
||||
let limit = q.limit.unwrap_or(50);
|
||||
let offset = q.offset.unwrap_or(0);
|
||||
let audit_q = AuditQuery::from(q);
|
||||
|
||||
let records = state
|
||||
.ai_tool_executions
|
||||
.list_recent(limit, offset)
|
||||
.list_by_query(&audit_q)
|
||||
.await
|
||||
.map_err(err_str)?;
|
||||
Ok(records
|
||||
let total = state
|
||||
.ai_tool_executions
|
||||
.count_by_query(&audit_q)
|
||||
.await
|
||||
.map_err(err_str)?;
|
||||
|
||||
let items: Vec<ToolExecutionDto> = records
|
||||
.into_iter()
|
||||
.map(|r| ToolExecutionDto {
|
||||
id: r.id,
|
||||
@@ -160,5 +214,12 @@ pub async fn list_tool_executions(
|
||||
executed_at: r.executed_at,
|
||||
decided_by: r.decided_by,
|
||||
})
|
||||
.collect())
|
||||
.collect();
|
||||
let has_more = (offset as i64 + items.len() as i64) < total;
|
||||
|
||||
Ok(ToolExecutionPage {
|
||||
items,
|
||||
total,
|
||||
has_more,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -625,9 +625,11 @@ async fn route_list_skills(state: &State<'_, AppState>) {
|
||||
/// `state.clone()`(tauri::State Clone,对齐 route_send_message:468-478 ai_chat_send 调用模式)。
|
||||
/// 不传 query/project_id/status 即走 list_active/list_by_query 空 query 全量等价路径。
|
||||
async fn route_list_entities(state: &State<'_, AppState>) {
|
||||
// list_projects(None query) → list_active 全量(deleted_at IS NULL + created_at DESC)。
|
||||
let projects = match list_projects(state.clone(), None).await {
|
||||
Ok(ps) => ps,
|
||||
// list_projects(None query) → list_active_with_activity(问题3:按最近活跃排序,
|
||||
// 返回带 last_active_at 的 ProjectActivityRecord)。本路由仅透传项目清单给 miniapp
|
||||
// 联想浮层(消费 ProjectRecord 字段),不消费 last_active_at,故剥出 .record 还原类型。
|
||||
let projects: Vec<df_storage::models::ProjectRecord> = match list_projects(state.clone(), None).await {
|
||||
Ok(ps) => ps.into_iter().map(|a| a.record).collect(),
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "[remote_bridge] list_entities list_projects 失败,降级空 Vec");
|
||||
Vec::new()
|
||||
|
||||
@@ -40,9 +40,10 @@ fn default_limit() -> u32 {
|
||||
/// 外层补 total 便于前端分页/计数。
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct TimelineResult {
|
||||
/// 过滤后的事件列表(时间倒序,Repo 已排)
|
||||
/// 过滤后的事件列表(时间倒序,Repo 已排;按 input.limit 截断)
|
||||
pub items: Vec<ProjectEventRecord>,
|
||||
/// 过滤后总数(≤ limit)
|
||||
/// 过滤后**真实总数**(M18:不受 input.limit 截断,反映某项目某 event_type 的完整事件量,
|
||||
/// 供前端分页计数;items.len ≤ total,只有当 total ≤ limit 时两者相等)
|
||||
pub total: usize,
|
||||
/// 查询的项目 ID(回显)
|
||||
pub project_id: String,
|
||||
@@ -70,10 +71,15 @@ pub async fn get_project_timeline(
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
// Repo 取最近 limit 条(内部钳制 ≤ 200),时间倒序。
|
||||
// Repo 取较宽窗口(内部钳制 ≤ 200),时间倒序。M18:不再用 input.limit 直接作 SQL LIMIT——
|
||||
// 应用层 event_type 过滤在 SQL LIMIT 之后,若直接 LIMIT N 会先取 N 条混合类型再过滤,
|
||||
// 过滤后可能远少于 N(类型稀疏时几乎全空),total 也失真。改为取较宽窗口(上限 200,与 Repo
|
||||
// 钳制上限一致),先 event_type 过滤,再按 input.limit 截断,使 total 反映过滤后真实总数
|
||||
// (而非过滤后被 limit 截断的 items.len)。
|
||||
let fetch_window = 200u32;
|
||||
let mut events = state
|
||||
.project_events
|
||||
.get_by_project(&project_id, input.limit)
|
||||
.get_by_project(&project_id, fetch_window)
|
||||
.await
|
||||
.map_err(err_str)?;
|
||||
|
||||
@@ -82,7 +88,15 @@ pub async fn get_project_timeline(
|
||||
events.retain(|e| e.event_type == *et);
|
||||
}
|
||||
|
||||
// M18:total = 过滤后真实总数(反映某项目某类型的完整事件量,供前端分页计数),
|
||||
// 不受 input.limit 截断影响。
|
||||
let total = events.len();
|
||||
// 再按 input.limit 截断返回的 items(默认 50,与文档一致)。
|
||||
let limit = input.limit.min(200) as usize;
|
||||
if events.len() > limit {
|
||||
events.truncate(limit);
|
||||
}
|
||||
|
||||
Ok(TimelineResult {
|
||||
items: events,
|
||||
total,
|
||||
|
||||
@@ -279,6 +279,9 @@ struct GitStatus {
|
||||
changed_files: Vec<GitChangedFile>,
|
||||
/// 最近 10 条提交
|
||||
recent_commits: Vec<GitRecentCommit>,
|
||||
/// 当前 HEAD 的全量提交数(`git rev-list --count HEAD`)。
|
||||
/// 前端历史 Tab 徽标 / 分支栏计数用它,而非 recent_commits.len()(后者受分页限制)。
|
||||
total_commits: i64,
|
||||
/// 该目录是否为 Git 仓库(无 .git 时 false,其余字段空)
|
||||
is_git_repo: bool,
|
||||
}
|
||||
@@ -289,6 +292,7 @@ fn empty_status() -> GitStatus {
|
||||
branch: String::new(),
|
||||
changed_files: Vec::new(),
|
||||
recent_commits: Vec::new(),
|
||||
total_commits: 0,
|
||||
is_git_repo: false,
|
||||
}
|
||||
}
|
||||
@@ -447,10 +451,19 @@ fn run_git_status(dir: &str) -> GitStatus {
|
||||
}
|
||||
}
|
||||
|
||||
// 4) 全量提交计数:`git rev-list --count HEAD`(供前端历史 Tab 徽标真实总数,
|
||||
// 非 recent_commits.len() 后者上限 50)。命令失败 → 退化为 recent_commits 长度。
|
||||
let total_commits = run_git_cmd(path, &["rev-list", "--count", "HEAD"], timeout)
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
.and_then(|s| s.parse::<i64>().ok())
|
||||
.unwrap_or(recent_commits.len() as i64);
|
||||
|
||||
GitStatus {
|
||||
branch,
|
||||
changed_files,
|
||||
recent_commits,
|
||||
total_commits,
|
||||
is_git_repo: true,
|
||||
}
|
||||
}
|
||||
@@ -949,7 +962,8 @@ fn collect_git_status_map(dir: &str) -> HashMap<String, String> {
|
||||
}
|
||||
|
||||
/// 查询工程 Git 提交历史(分页,按时间倒序)。
|
||||
/// 返回 { commits: [{ hash, subject, timestamp }], has_more: bool }。
|
||||
/// 返回 { commits: [{ hash, subject, timestamp, author }], has_more: bool, total: i64 }。
|
||||
/// total = `git rev-list --count HEAD` 的全量提交数,供前端徽标真实总数(commits.len() 受分页限制)。
|
||||
#[tauri::command]
|
||||
pub async fn get_module_commits(
|
||||
state: State<'_, AppState>,
|
||||
@@ -969,7 +983,7 @@ pub async fn get_module_commits(
|
||||
.ok_or_else(|| format!("工程 {module_id} 不存在"))?;
|
||||
let path = std::path::Path::new(&module.path);
|
||||
if !path.join(".git").exists() {
|
||||
return Ok(serde_json::json!({ "commits": [], "has_more": false }));
|
||||
return Ok(serde_json::json!({ "commits": [], "has_more": false, "total": 0 }));
|
||||
}
|
||||
let skip = skip.unwrap_or(0);
|
||||
let fetch = limit.unwrap_or(50);
|
||||
@@ -978,16 +992,18 @@ pub async fn get_module_commits(
|
||||
let dir = module.path.clone();
|
||||
let dir_for_git = dir.clone();
|
||||
// git 命令在 spawn_blocking 中执行(阻塞 IO 不污染 async runtime),10s 超时
|
||||
let commits: Vec<serde_json::Value> = tokio::task::spawn_blocking(move || -> Vec<serde_json::Value> {
|
||||
let (commits, total): (Vec<serde_json::Value>, i64) = tokio::task::spawn_blocking(move || -> (Vec<serde_json::Value>, i64) {
|
||||
let path = std::path::Path::new(&dir_for_git);
|
||||
let timeout = std::time::Duration::from_secs(10);
|
||||
let out = run_git_cmd(
|
||||
std::path::Path::new(&dir_for_git),
|
||||
path,
|
||||
&[
|
||||
"log",
|
||||
&format!("--skip={}", skip),
|
||||
&format!("-{}", fetch_plus),
|
||||
"--format=%h %ct %an %s",
|
||||
],
|
||||
std::time::Duration::from_secs(10),
|
||||
timeout,
|
||||
)
|
||||
.unwrap_or_default();
|
||||
let mut commits: Vec<serde_json::Value> = Vec::new();
|
||||
@@ -1010,7 +1026,14 @@ pub async fn get_module_commits(
|
||||
}));
|
||||
}
|
||||
}
|
||||
commits
|
||||
// 全量提交计数(`git rev-list --count HEAD`):前端历史 Tab 徽标真实总数。
|
||||
// 命令失败 → 退化为 0(前端会显示 0,但 has_more 仍可驱动分页)。
|
||||
let total = run_git_cmd(path, &["rev-list", "--count", "HEAD"], timeout)
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
.and_then(|s| s.parse::<i64>().ok())
|
||||
.unwrap_or(0);
|
||||
(commits, total)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("提交历史查询任务失败: {e}"))?;
|
||||
@@ -1023,6 +1046,7 @@ pub async fn get_module_commits(
|
||||
Ok(serde_json::json!({
|
||||
"commits": returned,
|
||||
"has_more": has_more,
|
||||
"total": total,
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -408,6 +408,15 @@ pub async fn update_task(
|
||||
}
|
||||
// B-260801-01(P0-1):update_field 返 affected>0;false = id 不存在或已软删(0 行)。
|
||||
// 不可静默返 false——前端 store.runWithCatch 把 Err 转 toast,而 false 会被忽略致假成功。
|
||||
//
|
||||
// 任务字段更新是高频「项目活跃」信号(改 title/priority/assignee 等),埋点 task_updated
|
||||
// 推动项目最近活跃排序反映真实业务(问题3)。best-effort 不阻断。
|
||||
// 读当前 project_id(一次轻量读):update_field 返 bool 不带 project_id,无法直接埋点。
|
||||
let current = state
|
||||
.tasks
|
||||
.get_by_id(&id)
|
||||
.await
|
||||
.map_err(err_str)?;
|
||||
let updated = state
|
||||
.tasks
|
||||
.update_field(&id, &field, &value)
|
||||
@@ -416,19 +425,68 @@ pub async fn update_task(
|
||||
if !updated {
|
||||
return Err(format!("任务 ID {id} 不存在或已删除"));
|
||||
}
|
||||
if let Some(rec) = current {
|
||||
emit_event(
|
||||
&state,
|
||||
&rec.project_id,
|
||||
"task_updated",
|
||||
Some("task"),
|
||||
Some(&rec.id),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// 删除任务(软删 → 回收站,可恢复)。对标 delete_project(SET deleted_at=now)。
|
||||
///
|
||||
/// 埋点 task_deleted(问题3 项目最近活跃排序):删除是业务事件,推动项目活跃时间。
|
||||
/// best-effort 不阻断。读 project_id 一次轻量读(soft_delete 返 bool 不带 project_id)。
|
||||
#[tauri::command]
|
||||
pub async fn delete_task(state: State<'_, AppState>, id: String) -> Result<bool, String> {
|
||||
state.tasks.soft_delete(&id).await.map_err(err_str)
|
||||
let rec = state.tasks.get_by_id(&id).await.map_err(err_str)?;
|
||||
let ok = state.tasks.soft_delete(&id).await.map_err(err_str)?;
|
||||
if ok {
|
||||
if let Some(r) = rec {
|
||||
emit_event(
|
||||
&state,
|
||||
&r.project_id,
|
||||
"task_deleted",
|
||||
Some("task"),
|
||||
Some(&r.id),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
Ok(ok)
|
||||
}
|
||||
|
||||
/// 恢复任务(从回收站还原,清 deleted_at)。对标 restore_project。
|
||||
///
|
||||
/// 埋点 task_restored(问题3 项目最近活跃排序):恢复是业务事件。best-effort 不阻断。
|
||||
#[tauri::command]
|
||||
pub async fn restore_task(state: State<'_, AppState>, id: String) -> Result<bool, String> {
|
||||
state.tasks.restore(&id).await.map_err(err_str)
|
||||
let rec = state.tasks.get_by_id(&id).await.map_err(err_str)?;
|
||||
let ok = state.tasks.restore(&id).await.map_err(err_str)?;
|
||||
if ok {
|
||||
if let Some(r) = rec {
|
||||
emit_event(
|
||||
&state,
|
||||
&r.project_id,
|
||||
"task_restored",
|
||||
Some("task"),
|
||||
Some(&r.id),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
Ok(ok)
|
||||
}
|
||||
|
||||
/// 推进任务状态(任务推进链 F-260616-02,推进链唯一 status 写入路径)。
|
||||
@@ -741,12 +799,20 @@ pub async fn move_task_queue(
|
||||
}
|
||||
|
||||
// 知识图谱 Phase 2(对标设计 §2.4 hook/after):queue 变化事件。best-effort 不阻断。
|
||||
// 仅在 queue 实际变化时埋点(避免 no-op 移动产噪音事件)。from/to 用 queue 值。
|
||||
// 仅在 queue 实际变化时埋点(避免 no-op 移动产噪音事件)。
|
||||
//
|
||||
// M17:用独立 event_type "task_queue_moved"(非复用 "task_advanced"),与 status 推进事件
|
||||
// 语义解耦——advance_task 触发的 task_advanced 用 from_state/to_state 装**执行态**值
|
||||
// (todo→in_progress 等),而 move_task_queue 改的是**管理维度池**(backlog/todo/decision/
|
||||
// active/done),两者正交。混用同一 event_type + 同样的 from/to 字段会让消费者无法区分
|
||||
// 「任务执行推进」与「跨池移动」,过滤 task_advanced 的事件流会混入 queue 漂移噪音。
|
||||
// 独立 type 后:status 推进走 task_advanced,queue 移动走 task_queue_moved,语义自洽,
|
||||
// from/to 字段各自装对应维度的值(queue_moved 装 queue 值),不冲突。
|
||||
if current.queue != new_queue {
|
||||
emit_event(
|
||||
&state,
|
||||
¤t.project_id,
|
||||
"task_advanced",
|
||||
"task_queue_moved",
|
||||
Some("task"),
|
||||
Some(¤t.id),
|
||||
Some(¤t.queue),
|
||||
|
||||
@@ -423,12 +423,23 @@ pub async fn run_workflow_inner(
|
||||
Ok(execution_id.to_string())
|
||||
}
|
||||
|
||||
/// 列出全部工作流执行记录
|
||||
/// 列出工作流执行记录(最近 N 条,对标设计 §10.5 单用户桌面应用分页)。
|
||||
///
|
||||
/// M19:原实现调 `list_all()` 无 limit/分页,工作流执行记录随使用累积(workflow_executions 表
|
||||
/// 只插不删,每次 run_workflow 落一条),长期使用后全量返回会撑爆前端列表 + IPC 传输 + 内存。
|
||||
/// 加可选 `limit` 参数(默认 100,钳制 ≤ 500),list_all 已按 created_at DESC 排序,截断取最近 N 条。
|
||||
/// 前端旧调用方不传 limit 走默认 100,零破坏(工作流历史本就按时间倒序展示,截断尾部老记录无感)。
|
||||
#[tauri::command]
|
||||
pub async fn list_workflow_executions(
|
||||
state: State<'_, AppState>,
|
||||
limit: Option<u32>,
|
||||
) -> Result<Vec<WorkflowRecord>, String> {
|
||||
state.workflows.list_all().await.map_err(err_str)
|
||||
let safe_limit = limit.unwrap_or(100).min(500) as usize;
|
||||
let mut records = state.workflows.list_all().await.map_err(err_str)?;
|
||||
if records.len() > safe_limit {
|
||||
records.truncate(safe_limit);
|
||||
}
|
||||
Ok(records)
|
||||
}
|
||||
|
||||
/// 按 ID 查询工作流执行记录
|
||||
|
||||
Reference in New Issue
Block a user