yonge 1 week ago
parent
commit
0a35542b25

+ 1 - 1
dist/assets/index-2JQ7pu3X.js → dist/assets/index-BKbgOKhw.js

@@ -1 +1 @@
-import{_ as a}from"./404-R0y5dkvZ.js";import{a5 as o,en as t,ae as r,af as c,bO as _,bP as d,ak as s,bH as p}from"./index-Qxv2V3to.js";const i=e=>(_("data-v-dda25179"),e=e(),d(),e),n={class:"errorPage"},m=i(()=>s("div",{class:"error"},[s("img",{src:a,class:"img",alt:""}),s("div",{class:"tit"},"页面找不到了~")],-1)),l=[m],f=o({__name:"errorPage",setup(e){return t(),(u,h)=>(r(),c("div",n,l))}}),I=p(f,[["__scopeId","data-v-dda25179"]]);export{I as default};
+import{_ as a}from"./404-R0y5dkvZ.js";import{a5 as o,en as t,ae as r,af as c,bO as _,bP as d,ak as s,bH as p}from"./index-CjBu6_TY.js";const i=e=>(_("data-v-dda25179"),e=e(),d(),e),n={class:"errorPage"},m=i(()=>s("div",{class:"error"},[s("img",{src:a,class:"img",alt:""}),s("div",{class:"tit"},"页面找不到了~")],-1)),l=[m],f=o({__name:"errorPage",setup(e){return t(),(u,h)=>(r(),c("div",n,l))}}),I=p(f,[["__scopeId","data-v-dda25179"]]);export{I as default};

+ 1 - 1
dist/assets/index-SW7j3WVK.js → dist/assets/index-BWMDK7H-.js

@@ -1 +1 @@
-import{_ as t}from"./404-R0y5dkvZ.js";import{E as c}from"./index-DZ98mI-K.js";import{a5 as n,ae as i,af as r,ak as o,aq as _,am as d,P as l,aS as p,bO as m,bP as f,bH as u}from"./index-Qxv2V3to.js";const s=a=>(m("data-v-340bbdaa"),a=a(),f(),a),h={class:"login"},b={class:"error"},g=s(()=>o("img",{src:t,class:"img",alt:""},null,-1)),k=s(()=>o("div",{class:"tit"},"登录已过期或服务器错误!",-1)),v=n({__name:"login",setup(a){function e(){window.close()}return(x,B)=>(i(),r("div",h,[o("div",b,[g,k,_(l(c),{class:"backBtn",type:"primary",plain:"",onClick:e},{default:d(()=>[p("关闭页面")]),_:1})])]))}}),C=u(v,[["__scopeId","data-v-340bbdaa"]]);export{C as default};
+import{_ as t}from"./404-R0y5dkvZ.js";import{E as c}from"./index-D0AS__l3.js";import{a5 as n,ae as i,af as r,ak as o,aq as _,am as d,P as l,aS as p,bO as m,bP as f,bH as u}from"./index-CjBu6_TY.js";const s=a=>(m("data-v-340bbdaa"),a=a(),f(),a),h={class:"login"},b={class:"error"},g=s(()=>o("img",{src:t,class:"img",alt:""},null,-1)),k=s(()=>o("div",{class:"tit"},"登录已过期或服务器错误!",-1)),v=n({__name:"login",setup(a){function e(){window.close()}return(x,B)=>(i(),r("div",h,[o("div",b,[g,k,_(l(c),{class:"backBtn",type:"primary",plain:"",onClick:e},{default:d(()=>[p("关闭页面")]),_:1})])]))}}),C=u(v,[["__scopeId","data-v-340bbdaa"]]);export{C as default};

File diff suppressed because it is too large
+ 0 - 0
dist/assets/index-B_R5r3G0.js


+ 1 - 1
dist/assets/index-Dj15YXuC.js → dist/assets/index-BoyV6XAB.js

@@ -1 +1 @@
-import{u as s,af as r}from"./index-DJYV1XR6.js";import{a5 as c,cD as n,R as p,ae as t,af as _,al as u,ag as i,bH as f}from"./index-Qxv2V3to.js";const l={class:"pptScreen"},m=c({__name:"pptScreen",setup(d){const a=n(),e=p(!0),o=s();return a.setScreenMode("pptScreen"),o.initPPTData().then(()=>{e.value=!1}),(S,k)=>(t(),_("div",l,[e.value?i("",!0):(t(),u(r,{key:0}))]))}}),h=f(m,[["__scopeId","data-v-678905e9"]]);export{h as default};
+import{u as s,af as r}from"./index-DGk1cQw8.js";import{a5 as c,cD as n,R as p,ae as t,af as _,al as u,ag as i,bH as f}from"./index-CjBu6_TY.js";const l={class:"pptScreen"},m=c({__name:"pptScreen",setup(d){const a=n(),e=p(!0),o=s();return a.setScreenMode("pptScreen"),o.initPPTData().then(()=>{e.value=!1}),(S,k)=>(t(),_("div",l,[e.value?i("",!0):(t(),u(r,{key:0}))]))}}),h=f(m,[["__scopeId","data-v-678905e9"]]);export{h as default};

