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