llm-in-text
A smart Markdown editor with local LLM intelligence.
@@ -641,4 +656,3 @@ const switchView = (view) => {
font-weight: 600;
}
-
diff --git a/src/components/TreeNodeItem.vue b/src/components/TreeNodeItem.vue
new file mode 100644
index 0000000..1f4650b
--- /dev/null
+++ b/src/components/TreeNodeItem.vue
@@ -0,0 +1,182 @@
+
+
+
+
+
+
+
+
+
+
+
{{ node.name }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/composables/useFileSystem.js b/src/composables/useFileSystem.js
index bb234d0..2d727d6 100644
--- a/src/composables/useFileSystem.js
+++ b/src/composables/useFileSystem.js
@@ -1,29 +1,188 @@
-import { ref, computed, watch } from 'vue'
+import { computed, ref } 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
+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
function generateId() {
- return Date.now().toString(36) + Math.random().toString(36).slice(2, 9)
+ return `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 10)}`
}
-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++
+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)
}
}
}
- traverse(nodes)
- return { files, folders }
+ sortChildren(roots)
+ return roots
}
function findNode(nodes, id) {
@@ -37,26 +196,6 @@ function findNode(nodes, id) {
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]
@@ -68,235 +207,360 @@ function getPath(nodes, id, path = []) {
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
+}
+
+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
+ }
+ }
+ 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
+ }
+ ]
+}
+
export function useFileSystem() {
- const tree = ref([])
+ const records = 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 clipboard = ref(null)
+ const contextMenu = ref(null)
const error = ref(null)
+ const loading = ref(false)
- function load() {
+ 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 }
+ })
+
+ async function load() {
+ loading.value = true
try {
- const stored = localStorage.getItem(STORAGE_KEY)
- if (stored) {
- tree.value = JSON.parse(stored)
+ 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
} 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()
+ records.value = nextRecords
}
+ error.value = null
} catch {
- tree.value = []
+ error.value = '读取本地文件失败,请刷新页面后重试'
+ records.value = []
+ } finally {
+ loading.value = false
}
}
- function save() {
- try {
- localStorage.setItem(STORAGE_KEY, JSON.stringify(tree.value))
- } catch {
- error.value = '存储空间不足'
- }
+ async function persistRecord(record) {
+ return withStore('readwrite', (store) => store.put(cloneRecord(record)))
}
- watch(tree, save, { deep: true })
+ async function deleteRecord(id) {
+ return withStore('readwrite', (store) => store.delete(id))
+ }
- function createFile(parentId, name, content = '') {
- const { files } = countFilesAndFolders(tree.value)
- if (files >= MAX_FILES) {
- error.value = `文件数量已达上限(${MAX_FILES}个)`
+ 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 = '更新目录时间失败'
+ })
+ }
+
+ function createFile(parentId, name, content = '', options = {}) {
+ if (records.value.length >= MAX_NODES) {
+ error.value = `文件数量不能超过 ${MAX_NODES} 个`
return false
}
- if (new Blob([content]).size > MAX_FILE_SIZE) {
- error.value = '文件大小不能超过 50MB'
+ const size = typeof options.size === 'number' ? options.size : new Blob([content]).size
+ if (size > MAX_FILE_SIZE) {
+ error.value = '单个文件不能超过 1GB'
return false
}
- const newFile = {
+ const now = Date.now()
+ const file = {
id: generateId(),
name,
type: 'file',
- content,
- parentId,
- createdAt: Date.now(),
- updatedAt: Date.now()
+ 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
}
- 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)
- }
+ records.value = [...records.value, file]
+ if (parentId) {
+ const next = new Set(expandedIds.value)
+ next.add(parentId)
+ expandedIds.value = next
}
+ selectedId.value = file.id
error.value = null
+ persistRecord(file).catch(() => {
+ error.value = '保存文件失败,可能是浏览器存储空间不足'
+ })
+ touchParent(parentId)
return true
}
function createFolder(parentId, name) {
- const { folders } = countFilesAndFolders(tree.value)
- if (folders >= MAX_FOLDERS) {
- error.value = `文件夹数量已达上限(${MAX_FOLDERS}个)`
+ if (records.value.length >= MAX_NODES) {
+ error.value = `目录项数量不能超过 ${MAX_NODES} 个`
return false
}
- const newFolder = {
+ const now = Date.now()
+ const folder = {
id: generateId(),
name,
type: 'folder',
- children: [],
- parentId,
- createdAt: Date.now(),
- updatedAt: Date.now()
+ parentId: parentId || null,
+ createdAt: now,
+ updatedAt: 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)
- }
+ records.value = [...records.value, folder]
+ if (parentId) {
+ const next = new Set(expandedIds.value)
+ next.add(parentId)
+ expandedIds.value = next
}
+ selectedId.value = folder.id
error.value = null
+ persistRecord(folder).catch(() => {
+ error.value = '保存文件夹失败'
+ })
+ touchParent(parentId)
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
+ 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
+ }
+ }
}
- return false
+ return [...ids]
}
function remove(id) {
- tree.value = removeNode(tree.value, id)
- if (selectedId.value === id) selectedId.value = null
+ 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
+ }
error.value = null
+ ids.forEach((currentId) => {
+ deleteRecord(currentId).catch(() => {
+ error.value = '删除文件失败'
+ })
+ })
}
-function select(id) {
- selectedId.value = id
-}
+ function select(id) {
+ selectedId.value = id
+ }
function toggleFolder(id) {
- const node = findNode(tree.value, id)
+ const node = records.value.find((item) => item.id === 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
+ 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) {
- clipboard.value = { mode: 'copy', node: JSON.parse(JSON.stringify(node)) }
+ if (!node) return
+ 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)) }
+ 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
+ }
+
+ 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)
+ }
}
}
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)
+ 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
}
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)
- }
+ node.updatedAt = Date.now()
+ persistRecord(node).catch(() => {
+ error.value = '移动文件失败'
+ })
+ touchParent(targetParentId)
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
+ return
}
+ const source = clipboard.value.node
+ if (!source) return
+ if (records.value.length >= MAX_NODES) {
+ error.value = `目录项数量不能超过 ${MAX_NODES} 个`
+ return
+ }
+ duplicateNode(source, targetParentId || null)
+ touchParent(targetParentId)
error.value = null
}
@@ -324,21 +588,20 @@ function select(id) {
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',
+ markdown: 'markdown',
txt: 'text',
json: 'json',
js: 'javascript',
+ jsx: 'javascript',
ts: 'typescript',
+ tsx: 'typescript',
css: 'css',
html: 'html',
+ htm: 'html',
py: 'python',
vue: 'vue',
xml: 'xml',
@@ -346,11 +609,64 @@ function select(id) {
yml: 'yaml',
csv: 'csv',
log: 'log',
- sql: 'sql'
+ 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'
}
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 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
+ }
+
return {
tree,
selectedId,
@@ -358,6 +674,8 @@ function select(id) {
clipboard,
contextMenu,
error,
+ loading,
+ stats,
load,
createFile,
createFolder,
@@ -376,8 +694,10 @@ function select(id) {
hideContextMenu,
getFileIcon,
getExtension,
+ getFileBlob,
+ isTextFile,
+ uploadFiles,
MAX_FILE_SIZE,
- MAX_FILES,
- MAX_FOLDERS
+ MAX_NODES
}
}
diff --git a/src/stores/settings.js b/src/stores/settings.js
index f52b1c8..32e7cc0 100644
--- a/src/stores/settings.js
+++ b/src/stores/settings.js
@@ -23,6 +23,8 @@ export const useSettingsStore = defineStore('settings', () => {
const backgroundType = ref('default') // 'default' | 'warm' | 'reading' | 'image'
const backgroundImage = ref('')
const backgroundOpacity = ref(0.2) // 0.05 - 0.50
+ // TTS Voice
+ const ttsInstruct = ref('')
// --- Getters ---
const uiLanguage = computed(() => {
@@ -70,6 +72,7 @@ export const useSettingsStore = defineStore('settings', () => {
}
if (data.backgroundImage) backgroundImage.value = data.backgroundImage
if (data.backgroundOpacity) backgroundOpacity.value = data.backgroundOpacity
+ if (typeof data.ttsInstruct === 'string') ttsInstruct.value = data.ttsInstruct
}
} catch {
// Failed to load settings, use defaults
@@ -89,6 +92,7 @@ export const useSettingsStore = defineStore('settings', () => {
backgroundType: backgroundType.value,
backgroundImage: backgroundImage.value,
backgroundOpacity: backgroundOpacity.value,
+ ttsInstruct: ttsInstruct.value,
}
localStorage.setItem('llm-in-text-settings', JSON.stringify(data))
} catch {
@@ -107,6 +111,7 @@ export const useSettingsStore = defineStore('settings', () => {
backgroundType.value = 'default'
backgroundImage.value = ''
backgroundOpacity.value = 0.2
+ ttsInstruct.value = ''
saveSettings()
}
@@ -122,6 +127,7 @@ export const useSettingsStore = defineStore('settings', () => {
backgroundType,
backgroundImage,
backgroundOpacity,
+ ttsInstruct,
],
() => {
saveSettings()
@@ -141,6 +147,7 @@ export const useSettingsStore = defineStore('settings', () => {
backgroundType,
backgroundImage,
backgroundOpacity,
+ ttsInstruct,
uiLanguage,
t,
initialMarkdown,
diff --git a/src/utils/api.js b/src/utils/api.js
index 2a85456..4b24f12 100644
--- a/src/utils/api.js
+++ b/src/utils/api.js
@@ -117,14 +117,14 @@ export async function fetchSuggestion(prefix, suffix, languageId, signal, apiUrl
}
}
-export async function fetchTTS(text, voice = 'af_bella', rate = 1.0, apiUrl = TTS_URL) {
+export async function fetchTTS(text, instruct = '', apiUrl = TTS_URL) {
const res = await fetch(apiUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-API-Key': API_KEY,
},
- body: JSON.stringify({ text, voice, rate, format: 'wav' }),
+ body: JSON.stringify({ text, instruct, speaker: 'Vivian', format: 'wav' }),
})
if (!res.ok) {
diff --git a/src/views/DocsView.vue b/src/views/DocsView.vue
index a69fe84..97e0c78 100644
--- a/src/views/DocsView.vue
+++ b/src/views/DocsView.vue
@@ -1,16 +1,11 @@
-
+
-
{
:node="fs.contextMenu.value?.node || null"
:can-paste="fs.canPaste()"
@close="fs.hideContextMenu()"
- @rename="(id) => { fs.hideContextMenu(); }"
+ @rename="fs.hideContextMenu()"
@delete="(id) => { fs.hideContextMenu(); handleDelete(id) }"
@copy="(id) => { fs.hideContextMenu(); fs.copy(id) }"
@cut="(id) => { fs.hideContextMenu(); fs.cut(id) }"
@@ -225,29 +190,24 @@ const isSelectedOfficeFile = computed(() => {
-
-
-
确认删除
-
确定要删除 {{ confirmDialog.name }} 吗?{{ confirmDialog.type === 'folder' ? '此操作将删除文件夹内的所有内容。' : '此操作不可撤销。' }}
+
+ 确定要删除 {{ confirmDialog.name }}
+ {{ confirmDialog.type === 'folder' ? ' 以及其中的全部内容' : '' }} 吗?
+
-
-
+
+
-
+
-
-
{{ fs.error }}
-
+
{{ fs.error.value }}
+
@@ -258,266 +218,220 @@ const isSelectedOfficeFile = computed(() => {
.docs-view {
width: 100%;
height: 100vh;
- display: flex;
- flex-direction: column;
overflow: hidden;
+ background: var(--github-bg);
}
.docs-layout {
- display: flex;
- flex: 1;
- overflow: hidden;
+ display: grid;
+ grid-template-columns: 320px minmax(0, 1fr);
+ height: 100%;
}
.docs-sidebar {
- width: 280px;
- min-width: 200px;
- max-width: 400px;
- height: 100%;
+ min-width: 0;
border-right: 1px solid var(--github-border);
background: var(--github-bg);
- flex-shrink: 0;
- overflow: hidden;
}
.docs-main {
- flex: 1;
+ min-width: 0;
display: flex;
flex-direction: column;
- overflow: hidden;
- min-width: 0;
}
.docs-toolbar {
display: flex;
align-items: center;
- gap: 8px;
- padding: 8px 16px;
+ justify-content: space-between;
+ gap: 16px;
+ min-height: 56px;
+ padding: 0 18px;
border-bottom: 1px solid var(--github-border);
- min-height: 44px;
- flex-shrink: 0;
+ background: var(--github-bg);
+}
+
+.toolbar-left,
+.toolbar-right,
+.toolbar-breadcrumb {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+}
+
+.toolbar-breadcrumb {
+ min-width: 0;
+ flex-wrap: wrap;
+}
+
+.sidebar-toggle,
+.toolbar-btn,
+.crumb-link {
+ border: none;
+ background: transparent;
+ cursor: pointer;
}
.sidebar-toggle {
- display: flex;
+ display: inline-flex;
align-items: center;
justify-content: center;
- width: 28px;
- height: 28px;
- padding: 0;
- border: 1px solid var(--panel-border);
- background: var(--app-bg);
- color: var(--muted-text);
- cursor: pointer;
- border-radius: 6px;
- flex-shrink: 0;
+ width: 34px;
+ height: 34px;
+ border: 1px solid var(--github-border);
+ border-radius: 8px;
+ color: var(--github-text-secondary);
+ background: var(--github-bg);
}
-.sidebar-toggle:hover {
- color: var(--app-text);
- border-color: var(--focus-ring);
+.sidebar-toggle:hover,
+.toolbar-btn:hover {
+ background: var(--github-hover);
}
-.breadcrumb-bar {
- flex: 1;
- display: flex;
+.crumb-link {
+ color: #0969da;
+ font-size: 14px;
+}
+
+.crumb-current {
+ color: var(--github-text);
+ font-size: 14px;
+ font-weight: 700;
+}
+
+.crumb-sep {
+ color: var(--github-text-secondary);
+}
+
+.storage-pill {
+ display: inline-flex;
align-items: center;
- gap: 4px;
- font-size: 13px;
- overflow: hidden;
- white-space: nowrap;
-}
-
-.breadcrumb-link {
- color: var(--focus-ring);
- cursor: pointer;
-}
-
-.breadcrumb-link:hover {
- text-decoration: underline;
-}
-
-.breadcrumb-current {
- color: var(--app-text);
- font-weight: 500;
-}
-
-.breadcrumb-sep {
- color: var(--muted-text);
-}
-
-.breadcrumb-root {
- color: var(--muted-text);
-}
-
-.toolbar-actions {
- display: flex;
- gap: 4px;
- flex-shrink: 0;
+ height: 32px;
+ padding: 0 12px;
+ border: 1px solid var(--github-border);
+ border-radius: 999px;
+ background: var(--github-hover);
+ color: var(--github-text-secondary);
+ font-size: 12px;
+ font-weight: 700;
}
.toolbar-btn {
- display: flex;
- align-items: center;
- gap: 6px;
- padding: 4px 12px;
- border: 1px solid var(--panel-border);
- background: var(--app-bg);
- color: var(--app-text);
- cursor: pointer;
- border-radius: 6px;
+ height: 32px;
+ padding: 0 12px;
+ border: 1px solid var(--github-border);
+ border-radius: 8px;
+ background: var(--github-bg);
+ color: var(--github-text);
font-size: 13px;
-}
-
-.toolbar-btn:hover {
- border-color: var(--focus-ring);
- color: var(--focus-ring);
-}
-
-.toolbar-btn.office-btn {
- border-color: var(--focus-ring);
- color: var(--focus-ring);
- background: rgba(59, 130, 246, 0.08);
-}
-
-.toolbar-btn.office-btn:hover {
- background: rgba(59, 130, 246, 0.15);
+ font-weight: 600;
}
.confirm-overlay {
position: fixed;
inset: 0;
- background: var(--overlay-bg);
display: flex;
align-items: center;
justify-content: center;
- z-index: 100001;
- animation: fadeIn 0.15s ease;
-}
-
-@keyframes fadeIn {
- from { opacity: 0; }
- to { opacity: 1; }
+ background: rgba(15, 23, 42, 0.35);
+ z-index: 10000;
}
.confirm-dialog {
- background: var(--panel-bg);
- backdrop-filter: blur(20px);
- -webkit-backdrop-filter: blur(20px);
- border: 1px solid var(--panel-border);
- border-radius: 12px;
- box-shadow: 0 16px 48px rgba(0, 0, 0, 0.2);
+ width: min(420px, calc(100vw - 32px));
padding: 24px;
- max-width: 400px;
- width: 90%;
- text-align: center;
- animation: slideUp 0.2s ease;
-}
-
-@keyframes slideUp {
- from { opacity: 0; transform: translateY(16px); }
- to { opacity: 1; transform: translateY(0); }
-}
-
-.confirm-icon {
- color: var(--danger-text);
- margin-bottom: 12px;
+ border: 1px solid var(--github-border);
+ border-radius: 16px;
+ background: var(--github-bg);
+ box-shadow: 0 28px 60px rgba(15, 23, 42, 0.22);
}
.confirm-dialog h3 {
- margin: 0 0 8px;
+ margin: 0 0 10px;
font-size: 1.1rem;
}
.confirm-dialog p {
- margin: 0 0 20px;
- color: var(--muted-text);
- font-size: 0.9rem;
- line-height: 1.5;
-}
-
-.confirm-dialog p strong {
- color: var(--app-text);
+ margin: 0;
+ color: var(--github-text-secondary);
+ line-height: 1.7;
}
.confirm-actions {
display: flex;
- gap: 8px;
- justify-content: center;
+ justify-content: flex-end;
+ gap: 10px;
+ margin-top: 18px;
}
-.btn-cancel,
-.btn-delete {
- padding: 8px 20px;
- border-radius: 6px;
- font-size: 14px;
+.btn {
+ height: 36px;
+ padding: 0 14px;
+ border: 1px solid var(--github-border);
+ border-radius: 10px;
+ font-weight: 700;
cursor: pointer;
- border: 1px solid var(--panel-border);
- font-weight: 500;
}
-.btn-cancel {
- background: var(--app-bg);
- color: var(--app-text);
+.btn-secondary {
+ background: var(--github-bg);
+ color: var(--github-text);
}
-.btn-cancel:hover {
- background: var(--ghost-code-bg);
-}
-
-.btn-delete {
- background: var(--danger-text);
+.btn-danger {
+ border-color: #cf222e;
+ background: #cf222e;
color: #fff;
- border-color: var(--danger-text);
-}
-
-.btn-delete:hover {
- opacity: 0.9;
}
.error-toast {
position: fixed;
- bottom: 24px;
left: 50%;
+ bottom: 24px;
transform: translateX(-50%);
- z-index: 100002;
- animation: slideUp 0.2s ease;
+ z-index: 10001;
}
.error-content {
display: flex;
align-items: center;
- gap: 8px;
- padding: 10px 16px;
- background: var(--danger-text);
+ gap: 12px;
+ max-width: min(720px, calc(100vw - 32px));
+ padding: 12px 16px;
+ border-radius: 12px;
+ background: #cf222e;
color: #fff;
- border-radius: 8px;
- font-size: 13px;
- box-shadow: 0 4px 12px rgba(220, 38, 38, 0.3);
+ box-shadow: 0 18px 40px rgba(207, 34, 46, 0.28);
}
.error-close {
- background: none;
border: none;
- color: #fff;
+ background: transparent;
+ color: inherit;
font-size: 18px;
cursor: pointer;
- padding: 0 4px;
- line-height: 1;
}
-.error-close:hover {
- opacity: 0.8;
-}
+@media (max-width: 900px) {
+ .docs-layout {
+ grid-template-columns: minmax(0, 1fr);
+ }
-@media (max-width: 768px) {
.docs-sidebar {
position: fixed;
- left: 0;
top: 0;
+ left: 0;
bottom: 0;
- z-index: 9000;
- box-shadow: 4px 0 24px rgba(0, 0, 0, 0.15);
+ z-index: 999;
+ width: min(88vw, 320px);
+ box-shadow: 20px 0 40px rgba(15, 23, 42, 0.16);
+ }
+
+ .docs-toolbar {
+ padding: 10px 14px;
+ align-items: flex-start;
+ flex-direction: column;
}
}