|
|
@@ -0,0 +1,511 @@
|
|
|
+import { nanoid } from "nanoid"
|
|
|
+import type { Slide, PPTElement, SlideTheme, PPTTextElement, PPTImageElement, PPTShapeElement, SlideBackground, PPTElementShadow } from "@/types/slides"
|
|
|
+import type { AiCoursewareOutline, AiCoursewarePage } from "@/api/aiCourseware"
|
|
|
+
|
|
|
+interface AiPptOptions {
|
|
|
+ backgroundImage?: string
|
|
|
+}
|
|
|
+
|
|
|
+interface Palette {
|
|
|
+ primary: string
|
|
|
+ secondary: string
|
|
|
+ accent: string
|
|
|
+ warm: string
|
|
|
+ background: string
|
|
|
+ panel: string
|
|
|
+ panelAlt: string
|
|
|
+ text: string
|
|
|
+ muted: string
|
|
|
+}
|
|
|
+
|
|
|
+interface VisualRect {
|
|
|
+ left: number
|
|
|
+ top: number
|
|
|
+ width: number
|
|
|
+ height: number
|
|
|
+}
|
|
|
+
|
|
|
+const SLIDE_WIDTH = 1600
|
|
|
+const SLIDE_HEIGHT = 900
|
|
|
+const FONT_NAME = "Microsoft Yahei"
|
|
|
+
|
|
|
+export function buildAiPptSlides(outline: AiCoursewareOutline, options: AiPptOptions = {}): { title: string; theme: Partial<SlideTheme>; slides: Slide[] } {
|
|
|
+ const palette = buildPalette(outline)
|
|
|
+ return {
|
|
|
+ title: outline.title || outline.chapterName || "音乐课件",
|
|
|
+ theme: {
|
|
|
+ themeColor: palette.primary,
|
|
|
+ backgroundColor: palette.background,
|
|
|
+ fontColor: palette.text,
|
|
|
+ fontName: FONT_NAME,
|
|
|
+ fontSize: "36px"
|
|
|
+ },
|
|
|
+ slides: outline.pages.map(page => buildSlide(page, outline, palette, options))
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function buildSlide(page: AiCoursewarePage, outline: AiCoursewareOutline, palette: Palette, options: AiPptOptions): Slide {
|
|
|
+ const elements = baseElements(palette, options)
|
|
|
+ const kind = pageKind(page)
|
|
|
+
|
|
|
+ if (page.pageNo === 1 || kind.includes("cover")) {
|
|
|
+ elements.push(...coverTemplate(page, outline, palette))
|
|
|
+ } else if (kind.includes("goal")) {
|
|
|
+ elements.push(...cardsTemplate(page, outline, palette, "学习目标"))
|
|
|
+ } else if (kind.includes("listen")) {
|
|
|
+ elements.push(...listenTemplate(page, outline, palette))
|
|
|
+ } else if (kind.includes("lyric") || kind.includes("content")) {
|
|
|
+ elements.push(...contentTemplate(page, outline, palette))
|
|
|
+ } else if (kind.includes("rhythm") || kind.includes("skill")) {
|
|
|
+ elements.push(...rhythmTemplate(page, outline, palette))
|
|
|
+ } else if (kind.includes("knowledge") || kind.includes("extension")) {
|
|
|
+ elements.push(...cardsTemplate(page, outline, palette, "音乐知识"))
|
|
|
+ } else if (kind.includes("interaction") || kind.includes("activity")) {
|
|
|
+ elements.push(...activityTemplate(page, outline, palette))
|
|
|
+ } else if (kind.includes("summary")) {
|
|
|
+ elements.push(...summaryTemplate(page, outline, palette))
|
|
|
+ } else if (kind.includes("homework")) {
|
|
|
+ elements.push(...homeworkTemplate(page, outline, palette))
|
|
|
+ } else {
|
|
|
+ elements.push(...splitTemplate(page, outline, palette))
|
|
|
+ }
|
|
|
+
|
|
|
+ return {
|
|
|
+ id: nanoid(10),
|
|
|
+ elements: keepInCanvas(elements),
|
|
|
+ background: backgroundConfig(palette, options),
|
|
|
+ remark: slideRemark(page)
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function coverTemplate(page: AiCoursewarePage, outline: AiCoursewareOutline, palette: Palette): PPTElement[] {
|
|
|
+ const elements: PPTElement[] = []
|
|
|
+ const subtitle = [outline.gradeName, outline.textbookName, outline.unitName].filter(Boolean).join(" · ")
|
|
|
+ elements.push(titleMark(92, 92, palette.primary))
|
|
|
+ elements.push(textElement({ left: 92, top: 138, width: 780, height: 100, content: pageTitle(page, outline), fontSize: 54, color: palette.primary, bold: true }))
|
|
|
+ if (subtitle) elements.push(textElement({ left: 96, top: 266, width: 780, height: 34, content: subtitle, fontSize: 22, color: palette.text }))
|
|
|
+ elements.push(...heroVisual(page, palette, { left: 940, top: 122, width: 470, height: 420 }))
|
|
|
+ elements.push(...compactBullets(studentPoints(page, 2), palette, 96, 390, 660, "♪"))
|
|
|
+ addTeacherAction(elements, page, palette, 96, 715, 900)
|
|
|
+ return elements
|
|
|
+}
|
|
|
+
|
|
|
+function splitTemplate(page: AiCoursewarePage, outline: AiCoursewareOutline, palette: Palette): PPTElement[] {
|
|
|
+ const elements: PPTElement[] = []
|
|
|
+ addSlideTitle(elements, page, outline, palette)
|
|
|
+ elements.push(...visualBlock(page, palette, { left: 925, top: 178, width: 500, height: 430 }))
|
|
|
+ elements.push(...largeStudentPanel(page, palette, 96, 188, 700, 440))
|
|
|
+ addTeacherAction(elements, page, palette, 96, 692, 1190)
|
|
|
+ return elements
|
|
|
+}
|
|
|
+
|
|
|
+function contentTemplate(page: AiCoursewarePage, outline: AiCoursewareOutline, palette: Palette): PPTElement[] {
|
|
|
+ const elements: PPTElement[] = []
|
|
|
+ addSlideTitle(elements, page, outline, palette)
|
|
|
+ elements.push(shapeElement({ left: 96, top: 184, width: 720, height: 460, fill: palette.panel, radius: 24, shadow: softShadow() }))
|
|
|
+ elements.push(textElement({ left: 136, top: 226, width: 640, height: 54, content: studentHeadline(page) || page.title, fontSize: 34, color: palette.primary, bold: true }))
|
|
|
+ studentPoints(page, 4).forEach((point, index) => {
|
|
|
+ const top = 318 + index * 72
|
|
|
+ elements.push(shapeElement({ left: 138, top, width: 24, height: 24, fill: index % 2 ? palette.secondary : palette.primary, radius: 6 }))
|
|
|
+ elements.push(textElement({ left: 186, top: top - 4, width: 560, height: 42, content: point, fontSize: 28, color: palette.text }))
|
|
|
+ })
|
|
|
+ elements.push(...visualBlock(page, palette, { left: 900, top: 204, width: 510, height: 410 }))
|
|
|
+ addTeacherAction(elements, page, palette, 110, 700, 1180)
|
|
|
+ return elements
|
|
|
+}
|
|
|
+
|
|
|
+function listenTemplate(page: AiCoursewarePage, outline: AiCoursewareOutline, palette: Palette): PPTElement[] {
|
|
|
+ const elements: PPTElement[] = []
|
|
|
+ addSlideTitle(elements, page, outline, palette)
|
|
|
+ const task = activityTask(page) || studentHeadline(page) || "听一听:音乐中有哪些声音?"
|
|
|
+ elements.push(shapeElement({ left: 96, top: 184, width: 760, height: 150, fill: palette.primary, radius: 26, shadow: softShadow() }))
|
|
|
+ elements.push(textElement({ left: 142, top: 226, width: 660, height: 60, content: task, fontSize: 34, color: "#FFFFFF", bold: true, lineHeight: 1.25 }))
|
|
|
+ elements.push(...soundWave(136, 390, palette, 610, 115))
|
|
|
+ elements.push(...compactBullets(studentPoints(page, 3), palette, 104, 552, 690, "听"))
|
|
|
+ elements.push(...visualBlock(page, palette, { left: 925, top: 190, width: 490, height: 430 }))
|
|
|
+ addTeacherAction(elements, page, palette, 110, 724, 1180)
|
|
|
+ return elements
|
|
|
+}
|
|
|
+
|
|
|
+function rhythmTemplate(page: AiCoursewarePage, outline: AiCoursewareOutline, palette: Palette): PPTElement[] {
|
|
|
+ const elements: PPTElement[] = []
|
|
|
+ addSlideTitle(elements, page, outline, palette)
|
|
|
+ const task = activityTask(page) || studentHeadline(page) || "拍一拍,感受节奏"
|
|
|
+ elements.push(shapeElement({ left: 105, top: 185, width: 570, height: 96, fill: palette.primary, radius: 22, shadow: softShadow() }))
|
|
|
+ elements.push(textElement({ left: 144, top: 216, width: 500, height: 36, content: task, fontSize: 30, color: "#FFFFFF", bold: true }))
|
|
|
+ const beats = activitySteps(page, 4)
|
|
|
+ beats.forEach((beat, index) => {
|
|
|
+ const left = 108 + index * 205
|
|
|
+ elements.push(shapeElement({ left, top: 365, width: 160, height: 160, fill: index % 2 ? palette.panelAlt : palette.panel, radius: 24, shadow: softShadow() }))
|
|
|
+ elements.push(textElement({ left: left + 44, top: 400, width: 74, height: 48, content: `${index + 1}`, fontSize: 38, color: palette.primary, bold: true }))
|
|
|
+ elements.push(textElement({ left: left + 22, top: 468, width: 116, height: 40, content: beat, fontSize: 20, color: palette.text, lineHeight: 1.25 }))
|
|
|
+ })
|
|
|
+ elements.push(...visualBlock(page, palette, { left: 980, top: 230, width: 360, height: 360 }))
|
|
|
+ addTeacherAction(elements, page, palette, 108, 704, 1190)
|
|
|
+ return elements
|
|
|
+}
|
|
|
+
|
|
|
+function cardsTemplate(page: AiCoursewarePage, outline: AiCoursewareOutline, palette: Palette, fallbackHeadline: string): PPTElement[] {
|
|
|
+ const elements: PPTElement[] = []
|
|
|
+ addSlideTitle(elements, page, outline, palette)
|
|
|
+ const points = studentPoints(page, 4)
|
|
|
+ points.forEach((point, index) => {
|
|
|
+ const left = 108 + (index % 2) * 520
|
|
|
+ const top = 210 + Math.floor(index / 2) * 188
|
|
|
+ elements.push(shapeElement({ left, top, width: 460, height: 136, fill: index % 2 ? palette.panelAlt : palette.panel, radius: 22, shadow: softShadow() }))
|
|
|
+ elements.push(shapeElement({ left: left + 28, top: top + 34, width: 42, height: 42, fill: index % 2 ? palette.secondary : palette.primary, radius: 12 }))
|
|
|
+ elements.push(textElement({ left: left + 42, top: top + 42, width: 18, height: 22, content: `${index + 1}`, fontSize: 20, color: "#FFFFFF", bold: true }))
|
|
|
+ elements.push(textElement({ left: left + 92, top: top + 34, width: 320, height: 58, content: point, fontSize: 25, color: palette.text, bold: true, lineHeight: 1.3 }))
|
|
|
+ })
|
|
|
+ elements.push(...visualBlock(page, palette, { left: 1145, top: 248, width: 265, height: 265 }))
|
|
|
+ addTeacherAction(elements, page, palette, 108, 706, 1180, activityTask(page) || fallbackHeadline)
|
|
|
+ return elements
|
|
|
+}
|
|
|
+
|
|
|
+function activityTemplate(page: AiCoursewarePage, outline: AiCoursewareOutline, palette: Palette): PPTElement[] {
|
|
|
+ const elements: PPTElement[] = []
|
|
|
+ addSlideTitle(elements, page, outline, palette)
|
|
|
+ const task = activityTask(page) || page.interaction?.question || studentHeadline(page) || "课堂互动"
|
|
|
+ elements.push(shapeElement({ left: 108, top: 185, width: 900, height: 165, fill: palette.primary, radius: 28, shadow: softShadow() }))
|
|
|
+ elements.push(textElement({ left: 154, top: 235, width: 800, height: 58, content: task, fontSize: 36, color: "#FFFFFF", bold: true, lineHeight: 1.25 }))
|
|
|
+ activitySteps(page, 3).forEach((step, index) => {
|
|
|
+ const left = 118 + index * 392
|
|
|
+ elements.push(shapeElement({ left, top: 455, width: 335, height: 142, fill: palette.panel, radius: 22, shadow: softShadow() }))
|
|
|
+ elements.push(textElement({ left: left + 26, top: 484, width: 88, height: 26, content: `步骤${index + 1}`, fontSize: 21, color: palette.primary, bold: true }))
|
|
|
+ elements.push(textElement({ left: left + 26, top: 532, width: 280, height: 42, content: step, fontSize: 24, color: palette.text, lineHeight: 1.28 }))
|
|
|
+ })
|
|
|
+ elements.push(...visualBlock(page, palette, { left: 1095, top: 195, width: 300, height: 300 }))
|
|
|
+ addTeacherAction(elements, page, palette, 116, 710, 1170, page.interaction?.answer || "")
|
|
|
+ return elements
|
|
|
+}
|
|
|
+
|
|
|
+function summaryTemplate(page: AiCoursewarePage, outline: AiCoursewareOutline, palette: Palette): PPTElement[] {
|
|
|
+ const elements: PPTElement[] = []
|
|
|
+ addSlideTitle(elements, page, outline, palette)
|
|
|
+ elements.push(shapeElement({ left: 112, top: 188, width: 900, height: 470, fill: palette.panel, radius: 26, shadow: softShadow() }))
|
|
|
+ studentPoints(page, 4).forEach((point, index) => {
|
|
|
+ const top = 240 + index * 90
|
|
|
+ elements.push(shapeElement({ left: 160, top, width: 44, height: 44, fill: palette.secondary, radius: 14 }))
|
|
|
+ elements.push(textElement({ left: 172, top: top + 8, width: 20, height: 20, content: "✓", fontSize: 23, color: "#FFFFFF", bold: true }))
|
|
|
+ elements.push(textElement({ left: 236, top: top + 4, width: 690, height: 38, content: point, fontSize: 28, color: palette.text, bold: true }))
|
|
|
+ })
|
|
|
+ elements.push(...visualBlock(page, palette, { left: 1120, top: 250, width: 280, height: 280 }))
|
|
|
+ return elements
|
|
|
+}
|
|
|
+
|
|
|
+function homeworkTemplate(page: AiCoursewarePage, outline: AiCoursewareOutline, palette: Palette): PPTElement[] {
|
|
|
+ const elements: PPTElement[] = []
|
|
|
+ addSlideTitle(elements, page, outline, palette)
|
|
|
+ elements.push(shapeElement({ left: 112, top: 196, width: 610, height: 460, fill: palette.panel, radius: 28, shadow: softShadow() }))
|
|
|
+ elements.push(textElement({ left: 154, top: 244, width: 460, height: 44, content: studentHeadline(page) || "课后练习", fontSize: 34, color: palette.primary, bold: true }))
|
|
|
+ elements.push(...compactBullets(activitySteps(page, 4), palette, 154, 340, 480, "做"))
|
|
|
+ elements.push(...visualBlock(page, palette, { left: 850, top: 200, width: 500, height: 420 }))
|
|
|
+ return elements
|
|
|
+}
|
|
|
+
|
|
|
+function largeStudentPanel(page: AiCoursewarePage, palette: Palette, left: number, top: number, width: number, height: number): PPTElement[] {
|
|
|
+ const elements: PPTElement[] = []
|
|
|
+ const headline = studentHeadline(page)
|
|
|
+ elements.push(shapeElement({ left, top, width, height, fill: palette.panel, radius: 26, shadow: softShadow() }))
|
|
|
+ if (headline) {
|
|
|
+ elements.push(textElement({ left: left + 42, top: top + 40, width: width - 84, height: 44, content: headline, fontSize: 34, color: palette.primary, bold: true }))
|
|
|
+ }
|
|
|
+ studentPoints(page, 4).forEach((point, index) => {
|
|
|
+ const itemTop = top + (headline ? 130 : 68) + index * 70
|
|
|
+ elements.push(shapeElement({ left: left + 42, top: itemTop + 8, width: 26, height: 26, fill: index % 2 ? palette.secondary : palette.primary, radius: 7 }))
|
|
|
+ elements.push(textElement({ left: left + 92, top: itemTop, width: width - 140, height: 38, content: point, fontSize: 27, color: palette.text, lineHeight: 1.25 }))
|
|
|
+ })
|
|
|
+ return elements
|
|
|
+}
|
|
|
+
|
|
|
+function compactBullets(items: string[], palette: Palette, left: number, top: number, width: number, mark: string): PPTElement[] {
|
|
|
+ const elements: PPTElement[] = []
|
|
|
+ items.slice(0, 4).forEach((item, index) => {
|
|
|
+ const itemTop = top + index * 64
|
|
|
+ elements.push(shapeElement({ left, top: itemTop, width, height: 48, fill: "#FFFFFF", opacity: 0.94, radius: 14, shadow: lightShadow() }))
|
|
|
+ elements.push(shapeElement({ left: left + 20, top: itemTop + 13, width: 22, height: 22, fill: index % 2 ? palette.secondary : palette.primary, radius: 5 }))
|
|
|
+ elements.push(textElement({ left: left + 62, top: itemTop + 8, width: width - 88, height: 30, content: item, fontSize: 24, color: palette.text }))
|
|
|
+ })
|
|
|
+ return elements
|
|
|
+}
|
|
|
+
|
|
|
+function visualBlock(page: AiCoursewarePage, palette: Palette, rect: VisualRect): PPTElement[] {
|
|
|
+ if (page.image?.url) {
|
|
|
+ return [
|
|
|
+ shapeElement({ left: rect.left - 14, top: rect.top + 16, width: rect.width, height: rect.height, fill: palette.warm, opacity: 0.45, radius: 26 }),
|
|
|
+ imageElement({ left: rect.left, top: rect.top, width: rect.width, height: rect.height, src: page.image.url, radius: 28 })
|
|
|
+ ]
|
|
|
+ }
|
|
|
+ return fallbackVisual(page, palette, rect)
|
|
|
+}
|
|
|
+
|
|
|
+function heroVisual(page: AiCoursewarePage, palette: Palette, rect: VisualRect): PPTElement[] {
|
|
|
+ return visualBlock(page, palette, rect)
|
|
|
+}
|
|
|
+
|
|
|
+function fallbackVisual(page: AiCoursewarePage, palette: Palette, rect: VisualRect): PPTElement[] {
|
|
|
+ const elements: PPTElement[] = []
|
|
|
+ const visual = visualConfig(page, palette)
|
|
|
+ const label = visualMain(page) || visual.label
|
|
|
+ const tags = visualElements(page).slice(0, 3)
|
|
|
+ elements.push(shapeElement({ left: rect.left, top: rect.top, width: rect.width, height: rect.height, fill: visual.light, radius: 28, shadow: softShadow() }))
|
|
|
+ elements.push(shapeElement({ left: rect.left + rect.width * 0.18, top: rect.top + rect.height * 0.13, width: rect.width * 0.64, height: rect.height * 0.36, fill: "#FFFFFF", opacity: 0.92, radius: 30 }))
|
|
|
+ elements.push(textElement({ left: rect.left + rect.width * 0.42, top: rect.top + rect.height * 0.2, width: rect.width * 0.18, height: 58, content: visual.icon, fontSize: 54, color: visual.color, bold: true }))
|
|
|
+ elements.push(...soundWave(rect.left + rect.width * 0.24, rect.top + rect.height * 0.58, palette, rect.width * 0.5, 70))
|
|
|
+ elements.push(textElement({ left: rect.left + rect.width * 0.14, top: rect.top + rect.height * 0.43, width: rect.width * 0.72, height: 34, content: trimText(label, 14), fontSize: 21, color: visual.color, bold: true }))
|
|
|
+ tags.forEach((tag, index) => {
|
|
|
+ const tagTop = rect.top + rect.height * 0.76 + index * 34
|
|
|
+ elements.push(shapeElement({ left: rect.left + rect.width * 0.22, top: tagTop, width: rect.width * 0.56, height: 26, fill: "#FFFFFF", opacity: 0.86, radius: 13 }))
|
|
|
+ elements.push(textElement({ left: rect.left + rect.width * 0.26, top: tagTop + 5, width: rect.width * 0.48, height: 16, content: trimText(tag, 12), fontSize: 14, color: palette.muted }))
|
|
|
+ })
|
|
|
+ return elements
|
|
|
+}
|
|
|
+
|
|
|
+function addSlideTitle(elements: PPTElement[], page: AiCoursewarePage, outline: AiCoursewareOutline, palette: Palette) {
|
|
|
+ elements.push(titleMark(92, 74, palette.primary))
|
|
|
+ elements.push(textElement({ left: 92, top: 102, width: 1120, height: 58, content: page.title || outline.title || outline.chapterName, fontSize: 40, color: palette.primary, bold: true }))
|
|
|
+}
|
|
|
+
|
|
|
+function addTeacherAction(elements: PPTElement[], page: AiCoursewarePage, palette: Palette, left: number, top: number, width: number, fallback = "") {
|
|
|
+ const text = fallback || page.interaction?.question || page.classroomAction?.studentTask || activityTask(page)
|
|
|
+ if (!text) return
|
|
|
+ elements.push(shapeElement({ left, top, width, height: 54, fill: palette.panelAlt, opacity: 0.92, radius: 14 }))
|
|
|
+ elements.push(textElement({ left: left + 24, top: top + 15, width: width - 48, height: 24, content: trimText(text, 48), fontSize: 20, color: palette.text }))
|
|
|
+}
|
|
|
+
|
|
|
+function titleMark(left: number, top: number, color: string): PPTShapeElement {
|
|
|
+ return shapeElement({ left, top, width: 78, height: 8, fill: color, radius: 5 })
|
|
|
+}
|
|
|
+
|
|
|
+function soundWave(left: number, top: number, palette: Palette, width = 330, maxHeight = 120): PPTElement[] {
|
|
|
+ const elements: PPTElement[] = []
|
|
|
+ const heights = [42, 76, 108, 70, 120, 88, 52].map(height => (height / 120) * maxHeight)
|
|
|
+ heights.forEach((height, index) => {
|
|
|
+ const barWidth = Math.max(14, width / 22)
|
|
|
+ const gap = Math.max(18, width / 9)
|
|
|
+ elements.push(shapeElement({ left: left + index * gap, top: top + (maxHeight - height) / 2, width: barWidth, height, fill: index % 2 === 0 ? palette.primary : palette.accent, radius: 8 }))
|
|
|
+ })
|
|
|
+ return elements
|
|
|
+}
|
|
|
+
|
|
|
+function baseElements(palette: Palette, options: AiPptOptions): PPTElement[] {
|
|
|
+ const elements: PPTElement[] = []
|
|
|
+ if (options.backgroundImage) {
|
|
|
+ elements.push(shapeElement({ left: 0, top: 0, width: SLIDE_WIDTH, height: SLIDE_HEIGHT, fill: "rgba(255,255,255,0.82)", opacity: 0.96 }))
|
|
|
+ }
|
|
|
+ elements.push(shapeElement({ left: 0, top: 0, width: 10, height: SLIDE_HEIGHT, fill: palette.primary, radius: 0 }))
|
|
|
+ elements.push(shapeElement({ left: 1260, top: 70, width: 210, height: 210, fill: palette.panelAlt, opacity: 0.28, radius: 100 }))
|
|
|
+ elements.push(shapeElement({ left: 1380, top: 690, width: 88, height: 88, fill: palette.warm, opacity: 0.3, radius: 42 }))
|
|
|
+ return elements
|
|
|
+}
|
|
|
+
|
|
|
+function studentHeadline(page: AiCoursewarePage) {
|
|
|
+ return cleanText(page.studentContent?.headline || page.subtitle || "")
|
|
|
+}
|
|
|
+
|
|
|
+function studentPoints(page: AiCoursewarePage, limit: number): string[] {
|
|
|
+ const structured = (page.studentContent?.points || []).map(cleanText).filter(Boolean)
|
|
|
+ const legacy = (page.bullets || []).map(cleanText).filter(Boolean)
|
|
|
+ const source = structured.length ? structured : legacy
|
|
|
+ return (source.length ? source : [studentHeadline(page) || page.title || "课堂内容"]).slice(0, limit).map(item => trimText(item, 34))
|
|
|
+}
|
|
|
+
|
|
|
+function activityTask(page: AiCoursewarePage) {
|
|
|
+ return cleanText(page.classroomAction?.studentTask || page.activity?.task || "")
|
|
|
+}
|
|
|
+
|
|
|
+function activitySteps(page: AiCoursewarePage, limit: number): string[] {
|
|
|
+ const steps = [page.classroomAction?.studentTask, ...(page.activity?.steps || [])].map(cleanText).filter(Boolean)
|
|
|
+ return (steps.length ? steps : studentPoints(page, limit)).slice(0, limit).map(item => trimText(item, 32))
|
|
|
+}
|
|
|
+
|
|
|
+function pageTitle(page: AiCoursewarePage, outline: AiCoursewareOutline) {
|
|
|
+ return cleanText(page.title || outline.title || outline.chapterName || "音乐课件")
|
|
|
+}
|
|
|
+
|
|
|
+function pageKind(page: AiCoursewarePage) {
|
|
|
+ return `${page.type || ""} ${page.visualPlan?.layout || ""} ${page.visual?.layout || ""}`.toLowerCase()
|
|
|
+}
|
|
|
+
|
|
|
+function slideRemark(page: AiCoursewarePage) {
|
|
|
+ return [page.speakerNotes, page.teacherTips, page.classroomAction?.teacherInstruction, page.teachingPurpose]
|
|
|
+ .map(cleanText)
|
|
|
+ .filter(Boolean)
|
|
|
+ .filter((item, index, array) => array.indexOf(item) === index)
|
|
|
+ .join("\n")
|
|
|
+}
|
|
|
+
|
|
|
+function visualMain(page: AiCoursewarePage) {
|
|
|
+ return cleanText(page.visualPlan?.mainVisual || page.visual?.mainVisual || "")
|
|
|
+}
|
|
|
+
|
|
|
+function visualElements(page: AiCoursewarePage) {
|
|
|
+ const elements = (page.visualPlan?.elements || []).map(cleanText).filter(Boolean)
|
|
|
+ const keywords = (page.visualPlan?.imageKeywords || page.visual?.imageKeywords || page.imageKeywords || []).map(cleanText).filter(Boolean)
|
|
|
+ return elements.length ? elements : keywords
|
|
|
+}
|
|
|
+
|
|
|
+function visualConfig(page: AiCoursewarePage, palette: Palette) {
|
|
|
+ const kind = pageKind(page)
|
|
|
+ const label = trimText(visualMain(page) || page.visual?.style || "", 12)
|
|
|
+ if (kind.includes("interaction") || kind.includes("activity")) return { icon: "?", label: label || "课堂互动", color: palette.accent, light: "#EEF2FF" }
|
|
|
+ if (kind.includes("summary")) return { icon: "✓", label: label || "课堂小结", color: "#2E7D32", light: "#EAF7EC" }
|
|
|
+ if (kind.includes("goal")) return { icon: "◎", label: label || "学习目标", color: "#7B1FA2", light: "#F3E8FF" }
|
|
|
+ if (kind.includes("listen")) return { icon: "♪", label: label || "听赏任务", color: palette.secondary, light: "#E5F6F3" }
|
|
|
+ if (kind.includes("rhythm") || kind.includes("skill")) return { icon: "♩", label: label || "节奏练习", color: palette.primary, light: "#FFF1E7" }
|
|
|
+ if (kind.includes("homework")) return { icon: "✎", label: label || "课后练习", color: "#6D4C41", light: "#F3ECE8" }
|
|
|
+ return { icon: "♫", label: label || "音乐课堂", color: palette.primary, light: palette.warm }
|
|
|
+}
|
|
|
+
|
|
|
+function backgroundConfig(palette: Palette, options: AiPptOptions): SlideBackground {
|
|
|
+ if (options.backgroundImage) return { type: "image", image: options.backgroundImage, imageSize: "cover" }
|
|
|
+ return { type: "solid", color: palette.background }
|
|
|
+}
|
|
|
+
|
|
|
+function buildPalette(outline: AiCoursewareOutline): Palette {
|
|
|
+ if (outline.style === "clean") {
|
|
|
+ return {
|
|
|
+ primary: "#2563EB",
|
|
|
+ secondary: "#0F766E",
|
|
|
+ accent: "#7C3AED",
|
|
|
+ warm: "#FEF3C7",
|
|
|
+ background: "#F7FAFC",
|
|
|
+ panel: "#FFFFFF",
|
|
|
+ panelAlt: "#EAF2FF",
|
|
|
+ text: "#1F2933",
|
|
|
+ muted: "#64748B"
|
|
|
+ }
|
|
|
+ }
|
|
|
+ const variants: Palette[] = [
|
|
|
+ {
|
|
|
+ primary: "#FF7A3D",
|
|
|
+ secondary: "#2BAE9F",
|
|
|
+ accent: "#5B8DEF",
|
|
|
+ warm: "#FFE8B8",
|
|
|
+ background: "#FFF7EC",
|
|
|
+ panel: "#FFFFFF",
|
|
|
+ panelAlt: "#EAF6F3",
|
|
|
+ text: "#263238",
|
|
|
+ muted: "#6B7C8F"
|
|
|
+ },
|
|
|
+ {
|
|
|
+ primary: "#EF5DA8",
|
|
|
+ secondary: "#36B37E",
|
|
|
+ accent: "#4C6FFF",
|
|
|
+ warm: "#FFF0C7",
|
|
|
+ background: "#FFF8F8",
|
|
|
+ panel: "#FFFFFF",
|
|
|
+ panelAlt: "#ECFDF5",
|
|
|
+ text: "#263238",
|
|
|
+ muted: "#6B7C8F"
|
|
|
+ }
|
|
|
+ ]
|
|
|
+ return variants[Math.abs((outline.chapterName || outline.title || "").length) % variants.length]
|
|
|
+}
|
|
|
+
|
|
|
+function textElement(params: {
|
|
|
+ left: number
|
|
|
+ top: number
|
|
|
+ width: number
|
|
|
+ height: number
|
|
|
+ content: string
|
|
|
+ fontSize: number
|
|
|
+ color: string
|
|
|
+ bold?: boolean
|
|
|
+ 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-family:${FONT_NAME};font-size:${params.fontSize}px;${params.bold ? "font-weight:700;" : ""}">${escapeHtml(params.content)}</div>`,
|
|
|
+ defaultFontName: FONT_NAME,
|
|
|
+ defaultColor: params.color,
|
|
|
+ lineHeight: params.lineHeight || 1.25,
|
|
|
+ wordSpace: 0,
|
|
|
+ opacity: 1
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function imageElement(params: { left: number; top: number; width: number; height: number; src: string; radius?: number }): 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: params.radius || 18,
|
|
|
+ shadow: softShadow()
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function shapeElement(params: {
|
|
|
+ left: number
|
|
|
+ top: number
|
|
|
+ width: number
|
|
|
+ height: number
|
|
|
+ fill: string
|
|
|
+ opacity?: number
|
|
|
+ radius?: number
|
|
|
+ shadow?: PPTElementShadow
|
|
|
+}): PPTShapeElement {
|
|
|
+ return {
|
|
|
+ id: nanoid(10),
|
|
|
+ type: "shape",
|
|
|
+ left: params.left,
|
|
|
+ top: params.top,
|
|
|
+ width: params.width,
|
|
|
+ height: params.height,
|
|
|
+ rotate: 0,
|
|
|
+ viewBox: [200, 200],
|
|
|
+ path: roundedRectPath(params.radius ?? 18),
|
|
|
+ fixedRatio: false,
|
|
|
+ fill: params.fill,
|
|
|
+ opacity: params.opacity ?? 1,
|
|
|
+ shadow: params.shadow
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function keepInCanvas(elements: PPTElement[]) {
|
|
|
+ return elements.map(element => ({
|
|
|
+ ...element,
|
|
|
+ left: Math.max(0, Math.min(element.left, SLIDE_WIDTH - 1)),
|
|
|
+ top: Math.max(0, Math.min(element.top, SLIDE_HEIGHT - 1)),
|
|
|
+ width: Math.max(1, Math.min(element.width, SLIDE_WIDTH - element.left)),
|
|
|
+ height: Math.max(1, Math.min(element.height, SLIDE_HEIGHT - element.top))
|
|
|
+ })) as PPTElement[]
|
|
|
+}
|
|
|
+
|
|
|
+function softShadow(): PPTElementShadow {
|
|
|
+ return { h: 0, v: 8, blur: 22, color: "rgba(38, 50, 56, 0.14)" }
|
|
|
+}
|
|
|
+
|
|
|
+function lightShadow(): PPTElementShadow {
|
|
|
+ return { h: 0, v: 6, blur: 16, color: "rgba(38, 50, 56, 0.1)" }
|
|
|
+}
|
|
|
+
|
|
|
+function roundedRectPath(radius: number) {
|
|
|
+ const r = Math.max(0, Math.min(radius, 100))
|
|
|
+ return `M ${r} 0 L ${200 - r} 0 Q 200 0 200 ${r} L 200 ${200 - r} Q 200 200 ${200 - r} 200 L ${r} 200 Q 0 200 0 ${200 - r} L 0 ${r} Q 0 0 ${r} 0 Z`
|
|
|
+}
|
|
|
+
|
|
|
+function cleanText(value?: string) {
|
|
|
+ return String(value || "").replace(/\s+/g, " ").trim()
|
|
|
+}
|
|
|
+
|
|
|
+function trimText(value: string, maxLength: number) {
|
|
|
+ const text = cleanText(value)
|
|
|
+ return text.length > maxLength ? `${text.slice(0, maxLength - 1)}...` : text
|
|
|
+}
|
|
|
+
|
|
|
+function escapeHtml(value: string) {
|
|
|
+ return cleanText(value).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">")
|
|
|
+}
|