新增: 任务推进链(7态状态机+advance_task CAS原子写)+软删除+前后端7态对齐
This commit is contained in:
@@ -8,6 +8,7 @@ df-core = { path = "../df-core" }
|
||||
df-execute = { path = "../df-execute" }
|
||||
df-workflow = { path = "../df-workflow" }
|
||||
df-ai = { path = "../df-ai" }
|
||||
df-storage = { path = "../df-storage" }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
|
||||
@@ -3,3 +3,5 @@
|
||||
pub mod ai_node;
|
||||
pub mod human_node;
|
||||
pub mod script_node;
|
||||
pub mod task_advance_node;
|
||||
pub mod task_state_machine;
|
||||
|
||||
@@ -36,6 +36,7 @@ impl Node for ScriptNode {
|
||||
working_dir,
|
||||
env: std::collections::HashMap::new(),
|
||||
timeout_secs,
|
||||
shell_type: Default::default(),
|
||||
};
|
||||
|
||||
tracing::info!("ScriptNode 执行命令: {}", command);
|
||||
|
||||
341
crates/df-nodes/src/task_advance_node.rs
Normal file
341
crates/df-nodes/src/task_advance_node.rs
Normal file
@@ -0,0 +1,341 @@
|
||||
//! 任务推进节点 — advance_task 推进链触发器(F-260616-02)
|
||||
//!
|
||||
//! 实现推进链的唯一 status 写入路径(D-260616-03 落 df-nodes Node):
|
||||
//! 1. 读当前 TaskRecord(取 from status)
|
||||
//! 2. 状态机校验 can_transition(from, to)(task_state_machine)
|
||||
//! 3. 原子写:下沉 SQL `WHERE id AND status=expected` 防 TOCTOU,退回转换一并
|
||||
//! review_rounds+=1(见 df_storage::crud::TaskRepo::advance_status_atomic)
|
||||
//!
|
||||
//! 两条调用路径复用同一推进逻辑:
|
||||
//! - DAG 工作流:TaskAdvanceNode 实现 Node trait,从 NodeContext.config 读 task_id /
|
||||
//! target_status,持有 Arc<Database> 构造 TaskRepo。
|
||||
//! - IPC 直驱:advance_task_atomic 公开 async fn,src-tauri commands::task::advance_task
|
||||
//! thin 入口直接调用(无需构造 NodeContext,避免 EventBus/StateMachine 等重依赖)。
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use df_storage::crud::TaskRepo;
|
||||
use df_storage::db::Database;
|
||||
use df_storage::models::TaskRecord;
|
||||
use df_workflow::node::{Node, NodeContext, NodeOutput, NodeResult, NodeSchema};
|
||||
|
||||
use crate::task_state_machine::{can_transition, is_regression, is_valid_state};
|
||||
|
||||
/// 推进任务到目标状态(核心逻辑,DAG 节点与 IPC 入口共用)。
|
||||
///
|
||||
/// 入参:
|
||||
/// - `repo`:df-storage TaskRepo
|
||||
/// - `id`:任务 ID
|
||||
/// - `target_status`:目标状态(7 态之一,snake_case)
|
||||
///
|
||||
/// 流程:
|
||||
/// 1. 任务存在性:找不到 → InvalidState 错误(复用 Error::InvalidState 语义,前端可据此提示)
|
||||
/// 2. target_status 合法性:非 7 态 → Validation 错误(防脏数据直入推进链)
|
||||
/// 3. 状态机:can_transition(from, to) 不合法 → InvalidState 错误(含 from/to 上下文)
|
||||
/// 4. 原子写:advance_status_atomic CAS,to 是退回转换时 bump_rounds=true
|
||||
/// 5. CAS 失败(affected==0):状态已被并发改动 → InvalidState 错误(防 TOCTOU 静默成功)
|
||||
///
|
||||
/// 返回:成功推进后的最新 TaskRecord(含新 status / 累加后的 review_rounds / 新 updated_at)。
|
||||
pub async fn advance_task_atomic(
|
||||
repo: &TaskRepo,
|
||||
id: &str,
|
||||
target_status: &str,
|
||||
) -> df_core::error::Result<TaskRecord> {
|
||||
// 1. target 合法性(7 态之一)。先于读库校验:即便任务不存在,也先拒绝非法状态值。
|
||||
if !is_valid_state(target_status) {
|
||||
return Err(df_core::error::Error::Validation(format!(
|
||||
"非法 target_status {:?},合法值: todo/in_progress/in_review/testing/done/blocked/cancelled",
|
||||
target_status
|
||||
)));
|
||||
}
|
||||
|
||||
// 2. 读当前任务(取 from status)。
|
||||
let current = repo
|
||||
.get_by_id(id)
|
||||
.await?
|
||||
.ok_or_else(|| df_core::error::Error::NotFound(format!("任务 {} 不存在", id)))?;
|
||||
|
||||
// 3. 状态机校验(同态拒绝:todo→todo 等,can_transition 返回 false)。
|
||||
let from = current.status.as_str();
|
||||
if from == target_status {
|
||||
return Err(df_core::error::Error::InvalidState {
|
||||
current: from.to_string(),
|
||||
expected: target_status.to_string(),
|
||||
});
|
||||
}
|
||||
if !can_transition(from, target_status) {
|
||||
return Err(df_core::error::Error::InvalidState {
|
||||
current: from.to_string(),
|
||||
expected: target_status.to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
// 4. 原子 CAS 写。退回转换一并 review_rounds+=1。
|
||||
let bump = is_regression(from, target_status);
|
||||
let updated = repo
|
||||
.advance_status_atomic(id, from, target_status, bump)
|
||||
.await?;
|
||||
|
||||
// 5. CAS 失败:并发已改动 status(或任务被删),拒绝静默成功。
|
||||
updated.ok_or_else(|| df_core::error::Error::InvalidState {
|
||||
current: "已变更(并发推进或旁路修改)".to_string(),
|
||||
expected: from.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// DAG 工作流节点 — TaskAdvanceNode(推进链在 DAG 内的形态)
|
||||
// ============================================================
|
||||
|
||||
/// 任务推进节点 — DAG 工作流内触发 advance_task。
|
||||
///
|
||||
/// 持有 Arc<Database> 在 NodeRegistry 注册时注入(state.rs build_registry),
|
||||
/// Node::execute 从 NodeContext.config 读 task_id / target_status,调 advance_task_atomic。
|
||||
/// 当前推进链 F-01~04 阶段为手动推进(IPC 直驱),DAG 形态为阶段 2(工作流联动)预留。
|
||||
pub struct TaskAdvanceNode {
|
||||
db: Arc<Database>,
|
||||
}
|
||||
|
||||
impl TaskAdvanceNode {
|
||||
/// 构造节点(注册表工厂调用,注入数据库句柄)
|
||||
pub fn new(db: Arc<Database>) -> Self {
|
||||
Self { db }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Node for TaskAdvanceNode {
|
||||
async fn execute(&self, ctx: NodeContext) -> NodeResult {
|
||||
let task_id = ctx
|
||||
.config
|
||||
.get("task_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("TaskAdvanceNode 缺少必填参数: task_id"))?;
|
||||
let target_status = ctx
|
||||
.config
|
||||
.get("target_status")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("TaskAdvanceNode 缺少必填参数: target_status"))?;
|
||||
|
||||
let repo = TaskRepo::new(&self.db);
|
||||
let updated = advance_task_atomic(&repo, task_id, target_status)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("推进任务失败: {}", e))?;
|
||||
|
||||
Ok(NodeOutput::from_value(serde_json::json!({
|
||||
"task": updated,
|
||||
"task_id": updated.id,
|
||||
"status": updated.status,
|
||||
"review_rounds": updated.review_rounds,
|
||||
})))
|
||||
}
|
||||
|
||||
fn schema(&self) -> NodeSchema {
|
||||
NodeSchema {
|
||||
params: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"task_id": { "type": "string" },
|
||||
"target_status": {
|
||||
"type": "string",
|
||||
"enum": ["todo", "in_progress", "in_review", "testing", "done", "blocked", "cancelled"]
|
||||
}
|
||||
},
|
||||
"required": ["task_id", "target_status"]
|
||||
}),
|
||||
output: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"task_id": { "type": "string" },
|
||||
"status": { "type": "string" },
|
||||
"review_rounds": { "type": "integer" }
|
||||
}
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn node_type(&self) -> &str {
|
||||
"task_advance"
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 单元测试 — advance_task_atomic 状态机集成(内存 DB)
|
||||
// ============================================================
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use df_storage::crud::ProjectRepo;
|
||||
use df_storage::models::{ProjectRecord, TaskRecord};
|
||||
|
||||
fn rec(id: &str, status: &str) -> TaskRecord {
|
||||
TaskRecord {
|
||||
id: id.to_string(),
|
||||
project_id: "p1".to_string(),
|
||||
title: format!("t-{id}"),
|
||||
description: "".to_string(),
|
||||
status: status.to_string(),
|
||||
priority: 2,
|
||||
branch_name: None,
|
||||
assignee: None,
|
||||
workflow_def_id: None,
|
||||
base_branch: None,
|
||||
review_rounds: 0,
|
||||
created_at: "0".to_string(),
|
||||
updated_at: "0".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn setup() -> TaskRepo {
|
||||
let db = Database::open_in_memory().await.expect("open_in_memory");
|
||||
// 先插父项目,满足 tasks.project_id FK 约束(PRAGMA foreign_keys=ON)。
|
||||
ProjectRepo::new(&db)
|
||||
.insert(ProjectRecord {
|
||||
id: "p1".to_string(),
|
||||
name: "proj".to_string(),
|
||||
description: "".to_string(),
|
||||
status: "planning".to_string(),
|
||||
idea_id: None,
|
||||
path: None,
|
||||
stack: None,
|
||||
created_at: "0".to_string(),
|
||||
updated_at: "0".to_string(),
|
||||
})
|
||||
.await
|
||||
.expect("insert project");
|
||||
TaskRepo::new(&db)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn forward_path_todo_to_done() {
|
||||
let repo = setup().await;
|
||||
repo.insert(rec("t1", "todo")).await.unwrap();
|
||||
// 主路径逐级推进
|
||||
let r = advance_task_atomic(&repo, "t1", "in_progress").await.unwrap();
|
||||
assert_eq!(r.status, "in_progress");
|
||||
assert_eq!(r.review_rounds, 0);
|
||||
let r = advance_task_atomic(&repo, "t1", "in_review").await.unwrap();
|
||||
assert_eq!(r.status, "in_review");
|
||||
assert_eq!(r.review_rounds, 0);
|
||||
let r = advance_task_atomic(&repo, "t1", "testing").await.unwrap();
|
||||
assert_eq!(r.status, "testing");
|
||||
let r = advance_task_atomic(&repo, "t1", "done").await.unwrap();
|
||||
assert_eq!(r.status, "done");
|
||||
assert_eq!(r.review_rounds, 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn regression_in_review_to_in_progress_bumps_rounds() {
|
||||
let repo = setup().await;
|
||||
repo.insert(rec("t1", "in_review")).await.unwrap();
|
||||
let r = advance_task_atomic(&repo, "t1", "in_progress").await.unwrap();
|
||||
assert_eq!(r.status, "in_progress");
|
||||
assert_eq!(r.review_rounds, 1, "退回应 +1");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn regression_testing_to_in_review_bumps_rounds() {
|
||||
let repo = setup().await;
|
||||
repo.insert(rec("t1", "testing")).await.unwrap();
|
||||
let r = advance_task_atomic(&repo, "t1", "in_review").await.unwrap();
|
||||
assert_eq!(r.status, "in_review");
|
||||
assert_eq!(r.review_rounds, 1, "退回应 +1");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn multiple_regressions_accumulate() {
|
||||
let repo = setup().await;
|
||||
repo.insert(rec("t1", "in_review")).await.unwrap();
|
||||
// in_review → in_progress (+1) → in_review (前向,不动) → in_progress (+1=2)
|
||||
advance_task_atomic(&repo, "t1", "in_progress").await.unwrap();
|
||||
advance_task_atomic(&repo, "t1", "in_review").await.unwrap();
|
||||
let r = advance_task_atomic(&repo, "t1", "in_progress").await.unwrap();
|
||||
assert_eq!(r.review_rounds, 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn illegal_skip_rejected() {
|
||||
let repo = setup().await;
|
||||
repo.insert(rec("t1", "todo")).await.unwrap();
|
||||
let err = advance_task_atomic(&repo, "t1", "done").await.unwrap_err();
|
||||
assert!(matches!(err, df_core::error::Error::InvalidState { .. }));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn terminal_done_no_successor() {
|
||||
let repo = setup().await;
|
||||
repo.insert(rec("t1", "done")).await.unwrap();
|
||||
let err = advance_task_atomic(&repo, "t1", "todo").await.unwrap_err();
|
||||
assert!(matches!(err, df_core::error::Error::InvalidState { .. }));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn same_status_rejected() {
|
||||
let repo = setup().await;
|
||||
repo.insert(rec("t1", "in_progress")).await.unwrap();
|
||||
let err = advance_task_atomic(&repo, "t1", "in_progress").await.unwrap_err();
|
||||
assert!(matches!(err, df_core::error::Error::InvalidState { .. }));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn invalid_target_rejected() {
|
||||
let repo = setup().await;
|
||||
repo.insert(rec("t1", "todo")).await.unwrap();
|
||||
let err = advance_task_atomic(&repo, "t1", "merged").await.unwrap_err();
|
||||
assert!(matches!(err, df_core::error::Error::Validation(_)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn not_found_rejected() {
|
||||
let repo = setup().await;
|
||||
let err = advance_task_atomic(&repo, "nope", "in_progress").await.unwrap_err();
|
||||
assert!(matches!(err, df_core::error::Error::NotFound(_)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn concurrent_cas_change_detected() {
|
||||
// 模拟 TOCTOU:推进前已被旁路改 status。CAS 的 expected 与库内不符 → None → 报错。
|
||||
let repo = setup().await;
|
||||
repo.insert(rec("t1", "todo")).await.unwrap();
|
||||
// 旁路把 status 改成 in_progress(模拟另一路并发推进)
|
||||
repo.update_field("t1", "status", "in_progress").await.unwrap();
|
||||
// 此时读出来是 in_progress,推进到 in_review 是合法的——这测的不是 CAS 失败,
|
||||
// 而是验证「读后改」路径在 status 一致时正常。CAS 失败路径靠 advance_status_atomic
|
||||
// 自身的 None 返回覆盖(下方 cas_returns_none_when_status_mismatch 单测)。
|
||||
let r = advance_task_atomic(&repo, "t1", "in_review").await.unwrap();
|
||||
assert_eq!(r.status, "in_review");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cas_returns_none_when_status_mismatch() {
|
||||
let repo = setup().await;
|
||||
repo.insert(rec("t1", "todo")).await.unwrap();
|
||||
// 直接调底层:expected 传错(模拟读到 todo 但实际已被改成 in_progress)
|
||||
let r = repo
|
||||
.advance_status_atomic("t1", "todo", "in_review", false)
|
||||
.await
|
||||
.unwrap();
|
||||
// 此时 status 确实是 todo,CAS 成功,返回 Some
|
||||
assert!(r.is_some());
|
||||
// 再用错误的 expected todo(实际已是 in_review)→ CAS 失败 None
|
||||
let r = repo
|
||||
.advance_status_atomic("t1", "todo", "done", false)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(r.is_none(), "expected 与库内不符应 CAS 失败");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn blocked_round_trip_does_not_bump() {
|
||||
let repo = setup().await;
|
||||
repo.insert(rec("t1", "in_progress")).await.unwrap();
|
||||
let r = advance_task_atomic(&repo, "t1", "blocked").await.unwrap();
|
||||
assert_eq!(r.status, "blocked");
|
||||
assert_eq!(r.review_rounds, 0, "进 blocked 不累加");
|
||||
let r = advance_task_atomic(&repo, "t1", "in_progress").await.unwrap();
|
||||
assert_eq!(r.status, "in_progress");
|
||||
assert_eq!(r.review_rounds, 0, "解除 blocked 不累加");
|
||||
}
|
||||
}
|
||||
256
crates/df-nodes/src/task_state_machine.rs
Normal file
256
crates/df-nodes/src/task_state_machine.rs
Normal file
@@ -0,0 +1,256 @@
|
||||
//! 任务推进状态机 — 7 态合法转换定义(F-260616-01)
|
||||
//!
|
||||
//! 独立模块,非挂在 df-core::TaskStatus enum 上(对齐 D-260616-03「推进链业务逻辑落
|
||||
//! df-nodes」)。本模块只做「给定 from/to 是否合法」的纯函数判定,不触碰存储层
|
||||
//! (原子写 SQL 在 task_advance_node.rs 完成,见 F-260616-02)。
|
||||
//!
|
||||
//! 7 态(与 df-core::TaskStatus / 前端对齐,D-260616-01):
|
||||
//! todo / in_progress / in_review / testing / done / blocked / cancelled
|
||||
//!
|
||||
//! 闸门链主路径: todo → in_progress → in_review → testing → done
|
||||
//!
|
||||
//! 合法转换矩阵(以报告 docs/05-代码审查/任务执行与推进能力分析-2026-06-16.md §8
|
||||
//! 与决策 docs/02-架构设计/任务推进链实施路径-2026-06-16.md F-01 为准):
|
||||
//! - todo → in_progress(开始), cancelled(取消)
|
||||
//! - in_progress → in_review(提交审查), blocked(阻塞), cancelled
|
||||
//! - in_review → testing(审查通过进测试), in_progress(退回修改, review_rounds+1),
|
||||
//! blocked, cancelled
|
||||
//! - testing → done(测试通过), in_review(退回重审, review_rounds+1),
|
||||
//! blocked, cancelled
|
||||
//! - done → 终态(原则上不可变;若需重开走 cancelled 或新任务)
|
||||
//! - blocked → in_progress(解除阻塞继续), cancelled
|
||||
//! - cancelled → 终态
|
||||
|
||||
// ============================================================
|
||||
// 状态字符串常量 — 与 df-core::TaskStatus::as_str 一一对应
|
||||
// ============================================================
|
||||
//
|
||||
// 不复用 df-core::TaskStatus enum(独立模块定位 + 避免推进链判定耦合存储枚举类型),
|
||||
// 但字符串值严格对齐(df-core::TaskStatus::as_str 产出的小写 snake_case),
|
||||
// 保证状态机判定的 from/to 与数据库 status 列存值语义一致。
|
||||
|
||||
/// 待开始
|
||||
pub const TODO: &str = "todo";
|
||||
/// 进行中
|
||||
pub const IN_PROGRESS: &str = "in_progress";
|
||||
/// 代码审查中
|
||||
pub const IN_REVIEW: &str = "in_review";
|
||||
/// 测试中
|
||||
pub const TESTING: &str = "testing";
|
||||
/// 已完成(终态)
|
||||
pub const DONE: &str = "done";
|
||||
/// 已阻塞
|
||||
pub const BLOCKED: &str = "blocked";
|
||||
/// 已取消(终态)
|
||||
pub const CANCELLED: &str = "cancelled";
|
||||
|
||||
/// 全部合法状态值(供输入校验与错误提示复用)
|
||||
pub const ALL_STATES: &[&str] = &[
|
||||
TODO,
|
||||
IN_PROGRESS,
|
||||
IN_REVIEW,
|
||||
TESTING,
|
||||
DONE,
|
||||
BLOCKED,
|
||||
CANCELLED,
|
||||
];
|
||||
|
||||
/// 字符串是否为合法状态值
|
||||
pub fn is_valid_state(s: &str) -> bool {
|
||||
ALL_STATES.contains(&s)
|
||||
}
|
||||
|
||||
/// 判定从 `from` 到 `to` 的状态转换是否合法(状态机核心)。
|
||||
///
|
||||
/// 终态(done/cancelled)无任何合法后继;非法或未知状态入参一律返回 false
|
||||
/// (调用方 advance_task 在前置校验已拦截非法 status,此处保守拒绝防漏)。
|
||||
///
|
||||
/// 注意:仅判「是否合法」,不判「是否是退回」——退回(导致 review_rounds+1)
|
||||
/// 的识别见 [`is_regression`],advance_task 据此决定是否一并 SET review_rounds+=1。
|
||||
pub fn can_transition(from: &str, to: &str) -> bool {
|
||||
use std::sync::OnceLock;
|
||||
|
||||
// 转换表静态构造一次(运行时常量,无锁开销分摊)。行=from,列=to。
|
||||
// 表条目为 true 即合法转换。未列入的 (from, to) 一律 false。
|
||||
static TABLE: OnceLock<std::collections::HashMap<(&'static str, &'static str), bool>> =
|
||||
OnceLock::new();
|
||||
let table = TABLE.get_or_init(|| {
|
||||
let mut m = std::collections::HashMap::new();
|
||||
let allowed: &[(&str, &str)] = &[
|
||||
// todo → in_progress(开始), cancelled(取消)
|
||||
(TODO, IN_PROGRESS),
|
||||
(TODO, CANCELLED),
|
||||
// in_progress → in_review(提交审查), blocked(阻塞), cancelled
|
||||
(IN_PROGRESS, IN_REVIEW),
|
||||
(IN_PROGRESS, BLOCKED),
|
||||
(IN_PROGRESS, CANCELLED),
|
||||
// in_review → testing(审查通过进测试), in_progress(退回修改),
|
||||
// blocked, cancelled
|
||||
(IN_REVIEW, TESTING),
|
||||
(IN_REVIEW, IN_PROGRESS),
|
||||
(IN_REVIEW, BLOCKED),
|
||||
(IN_REVIEW, CANCELLED),
|
||||
// testing → done(测试通过), in_review(退回重审), blocked, cancelled
|
||||
(TESTING, DONE),
|
||||
(TESTING, IN_REVIEW),
|
||||
(TESTING, BLOCKED),
|
||||
(TESTING, CANCELLED),
|
||||
// done → 终态,无后继
|
||||
// blocked → in_progress(解除阻塞继续), cancelled
|
||||
(BLOCKED, IN_PROGRESS),
|
||||
(BLOCKED, CANCELLED),
|
||||
// cancelled → 终态,无后继
|
||||
];
|
||||
for (f, t) in allowed {
|
||||
m.insert((*f, *t), true);
|
||||
}
|
||||
m
|
||||
});
|
||||
|
||||
table.get(&(from, to)).copied().unwrap_or(false)
|
||||
}
|
||||
|
||||
/// 判定一次转换是否为「退回」(review_rounds 应 +1)。
|
||||
///
|
||||
/// 退回语义:任务从前向推进阶段回退到更早的推进阶段,意味着上一轮产出未过闸门、
|
||||
/// 需重做。具体两类:
|
||||
/// - in_review → in_progress(审查退回修改)
|
||||
/// - testing → in_review(测试退回重审)
|
||||
///
|
||||
/// 其余合法转换(前向推进 / 进出 blocked / 进 cancelled)均不累加 review_rounds。
|
||||
pub fn is_regression(from: &str, to: &str) -> bool {
|
||||
matches!((from, to), (IN_REVIEW, IN_PROGRESS) | (TESTING, IN_REVIEW))
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 单元测试 — 转换矩阵 + 边界
|
||||
// ============================================================
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// ---------- can_transition 闸门主路径 ----------
|
||||
|
||||
#[test]
|
||||
fn main_path_todo_to_done_all_forward() {
|
||||
assert!(can_transition(TODO, IN_PROGRESS));
|
||||
assert!(can_transition(IN_PROGRESS, IN_REVIEW));
|
||||
assert!(can_transition(IN_REVIEW, TESTING));
|
||||
assert!(can_transition(TESTING, DONE));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancel_from_any_non_terminal() {
|
||||
for s in [TODO, IN_PROGRESS, IN_REVIEW, TESTING, BLOCKED] {
|
||||
assert!(can_transition(s, CANCELLED), "应允许 {s} → cancelled");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blocked_round_trip() {
|
||||
assert!(can_transition(IN_PROGRESS, BLOCKED));
|
||||
assert!(can_transition(BLOCKED, IN_PROGRESS));
|
||||
assert!(can_transition(IN_REVIEW, BLOCKED));
|
||||
assert!(can_transition(TESTING, BLOCKED));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn regression_paths_allowed() {
|
||||
assert!(can_transition(IN_REVIEW, IN_PROGRESS));
|
||||
assert!(can_transition(TESTING, IN_REVIEW));
|
||||
}
|
||||
|
||||
// ---------- 非法转换被拒 ----------
|
||||
|
||||
#[test]
|
||||
fn illegal_forward_skips_rejected() {
|
||||
// 不允许跳过闸门(直奔 done)
|
||||
assert!(!can_transition(TODO, DONE));
|
||||
assert!(!can_transition(TODO, TESTING));
|
||||
assert!(!can_transition(TODO, IN_REVIEW));
|
||||
assert!(!can_transition(IN_PROGRESS, DONE));
|
||||
assert!(!can_transition(IN_PROGRESS, TESTING));
|
||||
assert!(!can_transition(IN_REVIEW, DONE));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_states_have_no_successors() {
|
||||
// done / cancelled 是终态,任何后继都拒绝
|
||||
for term in [DONE, CANCELLED] {
|
||||
for to in ALL_STATES {
|
||||
assert!(!can_transition(term, to), "终态 {term} 不应有后继 → {to}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backward_to_todo_rejected() {
|
||||
// 不允许回退到 todo(开始即不可撤回)
|
||||
for s in [IN_PROGRESS, IN_REVIEW, TESTING, BLOCKED] {
|
||||
assert!(!can_transition(s, TODO), "不应允许 {s} → todo");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blocked_only_to_in_progress_or_cancelled() {
|
||||
assert!(can_transition(BLOCKED, IN_PROGRESS));
|
||||
assert!(can_transition(BLOCKED, CANCELLED));
|
||||
// 解除阻塞不能直接跳到 in_review/testing/done
|
||||
assert!(!can_transition(BLOCKED, IN_REVIEW));
|
||||
assert!(!can_transition(BLOCKED, TESTING));
|
||||
assert!(!can_transition(BLOCKED, DONE));
|
||||
}
|
||||
|
||||
// ---------- 未知状态 ----------
|
||||
|
||||
#[test]
|
||||
fn unknown_states_rejected() {
|
||||
assert!(!can_transition("unknown", TODO));
|
||||
assert!(!can_transition(TODO, "unknown"));
|
||||
assert!(!can_transition("", ""));
|
||||
}
|
||||
|
||||
// ---------- is_regression ----------
|
||||
|
||||
#[test]
|
||||
fn regression_only_on_review_back_edges() {
|
||||
assert!(is_regression(IN_REVIEW, IN_PROGRESS));
|
||||
assert!(is_regression(TESTING, IN_REVIEW));
|
||||
// 前向推进不累加
|
||||
assert!(!is_regression(TODO, IN_PROGRESS));
|
||||
assert!(!is_regression(IN_PROGRESS, IN_REVIEW));
|
||||
assert!(!is_regression(IN_REVIEW, TESTING));
|
||||
assert!(!is_regression(TESTING, DONE));
|
||||
// 进出 blocked 不累加
|
||||
assert!(!is_regression(IN_PROGRESS, BLOCKED));
|
||||
assert!(!is_regression(BLOCKED, IN_PROGRESS));
|
||||
// 进 cancelled 不累加
|
||||
assert!(!is_regression(IN_REVIEW, CANCELLED));
|
||||
// 不合法的"退回"(实际上不存在的转换)也不应判定为 regression
|
||||
assert!(!is_regression(TESTING, IN_PROGRESS));
|
||||
}
|
||||
|
||||
// ---------- is_valid_state ----------
|
||||
|
||||
#[test]
|
||||
fn is_valid_state_accepts_7_known() {
|
||||
for s in ALL_STATES {
|
||||
assert!(is_valid_state(s));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_valid_state_rejects_unknown() {
|
||||
assert!(!is_valid_state(""));
|
||||
assert!(!is_valid_state("unknown"));
|
||||
assert!(!is_valid_state("TODO")); // 大写
|
||||
assert!(!is_valid_state("merged")); // 历史 5 态残留
|
||||
assert!(!is_valid_state("abandoned"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_states_has_seven_entries() {
|
||||
assert_eq!(ALL_STATES.len(), 7);
|
||||
}
|
||||
}
|
||||
@@ -310,7 +310,8 @@ impl SettingsRepo {
|
||||
/// 白名单两重作用:① 防注入(列名参数化前校验);② 按表隔离(update_task 误传
|
||||
/// projects 的 "name" 会在校验阶段拒绝,而非靠 SQLite "no such column" 兜底报错)。
|
||||
/// 专用更新路径的列不列入:knowledges.embedding(set_embedding)、projects.deleted_at
|
||||
/// (soft_delete/restore)。未登记的表返回 None → 放行(仅靠参数化防注入,向后兼容)。
|
||||
/// (soft_delete/restore)、tasks.deleted_at(soft_delete/restore)。未登记的表返回 None
|
||||
/// → 放行(仅靠参数化防注入,向后兼容)。
|
||||
pub fn allowed_columns_for(table: &str) -> Option<&'static [&'static str]> {
|
||||
Some(match table {
|
||||
"ideas" => &[
|
||||
@@ -322,8 +323,13 @@ pub fn allowed_columns_for(table: &str) -> Option<&'static [&'static str]> {
|
||||
"updated_at",
|
||||
],
|
||||
"tasks" => &[
|
||||
"id", "project_id", "title", "description", "status", "priority", "branch_name",
|
||||
"assignee", "workflow_def_id", "base_branch", "created_at", "updated_at",
|
||||
// id/created_at 不列入:主键与创建时间不可通过通用 update_field 改写
|
||||
// (防篡改主键/伪造创建时间/跨项目移动)。
|
||||
"project_id", "title", "description", "status", "priority", "branch_name",
|
||||
"assignee", "workflow_def_id", "base_branch", "updated_at",
|
||||
// TODO(B-260616-16): project_id 跨表存在性校验待 commands/task.rs 层补。
|
||||
// 通用 CRUD 层(db repo)只懂表/列语义,不持有跨表业务约束(查 projects 表存在性)。
|
||||
// project_id 当前可在白名单内改写,合法目标存在性由上层命令层校验。
|
||||
],
|
||||
"releases" => &[
|
||||
"id", "project_id", "version", "status", "task_ids", "changelog", "created_at",
|
||||
@@ -464,6 +470,7 @@ fn task_from_row(row: &Row<'_>) -> std::result::Result<TaskRecord, rusqlite::Err
|
||||
assignee: row.get("assignee")?,
|
||||
workflow_def_id: row.get("workflow_def_id")?,
|
||||
base_branch: row.get("base_branch")?,
|
||||
review_rounds: row.get("review_rounds")?,
|
||||
created_at: row.get("created_at")?,
|
||||
updated_at: row.get("updated_at")?,
|
||||
})
|
||||
@@ -724,27 +731,173 @@ impl_repo!(
|
||||
from_row => |row| task_from_row(row),
|
||||
insert => |conn, rec| {
|
||||
conn.execute(
|
||||
"INSERT INTO tasks (id, project_id, title, description, status, priority, branch_name, assignee, workflow_def_id, base_branch, created_at, updated_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
|
||||
"INSERT INTO tasks (id, project_id, title, description, status, priority, branch_name, assignee, workflow_def_id, base_branch, review_rounds, created_at, updated_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
|
||||
params![
|
||||
rec.id, rec.project_id, rec.title, rec.description, rec.status, rec.priority,
|
||||
rec.branch_name, rec.assignee, rec.workflow_def_id, rec.base_branch,
|
||||
rec.created_at, rec.updated_at
|
||||
rec.review_rounds, rec.created_at, rec.updated_at
|
||||
],
|
||||
)
|
||||
},
|
||||
update => |conn, rec| {
|
||||
conn.execute(
|
||||
"UPDATE tasks SET project_id = ?1, title = ?2, description = ?3, status = ?4, priority = ?5, branch_name = ?6, assignee = ?7, workflow_def_id = ?8, base_branch = ?9, updated_at = ?10 WHERE id = ?11",
|
||||
"UPDATE tasks SET project_id = ?1, title = ?2, description = ?3, status = ?4, priority = ?5, branch_name = ?6, assignee = ?7, workflow_def_id = ?8, base_branch = ?9, review_rounds = ?10, updated_at = ?11 WHERE id = ?12",
|
||||
params![
|
||||
rec.project_id, rec.title, rec.description, rec.status, rec.priority,
|
||||
rec.branch_name, rec.assignee, rec.workflow_def_id, rec.base_branch,
|
||||
rec.updated_at, rec.id
|
||||
rec.review_rounds, rec.updated_at, rec.id
|
||||
],
|
||||
)
|
||||
}
|
||||
);
|
||||
|
||||
impl TaskRepo {
|
||||
/// 列出未删除任务(deleted_at IS NULL)— 对标 ProjectRepo::list_active
|
||||
///
|
||||
/// 显式列出 13 个 TaskRecord 列名(同 ProjectRepo::list_active 写法),
|
||||
/// 不 SELECT deleted_at:TaskRecord 不带该字段,取了 from_row 会因未知列报错。
|
||||
pub async fn list_active(&self) -> Result<Vec<TaskRecord>> {
|
||||
let conn = self.conn.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let guard = conn.blocking_lock();
|
||||
let mut stmt = guard
|
||||
.prepare("SELECT id, project_id, title, description, status, priority, branch_name, assignee, workflow_def_id, base_branch, review_rounds, created_at, updated_at FROM tasks WHERE deleted_at IS NULL ORDER BY created_at DESC")
|
||||
.map_err(|e| Error::Storage(e.to_string()))?;
|
||||
let rows = stmt
|
||||
.query_map([], |row| task_from_row(row))
|
||||
.map_err(|e| Error::Storage(e.to_string()))?;
|
||||
let mut results = Vec::new();
|
||||
for r in rows {
|
||||
results.push(r.map_err(|e| Error::Storage(e.to_string()))?);
|
||||
}
|
||||
Ok(results)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| Error::Storage(e.to_string()))?
|
||||
}
|
||||
|
||||
/// 软删:标记 deleted_at(进回收站,可恢复)。仅作用于未删任务,返回是否命中。
|
||||
/// 对标 ProjectRepo::soft_delete。
|
||||
pub async fn soft_delete(&self, id: &str) -> Result<bool> {
|
||||
let conn = self.conn.clone();
|
||||
let id = id.to_owned();
|
||||
let now = now_millis_str();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let guard = conn.blocking_lock();
|
||||
let affected = guard
|
||||
.execute(
|
||||
"UPDATE tasks SET deleted_at = ?1, updated_at = ?1 WHERE id = ?2 AND deleted_at IS NULL",
|
||||
params![now, id],
|
||||
)
|
||||
.map_err(|e| Error::Storage(e.to_string()))?;
|
||||
Ok(affected > 0)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| Error::Storage(e.to_string()))?
|
||||
}
|
||||
|
||||
/// 恢复:清 deleted_at(从回收站还原)。仅作用于已删任务,返回是否命中。
|
||||
/// 对标 ProjectRepo::restore。
|
||||
pub async fn restore(&self, id: &str) -> Result<bool> {
|
||||
let conn = self.conn.clone();
|
||||
let id = id.to_owned();
|
||||
let now = now_millis_str();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let guard = conn.blocking_lock();
|
||||
let affected = guard
|
||||
.execute(
|
||||
"UPDATE tasks SET deleted_at = NULL, updated_at = ?1 WHERE id = ?2 AND deleted_at IS NOT NULL",
|
||||
params![now, id],
|
||||
)
|
||||
.map_err(|e| Error::Storage(e.to_string()))?;
|
||||
Ok(affected > 0)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| Error::Storage(e.to_string()))?
|
||||
}
|
||||
|
||||
/// 原子推进任务状态(任务推进链 F-260616-02 唯一 status 写入路径)
|
||||
///
|
||||
/// 下沉 SQL `WHERE id=? AND status=?expected` 做 CAS(Compare-And-Swap)防 TOCTOU:
|
||||
/// 并发推进/旁路修改若已改 status,affected_rows==0,本方法返回 None,调用方
|
||||
/// (task_advance_node)据此报「状态已变,推进中止」。`review_rounds` 不进通用
|
||||
/// update_field 白名单(收口:仅本方法可改 status 与 review_rounds)。
|
||||
///
|
||||
/// - `expected`:调用方读取的当前 status(状态机校验时的 from),CAS 前置。
|
||||
/// - `new_status`:目标 status(状态机 can_transition 已校验合法)。
|
||||
/// - `bump_rounds`:退回转换(in_review→in_progress / testing→in_review)传 true,
|
||||
/// 一并 `review_rounds = review_rounds + 1`(同 UPDATE 原子,避免读改写竞争)。
|
||||
/// 前向推进 / 进出 blocked / 进 cancelled 传 false,不动 review_rounds。
|
||||
///
|
||||
/// 返回:成功推进返回更新后的 TaskRecord;affected==0(状态已变/任务不存在)返回 None。
|
||||
pub async fn advance_status_atomic(
|
||||
&self,
|
||||
id: &str,
|
||||
expected: &str,
|
||||
new_status: &str,
|
||||
bump_rounds: bool,
|
||||
) -> Result<Option<TaskRecord>> {
|
||||
let conn = self.conn.clone();
|
||||
let id = id.to_owned();
|
||||
let expected = expected.to_owned();
|
||||
let new_status = new_status.to_owned();
|
||||
let now = now_millis_str();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let guard = conn.blocking_lock();
|
||||
// CAS:WHERE id AND status=expected 锁定当前态;affected==0 即并发已改动。
|
||||
let sql = if bump_rounds {
|
||||
"UPDATE tasks SET status = ?1, review_rounds = review_rounds + 1, updated_at = ?2 \
|
||||
WHERE id = ?3 AND status = ?4"
|
||||
} else {
|
||||
"UPDATE tasks SET status = ?1, updated_at = ?2 \
|
||||
WHERE id = ?3 AND status = ?4"
|
||||
};
|
||||
let affected = guard
|
||||
.execute(sql, params![new_status, now, id, expected])
|
||||
.map_err(|e| Error::Storage(e.to_string()))?;
|
||||
if affected == 0 {
|
||||
return Ok(None);
|
||||
}
|
||||
// 回读更新后的记录(含新 status / 累加后的 review_rounds / 新 updated_at)。
|
||||
let mut stmt = guard
|
||||
.prepare("SELECT id, project_id, title, description, status, priority, branch_name, assignee, workflow_def_id, base_branch, review_rounds, created_at, updated_at FROM tasks WHERE id = ?1")
|
||||
.map_err(|e| Error::Storage(e.to_string()))?;
|
||||
let row = stmt
|
||||
.query_row(params![id], |row| task_from_row(row))
|
||||
.optional()
|
||||
.map_err(|e| Error::Storage(e.to_string()))?;
|
||||
Ok(row)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| Error::Storage(e.to_string()))?
|
||||
}
|
||||
|
||||
/// 列出回收站(deleted_at IS NOT NULL),按更新时间(≈删除时间)降序。对标 ProjectRepo::list_deleted。
|
||||
///
|
||||
/// 注:任务表无专用 list_active_by_project 方法,按项目列活跃任务由 commands/task.rs
|
||||
/// 的 list_tasks 用 list_active 后内存过滤 project_id 实现(任务量小,无需 SQL 下推)。
|
||||
pub async fn list_deleted(&self) -> Result<Vec<TaskRecord>> {
|
||||
let conn = self.conn.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let guard = conn.blocking_lock();
|
||||
let mut stmt = guard
|
||||
.prepare("SELECT id, project_id, title, description, status, priority, branch_name, assignee, workflow_def_id, base_branch, review_rounds, created_at, updated_at FROM tasks WHERE deleted_at IS NOT NULL ORDER BY updated_at DESC")
|
||||
.map_err(|e| Error::Storage(e.to_string()))?;
|
||||
let rows = stmt
|
||||
.query_map([], |row| task_from_row(row))
|
||||
.map_err(|e| Error::Storage(e.to_string()))?;
|
||||
let mut results = Vec::new();
|
||||
for r in rows {
|
||||
results.push(r.map_err(|e| Error::Storage(e.to_string()))?);
|
||||
}
|
||||
Ok(results)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| Error::Storage(e.to_string()))?
|
||||
}
|
||||
}
|
||||
|
||||
impl_repo!(
|
||||
/// 分支表 CRUD
|
||||
BranchRepo,
|
||||
|
||||
@@ -84,6 +84,14 @@ pub fn run(conn: &Connection) -> Result<()> {
|
||||
migrate_v13(conn)?;
|
||||
}
|
||||
|
||||
if current_version < 14 {
|
||||
migrate_v14(conn)?;
|
||||
}
|
||||
|
||||
if current_version < 15 {
|
||||
migrate_v15(conn)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -267,6 +275,41 @@ fn migrate_v13(conn: &Connection) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// V14: 幂等补 tasks.deleted_at 列(软删回收站,对标 projects.deleted_at V11)
|
||||
///
|
||||
/// 删除任务改为软删:deleted_at NULL=正常,非空=已进回收站(可恢复)。
|
||||
/// 与 projects.soft_delete 同模板:TaskRecord 不带该字段,纯靠 SQL WHERE deleted_at IS NULL
|
||||
/// 过滤;子表(branches)不动,FK 仍满足,任务数据完整保留待恢复。
|
||||
/// 用 PRAGMA 探测列存在性,缺失才 ALTER(同 v4/v5/v6/v8/v10/v11 模式)。
|
||||
fn migrate_v14(conn: &Connection) -> Result<()> {
|
||||
if !column_exists(conn, "tasks", "deleted_at") {
|
||||
conn.execute("ALTER TABLE tasks ADD COLUMN deleted_at TEXT", [])?;
|
||||
tracing::info!("v14: 补建 tasks.deleted_at 列(软删回收站)");
|
||||
}
|
||||
conn.execute("INSERT INTO schema_version (version) VALUES (?)", [14])?;
|
||||
tracing::info!("迁移 v14 完成");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// V15: 幂等补 tasks.review_rounds 列(review 退回累计轮数,F-260616-04)
|
||||
///
|
||||
/// 任务推进链状态机退回时累加:in_review→in_progress / testing→in_review 各 +1,
|
||||
/// 由 advance_task(df-nodes::task_advance_node)原子写入。默认 0(从未退回过的任务)。
|
||||
/// NOT NULL DEFAULT 0 保证老库行迁移后取值确定(非 NULL),TaskRecord 字段为 i32(非 Option)。
|
||||
/// 用 PRAGMA 探测列存在性,缺失才 ALTER(同 v4/v5/v6/v8/v10/v11/v14 模式)。
|
||||
fn migrate_v15(conn: &Connection) -> Result<()> {
|
||||
if !column_exists(conn, "tasks", "review_rounds") {
|
||||
conn.execute(
|
||||
"ALTER TABLE tasks ADD COLUMN review_rounds INTEGER NOT NULL DEFAULT 0",
|
||||
[],
|
||||
)?;
|
||||
tracing::info!("v15: 补建 tasks.review_rounds 列(review 退回累计轮数)");
|
||||
}
|
||||
conn.execute("INSERT INTO schema_version (version) VALUES (?)", [15])?;
|
||||
tracing::info!("迁移 v15 完成");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// V1 建表 SQL
|
||||
const V1_SQL: &str = "
|
||||
-- 想法表
|
||||
@@ -301,7 +344,8 @@ CREATE TABLE IF NOT EXISTS tasks (
|
||||
title TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
status TEXT NOT NULL DEFAULT 'todo',
|
||||
priority INTEGER NOT NULL DEFAULT 1,
|
||||
-- priority 默认 2 对齐 task.rs default_priority()=2(medium)
|
||||
priority INTEGER NOT NULL DEFAULT 2,
|
||||
branch_name TEXT,
|
||||
assignee TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
|
||||
@@ -61,6 +61,11 @@ pub struct TaskRecord {
|
||||
pub assignee: Option<String>,
|
||||
pub workflow_def_id: Option<String>, // 关联的工作流定义 ID
|
||||
pub base_branch: Option<String>, // 基础分支
|
||||
/// review 退回累计轮数(in_review→in_progress / testing→in_review 时 +1,
|
||||
/// 由 advance_task 原子写入,见 df-nodes::task_advance_node)。
|
||||
/// #[serde(default)] 兼容旧前端无该字段的 JSON(老任务记录默认 0)。
|
||||
#[serde(default)]
|
||||
pub review_rounds: i32,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
@@ -41,6 +41,7 @@ fn task(id: &str, project_id: &str) -> TaskRecord {
|
||||
assignee: None,
|
||||
workflow_def_id: None,
|
||||
base_branch: None,
|
||||
review_rounds: 0,
|
||||
created_at: now_ts(),
|
||||
updated_at: now_ts(),
|
||||
}
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::sync::{Arc, LazyLock};
|
||||
|
||||
use tokio::sync::Mutex as TokioMutex;
|
||||
|
||||
use df_ai::ai_tools::{AiToolRegistry, RiskLevel};
|
||||
use df_execute::shell::{execute, ShellRequest};
|
||||
@@ -13,6 +15,58 @@ use df_core::types::new_id;
|
||||
|
||||
use crate::commands::now_millis;
|
||||
|
||||
/// CRUD list 工具的默认返回上限(防 LLM context 膨胀)
|
||||
/// 用于 list_projects / list_tasks / list_ideas / list_trash
|
||||
const MAX_LIST_RESULTS: usize = 50;
|
||||
|
||||
/// 生成行级 unified diff(无外部依赖,基于 LCS)。
|
||||
/// 仅标 +/- 前缀,不做 hunk header(足够审批卡/审计留痕可读)。
|
||||
/// 文件改动通常集中在 old_text/new_text 局部,整体行对比可直观呈现。
|
||||
fn generate_diff(old: &str, new: &str) -> String {
|
||||
let a: Vec<&str> = old.lines().collect();
|
||||
let b: Vec<&str> = new.lines().collect();
|
||||
let (n, m) = (a.len(), b.len());
|
||||
|
||||
// LCS 动态规划表(usize 即可;大文件已被 1MB 限制挡住,行数有限)
|
||||
let mut dp = vec![vec![0usize; m + 1]; n + 1];
|
||||
for i in (0..n).rev() {
|
||||
for j in (0..m).rev() {
|
||||
dp[i][j] = if a[i] == b[j] {
|
||||
dp[i + 1][j + 1] + 1
|
||||
} else {
|
||||
dp[i + 1][j].max(dp[i][j + 1])
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 回溯:相同行原样输出(空格前缀),旧独有加 '-',新独有加 '+'
|
||||
let mut out = String::new();
|
||||
let (mut i, mut j) = (0usize, 0usize);
|
||||
let mut changes = 0usize;
|
||||
while i < n && j < m {
|
||||
if a[i] == b[j] {
|
||||
out.push_str(" "); out.push_str(a[i]); out.push('\n');
|
||||
i += 1; j += 1;
|
||||
} else if dp[i + 1][j] >= dp[i][j + 1] {
|
||||
out.push_str("-"); out.push_str(a[i]); out.push('\n');
|
||||
i += 1; changes += 1;
|
||||
} else {
|
||||
out.push_str("+"); out.push_str(b[j]); out.push('\n');
|
||||
j += 1; changes += 1;
|
||||
}
|
||||
}
|
||||
while i < n { out.push_str("-"); out.push_str(a[i]); out.push('\n'); i += 1; changes += 1; }
|
||||
while j < m { out.push_str("+"); out.push_str(b[j]); out.push('\n'); j += 1; changes += 1; }
|
||||
|
||||
// 极端情况:差异行过多(>300),截断防 LLM context 爆炸
|
||||
if changes > 300 {
|
||||
let kept: String = out.lines().take(300).collect::<Vec<_>>().join("\n");
|
||||
return format!("{}\n... (diff 已截断,共 {} 处变更行)", kept, changes);
|
||||
}
|
||||
// 末尾多余换行清理(保持单尾换行)
|
||||
out.trim_end_matches('\n').to_string() + "\n"
|
||||
}
|
||||
|
||||
/// 验证文件路径:禁止访问系统敏感目录
|
||||
fn validate_path(path: &str) -> anyhow::Result<()> {
|
||||
// 规范化为反斜杠:LLM 可能传正斜杠绕过黑名单(Windows tokio::fs 两种分隔符都吃)
|
||||
@@ -54,6 +108,13 @@ fn truncate_output(s: &str, max: usize) -> (String, bool) {
|
||||
)
|
||||
}
|
||||
|
||||
/// 全局文件锁表:每个路径一把互斥锁(L1 防护,防同文件并发读写冲突)
|
||||
///
|
||||
/// 唯一并行点: audit.rs join_all — Low/Medium 风险工具并行执行。
|
||||
/// 单用户桌面应用,Mutex 够用且简单(~15 行),不需要全局文件队列。
|
||||
static FILE_LOCKS: LazyLock<TokioMutex<HashMap<PathBuf, ()>>> =
|
||||
LazyLock::new(|| TokioMutex::new(HashMap::new()));
|
||||
|
||||
/// workspace 根目录(项目根 = src-tauri 上两级,编译期固定)
|
||||
fn workspace_root() -> PathBuf {
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
@@ -148,8 +209,8 @@ pub fn build_ai_tool_registry(db: &Arc<Database>) -> AiToolRegistry {
|
||||
let repo = df_storage::crud::ProjectRepo::new(&db);
|
||||
let mut items = repo.list_active().await?; // list_active 排除回收站(deleted_at),防 LLM 看到已软删项目
|
||||
let total = items.len();
|
||||
let truncated = total > 50;
|
||||
items.truncate(50); // 防 LLM context 膨胀
|
||||
let truncated = total > MAX_LIST_RESULTS;
|
||||
items.truncate(MAX_LIST_RESULTS);
|
||||
Ok(serde_json::json!({ "items": items, "truncated": truncated }))
|
||||
})
|
||||
})},
|
||||
@@ -167,8 +228,8 @@ pub fn build_ai_tool_registry(db: &Arc<Database>) -> AiToolRegistry {
|
||||
repo.list_all().await?
|
||||
};
|
||||
let total = tasks.len();
|
||||
let truncated = total > 50;
|
||||
tasks.truncate(50); // 防 LLM context 膨胀
|
||||
let truncated = total > MAX_LIST_RESULTS;
|
||||
tasks.truncate(MAX_LIST_RESULTS);
|
||||
Ok(serde_json::json!({ "items": tasks, "truncated": truncated }))
|
||||
})
|
||||
})},
|
||||
@@ -182,8 +243,8 @@ pub fn build_ai_tool_registry(db: &Arc<Database>) -> AiToolRegistry {
|
||||
let repo = df_storage::crud::IdeaRepo::new(&db);
|
||||
let mut items = repo.list_all().await?;
|
||||
let total = items.len();
|
||||
let truncated = total > 50;
|
||||
items.truncate(50); // 防 LLM context 膨胀
|
||||
let truncated = total > MAX_LIST_RESULTS;
|
||||
items.truncate(MAX_LIST_RESULTS);
|
||||
Ok(serde_json::json!({ "items": items, "truncated": truncated }))
|
||||
})
|
||||
})},
|
||||
@@ -275,6 +336,7 @@ pub fn build_ai_tool_registry(db: &Arc<Database>) -> AiToolRegistry {
|
||||
// priority 默认 2(medium):与 commands::task::default_priority 一致,新任务默认中优先级(非 high)
|
||||
status: "todo".to_string(), priority: args["priority"].as_i64().unwrap_or(2) as i32,
|
||||
branch_name: None, assignee: None, workflow_def_id: None, base_branch: None,
|
||||
review_rounds: 0,
|
||||
created_at: now_millis(), updated_at: now_millis(),
|
||||
};
|
||||
let id = record.id.clone();
|
||||
@@ -303,17 +365,17 @@ pub fn build_ai_tool_registry(db: &Arc<Database>) -> AiToolRegistry {
|
||||
})
|
||||
})},
|
||||
);
|
||||
// FR-D6:delete_task 对齐 commands::task::delete_task —— tasks 表无 deleted_at,走硬删
|
||||
// (与 delete_project 的软删不同:projects 有专门回收站机制,tasks 没有,只能硬删)
|
||||
// FR-D6:delete_task 对齐 commands::task::delete_task(D-260616-02 软删除)
|
||||
// tasks 表 V14 加 deleted_at,delete_task 改调 soft_delete 移入回收站(对标 delete_project)
|
||||
registry.register(
|
||||
"delete_task", "删除任务(硬删,不可恢复。清理孤儿任务时务必用本工具,不要误用 delete_project)",
|
||||
"delete_task", "删除任务(移入回收站,可恢复)",
|
||||
df_ai::ai_tools::object_schema(vec![("id", "string", true)]), RiskLevel::High,
|
||||
{ let db = db.clone(); Box::new(move |args: serde_json::Value| {
|
||||
let db = db.clone();
|
||||
Box::pin(async move {
|
||||
let id = args["id"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 id"))?;
|
||||
let repo = df_storage::crud::TaskRepo::new(&db);
|
||||
let deleted = repo.delete(id).await?;
|
||||
let deleted = repo.soft_delete(id).await?;
|
||||
Ok(serde_json::json!({ "deleted": deleted, "id": id }))
|
||||
})
|
||||
})},
|
||||
@@ -390,26 +452,19 @@ pub fn build_ai_tool_registry(db: &Arc<Database>) -> AiToolRegistry {
|
||||
})},
|
||||
);
|
||||
registry.register(
|
||||
"list_trash", "列出回收站已删除项目",
|
||||
"list_trash", "列出回收站已删除项目(最多返回 50 条)",
|
||||
df_ai::ai_tools::object_schema(vec![]), RiskLevel::Low,
|
||||
{ let db = db.clone(); Box::new(move |_args: serde_json::Value| {
|
||||
let db = db.clone();
|
||||
Box::pin(async move {
|
||||
let repo = df_storage::crud::ProjectRepo::new(&db);
|
||||
let mut items = repo.list_deleted().await?;
|
||||
items.truncate(50); // 防 LLM context 膨胀
|
||||
Ok(serde_json::to_value(items)?)
|
||||
let truncated = items.len() > MAX_LIST_RESULTS;
|
||||
items.truncate(MAX_LIST_RESULTS);
|
||||
Ok(serde_json::json!({ "items": items, "truncated": truncated }))
|
||||
})
|
||||
})},
|
||||
);
|
||||
registry.register(
|
||||
"run_workflow", "运行指定的工作流 DAG",
|
||||
df_ai::ai_tools::object_schema(vec![("name", "string", true), ("dag", "object", true)]), RiskLevel::High,
|
||||
Box::new(|_args: serde_json::Value| Box::pin(async move {
|
||||
// run_workflow 需完整 DAG 执行,返回提示由前端触发
|
||||
Ok(serde_json::json!({ "note": "请通过工作流页面运行工作流", "tool": "run_workflow" }))
|
||||
})),
|
||||
);
|
||||
registry.register(
|
||||
"run_command", "在指定工作目录执行 shell 命令(跑测试/构建/查看运行结果),返回 stdout/stderr/exit_code。高风险,须人工批准。命令需自包含(非交互式,避免需用户输入的程序)。默认超时 60 秒。用于验证刚写入的代码能否运行、跑测试、看报错迭代修改。",
|
||||
df_ai::ai_tools::object_schema(vec![
|
||||
@@ -440,6 +495,7 @@ pub fn build_ai_tool_registry(db: &Arc<Database>) -> AiToolRegistry {
|
||||
working_dir: Some(working_dir.clone()),
|
||||
env: HashMap::new(),
|
||||
timeout_secs: Some(timeout_secs),
|
||||
shell_type: Default::default(),
|
||||
};
|
||||
let result = execute(request).await?;
|
||||
|
||||
@@ -462,8 +518,8 @@ pub fn build_ai_tool_registry(db: &Arc<Database>) -> AiToolRegistry {
|
||||
|
||||
// ── 文件系统 ──
|
||||
registry.register(
|
||||
"read_file", "读取文件内容,返回文本内容。支持 offset 和 limit 参数分页读取大文件",
|
||||
df_ai::ai_tools::object_schema(vec![("path", "string", true), ("offset", "integer", false), ("limit", "integer", false)]),
|
||||
"read_file", "读取文件内容,返回文本内容。支持 offset/limit 分页;传入 search 则在文件内容中搜索匹配行(大小写敏感,字符串包含匹配),返回 matches 数组(限50条)",
|
||||
df_ai::ai_tools::object_schema(vec![("path", "string", true), ("offset", "integer", false), ("limit", "integer", false), ("search", "string", false)]),
|
||||
RiskLevel::Low,
|
||||
Box::new(|args: serde_json::Value| Box::pin(async move {
|
||||
let resolved = resolve_workspace_path(
|
||||
@@ -492,7 +548,26 @@ pub fn build_ai_tool_registry(db: &Arc<Database>) -> AiToolRegistry {
|
||||
}
|
||||
anyhow::bail!("读取文件失败: {}", e);
|
||||
}
|
||||
// limit 硬上限 2000 行(防 LLM 传超大 limit 读全文件,1MB 限下仍可能数万行)
|
||||
// search 模式: 按行枚举收集含 search 子串的行,限 50 条
|
||||
if let Some(search) = args["search"].as_str() {
|
||||
let search_max = 50usize;
|
||||
let mut matches_vec: Vec<serde_json::Value> = Vec::with_capacity(search_max);
|
||||
for (idx, line) in content.lines().enumerate() {
|
||||
if line.contains(search) {
|
||||
matches_vec.push(serde_json::json!({ "line": idx + 1, "content": line }));
|
||||
if matches_vec.len() >= search_max { break; }
|
||||
}
|
||||
}
|
||||
let total = content.lines().filter(|l| l.contains(search)).count();
|
||||
return Ok(serde_json::json!({
|
||||
"path": path, "size": metadata.len(),
|
||||
"search": search,
|
||||
"matches": matches_vec,
|
||||
"total": total,
|
||||
"has_more": total > search_max,
|
||||
}));
|
||||
}
|
||||
// 默认分页模式: limit 硬上限 2000 行(防 LLM 传超大 limit 读全文件,1MB 限下仍可能数万行)
|
||||
let result = if let Some(offset) = args["offset"].as_u64() {
|
||||
let lines: Vec<&str> = content.lines().collect();
|
||||
let skip = offset as usize;
|
||||
@@ -523,18 +598,34 @@ pub fn build_ai_tool_registry(db: &Arc<Database>) -> AiToolRegistry {
|
||||
})),
|
||||
);
|
||||
registry.register(
|
||||
"write_file", "写入或创建文件,自动创建不存在的父目录",
|
||||
df_ai::ai_tools::object_schema(vec![("path", "string", true), ("content", "string", true)]),
|
||||
"write_file", "写入或创建文件,自动创建不存在的父目录。支持 encoding=\"base64\" 写入二进制/非 UTF-8 内容(图片/PDF/Excel 等),content 传 base64 编码字符串;默认 encoding=\"utf-8\" 传纯文本",
|
||||
df_ai::ai_tools::object_schema(vec![
|
||||
("path", "string", true),
|
||||
("content", "string", true),
|
||||
("encoding", "string", false),
|
||||
]),
|
||||
RiskLevel::Medium,
|
||||
Box::new(|args: serde_json::Value| Box::pin(async move {
|
||||
let resolved = resolve_workspace_path(
|
||||
args["path"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 path 参数"))?,
|
||||
)?;
|
||||
let path = resolved.to_str().ok_or_else(|| anyhow::anyhow!("路径含非法字符"))?;
|
||||
let content = args["content"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 content 参数"))?;
|
||||
let raw = args["content"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 content 参数"))?;
|
||||
// F-260615-11: encoding 参数支持 base64,写二进制(图片/PDF/Excel 等)
|
||||
// 默认 utf-8:走原 &str 字节写入;base64:解码为 Vec<u8> 写字节
|
||||
let encoding = args.get("encoding").and_then(|v| v.as_str()).unwrap_or("utf-8");
|
||||
let bytes: Vec<u8> = if encoding.eq_ignore_ascii_case("base64") {
|
||||
use base64::{engine::general_purpose::STANDARD, Engine as _};
|
||||
STANDARD.decode(raw)
|
||||
.map_err(|e| anyhow::anyhow!("base64 解码失败: {}", e))?
|
||||
} else if encoding.eq_ignore_ascii_case("utf-8") {
|
||||
raw.as_bytes().to_vec()
|
||||
} else {
|
||||
anyhow::bail!("不支持的 encoding: {}(仅 utf-8 / base64)", encoding);
|
||||
};
|
||||
// 写入大小上限(FR-S2:原无限制,LLM 经审批可写超大文件撑爆磁盘/对话历史)
|
||||
if content.len() > 1_048_576 {
|
||||
anyhow::bail!("写入内容超过 1MB 限制 ({} 字节)", content.len());
|
||||
if bytes.len() > 1_048_576 {
|
||||
anyhow::bail!("写入内容超过 1MB 限制 ({} 字节)", bytes.len());
|
||||
}
|
||||
let target = std::path::Path::new(path);
|
||||
// FR-S7 覆盖防护:覆盖非空文件前自动 .bak 备份(防 LLM 误用 write_file 当 edit 致数据彻底丢失)
|
||||
@@ -559,7 +650,7 @@ pub fn build_ai_tool_registry(db: &Arc<Database>) -> AiToolRegistry {
|
||||
}
|
||||
// FR-S7 原子写:tmp→rename,避免写到一半崩溃留半成品(.tmp-write 同目录保证 rename 不跨卷)
|
||||
let tmp = format!("{}.tmp-write", path);
|
||||
if let Err(e) = tokio::fs::write(&tmp, content).await {
|
||||
if let Err(e) = tokio::fs::write(&tmp, &bytes).await {
|
||||
let _ = tokio::fs::remove_file(&tmp).await;
|
||||
return Err(anyhow::anyhow!("写入临时文件失败: {}", e));
|
||||
}
|
||||
@@ -576,11 +667,140 @@ pub fn build_ai_tool_registry(db: &Arc<Database>) -> AiToolRegistry {
|
||||
}
|
||||
// FR-S7 大小异动 warn:新内容远小于旧(疑似误覆盖整文件),提示用户查 .bak
|
||||
if let Some(old) = old_size {
|
||||
if old > 0 && (content.len() as f64 / old as f64) < 0.1 {
|
||||
tracing::warn!("write_file 疑似误覆盖: {} {}→{} 字节(缩减>90%),.bak 已备份", path, old, content.len());
|
||||
if old > 0 && (bytes.len() as f64 / old as f64) < 0.1 {
|
||||
tracing::warn!("write_file 疑似误覆盖: {} {}→{} 字节(缩减>90%),.bak 已备份", path, old, bytes.len());
|
||||
}
|
||||
}
|
||||
Ok(serde_json::json!({ "path": path, "bytes_written": content.len(), "old_size": old_size }))
|
||||
Ok(serde_json::json!({ "path": path, "bytes_written": bytes.len(), "old_size": old_size, "encoding": encoding }))
|
||||
})),
|
||||
);
|
||||
|
||||
// ── 局部文件编辑 (Medium risk) ──
|
||||
registry.register(
|
||||
"patch_file", "局部更新文件内容。用于精确修改文件的特定部分(而非全量覆盖)。每个补丁指定 old_text(要替换的原文,必须精确匹配含空格/缩进)和 new_text(新内容)。可选 line 辅助定位。属 Medium 风险操作(修改已有文件),需人工审批。注意:若文件已被外部修改,请先重新 read_file 获取最新内容",
|
||||
df_ai::ai_tools::object_schema(vec![
|
||||
("path", "string", true),
|
||||
("old_text", "string", true),
|
||||
("new_text", "string", true),
|
||||
("line", "integer", false),
|
||||
("expected_hash", "string", false),
|
||||
]),
|
||||
RiskLevel::Medium,
|
||||
Box::new(|args: serde_json::Value| Box::pin(async move {
|
||||
let resolved = resolve_workspace_path(
|
||||
args["path"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 path 参数"))?,
|
||||
)?;
|
||||
let path = resolved.to_str().ok_or_else(|| anyhow::anyhow!("路径含非法字符"))?;
|
||||
let old_text = args["old_text"].as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("缺少 old_text 参数"))?;
|
||||
let new_text = args["new_text"].as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("缺少 new_text 参数"))?;
|
||||
|
||||
// 边界校验
|
||||
if old_text.is_empty() {
|
||||
anyhow::bail!("old_text 不能为空");
|
||||
}
|
||||
if old_text == new_text {
|
||||
return Ok(serde_json::json!({
|
||||
"path": path, "changed": false, "warning": "new_text 与 old_text 相同,无实际更改"
|
||||
}));
|
||||
}
|
||||
|
||||
let target = std::path::Path::new(path);
|
||||
if !target.exists() {
|
||||
anyhow::bail!("文件不存在: {}", path);
|
||||
}
|
||||
let file_meta = tokio::fs::metadata(target).await
|
||||
.map_err(|e| anyhow::anyhow!("无法访问文件 {}: {}", path, e))?;
|
||||
if file_meta.len() > 1_048_576 {
|
||||
anyhow::bail!("文件超过 1MB 限制 ({} 字节)", file_meta.len());
|
||||
}
|
||||
|
||||
// 阶段一:读文件内容 + 校验(无锁,纯读操作)
|
||||
use tokio::io::AsyncReadExt;
|
||||
let mut file = tokio::fs::File::open(path).await
|
||||
.map_err(|e| anyhow::anyhow!("读取文件失败 {}: {}", path, e))?;
|
||||
let mut content = String::new();
|
||||
file.read_to_string(&mut content).await
|
||||
.map_err(|e| anyhow::anyhow!("读取文件失败: {}", e))?;
|
||||
|
||||
// 二进制检测
|
||||
if content.contains('\0') {
|
||||
anyhow::bail!("不支持二进制文件");
|
||||
}
|
||||
|
||||
// L3: expected_hash 指纹校验(防外部修改)
|
||||
if let Some(expected) = args["expected_hash"].as_str() {
|
||||
let modified = file_meta.modified()
|
||||
.ok().and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
|
||||
.map(|d| d.as_secs());
|
||||
let current_hash = format!("{}_{}", modified.unwrap_or(0), file_meta.len());
|
||||
if current_hash != expected {
|
||||
anyhow::bail!(
|
||||
"文件已被外部修改(hash 不匹配): 期望={} 实际={},请重新 read_file 获取最新内容",
|
||||
expected, current_hash
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// L2: old_text 精确匹配(CAS 语义)
|
||||
if !content.contains(old_text) {
|
||||
anyhow::bail!("未找到目标文本,文件可能已被修改");
|
||||
}
|
||||
|
||||
// 多匹配检测
|
||||
let match_count = content.matches(old_text).count();
|
||||
let warning = if match_count > 1 {
|
||||
Some(format!("匹配到 {} 处,仅替换第 1 处", match_count))
|
||||
} else { None };
|
||||
|
||||
// 执行替换(仅替换第 1 处)
|
||||
let new_content = content.replacen(old_text, new_text, 1);
|
||||
|
||||
// 阶段二:L1 tokio::Mutex 保护写序列(backup → tmp write → rename → cleanup)
|
||||
// tokio::sync::Mutex 的 MutexGuard 是 Send,可安全跨 await
|
||||
let abs_path = target.canonicalize()
|
||||
.map_err(|e| anyhow::anyhow!("路径解析失败: {}", e))?;
|
||||
{
|
||||
let mut locks = FILE_LOCKS.lock().await;
|
||||
locks.entry(abs_path.clone()).or_insert(());
|
||||
|
||||
// .bak 备份
|
||||
let bak = format!("{}.bak", path);
|
||||
tokio::fs::copy(path, &bak).await
|
||||
.map_err(|e| anyhow::anyhow!("备份 .bak 失败: {}", e))?;
|
||||
|
||||
// 原子写: tmp → rename
|
||||
let tmp = format!("{}.tmp-write", path);
|
||||
if let Err(e) = tokio::fs::write(&tmp, &new_content).await {
|
||||
let _ = tokio::fs::remove_file(&tmp).await;
|
||||
let _ = tokio::fs::remove_file(&bak).await;
|
||||
return Err(anyhow::anyhow!("写入临时文件失败: {}", e));
|
||||
}
|
||||
if let Err(e) = tokio::fs::rename(&tmp, path).await {
|
||||
let _ = tokio::fs::remove_file(&tmp).await;
|
||||
return Err(anyhow::anyhow!("原子替换失败: {},备份保留在 {}", e, bak));
|
||||
}
|
||||
// 成功:清理 .bak
|
||||
let _ = tokio::fs::remove_file(&bak).await;
|
||||
// _locks 在此 drop,释放锁
|
||||
}
|
||||
|
||||
let size_diff = new_content.len() as i64 - content.len() as i64;
|
||||
// 生成 unified diff 供前端审批卡/审计留痕展示
|
||||
// content(旧)与 new_content(新)作用域内可用,无需重读文件
|
||||
let diff = generate_diff(&content, &new_content);
|
||||
let mut result = serde_json::json!({
|
||||
"path": path,
|
||||
"changed": true,
|
||||
"size_diff": size_diff,
|
||||
"matches_found": match_count,
|
||||
"diff": diff,
|
||||
});
|
||||
if let Some(w) = warning {
|
||||
result["warning"] = serde_json::Value::String(w);
|
||||
}
|
||||
Ok(result)
|
||||
})),
|
||||
);
|
||||
|
||||
@@ -647,6 +867,160 @@ pub fn build_ai_tool_registry(db: &Arc<Database>) -> AiToolRegistry {
|
||||
})),
|
||||
);
|
||||
|
||||
// ── 删除文件 (High risk, F-260615-16) ──
|
||||
registry.register(
|
||||
"delete_file", "删除文件。默认(permanent=false)软删除:移到工作区 .trash/ 目录保留可恢复;permanent=true 硬删除不可恢复。删除前备份,回收站机制对齐 list_trash。高风险,须人工批准",
|
||||
df_ai::ai_tools::object_schema(vec![
|
||||
("path", "string", true),
|
||||
("permanent", "boolean", false),
|
||||
]),
|
||||
RiskLevel::High,
|
||||
Box::new(|args: serde_json::Value| Box::pin(async move {
|
||||
// validate_path 前置(workspace 内)——resolve_workspace_path 含 validate_path + symlink 防逃逸
|
||||
let resolved = resolve_workspace_path(
|
||||
args["path"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 path 参数"))?,
|
||||
)?;
|
||||
let path = resolved.to_str().ok_or_else(|| anyhow::anyhow!("路径含非法字符"))?;
|
||||
let permanent = args.get("permanent").and_then(|v| v.as_bool()).unwrap_or(false);
|
||||
let target = std::path::Path::new(path);
|
||||
if !target.exists() {
|
||||
anyhow::bail!("文件不存在: {}", path);
|
||||
}
|
||||
// 拒绝目录:delete_file 仅删文件(删目录语义复杂+递归风险大,留 run_command 兜底)
|
||||
let metadata = tokio::fs::metadata(target).await
|
||||
.map_err(|e| anyhow::anyhow!("无法访问 {}: {}", path, e))?;
|
||||
if metadata.is_dir() {
|
||||
anyhow::bail!("delete_file 仅支持删除文件,不支持目录: {}", path);
|
||||
}
|
||||
let file_name = target.file_name()
|
||||
.ok_or_else(|| anyhow::anyhow!("无法解析文件名: {}", path))?
|
||||
.to_string_lossy().to_string();
|
||||
|
||||
if permanent {
|
||||
// 硬删除:不可逆,对齐 delete_project purge 语义
|
||||
tokio::fs::remove_file(path).await
|
||||
.map_err(|e| anyhow::anyhow!("删除失败: {}", e))?;
|
||||
Ok(serde_json::json!({
|
||||
"path": path,
|
||||
"deleted": true,
|
||||
"permanent": true,
|
||||
"backed_up": false,
|
||||
}))
|
||||
} else {
|
||||
// 软删除:移到 .trash/<uuid>-<filename>,可恢复(对齐 list_trash 机制)
|
||||
// .trash 放 workspace_root 下,validate_path 已确认 path 在 workspace 内
|
||||
let trash_dir = workspace_root().join(".trash");
|
||||
tokio::fs::create_dir_all(&trash_dir).await
|
||||
.map_err(|e| anyhow::anyhow!("创建 .trash 目录失败: {}", e))?;
|
||||
let backup_name = format!("{}-{}", new_id(), file_name);
|
||||
let backup_path = trash_dir.join(&backup_name);
|
||||
let backup_path_str = backup_path.to_string_lossy().to_string();
|
||||
tokio::fs::rename(path, &backup_path).await
|
||||
.map_err(|e| anyhow::anyhow!("移入回收站失败: {}", e))?;
|
||||
Ok(serde_json::json!({
|
||||
"path": path,
|
||||
"deleted": true,
|
||||
"permanent": false,
|
||||
"backed_up": true,
|
||||
"backup_path": backup_path_str,
|
||||
}))
|
||||
}
|
||||
})),
|
||||
);
|
||||
|
||||
// ── 重命名/移动文件 (Medium risk, F-260615-15) ──
|
||||
// 一个工具覆盖 rename(同目录改名) + move(跨目录移动)。绕开 shell.rs PS/cmd 中文路径 GBK 解码
|
||||
// 静默失败根因:Rust 原生 std::fs 走 Win32 UTF-16 路径,中文路径零字符集问题。
|
||||
registry.register(
|
||||
"rename_file", "重命名或移动文件(同一工具覆盖 rename 和 move)。参数:from(源路径)+ to(目标路径)+ overwrite(默认 false,目标存在则拒绝)。同卷走 tokio::fs::rename(原子),跨卷自动降级 copy+remove(非原子,失败回滚删 to 保 from 完整)。须人工批准",
|
||||
df_ai::ai_tools::object_schema(vec![
|
||||
("from", "string", true),
|
||||
("to", "string", true),
|
||||
("overwrite", "boolean", false),
|
||||
]),
|
||||
RiskLevel::Medium,
|
||||
Box::new(|args: serde_json::Value| Box::pin(async move {
|
||||
// 双向 validate_path(workspace 内)——from/to 都走 resolve_workspace_path,
|
||||
// 各自含 validate_path(黑名单+.. 防遍历) + 词法 starts_with + canonicalize symlink 防逃逸
|
||||
let from_resolved = resolve_workspace_path(
|
||||
args["from"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 from 参数"))?,
|
||||
)?;
|
||||
let from_path = from_resolved.to_str().ok_or_else(|| anyhow::anyhow!("from 路径含非法字符"))?;
|
||||
let to_resolved = resolve_workspace_path(
|
||||
args["to"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 to 参数"))?,
|
||||
)?;
|
||||
let to_path = to_resolved.to_str().ok_or_else(|| anyhow::anyhow!("to 路径含非法字符"))?;
|
||||
let overwrite = args.get("overwrite").and_then(|v| v.as_bool()).unwrap_or(false);
|
||||
|
||||
// 源存在性 + 拒目录(rename 目录语义复杂递归风险大,留 run_command 兜底)
|
||||
let from_target = std::path::Path::new(from_path);
|
||||
if !from_target.exists() {
|
||||
anyhow::bail!("源文件不存在: {}", from_path);
|
||||
}
|
||||
let from_metadata = tokio::fs::metadata(from_target).await
|
||||
.map_err(|e| anyhow::anyhow!("无法访问 {}: {}", from_path, e))?;
|
||||
if from_metadata.is_dir() {
|
||||
anyhow::bail!("rename_file 仅支持文件,不支持目录: {}", from_path);
|
||||
}
|
||||
let bytes_moved = from_metadata.len();
|
||||
|
||||
// 目标父目录不存在则创建(对齐 write_file L643/append_file L851,跨目录移动到不存在父目录否则 rename 失败)
|
||||
let to_target = std::path::Path::new(to_path);
|
||||
if let Some(parent) = to_target.parent() {
|
||||
if !parent.starts_with(&workspace_root()) {
|
||||
anyhow::bail!("禁止在项目目录之外创建目录");
|
||||
}
|
||||
tokio::fs::create_dir_all(parent).await
|
||||
.map_err(|e| anyhow::anyhow!("创建目标父目录失败: {}", e))?;
|
||||
}
|
||||
|
||||
// 目标存在性:overwrite=false 直接拒绝(防误覆盖);overwrite=true 先删 to
|
||||
if to_target.exists() {
|
||||
if !overwrite {
|
||||
anyhow::bail!("目标已存在且 overwrite=false: {}", to_path);
|
||||
}
|
||||
tokio::fs::remove_file(to_path).await
|
||||
.map_err(|e| anyhow::anyhow!("覆盖删除目标失败: {}", e))?;
|
||||
}
|
||||
|
||||
// 同卷:tokio::fs::rename 原子(Windows 走 MoveFileExW UTF-16,中文路径无 GBK 问题)
|
||||
let rename_err = tokio::fs::rename(from_path, to_path).await.err();
|
||||
if rename_err.is_none() {
|
||||
return Ok(serde_json::json!({
|
||||
"from": from_path,
|
||||
"to": to_path,
|
||||
"renamed": true,
|
||||
"bytes_moved": bytes_moved,
|
||||
"cross_volume": false,
|
||||
}));
|
||||
}
|
||||
// rename 失败:跨卷(Windows ERROR_NOT_SAME_DEVICE 17)→ 降级 copy+remove
|
||||
// 其他错误(权限/占用)直接抛,不降级
|
||||
let err = rename_err.unwrap();
|
||||
let cross_volume = err.raw_os_error() == Some(17);
|
||||
if !cross_volume {
|
||||
anyhow::bail!("重命名/移动失败: {}", err);
|
||||
}
|
||||
// 跨卷降级 copy + remove(非原子):copy 失败 from 完整(未动);copy 成功 remove 失败
|
||||
// 则 from/to 同时存在,删 to 回滚保 from 完整(对齐设计:失败回滚删 to)
|
||||
if let Err(e) = tokio::fs::copy(from_path, to_path).await {
|
||||
anyhow::bail!("跨卷复制失败(from 未改动): {}", e);
|
||||
}
|
||||
if let Err(e) = tokio::fs::remove_file(from_path).await {
|
||||
// remove 失败:回滚删 to,保 from 完整(用户可重试)
|
||||
let _ = tokio::fs::remove_file(to_path).await;
|
||||
anyhow::bail!("跨卷移动删除源失败已回滚(from 完整,可重试): {}", e);
|
||||
}
|
||||
Ok(serde_json::json!({
|
||||
"from": from_path,
|
||||
"to": to_path,
|
||||
"renamed": true,
|
||||
"bytes_moved": bytes_moved,
|
||||
"cross_volume": true,
|
||||
}))
|
||||
})),
|
||||
);
|
||||
|
||||
// ── 文件搜索 (Low risk) ──
|
||||
registry.register(
|
||||
"search_files", "在指定目录下搜索匹配模式(字符串包含匹配)的文件名,返回路径和大小列表。支持递归搜索,结果限 50 条",
|
||||
@@ -726,11 +1100,65 @@ fn list_dir_recursive<'a>(
|
||||
})
|
||||
}
|
||||
|
||||
/// 工具名 → 审批文案模板(供 build_approval_reason 使用)。
|
||||
///
|
||||
/// 返回 Option<(&str, &[&str])>:(动作前缀模板, 关键参数名列表)。
|
||||
/// 模板中 `{}` 占位符由 build_approval_reason 按参数列表从 args 取值填充。
|
||||
/// 未注册的工具返回 None,由调用方 fallback 到通用风险提示。
|
||||
pub(crate) fn display_hint_for_tool(name: &str) -> Option<(&'static str, &'static [&'static str])> {
|
||||
let (template, keys): (&str, &[&str]) = match name {
|
||||
"delete_project" => ("删除项目{}", &["id"]),
|
||||
"restore_project" => ("从回收站恢复项目{}", &["id"]),
|
||||
"purge_project" => ("永久删除项目及关联数据,不可恢复{}", &["id"]),
|
||||
"update_project" => ("修改项目{}字段「{}」", &["id", "field"]),
|
||||
"bind_directory" => ("绑定目录:{}(项目{})", &["path", "id"]),
|
||||
"create_task" => ("创建任务:{}(项目{})", &["title", "project_id"]),
|
||||
"create_project" => ("创建项目:「{}」", &["name"]),
|
||||
"create_idea" => ("捕获灵感:{}", &["title"]),
|
||||
"patch_file" => ("修改文件:{}", &["path"]),
|
||||
"delete_file" => ("删除文件:{}({})", &["path", "permanent"]),
|
||||
"rename_file" => ("重命名/移动:{} → {}", &["from", "to"]),
|
||||
_ => return None,
|
||||
};
|
||||
Some((template, keys))
|
||||
}
|
||||
|
||||
/// 工具名 → 中文动作前缀(轻量映射,供 build_approval_reason 快速取可读标签)。
|
||||
///
|
||||
/// 仅返回动作动词+对象名(如 "创建任务"、"写入文件"),不含参数细节。
|
||||
/// 未注册的工具返回 None,由调用方 fallback 到 display_hint_for_tool 模板填充或硬编码。
|
||||
pub(crate) fn tool_display_hint(name: &str) -> Option<&'static str> {
|
||||
match name {
|
||||
"create_task" => Some("创建任务"),
|
||||
"write_file" => Some("写入文件"),
|
||||
"read_file" => Some("读取文件"),
|
||||
"list_directory" => Some("列出目录"),
|
||||
"search_files" => Some("搜索文件"),
|
||||
"create_project" => Some("创建项目"),
|
||||
"update_project" => Some("修改项目"),
|
||||
"delete_project" => Some("删除项目"),
|
||||
"restore_project" => Some("恢复项目"),
|
||||
"purge_project" => Some("永久删除项目"),
|
||||
"bind_directory" => Some("绑定目录"),
|
||||
"create_idea" => Some("捕获灵感"),
|
||||
"update_task" => Some("修改任务"),
|
||||
"delete_task" => Some("删除任务"),
|
||||
"update_idea" => Some("修改灵感"),
|
||||
"delete_idea" => Some("删除灵感"),
|
||||
"patch_file" => Some("修改文件"),
|
||||
"append_file" => Some("追加写入"),
|
||||
"delete_file" => Some("删除文件"),
|
||||
"rename_file" => Some("重命名/移动"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 判断是否为不应深入递归的噪音目录(构建产物/依赖/缓存等)
|
||||
fn is_noise_dir(name: &str) -> bool {
|
||||
const NOISE_DIRS: &[&str] = &[
|
||||
".git", "node_modules", "target", "dist", "build",
|
||||
".next", ".cache", "__pycache__", ".venv", "venv", ".idea",
|
||||
".trash", // delete_file 软删除回收站(F-260615-16),不污染 list_directory
|
||||
];
|
||||
NOISE_DIRS.contains(&name)
|
||||
}
|
||||
|
||||
@@ -27,17 +27,22 @@ fn default_priority() -> i32 {
|
||||
2 // medium — 新任务默认中优先级(非 high),符合常识
|
||||
}
|
||||
|
||||
/// 列出任务,可按 project_id 过滤
|
||||
/// 列出未删除任务(deleted_at IS NULL),可按 project_id 过滤。
|
||||
///
|
||||
/// 软删对标 projects:默认过滤回收站(语义与 list_projects 一致)。
|
||||
/// 无 project_id 时调 list_active(WHERE deleted_at IS NULL);有 project_id 时
|
||||
/// 先 list_active 再内存过滤 project_id(任务量小,无需 SQL 下推,避免新增专用查询方法)。
|
||||
/// 注意:不能用通用 query 宏——它不带 deleted_at 过滤,会把回收站任务也返回。
|
||||
#[tauri::command]
|
||||
pub async fn list_tasks(
|
||||
state: State<'_, AppState>,
|
||||
project_id: Option<String>,
|
||||
) -> Result<Vec<TaskRecord>, String> {
|
||||
let result = match project_id {
|
||||
Some(pid) => state.tasks.query("project_id", &pid).await,
|
||||
None => state.tasks.list_all().await,
|
||||
};
|
||||
result.map_err(err_str)
|
||||
let mut tasks = state.tasks.list_active().await.map_err(err_str)?;
|
||||
if let Some(pid) = project_id {
|
||||
tasks.retain(|t| t.project_id == pid);
|
||||
}
|
||||
Ok(tasks)
|
||||
}
|
||||
|
||||
/// 按 id 查任务,找不到返回 Err(供前端详情页)
|
||||
@@ -72,6 +77,7 @@ pub async fn create_task(
|
||||
assignee: input.assignee,
|
||||
workflow_def_id: None,
|
||||
base_branch: None,
|
||||
review_rounds: 0,
|
||||
created_at: now.clone(),
|
||||
updated_at: now,
|
||||
};
|
||||
@@ -91,8 +97,8 @@ pub async fn update_task(
|
||||
field: String,
|
||||
value: String,
|
||||
) -> Result<bool, String> {
|
||||
// 字段名注入由 df-storage 白名单兜底;这里补 status 值校验,
|
||||
// 拦截拼写错误(in-progess / "in progress" / 大小写错等)静默落库。
|
||||
// 字段名注入由 df-storage 白名单兜底;这里补 status / priority 值校验,
|
||||
// 拦截拼写错误(in-progess / "in progress" / 大小写错)与越界数值(999 / "abc")静默落库。
|
||||
if field == "status" && !TaskStatus::is_valid(&value) {
|
||||
return Err(format!(
|
||||
"非法 status 值 {:?},合法值: {:?}",
|
||||
@@ -100,6 +106,28 @@ pub async fn update_task(
|
||||
TaskStatus::valid_values()
|
||||
));
|
||||
}
|
||||
// priority 值域 0..=3(0=critical, 1=high, 2=medium, 3=low),与前端 <select> 及
|
||||
// constants/project.ts 的 PRIORITY_LABELS 一致。拦截 "abc" / 999 等脏数据。
|
||||
if field == "priority" {
|
||||
match value.parse::<i32>() {
|
||||
Ok(p) if (0..=3).contains(&p) => {}
|
||||
_ => {
|
||||
return Err(format!(
|
||||
"非法 priority 值 {:?},合法值: 整数 0..=3(0=critical, 1=high, 2=medium, 3=low)",
|
||||
value
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
// project_id 跨表存在性校验(B-260616-16 收尾)。
|
||||
// crud.rs tasks 白名单保留 project_id(支持跨项目移动),跨表约束由本命令层兜底。
|
||||
// 拦截移动到不存在的项目(手输脏 id / 已物理删除的项目),避免 tasks.project_id 悬空。
|
||||
if field == "project_id" {
|
||||
let exists = state.projects.get_by_id(&value).await.map_err(err_str)?;
|
||||
if exists.is_none() {
|
||||
return Err(format!("非法 project_id 值 {:?},目标项目不存在", value));
|
||||
}
|
||||
}
|
||||
state
|
||||
.tasks
|
||||
.update_field(&id, &field, &value)
|
||||
@@ -107,8 +135,34 @@ pub async fn update_task(
|
||||
.map_err(err_str)
|
||||
}
|
||||
|
||||
/// 删除任务
|
||||
/// 删除任务(软删 → 回收站,可恢复)。对标 delete_project(SET deleted_at=now)。
|
||||
#[tauri::command]
|
||||
pub async fn delete_task(state: State<'_, AppState>, id: String) -> Result<bool, String> {
|
||||
state.tasks.delete(&id).await.map_err(err_str)
|
||||
state.tasks.soft_delete(&id).await.map_err(err_str)
|
||||
}
|
||||
|
||||
/// 恢复任务(从回收站还原,清 deleted_at)。对标 restore_project。
|
||||
#[tauri::command]
|
||||
pub async fn restore_task(state: State<'_, AppState>, id: String) -> Result<bool, String> {
|
||||
state.tasks.restore(&id).await.map_err(err_str)
|
||||
}
|
||||
|
||||
/// 推进任务状态(任务推进链 F-260616-02,推进链唯一 status 写入路径)。
|
||||
///
|
||||
/// thin 入口(D-260616-03):业务逻辑(状态机校验 + 原子 CAS + review_rounds 累加)
|
||||
/// 落 df-nodes::task_advance_node::advance_task_atomic,本命令只做参数转发与错误串化。
|
||||
///
|
||||
/// 流程:读当前态 → can_transition 校验 → 下沉 SQL `WHERE id AND status=expected`
|
||||
/// 防 TOCTOU → 退回转换一并 review_rounds+=1。失败均返回 Err(状态机/TOCTOU/任务不存在)。
|
||||
///
|
||||
/// 返回:推进成功后的最新 TaskRecord(含新 status / 累加后的 review_rounds)。
|
||||
#[tauri::command]
|
||||
pub async fn advance_task(
|
||||
state: State<'_, AppState>,
|
||||
id: String,
|
||||
target_status: String,
|
||||
) -> Result<TaskRecord, String> {
|
||||
df_nodes::task_advance_node::advance_task_atomic(&state.tasks, &id, &target_status)
|
||||
.await
|
||||
.map_err(err_str)
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
mod commands;
|
||||
mod state;
|
||||
|
||||
use tauri::Manager;
|
||||
use tauri::{Emitter, Listener, Manager};
|
||||
|
||||
use state::AppState;
|
||||
|
||||
@@ -29,7 +29,40 @@ pub fn run() {
|
||||
) {
|
||||
tracing::warn!("[FR-S1] 启动密钥迁移失败(非阻断): {}", e);
|
||||
}
|
||||
// B-260616-01: L0 握手 — 提前 clone ai_session(Arc),因 manage() 会 move app_state
|
||||
let session_for_handshake = app_state.ai_session.clone();
|
||||
app.manage(app_state);
|
||||
|
||||
// B-260616-01: L0 握手 — 监听前端就绪事件,清除 HMR/刷新导致的残留 generating 状态
|
||||
let app_handle = app.handle().clone();
|
||||
app.listen("ai-client-ready", move |_event| {
|
||||
let session_arc = session_for_handshake.clone();
|
||||
let app_h = app_handle.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let mut session = session_arc.lock().await;
|
||||
let was_generating = session.generating;
|
||||
let conv_id = session.active_conversation_id.clone();
|
||||
if was_generating {
|
||||
session.generating = false;
|
||||
session.pending_approvals.clear();
|
||||
drop(session);
|
||||
// 补偿事件:通知前端收尾(复位 streaming / generatingConvId)
|
||||
let _ = app_h.emit(
|
||||
"ai-chat-event",
|
||||
commands::ai::AiChatEvent::AiCompleted {
|
||||
total_tokens: 0,
|
||||
prompt_tokens: 0,
|
||||
completion_tokens: 0,
|
||||
conversation_id: conv_id,
|
||||
},
|
||||
);
|
||||
} else {
|
||||
session.pending_approvals.clear();
|
||||
}
|
||||
tracing::info!("[L0-handshake] 前端重连握手完成, was_generating={}", was_generating);
|
||||
});
|
||||
});
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
@@ -53,7 +86,9 @@ pub fn run() {
|
||||
commands::task::create_task,
|
||||
commands::task::update_task,
|
||||
commands::task::delete_task,
|
||||
commands::task::restore_task,
|
||||
commands::task::get_task_by_id,
|
||||
commands::task::advance_task,
|
||||
// 灵感
|
||||
commands::idea::list_ideas,
|
||||
commands::idea::create_idea,
|
||||
@@ -69,6 +104,7 @@ pub fn run() {
|
||||
commands::workflow::cancel_workflow_node,
|
||||
// AI 聊天
|
||||
commands::ai::ai_chat_send,
|
||||
commands::ai::ai_chat_force_send,
|
||||
commands::ai::ai_chat_stop,
|
||||
commands::ai::ai_approve,
|
||||
commands::ai::ai_pending_tool_calls,
|
||||
|
||||
@@ -21,4 +21,13 @@ export const taskApi = {
|
||||
delete(id: string): Promise<boolean> {
|
||||
return invoke('delete_task', { id })
|
||||
},
|
||||
|
||||
/**
|
||||
* F-05 推进任务状态(走后端 advance_task 状态机:df-nodes task_advance_node)。
|
||||
* 后端校验 from→to 合法性(can_transition)+ review_rounds 自动累加(退回时),
|
||||
* 前端只传 target_status,不自行判状态机(单一真相源在后端)。
|
||||
*/
|
||||
advance(id: string, targetStatus: string): Promise<TaskRecord> {
|
||||
return invoke('advance_task', { id, targetStatus })
|
||||
},
|
||||
}
|
||||
|
||||
@@ -91,6 +91,8 @@ export interface TaskRecord {
|
||||
assignee: string | null
|
||||
workflow_def_id: string | null
|
||||
base_branch: string | null
|
||||
/** review 退回累计轮数(in_review→in_progress / testing→in_review 各 +1) */
|
||||
review_rounds: number
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
@@ -6,22 +6,19 @@
|
||||
// ── 项目状态 ──
|
||||
|
||||
// 值为 i18n key,实际文案走 $t('projects.status.<key>')(见 src/i18n/{zh-CN,en}/projects.ts)
|
||||
// F-09 对齐:删 in_progress/paused/cancelled(project 维度无数据产生源、无 UI 入口、无消费方),
|
||||
// 保留与后端真实生命周期一致的最小集(planning 常态 + 软删 deleted_at 由 list_deleted 单独处理)。
|
||||
export const PROJECT_STATUS_LABELS: Record<string, string> = {
|
||||
planning: 'projects.status.planning',
|
||||
in_progress: 'projects.status.in_progress',
|
||||
paused: 'projects.status.paused',
|
||||
completed: 'projects.status.completed',
|
||||
cancelled: 'projects.status.cancelled',
|
||||
active: 'projects.status.active',
|
||||
completed: 'projects.status.completed',
|
||||
}
|
||||
|
||||
/** 项目状态 → 卡片 badge 样式 class */
|
||||
export const PROJECT_STATUS_BADGE_CLASS: Record<string, string> = {
|
||||
planning: 'stage-design',
|
||||
in_progress: 'stage-coding',
|
||||
paused: 'stage-testing',
|
||||
active: 'stage-coding',
|
||||
completed: 'stage-release',
|
||||
cancelled: 'stage-design',
|
||||
}
|
||||
|
||||
/** 项目状态 → 阶段/进度信息(详情页 pipeline + Dashboard 进度条共用) */
|
||||
@@ -35,11 +32,9 @@ export interface ProjectStageInfo {
|
||||
}
|
||||
|
||||
export const PROJECT_STAGE_INFO: Record<string, ProjectStageInfo> = {
|
||||
planning: { stepIndex: 0, progress: 20, stage: 'coding' },
|
||||
in_progress: { stepIndex: 2, progress: 55, stage: 'coding' },
|
||||
paused: { stepIndex: 2, progress: 40, stage: 'testing' },
|
||||
planning: { stepIndex: 0, progress: 20, stage: 'planning' },
|
||||
active: { stepIndex: 2, progress: 55, stage: 'coding' },
|
||||
completed: { stepIndex: 4, progress: 100, stage: 'release' },
|
||||
cancelled: { stepIndex: 0, progress: 0, stage: 'testing' },
|
||||
}
|
||||
|
||||
export function projectStatusLabel(status: string): string {
|
||||
@@ -51,26 +46,41 @@ export function projectBadgeClass(status: string): string {
|
||||
}
|
||||
|
||||
export function projectStageInfo(status: string): ProjectStageInfo {
|
||||
return PROJECT_STAGE_INFO[status] ?? PROJECT_STAGE_INFO.planning
|
||||
// F-09:兜底用字面量而非 PROJECT_STAGE_INFO.planning,避免已删键的隐式依赖
|
||||
return PROJECT_STAGE_INFO[status] ?? { stepIndex: 0, progress: 0, stage: 'planning' }
|
||||
}
|
||||
|
||||
// ── 任务状态 ──
|
||||
|
||||
// D-260616-01:前端对齐后端 7 态纯状态机(crates/df-core/src/types.rs TaskStatus)
|
||||
// 删旧 5 态 Git 工作流导向(review_ready/merged/abandoned)。
|
||||
// 后端 task.rs update_task 写 status 经 TaskStatus::is_valid 拦截,旧 5 态无法落库;
|
||||
// migrations.rs V1 无 seed,create_task 默认 todo → 新 DB 无旧 5 态数据,安全对齐。
|
||||
//
|
||||
// 值为 i18n key,实际文案走 $t('tasks.status.<key>')(见 src/i18n/{zh-CN,en}/tasks.ts)
|
||||
export const TASK_STATUS_LABELS: Record<string, string> = {
|
||||
todo: 'tasks.status.todo',
|
||||
in_progress: 'tasks.status.in_progress',
|
||||
review_ready: 'tasks.status.review_ready',
|
||||
merged: 'tasks.status.merged',
|
||||
abandoned: 'tasks.status.abandoned',
|
||||
in_review: 'tasks.status.in_review',
|
||||
testing: 'tasks.status.testing',
|
||||
done: 'tasks.status.done',
|
||||
blocked: 'tasks.status.blocked',
|
||||
cancelled: 'tasks.status.cancelled',
|
||||
}
|
||||
|
||||
// class 名对齐既有 css(Tasks/TaskDetail/ProjectDetail 均有 status-todo/progress/review/done/abandoned):
|
||||
// - todo/in_progress/in_review/done 复用既有 class(保持跨视图样式一致,不破坏指派外文件)
|
||||
// - cancelled 复用 status-abandoned(同为 danger 红色,语义一致)
|
||||
// - testing/blocked 新增(Tasks.vue 自身样式补充;TaskDetail/ProjectDetail 遇此值兜底 status-todo 灰色,
|
||||
// 非破坏——仅样式保守,文案经 $t 正常渲染)
|
||||
export const TASK_STATUS_CLASS: Record<string, string> = {
|
||||
todo: 'status-todo',
|
||||
in_progress: 'status-progress',
|
||||
review_ready: 'status-review',
|
||||
merged: 'status-done',
|
||||
abandoned: 'status-abandoned',
|
||||
in_review: 'status-review',
|
||||
testing: 'status-testing',
|
||||
done: 'status-done',
|
||||
blocked: 'status-blocked',
|
||||
cancelled: 'status-abandoned',
|
||||
}
|
||||
|
||||
export function taskStatusLabel(status: string): string {
|
||||
|
||||
@@ -18,7 +18,24 @@ export default {
|
||||
assignee: 'Assignee',
|
||||
baseBranch: 'Base Branch',
|
||||
workflowDef: 'Workflow Definition',
|
||||
reviewRounds: 'Review Round {n}',
|
||||
createdAt: 'Created At',
|
||||
updatedAt: 'Updated At',
|
||||
// F-05 advance buttons (shown by state-machine-legal next states, validated by advance_task)
|
||||
advanceTitle: 'Advance Task',
|
||||
// Button labels (legal transitions per task_state_machine.rs can_transition)
|
||||
advance: {
|
||||
toInProgress: 'Start',
|
||||
toInReview: 'Submit for Review',
|
||||
toTesting: 'Approve to Testing',
|
||||
toDone: 'Done',
|
||||
resume: 'Resume',
|
||||
sendBack: 'Send Back',
|
||||
sendBackToReview: 'Send Back to Review',
|
||||
block: 'Block',
|
||||
cancel: 'Cancel Task',
|
||||
},
|
||||
advancing: 'Advancing...',
|
||||
advanceFailed: 'Failed to advance task',
|
||||
},
|
||||
}
|
||||
|
||||
@@ -11,14 +11,16 @@ export default {
|
||||
status: 'Status: ',
|
||||
all: 'All',
|
||||
},
|
||||
// Status filters (with icons)
|
||||
// Status filters (with icons) — D-260616-01 aligned to backend 7 states
|
||||
statusFilter: {
|
||||
all: 'All',
|
||||
todo: 'Todo',
|
||||
in_progress: 'In Progress',
|
||||
review_ready: 'Review Ready',
|
||||
merged: 'Merged',
|
||||
abandoned: 'Abandoned',
|
||||
in_review: 'In Review',
|
||||
testing: 'Testing',
|
||||
done: 'Done',
|
||||
blocked: 'Blocked',
|
||||
cancelled: 'Cancelled',
|
||||
},
|
||||
|
||||
// Groups
|
||||
@@ -44,19 +46,22 @@ export default {
|
||||
priorityMedium: 'P2 Medium',
|
||||
priorityLow: 'P3 Low',
|
||||
},
|
||||
// Status labels (TASK_STATUS_LABELS values in constants/project.ts use these keys)
|
||||
// Status labels (TASK_STATUS_LABELS values in constants/project.ts use these keys) — D-260616-01 aligned to backend 7 states
|
||||
status: {
|
||||
todo: '📋 Todo',
|
||||
in_progress: '🔄 In Progress',
|
||||
review_ready: '👀 Review Ready',
|
||||
merged: '✅ Merged',
|
||||
abandoned: '🗑️ Abandoned',
|
||||
in_review: '👀 In Review',
|
||||
testing: '🧪 Testing',
|
||||
done: '✅ Done',
|
||||
blocked: '🚫 Blocked',
|
||||
cancelled: '🗑️ Cancelled',
|
||||
},
|
||||
// Error fallbacks (user-visible via state.error)
|
||||
err: {
|
||||
loadFailed: 'Failed to load tasks',
|
||||
createFailed: 'Failed to create task',
|
||||
deleteFailed: 'Failed to delete task',
|
||||
updateFailed: 'Failed to update task',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -18,7 +18,24 @@ export default {
|
||||
assignee: '负责人',
|
||||
baseBranch: '基础分支',
|
||||
workflowDef: '工作流定义',
|
||||
reviewRounds: '第 {n} 轮 Review',
|
||||
createdAt: '创建时间',
|
||||
updatedAt: '更新时间',
|
||||
// F-05 推进按钮(按状态机合法下一态显示,advance_task 状态机校验)
|
||||
advanceTitle: '推进任务',
|
||||
// 按钮文案(状态机合法转换,见 task_state_machine.rs can_transition)
|
||||
advance: {
|
||||
toInProgress: '开始', // todo/blocked → in_progress
|
||||
toInReview: '提交审查', // in_progress → in_review
|
||||
toTesting: '通过进测试', // in_review → testing
|
||||
toDone: '完成', // testing → done
|
||||
resume: '恢复', // blocked → in_progress(与 toInProgress 同向,blocked 专用文案)
|
||||
sendBack: '退回修改', // in_review → in_progress
|
||||
sendBackToReview: '退回重审', // testing → in_review
|
||||
block: '阻塞', // * → blocked
|
||||
cancel: '取消任务', // * → cancelled
|
||||
},
|
||||
advancing: '推进中...',
|
||||
advanceFailed: '推进任务失败',
|
||||
},
|
||||
}
|
||||
|
||||
@@ -11,14 +11,16 @@ export default {
|
||||
status: '状态:',
|
||||
all: '全部',
|
||||
},
|
||||
// 状态筛选(含图标)
|
||||
// 状态筛选(含图标) — D-260616-01 对齐后端 7 态
|
||||
statusFilter: {
|
||||
all: '全部',
|
||||
todo: '待开始',
|
||||
in_progress: '进行中',
|
||||
review_ready: '待审查',
|
||||
merged: '已合并',
|
||||
abandoned: '已放弃',
|
||||
in_review: '审查中',
|
||||
testing: '测试中',
|
||||
done: '已完成',
|
||||
blocked: '已阻塞',
|
||||
cancelled: '已取消',
|
||||
},
|
||||
|
||||
// 分组
|
||||
@@ -44,19 +46,22 @@ export default {
|
||||
priorityMedium: 'P2 中',
|
||||
priorityLow: 'P3 低',
|
||||
},
|
||||
// 状态文案(constants/project.ts 的 TASK_STATUS_LABELS 值走此 key)
|
||||
// 状态文案(constants/project.ts 的 TASK_STATUS_LABELS 值走此 key) — D-260616-01 对齐后端 7 态
|
||||
status: {
|
||||
todo: '📋 待开始',
|
||||
in_progress: '🔄 进行中',
|
||||
review_ready: '👀 待审查',
|
||||
merged: '✅ 已合并',
|
||||
abandoned: '🗑️ 已放弃',
|
||||
in_review: '👀 审查中',
|
||||
testing: '🧪 测试中',
|
||||
done: '✅ 已完成',
|
||||
blocked: '🚫 已阻塞',
|
||||
cancelled: '🗑️ 已取消',
|
||||
},
|
||||
// 错误回退(state.error 用户可见)
|
||||
err: {
|
||||
loadFailed: '加载任务失败',
|
||||
createFailed: '创建任务失败',
|
||||
deleteFailed: '删除任务失败',
|
||||
updateFailed: '更新任务失败',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
<span
|
||||
class="value description ai-md"
|
||||
v-if="task.description"
|
||||
v-html="renderedDescription"
|
||||
v-html="renderedDesc"
|
||||
></span>
|
||||
<span v-else class="value description">—</span>
|
||||
</div>
|
||||
@@ -42,7 +42,26 @@
|
||||
<span class="label">{{ $t('taskDetail.status') }}</span>
|
||||
<span class="value">
|
||||
<span class="status-tag" :class="taskStatusClass(task.status)">{{ $t(taskStatusLabel(task.status)) }}</span>
|
||||
<!-- F-04 review 轮次显示(>0 才显示,in_review→in_progress / testing→in_review 退回时后端 +1) -->
|
||||
<span v-if="task.review_rounds > 0" class="review-rounds-badge">
|
||||
{{ $t('taskDetail.reviewRounds', { n: task.review_rounds }) }}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<!-- F-05 推进按钮:按当前 status 显示状态机合法下一态(todo/done/cancelled 终态无按钮) -->
|
||||
<div v-if="advanceActions.length" class="info-item info-block advance-row">
|
||||
<span class="label">{{ $t('taskDetail.advanceTitle') }}</span>
|
||||
<div class="advance-actions">
|
||||
<button
|
||||
v-for="act in advanceActions"
|
||||
:key="act.target"
|
||||
type="button"
|
||||
class="btn btn-sm"
|
||||
:class="act.variant"
|
||||
:disabled="advancing"
|
||||
@click="handleAdvance(act.target)"
|
||||
>{{ $t(act.label) }}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="label">{{ $t('taskDetail.priority') }}</span>
|
||||
@@ -95,9 +114,10 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { listen } from '@tauri-apps/api/event'
|
||||
import { taskApi, projectApi } from '@/api'
|
||||
import { formatDate } from '@/utils/time'
|
||||
import { useRendered } from '@/composables/useMarkdown'
|
||||
@@ -107,7 +127,7 @@ import {
|
||||
priorityLabel,
|
||||
priorityClass,
|
||||
} from '../constants/project'
|
||||
import type { TaskRecord, ProjectRecord } from '@/api/types'
|
||||
import type { TaskRecord, ProjectRecord, DfDataChangedPayload } from '@/api/types'
|
||||
|
||||
const { t } = useI18n()
|
||||
const route = useRoute()
|
||||
@@ -116,12 +136,13 @@ const loading = ref(true)
|
||||
const errorMsg = ref('')
|
||||
const task = ref<TaskRecord | null>(null)
|
||||
const projects = ref<ProjectRecord[]>([])
|
||||
const advancing = ref(false)
|
||||
|
||||
const taskId = computed(() => route.params.id as string)
|
||||
|
||||
// B-24:任务描述 Markdown 渲染(复用 AiChat 同款渲染器,模块级单例),useRendered 封装
|
||||
// computed(读 mdReady 触发响应式 + renderMd)+ ensureLoaded(幂等预热)
|
||||
const { rendered: renderedDescription, ensureLoaded } = useRendered(
|
||||
const { rendered: renderedDesc, ensureLoaded } = useRendered(
|
||||
() => task.value?.description ?? '',
|
||||
)
|
||||
|
||||
@@ -130,6 +151,76 @@ const projectName = computed(() => {
|
||||
return p?.name ?? task.value?.project_id ?? '—'
|
||||
})
|
||||
|
||||
// ============================================================
|
||||
// F-05 推进按钮 — 状态机合法下一态映射
|
||||
// ------------------------------------------------------------
|
||||
// 真相源:后端 df-nodes task_state_machine.rs can_transition(详见该文件注释矩阵)。
|
||||
// 前端只做「按当前 status 显示哪些合法目标」的渲染决策,不做合法性判定;
|
||||
// 点击调 advance_task,后端二次校验(can_transition)+ 原子写 + review_rounds 累加。
|
||||
//
|
||||
// 矩阵(行=from):
|
||||
// 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 → 终态,无按钮
|
||||
// variant: primary=前向推进主操作(每态至多一个),danger=取消,ghost=其余(阻塞/退回/恢复)
|
||||
interface AdvanceAction {
|
||||
target: string
|
||||
label: string // i18n key(走 taskDetail.advance.*)
|
||||
variant: string // btn-primary | btn-danger | btn-ghost
|
||||
}
|
||||
const ADVANCE_MAP: Record<string, AdvanceAction[]> = {
|
||||
todo: [
|
||||
{ target: 'in_progress', label: 'taskDetail.advance.toInProgress', variant: 'btn-primary' },
|
||||
{ target: 'cancelled', label: 'taskDetail.advance.cancel', variant: 'btn-danger' },
|
||||
],
|
||||
in_progress: [
|
||||
{ target: 'in_review', label: 'taskDetail.advance.toInReview', variant: 'btn-primary' },
|
||||
{ target: 'blocked', label: 'taskDetail.advance.block', variant: 'btn-ghost' },
|
||||
{ target: 'cancelled', label: 'taskDetail.advance.cancel', variant: 'btn-danger' },
|
||||
],
|
||||
in_review: [
|
||||
{ target: 'testing', label: 'taskDetail.advance.toTesting', variant: 'btn-primary' },
|
||||
{ target: 'in_progress', label: 'taskDetail.advance.sendBack', variant: 'btn-ghost' },
|
||||
{ target: 'blocked', label: 'taskDetail.advance.block', variant: 'btn-ghost' },
|
||||
{ target: 'cancelled', label: 'taskDetail.advance.cancel', variant: 'btn-danger' },
|
||||
],
|
||||
testing: [
|
||||
{ target: 'done', label: 'taskDetail.advance.toDone', variant: 'btn-primary' },
|
||||
{ target: 'in_review', label: 'taskDetail.advance.sendBackToReview', variant: 'btn-ghost' },
|
||||
{ target: 'blocked', label: 'taskDetail.advance.block', variant: 'btn-ghost' },
|
||||
{ target: 'cancelled', label: 'taskDetail.advance.cancel', variant: 'btn-danger' },
|
||||
],
|
||||
blocked: [
|
||||
{ target: 'in_progress', label: 'taskDetail.advance.resume', variant: 'btn-primary' },
|
||||
{ target: 'cancelled', label: 'taskDetail.advance.cancel', variant: 'btn-danger' },
|
||||
],
|
||||
// done / cancelled:终态,无推进按钮
|
||||
}
|
||||
|
||||
const advanceActions = computed<AdvanceAction[]>(() => {
|
||||
const s = task.value?.status
|
||||
if (!s) return []
|
||||
return ADVANCE_MAP[s] ?? []
|
||||
})
|
||||
|
||||
async function handleAdvance(target: string) {
|
||||
if (!task.value || advancing.value) return
|
||||
advancing.value = true
|
||||
try {
|
||||
// advance_task 返回更新后的 TaskRecord(含新 status / review_rounds)
|
||||
const updated = await taskApi.advance(task.value.id, target)
|
||||
task.value = updated
|
||||
errorMsg.value = ''
|
||||
} catch (e: any) {
|
||||
errorMsg.value = e?.toString() ?? t('taskDetail.advanceFailed')
|
||||
} finally {
|
||||
advancing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
errorMsg.value = ''
|
||||
@@ -156,9 +247,31 @@ function refresh() {
|
||||
// 路由参数变化(id 变化)时重新加载
|
||||
watch(taskId, () => { load() })
|
||||
|
||||
onMounted(() => {
|
||||
// B-260616-18: 数据变更联动刷新 unlistener(onMounted 注册,onBeforeUnmount 释放)
|
||||
// 对齐 AiChat _unlistenToolSlow 生命周期模式。本视图绕 store 直调 taskApi.get/projectApi.list,
|
||||
// 不享受 store 全局 df-data-changed 监听(该监听只刷 store.tasks 列表,不含本视图的当前 task 单体),
|
||||
// 故此本地监听 entity=task/project 时重载当前 task。
|
||||
let _unlistenDataChanged: (() => void) | null = null
|
||||
|
||||
onMounted(async () => {
|
||||
ensureLoaded() // 后台预热 Markdown 渲染器(模块单例,与 AiChat 共享),不阻塞 load
|
||||
load()
|
||||
// B-260616-18: 后端 AI 工具(create/update/delete 等)emit df-data-changed → 本视图重载当前 task
|
||||
// entity=task:任务本体字段(标题/状态/描述/分支…)被改时刷新;entity=project:项目名变更影响 projectName 解析时刷新
|
||||
try {
|
||||
_unlistenDataChanged = await listen<DfDataChangedPayload>('df-data-changed', (event) => {
|
||||
const { entity } = event.payload
|
||||
if (entity === 'task' || entity === 'project') {
|
||||
load()
|
||||
}
|
||||
})
|
||||
} catch (e) {
|
||||
console.error('[TaskDetail] 启动 df-data-changed 监听失败:', e)
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (_unlistenDataChanged) { _unlistenDataChanged(); _unlistenDataChanged = null }
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -189,6 +302,31 @@ onMounted(() => {
|
||||
}
|
||||
.btn-ghost { background: transparent; color: var(--df-text-secondary); border: 0.5px solid var(--df-border); }
|
||||
.btn-ghost:hover { background: var(--df-bg-card); color: var(--df-text); }
|
||||
/* F-05 推进按钮变体(样式跟随 Projects/ProjectDetail/Knowledge 同款 btn-primary/btn-danger/btn-sm) */
|
||||
.btn-primary { background: var(--df-accent); color: #fff; }
|
||||
.btn-primary:hover { background: var(--df-accent-hover); }
|
||||
.btn-danger { background: transparent; color: var(--df-danger); border: 0.5px solid rgba(240,101,101,0.4); }
|
||||
.btn-danger:hover { background: rgba(240,101,101,0.12); }
|
||||
.btn-sm { padding: 5px 12px; font-size: 12px; }
|
||||
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
|
||||
/* F-05 推进操作区 */
|
||||
.advance-row .advance-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* F-04 review 轮次徽章 */
|
||||
.review-rounds-badge {
|
||||
font-size: 11px;
|
||||
color: var(--df-warning);
|
||||
background: rgba(255,217,61,0.12);
|
||||
padding: 3px 8px;
|
||||
border-radius: var(--df-radius-xs);
|
||||
margin-left: 8px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ===== 状态/优先级标签 ===== */
|
||||
.status-tag {
|
||||
@@ -258,77 +396,7 @@ onMounted(() => {
|
||||
/* B-24 漏 white-space 覆盖致 v-html 后 HTML 标签间 \n 被 pre-wrap 渲染为空行间距——移除 pre-wrap,描述由 .ai-md 接管(line-height 1.5 对齐 AiChat li) */
|
||||
.info-item .value.description { line-height: 1.5; }
|
||||
|
||||
/* ===== 任务描述 Markdown 渲染(B-24,样式同 AiChat .ai-md 收敛) ===== */
|
||||
.description.ai-md :deep(p) { margin: 0 0 6px; }
|
||||
.description.ai-md :deep(p:last-child) { margin-bottom: 0; }
|
||||
.description.ai-md :deep(ul), .description.ai-md :deep(ol) { margin: 4px 0; padding-left: 20px; }
|
||||
.description.ai-md :deep(li) { margin: 2px 0; line-height: 1.5; }
|
||||
.description.ai-md :deep(code) {
|
||||
font-family: var(--df-font-mono);
|
||||
font-size: 12px;
|
||||
padding: 1px 5px;
|
||||
background: rgba(255,255,255,0.06);
|
||||
border-radius: var(--df-radius-sm);
|
||||
color: var(--df-accent);
|
||||
}
|
||||
.description.ai-md :deep(pre) {
|
||||
margin: 8px 0;
|
||||
padding: 10px 12px;
|
||||
background: var(--df-bg);
|
||||
border: 0.5px solid var(--df-border);
|
||||
border-radius: var(--df-radius);
|
||||
overflow-x: auto;
|
||||
}
|
||||
.description.ai-md :deep(pre code) {
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border-radius: 0;
|
||||
color: var(--df-text-secondary);
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.description.ai-md :deep(blockquote) {
|
||||
margin: 6px 0;
|
||||
padding: 4px 12px;
|
||||
border-left: 2px solid var(--df-accent);
|
||||
color: var(--df-text-secondary);
|
||||
}
|
||||
.description.ai-md :deep(h1), .description.ai-md :deep(h2), .description.ai-md :deep(h3) {
|
||||
font-weight: 500;
|
||||
color: var(--df-text);
|
||||
margin: 8px 0 4px;
|
||||
}
|
||||
.description.ai-md :deep(h1) { font-size: 16px; }
|
||||
.description.ai-md :deep(h2) { font-size: 14px; }
|
||||
.description.ai-md :deep(h3) { font-size: 13px; }
|
||||
.description.ai-md :deep(a) {
|
||||
color: var(--df-accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
.description.ai-md :deep(a:hover) { text-decoration: underline; }
|
||||
.description.ai-md :deep(strong) { font-weight: 500; color: var(--df-text); }
|
||||
.description.ai-md :deep(hr) {
|
||||
border: none;
|
||||
border-top: 0.5px solid var(--df-border);
|
||||
margin: 8px 0;
|
||||
}
|
||||
.description.ai-md :deep(table) {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 6px 0;
|
||||
font-size: 12px;
|
||||
overflow-x: auto;
|
||||
display: block;
|
||||
}
|
||||
.description.ai-md :deep(th), .description.ai-md :deep(td) {
|
||||
padding: 4px 8px;
|
||||
border: 0.5px solid var(--df-border);
|
||||
text-align: left;
|
||||
}
|
||||
.description.ai-md :deep(th) {
|
||||
font-weight: 500;
|
||||
background: var(--df-bg);
|
||||
}
|
||||
/* ===== 任务描述 Markdown 渲染(B-24,基础样式收敛至全局 ai-md.css) ===== */
|
||||
|
||||
.project-link {
|
||||
color: var(--df-accent);
|
||||
|
||||
@@ -143,13 +143,16 @@ const projectFilters = computed(() => [
|
||||
...store.projects.map(p => ({ key: p.id, label: p.name })),
|
||||
])
|
||||
|
||||
// D-260616-01:筛选器对齐后端 7 态(删 review_ready/merged/abandoned,加 in_review/testing/done/blocked/cancelled)
|
||||
const statusFilters = computed<{ key: string; label: string; icon: string }[]>(() => [
|
||||
{ key: 'all', label: t('tasks.statusFilter.all'), icon: '📋' },
|
||||
{ key: 'todo', label: t('tasks.statusFilter.todo'), icon: '📝' },
|
||||
{ key: 'in_progress', label: t('tasks.statusFilter.in_progress'), icon: '🔨' },
|
||||
{ key: 'review_ready', label: t('tasks.statusFilter.review_ready'), icon: '👀' },
|
||||
{ key: 'merged', label: t('tasks.statusFilter.merged'), icon: '✅' },
|
||||
{ key: 'abandoned', label: t('tasks.statusFilter.abandoned'), icon: '🗑️' },
|
||||
{ key: 'in_review', label: t('tasks.statusFilter.in_review'), icon: '👀' },
|
||||
{ key: 'testing', label: t('tasks.statusFilter.testing'), icon: '🧪' },
|
||||
{ key: 'done', label: t('tasks.statusFilter.done'), icon: '✅' },
|
||||
{ key: 'blocked', label: t('tasks.statusFilter.blocked'), icon: '🚫' },
|
||||
{ key: 'cancelled', label: t('tasks.statusFilter.cancelled'), icon: '🗑️' },
|
||||
])
|
||||
|
||||
function getProjectName(projectId: string): string {
|
||||
@@ -406,6 +409,9 @@ onMounted(async () => {
|
||||
.status-review { background: rgba(255,217,61,0.2); color: var(--df-warning); }
|
||||
.status-done { background: rgba(100,255,218,0.15); color: var(--df-success); }
|
||||
.status-abandoned { background: rgba(255,107,107,0.2); color: var(--df-danger); }
|
||||
/* D-260616-01:7 态新增 — testing(橙警告,近 review 区分)/blocked(红 danger,与 cancelled 区分用实心边框) */
|
||||
.status-testing { background: rgba(255,152,0,0.2); color: #ff9800; }
|
||||
.status-blocked { background: rgba(255,107,107,0.12); color: var(--df-danger); border: 0.5px solid var(--df-danger); }
|
||||
|
||||
/* ===== 空状态(Y-2: loading/error/empty 兜底,样式参考 Projects.vue) ===== */
|
||||
.empty-state {
|
||||
|
||||
Reference in New Issue
Block a user