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