From 156cd26dafe8ef66c54781d1231ca28e41268c4a Mon Sep 17 00:00:00 2001 From: HouYunFei <1844025705@qq.com> Date: Fri, 31 Oct 2025 23:25:20 +0800 Subject: [PATCH] =?UTF-8?q?feat(ai-ppt):=20=E5=AE=9E=E7=8E=B0AI=E7=94=9F?= =?UTF-8?q?=E6=88=90PPT=E7=9A=84=E6=B5=81=E5=BC=8F=E5=8A=A0=E8=BD=BD?= =?UTF-8?q?=E4=B8=8E=E5=8A=A8=E7=94=BB=E5=B1=95=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增loading类型消息提示,用于显示PPT生成进度 - 在AIPPTDialog中添加生成过程的loading状态管理 -优化readStream方法为异步递归调用,支持逐步解析数据块 - 完善消息组件Message.vue,增加loading图标及旋转动画 - 引入主存储模块到useAIPPT hook,支持元素选中状态控制- 开发progressiveAddSlides函数实现幻灯片渐进式添加效果 - 调整AIPPT核心逻辑,按顺序逐个渲染幻灯片及其内部元素 - 添加元素添加间隔延时,提升用户视觉体验和交互反馈 --- src/components/Message.vue | 15 +++++++++ src/hooks/useAIPPT.ts | 57 +++++++++++++++++++++++++++++--- src/utils/message.ts | 4 ++- src/views/Editor/AIPPTDialog.vue | 48 ++++++++++++++------------- 4 files changed, 96 insertions(+), 28 deletions(-) diff --git a/src/components/Message.vue b/src/components/Message.vue index 878dacd76..a322fcdcf 100644 --- a/src/components/Message.vue +++ b/src/components/Message.vue @@ -16,6 +16,7 @@ +
{{ title }}
@@ -44,6 +45,7 @@ const { IconCloseOne, IconInfo, IconCloseSmall, + IconRefresh, } = icons const props = withDefaults(defineProps<{ @@ -179,4 +181,17 @@ defineExpose({ margin-top: -45px; } } + +@keyframes spin { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } +} + +.loading-icon { + animation: spin 1s linear infinite; +} \ No newline at end of file diff --git a/src/hooks/useAIPPT.ts b/src/hooks/useAIPPT.ts index 0f6ad053e..0f2730a1f 100644 --- a/src/hooks/useAIPPT.ts +++ b/src/hooks/useAIPPT.ts @@ -2,7 +2,7 @@ import { ref } from 'vue' import { nanoid } from 'nanoid' import type { ImageClipDataRange, PPTElement, PPTImageElement, PPTShapeElement, PPTTextElement, Slide, TextType } from '@/types/slides' import type { AIPPTSlide } from '@/types/AIPPT' -import { useSlidesStore } from '@/store' +import { useSlidesStore, useMainStore } from '@/store' import useAddSlidesOrElements from './useAddSlidesOrElements' import useSlideHandler from './useSlideHandler' @@ -15,6 +15,7 @@ interface ImgPoolItem { export default () => { const slidesStore = useSlidesStore() + const mainStore = useMainStore() const { addSlidesFromData } = useAddSlidesOrElements() const { isEmptySlide } = useSlideHandler() @@ -248,7 +249,56 @@ export default () => { imgPool.value = imgs } - const AIPPT = (templateSlides: Slide[], _AISlides: AIPPTSlide[], imgs?: ImgPoolItem[]) => { + /** + * 渐进式添加幻灯片,模拟流式生成效果 + * @param slides + * @param delayPerElement + */ + const progressiveAddSlides = async (slides: Slide[], delayPerElement = 200) => { + for (let i = 0; i < slides.length; i++) { + const slide = slides[i] + const elements = slide.elements + // 先添加一个空的幻灯片(无元素) + const emptySlide: Slide = { + ...slide, + elements: [] + } + // 判断是否应该覆盖还是追加 + const shouldSetSlides = i === 0 && isEmptySlide.value + if (shouldSetSlides) { + slidesStore.setSlides([emptySlide]) + } + else { + // 临时切换到最后一个幻灯片,确保在正确位置添加 + const lastSlideIndex = slidesStore.slides.length - 1 + slidesStore.updateSlideIndex(lastSlideIndex) + // 现在在最后添加空幻灯片 + addSlidesFromData([emptySlide]) + } + + // 确定目标幻灯片索引(总是在最后一张) + const targetSlideIndex = slidesStore.slides.length - 1 + + // 逐个添加元素 + for (const element of elements) { + // 直接操作目标幻灯片,不依赖当前选中的幻灯片 + const targetSlide = slidesStore.slides[targetSlideIndex] + targetSlide.elements.push(element) + // 选中当前添加的元素,产生选中效果 + mainStore.setActiveElementIdList([element.id]) + // 等待一段时间,让用户看到选中效果 + await new Promise(resolve => setTimeout(resolve, delayPerElement)) + } + // 清除选中状态 + mainStore.setActiveElementIdList([]) + // 幻灯片之间稍微长一点的延迟 + if (i < slides.length - 1) { + await new Promise(resolve => setTimeout(resolve, delayPerElement * 2)) + } + } + } + + const AIPPT = async (templateSlides: Slide[], _AISlides: AIPPTSlide[], imgs?: ImgPoolItem[]) => { slidesStore.updateSlideIndex(slidesStore.slides.length - 1) if (imgs) imgPool.value = imgs @@ -533,8 +583,7 @@ export default () => { }) } } - if (isEmptySlide.value) slidesStore.setSlides(slides) - else addSlidesFromData(slides) + await progressiveAddSlides(slides) } return { diff --git a/src/utils/message.ts b/src/utils/message.ts index 84a4ce382..64f84533c 100644 --- a/src/utils/message.ts +++ b/src/utils/message.ts @@ -2,7 +2,7 @@ import { createVNode, render, type AppContext } from 'vue' import MessageComponent from '@/components/Message.vue' export interface MessageOptions { - type?: 'info' | 'success' | 'warning' | 'error' + type?: 'info' | 'success' | 'warning' | 'error' | 'loading' title?: string message?: string duration?: number @@ -24,6 +24,7 @@ export interface Message { success: MessageFn error: MessageFn warning: MessageFn + loading: MessageFn closeAll: () => void _context?: AppContext | null } @@ -93,6 +94,7 @@ message.success = (msg: string, options?: MessageTypeOptions) => message({ ...op message.info = (msg: string, options?: MessageTypeOptions) => message({ ...options, type: 'info', message: msg }) message.warning = (msg: string, options?: MessageTypeOptions) => message({ ...options, type: 'warning', message: msg }) message.error = (msg: string, options?: MessageTypeOptions) => message({ ...options, type: 'error', message: msg }) +message.loading = (msg: string, options?: MessageTypeOptions) => message({ ...options, type: 'loading', message: msg, duration: 0}) message.closeAll = function() { for (let i = instances.length - 1; i >= 0; i--) { diff --git a/src/views/Editor/AIPPTDialog.vue b/src/views/Editor/AIPPTDialog.vue index 2f4d947b2..039b5d56d 100644 --- a/src/views/Editor/AIPPTDialog.vue +++ b/src/views/Editor/AIPPTDialog.vue @@ -225,7 +225,9 @@ const createOutline = async () => { } const createPPT = async (template?: { slides: Slide[], theme: SlideTheme }) => { + mainStore.setAIPPTDialogState(false) loading.value = true + const loadingMessage = message.loading('PPT生成中,请耐心等待 ...') if (overwrite.value) resetSlides() @@ -249,30 +251,30 @@ const createPPT = async (template?: { slides: Slide[], theme: SlideTheme }) => { const reader: ReadableStreamDefaultReader = stream.body.getReader() const decoder = new TextDecoder('utf-8') - const readStream = () => { - reader.read().then(({ done, value }) => { - if (done) { - loading.value = false - mainStore.setAIPPTDialogState(false) - slidesStore.setTheme(templateTheme) - return - } - - const chunk = decoder.decode(value, { stream: true }) - try { - const text = chunk.replace('```json', '').replace('```', '').trim() - if (text) { - const slide: AIPPTSlide = JSON.parse(chunk) - AIPPT(templateSlides, [slide]) - } - } - catch (err) { - // eslint-disable-next-line - console.error(err) - } + const readStream = async () => { + const { done, value } = await reader.read() + if (done) { + loading.value = false + loadingMessage.close() + message.success('PPT生成完成!') + slidesStore.setTheme(templateTheme) + return + } - readStream() - }) + const chunk = decoder.decode(value, { stream: true }) + try { + const text = chunk.replace('```json', '').replace('```', '').trim() + if (text) { + const slide: AIPPTSlide = JSON.parse(chunk) + await AIPPT(templateSlides, [slide]) + } + } + catch (err) { + // eslint-disable-next-line + console.error(err) + loadingMessage.close() + } + readStream() } readStream() }