新增: 项目归档
This commit is contained in:
@@ -278,6 +278,7 @@ mod tests {
|
|||||||
idea_id: None,
|
idea_id: None,
|
||||||
path: None,
|
path: None,
|
||||||
stack: None,
|
stack: None,
|
||||||
|
archived: false,
|
||||||
created_at: "1700000000000".to_string(),
|
created_at: "1700000000000".to_string(),
|
||||||
updated_at: "1700000000000".to_string(),
|
updated_at: "1700000000000".to_string(),
|
||||||
})
|
})
|
||||||
@@ -426,6 +427,7 @@ mod tests {
|
|||||||
idea_id: None,
|
idea_id: None,
|
||||||
path: None,
|
path: None,
|
||||||
stack: None,
|
stack: None,
|
||||||
|
archived: false,
|
||||||
created_at: "1700000000000".to_string(),
|
created_at: "1700000000000".to_string(),
|
||||||
updated_at: "1700000000000".to_string(),
|
updated_at: "1700000000000".to_string(),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -255,6 +255,7 @@ mod tests {
|
|||||||
idea_id: None,
|
idea_id: None,
|
||||||
path: Some("/tmp".into()),
|
path: Some("/tmp".into()),
|
||||||
stack: Some("rust".into()),
|
stack: Some("rust".into()),
|
||||||
|
archived: false,
|
||||||
created_at: "0".to_string(),
|
created_at: "0".to_string(),
|
||||||
updated_at: "0".to_string(),
|
updated_at: "0".to_string(),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
//! 项目域 Repo:ProjectRepo / BranchRepo / ReleaseRepo / WorkflowRepo / NodeExecutionRepo
|
//! 项目域 Repo:ProjectRepo / BranchRepo / ReleaseRepo / WorkflowRepo / NodeExecutionRepo
|
||||||
|
//! 项目域 Repo:ProjectRepo / BranchRepo / ReleaseRepo / WorkflowRepo / NodeExecutionRepo
|
||||||
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
@@ -125,6 +126,7 @@ fn project_from_row(row: &Row<'_>) -> std::result::Result<ProjectRecord, rusqlit
|
|||||||
idea_id: row.get("idea_id")?,
|
idea_id: row.get("idea_id")?,
|
||||||
path: row.get("path")?,
|
path: row.get("path")?,
|
||||||
stack: row.get("stack")?,
|
stack: row.get("stack")?,
|
||||||
|
archived: row.get::<_, i32>("archived")? != 0,
|
||||||
created_at: row.get("created_at")?,
|
created_at: row.get("created_at")?,
|
||||||
updated_at: row.get("updated_at")?,
|
updated_at: row.get("updated_at")?,
|
||||||
})
|
})
|
||||||
@@ -200,20 +202,20 @@ impl_repo!(
|
|||||||
from_row => |row| project_from_row(row),
|
from_row => |row| project_from_row(row),
|
||||||
insert => |conn, rec| {
|
insert => |conn, rec| {
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO projects (id, name, description, status, idea_id, path, stack, created_at, updated_at)
|
"INSERT INTO projects (id, name, description, status, idea_id, path, stack, archived, created_at, updated_at)
|
||||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
|
||||||
params![
|
params![
|
||||||
rec.id, rec.name, rec.description, rec.status.as_str(), rec.idea_id,
|
rec.id, rec.name, rec.description, rec.status.as_str(), rec.idea_id,
|
||||||
rec.path, rec.stack, rec.created_at, rec.updated_at
|
rec.path, rec.stack, rec.archived as i32, rec.created_at, rec.updated_at
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
update => |conn, rec| {
|
update => |conn, rec| {
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"UPDATE projects SET name = ?1, description = ?2, status = ?3, idea_id = ?4, path = ?5, stack = ?6, updated_at = ?7 WHERE id = ?8",
|
"UPDATE projects SET name = ?1, description = ?2, status = ?3, idea_id = ?4, path = ?5, stack = ?6, archived = ?7, updated_at = ?8 WHERE id = ?9",
|
||||||
params![
|
params![
|
||||||
rec.name, rec.description, rec.status.as_str(), rec.idea_id,
|
rec.name, rec.description, rec.status.as_str(), rec.idea_id,
|
||||||
rec.path, rec.stack, rec.updated_at, rec.id
|
rec.path, rec.stack, rec.archived as i32, rec.updated_at, rec.id
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -226,7 +228,7 @@ impl ProjectRepo {
|
|||||||
tokio::task::spawn_blocking(move || {
|
tokio::task::spawn_blocking(move || {
|
||||||
let guard = conn.blocking_lock();
|
let guard = conn.blocking_lock();
|
||||||
let mut stmt = guard
|
let mut stmt = guard
|
||||||
.prepare("SELECT id, name, description, status, idea_id, path, stack, created_at, updated_at FROM projects WHERE deleted_at IS NULL ORDER BY created_at DESC")
|
.prepare("SELECT id, name, description, status, idea_id, path, stack, archived, created_at, updated_at FROM projects WHERE deleted_at IS NULL AND archived = 0 ORDER BY created_at DESC")
|
||||||
.map_err(storage_err)?;
|
.map_err(storage_err)?;
|
||||||
let rows = stmt
|
let rows = stmt
|
||||||
.query_map([], |row| project_from_row(row))
|
.query_map([], |row| project_from_row(row))
|
||||||
@@ -261,10 +263,10 @@ impl ProjectRepo {
|
|||||||
// 保证所有项目有可比活跃时间。ORDER BY 该列 DESC,同时间 created_at DESC 兜底稳定。
|
// 保证所有项目有可比活跃时间。ORDER BY 该列 DESC,同时间 created_at DESC 兜底稳定。
|
||||||
let mut stmt = guard
|
let mut stmt = guard
|
||||||
.prepare(
|
.prepare(
|
||||||
"SELECT id, name, description, status, idea_id, path, stack, created_at, updated_at, \
|
"SELECT id, name, description, status, idea_id, path, stack, archived, created_at, updated_at, \
|
||||||
COALESCE((SELECT MAX(created_at) FROM project_events WHERE project_id = projects.id), \
|
COALESCE((SELECT MAX(created_at) FROM project_events WHERE project_id = projects.id), \
|
||||||
updated_at) AS last_active_at \
|
updated_at) AS last_active_at \
|
||||||
FROM projects WHERE deleted_at IS NULL \
|
FROM projects WHERE deleted_at IS NULL AND archived = 0 \
|
||||||
ORDER BY last_active_at DESC, created_at DESC",
|
ORDER BY last_active_at DESC, created_at DESC",
|
||||||
)
|
)
|
||||||
.map_err(storage_err)?;
|
.map_err(storage_err)?;
|
||||||
@@ -315,8 +317,8 @@ impl ProjectRepo {
|
|||||||
// 动态拼 WHERE + 参数。WHERE 1=1 起点 + deleted_at IS NULL 固定基础条件,
|
// 动态拼 WHERE + 参数。WHERE 1=1 起点 + deleted_at IS NULL 固定基础条件,
|
||||||
// 后续可选条件 AND 拼接(对齐 KnowledgeRepo::search 的 if-let 分支模式)。
|
// 后续可选条件 AND 拼接(对齐 KnowledgeRepo::search 的 if-let 分支模式)。
|
||||||
let mut sql = String::from(
|
let mut sql = String::from(
|
||||||
"SELECT id, name, description, status, idea_id, path, stack, created_at, updated_at \
|
"SELECT id, name, description, status, idea_id, path, stack, archived, created_at, updated_at \
|
||||||
FROM projects WHERE deleted_at IS NULL",
|
FROM projects WHERE deleted_at IS NULL AND archived = 0",
|
||||||
);
|
);
|
||||||
// keyword 用 ?N 占位两次(name LIKE ? AND description LIKE ? 同 pattern),
|
// keyword 用 ?N 占位两次(name LIKE ? AND description LIKE ? 同 pattern),
|
||||||
// 对齐 KnowledgeRepo::search 的 `params![pattern, pattern, ...]`。
|
// 对齐 KnowledgeRepo::search 的 `params![pattern, pattern, ...]`。
|
||||||
@@ -421,7 +423,7 @@ impl ProjectRepo {
|
|||||||
tokio::task::spawn_blocking(move || {
|
tokio::task::spawn_blocking(move || {
|
||||||
let guard = conn.blocking_lock();
|
let guard = conn.blocking_lock();
|
||||||
let mut stmt = guard
|
let mut stmt = guard
|
||||||
.prepare("SELECT id, name, description, status, idea_id, path, stack, created_at, updated_at FROM projects WHERE deleted_at IS NOT NULL ORDER BY updated_at DESC")
|
.prepare("SELECT id, name, description, status, idea_id, path, stack, archived, created_at, updated_at FROM projects WHERE deleted_at IS NOT NULL ORDER BY updated_at DESC")
|
||||||
.map_err(storage_err)?;
|
.map_err(storage_err)?;
|
||||||
let rows = stmt
|
let rows = stmt
|
||||||
.query_map([], |row| project_from_row(row))
|
.query_map([], |row| project_from_row(row))
|
||||||
@@ -500,6 +502,54 @@ impl ProjectRepo {
|
|||||||
.map_err(storage_err)?
|
.map_err(storage_err)?
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 归档/取消归档(归档功能 P2,V48)。
|
||||||
|
///
|
||||||
|
/// 纯元数据标记,仅作用于未软删项目(回收站优先级高于归档,先恢复再操作)。
|
||||||
|
/// archived=true 后项目退出主视图(list_active/list_active_with_activity/list_by_query
|
||||||
|
/// 恒带 archived = 0),仅在归档视图可见。对标 IdeaRepo::set_archived。返回是否命中。
|
||||||
|
/// 注:archived 不进 update_field 通用白名单,防工具层旁路改写(布尔语义走专用通道)。
|
||||||
|
pub async fn set_archived(&self, id: &str, archived: bool) -> 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 projects SET archived = ?1, updated_at = ?2 WHERE id = ?3 AND deleted_at IS NULL",
|
||||||
|
params![if archived { 1 } else { 0 }, now, id],
|
||||||
|
)
|
||||||
|
.map_err(storage_err)?;
|
||||||
|
Ok(affected > 0)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(storage_err)?
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 列出归档项目(archived = 1 且未软删),按更新时间降序(最近归档在前)。
|
||||||
|
///
|
||||||
|
/// 归档视图数据源,与 list_active 对偶(恒带 archived = 1)。
|
||||||
|
/// 对标 IdeaRepo::list_archived。
|
||||||
|
pub async fn list_archived(&self) -> Result<Vec<ProjectRecord>> {
|
||||||
|
let conn = self.conn.clone();
|
||||||
|
tokio::task::spawn_blocking(move || {
|
||||||
|
let guard = conn.blocking_lock();
|
||||||
|
let mut stmt = guard
|
||||||
|
.prepare("SELECT id, name, description, status, idea_id, path, stack, archived, created_at, updated_at FROM projects WHERE deleted_at IS NULL AND archived = 1 ORDER BY updated_at DESC")
|
||||||
|
.map_err(storage_err)?;
|
||||||
|
let rows = stmt
|
||||||
|
.query_map([], |row| project_from_row(row))
|
||||||
|
.map_err(storage_err)?;
|
||||||
|
let mut results = Vec::new();
|
||||||
|
for r in rows {
|
||||||
|
results.push(r.map_err(storage_err)?);
|
||||||
|
}
|
||||||
|
Ok(results)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(storage_err)?
|
||||||
|
}
|
||||||
|
|
||||||
/// 彻底删除:事务级联删全部关联子表→projects(不可恢复)
|
/// 彻底删除:事务级联删全部关联子表→projects(不可恢复)
|
||||||
///
|
///
|
||||||
/// SQLite 已开 PRAGMA foreign_keys=ON 但表无 ON DELETE CASCADE,ALTER 改不了 FK 约束,
|
/// SQLite 已开 PRAGMA foreign_keys=ON 但表无 ON DELETE CASCADE,ALTER 改不了 FK 约束,
|
||||||
@@ -718,6 +768,7 @@ mod tests {
|
|||||||
idea_id: None,
|
idea_id: None,
|
||||||
path: None,
|
path: None,
|
||||||
stack: None,
|
stack: None,
|
||||||
|
archived: false,
|
||||||
created_at: "1700000000000".to_string(),
|
created_at: "1700000000000".to_string(),
|
||||||
updated_at: "1700000000000".to_string(),
|
updated_at: "1700000000000".to_string(),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -319,6 +319,7 @@ mod tests {
|
|||||||
idea_id: None,
|
idea_id: None,
|
||||||
path: None,
|
path: None,
|
||||||
stack: None,
|
stack: None,
|
||||||
|
archived: false,
|
||||||
created_at: "1700000000000".to_string(),
|
created_at: "1700000000000".to_string(),
|
||||||
updated_at: "1700000000000".to_string(),
|
updated_at: "1700000000000".to_string(),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
//! 任务横向关联 Repo:TaskLinkRepo(task_links 表,知识图谱 Phase 1 V29)
|
//! 任务横向关联 Repo:TaskLinkRepo(task_links 表,知识图谱 Phase 1 V29)
|
||||||
|
//! 任务横向关联 Repo:TaskLinkRepo(task_links 表,知识图谱 Phase 1 V29)
|
||||||
//!
|
//!
|
||||||
//! 表达任务间 depends_on / blocks / relates_to 关系,AI 拓扑排序编排调度的基础。
|
//! 表达任务间 depends_on / blocks / relates_to 关系,AI 拓扑排序编排调度的基础。
|
||||||
//! 对标设计 docs/02-架构设计/专项设计/项目知识图谱与任务队列系统-2026-06-26.md §2.2。
|
//! 对标设计 docs/02-架构设计/专项设计/项目知识图谱与任务队列系统-2026-06-26.md §2.2。
|
||||||
@@ -376,6 +377,7 @@ mod tests {
|
|||||||
idea_id: None,
|
idea_id: None,
|
||||||
path: None,
|
path: None,
|
||||||
stack: None,
|
stack: None,
|
||||||
|
archived: false,
|
||||||
created_at: "1700000000000".to_string(),
|
created_at: "1700000000000".to_string(),
|
||||||
updated_at: "1700000000000".to_string(),
|
updated_at: "1700000000000".to_string(),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
//! 任务域 Repo:TaskRepo(含 advance_status_atomic 状态机收口)
|
//! 任务域 Repo:TaskRepo(含 advance_status_atomic 状态机收口)
|
||||||
|
//! 任务域 Repo:TaskRepo(含 advance_status_atomic 状态机收口)
|
||||||
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
@@ -987,6 +988,7 @@ mod tests {
|
|||||||
idea_id: None,
|
idea_id: None,
|
||||||
path: None,
|
path: None,
|
||||||
stack: None,
|
stack: None,
|
||||||
|
archived: false,
|
||||||
created_at: "1700000000000".to_string(),
|
created_at: "1700000000000".to_string(),
|
||||||
updated_at: "1700000000000".to_string(),
|
updated_at: "1700000000000".to_string(),
|
||||||
})
|
})
|
||||||
@@ -1089,6 +1091,7 @@ mod tests {
|
|||||||
idea_id: None,
|
idea_id: None,
|
||||||
path: None,
|
path: None,
|
||||||
stack: None,
|
stack: None,
|
||||||
|
archived: false,
|
||||||
created_at: "1700000000000".to_string(),
|
created_at: "1700000000000".to_string(),
|
||||||
updated_at: "1700000000000".to_string(),
|
updated_at: "1700000000000".to_string(),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -103,6 +103,7 @@ mod tests {
|
|||||||
idea_id: None,
|
idea_id: None,
|
||||||
path: None,
|
path: None,
|
||||||
stack: None,
|
stack: None,
|
||||||
|
archived: false,
|
||||||
created_at: "1700000000000".to_string(),
|
created_at: "1700000000000".to_string(),
|
||||||
updated_at: "1700000000000".to_string(),
|
updated_at: "1700000000000".to_string(),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
//! 数据库迁移 — 建表 SQL 与版本管理
|
//! 数据库迁移 — 建表 SQL 与版本管理
|
||||||
|
//! 数据库迁移 — 建表 SQL 与版本管理
|
||||||
|
|
||||||
use crate::migrations_helpers::column_exists;
|
use crate::migrations_helpers::column_exists;
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
@@ -55,7 +56,8 @@ pub fn run(conn: &Connection) -> Result<()> {
|
|||||||
// 发起时一次声明存续期生效,随会话同亡)。
|
// 发起时一次声明存续期生效,随会话同亡)。
|
||||||
// V46/V47 = 任务/灵感归档(archived INTEGER NOT NULL DEFAULT 0,归档功能 P1,
|
// V46/V47 = 任务/灵感归档(archived INTEGER NOT NULL DEFAULT 0,归档功能 P1,
|
||||||
// 对标 ai_conversations.archived V4;归档与软删正交:回收站=删除缓冲,归档=低频可见保检索)。
|
// 对标 ai_conversations.archived V4;归档与软删正交:回收站=删除缓冲,归档=低频可见保检索)。
|
||||||
let steps: [(i32, fn(&Connection) -> Result<()>); 47] = [
|
// V48 = 项目归档(projects.archived 列,归档功能 P2,同款三方对齐)。
|
||||||
|
let steps: [(i32, fn(&Connection) -> Result<()>); 48] = [
|
||||||
(1, migrate_v1),
|
(1, migrate_v1),
|
||||||
(2, migrate_v2),
|
(2, migrate_v2),
|
||||||
(3, migrate_v3),
|
(3, migrate_v3),
|
||||||
@@ -105,6 +107,8 @@ pub fn run(conn: &Connection) -> Result<()> {
|
|||||||
// V47 = 灵感归档(ideas.archived 列,同款)。归档与软删正交:回收站=删除缓冲,归档=低频可见保检索。
|
// V47 = 灵感归档(ideas.archived 列,同款)。归档与软删正交:回收站=删除缓冲,归档=低频可见保检索。
|
||||||
(46, migrate_v46),
|
(46, migrate_v46),
|
||||||
(47, migrate_v47),
|
(47, migrate_v47),
|
||||||
|
// V48 = 项目归档(projects.archived 列,归档功能 P2,对标 V46/V47 同款)。
|
||||||
|
(48, migrate_v48),
|
||||||
];
|
];
|
||||||
|
|
||||||
for (version, migrate_fn) in steps {
|
for (version, migrate_fn) in steps {
|
||||||
@@ -1404,6 +1408,25 @@ fn migrate_v47(conn: &Connection) -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// V48: 幂等补 projects.archived 列(项目归档,归档功能 P2)
|
||||||
|
///
|
||||||
|
/// 对标 V46 tasks / V47 ideas 同款:归档与软删正交(回收站=删除缓冲,归档=低频可见保检索)。
|
||||||
|
/// archived 不进 update_field 通用白名单,走专用 set_archived(布尔语义 + 防旁路)。
|
||||||
|
/// 活跃查询(list_active/list_active_with_activity/list_by_query)恒带 `archived = 0`。
|
||||||
|
/// - **幂等**:column_exists 守卫;**可逆**:`ALTER TABLE projects DROP COLUMN archived`。
|
||||||
|
fn migrate_v48(conn: &Connection) -> Result<()> {
|
||||||
|
if !column_exists(conn, "projects", "archived") {
|
||||||
|
conn.execute(
|
||||||
|
"ALTER TABLE projects ADD COLUMN archived INTEGER NOT NULL DEFAULT 0",
|
||||||
|
[],
|
||||||
|
)?;
|
||||||
|
tracing::info!("v48: 补建 projects.archived 列(项目归档)");
|
||||||
|
}
|
||||||
|
conn.execute("INSERT OR IGNORE INTO schema_version (version) VALUES (?)", [48])?;
|
||||||
|
tracing::info!("迁移 v48 完成: projects 加 archived 列");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// V21 建表 SQL — 消息拆分存储 ai_messages 表
|
/// V21 建表 SQL — 消息拆分存储 ai_messages 表
|
||||||
///
|
///
|
||||||
/// 与 V9_SQL 中的 ai_messages 镜像(V9 给新库,此 const 给老库 V21 迁移用 IF NOT EXISTS)。
|
/// 与 V9_SQL 中的 ai_messages 镜像(V9 给新库,此 const 给老库 V21 迁移用 IF NOT EXISTS)。
|
||||||
@@ -2417,8 +2440,13 @@ mod tests {
|
|||||||
column_exists(&conn, "ideas", "archived"),
|
column_exists(&conn, "ideas", "archived"),
|
||||||
"ideas.archived 列缺失(V47 加)"
|
"ideas.archived 列缺失(V47 加)"
|
||||||
);
|
);
|
||||||
|
// projects.archived(V48 项目归档 P2)
|
||||||
|
assert!(
|
||||||
|
column_exists(&conn, "projects", "archived"),
|
||||||
|
"projects.archived 列缺失(V48 加)"
|
||||||
|
);
|
||||||
|
|
||||||
// 3. schema_version 应推进到 45(全量迁移成功落版本号)
|
// 3. schema_version 应推进到 48(全量迁移成功落版本号)
|
||||||
let max_version: i64 = conn
|
let max_version: i64 = conn
|
||||||
.query_row(
|
.query_row(
|
||||||
"SELECT COALESCE(MAX(version), 0) FROM schema_version",
|
"SELECT COALESCE(MAX(version), 0) FROM schema_version",
|
||||||
@@ -2427,8 +2455,8 @@ mod tests {
|
|||||||
)
|
)
|
||||||
.expect("查 schema_version 应成功");
|
.expect("查 schema_version 应成功");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
max_version, 47,
|
max_version, 48,
|
||||||
"全量迁移后 schema_version 应为 47(实际 {}),说明某条 migrate_vN 链路断在中间",
|
"全量迁移后 schema_version 应为 48(实际 {}),说明某条 migrate_vN 链路断在中间",
|
||||||
max_version
|
max_version
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -2505,7 +2533,7 @@ mod tests {
|
|||||||
|r| r.get(0),
|
|r| r.get(0),
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(max_v, 47, "首轮应推进到 47");
|
assert_eq!(max_v, 48, "首轮应推进到 48");
|
||||||
|
|
||||||
// 清空版本表强制全链第二遍(每步 execute 第二次)
|
// 清空版本表强制全链第二遍(每步 execute 第二次)
|
||||||
conn.execute("DELETE FROM schema_version", []).unwrap();
|
conn.execute("DELETE FROM schema_version", []).unwrap();
|
||||||
@@ -2517,7 +2545,7 @@ mod tests {
|
|||||||
|r| r.get(0),
|
|r| r.get(0),
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(max_v2, 47, "重跑后应重新推进到 47");
|
assert_eq!(max_v2, 48, "重跑后应重新推进到 48");
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|||||||
@@ -64,6 +64,11 @@ pub struct ProjectRecord {
|
|||||||
pub path: Option<String>,
|
pub path: Option<String>,
|
||||||
/// 技术栈 JSON 数组字符串(如 ["rust","vue","tauri"],由探测填充,可空)
|
/// 技术栈 JSON 数组字符串(如 ["rust","vue","tauri"],由探测填充,可空)
|
||||||
pub stack: Option<String>,
|
pub stack: Option<String>,
|
||||||
|
/// 项目归档标记(归档功能 P2,V48)。true=归档态,退出主视图(list_active/
|
||||||
|
/// list_active_with_activity/list_by_query 恒带 archived = 0),仅在归档视图可见。
|
||||||
|
/// 与软删正交:回收站=删除缓冲,归档=低频可见保检索。#[serde(default)] 兼容旧前端 JSON。
|
||||||
|
#[serde(default)]
|
||||||
|
pub archived: bool,
|
||||||
pub created_at: String,
|
pub created_at: String,
|
||||||
pub updated_at: String,
|
pub updated_at: String,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ fn project(id: &str) -> ProjectRecord {
|
|||||||
idea_id: None,
|
idea_id: None,
|
||||||
path: None,
|
path: None,
|
||||||
stack: None,
|
stack: None,
|
||||||
|
archived: false,
|
||||||
created_at: now_ts(),
|
created_at: now_ts(),
|
||||||
updated_at: now_ts(),
|
updated_at: now_ts(),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -96,6 +96,16 @@ export const projectApi = {
|
|||||||
return invoke('list_deleted_projects')
|
return invoke('list_deleted_projects')
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** 归档/取消归档项目(归档功能 P2,V48)。归档后退出主列表,归档箱内可见可恢复 */
|
||||||
|
archive(id: string, archived: boolean): Promise<boolean> {
|
||||||
|
return invoke('archive_project', { id, archived })
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 列出归档项目(归档箱数据源) */
|
||||||
|
listArchived(): Promise<ProjectRecord[]> {
|
||||||
|
return invoke('list_archived_projects')
|
||||||
|
},
|
||||||
|
|
||||||
restore(id: string): Promise<boolean> {
|
restore(id: string): Promise<boolean> {
|
||||||
return invoke('restore_project', { id })
|
return invoke('restore_project', { id })
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -430,6 +430,12 @@ export type AiChatEvent = ({
|
|||||||
// 技能注入反馈闭环:/ 技能 resolve 成功(ok=true)/失败(ok=false,未找到或读失败)通知,
|
// 技能注入反馈闭环:/ 技能 resolve 成功(ok=true)/失败(ok=false,未找到或读失败)通知,
|
||||||
// 前端轻 toast(成功「已注入技能《name》」/失败「技能 name 未找到」)。仅提示,不改消息流。
|
// 前端轻 toast(成功「已注入技能《name》」/失败「技能 name 未找到」)。仅提示,不改消息流。
|
||||||
type: 'AiSkillInjected'; name: string; ok: boolean
|
type: 'AiSkillInjected'; name: string; ok: boolean
|
||||||
|
} | {
|
||||||
|
// 工具执行实时流式输出(run_command shell 行级 / ask_uclaw 进度块级):
|
||||||
|
// 后端经 task-local sink(command_stream / tools/uclaw)emit,前端按 id 路由到对应
|
||||||
|
// 工具卡,在 Started→Completed 间实时展示。合批在后端(50ms/4KB 或 u-claw 2s 节流)。
|
||||||
|
// stream: "stdout"|"stderr"(来源,前端差异化着色);line:累积文本块(多行 '\n' 连接)。
|
||||||
|
type: 'AiCommandOutput'; id: string; stream: string; line: string
|
||||||
} | {
|
} | {
|
||||||
// path_auth 审批链阶段3b(统一审批模型):AiApprovalRequired 携带 kind 字段区分
|
// path_auth 审批链阶段3b(统一审批模型):AiApprovalRequired 携带 kind 字段区分
|
||||||
// 'path'(F-260619-03 Phase B 路径授权挂起,走 once/session/always/deny)vs 'risk'(普通 RiskLevel 审批,
|
// 'path'(F-260619-03 Phase B 路径授权挂起,走 once/session/always/deny)vs 'risk'(普通 RiskLevel 审批,
|
||||||
@@ -744,6 +750,13 @@ export interface AiToolCallInfo {
|
|||||||
startedAt?: number
|
startedAt?: number
|
||||||
/** 工具执行完成时间(前端在 AiToolCallCompleted 时记录,毫秒)。语义同 startedAt。 */
|
/** 工具执行完成时间(前端在 AiToolCallCompleted 时记录,毫秒)。语义同 startedAt。 */
|
||||||
completedAt?: number
|
completedAt?: number
|
||||||
|
/**
|
||||||
|
* 执行中实时流式输出(AiCommandOutput 事件累积):run_command shell 行级 /
|
||||||
|
* ask_uclaw 进度块级。数组按到达序追加,ToolCard running 态实时渲染(尾部 N 行),
|
||||||
|
* Completed 时清空(真实结果由 result 字段渲染,防双显)。
|
||||||
|
* undefined = 无流式输出的工具或历史消息(DB 回载不含此内存态)。
|
||||||
|
*/
|
||||||
|
streamLines?: string[]
|
||||||
/**
|
/**
|
||||||
* A2-B10 conv-scoped 审批收尾:挂起审批所属的会话 id(前端判定用,仅挂起项设置)。
|
* A2-B10 conv-scoped 审批收尾:挂起审批所属的会话 id(前端判定用,仅挂起项设置)。
|
||||||
* 来源:AiApprovalRequired / AiDirAuthRequired(统一审批归一)事件透传 conversation_id
|
* 来源:AiApprovalRequired / AiDirAuthRequired(统一审批归一)事件透传 conversation_id
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="card-top-actions">
|
<div class="card-top-actions">
|
||||||
<span class="stage-badge" :class="stageClass(project.status)">{{ $t(statusLabel(project.status)) }}</span>
|
<span class="stage-badge" :class="stageClass(project.status)">{{ $t(statusLabel(project.status)) }}</span>
|
||||||
|
<button class="card-del-btn" :title="$t('projects.archiveTitle')" @click.stop="emit('archive', project)">📦</button>
|
||||||
<button class="card-del-btn" :title="$t('projects.deleteTitle')" @click.stop="emit('delete', project)">🗑</button>
|
<button class="card-del-btn" :title="$t('projects.deleteTitle')" @click.stop="emit('delete', project)">🗑</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -68,6 +69,7 @@ const props = withDefaults(defineProps<{
|
|||||||
})
|
})
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(e: 'delete', project: ProjectRecord): void
|
(e: 'delete', project: ProjectRecord): void
|
||||||
|
(e: 'archive', project: ProjectRecord): void
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|||||||
@@ -23,6 +23,16 @@ export default {
|
|||||||
purge: 'Delete Permanently',
|
purge: 'Delete Permanently',
|
||||||
// Project card
|
// Project card
|
||||||
deleteTitle: 'Delete',
|
deleteTitle: 'Delete',
|
||||||
|
// Archive box (archive P2, V48; aligned with trash flow, reversible, no hard delete)
|
||||||
|
archiveBox: '📦 Archive',
|
||||||
|
archiveBoxTitle: '📦 Archive Box',
|
||||||
|
archiveBoxEmpty: 'Archive box is empty',
|
||||||
|
archiveTitle: 'Archive',
|
||||||
|
unarchive: '↩ Unarchive',
|
||||||
|
archivedAt: 'archived at',
|
||||||
|
confirmArchive: 'Archive project "{name}"? It will be hidden from the main list and can be restored from the archive box anytime.',
|
||||||
|
archivedToast: 'Archived',
|
||||||
|
unarchivedToast: 'Restored to main list',
|
||||||
// Empty state
|
// Empty state
|
||||||
empty: 'No projects yet, click the top-right button to create your first project',
|
empty: 'No projects yet, click the top-right button to create your first project',
|
||||||
// Confirm prompts
|
// Confirm prompts
|
||||||
@@ -80,6 +90,8 @@ export default {
|
|||||||
loadTrashFailed: 'Failed to load trash',
|
loadTrashFailed: 'Failed to load trash',
|
||||||
restoreFailed: 'Failed to restore project',
|
restoreFailed: 'Failed to restore project',
|
||||||
purgeFailed: 'Failed to delete permanently',
|
purgeFailed: 'Failed to delete permanently',
|
||||||
|
archiveFailed: 'Archive operation failed',
|
||||||
|
loadArchivedFailed: 'Failed to load archive box',
|
||||||
loadWorkflowFailed: 'Failed to load workflow records',
|
loadWorkflowFailed: 'Failed to load workflow records',
|
||||||
approveFailed: 'Approval operation failed',
|
approveFailed: 'Approval operation failed',
|
||||||
cancelFailed: 'Cancel approval failed',
|
cancelFailed: 'Cancel approval failed',
|
||||||
|
|||||||
@@ -54,6 +54,16 @@ export default {
|
|||||||
purge: '彻底删除',
|
purge: '彻底删除',
|
||||||
// 项目卡片
|
// 项目卡片
|
||||||
deleteTitle: '删除',
|
deleteTitle: '删除',
|
||||||
|
// 归档箱(归档功能 P2,V48;对齐回收站动线,可逆无彻底删除)
|
||||||
|
archiveBox: '📦 归档箱',
|
||||||
|
archiveBoxTitle: '📦 归档箱',
|
||||||
|
archiveBoxEmpty: '归档箱为空',
|
||||||
|
archiveTitle: '归档',
|
||||||
|
unarchive: '↩ 取消归档',
|
||||||
|
archivedAt: '归档于',
|
||||||
|
confirmArchive: '确定归档项目「{name}」?将从主列表隐藏,可随时从归档箱恢复。',
|
||||||
|
archivedToast: '已归档',
|
||||||
|
unarchivedToast: '已恢复到主列表',
|
||||||
// 空状态
|
// 空状态
|
||||||
empty: '暂无项目,点击右上角创建第一个项目',
|
empty: '暂无项目,点击右上角创建第一个项目',
|
||||||
// 确认文案
|
// 确认文案
|
||||||
@@ -79,6 +89,8 @@ export default {
|
|||||||
loadTrashFailed: '加载回收站失败',
|
loadTrashFailed: '加载回收站失败',
|
||||||
restoreFailed: '恢复项目失败',
|
restoreFailed: '恢复项目失败',
|
||||||
purgeFailed: '彻底删除失败',
|
purgeFailed: '彻底删除失败',
|
||||||
|
archiveFailed: '归档操作失败',
|
||||||
|
loadArchivedFailed: '加载归档箱失败',
|
||||||
loadWorkflowFailed: '加载工作流记录失败',
|
loadWorkflowFailed: '加载工作流记录失败',
|
||||||
approveFailed: '审批操作失败',
|
approveFailed: '审批操作失败',
|
||||||
cancelFailed: '取消审批失败',
|
cancelFailed: '取消审批失败',
|
||||||
|
|||||||
@@ -163,6 +163,7 @@ function createStore() {
|
|||||||
get loading() { return state.loading },
|
get loading() { return state.loading },
|
||||||
get error() { return state.error },
|
get error() { return state.error },
|
||||||
get deletedProjects() { return state.deletedProjects },
|
get deletedProjects() { return state.deletedProjects },
|
||||||
|
get archivedProjects() { return state.archivedProjects },
|
||||||
clearError,
|
clearError,
|
||||||
// project actions
|
// project actions
|
||||||
loadProjects: projectsStore.loadProjects,
|
loadProjects: projectsStore.loadProjects,
|
||||||
@@ -174,6 +175,9 @@ function createStore() {
|
|||||||
loadDeletedProjects: projectsStore.loadDeletedProjects,
|
loadDeletedProjects: projectsStore.loadDeletedProjects,
|
||||||
restoreProject: projectsStore.restoreProject,
|
restoreProject: projectsStore.restoreProject,
|
||||||
purgeProject: projectsStore.purgeProject,
|
purgeProject: projectsStore.purgeProject,
|
||||||
|
archiveProject: projectsStore.archiveProject,
|
||||||
|
loadArchivedProjects: projectsStore.loadArchivedProjects,
|
||||||
|
unarchiveProject: projectsStore.unarchiveProject,
|
||||||
// task actions
|
// task actions
|
||||||
loadTasks: tasksStore.loadTasks,
|
loadTasks: tasksStore.loadTasks,
|
||||||
createTask: tasksStore.createTask,
|
createTask: tasksStore.createTask,
|
||||||
|
|||||||
@@ -77,6 +77,34 @@ export function createProjectsStore() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 归档功能 P2(V48):归档箱三动作,对标 tasks/ideas 子 store 同款 ──
|
||||||
|
|
||||||
|
/** 归档/取消归档。归档=退出主列表;取消归档=从归档箱移回主列表(两端各刷一份) */
|
||||||
|
async function archiveProject(id: string, archived: boolean) {
|
||||||
|
await runWithCatch(state, t('projects.err.archiveFailed'), async () => {
|
||||||
|
await projectApi.archive(id, archived)
|
||||||
|
if (archived) {
|
||||||
|
// 从主列表移出,归档箱按需懒加载(打开归档箱时 loadArchivedProjects)
|
||||||
|
state.projects = state.projects.filter(p => p.id !== id)
|
||||||
|
} else {
|
||||||
|
state.archivedProjects = state.archivedProjects.filter(p => p.id !== id)
|
||||||
|
await loadProjects() // 取消归档回主列表
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 加载归档项目(归档箱 Tab 数据源) */
|
||||||
|
async function loadArchivedProjects() {
|
||||||
|
await runWithCatch(state, t('projects.err.loadArchivedFailed'), async () => {
|
||||||
|
state.archivedProjects = await projectApi.listArchived()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 归档箱内恢复到主列表(取消归档语义的 UI 别名,动线用词更自然) */
|
||||||
|
async function unarchiveProject(id: string) {
|
||||||
|
await archiveProject(id, false)
|
||||||
|
}
|
||||||
|
|
||||||
async function purgeProject(id: string) {
|
async function purgeProject(id: string) {
|
||||||
try {
|
try {
|
||||||
await projectApi.purge(id) // 彻底删(级联物理删,不可恢复)
|
await projectApi.purge(id) // 彻底删(级联物理删,不可恢复)
|
||||||
@@ -96,6 +124,9 @@ export function createProjectsStore() {
|
|||||||
loadDeletedProjects,
|
loadDeletedProjects,
|
||||||
restoreProject,
|
restoreProject,
|
||||||
purgeProject,
|
purgeProject,
|
||||||
|
archiveProject,
|
||||||
|
loadArchivedProjects,
|
||||||
|
unarchiveProject,
|
||||||
// clearError 来自 state(供 barrel re-export)
|
// clearError 来自 state(供 barrel re-export)
|
||||||
clearError,
|
clearError,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ export interface PendingApproval {
|
|||||||
export const state = reactive({
|
export const state = reactive({
|
||||||
projects: [] as ProjectRecord[],
|
projects: [] as ProjectRecord[],
|
||||||
deletedProjects: [] as ProjectRecord[],
|
deletedProjects: [] as ProjectRecord[],
|
||||||
|
// 归档箱数据源(归档功能 P2,V48;独立于 projects——主列表后端已默认排除 archived)
|
||||||
|
archivedProjects: [] as ProjectRecord[],
|
||||||
tasks: [] as TaskRecord[],
|
tasks: [] as TaskRecord[],
|
||||||
// 统计缓存,与 tasks 数组解耦,供 Dashboard,不被 Tasks 分页/筛选污染(P1-b A0)
|
// 统计缓存,与 tasks 数组解耦,供 Dashboard,不被 Tasks 分页/筛选污染(P1-b A0)
|
||||||
activeTaskCount: 0 as number,
|
activeTaskCount: 0 as number,
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
>▦</button>
|
>▦</button>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn btn-ghost" @click="openImportModal">{{ $t('projects.importHistory') }}</button>
|
<button class="btn btn-ghost" @click="openImportModal">{{ $t('projects.importHistory') }}</button>
|
||||||
|
<button class="btn btn-ghost" @click="openArchiveBox">{{ $t('projects.archiveBox') }}</button>
|
||||||
<button class="btn btn-ghost" @click="openTrash">{{ $t('projects.trash') }}</button>
|
<button class="btn btn-ghost" @click="openTrash">{{ $t('projects.trash') }}</button>
|
||||||
<button class="btn btn-primary" @click="showCreateModal = true">{{ $t('projects.create') }}</button>
|
<button class="btn btn-primary" @click="showCreateModal = true">{{ $t('projects.create') }}</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -91,6 +92,28 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 归档箱模态框(归档功能 P2,V48;对齐回收站 Modal 模式:列表+恢复,无彻底删除——归档可逆) -->
|
||||||
|
<div v-if="showArchiveModal" class="modal-overlay" @click.self="showArchiveModal = false">
|
||||||
|
<div class="modal-box" style="width: 520px">
|
||||||
|
<h3 class="modal-title">{{ $t('projects.archiveBoxTitle') }}</h3>
|
||||||
|
<div v-if="store.archivedProjects.length === 0" class="empty-state" style="padding: 30px 0">{{ $t('projects.archiveBoxEmpty') }}</div>
|
||||||
|
<div v-else class="trash-list">
|
||||||
|
<div class="trash-item" v-for="p in store.archivedProjects" :key="p.id">
|
||||||
|
<div class="trash-info">
|
||||||
|
<span class="trash-name">{{ p.name }}</span>
|
||||||
|
<span class="trash-date">{{ $t('projects.archivedAt') }} {{ formatDate(p.updated_at) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="trash-actions">
|
||||||
|
<button class="btn btn-ghost btn-sm" :disabled="!!unarchivingId" @click="handleUnarchive(p.id)">{{ $t('projects.unarchive') }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button class="btn btn-ghost" @click="showArchiveModal = false">{{ $t('common.close') }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 导入历史项目模态框(F-260614-06 scan 第二步) -->
|
<!-- 导入历史项目模态框(F-260614-06 scan 第二步) -->
|
||||||
<div v-if="showImportModal" class="modal-overlay" @click.self="closeImportModal">
|
<div v-if="showImportModal" class="modal-overlay" @click.self="closeImportModal">
|
||||||
<div class="modal-box import-box">
|
<div class="modal-box import-box">
|
||||||
@@ -162,6 +185,7 @@
|
|||||||
:project="project"
|
:project="project"
|
||||||
:view="viewMode"
|
:view="viewMode"
|
||||||
@delete="handleDelete"
|
@delete="handleDelete"
|
||||||
|
@archive="handleArchive"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -348,6 +372,34 @@ async function handlePurge(project: ProjectRecord) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 归档箱(归档功能 P2,V48;卡片 📦 归档 + 归档箱 Modal 恢复,对齐回收站动线) ──
|
||||||
|
const showArchiveModal = ref(false)
|
||||||
|
const unarchivingId = ref<string | null>(null)
|
||||||
|
|
||||||
|
/** 归档项目:确认后退出主列表(可从归档箱恢复) */
|
||||||
|
async function handleArchive(project: ProjectRecord) {
|
||||||
|
if (!await confirmDialog(t('projects.confirmArchive', { name: project.name }))) return
|
||||||
|
await store.archiveProject(project.id, true)
|
||||||
|
showToast(t('projects.archivedToast'), 'info')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openArchiveBox() {
|
||||||
|
await store.loadArchivedProjects()
|
||||||
|
showArchiveModal.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 归档箱内恢复:回主列表 */
|
||||||
|
async function handleUnarchive(id: string) {
|
||||||
|
if (unarchivingId.value) return
|
||||||
|
unarchivingId.value = id
|
||||||
|
try {
|
||||||
|
await store.unarchiveProject(id)
|
||||||
|
showToast(t('projects.unarchivedToast'), 'success')
|
||||||
|
} finally {
|
||||||
|
unarchivingId.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── 加载 ──
|
// ── 加载 ──
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
store.loadProjects()
|
store.loadProjects()
|
||||||
|
|||||||
Reference in New Issue
Block a user