初始化模版工程
This commit is contained in:
117
components/ppt-editor/FloatingToolbar.css
Normal file
117
components/ppt-editor/FloatingToolbar.css
Normal file
@@ -0,0 +1,117 @@
|
||||
/* ─── Floating Toolbar Container ─── */
|
||||
.ppt-floating-toolbar {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 8px;
|
||||
background: var(--glass-bg);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: 14px;
|
||||
box-shadow:
|
||||
0 12px 30px var(--editor-shadow),
|
||||
0 0 0 1px color-mix(in srgb, var(--border) 70%, transparent);
|
||||
user-select: none;
|
||||
backdrop-filter: blur(16px);
|
||||
}
|
||||
|
||||
/* ─── History (Undo / Redo) Section ─── */
|
||||
.ppt-floating-toolbar__history {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.ppt-floating-toolbar__btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: var(--muted-foreground);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.ppt-floating-toolbar__btn:hover:not(:disabled) {
|
||||
background: var(--accent);
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.ppt-floating-toolbar__btn:active:not(:disabled) {
|
||||
background: color-mix(in srgb, var(--accent) 86%, transparent);
|
||||
}
|
||||
|
||||
.ppt-floating-toolbar__btn:disabled {
|
||||
color: color-mix(in srgb, var(--muted-foreground) 70%, transparent);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* ─── Divider ─── */
|
||||
.ppt-floating-toolbar__divider {
|
||||
width: 1px;
|
||||
height: 22px;
|
||||
background: var(--border);
|
||||
margin: 0 4px;
|
||||
}
|
||||
|
||||
/* ─── Auto-saving indicator ─── */
|
||||
.ppt-floating-toolbar__auto-saving {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 14px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--primary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ─── Spinner animation ─── */
|
||||
.ppt-floating-toolbar__spinner {
|
||||
animation: ppt-toolbar-spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes ppt-toolbar-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* ─── Save button (idle + saving states) ─── */
|
||||
.ppt-floating-toolbar__save-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 16px;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
background: var(--primary);
|
||||
color: var(--primary-foreground);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.ppt-floating-toolbar__save-btn:hover:not(:disabled) {
|
||||
background: color-mix(in srgb, var(--primary) 88%, transparent);
|
||||
box-shadow: 0 2px 8px color-mix(in srgb, var(--primary) 35%, transparent);
|
||||
}
|
||||
|
||||
.ppt-floating-toolbar__save-btn:active:not(:disabled) {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
|
||||
/* Manual saving state — slightly transparent + disabled look */
|
||||
.ppt-floating-toolbar__save-btn--saving {
|
||||
background: color-mix(in srgb, var(--primary) 80%, transparent);
|
||||
opacity: 0.9;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
78
components/ppt-editor/FloatingToolbar.tsx
Normal file
78
components/ppt-editor/FloatingToolbar.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import React from 'react'
|
||||
import { Undo2, Redo2, Save, Loader2 } from 'lucide-react'
|
||||
import './FloatingToolbar.css'
|
||||
import { SaveType } from '../html-editor/hooks/useEditState'
|
||||
|
||||
interface FloatingToolbarProps {
|
||||
canUndo: boolean
|
||||
canRedo: boolean
|
||||
onUndo: () => void
|
||||
onRedo: () => void
|
||||
onSave: () => void
|
||||
isSaving: boolean
|
||||
saveType: SaveType
|
||||
}
|
||||
|
||||
export const FloatingToolbar: React.FC<FloatingToolbarProps> = ({
|
||||
canUndo,
|
||||
canRedo,
|
||||
onUndo,
|
||||
onRedo,
|
||||
onSave,
|
||||
isSaving,
|
||||
saveType,
|
||||
}) => {
|
||||
return (
|
||||
<div className="ppt-floating-toolbar">
|
||||
{/* Undo / Redo */}
|
||||
<div className="ppt-floating-toolbar__history">
|
||||
<button
|
||||
type="button"
|
||||
className="ppt-floating-toolbar__btn"
|
||||
disabled={!canUndo}
|
||||
onClick={onUndo}
|
||||
title="Undo"
|
||||
>
|
||||
<Undo2 size={18} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="ppt-floating-toolbar__btn"
|
||||
disabled={!canRedo}
|
||||
onClick={onRedo}
|
||||
title="Redo"
|
||||
>
|
||||
<Redo2 size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="ppt-floating-toolbar__divider" />
|
||||
|
||||
{/* Save area */}
|
||||
{isSaving && saveType === 'auto' ? (
|
||||
<div className="ppt-floating-toolbar__auto-saving">
|
||||
<Loader2 size={16} className="ppt-floating-toolbar__spinner" />
|
||||
<span>Auto-saving...</span>
|
||||
</div>
|
||||
) : isSaving && saveType === 'manual' ? (
|
||||
<button
|
||||
type="button"
|
||||
className="ppt-floating-toolbar__save-btn ppt-floating-toolbar__save-btn--saving"
|
||||
disabled
|
||||
>
|
||||
<Loader2 size={16} className="ppt-floating-toolbar__spinner" />
|
||||
<span>Saving...</span>
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="ppt-floating-toolbar__save-btn"
|
||||
onClick={onSave}
|
||||
>
|
||||
<Save size={16} />
|
||||
<span>Save</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
70
components/ppt-editor/README.md
Normal file
70
components/ppt-editor/README.md
Normal file
@@ -0,0 +1,70 @@
|
||||
# PPT Editor
|
||||
|
||||
PPT 幻灯片预览与编辑组件。通过 JSON URL 加载幻灯片列表,iframe 逐页渲染,支持元素级编辑、撤销/重做、自动保存。
|
||||
|
||||
## 用法
|
||||
|
||||
```tsx
|
||||
import PptPreview from "@/components/ppt-editor";
|
||||
|
||||
<PptPreview
|
||||
url="https://example.com/slides.json" // PPT JSON 数据 URL
|
||||
artifact={taskArtifact} // TaskArtifact 对象
|
||||
taskId="conversation-123" // 会话/任务 ID
|
||||
/>;
|
||||
```
|
||||
|
||||
> 外层需包裹 `<NovaKitProvider>`。
|
||||
|
||||
## Props
|
||||
|
||||
| 属性 | 类型 | 必填 | 说明 |
|
||||
| ---------- | ----------------------------- | ---- | -------------------------------- |
|
||||
| `url` | `string` | ✅ | PPT JSON 数据的远程 URL |
|
||||
| `artifact` | `TaskArtifact` | ✅ | 产物数据对象,包含文件路径等信息 |
|
||||
| `taskId` | `string \| null \| undefined` | ✅ | 会话/任务 ID,用于保存接口 |
|
||||
| `editable` | `boolean` | ❌ | 是否开启编辑模式。默认 `true` |
|
||||
|
||||
## PPT JSON 格式
|
||||
|
||||
`url` 返回的 JSON 须包含 `slide_list` 数组,每项:
|
||||
|
||||
```ts
|
||||
interface SliderListItem {
|
||||
id: string; // 幻灯片唯一 ID
|
||||
content: string; // HTML 内容(iframe srcDoc)
|
||||
file_name: string;
|
||||
file_type: string;
|
||||
path: string;
|
||||
title: string;
|
||||
}
|
||||
```
|
||||
|
||||
## 保存接口
|
||||
|
||||
内容变更后自动防抖 1 秒调用,也可手动保存:
|
||||
|
||||
```ts
|
||||
POST / v1 / super_agent / chat / slide;
|
||||
Body: {
|
||||
(task_id, slide_path, slide_content);
|
||||
}
|
||||
```
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
ppt-editor/
|
||||
├── index.tsx # 入口 PptPreview
|
||||
├── FloatingToolbar.tsx # 悬浮工具栏(Undo/Redo + Save 状态)
|
||||
├── FloatingToolbar.css
|
||||
├── hooks/
|
||||
│ ├── usePPTEditor.ts # 核心编辑 hook
|
||||
│ └── useSize.ts # 元素尺寸监听
|
||||
└── server/
|
||||
└── index.ts # savePPT 接口
|
||||
```
|
||||
|
||||
## 依赖
|
||||
|
||||
依赖 `html-editor` 模块:`PPTEditProvider`、`useIframeMode`、`useEditState`、`toolbar-web`。
|
||||
121
components/ppt-editor/hooks/usePPTEditor.ts
Normal file
121
components/ppt-editor/hooks/usePPTEditor.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
|
||||
import { useIframeMode } from '@/components/html-editor/hooks/useIframeMode'
|
||||
import type { TaskArtifact } from '@/components/nova-sdk'
|
||||
import { useDebounceFn } from 'ahooks'
|
||||
import { useEffect } from 'react'
|
||||
import { savePPT } from '../server'
|
||||
import { useEditState } from '@/components/html-editor/hooks/useEditState'
|
||||
import { usePPTEditContext } from '@/components/html-editor/context'
|
||||
|
||||
interface SliderListItem {
|
||||
content: string
|
||||
file_name: string
|
||||
file_type: string
|
||||
id: string
|
||||
path: string
|
||||
title: string
|
||||
}
|
||||
|
||||
export interface SlideJson {
|
||||
outline: Array<{
|
||||
id: string;
|
||||
summary: string;
|
||||
title: string;
|
||||
}>;
|
||||
project_dir: string;
|
||||
slide_ids: string[];
|
||||
slide_list: SliderListItem[]
|
||||
}
|
||||
|
||||
|
||||
export const usePPTEditor = (
|
||||
id: string,
|
||||
slideRef: React.RefObject<HTMLIFrameElement | null>,
|
||||
scale: number,
|
||||
slideJson: SlideJson | undefined,
|
||||
artifact: TaskArtifact,
|
||||
taskId: string | null | undefined,
|
||||
editable: boolean
|
||||
) => {
|
||||
const editorContext = usePPTEditContext()
|
||||
const editState = useEditState()
|
||||
|
||||
const saveHandler = async (type: 'auto' | 'manual' = 'auto', callback?: () => void) => {
|
||||
if (editState.isSaving) return
|
||||
const slideList = editorContext?.originalSlide.current || []
|
||||
if (!slideList || slideList.length === 0) return
|
||||
editState.setSaveType(type)
|
||||
editState.setIsSaving(true)
|
||||
try {
|
||||
console.log('保存变更, ppt页数', slideList.length, artifact)
|
||||
await savePPT({
|
||||
task_id: taskId!,
|
||||
slide_content: JSON.stringify({
|
||||
...slideJson,
|
||||
slide_list: slideList,
|
||||
}),
|
||||
slide_path: artifact.path,
|
||||
})
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
} finally {
|
||||
editState.setIsSaving(false)
|
||||
editState.setSaveType(null)
|
||||
setTimeout(() => {
|
||||
callback?.()
|
||||
}, 500);
|
||||
}
|
||||
}
|
||||
const handleSave = useDebounceFn(saveHandler, { wait: 1000 })
|
||||
|
||||
const manualSave = () => {
|
||||
handleSave.cancel()
|
||||
saveHandler('manual')
|
||||
}
|
||||
|
||||
const useIframeReturn = useIframeMode(
|
||||
id,
|
||||
slideRef,
|
||||
{
|
||||
onContentChange: content => {
|
||||
const originalSlide = editorContext?.originalSlide.current || []
|
||||
if (originalSlide && originalSlide.length) {
|
||||
const slide = originalSlide.find(s => s.id === id)
|
||||
if (slide) {
|
||||
console.log('内容变更写入', id)
|
||||
slide.content = content
|
||||
}
|
||||
handleSave.run('auto');
|
||||
}
|
||||
},
|
||||
onHistoryChange: (_state, instance) => {
|
||||
editState.handleHistoryChangeEvent(instance)
|
||||
},
|
||||
enabled: editable
|
||||
},
|
||||
scale,
|
||||
)
|
||||
const { selectedElement, editor, tipPosition } = useIframeReturn
|
||||
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
const hasActive = editor?.EditorRegistry.hasActiveEditor()
|
||||
const reWriteState = { ...useIframeReturn }
|
||||
if (hasActive && selectedElement) {
|
||||
// 有激活的实例且为当前的实例
|
||||
editorContext?.setState(reWriteState)
|
||||
} else if (!selectedElement && !hasActive) {
|
||||
// 失焦后清空所有状态,关闭tip
|
||||
reWriteState.position = null
|
||||
reWriteState.tipPosition = null
|
||||
editorContext?.setState(reWriteState)
|
||||
}
|
||||
}, [selectedElement, editor, tipPosition])
|
||||
|
||||
return {
|
||||
...editState,
|
||||
handleSave,
|
||||
manualSave,
|
||||
}
|
||||
}
|
||||
32
components/ppt-editor/hooks/useSize.ts
Normal file
32
components/ppt-editor/hooks/useSize.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { useState, useLayoutEffect } from 'react'
|
||||
|
||||
/**
|
||||
* 监听元素尺寸变化的 Hook
|
||||
*/
|
||||
export function useSize(target: React.RefObject<HTMLElement | null>) {
|
||||
const [size, setSize] = useState<{ width: number; height: number } | null>(null)
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const element = target.current
|
||||
if (!element) {
|
||||
console.log('useSize: element is null')
|
||||
return
|
||||
}
|
||||
|
||||
const resizeObserver = new ResizeObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
const { width, height } = entry.contentRect
|
||||
console.log('useSize: resize', width, height)
|
||||
setSize({ width, height })
|
||||
}
|
||||
})
|
||||
|
||||
resizeObserver.observe(element)
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect()
|
||||
}
|
||||
}, [target])
|
||||
|
||||
return size
|
||||
}
|
||||
270
components/ppt-editor/index.tsx
Normal file
270
components/ppt-editor/index.tsx
Normal file
@@ -0,0 +1,270 @@
|
||||
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'
|
||||
import { PPTEditProvider, type SlideJson } from '@/components/html-editor/context'
|
||||
import { PPTEditToolBar } from '@/components/html-editor/components/toolbar-web'
|
||||
import { usePPTEditor } from '@/components/ppt-editor/hooks/usePPTEditor'
|
||||
import { FloatingToolbar } from './FloatingToolbar'
|
||||
import type { TaskArtifact } from '../nova-sdk'
|
||||
|
||||
export interface SlideItem {
|
||||
content: string
|
||||
file_name: string
|
||||
file_type: string
|
||||
id: string
|
||||
path : string
|
||||
title: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export interface PptPreviewProps {
|
||||
/** conversationId */
|
||||
taskId: string | null | undefined
|
||||
/** PPT 文件的 URL */
|
||||
url: string
|
||||
/** TaskArtifact */
|
||||
artifact: TaskArtifact
|
||||
/** 是否可编辑 */
|
||||
editable?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* PPT 预览组件
|
||||
*/
|
||||
export const PptPreview = ({ url, artifact, taskId, editable = true}: PptPreviewProps) => {
|
||||
const [currentIndex, setCurrentIndex] = useState(0)
|
||||
const [slideList, setSlideList] = useState<SlideItem[]>([])
|
||||
const [sliderJson,setSliderJson] = useState<SlideJson>()
|
||||
const [loading, setLoading] = useState(true)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!url) return
|
||||
|
||||
setLoading(true)
|
||||
fetch(url)
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
const slides = data.slide_list || []
|
||||
setSlideList(slides)
|
||||
setSliderJson(data)
|
||||
})
|
||||
.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 h-full">
|
||||
<div className="text-6xl mb-4">📊</div>
|
||||
<p className="text-sm">暂无幻灯片内容</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<PPTEditProvider slides={slideList}>
|
||||
<div className='relative h-full' ref={containerRef}>
|
||||
{editable && <PPTEditToolBar containerRef={containerRef} />}
|
||||
<PptSlideViewer
|
||||
slideList={slideList}
|
||||
currentIndex={currentIndex}
|
||||
setCurrentIndex={setCurrentIndex}
|
||||
artifact={artifact}
|
||||
sliderJson={sliderJson}
|
||||
taskId={taskId}
|
||||
editable={editable}
|
||||
/>
|
||||
</div>
|
||||
</PPTEditProvider>
|
||||
)
|
||||
|
||||
}
|
||||
|
||||
function PptSlideViewer({
|
||||
slideList,
|
||||
currentIndex,
|
||||
setCurrentIndex,
|
||||
artifact,
|
||||
sliderJson,
|
||||
taskId,
|
||||
editable
|
||||
}: {
|
||||
slideList: SlideItem[]
|
||||
currentIndex: number
|
||||
setCurrentIndex: (index: number) => void
|
||||
artifact: TaskArtifact
|
||||
sliderJson: SlideJson | undefined
|
||||
taskId:string | null | undefined
|
||||
editable: boolean
|
||||
}) {
|
||||
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])
|
||||
|
||||
const editState = usePPTEditor(currentSlide.id, iframeRef, scale, sliderJson, artifact, taskId, editable)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* 悬浮工具栏 */}
|
||||
{editable && (
|
||||
<div style={{ width: 'max-content', margin: '0 auto', display: 'flex', justifyContent: 'center', padding: '12px 0 0', zIndex: 100, position: 'relative' }}>
|
||||
<FloatingToolbar
|
||||
canUndo={editState.canUndo}
|
||||
canRedo={editState.canRedo}
|
||||
onUndo={() => editState.undo.current()}
|
||||
onRedo={() => editState.redo.current()}
|
||||
onSave={editState.manualSave}
|
||||
isSaving={editState.isSaving}
|
||||
saveType={editState.saveType}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{/* 主预览区 */}
|
||||
<div className="flex flex-1 flex-col items-center overflow-hidden py-4">
|
||||
<ScrollArea className="flex-1 w-full px-4 rounded-lg">
|
||||
<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 shadow-[0_18px_42px_var(--editor-shadow)]"
|
||||
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 shadow-[0_10px_24px_var(--editor-shadow)]',
|
||||
currentIndex === index
|
||||
? 'border-primary shadow-md 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
|
||||
10
components/ppt-editor/server/index.ts
Normal file
10
components/ppt-editor/server/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { request } from '@/http/request';
|
||||
|
||||
|
||||
export function savePPT(content: {
|
||||
task_id: string
|
||||
slide_path: string
|
||||
slide_content: string
|
||||
}) {
|
||||
return request.post('/v1/super_agent/chat/slide', content)
|
||||
}
|
||||
Reference in New Issue
Block a user