2026-04-07 23:38:23 +08:00
|
|
|
import { computed, ref } from 'vue'
|
2026-04-05 23:22:00 +08:00
|
|
|
|
2026-04-07 23:38:23 +08:00
|
|
|
const DB_NAME = 'llm-in-text-docs'
|
|
|
|
|
const DB_VERSION = 1
|
|
|
|
|
const STORE_NAME = 'nodes'
|
|
|
|
|
const MAX_FILE_SIZE = 1024 * 1024 * 1024
|
|
|
|
|
const MAX_TEXT_SIZE = 8 * 1024 * 1024
|
|
|
|
|
const PREVIEW_TEXT_SIZE = 2 * 1024 * 1024
|
|
|
|
|
const MAX_NODES = 5000
|
|
|
|
|
|
|
|
|
|
let dbPromise = null
|
2026-04-05 23:22:00 +08:00
|
|
|
|
|
|
|
|
function generateId() {
|
2026-04-07 23:38:23 +08:00
|
|
|
return `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 10)}`
|
2026-04-05 23:22:00 +08:00
|
|
|
}
|
|
|
|
|
|
2026-04-07 23:38:23 +08:00
|
|
|
function openDatabase() {
|
|
|
|
|
if (dbPromise) return dbPromise
|
|
|
|
|
dbPromise = new Promise((resolve, reject) => {
|
|
|
|
|
const request = indexedDB.open(DB_NAME, DB_VERSION)
|
|
|
|
|
request.onupgradeneeded = () => {
|
|
|
|
|
const db = request.result
|
|
|
|
|
if (!db.objectStoreNames.contains(STORE_NAME)) {
|
|
|
|
|
db.createObjectStore(STORE_NAME, { keyPath: 'id' })
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
request.onsuccess = () => resolve(request.result)
|
|
|
|
|
request.onerror = () => reject(request.error || new Error('打开本地数据库失败'))
|
|
|
|
|
})
|
|
|
|
|
return dbPromise
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function withStore(mode, handler) {
|
|
|
|
|
const db = await openDatabase()
|
|
|
|
|
return new Promise((resolve, reject) => {
|
|
|
|
|
const transaction = db.transaction(STORE_NAME, mode)
|
|
|
|
|
const store = transaction.objectStore(STORE_NAME)
|
|
|
|
|
let result
|
|
|
|
|
try {
|
|
|
|
|
result = handler(store)
|
|
|
|
|
} catch (error) {
|
|
|
|
|
reject(error)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
transaction.oncomplete = () => resolve(result)
|
|
|
|
|
transaction.onerror = () => reject(transaction.error || new Error('本地数据库写入失败'))
|
|
|
|
|
transaction.onabort = () => reject(transaction.error || new Error('本地数据库操作已取消'))
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function cloneRecord(record) {
|
|
|
|
|
if (!record) return record
|
|
|
|
|
return {
|
|
|
|
|
...record,
|
|
|
|
|
children: undefined
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function getExtension(name = '') {
|
|
|
|
|
const parts = String(name).split('.')
|
|
|
|
|
return parts.length > 1 ? parts.pop().toLowerCase() : ''
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function isTextExtension(ext) {
|
|
|
|
|
return [
|
|
|
|
|
'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'
|
|
|
|
|
].includes(ext)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function inferMimeType(name, fallback = '') {
|
|
|
|
|
const ext = getExtension(name)
|
|
|
|
|
const map = {
|
|
|
|
|
md: 'text/markdown',
|
|
|
|
|
markdown: 'text/markdown',
|
|
|
|
|
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',
|
|
|
|
|
png: 'image/png',
|
|
|
|
|
gif: 'image/gif',
|
|
|
|
|
webp: 'image/webp',
|
|
|
|
|
svg: 'image/svg+xml',
|
|
|
|
|
pdf: 'application/pdf'
|
|
|
|
|
}
|
|
|
|
|
return fallback || map[ext] || 'application/octet-stream'
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function isTextFile(record) {
|
|
|
|
|
const ext = getExtension(record?.name)
|
|
|
|
|
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)
|
2026-04-05 23:22:00 +08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-04-07 23:38:23 +08:00
|
|
|
sortChildren(roots)
|
|
|
|
|
return roots
|
2026-04-05 23:22:00 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-07 23:38:23 +08:00
|
|
|
function getPath(nodes, id, path = []) {
|
2026-04-05 23:22:00 +08:00
|
|
|
for (const node of nodes) {
|
2026-04-07 23:38:23 +08:00
|
|
|
if (node.id === id) return [...path, node]
|
2026-04-05 23:22:00 +08:00
|
|
|
if (node.type === 'folder') {
|
2026-04-07 23:38:23 +08:00
|
|
|
const found = getPath(node.children || [], id, [...path, node])
|
|
|
|
|
if (found) return found
|
2026-04-05 23:22:00 +08:00
|
|
|
}
|
|
|
|
|
}
|
2026-04-07 23:38:23 +08:00
|
|
|
return null
|
2026-04-05 23:22:00 +08:00
|
|
|
}
|
|
|
|
|
|
2026-04-07 23:38:23 +08:00
|
|
|
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
|
2026-04-05 23:22:00 +08:00
|
|
|
}
|
|
|
|
|
|
2026-04-07 23:38:23 +08:00
|
|
|
async function readFilePayload(file) {
|
|
|
|
|
const mimeType = inferMimeType(file.name, file.type)
|
|
|
|
|
const ext = getExtension(file.name)
|
|
|
|
|
const textFile = isTextExtension(ext) || mimeType.startsWith('text/') || mimeType.includes('json') || mimeType.includes('xml')
|
|
|
|
|
if (!textFile) {
|
|
|
|
|
return {
|
|
|
|
|
mimeType,
|
|
|
|
|
size: file.size,
|
|
|
|
|
storageKind: 'blob',
|
|
|
|
|
blob: file
|
2026-04-05 23:22:00 +08:00
|
|
|
}
|
|
|
|
|
}
|
2026-04-07 23:38:23 +08:00
|
|
|
if (file.size <= MAX_TEXT_SIZE) {
|
|
|
|
|
const content = await file.text()
|
|
|
|
|
return {
|
|
|
|
|
mimeType,
|
|
|
|
|
size: file.size,
|
|
|
|
|
storageKind: 'text',
|
|
|
|
|
content,
|
|
|
|
|
previewText: content,
|
|
|
|
|
isTruncatedPreview: false
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
const previewText = await file.slice(0, PREVIEW_TEXT_SIZE).text()
|
|
|
|
|
return {
|
|
|
|
|
mimeType,
|
|
|
|
|
size: file.size,
|
|
|
|
|
storageKind: 'blob',
|
|
|
|
|
blob: file,
|
|
|
|
|
previewText,
|
|
|
|
|
isTruncatedPreview: true
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function createWelcomeRecords() {
|
|
|
|
|
const folderId = generateId()
|
|
|
|
|
const fileId = generateId()
|
|
|
|
|
const now = Date.now()
|
|
|
|
|
return [
|
|
|
|
|
{
|
|
|
|
|
id: folderId,
|
|
|
|
|
name: '示例文件夹',
|
|
|
|
|
type: 'folder',
|
|
|
|
|
parentId: null,
|
|
|
|
|
createdAt: now,
|
|
|
|
|
updatedAt: now
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
id: fileId,
|
|
|
|
|
name: '欢迎使用.md',
|
|
|
|
|
type: 'file',
|
|
|
|
|
parentId: null,
|
|
|
|
|
createdAt: now,
|
|
|
|
|
updatedAt: now,
|
|
|
|
|
mimeType: 'text/markdown',
|
|
|
|
|
storageKind: 'text',
|
|
|
|
|
size: 258,
|
|
|
|
|
content: [
|
|
|
|
|
'# 欢迎使用文档模式',
|
|
|
|
|
'',
|
|
|
|
|
'这里已经切换为更接近 GitHub 的文件浏览体验。',
|
|
|
|
|
'',
|
|
|
|
|
'## 现在支持',
|
|
|
|
|
'',
|
|
|
|
|
'- 左侧文件树与快速上传',
|
|
|
|
|
'- 浏览器本地持久化存储',
|
|
|
|
|
'- 文本、Markdown、图片、PDF 预览',
|
|
|
|
|
'- 大文件保留原始文件并显示截断预览'
|
|
|
|
|
].join('\n'),
|
|
|
|
|
previewText: '',
|
|
|
|
|
isTruncatedPreview: false
|
|
|
|
|
}
|
|
|
|
|
]
|
2026-04-05 23:22:00 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function useFileSystem() {
|
2026-04-07 23:38:23 +08:00
|
|
|
const records = ref([])
|
2026-04-05 23:22:00 +08:00
|
|
|
const selectedId = ref(null)
|
|
|
|
|
const expandedIds = ref(new Set())
|
2026-04-07 23:38:23 +08:00
|
|
|
const clipboard = ref(null)
|
|
|
|
|
const contextMenu = ref(null)
|
2026-04-05 23:22:00 +08:00
|
|
|
const error = ref(null)
|
2026-04-07 23:38:23 +08:00
|
|
|
const loading = ref(false)
|
|
|
|
|
|
|
|
|
|
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 }
|
|
|
|
|
})
|
2026-04-05 23:22:00 +08:00
|
|
|
|
2026-04-07 23:38:23 +08:00
|
|
|
async function load() {
|
|
|
|
|
loading.value = true
|
2026-04-05 23:22:00 +08:00
|
|
|
try {
|
2026-04-07 23:38:23 +08:00
|
|
|
const stored = await withStore('readonly', (store) => store.getAll())
|
|
|
|
|
const request = stored
|
|
|
|
|
const nextRecords = await new Promise((resolve, reject) => {
|
|
|
|
|
request.onsuccess = () => resolve(Array.isArray(request.result) ? request.result : [])
|
|
|
|
|
request.onerror = () => reject(request.error || new Error('读取本地文件失败'))
|
|
|
|
|
})
|
|
|
|
|
if (nextRecords.length === 0) {
|
|
|
|
|
const seed = createWelcomeRecords()
|
|
|
|
|
await Promise.all(seed.map((record) => persistRecord(record)))
|
|
|
|
|
records.value = seed
|
2026-04-06 11:14:09 +08:00
|
|
|
} else {
|
2026-04-07 23:38:23 +08:00
|
|
|
records.value = nextRecords
|
2026-04-05 23:22:00 +08:00
|
|
|
}
|
2026-04-07 23:38:23 +08:00
|
|
|
error.value = null
|
2026-04-05 23:22:00 +08:00
|
|
|
} catch {
|
2026-04-07 23:38:23 +08:00
|
|
|
error.value = '读取本地文件失败,请刷新页面后重试'
|
|
|
|
|
records.value = []
|
|
|
|
|
} finally {
|
|
|
|
|
loading.value = false
|
2026-04-05 23:22:00 +08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-07 23:38:23 +08:00
|
|
|
async function persistRecord(record) {
|
|
|
|
|
return withStore('readwrite', (store) => store.put(cloneRecord(record)))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function deleteRecord(id) {
|
|
|
|
|
return withStore('readwrite', (store) => store.delete(id))
|
2026-04-05 23:22:00 +08:00
|
|
|
}
|
|
|
|
|
|
2026-04-07 23:38:23 +08:00
|
|
|
function touchParent(parentId) {
|
|
|
|
|
if (!parentId) return
|
|
|
|
|
const parent = records.value.find((item) => item.id === parentId)
|
|
|
|
|
if (!parent) return
|
|
|
|
|
parent.updatedAt = Date.now()
|
|
|
|
|
persistRecord(parent).catch(() => {
|
|
|
|
|
error.value = '更新目录时间失败'
|
|
|
|
|
})
|
|
|
|
|
}
|
2026-04-05 23:22:00 +08:00
|
|
|
|
2026-04-07 23:38:23 +08:00
|
|
|
function createFile(parentId, name, content = '', options = {}) {
|
|
|
|
|
if (records.value.length >= MAX_NODES) {
|
|
|
|
|
error.value = `文件数量不能超过 ${MAX_NODES} 个`
|
2026-04-05 23:22:00 +08:00
|
|
|
return false
|
|
|
|
|
}
|
2026-04-07 23:38:23 +08:00
|
|
|
const size = typeof options.size === 'number' ? options.size : new Blob([content]).size
|
|
|
|
|
if (size > MAX_FILE_SIZE) {
|
|
|
|
|
error.value = '单个文件不能超过 1GB'
|
2026-04-05 23:22:00 +08:00
|
|
|
return false
|
|
|
|
|
}
|
2026-04-07 23:38:23 +08:00
|
|
|
const now = Date.now()
|
|
|
|
|
const file = {
|
2026-04-05 23:22:00 +08:00
|
|
|
id: generateId(),
|
|
|
|
|
name,
|
|
|
|
|
type: 'file',
|
2026-04-07 23:38:23 +08:00
|
|
|
parentId: parentId || null,
|
|
|
|
|
createdAt: now,
|
|
|
|
|
updatedAt: now,
|
|
|
|
|
mimeType: inferMimeType(name, options.mimeType),
|
|
|
|
|
storageKind: options.storageKind || 'text',
|
|
|
|
|
size,
|
|
|
|
|
content: options.content ?? content,
|
|
|
|
|
previewText: options.previewText ?? '',
|
|
|
|
|
isTruncatedPreview: Boolean(options.isTruncatedPreview),
|
|
|
|
|
blob: options.blob || null
|
2026-04-05 23:22:00 +08:00
|
|
|
}
|
2026-04-07 23:38:23 +08:00
|
|
|
records.value = [...records.value, file]
|
|
|
|
|
if (parentId) {
|
|
|
|
|
const next = new Set(expandedIds.value)
|
|
|
|
|
next.add(parentId)
|
|
|
|
|
expandedIds.value = next
|
2026-04-05 23:22:00 +08:00
|
|
|
}
|
2026-04-07 23:38:23 +08:00
|
|
|
selectedId.value = file.id
|
2026-04-05 23:22:00 +08:00
|
|
|
error.value = null
|
2026-04-07 23:38:23 +08:00
|
|
|
persistRecord(file).catch(() => {
|
|
|
|
|
error.value = '保存文件失败,可能是浏览器存储空间不足'
|
|
|
|
|
})
|
|
|
|
|
touchParent(parentId)
|
2026-04-05 23:22:00 +08:00
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function createFolder(parentId, name) {
|
2026-04-07 23:38:23 +08:00
|
|
|
if (records.value.length >= MAX_NODES) {
|
|
|
|
|
error.value = `目录项数量不能超过 ${MAX_NODES} 个`
|
2026-04-05 23:22:00 +08:00
|
|
|
return false
|
|
|
|
|
}
|
2026-04-07 23:38:23 +08:00
|
|
|
const now = Date.now()
|
|
|
|
|
const folder = {
|
2026-04-05 23:22:00 +08:00
|
|
|
id: generateId(),
|
|
|
|
|
name,
|
|
|
|
|
type: 'folder',
|
2026-04-07 23:38:23 +08:00
|
|
|
parentId: parentId || null,
|
|
|
|
|
createdAt: now,
|
|
|
|
|
updatedAt: now
|
2026-04-05 23:22:00 +08:00
|
|
|
}
|
2026-04-07 23:38:23 +08:00
|
|
|
records.value = [...records.value, folder]
|
|
|
|
|
if (parentId) {
|
|
|
|
|
const next = new Set(expandedIds.value)
|
|
|
|
|
next.add(parentId)
|
|
|
|
|
expandedIds.value = next
|
2026-04-05 23:22:00 +08:00
|
|
|
}
|
2026-04-07 23:38:23 +08:00
|
|
|
selectedId.value = folder.id
|
2026-04-05 23:22:00 +08:00
|
|
|
error.value = null
|
2026-04-07 23:38:23 +08:00
|
|
|
persistRecord(folder).catch(() => {
|
|
|
|
|
error.value = '保存文件夹失败'
|
|
|
|
|
})
|
|
|
|
|
touchParent(parentId)
|
2026-04-05 23:22:00 +08:00
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function rename(id, newName) {
|
2026-04-07 23:38:23 +08:00
|
|
|
const node = records.value.find((item) => item.id === id)
|
|
|
|
|
if (!node) return false
|
|
|
|
|
node.name = newName
|
|
|
|
|
node.updatedAt = Date.now()
|
|
|
|
|
error.value = null
|
|
|
|
|
persistRecord(node).catch(() => {
|
|
|
|
|
error.value = '重命名失败'
|
|
|
|
|
})
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-04-05 23:22:00 +08:00
|
|
|
}
|
2026-04-07 23:38:23 +08:00
|
|
|
return [...ids]
|
2026-04-05 23:22:00 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function remove(id) {
|
2026-04-07 23:38:23 +08:00
|
|
|
const ids = new Set(collectDescendantIds(id))
|
|
|
|
|
const deletingSelected = selectedId.value && ids.has(selectedId.value)
|
|
|
|
|
records.value = records.value.filter((item) => !ids.has(item.id))
|
|
|
|
|
if (deletingSelected) 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
|
|
|
|
|
}
|
2026-04-05 23:22:00 +08:00
|
|
|
error.value = null
|
2026-04-07 23:38:23 +08:00
|
|
|
ids.forEach((currentId) => {
|
|
|
|
|
deleteRecord(currentId).catch(() => {
|
|
|
|
|
error.value = '删除文件失败'
|
|
|
|
|
})
|
|
|
|
|
})
|
2026-04-05 23:22:00 +08:00
|
|
|
}
|
|
|
|
|
|
2026-04-07 23:38:23 +08:00
|
|
|
function select(id) {
|
|
|
|
|
selectedId.value = id
|
|
|
|
|
}
|
2026-04-05 23:22:00 +08:00
|
|
|
|
|
|
|
|
function toggleFolder(id) {
|
2026-04-07 23:38:23 +08:00
|
|
|
const node = records.value.find((item) => item.id === id)
|
2026-04-05 23:22:00 +08:00
|
|
|
if (!node || node.type !== 'folder') return
|
2026-04-07 23:38:23 +08:00
|
|
|
const next = new Set(expandedIds.value)
|
|
|
|
|
if (next.has(id)) next.delete(id)
|
|
|
|
|
else next.add(id)
|
|
|
|
|
expandedIds.value = next
|
2026-04-05 23:22:00 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function copy(id) {
|
|
|
|
|
const node = findNode(tree.value, id)
|
2026-04-07 23:38:23 +08:00
|
|
|
if (!node) return
|
|
|
|
|
clipboard.value = {
|
|
|
|
|
mode: 'copy',
|
|
|
|
|
node: JSON.parse(JSON.stringify(node))
|
2026-04-05 23:22:00 +08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function cut(id) {
|
2026-04-07 23:38:23 +08:00
|
|
|
const node = records.value.find((item) => item.id === id)
|
|
|
|
|
if (!node) return
|
|
|
|
|
clipboard.value = {
|
|
|
|
|
mode: 'cut',
|
|
|
|
|
nodeId: node.id
|
2026-04-05 23:22:00 +08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-07 23:38:23 +08:00
|
|
|
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
|
|
|
|
|
}
|
2026-04-05 23:22:00 +08:00
|
|
|
|
2026-04-07 23:38:23 +08:00
|
|
|
function duplicateNode(node, targetParentId) {
|
|
|
|
|
const now = Date.now()
|
|
|
|
|
const clonedId = generateId()
|
|
|
|
|
const record = {
|
|
|
|
|
...cloneRecord(node),
|
|
|
|
|
id: clonedId,
|
|
|
|
|
parentId: targetParentId,
|
|
|
|
|
createdAt: now,
|
|
|
|
|
updatedAt: now
|
|
|
|
|
}
|
|
|
|
|
records.value = [...records.value, record]
|
|
|
|
|
persistRecord(record).catch(() => {
|
|
|
|
|
error.value = '复制文件失败'
|
|
|
|
|
})
|
|
|
|
|
if (node.type === 'folder') {
|
|
|
|
|
for (const child of node.children || []) {
|
|
|
|
|
duplicateNode(child, clonedId)
|
2026-04-05 23:22:00 +08:00
|
|
|
}
|
2026-04-07 23:38:23 +08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function paste(targetParentId) {
|
|
|
|
|
if (!clipboard.value) return
|
|
|
|
|
if (clipboard.value.mode === 'cut') {
|
|
|
|
|
const node = records.value.find((item) => item.id === clipboard.value.nodeId)
|
|
|
|
|
if (!node) {
|
|
|
|
|
clipboard.value = null
|
2026-04-05 23:22:00 +08:00
|
|
|
return
|
|
|
|
|
}
|
2026-04-07 23:38:23 +08:00
|
|
|
if (node.id === targetParentId || (targetParentId && isDescendantOf(node.id, targetParentId))) {
|
|
|
|
|
error.value = '不能移动到自身或子目录中'
|
2026-04-05 23:22:00 +08:00
|
|
|
return
|
|
|
|
|
}
|
2026-04-07 23:38:23 +08:00
|
|
|
node.parentId = targetParentId || null
|
|
|
|
|
node.updatedAt = Date.now()
|
|
|
|
|
persistRecord(node).catch(() => {
|
|
|
|
|
error.value = '移动文件失败'
|
|
|
|
|
})
|
|
|
|
|
touchParent(targetParentId)
|
|
|
|
|
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
|
2026-04-05 23:22:00 +08:00
|
|
|
}
|
2026-04-07 23:38:23 +08:00
|
|
|
duplicateNode(source, targetParentId || null)
|
|
|
|
|
touchParent(targetParentId)
|
2026-04-05 23:22:00 +08:00
|
|
|
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 getFileIcon(name) {
|
|
|
|
|
const ext = getExtension(name)
|
|
|
|
|
const iconMap = {
|
|
|
|
|
md: 'markdown',
|
2026-04-07 23:38:23 +08:00
|
|
|
markdown: 'markdown',
|
2026-04-05 23:22:00 +08:00
|
|
|
txt: 'text',
|
|
|
|
|
json: 'json',
|
|
|
|
|
js: 'javascript',
|
2026-04-07 23:38:23 +08:00
|
|
|
jsx: 'javascript',
|
2026-04-05 23:22:00 +08:00
|
|
|
ts: 'typescript',
|
2026-04-07 23:38:23 +08:00
|
|
|
tsx: 'typescript',
|
2026-04-05 23:22:00 +08:00
|
|
|
css: 'css',
|
|
|
|
|
html: 'html',
|
2026-04-07 23:38:23 +08:00
|
|
|
htm: 'html',
|
2026-04-05 23:22:00 +08:00
|
|
|
py: 'python',
|
|
|
|
|
vue: 'vue',
|
|
|
|
|
xml: 'xml',
|
|
|
|
|
yaml: 'yaml',
|
|
|
|
|
yml: 'yaml',
|
|
|
|
|
csv: 'csv',
|
|
|
|
|
log: 'log',
|
2026-04-07 23:38:23 +08:00
|
|
|
sql: 'sql',
|
|
|
|
|
jpg: 'image',
|
|
|
|
|
jpeg: 'image',
|
|
|
|
|
png: 'image',
|
|
|
|
|
gif: 'image',
|
|
|
|
|
webp: 'image',
|
|
|
|
|
svg: 'image',
|
|
|
|
|
pdf: 'pdf',
|
|
|
|
|
doc: 'word',
|
|
|
|
|
docx: 'word',
|
|
|
|
|
ppt: 'ppt',
|
|
|
|
|
pptx: 'ppt',
|
|
|
|
|
xls: 'excel',
|
|
|
|
|
xlsx: 'excel',
|
|
|
|
|
zip: 'zip'
|
2026-04-05 23:22:00 +08:00
|
|
|
}
|
|
|
|
|
return iconMap[ext] || 'file'
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-07 23:38:23 +08:00
|
|
|
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 payload = await readFilePayload(file)
|
|
|
|
|
const created = createFile(parentId, file.name, payload.content || '', payload)
|
|
|
|
|
if (created) success += 1
|
|
|
|
|
else failed.push({ name: file.name, reason: error.value || '创建文件失败' })
|
|
|
|
|
} catch {
|
|
|
|
|
failed.push({ name: file.name, reason: '读取文件失败' })
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (success > 0 && parentId) {
|
|
|
|
|
const next = new Set(expandedIds.value)
|
|
|
|
|
next.add(parentId)
|
|
|
|
|
expandedIds.value = next
|
|
|
|
|
}
|
|
|
|
|
return { success, failed }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function getFileBlob(node) {
|
|
|
|
|
if (!node || node.type !== 'file') return null
|
|
|
|
|
if (node.blob instanceof Blob) return node.blob
|
|
|
|
|
if (typeof node.content === 'string') {
|
|
|
|
|
return new Blob([node.content], { type: inferMimeType(node.name, node.mimeType) })
|
|
|
|
|
}
|
|
|
|
|
if (typeof node.previewText === 'string' && node.previewText) {
|
|
|
|
|
return new Blob([node.previewText], { type: inferMimeType(node.name, node.mimeType) })
|
|
|
|
|
}
|
|
|
|
|
return null
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-05 23:22:00 +08:00
|
|
|
return {
|
|
|
|
|
tree,
|
|
|
|
|
selectedId,
|
|
|
|
|
expandedIds,
|
|
|
|
|
clipboard,
|
|
|
|
|
contextMenu,
|
|
|
|
|
error,
|
2026-04-07 23:38:23 +08:00
|
|
|
loading,
|
|
|
|
|
stats,
|
2026-04-05 23:22:00 +08:00
|
|
|
load,
|
|
|
|
|
createFile,
|
|
|
|
|
createFolder,
|
|
|
|
|
rename,
|
|
|
|
|
remove,
|
|
|
|
|
select,
|
|
|
|
|
toggleFolder,
|
|
|
|
|
copy,
|
|
|
|
|
cut,
|
|
|
|
|
paste,
|
|
|
|
|
canPaste,
|
|
|
|
|
clearClipboard,
|
|
|
|
|
getSelectedNode,
|
|
|
|
|
getBreadcrumbPath,
|
|
|
|
|
showContextMenu,
|
|
|
|
|
hideContextMenu,
|
|
|
|
|
getFileIcon,
|
|
|
|
|
getExtension,
|
2026-04-07 23:38:23 +08:00
|
|
|
getFileBlob,
|
|
|
|
|
isTextFile,
|
|
|
|
|
uploadFiles,
|
2026-04-05 23:22:00 +08:00
|
|
|
MAX_FILE_SIZE,
|
2026-04-07 23:38:23 +08:00
|
|
|
MAX_NODES
|
2026-04-05 23:22:00 +08:00
|
|
|
}
|
|
|
|
|
}
|