diff --git a/crates/df-storage/src/crud/project_event_repo.rs b/crates/df-storage/src/crud/project_event_repo.rs index fd720c8..4201ea1 100644 --- a/crates/df-storage/src/crud/project_event_repo.rs +++ b/crates/df-storage/src/crud/project_event_repo.rs @@ -278,6 +278,7 @@ mod tests { idea_id: None, path: None, stack: None, + archived: false, created_at: "1700000000000".to_string(), updated_at: "1700000000000".to_string(), }) @@ -426,6 +427,7 @@ mod tests { idea_id: None, path: None, stack: None, + archived: false, created_at: "1700000000000".to_string(), updated_at: "1700000000000".to_string(), }) diff --git a/crates/df-storage/src/crud/project_module_repo.rs b/crates/df-storage/src/crud/project_module_repo.rs index 20c1de4..088f09a 100644 --- a/crates/df-storage/src/crud/project_module_repo.rs +++ b/crates/df-storage/src/crud/project_module_repo.rs @@ -255,6 +255,7 @@ mod tests { idea_id: None, path: Some("/tmp".into()), stack: Some("rust".into()), + archived: false, created_at: "0".to_string(), updated_at: "0".to_string(), }) diff --git a/crates/df-storage/src/crud/project_repo.rs b/crates/df-storage/src/crud/project_repo.rs index 3df4f90..0368de3 100644 --- a/crates/df-storage/src/crud/project_repo.rs +++ b/crates/df-storage/src/crud/project_repo.rs @@ -1,4 +1,5 @@ //! 项目域 Repo:ProjectRepo / BranchRepo / ReleaseRepo / WorkflowRepo / NodeExecutionRepo +//! 项目域 Repo:ProjectRepo / BranchRepo / ReleaseRepo / WorkflowRepo / NodeExecutionRepo use std::sync::Arc; @@ -125,6 +126,7 @@ fn project_from_row(row: &Row<'_>) -> std::result::Result("archived")? != 0, created_at: row.get("created_at")?, updated_at: row.get("updated_at")?, }) @@ -200,20 +202,20 @@ impl_repo!( from_row => |row| project_from_row(row), insert => |conn, rec| { conn.execute( - "INSERT INTO projects (id, name, description, status, idea_id, path, stack, created_at, updated_at) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)", + "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, ?10)", params![ 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| { 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![ 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 || { let guard = conn.blocking_lock(); 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)?; let rows = stmt .query_map([], |row| project_from_row(row)) @@ -261,10 +263,10 @@ impl ProjectRepo { // 保证所有项目有可比活跃时间。ORDER BY 该列 DESC,同时间 created_at DESC 兜底稳定。 let mut stmt = guard .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), \ 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", ) .map_err(storage_err)?; @@ -315,8 +317,8 @@ impl ProjectRepo { // 动态拼 WHERE + 参数。WHERE 1=1 起点 + deleted_at IS NULL 固定基础条件, // 后续可选条件 AND 拼接(对齐 KnowledgeRepo::search 的 if-let 分支模式)。 let mut sql = String::from( - "SELECT id, name, description, status, idea_id, path, stack, created_at, updated_at \ - FROM projects WHERE deleted_at IS NULL", + "SELECT id, name, description, status, idea_id, path, stack, archived, created_at, updated_at \ + FROM projects WHERE deleted_at IS NULL AND archived = 0", ); // keyword 用 ?N 占位两次(name LIKE ? AND description LIKE ? 同 pattern), // 对齐 KnowledgeRepo::search 的 `params![pattern, pattern, ...]`。 @@ -421,7 +423,7 @@ impl ProjectRepo { tokio::task::spawn_blocking(move || { let guard = conn.blocking_lock(); 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)?; let rows = stmt .query_map([], |row| project_from_row(row)) @@ -500,6 +502,54 @@ impl ProjectRepo { .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 { + 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> { + 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(不可恢复) /// /// SQLite 已开 PRAGMA foreign_keys=ON 但表无 ON DELETE CASCADE,ALTER 改不了 FK 约束, @@ -718,6 +768,7 @@ mod tests { idea_id: None, path: None, stack: None, + archived: false, created_at: "1700000000000".to_string(), updated_at: "1700000000000".to_string(), } diff --git a/crates/df-storage/src/crud/project_service_repo.rs b/crates/df-storage/src/crud/project_service_repo.rs index dafbe07..6be0474 100644 --- a/crates/df-storage/src/crud/project_service_repo.rs +++ b/crates/df-storage/src/crud/project_service_repo.rs @@ -319,6 +319,7 @@ mod tests { idea_id: None, path: None, stack: None, + archived: false, created_at: "1700000000000".to_string(), updated_at: "1700000000000".to_string(), }) diff --git a/crates/df-storage/src/crud/task_link_repo.rs b/crates/df-storage/src/crud/task_link_repo.rs index bf5b837..4a46e33 100644 --- a/crates/df-storage/src/crud/task_link_repo.rs +++ b/crates/df-storage/src/crud/task_link_repo.rs @@ -1,4 +1,5 @@ //! 任务横向关联 Repo:TaskLinkRepo(task_links 表,知识图谱 Phase 1 V29) +//! 任务横向关联 Repo:TaskLinkRepo(task_links 表,知识图谱 Phase 1 V29) //! //! 表达任务间 depends_on / blocks / relates_to 关系,AI 拓扑排序编排调度的基础。 //! 对标设计 docs/02-架构设计/专项设计/项目知识图谱与任务队列系统-2026-06-26.md §2.2。 @@ -376,6 +377,7 @@ mod tests { idea_id: None, path: None, stack: None, + archived: false, created_at: "1700000000000".to_string(), updated_at: "1700000000000".to_string(), }) diff --git a/crates/df-storage/src/crud/task_repo.rs b/crates/df-storage/src/crud/task_repo.rs index bb66449..64a3f8e 100644 --- a/crates/df-storage/src/crud/task_repo.rs +++ b/crates/df-storage/src/crud/task_repo.rs @@ -1,4 +1,5 @@ //! 任务域 Repo:TaskRepo(含 advance_status_atomic 状态机收口) +//! 任务域 Repo:TaskRepo(含 advance_status_atomic 状态机收口) use std::sync::Arc; @@ -987,6 +988,7 @@ mod tests { idea_id: None, path: None, stack: None, + archived: false, created_at: "1700000000000".to_string(), updated_at: "1700000000000".to_string(), }) @@ -1089,6 +1091,7 @@ mod tests { idea_id: None, path: None, stack: None, + archived: false, created_at: "1700000000000".to_string(), updated_at: "1700000000000".to_string(), }) diff --git a/crates/df-storage/src/crud/task_validation.rs b/crates/df-storage/src/crud/task_validation.rs index 0ec294d..0889f08 100644 --- a/crates/df-storage/src/crud/task_validation.rs +++ b/crates/df-storage/src/crud/task_validation.rs @@ -103,6 +103,7 @@ mod tests { idea_id: None, path: None, stack: None, + archived: false, created_at: "1700000000000".to_string(), updated_at: "1700000000000".to_string(), }) diff --git a/crates/df-storage/src/migrations.rs b/crates/df-storage/src/migrations.rs index 8a53745..eabed6d 100644 --- a/crates/df-storage/src/migrations.rs +++ b/crates/df-storage/src/migrations.rs @@ -1,4 +1,5 @@ //! 数据库迁移 — 建表 SQL 与版本管理 +//! 数据库迁移 — 建表 SQL 与版本管理 use crate::migrations_helpers::column_exists; use anyhow::Result; @@ -55,7 +56,8 @@ pub fn run(conn: &Connection) -> Result<()> { // 发起时一次声明存续期生效,随会话同亡)。 // V46/V47 = 任务/灵感归档(archived INTEGER NOT NULL DEFAULT 0,归档功能 P1, // 对标 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), (2, migrate_v2), (3, migrate_v3), @@ -105,6 +107,8 @@ pub fn run(conn: &Connection) -> Result<()> { // V47 = 灵感归档(ideas.archived 列,同款)。归档与软删正交:回收站=删除缓冲,归档=低频可见保检索。 (46, migrate_v46), (47, migrate_v47), + // V48 = 项目归档(projects.archived 列,归档功能 P2,对标 V46/V47 同款)。 + (48, migrate_v48), ]; for (version, migrate_fn) in steps { @@ -1404,6 +1408,25 @@ fn migrate_v47(conn: &Connection) -> Result<()> { 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 表 /// /// 与 V9_SQL 中的 ai_messages 镜像(V9 给新库,此 const 给老库 V21 迁移用 IF NOT EXISTS)。 @@ -2417,8 +2440,13 @@ mod tests { column_exists(&conn, "ideas", "archived"), "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 .query_row( "SELECT COALESCE(MAX(version), 0) FROM schema_version", @@ -2427,8 +2455,8 @@ mod tests { ) .expect("查 schema_version 应成功"); assert_eq!( - max_version, 47, - "全量迁移后 schema_version 应为 47(实际 {}),说明某条 migrate_vN 链路断在中间", + max_version, 48, + "全量迁移后 schema_version 应为 48(实际 {}),说明某条 migrate_vN 链路断在中间", max_version ); @@ -2505,7 +2533,7 @@ mod tests { |r| r.get(0), ) .unwrap(); - assert_eq!(max_v, 47, "首轮应推进到 47"); + assert_eq!(max_v, 48, "首轮应推进到 48"); // 清空版本表强制全链第二遍(每步 execute 第二次) conn.execute("DELETE FROM schema_version", []).unwrap(); @@ -2517,7 +2545,7 @@ mod tests { |r| r.get(0), ) .unwrap(); - assert_eq!(max_v2, 47, "重跑后应重新推进到 47"); + assert_eq!(max_v2, 48, "重跑后应重新推进到 48"); } // ============================================================ diff --git a/crates/df-storage/src/models.rs b/crates/df-storage/src/models.rs index ce6ce2a..7ca93ac 100644 --- a/crates/df-storage/src/models.rs +++ b/crates/df-storage/src/models.rs @@ -64,6 +64,11 @@ pub struct ProjectRecord { pub path: Option, /// 技术栈 JSON 数组字符串(如 ["rust","vue","tauri"],由探测填充,可空) pub stack: Option, + /// 项目归档标记(归档功能 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 updated_at: String, } diff --git a/crates/df-storage/tests/project_soft_delete.rs b/crates/df-storage/tests/project_soft_delete.rs index d80f019..8460fbf 100644 --- a/crates/df-storage/tests/project_soft_delete.rs +++ b/crates/df-storage/tests/project_soft_delete.rs @@ -32,6 +32,7 @@ fn project(id: &str) -> ProjectRecord { idea_id: None, path: None, stack: None, + archived: false, created_at: now_ts(), updated_at: now_ts(), } diff --git a/src/api/project.ts b/src/api/project.ts index 0b4fda9..7cec704 100644 --- a/src/api/project.ts +++ b/src/api/project.ts @@ -96,6 +96,16 @@ export const projectApi = { return invoke('list_deleted_projects') }, + /** 归档/取消归档项目(归档功能 P2,V48)。归档后退出主列表,归档箱内可见可恢复 */ + archive(id: string, archived: boolean): Promise { + return invoke('archive_project', { id, archived }) + }, + + /** 列出归档项目(归档箱数据源) */ + listArchived(): Promise { + return invoke('list_archived_projects') + }, + restore(id: string): Promise { return invoke('restore_project', { id }) }, diff --git a/src/api/types.ts b/src/api/types.ts index d5b171d..a532d99 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -430,6 +430,12 @@ export type AiChatEvent = ({ // 技能注入反馈闭环:/ 技能 resolve 成功(ok=true)/失败(ok=false,未找到或读失败)通知, // 前端轻 toast(成功「已注入技能《name》」/失败「技能 name 未找到」)。仅提示,不改消息流。 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'(F-260619-03 Phase B 路径授权挂起,走 once/session/always/deny)vs 'risk'(普通 RiskLevel 审批, @@ -744,6 +750,13 @@ export interface AiToolCallInfo { startedAt?: number /** 工具执行完成时间(前端在 AiToolCallCompleted 时记录,毫秒)。语义同 startedAt。 */ completedAt?: number + /** + * 执行中实时流式输出(AiCommandOutput 事件累积):run_command shell 行级 / + * ask_uclaw 进度块级。数组按到达序追加,ToolCard running 态实时渲染(尾部 N 行), + * Completed 时清空(真实结果由 result 字段渲染,防双显)。 + * undefined = 无流式输出的工具或历史消息(DB 回载不含此内存态)。 + */ + streamLines?: string[] /** * A2-B10 conv-scoped 审批收尾:挂起审批所属的会话 id(前端判定用,仅挂起项设置)。 * 来源:AiApprovalRequired / AiDirAuthRequired(统一审批归一)事件透传 conversation_id diff --git a/src/components/project/ProjectCard.vue b/src/components/project/ProjectCard.vue index f330957..1c234a9 100644 --- a/src/components/project/ProjectCard.vue +++ b/src/components/project/ProjectCard.vue @@ -6,6 +6,7 @@
{{ $t(statusLabel(project.status)) }} +
@@ -68,6 +69,7 @@ const props = withDefaults(defineProps<{ }) const emit = defineEmits<{ (e: 'delete', project: ProjectRecord): void + (e: 'archive', project: ProjectRecord): void }>() const router = useRouter() diff --git a/src/i18n/en/projects.ts b/src/i18n/en/projects.ts index 1f037f1..bac7e26 100644 --- a/src/i18n/en/projects.ts +++ b/src/i18n/en/projects.ts @@ -23,6 +23,16 @@ export default { purge: 'Delete Permanently', // Project card 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: 'No projects yet, click the top-right button to create your first project', // Confirm prompts @@ -80,6 +90,8 @@ export default { loadTrashFailed: 'Failed to load trash', restoreFailed: 'Failed to restore project', purgeFailed: 'Failed to delete permanently', + archiveFailed: 'Archive operation failed', + loadArchivedFailed: 'Failed to load archive box', loadWorkflowFailed: 'Failed to load workflow records', approveFailed: 'Approval operation failed', cancelFailed: 'Cancel approval failed', diff --git a/src/i18n/zh-CN/projects.ts b/src/i18n/zh-CN/projects.ts index 2b45120..8743d67 100644 --- a/src/i18n/zh-CN/projects.ts +++ b/src/i18n/zh-CN/projects.ts @@ -54,6 +54,16 @@ export default { purge: '彻底删除', // 项目卡片 deleteTitle: '删除', + // 归档箱(归档功能 P2,V48;对齐回收站动线,可逆无彻底删除) + archiveBox: '📦 归档箱', + archiveBoxTitle: '📦 归档箱', + archiveBoxEmpty: '归档箱为空', + archiveTitle: '归档', + unarchive: '↩ 取消归档', + archivedAt: '归档于', + confirmArchive: '确定归档项目「{name}」?将从主列表隐藏,可随时从归档箱恢复。', + archivedToast: '已归档', + unarchivedToast: '已恢复到主列表', // 空状态 empty: '暂无项目,点击右上角创建第一个项目', // 确认文案 @@ -79,6 +89,8 @@ export default { loadTrashFailed: '加载回收站失败', restoreFailed: '恢复项目失败', purgeFailed: '彻底删除失败', + archiveFailed: '归档操作失败', + loadArchivedFailed: '加载归档箱失败', loadWorkflowFailed: '加载工作流记录失败', approveFailed: '审批操作失败', cancelFailed: '取消审批失败', diff --git a/src/stores/project.ts b/src/stores/project.ts index aa2621f..7f5e222 100644 --- a/src/stores/project.ts +++ b/src/stores/project.ts @@ -163,6 +163,7 @@ function createStore() { get loading() { return state.loading }, get error() { return state.error }, get deletedProjects() { return state.deletedProjects }, + get archivedProjects() { return state.archivedProjects }, clearError, // project actions loadProjects: projectsStore.loadProjects, @@ -174,6 +175,9 @@ function createStore() { loadDeletedProjects: projectsStore.loadDeletedProjects, restoreProject: projectsStore.restoreProject, purgeProject: projectsStore.purgeProject, + archiveProject: projectsStore.archiveProject, + loadArchivedProjects: projectsStore.loadArchivedProjects, + unarchiveProject: projectsStore.unarchiveProject, // task actions loadTasks: tasksStore.loadTasks, createTask: tasksStore.createTask, diff --git a/src/stores/project/projects.ts b/src/stores/project/projects.ts index ec1e921..fe74c91 100644 --- a/src/stores/project/projects.ts +++ b/src/stores/project/projects.ts @@ -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) { try { await projectApi.purge(id) // 彻底删(级联物理删,不可恢复) @@ -96,6 +124,9 @@ export function createProjectsStore() { loadDeletedProjects, restoreProject, purgeProject, + archiveProject, + loadArchivedProjects, + unarchiveProject, // clearError 来自 state(供 barrel re-export) clearError, } diff --git a/src/stores/project/state.ts b/src/stores/project/state.ts index 6b817ae..27c7ff6 100644 --- a/src/stores/project/state.ts +++ b/src/stores/project/state.ts @@ -16,6 +16,8 @@ export interface PendingApproval { export const state = reactive({ projects: [] as ProjectRecord[], deletedProjects: [] as ProjectRecord[], + // 归档箱数据源(归档功能 P2,V48;独立于 projects——主列表后端已默认排除 archived) + archivedProjects: [] as ProjectRecord[], tasks: [] as TaskRecord[], // 统计缓存,与 tasks 数组解耦,供 Dashboard,不被 Tasks 分页/筛选污染(P1-b A0) activeTaskCount: 0 as number, diff --git a/src/views/Projects.vue b/src/views/Projects.vue index aa212f5..5c571b8 100644 --- a/src/views/Projects.vue +++ b/src/views/Projects.vue @@ -19,6 +19,7 @@ >▦ + @@ -91,6 +92,28 @@ + + +