feat: 支持 AI 生成 PPT

This commit is contained in:
pipipi-pikachu 2025-01-05 15:08:39 +08:00
parent 0f5ce81539
commit a09dcc4c34
18 changed files with 264 additions and 72 deletions

View File

@ -21,7 +21,7 @@
# ✨ Highlights
1. <b>Easy Development</b>: Built with Vue 3.x and TypeScript, it does not rely on UI component libraries and avoids third-party components as much as possible. This makes styling customization easier and functionality extension more convenient.
2. <b>User Friendly</b>: It offers a context menu available everywhere, dozens of keyboard shortcuts, and countless editing detail optimizations, striving to replicate a desktop application-level experience.
3. <b>Feature Rich</b>: Supports most of the commonly used elements and functionalities found in PowerPoint, supports exporting in various formats, and offers basic editing and previewing on mobile devices.
3. <b>Feature Rich</b>: Supports most of the commonly used elements and functionalities found in PowerPoint, supports generate PPT by AI, supports exporting in various formats, and offers basic editing and previewing on mobile devices.
# 🚀 Installation
@ -40,6 +40,7 @@ npm run dev
- Export local files (PPTX, JSON, images, PDF)
- Import and export pptist files
- Print
- AI PPT
### Slide Page Editing
- Add/delete pages
- Copy/paste pages

View File

@ -9,7 +9,7 @@
# ✨ 项目特色
1. 易开发:基于 Vue3.x + TypeScript 构建不依赖UI组件库尽量避免第三方组件样式定制更轻松、功能扩展更方便。
2. 易使用:随处可用的右键菜单、几十种快捷键、无数次编辑细节打磨,力求还原桌面应用级体验。
3. 功能丰富:支持 PPT 中的大部分常用元素和功能,支持多种格式导出、支持移动端基础编辑和预览...
3. 功能丰富:支持 PPT 中的大部分常用元素和功能,支持AI生成PPT、支持多种格式导出、支持移动端基础编辑和预览...
# 👀 前排提示
@ -36,6 +36,7 @@ npm run dev
- 导出本地文件PPTX、JSON、图片、PDF
- 导入导出特有 .pptist 文件
- 打印
- AI生成PPT
### 幻灯片页面编辑
- 页面添加、删除
- 页面顺序调整

View File

