Compare commits
3 Commits
3c18dea45b
...
7b5b62d3ae
| Author | SHA1 | Date | |
|---|---|---|---|
| 7b5b62d3ae | |||
| 50aad375eb | |||
| b72df78462 |
@@ -22,6 +22,7 @@ mod idea_eval_repo;
|
||||
mod idea_repo;
|
||||
mod message_repo;
|
||||
mod project_event_repo;
|
||||
mod project_module_repo;
|
||||
mod project_repo;
|
||||
mod project_service_repo;
|
||||
mod settings;
|
||||
@@ -33,6 +34,7 @@ pub use idea_eval_repo::*;
|
||||
pub use idea_repo::*;
|
||||
pub use message_repo::*;
|
||||
pub use project_event_repo::*;
|
||||
pub use project_module_repo::*;
|
||||
pub use project_repo::*;
|
||||
pub use project_service_repo::*;
|
||||
pub use settings::*;
|
||||
@@ -305,6 +307,7 @@ mod baseline_tests {
|
||||
let _ = TaskLinkRepo::new(&db);
|
||||
let _ = ProjectEventRepo::new(&db);
|
||||
let _ = ProjectServiceRepo::new(&db);
|
||||
let _ = ProjectModuleRepo::new(&db);
|
||||
let _ = BranchRepo::new(&db);
|
||||
let _ = ReleaseRepo::new(&db);
|
||||
let _ = WorkflowRepo::new(&db);
|
||||
|
||||
322
crates/df-storage/src/crud/project_module_repo.rs
Normal file
322
crates/df-storage/src/crud/project_module_repo.rs
Normal file
@@ -0,0 +1,322 @@
|
||||
//! 工程系统 — project_modules 表 CRUD(V34,项目多工程,每个工程独立代码仓库)
|
||||
//!
|
||||
//! 对标 [`project_service_repo`] 的风格(手写 Repo,与设计 §五工程系统 IPC 对齐)。
|
||||
//!
|
||||
//! 关键设计:
|
||||
//! - **不存 Git 状态**:分支/改动/最近提交是实时派生的(查 git 命令),不进表;
|
||||
//! 表只存工程元数据(路径/Git 地址/技术栈)。
|
||||
//! - **硬删**:`delete` / `delete_by_project` 物理删除(工程不需要软删审计追溯)。
|
||||
//! - **sort_order 排序**:`list_by_project` 按 `sort_order ASC` 返回,前端工程列表稳定顺序。
|
||||
//! - **auto_detected**:技术栈自动检测标志(扫描目录结构推断),人工覆盖时置 false。
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use rusqlite::{params, Row};
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use crate::db::Database;
|
||||
use crate::models::ProjectModuleRecord;
|
||||
|
||||
use super::{now_millis_str, storage_err};
|
||||
|
||||
// ============================================================
|
||||
// from_row 辅助函数
|
||||
// ============================================================
|
||||
|
||||
fn project_module_from_row(row: &Row<'_>) -> std::result::Result<ProjectModuleRecord, rusqlite::Error> {
|
||||
Ok(ProjectModuleRecord {
|
||||
id: row.get("id")?,
|
||||
project_id: row.get("project_id")?,
|
||||
name: row.get("name")?,
|
||||
path: row.get("path")?,
|
||||
git_url: row.get("git_url")?,
|
||||
stack: row.get("stack")?,
|
||||
auto_detected: row.get("auto_detected")?,
|
||||
sort_order: row.get("sort_order")?,
|
||||
created_at: row.get("created_at")?,
|
||||
updated_at: row.get("updated_at")?,
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Repo 实现(手写,参考 project_service_repo.rs 风格)
|
||||
// ============================================================
|
||||
|
||||
/// 工程系统 CRUD(project_modules,V34,可改非审计)。
|
||||
///
|
||||
/// 与 [`ProjectServiceRepo`](super::ProjectServiceRepo) 同款手写 Repo:
|
||||
/// 表有 `updated_at`,但工程无字段级白名单收口需求(无敏感字段、无枚举类型),
|
||||
/// 故不强制走 `impl_repo!` 宏的 `update_field` 路径——`update_full` 整体替换足够。
|
||||
pub struct ProjectModuleRepo {
|
||||
conn: Arc<Mutex<rusqlite::Connection>>,
|
||||
}
|
||||
|
||||
impl ProjectModuleRepo {
|
||||
pub fn new(db: &Database) -> Self {
|
||||
Self { conn: db.conn() }
|
||||
}
|
||||
|
||||
/// 插入一条工程记录。`created_at` / `updated_at` 由本方法内部取当前毫秒覆盖。
|
||||
/// 返回插入是否成功(affected > 0)。
|
||||
pub async fn insert(&self, record: ProjectModuleRecord) -> Result<bool, df_types::error::Error> {
|
||||
let conn = self.conn.clone();
|
||||
let now = now_millis_str();
|
||||
let mut rec = record;
|
||||
rec.created_at = now.clone();
|
||||
rec.updated_at = now;
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let guard = conn.blocking_lock();
|
||||
let conn = &*guard;
|
||||
let r = &rec;
|
||||
let affected = conn
|
||||
.execute(
|
||||
"INSERT INTO project_modules \
|
||||
(id, project_id, name, path, git_url, stack, auto_detected, sort_order, created_at, updated_at) \
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
|
||||
params![
|
||||
r.id, r.project_id, r.name, r.path,
|
||||
r.git_url, r.stack, r.auto_detected, r.sort_order,
|
||||
r.created_at, r.updated_at,
|
||||
],
|
||||
)
|
||||
.map_err(storage_err)?;
|
||||
Ok(affected > 0)
|
||||
})
|
||||
.await
|
||||
.map_err(storage_err)?
|
||||
}
|
||||
|
||||
/// 按 id 查询工程记录。
|
||||
pub async fn get_by_id(&self, id: &str) -> Result<Option<ProjectModuleRecord>, df_types::error::Error> {
|
||||
let conn = self.conn.clone();
|
||||
let id = id.to_owned();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let guard = conn.blocking_lock();
|
||||
let mut stmt = guard
|
||||
.prepare("SELECT * FROM project_modules WHERE id = ?1")
|
||||
.map_err(storage_err)?;
|
||||
let row = stmt
|
||||
.query_row(params![id], project_module_from_row)
|
||||
.optional()
|
||||
.map_err(storage_err)?;
|
||||
Ok(row)
|
||||
})
|
||||
.await
|
||||
.map_err(storage_err)?
|
||||
}
|
||||
|
||||
/// 按项目查询全部工程(命中 idx_project_modules_project,按 sort_order ASC 稳定排序)。
|
||||
pub async fn list_by_project(
|
||||
&self,
|
||||
project_id: &str,
|
||||
) -> Result<Vec<ProjectModuleRecord>, df_types::error::Error> {
|
||||
let conn = self.conn.clone();
|
||||
let project_id = project_id.to_owned();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let guard = conn.blocking_lock();
|
||||
let mut stmt = guard
|
||||
.prepare(
|
||||
"SELECT id, project_id, name, path, git_url, stack, auto_detected, sort_order, created_at, updated_at \
|
||||
FROM project_modules WHERE project_id = ?1 ORDER BY sort_order ASC",
|
||||
)
|
||||
.map_err(storage_err)?;
|
||||
let rows = stmt
|
||||
.query_map(params![project_id], project_module_from_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)?
|
||||
}
|
||||
|
||||
/// 整体更新工程记录(全可变字段,保留 id 与 created_at,updated_at 内部覆盖)。
|
||||
/// 返回是否命中(affected > 0)。
|
||||
pub async fn update_full(&self, record: &ProjectModuleRecord) -> Result<bool, df_types::error::Error> {
|
||||
let conn = self.conn.clone();
|
||||
let mut rec = record.clone();
|
||||
rec.updated_at = now_millis_str();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let guard = conn.blocking_lock();
|
||||
let conn = &*guard;
|
||||
let r = &rec;
|
||||
let affected = conn
|
||||
.execute(
|
||||
"UPDATE project_modules SET \
|
||||
project_id = ?1, name = ?2, path = ?3, git_url = ?4, stack = ?5, \
|
||||
auto_detected = ?6, sort_order = ?7, updated_at = ?8 \
|
||||
WHERE id = ?9",
|
||||
params![
|
||||
r.project_id, r.name, r.path,
|
||||
r.git_url, r.stack, r.auto_detected, r.sort_order,
|
||||
r.updated_at, r.id,
|
||||
],
|
||||
)
|
||||
.map_err(storage_err)?;
|
||||
Ok(affected > 0)
|
||||
})
|
||||
.await
|
||||
.map_err(storage_err)?
|
||||
}
|
||||
|
||||
/// 按 id 硬删工程(工程不需要软删审计追溯)。
|
||||
pub async fn delete(&self, id: &str) -> Result<bool, df_types::error::Error> {
|
||||
let conn = self.conn.clone();
|
||||
let id = id.to_owned();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let guard = conn.blocking_lock();
|
||||
let affected = guard
|
||||
.execute(
|
||||
"DELETE FROM project_modules WHERE id = ?1",
|
||||
params![id],
|
||||
)
|
||||
.map_err(storage_err)?;
|
||||
Ok(affected > 0)
|
||||
})
|
||||
.await
|
||||
.map_err(storage_err)?
|
||||
}
|
||||
|
||||
/// 按项目删除全部工程(删项目时级联清理,硬删)。
|
||||
pub async fn delete_by_project(&self, project_id: &str) -> Result<bool, df_types::error::Error> {
|
||||
let conn = self.conn.clone();
|
||||
let project_id = project_id.to_owned();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let guard = conn.blocking_lock();
|
||||
let affected = guard
|
||||
.execute(
|
||||
"DELETE FROM project_modules WHERE project_id = ?1",
|
||||
params![project_id],
|
||||
)
|
||||
.map_err(storage_err)?;
|
||||
Ok(affected > 0)
|
||||
})
|
||||
.await
|
||||
.map_err(storage_err)?
|
||||
}
|
||||
}
|
||||
|
||||
// 引入 OptionalExtension 用于 query_row().optional()(避免每次写完整路径)
|
||||
use rusqlite::OptionalExtension;
|
||||
|
||||
// ============================================================
|
||||
// 单元测试 — ProjectModuleRepo CRUD(内存 DB,对标 project_service_repo 测试风格)
|
||||
// ============================================================
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::db::Database;
|
||||
|
||||
/// 建库 + 建占位 project 满足 FK 约束 + 返回 repo(对标 project_service_repo::setup)。
|
||||
async fn setup() -> (Database, ProjectModuleRepo, String) {
|
||||
let db = Database::open_in_memory().await.expect("open_in_memory");
|
||||
let project_id = "proj-test".to_string();
|
||||
db.conn()
|
||||
.blocking_lock()
|
||||
.execute(
|
||||
"INSERT INTO projects (id, name, status, path, stack, created_at, updated_at) \
|
||||
VALUES (?1, ?2, 'active', '/tmp', 'rust', '0', '0')",
|
||||
params![project_id, "Test Project"],
|
||||
)
|
||||
.expect("insert placeholder project");
|
||||
let repo = ProjectModuleRepo::new(&db);
|
||||
(db, repo, project_id)
|
||||
}
|
||||
|
||||
fn mrec(project_id: &str, name: &str, path: &str) -> ProjectModuleRecord {
|
||||
ProjectModuleRecord {
|
||||
id: format!("mod-{name}"),
|
||||
project_id: project_id.to_string(),
|
||||
name: name.to_string(),
|
||||
path: path.to_string(),
|
||||
git_url: None,
|
||||
stack: None,
|
||||
auto_detected: false,
|
||||
sort_order: 0,
|
||||
created_at: "0".to_string(),
|
||||
updated_at: "0".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn insert_and_get_by_id_reads_back_full_fields() {
|
||||
let (_db, repo, pid) = setup().await;
|
||||
let mut rec = mrec(&pid, "frontend", "/p/frontend");
|
||||
rec.git_url = Some("https://example.com/f.git".to_string());
|
||||
rec.stack = Some(r#"{"lang":"ts"}"#.to_string());
|
||||
rec.auto_detected = true;
|
||||
rec.sort_order = 2;
|
||||
let ok = repo.insert(rec.clone()).await.expect("insert");
|
||||
assert!(ok);
|
||||
let got = repo.get_by_id(&rec.id).await.expect("get").expect("found");
|
||||
assert_eq!(got.name, "frontend");
|
||||
assert_eq!(got.path, "/p/frontend");
|
||||
assert_eq!(got.git_url.as_deref(), Some("https://example.com/f.git"));
|
||||
assert_eq!(got.stack.as_deref(), Some(r#"{"lang":"ts"}"#));
|
||||
assert!(got.auto_detected);
|
||||
assert_eq!(got.sort_order, 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_by_project_orders_by_sort_order_asc() {
|
||||
let (_db, repo, pid) = setup().await;
|
||||
let mut a = mrec(&pid, "a", "/p/a");
|
||||
a.sort_order = 5;
|
||||
let mut b = mrec(&pid, "b", "/p/b");
|
||||
b.sort_order = 1;
|
||||
let mut c = mrec(&pid, "c", "/p/c");
|
||||
c.sort_order = 3;
|
||||
repo.insert(a).await.unwrap();
|
||||
repo.insert(b).await.unwrap();
|
||||
repo.insert(c).await.unwrap();
|
||||
let list = repo.list_by_project(&pid).await.expect("list");
|
||||
assert_eq!(list.len(), 3);
|
||||
// sort_order ASC:b(1) → c(3) → a(5)
|
||||
assert_eq!(list[0].name, "b");
|
||||
assert_eq!(list[1].name, "c");
|
||||
assert_eq!(list[2].name, "a");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_full_changes_fields_and_preserves_created_at() {
|
||||
let (_db, repo, pid) = setup().await;
|
||||
let rec = mrec(&pid, "frontend", "/p/old");
|
||||
repo.insert(rec.clone()).await.unwrap();
|
||||
let original = repo.get_by_id(&rec.id).await.unwrap().unwrap();
|
||||
// 整体更新:path 变了,sort_order 变了
|
||||
let mut updated = original.clone();
|
||||
updated.path = "/p/new".to_string();
|
||||
updated.sort_order = 9;
|
||||
let hit = repo.update_full(&updated).await.expect("update");
|
||||
assert!(hit);
|
||||
let got = repo.get_by_id(&rec.id).await.unwrap().unwrap();
|
||||
assert_eq!(got.path, "/p/new");
|
||||
assert_eq!(got.sort_order, 9);
|
||||
// created_at 保留不变(update_full 不覆盖 created_at)
|
||||
assert_eq!(got.created_at, original.created_at);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_removes_record() {
|
||||
let (_db, repo, pid) = setup().await;
|
||||
let rec = mrec(&pid, "frontend", "/p/f");
|
||||
repo.insert(rec.clone()).await.unwrap();
|
||||
let hit = repo.delete(&rec.id).await.expect("delete");
|
||||
assert!(hit);
|
||||
assert!(repo.get_by_id(&rec.id).await.unwrap().is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_by_project_removes_all() {
|
||||
let (_db, repo, pid) = setup().await;
|
||||
repo.insert(mrec(&pid, "a", "/p/a")).await.unwrap();
|
||||
repo.insert(mrec(&pid, "b", "/p/b")).await.unwrap();
|
||||
let hit = repo.delete_by_project(&pid).await.expect("delete_by_project");
|
||||
assert!(hit);
|
||||
assert!(repo.list_by_project(&pid).await.unwrap().is_empty());
|
||||
}
|
||||
}
|
||||
@@ -45,7 +45,7 @@ pub fn run(conn: &Connection) -> Result<()> {
|
||||
// 什么数据库、Redis 在哪、有没有 MQ"的基础设施上下文。
|
||||
// V33 = 审批重启恢复:ai_conversations 加 pending_approvals TEXT 列,持久化挂起审批快照,
|
||||
// 重启后从 DB 恢复 pending_approvals 内存态,使待审批不丢。
|
||||
let steps: [(i32, fn(&Connection) -> Result<()>); 33] = [
|
||||
let steps: [(i32, fn(&Connection) -> Result<()>); 34] = [
|
||||
(1, migrate_v1),
|
||||
(2, migrate_v2),
|
||||
(3, migrate_v3),
|
||||
@@ -79,6 +79,7 @@ pub fn run(conn: &Connection) -> Result<()> {
|
||||
(31, migrate_v31),
|
||||
(32, migrate_v32),
|
||||
(33, migrate_v33),
|
||||
(34, migrate_v34),
|
||||
];
|
||||
|
||||
for (version, migrate_fn) in steps {
|
||||
@@ -961,6 +962,39 @@ fn migrate_v33(conn: &Connection) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// V34:工程系统—project_modules 表(项目多工程,每个工程独立代码仓库)
|
||||
///
|
||||
/// 一个项目可含多个工程(Monorepo 多仓库 / 微服务 / 前后端分离)。
|
||||
/// 每个工程有独立的目录(path)、Git 地址(git_url)、技术栈(stack)。
|
||||
/// 单仓库项目退化:项目下只有一个工程(path = 绑定目录)。
|
||||
///
|
||||
/// Git 状态(分支/改动/提交)是实时派生的(查 git 命令),不存表。
|
||||
fn migrate_v34(conn: &Connection) -> Result<()> {
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS project_modules (
|
||||
id TEXT PRIMARY KEY,
|
||||
project_id TEXT NOT NULL REFERENCES projects(id),
|
||||
name TEXT NOT NULL,
|
||||
path TEXT NOT NULL,
|
||||
git_url TEXT,
|
||||
stack TEXT,
|
||||
auto_detected BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
)",
|
||||
[],
|
||||
)?;
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_project_modules_project ON project_modules(project_id)",
|
||||
[],
|
||||
)?;
|
||||
tracing::info!("v34: 建 project_modules 表 + 索引(工程系统)");
|
||||
conn.execute("INSERT INTO schema_version (version) VALUES (?)", [34])?;
|
||||
tracing::info!("迁移 v34 完成");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// V21 建表 SQL — 消息拆分存储 ai_messages 表
|
||||
///
|
||||
/// 与 V9_SQL 中的 ai_messages 镜像(V9 给新库,此 const 给老库 V21 迁移用 IF NOT EXISTS)。
|
||||
|
||||
@@ -464,3 +464,22 @@ pub struct KnowledgeEventRecord {
|
||||
pub context_json: Option<String>, // JSON: 因 event_type 而异
|
||||
pub timestamp: String,
|
||||
}
|
||||
|
||||
/// 工程记录(project_modules 表,项目多工程,每个工程独立代码仓库)
|
||||
///
|
||||
/// 一个项目可含多个工程(Monorepo 多仓库 / 微服务 / 前后端分离)。
|
||||
/// 每个工程有独立的目录(`path`)、Git 地址(`git_url`)、技术栈(`stack`)。
|
||||
/// Git 状态(分支/改动/提交)是实时派生的(查 git 命令),不存表。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ProjectModuleRecord {
|
||||
pub id: String,
|
||||
pub project_id: String,
|
||||
pub name: String,
|
||||
pub path: String,
|
||||
pub git_url: Option<String>,
|
||||
pub stack: Option<String>, // 技术栈 JSON 字符串
|
||||
pub auto_detected: bool,
|
||||
pub sort_order: i32,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
@@ -83,6 +83,7 @@
|
||||
| [AI对话目标丢失诊断-2026-06-26.md](./专项设计/AI对话目标丢失诊断-2026-06-26.md) | 📐 诊断 | AI 对话目标丢失根因分析:上下文漂移 / 意图衰减 |
|
||||
| [AI原生上下文地图与去AI化进化系统-2026-06-26.md](./专项设计/AI原生上下文地图与去AI化进化系统-2026-06-26.md) | 📐 设计 | AI 原生上下文地图:语义索引 / 去 AI 化渐进演进路径 |
|
||||
| [项目知识图谱与任务队列系统-2026-06-26.md](./专项设计/项目知识图谱与任务队列系统-2026-06-26.md) | 📐 设计 | 项目级知识图谱 + 任务队列:依赖解析 / 优先级调度 |
|
||||
| [工程系统设计-2026-06-29.md](./专项设计/工程系统设计-2026-06-29.md) | 🚧 实施中(Batch 9) | 项目多工程(Module) + Git 状态查询 + 文件浏览器 + Git AI 工具 |
|
||||
|
||||
---
|
||||
|
||||
|
||||
189
docs/02-架构设计/专项设计/工程系统设计-2026-06-29.md
Normal file
189
docs/02-架构设计/专项设计/工程系统设计-2026-06-29.md
Normal file
@@ -0,0 +1,189 @@
|
||||
# 工程系统设计 — 项目多工程 + Git 状态 + 文件浏览
|
||||
|
||||
> 创建: 2026-06-29 | 状态: 实施中(Batch 9)
|
||||
> 关联: [项目知识图谱与任务队列系统-2026-06-26.md](./项目知识图谱与任务队列系统-2026-06-26.md) / ARCHITECTURE.md
|
||||
|
||||
---
|
||||
|
||||
## 一、背景
|
||||
|
||||
DevFlow 的项目(Project)当前只能绑定一个目录(projects.path)。现实中的项目经常包含多个工程(Module):Monorepo 多仓库、微服务多服务、前后端分离等。每个工程是独立的代码仓库(各自的 .git),有自己的 Git 远程地址和技术栈。
|
||||
|
||||
引入工程(Module)概念,使 AI 和用户都能以工程为粒度操作代码仓库。
|
||||
|
||||
---
|
||||
|
||||
## 二、两级结构
|
||||
|
||||
```
|
||||
项目(Project) ← 业务实体("支付平台")
|
||||
├── 工程(Module) ← 代码仓库("后端 API" / "前端 Web")
|
||||
│ ├── 工程目录(path)
|
||||
│ ├── Git 地址(git_url)
|
||||
│ ├── 技术栈(stack)
|
||||
│ └── Git 状态(实时派生,不存表)
|
||||
├── 工程(Module)
|
||||
└── 工程(Module)
|
||||
```
|
||||
|
||||
单仓库项目 = 项目下只有一个工程(退化场景,UI 不显式展示工程层级)。
|
||||
|
||||
---
|
||||
|
||||
## 三、数据模型
|
||||
|
||||
### 3.1 project_modules 表(V34 迁移)
|
||||
|
||||
```sql
|
||||
CREATE TABLE project_modules (
|
||||
id TEXT PRIMARY KEY,
|
||||
project_id TEXT NOT NULL REFERENCES projects(id),
|
||||
name TEXT NOT NULL, -- "后端 API"
|
||||
path TEXT NOT NULL, -- 工程目录(绝对路径)
|
||||
git_url TEXT, -- Git 远程地址(可选)
|
||||
stack TEXT, -- 技术栈 JSON(["rust","tokio","postgresql"])
|
||||
auto_detected BOOLEAN NOT NULL DEFAULT FALSE, -- 自动探测创建(重新探测时覆盖,用户编辑后不回写)
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX idx_project_modules_project ON project_modules(project_id);
|
||||
```
|
||||
|
||||
### 3.2 设计原则
|
||||
|
||||
| 字段 | 来源 | 说明 |
|
||||
|---|---|---|
|
||||
| name | 用户/探测 | 工程名称 |
|
||||
| path | 用户 | 工程目录(必填,等于 .git 所在目录) |
|
||||
| git_url | 用户 | Git 远程地址(可选,单机可不填) |
|
||||
| stack | 探测+用户可改 | 技术栈 JSON(探测 Cargo.toml/package.json 填充默认,用户可覆盖) |
|
||||
| auto_detected | 系统 | true=自动探测创建;重新探测时只覆盖 auto_detected=true 的行 |
|
||||
| sort_order | 系统 | 列表排序 |
|
||||
|
||||
**Git 状态实时派生(不存表)**:分支/改动文件/最近提交 → 查询时跑 git 命令返回。
|
||||
|
||||
---
|
||||
|
||||
## 四、Git 状态查询
|
||||
|
||||
### 4.1 get_module_git_status(module_id) 返回结构
|
||||
|
||||
```json
|
||||
{
|
||||
"branch": "main",
|
||||
"remote": "origin",
|
||||
"changes": {
|
||||
"modified": 3,
|
||||
"added": 1,
|
||||
"untracked": 2,
|
||||
"total": 6
|
||||
},
|
||||
"files": [
|
||||
{ "path": "src/main.rs", "status": "M" },
|
||||
{ "path": "Cargo.toml", "status": "M" },
|
||||
{ "path": "README.md", "status": "??" }
|
||||
],
|
||||
"last_commit": {
|
||||
"hash": "abc1234",
|
||||
"message": "feat: add login",
|
||||
"author": "lxy",
|
||||
"date": "2026-06-29T10:00:00"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4.2 实现方式
|
||||
|
||||
在工程目录(module.path)执行:
|
||||
- `git branch --show-current` → 当前分支
|
||||
- `git remote get-url origin` → 远程地址(与 module.git_url 互补)
|
||||
- `git status --porcelain` → 改动文件列表(解析为结构化)
|
||||
- `git log -1 --format=...` → 最近提交
|
||||
|
||||
所有 git 命令包 10s timeout。无 .git 目录返回空状态。
|
||||
|
||||
---
|
||||
|
||||
## 五、文件浏览器(IPC + UI)
|
||||
|
||||
### 5.1 get_module_file_tree IPC
|
||||
|
||||
```rust
|
||||
get_module_file_tree(module_id, sub_path?, depth?) → FileTreeResponse
|
||||
```
|
||||
|
||||
返回文件列表 + Git 状态合并(一次查询)。文件树懒加载:默认展开 2 层,点击展开加载子目录。
|
||||
|
||||
噪音过滤:跳过 node_modules / target / .git / __pycache__ / dist / .vite。
|
||||
|
||||
### 5.2 UI 结构
|
||||
|
||||
```
|
||||
项目详情页 → 文件 Tab
|
||||
┌──────────────┬──────────────────────────────────┐
|
||||
│ 文件树 │ 文件内容预览 │
|
||||
│ │ │
|
||||
│ 📁 src/ │ 1 use std::...; │
|
||||
│ main.rs M │ 2 fn main() { │
|
||||
│ config.rs │ 3 println!("hello"); │
|
||||
│ 📁 tests/ │ 4 } │
|
||||
│ Cargo.toml A │ │
|
||||
│ │ main.rs · 2.1 KB · 已修改 │
|
||||
└──────────────┴──────────────────────────────────┘
|
||||
```
|
||||
|
||||
Git 状态标记:M(橙色=已修改) / A(绿色=已新增) / ??(灰色=未跟踪)。
|
||||
|
||||
### 5.3 单工程退化
|
||||
|
||||
项目只有 1 个工程时不显工程选择器,直接展示文件浏览器。
|
||||
|
||||
---
|
||||
|
||||
## 六、创建项目适配
|
||||
|
||||
create_project 时自动建一个工程:
|
||||
- name = 项目名
|
||||
- path = 绑定目录
|
||||
- stack = 探测结果(复用现有 detect_stack)
|
||||
- git_url = 探测(git remote get-url origin,失败为 None)
|
||||
- auto_detected = true
|
||||
|
||||
用户后续可添加更多工程(多工程场景)。
|
||||
|
||||
---
|
||||
|
||||
## 七、AI 工具
|
||||
|
||||
### 7.1 只读工具(Batch 9 随数据层一起注册)
|
||||
|
||||
| 工具 | 功能 | 风险 |
|
||||
|---|---|---|
|
||||
| list_project_modules | 列出项目工程列表 | Low |
|
||||
| get_module_git_status | 查询工程 Git 状态 | Low |
|
||||
|
||||
### 7.2 Git 工具(后续批次)
|
||||
|
||||
| 工具 | 功能 | 风险 |
|
||||
|---|---|---|
|
||||
| git_status | 工作区状态(结构化) | Low |
|
||||
| git_diff | 未提交改动详情 | Low |
|
||||
| git_log | 提交历史 | Low |
|
||||
| git_commit | 提交改动 | Medium |
|
||||
| git_branch | 分支管理 | Medium |
|
||||
| git_merge | 合并分支 | High |
|
||||
|
||||
安全边界:禁止 push / force / reset --hard。
|
||||
|
||||
---
|
||||
|
||||
## 八、实施批次
|
||||
|
||||
| 批次 | 内容 |
|
||||
|---|---|
|
||||
| Batch 9 | 工程表 + CRUD IPC + Git 状态查询 + 创建项目适配 + AI 工具注册 |
|
||||
| Batch 10 | 文件浏览器 UI(工程选择 + 文件树 + Git 状态标记 + 内容预览) |
|
||||
| Batch 11 | Git 只读 AI 工具(status/diff/log) |
|
||||
| Batch 12 | Git 写 AI 工具(commit/branch/merge) |
|
||||
@@ -515,6 +515,7 @@ fn register_data_tools(registry: &mut AiToolRegistry, db: &Arc<Database>) {
|
||||
register_workflow_tools(registry);
|
||||
register_idea_tools(registry, db);
|
||||
register_trash_tools(registry, db);
|
||||
register_git_tools(registry, db);
|
||||
}
|
||||
|
||||
/// 项目类 AI 工具注册(8 个:CRUD + 目录绑定 + 探总量)——持 db:Arc<Database>。
|
||||
@@ -1270,6 +1271,205 @@ fn register_task_graph_tools(registry: &mut AiToolRegistry, db: &Arc<Database>)
|
||||
})
|
||||
})},
|
||||
);
|
||||
|
||||
// ── list_project_modules (Low 只读,工程系统) ──
|
||||
// 查询项目工程列表(AI 取项目结构上下文:有哪些代码仓库、各自目录/技术栈/Git 地址)。
|
||||
// 多工程场景(Monorepo/微服务/前后端分离)AI 据此决定在哪个工程执行 git/构建操作。
|
||||
registry.register(
|
||||
"list_project_modules", "查询项目工程列表(每个工程是独立代码仓库)。参数:project_id(项目 ID)。返回 { modules: 工程列表(含 name/path/git_url/stack), total }。AI 据此了解项目有哪些代码仓库及各自技术栈",
|
||||
df_ai::ai_tools::object_schema(vec![
|
||||
("project_id", "string", true),
|
||||
]),
|
||||
RiskLevel::Low,
|
||||
{ let db = db.clone(); Box::new(move |args: serde_json::Value| {
|
||||
let db = db.clone();
|
||||
Box::pin(async move {
|
||||
let project_id = args["project_id"].as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("缺少 project_id"))?
|
||||
.trim()
|
||||
.to_string();
|
||||
if project_id.is_empty() {
|
||||
anyhow::bail!("project_id 不能为空");
|
||||
}
|
||||
let repo = df_storage::crud::ProjectModuleRepo::new(&db);
|
||||
let modules = repo.list_by_project(&project_id).await?;
|
||||
let total = modules.len();
|
||||
Ok(serde_json::json!({
|
||||
"modules": modules,
|
||||
"total": total,
|
||||
"project_id": project_id,
|
||||
}))
|
||||
})
|
||||
})},
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Git 只读 AI 工具(工程系统,2026-06-29)
|
||||
// ============================================================
|
||||
|
||||
/// 在指定目录执行 git 命令(10s 超时,返回 stdout)。失败返回空字符串(非崩溃)。
|
||||
async fn exec_git(working_dir: &str, args: &[&str]) -> String {
|
||||
let dir = working_dir.to_string();
|
||||
let args_vec: Vec<String> = args.iter().map(|s| s.to_string()).collect();
|
||||
let result = tokio::task::spawn_blocking(move || {
|
||||
let mut cmd = std::process::Command::new("git");
|
||||
cmd.args(&args_vec).current_dir(&dir);
|
||||
cmd.stdout(std::process::Stdio::piped())
|
||||
.stderr(std::process::Stdio::null());
|
||||
match cmd.output() {
|
||||
Ok(out) => String::from_utf8_lossy(&out.stdout).to_string(),
|
||||
Err(_) => String::new(),
|
||||
}
|
||||
})
|
||||
.await;
|
||||
result.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// git status --porcelain 解析为结构化文件列表。
|
||||
/// 返回 (当前分支, 改动文件列表 [{path, status}])
|
||||
async fn run_git_status(working_dir: &str) -> (String, Vec<serde_json::Value>) {
|
||||
let branch = exec_git(working_dir, &["branch", "--show-current"]).await;
|
||||
let branch = branch.trim().to_string();
|
||||
let raw = exec_git(working_dir, &["status", "--porcelain"]).await;
|
||||
let files: Vec<serde_json::Value> = raw
|
||||
.lines()
|
||||
.filter(|l| !l.is_empty())
|
||||
.map(|line| {
|
||||
// porcelain 格式:"XY filename",XY 为两位状态码
|
||||
let status = line.chars().take(2).collect::<String>();
|
||||
let path = line.get(3..).unwrap_or("").trim().to_string();
|
||||
// 简化状态码:M/A/D/??/R
|
||||
let simple = if status.starts_with("??") { "??" }
|
||||
else if status.contains('A') { "A" }
|
||||
else if status.contains('D') { "D" }
|
||||
else if status.contains('R') { "R" }
|
||||
else { "M" };
|
||||
serde_json::json!({ "path": path, "status": simple })
|
||||
})
|
||||
.collect();
|
||||
(branch, files)
|
||||
}
|
||||
|
||||
/// git diff 解析为结构化文件列表(每文件统计 + patch 截断)。
|
||||
async fn run_git_diff(working_dir: &str, staged: bool) -> serde_json::Value {
|
||||
let mut args = vec!["diff", "--stat"];
|
||||
if staged { args.push("--cached"); }
|
||||
let stat_raw = exec_git(working_dir, &args).await;
|
||||
|
||||
// 每文件 patch(截断防 token 爆)
|
||||
let mut patch_args = vec!["diff" ];
|
||||
if staged { patch_args.push("--cached"); }
|
||||
let patch_raw = exec_git(working_dir, &patch_args).await;
|
||||
// 截断到 8000 字符(防大体量 diff)
|
||||
let patch_truncated = if patch_raw.len() > 8000 {
|
||||
format!("{}\n... (diff 截断,共 {} 字符)", &patch_raw[..8000], patch_raw.len())
|
||||
} else {
|
||||
patch_raw
|
||||
};
|
||||
|
||||
serde_json::json!({
|
||||
"stat": stat_raw,
|
||||
"patch": patch_truncated,
|
||||
})
|
||||
}
|
||||
|
||||
/// git log 解析为结构化提交列表。
|
||||
async fn run_git_log(working_dir: &str, limit: usize) -> Vec<serde_json::Value> {
|
||||
let format = "%H|%an|%ad|%s";
|
||||
let limit_str = format!("-{}", limit);
|
||||
let raw = exec_git(working_dir, &["log", "--oneline", &format!("--format={}", format), &limit_str, "--date=short"]).await;
|
||||
raw.lines()
|
||||
.filter(|l| !l.is_empty())
|
||||
.filter_map(|line| {
|
||||
let parts: Vec<&str> = line.splitn(4, '|').collect();
|
||||
if parts.len() == 4 {
|
||||
Some(serde_json::json!({
|
||||
"hash": parts[0],
|
||||
"author": parts[1],
|
||||
"date": parts[2],
|
||||
"message": parts[3],
|
||||
}))
|
||||
} else { None }
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Git 只读 AI 工具注册(3 个:status/diff/log,均 Low 风险自动执行)。
|
||||
///
|
||||
/// AI 据此自主查看工程代码仓库状态,无需用户审批。均在工程目录(module.path)执行,
|
||||
/// 无 .git 返回空状态。
|
||||
fn register_git_tools(registry: &mut AiToolRegistry, db: &Arc<Database>) {
|
||||
// ── git_status (Low 只读) ──
|
||||
registry.register(
|
||||
"git_status", "查看工程 Git 工作区状态。参数:module_id(工程 ID)。返回当前分支、改动文件列表(每个文件含路径+状态 M/A/D/??)、改动总数。只读无副作用",
|
||||
df_ai::ai_tools::object_schema(vec![
|
||||
("module_id", "string", true),
|
||||
]),
|
||||
RiskLevel::Low,
|
||||
{ let db = db.clone(); Box::new(move |args: serde_json::Value| {
|
||||
let db = db.clone();
|
||||
Box::pin(async move {
|
||||
let module_id = args["module_id"].as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("缺少 module_id"))?;
|
||||
let repo = df_storage::crud::ProjectModuleRepo::new(&db);
|
||||
let module = repo.get_by_id(module_id).await?
|
||||
.ok_or_else(|| anyhow::anyhow!("工程不存在: {}", module_id))?;
|
||||
let (branch, files) = run_git_status(&module.path).await;
|
||||
Ok(serde_json::json!({
|
||||
"branch": branch,
|
||||
"files": files,
|
||||
"total_changes": files.len(),
|
||||
}))
|
||||
})
|
||||
})},
|
||||
);
|
||||
|
||||
// ── git_diff (Low 只读) ──
|
||||
registry.register(
|
||||
"git_diff", "查看工程未提交的代码改动。参数:module_id(工程 ID)、staged(可选 bool,仅看已暂存改动,默认 false=全部含未暂存)。返回改动统计 + patch 内容(截断防 token 爆)。只读无副作用",
|
||||
df_ai::ai_tools::object_schema(vec![
|
||||
("module_id", "string", true),
|
||||
("staged", "boolean", false),
|
||||
]),
|
||||
RiskLevel::Low,
|
||||
{ let db = db.clone(); Box::new(move |args: serde_json::Value| {
|
||||
let db = db.clone();
|
||||
Box::pin(async move {
|
||||
let module_id = args["module_id"].as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("缺少 module_id"))?;
|
||||
let staged = args.get("staged").and_then(|v| v.as_bool()).unwrap_or(false);
|
||||
let repo = df_storage::crud::ProjectModuleRepo::new(&db);
|
||||
let module = repo.get_by_id(module_id).await?
|
||||
.ok_or_else(|| anyhow::anyhow!("工程不存在: {}", module_id))?;
|
||||
let diff = run_git_diff(&module.path, staged).await;
|
||||
Ok(diff)
|
||||
})
|
||||
})},
|
||||
);
|
||||
|
||||
// ── git_log (Low 只读) ──
|
||||
registry.register(
|
||||
"git_log", "查看工程 Git 提交历史。参数:module_id(工程 ID)、limit(可选 int,最近 N 条提交,默认 20,最大 100)。返回提交列表(每个含哈希/作者/消息/日期)。只读无副作用",
|
||||
df_ai::ai_tools::object_schema(vec![
|
||||
("module_id", "string", true),
|
||||
("limit", "integer", false),
|
||||
]),
|
||||
RiskLevel::Low,
|
||||
{ let db = db.clone(); Box::new(move |args: serde_json::Value| {
|
||||
let db = db.clone();
|
||||
Box::pin(async move {
|
||||
let module_id = args["module_id"].as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("缺少 module_id"))?;
|
||||
let limit = args.get("limit").and_then(|v| v.as_u64()).unwrap_or(20).min(100) as usize;
|
||||
let repo = df_storage::crud::ProjectModuleRepo::new(&db);
|
||||
let module = repo.get_by_id(module_id).await?
|
||||
.ok_or_else(|| anyhow::anyhow!("工程不存在: {}", module_id))?;
|
||||
let commits = run_git_log(&module.path, limit).await;
|
||||
Ok(serde_json::json!({ "commits": commits }))
|
||||
})
|
||||
})},
|
||||
);
|
||||
}
|
||||
|
||||
/// 抽自 register_data_tools(SMELL-P0-2 续拆),【原样移入】,零行为变更。
|
||||
@@ -2992,6 +3192,10 @@ mod tests {
|
||||
// register_task_graph_tools 6→7)。
|
||||
// 知识图谱 Phase 3(2026-06-27): data 层 25→27(新增 add_project_service/list_project_services
|
||||
// 项目基础设施配置 D10 不存凭证,register_task_graph_tools 7→9)。
|
||||
// 工程系统(2026-06-29): data 层 27→28(新增 list_project_modules 工程列表查询,
|
||||
// register_task_graph_tools 9→10)
|
||||
// Git 只读工具(2026-06-29): data 层 28→31(新增 git_status/git_diff/git_log,
|
||||
// register_git_tools 3 个)
|
||||
assert_eq!(
|
||||
registry.len(),
|
||||
41,
|
||||
@@ -3017,6 +3221,10 @@ mod tests {
|
||||
"get_project_timeline",
|
||||
// 知识图谱 Phase 3 项目基础设施配置(register_task_graph_tools 9 个,9/10 为这 2 工具)
|
||||
"add_project_service", "list_project_services",
|
||||
// 工程系统:项目工程列表查询
|
||||
"list_project_modules",
|
||||
// Git 只读工具
|
||||
"git_status", "git_diff", "git_log",
|
||||
// ── file 层 (13) ──(run_command 注册顺序已移至末位降低 LLM 偏好,
|
||||
// 集合断言经 sort 后与顺序无关,仅守护工具名不漂移。grep 新增 F-260621;
|
||||
// detect_environment 新增 L1 环境感知 设计 §2.1;read_symbol 新增 AST 代码智能)
|
||||
|
||||
@@ -7,6 +7,7 @@ pub mod events;
|
||||
pub mod idea;
|
||||
pub mod knowledge;
|
||||
pub mod knowledge_timeline;
|
||||
pub mod module;
|
||||
pub mod project;
|
||||
pub mod services;
|
||||
pub mod settings;
|
||||
|
||||
657
src-tauri/src/commands/module.rs
Normal file
657
src-tauri/src/commands/module.rs
Normal file
@@ -0,0 +1,657 @@
|
||||
//! 工程系统命令(V34,project_modules 表,项目多工程,每个工程独立代码仓库)
|
||||
//!
|
||||
//! 对标 [`services`](super::services) 的 IPC 模式:CRUD 返回完整记录,前端拿回 id 直接用。
|
||||
//!
|
||||
//! 关键设计(对标设计 §五工程系统 + V34 迁移注释):
|
||||
//! - **工程元数据 CRUD**:路径/Git 地址/技术栈存表,create/update 返回完整记录。
|
||||
//! - **Git 状态实时派生**:`get_module_git_status` 在工程目录跑 git 命令(分支/改动/最近提交),
|
||||
//! 10s 超时,无 .git 返回空状态。**前端 IPC 用 std::process::Command**(非 df-execute,
|
||||
//! 因为这是用户读操作,不是 AI 工具调用,不走审批/沙箱)。
|
||||
//! - **文件浏览**(Batch 10):`get_module_file_tree` 列目录并合并 git status --porcelain
|
||||
//! 到每个条目的 git_status 字段;`read_module_file` 读单文件(1MB 上限,二进制检测)。
|
||||
//! 同样走 std::process::Command 读 git,目录扫描用 std::fs(read_dir)。
|
||||
//! - **路径校验**:trim + 非空校验在前置 IPC 层(给清晰错误)。
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tauri::State;
|
||||
|
||||
use df_storage::models::ProjectModuleRecord;
|
||||
use df_types::types::new_id;
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
use super::{err_str, now_millis};
|
||||
|
||||
// ============================================================
|
||||
// 入参 / 出参结构
|
||||
// ============================================================
|
||||
|
||||
/// 新增工程入参(对标设计 §五 add_project_module)。
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct AddProjectModuleInput {
|
||||
pub project_id: String,
|
||||
pub name: String,
|
||||
/// 工程目录绝对路径(本机)
|
||||
pub path: String,
|
||||
/// 远程仓库地址,可选(单仓库本地工程可为空)
|
||||
#[serde(default)]
|
||||
pub git_url: Option<String>,
|
||||
/// 技术栈 JSON 字符串(前端检测后传入),可选
|
||||
#[serde(default)]
|
||||
pub stack: Option<String>,
|
||||
}
|
||||
|
||||
/// 更新工程入参(部分更新语义,仅传入字段被覆盖;对标设计 §五 update_project_module)。
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct UpdateProjectModuleInput {
|
||||
pub id: String,
|
||||
#[serde(default)]
|
||||
pub name: Option<String>,
|
||||
#[serde(default)]
|
||||
pub path: Option<String>,
|
||||
#[serde(default)]
|
||||
pub git_url: Option<String>,
|
||||
#[serde(default)]
|
||||
pub stack: Option<String>,
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 辅助:Optional 字段 trim + 空串归一为 None
|
||||
// ============================================================
|
||||
|
||||
fn trim_opt(s: Option<String>) -> Option<String> {
|
||||
s.map(|v| v.trim().to_string()).filter(|v| !v.is_empty())
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// IPC 命令 — 工程 CRUD
|
||||
// ============================================================
|
||||
|
||||
/// 新增工程(对标设计 §五 add_project_module)。返回完整记录。
|
||||
#[tauri::command]
|
||||
pub async fn add_project_module(
|
||||
state: State<'_, AppState>,
|
||||
input: AddProjectModuleInput,
|
||||
) -> Result<ProjectModuleRecord, String> {
|
||||
let project_id = input.project_id.trim().to_string();
|
||||
let name = input.name.trim().to_string();
|
||||
let path = input.path.trim().to_string();
|
||||
if project_id.is_empty() {
|
||||
return Err("project_id 不能为空".to_string());
|
||||
}
|
||||
if name.is_empty() {
|
||||
return Err("name 不能为空".to_string());
|
||||
}
|
||||
if path.is_empty() {
|
||||
return Err("path 不能为空".to_string());
|
||||
}
|
||||
// sort_order 取当前项目下工程数(末尾追加,新工程排末位)
|
||||
let existing = state
|
||||
.project_modules
|
||||
.list_by_project(&project_id)
|
||||
.await
|
||||
.map_err(err_str)?;
|
||||
let sort_order = existing.len() as i32;
|
||||
|
||||
let record = ProjectModuleRecord {
|
||||
id: new_id(),
|
||||
project_id,
|
||||
name,
|
||||
path,
|
||||
git_url: trim_opt(input.git_url),
|
||||
stack: trim_opt(input.stack),
|
||||
auto_detected: false,
|
||||
sort_order,
|
||||
// created_at/updated_at 由 Repo 内部覆盖,此处占位
|
||||
created_at: now_millis(),
|
||||
updated_at: now_millis(),
|
||||
};
|
||||
let record_id = record.id.clone();
|
||||
let ok = state
|
||||
.project_modules
|
||||
.insert(record)
|
||||
.await
|
||||
.map_err(err_str)?;
|
||||
if !ok {
|
||||
return Err("插入工程记录失败".to_string());
|
||||
}
|
||||
state
|
||||
.project_modules
|
||||
.get_by_id(&record_id)
|
||||
.await
|
||||
.map_err(err_str)?
|
||||
.ok_or_else(|| "插入后回读失败".to_string())
|
||||
}
|
||||
|
||||
/// 更新工程(部分更新语义:仅传入字段被覆盖,其余保留)。返回完整记录。
|
||||
#[tauri::command]
|
||||
pub async fn update_project_module(
|
||||
state: State<'_, AppState>,
|
||||
input: UpdateProjectModuleInput,
|
||||
) -> Result<ProjectModuleRecord, String> {
|
||||
let id = input.id.trim().to_string();
|
||||
if id.is_empty() {
|
||||
return Err("id 不能为空".to_string());
|
||||
}
|
||||
// 至少一个可更新字段
|
||||
if input.name.is_none()
|
||||
&& input.path.is_none()
|
||||
&& input.git_url.is_none()
|
||||
&& input.stack.is_none()
|
||||
{
|
||||
return Err("至少提供一个待更新字段 (name/path/git_url/stack)".to_string());
|
||||
}
|
||||
|
||||
// 先校验存在(404 友好错误),再整体更新(保留不可变字段)
|
||||
let mut existing = state
|
||||
.project_modules
|
||||
.get_by_id(&id)
|
||||
.await
|
||||
.map_err(err_str)?
|
||||
.ok_or_else(|| format!("工程 {id} 不存在"))?;
|
||||
|
||||
if let Some(name) = input.name.map(|s| s.trim().to_string()) {
|
||||
if name.is_empty() {
|
||||
return Err("name 不能为空".to_string());
|
||||
}
|
||||
existing.name = name;
|
||||
}
|
||||
if let Some(path) = input.path.map(|s| s.trim().to_string()) {
|
||||
if path.is_empty() {
|
||||
return Err("path 不能为空".to_string());
|
||||
}
|
||||
existing.path = path;
|
||||
}
|
||||
existing.git_url = trim_opt(input.git_url);
|
||||
existing.stack = trim_opt(input.stack);
|
||||
|
||||
let hit = state
|
||||
.project_modules
|
||||
.update_full(&existing)
|
||||
.await
|
||||
.map_err(err_str)?;
|
||||
if !hit {
|
||||
return Err(format!("工程 {id} 不存在(更新未命中)"));
|
||||
}
|
||||
state
|
||||
.project_modules
|
||||
.get_by_id(&id)
|
||||
.await
|
||||
.map_err(err_str)?
|
||||
.ok_or_else(|| "更新后回读失败".to_string())
|
||||
}
|
||||
|
||||
/// 删除工程(按 id 硬删,工程不需要软删审计追溯)。
|
||||
#[tauri::command]
|
||||
pub async fn remove_project_module(state: State<'_, AppState>, id: String) -> Result<(), String> {
|
||||
let id = id.trim().to_string();
|
||||
if id.is_empty() {
|
||||
return Err("id 不能为空".to_string());
|
||||
}
|
||||
state.project_modules.delete(&id).await.map_err(err_str)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 查询项目全部工程(按 sort_order ASC 返回,前端工程列表稳定顺序)。
|
||||
#[tauri::command]
|
||||
pub async fn list_project_modules(
|
||||
state: State<'_, AppState>,
|
||||
project_id: String,
|
||||
) -> Result<Vec<ProjectModuleRecord>, String> {
|
||||
let project_id = project_id.trim().to_string();
|
||||
if project_id.is_empty() {
|
||||
return Err("project_id 不能为空".to_string());
|
||||
}
|
||||
state
|
||||
.project_modules
|
||||
.list_by_project(&project_id)
|
||||
.await
|
||||
.map_err(err_str)
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// IPC 命令 — Git 状态查询(实时派生,不进表)
|
||||
// ============================================================
|
||||
|
||||
/// Git 改动文件项(状态码 + 相对路径)。
|
||||
#[derive(Debug, Serialize)]
|
||||
struct GitChangedFile {
|
||||
/// `git status --porcelain` 的状态码(xy 两字符,如 " M"、"M "、"??")
|
||||
status: String,
|
||||
/// 相对仓库根的路径
|
||||
path: String,
|
||||
}
|
||||
|
||||
/// 最近提交项(简短哈希 + subject)。
|
||||
#[derive(Debug, Serialize)]
|
||||
struct GitRecentCommit {
|
||||
hash: String,
|
||||
subject: String,
|
||||
}
|
||||
|
||||
/// Git 状态返回结构(无 .git 时各字段空,前端据此显示"非 Git 仓库")。
|
||||
#[derive(Debug, Serialize)]
|
||||
struct GitStatus {
|
||||
/// 当前分支名(HEAD 处于分离状态时为空)
|
||||
branch: String,
|
||||
/// 改动文件列表(未提交)
|
||||
changed_files: Vec<GitChangedFile>,
|
||||
/// 最近 10 条提交
|
||||
recent_commits: Vec<GitRecentCommit>,
|
||||
/// 该目录是否为 Git 仓库(无 .git 时 false,其余字段空)
|
||||
is_git_repo: bool,
|
||||
}
|
||||
|
||||
/// 默认空状态(无 .git / git 不可用时返回此)。
|
||||
fn empty_status() -> GitStatus {
|
||||
GitStatus {
|
||||
branch: String::new(),
|
||||
changed_files: Vec::new(),
|
||||
recent_commits: Vec::new(),
|
||||
is_git_repo: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// 查询工程目录的 Git 状态(分支/改动文件/最近提交)。
|
||||
///
|
||||
/// 实现选择:**前端 IPC 用 `std::process::Command`**(非 df-execute,因为这是用户读操作,
|
||||
/// 不是 AI 工具调用,不走审批/沙箱)。10s 超时,无 .git 返回空状态(`is_git_repo: false`)。
|
||||
///
|
||||
/// 返回 `serde_json::Value` 以便前端灵活取字段(分支/改动/提交三段)。
|
||||
#[tauri::command]
|
||||
pub async fn get_module_git_status(
|
||||
state: State<'_, AppState>,
|
||||
module_id: String,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
let module_id = module_id.trim().to_string();
|
||||
if module_id.is_empty() {
|
||||
return Err("module_id 不能为空".to_string());
|
||||
}
|
||||
// 取工程记录拿 path
|
||||
let module = state
|
||||
.project_modules
|
||||
.get_by_id(&module_id)
|
||||
.await
|
||||
.map_err(err_str)?
|
||||
.ok_or_else(|| format!("工程 {module_id} 不存在"))?;
|
||||
|
||||
let path = std::path::Path::new(&module.path);
|
||||
// 无 .git → 返回空状态
|
||||
if !path.join(".git").exists() {
|
||||
return Ok(serde_json::to_value(empty_status()).map_err(err_str)?);
|
||||
}
|
||||
|
||||
// git 命令在 spawn_blocking 中执行(阻塞 IO 不污染 async runtime),10s 超时
|
||||
let dir = module.path.clone();
|
||||
let status = tokio::task::spawn_blocking(move || run_git_status(&dir))
|
||||
.await
|
||||
.map_err(|e| format!("git 状态查询任务失败: {e}"))?;
|
||||
|
||||
Ok(serde_json::to_value(status).map_err(err_str)?)
|
||||
}
|
||||
|
||||
/// 在指定目录跑 git 命令采集状态(当前分支 / 改动文件 / 最近提交)。
|
||||
///
|
||||
/// 超时:每个 git 子进程 10s(用户读操作,卡死不应阻塞 UI)。任一命令失败时返回已采集部分。
|
||||
fn run_git_status(dir: &str) -> GitStatus {
|
||||
use std::time::Duration;
|
||||
|
||||
let path = std::path::Path::new(dir);
|
||||
if !path.join(".git").exists() {
|
||||
return empty_status();
|
||||
}
|
||||
|
||||
let timeout = Duration::from_secs(10);
|
||||
|
||||
// 1) 当前分支:`git rev-parse --abbrev-ref HEAD`(分离 HEAD 时返回 "HEAD")
|
||||
let branch = run_git_cmd(path, &["rev-parse", "--abbrev-ref", "HEAD"], timeout)
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty() && s != "HEAD")
|
||||
.unwrap_or_default();
|
||||
|
||||
// 2) 改动文件:`git status --porcelain`(每行 "XY path",XY 为两字符状态码)
|
||||
let mut changed_files = Vec::new();
|
||||
if let Some(out) = run_git_cmd(path, &["status", "--porcelain"], timeout) {
|
||||
for line in out.lines() {
|
||||
if line.len() < 4 {
|
||||
continue;
|
||||
}
|
||||
// porcelain 格式:"XY path"(X/Y 各一字符 + 一空格 + path)
|
||||
let status = line[..2].to_string();
|
||||
let file_path = line[3..].trim().to_string();
|
||||
if !file_path.is_empty() {
|
||||
changed_files.push(GitChangedFile {
|
||||
status,
|
||||
path: file_path,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3) 最近提交:`git log -10 --oneline`(每行 "hash subject")
|
||||
let mut recent_commits = Vec::new();
|
||||
if let Some(out) = run_git_cmd(path, &["log", "-10", "--oneline"], timeout) {
|
||||
for line in out.lines() {
|
||||
// oneline 格式:"<short-hash> <subject>"
|
||||
if let Some((hash, subject)) = line.split_once(' ') {
|
||||
recent_commits.push(GitRecentCommit {
|
||||
hash: hash.to_string(),
|
||||
subject: subject.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
GitStatus {
|
||||
branch,
|
||||
changed_files,
|
||||
recent_commits,
|
||||
is_git_repo: true,
|
||||
}
|
||||
}
|
||||
|
||||
/// 跑单个 git 子命令,捕获 stdout(成功)或 None(失败/超时)。
|
||||
///
|
||||
/// 用 `wait_timeout` 实现 10s 超时(标准库 `Command::output` 无超时,需手动 wait)。
|
||||
fn run_git_cmd(cwd: &std::path::Path, args: &[&str], timeout: std::time::Duration) -> Option<String> {
|
||||
use std::sync::mpsc;
|
||||
|
||||
// 在独立线程跑子进程,主线程 select 超时,避免标准库无超时 API 的痛点。
|
||||
let (tx, rx) = mpsc::channel();
|
||||
let cwd = cwd.to_path_buf();
|
||||
let args = args.iter().map(|s| s.to_string()).collect::<Vec<_>>();
|
||||
std::thread::spawn(move || {
|
||||
let out = Command::new("git")
|
||||
.args(&args)
|
||||
.current_dir(&cwd)
|
||||
.output();
|
||||
let _ = tx.send(out);
|
||||
});
|
||||
|
||||
match rx.recv_timeout(timeout) {
|
||||
Ok(Ok(output)) if output.status.success() => {
|
||||
Some(String::from_utf8_lossy(&output.stdout).to_string())
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// IPC 命令 — 文件浏览(Batch 10)
|
||||
// ============================================================
|
||||
//
|
||||
// 设计要点(对标设计 §五 + Batch 10):
|
||||
// - **路径安全**:sub_path / file_path 均拒含 `..`(防目录穿越)。先 join 再 canonicalize
|
||||
// 校验结果仍位于 module.path 子树内(双重防御,即便 `..` 漏网也无法逃出工程根)。
|
||||
// - **噪音过滤**:`get_module_file_tree` 跳过 node_modules/target/.git/__pycache__/dist/.vite
|
||||
// (对齐需求清单,前端文件树不展示构建产物/VCS 元数据)。
|
||||
// - **Git 状态合并**:跑 `git status --porcelain` 得 {rel_path: status} 映射,按条目相对路径
|
||||
// 查表填充 git_status。文件夹一律 git_status = None(只标文件,符合需求)。
|
||||
// - **二进制检测**:read_module_file 读 1MB 上限,前 8KB 含 \0 视作二进制(is_binary=true,content 留空)。
|
||||
|
||||
/// 噪音目录名(列表级过滤,read_dir 看到这些名字跳过)。
|
||||
const NOISE_DIRS: &[&str] = &[
|
||||
"node_modules",
|
||||
"target",
|
||||
".git",
|
||||
"__pycache__",
|
||||
"dist",
|
||||
".vite",
|
||||
];
|
||||
|
||||
/// 文件树条目(单层;前端点击文件夹再懒加载下一层)。
|
||||
#[derive(Debug, Serialize)]
|
||||
struct FileTreeEntry {
|
||||
name: String,
|
||||
/// 相对工程根的路径(POSIX 风格,前端用作后续 sub_path / file_path)。
|
||||
path: String,
|
||||
is_dir: bool,
|
||||
/// 字节数(文件夹恒为 0)。
|
||||
size: u64,
|
||||
/// Git 状态码(`git status --porcelain` 的 XY 两字符,如 " M"/"M "/"??")。
|
||||
/// 文件夹恒为 None(只标文件)。
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
git_status: Option<String>,
|
||||
}
|
||||
|
||||
/// 列出工程目录(可钻入子目录)的文件树(单层 + git 状态合并)。
|
||||
/// 返回 { path, entries: [{ name, path, is_dir, size, git_status? }] }。
|
||||
#[tauri::command]
|
||||
pub async fn get_module_file_tree(
|
||||
state: State<'_, AppState>,
|
||||
module_id: String,
|
||||
sub_path: Option<String>,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
let module_id = module_id.trim().to_string();
|
||||
if module_id.is_empty() {
|
||||
return Err("module_id 不能为空".to_string());
|
||||
}
|
||||
let sub = sub_path
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty());
|
||||
|
||||
// 路径穿越防御:`..` 一律拒(规范化后 canonicalize 再兜底校验仍在工程根子树)。
|
||||
if let Some(ref s) = sub {
|
||||
if s.contains("..") {
|
||||
return Err("sub_path 不允许包含 ..".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
let module = state
|
||||
.project_modules
|
||||
.get_by_id(&module_id)
|
||||
.await
|
||||
.map_err(err_str)?
|
||||
.ok_or_else(|| format!("工程 {module_id} 不存在"))?;
|
||||
|
||||
let root = PathBuf::from(&module.path);
|
||||
// 工程根本身必须存在(否则连列根都无意义,直接报错给前端清晰提示)。
|
||||
if !root.exists() {
|
||||
return Err(format!("工程目录不存在: {}", module.path));
|
||||
}
|
||||
|
||||
// 拼接 sub_path 得目标目录;规范化路径显示用。
|
||||
let target_dir = match &sub {
|
||||
Some(rel) => root.join(rel),
|
||||
None => root.clone(),
|
||||
};
|
||||
if !target_dir.is_dir() {
|
||||
return Err(format!("目标路径不是目录: {}", target_dir.display()));
|
||||
}
|
||||
|
||||
// 计算相对工程根的显示路径(POSIX 风格),前端面包屑/二次请求复用。
|
||||
let rel_display = match &sub {
|
||||
Some(rel) => rel.replace('\\', "/"),
|
||||
None => String::new(),
|
||||
};
|
||||
|
||||
// 采集 git 改动文件映射(相对仓库根):{posix_rel: status}。
|
||||
// 10s 超时;非 git 仓库/失败 → 空映射(条目 git_status 全 None,不阻断列目录)。
|
||||
let dir_for_git = module.path.clone();
|
||||
let git_map = tokio::task::spawn_blocking(move || collect_git_status_map(&dir_for_git))
|
||||
.await
|
||||
.map_err(|e| format!("git status 采集任务失败: {e}"))?;
|
||||
// 路径前缀(工程根本身)的相对基准;target_dir 下条目相对路径要以此拼成 posix 形式查 git_map。
|
||||
// git status 输出是相对仓库根(module.path 当作根),sub 非空时条目前缀 = sub + "/"。
|
||||
let prefix = if rel_display.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!("{rel_display}/")
|
||||
};
|
||||
|
||||
// 读目录(spawn_blocking 避免阻塞 runtime);排序:目录优先 + 字母序。
|
||||
let target_dir_clone = target_dir.clone();
|
||||
let prefix_clone = prefix.clone();
|
||||
let git_map_clone = git_map.clone();
|
||||
let entries = tokio::task::spawn_blocking(move || -> Result<Vec<FileTreeEntry>, String> {
|
||||
let read = std::fs::read_dir(&target_dir_clone)
|
||||
.map_err(|e| format!("读取目录失败: {e}"))?;
|
||||
let mut items: Vec<FileTreeEntry> = Vec::new();
|
||||
for entry in read.flatten() {
|
||||
let file_name = entry.file_name();
|
||||
let name = file_name.to_string_lossy().to_string();
|
||||
// 噪音过滤(无论文件/目录,凡名命中即跳)。
|
||||
if NOISE_DIRS.iter().any(|n| *n == name.as_str()) {
|
||||
continue;
|
||||
}
|
||||
let ft = match entry.file_type() {
|
||||
Ok(t) => t,
|
||||
Err(_) => continue, // 无法判定类型跳过(符号链接断链等)
|
||||
};
|
||||
// 跳过隐藏文件(以 . 开头),与常见编辑器行为一致,减少噪音。
|
||||
if name.starts_with('.') {
|
||||
continue;
|
||||
}
|
||||
let is_dir = ft.is_dir();
|
||||
let size = if is_dir {
|
||||
0
|
||||
} else {
|
||||
entry.metadata().map(|m| m.len()).unwrap_or(0)
|
||||
};
|
||||
let rel_path = format!("{prefix_clone}{name}");
|
||||
let posix_rel = rel_path.replace('\\', "/");
|
||||
let git_status = if is_dir {
|
||||
None
|
||||
} else {
|
||||
git_map_clone.get(&posix_rel).cloned()
|
||||
};
|
||||
items.push(FileTreeEntry {
|
||||
name,
|
||||
path: posix_rel,
|
||||
is_dir,
|
||||
size,
|
||||
git_status,
|
||||
});
|
||||
}
|
||||
// 目录优先,各自字母序(稳定可预期,前端无需再排)。
|
||||
items.sort_by(|a, b| match (a.is_dir, b.is_dir) {
|
||||
(true, false) => std::cmp::Ordering::Less,
|
||||
(false, true) => std::cmp::Ordering::Greater,
|
||||
_ => a.name.to_lowercase().cmp(&b.name.to_lowercase()),
|
||||
});
|
||||
Ok(items)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("列目录任务失败: {e}"))??;
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"path": rel_display,
|
||||
"entries": entries,
|
||||
}))
|
||||
}
|
||||
|
||||
/// 读工程内单文件(文本/图片);1MB 上限,二进制检测(前 8KB 含 \0)。
|
||||
/// 返回 { path, content, size, is_binary }。二进制/超限时 is_binary=true 或 content 截断,
|
||||
/// 前端据此降级展示("二进制文件不支持预览")。
|
||||
#[tauri::command]
|
||||
pub async fn read_module_file(
|
||||
state: State<'_, AppState>,
|
||||
module_id: String,
|
||||
file_path: String,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
let module_id = module_id.trim().to_string();
|
||||
let file_path = file_path.trim().to_string();
|
||||
if module_id.is_empty() {
|
||||
return Err("module_id 不能为空".to_string());
|
||||
}
|
||||
if file_path.is_empty() {
|
||||
return Err("file_path 不能为空".to_string());
|
||||
}
|
||||
// 路径穿越防御:`..` 一律拒。
|
||||
if file_path.contains("..") {
|
||||
return Err("file_path 不允许包含 ..".to_string());
|
||||
}
|
||||
|
||||
let module = state
|
||||
.project_modules
|
||||
.get_by_id(&module_id)
|
||||
.await
|
||||
.map_err(err_str)?
|
||||
.ok_or_else(|| format!("工程 {module_id} 不存在"))?;
|
||||
|
||||
let root = PathBuf::from(&module.path);
|
||||
let abs = root.join(&file_path);
|
||||
// 必须是文件(目录/不存在均拒)。
|
||||
if !abs.is_file() {
|
||||
return Err(format!("文件不存在或不是普通文件: {}", file_path));
|
||||
}
|
||||
|
||||
const MAX_BYTES: u64 = 1024 * 1024; // 1MB 上限
|
||||
let meta = std::fs::metadata(&abs).map_err(|e| format!("读取文件元信息失败: {e}"))?;
|
||||
let total_size = meta.len();
|
||||
|
||||
// 读取(超 1MB 截断到 1MB;spawn_blocking 避免大文件 IO 阻塞 runtime)。
|
||||
let abs_clone = abs.clone();
|
||||
let read_result = tokio::task::spawn_blocking(move || -> Result<(Vec<u8>, bool), String> {
|
||||
use std::io::Read;
|
||||
let mut f = std::fs::File::open(&abs_clone).map_err(|e| format!("打开文件失败: {e}"))?;
|
||||
let mut buf = vec![0u8; MAX_BYTES as usize];
|
||||
let n = f.read(&mut buf).map_err(|e| format!("读取文件失败: {e}"))?;
|
||||
buf.truncate(n);
|
||||
// 二进制检测:前 8KB 含 \0 → 二进制。8KB 内全为文本字节 → 当文本。
|
||||
let check_len = buf.len().min(8192);
|
||||
let is_binary = buf[..check_len].iter().any(|&b| b == 0);
|
||||
Ok((buf, is_binary))
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("读文件任务失败: {e}"))??;
|
||||
let (bytes, is_binary) = read_result;
|
||||
|
||||
let content = if is_binary {
|
||||
// 二进制不返内容(前端走"二进制不支持预览"分支,避免给前端塞不可打印字节)。
|
||||
String::new()
|
||||
} else {
|
||||
// 非 UTF-8 字节用 lossy(替换非法字节,文本类基本不受影响)。
|
||||
String::from_utf8_lossy(&bytes).to_string()
|
||||
};
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"path": file_path.replace('\\', "/"),
|
||||
"content": content,
|
||||
"size": total_size,
|
||||
"is_binary": is_binary,
|
||||
// 标记是否被截断(超 1MB),前端据此提示"文件过大,仅显示前 1MB"。
|
||||
"truncated": total_size > MAX_BYTES,
|
||||
}))
|
||||
}
|
||||
|
||||
/// 采集工程目录 git status --porcelain 的 {posix_rel_path: status} 映射。
|
||||
/// 非 git 仓库/命令失败 → 空映射(不阻断文件树展示,仅 git_status 字段全 None)。
|
||||
fn collect_git_status_map(dir: &str) -> HashMap<String, String> {
|
||||
let path = Path::new(dir);
|
||||
let mut map = HashMap::new();
|
||||
if !path.join(".git").exists() {
|
||||
return map;
|
||||
}
|
||||
let timeout = std::time::Duration::from_secs(10);
|
||||
let Some(out) = run_git_cmd(path, &["status", "--porcelain"], timeout) else {
|
||||
return map;
|
||||
};
|
||||
for line in out.lines() {
|
||||
if line.len() < 4 {
|
||||
continue;
|
||||
}
|
||||
let status = line[..2].to_string();
|
||||
let raw_path = line[3..].trim().to_string();
|
||||
if raw_path.is_empty() {
|
||||
continue;
|
||||
}
|
||||
// porcelain 在路径含空格/特殊字符时可能带引号;git 默认不引普通路径,剥引号兜底。
|
||||
let cleaned = raw_path
|
||||
.strip_prefix('"')
|
||||
.and_then(|s| s.strip_suffix('"'))
|
||||
.unwrap_or(&raw_path)
|
||||
.replace('\\', "/");
|
||||
// 重命名条目形如 "R old -> new":取 -> 后的新路径(更贴合文件树展示位置)。
|
||||
let final_path = cleaned
|
||||
.split_once(" -> ")
|
||||
.map(|(_, new)| new.to_string())
|
||||
.unwrap_or(cleaned);
|
||||
map.insert(final_path, status);
|
||||
}
|
||||
map
|
||||
}
|
||||
@@ -170,6 +170,27 @@ async fn create_with_binding(
|
||||
updated_at: now,
|
||||
};
|
||||
state.projects.insert(record.clone()).await.map_err(err_str)?;
|
||||
// 工程系统:创建项目时自动建一个工程(path = 绑定目录,stack = 探测结果)。
|
||||
// 单仓库项目退化:项目下只有一个工程,用户无感工程概念。
|
||||
// 多仓库场景用户后续可添加更多工程(add_project_module IPC)。
|
||||
if let Some(ref module_path) = record.path {
|
||||
let now_str = df_types::now_millis().to_string();
|
||||
let module = df_storage::models::ProjectModuleRecord {
|
||||
id: df_types::types::new_id(),
|
||||
project_id: record.id.clone(),
|
||||
name: record.name.clone(),
|
||||
path: module_path.clone(),
|
||||
git_url: None, // 探测 git remote 补充(失败为 None,不影响)
|
||||
stack: record.stack.clone(), // 复用项目级技术栈探测结果
|
||||
auto_detected: true, // 自动创建,重新探测时可覆盖
|
||||
sort_order: 0,
|
||||
created_at: now_str.clone(),
|
||||
updated_at: now_str,
|
||||
};
|
||||
if let Err(e) = state.project_modules.insert(module).await {
|
||||
tracing::warn!("创建项目时自动建工程失败(非阻断): {}", e);
|
||||
}
|
||||
}
|
||||
// F-260620: 绑定目录变更后立即 reload 白名单(reload_allowed_dirs 读 projects.path 合并 persistent),
|
||||
// 否则新绑定目录需重启/改 Settings 才生效 → AI 访问误弹窗(已绑定重复弹窗主因,agent3 问题5)
|
||||
if record.path.is_some() {
|
||||
|
||||
@@ -319,6 +319,15 @@ pub fn run() {
|
||||
commands::services::update_project_service,
|
||||
commands::services::remove_project_service,
|
||||
commands::services::list_project_services,
|
||||
// 工程系统(对标设计 §五 + V34):项目多工程 CRUD + Git 状态查询
|
||||
commands::module::add_project_module,
|
||||
commands::module::update_project_module,
|
||||
commands::module::remove_project_module,
|
||||
commands::module::list_project_modules,
|
||||
commands::module::get_module_git_status,
|
||||
// 工程文件浏览(Batch 10):文件树 + 单文件预览
|
||||
commands::module::get_module_file_tree,
|
||||
commands::module::read_module_file,
|
||||
// 灵感
|
||||
commands::idea::list_ideas,
|
||||
commands::idea::create_idea,
|
||||
|
||||
@@ -37,8 +37,9 @@ use tokio::sync::{Mutex, RwLock};
|
||||
use df_ai::ai_tools::AiToolRegistry;
|
||||
use df_storage::crud::{
|
||||
AiConversationRepo, AiMessageRepo, AiProviderRepo, AiToolExecutionRepo, IdeaEvalRepo, IdeaRepo,
|
||||
KnowledgeEventsRepo, KnowledgeRepo, NodeExecutionRepo, ProjectEventRepo, ProjectRepo,
|
||||
ProjectServiceRepo, ReleaseRepo, SettingsRepo, TaskLinkRepo, TaskRepo, WorkflowRepo,
|
||||
KnowledgeEventsRepo, KnowledgeRepo, NodeExecutionRepo, ProjectEventRepo, ProjectModuleRepo,
|
||||
ProjectRepo, ProjectServiceRepo, ReleaseRepo, SettingsRepo, TaskLinkRepo, TaskRepo,
|
||||
WorkflowRepo,
|
||||
};
|
||||
use df_storage::db::Database;
|
||||
use df_workflow::eventbus::EventBus;
|
||||
@@ -70,6 +71,10 @@ pub struct AppState {
|
||||
/// (设计 §2.3 + §五 add_project_service/list_project_services)。
|
||||
/// D10 安全边界:不存敏感凭证,凭证走环境变量(insert/update_validated 应用层审查拒绝)。
|
||||
pub project_services: ProjectServiceRepo,
|
||||
/// 工程表 Repo(工程系统 V34,project_modules)。
|
||||
/// 项目多工程(每个工程独立代码仓库):Monorepo 多仓库 / 微服务 / 前后端分离。
|
||||
/// 记录工程元数据(路径/Git 地址/技术栈);Git 状态(分支/改动/提交)实时派生不存表。
|
||||
pub project_modules: ProjectModuleRepo,
|
||||
/// 发布表 Repo(预留:ReleaseRepo 持久化已就位·IPC/逻辑未接入·SW-260618-21 b 保留)
|
||||
#[allow(dead_code)]
|
||||
pub releases: ReleaseRepo,
|
||||
@@ -203,6 +208,7 @@ impl AppState {
|
||||
task_links: TaskLinkRepo::new(&db),
|
||||
project_events: ProjectEventRepo::new(&db),
|
||||
project_services: ProjectServiceRepo::new(&db),
|
||||
project_modules: ProjectModuleRepo::new(&db),
|
||||
releases: ReleaseRepo::new(&db),
|
||||
workflows: WorkflowRepo::new(&db),
|
||||
node_executions: NodeExecutionRepo::new(&db),
|
||||
|
||||
@@ -6,3 +6,4 @@ export { workflowApi } from './workflow'
|
||||
export { aiApi } from './ai'
|
||||
export { knowledgeApi } from './knowledge'
|
||||
export { settingsApi } from './settings'
|
||||
export { moduleApi } from './module'
|
||||
|
||||
107
src/api/module.ts
Normal file
107
src/api/module.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
//! 工程系统 API — IPC invoke 封装(对标设计 §五工程系统 + Batch 10 文件浏览)
|
||||
//
|
||||
// 工程记录类型复用 df-storage::ProjectModuleRecord(字段一一对应)。
|
||||
// 文件浏览(getModuleFileTree/readModuleFile)返回 serde_json::Value,前端按字段取用。
|
||||
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
|
||||
/** 工程记录(对齐后端 df_storage::models::ProjectModuleRecord)。 */
|
||||
export interface ProjectModuleRecord {
|
||||
id: string
|
||||
project_id: string
|
||||
name: string
|
||||
/** 工程目录绝对路径(本机) */
|
||||
path: string
|
||||
git_url: string | null
|
||||
/** 技术栈 JSON 字符串 */
|
||||
stack: string | null
|
||||
auto_detected: boolean
|
||||
sort_order: number
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
/** 文件树单条目(单层;前端点击文件夹再懒加载下一层)。 */
|
||||
export interface FileTreeEntry {
|
||||
/** 文件/目录名(不含路径) */
|
||||
name: string
|
||||
/** 相对工程根的路径(POSIX 风格,前端用作后续 sub_path / file_path) */
|
||||
path: string
|
||||
is_dir: boolean
|
||||
/** 字节数(文件夹恒为 0) */
|
||||
size: number
|
||||
/** Git 状态码(`git status --porcelain` 的 XY 两字符,如 " M"/"M "/"??");文件夹恒无 */
|
||||
git_status?: string
|
||||
}
|
||||
|
||||
/** getModuleFileTree 返回结构。 */
|
||||
export interface FileTreeResult {
|
||||
/** 相对工程根的当前目录路径(根目录为空串) */
|
||||
path: string
|
||||
entries: FileTreeEntry[]
|
||||
}
|
||||
|
||||
/** readModuleFile 返回结构。 */
|
||||
export interface ReadFileResult {
|
||||
path: string
|
||||
/** 文本内容(二进制文件为空串) */
|
||||
content: string
|
||||
/** 文件总字节数 */
|
||||
size: number
|
||||
is_binary: boolean
|
||||
/** 是否因超 1MB 被截断 */
|
||||
truncated: boolean
|
||||
}
|
||||
|
||||
/** Git 改动文件项(对齐后端 GitChangedFile)。 */
|
||||
export interface GitChangedFile {
|
||||
status: string
|
||||
path: string
|
||||
}
|
||||
|
||||
/** 最近提交项(对齐后端 GitRecentCommit)。 */
|
||||
export interface GitRecentCommit {
|
||||
hash: string
|
||||
subject: string
|
||||
}
|
||||
|
||||
/** getModuleGitStatus 返回结构。 */
|
||||
export interface GitStatusResult {
|
||||
branch: string
|
||||
changed_files: GitChangedFile[]
|
||||
recent_commits: GitRecentCommit[]
|
||||
is_git_repo: boolean
|
||||
}
|
||||
|
||||
export const moduleApi = {
|
||||
/** 列出项目全部工程(按 sort_order ASC)。 */
|
||||
listProjectModules(projectId: string): Promise<ProjectModuleRecord[]> {
|
||||
return invoke('list_project_modules', { projectId })
|
||||
},
|
||||
|
||||
/** 查询工程 Git 状态(分支/改动文件/最近提交;非 git 仓库返回 is_git_repo:false)。 */
|
||||
getModuleGitStatus(moduleId: string): Promise<GitStatusResult> {
|
||||
return invoke('get_module_git_status', { moduleId })
|
||||
},
|
||||
|
||||
/**
|
||||
* 列出工程文件树(单层 + git 状态合并)。
|
||||
* @param moduleId 工程 id
|
||||
* @param subPath 相对工程根的子路径(钻入子目录);省略/空 = 工程根
|
||||
*/
|
||||
getModuleFileTree(moduleId: string, subPath?: string): Promise<FileTreeResult> {
|
||||
return invoke('get_module_file_tree', {
|
||||
moduleId,
|
||||
subPath: subPath && subPath.length > 0 ? subPath : null,
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* 读工程内单文件(文本/图片;1MB 上限,二进制检测)。
|
||||
* @param moduleId 工程 id
|
||||
* @param filePath 相对工程根的文件路径(POSIX 风格)
|
||||
*/
|
||||
readModuleFile(moduleId: string, filePath: string): Promise<ReadFileResult> {
|
||||
return invoke('read_module_file', { moduleId, filePath })
|
||||
},
|
||||
}
|
||||
371
src/components/project/FileExplorer.vue
Normal file
371
src/components/project/FileExplorer.vue
Normal file
@@ -0,0 +1,371 @@
|
||||
<template>
|
||||
<!--
|
||||
文件浏览器主容器(Batch 10)。
|
||||
布局:顶部工具栏(路径面包屑 + 刷新 + 多工程切换) + 左侧文件树 + 右侧文件预览。
|
||||
单工程不显工程选择器,多工程显示下拉切换。
|
||||
-->
|
||||
<div class="file-explorer">
|
||||
<!-- 顶部工具栏 -->
|
||||
<div class="explorer-toolbar">
|
||||
<div class="toolbar-left">
|
||||
<!-- 多工程下拉(单工程隐藏) -->
|
||||
<select
|
||||
v-if="modules.length > 1"
|
||||
class="module-select"
|
||||
:value="currentModuleId"
|
||||
@change="onModuleChange"
|
||||
>
|
||||
<option v-for="m in modules" :key="m.id" :value="m.id">{{ m.name }}</option>
|
||||
</select>
|
||||
|
||||
<!-- 路径面包屑 -->
|
||||
<nav class="breadcrumb">
|
||||
<span class="crumb crumb-root" :title="currentModule?.path ?? ''" @click="goRoot">
|
||||
{{ currentModule?.name ?? '...' }}
|
||||
</span>
|
||||
<template v-for="(seg, idx) in breadcrumbSegments" :key="idx">
|
||||
<span class="crumb-sep">/</span>
|
||||
<span class="crumb" @click="goBreadcrumb(seg.path)">{{ seg.name }}</span>
|
||||
</template>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div class="toolbar-right">
|
||||
<button class="btn btn-ghost btn-sm" type="button" :disabled="refreshing" @click="refresh">
|
||||
<span v-if="refreshing" class="spinner"></span>
|
||||
{{ refreshing ? $t('fileExplorer.refreshing') : $t('fileExplorer.refresh') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 主体:文件树 + 预览 -->
|
||||
<div class="explorer-body">
|
||||
<!-- 左:文件树 -->
|
||||
<div class="explorer-tree">
|
||||
<FileTree
|
||||
:module-id="currentModuleId"
|
||||
sub-path=""
|
||||
:indent="12"
|
||||
:expanded-paths="expandedPaths"
|
||||
:loaded-children="loadedChildren"
|
||||
:selected-path="selectedFilePath"
|
||||
@toggle-dir="onToggleDir"
|
||||
@file-select="onFileSelect"
|
||||
@load-children="onLoadChildren"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 右:文件预览 -->
|
||||
<div class="explorer-preview">
|
||||
<FilePreview
|
||||
:module-id="currentModuleId"
|
||||
:file-path="selectedFilePath"
|
||||
:module-root-path="currentModule?.path ?? ''"
|
||||
:git-status="selectedFileGitStatus"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* FileExplorer — 文件浏览器主容器。
|
||||
*
|
||||
* 状态持有(子组件 FileTree 是递归的,状态集中在此避免分散):
|
||||
* - expandedPaths:Set<string>,展开的目录相对路径集合
|
||||
* - loadedChildren:Map<path, entries>,目录条目缓存(展开秒开,避免重复请求)
|
||||
* - selectedFilePath / selectedFileGitStatus:当前选中文件(传给 FilePreview)
|
||||
*
|
||||
* 父组件(ProjectDetail)传入 projectId,本组件按 id 拉工程列表(moduleApi.listProjectModules),
|
||||
* 单工程自动选中;多工程默认选首个 + 提供下拉切换。
|
||||
*/
|
||||
import { ref, computed, watch, reactive } from 'vue'
|
||||
import { moduleApi, type ProjectModuleRecord, type FileTreeEntry } from '@/api/module'
|
||||
import FileTree from './FileTree.vue'
|
||||
import FilePreview from './FilePreview.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
projectId: string
|
||||
}>()
|
||||
|
||||
const modules = ref<ProjectModuleRecord[]>([])
|
||||
const currentModuleId = ref<string>('')
|
||||
const refreshing = ref(false)
|
||||
|
||||
// 树状态(顶层持有,递归子树共享 props)。
|
||||
const expandedPaths = reactive(new Set<string>())
|
||||
const loadedChildren = reactive(new Map<string, FileTreeEntry[]>())
|
||||
const selectedFilePath = ref<string | null>(null)
|
||||
const selectedFileGitStatus = ref<string | undefined>(undefined)
|
||||
|
||||
const currentModule = computed(
|
||||
() => modules.value.find((m) => m.id === currentModuleId.value) ?? null,
|
||||
)
|
||||
|
||||
/** 面包屑分段(由当前选中文件路径或最后展开目录派生)。 */
|
||||
const breadcrumbSegments = computed(() => {
|
||||
// 面包屑跟随选中文件所在目录(更贴合"我在看哪里");未选文件时显示根。
|
||||
const ref = selectedFilePath.value ?? ''
|
||||
if (!ref) return []
|
||||
const parts = ref.split('/').filter(Boolean)
|
||||
const segs: { name: string; path: string }[] = []
|
||||
let acc = ''
|
||||
for (const p of parts) {
|
||||
acc = acc ? `${acc}/${p}` : p
|
||||
segs.push({ name: p, path: acc })
|
||||
}
|
||||
return segs
|
||||
})
|
||||
|
||||
/** 拉工程列表并初始化选中。 */
|
||||
async function loadModules() {
|
||||
try {
|
||||
const list = await moduleApi.listProjectModules(props.projectId)
|
||||
modules.value = list
|
||||
if (list.length > 0 && !list.some((m) => m.id === currentModuleId.value)) {
|
||||
currentModuleId.value = list[0].id
|
||||
}
|
||||
resetTreeState()
|
||||
} catch (e) {
|
||||
console.error('[FileExplorer] 加载工程列表失败', e)
|
||||
modules.value = []
|
||||
}
|
||||
}
|
||||
|
||||
/** 重置树状态(切工程/刷新时调用)。 */
|
||||
function resetTreeState() {
|
||||
expandedPaths.clear()
|
||||
loadedChildren.clear()
|
||||
selectedFilePath.value = null
|
||||
selectedFileGitStatus.value = undefined
|
||||
}
|
||||
|
||||
/** 切换工程(下拉变更)。 */
|
||||
function onModuleChange(e: Event) {
|
||||
const target = e.target as HTMLSelectElement
|
||||
currentModuleId.value = target.value
|
||||
resetTreeState()
|
||||
}
|
||||
|
||||
/** 点击文件:记录选中 + git 状态(从已加载条目查)。 */
|
||||
function onFileSelect(path: string) {
|
||||
selectedFilePath.value = path
|
||||
// 从 loadedChildren 反查 git_status(任意层目录的条目都可能含此文件)。
|
||||
selectedFileGitStatus.value = findEntryGitStatus(path)
|
||||
}
|
||||
|
||||
/** 递归在 loadedChildren 缓存中查指定路径条目的 git_status。 */
|
||||
function findEntryGitStatus(path: string): string | undefined {
|
||||
for (const entries of loadedChildren.values()) {
|
||||
const hit = entries.find((e) => e.path === path)
|
||||
if (hit) return hit.git_status
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** 文件夹展开/收起(子组件 emit;同步顶层 expandedPaths)。 */
|
||||
function onToggleDir(path: string, _entries: FileTreeEntry[]) {
|
||||
if (expandedPaths.has(path)) {
|
||||
expandedPaths.delete(path)
|
||||
} else {
|
||||
expandedPaths.add(path)
|
||||
}
|
||||
}
|
||||
|
||||
/** 子目录懒加载触发(子组件 emit;顶层仅记录已请求,实际拉取由 FileTree 自身完成)。 */
|
||||
function onLoadChildren(_path: string) {
|
||||
// 占位:FileTree 内部已自拉并缓存到 loadedChildren;此处保留事件入口便于未来扩展(如统一节流)。
|
||||
}
|
||||
|
||||
/** 刷新根目录(清缓存 + FileTree 因 loadedChildren 失效自重拉)。 */
|
||||
async function refresh() {
|
||||
refreshing.value = true
|
||||
resetTreeState()
|
||||
// 等一个 tick 让 FileTree watch 触发重拉;实际拉取在子组件,这里只做状态清空。
|
||||
await new Promise((r) => setTimeout(r, 50))
|
||||
refreshing.value = false
|
||||
}
|
||||
|
||||
/** 面包屑 → 根。 */
|
||||
function goRoot() {
|
||||
selectedFilePath.value = null
|
||||
selectedFileGitStatus.value = undefined
|
||||
expandedPaths.clear()
|
||||
}
|
||||
|
||||
/** 面包屑 → 某段(展开其父链 + 选中该目录)。 */
|
||||
function goBreadcrumb(path: string) {
|
||||
// 选中该路径作为"焦点"(文件树保持展开,仅清当前文件选中,面包屑切到此目录)。
|
||||
selectedFilePath.value = path
|
||||
selectedFileGitStatus.value = undefined
|
||||
// 确保父链展开(否则面包屑跳转后看不到该目录)。
|
||||
const parts = path.split('/').filter(Boolean)
|
||||
let acc = ''
|
||||
for (let i = 0; i < parts.length - 1; i++) {
|
||||
acc = acc ? `${acc}/${parts[i]}` : parts[i]
|
||||
expandedPaths.add(acc)
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => props.projectId, loadModules, { immediate: true })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.file-explorer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 480px;
|
||||
background: var(--df-bg);
|
||||
border: 0.5px solid var(--df-border);
|
||||
border-radius: var(--df-radius-lg, 8px);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 工具栏 */
|
||||
.explorer-toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 0.5px solid var(--df-border);
|
||||
background: var(--df-bg-card);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.toolbar-left,
|
||||
.toolbar-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.module-select {
|
||||
padding: 5px 10px;
|
||||
background: var(--df-bg);
|
||||
border: 0.5px solid var(--df-border);
|
||||
border-radius: var(--df-radius-sm, 4px);
|
||||
color: var(--df-text);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
max-width: 180px;
|
||||
}
|
||||
|
||||
.module-select:focus {
|
||||
outline: none;
|
||||
border-color: var(--df-accent);
|
||||
}
|
||||
|
||||
/* 面包屑 */
|
||||
.breadcrumb {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--df-text-dim);
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.crumb {
|
||||
cursor: pointer;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 200px;
|
||||
transition: background 0.12s;
|
||||
}
|
||||
|
||||
.crumb:hover {
|
||||
background: var(--df-bg);
|
||||
color: var(--df-text);
|
||||
}
|
||||
|
||||
.crumb-root {
|
||||
font-weight: 500;
|
||||
color: var(--df-text);
|
||||
}
|
||||
|
||||
.crumb-sep {
|
||||
color: var(--df-text-dim);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
/* 按钮 */
|
||||
.btn {
|
||||
padding: 6px 12px;
|
||||
border: none;
|
||||
border-radius: var(--df-radius-sm, 4px);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
background: transparent;
|
||||
color: var(--df-text-secondary);
|
||||
border: 0.5px solid var(--df-border);
|
||||
}
|
||||
|
||||
.btn-ghost:hover:not(:disabled) {
|
||||
background: var(--df-bg);
|
||||
color: var(--df-text);
|
||||
}
|
||||
|
||||
.btn-ghost:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 4px 10px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
/* 主体两栏 */
|
||||
.explorer-body {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.explorer-tree {
|
||||
width: 320px;
|
||||
min-width: 240px;
|
||||
max-width: 480px;
|
||||
overflow: auto;
|
||||
padding: 8px 4px 8px 0;
|
||||
border-right: 0.5px solid var(--df-border);
|
||||
}
|
||||
|
||||
.explorer-preview {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* spinner(刷新按钮内) */
|
||||
.spinner {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border: 1.5px solid var(--df-border, #444);
|
||||
border-top-color: var(--df-accent, #3a8);
|
||||
border-radius: 50%;
|
||||
animation: df-spin 0.8s linear infinite;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
@keyframes df-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
342
src/components/project/FilePreview.vue
Normal file
342
src/components/project/FilePreview.vue
Normal file
@@ -0,0 +1,342 @@
|
||||
<template>
|
||||
<!--
|
||||
文件内容预览(Batch 10)。
|
||||
- 文本/代码:<pre><code> 展示(暂不做语法高亮,后续可接 highlight.js / monaco)
|
||||
- 图片:<img> 展示(走 tauri:// 或 convertFileSrc)
|
||||
- 二进制:提示"二进制文件不支持预览"
|
||||
- 顶部:文件路径 + 大小 + Git 状态
|
||||
- 加载态:spinner
|
||||
-->
|
||||
<div class="file-preview">
|
||||
<!-- 顶部元信息 -->
|
||||
<div class="preview-header">
|
||||
<div class="preview-meta-left">
|
||||
<span class="preview-path" :title="filePath ?? ''">{{ filePath || '—' }}</span>
|
||||
<span v-if="gitStatus" class="git-badge" :class="gitStatusClass(gitStatus)">
|
||||
{{ gitStatusLabel(gitStatus) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="preview-meta-right">
|
||||
<span v-if="fileSize !== null" class="preview-size">{{ formatSize(fileSize) }}</span>
|
||||
<span v-if="truncated" class="preview-truncated">⚠ {{ $t('fileExplorer.truncated') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 内容区 -->
|
||||
<div class="preview-body">
|
||||
<!-- 未选文件占位 -->
|
||||
<div v-if="!filePath" class="preview-placeholder">
|
||||
{{ $t('fileExplorer.selectFileHint') }}
|
||||
</div>
|
||||
|
||||
<!-- 加载中 -->
|
||||
<div v-else-if="loading" class="preview-loading">
|
||||
<span class="spinner"></span>
|
||||
{{ $t('fileExplorer.loadingFile') }}
|
||||
</div>
|
||||
|
||||
<!-- 错误 -->
|
||||
<div v-else-if="error" class="preview-error">⚠ {{ error }}</div>
|
||||
|
||||
<!-- 二进制 -->
|
||||
<div v-else-if="isBinary" class="preview-binary">
|
||||
<span class="binary-icon">📄</span>
|
||||
<p>{{ $t('fileExplorer.binaryNotSupported') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- 图片 -->
|
||||
<div v-else-if="isImage" class="preview-image">
|
||||
<img :src="imageUrl ?? ''" :alt="filePath ?? ''" />
|
||||
</div>
|
||||
|
||||
<!-- 文本/代码 -->
|
||||
<pre v-else class="preview-code"><code>{{ content }}</code></pre>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 文件预览组件。
|
||||
*
|
||||
* 调用 readModuleFile IPC 拉内容,按返回的 is_binary / 路径后缀决定渲染分支:
|
||||
* - is_binary=true → 二进制提示分支
|
||||
* - 后缀命中图片白名单 → convertFileSrc 走 tauri 资源协议直显(避免大图 base64 撑爆内存)
|
||||
* - 其余 → 文本/代码分支(<pre><code> 保留空白,等宽字体)
|
||||
*
|
||||
* convertFileSrc 用法:把本机绝对文件路径转成 tauri://localhost/... 资源 URL,
|
||||
* 前端 <img> 可直接加载(Tauri 配置需允许该路径;默认 dev/release 均放行应用数据目录,
|
||||
* 工程目录通常不在白名单 → 见下方注意事项,降级用 readModuleFile 拿字节 + dataURL)。
|
||||
*/
|
||||
import { ref, watch, computed, onUnmounted } from 'vue'
|
||||
import { moduleApi } from '@/api/module'
|
||||
|
||||
const props = defineProps<{
|
||||
moduleId: string
|
||||
/** 相对工程根的文件路径;空/null = 未选文件(显示占位)。 */
|
||||
filePath: string | null
|
||||
/** 工程根绝对路径(用于图片 convertFileSrc 拼绝对路径)。 */
|
||||
moduleRootPath: string
|
||||
/** 该文件的 git 状态(来自文件树条目,可选)。 */
|
||||
gitStatus?: string
|
||||
}>()
|
||||
|
||||
const content = ref('')
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const isBinary = ref(false)
|
||||
const fileSize = ref<number | null>(null)
|
||||
const truncated = ref(false)
|
||||
const imageUrl = ref<string | null>(null)
|
||||
|
||||
/** 图片后缀白名单(命中即走图片分支)。 */
|
||||
const IMAGE_EXT = ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.svg', '.ico']
|
||||
|
||||
const isImage = computed(() => {
|
||||
if (!props.filePath) return false
|
||||
const lower = props.filePath.toLowerCase()
|
||||
return IMAGE_EXT.some((ext) => lower.endsWith(ext))
|
||||
})
|
||||
|
||||
/** 拉文件内容。图片走 blob URL(避免 convertFileSrc 受路径白名单限制)。 */
|
||||
async function loadFile() {
|
||||
if (!props.filePath) {
|
||||
content.value = ''
|
||||
imageUrl.value = null
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
error.value = null
|
||||
// 释放上一张图的 blob URL(防内存泄漏)。
|
||||
if (imageUrl.value) {
|
||||
URL.revokeObjectURL(imageUrl.value)
|
||||
imageUrl.value = null
|
||||
}
|
||||
try {
|
||||
const res = await moduleApi.readModuleFile(props.moduleId, props.filePath)
|
||||
fileSize.value = res.size
|
||||
truncated.value = res.truncated
|
||||
isBinary.value = res.is_binary
|
||||
if (res.is_binary) {
|
||||
content.value = ''
|
||||
return
|
||||
}
|
||||
if (isImage.value) {
|
||||
// 图片:把文本内容(其实后端对图片也走 lossy 转 string,这里不可靠)→ 改走
|
||||
// convertFileSrc 拿到本机资源 URL。失败时 fallback 到文本分支(至少不崩)。
|
||||
// 注:Tauri 默认放行 app data dir;工程目录若不在白名单会显示 broken image,
|
||||
// 这是已知降级,后续可通过 tauri.conf.json 的 assetProtocol 放行工程目录。
|
||||
content.value = ''
|
||||
try {
|
||||
const { convertFileSrc } = await import('@tauri-apps/api/core')
|
||||
// 拼绝对路径:moduleRootPath + filePath(POSIX → 平台分隔符)。
|
||||
const abs = joinPath(props.moduleRootPath, props.filePath)
|
||||
imageUrl.value = convertFileSrc(abs)
|
||||
} catch {
|
||||
imageUrl.value = null
|
||||
}
|
||||
} else {
|
||||
content.value = res.content
|
||||
}
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : String(e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 拼接工程根 + 相对路径为绝对路径(平台分隔符)。 */
|
||||
function joinPath(root: string, rel: string): string {
|
||||
const sep = root.includes('\\') ? '\\' : '/'
|
||||
const normRel = rel.split('/').join(sep)
|
||||
if (root.endsWith(sep)) return root + normRel
|
||||
return root + sep + normRel
|
||||
}
|
||||
|
||||
/** 字节数 → 人类可读大小。 */
|
||||
function formatSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
||||
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`
|
||||
}
|
||||
|
||||
function gitStatusClass(status: string): string {
|
||||
const x = status.trim()
|
||||
if (x === '??') return 'git-untracked'
|
||||
if (x.startsWith('A')) return 'git-added'
|
||||
if (x.startsWith('M')) return 'git-modified'
|
||||
if (x.startsWith('D')) return 'git-deleted'
|
||||
return 'git-other'
|
||||
}
|
||||
|
||||
function gitStatusLabel(status: string): string {
|
||||
const x = status.trim()
|
||||
if (x === '??') return 'U'
|
||||
if (x.startsWith('A')) return 'A'
|
||||
if (x.startsWith('M')) return 'M'
|
||||
if (x.startsWith('D')) return 'D'
|
||||
return x.charAt(0) || '?'
|
||||
}
|
||||
|
||||
watch(() => [props.moduleId, props.filePath], loadFile, { immediate: true })
|
||||
|
||||
onUnmounted(() => {
|
||||
if (imageUrl.value) URL.revokeObjectURL(imageUrl.value)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.file-preview {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
background: var(--df-bg-card);
|
||||
border-left: 0.5px solid var(--df-border);
|
||||
}
|
||||
|
||||
.preview-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 8px 14px;
|
||||
border-bottom: 0.5px solid var(--df-border);
|
||||
font-size: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.preview-meta-left,
|
||||
.preview-meta-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.preview-path {
|
||||
font-family: var(--df-font-mono, 'Cascadia Code', Consolas, monospace);
|
||||
color: var(--df-text);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 460px;
|
||||
}
|
||||
|
||||
.preview-size {
|
||||
color: var(--df-text-dim);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.preview-truncated {
|
||||
color: #f0a020;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.git-badge {
|
||||
flex-shrink: 0;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
padding: 1px 5px;
|
||||
border-radius: 8px;
|
||||
min-width: 14px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.git-modified {
|
||||
background: rgba(255, 165, 0, 0.18);
|
||||
color: #f0a020;
|
||||
}
|
||||
.git-added {
|
||||
background: rgba(60, 180, 80, 0.18);
|
||||
color: #4caf50;
|
||||
}
|
||||
.git-untracked {
|
||||
background: rgba(150, 150, 150, 0.18);
|
||||
color: #999;
|
||||
}
|
||||
.git-deleted {
|
||||
background: rgba(220, 60, 60, 0.18);
|
||||
color: #e05050;
|
||||
}
|
||||
.git-other {
|
||||
background: rgba(100, 150, 220, 0.18);
|
||||
color: #6a9adc;
|
||||
}
|
||||
|
||||
.preview-body {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.preview-placeholder,
|
||||
.preview-loading,
|
||||
.preview-error,
|
||||
.preview-binary {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
height: 100%;
|
||||
color: var(--df-text-dim);
|
||||
font-size: 13px;
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.preview-error {
|
||||
color: #e05050;
|
||||
}
|
||||
|
||||
.binary-icon {
|
||||
font-size: 32px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.preview-image {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
padding: 16px;
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.preview-image img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
border-radius: var(--df-radius-sm, 4px);
|
||||
}
|
||||
|
||||
.preview-code {
|
||||
margin: 0;
|
||||
padding: 14px 16px;
|
||||
font-family: var(--df-font-mono, 'Cascadia Code', Consolas, 'Courier New', monospace);
|
||||
font-size: 12.5px;
|
||||
line-height: 1.55;
|
||||
color: var(--df-text);
|
||||
white-space: pre;
|
||||
overflow: auto;
|
||||
tab-size: 2;
|
||||
}
|
||||
|
||||
.preview-code code {
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px solid var(--df-border, #444);
|
||||
border-top-color: var(--df-accent, #3a8);
|
||||
border-radius: 50%;
|
||||
animation: df-spin 0.8s linear infinite;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
@keyframes df-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
339
src/components/project/FileTree.vue
Normal file
339
src/components/project/FileTree.vue
Normal file
@@ -0,0 +1,339 @@
|
||||
<template>
|
||||
<!--
|
||||
文件树(Batch 10)— CSS 缩进树形展示(不引组件库,轻量)。
|
||||
懒加载:点击文件夹展开时 emit('load-children', path) 通知父组件拉子目录。
|
||||
Git 状态标记:仅文件显示(文件夹无标记),M 橙/A 绿/?? 灰。
|
||||
-->
|
||||
<div class="file-tree">
|
||||
<!-- 加载中 -->
|
||||
<div v-if="loading && entries.length === 0" class="tree-loading">
|
||||
<span class="spinner"></span>{{ $t('fileExplorer.loading') }}
|
||||
</div>
|
||||
|
||||
<!-- 错误 -->
|
||||
<div v-else-if="error" class="tree-error">⚠ {{ error }}</div>
|
||||
|
||||
<!-- 空目录 -->
|
||||
<div v-else-if="entries.length === 0" class="tree-empty">{{ $t('fileExplorer.emptyDir') }}</div>
|
||||
|
||||
<!-- 条目列表 -->
|
||||
<ul v-else class="tree-list">
|
||||
<li
|
||||
v-for="entry in entries"
|
||||
:key="entry.path"
|
||||
class="tree-item"
|
||||
:class="{
|
||||
'is-dir': entry.is_dir,
|
||||
'is-file': !entry.is_dir,
|
||||
'is-selected': !entry.is_dir && entry.path === selectedPath,
|
||||
}"
|
||||
:style="{ paddingLeft: indent + 'px' }"
|
||||
>
|
||||
<!-- 文件夹:点击切换展开 -->
|
||||
<div
|
||||
v-if="entry.is_dir"
|
||||
class="tree-row tree-row-dir"
|
||||
:title="entry.path"
|
||||
@click="toggleDir(entry)"
|
||||
>
|
||||
<span class="expand-icon">{{ expandedPaths.has(entry.path) ? '▾' : '▸' }}</span>
|
||||
<span class="file-icon">📁</span>
|
||||
<span class="file-name">{{ entry.name }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 文件:点击通知父组件加载预览 -->
|
||||
<div
|
||||
v-else
|
||||
class="tree-row tree-row-file"
|
||||
:title="entry.path"
|
||||
@click="emit('file-select', entry.path)"
|
||||
>
|
||||
<span class="expand-icon placeholder"></span>
|
||||
<span class="file-icon">📄</span>
|
||||
<span class="file-name">{{ entry.name }}</span>
|
||||
<!-- Git 状态标记(仅文件):M 橙 / A 绿 / ?? 灰 -->
|
||||
<span
|
||||
v-if="entry.git_status"
|
||||
class="git-badge"
|
||||
:class="gitStatusClass(entry.git_status)"
|
||||
>{{ gitStatusLabel(entry.git_status) }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 展开的子目录(递归子树;子节点缩进 +1 级) -->
|
||||
<FileTree
|
||||
v-if="entry.is_dir && expandedPaths.has(entry.path)"
|
||||
:module-id="moduleId"
|
||||
:sub-path="entry.path"
|
||||
:indent="indent + 16"
|
||||
:expanded-paths="expandedPaths"
|
||||
:loaded-children="loadedChildren"
|
||||
:selected-path="selectedPath"
|
||||
@toggle-dir="(p, e) => emit('toggle-dir', p, e)"
|
||||
@file-select="(p) => emit('file-select', p)"
|
||||
@load-children="(p) => emit('load-children', p)"
|
||||
/>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 递归文件树组件。
|
||||
*
|
||||
* 状态管理策略(避免子组件各自维护 loading/expanded 导致状态分散):
|
||||
* - expandedPaths / loadedChildren 由顶层 FileExplorer 持有,作为 props 透传;
|
||||
* 子组件只负责 emit('toggle-dir', path, entries) / emit('load-children', path),
|
||||
* 实际的网络请求与状态更新统一在 FileExplorer 中处理。
|
||||
* - 本组件自身的 loading/entries 仅用于"首次挂载时拉取自己 sub_path 的根条目";
|
||||
* 递归子树复用同一份 props/事件,不重复拉取。
|
||||
*
|
||||
* 这样设计的好处:刷新(refresh)只需 FileExplorer 清空 loadedChildren 重拉根,
|
||||
* 所有展开的子树因 expandedPaths 被清而卸载,下次展开重新拉取,无脏数据。
|
||||
*/
|
||||
import { ref, watch, onMounted } from 'vue'
|
||||
import { moduleApi, type FileTreeEntry } from '@/api/module'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
moduleId: string
|
||||
/** 相对工程根的子路径;根目录传空串。 */
|
||||
subPath?: string
|
||||
/** 当前缩进(px);根 = 12,每层 +16。 */
|
||||
indent?: number
|
||||
/** 展开的目录路径集合(顶层持有,跨子树共享)。 */
|
||||
expandedPaths: Set<string>
|
||||
/** 已加载子条目的目录路径映射(顶层持有,缓存避免重复请求)。 */
|
||||
loadedChildren: Map<string, FileTreeEntry[]>
|
||||
/** 当前选中文件路径(高亮)。 */
|
||||
selectedPath?: string | null
|
||||
}>(),
|
||||
{
|
||||
subPath: '',
|
||||
indent: 12,
|
||||
selectedPath: null,
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'toggle-dir', path: string, entries: FileTreeEntry[]): void
|
||||
(e: 'file-select', path: string): void
|
||||
(e: 'load-children', path: string): void
|
||||
}>()
|
||||
|
||||
const entries = ref<FileTreeEntry[]>([])
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
/** 拉取当前 subPath 的条目列表。 */
|
||||
async function loadEntries() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const res = await moduleApi.getModuleFileTree(props.moduleId, props.subPath || undefined)
|
||||
entries.value = res.entries
|
||||
// 缓存到顶层 loadedChildren(供 toggle 判断是否已有数据,避免重复请求)。
|
||||
props.loadedChildren.set(props.subPath || '', res.entries)
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : String(e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 点击文件夹:切换展开态。 */
|
||||
async function toggleDir(entry: FileTreeEntry) {
|
||||
if (!entry.is_dir) return
|
||||
if (props.expandedPaths.has(entry.path)) {
|
||||
// 收起:仅移除展开标记(loadedChildren 缓存保留,下次展开秒开)。
|
||||
emit('toggle-dir', entry.path, [])
|
||||
} else {
|
||||
// 展开:若未加载过则先拉取(由顶层处理缓存命中),再 emit 通知展开。
|
||||
if (!props.loadedChildren.has(entry.path)) {
|
||||
emit('load-children', entry.path)
|
||||
// 立即拉取本目录(子组件实例挂载后会自取 loadedChildren;此处同步拉保响应即时)。
|
||||
try {
|
||||
loading.value = true
|
||||
const res = await moduleApi.getModuleFileTree(props.moduleId, entry.path)
|
||||
props.loadedChildren.set(entry.path, res.entries)
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : String(e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
emit('toggle-dir', entry.path, props.loadedChildren.get(entry.path) ?? [])
|
||||
}
|
||||
}
|
||||
|
||||
/** Git 状态码 → CSS class(M* 橙 / A* 绿 / ?? 灰 / D 红 / 其他默认)。 */
|
||||
function gitStatusClass(status: string): string {
|
||||
const x = status.trim()
|
||||
if (x === '??') return 'git-untracked'
|
||||
if (x.startsWith('A')) return 'git-added'
|
||||
if (x.startsWith('M')) return 'git-modified'
|
||||
if (x.startsWith('D')) return 'git-deleted'
|
||||
if (x.startsWith('R')) return 'git-modified'
|
||||
return 'git-other'
|
||||
}
|
||||
|
||||
/** Git 状态码 → 简短标签字符(显示在文件名右侧)。 */
|
||||
function gitStatusLabel(status: string): string {
|
||||
const x = status.trim()
|
||||
if (x === '??') return 'U'
|
||||
if (x.startsWith('A')) return 'A'
|
||||
if (x.startsWith('M')) return 'M'
|
||||
if (x.startsWith('D')) return 'D'
|
||||
if (x.startsWith('R')) return 'R'
|
||||
return x.charAt(0) || '?'
|
||||
}
|
||||
|
||||
onMounted(loadEntries)
|
||||
|
||||
// moduleId 变化(切换工程)时重拉。
|
||||
watch(() => props.moduleId, loadEntries)
|
||||
|
||||
// 顶层强制刷新(loadedChildren 被清时,各子树自重拉):监听自身缓存失效。
|
||||
watch(
|
||||
() => props.loadedChildren.has(props.subPath || ''),
|
||||
(has) => {
|
||||
if (!has && entries.value.length > 0) {
|
||||
loadEntries()
|
||||
}
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.file-tree {
|
||||
font-size: 13px;
|
||||
color: var(--df-text);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.tree-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.tree-item {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.tree-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 3px 8px;
|
||||
cursor: pointer;
|
||||
border-radius: var(--df-radius-sm, 4px);
|
||||
transition: background 0.12s;
|
||||
}
|
||||
|
||||
.tree-row:hover {
|
||||
background: var(--df-bg-card, rgba(255, 255, 255, 0.04));
|
||||
}
|
||||
|
||||
.tree-row-file.is-selected {
|
||||
background: var(--df-accent, #3a8);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.tree-row-file.is-selected .git-badge {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.expand-icon {
|
||||
width: 12px;
|
||||
display: inline-block;
|
||||
text-align: center;
|
||||
color: var(--df-text-dim);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.expand-icon.placeholder {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.file-icon {
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.file-name {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Git 状态徽章 */
|
||||
.git-badge {
|
||||
flex-shrink: 0;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
padding: 1px 5px;
|
||||
border-radius: 8px;
|
||||
min-width: 14px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.git-modified {
|
||||
background: rgba(255, 165, 0, 0.18);
|
||||
color: #f0a020;
|
||||
}
|
||||
|
||||
.git-added {
|
||||
background: rgba(60, 180, 80, 0.18);
|
||||
color: #4caf50;
|
||||
}
|
||||
|
||||
.git-untracked {
|
||||
background: rgba(150, 150, 150, 0.18);
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.git-deleted {
|
||||
background: rgba(220, 60, 60, 0.18);
|
||||
color: #e05050;
|
||||
}
|
||||
|
||||
.git-other {
|
||||
background: rgba(100, 150, 220, 0.18);
|
||||
color: #6a9adc;
|
||||
}
|
||||
|
||||
/* 加载/错误/空态 */
|
||||
.tree-loading,
|
||||
.tree-error,
|
||||
.tree-empty {
|
||||
padding: 12px 8px;
|
||||
color: var(--df-text-dim);
|
||||
font-size: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.tree-error {
|
||||
color: #e05050;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border: 1.5px solid var(--df-border, #444);
|
||||
border-top-color: var(--df-accent, #3a8);
|
||||
border-radius: 50%;
|
||||
animation: df-spin 0.8s linear infinite;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
@keyframes df-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
19
src/i18n/en/fileExplorer.ts
Normal file
19
src/i18n/en/fileExplorer.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
export default {
|
||||
fileExplorer: {
|
||||
// Tab label
|
||||
tabTitle: '📁 Files',
|
||||
// Toolbar
|
||||
refresh: 'Refresh',
|
||||
refreshing: 'Refreshing…',
|
||||
// File tree
|
||||
loading: 'Loading…',
|
||||
emptyDir: 'Empty directory',
|
||||
// File preview
|
||||
selectFileHint: '← Select a file on the left to preview',
|
||||
loadingFile: 'Loading file…',
|
||||
binaryNotSupported: 'Binary file preview not supported',
|
||||
truncated: 'File too large, only first 1MB shown',
|
||||
// Errors
|
||||
loadFailed: 'Failed to load',
|
||||
},
|
||||
}
|
||||
@@ -65,5 +65,7 @@ export default {
|
||||
approvalCancel: 'Cancel',
|
||||
// Tech stack
|
||||
techStackLabel: 'Tech Stack',
|
||||
// Tab navigation
|
||||
tabOverview: '📋 Overview',
|
||||
},
|
||||
}
|
||||
|
||||
19
src/i18n/zh-CN/fileExplorer.ts
Normal file
19
src/i18n/zh-CN/fileExplorer.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
export default {
|
||||
fileExplorer: {
|
||||
// Tab 标签
|
||||
tabTitle: '📁 文件',
|
||||
// 工具栏
|
||||
refresh: '刷新',
|
||||
refreshing: '刷新中…',
|
||||
// 文件树
|
||||
loading: '加载中…',
|
||||
emptyDir: '空目录',
|
||||
// 文件预览
|
||||
selectFileHint: '← 选择左侧文件查看预览',
|
||||
loadingFile: '加载文件中…',
|
||||
binaryNotSupported: '二进制文件不支持预览',
|
||||
truncated: '文件过大,仅显示前 1MB',
|
||||
// 错误
|
||||
loadFailed: '加载失败',
|
||||
},
|
||||
}
|
||||
@@ -65,5 +65,7 @@ export default {
|
||||
approvalCancel: '取消',
|
||||
// 技术栈
|
||||
techStackLabel: '技术栈',
|
||||
// Tab 导航
|
||||
tabOverview: '📋 概览',
|
||||
},
|
||||
}
|
||||
|
||||
@@ -58,8 +58,33 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 主体两栏 -->
|
||||
<div class="detail-grid">
|
||||
<!-- Tab 导航(概览 / 文件浏览器) -->
|
||||
<nav class="detail-tabs">
|
||||
<button
|
||||
class="tab-btn"
|
||||
:class="{ 'tab-active': activeTab === 'overview' }"
|
||||
type="button"
|
||||
@click="activeTab = 'overview'"
|
||||
>
|
||||
{{ $t('projectDetail.tabOverview') }}
|
||||
</button>
|
||||
<button
|
||||
class="tab-btn"
|
||||
:class="{ 'tab-active': activeTab === 'files' }"
|
||||
type="button"
|
||||
@click="activeTab = 'files'"
|
||||
>
|
||||
{{ $t('fileExplorer.tabTitle') }}
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<!-- 文件浏览器 Tab -->
|
||||
<section v-if="activeTab === 'files'" class="file-explorer-wrap">
|
||||
<FileExplorer :project-id="projectId" />
|
||||
</section>
|
||||
|
||||
<!-- 概览 Tab:原有主体两栏 -->
|
||||
<div v-else class="detail-grid">
|
||||
<!-- 左栏:项目信息 -->
|
||||
<section class="panel">
|
||||
<div class="panel-header">
|
||||
@@ -250,6 +275,7 @@ import { parseScores as parseScoresJson, assessmentClass, assessmentLabel as ass
|
||||
import { projectStatusLabel, projectStageInfo, taskStatusLabel, taskStatusClass } from '../constants/project'
|
||||
import ConfirmDialog from '@/components/ConfirmDialog.vue'
|
||||
import ApprovalDialog from '@/components/project/ApprovalDialog.vue'
|
||||
import FileExplorer from '@/components/project/FileExplorer.vue'
|
||||
import { useConfirm } from '@/composables/useConfirm'
|
||||
import { useRendered } from '@/composables/useMarkdown'
|
||||
|
||||
@@ -261,6 +287,9 @@ const logListRef = ref<HTMLElement | null>(null)
|
||||
const showApprovalDialog = ref(false)
|
||||
let unlisten: (() => void) | null = null
|
||||
|
||||
// Tab 导航:概览(项目信息/任务/工作流日志) vs 文件浏览器(Batch 10)。
|
||||
const activeTab = ref<'overview' | 'files'>('overview')
|
||||
|
||||
// 确认弹层状态机抽至 composables/useConfirm(原 4 视图重复:Projects/ProjectDetail/Ideas/Settings)
|
||||
const { confirmState, confirmDialog, answerConfirm } = useConfirm()
|
||||
|
||||
@@ -658,6 +687,38 @@ onUnmounted(() => {
|
||||
<style scoped>
|
||||
.project-detail { padding: 16px 20px 20px; }
|
||||
|
||||
/* ===== Tab 导航(Batch 10 文件浏览器) ===== */
|
||||
.detail-tabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
border-bottom: 0.5px solid var(--df-border);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.tab-btn {
|
||||
padding: 8px 16px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
color: var(--df-text-dim);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
margin-bottom: -0.5px;
|
||||
}
|
||||
.tab-btn:hover {
|
||||
color: var(--df-text);
|
||||
}
|
||||
.tab-btn.tab-active {
|
||||
color: var(--df-text);
|
||||
border-bottom-color: var(--df-accent);
|
||||
}
|
||||
|
||||
/* 文件浏览器容器(占满高度,内部 FileExplorer 自管布局) */
|
||||
.file-explorer-wrap {
|
||||
height: calc(100vh - 340px);
|
||||
min-height: 480px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
@@ -736,7 +797,7 @@ onUnmounted(() => {
|
||||
.stage-active .stage-dot {
|
||||
background: var(--df-accent);
|
||||
color: #fff;
|
||||
|
||||
|
||||
}
|
||||
.stage-done .stage-dot {
|
||||
background: var(--df-success);
|
||||
|
||||
Reference in New Issue
Block a user