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