Private
Public Access
1
0
Files
u-desk/web/src/components/FileSystem.vue

715 lines
19 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="file-system">
<a-space direction="vertical" style="width: 100%" :size="16">
<!-- 路径输入 -->
<a-card title="📂 文件浏览">
<a-space direction="vertical" :size="12" style="width: 100%">
<a-input-group>
<a-auto-complete
v-model="filePath"
:data="pathHistory"
placeholder="输入文件或目录路径 (如: C:\Users 或 /home/user)"
style="flex: 1"
@select="onPathSelect"
/>
<a-button @click="browseDirectory">
<template #icon>
<icon-folder />
</template>
浏览
</a-button>
<a-button type="primary" @click="listDirectory" :loading="fileLoading">
<template #icon>
<icon-list />
</template>
列出目录
</a-button>
</a-input-group>
<!-- 常用路径快捷按钮 -->
<a-space wrap>
<a-tag
v-for="shortcut in commonPaths"
:key="shortcut.path"
@click="openPath(shortcut.path)"
style="cursor: pointer"
>
<template #icon>
<icon-forward />
</template>
{{ shortcut.name }}
</a-tag>
</a-space>
</a-space>
</a-card>
<!-- 收藏的文件 -->
<a-card title="⭐ 收藏夹" v-if="favoriteFiles.length > 0">
<a-space wrap>
<a-tag
v-for="fav in favoriteFiles"
:key="fav.path"
closable
@close="removeFavorite(fav.path)"
@click="openFavoriteFile(fav.path)"
style="cursor: pointer; margin-bottom: 4px; padding: 4px 8px"
>
<template #icon>
<span style="font-size: 16px">{{ fav.is_dir ? '📁' : '📄' }}</span>
</template>
{{ fav.name }}
</a-tag>
</a-space>
</a-card>
<!-- 文件列表和编辑器 -->
<a-row :gutter="16">
<a-col :span="12">
<a-card title="📋 文件列表" style="height: 100%">
<a-list
:data="fileList"
:loading="fileLoading"
:bordered="false"
style="max-height: 500px; overflow-y: auto"
:pagination="false"
>
<template #item="{ item }">
<a-list-item class="file-item-compact">
<a-list-item-meta>
<template #title>
<div class="file-item-content">
<span class="file-icon">{{ getFileIcon(item) }}</span>
<a @click="selectFile(item.path)" class="file-name-compact">{{ item.name }}</a>
<span class="file-info-inline">
<span v-if="!item.is_dir" class="file-size">{{ formatBytes(item.size) }}</span>
<span class="file-time">{{ item.mod_time }}</span>
</span>
<a-button
type="text"
size="mini"
@click.stop="toggleFavorite(item)"
class="favorite-btn"
>
<template #icon>
<icon-star-fill v-if="isFavorite(item.path)" />
<icon-star v-else />
</template>
</a-button>
</div>
</template>
</a-list-item-meta>
</a-list-item>
</template>
</a-list>
</a-card>
</a-col>
<a-col :span="12">
<a-card title="📝 文件内容">
<a-space direction="vertical" :size="8" style="width: 100%">
<div
class="file-content-wrapper"
:style="{ height: fileContentHeight + 'px' }"
>
<a-textarea
v-model="fileContent"
class="file-content-textarea"
placeholder="文件内容将显示在这里,点击文件列表中的文件即可查看"
/>
</div>
<div
class="resize-handle"
@mousedown="startResize"
title="拖拽调整高度"
>
<div class="resize-handle-bar"></div>
</div>
<a-space>
<a-button type="primary" @click="readFile" :loading="fileLoading">
<template #icon>
<icon-file />
</template>
读取
</a-button>
<a-button @click="writeFile" :loading="fileLoading">
<template #icon>
<icon-save />
</template>
保存
</a-button>
<a-button status="danger" @click="deleteFile" :loading="fileLoading">
<template #icon>
<icon-delete />
</template>
删除
</a-button>
<a-button @click="clearContent">
<template #icon>
<icon-eraser />
</template>
清空
</a-button>
</a-space>
</a-space>
</a-card>
</a-col>
</a-row>
</a-space>
</div>
</template>
<script setup>
import { computed, onMounted, ref, watch } from 'vue'
import { Message, Modal } from '@arco-design/web-vue'
import {
IconFolder,
IconList,
IconForward,
IconStarFill,
IconStar,
IconFile,
IconSave,
IconDelete,
IconEraser
} from '@arco-design/web-vue/es/icon'
import {
listDir,
readFile as readFileApi,
writeFile as writeFileApi,
deletePath
} from '@/api'
// localStorage 键名
const STORAGE_KEYS = {
FILE_PATH: 'filesystem-file-path',
FILE_LIST: 'filesystem-file-list',
FILE_CONTENT: 'filesystem-file-content',
PATH_HISTORY: 'filesystem-path-history',
FILE_CONTENT_HEIGHT: 'filesystem-file-content-height',
FAVORITE_FILES: 'filesystem-favorite-files'
}
// 常用路径快捷方式
const commonPaths = ref([])
const systemPaths = ref({})
// 加载常用系统路径
const loadCommonPaths = async () => {
try {
const paths = await window.go.main.App.GetCommonPaths()
systemPaths.value = paths
const platform = window.navigator.platform
if (platform.includes('Win')) {
// 基础路径
const pathList = [
{ name: '🖥️ 桌面', path: paths.desktop },
{ name: '📁 文档', path: paths.documents },
{ name: '📥 下载', path: paths.downloads },
{ name: '💾 用户目录', path: paths.home }
]
// 动态添加所有盘符(按字母顺序)
const drives = []
for (const key in paths) {
if (key.startsWith('root_')) {
const driveLetter = key.substring(5)
drives.push({
letter: driveLetter,
path: paths[key]
})
}
}
drives.sort((a, b) => a.letter.localeCompare(b.letter))
// 添加盘符到路径列表
drives.forEach(drive => {
pathList.push({
name: `💿 ${drive.letter}`,
path: drive.path
})
})
commonPaths.value = pathList
} else {
commonPaths.value = [
{ name: '🖥️ 桌面', path: paths.desktop },
{ name: '📁 文档', path: paths.documents },
{ name: '📥 下载', path: paths.downloads },
{ name: '🏠 主目录', path: paths.home },
{ name: '📂 根目录', path: '/' }
]
}
} catch (error) {
console.error('加载常用路径失败:', error)
// 降级方案:使用默认路径
commonPaths.value = [
{ name: '💿 C盘', path: 'C:\\' },
{ name: '💿 D盘', path: 'D:\\' }
]
}
}
// 状态
const filePath = ref('')
const fileContent = ref('')
const fileList = ref([])
const fileLoading = ref(false)
const pathHistory = ref([])
const fileContentHeight = ref(250)
const favoriteFiles = ref([])
// 格式化文件大小
const formatBytes = (bytes) => {
if (!bytes) return '0 B'
const unit = 1024
if (bytes < unit) return bytes + ' B'
const exp = Math.floor(Math.log(bytes) / Math.log(unit))
return (bytes / Math.pow(unit, exp)).toFixed(2) + ' ' + 'KMGTPE'[exp - 1] + 'B'
}
// 获取文件图标(根据文件类型)
const getFileIcon = (item) => {
if (item.is_dir) {
return '📁'
}
const ext = item.name.split('.').pop()?.toLowerCase() || ''
// 图片文件
const imageExts = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'svg', 'webp', 'ico', 'heic', 'heif']
if (imageExts.includes(ext)) return '🖼️'
// 视频文件
const videoExts = ['mp4', 'avi', 'mkv', 'mov', 'wmv', 'flv', 'webm', 'm4v', 'rmvb', '3gp']
if (videoExts.includes(ext)) return '🎬'
// 音频文件
const audioExts = ['mp3', 'wav', 'flac', 'aac', 'ogg', 'wma', 'm4a', 'opus']
if (audioExts.includes(ext)) return '🎵'
// 文档文件
const docExts = ['doc', 'docx', 'pdf', 'xls', 'xlsx', 'ppt', 'pptx', 'txt', 'rtf', 'odt', 'ods', 'odp']
if (docExts.includes(ext)) {
if (ext === 'pdf') return '📕'
if (['doc', 'docx'].includes(ext)) return '📘'
if (['xls', 'xlsx'].includes(ext)) return '📗'
if (['ppt', 'pptx'].includes(ext)) return '📙'
if (ext === 'txt') return '📃'
return '📄'
}
// 压缩文件
const archiveExts = ['zip', 'rar', '7z', 'tar', 'gz', 'bz2', 'xz', 'z', 'cab', 'iso']
if (archiveExts.includes(ext)) return '📦'
// 代码文件
const codeExts = ['js', 'ts', 'jsx', 'tsx', 'vue', 'py', 'java', 'c', 'cpp', 'h', 'go', 'rs', 'php', 'rb', 'cs', 'swift', 'kt', 'scala', 'html', 'css', 'scss', 'less', 'json', 'xml', 'yaml', 'yml', 'sql', 'sh', 'bat', 'ps1']
if (codeExts.includes(ext)) return '💻'
// 数据库文件
const dbExts = ['db', 'sqlite', 'mdb', 'accdb']
if (dbExts.includes(ext)) return '🗄️'
// 可执行文件
const exeExts = ['exe', 'msi', 'app', 'dmg', 'deb', 'rpm']
if (exeExts.includes(ext)) return '⚙️'
// 字体文件
const fontExts = ['ttf', 'otf', 'woff', 'woff2', 'eot']
if (fontExts.includes(ext)) return '🔤'
// 默认文件图标
return '📄'
}
// 列出目录
const listDirectory = async () => {
if (!filePath.value) {
Message.error('请输入目录路径')
return
}
addToHistory(filePath.value)
fileLoading.value = true
try {
fileList.value = await listDir(filePath.value)
} catch (error) {
console.error('列出目录失败:', error)
Message.error('列出目录失败: ' + (error.message || error))
} finally {
fileLoading.value = false
}
}
// 路径选择
const onPathSelect = (value) => {
filePath.value = value
listDirectory()
}
// 打开路径
const openPath = (path) => {
filePath.value = path
listDirectory()
}
// 浏览目录
const browseDirectory = () => {
const path = prompt('请输入目录路径(例如: C:\\Users 或 /home/user')
if (path) {
filePath.value = path
listDirectory()
}
}
// 选择文件
const selectFile = (path) => {
filePath.value = path
addToHistory(path)
}
// 读取文件
const readFile = async () => {
if (!filePath.value) {
Message.error('请输入文件路径')
return
}
addToHistory(filePath.value)
fileLoading.value = true
try {
fileContent.value = await readFileApi(filePath.value)
Message.success('文件读取成功')
} catch (error) {
console.error('读取文件失败:', error)
Message.error('读取文件失败: ' + (error.message || error))
} finally {
fileLoading.value = false
}
}
// 写入文件
const writeFile = async () => {
if (!filePath.value) {
Message.error('请输入文件路径')
return
}
fileLoading.value = true
try {
await writeFileApi(filePath.value, fileContent.value)
Message.success('文件保存成功')
} catch (error) {
console.error('写入文件失败:', error)
Message.error('写入文件失败: ' + (error.message || error))
} finally {
fileLoading.value = false
}
}
// 删除文件
const deleteFile = async () => {
if (!filePath.value) {
Message.error('请输入文件路径')
return
}
Modal.confirm({
title: '确认删除',
content: `确定要删除 ${filePath.value} 吗?此操作不可恢复!`,
onOk: async () => {
fileLoading.value = true
try {
await deletePath(filePath.value)
Message.success('删除成功')
// 清空状态
filePath.value = ''
fileContent.value = ''
fileList.value = []
// 重新列出目录
if (pathHistory.value.length > 0) {
filePath.value = pathHistory.value[0]
listDirectory()
}
} catch (error) {
console.error('删除失败:', error)
Message.error('删除失败: ' + (error.message || error))
} finally {
fileLoading.value = false
}
}
})
}
// 清空内容
const clearContent = () => {
fileContent.value = ''
Message.info('内容已清空')
}
// 拖拽调整高度
const startResize = (e) => {
const startY = e.clientY
const startHeight = fileContentHeight.value
const onMouseMove = (moveEvent) => {
const deltaY = moveEvent.clientY - startY
const newHeight = startHeight + deltaY
if (newHeight >= 150 && newHeight <= 800) {
fileContentHeight.value = newHeight
}
}
const onMouseUp = () => {
document.removeEventListener('mousemove', onMouseMove)
document.removeEventListener('mouseup', onMouseUp)
saveToStorage(STORAGE_KEYS.FILE_CONTENT_HEIGHT, fileContentHeight.value.toString())
}
document.addEventListener('mousemove', onMouseMove)
document.addEventListener('mouseup', onMouseUp)
}
// 历史记录
const addToHistory = (path) => {
if (!path || path.trim() === '') return
const index = pathHistory.value.indexOf(path)
if (index > -1) {
pathHistory.value.splice(index, 1)
}
pathHistory.value.unshift(path)
if (pathHistory.value.length > 20) {
pathHistory.value = pathHistory.value.slice(0, 20)
}
saveToStorage(STORAGE_KEYS.PATH_HISTORY, pathHistory.value)
}
// 收藏功能
const isFavorite = (path) => {
return favoriteFiles.value.some(fav => fav.path === path)
}
const toggleFavorite = (item) => {
const index = favoriteFiles.value.findIndex(fav => fav.path === item.path)
if (index > -1) {
favoriteFiles.value.splice(index, 1)
Message.info('已取消收藏: ' + item.name)
} else {
favoriteFiles.value.push({
path: item.path,
name: item.name,
is_dir: item.is_dir
})
Message.success('已收藏: ' + item.name)
}
saveToStorage(STORAGE_KEYS.FAVORITE_FILES, favoriteFiles.value)
}
const removeFavorite = (path) => {
const index = favoriteFiles.value.findIndex(fav => fav.path === path)
if (index > -1) {
const name = favoriteFiles.value[index].name
favoriteFiles.value.splice(index, 1)
saveToStorage(STORAGE_KEYS.FAVORITE_FILES, favoriteFiles.value)
Message.info('已取消收藏: ' + name)
}
}
const openFavoriteFile = (path) => {
filePath.value = path
addToHistory(path)
const fav = favoriteFiles.value.find(f => f.path === path)
if (fav && fav.is_dir) {
listDirectory()
} else {
readFile()
}
}
// localStorage 操作
const loadFromStorage = () => {
try {
const savedPath = localStorage.getItem(STORAGE_KEYS.FILE_PATH)
const savedFileList = localStorage.getItem(STORAGE_KEYS.FILE_LIST)
const savedFileContent = localStorage.getItem(STORAGE_KEYS.FILE_CONTENT)
const savedHistory = localStorage.getItem(STORAGE_KEYS.PATH_HISTORY)
const savedHeight = localStorage.getItem(STORAGE_KEYS.FILE_CONTENT_HEIGHT)
const savedFavorites = localStorage.getItem(STORAGE_KEYS.FAVORITE_FILES)
if (savedPath) filePath.value = savedPath
if (savedFileList) fileList.value = JSON.parse(savedFileList)
if (savedFileContent) fileContent.value = savedFileContent
if (savedHistory) pathHistory.value = JSON.parse(savedHistory)
if (savedFavorites) favoriteFiles.value = JSON.parse(savedFavorites)
if (savedHeight) {
const height = parseInt(savedHeight)
if (!isNaN(height) && height >= 150 && height <= 800) {
fileContentHeight.value = height
}
}
} catch (error) {
console.error('从 localStorage 加载数据失败:', error)
}
}
const saveToStorage = (key, value) => {
try {
if (typeof value === 'string') {
localStorage.setItem(key, value)
} else {
localStorage.setItem(key, JSON.stringify(value))
}
} catch (error) {
console.error('保存到 localStorage 失败:', error)
}
}
// 监听数据变化自动保存
watch(filePath, (newPath) => {
saveToStorage(STORAGE_KEYS.FILE_PATH, newPath)
})
watch(fileContent, (newContent) => {
saveToStorage(STORAGE_KEYS.FILE_CONTENT, newContent)
})
watch(fileList, (newList) => {
saveToStorage(STORAGE_KEYS.FILE_LIST, newList)
}, { deep: true })
onMounted(() => {
loadFromStorage()
loadCommonPaths()
})
</script>
<style scoped>
.file-system {
padding: 16px;
}
/* 紧凑型文件列表项 */
.file-item-compact {
padding: 4px 0 !important;
margin: 0 !important;
}
.file-item-content {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 4px 8px;
border-radius: 4px;
transition: background 0.2s;
}
.file-item-content:hover {
background: var(--color-fill-2);
}
.file-icon {
font-size: 18px;
flex-shrink: 0;
width: 24px;
text-align: center;
}
.file-name-compact {
flex: 1;
font-size: 13px;
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.file-info-inline {
display: flex;
gap: 12px;
font-size: 11px;
color: var(--color-text-3);
flex-shrink: 0;
}
.file-size {
color: var(--color-text-3);
}
.file-time {
color: var(--color-text-3);
}
.favorite-btn {
flex-shrink: 0;
padding: 0 4px;
font-size: 14px;
}
/* 移除列表项的默认边距和内边距 */
.file-item-compact :deep(.arco-list-item-meta) {
padding: 0;
margin: 0;
}
.file-item-compact :deep(.arco-list-item-meta-title) {
margin: 0;
}
.file-item-compact :deep(.arco-list-item) {
padding: 0;
margin: 0;
border: none;
}
.file-content-wrapper {
position: relative;
overflow: hidden;
transition: height 0.1s ease;
border: 1px solid var(--color-border-2);
border-radius: 4px;
}
.file-content-textarea {
width: 100%;
height: 100%;
resize: none;
border: none;
}
.resize-handle {
height: 8px;
cursor: ns-resize;
display: flex;
align-items: center;
justify-content: center;
background: var(--color-fill-2);
border-radius: 4px;
margin: 4px 0;
transition: background 0.2s;
}
.resize-handle:hover {
background: var(--color-fill-3);
}
.resize-handle-bar {
width: 40px;
height: 3px;
background: var(--color-border-3);
border-radius: 2px;
}
.resize-handle:hover .resize-handle-bar {
background: rgb(var(--primary-6));
}
</style>