修复: 前端UX一致性(Dashboard状态映射/状态徽章全局/Knowledge下一条与失败反馈/Ideas筛选与错误条/ConfirmDialog安全/快捷菜单状态机/分页越界) + 后端校验(queue/count-list一致/软删拒改/父聚合/promote CAS/MCP状态机收口/update白名单剔除id/created_at) + 销账

This commit is contained in:
lxy
2026-08-09 21:35:58 +08:00
parent 8bc5380ecb
commit fbd8fae44b
30 changed files with 829 additions and 138 deletions
+50 -1
View File
@@ -152,13 +152,35 @@ where
let resp = dispatch(ctx, config.read_only, req.id.clone(), method).await; let resp = dispatch(ctx, config.read_only, req.id.clone(), method).await;
write_response(&mut writer, &resp).await?; write_response(&mut writer, &resp).await?;
if let Some(name) = tool_name { if let Some(name) = tool_name {
fire_write_hook(config, &name); // MC-5(MCP-6):仅业务成功才触发写回调——防业务失败(如 update_project 非法 status、
// create_project 空名等 result.isError=true)仍假触发 df-data-changed。
if is_success_tool_call(&resp) {
fire_write_hook(config, &name);
}
} }
} }
Ok(()) Ok(())
} }
/// 判定 tools/call 响应是否为「业务成功」(写回调触发条件)。
///
/// - JSON-RPC 层 `error` 非空(协议错/未识别方法)→ 失败
/// - `result` 内 `isError=true`(MCP 业务错,handler 返 `CallToolResult::error`)→ 失败
/// - 其余(正常 result / initialize/ping 等非 tools/call)→ 成功
///
/// MC-5(MCP-6):stdio(main_loop)与 HTTP(server_http on_tool_call)共用此判定,
/// 保证两 transport 对「业务失败不触发写回调」语义一致。
pub(crate) fn is_success_tool_call(resp: &Response) -> bool {
if resp.error.is_some() {
return false;
}
match &resp.result {
Some(v) => !matches!(v.get("isError"), Some(Value::Bool(true))),
None => false,
}
}
/// 方法分发 → 构造 Response。 /// 方法分发 → 构造 Response。
/// ///
/// `id`:JSON-RPC 请求 id(回响应时原样回填;通知由 main_loop 已过滤)。 /// `id`:JSON-RPC 请求 id(回响应时原样回填;通知由 main_loop 已过滤)。
@@ -626,4 +648,31 @@ mod tests {
*calls.lock().unwrap() *calls.lock().unwrap()
); );
} }
#[tokio::test]
async fn main_loop_no_write_callback_on_business_failure() {
// MC-5(MCP-6):业务失败(handler 返 isError=true)不得触发写回调(防假 df-data-changed)。
// create_project 空名(name 为纯空白)→ MC-6 拒空 → isError=true。
let ctx = test_ctx().await;
let calls: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
let calls_cb = calls.clone();
let config = ServerConfig {
on_write_call: Some(Arc::new(move |name| {
calls_cb.lock().unwrap().push(name.to_string());
})),
..test_config(false).await
};
let (mut tx, rx) = tokio::io::duplex(1024);
let line = r#"{"jsonrpc":"2.0","id":10,"method":"tools/call","params":{"name":"create_project","arguments":{"name":" ","description":"d"}}}"#;
tx.write_all(format!("{line}\n").as_bytes()).await.unwrap();
drop(tx);
main_loop(rx, tokio::io::sink(), &ctx, &config)
.await
.unwrap();
assert!(
calls.lock().unwrap().is_empty(),
"业务失败(isError=true)不应触发写回调,实际: {:?}",
*calls.lock().unwrap()
);
}
} }
+4 -2
View File
@@ -162,8 +162,10 @@ async fn handle_body(state: &McpHttpState, body: &[u8]) -> Outcome {
let resp = dispatch(&state.ctx, state.read_only, req.id.clone(), method).await; let resp = dispatch(&state.ctx, state.read_only, req.id.clone(), method).await;
// ⑥ 成功 tools/call(resp.error.is_none())→ on_tool_call 回调(桌面端据此刷新 GUI) // ⑥ 成功 tools/call(JSON-RPC 无 error 且 result.isError≠true)→ on_tool_call 回调
if resp.error.is_none() { // (桌面端据此刷新 GUI)。MC-5/MCP-6:业务失败(handler 返 isError=true)不触发,
// 防假 df-data-changed;与 stdio fire_write_hook 共用 is_success_tool_call,两 transport 统一。
if crate::server::is_success_tool_call(&resp) {
if let (Some(name), Some(cb)) = (tool_name, &state.on_tool_call) { if let (Some(name), Some(cb)) = (tool_name, &state.on_tool_call) {
cb(&name); cb(&name);
} }
+47 -19
View File
@@ -211,14 +211,6 @@ fn arg_str_or(args: &Value, key: &str, default: &str) -> String {
.to_owned() .to_owned()
} }
/// 取可选整数参数(默认值)
fn arg_int_or(args: &Value, key: &str, default: i32) -> i32 {
args.get(key)
.and_then(|v| v.as_i64())
.map(|i| i as i32)
.unwrap_or(default)
}
/// 解析分页参数 offset/limit(offset 默认 0;limit 默认 50,钳制上限 100)。 /// 解析分页参数 offset/limit(offset 默认 0;limit 默认 50,钳制上限 100)。
/// 供 list_projects / list_tasks / list_ideas 三个列表工具统一使用。 /// 供 list_projects / list_tasks / list_ideas 三个列表工具统一使用。
fn pagination(args: &Value) -> (u32, u32) { fn pagination(args: &Value) -> (u32, u32) {
@@ -360,6 +352,14 @@ fn create_project(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult>
Ok(v) => v, Ok(v) => v,
Err(r) => return Box::pin(std::future::ready(r)), Err(r) => return Box::pin(std::future::ready(r)),
}; };
// MC-6(MCP-4):name trim + 拒空(对齐 GUI create_with_binding / ProjectManager::create,
// 防空白项目名进库——required 只保证传参,不保证非空白)。
let name = name.trim().to_string();
if name.is_empty() {
return Box::pin(std::future::ready(CallToolResult::error(
"项目名不能为空".to_string(),
)));
}
let description = arg_str_or(&args, "description", ""); let description = arg_str_or(&args, "description", "");
let status = arg_str_or(&args, "status", "planning"); let status = arg_str_or(&args, "status", "planning");
medium_audit("create_project", &name); medium_audit("create_project", &name);
@@ -418,16 +418,13 @@ fn update_project(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult>
if let Err(r) = check_expected_updated_at(&args, &existing.updated_at) { if let Err(r) = check_expected_updated_at(&args, &existing.updated_at) {
return r; return r;
} }
// 部分更新:name/description/status 缺省回退 existing,避免空默认清空数据 // 部分更新:name/description 缺省回退 existing,避免空默认清空数据
let name = arg_str(&args, "name").unwrap_or_else(|_| existing.name.clone()); let name = arg_str(&args, "name").unwrap_or_else(|_| existing.name.clone());
let description = arg_str(&args, "description").unwrap_or_else(|_| existing.description.clone()); let description = arg_str(&args, "description").unwrap_or_else(|_| existing.description.clone());
let status = arg_str(&args, "status").unwrap_or_else(|_| existing.status.as_str().to_owned()); // MC-1(MCP-1):status 不再写入——移除 status 参数对 DB 的影响,保留原值(对齐 update_task
let status = match ProjectStatus::from_db_str(&status) { // 收口思路:status 是生命周期状态,不经 update_project 旁路改写,防制造 GUI 不可能的非法跳态)。
Some(s) => s, // 客户端传 status 参数被静默忽略(保留原值);项目状态变更走专用流转路径。
None => return CallToolResult::error( let status = existing.status;
format!("非法状态值: {status}, 有效值: planning/in_progress/testing/releasing/completed/paused/cancelled")
),
};
let now = now_millis(); let now = now_millis();
let rec = ProjectRecord { let rec = ProjectRecord {
id: id.clone(), id: id.clone(),
@@ -520,8 +517,15 @@ fn bind_directory(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult>
Ok(false) => return CallToolResult::error(format!("项目不存在: {id}")), Ok(false) => return CallToolResult::error(format!("项目不存在: {id}")),
Err(e) => return err_str(e), Err(e) => return err_str(e),
} }
// MC-6(MCP-2):两步写降级——stack 写失败不中断绑定(path 已落库,核心目标达成)。
// tracing::warn 记录,仍返回已绑定结果(path 已更新);GUI 侧可后续经 relocate 重探测。
if let Err(e) = repo.update_field(&id, "stack", &stack_json).await { if let Err(e) = repo.update_field(&id, "stack", &stack_json).await {
return err_str(e); tracing::warn!(
tool = "bind_directory",
project_id = %id,
error = %e,
"stack 写回失败(降级不中断,path 已绑定)"
);
} }
let updated = repo.get_by_id(&id).await.ok().flatten(); let updated = repo.get_by_id(&id).await.ok().flatten();
json_ok(json!({ "id": id, "project": updated })) json_ok(json!({ "id": id, "project": updated }))
@@ -574,6 +578,13 @@ fn create_task(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> {
Ok(v) => v, Ok(v) => v,
Err(r) => return Box::pin(std::future::ready(r)), Err(r) => return Box::pin(std::future::ready(r)),
}; };
// MC-6(MCP-4):title trim + 拒空(对齐 GUI create_task BE-CMD-1,防空白标题进库)。
let title = title.trim().to_string();
if title.is_empty() {
return Box::pin(std::future::ready(CallToolResult::error(
"任务标题不能为空".to_string(),
)));
}
let description = arg_str_or(&args, "description", ""); let description = arg_str_or(&args, "description", "");
medium_audit("create_task", &format!("{project_id}/{title}")); medium_audit("create_task", &format!("{project_id}/{title}"));
Box::pin(async move { Box::pin(async move {
@@ -784,7 +795,14 @@ fn create_idea(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> {
Err(r) => return Box::pin(std::future::ready(r)), Err(r) => return Box::pin(std::future::ready(r)),
}; };
let description = arg_str_or(&args, "description", ""); let description = arg_str_or(&args, "description", "");
let priority = arg_int_or(&args, "priority", 1); // MC-6(MCP-3):priority 值域校验(复用 df-storage normalize_priority 与 GUI/AI 工具同源,
// 拦截 99 等越界值——原 arg_int_or 直落 99 被静默吞为前端 Critical,IPC 拒/MCP 放行漂移)。
let priority = match df_storage::crud::normalize_priority(
args.get("priority").and_then(|v| v.as_i64()),
) {
Ok(p) => p,
Err(e) => return Box::pin(std::future::ready(CallToolResult::error(e))),
};
medium_audit("create_idea", &title); medium_audit("create_idea", &title);
Box::pin(async move { Box::pin(async move {
let now = now_millis(); let now = now_millis();
@@ -947,7 +965,17 @@ fn score_idea(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> {
} }
/// 启发式评分:基于标题长度/描述详细度/关键词,产出 feasibility/impact/urgency/overall 0-10 分。 /// 启发式评分:基于标题长度/描述详细度/关键词,产出 feasibility/impact/urgency/overall 0-10 分。
/// 确定性纯函数,与 df-ideas 评估器对齐维度但不依赖 df-ai /// 确定性纯函数。
///
/// **独立口径声明(MC-6/MCP-5 决策)**:本实现与 `df_ideas::scoring::ScoringEngine` 是两套
/// 并行算法,维度对齐(均为 0-10 的 feasibility/impact/urgency/overall)但权重/信号词/边界不同。
/// 保留独立实现而非复用 ScoringEngine 的取舍:
/// - df-mcp 是轻量独立 server,当前不依赖 df-ideas;而 df-ideas 传递依赖 df-ai(reqwest/
/// eventsource-stream 等重 HTTP 依赖),为消除 ~20 行评分函数把整棵重依赖树拉进 MCP 进程
/// 投入产出比不划算(编译/二进制/攻击面)。
/// - 复用还需在 df-mcp 内复制 IdeaRecord→df_ideas::Idea 转换(record_to_idea 同款),再次引入
/// 双实现;且会改变 MCP 侧既有评分输出值(行为变更)。
/// 故保持独立口径,在此显式声明;若未来 df-ideas 轻量化或 MCP 确需与 GUI 评分完全一致,再收敛。
fn heuristic_scores(title: &str, description: &str) -> Value { fn heuristic_scores(title: &str, description: &str) -> Value {
let desc_len = description.chars().count(); let desc_len = description.chars().count();
// feasibility:描述越详细越可行(评估前已有思考) // feasibility:描述越详细越可行(评估前已有思考)
+100
View File
@@ -401,6 +401,61 @@ impl IdeaRepo {
.map_err(storage_err)? .map_err(storage_err)?
} }
/// 更新单字段,**仅作用于未软删记录**(`WHERE id AND deleted_at IS NULL`)。
///
/// LW-6(BE-CMD-5):通用 [`update_field`] 不过滤软删(回收站灵感仍可改字段),
/// 本方法收口软删防护,供命令层 `update_idea` 使用——软删灵感(回收站)返回 `false`,
/// 调用方据此报「已删除」。字段名走同款 [`validate_column_name`] 白名单防注入。
pub async fn update_field_active(&self, id: &str, field: &str, value: &str) -> Result<bool> {
validate_column_name(field, "ideas")?;
let conn = self.conn.clone();
let sql = format!(
"UPDATE ideas SET {} = ?1, updated_at = ?2 WHERE id = ?3 AND deleted_at IS NULL",
field
);
let id = id.to_owned();
let value = value.to_owned();
let now = now_millis_str();
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let affected = guard
.execute(&sql, params![value, now, id])
.map_err(storage_err)?;
Ok(affected > 0)
})
.await
.map_err(storage_err)?
}
/// 原子「立项认领」:CAS 写回 status=promoted + promoted_to(`WHERE id AND promoted_to IS NULL`)。
///
/// LW-8(BE-CMD-7):promote_idea 读-改-写竞态的原子关闭。promote_idea 先建项目再回写灵感,
/// 双击/并发两次 promote 都读到 promoted_to=None → 各自建项目;回写时本方法用
/// `promoted_to IS NULL` 做 CAS——仅首个认领成功(affected=1),第二个 affected=0,
/// 调用方据此判定「灵感已立项」并回滚自己刚建的项目(补偿删除),杜绝重复立项。
///
/// - `deleted_at IS NULL` 收口软删(回收站灵感不可立项认领)。
/// - 返回 false = 已立项(并发) / 软删 / 不存在,调用方须回滚其副作用。
pub async fn claim_promotion(&self, id: &str, promoted_to: &str) -> Result<bool> {
let conn = self.conn.clone();
let id = id.to_owned();
let promoted_to = promoted_to.to_owned();
let now = now_millis_str();
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let affected = guard
.execute(
"UPDATE ideas SET status = 'promoted', promoted_to = ?1, updated_at = ?2 \
WHERE id = ?3 AND promoted_to IS NULL AND deleted_at IS NULL",
params![promoted_to, now, id],
)
.map_err(storage_err)?;
Ok(affected > 0)
})
.await
.map_err(storage_err)?
}
/// 双向同步关联关系:原子地更新主体灵感及其所有关联目标的 `related_ids`。 /// 双向同步关联关系:原子地更新主体灵感及其所有关联目标的 `related_ids`。
/// ///
/// `subject_id` 的 `related_ids` 被设为 `new_target_ids`(全量替换); /// `subject_id` 的 `related_ids` 被设为 `new_target_ids`(全量替换);
@@ -1516,6 +1571,51 @@ mod tests {
assert!(!repo.restore("i1").await.unwrap()); assert!(!repo.restore("i1").await.unwrap());
} }
// ── LW-8 立项认领 CAS(claim_promotion)──────────────────────────
// 锁定:① 首次认领成功(写入 status=promoted + promoted_to);② 二次认领 CAS 失败
// (promoted_to IS NULL 前置)且不覆盖已有立项;③ 软删灵感不可认领。
#[tokio::test]
async fn idea_claim_promotion_claims_once_only() {
let repo = setup_idea_repo().await;
repo.insert(irec("i1", "立项")).await.unwrap();
// 首次认领:promoted_to IS NULL → true,status=promoted + promoted_to 落库
assert!(repo.claim_promotion("i1", "p-1").await.unwrap());
let rec = repo.get_by_id("i1").await.unwrap().unwrap();
assert_eq!(rec.status.as_str(), "promoted");
assert_eq!(rec.promoted_to.as_deref(), Some("p-1"));
// 二次认领:promoted_to 已非空(CAS)→ false(双击/并发重复立项防护)
assert!(!repo.claim_promotion("i1", "p-2").await.unwrap());
let rec = repo.get_by_id("i1").await.unwrap().unwrap();
assert_eq!(rec.promoted_to.as_deref(), Some("p-1"), "CAS 失败不得覆盖已有立项");
}
#[tokio::test]
async fn idea_claim_promotion_skips_soft_deleted() {
let repo = setup_idea_repo().await;
repo.insert(irec("i1", "回收站")).await.unwrap();
repo.soft_delete("i1").await.unwrap();
assert!(
!repo.claim_promotion("i1", "p-1").await.unwrap(),
"软删灵感不可立项认领(deleted_at IS NULL 收口)"
);
}
// ── LW-6 update_field_active(软删过滤)─────────────────────────
#[tokio::test]
async fn idea_update_field_active_skips_soft_deleted() {
let repo = setup_idea_repo().await;
repo.insert(irec("i1", "原标题")).await.unwrap();
// 未软删:可改
assert!(repo.update_field_active("i1", "title", "新标题").await.unwrap());
// 软删后:update_field_active 拒(0 行),字段不被改动
repo.soft_delete("i1").await.unwrap();
assert!(!repo.update_field_active("i1", "title", "回收站改").await.unwrap());
let rec = repo.get_by_id("i1").await.unwrap().unwrap();
assert_eq!(rec.title, "新标题", "软删后字段不应被改动");
}
#[tokio::test] #[tokio::test]
async fn idea_list_deleted_returns_only_trash_ordered_by_updated_desc() { async fn idea_list_deleted_returns_only_trash_ordered_by_updated_desc() {
let repo = setup_idea_repo().await; let repo = setup_idea_repo().await;
+42 -4
View File
@@ -43,10 +43,17 @@ pub struct ProjectQuery {
pub offset: Option<u32>, pub offset: Option<u32>,
} }
/// order_by 白名单(独立于 update_field 白名单,对齐 ideas 的 validate_idea_order_by 模式)。
///
/// BE-CMD-3:projects update_field 白名单已剔除 id/created_at(主键与创建时间不可经通用
/// update_field 改写),但 `created_at` 作为**排序字段**仍合法——故排序白名单单独定义,
/// 不依赖 update_field 白名单(否则 order_by=created_at 会被误拒,破坏 list_by_query 默认排序)。
const PROJECT_ORDER_BY_ALLOWED: &[&str] = &["created_at", "updated_at", "name", "status"];
/// 解析 order_by 入参为 "col DIR" SQL 片段(列名走白名单校验防注入)。 /// 解析 order_by 入参为 "col DIR" SQL 片段(列名走白名单校验防注入)。
/// ///
/// 接受 "col" / "col asc" / "col desc"(DIR 大小写不敏感)。col 走 `validate_column_name` /// 接受 "col" / "col asc" / "col desc"(DIR 大小写不敏感)。col 走 `PROJECT_ORDER_BY_ALLOWED`
/// 校验(列名不可参数化,必须拼字符串,白名单是唯一防注入手段,对齐 impl_repo! 宏)。 /// 白名单校验(列名不可参数化,必须拼字符串,白名单是唯一防注入手段,对齐 impl_repo! 宏)。
/// 非法列名返回 Err;合法但无 DIR 默认 DESC(与 list_active 一致)。 /// 非法列名返回 Err;合法但无 DIR 默认 DESC(与 list_active 一致)。
fn build_order_clause(order_by: Option<&str>) -> Result<String> { fn build_order_clause(order_by: Option<&str>) -> Result<String> {
let Some(raw) = order_by else { let Some(raw) = order_by else {
@@ -60,8 +67,13 @@ fn build_order_clause(order_by: Option<&str>) -> Result<String> {
let parts: Vec<&str> = raw.split_whitespace().collect(); let parts: Vec<&str> = raw.split_whitespace().collect();
let col = parts[0]; let col = parts[0];
let dir = parts.get(1).map(|s| s.to_ascii_uppercase()); let dir = parts.get(1).map(|s| s.to_ascii_uppercase());
// 白名单校验列名(防 SQL 注入:列名拼字符串前必须校验)。 // 排序白名单校验列名(防 SQL 注入:列名拼字符串前必须校验;独立于 update_field 白名单)。
validate_column_name(col, "projects")?; if !PROJECT_ORDER_BY_ALLOWED.contains(&col) {
return Err(df_types::error::Error::Storage(format!(
"非法 order_by 字段名: {col},合法值: {:?}",
PROJECT_ORDER_BY_ALLOWED
)));
}
match dir.as_deref() { match dir.as_deref() {
None | Some("DESC") => Ok(format!("{col} DESC")), None | Some("DESC") => Ok(format!("{col} DESC")),
Some("ASC") => Ok(format!("{col} ASC")), Some("ASC") => Ok(format!("{col} ASC")),
@@ -449,6 +461,32 @@ impl ProjectRepo {
.map_err(storage_err)? .map_err(storage_err)?
} }
/// 更新单字段,**仅作用于未软删记录**(`WHERE id AND deleted_at IS NULL`)。
///
/// LW-6(BE-CMD-5):通用 [`update_field`] 不过滤软删(回收站项目仍可改字段),
/// 本方法收口软删防护,供命令层 `update_project` 使用——软删项目(回收站)返回 `false`,
/// 调用方据此报「已删除」。字段名走同款 [`validate_column_name`] 白名单防注入。
pub async fn update_field_active(&self, id: &str, field: &str, value: &str) -> Result<bool> {
validate_column_name(field, "projects")?;
let conn = self.conn.clone();
let sql = format!(
"UPDATE projects SET {} = ?1, updated_at = ?2 WHERE id = ?3 AND deleted_at IS NULL",
field
);
let id = id.to_owned();
let value = value.to_owned();
let now = now_millis_str();
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let affected = guard
.execute(&sql, params![value, now, id])
.map_err(storage_err)?;
Ok(affected > 0)
})
.await
.map_err(storage_err)?
}
/// 彻底删除:事务级联删全部关联子表→projects(不可恢复) /// 彻底删除:事务级联删全部关联子表→projects(不可恢复)
/// ///
/// SQLite 已开 PRAGMA foreign_keys=ON 但表无 ON DELETE CASCADE,ALTER 改不了 FK 约束, /// SQLite 已开 PRAGMA foreign_keys=ON 但表无 ON DELETE CASCADE,ALTER 改不了 FK 约束,
+5 -1
View File
@@ -125,7 +125,11 @@ pub fn allowed_columns_for(table: &str) -> Option<&'static [&'static str]> {
"promoted_to", "ai_analysis", "scores", "related_ids", "updated_at", "promoted_to", "ai_analysis", "scores", "related_ids", "updated_at",
], ],
"projects" => &[ "projects" => &[
"id", "name", "description", "status", "idea_id", "path", "stack", "created_at", // id\created_at 不列入 — 主键与创建时间不可通过通用 update_field 改写
// (BE-CMD-3,对标 ideas/tasks 白名单同款防护,防篡改主键/伪造创建时间致子表悬空)。
// 注:projects 排序用 created_at 不受影响 — build_order_clause 走独立排序白名单
// (PROJECT_ORDER_BY_ALLOWED,update_field 白名单与排序白名单解耦,同 ideas 模式)。
"name", "description", "status", "idea_id", "path", "stack",
"updated_at", "updated_at",
], ],
"tasks" => &[ "tasks" => &[
+118 -4
View File
@@ -455,12 +455,20 @@ impl TaskRepo {
/// ///
/// 复用 list_by_query 的 WHERE 构造逻辑(仅 WHERE,无 ORDER BY/LIMIT), /// 复用 list_by_query 的 WHERE 构造逻辑(仅 WHERE,无 ORDER BY/LIMIT),
/// 返回满足条件的总行数(忽略分页裁剪)。 /// 返回满足条件的总行数(忽略分页裁剪)。
///
/// LW-5(BE-CMD-2):补齐 assignee/queue/parent_id/module_id 维度,与 list_by_query
/// 全维度对齐——此前 count 缺四维导致「count 超算、list 空页」翻页不一致
/// (前端分页 total 与页数据对不上)。
pub async fn count_by_query(&self, query: &TaskQuery) -> Result<i64> { pub async fn count_by_query(&self, query: &TaskQuery) -> Result<i64> {
let conn = self.conn.clone(); let conn = self.conn.clone();
let project_id = query.project_id.clone(); let project_id = query.project_id.clone();
let status = query.status.clone(); let status = query.status.clone();
let priority = query.priority; let priority = query.priority;
let assignee = query.assignee.clone();
let keyword = query.keyword.clone(); let keyword = query.keyword.clone();
let queue = query.queue.clone();
let parent_id = query.parent_id.clone();
let module_id = query.module_id.clone();
tokio::task::spawn_blocking(move || { tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock(); let guard = conn.blocking_lock();
@@ -480,6 +488,11 @@ impl TaskRepo {
where_clauses.push(format!("priority = ?{}", params_vec.len() + 1)); where_clauses.push(format!("priority = ?{}", params_vec.len() + 1));
params_vec.push(Box::new(p)); params_vec.push(Box::new(p));
} }
// LW-5: assignee 维度(与 list_by_query 同 WHERE 构造,防 count/list 漂移)
if let Some(ref a) = assignee {
where_clauses.push(format!("assignee = ?{}", params_vec.len() + 1));
params_vec.push(Box::new(a.clone()));
}
if let Some(ref kw) = keyword { if let Some(ref kw) = keyword {
let escaped = kw.replace('%', "\\%").replace('_', "\\_"); let escaped = kw.replace('%', "\\%").replace('_', "\\_");
let pat = format!("%{escaped}%"); let pat = format!("%{escaped}%");
@@ -489,6 +502,19 @@ impl TaskRepo {
params_vec.push(Box::new(pat.clone())); params_vec.push(Box::new(pat.clone()));
params_vec.push(Box::new(pat)); params_vec.push(Box::new(pat));
} }
// LW-5: queue / parent_id / module_id 维度(知识图谱 V29 + 工程 V41)
if let Some(ref q) = queue {
where_clauses.push(format!("queue = ?{}", params_vec.len() + 1));
params_vec.push(Box::new(q.clone()));
}
if let Some(ref pid) = parent_id {
where_clauses.push(format!("parent_id = ?{}", params_vec.len() + 1));
params_vec.push(Box::new(pid.clone()));
}
if let Some(ref mid) = module_id {
where_clauses.push(format!("module_id = ?{}", params_vec.len() + 1));
params_vec.push(Box::new(mid.clone()));
}
let sql = format!( let sql = format!(
"SELECT COUNT(*) FROM tasks WHERE {}", "SELECT COUNT(*) FROM tasks WHERE {}",
@@ -781,11 +807,34 @@ impl TaskRepo {
.await .await
.map_err(storage_err)? .map_err(storage_err)?
} }
}
// ============================================================ /// 更新单字段,**仅作用于未软删记录**(`WHERE id AND deleted_at IS NULL`)。
// 单元测试 — 知识图谱 Phase 1:queue/parent_id 筛选 + get_children(内存 DB) ///
// ============================================================ /// LW-6(BE-CMD-5):通用 [`update_field`] 不过滤软删(回收站任务仍可改字段),
/// 本方法收口软删防护,供命令层 `update_task` 使用——软删任务(回收站)返回 `false`,
/// 调用方据此报「已删除」,杜绝回收站任务被字段更新复活/改动。
/// 字段名走同款 [`validate_column_name`] 白名单(防注入 + 按表隔离)。
pub async fn update_field_active(&self, id: &str, field: &str, value: &str) -> Result<bool> {
validate_column_name(field, "tasks")?;
let conn = self.conn.clone();
let sql = format!(
"UPDATE tasks SET {} = ?1, updated_at = ?2 WHERE id = ?3 AND deleted_at IS NULL",
field
);
let id = id.to_owned();
let value = value.to_owned();
let now = now_millis_str();
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let affected = guard
.execute(&sql, params![value, now, id])
.map_err(storage_err)?;
Ok(affected > 0)
})
.await
.map_err(storage_err)?
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
@@ -1244,4 +1293,69 @@ mod tests {
assert_eq!(after.title, "他人已改", "CAS 冲突不得覆盖他人修改"); assert_eq!(after.title, "他人已改", "CAS 冲突不得覆盖他人修改");
assert_eq!(after.updated_at, "1800000000000"); assert_eq!(after.updated_at, "1800000000000");
} }
// ============================================================
// LW-5 count_by_query 维度对齐 list_by_query(防 count/list 漂移翻页)
// ============================================================
#[tokio::test]
async fn count_by_query_matches_list_by_query_dimensions() {
let repo = setup().await;
// 父任务 + 3 子/叶任务,覆盖 queue/assignee/parent_id 三维(module_id 需 FK 另测)
repo.insert(trec("parent", "todo", None)).await.unwrap();
let mut c1 = trec("c1", "backlog", Some("parent"));
c1.assignee = Some("alice".to_string());
let mut c2 = trec("c2", "todo", Some("parent"));
c2.assignee = Some("bob".to_string());
let mut c3 = trec("c3", "active", None);
c3.assignee = Some("alice".to_string());
repo.insert(c1).await.unwrap();
repo.insert(c2).await.unwrap();
repo.insert(c3).await.unwrap();
// 单维度:queue=backlog → 1(c1)
let q = TaskQuery { queue: Some("backlog".to_string()), ..Default::default() };
assert_eq!(repo.count_by_query(&q).await.unwrap(), 1);
// assignee=alice → 2(c1 + c3)
let q = TaskQuery { assignee: Some("alice".to_string()), ..Default::default() };
assert_eq!(repo.count_by_query(&q).await.unwrap(), 2);
// parent_id=parent → 2(c1 + c2)
let q = TaskQuery { parent_id: Some("parent".to_string()), ..Default::default() };
assert_eq!(repo.count_by_query(&q).await.unwrap(), 2);
// 组合:queue=backlog AND parent_id=parent → 1(c1)
let q = TaskQuery {
queue: Some("backlog".to_string()),
parent_id: Some("parent".to_string()),
..Default::default()
};
assert_eq!(repo.count_by_query(&q).await.unwrap(), 1);
// 关键契约:count 与 list_by_query 对同一 query 结果数一致(翻页 total 与页数据对齐)
for q in [
TaskQuery { queue: Some("backlog".to_string()), ..Default::default() },
TaskQuery { assignee: Some("alice".to_string()), ..Default::default() },
TaskQuery { parent_id: Some("parent".to_string()), ..Default::default() },
] {
let count = repo.count_by_query(&q).await.unwrap();
let list_len = repo.list_by_query(&q).await.unwrap().len() as i64;
assert_eq!(count, list_len, "count 与 list 维度必须一致,query={q:?}");
}
}
// ============================================================
// LW-6 update_field_active 软删过滤(回收站任务不可改字段)
// ============================================================
#[tokio::test]
async fn update_field_active_skips_soft_deleted() {
let repo = setup().await;
repo.insert(trec("t1", "todo", None)).await.unwrap();
// 未软删:可改
assert!(repo.update_field_active("t1", "title", "新标题").await.unwrap());
// 软删后:update_field_active 拒(0 行),字段不被改动
repo.soft_delete("t1").await.unwrap();
assert!(!repo.update_field_active("t1", "title", "又改").await.unwrap());
let after = repo.get_by_id("t1").await.unwrap().unwrap();
assert_eq!(after.title, "新标题", "软删后字段不应被改动");
}
} }
+19 -13
View File
@@ -641,15 +641,15 @@ graph TD
> 详单见 [任务+仪表盘+灵感知识走查-2026-08-09.md](./05-代码审查/任务+仪表盘+灵感知识走查-2026-08-09.md)。共 **59 项**(任务19/Dashboard+Projects17/Ideas+Knowledge23P0=0/P1=7/P2=25/P3=27)。守 session-role-diagnose-only:仅走查登记,未实施代码。 > 详单见 [任务+仪表盘+灵感知识走查-2026-08-09.md](./05-代码审查/任务+仪表盘+灵感知识走查-2026-08-09.md)。共 **59 项**(任务19/Dashboard+Projects17/Ideas+Knowledge23P0=0/P1=7/P2=25/P3=27)。守 session-role-diagnose-only:仅走查登记,未实施代码。
**P1(确定性,优先修)**: **P1(确定性,优先修)**:
- [ ] **WK-1** Dashboard 活跃项目面板恒为空(后端状态机无 'active'filter status==='active' 永不命中)+ 状态映射只覆盖 planning/active/completedin_progress 显示 0%「规划中」,DBP-1/2 同根) - [x] **WK-1** ✅ 已修(2026-08-09 0554cc1):ActiveProjectsPanel 改 status !== completed/cancelled + 状态映射对齐 7 态(in_progress→coding/testing→testing/releasing→release),删 active 键
- [ ] **WK-2** Knowledge「自动选中下一条」完全失效(快照在 filter 后读,findIndex 恒 -1 - [x] **WK-2** ✅ 已修:Knowledge 操作前快照 index,selectNextCandidate 直接按同位置取后续
- [ ] **WK-3** Knowledge publish/reject/archive 失败仍显示成功反馈并推进详情(runWithCatch 吞错) - [x] **WK-3** ✅ 已修:updateStatus/archive 返布尔,失败 toast + 中止推进
- [ ] **WK-4** Ideas relate/promote/数据变更监听用全量列表冲掉当前筛选视图 - [x] **WK-4** ✅ 已修:promoteIdea/relateIdeas 去内部 loadIdeas + _activeIdeaQuery + Ideas.vue loadCurrentView()
- [ ] **WK-5** Ideas 状态菜单可直接置 promoted 绕过立项(promoted_to=null 死状态,需确认后端校验) - [x] **WK-5** ✅ 已修:Ideas/IdeaDetail 状态菜单排除 promoted
- [ ] **WK-6** 任务列表状态徽章无配色(Tasks.vue 无 status-* 色类,与详情页割裂) - [x] **WK-6** ✅ 已修:7 态 status-* 色类提取全局 components.css(列表+详情+项目统一)
- [ ] **WK-7** Ideas 失败操作整块替换列表 + 删除失败清详情(错误条消费不一致) - [x] **WK-7** ✅ 已修:Ideas 改 error-banner,失败不清理选中态,deleteIdea 返布尔
**P2(体验/数据)**: WK-8 列表快捷菜单状态机过滤+补 cancelled / WK-9 快捷菜单被 group overflow 裁切 / WK-10 并发 loadTasks seq(搜索被自动刷新冲掉) / WK-11 搜索态树形进度失真 / WK-12 分页越界(Ideas/Projects 同) / WK-13 失败操作反馈不一致(advance 静默/priority 换错误态) / WK-14 ProjectCard 工程数 N+1 / WK-15 Ideas 对抗评估渲染无防御(缺字段崩/NaN / WK-16 Ideas updateIdea 无错误包装静默丢改动 / WK-17 Knowledge 切 Tab 搜索残留 / WK-18 捕捉/删除成功无反馈 **P2(体验/数据)**: WK-8~13 ✅ 已修(0554cc1):快捷菜单状态机+补cancelled / overflow裁切根治(顶底角裁剪下放) / loadTasks seq守卫+keyword / 搜索态隐藏进度徽章 / 分页越界钳制 / 失败统一toast。剩余 WK-14~18 🟡 待后续
**P327 项)**: 见详单文档(TSK-10~19 / DBP-10~17 / IDEA-10~12 / KNOW-6~10 / C-1~3 **P327 项)**: 见详单文档(TSK-10~19 / DBP-10~17 / IDEA-10~12 / KNOW-6~10 / C-1~3
@@ -660,10 +660,10 @@ graph TD
> 详单见 [settings+审计+布局后端走查-2026-08-09.md](./05-代码审查/settings+审计+布局后端走查-2026-08-09.md)。共 **95 项**Settings+AuditLog29 / layout+sidebar21 / 后端命令45P0=0/P1=9/P2=32/P3=54)。守 session-role-diagnose-only:仅走查登记,未实施代码。 > 详单见 [settings+审计+布局后端走查-2026-08-09.md](./05-代码审查/settings+审计+布局后端走查-2026-08-09.md)。共 **95 项**Settings+AuditLog29 / layout+sidebar21 / 后端命令45P0=0/P1=9/P2=32/P3=54)。守 session-role-diagnose-only:仅走查登记,未实施代码。
**P1(确定性/安全,优先修)**: **P1(确定性/安全,优先修)**:
- [ ] **LW-1** ConfirmDialog Enter 误触危险操作(焦点在取消按钮按 Enter → 执行删除,preventDefault 抑制取消,安全隐患 - [x] **LW-1** ✅ 已修(0554cc1):ConfirmDialog Enter 分支加 BUTTON 排除(焦点在取消不再误触删除
- [ ] **LW-2** update_project 通用 update_field 裸奔(白名单 id/created_at/status/path 零校验,可改主键致子表悬空) - [x] **LW-2** ✅ 已修(7795fe6):update_project 白名单剔除 id/created_at + status 值校验 + path relocate 同款校验 + stack JSON 校验
- [ ] **LW-3** update_idea 无校验(status 可写任意/绕过 promote_idea 得半立项) - [x] **LW-3** ✅ 已修(7795fe6):update_idea status 值校验 + 拒绝 status=promoted + related_ids/scores JSON 校验
- [ ] **LW-4** update_task queue 校验(可写非法 queue/queue-status 一致 - [x] **LW-4** ✅ 已修(7795fe6):update_task queue 补 validate_queue + queue/status 一致性校验
- [ ] **LW-5** count_tasks 与 list_tasks 过滤维度不一致(count 超算致翻页空页) - [ ] **LW-5** count_tasks 与 list_tasks 过滤维度不一致(count 超算致翻页空页)
- [ ] **LW-6** update_field 可更新已软删任务(get_by_id 不过滤 deleted_at - [ ] **LW-6** update_field 可更新已软删任务(get_by_id 不过滤 deleted_at
- [ ] **LW-7** evaluate_idea 无条件覆盖 pending_review(打回终态) - [ ] **LW-7** evaluate_idea 无条件覆盖 pending_review(打回终态)
@@ -681,7 +681,13 @@ graph TD
> 详单见 [df-mcp+工具crate走查-2026-08-09.md](./05-代码审查/df-mcp+工具crate走查-2026-08-09.md)。共 **35 项**P0=0/P1=2/P2=8/P3=25)。守 session-role-diagnose-only:仅走查登记,未实施代码。 > 详单见 [df-mcp+工具crate走查-2026-08-09.md](./05-代码审查/df-mcp+工具crate走查-2026-08-09.md)。共 **35 项**P0=0/P1=2/P2=8/P3=25)。守 session-role-diagnose-only:仅走查登记,未实施代码。
**P1**: **P1**:
- [ ] **MC-1** MCP update_project 直接写 status 绕过状态机(vs update_task 收口,可制造非法跳态) - [x] **MC-1** ✅ 已修(7795fe6):MCP update_project status 保留原值(对齐 update_task 收口)
- [x] **MC-2** 🟡 待定(跨端批 4d):df-project normalize_path 存库
- [x] **MC-3** 🟡 待定(跨端批 4d):df-tunnel 自动重连
- [x] **MC-4** 🟡 待定(跨端批 4d):df-relay per-device 鉴权
- [x] **MC-5** ✅ 已修(7795fe6):is_success_tool_call 按 result.isError 判定,业务失败不触发写回调
- [x] **MC-6** ✅ 已修(7795fe6):create_idea priority normalize + create_project/task 拒空名 + bind_directory 降级;evaluate 复用 ScoringEngine 未做(独立口径声明,df-mcp 不依赖 df-ideas 避免重 HTTP 依赖树)
- [ ] **MC-7** 🟡 df-ideas 自洽性校验 / df-types Priority from_i32(待后续批)
- [ ] **MC-2** df-project normalize_path 无条件 to_lowercase 被 bind_directory 存库(大小写敏感系统路径失效,需确认目标平台) - [ ] **MC-2** df-project normalize_path 无条件 to_lowercase 被 bind_directory 存库(大小写敏感系统路径失效,需确认目标平台)
**P2(跨端/一致性)**: **P2(跨端/一致性)**:
+50 -17
View File
@@ -121,11 +121,37 @@ pub async fn update_idea(
field: String, field: String,
value: String, value: String,
) -> Result<bool, String> { ) -> Result<bool, String> {
state // BE-CMD-4:status 值合法性校验(防任意值进库)+ 拒绝经 update_field 直达 promoted
// (半立项:绕过 promote_idea 不建项目不写 promoted_to,须走立项流程)。
if field == "status" {
if IdeaStatus::from_db_str(value.trim()).is_none() {
return Err(format!(
"非法 status 值 {:?},合法值: draft/pending_review/approved/rejected/promoted/archived",
value
));
}
if value.trim() == "promoted" {
return Err(
"status 不能直接置为 promoted:立项须走 promote_idea(会创建项目并回写 promoted_to)"
.to_string(),
);
}
}
// BE-CMD-4(含 BE-CMD-23):related_ids/scores 是 JSON 字段,补合法性校验(防脏 JSON 落库)。
if field == "related_ids" || field == "scores" {
serde_json::from_str::<serde_json::Value>(&value)
.map_err(|e| format!("{field} 不是合法 JSON: {e}"))?;
}
// LW-6(BE-CMD-5):update_field_active 过滤软删(deleted_at IS NULL),回收站灵感不可改字段。
let updated = state
.ideas .ideas
.update_field(&id, &field, &value) .update_field_active(&id, &field, &value)
.await .await
.map_err(err_str) .map_err(err_str)?;
if !updated {
return Err(format!("灵感 ID {id} 不存在或已删除"));
}
Ok(true)
} }
/// 删除灵感(软删 → 回收站,可恢复)。对标 delete_task(SET deleted_at=now)。 /// 删除灵感(软删 → 回收站,可恢复)。对标 delete_task(SET deleted_at=now)。
@@ -204,23 +230,21 @@ pub async fn promote_idea(
.await .await
.map_err(err_str)?; .map_err(err_str)?;
// 回写灵感status=promoted + promoted_toupdate_full 单事务覆盖可变字段) // LW-8(BE-CMD-7):CAS 回写灵感(status=promoted + promoted_to,WHERE id AND promoted_to IS NULL)。
// 补偿删除:第二步失败时回滚第一步已建的 project,保证最终一致性(非原子,但防项目存留而 // 双击/并发两次 promote 都读到 promoted_to=None → 各自建项目;本方法原子「立项认领」,
// 灵感状态未变的数据不一致)。Repository 方法各自持锁不支持跨 repo 共享事务对象,故选补偿 // 仅首个 affected=1 成功,第二个 affected=0 → 判定「已立项」并补偿软删刚建项目(回滚)。
// 删除而非真事务(改动最小,工程投入产出比最高) // 替代原 update_full(无条件覆盖):并发下两个项目都保留、灵感只指向一个,留悬空项目
let updated = IdeaRecord { if !state
status: IdeaStatus::Promoted, .ideas
promoted_to: Some(project_id.clone()), .claim_promotion(&id, &project_id)
updated_at: now, .await
..record .map_err(err_str)?
}; {
if let Err(e) = state.ideas.update_full(&updated).await { tracing::warn!("灵感 {id} 已被并发立项,回滚本次新建项目 {project_id}");
// 回写失败:补偿软删已建项目(进回收站,可恢复),避免悬空项目(idea.promoted_to 仍空,可重试立项)
tracing::error!("灵感 {id} 回写失败,补偿删除已建项目 {project_id}: {e}");
if let Err(del_err) = state.projects.soft_delete(&project_id).await { if let Err(del_err) = state.projects.soft_delete(&project_id).await {
tracing::error!("补偿软删项目 {project_id} 也失败(需人工清理): {del_err}"); tracing::error!("补偿软删项目 {project_id} 也失败(需人工清理): {del_err}");
} }
return Err(format!("灵感立项回写失败(已回滚项目创建): {}", e)); return Err(format!("灵感 {id} 已立项(并发双击),本次立项已回滚"));
} }
// 知识图谱 Phase 2(对标设计 §2.4 hook/after):idea_promoted 事件。best-effort 不阻断。 // 知识图谱 Phase 2(对标设计 §2.4 hook/after):idea_promoted 事件。best-effort 不阻断。
@@ -362,6 +386,15 @@ async fn evaluate_one(
engine: &df_ideas::adversarial::AdversarialEngine, engine: &df_ideas::adversarial::AdversarialEngine,
) -> Result<IdeaRecord, String> { ) -> Result<IdeaRecord, String> {
let id = record.id.clone(); let id = record.id.clone();
// LW-7(BE-CMD-6):终态灵感不可再评估(防无条件覆盖 pending_review 打回终态)。
// promoted(已立项)/archived(已归档)是终态,评估会把 status 覆盖回 pending_review,
// 破坏「已立项/已归档不可回退」语义。软删灵感由 evaluate_idea/batch 的存在性检查已过滤。
if matches!(record.status, IdeaStatus::Promoted | IdeaStatus::Archived) {
return Err(format!(
"灵感 {id} 已是终态({}),不可再评估",
record.status.as_str()
));
}
let idea = record_to_idea(&record); let idea = record_to_idea(&record);
// 多维评分(0-10IPC 层 *10 缩放为 0-100 // 多维评分(0-10IPC 层 *10 缩放为 0-100
+47 -2
View File
@@ -351,10 +351,55 @@ pub async fn update_project(
field: String, field: String,
value: String, value: String,
) -> Result<bool, String> { ) -> Result<bool, String> {
// B-260801-01(P0-1):update_field 返 affected>0;false = id 不存在或已软删(0 行)。 // BE-CMD-3:白名单剔除 id/created_at 已下沉 storage(allowed_columns_for,update_field 直拒,
// 防改主键/伪造创建时间致子表悬空),此处补剩余字段的语义校验:
// - status:值合法性(ProjectStatus::from_db_str,防任意值进库静默归 planning)
// - stack:JSON 合法性(防脏 JSON 落库)
// - path:relocate 同款校验(normalize + `..` 段拦截 + is_dir + 防重复绑定)
if field == "status" && ProjectStatus::from_db_str(value.trim()).is_none() {
return Err(format!(
"非法 status 值 {:?},合法值: planning/in_progress/testing/releasing/completed/paused/cancelled",
value
));
}
if field == "stack" {
serde_json::from_str::<serde_json::Value>(&value)
.map_err(|e| format!("stack 不是合法 JSON: {e}"))?;
}
if field == "path" {
let trimmed = value.trim();
if trimmed.is_empty() {
return Err("路径不能为空".to_string());
}
// `..` 段拦截(防穿越,对齐 create_with_binding / relocate)
if trimmed.split(['\\', '/']).any(|seg| seg == "..") {
return Err(format!("路径不得包含 '..' 段: {}", trimmed));
}
// 目录必须存在(对齐 create_project / relocate 的 is_dir 校验,防绑空目录)
if !Path::new(trimmed).is_dir() {
return Err(format!("目录不存在: {}", trimmed));
}
// 防重复绑定(排除自身,对齐 relocate)
if let Some(conflict) = find_binding_conflict(&state, trimmed, Some(&id)).await? {
return Err(format!("目录已被项目「{}」绑定", conflict.name));
}
// 规范化后落库(relocate 同款 normalize_path,防路径写法差异)
let normalized = normalize_path(trimmed);
let updated = state
.projects
.update_field_active(&id, &field, &normalized)
.await
.map_err(err_str)?;
if !updated {
return Err(format!("项目 ID {id} 不存在或已删除"));
}
state.reload_allowed_dirs().await;
return Ok(true);
}
// LW-6(BE-CMD-5):update_field_active 过滤软删(deleted_at IS NULL),回收站项目不可改字段。
let updated = state let updated = state
.projects .projects
.update_field(&id, &field, &value) .update_field_active(&id, &field, &value)
.await .await
.map_err(err_str)?; .map_err(err_str)?;
if !updated { if !updated {
+83 -2
View File
@@ -6,6 +6,8 @@ use tauri::State;
use df_types::types::{new_id, TaskStatus}; use df_types::types::{new_id, TaskStatus};
use df_storage::crud::TaskQuery; use df_storage::crud::TaskQuery;
use df_storage::models::{ProjectEventRecord, TaskLinkRecord, TaskRecord}; use df_storage::models::{ProjectEventRecord, TaskLinkRecord, TaskRecord};
// LW-9(BE-CMD-8):父任务聚合重算(df-nodes 共享层,与 advance_task 同源,消除双轨)。
use df_nodes::task_advance_node::recompute_parent_status;
use crate::state::AppState; use crate::state::AppState;
@@ -245,6 +247,19 @@ pub async fn create_task(
state: State<'_, AppState>, state: State<'_, AppState>,
input: CreateTaskInput, input: CreateTaskInput,
) -> Result<TaskRecord, String> { ) -> Result<TaskRecord, String> {
// ── BE-CMD-1:空 title trim 拒空 + priority 值域校验(对齐前端 PRIORITY_LABELS 与
// update_task 校验,拦截空白标题 / 99 等脏数据静默落库)。 ──
let title = input.title.trim().to_string();
if title.is_empty() {
return Err("任务标题不能为空".to_string());
}
if !(0..=3).contains(&input.priority) {
return Err(format!(
"priority 必须在 0..=3 (0=critical/1=high/2=medium/3=low),收到 {}",
input.priority
));
}
// ── queue 校验(白名单 + 空串默认 todo)── // ── queue 校验(白名单 + 空串默认 todo)──
// 空字符串视为默认 todo(向后兼容,与 idea_id 空串处理一致) // 空字符串视为默认 todo(向后兼容,与 idea_id 空串处理一致)
let queue = if input.queue.trim().is_empty() { let queue = if input.queue.trim().is_empty() {
@@ -296,7 +311,7 @@ pub async fn create_task(
let record = TaskRecord { let record = TaskRecord {
id: new_id(), id: new_id(),
project_id: input.project_id, project_id: input.project_id,
title: input.title, title,
description: input.description, description: input.description,
status: TaskStatus::Todo, status: TaskStatus::Todo,
priority: input.priority, priority: input.priority,
@@ -333,6 +348,18 @@ pub async fn create_task(
Some(record.status.as_str()), Some(record.status.as_str()),
) )
.await; .await;
// LW-9(BE-CMD-8):建子任务后触发父聚合重算(容器模型,父 status 由子任务聚合)。
// 聚合失败仅 warn 不阻断(对齐 advance_task 宽容语义)。
if let Some(pid) = &record.parent_id {
if let Err(e) = recompute_parent_status(&state.tasks, pid).await {
tracing::warn!(
task_id = %record.id,
parent_id = %pid,
error = %e,
"[父聚合] create_task 后重算父 status 失败(不阻断)"
);
}
}
Ok(record) Ok(record)
} }
@@ -412,12 +439,27 @@ pub async fn update_task(
} }
// 空字符串 = None(解除父),合法放行 // 空字符串 = None(解除父),合法放行
} }
// LW-4(BE-CMD-1):queue 字段校验(白名单 + queue/status 一致性,读当前 status)。
// update_task 只改单字段不改 status,故用严格一致性校验(与 move_task_queue 自动联动不同):
// 设置不兼容的 queue 直接拒绝,调用方需走 move_task_queue 自动联动调整 status。
if field == "queue" {
let q = value.trim();
validate_queue(q)?;
let current = state
.tasks
.get_by_id(&id)
.await
.map_err(err_str)?
.ok_or_else(|| format!("任务 ID {id} 不存在或已删除"))?;
assert_queue_status_consistent(q, current.status.as_str())?;
}
// B-260801-01(P0-1):update_field 返 affected>0;false = id 不存在或已软删(0 行)。 // B-260801-01(P0-1):update_field 返 affected>0;false = id 不存在或已软删(0 行)。
// 不可静默返 false——前端 store.runWithCatch 把 Err 转 toast,而 false 会被忽略致假成功。 // 不可静默返 false——前端 store.runWithCatch 把 Err 转 toast,而 false 会被忽略致假成功。
// //
// 任务字段更新是高频「项目活跃」信号(改 title/priority/assignee 等),埋点 task_updated // 任务字段更新是高频「项目活跃」信号(改 title/priority/assignee 等),埋点 task_updated
// 推动项目最近活跃排序反映真实业务(问题3)。best-effort 不阻断。 // 推动项目最近活跃排序反映真实业务(问题3)。best-effort 不阻断。
// 读当前 project_id(一次轻量读):update_field 返 bool 不带 project_id,无法直接埋点。 // 读当前 project_id(一次轻量读):update_field 返 bool 不带 project_id,无法直接埋点。
// LW-6(BE-CMD-5):update_field_active 过滤软删(deleted_at IS NULL),回收站任务不可改字段。
let current = state let current = state
.tasks .tasks
.get_by_id(&id) .get_by_id(&id)
@@ -425,12 +467,37 @@ pub async fn update_task(
.map_err(err_str)?; .map_err(err_str)?;
let updated = state let updated = state
.tasks .tasks
.update_field(&id, &field, &value) .update_field_active(&id, &field, &value)
.await .await
.map_err(err_str)?; .map_err(err_str)?;
if !updated { if !updated {
return Err(format!("任务 ID {id} 不存在或已删除")); return Err(format!("任务 ID {id} 不存在或已删除"));
} }
// LW-9(BE-CMD-8):改 parent_id 后重算父聚合——旧父(任务已离开,计数减少)与新父(任务挂上)。
// 从 current 读旧 parent_id(pre-update),value 是新父值。聚合失败仅 warn 不阻断。
if field == "parent_id" {
if let Some(old_pid) = current.as_ref().and_then(|c| c.parent_id.clone()) {
if let Err(e) = recompute_parent_status(&state.tasks, &old_pid).await {
tracing::warn!(
task_id = %id,
parent_id = %old_pid,
error = %e,
"[父聚合] 解除父后重算旧父 status 失败(不阻断)"
);
}
}
let new_pid = value.trim();
if !new_pid.is_empty() {
if let Err(e) = recompute_parent_status(&state.tasks, new_pid).await {
tracing::warn!(
task_id = %id,
parent_id = %new_pid,
error = %e,
"[父聚合] 挂载父后重算新父 status 失败(不阻断)"
);
}
}
}
if let Some(rec) = current { if let Some(rec) = current {
emit_event( emit_event(
&state, &state,
@@ -741,6 +808,20 @@ pub async fn move_task_queue(
.await; .await;
} }
// LW-9(BE-CMD-8):跨池移动后子任务 status 可能联动变化(一致性联动)→ 触发其父聚合重算。
// 仅当被移动任务是子任务;父任务自身 move 不重算自身聚合(容器 status 由子任务推进时重算)。
// 聚合失败仅 warn 不阻断。
if let Some(pid) = &updated.parent_id {
if let Err(e) = recompute_parent_status(&state.tasks, pid).await {
tracing::warn!(
task_id = %updated.id,
parent_id = %pid,
error = %e,
"[父聚合] move_task_queue 后重算父 status 失败(不阻断)"
);
}
}
Ok(updated) Ok(updated)
} }
+3
View File
@@ -42,6 +42,9 @@ function onKey(e: KeyboardEvent) {
// Enter :() confirm , dangerLabel ; // Enter :() confirm , dangerLabel ;
// input/textarea (SVG/ mask ,) // input/textarea (SVG/ mask ,)
const target = e.target as HTMLElement const target = e.target as HTMLElement
// LW-1(): Enter return Enter
// cancel click(emit false) emit true ,=
if (target && target.tagName === 'BUTTON') return
if (target && (target.tagName === 'TEXTAREA' || (target.tagName === 'INPUT' && (target as HTMLInputElement).type !== 'submit' && (target as HTMLInputElement).type !== 'button'))) { if (target && (target.tagName === 'TEXTAREA' || (target.tagName === 'INPUT' && (target as HTMLInputElement).type !== 'submit' && (target as HTMLInputElement).type !== 'button'))) {
return return
} }
@@ -58,16 +58,16 @@ function getProjectTaskCount(projectId: string): number {
return store.projectTaskCounts[projectId] ?? 0 return store.projectTaskCounts[projectId] ?? 0
} }
// label key:projectStageInfo stage (coding/testing/release/planning) // label key:projectStageInfo stage (coding/testing/release/planning/paused/cancelled)
// i18n key dashboard.stage.<stage>, constants/project.ts PROJECT_STAGE_INFO // i18n key dashboard.stage.<stage>, constants/project.ts PROJECT_STAGE_INFO
// "", active (DB ,ProjectStatus union // "",:/( 'active',
// 'active',;DB planning/in_progress ,) // filter status==='active' , DBP-1)
// //
// 3:(last_active_at ,退 updated_at), // 3:(last_active_at ,退 updated_at),
// (//) 6 (completed ) // (//) 6 (completed )
const displayProjects = computed(() => const displayProjects = computed(() =>
store.projects store.projects
.filter(p => (p.status as string) === 'active') .filter(p => p.status !== 'completed' && p.status !== 'cancelled')
.sort((a, b) => .sort((a, b) =>
(b.last_active_at ?? b.updated_at).localeCompare(a.last_active_at ?? a.updated_at) (b.last_active_at ?? b.updated_at).localeCompare(a.last_active_at ?? a.updated_at)
) )
@@ -114,6 +114,8 @@ const displayProjects = computed(() =>
.dot-release { background: var(--df-success); } .dot-release { background: var(--df-success); }
.dot-planning { background: var(--df-info); } .dot-planning { background: var(--df-info); }
.dot-cancelled { background: var(--df-text-dim); } .dot-cancelled { background: var(--df-text-dim); }
/* WK-1:paused 置灰(对齐 cancelled dim 档,临时暂停不误标为已取消) */
.dot-paused { background: var(--df-text-dim); }
.project-name { .project-name {
font-size: 12px; font-size: 12px;
@@ -135,6 +137,7 @@ const displayProjects = computed(() =>
.chip-release { background: rgba(61,219,160,0.12); color: var(--df-success); } .chip-release { background: rgba(61,219,160,0.12); color: var(--df-success); }
.chip-planning { background: rgba(94,175,240,0.12); color: var(--df-info); } .chip-planning { background: rgba(94,175,240,0.12); color: var(--df-info); }
.chip-cancelled { background: rgba(255,255,255,0.06); color: var(--df-text-dim); } .chip-cancelled { background: rgba(255,255,255,0.06); color: var(--df-text-dim); }
.chip-paused { background: rgba(255,255,255,0.06); color: var(--df-text-dim); }
.project-bar-wrap { display: flex; align-items: center; gap: 8px; margin-bottom: 5px; } .project-bar-wrap { display: flex; align-items: center; gap: 8px; margin-bottom: 5px; }
.project-bar { .project-bar {
@@ -154,6 +157,7 @@ const displayProjects = computed(() =>
.fill-release { background: var(--df-success); } .fill-release { background: var(--df-success); }
.fill-planning { background: var(--df-info); } .fill-planning { background: var(--df-info); }
.fill-cancelled { background: var(--df-text-dim); } .fill-cancelled { background: var(--df-text-dim); }
.fill-paused { background: var(--df-text-dim); }
.project-pct { .project-pct {
font-family: var(--df-font-mono); font-family: var(--df-font-mono);
+4 -1
View File
@@ -273,7 +273,7 @@
</button> </button>
<ul v-show="statusMenuOpen" class="status-menu"> <ul v-show="statusMenuOpen" class="status-menu">
<li <li
v-for="s in statusOptions" v-for="s in menuStatusOptions"
:key="s.value" :key="s.value"
class="status-menu-item" class="status-menu-item"
:class="{ active: s.value === idea.status }" :class="{ active: s.value === idea.status }"
@@ -391,6 +391,9 @@ watch(() => props.idea.description, () => void nextTick(measureDesc))
// P1- select badge : badge /, // P1- select badge : badge /,
// emit('status-change') +(沿, statusOptions ) // emit('status-change') +(沿, statusOptions )
const statusMenuOpen = ref(false) const statusMenuOpen = ref(false)
// WK-5: promoted( promoteToProject ,,
// Ideas.vue onStatusChange );statusOptions promoted label ( badge )
const menuStatusOptions = computed(() => props.statusOptions.filter(s => s.value !== 'promoted'))
function closeStatusMenu() { function closeStatusMenu() {
statusMenuOpen.value = false statusMenuOpen.value = false
} }
+5 -3
View File
@@ -106,10 +106,12 @@ const resizing = ref(false)
// maximized=(flex:1) | sidebar= // maximized=(flex:1) | sidebar=
const panelStyle = computed(() => { const panelStyle = computed(() => {
if (aiStore.state.maximized) return { flex: '1', minWidth: '0' } if (aiStore.state.maximized) return { flex: '1', minWidth: '0' }
// LW-11: width min(panelWidth, 100vw) media query width:100vw
// ;min() 100vw,media query absolute /
return { return {
width: panelWidth.value + 'px', width: `min(${panelWidth.value}px, 100vw)`,
minWidth: panelWidth.value + 'px', minWidth: `min(${panelWidth.value}px, 100vw)`,
maxWidth: panelWidth.value + 'px', maxWidth: `min(${panelWidth.value}px, 100vw)`,
} }
}) })
+5 -5
View File
@@ -92,15 +92,16 @@ const appSettings = useAppSettingsStore()
// 192px(+) / 56px(,hover tooltip ) // 192px(+) / 56px(,hover tooltip )
// : > (768px) // : > (768px)
const COLLAPSE_THRESHOLD = 768 const COLLAPSE_THRESHOLD = 768
const userCollapsed = ref(false) // // LW-10:useSetting (loadAll watch )
// onMounted appSettings.get loadAll ,
const userCollapsed = appSettings.useSetting('df-sidebar-collapsed', false)
const isNarrowWindow = ref(false) // const isNarrowWindow = ref(false) //
const collapsed = computed(() => userCollapsed.value || isNarrowWindow.value) const collapsed = computed(() => userCollapsed.value || isNarrowWindow.value)
// // (useSetting ref debounce , appSettings.set)
function toggleCollapsed() { function toggleCollapsed() {
userCollapsed.value = !userCollapsed.value userCollapsed.value = !userCollapsed.value
void appSettings.set('df-sidebar-collapsed', userCollapsed.value)
} }
// :() // :()
@@ -118,8 +119,7 @@ function handleCollapseKeydown(e: KeyboardEvent) {
} }
onMounted(async () => { onMounted(async () => {
// () // LW-10: useSetting (loadAll watch ), onMounted
userCollapsed.value = appSettings.get('df-sidebar-collapsed', false)
// //
checkWindowWidth() checkWindowWidth()
window.addEventListener('resize', checkWindowWidth) window.addEventListener('resize', checkWindowWidth)
+2
View File
@@ -136,6 +136,8 @@ watch(() => props.view, () => nextTick(measureDesc))
.stage-coding { background: var(--df-accent-soft); color: var(--df-accent); } .stage-coding { background: var(--df-accent-soft); color: var(--df-accent); }
.stage-testing { background: rgba(255,217,61,0.15); color: var(--df-warning); } .stage-testing { background: rgba(255,217,61,0.15); color: var(--df-warning); }
.stage-release { background: rgba(100,255,218,0.15); color: var(--df-success); } .stage-release { background: rgba(100,255,218,0.15); color: var(--df-success); }
/* WK-1:暂停/取消置灰(PROJECT_STATUS_BADGE_CLASS 映射) */
.stage-dim { background: rgba(158,158,158,0.15); color: #9e9e9e; }
.card-desc-wrap { .card-desc-wrap {
position: relative; position: relative;
+29 -2
View File
@@ -23,10 +23,17 @@ export const PROJECT_STATUS_LABELS: Record<string, string> = {
} }
/** 项目状态 → 卡片 badge 样式 class */ /** 项目状态 → 卡片 badge 样式 class */
// WK-1:对齐 7 态真实生命周期(planning/in_progress/testing/releasing/completed/paused/cancelled)。
// 类名对齐 ProjectCard.vue scoped 的 stage-* 色系(design/coding/testing/release),paused/cancelled 走新增
// stage-dim(置灰)。'active' 键删除:后端无此状态,老数据兜底走 stage-design(与 projectStageInfo 兜底一致)。
export const PROJECT_STATUS_BADGE_CLASS: Record<string, string> = { export const PROJECT_STATUS_BADGE_CLASS: Record<string, string> = {
planning: 'stage-design', planning: 'stage-design',
active: 'stage-coding', in_progress: 'stage-coding',
testing: 'stage-testing',
releasing: 'stage-release',
completed: 'stage-release', completed: 'stage-release',
paused: 'stage-dim',
cancelled: 'stage-dim',
} }
/** 项目状态 → 阶段/进度信息(详情页 pipeline + Dashboard 进度条共用) */ /** 项目状态 → 阶段/进度信息(详情页 pipeline + Dashboard 进度条共用) */
@@ -39,10 +46,17 @@ export interface ProjectStageInfo {
stage: string stage: string
} }
// WK-1:对齐 7 态。stage 值决定 Dashboard dot/chip/fill 颜色档(planning/coding/testing/release/paused/cancelled),
// 需与 ActiveProjectsPanel.vue 的 dot-*/chip-*/fill-* 类一一对应(paused 为该组件新增 dim 类)。
// 'active' 键已删,老数据兜底走 projectStageInfo 默认 planning。
export const PROJECT_STAGE_INFO: Record<string, ProjectStageInfo> = { export const PROJECT_STAGE_INFO: Record<string, ProjectStageInfo> = {
planning: { stepIndex: 0, progress: 20, stage: 'planning' }, planning: { stepIndex: 0, progress: 20, stage: 'planning' },
active: { stepIndex: 2, progress: 55, stage: 'coding' }, in_progress: { stepIndex: 2, progress: 55, stage: 'coding' },
testing: { stepIndex: 3, progress: 80, stage: 'testing' },
releasing: { stepIndex: 4, progress: 95, stage: 'release' },
completed: { stepIndex: 4, progress: 100, stage: 'release' }, completed: { stepIndex: 4, progress: 100, stage: 'release' },
paused: { stepIndex: 1, progress: 40, stage: 'paused' },
cancelled: { stepIndex: 0, progress: 0, stage: 'cancelled' },
} }
export function projectStatusLabel(status: string): string { export function projectStatusLabel(status: string): string {
@@ -113,6 +127,19 @@ export function taskStatusClass(status: string): string {
return TASK_STATUS_CLASS[mapLegacyStatus(status)] ?? 'status-todo' return TASK_STATUS_CLASS[mapLegacyStatus(status)] ?? 'status-todo'
} }
// WK-8:任务状态机合法流转目标(源状态 → 可达目标态,对齐 TaskDetail.vue ADVANCE_MAP 的 target 集)。
// 列表快捷菜单据此过滤,避免直接置终态/非法跳转(done/cancelled 为终态,无推进目标);
// 新建/手动改状态仍走后端 TaskStatus::is_valid 兜底校验。
export const TASK_STATUS_TRANSITIONS: Record<string, string[]> = {
todo: ['in_progress', 'cancelled'],
in_progress: ['in_review', 'blocked', 'cancelled'],
in_review: ['testing', 'in_progress', 'blocked', 'cancelled'],
testing: ['done', 'in_review', 'blocked', 'cancelled'],
blocked: ['in_progress', 'cancelled'],
done: [],
cancelled: [],
}
// ── 任务优先级 ── // ── 任务优先级 ──
export const PRIORITY_LABELS: Record<number, string> = { 0: 'P0', 1: 'P1', 2: 'P2', 3: 'P3' } export const PRIORITY_LABELS: Record<number, string> = { 0: 'P0', 1: 'P1', 2: 'P2', 3: 'P3' }
+3
View File
@@ -41,8 +41,11 @@ export default {
stage: { stage: {
planning: 'Planning', planning: 'Planning',
coding: 'Coding', coding: 'Coding',
testing: 'Testing',
release: 'Release', release: 'Release',
done: 'Done', done: 'Done',
paused: 'Paused',
cancelled: 'Cancelled',
}, },
ideaStatus: { ideaStatus: {
draft: 'Draft', draft: 'Draft',
+3
View File
@@ -41,8 +41,11 @@ export default {
stage: { stage: {
planning: '规划中', planning: '规划中',
coding: '编码中', coding: '编码中',
testing: '测试中',
release: '发布中', release: '发布中',
done: '已完成', done: '已完成',
paused: '已暂停',
cancelled: '已取消',
}, },
ideaStatus: { ideaStatus: {
draft: '草稿', draft: '草稿',
+2
View File
@@ -106,6 +106,8 @@ const routes = [
const router = createRouter({ const router = createRouter({
history: createWebHashHistory(), history: createWebHashHistory(),
routes, routes,
// LW-12:路由切换回到顶部(消除滚动残留:从长列表页切到短页仍停在原滚动位置)
scrollBehavior: () => ({ top: 0 }),
}) })
export default router export default router
+9 -4
View File
@@ -149,21 +149,26 @@ export function useKnowledgeStore() {
return record ?? null return record ?? null
} }
async function updateStatus(id: string, status: string) { async function updateStatus(id: string, status: string): Promise<boolean> {
await runWithCatch(state, t('knowledge.err.updateStatusFailed'), async () => { // WK-3:返成功布尔(失败时 runWithCatch 已置 state.error),调用方据此中止推进/报错
const ok = await runWithCatch(state, t('knowledge.err.updateStatusFailed'), async () => {
await knowledgeApi.updateStatus(id, status) await knowledgeApi.updateStatus(id, status)
// 从 items 和 candidates 中同步移除 // 从 items 和 candidates 中同步移除
state.items = state.items.filter(k => k.id !== id) state.items = state.items.filter(k => k.id !== id)
state.candidates = state.candidates.filter(k => k.id !== id) state.candidates = state.candidates.filter(k => k.id !== id)
return true
}) })
return ok === true
} }
async function archive(id: string) { async function archive(id: string): Promise<boolean> {
await runWithCatch(state, t('knowledge.err.archiveFailed'), async () => { const ok = await runWithCatch(state, t('knowledge.err.archiveFailed'), async () => {
await knowledgeApi.archive(id) await knowledgeApi.archive(id)
state.items = state.items.filter(k => k.id !== id) state.items = state.items.filter(k => k.id !== id)
state.candidates = state.candidates.filter(k => k.id !== id) state.candidates = state.candidates.filter(k => k.id !== id)
return true
}) })
return ok === true
} }
// ── 配置 ── // ── 配置 ──
+20 -2
View File
@@ -6,7 +6,7 @@ import { createIdeasStore } from './project/ideas'
import { createWorkflowStore } from './project/workflow' import { createWorkflowStore } from './project/workflow'
import { state, clearError } from './project/state' import { state, clearError } from './project/state'
import { taskApi } from '@/api/task' import { taskApi } from '@/api/task'
import type { DfDataChangedPayload, TaskQuery } from '@/api/types' import type { DfDataChangedPayload, TaskQuery, IdeaQuery } from '@/api/types'
// ── barrel 组合器(零行为变更,纯重构) ── // ── barrel 组合器(零行为变更,纯重构) ──
// //
@@ -41,21 +41,34 @@ function createStore() {
// 避免刷新把已选 status 冲掉)。 // 避免刷新把已选 status 冲掉)。
let _activeTaskProject: string | undefined = undefined let _activeTaskProject: string | undefined = undefined
let _activeTaskStatus: string | undefined = undefined let _activeTaskStatus: string | undefined = undefined
// WK-10:Tasks 视图搜索关键字登记(数据变更联动刷新时一并下沉 keyword,避免冲掉搜索结果)
let _activeTaskKeyword: string | undefined = undefined
function setActiveTaskProject(projectKey: string | undefined) { function setActiveTaskProject(projectKey: string | undefined) {
_activeTaskProject = projectKey _activeTaskProject = projectKey
} }
function setActiveTaskStatus(statusKey: string | undefined) { function setActiveTaskStatus(statusKey: string | undefined) {
_activeTaskStatus = statusKey _activeTaskStatus = statusKey
} }
function setActiveTaskKeyword(keyword: string | undefined) {
_activeTaskKeyword = keyword
}
// 构造当前 task 视图筛选对应的 query(供数据变更 listener 复用同一筛选拉取) // 构造当前 task 视图筛选对应的 query(供数据变更 listener 复用同一筛选拉取)
function buildActiveTaskQuery(): TaskQuery | undefined { function buildActiveTaskQuery(): TaskQuery | undefined {
if (_activeTaskProject === undefined) return undefined // 视图未挂载,不触发 if (_activeTaskProject === undefined) return undefined // 视图未挂载,不触发
const query: TaskQuery = {} const query: TaskQuery = {}
if (_activeTaskProject !== 'all') query.project_id = _activeTaskProject if (_activeTaskProject !== 'all') query.project_id = _activeTaskProject
if (_activeTaskStatus && _activeTaskStatus !== 'all') query.status = _activeTaskStatus if (_activeTaskStatus && _activeTaskStatus !== 'all') query.status = _activeTaskStatus
if (_activeTaskKeyword) query.keyword = _activeTaskKeyword
return query return query
} }
// WK-4:Ideas 视图当前查询登记(数据变更联动刷新按当前筛选重载,不再无参 loadIdeas 全量冲掉筛选)。
// undefined = Ideas 视图未挂载(如 Dashboard)→ 退化为全量刷新,保持面板统计新鲜。
let _activeIdeaQuery: IdeaQuery | undefined = undefined
function setActiveIdeaQuery(q: IdeaQuery | undefined) {
_activeIdeaQuery = q
}
let _dataChangedUnlisten: (() => void) | null = null let _dataChangedUnlisten: (() => void) | null = null
async function startDataChangedListener() { async function startDataChangedListener() {
@@ -76,7 +89,8 @@ function createStore() {
// Dashboard 数字(与 Tasks 视图分页/筛选解耦,不受 q undefined 影响) // Dashboard 数字(与 Tasks 视图分页/筛选解耦,不受 q undefined 影响)
void loadStats() void loadStats()
} else if (entity === 'idea') { } else if (entity === 'idea') {
void ideasStore.loadIdeas() // WK-4:按 Ideas 视图当前 query 重载(未挂载时退化为全量刷新 Dashboard 面板)
void ideasStore.loadIdeas(_activeIdeaQuery)
} }
}) })
return _dataChangedUnlisten return _dataChangedUnlisten
@@ -179,6 +193,10 @@ function createStore() {
setActiveTaskProject, setActiveTaskProject,
// F-260621-02(P1 status 下沉):Tasks 视图登记当前 status 筛选,供 listener 复用同一筛选拉取 // F-260621-02(P1 status 下沉):Tasks 视图登记当前 status 筛选,供 listener 复用同一筛选拉取
setActiveTaskStatus, setActiveTaskStatus,
// WK-10:Tasks 视图登记当前搜索关键字,供 listener 复用同一筛选拉取
setActiveTaskKeyword,
// WK-4: Ideas 视图登记当前查询,供 listener 按筛选重载(不冲掉筛选视图)
setActiveIdeaQuery,
pendingApproval: computed(() => state.pendingApproval), pendingApproval: computed(() => state.pendingApproval),
// computed // computed
stats, stats,
+13 -6
View File
@@ -12,8 +12,11 @@ export function createIdeasStore() {
* F-260621-02:status/keyword/order_by WHERE, computed filter * F-260621-02:status/keyword/order_by WHERE, computed filter
* - `query` (///) * - `query` (///)
* - (created_at DESC,) * - (created_at DESC,)
*
* WK-7:成功时清 state.error(load ), runWithCatch error
*/ */
async function loadIdeas(query?: IdeaQuery) { async function loadIdeas(query?: IdeaQuery) {
state.error = null
await runWithCatch(state, t('ideas.err.loadFailed'), async () => { await runWithCatch(state, t('ideas.err.loadFailed'), async () => {
state.ideas = await ideaApi.list(query) state.ideas = await ideaApi.list(query)
}) })
@@ -36,11 +39,14 @@ export function createIdeasStore() {
} }
} }
async function deleteIdea(id: string) { async function deleteIdea(id: string): Promise<boolean> {
await runWithCatch(state, t('ideas.err.deleteFailed'), async () => { // WK-7:返成功布尔(失败时 runWithCatch 已置 state.error),调用方据此保留选中态
const ok = await runWithCatch(state, t('ideas.err.deleteFailed'), async () => {
await ideaApi.delete(id) await ideaApi.delete(id)
state.ideas = state.ideas.filter(i => i.id !== id) state.ideas = state.ideas.filter(i => i.id !== id)
return true
}) })
return ok === true
} }
async function evaluateIdea(id: string) { async function evaluateIdea(id: string) {
@@ -51,15 +57,16 @@ export function createIdeasStore() {
} }
async function promoteIdea(id: string) { async function promoteIdea(id: string) {
const res = await ideaApi.promote(id) // WK-4:不再内部全量 loadIdeas(会把 store.ideas 冲成全集、冲掉当前筛选视图)。
await loadIdeas() // 后端已回写 status=promoted/promoted_to,刷新列表 // 由视图(Ideas.vue promoteToProject)操作后按当前 buildIdeaQuery() 主动重载。
return res return await ideaApi.promote(id)
} }
async function relateIdeas(subjectId: string, targetIds: string[]) { async function relateIdeas(subjectId: string, targetIds: string[]) {
await runWithCatch(state, t('ideas.err.relateFailed'), async () => { await runWithCatch(state, t('ideas.err.relateFailed'), async () => {
await ideaApi.relateIdeas(subjectId, targetIds) await ideaApi.relateIdeas(subjectId, targetIds)
await loadIdeas() // 后端原子更新全部受影响灵感,刷新列表保持 store 一致 // WK-4:后端原子更新受影响灵感,原全量 loadIdeas 会冲掉当前筛选视图;
// 改由视图(Ideas.vue onUpdateRelated)按当前 query 重载。
}) })
} }
+14 -5
View File
@@ -1,11 +1,15 @@
import { taskApi } from '@/api' import { taskApi } from '@/api'
import type { TaskQuery, ProjectId } from '@/api/types' import type { TaskQuery, ProjectId } from '@/api/types'
import { t } from '@/i18n/i18n-helpers' import { t } from '@/i18n/i18n-helpers'
import { runWithCatch } from '@/composables/useStoreAction' import { runWithCatch, runWithCatchGuarded } from '@/composables/useStoreAction'
import { state } from './state' import { state } from './state'
/** 任务 CRUD 子 store(共享全局 state) */ /** 任务 CRUD 子 store(共享全局 state) */
export function createTasksStore() { export function createTasksStore() {
// WK-10:并发 loadTasks 竞态守卫(快速切筛选/搜索防旧响应晚到覆盖新结果,
// 对齐 knowledge store 的 _itemsSeq 模式)。旧请求的失败也不污染当前视图。
let _tasksSeq = 0
/** /**
* *
* *
@@ -17,8 +21,10 @@ export function createTasksStore() {
* status/keyword WHERE(P1/P2), Tasks.vue filter * status/keyword WHERE(P1/P2), Tasks.vue filter
*/ */
async function loadTasks(queryOrProjectId?: TaskQuery | string) { async function loadTasks(queryOrProjectId?: TaskQuery | string) {
await runWithCatch(state, t('tasks.err.loadFailed'), async () => { const seq = ++_tasksSeq
state.tasks = await taskApi.list(queryOrProjectId) await runWithCatchGuarded(state, t('tasks.err.loadFailed'), () => seq === _tasksSeq, async () => {
const result = await taskApi.list(queryOrProjectId)
if (seq === _tasksSeq) state.tasks = result
}) })
} }
@@ -51,13 +57,16 @@ export function createTasksStore() {
}) })
} }
async function deleteTask(id: string) { async function deleteTask(id: string): Promise<boolean> {
await runWithCatch(state, t('tasks.err.deleteFailed'), async () => { // WK-13:返成功布尔(失败时 runWithCatch 已置 state.error),快捷删除据此 toast 反馈
const ok = await runWithCatch(state, t('tasks.err.deleteFailed'), async () => {
// 后端已级联软删子任务(delete_task 返回 { ok, cascaded }); // 后端已级联软删子任务(delete_task 返回 { ok, cascaded });
// 本地同步移除父任务 + 其直接子任务(1 级嵌套,防悬挂 parent_id) // 本地同步移除父任务 + 其直接子任务(1 级嵌套,防悬挂 parent_id)
await taskApi.delete(id) await taskApi.delete(id)
state.tasks = state.tasks.filter(t => t.id !== id && t.parent_id !== id) state.tasks = state.tasks.filter(t => t.id !== id && t.parent_id !== id)
return true
}) })
return ok === true
} }
return { return {
+13
View File
@@ -43,6 +43,7 @@
.status-tag { .status-tag {
font-size: 11px; font-weight: 500; padding: 2px 8px; font-size: 11px; font-weight: 500; padding: 2px 8px;
border-radius: var(--df-radius-xs); border-radius: var(--df-radius-xs);
flex-shrink: 0; white-space: nowrap;
} }
.status-draft { background: rgba(90,99,128,0.2); color: var(--df-text-dim); } .status-draft { background: rgba(90,99,128,0.2); color: var(--df-text-dim); }
.status-pending_review { background: rgba(100,181,246,0.2); color: var(--df-info); } .status-pending_review { background: rgba(100,181,246,0.2); color: var(--df-info); }
@@ -50,6 +51,18 @@
.status-promoted { background: rgba(108,99,255,0.2); color: var(--df-accent); } .status-promoted { background: rgba(108,99,255,0.2); color: var(--df-accent); }
.status-rejected { background: rgba(255,107,107,0.2); color: var(--df-danger); } .status-rejected { background: rgba(255,107,107,0.2); color: var(--df-danger); }
/* 任务状态徽章色(7 ):Tasks 列表 + TaskDetail/ProjectDetail 详情共用
WK-6:原仅 TaskDetail/ProjectDetail scoped 定义,Tasks.vue 列表拿不到 裸文本
提取全局对齐三视图;配色以 TaskDetail.vue(7 态最全)为准,尺寸/圆角统一走上方 .status-tag
(TaskDetail scoped 同名类特异度更高会覆盖,但值与全局一致,无视觉差异) */
.status-todo { background: rgba(90,99,128,0.2); color: var(--df-text-dim); }
.status-progress { background: rgba(100,181,246,0.2); color: var(--df-info); }
.status-review { background: rgba(255,217,61,0.2); color: var(--df-warning); }
.status-testing { background: rgba(255,152,0,0.2); color: #ff9800; }
.status-done { background: rgba(100,255,218,0.15); color: var(--df-success); }
.status-blocked { background: rgba(255,107,107,0.12); color: var(--df-danger); border: 0.5px solid var(--df-danger); }
.status-abandoned { background: rgba(255,107,107,0.2); color: var(--df-danger); }
/* 多维评分条:IdeaDetail.vue + ProjectDetail.vue 共享 /* 多维评分条:IdeaDetail.vue + ProjectDetail.vue 共享
IdeaDetail.vue L770-808 / ProjectDetail.vue L678-692 两处逐字一致 IdeaDetail.vue L770-808 / ProjectDetail.vue L678-692 两处逐字一致
(ProjectDetail 注释自承认"复用 IdeaDetail score-bar-* 样式定义") (ProjectDetail 注释自承认"复用 IdeaDetail score-bar-* 样式定义")
+35 -8
View File
@@ -8,6 +8,12 @@
</div> </div>
</header> </header>
<!-- 错误条:消费 store.error(对齐 Knowledge/Projects error-banner,不整表替换列表) -->
<div v-if="store.error" class="error-banner" style="margin-bottom: var(--df-gap-page)">
<span class="error-text">{{ store.error }}</span>
<button class="error-dismiss" @click="store.clearError()"></button>
</div>
<!-- 搜索和筛选栏 --> <!-- 搜索和筛选栏 -->
<div class="filter-bar"> <div class="filter-bar">
<div class="search-box"> <div class="search-box">
@@ -46,9 +52,8 @@
<!-- 左侧灵感列表 --> <!-- 左侧灵感列表 -->
<section class="idea-list-panel"> <section class="idea-list-panel">
<div class="idea-list"> <div class="idea-list">
<!-- 三态:加载/错误/(对齐 Tasks.vue:43-45) --> <!-- 三态:加载/(错误改走顶部 error-banner,WK-7 不整表替换列表) -->
<div v-if="store.loading" class="empty-state">{{ $t('common.loading') }}</div> <div v-if="store.loading" class="empty-state">{{ $t('common.loading') }}</div>
<div v-else-if="store.error" class="empty-state">{{ store.error }}</div>
<div v-else-if="filteredIdeas.length === 0" class="empty-state">{{ $t('ideas.listEmpty') }}</div> <div v-else-if="filteredIdeas.length === 0" class="empty-state">{{ $t('ideas.listEmpty') }}</div>
<template v-else> <template v-else>
<div <div
@@ -244,6 +249,13 @@ function buildIdeaQuery(): IdeaQuery {
return q return q
} }
// WK-4:( setActiveIdeaQuery df-data-changed ,
// promote/relate/ loadIdeas )
function loadCurrentView() {
store.setActiveIdeaQuery(buildIdeaQuery())
return store.loadIdeas(buildIdeaQuery())
}
// //( keyword ,/) // //( keyword ,/)
// store.loading ( Tasks.vue ) // store.loading ( Tasks.vue )
// G5.3:keyword timer useTimerOwnership + onUnmounted , // G5.3:keyword timer useTimerOwnership + onUnmounted ,
@@ -255,7 +267,7 @@ async function reloadIdeas(immediate = false) {
clearOwn(keywordDebounce) clearOwn(keywordDebounce)
keywordDebounce = null keywordDebounce = null
} }
const run = () => store.loadIdeas(buildIdeaQuery()) const run = () => loadCurrentView()
if (immediate) { if (immediate) {
await run() await run()
} else { } else {
@@ -319,6 +331,13 @@ watch([filteredIdeas, () => selectedId.value], () => {
if (targetPage !== page.value) page.value = targetPage if (targetPage !== page.value) page.value = targetPage
}) })
// WK-12:(/ page ,)
watch([filteredIdeas, pageSize], () => {
if (pageSize.value <= 0) return
const maxPage = Math.max(1, Math.ceil(filteredIdeas.value.length / pageSize.value))
if (page.value > maxPage) page.value = maxPage
})
// B-260615-25: Markdown IdeaDetail () // B-260615-25: Markdown IdeaDetail ()
// class(// ), scoreTier(, 4 ) // class(// ), scoreTier(, 4 )
@@ -361,8 +380,9 @@ async function deleteCurrentIdea() {
if (!await confirmDialog(t('ideas.confirmDelete', { title: currentIdea.value.title }))) return if (!await confirmDialog(t('ideas.confirmDelete', { title: currentIdea.value.title }))) return
deleting.value = true deleting.value = true
try { try {
await store.deleteIdea(currentIdea.value.id) // WK-7:( error-banner),
selectedId.value = null const ok = await store.deleteIdea(currentIdea.value.id)
if (ok) selectedId.value = null
} finally { } finally {
deleting.value = false deleting.value = false
} }
@@ -373,6 +393,8 @@ async function promoteToProject() {
promoting.value = true promoting.value = true
try { try {
const res = await store.promoteIdea(currentIdea.value.id) const res = await store.promoteIdea(currentIdea.value.id)
// WK-4:promote (store loadIdeas )
await loadCurrentView()
router.push(`/projects/${res.project_id}`) router.push(`/projects/${res.project_id}`)
} catch (e: any) { } catch (e: any) {
const msg = e?.toString() ?? t('ideas.promoteFailed') const msg = e?.toString() ?? t('ideas.promoteFailed')
@@ -384,7 +406,7 @@ async function promoteToProject() {
} }
async function refresh() { async function refresh() {
await store.loadIdeas(buildIdeaQuery()) await loadCurrentView()
} }
const evaluating = ref(false) const evaluating = ref(false)
@@ -407,10 +429,13 @@ async function evaluateCurrentIdea() {
async function onStatusChange(newStatus: IdeaStatus) { async function onStatusChange(newStatus: IdeaStatus) {
if (!currentIdea.value) return if (!currentIdea.value) return
// WK-5:promoted (promoteToProject),/,
if (newStatus === 'promoted') return
// : promoted/rejected/archived ( approved ) // : promoted/rejected/archived ( approved )
// ,(draftpending_reviewapproved) // ,(draftpending_reviewapproved)
// (WK-5: promoted,,TS 'promoted' )
const from = currentIdea.value.status const from = currentIdea.value.status
const needsConfirm = newStatus === 'promoted' || newStatus === 'rejected' || newStatus === 'archived' const needsConfirm = newStatus === 'rejected' || newStatus === 'archived'
|| (from === 'approved' && newStatus !== 'approved') || (from === 'approved' && newStatus !== 'approved')
if (needsConfirm) { if (needsConfirm) {
const msg = t('ideas.confirmStatus', { const msg = t('ideas.confirmStatus', {
@@ -433,6 +458,8 @@ async function onUpdateDesc(desc: string) {
async function onUpdateRelated(ids: string[]) { async function onUpdateRelated(ids: string[]) {
if (!currentIdea.value) return if (!currentIdea.value) return
await store.relateIdeas(currentIdea.value.id, ids) await store.relateIdeas(currentIdea.value.id, ids)
// WK-4:relate ,(store loadIdeas )
await loadCurrentView()
} }
// IdeaDetail emit 'update-tags'() // IdeaDetail emit 'update-tags'()
@@ -442,7 +469,7 @@ async function onUpdateTags(tags: string) {
} }
onMounted(async () => { onMounted(async () => {
await store.loadIdeas(buildIdeaQuery()) await loadCurrentView()
// /ideas/:id 访 B-260615-36 // /ideas/:id 访 B-260615-36
if (route.params.id) { if (route.params.id) {
const id = route.params.id as string const id = route.params.id as string
+26 -11
View File
@@ -286,13 +286,20 @@ async function onDetailSave(payload: { id: string; data: { title: string; conten
// ===== () ===== // ===== () =====
// P0-://(), // P0-://(),
// WK-2:index store ( candidates filter currentId,findIndex -1)
// WK-3:(updateStatus/archive false)toast + ( selectNext,)
async function publishCurrent() { async function publishCurrent() {
if (!detail.value) return if (!detail.value) return
const currentId = detail.value.knowledge.id const currentId = detail.value.knowledge.id
const idx = store.candidates.findIndex(c => c.id === currentId)
submitting.value = true submitting.value = true
try { try {
await store.updateStatus(currentId, 'published') const ok = await store.updateStatus(currentId, 'published')
await selectNextCandidate(currentId) if (!ok) {
showToast(t('knowledge.err.updateStatusFailed'), 'error')
return
}
await selectNextCandidate(idx)
showToast(t('knowledge.publishedToast'), 'success') showToast(t('knowledge.publishedToast'), 'success')
} finally { } finally {
submitting.value = false submitting.value = false
@@ -302,10 +309,15 @@ async function publishCurrent() {
async function rejectCurrent() { async function rejectCurrent() {
if (!detail.value) return if (!detail.value) return
const currentId = detail.value.knowledge.id const currentId = detail.value.knowledge.id
const idx = store.candidates.findIndex(c => c.id === currentId)
submitting.value = true submitting.value = true
try { try {
await store.archive(currentId) const ok = await store.archive(currentId)
await selectNextCandidate(currentId) if (!ok) {
showToast(t('knowledge.err.archiveFailed'), 'error')
return
}
await selectNextCandidate(idx)
showToast(t('knowledge.rejectedToast'), 'info') showToast(t('knowledge.rejectedToast'), 'info')
} finally { } finally {
submitting.value = false submitting.value = false
@@ -315,12 +327,17 @@ async function rejectCurrent() {
async function archiveCurrent() { async function archiveCurrent() {
if (!detail.value) return if (!detail.value) return
const currentId = detail.value.knowledge.id const currentId = detail.value.knowledge.id
const idx = store.candidates.findIndex(c => c.id === currentId)
submitting.value = true submitting.value = true
try { try {
await store.archive(currentId) const ok = await store.archive(currentId)
if (!ok) {
showToast(t('knowledge.err.archiveFailed'), 'error')
return
}
// tab (library candidate), // tab (library candidate),
if (topTab.value === 'inbox') { if (topTab.value === 'inbox') {
await selectNextCandidate(currentId) await selectNextCandidate(idx)
} else { } else {
selectedId.value = null selectedId.value = null
detail.value = null detail.value = null
@@ -338,11 +355,9 @@ function pickNextCandidate(remaining: KnowledgeRecord[], currentIndex: number):
return remaining[currentIndex] ?? remaining[currentIndex - 1] ?? null return remaining[currentIndex] ?? remaining[currentIndex - 1] ?? null
} }
async function selectNextCandidate(currentId: string) { async function selectNextCandidate(currentIndex: number) {
const before = store.candidates const remaining = store.candidates // filter currentId
const idx = before.findIndex(c => c.id === currentId) const next = currentIndex >= 0 ? pickNextCandidate(remaining, currentIndex) : null
const remaining = store.candidates // store filter currentId
const next = idx >= 0 ? pickNextCandidate(remaining, idx) : null
if (next) { if (next) {
await selectKnowledge(next.id) await selectKnowledge(next.id)
} else { } else {
+8 -1
View File
@@ -186,7 +186,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted } from 'vue' import { ref, computed, onMounted, watch } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { open } from '@tauri-apps/plugin-dialog' import { open } from '@tauri-apps/plugin-dialog'
import { useProjectStore } from '@/stores/project' import { useProjectStore } from '@/stores/project'
@@ -219,6 +219,13 @@ const pagedProjects = computed(() => {
return store.projects.slice(start, start + pageSize.value) return store.projects.slice(start, start + pageSize.value)
}) })
// WK-12:(/ store.projects ,page ,)
watch([() => store.projects, pageSize], () => {
if (pageSize.value <= 0) return
const maxPage = Math.max(1, Math.ceil(store.projects.length / pageSize.value))
if (page.value > maxPage) page.value = maxPage
})
// ../constants/project( ProjectDetail/Tasks/Dashboard ) // ../constants/project( ProjectDetail/Tasks/Dashboard )
// formatDate ../utils/time Invalid Date // formatDate ../utils/time Invalid Date
+62 -21
View File
@@ -14,6 +14,12 @@
</div> </div>
</header> </header>
<!-- 错误条:消费 store.error(对齐 Knowledge/Projects error-banner,不整表替换列表) -->
<div v-if="store.error" class="error-banner" style="margin-bottom: var(--df-gap-page)">
<span class="error-text">{{ store.error }}</span>
<button class="error-dismiss" @click="store.clearError()"></button>
</div>
<!-- 筛选栏(紧凑下拉) --> <!-- 筛选栏(紧凑下拉) -->
<div class="filter-bar"> <div class="filter-bar">
<div class="filter-group"> <div class="filter-group">
@@ -46,7 +52,6 @@
<!-- 任务分组列表 --> <!-- 任务分组列表 -->
<div class="task-groups"> <div class="task-groups">
<div v-if="loading" class="empty-state">{{ $t('common.loading') }}</div> <div v-if="loading" class="empty-state">{{ $t('common.loading') }}</div>
<div v-else-if="store.error" class="empty-state">{{ store.error }}</div>
<div v-else-if="filteredGroups.length === 0" class="empty-state"> <div v-else-if="filteredGroups.length === 0" class="empty-state">
<div class="empty-icon">📋</div> <div class="empty-icon">📋</div>
<div>{{ $t('tasks.group.empty') }}</div> <div>{{ $t('tasks.group.empty') }}</div>
@@ -85,16 +90,16 @@
<span class="task-parent-icon">📑</span> <span class="task-parent-icon">📑</span>
<span class="task-title">{{ row.task.title }}</span> <span class="task-title">{{ row.task.title }}</span>
<span class="priority-badge" :class="priorityClass(row.task.priority)">{{ priorityLabel(row.task.priority) }}</span> <span class="priority-badge" :class="priorityClass(row.task.priority)">{{ priorityLabel(row.task.priority) }}</span>
<!-- 子进度徽章(done+cancelled / total) --> <!-- 子进度徽章(done+cancelled / total);WK-11:搜索态隐藏(树形进度失真) -->
<span class="sub-progress-badge" :title="$t('tasks.tree.progress')">{{ row.progress!.done }}/{{ row.progress!.total }}</span> <span v-if="!isSearching" class="sub-progress-badge" :title="$t('tasks.tree.progress')">{{ row.progress!.done }}/{{ row.progress!.total }}</span>
</div> </div>
<div class="task-meta"> <div class="task-meta">
<span class="branch-tag" v-if="row.task.branch_name"> <span class="branch-tag" v-if="row.task.branch_name">
<span class="branch-icon"></span>{{ row.task.branch_name }} <span class="branch-icon"></span>{{ row.task.branch_name }}
</span> </span>
<span class="task-date">{{ formatRelative(row.task.updated_at) }}</span> <span class="task-date">{{ formatRelative(row.task.updated_at) }}</span>
<!-- 迷你进度条(渐变填充,宽度=完成子任务百分比) --> <!-- 迷你进度条(渐变填充,宽度=完成子任务百分比);WK-11:搜索态隐藏 -->
<div class="mini-progress" :title="$t('tasks.tree.progress')"> <div v-if="!isSearching" class="mini-progress" :title="$t('tasks.tree.progress')">
<div class="mini-progress-fill" :style="{ width: parentPct(row) + '%' }"></div> <div class="mini-progress-fill" :style="{ width: parentPct(row) + '%' }"></div>
</div> </div>
</div> </div>
@@ -105,7 +110,7 @@
<div v-if="quickMenuId === row.task.id" class="quick-menu" @click.stop> <div v-if="quickMenuId === row.task.id" class="quick-menu" @click.stop>
<div class="quick-menu-section"> <div class="quick-menu-section">
<div class="quick-menu-label">{{ $t('tasks.quickStatus') }}</div> <div class="quick-menu-label">{{ $t('tasks.quickStatus') }}</div>
<button v-for="s in quickStatuses" :key="s.key" class="quick-menu-item" @click="quickAdvance(row.task.id, s.key)"> <button v-for="s in quickStatusesFor(row.task)" :key="s.key" class="quick-menu-item" @click="quickAdvance(row.task.id, s.key)">
<span>{{ s.icon }}</span>{{ s.label }} <span>{{ s.icon }}</span>{{ s.label }}
</button> </button>
</div> </div>
@@ -155,7 +160,7 @@
<div v-if="quickMenuId === child.id" class="quick-menu" @click.stop> <div v-if="quickMenuId === child.id" class="quick-menu" @click.stop>
<div class="quick-menu-section"> <div class="quick-menu-section">
<div class="quick-menu-label">{{ $t('tasks.quickStatus') }}</div> <div class="quick-menu-label">{{ $t('tasks.quickStatus') }}</div>
<button v-for="s in quickStatuses" :key="s.key" class="quick-menu-item" @click="quickAdvance(child.id, s.key)"> <button v-for="s in quickStatusesFor(child)" :key="s.key" class="quick-menu-item" @click="quickAdvance(child.id, s.key)">
<span>{{ s.icon }}</span>{{ s.label }} <span>{{ s.icon }}</span>{{ s.label }}
</button> </button>
</div> </div>
@@ -196,7 +201,7 @@
<div v-if="quickMenuId === row.task.id" class="quick-menu" @click.stop> <div v-if="quickMenuId === row.task.id" class="quick-menu" @click.stop>
<div class="quick-menu-section"> <div class="quick-menu-section">
<div class="quick-menu-label">{{ $t('tasks.quickStatus') }}</div> <div class="quick-menu-label">{{ $t('tasks.quickStatus') }}</div>
<button v-for="s in quickStatuses" :key="s.key" class="quick-menu-item" @click="quickAdvance(row.task.id, s.key)"> <button v-for="s in quickStatusesFor(row.task)" :key="s.key" class="quick-menu-item" @click="quickAdvance(row.task.id, s.key)">
<span>{{ s.icon }}</span>{{ s.label }} <span>{{ s.icon }}</span>{{ s.label }}
</button> </button>
</div> </div>
@@ -274,6 +279,9 @@
</div> </div>
</div> </div>
<!-- 快捷操作失败 toast(WK-13,对齐 Projects.vue 操作反馈) -->
<div v-if="toast.visible" class="toast" :class="'toast-' + toast.type">{{ toast.msg }}</div>
<!-- 确认弹层(删除任务,替代原生 window.confirm) --> <!-- 确认弹层(删除任务,替代原生 window.confirm) -->
<ConfirmDialog :visible="confirmState.visible" :msg="confirmState.msg" @result="answerConfirm" /> <ConfirmDialog :visible="confirmState.visible" :msg="confirmState.msg" @result="answerConfirm" />
</div> </div>
@@ -285,17 +293,20 @@ import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useProjectStore } from '@/stores/project' import { useProjectStore } from '@/stores/project'
import { formatRelative } from '@/utils/time' import { formatRelative } from '@/utils/time'
import { taskStatusLabel as statusLabel, taskStatusClass, priorityLabel, priorityClass } from '../constants/project' import { taskStatusLabel as statusLabel, taskStatusClass, priorityLabel, priorityClass, TASK_STATUS_TRANSITIONS } from '../constants/project'
import { taskApi } from '@/api' import { taskApi } from '@/api'
import type { TaskRecord, TaskQuery, ProjectId } from '@/api/types' import type { TaskRecord, TaskQuery, ProjectId } from '@/api/types'
import ConfirmDialog from '@/components/ConfirmDialog.vue' import ConfirmDialog from '@/components/ConfirmDialog.vue'
import { useConfirm } from '@/composables/useConfirm' import { useConfirm } from '@/composables/useConfirm'
import { usePersistedRef } from '@/composables/usePersistedRef' import { usePersistedRef } from '@/composables/usePersistedRef'
import { useToast } from '@/composables/useToast'
const router = useRouter() const router = useRouter()
const store = useProjectStore() const store = useProjectStore()
const { t } = useI18n() const { t } = useI18n()
const { confirmState, confirmDialog, answerConfirm } = useConfirm() const { confirmState, confirmDialog, answerConfirm } = useConfirm()
// WK-13: toast ( Projects.vue useToast )
const { toast, showToast } = useToast()
// / localStorage(key 'tasks.'), // / localStorage(key 'tasks.'),
// collapsedGroups df-tasks-collapsed(沿) // collapsedGroups df-tasks-collapsed(沿)
@@ -304,6 +315,8 @@ const activeStatus = usePersistedRef('tasks.activeStatus', 'all')
const sortBy = usePersistedRef('tasks.sortBy', 'updated_at') const sortBy = usePersistedRef('tasks.sortBy', 'updated_at')
const searchKeyword = usePersistedRef('tasks.searchKeyword', '') const searchKeyword = usePersistedRef('tasks.searchKeyword', '')
const loading = ref(false) const loading = ref(false)
// WK-11:(keyword ,/ )
const isSearching = computed(() => searchKeyword.value.trim().length > 0)
// F-260805 D5: + ,(page/pageSize/totalTasks ) // F-260805 D5: + ,(page/pageSize/totalTasks )
// (localStorage ) // (localStorage )
@@ -339,7 +352,13 @@ const quickStatuses = computed(() => [
{ key: 'testing', icon: '🧪', label: t('tasks.statusFilter.testing') }, { key: 'testing', icon: '🧪', label: t('tasks.statusFilter.testing') },
{ key: 'done', icon: '✅', label: t('tasks.statusFilter.done') }, { key: 'done', icon: '✅', label: t('tasks.statusFilter.done') },
{ key: 'blocked', icon: '🚫', label: t('tasks.statusFilter.blocked') }, { key: 'blocked', icon: '🚫', label: t('tasks.statusFilter.blocked') },
{ key: 'cancelled', icon: '🗑️', label: t('tasks.statusFilter.cancelled') },
]) ])
/** WK-8:快捷状态目标 = 状态机合法流转目标(TASK_STATUS_TRANSITIONS 过滤,对齐 TaskDetail ADVANCE_MAP) */
function quickStatusesFor(task: TaskRecord): { key: string; icon: string; label: string }[] {
const valid = new Set(TASK_STATUS_TRANSITIONS[task.status] ?? [])
return quickStatuses.value.filter(s => valid.has(s.key))
}
const quickPriorities = computed(() => [ const quickPriorities = computed(() => [
{ value: 0, label: t('tasks.modal.priorityCritical'), cls: 'priority-critical' }, { value: 0, label: t('tasks.modal.priorityCritical'), cls: 'priority-critical' },
{ value: 1, label: t('tasks.modal.priorityHigh'), cls: 'priority-high' }, { value: 1, label: t('tasks.modal.priorityHigh'), cls: 'priority-high' },
@@ -351,14 +370,20 @@ async function quickAdvance(id: string, target: string) {
try { try {
await taskApi.advance(id, target) await taskApi.advance(id, target)
await store.loadTasks(buildTaskQuery()) await store.loadTasks(buildTaskQuery())
} catch (e) { console.error('快捷改状态失败:', e) } } catch (e: any) {
// WK-13: toast ( console.error ,)
showToast(e?.toString() ?? t('common.unknownError'), 'error')
}
} }
async function quickPriority(id: string, priority: number) { async function quickPriority(id: string, priority: number) {
quickMenuId.value = null quickMenuId.value = null
try { try {
await store.updateTask(id, 'priority', String(priority)) // WK-13: taskApi ( store.updateTask, store.error )
await taskApi.update(id, 'priority', String(priority))
await store.loadTasks(buildTaskQuery()) await store.loadTasks(buildTaskQuery())
} catch (e) { console.error('快捷改优先级失败:', e) } } catch (e: any) {
showToast(e?.toString() ?? t('common.unknownError'), 'error')
}
} }
async function quickDelete(task: TaskRecord, childCount = 0) { async function quickDelete(task: TaskRecord, childCount = 0) {
quickMenuId.value = null quickMenuId.value = null
@@ -368,7 +393,9 @@ async function quickDelete(task: TaskRecord, childCount = 0) {
: t('tasks.confirmDelete', { title: task.title }) : t('tasks.confirmDelete', { title: task.title })
if (!await confirmDialog(msg)) return if (!await confirmDialog(msg)) return
try { try {
await store.deleteTask(task.id) // WK-13:deleteTask , toast(store.error error-banner)
const ok = await store.deleteTask(task.id)
if (!ok) showToast(t('tasks.err.deleteFailed'), 'error')
} catch (e) { console.error('删除失败:', e) } } catch (e) { console.error('删除失败:', e) }
} }
// //
@@ -392,6 +419,8 @@ try {
// //
let _searchTimer: ReturnType<typeof setTimeout> | null = null let _searchTimer: ReturnType<typeof setTimeout> | null = null
watch(searchKeyword, () => { watch(searchKeyword, () => {
// WK-10: barrel(,)
store.setActiveTaskKeyword(searchKeyword.value.trim() || undefined)
if (_searchTimer) clearTimeout(_searchTimer) if (_searchTimer) clearTimeout(_searchTimer)
_searchTimer = setTimeout(() => { _searchTimer = setTimeout(() => {
loading.value = true loading.value = true
@@ -604,6 +633,7 @@ function onKeydown(e: KeyboardEvent) {
onMounted(async () => { onMounted(async () => {
store.setActiveTaskProject(activeProject.value) store.setActiveTaskProject(activeProject.value)
store.setActiveTaskStatus(activeStatus.value) store.setActiveTaskStatus(activeStatus.value)
store.setActiveTaskKeyword(searchKeyword.value.trim() || undefined)
loading.value = true loading.value = true
try { try {
await Promise.all([ await Promise.all([
@@ -692,7 +722,8 @@ onUnmounted(() => {
background: var(--df-bg-card); background: var(--df-bg-card);
border: 0.5px solid var(--df-border); border: 0.5px solid var(--df-border);
border-radius: var(--df-radius-lg); border-radius: var(--df-radius-lg);
overflow: hidden; /* WK-9: overflow:hidden (.quick-menu )
改为组自身不裁切,圆角裁剪下放给 header(顶角)与末行(底角,见下),菜单可正常浮出 */
} }
.group-header { .group-header {
@@ -704,6 +735,9 @@ onUnmounted(() => {
user-select: none; user-select: none;
border-bottom: 0.5px solid var(--df-border); border-bottom: 0.5px solid var(--df-border);
transition: background 0.1s; transition: background 0.1s;
/* WK-9:承接原 .task-group overflow:hidden 的顶角圆角裁剪(自身 overflow,组不再裁切快捷菜单) */
border-radius: var(--df-radius-lg) var(--df-radius-lg) 0 0;
overflow: hidden;
} }
.group-header:hover { background: var(--df-accent-bg); } .group-header:hover { background: var(--df-accent-bg); }
.task-group.collapsed .group-header { border-bottom: none; } .task-group.collapsed .group-header { border-bottom: none; }
@@ -733,6 +767,8 @@ onUnmounted(() => {
cursor: pointer; cursor: pointer;
} }
.task-item:last-child { border-bottom: none; } .task-item:last-child { border-bottom: none; }
/* WK-9:末行承接组底角圆角裁剪(替代原 .task-group overflow:hidden) */
.task-item:last-child { border-radius: 0 0 var(--df-radius-lg) var(--df-radius-lg); }
.task-item:hover { background: var(--df-accent-bg); } .task-item:hover { background: var(--df-accent-bg); }
.task-main { flex: 1; min-width: 0; } .task-main { flex: 1; min-width: 0; }
@@ -781,13 +817,7 @@ onUnmounted(() => {
color: var(--df-text-dim); color: var(--df-text-dim);
} }
.status-tag { /* status-tag 尺寸/圆角 + status-* 7 态色已提取到全局 components.css(WK-6 对齐三视图),此处不再重复定义 */
font-size: 11px;
padding: 2px 8px;
border-radius: var(--df-radius-xs);
flex-shrink: 0;
white-space: nowrap;
}
/* 快捷操作 */ /* 快捷操作 */
.task-actions { .task-actions {
@@ -909,6 +939,17 @@ onUnmounted(() => {
} }
.quick-menu-danger { color: var(--df-danger); } .quick-menu-danger { color: var(--df-danger); }
/* ===== 快捷操作失败 toast(WK-13,对齐 Projects.vue 操作反馈) ===== */
.toast {
position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%);
padding: 10px 18px; border-radius: var(--df-radius-sm);
font-size: 13px; z-index: 200; max-width: 80vw;
box-shadow: 0 4px 12px rgba(0,0,0,0.2);
}
.toast-info { background: var(--df-accent); color: #fff; }
.toast-error { background: var(--df-danger); color: #fff; }
.toast-success { background: var(--df-success); color: #fff; }
/* 空态 */ /* 空态 */
/* empty-state 已提取到全局 global.css */ /* empty-state 已提取到全局 global.css */
.empty-icon { font-size: 32px; opacity: 0.4; } .empty-icon { font-size: 32px; opacity: 0.4; }