feat(ui): add file explorer, TTS UI, views and routing
Add a file tree UI and corresponding composable for local file management. Introduce TTS menu and player components for voice synthesis integration. Add new EditorView and DocsView routes and update SettingsPanel view switching. Enhance Mermaid plugin with improved styling and action buttons.
This commit is contained in:
@@ -0,0 +1,362 @@
|
||||
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)
|
||||
}
|
||||
} 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
|
||||
}
|
||||
|
||||
function select(id) {
|
||||
selectedId.value = id
|
||||
const node = findNode(tree.value, id)
|
||||
if (node && node.type === 'folder') {
|
||||
toggleFolder(id)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user