Files
agent/apps/web_v1/src/pages/projects/project-documents.tsx

859 lines
33 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<DocumentTab, 'key' | 'documentId' | 'parentId' | 'name' | 'extension' | 'mimeType' | 'markdown' | 'revision' | 'status' | 'mode'>
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<DocumentDTO[]>([])
const [tabs, setTabs] = useState<DocumentTab[]>([])
const [activeKey, setActiveKey] = useState('')
const [expanded, setExpanded] = useState<Set<string>>(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<UploadItem[]>([])
const tabsRef = useRef<DocumentTab[]>([])
const timersRef = useRef(new Map<string, number>())
const uploadControlsRef = useRef(new Map<string, UploadControl>())
const fileInputRef = useRef<HTMLInputElement>(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 (
<section
className={`documents-canvas mobile-${mobilePane}`}
onClick={() => setContextMenu(null)}
onDragOver={(event) => event.preventDefault()}
onDrop={(event) => {
event.preventDefault()
if (event.dataTransfer.files.length > 0) enqueueFiles(event.dataTransfer.files)
}}
>
{!treeCollapsed && (
<aside className="documents-tree-pane" style={{ width: treeWidth }}>
<div className="documents-tree-tools">
<Tooltip content="新建目录"><Button type="text" icon={<IconFolderAdd />} onClick={() => void addFolder()} /></Tooltip>
<Tooltip content="新建 Markdown"><Button type="text" icon={<IconPlus />} onClick={() => createDraft()} /></Tooltip>
<span className="documents-tree-spacer" />
<Tooltip content="上传文件"><Button type="text" icon={<IconUpload />} onClick={() => fileInputRef.current?.click()} /></Tooltip>
<Tooltip content="折叠文件树"><Button type="text" icon={<IconLeft />} onClick={() => setTreeCollapsed(true)} /></Tooltip>
<input ref={fileInputRef} hidden multiple type="file" onChange={(event) => event.target.files && enqueueFiles(event.target.files)} />
</div>
<div
className="documents-tree"
onDragOver={(event) => event.preventDefault()}
onDrop={(event) => {
const id = event.dataTransfer.getData('application/x-senlin-document')
if (id) {
event.stopPropagation()
void moveNode(id, '')
}
}}
>
{loading ? <Spin /> : rootDocuments.length === 0 ? <Empty description="暂无文件" /> : rootDocuments.map((node) => (
<TreeNode
key={node.document.id}
node={node}
depth={0}
expanded={expanded}
activeDocumentId={activeTab?.documentId}
onToggle={(id) => setExpanded((current) => toggleSet(current, id))}
onOpen={(document) => void openDocument(document)}
onMove={(id, parent) => void moveNode(id, parent)}
onContextMenu={(event, document) => {
event.preventDefault()
setContextMenu({ x: event.clientX, y: event.clientY, document })
}}
/>
))}
</div>
{uploads.length > 0 && (
<div className="document-upload-summary">
<div><span> {uploads.filter((item) => item.status === 'done').length}/{uploads.length}</span><span>{uploadProgress}%</span></div>
<Progress percent={uploadProgress} showText={false} size="small" />
{uploads.filter((item) => item.status === 'uploading' || item.status === 'error').map((item) => (
<div className="document-upload-row" key={item.id}>
<span title={item.error}>{item.file.name}</span>
<span>{item.status === 'error' ? '失败' : `${item.progress}%`}</span>
{item.status === 'uploading' ? (
<button onClick={() => uploadControlsRef.current.get(item.id)?.cancel()}></button>
) : (
<button onClick={() => setUploads((current) => current.map((candidate) => candidate.id === item.id
? { ...candidate, progress: 0, status: 'queued', error: undefined }
: candidate))}></button>
)}
</div>
))}
</div>
)}
</aside>
)}
{!treeCollapsed && <div className="documents-resizer" onMouseDown={beginResize} />}
<main className="documents-editor-pane">
<div className="documents-tabs-bar">
{treeCollapsed && <Button type="text" icon={<IconRight />} onClick={() => setTreeCollapsed(false)} />}
<div className="documents-tabs">
{tabs.map((tab) => (
<button className={tab.key === activeKey ? 'document-tab active' : 'document-tab'} key={tab.key} onClick={() => setActiveKey(tab.key)}>
{fileIcon(tab.extension, tab.mimeType)}
<span>{tab.name}</span>
<i className={`save-dot ${tab.status}`} title={saveLabel(tab.status)} />
<IconClose onClick={(event) => { event.stopPropagation(); closeTab(tab) }} />
</button>
))}
</div>
{activeTab && (
<div className="documents-tab-actions">
{activeTab.extension === '.md' && (
<Tooltip content={activeTab.mode === 'edit' ? '预览' : '编辑'}>
<Button
type="text"
icon={activeTab.mode === 'edit' ? <IconEye /> : <IconEdit />}
onClick={() => setTabs((current) => patchTab(current, activeTab.key, { mode: activeTab.mode === 'edit' ? 'preview' : 'edit' }))}
/>
</Tooltip>
)}
{activeTab.extension === '.md' && (
<Tooltip content="保存"><Button type="text" icon={<IconSave />} onClick={() => void persistTab(activeTab.key)} /></Tooltip>
)}
{activeTab.documentId && activeTab.extension === '.md' ? (
<Dropdown
trigger="click"
droplist={<Menu onClickMenuItem={(format: string) => void exportDocument(session, projectId, activeTab.documentId!, format as 'md' | 'pdf' | 'docx')}>
<Menu.Item key="md"> Markdown</Menu.Item>
<Menu.Item key="pdf"> PDF</Menu.Item>
<Menu.Item key="docx"> Word</Menu.Item>
</Menu>}
>
<Button type="text" icon={<IconDownload />} />
</Dropdown>
) : activeTab.documentId ? (
<Tooltip content="下载原文件"><Button type="text" icon={<IconDownload />} onClick={() => void downloadActive()} /></Tooltip>
) : null}
<Dropdown
trigger="click"
droplist={<Menu onClickMenuItem={(days) => void shareActive(Number(days) as 1 | 7 | 30)}>
<Menu.Item key="1"> 1 </Menu.Item>
<Menu.Item key="7"> 7 </Menu.Item>
<Menu.Item key="30"> 30 </Menu.Item>
</Menu>}
>
<Tooltip content="分享预览"><Button type="text" icon={<IconShareAlt />} /></Tooltip>
</Dropdown>
</div>
)}
</div>
<div className="documents-mobile-switch">
<Button icon={<IconLeft />} onClick={() => setMobilePane('tree')}></Button>
</div>
<div className="document-editor-body">
{!activeTab ? <Empty description="没有打开的文件" /> : activeTab.extension === '.md' && activeTab.mode === 'edit' ? (
<DocumentMarkdownEditor
value={activeTab.markdown}
dark={theme === 'dark'}
onChange={(value) => changeMarkdown(activeTab.key, value)}
onSave={() => void persistTab(activeTab.key)}
/>
) : activeTab.extension === '.md' ? (
<MarkdownPreview markdown={activeTab.markdown} />
) : activeTab.documentId ? (
<SmartPreview session={session} projectId={projectId} tab={activeTab} />
) : null}
</div>
</main>
{contextMenu && (
<div className="document-context-menu" style={{ left: contextMenu.x, top: contextMenu.y }}>
{contextMenu.document.kind === 'folder' && (
<>
<button onClick={() => void addFolder(contextMenu.document.id)}><IconFolderAdd /></button>
<button onClick={() => createDraft('新建文件1.md', '', contextMenu.document.id)}><IconPlus /> Markdown</button>
</>
)}
<button onClick={() => void renameNode(contextMenu.document)}><IconEdit /></button>
<button className="danger" onClick={() => void removeNode(contextMenu.document)}><IconDelete /></button>
</div>
)}
</section>
)
}
type TreeEntry = { document: DocumentDTO; children: TreeEntry[] }
function TreeNode({
node,
depth,
expanded,
activeDocumentId,
onToggle,
onOpen,
onMove,
onContextMenu,
}: {
node: TreeEntry
depth: number
expanded: Set<string>
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 (
<>
<button
className={document.id === activeDocumentId ? 'document-tree-row active' : 'document-tree-row'}
style={{ paddingLeft: 8 + depth * 16 }}
draggable
onDragStart={(event) => event.dataTransfer.setData('application/x-senlin-document', document.id)}
onDragOver={(event) => { if (document.kind === 'folder') event.preventDefault() }}
onDrop={(event) => {
if (document.kind !== 'folder') return
event.preventDefault()
event.stopPropagation()
const id = event.dataTransfer.getData('application/x-senlin-document')
if (id) onMove(id, document.id)
}}
onContextMenu={(event) => onContextMenu(event, document)}
onClick={() => document.kind === 'folder' ? onToggle(document.id) : onOpen(document)}
onDoubleClick={() => onOpen(document)}
>
{document.kind === 'folder' ? <span className="tree-chevron">{open ? '⌄' : ''}</span> : <span className="tree-chevron" />}
{document.kind === 'folder' ? <IconFolder /> : fileIcon(document.extension, document.mimeType)}
<span>{document.name}</span>
</button>
{document.kind === 'folder' && open && node.children.map((child) => (
<TreeNode
key={child.document.id}
node={child}
depth={depth + 1}
expanded={expanded}
activeDocumentId={activeDocumentId}
onToggle={onToggle}
onOpen={onOpen}
onMove={onMove}
onContextMenu={onContextMenu}
/>
))}
</>
)
}
function MarkdownPreview({ markdown }: { markdown: string }) {
return (
<article className="document-markdown-preview">
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeSanitize]}>{markdown}</ReactMarkdown>
</article>
)
}
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<unknown[][]>([])
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 <Spin />
if (error) return <Empty description={error} />
if (tab.mimeType.startsWith('image/')) return <div className="document-media-preview"><img src={url} alt={tab.name} /></div>
if (tab.mimeType.startsWith('audio/')) return <div className="document-media-preview"><audio src={url} controls /></div>
if (tab.mimeType.startsWith('video/')) return <div className="document-media-preview"><video src={url} controls /></div>
if (tab.extension === '.pdf') return <iframe className="document-pdf-preview" src={url} title={tab.name} />
if (tab.extension === '.docx') return <article className="document-office-preview" dangerouslySetInnerHTML={{ __html: html }} />
if (tab.extension === '.xlsx' || tab.extension === '.xls') return (
<div className="document-sheet-preview"><table><tbody>{rows.map((row, rowIndex) => (
<tr key={rowIndex}>{row.map((cell, cellIndex) => <td key={cellIndex}>{String(cell ?? '')}</td>)}</tr>
))}</tbody></table></div>
)
if (text) return <pre className="document-text-preview">{text}</pre>
return <Empty description="此格式暂不支持在线预览,可使用右上角下载" />
}
function buildTree(documents: DocumentDTO[]) {
const entries = new Map<string, TreeEntry>()
for (const document of documents) entries.set(document.id, { document, children: [] })
const roots: TreeEntry[] = []
for (const entry of entries.values()) {
const parent = entry.document.parentId ? entries.get(entry.document.parentId) : null
if (parent) parent.children.push(entry)
else roots.push(entry)
}
const sort = (items: TreeEntry[]) => {
items.sort((left, right) => {
if (left.document.kind !== right.document.kind) return left.document.kind === 'folder' ? -1 : 1
return left.document.name.localeCompare(right.document.name, 'zh-CN', { numeric: true, sensitivity: 'base' })
})
items.forEach((item) => sort(item.children))
}
sort(roots)
return roots
}
function tabFromDocument(document: DocumentDTO): DocumentTab {
return {
key: document.id,
documentId: document.id,
parentId: document.parentId ?? '',
name: document.name,
extension: document.extension.toLowerCase(),
mimeType: document.mimeType,
markdown: document.markdown ?? '',
revision: document.revision,
status: 'clean',
mode: document.extension.toLowerCase() === '.md' ? 'edit' : 'preview',
}
}
function patchTab(tabs: DocumentTab[], key: string, patch: Partial<DocumentTab>) {
return tabs.map((tab) => tab.key === key ? { ...tab, ...patch } : tab)
}
function toggleSet(current: Set<string>, value: string) {
const next = new Set(current)
if (next.has(value)) next.delete(value)
else next.add(value)
return next
}
function descendantIds(documents: DocumentDTO[], root: string) {
const ids = new Set([root])
let changed = true
while (changed) {
changed = false
for (const document of documents) {
if (document.parentId && ids.has(document.parentId) && !ids.has(document.id)) {
ids.add(document.id)
changed = true
}
}
}
return ids
}
function storageKey(projectId: string) {
return `senlin:documents:tabs:${projectId}`
}
function draftStorageKey(projectId: string) {
return `senlin:documents:drafts:${projectId}`
}
function readStoredTabs(projectId: string): string[] {
try {
const value: unknown = JSON.parse(localStorage.getItem(storageKey(projectId)) ?? '[]')
return Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : []
} catch {
return []
}
}
function readStoredDrafts(projectId: string): PersistedDocumentDraft[] {
try {
const value: unknown = JSON.parse(localStorage.getItem(draftStorageKey(projectId)) ?? '[]')
if (!Array.isArray(value)) return []
return value.filter(isPersistedDocumentDraft)
} catch {
return []
}
}
function isPersistedDocumentDraft(value: unknown): value is PersistedDocumentDraft {
if (!value || typeof value !== 'object') return false
const draft = value as Partial<PersistedDocumentDraft>
return typeof draft.key === 'string' && typeof draft.name === 'string' && typeof draft.extension === 'string' &&
typeof draft.mimeType === 'string' && typeof draft.markdown === 'string' && typeof draft.revision === 'number' &&
(draft.status === 'dirty' || draft.status === 'saving' || draft.status === 'error') &&
(draft.mode === 'edit' || draft.mode === 'preview') &&
(draft.documentId === undefined || typeof draft.documentId === 'string') &&
(draft.parentId === undefined || typeof draft.parentId === 'string')
}
function restoreDocumentDraft(tab: DocumentTab, draft?: PersistedDocumentDraft): DocumentTab {
if (!draft) return tab
return { ...tab, markdown: draft.markdown, revision: draft.revision, status: draft.status === 'saving' ? 'dirty' : draft.status, mode: draft.mode }
}
function saveLabel(status: SaveStatus) {
return { clean: '已保存', dirty: '未保存', saving: '保存中', saved: '已保存', error: '保存失败' }[status]
}
function fileIcon(extension: string, mimeType: string) {
if (extension === '.pdf') return <IconFilePdf />
if (mimeType.startsWith('image/')) return <IconFileImage />
if (mimeType.startsWith('audio/')) return <IconFileAudio />
if (mimeType.startsWith('video/')) return <IconFileVideo />
return <IconFile />
}
function isText(extension: string, mimeType: string) {
return mimeType.startsWith('text/') || [
'.txt', '.json', '.yaml', '.yml', '.xml', '.csv', '.ts', '.tsx', '.js', '.jsx', '.go', '.py', '.css', '.html',
].includes(extension)
}