import { computed, ref } from 'vue' import { createDocFolder, createDocTextFile, deleteDocNode, fetchDocBlob, fetchDocNodes, replaceDocBlob, updateDocNode, uploadDocFile, } from '../utils/docsApi' const MAX_FILE_SIZE = 1024 * 1024 * 1024 const MAX_NODES = 5000 function getExtension(name = '') { const parts = String(name).split('.') return parts.length > 1 ? parts.pop().toLowerCase() : '' } function isTextExtension(ext) { const textExtensions = [ 'md', 'markdown', 'txt', 'json', 'js', 'jsx', 'ts', 'tsx', 'css', 'scss', 'less', 'html', 'htm', 'py', 'vue', 'xml', 'yaml', 'yml', 'csv', 'log', 'sql', 'toml', 'ini', 'cfg', 'conf', 'sh', 'bat', 'ps1', 'java', 'c', 'cpp', 'h', 'hpp', 'go', 'rs', 'swift', 'kt', 'rb', 'php', 'pl', 'r', 'scala', 'gradle', 'properties', 'env', 'gitignore', 'dockerfile' ] return textExtensions.includes(ext) } function isBinaryExtension(ext) { const binaryExtensions = [ 'exe', 'dll', 'so', 'dylib', 'bin', 'dat', 'obj', 'o', 'a', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'odt', 'ods', 'odp', 'pdf', 'zip', 'rar', '7z', 'tar', 'gz', 'bz2', 'xz', 'png', 'jpg', 'jpeg', 'gif', 'bmp', 'ico', 'webp', 'svg', 'mp3', 'mp4', 'wav', 'avi', 'mov', 'mkv', 'flv', 'wmv', 'ttf', 'otf', 'woff', 'woff2', 'eot', 'class', 'pyc', 'pyo', 'jar', 'war', 'ear', 'db', 'sqlite', 'mdb', 'accdb', 'pem', 'key', 'crt', 'cer', 'p12', 'pfx', 'jks', 'msg', 'eml', 'pst', 'ost', 'dwg', 'dxf', 'step', 'stl', 'obj', 'fbx', '3ds', 'blend' ] return binaryExtensions.includes(ext.toLowerCase()) } function inferMimeType(name, fallback = '') { const ext = getExtension(name) const map = { avi: 'video/x-msvideo', md: 'text/markdown', markdown: 'text/markdown', mkv: 'video/x-matroska', mov: 'video/quicktime', mp4: 'video/mp4', txt: 'text/plain', json: 'application/json', js: 'text/javascript', jsx: 'text/javascript', ts: 'text/typescript', tsx: 'text/typescript', css: 'text/css', html: 'text/html', htm: 'text/html', py: 'text/x-python', vue: 'text/plain', xml: 'application/xml', yaml: 'text/yaml', yml: 'text/yaml', csv: 'text/csv', log: 'text/plain', sql: 'text/plain', toml: 'text/plain', ini: 'text/plain', cfg: 'text/plain', conf: 'text/plain', sh: 'text/plain', bat: 'text/plain', ps1: 'text/plain', jpg: 'image/jpeg', jpeg: 'image/jpeg', flv: 'video/x-flv', m4v: 'video/x-m4v', png: 'image/png', gif: 'image/gif', webp: 'image/webp', svg: 'image/svg+xml', pdf: 'application/pdf', ogg: 'video/ogg', ogv: 'video/ogg', webm: 'video/webm', wmv: 'video/x-ms-wmv' } return fallback || map[ext] || 'application/octet-stream' } function isTextFile(record) { const ext = getExtension(record?.name) if (isBinaryExtension(ext)) return false const mime = String(record?.mimeType || '') return isTextExtension(ext) || mime.startsWith('text/') || mime.includes('json') || mime.includes('xml') } function buildTree(records) { const map = new Map() const roots = [] for (const record of records) { map.set(record.id, { ...record, children: record.type === 'folder' ? [] : undefined }) } for (const record of records) { const current = map.get(record.id) if (!record.parentId) { roots.push(current) continue } const parent = map.get(record.parentId) if (parent?.type === 'folder') parent.children.push(current) else roots.push(current) } const sorter = (a, b) => { if (a.type !== b.type) return a.type === 'folder' ? -1 : 1 return a.name.localeCompare(b.name, 'zh-CN', { sensitivity: 'base' }) } const sortChildren = (nodes) => { nodes.sort(sorter) for (const node of nodes) { if (node.type === 'folder' && Array.isArray(node.children)) sortChildren(node.children) } } sortChildren(roots) return roots } 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 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 } function estimateRecordSize(record) { if (typeof record?.size === 'number') return record.size if (typeof record?.content === 'string') return new Blob([record.content]).size if (typeof record?.previewText === 'string') return new Blob([record.previewText]).size return 0 } function cloneNode(node) { return JSON.parse(JSON.stringify(node)) } let singleton = null export function useFileSystem() { if (singleton) return singleton const records = ref([]) const selectedId = ref(null) const expandedIds = ref(new Set()) const clipboard = ref(null) const contextMenu = ref(null) const error = ref(null) const loading = ref(false) const blobCache = new Map() const tree = computed(() => buildTree(records.value)) const stats = computed(() => { let fileCount = 0 let folderCount = 0 let usedBytes = 0 for (const record of records.value) { if (record.type === 'folder') folderCount += 1 else fileCount += 1 usedBytes += estimateRecordSize(record) } return { fileCount, folderCount, usedBytes } }) function clearBlobCache(id = null) { if (id) { blobCache.delete(id) return } blobCache.clear() } function upsertRecord(nextRecord) { const index = records.value.findIndex((item) => item.id === nextRecord.id) if (index === -1) records.value = [...records.value, nextRecord] else { const next = [...records.value] next[index] = nextRecord records.value = next } return nextRecord } // --- Async operations with unified error handling --- async function load() { loading.value = true; records.value = [] try { records.value = await fetchDocNodes() } catch (err) { error.value = err?.message || '读取文档空间失败,请稍后重试' } finally { loading.value = false } } async function createFile(parentId, name, content = '') { if (records.value.length >= MAX_NODES) { error.value = `文件数量不能超过 ${MAX_NODES} 个` return false } try { const node = await createDocTextFile(name, parentId || null, content) upsertRecord(node); selectedId.value = node.id if (parentId) { expandedIds.value = new Set([...expandedIds.value, parentId]) } return true } catch (err) { error.value = err?.message || '创建文件失败'; return false } } async function updateFile(id, nextValue, options = {}) { const file = records.value.find((item) => item.id === id && item.type === 'file') if (!file) return false try { const node = nextValue instanceof Blob ? await replaceDocBlob(id, nextValue instanceof File ? nextValue : new File([nextValue], options.name || file.name, { type: options.mimeType || nextValue.type || '' })) : await updateDocNode(id, { name: options.name || file.name, content: String(options.content ?? nextValue) }) upsertRecord(node); clearBlobCache(id); return true } catch (err) { error.value = err?.message || '保存文件失败'; return false } } async function createFolder(parentId, name) { if (records.value.length >= MAX_NODES) { error.value = `目录项数量不能超过 ${MAX_NODES} 个` return false } try { const node = await createDocFolder(name, parentId || null) upsertRecord(node); selectedId.value = node.id if (parentId) expandedIds.value = new Set([...expandedIds.value, parentId]) return true } catch (err) { error.value = err?.message || '创建文件夹失败'; return false } } async function rename(id, newName) { const node = records.value.find((item) => item.id === id) if (!node) return false try { const updated = await updateDocNode(id, { name: newName }) upsertRecord(updated); clearBlobCache(id); return true } catch (err) { error.value = err?.message || '重命名失败'; return false } } function collectDescendantIds(id) { const ids = new Set([id]) let changed = true while (changed) { changed = false for (const record of records.value) { if (record.parentId && ids.has(record.parentId) && !ids.has(record.id)) { ids.add(record.id) changed = true } } } return [...ids] } async function remove(id) { const ids = new Set(collectDescendantIds(id)) try { await deleteDocNode(id) records.value = records.value.filter((item) => !ids.has(item.id)) if (selectedId.value && ids.has(selectedId.value)) selectedId.value = null if (clipboard.value?.nodeId && ids.has(clipboard.value.nodeId)) clipboard.value = null if (clipboard.value?.node?.id && ids.has(clipboard.value.node.id)) clipboard.value = null ids.forEach((currentId) => clearBlobCache(currentId)) error.value = null return true } catch (err) { error.value = err instanceof Error && err.message ? err.message : '删除文件失败' return false } } function select(id) { selectedId.value = id } function toggleFolder(id) { const node = records.value.find((item) => item.id === id) if (!node || node.type !== 'folder') return const next = new Set(expandedIds.value) if (next.has(id)) next.delete(id) else next.add(id) expandedIds.value = next } function copy(id) { const node = findNode(tree.value, id) if (!node) return clipboard.value = { mode: 'copy', node: cloneNode(node) } } function cut(id) { const node = records.value.find((item) => item.id === id) if (!node) return clipboard.value = { mode: 'cut', nodeId: node.id } } function isDescendantOf(sourceId, targetParentId) { let current = records.value.find((item) => item.id === targetParentId) while (current) { if (current.parentId === sourceId) return true current = current.parentId ? records.value.find((item) => item.id === current.parentId) : null } return false } async function duplicateNode(node, targetParentId) { let created if (node.type === 'folder') { created = await createDocFolder(node.name, targetParentId) upsertRecord(created) for (const child of node.children || []) { await duplicateNode(child, created.id) } return created } if (node.storageKind === 'blob') { const blob = await getFileBlob(node) const upload = new File([blob], node.name, { type: node.mimeType || blob.type || inferMimeType(node.name) }) created = await uploadDocFile(upload, targetParentId) } else { created = await createDocTextFile(node.name, targetParentId, node.content || node.previewText || '') } upsertRecord(created) return created } async function paste(targetParentId) { if (!clipboard.value) return try { if (clipboard.value.mode === 'cut') { const node = records.value.find((item) => item.id === clipboard.value.nodeId) if (!node) { clipboard.value = null return } if (node.id === targetParentId || (targetParentId && isDescendantOf(node.id, targetParentId))) { error.value = '不能移动到自身或子目录中' return } const updated = await updateDocNode(node.id, { parentId: targetParentId || null }) upsertRecord(updated) clipboard.value = null error.value = null return } const source = clipboard.value.node if (!source) return if (records.value.length >= MAX_NODES) { error.value = `目录项数量不能超过 ${MAX_NODES} 个` return } await duplicateNode(source, targetParentId || null) error.value = null } catch (err) { error.value = err instanceof Error && err.message ? err.message : '粘贴失败' } } 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 getFileIcon(name) { const ext = getExtension(name) const iconMap = { avi: 'video', md: 'markdown', markdown: 'markdown', mkv: 'video', mov: 'video', mp4: 'video', txt: 'text', json: 'json', js: 'javascript', jsx: 'javascript', ts: 'typescript', tsx: 'typescript', css: 'css', html: 'html', htm: 'html', py: 'python', vue: 'vue', xml: 'xml', yaml: 'yaml', yml: 'yaml', csv: 'csv', log: 'log', sql: 'sql', jpg: 'image', jpeg: 'image', png: 'image', gif: 'image', flv: 'video', m4v: 'video', webp: 'image', svg: 'image', ogg: 'video', ogv: 'video', pdf: 'pdf', doc: 'word', docx: 'word', ppt: 'ppt', pptx: 'ppt', webm: 'video', wmv: 'video', xls: 'excel', xlsx: 'excel', zip: 'zip' } return iconMap[ext] || 'file' } async function uploadFiles(files, parentId = null) { const source = Array.from(files || []) if (source.length === 0) return { success: 0, failed: [] } const failed = [] let success = 0 for (const file of source) { if (file.size > MAX_FILE_SIZE) { failed.push({ name: file.name, reason: '单个文件不能超过 1GB' }) continue } try { const node = await uploadDocFile(file, parentId) upsertRecord(node) success += 1 } catch (err) { failed.push({ name: file.name, reason: err instanceof Error && err.message ? err.message : '上传文件失败', }) } } if (success > 0 && parentId) { const next = new Set(expandedIds.value) next.add(parentId) expandedIds.value = next } return { success, failed } } async function getFileBlob(node) { if (!node || node.type !== 'file') return null if (blobCache.has(node.id)) return blobCache.get(node.id) if (node.storageKind === 'text' && typeof node.content === 'string' && node.content && !node.isTruncatedPreview) { const blob = new Blob([node.content], { type: inferMimeType(node.name, node.mimeType) }) blobCache.set(node.id, blob) return blob } const blob = await fetchDocBlob(node.id) blobCache.set(node.id, blob) return blob } singleton = { tree, selectedId, expandedIds, clipboard, contextMenu, error, loading, stats, load, createFile, updateFile, createFolder, rename, remove, select, toggleFolder, copy, cut, paste, canPaste, clearClipboard, getSelectedNode, getBreadcrumbPath, showContextMenu, hideContextMenu, getFileIcon, getExtension, getFileBlob, isTextFile, uploadFiles, MAX_FILE_SIZE, MAX_NODES } return singleton }