|
@@ -0,0 +1,322 @@
|
|
|
|
|
+<template>
|
|
|
|
|
+ <Modal
|
|
|
|
|
+ :visible="visible"
|
|
|
|
|
+ :width="880"
|
|
|
|
|
+ :close-button="true"
|
|
|
|
|
+ :close-on-click-mask="!working"
|
|
|
|
|
+ @update:visible="value => emit('update:visible', value)"
|
|
|
|
|
+ >
|
|
|
|
|
+ <div class="ai-dialog">
|
|
|
|
|
+ <div class="header">
|
|
|
|
|
+ <div>
|
|
|
|
|
+ <div class="title">AI生成课件</div>
|
|
|
|
|
+ <div class="sub">根据当前教材、单元和章节生成15页音乐课件</div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div v-if="taskInfo" class="progress">{{ taskInfo.progress || 0 }}%</div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <div class="extra">
|
|
|
|
|
+ <div class="label">补充要求</div>
|
|
|
|
|
+ <TextArea v-model:value="extraPrompt" :rows="2" placeholder="可选,例如:多一些节奏互动,语言更适合小学低年级" :disabled="working" />
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <div v-if="outline" class="outline">
|
|
|
|
|
+ <div class="outline-head">
|
|
|
|
|
+ <div>
|
|
|
|
|
+ <div class="outline-title">{{ outline.title }}</div>
|
|
|
|
|
+ <div class="outline-meta">{{ outline.gradeName }} · {{ outline.unitName }} · {{ outline.courseType }}</div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="count">{{ outline.pages.length }}页</div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <div class="page-list">
|
|
|
|
|
+ <div v-for="page in outline.pages" :key="page.pageNo" class="page-item">
|
|
|
|
|
+ <div class="page-no">{{ page.pageNo }}</div>
|
|
|
|
|
+ <div class="page-content">
|
|
|
|
|
+ <input v-model="page.title" class="page-title" :disabled="working" />
|
|
|
|
|
+ <textarea
|
|
|
|
|
+ :value="page.bullets.join('\n')"
|
|
|
|
|
+ :disabled="working"
|
|
|
|
|
+ class="page-bullets"
|
|
|
|
|
+ @input="event => updateBullets(page, event)"
|
|
|
|
|
+ ></textarea>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <div v-else class="empty">点击生成大纲后,可在这里确认和微调15页结构。</div>
|
|
|
|
|
+
|
|
|
|
|
+ <div v-if="taskInfo" class="task">{{ taskInfo.message }}</div>
|
|
|
|
|
+
|
|
|
|
|
+ <div class="footer">
|
|
|
|
|
+ <Button @click="emit('update:visible', false)" :disabled="working">取消</Button>
|
|
|
|
|
+ <Button type="primary" @click="handleOutline" :disabled="working">{{ outline ? "重新生成大纲" : "生成大纲" }}</Button>
|
|
|
|
|
+ <Button type="primary" @click="handleGenerate" :disabled="!outline || working">确认并生成PPT</Button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </Modal>
|
|
|
|
|
+</template>
|
|
|
|
|
+
|
|
|
|
|
+<script lang="ts" setup>
|
|
|
|
|
+import { ref, computed, watch } from "vue"
|
|
|
|
|
+import { ElMessage, ElMessageBox } from "element-plus"
|
|
|
|
|
+import Modal from "@/components/Modal.vue"
|
|
|
|
|
+import Button from "@/components/Button.vue"
|
|
|
|
|
+import TextArea from "@/components/TextArea.vue"
|
|
|
|
|
+import queryParams from "@/queryParams"
|
|
|
|
|
+import usePptWork from "@/store/pptWork"
|
|
|
|
|
+import { useSlidesStore } from "@/store"
|
|
|
|
|
+import useHistorySnapshot from "@/hooks/useHistorySnapshot"
|
|
|
|
|
+import { httpAjaxErrMsg } from "@/plugins/httpAjax"
|
|
|
|
|
+import {
|
|
|
|
|
+ aiCoursewareGenerateApi,
|
|
|
|
|
+ aiCoursewareOutlineApi,
|
|
|
|
|
+ aiCoursewareResultApi,
|
|
|
|
|
+ aiCoursewareTaskApi,
|
|
|
|
|
+ type AiCoursewareOutline,
|
|
|
|
|
+ type AiCoursewarePage,
|
|
|
|
|
+ type AiCoursewareTaskInfo,
|
|
|
|
|
+ type AiCoursewareGenerateResult
|
|
|
|
|
+} from "@/api/aiCourseware"
|
|
|
|
|
+import { aiCoursewareToSlides } from "@/utils/aiCoursewareToSlides"
|
|
|
|
|
+
|
|
|
|
|
+const props = defineProps<{
|
|
|
|
|
+ visible: boolean
|
|
|
|
|
+}>()
|
|
|
|
|
+
|
|
|
|
|
+const emit = defineEmits<{
|
|
|
|
|
+ (event: "update:visible", payload: boolean): void
|
|
|
|
|
+}>()
|
|
|
|
|
+
|
|
|
|
|
+const pptWork = usePptWork()
|
|
|
|
|
+const slidesStore = useSlidesStore()
|
|
|
|
|
+const { addHistorySnapshot } = useHistorySnapshot()
|
|
|
|
|
+
|
|
|
|
|
+const extraPrompt = ref("")
|
|
|
|
|
+const outline = ref<AiCoursewareOutline>()
|
|
|
|
|
+const taskInfo = ref<AiCoursewareTaskInfo>()
|
|
|
|
|
+const pollTimer = ref<number>()
|
|
|
|
|
+const working = computed(() => taskInfo.value?.status === "PENDING" || taskInfo.value?.status === "RUNNING")
|
|
|
|
|
+
|
|
|
|
|
+watch(
|
|
|
|
|
+ () => props.visible,
|
|
|
|
|
+ visible => {
|
|
|
|
|
+ if (!visible) clearTask()
|
|
|
|
|
+ }
|
|
|
|
|
+)
|
|
|
|
|
+
|
|
|
|
|
+async function handleOutline() {
|
|
|
|
|
+ if (!pptWork.id) {
|
|
|
|
|
+ ElMessage.error("当前课件ID为空")
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+ taskInfo.value = { taskId: "", status: "RUNNING", progress: 0, message: "正在生成大纲" }
|
|
|
|
|
+ const res = await httpAjaxErrMsg(aiCoursewareOutlineApi, {
|
|
|
|
|
+ id: pptWork.id,
|
|
|
|
|
+ fromType: queryParams.pptResourcesType || queryParams.fromType,
|
|
|
|
|
+ extraPrompt: extraPrompt.value
|
|
|
|
|
+ })
|
|
|
|
|
+ taskInfo.value = undefined
|
|
|
|
|
+ if (res.code === 200) {
|
|
|
|
|
+ outline.value = res.data as AiCoursewareOutline
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+async function handleGenerate() {
|
|
|
|
|
+ if (!outline.value || !pptWork.id) return
|
|
|
|
|
+ const confirmed = await ElMessageBox.confirm("生成后会替换当前编辑器中的课件内容,未保存内容将丢失。确认继续?", "确认生成", {
|
|
|
|
|
+ confirmButtonText: "确认生成",
|
|
|
|
|
+ cancelButtonText: "取消",
|
|
|
|
|
+ type: "warning"
|
|
|
|
|
+ })
|
|
|
|
|
+ .then(() => true)
|
|
|
|
|
+ .catch(() => false)
|
|
|
|
|
+ if (!confirmed) return
|
|
|
|
|
+
|
|
|
|
|
+ const res = await httpAjaxErrMsg(aiCoursewareGenerateApi, {
|
|
|
|
|
+ id: pptWork.id,
|
|
|
|
|
+ fromType: queryParams.pptResourcesType || queryParams.fromType,
|
|
|
|
|
+ outline: outline.value
|
|
|
|
|
+ })
|
|
|
|
|
+ if (res.code !== 200) return
|
|
|
|
|
+
|
|
|
|
|
+ taskInfo.value = res.data as AiCoursewareTaskInfo
|
|
|
|
|
+ pollTask(taskInfo.value.taskId)
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+async function pollTask(taskId: string) {
|
|
|
|
|
+ if (pollTimer.value) window.clearInterval(pollTimer.value)
|
|
|
|
|
+ await fetchTask(taskId)
|
|
|
|
|
+ pollTimer.value = window.setInterval(() => fetchTask(taskId), 2000)
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+async function fetchTask(taskId: string) {
|
|
|
|
|
+ const res = await httpAjaxErrMsg(aiCoursewareTaskApi, taskId)
|
|
|
|
|
+ if (res.code !== 200) {
|
|
|
|
|
+ clearTask()
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+ taskInfo.value = res.data as AiCoursewareTaskInfo
|
|
|
|
|
+ if (taskInfo.value.status === "DONE") {
|
|
|
|
|
+ clearPollTimer()
|
|
|
|
|
+ await applyResult(taskId)
|
|
|
|
|
+ } else if (taskInfo.value.status === "FAILED") {
|
|
|
|
|
+ clearPollTimer()
|
|
|
|
|
+ ElMessage.error(taskInfo.value.message || "AI课件生成失败")
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function clearTask() {
|
|
|
|
|
+ clearPollTimer()
|
|
|
|
|
+ taskInfo.value = undefined
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function clearPollTimer() {
|
|
|
|
|
+ if (pollTimer.value) {
|
|
|
|
|
+ window.clearInterval(pollTimer.value)
|
|
|
|
|
+ pollTimer.value = undefined
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+async function applyResult(taskId: string) {
|
|
|
|
|
+ const res = await httpAjaxErrMsg(aiCoursewareResultApi, taskId)
|
|
|
|
|
+ if (res.code !== 200) return
|
|
|
|
|
+
|
|
|
|
|
+ const result = res.data as AiCoursewareGenerateResult
|
|
|
|
|
+ const converted = aiCoursewareToSlides(result.outline)
|
|
|
|
|
+ slidesStore.setTitle(converted.title)
|
|
|
|
|
+ slidesStore.setTheme(converted.theme)
|
|
|
|
|
+ slidesStore.setSlides(converted.slides)
|
|
|
|
|
+ slidesStore.updateSlideIndex(0)
|
|
|
|
|
+ addHistorySnapshot()
|
|
|
|
|
+ ElMessage.success("AI课件已生成,请检查后手动保存")
|
|
|
|
|
+ emit("update:visible", false)
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function updateBullets(page: AiCoursewarePage, event: Event) {
|
|
|
|
|
+ page.bullets = (event.target as HTMLTextAreaElement).value
|
|
|
|
|
+ .split("\n")
|
|
|
|
|
+ .map(item => item.trim())
|
|
|
|
|
+ .filter(Boolean)
|
|
|
|
|
+}
|
|
|
|
|
+</script>
|
|
|
|
|
+
|
|
|
|
|
+<style lang="scss" scoped>
|
|
|
|
|
+.ai-dialog {
|
|
|
|
|
+ height: 76vh;
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ flex-direction: column;
|
|
|
|
|
+}
|
|
|
|
|
+.header,
|
|
|
|
|
+.outline-head,
|
|
|
|
|
+.footer {
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ align-items: center;
|
|
|
|
|
+ justify-content: space-between;
|
|
|
|
|
+}
|
|
|
|
|
+.title {
|
|
|
|
|
+ font-size: 18px;
|
|
|
|
|
+ font-weight: 700;
|
|
|
|
|
+ color: #1f2933;
|
|
|
|
|
+}
|
|
|
|
|
+.sub,
|
|
|
|
|
+.outline-meta,
|
|
|
|
|
+.task {
|
|
|
|
|
+ margin-top: 6px;
|
|
|
|
|
+ color: #7b8794;
|
|
|
|
|
+ font-size: 13px;
|
|
|
|
|
+}
|
|
|
|
|
+.progress {
|
|
|
|
|
+ color: $themeColor;
|
|
|
|
|
+ font-weight: 700;
|
|
|
|
|
+}
|
|
|
|
|
+.extra {
|
|
|
|
|
+ margin-top: 18px;
|
|
|
|
|
+}
|
|
|
|
|
+.label {
|
|
|
|
|
+ margin-bottom: 8px;
|
|
|
|
|
+ font-size: 13px;
|
|
|
|
|
+ color: #52616b;
|
|
|
|
|
+}
|
|
|
|
|
+.outline {
|
|
|
|
|
+ margin-top: 18px;
|
|
|
|
|
+ min-height: 0;
|
|
|
|
|
+ flex: 1;
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ flex-direction: column;
|
|
|
|
|
+}
|
|
|
|
|
+.outline-title {
|
|
|
|
|
+ font-size: 15px;
|
|
|
|
|
+ font-weight: 700;
|
|
|
|
|
+}
|
|
|
|
|
+.count {
|
|
|
|
|
+ color: #52616b;
|
|
|
|
|
+ font-size: 13px;
|
|
|
|
|
+}
|
|
|
|
|
+.page-list {
|
|
|
|
|
+ margin-top: 12px;
|
|
|
|
|
+ overflow: auto;
|
|
|
|
|
+ border: 1px solid #edf1f5;
|
|
|
|
|
+}
|
|
|
|
|
+.page-item {
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ padding: 12px;
|
|
|
|
|
+ border-bottom: 1px solid #edf1f5;
|
|
|
|
|
+ &:last-child {
|
|
|
|
|
+ border-bottom: 0;
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+.page-no {
|
|
|
|
|
+ width: 34px;
|
|
|
|
|
+ height: 34px;
|
|
|
|
|
+ line-height: 34px;
|
|
|
|
|
+ text-align: center;
|
|
|
|
|
+ border-radius: 6px;
|
|
|
|
|
+ color: #fff;
|
|
|
|
|
+ background: $themeColor;
|
|
|
|
|
+ flex-shrink: 0;
|
|
|
|
|
+}
|
|
|
|
|
+.page-content {
|
|
|
|
|
+ margin-left: 12px;
|
|
|
|
|
+ min-width: 0;
|
|
|
|
|
+ flex: 1;
|
|
|
|
|
+}
|
|
|
|
|
+.page-title,
|
|
|
|
|
+.page-bullets {
|
|
|
|
|
+ width: 100%;
|
|
|
|
|
+ border: 1px solid #d9d9d9;
|
|
|
|
|
+ border-radius: 6px;
|
|
|
|
|
+ box-sizing: border-box;
|
|
|
|
|
+ font-family: inherit;
|
|
|
|
|
+}
|
|
|
|
|
+.page-title {
|
|
|
|
|
+ height: 32px;
|
|
|
|
|
+ padding: 0 8px;
|
|
|
|
|
+ font-weight: 600;
|
|
|
|
|
+}
|
|
|
|
|
+.page-bullets {
|
|
|
|
|
+ margin-top: 8px;
|
|
|
|
|
+ height: 74px;
|
|
|
|
|
+ padding: 8px;
|
|
|
|
|
+ resize: vertical;
|
|
|
|
|
+ line-height: 1.5;
|
|
|
|
|
+}
|
|
|
|
|
+.empty {
|
|
|
|
|
+ flex: 1;
|
|
|
|
|
+ margin-top: 18px;
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ align-items: center;
|
|
|
|
|
+ justify-content: center;
|
|
|
|
|
+ border: 1px dashed #d9d9d9;
|
|
|
|
|
+ color: #7b8794;
|
|
|
|
|
+}
|
|
|
|
|
+.task {
|
|
|
|
|
+ min-height: 20px;
|
|
|
|
|
+}
|
|
|
|
|
+.footer {
|
|
|
|
|
+ margin-top: 18px;
|
|
|
|
|
+ justify-content: flex-end;
|
|
|
|
|
+ gap: 10px;
|
|
|
|
|
+}
|
|
|
|
|
+</style>
|