feat(documents): rebuild project document workbench

This commit is contained in:
2026-07-24 12:16:02 +08:00
parent 366136cee9
commit 77beaebb30
73 changed files with 5867 additions and 1264 deletions

View File

@@ -0,0 +1,821 @@
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 mammoth from 'mammoth'
import { readSheet as readXlsxSheet } from 'read-excel-file/browser'
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
}
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 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(tabFromDocument(detail))
} catch {
// Missing tabs are discarded.
}
}
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))
}, [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 result = await mammoth.convertToHtml({ arrayBuffer: await blob.arrayBuffer() })
if (active) setHtml(DOMPurify.sanitize(result.value))
} else if (extension === '.xlsx') {
const parsed = await readXlsxSheet(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 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 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)
}