@ -7,7 +7,7 @@
<meta name="renderer" content="webkit">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="PPTist基于 Vue3.x + TypeScript 的在线演示文稿(幻灯片)应用,还原了大部分 Office PowerPoint 常用功能实现在线PPT的编辑、演示。支持导出PPT文件。" />
<meta name="keywords" content="pptist,ppt,powerpoint,office powerpoint,在线ppt,幻灯片,演示文稿,ppt在线制作,Vue3,TypeScript" />
<meta name="keywords" content="pptist,ppt,powerpoint,office powerpoint,在线ppt,幻灯片,演示文稿,ppt在线制作,aippt" />
<title>PPTist - 在线演示文稿</title>
<style>
@ -25,8 +25,8 @@
align-items: center;
}
.first-screen-loading-spinner {
width: 42px;
height: 42px;
width: 36px;
height: 36px;
border: 3px solid #d14424;
border-top-color: transparent;
border-radius: 50%;

View File

@ -1,6 +1,6 @@
{
"name": "pptist",
"version": "1.0.0",
"version": "2.0.0",
"private": true,
"type": "module",
"scripts": {

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -4,7 +4,7 @@
<Editor v-else-if="_isPC" />
<Mobile v-else />
</template>
<Loading text="数据初始化中,请稍等 ..." v-else />
<FullscreenSpin tip="数据初始化中,请稍等 ..." v-else loading :mask="false" />
</template>
@ -22,7 +22,7 @@ import api from '@/services'
import Editor from './views/Editor/index.vue'
import Screen from './views/Screen/index.vue'
import Mobile from './views/Mobile/index.vue'
import Loading from './components/Loading.vue'
import FullscreenSpin from '@/components/FullscreenSpin.vue'
const _isPC = isPC()
@ -38,10 +38,10 @@ if (import.meta.env.MODE !== 'development') {
}
onMounted(async () => {
api.getMockData('slides').then((slides: Slide[]) => {
api.getFileData('slides').then((slides: Slide[]) => {
slidesStore.setSlides(slides)
})
api.getMockData('layouts').then((slides: Slide[]) => {
api.getFileData('layouts').then((slides: Slide[]) => {
slidesStore.setLayouts(slides)
})

View File

@ -1,5 +1,5 @@
<template>
<div class="fullscreen-spin" v-if="loading">
<div class="fullscreen-spin" :class="{ 'mask': mask }" v-if="loading">
<div class="spin">
<div class="spinner"></div>
<div class="text">{{tip}}</div>
@ -10,9 +10,11 @@
<script lang="ts" setup>
withDefaults(defineProps<{
loading?: boolean
mask?: boolean
tip?: string
}>(), {
loading: false,
mask: true,
tip: '',
})
</script>
@ -28,7 +30,10 @@ withDefaults(defineProps<{
display: flex;
justify-content: center;
align-items: center;
background-color: rgba($color: #f1f1f1, $alpha: .7);
&.mask {
background-color: rgba($color: #f1f1f1, $alpha: .7);
}
}
.spin {
width: 200px;

View File

@ -16,6 +16,7 @@
:disabled="disabled"
:value="value"
:placeholder="placeholder"
:maxlength="maxlength"
@input="$event => handleInput($event)"
@focus="$event => handleFocus($event)"
@blur="$event => handleBlur($event)"
@ -36,6 +37,7 @@ withDefaults(defineProps<{
disabled?: boolean
placeholder?: string
simple?: boolean
maxlength?: number
}>(), {
disabled: false,
placeholder: '',

View File

@ -1,51 +0,0 @@
<template>
<div class="loading">
<div class="loading-spinner"></div>
<div class="loading-text">{{ text }}</div>
</div>
</template>
<script lang="ts" setup>
withDefaults(defineProps<{
text?: string
}>(), {
text: '正在加载中,请稍等 ...',
})
</script>
<style lang="scss" scoped>
.loading {
width: 200px;
height: 200px;
position: fixed;
top: 50%;
left: 50%;
margin-top: -100px;
margin-left: -100px;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
.loading-spinner {
width: 42px;
height: 42px;
border: 3px solid #d14424;
border-top-color: transparent;
border-radius: 50%;
animation: spinner .8s linear infinite;
}
.loading-text {
margin-top: 20px;
color: #d14424;
}
}
@keyframes spinner {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
</style>

View File

@ -1,4 +1,3 @@
import api from '@/services'
import { nanoid } from 'nanoid'
import type { PPTElement, PPTShapeElement, PPTTextElement, Slide, TextType } from '@/types/slides'
import type { AIPPTSlide } from '@/types/AIPPT'
@ -158,10 +157,14 @@ export default () => {
const { addSlidesFromData } = useAddSlidesOrElements()
const { isEmptySlide } = useSlideHandler()
const AIPPT = async () => {
const templateSlides: Slide[] = await api.getMockData('template').then(ret => ret.slides)
const _AISlides: AIPPTSlide[] = await api.getMockData('AIPPT')
const getMdContent = (content: string) => {
const regex = /```markdown([^```]*)```/
const match = content.match(regex)
if (match) return match[1].trim()
return content.replace('```markdown', '').replace('```', '')
}
const AIPPT = (templateSlides: Slide[], _AISlides: AIPPTSlide[]) => {
const AISlides: AIPPTSlide[] = []
for (const template of _AISlides) {
if (template.type === 'content') {
@ -411,5 +414,6 @@ export default () => {
return {
AIPPT,
getMdContent,
}
}

View File

@ -36,6 +36,7 @@ import {
AlignHorizontally,
BringToFront,
SendToBack,
Send,
AlignTextLeft,
AlignTextRight,
AlignTextCenter,
@ -166,6 +167,7 @@ export const icons: Icons = {
IconAlignHorizontally: AlignHorizontally,
IconBringToFront: BringToFront,
IconSendToBack: SendToBack,
IconSend: Send,
IconAlignTextLeft: AlignTextLeft,
IconAlignTextRight: AlignTextRight,
IconAlignTextCenter: AlignTextCenter,

View File

@ -1,7 +1,22 @@
import axios from './config'
const SERVER_URL = (import.meta.env.MODE === 'development') ? '/api' : 'https://server.pptist.cn'
const ASSET_URL = 'https://asset.pptist.cn'
export default {
getMockData(filename: string): Promise<any> {
return axios.get(`./mocks/${filename}.json`)
},
getFileData(filename: string): Promise<any> {
return axios.get(`${ASSET_URL}/data/${filename}.json`)
},
AIPPT_Outline(content: string) {
return axios.post(`${SERVER_URL}/tools/aippt_outline`, { content })
},
AIPPT(content: string) {
return axios.post(`${SERVER_URL}/tools/aippt`, { content })
},
}

View File

@ -36,6 +36,7 @@ export interface MainState {
showSearchPanel: boolean
showNotesPanel: boolean
showMarkupPanel: boolean
showAIPPTDialog: boolean
}
const nanoid = customAlphabet('0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz')
@ -71,6 +72,7 @@ export const useMainStore = defineStore('main', {
showSearchPanel: false, // 打开查找替换面板
showNotesPanel: false, // 打开批注面板
showMarkupPanel: false, // 打开类型标注面板
showAIPPTDialog: false, // 打开AIPPT创建窗口
}),
getters: {
@ -200,5 +202,9 @@ export const useMainStore = defineStore('main', {
setMarkupPanelState(show: boolean) {
this.showMarkupPanel = show
},
setAIPPTDialogState(show: boolean) {
this.showAIPPTDialog = show
},
},
})

View File

@ -0,0 +1,175 @@
<template>
<div class="aippt-dialog">
<div class="header">
<span class="title">AIPPT</span>
<span class="subtite" v-if="outline">检查确认下方PPT大纲点击继续生成PPT</span>
<span class="subtite" v-else>在下方输入您的PPT主题并适当补充信息如行业岗位学科用途等</span>
</div>
<div class="preview" v-if="outline">
<pre>{{ outline }}</pre>
<div class="btns">
<Button class="btn" type="primary" @click="createPPT()">继续</Button>
<Button class="btn" @click="outline = ''">返回重新生成</Button>
</div>
</div>
<template v-else>
<Input class="input"
ref="inputRef"
v-model:value="keyword"
:maxlength="50"
placeholder="请输入PPT主题大学生职业生涯规划"
@enter="createOutline()"
>
<template #suffix>
<span class="count">{{ keyword.length }} / 50</span>
<span class="submit" @click="createOutline()"><IconSend class="icon" />AI生成</span>
</template>
</Input>
<div class="recommends">
<div class="recommend" v-for="(item, index) in recommends" :key="index" @click="keyword = item">{{ item }}</div>
</div>
</template>
<FullscreenSpin :loading="loading" tip="AI生成中请稍等 ..." />
</div>
</template>
<script lang="ts" setup>
import { ref, onMounted } from 'vue'
import api from '@/services'
import useAIPPT from '@/hooks/useAIPPT'
import type { AIPPTSlide } from '@/types/AIPPT'
import type { Slide } from '@/types/slides'
import message from '@/utils/message'
import { useMainStore } from '@/store'
import Input from '@/components/Input.vue'
import Button from '@/components/Button.vue'
import FullscreenSpin from '@/components/FullscreenSpin.vue'
const mainStore = useMainStore()
const { getMdContent, AIPPT } = useAIPPT()
const keyword = ref('')
const outline = ref('')
const loading = ref(false)
const inputRef = ref<InstanceType<typeof Input>>()
const recommends = ref([
'年度工作总结',
'大学生职业生涯规划',
'公司年会策划方案',
'大数据如何改变世界',
'餐饮市场调查与研究',
])
onMounted(() => {
setTimeout(() => {
inputRef.value!.focus()
}, 500)
})
const createOutline = async () => {
if (!keyword.value) return message.error('请先输入PPT主题')
loading.value = true
outline.value = await api.AIPPT_Outline(keyword.value).then(ret => {
return getMdContent(ret.data[0].content)
})
loading.value = false
}
const createPPT = async () => {
if (!outline.value) return message.error('缺少PPT大纲')
loading.value = true
// const AISlides: AIPPTSlide[] = await api.getMockData('AIPPT')
const AISlides: AIPPTSlide[] = await api.AIPPT(outline.value).then(ret => {
const obj = JSON.parse(ret.data[0].content)
return obj.data
})
const templateSlides: Slide[] = await api.getFileData('template_1').then(ret => ret.slides)
AIPPT(templateSlides, AISlides)
loading.value = false
mainStore.setAIPPTDialogState(false)
}
</script>
<style lang="scss" scoped>
.aippt-dialog {
margin: -20px;
padding: 30px;
}
.header {
margin-bottom: 12px;
.title {
font-weight: 700;
font-size: 18px;
margin-right: 8px;
}
.subtite {
color: #888;
font-size: 12px;
}
}
.preview {
pre {
max-height: 450px;
padding: 10px;
margin-bottom: 15px;
background-color: #f1f1f1;
overflow: auto;
}
.btns {
display: flex;
justify-content: center;
align-items: center;
.btn {
width: 120px;
margin: 0 5px;
}
}
}
.recommends {
display: flex;
flex-wrap: wrap;
margin-top: 10px;
.recommend {
font-size: 12px;
background-color: #f1f1f1;
border-radius: $borderRadius;
padding: 2px 4px;
margin-right: 5px;
cursor: pointer;
}
}
.count {
font-size: 12px;
color: #999;
margin-right: 10px;
}
.submit {
width: 65px;
height: 20px;
font-size: 12px;
background-color: $themeColor;
color: #fff;
display: flex;
justify-content: center;
align-items: center;
border-radius: $borderRadius;
cursor: pointer;
.icon {
font-size: 15px;
margin-right: 3px;
}
}
</style>

View File

@ -3,7 +3,7 @@
<div class="left">
<Popover trigger="click" placement="bottom-start" v-model:value="mainMenuVisible">
<template #content>
<PopoverMenuItem @click="AIPPT(); mainMenuVisible = false">AI PPT测试版</PopoverMenuItem>
<PopoverMenuItem @click="openAIPPTDialog(); mainMenuVisible = false">AI 生成 PPT测试版</PopoverMenuItem>
<FileInput accept="application/vnd.openxmlformats-officedocument.presentationml.presentation" @change="files => {
importPPTXFile(files)
mainMenuVisible = false
@ -56,6 +56,9 @@
<div class="arrow-btn"><IconDown class="arrow" /></div>
</Popover>
</div>
<div class="menu-item" v-tooltip="'AI生成PPT'" @click="openAIPPTDialog(); mainMenuVisible = false">
<span class="text">AI</span>
</div>
<div class="menu-item" v-tooltip="'导出'" @click="setDialogForExport('pptx')">
<IconDownload class="icon" />
</div>
@ -84,7 +87,6 @@ import { useMainStore, useSlidesStore } from '@/store'
import useScreening from '@/hooks/useScreening'
import useImport from '@/hooks/useImport'
import useSlideHandler from '@/hooks/useSlideHandler'
import useAIPPT from '@/hooks/useAIPPT'
import type { DialogForExportTypes } from '@/types/export'
import HotkeyDoc from './HotkeyDoc.vue'
@ -101,7 +103,6 @@ const { title } = storeToRefs(slidesStore)
const { enterScreening, enterScreeningFromStart } = useScreening()
const { importSpecificFile, importPPTXFile, exporting } = useImport()
const { resetSlides } = useSlideHandler()
const { AIPPT } = useAIPPT()
const mainMenuVisible = ref(false)
const hotkeyDrawerVisible = ref(false)
@ -133,6 +134,10 @@ const setDialogForExport = (type: DialogForExportTypes) => {
const openMarkupPanel = () => {
mainStore.setMarkupPanelState(true)
}
const openAIPPTDialog = () => {
mainStore.setAIPPTDialogState(true)
}
</script>
<style lang="scss" scoped>
@ -163,6 +168,11 @@ const openMarkupPanel = () => {
font-size: 18px;
color: #666;
}
.text {
width: 18px;
text-align: center;
font-size: 16px;
}
&:hover {
background-color: #f1f1f1;

View File

@ -28,6 +28,17 @@
>
<ExportDialog />
</Modal>
<Modal
:visible="showAIPPTDialog"
:width="680"
:closeOnClickMask="false"
:closeOnEsc="false"
closeButton
@closed="closeAIPPTDialog()"
>
<AIPPTDialog />
</Modal>
</template>
<script lang="ts" setup>
@ -48,11 +59,13 @@ import SelectPanel from './SelectPanel.vue'
import SearchPanel from './SearchPanel.vue'
import NotesPanel from './NotesPanel.vue'
import MarkupPanel from './MarkupPanel.vue'
import AIPPTDialog from './AIPPTDialog.vue'
import Modal from '@/components/Modal.vue'
const mainStore = useMainStore()
const { dialogForExport, showSelectPanel, showSearchPanel, showNotesPanel, showMarkupPanel } = storeToRefs(mainStore)
const { dialogForExport, showSelectPanel, showSearchPanel, showNotesPanel, showMarkupPanel, showAIPPTDialog } = storeToRefs(mainStore)
const closeExportDialog = () => mainStore.setDialogForExport('')
const closeAIPPTDialog = () => mainStore.setAIPPTDialogState(false)
const remarkHeight = ref(40)

View File

@ -9,6 +9,15 @@ export default defineConfig({
plugins: [
vue(),
],
server: {
proxy: {
'/api': {
target: 'https://server.pptist.cn',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, ''),
}
}
},
css: {
preprocessorOptions: {
scss: {