import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Button, Dropdown, Empty, Menu, Message, Progress, Spin, Tooltip, } from '@arco-design/web-react' import { IconClose, IconDelete, IconDownload, IconEdit, IconEye, IconFile, IconFileAudio, IconFileImage, IconFilePdf, IconFileVideo, IconFolder, IconFolderAdd, IconLeft, IconPlus, IconRight, IconSave, IconShareAlt, IconUpload, } from '@arco-design/web-react/icon' import ReactMarkdown from 'react-markdown' import rehypeSanitize from 'rehype-sanitize' import remarkGfm from 'remark-gfm' import DOMPurify from 'dompurify' import type { ApiSession } from '../../api/client' import { createDocumentFolder, createDocumentShare, createMarkdownDocument, deleteDocument, downloadBlob, exportDocument, fetchDocumentBlob, fetchLegacySheetPreview, getDocument, listDocuments, updateDocument, uploadDocument, type DocumentDTO, type DocumentOpenIntent, type UploadControl, } from '../../api/documents' import type { Theme } from './project-types' import { DocumentMarkdownEditor } from './document-markdown-editor' type SaveStatus = 'clean' | 'dirty' | 'saving' | 'saved' | 'error' type DocumentTab = { key: string documentId?: string parentId?: string name: string extension: string mimeType: string markdown: string revision: number status: SaveStatus mode: 'edit' | 'preview' } type UploadItem = { id: string file: File progress: number status: 'queued' | 'uploading' | 'done' | 'error' | 'cancelled' error?: string } type PersistedDocumentDraft = Pick export function ProjectDocuments({ session, projectId, theme, intent, onIntentConsumed, }: { session: ApiSession projectId: string theme: Theme intent: DocumentOpenIntent | null onIntentConsumed: (intentId: string) => void }) { const [documents, setDocuments] = useState([]) const [tabs, setTabs] = useState([]) const [activeKey, setActiveKey] = useState('') const [expanded, setExpanded] = useState>(new Set()) const [loading, setLoading] = useState(true) const [treeWidth, setTreeWidth] = useState(260) const [treeCollapsed, setTreeCollapsed] = useState(false) const [mobilePane, setMobilePane] = useState<'tree' | 'editor'>('tree') const [contextMenu, setContextMenu] = useState<{ x: number; y: number; document: DocumentDTO } | null>(null) const [uploads, setUploads] = useState([]) const tabsRef = useRef([]) const timersRef = useRef(new Map()) const uploadControlsRef = useRef(new Map()) const fileInputRef = useRef(null) tabsRef.current = tabs const activeTab = tabs.find((tab) => tab.key === activeKey) const rootDocuments = useMemo(() => buildTree(documents), [documents]) const refreshTree = useCallback(async () => { const result = await listDocuments(session, projectId) setDocuments(result) return result }, [projectId, session]) const openDocument = useCallback(async (document: DocumentDTO) => { if (document.kind === 'folder') { setExpanded((current) => toggleSet(current, document.id)) return } const existing = tabsRef.current.find((tab) => tab.documentId === document.id) if (existing) { setActiveKey(existing.key) setMobilePane('editor') return } try { const detail = await getDocument(session, projectId, document.id) const next = tabFromDocument(detail) setTabs((current) => [...current, next]) setActiveKey(next.key) setMobilePane('editor') } catch (error) { Message.error(error instanceof Error ? error.message : '文档打开失败') } }, [projectId, session]) const createDraft = useCallback((name = '新建文件1.md', markdown = '', parentId = '') => { const key = `draft:${crypto.randomUUID()}` const tab: DocumentTab = { key, parentId, name, extension: '.md', mimeType: 'text/markdown; charset=utf-8', markdown, revision: 0, status: markdown ? 'dirty' : 'clean', mode: 'edit', } setTabs((current) => [...current, tab]) setActiveKey(key) setMobilePane('editor') return key }, []) useEffect(() => { let cancelled = false const timers = timersRef.current setTabs([]) setActiveKey('') setLoading(true) void refreshTree().then(async (items) => { if (cancelled) return const stored = readStoredTabs(projectId) const drafts = readStoredDrafts(projectId) const draftByDocumentID = new Map(drafts.filter((draft) => draft.documentId).map((draft) => [draft.documentId!, draft])) const restored: DocumentTab[] = [] for (const id of stored) { const item = items.find((document) => document.id === id && document.kind === 'file') if (!item) continue try { const detail = await getDocument(session, projectId, id) if (!cancelled) restored.push(restoreDocumentDraft(tabFromDocument(detail), draftByDocumentID.get(id))) } catch { // Missing tabs are discarded. } } restored.push(...drafts.filter((draft) => !draft.documentId)) if (cancelled) return if (restored.length > 0) { setTabs(restored) setActiveKey(restored[0].key) setMobilePane('editor') } else { createDraft() } }).catch((error) => { if (!cancelled) Message.error(error instanceof Error ? error.message : '文档树加载失败') }).finally(() => { if (!cancelled) setLoading(false) }) return () => { cancelled = true for (const timer of timers.values()) window.clearTimeout(timer) timers.clear() } }, [createDraft, projectId, refreshTree, session]) useEffect(() => { const ids = tabs.flatMap((tab) => tab.documentId ? [tab.documentId] : []) localStorage.setItem(storageKey(projectId), JSON.stringify(ids)) const drafts = tabs.filter((tab) => tab.status === 'dirty' || tab.status === 'saving' || tab.status === 'error') localStorage.setItem(draftStorageKey(projectId), JSON.stringify(drafts)) }, [projectId, tabs]) useEffect(() => { if (!intent || intent.projectId !== projectId) return if (intent.mode === 'open' && intent.documentId) { const document = documents.find((item) => item.id === intent.documentId) if (document) void openDocument(document) } else { createDraft(intent.suggestedName || '新建文件1.md', intent.initialMarkdown || '', intent.targetDirectoryId || '') } onIntentConsumed(intent.intentId) }, [createDraft, documents, intent, onIntentConsumed, openDocument, projectId]) const persistTab = useCallback(async (key: string, forcedMarkdown?: string) => { const tab = tabsRef.current.find((item) => item.key === key) if (!tab || tab.extension !== '.md' || tab.status === 'saving') return const markdown = forcedMarkdown ?? tab.markdown setTabs((current) => patchTab(current, key, { status: 'saving' })) try { if (!tab.documentId) { const created = await createMarkdownDocument(session, projectId, { name: tab.name, parentId: tab.parentId, markdown }) setDocuments((current) => [...current, created]) setTabs((current) => patchTab(current, key, { documentId: created.id, key: created.id, name: created.name, revision: created.revision, status: 'saved', })) setActiveKey((current) => current === key ? created.id : current) } else { const updated = await updateDocument(session, projectId, tab.documentId, { markdown, revision: tab.revision, }) setDocuments((current) => current.map((item) => item.id === updated.id ? updated : item)) setTabs((current) => patchTab(current, key, { markdown: updated.markdown ?? markdown, revision: updated.revision, status: 'saved', })) } } catch (error) { setTabs((current) => patchTab(current, key, { status: 'error' })) Message.error(error instanceof Error ? error.message : '自动保存失败,本地草稿已保留') } }, [projectId, session]) function changeMarkdown(key: string, markdown: string) { setTabs((current) => patchTab(current, key, { markdown, status: 'dirty' })) const previous = timersRef.current.get(key) if (previous) window.clearTimeout(previous) timersRef.current.set(key, window.setTimeout(() => { timersRef.current.delete(key) void persistTab(key, markdown) }, 800)) } async function addFolder(parentId = '') { const name = window.prompt('目录名称') if (!name?.trim()) return try { const created = await createDocumentFolder(session, projectId, name.trim(), parentId) setDocuments((current) => [...current, created]) } catch (error) { Message.error(error instanceof Error ? error.message : '目录创建失败') } } async function renameNode(document: DocumentDTO) { const name = window.prompt('重命名', document.name) if (!name?.trim() || name.trim() === document.name) return try { const updated = await updateDocument(session, projectId, document.id, { name: name.trim() }) setDocuments((current) => current.map((item) => item.id === updated.id ? updated : item)) setTabs((current) => current.map((tab) => tab.documentId === updated.id ? { ...tab, name: updated.name } : tab)) } catch (error) { Message.error(error instanceof Error ? error.message : '重命名失败') } } async function removeNode(document: DocumentDTO) { if (!window.confirm(`确定删除“${document.name}”${document.kind === 'folder' ? '及其全部内容' : ''}吗?`)) return try { await deleteDocument(session, projectId, document.id) const removed = descendantIds(documents, document.id) setDocuments((current) => current.filter((item) => !removed.has(item.id))) setTabs((current) => current.filter((tab) => !tab.documentId || !removed.has(tab.documentId))) if (activeTab?.documentId && removed.has(activeTab.documentId)) setActiveKey('') } catch (error) { Message.error(error instanceof Error ? error.message : '删除失败') } } async function moveNode(documentId: string, parentId: string) { const document = documents.find((item) => item.id === documentId) if (!document || document.parentId === (parentId || null)) return try { const updated = await updateDocument(session, projectId, documentId, { parentId }) setDocuments((current) => current.map((item) => item.id === updated.id ? updated : item)) } catch (error) { Message.error(error instanceof Error ? error.message : '移动失败') } } function enqueueFiles(files: FileList | File[]) { const accepted = Array.from(files).filter((file) => { if (file.size <= 50 * 1024 * 1024) return true Message.error(`${file.name} 超过 50 MB,未加入上传队列`) return false }) setUploads((current) => [ ...current, ...accepted.map((file) => ({ id: crypto.randomUUID(), file, progress: 0, status: 'queued' as const, })), ]) } useEffect(() => { const running = uploads.filter((item) => item.status === 'uploading').length const queued = uploads.filter((item) => item.status === 'queued').slice(0, Math.max(0, 3 - running)) for (const item of queued) { setUploads((current) => current.map((candidate) => candidate.id === item.id ? { ...candidate, status: 'uploading' } : candidate)) const control = uploadDocument(session, projectId, item.file, '', (progress) => { setUploads((current) => current.map((candidate) => candidate.id === item.id ? { ...candidate, progress } : candidate)) }) uploadControlsRef.current.set(item.id, control) void control.promise.then((document) => { setDocuments((current) => [...current, document]) setUploads((current) => current.map((candidate) => candidate.id === item.id ? { ...candidate, progress: 100, status: 'done' } : candidate)) void openDocument(document) }).catch((error) => { setUploads((current) => current.map((candidate) => candidate.id === item.id ? { ...candidate, status: 'error', error: error instanceof Error ? error.message : '上传失败' } : candidate)) }).finally(() => { uploadControlsRef.current.delete(item.id) window.setTimeout(() => { setUploads((current) => current.filter((candidate) => candidate.id !== item.id || candidate.status !== 'done')) }, 1800) }) } }, [openDocument, projectId, session, uploads]) function closeTab(tab: DocumentTab) { if ((tab.status === 'dirty' || tab.status === 'error') && !window.confirm(`“${tab.name}”尚未成功保存,仍要关闭吗?`)) return const index = tabs.findIndex((item) => item.key === tab.key) const next = tabs.filter((item) => item.key !== tab.key) if (tab.key !== activeKey) { setTabs(next) return } const replacement = next[Math.max(0, index - 1)] if (replacement) { setTabs(next) setActiveKey(replacement.key) return } const key = `draft:${crypto.randomUUID()}` setTabs([{ key, name: '新建文件1.md', extension: '.md', mimeType: 'text/markdown; charset=utf-8', markdown: '', revision: 0, status: 'clean', mode: 'edit', }]) setActiveKey(key) } async function downloadActive() { if (!activeTab?.documentId) return try { const blob = await fetchDocumentBlob(session, projectId, activeTab.documentId, true) downloadBlob(blob, activeTab.name) } catch (error) { Message.error(error instanceof Error ? error.message : '下载失败') } } async function shareActive(days: 1 | 7 | 30) { if (!activeTab?.documentId) { Message.warning('请先保存文档') return } try { const share = await createDocumentShare(session, projectId, activeTab.documentId, days) if (share.url) { try { await navigator.clipboard.writeText(share.url) Message.success(`${days} 天有效的预览链接已复制`) } catch { window.prompt('复制分享链接', share.url) } } } catch (error) { Message.error(error instanceof Error ? error.message : '分享链接创建失败') } } function beginResize(event: React.MouseEvent) { const startX = event.clientX const startWidth = treeWidth function move(moveEvent: MouseEvent) { setTreeWidth(Math.min(420, Math.max(210, startWidth + moveEvent.clientX - startX))) } function finish() { window.removeEventListener('mousemove', move) window.removeEventListener('mouseup', finish) } window.addEventListener('mousemove', move) window.addEventListener('mouseup', finish) } const uploadProgress = uploads.length === 0 ? 0 : Math.round(uploads.reduce((sum, item) => sum + item.progress, 0) / uploads.length) return (
setContextMenu(null)} onDragOver={(event) => event.preventDefault()} onDrop={(event) => { event.preventDefault() if (event.dataTransfer.files.length > 0) enqueueFiles(event.dataTransfer.files) }} > {!treeCollapsed && ( )} {!treeCollapsed &&
}
{treeCollapsed && ))}
{activeTab && (
{activeTab.extension === '.md' && (
)}
{!activeTab ? : activeTab.extension === '.md' && activeTab.mode === 'edit' ? ( changeMarkdown(activeTab.key, value)} onSave={() => void persistTab(activeTab.key)} /> ) : activeTab.extension === '.md' ? ( ) : activeTab.documentId ? ( ) : null}
{contextMenu && (
{contextMenu.document.kind === 'folder' && ( <> )}
)}
) } type TreeEntry = { document: DocumentDTO; children: TreeEntry[] } function TreeNode({ node, depth, expanded, activeDocumentId, onToggle, onOpen, onMove, onContextMenu, }: { node: TreeEntry depth: number expanded: Set activeDocumentId?: string onToggle: (id: string) => void onOpen: (document: DocumentDTO) => void onMove: (documentId: string, parentId: string) => void onContextMenu: (event: React.MouseEvent, document: DocumentDTO) => void }) { const { document } = node const open = expanded.has(document.id) return ( <> {document.kind === 'folder' && open && node.children.map((child) => ( ))} ) } function MarkdownPreview({ markdown }: { markdown: string }) { return (
{markdown}
) } function SmartPreview({ session, projectId, tab }: { session: ApiSession; projectId: string; tab: DocumentTab }) { const [url, setUrl] = useState('') const [text, setText] = useState('') const [html, setHtml] = useState('') const [rows, setRows] = useState([]) const [loading, setLoading] = useState(true) const [error, setError] = useState('') useEffect(() => { let active = true let objectURL = '' setLoading(true) setError('') const extension = tab.extension.toLowerCase() if (extension === '.xls') { void fetchLegacySheetPreview(session, projectId, tab.documentId!).then((preview) => { if (active) setRows(preview.rows) }).catch((reason) => { if (active) setError(reason instanceof Error ? reason.message : '预览加载失败') }).finally(() => { if (active) setLoading(false) }) return () => { active = false } } void fetchDocumentBlob(session, projectId, tab.documentId!).then(async (blob) => { if (!active) return if (isText(extension, tab.mimeType)) { setText(await blob.text()) } else if (extension === '.docx') { const mammoth = await import('mammoth') const result = await mammoth.default.convertToHtml({ arrayBuffer: await blob.arrayBuffer() }) if (active) setHtml(DOMPurify.sanitize(result.value)) } else if (extension === '.xlsx') { const { readSheet } = await import('read-excel-file/browser') const parsed = await readSheet(blob) if (active) setRows(parsed as unknown[][]) } else { const previewBlob = extension === '.svg' ? new Blob([DOMPurify.sanitize(await blob.text(), { USE_PROFILES: { svg: true, svgFilters: true } })], { type: 'image/svg+xml' }) : blob objectURL = URL.createObjectURL(previewBlob) setUrl(objectURL) } }).catch((reason) => { if (active) setError(reason instanceof Error ? reason.message : '预览加载失败') }).finally(() => { if (active) setLoading(false) }) return () => { active = false if (objectURL) URL.revokeObjectURL(objectURL) } }, [projectId, session, tab.documentId, tab.extension, tab.mimeType]) if (loading) return if (error) return if (tab.mimeType.startsWith('image/')) return
{tab.name}
if (tab.mimeType.startsWith('audio/')) return
if (tab.mimeType.startsWith('video/')) return
if (tab.extension === '.pdf') return