新增: 项目文件浏览器(文件树+内容预览+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

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