diff --git a/crates/df-storage/src/crud/idea_repo.rs b/crates/df-storage/src/crud/idea_repo.rs index b9f84e4..161e2de 100644 --- a/crates/df-storage/src/crud/idea_repo.rs +++ b/crates/df-storage/src/crud/idea_repo.rs @@ -131,6 +131,53 @@ fn knowledge_event_from_row(row: &Row<'_>) -> std::result::Result, + pub keyword: Option, + pub order_by: Option, + pub limit: Option, + pub offset: Option, +} + +/// `order_by` 白名单校验(防 SQL 注入,对齐 `impl_repo!` 宏 `validate_column_name` 思路)。 +/// +/// 列名直进 SQL 字符串(`ORDER BY {col} DESC`),故必须白名单枚举校验,不接受任意字符串。 +/// 允许的排序列:created_at(默认/新在前)、updated_at(最近更新)、priority(优先级)、 +/// status(状态聚合)、score(评分,后端 NULL 视为 0)。 +fn validate_idea_order_by(col: &str) -> df_types::error::Result<&'static str> { + Ok(match col { + "created_at" => "created_at", + "updated_at" => "updated_at", + "priority" => "priority", + "status" => "status", + "score" => "score", + _ => { + return Err(df_types::error::Error::Storage(format!( + "ideas order_by 不允许的字段名: {col}" + ))) + } + }) +} + impl_repo!( /// 想法表 CRUD IdeaRepo, @@ -193,6 +240,109 @@ impl_repo!( // KnowledgeRepo 的整体更新已由 impl_repo! 宏统一生成的 update_full 提供。 +impl IdeaRepo { + /// 多条件查询:动态 WHERE 拼接(status / keyword) + 白名单排序 + 分页(F-260621-02)。 + /// + /// 复用 `KnowledgeRepo::search` 的动态 WHERE 模式:if-let 分支按可选条件拼 SQL 片段, + /// 各分支化参数绑定到 `?N` 占位符。`order_by` 经 `validate_idea_order_by` 白名单校验后 + /// 拼入(防 SQL 注入),`limit` 钳制上限 200(对齐 `KnowledgeEventsRepo::list_recent`)。 + /// + /// **向后兼容**:空 query(全 None)→ `WHERE` 子句空 + 默认 `created_at DESC`,等价 `list_all`。 + /// + /// `score DESC NULLS LAST`:SQLite NULL 在 ASC 升序最前、DESC 降序最后,但为与前端原 + /// computed 语义一致(`(b.score ?? 0) - (a.score ?? 0)`,NULL 当 0),显式 `COALESCE(score,0)` + /// 把 NULL 当 0 排序,避免 NULL 意外下沉到结果末尾。 + pub async fn list_by_query(&self, q: &IdeaQuery) -> Result> { + // 解析 + 校验 order_by(默认 created_at DESC) + let order_col = match &q.order_by { + Some(o) => validate_idea_order_by(o)?, + None => "created_at", + }; + + let conn = self.conn.clone(); + + // 所有按值移动进闭包的量,提前 clone 避免 move 借用问题 + let status = q.status.clone(); + let keyword = q.keyword.clone(); + // 钳制 limit 上限 200(对齐 list_recent),默认不限制分页(None → 不拼 LIMIT) + let limit_i: Option = q.limit.map(|l| (l.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(); + + // 动态拼 WHERE 子句 + 收集参数(按出现顺序绑定占位符) + let mut where_clauses: Vec = Vec::new(); + let mut params_vec: Vec> = Vec::new(); + + if let Some(s) = &status { + where_clauses.push(format!("status = ?{}", where_clauses.len() + 1)); + params_vec.push(Box::new(s.clone())); + } + if let Some(kw) = &keyword { + let pat = format!("%{kw}%"); + let p1 = where_clauses.len() + 1; + let p2 = p1 + 1; + where_clauses.push(format!("(title LIKE ?{p1} OR description LIKE ?{p2})")); + params_vec.push(Box::new(pat.clone())); + params_vec.push(Box::new(pat)); + } + + let where_sql = if where_clauses.is_empty() { + String::new() + } else { + format!(" WHERE {}", where_clauses.join(" AND ")) + }; + + // 排序:score 走 COALESCE(NULL 当 0,对齐前端 computed 语义);其余直接列名。 + // order_col 来自白名单 &'static str,format! 出来是 String,生命周期随 sql 一起 OK。 + let order_expr = if order_col == "score" { + "COALESCE(score, 0) DESC".to_string() + } else { + format!("{order_col} DESC") + }; + + // LIMIT/OFFSET:limit 为 None → 不拼(全量),offset 仅在 limit 存在时有意义。 + let where_param_count = params_vec.len(); + let limit_sql_bound = match limit_i { + Some(_) => format!( + " LIMIT ?{} OFFSET ?{}", + where_param_count + 1, + where_param_count + 2 + ), + None => String::new(), + }; + + let sql = format!( + "SELECT {IDEA_COLS} FROM ideas{where_sql} ORDER BY {order_expr}{limit_sql_bound}" + ); + + let mut stmt = guard.prepare(&sql).map_err(storage_err)?; + + // 组装参数引用数组(where 参数 + 可选 limit/offset)。 + // limit/offset 也压入 Box 收口:借用引用需指向同一生命周期存活处, + // 收口到 params_vec 后再统一取引用,避免局部 l 生命周期不足(E0597)。 + if let Some(l) = limit_i { + params_vec.push(Box::new(l)); + 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| idea_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)? + } +} + impl KnowledgeRepo { /// 检索知识: title/content LIKE 匹配,可选 kind 过滤,按 reuse_count 降序,top-N /// diff --git a/crates/df-storage/src/crud/project_repo.rs b/crates/df-storage/src/crud/project_repo.rs index d224008..a53908b 100644 --- a/crates/df-storage/src/crud/project_repo.rs +++ b/crates/df-storage/src/crud/project_repo.rs @@ -15,6 +15,62 @@ use crate::models::{ use super::impl_repo; use super::{normalize_stored_path, now_millis_str, storage_err, validate_column_name}; +// ============================================================ +// 项目查询入参(F-260621-02 P2/P3 查询维度补全) +// ============================================================ + +/// 项目列表查询条件(可选字段,全 None = 当前 list_active 全量行为)。 +/// +/// 对齐方案 docs/02-架构设计/专项设计/查询能力补全方案-2026-06-21.md §4.1 统一查询结构。 +/// 项目表无 status 生命周期(P1 跳过),仅软删 deleted_at(由 list_by_query 固定排除)。 +/// 故本结构不含 status,只含 keyword/order_by/limit/offset 四维。 +/// +/// 序列化 derive:命令层经 serde 从前端 IPC 接收(Option 字段缺省 None,旧调用方不传等价全量)。 +#[derive(Debug, Default, Clone, serde::Serialize, serde::Deserialize)] +pub struct ProjectQuery { + /// 关键词:name/description LIKE %kw%(trim 后空字符串视为无关键词) + #[serde(default)] + pub keyword: Option, + /// 排序字段(白名单校验,格式 "col" / "col asc" / "col desc",默认 created_at desc) + #[serde(default)] + pub order_by: Option, + /// 返回上限(钳制 [0,200],None = 不加 LIMIT 全量) + #[serde(default)] + pub limit: Option, + /// 偏移量(None = 0,配合 limit 分页) + #[serde(default)] + pub offset: Option, +} + +/// 解析 order_by 入参为 "col DIR" SQL 片段(列名走白名单校验防注入)。 +/// +/// 接受 "col" / "col asc" / "col desc"(DIR 大小写不敏感)。col 走 `validate_column_name` +/// 校验(列名不可参数化,必须拼字符串,白名单是唯一防注入手段,对齐 impl_repo! 宏)。 +/// 非法列名返回 Err;合法但无 DIR 默认 DESC(与 list_active 一致)。 +fn build_order_clause(order_by: Option<&str>) -> Result { + let Some(raw) = order_by else { + return Ok("created_at DESC".to_string()); + }; + let raw = raw.trim(); + if raw.is_empty() { + return Ok("created_at DESC".to_string()); + } + // 拆 "col [dir]",最多 2 段。 + let parts: Vec<&str> = raw.split_whitespace().collect(); + let col = parts[0]; + let dir = parts.get(1).map(|s| s.to_ascii_uppercase()); + // 白名单校验列名(防 SQL 注入:列名拼字符串前必须校验)。 + validate_column_name(col, "projects")?; + match dir.as_deref() { + None | Some("DESC") => Ok(format!("{col} DESC")), + Some("ASC") => Ok(format!("{col} ASC")), + // 其它非法方向词:拒绝(防 "; DROP TABLE" 之类拼串绕过)。 + Some(other) => Err(df_types::error::Error::Storage(format!( + "非法排序方向: {other}(仅支持 asc/desc)" + ))), + } +} + // ============================================================ // from_row 辅助函数 // ============================================================ @@ -144,6 +200,74 @@ impl ProjectRepo { .map_err(storage_err)? } + /// 按条件查询未删除项目(F-260621-02 P2/P3:关键词搜索 + 排序 + 分页)。 + /// + /// 复用 `KnowledgeRepo::search` 动态 WHERE 拼接模式:按可选字段 if-let 拼 SQL 子句 + + /// 分支化参数绑定。始终排除 `deleted_at IS NOT NULL`(软删),与 `list_active` 行为对齐。 + /// + /// - `keyword`:name/description `LIKE %kw%`(对齐知识库 title/content LIKE)。 + /// - `order_by`:走白名单 `validate_column_name` 防 SQL 注入(列名不可参数化,必须拼字符串, + /// 白名单是唯一防注入手段)。默认 `created_at DESC`(与 list_active 等价)。 + /// - `limit`:钳制上限 200(对齐 `KnowledgeEventsRepo::list_recent`),防恶意/失误传超大值。 + /// - `offset`:偏移量,配合 limit 做分页。 + /// + /// 向后兼容:全字段 None 时 SQL 退化为 + /// `SELECT ... WHERE deleted_at IS NULL ORDER BY created_at DESC` + /// 与 `list_active` 完全等价,零破坏。 + pub async fn list_by_query(&self, q: ProjectQuery) -> Result> { + // order_by 白名单校验(列名拼字符串前必须校验,防注入)。 + // 校验后再拼方向:允许 "col" / "col asc" / "col desc"(大小写不敏感)。 + let order_clause = build_order_clause(q.order_by.as_deref())?; + + let conn = self.conn.clone(); + // limit 钳制:None 不加 LIMIT(全量,对齐 list_active);Some 钳到 [0,200]。 + let limit_i: Option = q.limit.map(|n| n.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(); + // 动态拼 WHERE + 参数。WHERE 1=1 起点 + deleted_at IS NULL 固定基础条件, + // 后续可选条件 AND 拼接(对齐 KnowledgeRepo::search 的 if-let 分支模式)。 + let mut sql = String::from( + "SELECT id, name, description, status, idea_id, path, stack, created_at, updated_at \ + FROM projects WHERE deleted_at IS NULL", + ); + // keyword 用 ?N 占位两次(name LIKE ? AND description LIKE ? 同 pattern), + // 对齐 KnowledgeRepo::search 的 `params![pattern, pattern, ...]`。 + let mut params_vec: Vec> = Vec::new(); + if let Some(kw) = &q.keyword { + let trimmed = kw.trim(); + if !trimmed.is_empty() { + sql.push_str(" AND (name LIKE ? OR description LIKE ?)"); + let pattern = format!("%{trimmed}%"); + params_vec.push(Box::new(pattern.clone())); + params_vec.push(Box::new(pattern)); + } + } + sql.push_str(" ORDER BY "); + sql.push_str(&order_clause); + // 分页:有 limit 才加 LIMIT/OFFSET(全量场景不加,等价 list_active)。 + if let Some(lim) = limit_i { + sql.push_str(" LIMIT ? OFFSET ?"); + params_vec.push(Box::new(lim)); + params_vec.push(Box::new(offset_i)); + } + let mut stmt = guard.prepare(&sql).map_err(storage_err)?; + 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| project_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)? + } + /// 软删:标记 deleted_at(进回收站,可恢复)。仅作用于未删项目,返回是否命中。 pub async fn soft_delete(&self, id: &str) -> Result { let conn = self.conn.clone(); diff --git a/crates/df-storage/src/crud/task_repo.rs b/crates/df-storage/src/crud/task_repo.rs index cca324f..70111a7 100644 --- a/crates/df-storage/src/crud/task_repo.rs +++ b/crates/df-storage/src/crud/task_repo.rs @@ -3,6 +3,7 @@ use std::sync::Arc; use rusqlite::{params, Connection, OptionalExtension, Row}; +use serde::{Deserialize, Serialize}; use tokio::sync::Mutex; use df_types::error::Result; @@ -37,6 +38,59 @@ fn task_from_row(row: &Row<'_>) -> std::result::Result, + /// 状态过滤(P1 下沉,命中 idx_tasks_status)。值集由上层 TaskStatus::is_valid 兜底校验。 + pub status: Option, + /// 优先级过滤(0=critical..3=low)。P3 基建就绪,视图暂不用。 + pub priority: Option, + /// 负责人过滤。P3 基建就绪,视图暂不用。 + pub assignee: Option, + /// 关键词搜索(P2):title/description LIKE %kw%,对齐知识库 search 的 LIKE 模式。 + pub keyword: Option, + /// 排序字段(白名单 created_at/updated_at/priority/status,降序)。P3 基建就绪。 + pub order_by: Option, + /// 分页上限(钳制 ≤500)。P3 基建就绪。 + pub limit: Option, + /// 分页偏移。P3 基建就绪。 + pub offset: Option, +} + +/// order_by 白名单:只允许这些列名拼进 SQL(防注入,列名不可参数化只能白名单)。 +/// 对齐 impl_repo! 宏 validate_column_name 的「白名单先于拼接校验」防注入思路。 +/// 注:created_at 是默认值(白名单含它,query 未指定时用 created_at DESC)。 +const TASK_ORDER_BY_WHITELIST: &[&str] = &["created_at", "updated_at", "priority", "status"]; + +/// 校验 order_by 列名在白名单内,否则返回 Err(防 SQL 注入)。 +fn validate_order_by(field: &str) -> Result<()> { + if TASK_ORDER_BY_WHITELIST.contains(&field) { + Ok(()) + } else { + Err(df_types::error::Error::Storage(format!( + "非法 order_by 字段名: {},合法值: {:?}", + field, TASK_ORDER_BY_WHITELIST + ))) + } +} + // ============================================================ // Repo 实现 // ============================================================ @@ -218,6 +272,127 @@ impl TaskRepo { .map_err(storage_err)? } + /// 动态条件列出未删除任务(F-260621-02 查询维度补全)。 + /// + /// 复用 KnowledgeRepo::search 的「动态 WHERE + 参数绑定」模式,但用累积式条件收集 + /// (Vec WHERE 子句 + Vec 参数)替代 if-let 二分支—— + /// TaskQuery 有 4 个过滤维度(project_id/status/priority/assignee/keyword),2^n 分支不可行, + /// 累积式天然支持任意维度组合,且每个 if-let 分支只 push 子句+参数,新增维度零样板。 + /// + /// - 过滤维度:project_id / status / priority / assignee(精确等值)+ keyword(title/description LIKE) + /// - keyword 拼成 `(title LIKE ?N OR description LIKE ?M)`,pattern = `%kw%`(对齐知识库 search) + /// - order_by 白名单(validate_order_by 防 SQL 注入),默认 created_at,恒 DESC(与 list_active 一致) + /// - limit/offset 钳制(limit ≤500 防滥用,对齐 conversation_repo::list_recent 的 limit≤200 思路) + /// - deleted_at IS NULL 恒带(回收站任务不进结果,语义同 list_active,不可被 query 关闭) + /// - 显式列出全部 15 列(不 SELECT deleted_at:TaskRecord 不带该字段,取了 from_row 报未知列) + /// + /// 空 query(全 None)→ 等价 list_active(全量未删,created_at DESC),向后兼容。 + /// status 值合法性由上层 list_tasks 命令(TaskStatus::is_valid)兜底,本层不过滤值集 + /// (非法 status 在 DB 无匹配行,返回空 Vec,无害)。 + pub async fn list_by_query(&self, query: &TaskQuery) -> Result> { + let conn = self.conn.clone(); + + // order_by 白名单校验在闭包外做(提前 fail-fast,非法值不进 DB 层)。列名不可 + // 参数化,只能白名单防注入(对齐 impl_repo! 宏 validate_column_name 思路)。 + // order_col 转 String 拥有所有权:避免 &str 借用 query 跨 spawn_blocking 'static + // 闭包(E0521 borrowed data escapes)。 + let order_col = query.order_by.as_deref().unwrap_or("created_at"); + validate_order_by(order_col)?; + let order_col = order_col.to_string(); + + // limit 钳制上限 500 防滥用(对齐 conversation_repo::list_recent 的 limit.min(200) + // 思路,任务场景放宽;None → 不拼 LIMIT = 全量,语义同 list_active)。 + let limit_i: Option = query.limit.map(|l| (l.min(500)) as i64); + let offset_i: i64 = query.offset.unwrap_or(0) as i64; + + // 拷贝 query 字段进闭包('static 生命周期,spawn_blocking 要求) + let project_id = query.project_id.clone(); + let status = query.status.clone(); + let priority = query.priority; + let assignee = query.assignee.clone(); + let keyword = query.keyword.clone(); + + tokio::task::spawn_blocking(move || { + let guard = conn.blocking_lock(); + + // ── 累积 WHERE 子句 + 收集参数(按出现顺序绑定占位符 ?N,序号 = 已有子句数+1)── + // deleted_at IS NULL 恒带(常量条件无占位符),回收站任务不进结果(语义同 list_active)。 + let mut where_clauses: Vec = vec!["deleted_at IS NULL".to_string()]; + let mut params_vec: Vec> = Vec::new(); + + if let Some(pid) = &project_id { + where_clauses.push(format!("project_id = ?{}", where_clauses.len() + 1)); + params_vec.push(Box::new(pid.clone())); + } + if let Some(s) = &status { + where_clauses.push(format!("status = ?{}", where_clauses.len() + 1)); + params_vec.push(Box::new(s.clone())); + } + if let Some(p) = priority { + where_clauses.push(format!("priority = ?{}", where_clauses.len() + 1)); + params_vec.push(Box::new(p)); + } + if let Some(a) = &assignee { + where_clauses.push(format!("assignee = ?{}", where_clauses.len() + 1)); + params_vec.push(Box::new(a.clone())); + } + // keyword: title/description LIKE %kw%(P2,对齐知识库 search 的 LIKE 模式) + if let Some(kw) = &keyword { + let pat = format!("%{kw}%"); + let p1 = where_clauses.len() + 1; + let p2 = p1 + 1; + where_clauses.push(format!("(title LIKE ?{p1} OR description LIKE ?{p2})")); + params_vec.push(Box::new(pat.clone())); + params_vec.push(Box::new(pat)); + } + + // LIMIT/OFFSET:limit 为 None → 不拼(全量);有 limit 时 offset 紧跟其后。 + let where_param_count = params_vec.len(); + let limit_sql_bound = match limit_i { + Some(_) => format!( + " LIMIT ?{} OFFSET ?{}", + where_param_count + 1, + where_param_count + 2 + ), + None => String::new(), + }; + + // 拼 SQL:?N 占位符序号与 params_vec 顺序严格对应(累积时按 +1 递增保证)。 + let sql = format!( + "SELECT id, project_id, title, description, status, priority, branch_name, \ + assignee, workflow_def_id, base_branch, review_rounds, output_json, idea_id, \ + created_at, updated_at FROM tasks WHERE {} ORDER BY {} DESC{}", + where_clauses.join(" AND "), + order_col, + limit_sql_bound + ); + + let mut stmt = guard.prepare(&sql).map_err(storage_err)?; + + // 组装参数引用数组(where 参数 + 可选 limit/offset)。 + // limit/offset 也压入 params_vec 收口:借用引用需指向同一生命周期存活处, + // 收口到 params_vec 后再统一取引用,避免局部 l 生命周期不足(E0597, + // 对齐 idea_repo.rs list_by_query 同名模式)。 + if let Some(l) = limit_i { + params_vec.push(Box::new(l)); + 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| task_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)? + } + /// 列出回收站(deleted_at IS NOT NULL),按更新时间(≈删除时间)降序。对标 ProjectRepo::list_deleted。 /// /// 注:按项目列活跃任务走 list_active_by_project(SQL 下推 project_id), diff --git a/docs/02-架构设计/专项设计/查询能力补全方案-2026-06-21.md b/docs/02-架构设计/专项设计/查询能力补全方案-2026-06-21.md new file mode 100644 index 0000000..78484b0 --- /dev/null +++ b/docs/02-架构设计/专项设计/查询能力补全方案-2026-06-21.md @@ -0,0 +1,114 @@ +# F-260621-02 查询能力维度补全方案(任务/项目/灵感) + +> **状态**:方案设计(待办登记),待排期实施 +> **日期**:2026-06-21 +> **范围**:DevFlow 任务/项目/灵感三实体**列表查询维度**补全 +> **关联**:[PERF-260619-01 查询效率优化方案](./查询效率优化方案-2026-06-19.md)(查询**性能**,与本方案正交) / todo.md F-15-03 分页(本方案细化) +> **本轮排查结论**:本轮仅登记待办,不实施代码。 + +--- + +## 一、现状盘点 + +### 1.1 详情查询(已齐全,无需动) + +| 实体 | 详情接口 | 位置 | +|------|---------|------| +| 任务 | `get_task_by_id` | `src-tauri/src/commands/task.rs:58` | +| 项目 | `get_project(id)` | `src-tauri/src/commands/project.rs:193` | +| 灵感 | `get_idea`(宏生成) | `src-tauri/src/commands/idea.rs` | + +均按主键查,返回 `Option`/`Record`。这部分能力完整。 + +### 1.2 列表查询(本方案重点,维度薄弱) + +| 实体 | 后端 WHERE 维度 | 前端筛选 | 分页 | 搜索 | 排序 | +|------|----------------|---------|------|------|------| +| 任务 | `project_id`(仅此 1 维,`task.rs:41`) | status 内存 filter | ❌ | ❌ | 固定 created_at DESC | +| 项目 | 仅软删(`deleted_at`) | ❌ 无 UI | ❌ | ❌ | 固定 created_at DESC | +| 灵感 | `status`(宏 `query`,单字段) | status+关键词+排序**全前端** | ❌ | ❌(后端) | 前端 computed | +| 知识库 | status+kind+LIKE+向量 | — | ✅ limit | ✅ LIKE+向量 | ✅ 多维 | + +**核心问题**:任务/项目/灵感的筛选全堆在**前端内存**(`Tasks.vue:177` `.filter()` / `Ideas.vue:184` computed),后端只给"按 project_id 拉"或"全量拉"。数据量增长后线性退化,且无法做关键词/多条件。 + +> 注:AI agent 用的 `list_tasks` 工具(`tool_registry.rs`)另有 status 内存过滤,但那是给 LLM 调的,非 UI 查询链路。 + +--- + +## 二、缺口清单 + +1. **status 未下沉后端**:任务/灵感的 status 过滤是前端 `.filter()`,后端 `list_tasks`/`list_ideas` 未支持 status 维度 SQL WHERE。 +2. **无关键词搜索**:三实体均无 title/description 文本检索。 +3. **无排序选项**:全固定 `created_at DESC`,无按优先级/状态/更新时间排序。 +4. **无分页**:全量返回,无 LIMIT/OFFSET。数据量大时撑爆前端内存 + IPC。 +5. **索引缺口**:tasks 缺 `idx_tasks_priority`/`idx_tasks_assignee`(已有 `idx_tasks_status`/`idx_tasks_project_id`,`migrations.rs:763-764`);projects/ideas 表无任何显式索引(仅主键)。 + +--- + +## 三、可复用基建(非从零造) + +知识库模块(`KnowledgeRepo`)是唯一查询能力完善的实体,其模式可直接推广: + +| 模式 | 位置 | 复用点 | +|------|------|--------| +| **动态 WHERE 拼接** | `crates/df-storage/src/crud/idea_repo.rs:200` `KnowledgeRepo::search` | if-let 分支按可选条件拼 SQL + 分支化参数绑定 | +| **分页 + 上限钳制** | `crates/df-storage/src/crud/conversation_repo.rs:270` `list_recent` | `LIMIT ?1 OFFSET ?2` + `limit.min(200)` 防滥用 | +| **统一查询入口** | `crates/df-storage/src/crud/mod.rs` `impl_repo!` 宏 `query(field,value)` | 单字段白名单动态查询,可扩展为多条件 | + +SQL 库为 `rusqlite`,`?N` 占位符 + `params![]` 宏绑定参数。 + +--- + +## 四、设计方向 + +### 4.1 统一查询结构(对齐 [[no-patch-groundwork]],根本设计非补丁堆砌) + +三实体各引入可选字段的 `XxxQuery` struct,而非逐个加 IPC 参数: + +```rust +// 示意:TaskQuery(项目/灵感同构) +pub struct TaskQuery { + pub project_id: Option, + pub status: Option, + pub priority: Option, + pub assignee: Option, + pub keyword: Option, // title/description LIKE %kw% + pub order_by: Option, // created_at/updated_at/priority/status + pub limit: Option, // 钳制上限(对齐 list_recent) + pub offset: Option, +} +``` + +### 4.2 repo 层:`list_by_query` 动态 WHERE + +复用 `KnowledgeRepo::search` 模式:动态拼 `WHERE` 子句 + 参数绑定。`order_by` 走白名单(防 SQL 注入,对齐 `impl_repo!` 宏的 `validate_column_name`)。 + +### 4.3 命令层:`list_xxx` 吃 query,向后兼容 + +`list_tasks(query: Option)`。旧调用方不传(或传空 query)→ 等价全量(当前行为),零破坏。新调用方按需传筛选。 + +### 4.4 前端对接 + +- `api/{task,project,idea}.ts`:暴露 query 参数 +- `stores/project/*.ts`:`loadXxx(query)` 透传 +- 视图:现有前端内存 filter 改为构造 query 调后端;补 UI 控件(优先级筛选/搜索框;分页器待数据量大再加) + +--- + +## 五、实施分阶段 + +| 阶段 | 维度 | 优先级 | 说明 | +|------|------|--------|------| +| P1 | status 下沉后端 | 中 | 任务 + 灵感 status 过滤从前端 filter 改后端 WHERE(已有 `idx_tasks_status` 索引) | +| P2 | 关键词搜索(LIKE) | 中 | title/description `LIKE %kw%`,对齐知识库。加搜索框 UI。不上 FTS5(需虚拟表 + 触发器同步,过度) | +| P3 | 排序 + 分页 | 低 | 排序选项(优先级/状态/更新时间)+ limit 钳制防爆。UI 分页器待数据量 > 百级再加 | + +> 数据量:当前 ~6 项目 ~15 任务,性能无感。P1/P2 是能力补全,P3 性能驱动——数据量上来才有必要,与 PERF-260619-01 性能方案协同。 + +--- + +## 六、关联文档 + +- [PERF-260619-01 查询效率优化方案](./查询效率优化方案-2026-06-19.md)——查询**性能**(全量加载/无缓存/无字段投影),与本方案(查询**维度**)正交,实施时协同改 `list_xxx`。 +- todo.md `F-15-03 分页`——本方案 P3 细化。 +- todo.md `BUG-260621-01`——run_workflow 空 dag 缺陷(本轮同批登记,与本查询方案独立)。 diff --git a/docs/todo.md b/docs/todo.md index ce0f0a6..5fbca3f 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -50,6 +50,16 @@ --- +### 🔍 2026-06-21 查询能力缺口 + run_workflow 缺陷(本轮排查登记·未实施) + +> 用户排查任务/项目/灵感查询能力现状,登记缺口为待办/缺陷,本轮不实施代码。详单见 [查询能力补全方案-2026-06-21.md](./02-架构设计/专项设计/查询能力补全方案-2026-06-21.md)。 + +- [ ] BUG-260621-01 [P1🔴·确定修法] — **run_workflow 空 dag 反序列化失败(missing field nodes),阻塞任务工作流推进**。前端 TaskDetail.vue:333 传 `{}` 空 dag(设计意图:空 dag+target_status→template_for 选模板),但 DagDef.nodes/edges(dag_def.rs:9-10)无 #[serde(default)],serde 反序列化挡在选模板逻辑(workflow.rs:91)之前。修法(2 行):nodes/edges 各加 #[serde(default)]。非空 dag 零回归,空 dag 走选模板分支符合设计。— `crates/df-workflow/src/dag_def.rs`(:9-10) + `src/views/TaskDetail.vue`(:333) + +- [x] F-260621-02 [P2] — **任务/项目/灵感列表查询维度补全(下沉后端+关键词搜索+排序分页)** ✅ **2026-06-22 实施**:三实体新增 `list_by_query` 动态 WHERE(累积式 where_clauses+params_vec 收口)+ order_by 白名单防注入 + limit/offset 钳制;命令层 `list_{tasks,projects,ideas}` 吃 `Option` 双参向后兼容(旧无参/单参路径等价全量);前端 Tasks/Ideas status/keyword 筛选下沉后端 query(Tasks.vue buildTaskQuery / Ideas.vue watch 防抖)。索引缺口(idx_tasks_priority/assignee)与 P3 排序分页 UI 待数据量驱动再加。详见查询能力补全方案-2026-06-21.md。— `commands/{task,project,idea}.rs` + `crates/df-storage/src/crud/{task,project,idea}_repo.rs` + `src/api/{task,project,idea}.ts` + `src/views/{Tasks,Projects,Ideas}.vue` + +--- + ### 📋 编排推进总览(2026-06-18 更新) 未完成待办按可执行性分 8 组(详细条目见下方各分类,勿重复记录): diff --git a/src-tauri/src/commands/idea.rs b/src-tauri/src/commands/idea.rs index 40ff37c..1040966 100644 --- a/src-tauri/src/commands/idea.rs +++ b/src-tauri/src/commands/idea.rs @@ -8,7 +8,7 @@ use tauri::State; use df_ai::provider::LlmProvider; use df_types::types::{new_id, Priority}; use df_ideas::capture::Idea; -use df_storage::crud::is_unique_constraint_err; +use df_storage::crud::{is_unique_constraint_err, IdeaQuery}; use df_storage::models::{IdeaEvaluationRecord, IdeaRecord, ProjectRecord}; use crate::state::AppState; @@ -32,16 +32,29 @@ fn default_priority() -> i32 { 1 } -/// 列出灵感,可选按 status 过滤(指定状态走 query 走白名单索引列,否则全量) +/// 列出灵感。 +/// +/// **双路径向后兼容**(F-260621-02): +/// - 旧调用方仅传 `status`(`ideaApi.list(status)`)→ 转 IdeaQuery 仅带 status,走 +/// `list_by_query`(白名单 status 列 WHERE),与原 `query("status", s)` 等价。 +/// - 新调用方传 `query`(`ideaApi.list(query)`)→ 多条件(status/keyword/order_by/limit/offset)。 +/// - 两者都不传 → 等价全量(`list_by_query` 空 query 走默认 created_at DESC,与 list_all 等价)。 +/// +/// `query` 优先于 `status`(二者同传时以 query 为准,避免重复过滤语义冲突)。 #[tauri::command] pub async fn list_ideas( state: State<'_, AppState>, status: Option, + query: Option, ) -> Result, String> { - match status { - Some(s) => state.ideas.query("status", &s).await.map_err(err_str), - None => state.ideas.list_all().await.map_err(err_str), - } + let q = match query { + Some(q) => q, + None => IdeaQuery { + status, + ..Default::default() + }, + }; + state.ideas.list_by_query(&q).await.map_err(err_str) } /// 列出指定灵感的评估历史(version DESC,最新版本在前)。 diff --git a/src-tauri/src/commands/project.rs b/src-tauri/src/commands/project.rs index fd69366..71303d7 100644 --- a/src-tauri/src/commands/project.rs +++ b/src-tauri/src/commands/project.rs @@ -17,6 +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::models::ProjectRecord; use crate::state::AppState; @@ -38,10 +39,27 @@ pub struct CreateProjectInput { pub stack: Option, } -/// 列出未删除项目(过滤回收站) +/// 列出未删除项目(过滤回收站)。 +/// +/// F-260621-02:吃可选 query(关键词/排序/分页),向后兼容——不传(或 None)走 list_active +/// 全量(deleted_at IS NULL + created_at DESC),零破坏;传 query 走 list_by_query 动态 WHERE。 #[tauri::command] -pub async fn list_projects(state: State<'_, AppState>) -> Result, String> { - state.projects.list_active().await.map_err(err_str) +pub async fn list_projects( + state: State<'_, AppState>, + query: Option, +) -> Result, String> { + match query { + // None 或全空 query(trim 后 keyword 空 + 无 order_by/limit/offset)→ 走 list_active, + // 与历史行为完全等价(列表上 list_by_query 空也等价,但保留 list_active 分支明示向后兼容契约)。 + 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) + } + _ => state.projects.list_active().await.map_err(err_str), + } } /// 创建项目,返回完整记录 diff --git a/src-tauri/src/commands/task.rs b/src-tauri/src/commands/task.rs index 5001778..ac3d706 100644 --- a/src-tauri/src/commands/task.rs +++ b/src-tauri/src/commands/task.rs @@ -4,6 +4,7 @@ use serde::Deserialize; use tauri::State; use df_types::types::{new_id, TaskStatus}; +use df_storage::crud::TaskQuery; use df_storage::models::TaskRecord; use crate::state::AppState; @@ -30,18 +31,41 @@ fn default_priority() -> i32 { 2 // medium — 新任务默认中优先级(非 high),符合常识 } -/// 列出未删除任务(deleted_at IS NULL),可按 project_id 过滤。 +/// 列出未删除任务(deleted_at IS NULL)。 /// -/// 软删对标 projects:默认过滤回收站(语义与 list_projects 一致)。 -/// 有 project_id 时走 list_active_by_project(SQL 下推 WHERE deleted_at IS NULL AND -/// project_id = ?,避免旧 list_active 全表扫 + 内存 retain 的 N×M 热点); -/// 无 project_id 时 fallback list_active(列全部未删任务)。 -/// 注意:不能用通用 query 宏——它不带 deleted_at 过滤,会把回收站任务也返回。 +/// **向后兼容铁律**:`project_id` 与 `query` 两参都可选,旧调用方不传(或只传 project_id) +/// 必须等价改造前的全量行为,零破坏。 +/// +/// F-260621-02 查询维度补全(机制优先 prompt 说教): +/// - 优先走 `query`(`TaskQuery` 多维动态 WHERE):status(P1 下沉)/ keyword(P2 LIKE) +/// /project_id/priority/assignee/order_by/limit/offset 任意组合。 +/// - 旧调用方仍可直接传 `project_id`(单维度),此时走 list_active_by_project +/// (SQL 下推,命中 idx_tasks_project_id),保持等价行为。 +/// - query 与 project_id 同时传时:query 优先(其内含 project_id 维度,更全),project_id 忽略。 +/// - 均不传时:全量未删任务(等价改造前 list_active 行为)。 +/// +/// status 值合法性兜底:TaskStatus::is_valid 拦截非法值(拼写错/越界),非法值返回 Err +/// (与 update_task 的 status 校验一致),不静默返回空结果误导调用方。 #[tauri::command] pub async fn list_tasks( state: State<'_, AppState>, project_id: Option, + query: Option, ) -> Result, String> { + if let Some(q) = &query { + // status 值兜底校验(非法值早 Err,不进 DB 层) + if let Some(status) = &q.status { + if !TaskStatus::is_valid(status) { + return Err(format!( + "非法 status 值 {:?},合法值: {:?}", + status, + TaskStatus::valid_values() + )); + } + } + return state.tasks.list_by_query(q).await.map_err(err_str); + } + // 旧调用方:单维度 project_id(SQL 下推,fallback 等价改造前行为) let tasks = match project_id { Some(pid) => state .tasks diff --git a/src/api/idea.ts b/src/api/idea.ts index 38350d7..4392694 100644 --- a/src/api/idea.ts +++ b/src/api/idea.ts @@ -1,10 +1,21 @@ import { invoke } from '@tauri-apps/api/core' -import type { IdeaRecord, CreateIdeaInput, PromotionResult, IdeaEvaluationRecord } from './types' +import type { IdeaRecord, CreateIdeaInput, PromotionResult, IdeaEvaluationRecord, IdeaQuery } from './types' export const ideaApi = { - /** 列出灵感,可选按 status 过滤(draft/pending_review/promoted 等) */ - list(status?: string): Promise { - return invoke('list_ideas', { status: status ?? null }) + /** + * 列出灵感。 + * + * **双签名向后兼容**(F-260621-02): + * - `list(status?)`:旧签名,仅状态过滤(等价 `list({ status })`)。 + * - `list(query?)`:新签名,多条件(status/keyword/order_by/limit/offset)走后端 WHERE。 + * + * 参数为空 → 后端返回全量(created_at DESC,与旧 list_all 等价)。 + */ + list(queryOrStatus?: IdeaQuery | string): Promise { + if (typeof queryOrStatus === 'string') { + return invoke('list_ideas', { status: queryOrStatus ?? null, query: null }) + } + return invoke('list_ideas', { status: null, query: queryOrStatus ?? null }) }, create(input: CreateIdeaInput): Promise { diff --git a/src/api/project.ts b/src/api/project.ts index b217b37..ad60c79 100644 --- a/src/api/project.ts +++ b/src/api/project.ts @@ -46,9 +46,29 @@ export interface ImportBatchResult { items: ImportBatchItemResult[] } +/** + * 项目列表查询条件(F-260621-02 P2/P3)。 + * 全可选;不传(或全 undefined)= 当前全量行为(deleted_at IS NULL + created_at DESC)。 + * 对齐后端 df_storage::crud::ProjectQuery。 + */ +export interface ProjectQuery { + /** 关键词:name/description LIKE %kw%(trim 后空字符串视为无关键词) */ + keyword?: string + /** 排序:格式 "col" / "col asc" / "col desc",默认 created_at desc(列名白名单) */ + order_by?: string + /** 返回上限(后端钳制 [0,200],undefined = 不加 LIMIT 全量) */ + limit?: number + /** 偏移量(undefined = 0,配合 limit 分页) */ + offset?: number +} + export const projectApi = { - list(): Promise { - return invoke('list_projects') + /** + * 列出未删除项目。 + * F-260621-02:可选 query(关键词/排序/分页)。不传 = 当前全量行为(向后兼容)。 + */ + list(query?: ProjectQuery): Promise { + return invoke('list_projects', { query }) }, create(input: CreateProjectInput): Promise { diff --git a/src/api/task.ts b/src/api/task.ts index 9bf7a8a..7932225 100644 --- a/src/api/task.ts +++ b/src/api/task.ts @@ -1,9 +1,21 @@ import { invoke } from '@tauri-apps/api/core' -import type { TaskRecord, CreateTaskInput } from './types' +import type { TaskRecord, CreateTaskInput, TaskQuery } from './types' export const taskApi = { - list(projectId?: string): Promise { - return invoke('list_tasks', { projectId: projectId ?? null }) + /** + * 列出任务。 + * + * **双签名向后兼容**(F-260621-02): + * - `list(projectId?)`:旧签名,仅项目过滤(等价 `list({ project_id: projectId })`)。 + * - `list(query?)`:新签名,多条件(status/keyword/project_id/priority/assignee/...)走后端 WHERE。 + * + * 参数为空 → 后端返回全量未删任务(created_at DESC,等价改造前 list_active)。 + */ + list(queryOrProjectId?: TaskQuery | string): Promise { + if (typeof queryOrProjectId === 'string') { + return invoke('list_tasks', { projectId: queryOrProjectId ?? null, query: null }) + } + return invoke('list_tasks', { projectId: null, query: queryOrProjectId ?? null }) }, get(id: string): Promise { diff --git a/src/api/types.ts b/src/api/types.ts index af93ff5..0a9e998 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -34,6 +34,23 @@ export interface CreateIdeaInput { source?: string } +/** + * 灵感多条件查询入参(F-260621-02,对齐后端 IdeaQuery)。 + * + * 所有字段可选;全空 → 后端返回全量(created_at DESC)。 + * - `status`:状态精确匹配(后端 WHERE,取代前端 filter) + * - `keyword`:title/description LIKE %kw%(后端 LIKE,取代前端 filter) + * - `order_by`:白名单 created_at/updated_at/priority/status/score(后端白名单校验防注入) + * - `limit`/`offset`:分页(limit 钳制上限 200) + */ +export interface IdeaQuery { + status?: string | null + keyword?: string | null + order_by?: string | null + limit?: number | null + offset?: number | null +} + /** * 灵感评估历史记录(后端 list_idea_evaluations 返回)。 * 每次评估(启发式/LLM/降级)落一行,version 递增,按 version DESC 返回。 @@ -152,6 +169,32 @@ export interface CreateTaskInput { idea_id?: string } +/** + * 任务多条件查询入参(F-260621-02,对齐后端 TaskQuery)。 + * + * 所有字段可选;全空 → 后端返回全量未删任务(created_at DESC,等价改造前 list_active)。 + * - `project_id`:项目精确匹配(命中 idx_tasks_project_id) + * - `status`:状态精确匹配(P1 下沉,命中 idx_tasks_status,取代前端 filter) + * - `priority`:优先级精确匹配(0=critical..3=low) + * - `assignee`:负责人精确匹配 + * - `keyword`:title/description LIKE %kw%(P2,取代前端 filter) + * - `order_by`:白名单 created_at/updated_at/priority/status(后端白名单校验防注入),恒 DESC + * - `limit`/`offset`:分页(limit 钳制上限 500) + * + * 注:project_id/priority/assignee/order_by/limit/offset 为基建就绪,当前视图仅用 + * status(P1)+keyword(P2);P3 排序分页 UI 待数据量增长。 + */ +export interface TaskQuery { + project_id?: string | null + status?: string | null + priority?: number | null + assignee?: string | null + keyword?: string | null + order_by?: string | null + limit?: number | null + offset?: number | null +} + // ============================================================ // 工作流 // ============================================================ diff --git a/src/stores/project.ts b/src/stores/project.ts index 2f23c0c..f6bdf4c 100644 --- a/src/stores/project.ts +++ b/src/stores/project.ts @@ -5,7 +5,7 @@ import { createTasksStore } from './project/tasks' import { createIdeasStore } from './project/ideas' import { createWorkflowStore } from './project/workflow' import { state, clearError } from './project/state' -import type { DfDataChangedPayload } from '@/api/types' +import type { DfDataChangedPayload, TaskQuery } from '@/api/types' // ── barrel 组合器(零行为变更,纯重构) ── // @@ -34,10 +34,26 @@ function createStore() { // - 'all' : 全量拉取 // - 具体 id : 按项目拉取 // 由 Tasks.vue 经 setActiveTaskProject 在 onMounted/watch 更新本变量。 + // + // F-260621-02(P1 status 下沉):新增 _activeTaskStatus 追踪当前 status 筛选,数据变更 + // 联动刷新时一并下沉到后端 query(保 B-29「store.tasks 反映当前筛选」契约, + // 避免刷新把已选 status 冲掉)。 let _activeTaskProject: string | undefined = undefined + let _activeTaskStatus: string | undefined = undefined function setActiveTaskProject(projectKey: string | undefined) { _activeTaskProject = projectKey } + function setActiveTaskStatus(statusKey: string | undefined) { + _activeTaskStatus = statusKey + } + // 构造当前 task 视图筛选对应的 query(供数据变更 listener 复用同一筛选拉取) + function buildActiveTaskQuery(): TaskQuery | undefined { + if (_activeTaskProject === undefined) return undefined // 视图未挂载,不触发 + const query: TaskQuery = {} + if (_activeTaskProject !== 'all') query.project_id = _activeTaskProject + if (_activeTaskStatus && _activeTaskStatus !== 'all') query.status = _activeTaskStatus + return query + } let _dataChangedUnlisten: (() => void) | null = null @@ -49,9 +65,11 @@ function createStore() { if (entity === 'project') { void projectsStore.loadProjects() } else if (entity === 'task') { - // 无筛选状态(undefined)时不触发,保 B-29 筛选契约;有筛选则按当前 activeProject 拉 - if (_activeTaskProject !== undefined) { - void tasksStore.loadTasks(_activeTaskProject === 'all' ? undefined : _activeTaskProject) + // 无筛选状态(_activeTaskProject undefined)时不触发,保 B-29 筛选契约; + // 有筛选则按当前 activeProject + activeStatus 构造 query 下沉后端拉取 + const q = buildActiveTaskQuery() + if (q !== undefined) { + void tasksStore.loadTasks(q) } } else if (entity === 'idea') { void ideasStore.loadIdeas() @@ -126,6 +144,8 @@ function createStore() { stopDataChangedListener, // CR-260615-26(R-1): Tasks 视图登记当前 activeProject 筛选,供 listener 按筛选拉取 setActiveTaskProject, + // F-260621-02(P1 status 下沉):Tasks 视图登记当前 status 筛选,供 listener 复用同一筛选拉取 + setActiveTaskStatus, pendingApproval: computed(() => state.pendingApproval), // computed stats, diff --git a/src/stores/project/ideas.ts b/src/stores/project/ideas.ts index a375a61..be8ace6 100644 --- a/src/stores/project/ideas.ts +++ b/src/stores/project/ideas.ts @@ -1,12 +1,20 @@ import { ideaApi } from '@/api' +import type { IdeaQuery } from '@/api/types' import { t } from '@/i18n/i18n-helpers' import { state } from './state' /** 灵感 CRUD 子 store(共享全局 state) */ export function createIdeasStore() { - async function loadIdeas() { + /** + * 加载灵感列表。 + * + * F-260621-02:status/keyword/order_by 下沉后端 WHERE,取代前端 computed filter。 + * - 传 `query` → 多条件查询(状态/关键词/排序/分页)。 + * - 不传 → 全量(created_at DESC,等价旧行为)。 + */ + async function loadIdeas(query?: IdeaQuery) { try { - state.ideas = await ideaApi.list() + state.ideas = await ideaApi.list(query) } catch (e: any) { state.error = e?.toString() ?? t('ideas.err.loadFailed') } diff --git a/src/stores/project/projects.ts b/src/stores/project/projects.ts index 27462cf..5cf64d4 100644 --- a/src/stores/project/projects.ts +++ b/src/stores/project/projects.ts @@ -1,15 +1,19 @@ import { projectApi } from '@/api' -import type { ImportProjectInput } from '@/api/project' +import type { ImportProjectInput, ProjectQuery } from '@/api/project' import { t } from '@/i18n/i18n-helpers' import { state, clearError } from './state' /** 项目 CRUD 子 store(共享全局 state) */ export function createProjectsStore() { - async function loadProjects() { + /** + * 加载未删除项目。 + * F-260621-02:可选 query 透传(关键词/排序/分页)。不传 = 当前全量行为(向后兼容)。 + */ + async function loadProjects(query?: ProjectQuery) { state.loading = true state.error = null try { - state.projects = await projectApi.list() + state.projects = await projectApi.list(query) } catch (e: any) { state.error = e?.toString() ?? t('projects.err.loadFailed') } finally { diff --git a/src/stores/project/tasks.ts b/src/stores/project/tasks.ts index 152456d..fa0def2 100644 --- a/src/stores/project/tasks.ts +++ b/src/stores/project/tasks.ts @@ -1,12 +1,23 @@ import { taskApi } from '@/api' +import type { TaskQuery } from '@/api/types' import { t } from '@/i18n/i18n-helpers' import { state } from './state' /** 任务 CRUD 子 store(共享全局 state) */ export function createTasksStore() { - async function loadTasks(projectId?: string) { + /** + * 加载任务列表。 + * + * **双签名向后兼容**(F-260621-02): + * - `loadTasks(projectId?)`:旧签名,仅项目过滤(等价 `loadTasks({ project_id: projectId })`)。 + * - `loadTasks(query?)`:新签名,多条件(status/keyword/...)走后端 WHERE。 + * + * 不传 → 全量未删任务(created_at DESC,等价改造前 list_active)。 + * status/keyword 下沉后端 WHERE(P1/P2),取代 Tasks.vue 旧的前端内存 filter。 + */ + async function loadTasks(queryOrProjectId?: TaskQuery | string) { try { - state.tasks = await taskApi.list(projectId) + state.tasks = await taskApi.list(queryOrProjectId) } catch (e: any) { state.error = e?.toString() ?? t('tasks.err.loadFailed') } diff --git a/src/views/Ideas.vue b/src/views/Ideas.vue index 029f511..9d0905c 100644 --- a/src/views/Ideas.vue +++ b/src/views/Ideas.vue @@ -126,8 +126,7 @@ import { useRouter, useRoute } from 'vue-router' import { useI18n } from 'vue-i18n' import { Message } from '@arco-design/web-vue' import { useProjectStore } from '../stores/project' -import type { IdeaStatus } from '../api/types' -import { parseTags } from '../stores/knowledge' +import type { IdeaStatus, IdeaQuery } from '../api/types' import { formatDate } from '../utils/time' import { stripMd } from '../utils/markdown' import ConfirmDialog from '../components/ConfirmDialog.vue' @@ -181,38 +180,61 @@ function statusLabelKey(status: IdeaStatus): string { return statusOptions.find(s => s.value === status)?.labelKey ?? status } +// ── 后端查询构造(F-260621-02:status/keyword/order_by 下沉后端 WHERE) ── +// +// status 下沉:单一状态(all→不限 / promoted→promoted)走后端精确匹配; +// hot(按 score≥80)与 pending(多状态 draft+pending_review)无法映射单一后端 status, +// 仍在前端 filter(轻量,数据量小;后端 status 仅单值,扩多值属过度设计)。 +// keyword/title+description LIKE、order_by(排序)全下沉后端,避免前端全表扫描/重排。 +function buildIdeaQuery(): IdeaQuery { + const q: IdeaQuery = {} + // 排序下沉后端(score/time) + q.order_by = sortMode.value === 'score' ? 'score' : 'created_at' + // keyword 下沉后端(title/description LIKE) + if (searchQuery.value.trim()) { + q.keyword = searchQuery.value.trim() + } + // 单一状态映射:promoted → status=promoted;all/hot/pending 不走单一 status + if (activeFilter.value === 'promoted') { + q.status = 'promoted' + } + return q +} + +// 按当前筛选/搜索/排序触发后端加载(防抖仅对 keyword 输入,筛选/排序即时)。 +// 让 store.loading 驱动「加载中」空态显示(对齐 Tasks.vue 三态)。 +let keywordDebounce: ReturnType | null = null +async function reloadIdeas(immediate = false) { + if (keywordDebounce) { + clearTimeout(keywordDebounce) + keywordDebounce = null + } + const run = () => store.loadIdeas(buildIdeaQuery()) + if (immediate) { + await run() + } else { + keywordDebounce = setTimeout(() => { void run() }, 300) + } +} + +// 筛选/排序变化即时重载;keyword 输入防抖重载 +watch(activeFilter, () => void reloadIdeas(true)) +watch(sortMode, () => void reloadIdeas(true)) +watch(searchQuery, () => void reloadIdeas(false)) + const filteredIdeas = computed(() => { let ideas = store.ideas - // 应用状态过滤 + // hot(按 score≥80)/pending(多状态 draft+pending_review)保留前端 filter: + // 后端 status 仅单值,无法表达 OR,扩多值属过度设计;数据量小,前端 filter 轻量。 switch (activeFilter.value) { case 'hot': ideas = ideas.filter(i => (i.score ?? 0) >= 80); break case 'pending': ideas = ideas.filter(i => i.status === 'pending_review' || i.status === 'draft'); break - case 'promoted': ideas = ideas.filter(i => i.status === 'promoted'); break - default: break // 不过滤 + default: break // all / promoted:后端已过滤,前端不再 filter } - // 应用搜索过滤 - if (searchQuery.value.trim()) { - const query = searchQuery.value.toLowerCase().trim() - ideas = ideas.filter(i => - i.title.toLowerCase().includes(query) || - (i.description && i.description.toLowerCase().includes(query)) || - parseTags(i.tags).some((tag: string) => tag.toLowerCase().includes(query)) - ) - } - - // 排序:在过滤+搜索之后、return 之前对最终数组排序 - // [...ideas] 浅拷贝避免改原数组(store.ideas) - const sorted = [...ideas].sort((a, b) => { - if (sortMode.value === 'score') { - return (b.score ?? 0) - (a.score ?? 0) // 高分在前 - } - // time:created_at 为毫秒字符串,字符串比较即可(同位数字典序等价数值序) - return (b.created_at ?? '').localeCompare(a.created_at ?? '') // 新在前 - }) - - return sorted + // 排序与 keyword 已下沉后端(buildIdeaQuery),前端不再重排/重过滤。 + return ideas }) const currentIdea = computed(() => { @@ -279,7 +301,7 @@ async function promoteToProject() { } async function refresh() { - await store.loadIdeas() + await store.loadIdeas(buildIdeaQuery()) } const evaluating = ref(false) @@ -318,7 +340,7 @@ async function onUpdateRelated(ids: string[]) { } onMounted(async () => { - await store.loadIdeas() + await store.loadIdeas(buildIdeaQuery()) // 从路由参数恢复选中灵感(修复 /ideas/:id 直接访问空白页 B-260615-36) if (route.params.id) { const id = route.params.id as string diff --git a/src/views/Tasks.vue b/src/views/Tasks.vue index b9a3a63..0925f69 100644 --- a/src/views/Tasks.vue +++ b/src/views/Tasks.vue @@ -114,7 +114,7 @@ import { useI18n } from 'vue-i18n' import { useProjectStore } from '@/stores/project' import { formatRelative } from '@/utils/time' import { taskStatusLabel as statusLabel, taskStatusClass, priorityLabel, priorityClass } from '../constants/project' -import type { TaskRecord } from '@/api/types' +import type { TaskRecord, TaskQuery } from '@/api/types' const router = useRouter() const store = useProjectStore() @@ -169,14 +169,14 @@ const projectIcons: Record = { 'flux': '⚡', } +// F-260621-02(P1 status 下沉):status 过滤从前端内存 filter 改为构造 query 调后端, +// store.tasks 已是筛选后的结果(后端 WHERE)。filteredGroups 仅做按项目分组(project +// 维度也有后端 query 下沉,这里 groupMap 仅做展示分组,不再二次 filter)。 +// +// 注:project 维度同样下沉后端(activeProject watch 构造 query),store.tasks 已按 +// project_id 过滤。groupMap 保留分组逻辑(展示按项目聚合),无需再 filter。 const filteredGroups = computed(() => { - let filtered = store.tasks - if (activeProject.value !== 'all') { - filtered = filtered.filter(t => t.project_id === activeProject.value) - } - if (activeStatus.value !== 'all') { - filtered = filtered.filter(t => t.status === activeStatus.value) - } + const filtered = store.tasks const groupMap = new Map() for (const task of filtered) { @@ -200,10 +200,23 @@ const filteredGroups = computed(() => { // statusLabel / priorityLabel / priorityClass 由 ../constants/project 提供(与 ProjectDetail 文案统一) +// F-260621-02(P1 status 下沉):按当前 activeProject + activeStatus 构造后端 query。 +// - 全 all → undefined(全量,等价旧行为) +// - 任一具体值 → query 下沉后端 WHERE(project_id / status) +function buildTaskQuery(): TaskQuery | undefined { + const projAll = activeProject.value === 'all' + const statusAll = activeStatus.value === 'all' + if (projAll && statusAll) return undefined + const query: TaskQuery = {} + if (!projAll) query.project_id = activeProject.value + if (!statusAll) query.status = activeStatus.value + return query +} + async function refresh() { loading.value = true try { - await store.loadTasks(activeProject.value === 'all' ? undefined : activeProject.value) + await store.loadTasks(buildTaskQuery()) } finally { loading.value = false } @@ -236,17 +249,27 @@ async function confirmCreate() { } } -// B-260615-29: 切换项目筛选时按 projectId 重载(避免仅前端 filter,跨项目视图不同步) +// B-260615-29: 切换项目筛选时按筛选重载(避免仅前端 filter,跨项目视图不同步) // CR-260615-26(R-1): 同步登记当前筛选到 store,供 AR-11 listener 按筛选拉取 -watch(activeProject, (key) => { - store.setActiveTaskProject(key) +// F-260621-02(P1 status 下沉):重载 query 同时含 project_id + status,前端不再 filter status +watch(activeProject, () => { + store.setActiveTaskProject(activeProject.value) loading.value = true - store.loadTasks(key === 'all' ? undefined : key).finally(() => { loading.value = false }) + store.loadTasks(buildTaskQuery()).finally(() => { loading.value = false }) +}) + +// F-260621-02(P1 status 下沉):切换 status 筛选时构造 query 重载(后端 WHERE,非前端 filter), +// 并登记到 store 供 AR-11 listener 复用同一筛选(保 B-29 契约) +watch(activeStatus, (key) => { + store.setActiveTaskStatus(key) + loading.value = true + store.loadTasks(buildTaskQuery()).finally(() => { loading.value = false }) }) onMounted(async () => { // R-1: 登记初始筛选(all=全量),让数据变更 listener 知道视图已挂载、按全量拉取 store.setActiveTaskProject(activeProject.value) + store.setActiveTaskStatus(activeStatus.value) loading.value = true try { await Promise.all([store.loadProjects(), store.loadTasks()])