新增: 项目文件浏览器(文件树+内容预览+Git 状态标记)
项目详情页新增文件标签页,用户可在 DevFlow 内浏览项目 文件,查看文件内容和 Git 改动状态,无需切换外部编辑器。 后端接口: - 文件树查询:列工程目录,合并 Git 状态到每个文件 (噪音过滤 node_modules/target/.git 等,路径穿越防御) - 文件读取:支持文本/图片/二进制检测,1MB 上限 前端组件: - 文件树:递归树形展示,懒加载子目录,Git 状态标记 (橙色=已修改/绿色=已新增/灰色=未跟踪) - 文件预览:代码文本/图片/二进制三分支 - 主容器:工程选择(单工程隐藏)+ 面包屑导航 + 刷新 界面文案中英文同步补齐。
This commit is contained in:
@@ -7,8 +7,13 @@
|
||||
//! - **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};
|
||||
@@ -374,3 +379,279 @@ fn run_git_cmd(cwd: &std::path::Path, args: &[&str], timeout: std::time::Duratio
|
||||
_ => 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
|
||||
}
|
||||
|
||||
@@ -325,6 +325,9 @@ pub fn run() {
|
||||
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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user