File diff suppressed because it is too large
+ 0 - 0
dist/assets/index-BxJOV7uU.css


File diff suppressed because it is too large
+ 1 - 1
dist/assets/index-CjBu6_TY.js


File diff suppressed because it is too large
+ 0 - 0
dist/assets/index-D0AS__l3.js


+ 1 - 1
dist/assets/index-a1O_7-2N.js → dist/assets/index-DBrB9ftR.js

@@ -1 +1 @@
-import{u as c,af as l}from"./index-DJYV1XR6.js";import{w as p,bA as d,a5 as g,cD as m,R as t,ae as r,af as u,al as f,ag as w,bH as k}from"./index-Qxv2V3to.js";import"./shapes-H9Hj85K_.js";const S=()=>{function o(a){if(window.parent===window)return;const{api:e,playState:n}=a.data||{};e==="headerTogge"?window.parent.postMessage({api:"headerTogge",playState:n},"*"):e==="changeTogge"?window.parent.postMessage({api:"changeTogge"},"*"):["clickTempo","clickViewFigner"].includes(e)&&window.parent.postMessage({api:e},"*")}p(()=>{window.addEventListener("message",o)}),d(()=>{window.removeEventListener("message",o)})},_=g({__name:"mobileScreen",setup(o){S();const a=m(),e=t(!0),n=t(!1),i=c();return a.setScreenMode("mobileScreen"),i.initPPTData().then(()=>{e.value=!1}),(v,s)=>(r(),u("div",{class:"mobileScreen",onClick:s[0]||(s[0]=b=>n.value=!1)},[e.value?w("",!0):(r(),f(l,{key:0}))]))}}),P=k(_,[["__scopeId","data-v-90b2692a"]]);export{P as default};
+import{u as c,af as l}from"./index-DGk1cQw8.js";import{w as p,bA as d,a5 as g,cD as m,R as t,ae as r,af as u,al as f,ag as w,bH as k}from"./index-CjBu6_TY.js";import"./shapes-CvQU5hOE.js";const S=()=>{function o(a){if(window.parent===window)return;const{api:e,playState:n}=a.data||{};e==="headerTogge"?window.parent.postMessage({api:"headerTogge",playState:n},"*"):e==="changeTogge"?window.parent.postMessage({api:"changeTogge"},"*"):["clickTempo","clickViewFigner"].includes(e)&&window.parent.postMessage({api:e},"*")}p(()=>{window.addEventListener("message",o)}),d(()=>{window.removeEventListener("message",o)})},_=g({__name:"mobileScreen",setup(o){S();const a=m(),e=t(!0),n=t(!1),i=c();return a.setScreenMode("mobileScreen"),i.initPPTData().then(()=>{e.value=!1}),(v,s)=>(r(),u("div",{class:"mobileScreen",onClick:s[0]||(s[0]=b=>n.value=!1)},[e.value?w("",!0):(r(),f(l,{key:0}))]))}}),P=k(_,[["__scopeId","data-v-90b2692a"]]);export{P as default};

+ 1 - 1
dist/assets/index-DJYV1XR6.js → dist/assets/index-DGk1cQw8.js

