2026-04-05 23:22:00 +08:00
|
|
|
import { ref, computed, watch } from 'vue'
|
|
|
|
|
|
|
|
|
|
const STORAGE_KEY = 'llm-in-text-file-system'
|
|
|
|
|
const MAX_FILE_SIZE = 50 * 1024 * 1024 // 50MB
|
|
|
|
|
const MAX_FILES = 100
|
|
|
|
|
const MAX_FOLDERS = 50
|
|
|
|
|
|
|
|
|
|
function generateId() {
|
|
|
|
|
return Date.now().toString(36) + Math.random().toString(36).slice(2, 9)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function countFilesAndFolders(nodes) {
|
|
|
|
|
let files = 0
|
|
|
|
|
let folders = 0
|
|
|
|
|
function traverse(items) {
|
|
|
|
|
for (const item of items) {
|
|
|
|
|
if (item.type === 'folder') {
|
|
|
|
|
folders++
|
|
|
|
|
traverse(item.children || [])
|
|
|
|
|
} else {
|
|
|
|
|
files++
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
traverse(nodes)
|
|
|
|
|
return { files, folders }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function findNode(nodes, id) {
|
|
|
|
|
for (const node of nodes) {
|
|
|
|
|
if (node.id === id) return node
|
|
|
|
|
if (node.type === 'folder') {
|
|
|
|
|
const found = findNode(node.children || [], id)
|
|
|
|
|
if (found) return found
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return null
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function findParent(nodes, id, parent = null) {
|
|
|
|
|
for (const node of nodes) {
|
|
|
|
|
if (node.id === id) return parent
|
|
|
|
|
if (node.type === 'folder') {
|
|
|
|
|
const found = findParent(node.children || [], id, node)
|
|
|
|
|
if (found !== undefined) return found
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return undefined
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function removeNode(nodes, id) {
|
|
|
|
|
return nodes.filter(n => n.id !== id).map(n => {
|
|
|
|
|
if (n.type === 'folder') {
|
|
|
|
|
return { ...n, children: removeNode(n.children || [], id) }
|
|
|
|
|
}
|
|
|
|
|
return n
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function getPath(nodes, id, path = []) {
|
|
|
|
|
for (const node of nodes) {
|
|
|
|
|
if (node.id === id) return [...path, node]
|
|
|
|
|
if (node.type === 'folder') {
|
|
|
|
|
const found = getPath(node.children || [], id, [...path, node])
|
|
|
|
|
if (found) return found
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return null
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function useFileSystem() {
|
|
|
|
|
const tree = ref([])
|
|
|
|
|
const selectedId = ref(null)
|
|
|
|
|
const expandedIds = ref(new Set())
|
|
|
|
|
const clipboard = ref(null) // { mode: 'copy' | 'cut', node: {...} }
|
|
|
|
|
const contextMenu = ref(null) // { x, y, node }
|
|
|
|
|
const error = ref(null)
|
|
|
|
|
|
|
|
|
|
function load() {
|
|
|
|
|
try {
|
|
|
|
|
const stored = localStorage.getItem(STORAGE_KEY)
|
|
|
|
|
if (stored) {
|
|
|
|
|
tree.value = JSON.parse(stored)
|
2026-04-06 11:14:09 +08:00
|
|
|
} else {
|
|
|
|
|
// 创建示例文件和文件夹
|
|
|
|
|
const welcomeId = generateId()
|
|
|
|
|
const folderId = generateId()
|
|
|
|
|
tree.value = [
|
|
|
|
|
{
|
|
|
|
|
id: folderId,
|
|
|
|
|
name: '示例文件夹',
|
|
|
|
|
type: 'folder',
|
|
|
|
|
children: [],
|
|
|
|
|
parentId: null,
|
|
|
|
|
createdAt: Date.now(),
|
|
|
|
|
updatedAt: Date.now()
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
id: welcomeId,
|
|
|
|
|
name: '欢迎使用.md',
|
|
|
|
|
type: 'file',
|
|
|
|
|
content: '# 欢迎使用文件系统\n\n这是一个类似 GitHub 风格的文件浏览器。\n\n## 功能\n\n- ✅ 文件夹展开/折叠\n- ✅ 文件选中高亮\n- ✅ 拖拽移动\n- ✅ 右键菜单\n- ✅ 重命名\n- ✅ 新建/删除\n\n点击左侧的文件或文件夹来查看内容。\n',
|
|
|
|
|
parentId: null,
|
|
|
|
|
createdAt: Date.now(),
|
|
|
|
|
updatedAt: Date.now()
|
|
|
|
|
}
|
|
|
|
|
]
|
|
|
|
|
save()
|
2026-04-05 23:22:00 +08:00
|
|
|
}
|
|
|
|
|
} catch {
|
|
|
|
|
tree.value = []
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function save() {
|
|
|
|
|
try {
|
|
|
|
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(tree.value))
|
|
|
|
|
} catch {
|
|
|
|
|
error.value = '存储空间不足'
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
watch(tree, save, { deep: true })
|
|
|
|
|
|
|
|
|
|
function createFile(parentId, name, content = '') {
|
|
|
|
|
const { files } = countFilesAndFolders(tree.value)
|
|
|
|
|
if (files >= MAX_FILES) {
|
|
|
|
|
error.value = `文件数量已达上限(${MAX_FILES}个)`
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
if (new Blob([content]).size > MAX_FILE_SIZE) {
|
|
|
|
|
error.value = '文件大小不能超过 50MB'
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
const newFile = {
|
|
|
|
|
id: generateId(),
|
|
|
|
|
name,
|
|
|
|
|
type: 'file',
|
|
|
|
|
content,
|
|
|
|
|
parentId,
|
|
|
|
|
createdAt: Date.now(),
|
|
|
|
|
updatedAt: Date.now()
|
|
|
|
|
}
|
|
|
|
|
if (!parentId) {
|
|
|
|
|
tree.value.push(newFile)
|
|
|
|
|
} else {
|
|
|
|
|
const parent = findNode(tree.value, parentId)
|
|
|
|
|
if (parent && parent.type === 'folder') {
|
|
|
|
|
parent.children = parent.children || []
|
|
|
|
|
parent.children.push(newFile)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
error.value = null
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function createFolder(parentId, name) {
|
|
|
|
|
const { folders } = countFilesAndFolders(tree.value)
|
|
|
|
|
if (folders >= MAX_FOLDERS) {
|
|
|
|
|
error.value = `文件夹数量已达上限(${MAX_FOLDERS}个)`
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
const newFolder = {
|
|
|
|
|
id: generateId(),
|
|
|
|
|
name,
|
|
|
|
|
type: 'folder',
|
|
|
|
|
children: [],
|
|
|
|
|
parentId,
|
|
|
|
|
createdAt: Date.now(),
|
|
|
|
|
updatedAt: Date.now()
|
|
|
|
|
}
|
|
|
|
|
if (!parentId) {
|
|
|
|
|
tree.value.push(newFolder)
|
|
|
|
|
} else {
|
|
|
|
|
const parent = findNode(tree.value, parentId)
|
|
|
|
|
if (parent && parent.type === 'folder') {
|
|
|
|
|
parent.children = parent.children || []
|
|
|
|
|
parent.children.push(newFolder)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
error.value = null
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function rename(id, newName) {
|
|
|
|
|
const node = findNode(tree.value, id)
|
|
|
|
|
if (node) {
|
|
|
|
|
node.name = newName
|
|
|
|
|
node.updatedAt = Date.now()
|
|
|
|
|
error.value = null
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function remove(id) {
|
|
|
|
|
tree.value = removeNode(tree.value, id)
|
|
|
|
|
if (selectedId.value === id) selectedId.value = null
|
|
|
|
|
error.value = null
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-06 11:14:09 +08:00
|
|
|
function select(id) {
|
|
|
|
|
selectedId.value = id
|
|
|
|
|
}
|
2026-04-05 23:22:00 +08:00
|
|
|
|
|
|
|
|
function toggleFolder(id) {
|
|
|
|
|
const node = findNode(tree.value, id)
|
|
|
|
|
if (!node || node.type !== 'folder') return
|
|
|
|
|
const set = new Set(expandedIds.value)
|
|
|
|
|
if (set.has(id)) {
|
|
|
|
|
set.delete(id)
|
|
|
|
|
} else {
|
|
|
|
|
set.add(id)
|
|
|
|
|
}
|
|
|
|
|
expandedIds.value = set
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function copy(id) {
|
|
|
|
|
const node = findNode(tree.value, id)
|
|
|
|
|
if (node) {
|
|
|
|
|
clipboard.value = { mode: 'copy', node: JSON.parse(JSON.stringify(node)) }
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function cut(id) {
|
|
|
|
|
const node = findNode(tree.value, id)
|
|
|
|
|
if (node) {
|
|
|
|
|
clipboard.value = { mode: 'cut', node: JSON.parse(JSON.stringify(node)) }
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function paste(targetParentId) {
|
|
|
|
|
if (!clipboard.value) return
|
|
|
|
|
const { mode, node } = clipboard.value
|
|
|
|
|
|
|
|
|
|
if (mode === 'cut') {
|
|
|
|
|
const oldParent = findParent(tree.value, node.id)
|
|
|
|
|
if (oldParent) {
|
|
|
|
|
oldParent.children = (oldParent.children || []).filter(c => c.id !== node.id)
|
|
|
|
|
} else {
|
|
|
|
|
tree.value = tree.value.filter(n => n.id !== node.id)
|
|
|
|
|
}
|
|
|
|
|
node.parentId = targetParentId || null
|
|
|
|
|
if (targetParentId) {
|
|
|
|
|
const target = findNode(tree.value, targetParentId)
|
|
|
|
|
if (target && target.type === 'folder') {
|
|
|
|
|
target.children = target.children || []
|
|
|
|
|
target.children.push(node)
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
tree.value.push(node)
|
|
|
|
|
}
|
|
|
|
|
clipboard.value = null
|
|
|
|
|
} else {
|
|
|
|
|
const { files, folders } = countFilesAndFolders(tree.value)
|
|
|
|
|
function countInNode(n) {
|
|
|
|
|
let f = 0, fl = 0
|
|
|
|
|
if (n.type === 'folder') {
|
|
|
|
|
fl = 1
|
|
|
|
|
for (const c of (n.children || [])) {
|
|
|
|
|
const sub = countInNode(c)
|
|
|
|
|
f += sub.f
|
|
|
|
|
fl += sub.fl
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
f = 1
|
|
|
|
|
}
|
|
|
|
|
return { f, fl }
|
|
|
|
|
}
|
|
|
|
|
const counts = countInNode(node)
|
|
|
|
|
if (files + counts.f > MAX_FILES) {
|
|
|
|
|
error.value = `文件数量将达上限`
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if (folders + counts.fl > MAX_FOLDERS) {
|
|
|
|
|
error.value = `文件夹数量将达上限`
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function cloneWithNewIds(n) {
|
|
|
|
|
const clone = { ...n, id: generateId(), createdAt: Date.now(), updatedAt: Date.now() }
|
|
|
|
|
if (clone.type === 'folder') {
|
|
|
|
|
clone.children = (n.children || []).map(c => cloneWithNewIds(c))
|
|
|
|
|
}
|
|
|
|
|
return clone
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const cloned = cloneWithNewIds(node)
|
|
|
|
|
cloned.parentId = targetParentId || null
|
|
|
|
|
if (targetParentId) {
|
|
|
|
|
const target = findNode(tree.value, targetParentId)
|
|
|
|
|
if (target && target.type === 'folder') {
|
|
|
|
|
target.children = target.children || []
|
|
|
|
|
target.children.push(cloned)
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
tree.value.push(cloned)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
error.value = null
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function canPaste() {
|
|
|
|
|
return clipboard.value !== null
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function clearClipboard() {
|
|
|
|
|
clipboard.value = null
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function getSelectedNode() {
|
|
|
|
|
return selectedId.value ? findNode(tree.value, selectedId.value) : null
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function getBreadcrumbPath(id) {
|
|
|
|
|
return getPath(tree.value, id) || []
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function showContextMenu(x, y, node) {
|
|
|
|
|
contextMenu.value = { x, y, node }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function hideContextMenu() {
|
|
|
|
|
contextMenu.value = null
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function getExtension(name) {
|
|
|
|
|
const parts = name.split('.')
|
|
|
|
|
return parts.length > 1 ? parts.pop().toLowerCase() : ''
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function getFileIcon(name) {
|
|
|
|
|
const ext = getExtension(name)
|
|
|
|
|
const iconMap = {
|
|
|
|
|
md: 'markdown',
|
|
|
|
|
txt: 'text',
|
|
|
|
|
json: 'json',
|
|
|
|
|
js: 'javascript',
|
|
|
|
|
ts: 'typescript',
|
|
|
|
|
css: 'css',
|
|
|
|
|
html: 'html',
|
|
|
|
|
py: 'python',
|
|
|
|
|
vue: 'vue',
|
|
|
|
|
xml: 'xml',
|
|
|
|
|
yaml: 'yaml',
|
|
|
|
|
yml: 'yaml',
|
|
|
|
|
csv: 'csv',
|
|
|
|
|
log: 'log',
|
|
|
|
|
sql: 'sql'
|
|
|
|
|
}
|
|
|
|
|
return iconMap[ext] || 'file'
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
tree,
|
|
|
|
|
selectedId,
|
|
|
|
|
expandedIds,
|
|
|
|
|
clipboard,
|
|
|
|
|
contextMenu,
|
|
|
|
|
error,
|
|
|
|
|
load,
|
|
|
|
|
createFile,
|
|
|
|
|
createFolder,
|
|
|
|
|
rename,
|
|
|
|
|
remove,
|
|
|
|
|
select,
|
|
|
|
|
toggleFolder,
|
|
|
|
|
copy,
|
|
|
|
|
cut,
|
|
|
|
|
paste,
|
|
|
|
|
canPaste,
|
|
|
|
|
clearClipboard,
|
|
|
|
|
getSelectedNode,
|
|
|
|
|
getBreadcrumbPath,
|
|
|
|
|
showContextMenu,
|
|
|
|
|
hideContextMenu,
|
|
|
|
|
getFileIcon,
|
|
|
|
|
getExtension,
|
|
|
|
|
MAX_FILE_SIZE,
|
|
|
|
|
MAX_FILES,
|
|
|
|
|
MAX_FOLDERS
|
|
|
|
|
}
|
|
|
|
|
}
|