From f736f435bcbe829410fad8898b44b95cafac7859 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=BB=9D=E5=B0=98?= <237809796@qq.com> Date: Sun, 2 Aug 2026 13:11:06 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96:=20=E6=89=80=E6=9C=89?= =?UTF-8?q?=E5=89=A9=E4=BD=99UI/UX=E5=BE=85=E5=8A=9E=E4=B8=80=E6=89=B9?= =?UTF-8?q?=E5=AE=8C=E6=88=90(=E6=8C=81=E4=B9=85=E5=8C=96+AuditLog+?= =?UTF-8?q?=E8=A7=A3=E8=80=A6+total+=E5=8E=9F12=E5=A4=A7=E6=94=B9+P2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 持久化(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全选) --- .../df-storage/src/crud/conversation_repo.rs | 145 +++++++ .../df-storage/src/crud/project_event_repo.rs | 89 ++++ crates/df-storage/src/crud/project_repo.rs | 61 +++ src-tauri/src/commands/ai/audit/mod.rs | 2 +- src-tauri/src/commands/ai/audit/record.rs | 81 +++- src-tauri/src/commands/ai/remote_bridge.rs | 8 +- src-tauri/src/commands/events.rs | 22 +- src-tauri/src/commands/module.rs | 36 +- src-tauri/src/commands/project.rs | 136 ++++-- src-tauri/src/commands/task.rs | 74 +++- src-tauri/src/commands/workflow.rs | 15 +- src/api/module.ts | 4 + src/api/types.ts | 6 + src/components/ConfirmDialog.vue | 32 +- src/components/ToolResultBody.vue | 6 +- src/components/ai/CIStatus.vue | 11 +- src/components/ai/ChatInput.vue | 60 ++- src/components/ai/ConversationSidebar.vue | 45 +- src/components/ai/DirAuthDialog.vue | 18 +- src/components/ai/HelpRequiredCard.vue | 16 +- src/components/ai/MaxRoundsCard.vue | 16 +- src/components/ai/TopBar.vue | 28 +- .../dashboard/ActiveProjectsPanel.vue | 10 +- src/components/ideas/IdeaDetail.vue | 392 +++++++++++++----- src/components/knowledge/KnowledgeDetail.vue | 173 ++++++-- src/components/layout/AppLayout.vue | 66 ++- src/components/layout/AppSidebar.vue | 73 ++-- src/components/project/ApprovalDialog.vue | 34 ++ src/components/project/FileExplorer.vue | 10 +- src/components/project/FilePreview.vue | 22 +- src/components/project/FileTree.vue | 17 +- src/components/project/GitChanges.vue | 14 +- src/components/project/ModuleNode.vue | 19 +- src/components/project/ProjectCard.vue | 83 +++- src/components/settings/AdvancedSection.vue | 22 +- src/components/settings/ConnectionPanel.vue | 8 +- src/components/settings/KnowledgePanel.vue | 25 +- src/components/settings/ProviderPanel.vue | 65 ++- src/composables/ai/useAiConversations.ts | 56 ++- src/composables/usePersistedRef.ts | 56 +++ src/i18n/en/ai.ts | 8 + src/i18n/en/aiChat.ts | 6 + src/i18n/en/auditLog.ts | 2 + src/i18n/en/common.ts | 3 + src/i18n/en/ideas.ts | 2 + src/i18n/en/knowledge.ts | 7 + src/i18n/en/projectDetail.ts | 2 + src/i18n/en/projects.ts | 4 + src/i18n/en/settings.ts | 6 +- src/i18n/en/taskDetail.ts | 5 + src/i18n/zh-CN/ai.ts | 8 + src/i18n/zh-CN/aiChat.ts | 6 + src/i18n/zh-CN/auditLog.ts | 2 + src/i18n/zh-CN/common.ts | 3 + src/i18n/zh-CN/ideas.ts | 2 + src/i18n/zh-CN/knowledge.ts | 7 + src/i18n/zh-CN/projectDetail.ts | 2 + src/i18n/zh-CN/projects.ts | 4 + src/i18n/zh-CN/settings.ts | 6 +- src/i18n/zh-CN/taskDetail.ts | 5 + src/views/ApprovalPopup.vue | 64 ++- src/views/AuditLog.vue | 113 +++-- src/views/Dashboard.vue | 65 ++- src/views/Ideas.vue | 35 +- src/views/Knowledge.vue | 127 +++++- src/views/ProjectDetail.vue | 163 ++++++-- src/views/Projects.vue | 72 +++- src/views/Settings.vue | 36 +- src/views/TaskDetail.vue | 385 +++++++++++------ src/views/Tasks.vue | 15 +- 70 files changed, 2645 insertions(+), 576 deletions(-) create mode 100644 src/composables/usePersistedRef.ts diff --git a/crates/df-storage/src/crud/conversation_repo.rs b/crates/df-storage/src/crud/conversation_repo.rs index f67fc1c..732d3a6 100644 --- a/crates/df-storage/src/crud/conversation_repo.rs +++ b/crates/df-storage/src/crud/conversation_repo.rs @@ -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, + pub risk_level: Option, + pub tool_keyword: Option, + pub limit: Option, + pub offset: Option, +} + 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> { + 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 = Vec::new(); + let mut params_vec: Vec> = 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 { + 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 = Vec::new(); + let mut params_vec: Vec> = 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 提供。 diff --git a/crates/df-storage/src/crud/project_event_repo.rs b/crates/df-storage/src/crud/project_event_repo.rs index ce1c1bb..fd720c8 100644 --- a/crates/df-storage/src/crud/project_event_repo.rs +++ b/crates/df-storage/src/crud/project_event_repo.rs @@ -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`。无事件的项目不在 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> { + 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() { diff --git a/crates/df-storage/src/crud/project_repo.rs b/crates/df-storage/src/crud/project_repo.rs index e7b1995..7dd3e9c 100644 --- a/crates/df-storage/src/crud/project_repo.rs +++ b/crates/df-storage/src/crud/project_repo.rs @@ -72,6 +72,22 @@ fn build_order_clause(order_by: Option<&str>) -> Result { } } +/// 带「最近活跃时间」的项目记录(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> { + 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 子句 + diff --git a/src-tauri/src/commands/ai/audit/mod.rs b/src-tauri/src/commands/ai/audit/mod.rs index 7fbc187..88c1814 100644 --- a/src-tauri/src/commands/ai/audit/mod.rs +++ b/src-tauri/src/commands/ai/audit/mod.rs @@ -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 抽离,行为零变更)。 diff --git a/src-tauri/src/commands/ai/audit/record.rs b/src-tauri/src/commands/ai/audit/record.rs index 68d7d79..43be64a 100644 --- a/src-tauri/src/commands/ai/audit/record.rs +++ b/src-tauri/src/commands/ai/audit/record.rs @@ -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, } +/// 审批历史查询入参(前端透传,空值=不过滤)。 +/// +/// 复用 [`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, + /// 风险等级精确匹配(low/medium/high) + pub risk_level: Option, + /// 工具名关键词(tool_name LIKE %kw%) + pub tool_keyword: Option, + pub limit: Option, + pub offset: Option, +} + +impl From 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, + 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, - offset: Option, -) -> Result, String> { - let limit = limit.unwrap_or(50); - let offset = offset.unwrap_or(0); + query: Option, +) -> Result { + 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 = 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, + }) } diff --git a/src-tauri/src/commands/ai/remote_bridge.rs b/src-tauri/src/commands/ai/remote_bridge.rs index 0c709a7..6716068 100644 --- a/src-tauri/src/commands/ai/remote_bridge.rs +++ b/src-tauri/src/commands/ai/remote_bridge.rs @@ -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 = 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() diff --git a/src-tauri/src/commands/events.rs b/src-tauri/src/commands/events.rs index f9a4d05..64ecc49 100644 --- a/src-tauri/src/commands/events.rs +++ b/src-tauri/src/commands/events.rs @@ -40,9 +40,10 @@ fn default_limit() -> u32 { /// 外层补 total 便于前端分页/计数。 #[derive(Debug, Serialize)] pub struct TimelineResult { - /// 过滤后的事件列表(时间倒序,Repo 已排) + /// 过滤后的事件列表(时间倒序,Repo 已排;按 input.limit 截断) pub items: Vec, - /// 过滤后总数(≤ 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, diff --git a/src-tauri/src/commands/module.rs b/src-tauri/src/commands/module.rs index a2d1944..8d112ab 100644 --- a/src-tauri/src/commands/module.rs +++ b/src-tauri/src/commands/module.rs @@ -279,6 +279,9 @@ struct GitStatus { changed_files: Vec, /// 最近 10 条提交 recent_commits: Vec, + /// 当前 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::().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 { } /// 查询工程 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 = tokio::task::spawn_blocking(move || -> Vec { + let (commits, total): (Vec, i64) = tokio::task::spawn_blocking(move || -> (Vec, 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 = 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::().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, })) } diff --git a/src-tauri/src/commands/project.rs b/src-tauri/src/commands/project.rs index f2f6233..3c8288b 100644 --- a/src-tauri/src/commands/project.rs +++ b/src-tauri/src/commands/project.rs @@ -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, -) -> Result, String> { +) -> Result, 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 = 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 { diff --git a/src-tauri/src/commands/task.rs b/src-tauri/src/commands/task.rs index 4108d32..573dec6 100644 --- a/src-tauri/src/commands/task.rs +++ b/src-tauri/src/commands/task.rs @@ -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 { - 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 { - 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), diff --git a/src-tauri/src/commands/workflow.rs b/src-tauri/src/commands/workflow.rs index fe9b3cc..506d099 100644 --- a/src-tauri/src/commands/workflow.rs +++ b/src-tauri/src/commands/workflow.rs @@ -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, ) -> Result, 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 查询工作流执行记录 diff --git a/src/api/module.ts b/src/api/module.ts index d26b9db..e9c277f 100644 --- a/src/api/module.ts +++ b/src/api/module.ts @@ -85,6 +85,8 @@ export interface GitStatusResult { branch: string changed_files: GitChangedFile[] recent_commits: GitRecentCommit[] + /** 当前 HEAD 的全量提交数(`git rev-list --count HEAD`)。0 = 非 git 仓库或命令失败。 */ + total_commits: number is_git_repo: boolean } @@ -191,6 +193,8 @@ export const moduleApi = { getModuleCommits(moduleId: string, skip?: number, limit?: number): Promise<{ commits: { hash: string; subject: string; timestamp: number; author: string }[] has_more: boolean + /** 当前 HEAD 的全量提交数(`git rev-list --count HEAD`)。0 = 非 git 仓库或命令失败。 */ + total: number }> { return invoke('get_module_commits', { moduleId, skip: skip ?? 0, limit: limit ?? 50 }) }, diff --git a/src/api/types.ts b/src/api/types.ts index 2a55f92..6617abe 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -104,6 +104,12 @@ export interface ProjectRecord { stack: string | null created_at: string updated_at: string + /** + * 最近活跃时间(问题3):COALESCE(project_events 最新事件 created_at, updated_at)。 + * 反映业务活跃(任务/灵感/状态推进等事件),无事件回退 updated_at。 + * 可选(旧后端/回收站等路径不返):消费方需用 ?? updated_at 兜底。 + */ + last_active_at?: string } export interface CreateProjectInput { diff --git a/src/components/ConfirmDialog.vue b/src/components/ConfirmDialog.vue index a466488..6fd7c10 100644 --- a/src/components/ConfirmDialog.vue +++ b/src/components/ConfirmDialog.vue @@ -15,7 +15,9 @@ diff --git a/src/composables/ai/useAiConversations.ts b/src/composables/ai/useAiConversations.ts index a9f1795..4e5d6d5 100644 --- a/src/composables/ai/useAiConversations.ts +++ b/src/composables/ai/useAiConversations.ts @@ -276,31 +276,69 @@ async function deleteConversation(id: string) { notifyConversationChanged() } -/** 重命名会话(后端 + 本地侧栏摘要同步) */ +/** M30:写后端 IPC 失败时推送错误气泡(对齐 loadConversations/switchConversation 的失败反馈), + * 保持侧栏已有交互不被无声吞掉(原仅向上抛,调用方 store 直调无 try → 异常进 unhandledrejection)。 */ +function pushConvOpFail(key: 'renameConvFail' | 'archiveConvFail' | 'pinConvFail', params?: Record) { + state.messages.push({ + id: `conv-op-fail-${nextMsgId()}`, + role: 'assistant', + content: t(`ai.${key}`, params), + isError: true, + timestamp: Date.now(), + } as AiMessage) + notifyConversationChanged() +} + +/** 重命名会话(后端 + 本地侧栏摘要同步)。 + * M30:乐观更新(本地先写 → IPC → 失败回滚 + 提示)。原逻辑 IPC 成功才写本地,IPC 失败时异常 + * 向上抛但 store 直调处无 try/catch → unhandledrejection,用户无感。改乐观更新让失败可见可回滚。 */ async function renameConversation(id: string, title: string) { - await aiApi.renameConversation(id, title) - // 本地同步更新侧栏摘要标题 const conv = state.conversations.find(c => c.id === id) + const prevTitle = conv?.title ?? null if (conv) conv.title = title notifyConversationChanged() + try { + await aiApi.renameConversation(id, title) + } catch (e) { + console.error('[AI] 重命名会话失败:', e) + if (conv) conv.title = prevTitle + notifyConversationChanged() + pushConvOpFail('renameConvFail') + } } -/** 归档/取消归档(后端 + 本地侧栏分组同步) */ +/** 归档/取消归档(后端 + 本地侧栏分组同步)。 + * M30:乐观更新 + 失败回滚 + 提示(同 renameConversation)。 */ async function archiveConversation(id: string, archived: boolean) { - await aiApi.archiveConversation(id, archived) - // 本地同步归档态(侧栏立即移入/移出归档分组) const conv = state.conversations.find(c => c.id === id) + const prevArchived = conv?.archived ?? false if (conv) conv.archived = archived notifyConversationChanged() + try { + await aiApi.archiveConversation(id, archived) + } catch (e) { + console.error('[AI] 归档会话失败:', e) + if (conv) conv.archived = prevArchived + notifyConversationChanged() + pushConvOpFail('archiveConvFail', { action: archived ? t('ai.archiveAction') : t('ai.unarchiveAction') }) + } } -/** 置顶/取消置顶(后端 + 本地侧栏排序同步;UX-17) */ +/** 置顶/取消置顶(后端 + 本地侧栏排序同步;UX-17)。 + * M30:乐观更新 + 失败回滚 + 提示(同 renameConversation)。 */ async function setPinnedConversation(id: string, pinned: boolean) { - await aiApi.setPinnedConversation(id, pinned) - // 本地同步置顶态(排序 computed 读 pinned,立即重排) const conv = state.conversations.find(c => c.id === id) + const prevPinned = conv?.pinned ?? false if (conv) conv.pinned = pinned notifyConversationChanged() + try { + await aiApi.setPinnedConversation(id, pinned) + } catch (e) { + console.error('[AI] 置顶会话失败:', e) + if (conv) conv.pinned = prevPinned + notifyConversationChanged() + pushConvOpFail('pinConvFail', { action: pinned ? t('ai.pinAction') : t('ai.unpinAction') }) + } } /** 折叠/展开归档分组 */ diff --git a/src/composables/usePersistedRef.ts b/src/composables/usePersistedRef.ts new file mode 100644 index 0000000..6fbfc21 --- /dev/null +++ b/src/composables/usePersistedRef.ts @@ -0,0 +1,56 @@ +//! localStorage 持久化 ref — 读初始化 + 写回 localStorage(JSON) +//! +//! 背景:Tasks/Projects/Ideas 等列表页的筛选/分页状态散布在多个 ref,各自手写 +//! localStorage.getItem/setItem 易漏且风格不一。本 composable 收敛为声明式一行: +//! const page = usePersistedRef('tasks.page', 1) +//! +//! 行为: +//! - 初始化时读 localStorage,存在且 JSON 解析成功则用其值,否则用 default +//! - watch ref 深度变化,JSON.stringify 写回 localStorage(try/catch 防脏数据/配额) +//! - SSR/无 localStorage 环境(SSR 不适用于 Tauri webview,但防御性兜底)返回纯 ref +//! +//! 设计取舍: +//! - 不做跨 tab 同步(storage 事件)——单实例 Tauri 应用无此需求 +//! - 不做类型校验(解析出的值类型可能漂移)——调用方 default 决定类型,JSON 解析成功即采用, +//! 若需严格类型校验可在调用方业务层判断(当前所有调用点均为 string/number,无需求) + +import { ref, watch, type Ref } from 'vue' + +/** + * 创建一个持久化到 localStorage 的 ref。 + * + * @param key localStorage 键名(建议带模块前缀,如 'tasks.page') + * @param defaultValue 默认值(localStorage 无值或解析失败时使用) + * @returns 与普通 ref 行为一致的 Ref + */ +export function usePersistedRef(key: string, defaultValue: T): Ref { + // 初始化:读 localStorage,失败/不存在回落到 default + const initial = readPersisted(key, defaultValue) + const r = ref(initial) as Ref + + // 写回:深度 watch,JSON.stringify 失败静默(防循环引用等异常) + watch( + r, + (val) => { + try { + localStorage.setItem(key, JSON.stringify(val)) + } catch { + /* 忽略:配额溢出 / 序列化失败 / 隐私模式禁用 localStorage */ + } + }, + { deep: true }, + ) + + return r +} + +/** 读 localStorage 并 JSON 解析,失败/不存在返回 defaultValue。仅本模块内部使用。 */ +function readPersisted(key: string, defaultValue: T): T { + try { + const raw = localStorage.getItem(key) + if (raw === null) return defaultValue + return JSON.parse(raw) as T + } catch { + return defaultValue + } +} diff --git a/src/i18n/en/ai.ts b/src/i18n/en/ai.ts index 9fe516c..972d833 100644 --- a/src/i18n/en/ai.ts +++ b/src/i18n/en/ai.ts @@ -41,6 +41,14 @@ export default { // UX-260617-08: conversation load/switch failure feedback (network jitter/IPC down → empty list/blank, was silent) loadConvFail: 'Failed to load conversations. Please check your network or backend connection.', switchConvFail: 'Failed to switch conversation. Please retry.', + // M30: rename/archive/pin IPC failure feedback (was no try/catch, exceptions leaked to unhandledrejection) + renameConvFail: 'Failed to rename. Please retry.', + archiveConvFail: 'Failed to {action}. Please retry.', + pinConvFail: 'Failed to {action}. Please retry.', + archiveAction: 'archive', + unarchiveAction: 'unarchive', + pinAction: 'pin', + unpinAction: 'unpin', // AE-2025-07: Agentic loop progress bar copy // Note: max round / completed tool count are not surfaced by backend this round; // template conditionally omits them. Add *WithMax keys once backend exposes them. diff --git a/src/i18n/en/aiChat.ts b/src/i18n/en/aiChat.ts index 2d8c20d..2d509bc 100644 --- a/src/i18n/en/aiChat.ts +++ b/src/i18n/en/aiChat.ts @@ -240,5 +240,11 @@ export default { // ── AE-2025-04 Session Trust ── // Auto-approval toast (same-session already approved same-kind op: same tool + same dir) autoApprovedToast: 'Auto-approved: {tool}({dir})', + + // ── CIStatus.vue (commit CI checks status card) ── + ciTitle: 'CI Checks', + ciNFailed: '{n} failed', + ciNPending: '{n} pending', + ciNPassed: '{n} passed', }, } diff --git a/src/i18n/en/auditLog.ts b/src/i18n/en/auditLog.ts index 5b12fd4..5e89dcd 100644 --- a/src/i18n/en/auditLog.ts +++ b/src/i18n/en/auditLog.ts @@ -27,6 +27,7 @@ export default { next: 'Next', page: 'Page {n}', last: '(last)', + total: '{n} total', }, risk: { @@ -42,6 +43,7 @@ export default { executing: 'Executing', completed: 'Completed', failed: 'Failed', + interrupted: 'Interrupted', }, decided: { diff --git a/src/i18n/en/common.ts b/src/i18n/en/common.ts index c53d03e..9aad814 100644 --- a/src/i18n/en/common.ts +++ b/src/i18n/en/common.ts @@ -14,6 +14,9 @@ export default { close: 'Close', loading: 'Loading…', unknownError: 'Unknown error', + // Collapse/expand (reusable generic verbs for description/log/future long content) + expand: 'Expand', + collapse: 'Collapse', // Pagination UI (F-260621-02 P3 baseline; off by default, opt-in per-page) pagination: { prev: 'Prev', diff --git a/src/i18n/en/ideas.ts b/src/i18n/en/ideas.ts index 31558a6..a79e259 100644 --- a/src/i18n/en/ideas.ts +++ b/src/i18n/en/ideas.ts @@ -135,6 +135,8 @@ export default { // Native dialog text (confirm / alert) confirmDelete: 'Delete idea "{title}"? This cannot be undone.', + // ⑥ Status change confirm (terminal promoted/rejected/archived or leaving approved) + confirmStatus: 'Change idea "{title}" status from {from} to {to}?', promoteFailed: 'Promotion failed', evalFailed: 'Evaluation failed', }, diff --git a/src/i18n/en/knowledge.ts b/src/i18n/en/knowledge.ts index 2ef49b1..e816130 100644 --- a/src/i18n/en/knowledge.ts +++ b/src/i18n/en/knowledge.ts @@ -15,6 +15,12 @@ export default { publish: 'Publish', reject: 'Reject', archive: 'Archive', + // ⑤ Operation feedback toast + savedToast: 'Saved', + publishedToast: 'Published to library', + rejectedToast: 'Rejected', + archivedToast: 'Archived', + createdToast: 'Created, moved to pending', reuseCount: '🔄 Reused {n} times', confidenceBadge: 'Confidence: {label}', contentLabel: 'Content', @@ -83,6 +89,7 @@ export default { createFailed: 'Failed to create knowledge', updateStatusFailed: 'Failed to update status', archiveFailed: 'Failed to archive', + saveFailed: 'Failed to save', loadConfigFailed: 'Failed to load config', saveConfigFailed: 'Failed to save config', extractFailed: 'Extraction failed', diff --git a/src/i18n/en/projectDetail.ts b/src/i18n/en/projectDetail.ts index 7f5a71f..969518c 100644 --- a/src/i18n/en/projectDetail.ts +++ b/src/i18n/en/projectDetail.ts @@ -64,6 +64,8 @@ export default { // Approval actions approvalConfirm: 'Confirm ({count})', approvalCancel: 'Cancel', + approvalSelectAll: 'Select all', + approvalInvert: 'Invert', // Tech stack techStackLabel: 'Tech Stack', // Tab navigation diff --git a/src/i18n/en/projects.ts b/src/i18n/en/projects.ts index 1934ac5..eb1a728 100644 --- a/src/i18n/en/projects.ts +++ b/src/i18n/en/projects.ts @@ -4,6 +4,10 @@ export default { // Header actions create: '+ New Project', trash: '🗑 Trash', + // View mode toggle (compact list default / spacious card) + viewModeTitle: 'Switch view mode', + viewList: 'List view', + viewCard: 'Card view', // New project modal createTitle: 'New Project', nameLabel: 'Project Name', diff --git a/src/i18n/en/settings.ts b/src/i18n/en/settings.ts index eecd5e7..6a7d3f7 100644 --- a/src/i18n/en/settings.ts +++ b/src/i18n/en/settings.ts @@ -67,8 +67,9 @@ export default { fetchHintSaveFirst: 'Save the provider first, then fetch models', labelWeight: 'Weight', modelListTitle: 'Fetched models ({count})', - modelListHint: 'Enabled / weight are session-local edits; the next "Test connection" overwrites with fresh probe results', + modelListHint: 'After toggling enabled / weight, click "Save" below to write them into the provider config; the next "Test connection" overwrites with fresh probe results', saveModels: 'Save model config', + modelListUnsavedHint: 'Model list has unsaved changes (enabled / weight) — closing or navigating away will discard them', toastFetchOk: 'Fetched {count} models', toastFetchFail: 'Fetch failed: {msg}', fetchFailedTimeout: 'Fetch timed out, check that the Base URL is reachable: {msg}', @@ -213,6 +214,8 @@ export default { // ===== Toast ===== toastLoadProviderFail: 'Failed to load providers', toastSaveIncomplete: 'Please fill in all fields (Name / Base URL / API Key / Model)', + // L19: confirm before discarding an edited form + confirmDiscardUnsaved: 'The form has unsaved changes. Discard them?', toastSaved: 'Saved', toastSaveFail: 'Save failed: {msg}', // P0-2: save-failure classified guidance (persistent banner, not raw Err text) @@ -225,6 +228,7 @@ export default { toastSetDefaultOk: 'Set as default', toastSetDefaultFail: 'Failed to set default: {msg}', toastConnIncomplete: 'Please fill in Name and Host', + toastConnPortInvalid: 'Port must be between 1 and 65535', // ===== Import / Export (phase 6) ===== export: 'Export', diff --git a/src/i18n/en/taskDetail.ts b/src/i18n/en/taskDetail.ts index a34c2c2..1070a76 100644 --- a/src/i18n/en/taskDetail.ts +++ b/src/i18n/en/taskDetail.ts @@ -62,5 +62,10 @@ export default { workflowEdgeDetail: 'Edge Condition Editor', workflowUnconditional: 'Unconditional', workflowCondPlaceholder: 'Enter condition expression (e.g. outputs.done == true)', + // Description collapse (show expand button beyond 400px, Problem4 redesign) + expand: 'Expand', + collapse: 'Collapse', + // Related info panel title (wide-screen right column / info grouping) + relatedTitle: 'Related', }, } diff --git a/src/i18n/zh-CN/ai.ts b/src/i18n/zh-CN/ai.ts index e104913..2b125e7 100644 --- a/src/i18n/zh-CN/ai.ts +++ b/src/i18n/zh-CN/ai.ts @@ -41,6 +41,14 @@ export default { // UX-260617-08:对话加载/切换失败反馈(网络抖动/IPC 断开 → 列表空/切换空白,原静默无反馈) loadConvFail: '对话列表加载失败,请检查网络或后端连接', switchConvFail: '对话切换失败,请重试', + // M30:重命名/归档/置顶 IPC 失败反馈(原无 try/catch 异常进 unhandledrejection,用户无感) + renameConvFail: '重命名失败,请重试', + archiveConvFail: '{action}失败,请重试', + pinConvFail: '{action}失败,请重试', + archiveAction: '归档', + unarchiveAction: '取消归档', + pinAction: '置顶', + unpinAction: '取消置顶', // AE-2025-07: Agentic 循环进度条文案 // 注:max 轮次/已完成工具数本轮后端暂不透传,模板条件渲染省略;后端补全后新增 *WithMax key agenticProgress: '🔄 循环 {round} · ⏳{pending}待审批', diff --git a/src/i18n/zh-CN/aiChat.ts b/src/i18n/zh-CN/aiChat.ts index 300ac07..f27d2d8 100644 --- a/src/i18n/zh-CN/aiChat.ts +++ b/src/i18n/zh-CN/aiChat.ts @@ -241,5 +241,11 @@ export default { // ── AE-2025-04 会话级信任(Session Trust) ── // 自动放行 toast(同会话已批准过同类操作:同工具+同目录,轻量 info 非审批气泡) autoApprovedToast: '已自动放行: {tool}({dir})', + + // ── CIStatus.vue(代码提交 CI 检查状态卡)── + ciTitle: 'CI 检查', + ciNFailed: '{n} 项失败', + ciNPending: '{n} 项进行中', + ciNPassed: '{n} 项通过', }, } diff --git a/src/i18n/zh-CN/auditLog.ts b/src/i18n/zh-CN/auditLog.ts index 316137b..55b47d0 100644 --- a/src/i18n/zh-CN/auditLog.ts +++ b/src/i18n/zh-CN/auditLog.ts @@ -27,6 +27,7 @@ export default { next: '下一页', page: '第 {n} 页', last: '(末页)', + total: '共 {n} 条', }, risk: { @@ -42,6 +43,7 @@ export default { executing: '执行中', completed: '已完成', failed: '失败', + interrupted: '已中断', }, decided: { diff --git a/src/i18n/zh-CN/common.ts b/src/i18n/zh-CN/common.ts index f91d864..9fa649e 100644 --- a/src/i18n/zh-CN/common.ts +++ b/src/i18n/zh-CN/common.ts @@ -14,6 +14,9 @@ export default { close: '关闭', loading: '加载中…', unknownError: '未知错误', + // 折叠/展开(可复用通用词,描述折叠/日志折叠/未来长内容) + expand: '展开', + collapse: '收起', // 分页 UI(F-260621-02 P3 基建,默认不分页,用户选每页 N 条才触发) pagination: { prev: '上一页', diff --git a/src/i18n/zh-CN/ideas.ts b/src/i18n/zh-CN/ideas.ts index 23cd921..fc92d0c 100644 --- a/src/i18n/zh-CN/ideas.ts +++ b/src/i18n/zh-CN/ideas.ts @@ -135,6 +135,8 @@ export default { // 原生对话框文案(confirm / alert) confirmDelete: '确定删除灵感「{title}」?此操作不可撤销。', + // ⑥ 状态切换确认(终态 promoted/rejected/archived 或离开 approved 失去立项入口) + confirmStatus: '将灵感「{title}」状态从 {from} 改为 {to}?', promoteFailed: '立项失败', evalFailed: '评估失败', }, diff --git a/src/i18n/zh-CN/knowledge.ts b/src/i18n/zh-CN/knowledge.ts index 18d24ce..4ae3980 100644 --- a/src/i18n/zh-CN/knowledge.ts +++ b/src/i18n/zh-CN/knowledge.ts @@ -20,6 +20,12 @@ export default { publish: '发布', reject: '拒绝', archive: '归档', + // ⑤ 操作反馈 toast + savedToast: '已保存', + publishedToast: '已发布到知识库', + rejectedToast: '已拒绝', + archivedToast: '已归档', + createdToast: '已创建,进入待处理', // 详情徽章 reuseCount: '🔄 复用 {n} 次', confidenceBadge: '置信度: {label}', @@ -99,6 +105,7 @@ export default { createFailed: '创建知识失败', updateStatusFailed: '更新状态失败', archiveFailed: '归档失败', + saveFailed: '保存失败', loadConfigFailed: '加载配置失败', saveConfigFailed: '保存配置失败', extractFailed: '立即抽取失败', diff --git a/src/i18n/zh-CN/projectDetail.ts b/src/i18n/zh-CN/projectDetail.ts index a1e57e7..353a667 100644 --- a/src/i18n/zh-CN/projectDetail.ts +++ b/src/i18n/zh-CN/projectDetail.ts @@ -64,6 +64,8 @@ export default { // 审批操作 approvalConfirm: '确认({count})', approvalCancel: '取消', + approvalSelectAll: '全选', + approvalInvert: '反选', // 技术栈 techStackLabel: '技术栈', // Tab 导航 diff --git a/src/i18n/zh-CN/projects.ts b/src/i18n/zh-CN/projects.ts index 62ad25f..5310eed 100644 --- a/src/i18n/zh-CN/projects.ts +++ b/src/i18n/zh-CN/projects.ts @@ -4,6 +4,10 @@ export default { // 头部操作 create: '+ 新建项目', trash: '🗑 回收站', + // 视图模式切换(list 紧凑单列默认 / card 宽松双列) + viewModeTitle: '切换视图模式', + viewList: '列表视图', + viewCard: '卡片视图', // 新建项目模态框 createTitle: '新建项目', nameLabel: '项目名称', diff --git a/src/i18n/zh-CN/settings.ts b/src/i18n/zh-CN/settings.ts index aa28a18..2a247da 100644 --- a/src/i18n/zh-CN/settings.ts +++ b/src/i18n/zh-CN/settings.ts @@ -66,8 +66,9 @@ export default { fetchHintSaveFirst: '请先保存 Provider 后再拉取模型', labelWeight: '权重', modelListTitle: '已拉取模型({count} 个)', - modelListHint: '已启用 / 权重为本次会话内调整,下次「测试连接」会以最新探测结果覆盖', + modelListHint: '勾选 / 权重调整后,请点下方「保存」一并写入 Provider 配置;下次「测试连接」会以最新探测结果覆盖', saveModels: '保存模型配置', + modelListUnsavedHint: '模型列表有未保存的调整(启用 / 权重),关闭或切走将丢弃', toastFetchOk: '已拉取 {count} 个模型', toastFetchFail: '拉取模型失败:{msg}', fetchFailedTimeout: '拉取超时,请检查 Base URL 是否可达:{msg}', @@ -212,6 +213,8 @@ export default { // ===== Toast 提示 ===== toastLoadProviderFail: '加载提供商失败', toastSaveIncomplete: '请填写完整(名称 / Base URL / API Key / 模型)', + // L19: 表单有改动时取消/关闭的二次确认 + confirmDiscardUnsaved: '表单有未保存的改动,确定放弃吗?', toastSaved: '已保存', toastSaveFail: '保存失败:{msg}', // P0-2: 保存失败分类建议(常驻错误横幅,非裸 Err 文本) @@ -224,6 +227,7 @@ export default { toastSetDefaultOk: '已设为默认', toastSetDefaultFail: '设置默认失败:{msg}', toastConnIncomplete: '请填写名称和 Host', + toastConnPortInvalid: '端口必须在 1-65535 范围内', // ===== 导入 / 导出(阶段6) ===== export: '导出', diff --git a/src/i18n/zh-CN/taskDetail.ts b/src/i18n/zh-CN/taskDetail.ts index 4e61ffb..23fe764 100644 --- a/src/i18n/zh-CN/taskDetail.ts +++ b/src/i18n/zh-CN/taskDetail.ts @@ -62,5 +62,10 @@ export default { workflowEdgeDetail: '边条件编辑', workflowUnconditional: '无条件', workflowCondPlaceholder: '输入条件表达式(如 outputs.done == true)', + // 描述折叠(超 400px 显展开按钮,Problem4 重设计) + expand: '展开', + collapse: '收起', + // 关联信息面板标题(宽屏右栏 / 信息分组) + relatedTitle: '关联信息', }, } diff --git a/src/views/ApprovalPopup.vue b/src/views/ApprovalPopup.vue index 7a2db12..12216eb 100644 --- a/src/views/ApprovalPopup.vue +++ b/src/views/ApprovalPopup.vue @@ -72,7 +72,7 @@ > - {{ $t('aiChat.dirAuthOnce') }} + {{ $t('aiChat.dirAuthOnce') }}1 @@ -244,11 +244,45 @@ let _closeFallbackTimer: ReturnType | null = null const CLOSE_TIMEOUT_MS = 1500 /** Esc 键关浮窗(键盘可达性)。模块级命名函数,onBeforeUnmount 据此精确移除同一引用。 - * 仅响应 Esc(KeyDown),其他键透传(不拦截 Tab/Enter 等原生 button 导航)。 */ + * 仅响应 Esc(KeyDown),其他键透传(不拦截 Tab/Enter 等原生 button 导航)。 + * + * 7c:数字键快捷审批(对列表顶部第一条生效,多审批场景用户应逐条点或用方向键导航)。 + * - path 类:1=once / 2=always / 3=deny(deny 仍走二次确认,与按钮同款不可逆守卫) + * - risk 类:1=approve / 3=reject(2 仅 path 类有 always,risk 类忽略 '2') + * 守卫: + * - 确认对话框打开时(confirmState.visible)禁用快捷键,避免与确认按钮的 Enter/Esc 冲突 + * - 处理中(processingId)时忽略,防重复触发 + * - 输入元素聚焦时不拦截(input/textarea/contenteditable 场景,本浮窗暂无 input 但防御) + * - Ctrl/Meta/Alt 组合不拦截(避免劫持浏览器/系统快捷键) */ function _onKeyDown(e: KeyboardEvent) { if (e.key === 'Escape') { e.preventDefault() void onClose() + return + } + // 7c:数字键快捷审批 + if (e.key === '1' || e.key === '2' || e.key === '3') { + // 组合键(Ctrl+1 等)不拦截;确认对话框打开时不拦截;处理中不拦截 + if (e.ctrlKey || e.metaKey || e.altKey) return + if (confirmState.visible) return + if (processingId.value) return + // 输入元素聚焦时不拦截(防御:本浮窗无 input,但 ConfirmDialog 未来或有) + const tgt = e.target as HTMLElement | null + if (tgt && (tgt.tagName === 'INPUT' || tgt.tagName === 'TEXTAREA' || tgt.isContentEditable)) return + const first = approvals.value[0] + if (!first) return + e.preventDefault() + if (e.key === '1') { + // 1 = once(path) / approve(risk) + if (first.kind === 'path') void onApproveOnce(first) + else void onApprove(first) + } else if (e.key === '2') { + // 2 = always(仅 path 类;risk 类无 always,忽略) + if (first.kind === 'path') void onApproveAlways(first) + } else { + // 3 = deny(path) / reject(risk) + void onReject(first) + } } } @@ -683,6 +717,24 @@ function onDragEnd() { isDragging.value = false } opacity: 0.6; cursor: not-allowed; } +/* 7c:数字键快捷键标记(1/2/3 对应 once/approve、always、deny/reject)。 + 小尺寸单字符徽章,提示键盘可达性;继承父按钮对比色但降透明度作辅助信息层。 */ +.popup-btn-key { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 13px; + height: 13px; + padding: 0 3px; + margin-left: 1px; + border-radius: var(--df-radius-xs, 3px); + font-family: var(--df-font-mono); + font-size: 9px; + font-weight: 700; + line-height: 1; + background: rgba(0, 0, 0, 0.18); + opacity: 0.7; +} .popup-btn--approve { background: var(--df-accent); color: #fff; diff --git a/src/views/AuditLog.vue b/src/views/AuditLog.vue index 10ce3f9..5b849cd 100644 --- a/src/views/AuditLog.vue +++ b/src/views/AuditLog.vue @@ -92,17 +92,18 @@
- {{ t('auditLog.pager.page', { n: page }) }}{{ hasMore ? '' : t('auditLog.pager.last') }} + {{ t('auditLog.pager.page', { n: page }) }}{{ hasMore ? '' : t('auditLog.pager.last') }}{{ t('auditLog.pager.total', { n: total }) }}
@@ -347,6 +433,19 @@ onMounted(() => { diff --git a/src/views/Projects.vue b/src/views/Projects.vue index 8bf1c20..a52612f 100644 --- a/src/views/Projects.vue +++ b/src/views/Projects.vue @@ -3,6 +3,21 @@