初始化模版工程
This commit is contained in:
211
components/nova-sdk/task-panel/Preview/PptPreview.tsx
Normal file
211
components/nova-sdk/task-panel/Preview/PptPreview.tsx
Normal file
@@ -0,0 +1,211 @@
|
||||
import React, { useState, useRef } from 'react'
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react'
|
||||
import { cn } from '@/utils/cn'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area'
|
||||
import { useSize } from '../../hooks/useSize'
|
||||
|
||||
export interface SlideItem {
|
||||
content: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export interface PptPreviewProps {
|
||||
/** PPT 文件的 URL */
|
||||
url: string
|
||||
}
|
||||
|
||||
/**
|
||||
* PPT 预览组件
|
||||
*/
|
||||
export function PptPreview({ url }: PptPreviewProps) {
|
||||
const [currentIndex, setCurrentIndex] = useState(0)
|
||||
const [slideList, setSlideList] = useState<SlideItem[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!url) return
|
||||
|
||||
setLoading(true)
|
||||
fetch(url)
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
const slides = data.slide_list || []
|
||||
setSlideList(slides)
|
||||
})
|
||||
.catch(() => setSlideList([]))
|
||||
.finally(() => setLoading(false))
|
||||
}, [url])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex-1 flex flex-col items-center justify-center p-8 text-muted-foreground h-full">
|
||||
<div className="w-8 h-8 border-2 border-muted border-t-primary rounded-full animate-spin" />
|
||||
<span className="text-sm mt-2">加载中...</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!slideList || slideList.length === 0) {
|
||||
return (
|
||||
<div className="flex-1 flex flex-col items-center justify-center p-8 text-muted-foreground">
|
||||
<div className="text-6xl mb-4">📊</div>
|
||||
<p className="text-sm">暂无幻灯片内容</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return <PptSlideViewer slideList={slideList} currentIndex={currentIndex} setCurrentIndex={setCurrentIndex} />
|
||||
}
|
||||
|
||||
function PptSlideViewer({
|
||||
slideList,
|
||||
currentIndex,
|
||||
setCurrentIndex
|
||||
}: {
|
||||
slideList: SlideItem[]
|
||||
currentIndex: number
|
||||
setCurrentIndex: (index: number) => void
|
||||
}) {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null)
|
||||
const size = useSize(containerRef)
|
||||
const [iframeHeight, setIframeHeight] = useState(720)
|
||||
const [loadState, setLoadState] = useState<'loading' | 'loaded' | 'error'>('loading')
|
||||
|
||||
const currentSlide = slideList[currentIndex]
|
||||
const scale = size ? size.width / 1280 : 1
|
||||
|
||||
const handleIframeLoad = (event: React.SyntheticEvent<HTMLIFrameElement>) => {
|
||||
const iframe = event.currentTarget
|
||||
try {
|
||||
const actualHeight = iframe.contentDocument?.documentElement.scrollHeight
|
||||
if (actualHeight && actualHeight > 0) {
|
||||
setIframeHeight(actualHeight)
|
||||
}
|
||||
setLoadState('loaded')
|
||||
} catch (error) {
|
||||
console.warn('Cannot access iframe content:', error)
|
||||
setLoadState('loaded')
|
||||
}
|
||||
}
|
||||
|
||||
const handleIframeError = () => {
|
||||
setLoadState('error')
|
||||
}
|
||||
|
||||
// 切换幻灯片时重置加载状态
|
||||
React.useEffect(() => {
|
||||
setLoadState('loading')
|
||||
setIframeHeight(720)
|
||||
}, [currentIndex])
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* 主预览区 */}
|
||||
<div className="flex flex-1 flex-col items-center overflow-hidden p-4">
|
||||
<ScrollArea className="flex-1 w-full">
|
||||
<div className="flex min-h-full w-full justify-center">
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="relative w-full flex-none overflow-hidden rounded-lg border border-solid border-border bg-card"
|
||||
style={{
|
||||
height: scale ? `${iframeHeight * scale}px` : '720px',
|
||||
}}
|
||||
>
|
||||
{loadState === 'loading' && (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center bg-card/92 backdrop-blur-sm">
|
||||
<div className="w-8 h-8 border-2 border-muted border-t-primary rounded-full animate-spin" />
|
||||
<p className="text-muted-foreground text-sm mt-2">加载中...</p>
|
||||
</div>
|
||||
)}
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
srcDoc={currentSlide.content}
|
||||
className={cn(
|
||||
'w-[1280px] border-0 origin-top-left transition-opacity duration-300 ease-in-out',
|
||||
loadState === 'loading' ? 'opacity-0' : 'opacity-100'
|
||||
)}
|
||||
title={`Slide ${currentIndex + 1}`}
|
||||
sandbox="allow-same-origin allow-scripts"
|
||||
onLoad={handleIframeLoad}
|
||||
onError={handleIframeError}
|
||||
style={{
|
||||
height: `${iframeHeight}px`,
|
||||
transform: `scale(${scale})`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
{/* 页码和导航 */}
|
||||
<div className="mt-4 flex shrink-0 items-center gap-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => setCurrentIndex(Math.max(0, currentIndex - 1))}
|
||||
disabled={currentIndex === 0}
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
</Button>
|
||||
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
{currentIndex + 1} / {slideList.length}
|
||||
</span>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => setCurrentIndex(Math.min(slideList.length - 1, currentIndex + 1))}
|
||||
disabled={currentIndex === slideList.length - 1}
|
||||
>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 缩略图列表 */}
|
||||
{slideList.length > 1 && (
|
||||
<div className="shrink-0 border-t border-border/80 bg-secondary/35 p-3">
|
||||
<ScrollArea className="w-full">
|
||||
<div className="flex gap-2 pb-1 w-max">
|
||||
{slideList.map((slide, index) => (
|
||||
<button
|
||||
key={index}
|
||||
type="button"
|
||||
className={cn(
|
||||
'relative aspect-video w-48 shrink-0 overflow-hidden rounded-lg border-2 bg-card transition-all',
|
||||
currentIndex === index
|
||||
? 'border-primary ring-2 ring-primary/20'
|
||||
: 'border-transparent hover:border-muted'
|
||||
)}
|
||||
onClick={() => setCurrentIndex(index)}
|
||||
>
|
||||
<div className="w-full h-full overflow-hidden">
|
||||
<iframe
|
||||
srcDoc={slide.content}
|
||||
className="w-full h-full border-0 pointer-events-none origin-top-left"
|
||||
title={`Thumbnail ${index + 1}`}
|
||||
sandbox="allow-same-origin"
|
||||
style={{
|
||||
transform: 'scale(1)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="absolute inset-0 bg-transparent pointer-events-none" />
|
||||
<div className="pointer-events-none absolute bottom-0 left-0 right-0 bg-black/55 py-1 text-center text-xs text-white">
|
||||
{index + 1}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<ScrollBar orientation="horizontal" />
|
||||
</ScrollArea>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default PptPreview
|
||||
Reference in New Issue
Block a user