新增: 项目文件浏览器(文件树+内容预览+Git 状态标记)

项目详情页新增文件标签页,用户可在 DevFlow 内浏览项目
文件,查看文件内容和 Git 改动状态,无需切换外部编辑器。

后端接口:
- 文件树查询:列工程目录,合并 Git 状态到每个文件
  (噪音过滤 node_modules/target/.git 等,路径穿越防御)
- 文件读取:支持文本/图片/二进制检测,1MB 上限

前端组件:
- 文件树:递归树形展示,懒加载子目录,Git 状态标记
  (橙色=已修改/绿色=已新增/灰色=未跟踪)
- 文件预览:代码文本/图片/二进制三分支
- 主容器:工程选择(单工程隐藏)+ 面包屑导航 + 刷新

界面文案中英文同步补齐。
This commit is contained in:
2026-06-29 01:48:52 +08:00
parent b72df78462
commit 50aad375eb
12 changed files with 1550 additions and 3 deletions

View File

@@ -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
}

View File

@@ -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,

View File

@@ -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
View 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 })
},
}

View 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>

View 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>

View 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>

View 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',
},
}

View File

@@ -65,5 +65,7 @@ export default {
approvalCancel: 'Cancel',
// Tech stack
techStackLabel: 'Tech Stack',
// Tab navigation
tabOverview: '📋 Overview',
},
}

View File

@@ -0,0 +1,19 @@
export default {
fileExplorer: {
// Tab 标签
tabTitle: '📁 文件',
// 工具栏
refresh: '刷新',
refreshing: '刷新中…',
// 文件树
loading: '加载中…',
emptyDir: '空目录',
// 文件预览
selectFileHint: '← 选择左侧文件查看预览',
loadingFile: '加载文件中…',
binaryNotSupported: '二进制文件不支持预览',
truncated: '文件过大,仅显示前 1MB',
// 错误
loadFailed: '加载失败',
},
}

View File

@@ -65,5 +65,7 @@ export default {
approvalCancel: '取消',
// 技术栈
techStackLabel: '技术栈',
// Tab 导航
tabOverview: '📋 概览',
},
}

View File

@@ -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);