yonge 1 tydzień temu
rodzic
commit
dc4d9cefe8

+ 86 - 0
src/api/aiCourseware.ts

@@ -0,0 +1,86 @@
+import { httpAxios } from "@/api/ApiInstance"
+
+export interface AiCoursewareOutlineQuery {
+  id: string
+  fromType?: string
+  extraPrompt?: string
+}
+
+export interface AiCoursewareGenerateQuery {
+  id: string
+  fromType?: string
+  outline: AiCoursewareOutline
+}
+
+export interface AiCoursewareOutline {
+  title: string
+  gradeName: string
+  textbookName: string
+  unitName: string
+  chapterName: string
+  courseType: string
+  style: "cartoon" | "clean" | string
+  pages: AiCoursewarePage[]
+}
+
+export interface AiCoursewarePage {
+  pageNo: number
+  type: string
+  title: string
+  subtitle?: string
+  bullets: string[]
+  teacherTips?: string
+  interaction?: {
+    type: string
+    question: string
+    answer?: string
+  }
+  imageKeywords: string[]
+  image?: {
+    url: string
+    keyword: string
+    provider: string
+  }
+}
+
+export interface AiCoursewareTaskInfo {
+  taskId: string
+  status: "PENDING" | "RUNNING" | "DONE" | "FAILED"
+  progress: number
+  message: string
+}
+
+export interface AiCoursewareGenerateResult {
+  context: Record<string, any>
+  outline: AiCoursewareOutline
+}
+
+export const aiCoursewareOutlineApi = (data: AiCoursewareOutlineQuery) => {
+  return httpAxios.axioseRquest({
+    method: "post",
+    url: "/edu-app/aiCourseware/outline",
+    data
+  })
+}
+
+export const aiCoursewareGenerateApi = (data: AiCoursewareGenerateQuery) => {
+  return httpAxios.axioseRquest({
+    method: "post",
+    url: "/edu-app/aiCourseware/generate",
+    data
+  })
+}
+
+export const aiCoursewareTaskApi = (taskId: string) => {
+  return httpAxios.axioseRquest({
+    method: "get",
+    url: `/edu-app/aiCourseware/task/${taskId}`
+  })
+}
+
+export const aiCoursewareResultApi = (taskId: string) => {
+  return httpAxios.axioseRquest({
+    method: "get",
+    url: `/edu-app/aiCourseware/task/${taskId}/result`
+  })
+}

+ 196 - 0
src/utils/aiCoursewareToSlides.ts

@@ -0,0 +1,196 @@
+import { nanoid } from "nanoid"
+import type { Slide, PPTElement, SlideTheme, PPTTextElement, PPTImageElement } from "@/types/slides"
+import type { AiCoursewareOutline, AiCoursewarePage } from "@/api/aiCourseware"
+
+export function aiCoursewareToSlides(outline: AiCoursewareOutline): { title: string; theme: Partial<SlideTheme>; slides: Slide[] } {
+  const primary = outline.style === "clean" ? "#2D6CDF" : "#FF8A3D"
+  const background = outline.style === "clean" ? "#F7F9FC" : "#FFF8EF"
+  const fontColor = "#263238"
+  const theme: Partial<SlideTheme> = {
+    themeColor: primary,
+    backgroundColor: background,
+    fontColor,
+    fontName: "Microsoft Yahei",
+    fontSize: "36px"
+  }
+
+  return {
+    title: outline.title || outline.chapterName || "AI音乐课件",
+    theme,
+    slides: outline.pages.map(page => buildSlide(page, outline, primary, background, fontColor))
+  }
+}
+
+function buildSlide(page: AiCoursewarePage, outline: AiCoursewareOutline, primary: string, background: string, fontColor: string): Slide {
+  const isCover = page.type === "cover" || page.pageNo === 1
+  const elements: PPTElement[] = []
+
+  elements.push(
+    textElement({
+      left: 110,
+      top: isCover ? 130 : 70,
+      width: isCover ? 980 : 1040,
+      height: isCover ? 160 : 82,
+      content: page.title || outline.title,
+      fontSize: isCover ? 58 : 38,
+      color: primary,
+      bold: true
+    })
+  )
+
+  const subtitle = isCover ? [outline.gradeName, outline.textbookName, outline.unitName].filter(Boolean).join(" · ") : page.subtitle
+  if (subtitle) {
+    elements.push(
+      textElement({
+        left: 116,
+        top: isCover ? 312 : 150,
+        width: 920,
+        height: 48,
+        content: subtitle,
+        fontSize: isCover ? 28 : 20,
+        color: fontColor
+      })
+    )
+  }
+
+  if (page.image?.url) {
+    elements.push(
+      imageElement({
+        left: isCover ? 1080 : 1180,
+        top: isCover ? 150 : 150,
+        width: isCover ? 650 : 590,
+        height: isCover ? 620 : 480,
+        src: page.image.url
+      })
+    )
+  }
+
+  const bulletTop = isCover ? 430 : 250
+  const bulletWidth = page.image?.url ? 900 : 1320
+  const bullets = normalizeBullets(page)
+  elements.push(
+    textElement({
+      left: 120,
+      top: bulletTop,
+      width: bulletWidth,
+      height: Math.min(430, 72 + bullets.length * 58),
+      content: bullets.map(item => `• ${escapeHtml(item)}`).join("<br>"),
+      fontSize: isCover ? 30 : 28,
+      color: fontColor,
+      lineHeight: 1.45
+    })
+  )
+
+  if (page.interaction?.question) {
+    elements.push(
+      textElement({
+        left: 120,
+        top: 760,
+        width: 1200,
+        height: 105,
+        content: `互动:${escapeHtml(page.interaction.question)}`,
+        fontSize: 28,
+        color: "#FFFFFF",
+        fill: primary,
+        lineHeight: 1.35
+      })
+    )
+  } else if (page.teacherTips) {
+    elements.push(
+      textElement({
+        left: 120,
+        top: 780,
+        width: 1200,
+        height: 86,
+        content: `提示:${escapeHtml(page.teacherTips)}`,
+        fontSize: 22,
+        color: "#52616B",
+        fill: "#FFFFFF",
+        lineHeight: 1.35
+      })
+    )
+  }
+
+  elements.push(
+    textElement({
+      left: 1700,
+      top: 995,
+      width: 120,
+      height: 36,
+      content: `${page.pageNo || ""}/15`,
+      fontSize: 18,
+      color: "#90A4AE"
+    })
+  )
+
+  return {
+    id: nanoid(10),
+    elements,
+    background: {
+      type: "solid",
+      color: background
+    },
+    remark: page.teacherTips || ""
+  }
+}
+
+function normalizeBullets(page: AiCoursewarePage): string[] {
+  const bullets = (page.bullets || []).filter(Boolean)
+  if (bullets.length) return bullets.slice(0, 5)
+  return [page.subtitle || page.title || "课堂内容"]
+}
+
+function textElement(params: {
+  left: number
+  top: number
+  width: number
+  height: number
+  content: string
+  fontSize: number
+  color: string
+  bold?: boolean
+  fill?: string
+  lineHeight?: number
+}): PPTTextElement {
+  return {
+    id: nanoid(10),
+    type: "text",
+    left: params.left,
+    top: params.top,
+    width: params.width,
+    height: params.height,
+    rotate: 0,
+    content: `<div style="font-size:${params.fontSize}px;${params.bold ? "font-weight:700;" : ""}">${params.content}</div>`,
+    defaultFontName: "Microsoft Yahei",
+    defaultColor: params.color,
+    fill: params.fill,
+    lineHeight: params.lineHeight || 1.25,
+    wordSpace: 0,
+    opacity: 1
+  }
+}
+
+function imageElement(params: { left: number; top: number; width: number; height: number; src: string }): PPTImageElement {
+  return {
+    id: nanoid(10),
+    type: "image",
+    left: params.left,
+    top: params.top,
+    width: params.width,
+    height: params.height,
+    rotate: 0,
+    src: params.src,
+    fixedRatio: false,
+    radius: 12,
+    shadow: {
+      h: 0,
+      v: 8,
+      blur: 18,
+      color: "rgba(38, 50, 56, 0.18)"
+    }
+  }
+}
+
+function escapeHtml(value: string) {
+  return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
+}

