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

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