feat: add Docker support and update backend dependencies

- Introduced `requirements.docker.txt` for Docker-specific dependencies.
- Updated `requirements.txt` to include `psycopg[binary]` and `python-multipart`.
- Enhanced test suite in `test_main_endpoints.py` to cover document CRUD operations.
- Modified `docker-compose.yml` to include PostgreSQL and frontend services.
- Added Nginx configuration for reverse proxying API requests.
- Refactored file handling in Vue components to support new document storage backend.
- Created new utility functions in `docsApi.js` for document management.
- Updated configuration to support new API endpoints for document operations.
- Adjusted Vite configuration to proxy API requests to the local backend.
This commit is contained in:
“ydy0615”
2026-06-06 17:18:15 +08:00
parent 81f711ef0b
commit b55af1eff0
20 changed files with 1413 additions and 407 deletions
+176 -349
View File
@@ -1,66 +1,18 @@
import { computed, ref } from 'vue'
import {
createDocFolder,
createDocTextFile,
deleteDocNode,
fetchDocBlob,
fetchDocNodes,
replaceDocBlob,
updateDocNode,
uploadDocFile,
} from '../utils/docsApi'
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, 10)}`
}
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 request
try {
request = handler(store)
} catch (error) {
reject(error)
return
}
if (request && typeof request.onsuccess === 'function') {
request.onsuccess = () => resolve(request.result)
request.onerror = () => reject(request.error || new Error('本地数据库操作失败'))
} else {
transaction.oncomplete = () => resolve(request)
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() : ''
@@ -147,7 +99,6 @@ function inferMimeType(name, fallback = '') {
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')
@@ -169,11 +120,8 @@ function buildTree(records) {
continue
}
const parent = map.get(record.parentId)
if (parent?.type === 'folder') {
parent.children.push(current)
} else {
roots.push(current)
}
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
@@ -182,9 +130,7 @@ function buildTree(records) {
const sortChildren = (nodes) => {
nodes.sort(sorter)
for (const node of nodes) {
if (node.type === 'folder' && Array.isArray(node.children)) {
sortChildren(node.children)
}
if (node.type === 'folder' && Array.isArray(node.children)) sortChildren(node.children)
}
}
sortChildren(roots)
@@ -220,94 +166,15 @@ function estimateRecordSize(record) {
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 (isBinaryExtension(ext)) {
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 cloneNode(node) {
return JSON.parse(JSON.stringify(node))
}
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
}
]
}
let singleton = null
export function useFileSystem() {
if (singleton) return singleton
const records = ref([])
const selectedId = ref(null)
const expandedIds = ref(new Set())
@@ -315,6 +182,7 @@ export function useFileSystem() {
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(() => {
@@ -329,161 +197,123 @@ export function useFileSystem() {
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 function load() {
loading.value = true
try {
const nextRecords = await withStore('readonly', (store) => store.getAll())
if (!Array.isArray(nextRecords) || nextRecords.length === 0) {
const seed = createWelcomeRecords()
await Promise.all(seed.map((record) => persistRecord(record)))
records.value = seed
} else {
records.value = nextRecords
}
records.value = await fetchDocNodes()
error.value = null
} catch {
error.value = '读取本地文件失败,请刷新页面后重试'
} catch (err) {
error.value = err instanceof Error && err.message ? err.message : '读取文档空间失败,请稍后重试'
records.value = []
} finally {
loading.value = false
}
}
async function persistRecord(record) {
return withStore('readwrite', (store) => store.put(cloneRecord(record)))
}
async function deleteRecord(id) {
return withStore('readwrite', (store) => store.delete(id))
}
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 = {}) {
async function createFile(parentId, name, content = '') {
if (records.value.length >= MAX_NODES) {
error.value = `文件数量不能超过 ${MAX_NODES}`
return false
}
const size = typeof options.size === 'number' ? options.size : new Blob([content]).size
if (size > MAX_FILE_SIZE) {
error.value = '单个文件不能超过 1GB'
try {
const node = await createDocTextFile(name, parentId || null, content)
upsertRecord(node)
if (parentId) {
const next = new Set(expandedIds.value)
next.add(parentId)
expandedIds.value = next
}
selectedId.value = node.id
error.value = null
return true
} catch (err) {
error.value = err instanceof Error && err.message ? err.message : '创建文件失败'
return false
}
const now = Date.now()
const file = {
id: generateId(),
name,
type: 'file',
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
}
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 updateFile(id, nextValue, options = {}) {
async function updateFile(id, nextValue, options = {}) {
const file = records.value.find((item) => item.id === id && item.type === 'file')
if (!file) return false
const nextName = options.name || file.name
const isBlobValue = nextValue instanceof Blob
const nextContent = isBlobValue ? (options.content ?? '') : String(options.content ?? nextValue ?? '')
const nextSize = typeof options.size === 'number'
? options.size
: isBlobValue
? nextValue.size
: new Blob([nextContent]).size
if (nextSize > MAX_FILE_SIZE) {
error.value = '单个文件不能超过 1GB'
try {
let node
if (nextValue instanceof Blob) {
const filename = options.name || file.name
const upload = nextValue instanceof File
? nextValue
: new File([nextValue], filename, { type: options.mimeType || nextValue.type || file.mimeType || '' })
node = await replaceDocBlob(id, upload)
} else {
const content = String(options.content ?? nextValue ?? '')
node = await updateDocNode(id, {
name: options.name || file.name,
content,
})
}
upsertRecord(node)
clearBlobCache(id)
error.value = null
return true
} catch (err) {
error.value = err instanceof Error && err.message ? err.message : '保存文件失败'
return false
}
file.name = nextName
file.updatedAt = Date.now()
file.mimeType = inferMimeType(nextName, options.mimeType || (isBlobValue ? nextValue.type : file.mimeType))
file.size = nextSize
file.storageKind = options.storageKind || (isBlobValue ? 'blob' : 'text')
file.content = nextContent
file.previewText = options.previewText ?? (file.storageKind === 'text' ? nextContent : '')
file.isTruncatedPreview = Boolean(options.isTruncatedPreview)
file.blob = isBlobValue ? nextValue : null
records.value = [...records.value]
error.value = null
persistRecord(file).catch(() => {
error.value = '保存文件失败,可能是浏览器存储空间不足'
})
touchParent(file.parentId)
return true
}
function createFolder(parentId, name) {
async function createFolder(parentId, name) {
if (records.value.length >= MAX_NODES) {
error.value = `目录项数量不能超过 ${MAX_NODES}`
return false
}
const now = Date.now()
const folder = {
id: generateId(),
name,
type: 'folder',
parentId: parentId || null,
createdAt: now,
updatedAt: now
try {
const node = await createDocFolder(name, parentId || null)
upsertRecord(node)
if (parentId) {
const next = new Set(expandedIds.value)
next.add(parentId)
expandedIds.value = next
}
selectedId.value = node.id
error.value = null
return true
} catch (err) {
error.value = err instanceof Error && err.message ? err.message : '创建文件夹失败'
return false
}
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) {
async function rename(id, newName) {
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
try {
const updated = await updateDocNode(id, { name: newName })
upsertRecord(updated)
clearBlobCache(id)
error.value = null
return true
} catch (err) {
error.value = err instanceof Error && err.message ? err.message : '重命名失败'
return false
}
}
function collectDescendantIds(id) {
@@ -501,23 +331,21 @@ export function useFileSystem() {
return [...ids]
}
function remove(id) {
async function remove(id) {
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
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
}
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) {
@@ -536,19 +364,13 @@ export function useFileSystem() {
function copy(id) {
const node = findNode(tree.value, id)
if (!node) return
clipboard.value = {
mode: 'copy',
node: JSON.parse(JSON.stringify(node))
}
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
}
clipboard.value = { mode: 'cut', nodeId: node.id }
}
function isDescendantOf(sourceId, targetParentId) {
@@ -560,58 +382,58 @@ export function useFileSystem() {
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 = '复制文件失败'
})
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 || []) {
duplicateNode(child, clonedId)
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
}
function paste(targetParentId) {
async 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) {
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
}
if (node.id === targetParentId || (targetParentId && isDescendantOf(node.id, targetParentId))) {
error.value = '不能移动到自身或子目录中'
const source = clipboard.value.node
if (!source) return
if (records.value.length >= MAX_NODES) {
error.value = `目录项数量不能超过 ${MAX_NODES}`
return
}
node.parentId = targetParentId || null
node.updatedAt = Date.now()
persistRecord(node).catch(() => {
error.value = '移动文件失败'
})
touchParent(targetParentId)
clipboard.value = null
await duplicateNode(source, targetParentId || null)
error.value = null
return
} catch (err) {
error.value = err instanceof Error && err.message ? err.message : '粘贴失败'
}
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
}
function canPaste() {
@@ -699,12 +521,14 @@ export function useFileSystem() {
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: '读取文件失败' })
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) {
@@ -715,19 +539,20 @@ export function useFileSystem() {
return { success, failed }
}
function getFileBlob(node) {
async 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 (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
}
if (typeof node.previewText === 'string' && node.previewText) {
return new Blob([node.previewText], { type: inferMimeType(node.name, node.mimeType) })
}
return null
const blob = await fetchDocBlob(node.id)
blobCache.set(node.id, blob)
return blob
}
return {
singleton = {
tree,
selectedId,
expandedIds,
@@ -761,4 +586,6 @@ export function useFileSystem() {
MAX_FILE_SIZE,
MAX_NODES
}
return singleton
}