Why: the adjudicated plan (D1-D4) calls for office/link previews with
loaders injected from the host, right-rail light preview alongside the
overlay viewer, and artifact deletion kept as an API capability with no
UI entry.
Evidence: previewsetup.ts injects docx (mammoth), xlsx (SheetJS, sheet
tabs, 200-row cap), pptx (pptx-preview full-fidelity; pptx2html was
dropped for a broken module entry — tXml case mismatch breaks Linux
builds — per the D2 fallback clause), and the /api/url-preview fetcher,
all via dynamic import: the main entry grows ~12KB while xlsx (430KB),
pptx (1.2MB) and jszip (98KB) load on first preview. DocViewer becomes a
FilePreview container (keeps text-family editing); Workbench gains a
preview tab with rail-preview state (store.railPreview); artifact chips
and panel rows gain a rail-preview action; the panel delete button is
removed while api.deleteArtifact and DELETE /api/artifacts/{id} remain.
tsc clean; vitest 33 passed; production build succeeds.
Impact: previews work end to end for the whole format matrix; deletion
stays plugin-reachable only.
Refs: task feature/file-preview-caps; ui fb76239; core 4d29301; backend
6d4ccfd.
69 lines
2.7 KiB
TypeScript
69 lines
2.7 KiB
TypeScript
// 文档查看器(4.4 → 预览能力收敛):格式渲染统一交给 @ui FilePreview
|
||
// (md/图片/pdf/代码/数据/docx/xlsx/pptx/URL 一套分派),这里只保留 OverlayHost
|
||
// 容器、工具栏与文本族的在线编辑保存。
|
||
import { useEffect, useState } from 'react'
|
||
import { api } from '../api'
|
||
import { store } from '../store'
|
||
import { OverlayHost, Icon, Btn, FilePreview } from '@ui'
|
||
import type { Artifact } from '../contracts'
|
||
import { humanSize } from '../messageparts'
|
||
|
||
export function DocViewer({ artifact }: { artifact: Artifact }) {
|
||
const [content, setContent] = useState<string | null>(null)
|
||
const [editing, setEditing] = useState(false)
|
||
const [dirty, setDirty] = useState(false)
|
||
|
||
const textKind = isTextKind(artifact)
|
||
useEffect(() => {
|
||
if (!textKind) return
|
||
setContent(null)
|
||
fetch(api.artifactContentUrl(artifact.id)).then(r => r.text()).then(setContent).catch(() => setContent('(读取失败)'))
|
||
}, [artifact.id])
|
||
|
||
const source = {
|
||
kind: 'artifact' as const,
|
||
id: artifact.id,
|
||
name: artifact.name,
|
||
mime: artifact.mime,
|
||
contentUrl: api.artifactContentUrl(artifact.id),
|
||
downloadUrl: api.artifactDownloadUrl(artifact.id),
|
||
}
|
||
|
||
return (
|
||
<OverlayHost title={artifact.name} icon="📄" onClose={() => store.viewArtifact(null)}>
|
||
<div className="docv">
|
||
<div className="docv-toolbar">
|
||
<span className="docv-meta">{artifact.kind}{humanSize(artifact.size) ? ` · ${humanSize(artifact.size)}` : ''}</span>
|
||
<span className="spacer" />
|
||
{dirty && <span className="docv-dirty">未保存</span>}
|
||
{textKind && (
|
||
<>
|
||
<Btn variant="ghost" icon={editing ? 'eye' : 'list'} onClick={() => setEditing(e => !e)}>{editing ? '预览' : '编辑'}</Btn>
|
||
{editing && <Btn variant="primary" icon="check" disabled={!dirty} onClick={async () => {
|
||
await api.saveArtifactContent(artifact.id, content ?? '')
|
||
setDirty(false)
|
||
store.toast('已保存')
|
||
}}>保存</Btn>}
|
||
</>
|
||
)}
|
||
<a className="btn v-ghost docv-dl" href={api.artifactDownloadUrl(artifact.id)} download={artifact.name}>
|
||
<Icon name="download" size={14} /> 下载
|
||
</a>
|
||
</div>
|
||
<div className="docv-body">
|
||
{editing && textKind ? (
|
||
<textarea className="search-input docv-editor" value={content ?? ''}
|
||
onChange={e => { setContent(e.target.value); setDirty(true) }} />
|
||
) : (
|
||
<FilePreview source={source} />
|
||
)}
|
||
</div>
|
||
</div>
|
||
</OverlayHost>
|
||
)
|
||
}
|
||
|
||
function isTextKind(a: Artifact): boolean {
|
||
return a.kind === 'document' && /\.(md|txt|csv|log|json)$/i.test(a.name)
|
||
}
|