|
|
@@ -1,5 +1,9 @@
|
|
|
import type { AiCoursewareTemplateId } from "@/config/aiCoursewareTemplates"
|
|
|
|
|
|
+export type TemplatePageRole = "cover" | "intro" | "goal" | "story" | "listen" | "lyric" | "notation" | "instrument" | "compare" | "knowledge" | "rhythm" | "interaction" | "performance" | "practice" | "activity" | "summary" | "homework" | "content"
|
|
|
+export type TemplatePageLayout = "cover" | "hero" | "split" | "list" | "compare" | "activity" | "summary"
|
|
|
+export type TemplateSlotKind = "title" | "meta" | "headline" | "body" | "image" | "media" | "left" | "right" | "steps"
|
|
|
+
|
|
|
export interface TemplateMasterElement {
|
|
|
type: "shape" | "text"
|
|
|
left: number
|
|
|
@@ -15,66 +19,137 @@ export interface TemplateMasterElement {
|
|
|
bold?: boolean
|
|
|
}
|
|
|
|
|
|
+export interface TemplateSlot {
|
|
|
+ id: string
|
|
|
+ kind: TemplateSlotKind
|
|
|
+ left: number
|
|
|
+ top: number
|
|
|
+ width: number
|
|
|
+ height: number
|
|
|
+ maxItems?: number
|
|
|
+ preferredFontSize?: number
|
|
|
+ minimumFontSize?: number
|
|
|
+}
|
|
|
+
|
|
|
export interface TemplateMasterPage {
|
|
|
+ id: string
|
|
|
+ layout: TemplatePageLayout
|
|
|
+ roles: TemplatePageRole[]
|
|
|
background: string
|
|
|
base: TemplateMasterElement[]
|
|
|
chrome: TemplateMasterElement[]
|
|
|
+ slots: TemplateSlot[]
|
|
|
}
|
|
|
|
|
|
export interface TemplateMaster {
|
|
|
- cover: TemplateMasterPage & {
|
|
|
- label: string
|
|
|
- titleColor: string
|
|
|
- subtitleColor: string
|
|
|
- imageFrame: { left: number; top: number; width: number; height: number; radius: number }
|
|
|
- }
|
|
|
- content: TemplateMasterPage
|
|
|
+ id: AiCoursewareTemplateId
|
|
|
+ label: string
|
|
|
+ titleColor: string
|
|
|
+ coverTitleColor: string
|
|
|
+ textColor: string
|
|
|
+ mutedColor: string
|
|
|
+ surfaceColor: string
|
|
|
+ borderColor: string
|
|
|
+ pages: TemplateMasterPage[]
|
|
|
}
|
|
|
|
|
|
-const shape = (left: number, top: number, width: number, height: number, fill: string, opacity = 1, radius = 0): TemplateMasterElement => ({ type: "shape", left, top, width, height, fill, opacity, radius })
|
|
|
-const text = (left: number, top: number, width: number, content: string, color: string): TemplateMasterElement => ({ type: "text", left, top, width, height: 30, content, fontSize: 14, color, bold: true })
|
|
|
+interface MasterTheme extends Omit<TemplateMaster, "pages"> {
|
|
|
+ background: string
|
|
|
+ coverBackground: string
|
|
|
+ coverBase: TemplateMasterElement[]
|
|
|
+ contentBase: TemplateMasterElement[]
|
|
|
+ contentChrome: TemplateMasterElement[]
|
|
|
+}
|
|
|
|
|
|
-const master = (
|
|
|
- cover: TemplateMaster["cover"],
|
|
|
- content: TemplateMaster["content"]
|
|
|
-): TemplateMaster => ({ cover, content })
|
|
|
+const shape = (left: number, top: number, width: number, height: number, fill: string, opacity = 1, radius = 0): TemplateMasterElement => ({ type: "shape", left, top, width, height, fill, opacity, radius })
|
|
|
+const label = (content: string, color: string): TemplateMasterElement => ({ type: "text", left: 82, top: 28, width: 300, height: 34, content, fontSize: 18, color, bold: true })
|
|
|
+const slot = (id: string, kind: TemplateSlotKind, left: number, top: number, width: number, height: number, maxItems?: number, preferredFontSize?: number, minimumFontSize?: number): TemplateSlot => ({ id, kind, left, top, width, height, maxItems, preferredFontSize, minimumFontSize })
|
|
|
|
|
|
-const DEFAULT_MASTER: TemplateMaster = master(
|
|
|
- { background: "#F7FBFA", label: "MUSIC CLASS", titleColor: "#20313A", subtitleColor: "#6B7E88", imageFrame: { left: 935, top: 175, width: 490, height: 470, radius: 24 }, base: [], chrome: [] },
|
|
|
- { background: "#F7FBFA", base: [], chrome: [text(82, 28, 260, "MUSIC CLASS", "#2878C8")] }
|
|
|
-)
|
|
|
+function createMaster(theme: MasterTheme): TemplateMaster {
|
|
|
+ const page = (id: string, layout: TemplatePageLayout, roles: TemplatePageRole[], slots: TemplateSlot[], cover = false): TemplateMasterPage => ({
|
|
|
+ id, layout, roles, slots,
|
|
|
+ background: cover ? theme.coverBackground : theme.background,
|
|
|
+ base: cover ? theme.coverBase : theme.contentBase,
|
|
|
+ chrome: cover ? [] : theme.contentChrome
|
|
|
+ })
|
|
|
+ return {
|
|
|
+ id: theme.id,
|
|
|
+ label: theme.label,
|
|
|
+ titleColor: theme.titleColor,
|
|
|
+ coverTitleColor: theme.coverTitleColor,
|
|
|
+ textColor: theme.textColor,
|
|
|
+ mutedColor: theme.mutedColor,
|
|
|
+ surfaceColor: theme.surfaceColor,
|
|
|
+ borderColor: theme.borderColor,
|
|
|
+ pages: [
|
|
|
+ page("cover", "cover", ["cover"], [slot("title", "title", 120, 190, 710, 170, 1, 70, 48), slot("meta", "meta", 124, 405, 650, 48, 1, 24, 22), slot("headline", "headline", 124, 540, 650, 120, 2, 28, 22), slot("image", "image", 935, 175, 490, 470)], true),
|
|
|
+ page("hero", "hero", ["intro", "story", "listen"], [slot("title", "title", 92, 120, 1390, 78, 1, 44, 32), slot("image", "image", 92, 230, 900, 520), slot("body", "body", 1040, 230, 440, 360, 4, 30, 24), slot("media", "media", 1040, 630, 440, 100)]),
|
|
|
+ page("split", "split", ["lyric", "notation", "instrument", "content"], [slot("title", "title", 92, 120, 1390, 78, 1, 44, 32), slot("image", "image", 92, 230, 650, 500), slot("body", "body", 800, 230, 680, 500, 5, 30, 23), slot("media", "media", 92, 760, 1388, 84)]),
|
|
|
+ page("list", "list", ["goal", "knowledge"], [slot("title", "title", 92, 120, 1390, 78, 1, 44, 32), slot("body", "body", 92, 230, 1388, 520, 5, 30, 23)]),
|
|
|
+ page("compare", "compare", ["compare", "interaction"], [slot("title", "title", 92, 120, 1390, 78, 1, 44, 32), slot("left", "left", 92, 240, 650, 470, 3, 30, 23), slot("right", "right", 830, 240, 650, 470, 3, 30, 23), slot("media", "media", 92, 760, 1388, 84)]),
|
|
|
+ page("activity", "activity", ["activity", "performance", "rhythm"], [slot("title", "title", 92, 120, 1390, 78, 1, 44, 32), slot("headline", "headline", 92, 230, 1388, 86, 1, 34, 26), slot("steps", "steps", 92, 360, 1388, 360, 4, 28, 22), slot("media", "media", 92, 760, 1388, 84)]),
|
|
|
+ page("summary", "summary", ["practice", "summary", "homework"], [slot("title", "title", 92, 120, 1390, 78, 1, 44, 32), slot("body", "body", 92, 240, 1388, 470, 5, 30, 23), slot("headline", "headline", 92, 750, 1388, 74, 1, 28, 22)])
|
|
|
+ ]
|
|
|
+ }
|
|
|
+}
|
|
|
|
|
|
-/**
|
|
|
- * 新增模板时,只需在这里注册封面与内页母版,并在 aiCoursewareTemplates.ts 增加选择器信息和 PPTX 文件。
|
|
|
- * base 位于内容下方,chrome 位于内容上方,避免全屏资源页跳过模板视觉。
|
|
|
- */
|
|
|
export const AI_COURSEWARE_TEMPLATE_MASTERS: Record<string, TemplateMaster> = {
|
|
|
- "fairytale-forest": master(
|
|
|
- { background: "#F7FBF8", label: "MUSIC FOREST", titleColor: "#24495C", subtitleColor: "#69808A", imageFrame: { left: 935, top: 175, width: 490, height: 470, radius: 28 }, base: [shape(1150, -170, 470, 470, "#F7D66D", 0.85, 235), shape(-100, 710, 550, 290, "#32A07C", 0.74, 200), shape(1140, 630, 550, 320, "#3478C8", 0.16, 180)], chrome: [] },
|
|
|
- { background: "#F7FBF8", base: [shape(1370, -90, 300, 300, "#F7D66D", 0.42, 150), shape(-80, 760, 300, 220, "#32A07C", 0.22, 150)], chrome: [text(82, 28, 240, "MUSIC FOREST", "#3478C8")] }
|
|
|
- ),
|
|
|
- "warm-handdrawn": master(
|
|
|
- { background: "#F7FBFA", label: "MUSIC FOR TODAY", titleColor: "#284C52", subtitleColor: "#687C82", imageFrame: { left: 1060, top: 105, width: 430, height: 470, radius: 36 }, base: [shape(0, 0, 1600, 22, "#8FC6B6"), shape(1060, 105, 430, 470, "#FFFFFF", 1, 36), shape(1210, 38, 180, 180, "#F2CC8F", 0.55, 90)], chrome: [] },
|
|
|
- { background: "#F7FBFA", base: [shape(0, 0, 1600, 16, "#8FC6B6", 0.9)], chrome: [shape(1405, 730, 230, 230, "#E4F1EE", 0.82, 115), text(82, 28, 280, "MUSIC FOR TODAY", "#347D73")] }
|
|
|
- ),
|
|
|
- "music-doodle": master(
|
|
|
- { background: "#17233D", label: "MUSIC DOODLE", titleColor: "#FFFFFF", subtitleColor: "#D5E0F1", imageFrame: { left: 935, top: 175, width: 490, height: 470, radius: 28 }, base: [shape(0, 0, 1600, 16, "#F6C945"), shape(1040, 135, 290, 290, "#F6C945", 1, 145), shape(1260, 390, 180, 180, "#80B9D7", 1, 90), { ...text(1150, 205, 110, "♪", "#17233D"), height: 90, fontSize: 76 }], chrome: [] },
|
|
|
- { background: "#FFF8F2", base: [], chrome: [shape(0, 0, 1600, 84, "#17233D"), shape(0, 84, 1600, 8, "#F6C945"), { ...text(76, 26, 100, "♪", "#F6C945"), height: 38, fontSize: 28 }, { ...text(1420, 24, 100, "♫", "#80B9D7"), height: 38, fontSize: 28 }] }
|
|
|
- ),
|
|
|
- "ink-wash": master(
|
|
|
- { background: "#F7F1E7", label: "东方音乐", titleColor: "#332A24", subtitleColor: "#746A5F", imageFrame: { left: 935, top: 175, width: 490, height: 470, radius: 12 }, base: [shape(1110, 590, 580, 270, "#36594B", 0.16, 130), shape(1250, 665, 380, 190, "#9D2B22", 0.17, 95), shape(92, 142, 6, 560, "#9D2B22", 0.75, 2)], chrome: [] },
|
|
|
- { background: "#F7F1E7", base: [shape(1250, 690, 420, 250, "#36594B", 0.12, 125)], chrome: [shape(80, 116, 5, 680, "#9D2B22", 0.45, 2), text(116, 79, 220, "东方音乐", "#9D2B22")] }
|
|
|
- ),
|
|
|
- "stage-documentary": master(
|
|
|
- { background: "#1C2940", label: "MUSIC ON STAGE", titleColor: "#FFFFFF", subtitleColor: "#DDE6F4", imageFrame: { left: 935, top: 175, width: 490, height: 470, radius: 0 }, base: [shape(1040, -170, 610, 610, "#D8B45B", 0.74, 305), shape(1180, 170, 430, 430, "#8AA1C2", 0.42, 215), shape(0, 830, 1600, 70, "#D8B45B", 0.72)], chrome: [] },
|
|
|
- { background: "#F7F8FA", base: [], chrome: [shape(0, 0, 1600, 96, "#1C2940"), shape(0, 76, 1600, 20, "#D8B45B", 0.84), shape(1450, 0, 150, 18, "#E7C469"), text(82, 29, 260, "MUSIC ON STAGE", "#FFFFFF")] }
|
|
|
- ),
|
|
|
- "modern-geometry": master(
|
|
|
- { background: "#F8FBFA", label: "MUSIC STRUCTURE", titleColor: "#20342F", subtitleColor: "#6B817A", imageFrame: { left: 935, top: 175, width: 490, height: 470, radius: 14 }, base: [shape(1120, 0, 480, 900, "#E7F3EF"), shape(1220, 160, 270, 120, "#4FAD93", 0.25, 14), shape(1345, 610, 180, 180, "#E07A35", 0.3, 16)], chrome: [] },
|
|
|
- { background: "#F8FBFA", base: [shape(1325, 0, 275, 900, "#E7F3EF", 0.78)], chrome: [shape(1415, 88, 145, 68, "#4FAD93", 0.24, 8), shape(1470, 690, 110, 110, "#E07A35", 0.26, 10), shape(80, 118, 1160, 2, "#287B72", 0.18), text(82, 28, 280, "MUSIC STRUCTURE", "#287B72")] }
|
|
|
- )
|
|
|
+ "fairytale-forest": createMaster({ id: "fairytale-forest", label: "MUSIC FOREST", background: "#F7FBF8", coverBackground: "#F7FBF8", titleColor: "#24495C", coverTitleColor: "#24495C", textColor: "#24333B", mutedColor: "#69808A", surfaceColor: "#FFFFFF", borderColor: "#BFD8D1", coverBase: [shape(1150, 0, 450, 300, "#F7D66D", 0.85, 220), shape(0, 710, 450, 190, "#32A07C", 0.72, 150), shape(1160, 650, 440, 250, "#3478C8", 0.16, 150)], contentBase: [shape(1370, 0, 230, 200, "#F7D66D", 0.35, 110), shape(0, 780, 220, 120, "#32A07C", 0.2, 90)], contentChrome: [label("MUSIC FOREST", "#3478C8")] }),
|
|
|
+ "warm-handdrawn": createMaster({ id: "warm-handdrawn", label: "MUSIC FOR TODAY", background: "#F7FBFA", coverBackground: "#F7FBFA", titleColor: "#284C52", coverTitleColor: "#284C52", textColor: "#21343A", mutedColor: "#687C82", surfaceColor: "#FFFFFF", borderColor: "#9ECABD", coverBase: [shape(0, 0, 1600, 22, "#8FC6B6"), shape(1060, 105, 430, 470, "#FFFFFF", 1, 36), shape(1210, 38, 180, 180, "#F2CC8F", 0.55, 90)], contentBase: [shape(0, 0, 1600, 16, "#8FC6B6", 0.9)], contentChrome: [shape(1405, 750, 195, 150, "#E4F1EE", 0.82, 90), label("MUSIC FOR TODAY", "#347D73")] }),
|
|
|
+ "music-doodle": createMaster({ id: "music-doodle", label: "MUSIC DOODLE", background: "#FFF8F2", coverBackground: "#17233D", titleColor: "#2D2B36", coverTitleColor: "#FFFFFF", textColor: "#2D2B36", mutedColor: "#756F7C", surfaceColor: "#FFFFFF", borderColor: "#93B0D8", coverBase: [shape(0, 0, 1600, 16, "#F6C945"), shape(1040, 135, 290, 290, "#F6C945", 1, 145), shape(1260, 390, 180, 180, "#80B9D7", 1, 90)], contentBase: [], contentChrome: [shape(0, 0, 1600, 78, "#17233D"), shape(0, 78, 1600, 8, "#F6C945"), label("♪ MUSIC DOODLE", "#F6C945")] }),
|
|
|
+ "ink-wash": createMaster({ id: "ink-wash", label: "东方音乐", background: "#F7F1E7", coverBackground: "#F7F1E7", titleColor: "#332A24", coverTitleColor: "#332A24", textColor: "#332A24", mutedColor: "#746A5F", surfaceColor: "#FFFDF8", borderColor: "#B99058", coverBase: [shape(1110, 590, 490, 270, "#36594B", 0.16, 130), shape(1250, 665, 350, 190, "#9D2B22", 0.17, 95), shape(92, 142, 6, 560, "#9D2B22", 0.75, 2)], contentBase: [shape(1250, 700, 350, 200, "#36594B", 0.12, 110)], contentChrome: [shape(80, 116, 5, 680, "#9D2B22", 0.45, 2), label("东方音乐", "#9D2B22")] }),
|
|
|
+ "stage-documentary": createMaster({ id: "stage-documentary", label: "MUSIC ON STAGE", background: "#F7F8FA", coverBackground: "#1C2940", titleColor: "#172231", coverTitleColor: "#FFFFFF", textColor: "#172231", mutedColor: "#697586", surfaceColor: "#FFFFFF", borderColor: "#C7D2E2", coverBase: [shape(1040, 0, 560, 420, "#D8B45B", 0.74, 280), shape(1180, 170, 420, 430, "#8AA1C2", 0.42, 210), shape(0, 830, 1600, 70, "#D8B45B", 0.72)], contentBase: [], contentChrome: [shape(0, 0, 1600, 78, "#1C2940"), shape(0, 78, 1600, 8, "#D8B45B"), label("MUSIC ON STAGE", "#FFFFFF")] }),
|
|
|
+ "modern-geometry": createMaster({ id: "modern-geometry", label: "MUSIC STRUCTURE", background: "#F8FBFA", coverBackground: "#F8FBFA", titleColor: "#20342F", coverTitleColor: "#20342F", textColor: "#20342F", mutedColor: "#6B817A", surfaceColor: "#FFFFFF", borderColor: "#7BB6A4", coverBase: [shape(1120, 0, 480, 900, "#E7F3EF"), shape(1220, 160, 270, 120, "#4FAD93", 0.25, 14), shape(1345, 610, 180, 180, "#E07A35", 0.3, 16)], contentBase: [shape(1325, 0, 275, 900, "#E7F3EF", 0.78)], contentChrome: [shape(1415, 18, 145, 58, "#4FAD93", 0.24, 8), shape(1470, 690, 110, 110, "#E07A35", 0.26, 10), label("MUSIC STRUCTURE", "#287B72")] })
|
|
|
}
|
|
|
|
|
|
-export function getAiCoursewareTemplateMaster(templateId?: AiCoursewareTemplateId): TemplateMaster {
|
|
|
- return AI_COURSEWARE_TEMPLATE_MASTERS[templateId || ""] || DEFAULT_MASTER
|
|
|
+export function getAiCoursewareTemplateMaster(templateId: AiCoursewareTemplateId): TemplateMaster {
|
|
|
+ const master = AI_COURSEWARE_TEMPLATE_MASTERS[templateId]
|
|
|
+ if (!master) throw new Error(`课件模板未注册母版:${templateId}`)
|
|
|
+ return master
|
|
|
+}
|
|
|
+
|
|
|
+export function getTemplateMasterPage(master: TemplateMaster, role: TemplatePageRole): TemplateMasterPage {
|
|
|
+ const page = master.pages.find(item => item.roles.includes(role))
|
|
|
+ if (!page) throw new Error(`课件模板 ${master.id} 缺少页面角色:${role}`)
|
|
|
+ return page
|
|
|
+}
|
|
|
+
|
|
|
+export function validateTemplateMasterRegistry(templateIds: AiCoursewareTemplateId[]) {
|
|
|
+ const missing = templateIds.filter(id => !AI_COURSEWARE_TEMPLATE_MASTERS[id])
|
|
|
+ const orphaned = Object.keys(AI_COURSEWARE_TEMPLATE_MASTERS).filter(id => !templateIds.includes(id))
|
|
|
+ const problems: string[] = []
|
|
|
+ if (missing.length) problems.push(`missing=${missing.join(",")}`)
|
|
|
+ if (orphaned.length) problems.push(`orphaned=${orphaned.join(",")}`)
|
|
|
+
|
|
|
+ const requiredSlots: Record<TemplatePageLayout, TemplateSlotKind[]> = {
|
|
|
+ cover: ["title", "meta", "headline", "image"],
|
|
|
+ hero: ["title", "image", "body", "media"],
|
|
|
+ split: ["title", "image", "body", "media"],
|
|
|
+ list: ["title", "body"],
|
|
|
+ compare: ["title", "left", "right", "media"],
|
|
|
+ activity: ["title", "headline", "steps", "media"],
|
|
|
+ summary: ["title", "body", "headline"]
|
|
|
+ }
|
|
|
+ const requiredRoles: TemplatePageRole[] = ["cover", "intro", "goal", "story", "listen", "lyric", "notation", "instrument", "compare", "knowledge", "rhythm", "interaction", "performance", "practice", "activity", "summary", "homework", "content"]
|
|
|
+
|
|
|
+ Object.entries(AI_COURSEWARE_TEMPLATE_MASTERS).forEach(([registryId, master]) => {
|
|
|
+ if (registryId !== master.id) problems.push(`${registryId}: master.id=${master.id}`)
|
|
|
+ const roleCounts = new Map<TemplatePageRole, number>()
|
|
|
+ master.pages.forEach(page => {
|
|
|
+ page.roles.forEach(role => roleCounts.set(role, (roleCounts.get(role) || 0) + 1))
|
|
|
+ const slotKinds = new Set(page.slots.map(item => item.kind))
|
|
|
+ const absentSlots = requiredSlots[page.layout].filter(kind => !slotKinds.has(kind))
|
|
|
+ if (absentSlots.length) problems.push(`${registryId}/${page.id}: slots=${absentSlots.join(",")}`)
|
|
|
+ page.slots.forEach(item => {
|
|
|
+ const outsideCanvas = item.left < 0 || item.top < 0 || item.width <= 0 || item.height <= 0 || item.left + item.width > 1600 || item.top + item.height > 900
|
|
|
+ if (outsideCanvas) problems.push(`${registryId}/${page.id}/${item.id}: out-of-canvas`)
|
|
|
+ })
|
|
|
+ })
|
|
|
+ requiredRoles.forEach(role => {
|
|
|
+ const count = roleCounts.get(role) || 0
|
|
|
+ if (count !== 1) problems.push(`${registryId}: role=${role}, count=${count}`)
|
|
|
+ })
|
|
|
+ })
|
|
|
+
|
|
|
+ if (problems.length) throw new Error(`课件模板注册不一致:${problems.join("; ")}`)
|
|
|
}
|