+ 322 - 0
src/views/Editor/EditorHeader/AiCoursewareDialog.vue

@@ -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>

+ 12 - 0
src/views/Editor/EditorHeader/index.vue

@@ -19,6 +19,15 @@
           <PopoverMenuItem
           <PopoverMenuItem
             @click="
             @click="
               () => {
               () => {
+                mainMenuVisible = false
+                aiCoursewareVisible = true
+              }
+            "
+            >AI生成课件</PopoverMenuItem
+          >
+          <PopoverMenuItem
+            @click="
+              () => {
                 resetSlides()
                 resetSlides()
                 mainMenuVisible = false
                 mainMenuVisible = false
               }
               }
@@ -54,6 +63,7 @@
       <HotkeyDoc />
       <HotkeyDoc />
       <template v-slot:title>快捷操作</template>
       <template v-slot:title>快捷操作</template>
     </Drawer>
     </Drawer>
+    <AiCoursewareDialog v-model:visible="aiCoursewareVisible" />
     <FullscreenSpin :loading="fullscreenSpinData.loading" :progress="fullscreenSpinData.progress" :tip="fullscreenSpinData.tip" />
     <FullscreenSpin :loading="fullscreenSpinData.loading" :progress="fullscreenSpinData.progress" :tip="fullscreenSpinData.tip" />
   </div>
   </div>
 </template>
 </template>
@@ -71,6 +81,7 @@ import FullscreenSpin from "@/components/FullscreenSpin.vue"
 import Drawer from "@/components/Drawer.vue"
 import Drawer from "@/components/Drawer.vue"
 import Popover from "@/components/Popover.vue"
 import Popover from "@/components/Popover.vue"
 import PopoverMenuItem from "@/components/PopoverMenuItem.vue"
 import PopoverMenuItem from "@/components/PopoverMenuItem.vue"
+import AiCoursewareDialog from "./AiCoursewareDialog.vue"
 import { ref, computed } from "vue"
 import { ref, computed } from "vue"
 import { ElMessageBox } from "element-plus"
 import { ElMessageBox } from "element-plus"
 import usePptWork from "@/store/pptWork"
 import usePptWork from "@/store/pptWork"
@@ -85,6 +96,7 @@ const { resetSlides } = useSlideHandler()
 
 
 const mainMenuVisible = ref(false)
 const mainMenuVisible = ref(false)
 const hotkeyDrawerVisible = ref(false)
 const hotkeyDrawerVisible = ref(false)
+const aiCoursewareVisible = ref(false)
 
 
 const spinType = ref<"import" | "export">("import")
 const spinType = ref<"import" | "export">("import")
 /* 导入 */
 /* 导入 */