@@ -1,4 +1,4 @@
-import{cH as dw,cI as hw,aG as vw,cJ as pw,cK as gw,cL as mw,cM as yw,ah as Yt,cN as Sw,cO as bw,cP as xw,cQ as Cw,aM as ww,aF as Tw,cR as Iw,aB as c1,bg as _w,cS as Aw,cT as Ew,cU as Pw,cV as Mw,cW as Rw,cX as Dw,cY as Lw,aE as kw,cZ as Ow,s as Ae,bl as Bw,al as je,ag as ze,af as ve,ak as Z,c_ as Nw,c$ as Fw,d0 as $w,d1 as zw,bk as Uw,cy as ul,aS as Cv,aq as Ye,d2 as Hw,d3 as Vw,a5 as $e,d4 as Gw,d5 as Ww,d6 as jw,d7 as Kw,d8 as Xw,d9 as Yw,da as Zw,db as Qw,dc as Jw,dd as qw,de as eT,v as tT,df as rT,dg as nT,dh as iT,aW as aT,di as oT,dj as sT,dk as lT,dl as uT,dm as cT,ay as sr,dn as fT,dp as dT,dq as hT,dr as vT,A as pT,ds as gT,dt as mT,bq as yT,du as ST,dv as bT,dw as xT,ap as CT,ac as f1,ai as dt,dx as wT,au as Ce,dy as TT,X as d1,y as IT,dz as _T,aP as AT,dA as ET,w as _r,dB as PT,dC as MT,H as RT,dD as DT,bA as hn,aD as LT,ae as ie,bP as ea,$ as wc,dE as kT,bO as ta,dF as OT,aC as BT,aO as NT,R as fe,dG as FT,br as rh,b3 as Lr,aj as Hs,b1 as lt,b2 as Ua,an as wv,dH as $T,dI as zT,dJ as UT,dK as HT,dL as VT,dM as GT,dN as WT,Q as jT,dO as KT,dP as XT,dQ as YT,at as Mt,dR as ZT,aR as QT,aY as JT,ad as cl,aZ as qT,dS as eI,dT as tI,dU as rI,P as le,a6 as nI,dV as iI,dW as aI,dX as oI,dY as sI,a7 as lI,dZ as uI,bL as cI,d_ as fI,d$ as dI,bK as hI,b4 as vI,aA as Tv,e0 as pI,e1 as gI,E as gr,x as mI,e2 as yI,e3 as SI,e4 as bI,am as _n,e5 as xI,az as hr,aU as nh,e6 as CI,as as jr,e7 as wI,e8 as Iv,cG as mr,by as ih,cv as ah,bI as TI,bH as Ve,bu as oh,bv as _v,bx as Wt,bB as Tc,bN as di,e9 as wu,bE as hi,ea as II,bJ as ra,bC as _I,bw as Bt,eb as AI,ec as ui,cw as EI,ed as PI,ee as MI,ef as RI,eg as DI,bM as Zp,eh as LI,bF as Qp,cD as Ic,cC as kI,ei as Hu,ej as Il}from"./index-Qxv2V3to.js";/**
+import{cH as dw,cI as hw,aG as vw,cJ as pw,cK as gw,cL as mw,cM as yw,ah as Yt,cN as Sw,cO as bw,cP as xw,cQ as Cw,aM as ww,aF as Tw,cR as Iw,aB as c1,bg as _w,cS as Aw,cT as Ew,cU as Pw,cV as Mw,cW as Rw,cX as Dw,cY as Lw,aE as kw,cZ as Ow,s as Ae,bl as Bw,al as je,ag as ze,af as ve,ak as Z,c_ as Nw,c$ as Fw,d0 as $w,d1 as zw,bk as Uw,cy as ul,aS as Cv,aq as Ye,d2 as Hw,d3 as Vw,a5 as $e,d4 as Gw,d5 as Ww,d6 as jw,d7 as Kw,d8 as Xw,d9 as Yw,da as Zw,db as Qw,dc as Jw,dd as qw,de as eT,v as tT,df as rT,dg as nT,dh as iT,aW as aT,di as oT,dj as sT,dk as lT,dl as uT,dm as cT,ay as sr,dn as fT,dp as dT,dq as hT,dr as vT,A as pT,ds as gT,dt as mT,bq as yT,du as ST,dv as bT,dw as xT,ap as CT,ac as f1,ai as dt,dx as wT,au as Ce,dy as TT,X as d1,y as IT,dz as _T,aP as AT,dA as ET,w as _r,dB as PT,dC as MT,H as RT,dD as DT,bA as hn,aD as LT,ae as ie,bP as ea,$ as wc,dE as kT,bO as ta,dF as OT,aC as BT,aO as NT,R as fe,dG as FT,br as rh,b3 as Lr,aj as Hs,b1 as lt,b2 as Ua,an as wv,dH as $T,dI as zT,dJ as UT,dK as HT,dL as VT,dM as GT,dN as WT,Q as jT,dO as KT,dP as XT,dQ as YT,at as Mt,dR as ZT,aR as QT,aY as JT,ad as cl,aZ as qT,dS as eI,dT as tI,dU as rI,P as le,a6 as nI,dV as iI,dW as aI,dX as oI,dY as sI,a7 as lI,dZ as uI,bL as cI,d_ as fI,d$ as dI,bK as hI,b4 as vI,aA as Tv,e0 as pI,e1 as gI,E as gr,x as mI,e2 as yI,e3 as SI,e4 as bI,am as _n,e5 as xI,az as hr,aU as nh,e6 as CI,as as jr,e7 as wI,e8 as Iv,cG as mr,by as ih,cv as ah,bI as TI,bH as Ve,bu as oh,bv as _v,bx as Wt,bB as Tc,bN as di,e9 as wu,bE as hi,ea as II,bJ as ra,bC as _I,bw as Bt,eb as AI,ec as ui,cw as EI,ed as PI,ee as MI,ef as RI,eg as DI,bM as Zp,eh as LI,bF as Qp,cD as Ic,cC as kI,ei as Hu,ej as Il}from"./index-CjBu6_TY.js";/**
 * vue v3.4.34
 * vue v3.4.34
 * (c) 2018-present Yuxi (Evan) You and Vue contributors
 * (c) 2018-present Yuxi (Evan) You and Vue contributors
 * @license MIT
 * @license MIT

File diff suppressed because it is too large
+ 0 - 0
dist/assets/index-DzU5SPDk.js


+ 1 - 1
dist/assets/shapes-H9Hj85K_.js → dist/assets/shapes-CvQU5hOE.js

@@ -1,4 +1,4 @@
-import{cG as k,bB as $1}from"./index-Qxv2V3to.js";import{ag as Z,ah as H1,ai as U,aj as n0,ak as c0,al as i0,am as j1,an as K1,ao as Y1,ap as R}from"./index-DJYV1XR6.js";var z1={exports:{}};/*!
+import{cG as k,bB as $1}from"./index-CjBu6_TY.js";import{ag as Z,ah as H1,ai as U,aj as n0,ak as c0,al as i0,am as j1,an as K1,ao as Y1,ap as R}from"./index-DGk1cQw8.js";var z1={exports:{}};/*!
  * clipboard.js v2.0.11
  * clipboard.js v2.0.11
  * https://clipboardjs.com/
  * https://clipboardjs.com/
  *
  *

+ 1 - 1
dist/index.html

@@ -10,7 +10,7 @@
     <meta name="description" content="ppt编辑器" />
     <meta name="description" content="ppt编辑器" />
     <meta name="keywords" content="ppt,powerpoint,office powerpoint,在线ppt,幻灯片,演示文稿,ppt在线制作,Vue3,TypeScript" />
     <meta name="keywords" content="ppt,powerpoint,office powerpoint,在线ppt,幻灯片,演示文稿,ppt在线制作,Vue3,TypeScript" />
     <title>PPT</title>
     <title>PPT</title>
-    <script type="module" crossorigin src="./assets/index-Qxv2V3to.js"></script>
+    <script type="module" crossorigin src="./assets/index-CjBu6_TY.js"></script>
     <link rel="stylesheet" crossorigin href="./assets/index-AavUEaYy.css">
     <link rel="stylesheet" crossorigin href="./assets/index-AavUEaYy.css">
   </head>
   </head>
   <body>
   <body>

+ 1 - 0
src/api/aiCourseware.ts

@@ -31,6 +31,7 @@ export interface AiCoursewarePreferences {
 
 
 export interface AiCoursewareOutline {
 export interface AiCoursewareOutline {
   title: string
   title: string
+  templateId?: AiCoursewareTemplateId
   gradeName: string
   gradeName: string
   textbookName: string
   textbookName: string
   unitName: string
   unitName: string

+ 122 - 47
src/config/aiCoursewareTemplateMasters.ts

@@ -1,5 +1,9 @@
 import type { AiCoursewareTemplateId } from "@/config/aiCoursewareTemplates"
 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 {
 export interface TemplateMasterElement {
   type: "shape" | "text"
   type: "shape" | "text"
   left: number
   left: number
@@ -15,66 +19,137 @@ export interface TemplateMasterElement {
   bold?: boolean
   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 {
 export interface TemplateMasterPage {
+  id: string
+  layout: TemplatePageLayout
+  roles: TemplatePageRole[]
   background: string
   background: string
   base: TemplateMasterElement[]
   base: TemplateMasterElement[]
   chrome: TemplateMasterElement[]
   chrome: TemplateMasterElement[]
+  slots: TemplateSlot[]
 }
 }
 
 
 export interface TemplateMaster {
 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> = {
 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("; ")}`)
 }
 }

+ 152 - 43
src/utils/aiPptTemplateEngine.ts

@@ -17,13 +17,23 @@ import type {
 } from "@/types/slides"
 } from "@/types/slides"
 import type { AiCoursewareOutline, AiCoursewarePage } from "@/api/aiCourseware"
 import type { AiCoursewareOutline, AiCoursewarePage } from "@/api/aiCourseware"
 import {
 import {
+  AI_COURSEWARE_TEMPLATES,
   getAiCoursewarePalette,
   getAiCoursewarePalette,
   getAiCoursewareTemplate,
   getAiCoursewareTemplate,
   type AiCoursewareTemplateId,
   type AiCoursewareTemplateId,
   type AiCoursewareTemplatePageRole,
   type AiCoursewareTemplatePageRole,
   type AiCoursewareThemePalette
   type AiCoursewareThemePalette
 } from "@/config/aiCoursewareTemplates"
 } from "@/config/aiCoursewareTemplates"
-import { getAiCoursewareTemplateMaster, type TemplateMasterElement, type TemplateMasterPage } from "@/config/aiCoursewareTemplateMasters"
+import {
+  getAiCoursewareTemplateMaster,
+  getTemplateMasterPage,
+  validateTemplateMasterRegistry,
+  type TemplateMaster,
+  type TemplateMasterElement,
+  type TemplateMasterPage,
+  type TemplatePageRole,
+  type TemplateSlot
+} from "@/config/aiCoursewareTemplateMasters"
 
 
 interface AiPptOptions {
 interface AiPptOptions {
   backgroundImage?: string
   backgroundImage?: string
@@ -32,25 +42,7 @@ interface AiPptOptions {
   templateId?: AiCoursewareTemplateId
   templateId?: AiCoursewareTemplateId
 }
 }
 
 
-type PageRole =
-  | "cover"
-  | "intro"
-  | "goal"
-  | "story"
-  | "listen"
-  | "lyric"
-  | "notation"
-  | "instrument"
-  | "compare"
-  | "knowledge"
-  | "rhythm"
-  | "interaction"
-  | "performance"
-  | "practice"
-  | "activity"
-  | "summary"
-  | "homework"
-  | "content"
+type PageRole = TemplatePageRole
 
 
 type Palette = AiCoursewareThemePalette
 type Palette = AiCoursewareThemePalette
 
 
@@ -75,8 +67,11 @@ export function buildAiPptSlides(
   options: AiPptOptions = {}
   options: AiPptOptions = {}
 ): { title: string; theme: Partial<SlideTheme>; slides: Slide[] } {
 ): { title: string; theme: Partial<SlideTheme>; slides: Slide[] } {
   const normalized = normalizeOutline(outline)
   const normalized = normalizeOutline(outline)
-  const palette = buildPalette(normalized, options.paletteVariant || 0, options.templateId)
-  const slides = normalized.pages.map((page, index) => buildSlide(page, normalized, palette, options, index))
+  validateTemplateMasterRegistry(AI_COURSEWARE_TEMPLATES.map(template => template.id))
+  const templateId = options.templateId || normalized.templateId || (normalized.style === "cartoon" ? "fairytale-forest" : "warm-handdrawn")
+  const palette = buildPalette(normalized, options.paletteVariant || 0, templateId)
+  const resolvedOptions = { ...options, templateId }
+  const slides = normalized.pages.map((page, index) => buildSlide(page, normalized, palette, resolvedOptions, index))
   runPreflight(slides)
   runPreflight(slides)
   return {
   return {
     title: normalized.title || normalized.chapterName || "音乐课件",
     title: normalized.title || normalized.chapterName || "音乐课件",
@@ -93,14 +88,9 @@ export function buildAiPptSlides(
 
 
 function buildSlide(page: AiCoursewarePage, outline: AiCoursewareOutline, palette: Palette, options: AiPptOptions, index: number): Slide {
 function buildSlide(page: AiCoursewarePage, outline: AiCoursewareOutline, palette: Palette, options: AiPptOptions, index: number): Slide {
   const role = pageRole(page, index)
   const role = pageRole(page, index)
-  const master = getAiCoursewareTemplateMaster(options.templateId)
-  const masterPage = role === "cover" ? master.cover : master.content
-  const dedicatedResource = dedicatedResourceElement(page)
-  const contentElements = dedicatedResource
-    ? [dedicatedResource]
-    : videoBinding(page)
-      ? videoResourceTemplate(page, outline, palette)
-      : [...templateForPage(page, role, options.templateId)(page, outline, palette, index), ...inlineResourceElements(page)]
+  const master = getAiCoursewareTemplateMaster(options.templateId!)
+  const masterPage = getTemplateMasterPage(master, role)
+  const contentElements = fillTemplatePage(master, masterPage, page, outline, palette)
   const elements = [
   const elements = [
     ...renderTemplateMasterElements(masterPage.base),
     ...renderTemplateMasterElements(masterPage.base),
     ...contentElements,
     ...contentElements,
@@ -116,6 +106,136 @@ function buildSlide(page: AiCoursewarePage, outline: AiCoursewareOutline, palett
   }
   }
 }
 }
 
 
+function fillTemplatePage(master: TemplateMaster, masterPage: TemplateMasterPage, page: AiCoursewarePage, outline: AiCoursewareOutline, palette: Palette): PPTElement[] {
+  const elements: PPTElement[] = []
+  const title = masterPage.layout === "cover" ? chapterTitle(outline, page) : displayPageTitle(page, outline)
+  const points = studentPoints(page, 6)
+  const headline = activityTask(page) || studentHeadline(page)
+
+  addSlotText(elements, slotOf(masterPage, "title"), title, masterPage.layout === "cover" ? master.coverTitleColor : master.titleColor, true)
+
+  if (masterPage.layout === "cover") {
+    const meta = [outline.gradeName, conciseTextbook(outline.textbookName), outline.unitName].filter(Boolean).join(" · ")
+    addSlotText(elements, slotOf(masterPage, "meta"), meta, master.mutedColor)
+    addSlotList(elements, slotOf(masterPage, "headline"), points, master, palette, false)
+    addVisualOrResource(elements, slotOf(masterPage, "image"), page)
+    return elements
+  }
+
+  if (masterPage.layout === "hero") {
+    addVisualOrResource(elements, slotOf(masterPage, "image"), page)
+    const body = uniqueVisibleTexts([page.interaction?.question || "", headline, ...points])
+    addSlotList(elements, slotOf(masterPage, "body"), body, master, palette)
+    addInlineMedia(elements, slotOf(masterPage, "media"), page)
+    return elements
+  }
+
+  if (masterPage.layout === "split") {
+    addVisualOrResource(elements, slotOf(masterPage, "image"), page)
+    addSlotList(elements, slotOf(masterPage, "body"), uniqueVisibleTexts([headline, ...points]), master, palette)
+    addInlineMedia(elements, slotOf(masterPage, "media"), page)
+    return elements
+  }
+
+  if (masterPage.layout === "compare") {
+    const values = uniqueVisibleTexts([...(page.interaction?.options || []), ...points])
+    const midpoint = Math.max(1, Math.ceil(values.length / 2))
+    addSlotList(elements, slotOf(masterPage, "left"), values.slice(0, midpoint), master, palette)
+    addSlotList(elements, slotOf(masterPage, "right"), values.slice(midpoint), master, palette)
+    addInlineMedia(elements, slotOf(masterPage, "media"), page)
+    return elements
+  }
+
+  if (masterPage.layout === "activity") {
+    addSlotText(elements, slotOf(masterPage, "headline"), headline || "一起完成音乐任务", palette.primary, true)
+    addSlotList(elements, slotOf(masterPage, "steps"), activitySteps(page, 8), master, palette)
+    addInlineMedia(elements, slotOf(masterPage, "media"), page)
+    return elements
+  }
+
+  addSlotList(elements, slotOf(masterPage, "body"), uniqueVisibleTexts([headline, ...points]), master, palette)
+  if (masterPage.layout === "summary") addSlotText(elements, slotOf(masterPage, "headline"), page.learningEvidence || "说一说今天的收获", palette.primary, true)
+  return elements
+}
+
+function slotOf(page: TemplateMasterPage, id: string) {
+  return page.slots.find(item => item.id === id)
+}
+
+function addSlotText(elements: PPTElement[], slot: TemplateSlot | undefined, content: string, color: string, bold = false) {
+  if (!slot || !cleanText(content)) return
+  elements.push(textElement({
+    left: slot.left,
+    top: slot.top,
+    width: slot.width,
+    height: slot.height,
+    content,
+    fontSize: fitFont(content, slot.width, slot.height, slot.preferredFontSize || 30, slot.minimumFontSize || 22),
+    color,
+    bold
+  }))
+}
+
+function addSlotList(elements: PPTElement[], slot: TemplateSlot | undefined, sourceItems: string[], master: TemplateMaster, palette: Palette, cards = true) {
+  if (!slot) return
+  const items = fitItemsToSlot(uniqueVisibleTexts(sourceItems.map(cleanText).filter(Boolean)), slot.maxItems || 4)
+  if (!items.length) return
+  const gap = 16
+  const rowHeight = Math.max(58, (slot.height - gap * (items.length - 1)) / items.length)
+  items.forEach((item, index) => {
+    const top = slot.top + index * (rowHeight + gap)
+    if (cards) {
+      elements.push(shapeElement({ left: slot.left, top, width: slot.width, height: rowHeight, fill: master.surfaceColor, radius: 14, shadow: lightShadow() }))
+      elements.push(shapeElement({ left: slot.left, top, width: 10, height: rowHeight, fill: index % 2 ? palette.secondary : palette.primary, radius: 0 }))
+    }
+    elements.push(textElement({
+      left: slot.left + (cards ? 34 : 0),
+      top: top + 8,
+      width: slot.width - (cards ? 58 : 0),
+      height: Math.max(42, rowHeight - 16),
+      content: item,
+      fontSize: fitFont(item, slot.width - 58, Math.max(42, rowHeight - 16), slot.preferredFontSize || 30, slot.minimumFontSize || 22),
+      color: master.textColor,
+      bold: index === 0 && items.length === 1
+    }))
+  })
+}
+
+function fitItemsToSlot(items: string[], maxItems: number) {
+  if (items.length <= maxItems) return items
+  return [...items.slice(0, Math.max(0, maxItems - 1)), items.slice(Math.max(0, maxItems - 1)).join(";")]
+}
+
+function addVisualOrResource(elements: PPTElement[], slot: TemplateSlot | undefined, page: AiCoursewarePage) {
+  if (!slot) return
+  const dedicated = (page.resourceBindings || []).find(item => item.verified !== false && item.dedicatedPage)
+  if (dedicated?.resourceType === "MUSIC") {
+    elements.push({ id: nanoid(10), type: "elf", subtype: "elf-sing-play", sid: dedicated.resourceId, title: dedicated.name || page.title, left: slot.left, top: slot.top, width: slot.width, height: slot.height, rotate: 0 } as PPTCloudCoachElement)
+    return
+  }
+  const video = videoBinding(page)
+  if (video) {
+    elements.push({ id: nanoid(10), type: "elf", subtype: "elf-video", left: slot.left, top: slot.top, width: slot.width, height: slot.height, rotate: 0, src: video.contentUrl || "", poster: video.coverUrl, autoplay: false } as PPTVideoElement)
+    return
+  }
+  if (hasImage(page)) {
+    elements.push(imageElement(page, { left: slot.left, top: slot.top, width: slot.width, height: slot.height, radius: 18 }))
+    return
+  }
+  elements.push(shapeElement({ left: slot.left, top: slot.top, width: slot.width, height: slot.height, fill: "#FFFFFF", opacity: 0.54, radius: 18 }))
+}
+
+function addInlineMedia(elements: PPTElement[], slot: TemplateSlot | undefined, page: AiCoursewarePage) {
+  if (!slot) return
+  const binding = (page.resourceBindings || []).find(item => item.verified !== false && item.resourceType === "SONG" && item.contentUrl)
+  if (!binding) return
+  elements.push({
+    id: nanoid(10), type: "elf", subtype: "elf-enjoy", left: slot.left, top: slot.top, width: slot.width, height: slot.height, rotate: 0,
+    sid: binding.resourceId, title: binding.name || page.title, src: binding.contentUrl || "",
+    enjoyList: [{ id: binding.resourceId, title: binding.name || page.title, src: binding.contentUrl || "" }]
+  } as PPTEnjoyElement)
+}
+
 function templateDecorationElements(templateId: AiCoursewareTemplateId | undefined, palette: Palette, role: PageRole): PPTElement[] {
 function templateDecorationElements(templateId: AiCoursewareTemplateId | undefined, palette: Palette, role: PageRole): PPTElement[] {
   if (!templateId || role === "cover") return []
   if (!templateId || role === "cover") return []
   switch (templateId) {
   switch (templateId) {
@@ -1405,19 +1525,8 @@ function templateCover(page: AiCoursewarePage, outline: AiCoursewareOutline, pal
 }
 }
 
 
 function templateCoverFromMaster(page: AiCoursewarePage, outline: AiCoursewareOutline, palette: Palette, templateId: AiCoursewareTemplateId): PPTElement[] {
 function templateCoverFromMaster(page: AiCoursewarePage, outline: AiCoursewareOutline, palette: Palette, templateId: AiCoursewareTemplateId): PPTElement[] {
-  const master = getAiCoursewareTemplateMaster(templateId).cover
-  const title = chapterTitle(outline, page)
-  const subtitle = [outline.gradeName, conciseTextbook(outline.textbookName), outline.unitName].filter(Boolean).join(" · ")
-  const points = studentPoints(page, 2)
-  const elements: PPTElement[] = []
-
-  if (hasImage(page)) elements.push(imageElement(page, { ...master.imageFrame }))
-  elements.push(textElement({ left: 122, top: 108, width: 420, height: 32, content: master.label, fontSize: 15, color: palette.primary, bold: true }))
-  elements.push(textElement({ left: 120, top: 195, width: 710, height: 170, content: title, fontSize: fitFont(title, 710, 170, 70, 48), color: master.titleColor, bold: true }))
-  if (subtitle) elements.push(textElement({ left: 124, top: 405, width: 650, height: 46, content: subtitle, fontSize: 24, color: master.subtitleColor }))
-  elements.push(shapeElement({ left: 124, top: 492, width: 120, height: 10, fill: palette.accent, radius: 5 }))
-  points.forEach((point, index) => elements.push(textElement({ left: 124, top: 552 + index * 62, width: 650, height: 44, content: point, fontSize: fitFont(point, 650, 44, 29, 22), color: master.titleColor, bold: index === 0 })))
-  return elements
+  const master = getAiCoursewareTemplateMaster(templateId)
+  return fillTemplatePage(master, getTemplateMasterPage(master, "cover"), page, outline, palette)
 }
 }
 
 
 function resolveTemplateLayout(requestedLayout: string, role: PageRole, templateId?: AiCoursewareTemplateId) {
 function resolveTemplateLayout(requestedLayout: string, role: PageRole, templateId?: AiCoursewareTemplateId) {

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

@@ -657,6 +657,7 @@ async function handleOutline() {
     outline.value = res.data as AiCoursewareOutline
     outline.value = res.data as AiCoursewareOutline
     if (templateSelectionMode.value === "auto") selectedTemplateId.value = recommendAiCoursewareTemplate(outline.value).id
     if (templateSelectionMode.value === "auto") selectedTemplateId.value = recommendAiCoursewareTemplate(outline.value).id
     outline.value.style = selectedStyle.value
     outline.value.style = selectedStyle.value
+    outline.value.templateId = selectedTemplateId.value
     selectedPageNo.value = outline.value.pages[0]?.pageNo || 1
     selectedPageNo.value = outline.value.pages[0]?.pageNo || 1
     sampleFeedbackTags.value = []
     sampleFeedbackTags.value = []
     sampleFeedbackText.value = ""
     sampleFeedbackText.value = ""
@@ -679,6 +680,7 @@ async function handleGenerate() {
   draftConverted.value = undefined
   draftConverted.value = undefined
   draftQuality.value = undefined
   draftQuality.value = undefined
   sourceOutline.style = selectedStyle.value
   sourceOutline.style = selectedStyle.value
+  sourceOutline.templateId = selectedTemplateId.value
   taskMode.value = "full"
   taskMode.value = "full"
   const res = await httpAjaxErrMsg(aiCoursewareGenerateApi, {
   const res = await httpAjaxErrMsg(aiCoursewareGenerateApi, {
     id: pptWork.id,
     id: pptWork.id,
@@ -760,6 +762,7 @@ async function loadDraft(taskId: string) {
 
 
   const result = res.data as AiCoursewareGenerateResult
   const result = res.data as AiCoursewareGenerateResult
   draftOutline.value = result.outline
   draftOutline.value = result.outline
+  draftOutline.value.templateId = selectedTemplateId.value
   rebuildDraftPreview()
   rebuildDraftPreview()
   workflowStep.value = 3
   workflowStep.value = 3
   clearTask()
   clearTask()
@@ -776,6 +779,7 @@ async function loadSampleResult(taskId: string) {
   const result = res.data as AiCoursewareGenerateResult
   const result = res.data as AiCoursewareGenerateResult
   outline.value = result.outline
   outline.value = result.outline
   outline.value.style = selectedStyle.value
   outline.value.style = selectedStyle.value
+  outline.value.templateId = selectedTemplateId.value
   sampleFeedbackTags.value = []
   sampleFeedbackTags.value = []
   sampleFeedbackText.value = ""
   sampleFeedbackText.value = ""
   workflowStep.value = 2
   workflowStep.value = 2
@@ -790,6 +794,7 @@ function rebuildDraftPreview() {
     return
     return
   }
   }
   draftOutline.value.style = selectedStyle.value
   draftOutline.value.style = selectedStyle.value
+  draftOutline.value.templateId = selectedTemplateId.value
   draftConverted.value = aiCoursewareToSlides(draftOutline.value, {
   draftConverted.value = aiCoursewareToSlides(draftOutline.value, {
     enableAnimations: preferences.enableAnimations,
     enableAnimations: preferences.enableAnimations,
     paletteVariant: paletteVariant.value,
     paletteVariant: paletteVariant.value,

Some files were not shown because too many files changed in this diff