初始化模版工程

This commit is contained in:
Cloud Bot
2026-03-20 07:33:46 +00:00
commit 23717e0ecd
386 changed files with 51675 additions and 0 deletions

View 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;
}

View 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>
)
}

View 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`

View 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,
}
}

View 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
}

View 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

View 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)
}