yonge пре 13 часа
родитељ
комит
9b53db90bd

+ 1 - 1
dist/assets/index-BAKc3dgA.js → dist/assets/index-B2tQ0n4e.js

@@ -1 +1 @@
-import{_ as t}from"./404-R0y5dkvZ.js";import{E as c}from"./index-CHshgQoq.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-V3qgdgGd.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-BiN7PaxE.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,bP as m,bQ as f,bH as u}from"./index-BunKwzZX.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};

+ 1 - 1
dist/assets/index-BXNEIpoI.js → dist/assets/index-BSFzaoNr.js

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

Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
dist/assets/index-BhUEbTUz.js


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
dist/assets/index-BiN7PaxE.js


+ 1 - 1
dist/assets/index-DUkyeNq7.js → dist/assets/index-BkVgy0Su.js

@@ -1 +1 @@
-import{_ as a}from"./404-R0y5dkvZ.js";import{a5 as o,eo as t,ae as r,af as c,bO as _,bP as d,ak as s,bH as p}from"./index-V3qgdgGd.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,eo as t,ae as r,af as c,bP as _,bQ as d,ak as s,bH as p}from"./index-BunKwzZX.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-BunKwzZX.js


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
dist/assets/index-CL-4I1LD.js


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
dist/assets/index-CPUAq0_P.css


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
dist/assets/index-CYi5OxJk.css


+ 1 - 1
dist/assets/index-C0Fh1NG_.js → dist/assets/index-DEbW_nVK.js

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

+ 1 - 1
dist/assets/index-Dfi5vntL.js → dist/assets/index-U_S8Nx7O.js

@@ -1 +1 @@
-import{u as c,ag as l}from"./index-BXNEIpoI.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-V3qgdgGd.js";import"./shapes-BQ6QuFfS.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,ag as l}from"./index-BSFzaoNr.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-BunKwzZX.js";import"./shapes-BlsOIWqa.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/shapes-BQ6QuFfS.js → dist/assets/shapes-BlsOIWqa.js

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

+ 1 - 1
dist/index.html

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

+ 14 - 1
scripts/aiPptSelfTest.mjs

@@ -5,12 +5,23 @@ import assert from "node:assert/strict"
 import { classroomFixture } from "./fixtures/aiPptClassroom.mjs"
 import { semanticDesignFixture, densityRegressionFixture, heroImageRegressionFixture } from "./fixtures/aiPptSemanticDesign.mjs"
 import { modelDesignFixture } from "./fixtures/aiPptModelDesign.mjs"
+import { inquiryFixture } from './fixtures/aiPptInquiry.mjs'
 
 const vite = await createServer({ server: { middlewareMode: true }, appType: "custom", logLevel: "error" })
 
 try {
   const { aiCoursewareToSlides } = await vite.ssrLoadModule("/src/utils/aiCoursewareToSlides.ts")
   const { inspectAiPptQuality } = await vite.ssrLoadModule("/src/utils/aiPptQuality.ts")
+  const inquiry = inquiryFixture()
+  const inquiryResult = aiCoursewareToSlides(inquiry)
+  const inquiryReport = inspectAiPptQuality(inquiry, inquiryResult.slides, {
+    lessonMinutes: 40, minPages: 9, maxPages: 15, resourceMode: 'teacher_supplement', requireAudio: true, requireVideo: true
+  })
+  assert.deepEqual(inquiryReport.errors, [], `无媒体教学样例:${JSON.stringify(inquiryReport.errors)}`)
+  assert(inquiryResult.slides.every(slide => slide.background.color === '#FAF8F2'), '默认模板必须采用整课主题色')
+  assert(inquiryResult.slides.every(slide => !slide.elements.some(element => element.type === 'elf')), '媒体由教师补充时不生成空播放器或谱例')
+  assert(inquiryResult.slides[3].elements.filter(element => element.type === 'shape').length >= 4, '强弱图须绘制为可编辑圆形')
+  assert(inquiryResult.slides[5].remark.includes('教学示范:') && inquiryResult.slides[5].remark.includes('判断标准:'), '示范与反馈必须进入备注')
   const { AI_COURSEWARE_TEMPLATES } = await vite.ssrLoadModule("/src/config/aiCoursewareTemplates.ts")
   const { getAiCoursewareTemplateMaster, getTemplateMasterPage, resolveTemplatePageLayout } = await vite.ssrLoadModule("/src/config/aiCoursewareTemplateMasters.ts")
   const { resolveAiPptLayout } = await vite.ssrLoadModule("/src/utils/aiPptTemplateEngine.ts")
@@ -228,7 +239,9 @@ try {
   assert(!JSON.stringify(render(missingAudio)).includes("请老师在此补充"))
   const noScore = { ...completeFixture(singingOutline), templateId: "ethnic-flower" }
   noScore.pages[7].resourceBindings = []
-  assert(codes(noScore).includes("missing_score"))
+  assert(!codes(noScore).includes("missing_score"), '缺少谱例不再阻止课件交付')
+  assert(inspectAiPptQuality(noScore, render(noScore), preferences).warnings.some(issue => issue.code === 'missing_score'))
+  assert(render(noScore)[7].elements.some(element => elementText(element).includes('向上表示声音变高')), '缺谱仍保留完整教学内容')
   assert.equal(render(noScore)[7].elements.filter(element => element.type === "line").length, 0, "缺谱不得绘制假五线谱")
   noScore.pages[7].resourceBindings = [{ resourceType: "MUSIC", resourceId: "verified-score", verified: true, dedicatedPage: true }]
   assert(render(noScore)[7].elements.some(element => element.subtype === "elf-sing-play" && element.sid === "verified-score"))

+ 2 - 1
scripts/aiPptVisualTest.html

@@ -25,6 +25,7 @@
       import { classroomFixture } from "./fixtures/aiPptClassroom.mjs"
       import { semanticDesignFixture, densityRegressionFixture, heroImageRegressionFixture } from "./fixtures/aiPptSemanticDesign.mjs"
       import { modelDesignFixture } from "./fixtures/aiPptModelDesign.mjs"
+      import { inquiryFixture } from './fixtures/aiPptInquiry.mjs'
 
       const id = new URLSearchParams(location.search).get("template") || "ethnic-flower"
       const page = (pageNo, type, title, layout, points, extra = {}) => ({ pageNo, type, title, bullets: points, studentContent: { points }, imageKeywords: [], visualPlan: { layout }, ...extra })
@@ -45,7 +46,7 @@
       }
       const scenario = new URLSearchParams(location.search).get("scenario")
       const fixtureImage = '/ai-courseware-templates/extracted/bird-garden/background-01.jpg'
-      const fixture = scenario === 'hero-images' ? heroImageRegressionFixture(fixtureImage) : scenario === 'density' ? densityRegressionFixture(fixtureImage) : scenario === 'semantic' ? semanticDesignFixture(fixtureImage) : scenario === 'model-design' ? modelDesignFixture(fixtureImage)
+      const fixture = scenario === 'inquiry' ? inquiryFixture() : scenario === 'hero-images' ? heroImageRegressionFixture(fixtureImage) : scenario === 'density' ? densityRegressionFixture(fixtureImage) : scenario === 'semantic' ? semanticDesignFixture(fixtureImage) : scenario === 'model-design' ? modelDesignFixture(fixtureImage)
         : scenario === 'classroom' ? classroomFixture(fixtureImage) : outline
       const result = aiCoursewareToSlides(fixture, { templateId: id })
       const group = ref(0)

+ 54 - 0
scripts/fixtures/aiPptInquiry.mjs

@@ -0,0 +1,54 @@
+// 人工编写的渲染回归样例,不代表模型实测输出或已核验的教材原文。
+export function inquiryFixture() {
+  const art = `<svg xmlns="http://www.w3.org/2000/svg" width="740" height="720" viewBox="0 0 740 720"><rect width="740" height="720" rx="32" fill="#E8E1CF"/><circle cx="550" cy="140" r="75" fill="#EBC16E"/><path d="M0 560Q150 460 350 560T740 530V720H0Z" fill="#648E90"/><path d="M0 620Q170 530 380 620T740 610" fill="none" stroke="#FAF8F2" stroke-width="12"/><g stroke="#203345" stroke-width="14" stroke-linecap="round" fill="none"><path d="M270 420L200 390L150 410M280 450L190 460L160 490M480 420L550 390L600 410M470 450L560 460L590 490"/><path d="M300 350L210 290M450 350L520 270"/></g><g fill="#BF513B" stroke="#203345" stroke-width="9"><ellipse cx="375" cy="400" rx="125" ry="88"/><path d="M220 300Q130 310 150 210L195 260L235 205Q280 285 220 300Z"/><path d="M510 290Q465 235 500 185L530 240L570 195Q600 290 510 290Z"/></g><g fill="#FAF8F2" stroke="#203345" stroke-width="8"><circle cx="330" cy="326" r="25"/><circle cx="420" cy="326" r="25"/></g><g fill="#203345"><circle cx="333" cy="330" r="8"/><circle cx="417" cy="330" r="8"/></g><path d="M350 402Q375 427 400 402" fill="none" stroke="#203345" stroke-width="9" stroke-linecap="round"/></svg>`
+  const image = { url: `data:image/svg+xml;charset=utf-8,${encodeURIComponent(art)}`, width: 740, height: 720 }
+  const page = (pageNo, type, title, headline, points, composition, extra = {}) => ({
+    pageNo, type, title, bullets: [], imageKeywords: [],
+    teachingPurpose: '比较声音的表现差异,并在演唱中尝试运用。',
+    teachingBrief: '围绕歌曲表现问题,提供具体听辨与模仿任务。',
+    learningEvidence: '能说明听到的差别,并在自己的演唱中展示一种变化。',
+    durationMinutes: pageNo === 1 ? 1 : pageNo === 9 ? 4 : 5,
+    objectiveIds: ['1'], optional: false, requiredVisual: false,
+    studentContent: { headline, points },
+    classroomAction: {
+      teacherInstruction: '先示范两种表现,请学生选择并说明理由,再请一组尝试。',
+      studentTask: headline,
+      modelExample: '选择教材歌曲中的同一句,保持音高和速度一致,分别轻巧与有力地演唱。',
+      successCriteria: '学生能指出力度差别,尝试改变唱法,声音保持自然。'
+    },
+    speakerNotes: '先让学生表达听感,再请他们用声音说明。接着将发现带回歌曲中验证。',
+    visualPlan: { composition, visualMode: 'text_only', visualShare: .55, textPresentation: 'plain', imageNecessity: 'none', imagePurpose: 'none', layout: pageNo === 1 ? 'cover_scene' : 'text_focus' },
+    ...extra
+  })
+  return {
+    title: '螃蟹歌', chapterName: '螃蟹歌', gradeName: '三年级', textbookName: '音乐课堂 · 设计样例', unitName: '', courseType: 'singing', templateId: 'blank', style: 'clean',
+    teachingDesign: { approach: '从声音差异出发,尝试有表现力的演唱。', drivingQuestion: '怎样把螃蟹唱得有趣?', learningPath: ['听见特点', '比较唱法', '尝试表现', '交流发现'] },
+    visualBible: { artStyle: '纸本插画与简洁音乐图示', palette: ['#203345', '#FAF8F2', '#BF513B'], environment: '河边音乐课堂' },
+    textbookResearch: { exactMatch: true, confidence: 'HIGH', sources: [{ url: 'https://example.com/test-fixture', title: '测试专用,不是教材来源' }], teachingObjectives: ['比较不同唱法并尝试有表现力地演唱'] },
+    pages: [
+      page(1, 'cover', '螃蟹歌', '怎样把螃蟹唱得有趣?', ['听见特点,再唱出自己的发现'], 'image_right', { image,
+        visualPlan: { composition: 'image_right', visualMode: 'generated_image', visualShare: .55, textPresentation: 'plain', imageNecessity: 'supporting', imagePurpose: 'scene', layout: 'cover_scene' } }),
+      page(2, 'first_listen', '先用耳朵认识它', '哪一句最让你想学?', ['记住一句最有趣的声音', '用表情或动作回应它', '说说你为什么选这一句'], 'image_left', { image,
+        visualPlan: { composition: 'image_left', visualMode: 'generated_image', visualShare: .5, textPresentation: 'plain', imageNecessity: 'supporting', imagePurpose: 'scene', layout: 'split_content' } }),
+      page(3, 'music_knowledge', '我们的发现路线', '听到的特点,怎样变成唱法?', [], 'steps', {
+        diagram: { kind: 'sequence', caption: '每一次尝试,都用声音说明理由', items: [{ label: '听见不同' }, { label: '比较唱法' }, { label: '尝试表现' }, { label: '交流发现' }] },
+        visualPlan: { composition: 'steps', visualMode: 'native_diagram', textPresentation: 'plain', imageNecessity: 'none', imagePurpose: 'none', layout: 'diagram_focus' } }),
+      page(4, 'rhythm_practice', '同样的拍子,不同的力量', '哪一下听起来更突出?', ['先模仿,再换一种强弱顺序'], 'concept_grid', {
+        diagram: { kind: 'beats', caption: '教师自编强弱练习 · 圆的大小表示相对力度', items: [{ label: '强', value: 1 }, { label: '弱', value: .35 }, { label: '强', value: 1 }, { label: '弱', value: .35 }] },
+        visualPlan: { composition: 'concept_grid', visualMode: 'native_diagram', textPresentation: 'plain', imageNecessity: 'none', imagePurpose: 'none', layout: 'diagram_focus' } }),
+      page(5, 'phrase_sing', '一句歌,两种表情', '你更喜欢哪种唱法?', [], 'comparison', {
+        studentContent: { headline: '你更喜欢哪种唱法?', points: [], comparisonGroups: [
+          { label: '轻巧地唱', points: ['声音轻一些,字句仍清楚', '听起来像怎样的螃蟹?'] },
+          { label: '有力地唱', points: ['声音更饱满,保持自然', '同一句的表情改变了吗?'] }
+        ] } }),
+      page(6, 'melody_score', '把发现放进歌里', '选一句,唱出你想表现的样子', ['先跟着老师唱准', '再尝试一种力度变化', '说说你改变了哪里'], 'steps', {
+        studentContent: { headline: '选一句,唱出你想表现的样子', points: [], steps: ['先跟着老师唱准', '再尝试一种力度变化', '说说你改变了哪里'] },
+        visualPlan: { composition: 'steps', visualMode: 'text_only', textPresentation: 'steps', imageNecessity: 'none', imagePurpose: 'none', layout: 'activity' } }),
+      page(7, 'performance_activity', '小组的螃蟹舞台', '让别人听懂你们的设计', ['选一句最想表现的乐句', '商量唱法,试唱一次', '一组表演,另一组说出听到的变化'], 'concept_grid'),
+      page(8, 'practice_check', '谁听出了变化?', '同一句唱了两次,哪里不同?', [], 'focus_question', {
+        interaction: { type: 'question', question: '同一句唱了两次,哪里不同?', options: ['声音的力度', '演唱的速度'], answer: '声音的力度', explanation: '示范时保持音高、速度与字句一致,仅改变力度;以学生能指出并模仿差别为准。' } }),
+      page(9, 'summary', '带走一个新发现', '我让这句歌更有趣的方法是……', ['唱给同伴听,让他猜一猜', '用一句话说明自己的唱法'], 'image_top', { image,
+        visualPlan: { composition: 'image_top', visualMode: 'generated_image', visualShare: .55, textPresentation: 'plain', imageNecessity: 'supporting', imagePurpose: 'scene', layout: 'summary' } })
+    ]
+  }
+}

+ 15 - 0
src/api/aiCourseware.ts

@@ -24,6 +24,8 @@ export interface AiCoursewareGenerateQuery {
 }
 
 export interface AiCoursewarePreferences {
+  teachingApproach?: 'auto' | 'inquiry' | 'story' | 'practice'
+  resourceMode?: 'teacher_supplement' | 'auto_bind'
   learningContext?: string
   lessonScope?: string
   lessonMinutes: number
@@ -37,6 +39,11 @@ export interface AiCoursewarePreferences {
 }
 
 export interface AiCoursewareOutline {
+  teachingDesign?: {
+    approach: string
+    drivingQuestion: string
+    learningPath: string[]
+  }
   teacherRequirements?: string
   title: string
   templateId?: AiCoursewareTemplateId
@@ -115,6 +122,14 @@ export interface AiCoursewarePage {
     teacherInstruction?: string
     studentTask?: string
     interactionType?: string
+    modelExample?: string
+    successCriteria?: string
+    storyScript?: string
+  }
+  diagram?: {
+    kind: 'beats' | 'duration' | 'sequence'
+    caption: string
+    items: Array<{ label: string; value?: number }>
   }
   visual?: {
     layout?: string

+ 5 - 5
src/config/aiCoursewareTemplates.ts

@@ -144,22 +144,22 @@ function extractedTemplate(
 export const AI_COURSEWARE_TEMPLATES: AiCoursewareTemplate[] = [
   {
     id: DEFAULT_AI_COURSEWARE_TEMPLATE_ID,
-    name: "空白模板",
+    name: "AI 主题设计",
     sourceName: "空白模板",
     scene: "通用课堂",
-    description: "纯白背景,无预置装饰,展示教学内容与生成配图",
+    description: "根据本课主题统一配色、主视觉和教学图示,按内容设计页面",
     templateFile: "",
     templateSlides: 10,
     backgroundFiles: [],
     baseStyle: "clean",
     layoutFamily: "education",
     typography: "深色大字,适合教室投屏",
-    backgroundStyle: "纯白背景,不添加预置人物、边饰或背景图片",
+    backgroundStyle: "采用本课主题的浅色背景与高对比文字,保持整套配色统一",
     illustrationStyle: "配图依据教学内容、学生年级和教师要求生成",
     layoutCapabilities: ["cover_scene", "hero_focus", "objectives", "media_focus", "split_content", "text_focus", "diagram_focus", "compare", "activity", "summary"],
     pageLayouts: extractedPageLayouts,
-    visualPrompt: "纯白空白母版,清晰排版教学文字和课程配图,不添加主题背景和装饰;配图风格遵循学生年级与教师要求。",
-    matching: { keywords: [], matchedReason: "适用于各类课程", unmatchedReason: "纯白背景,适用于各类课程" },
+    visualPrompt: "根据已确认教学主线设计整套课件,采用visualBible.palette中的浅色背景、深色文字与强调色,保持投屏对比度。封面用大标题与主题主视觉,探究页用图示或比较,实践页用清晰操作路径;不要每页都套白底列表。配图画风与本课内容、学生年龄一致,装饰不能抢占教学区域。",
+    matching: { keywords: [], matchedReason: "适用于各类课程", unmatchedReason: "根据本课主题设计,适用于各类课程" },
     palettes: extractedPalettes("#334155", "#64748B", "#475569", "#94A3B8", "#FFFFFF")
   },
   extractedTemplate("ethnic-flower", "民族花园", "《彝家娃娃真幸福》", "民族歌曲与律动", "民族人物、花草边饰与低年级互动", "culture", ["#DF7A2B", "#4C9A71", "#E45667", "#F2C84B", "#FFFDF6"], {

+ 3 - 1
src/utils/aiPptContent.ts

@@ -6,7 +6,8 @@ export const scoreBinding = (page: AiCoursewarePage) => (page.resourceBindings |
   .find(item => item.verified === true && item.resourceType === "MUSIC" && Boolean(item.resourceId))
 export const audioBindings = (page: AiCoursewarePage) => (page.resourceBindings || [])
   .filter(item => item.verified === true && item.resourceType === "SONG" && usableResourceUrl(item.contentUrl))
-export const isScorePage = (page: AiCoursewarePage) => /melody|score/.test(page.type || "") || Boolean(scoreBinding(page))
+// 只有真实乐谱才占用整页;缺谱的旋律活动照常呈现问题与任务。
+export const isScorePage = (page: AiCoursewarePage) => Boolean(scoreBinding(page))
 export const needsAudio = (page: AiCoursewarePage) => /listen|phrase_sing|expressive_sing/.test(page.type || "") ||
   (page.resourceBindings || []).some(item => item.required && item.resourceType === "SONG")
 
@@ -22,6 +23,7 @@ export function teacherFacingTexts(page: AiCoursewarePage): string[] {
 
 export function studentTexts(page: AiCoursewarePage): string[] {
   return [page.title, page.studentContent?.headline, page.classroomAction?.studentTask,
+    page.diagram?.caption, ...(page.diagram?.items || []).map(item => item?.label),
     ...bodyPoints(page),
     ...(page.studentContent?.steps || []),
     ...(page.studentContent?.comparisonGroups || []).flatMap(group => [group.label, ...(group.points || [])]),

+ 27 - 0
src/utils/aiPptPageDesign.ts

@@ -89,6 +89,33 @@ export function compilePageDesign(page: AiCoursewarePage, index: number, master:
       else if (item.id === 'body') { item.top = content.task ? 625 : 550; item.height = 180 }
     }
   }
+  if (cover && hasVisual && !showMedia && !content.task && page.title.length <= 20 && content.heading.length <= 30
+    && content.points.length <= 2 && ['image_left', 'image_right'].includes(composition)) {
+    // 封面采用真正的海报式图文分区,让课题与主角都成为视觉焦点。
+    const textLeft = composition === 'image_right' ? 80 : 900
+    const image = slots.find(item => item.id === 'image')
+    if (image) Object.assign(image, { left: composition === 'image_right' ? 780 : 80, top: 90, width: 740, height: 720 })
+    let cursor = 170
+    for (const id of ['title', 'meta', 'headline', 'instruction', 'body']) {
+      const item = slots.find(entry => entry.id === id)
+      if (!item) continue
+      const value = id === 'title' ? page.title : id === 'headline' ? content.heading : id === 'instruction' ? content.task : ''
+      const font = id === 'title' ? 68 : id === 'headline' ? 36 : 28
+      const height = id === 'body' ? Math.max(80, 820 - cursor) : id === 'meta' ? 100 : Math.max(60, measureAiPptText(value, 620, font, 1.25, id === 'title'))
+      Object.assign(item, { left: textLeft, top: cursor, width: 620, height, preferredFontSize: font, textFlow: undefined })
+      cursor += height + 26
+    }
+  }
+  if (!cover && !hasVisual && !showMedia && composition === 'focus_question' && hasHeadline
+    && content.heading.length <= 36 && !content.task && !content.steps.length && content.points.length <= 4) {
+    const title = slots.find(item => item.id === 'title')!
+    Object.assign(title, { preferredFontSize: 38, height: 70 })
+    const heading = slots.find(item => item.id === 'headline')!
+    const questionHeight = measureAiPptText(content.heading, 1360, 64, 1.25, true)
+    Object.assign(heading, { top: 260, width: 1360, height: questionHeight, preferredFontSize: 64 })
+    const body = slots.find(item => item.id === 'body')!
+    Object.assign(body, { top: 300 + questionHeight, height: 500 - questionHeight })
+  }
   return {
     id: `model-${composition}`, layout: cover ? 'cover' : 'text', roles: [cover ? 'cover' : 'content'],
     // 模型设计使用模板配色;固定插画背景不再限制模型构图,也不冒充教学主图。

+ 7 - 5
src/utils/aiPptQuality.ts

@@ -2,7 +2,7 @@ import type { AiCoursewareOutline, AiCoursewarePreferences } from "@/api/aiCours
 import { scoreAiCoursewareTemplate } from "@/config/aiCoursewareTemplates"
 import type { PPTElement, Slide } from "@/types/slides"
 import { audioBindings, scoreBinding, isScorePage, needsAudio, studentTexts, usableResourceUrl } from "@/utils/aiPptContent"
-import { resolveAiPptLayout } from "@/utils/aiPptTemplateEngine"
+import { resolveAiPptLayout, validTeachingDiagram } from "@/utils/aiPptTemplateEngine"
 import { resolveTemplatePageLayout } from "@/config/aiCoursewareTemplateMasters"
 import { aiPptTextGeometry } from "@/utils/aiPptTextMetrics"
 import { hasModelPageDesign, externalVisual } from "@/utils/aiPptPageDesign"
@@ -108,6 +108,8 @@ function inspectOutline(outline: AiCoursewareOutline, issues: AiPptQualityIssue[
 
   pages.forEach((page, index) => {
     const pageNo = page.pageNo || index + 1
+    if (page.diagram && (!validTeachingDiagram(page.diagram) || page.visualPlan?.visualMode !== 'native_diagram'))
+      push(issues, 'invalid_teaching_diagram', 'error', pageNo, '教学图示数据不完整,请重新生成本页')
     const resolved = resolveAiPptLayout(page, index)
     const layout = ({ cover: "cover_scene", hero: "hero_focus", media: "media_focus", split: "split_content", text: "text_focus", diagram: "diagram_focus" } as Record<string, string>)[resolved] || resolved
     if (!resolveTemplatePageLayout(layout)) push(issues, "invalid_layout", "error", pageNo, "本页排版需要检查,请确认文字、图片和活动内容显示完整")
@@ -177,10 +179,10 @@ function inspectOutline(outline: AiCoursewareOutline, issues: AiPptQualityIssue[
   const verifiedBindings = pages
     .flatMap(page => page.resourceBindings || [])
     .filter(binding => binding.verified === true && usableResourceUrl(binding.contentUrl))
-  if (preferences?.requireAudio && !verifiedBindings.some(binding => binding.resourceType === "SONG")) {
+  if (preferences?.resourceMode !== 'teacher_supplement' && preferences?.requireAudio && !verifiedBindings.some(binding => binding.resourceType === "SONG")) {
     push(issues, "required_audio", "error", undefined, "已选择需要音频,但课程资源中没有可用音频")
   }
-  if (preferences?.requireVideo && !verifiedBindings.some(binding => binding.resourceType === "VIDEO")) {
+  if (preferences?.resourceMode !== 'teacher_supplement' && preferences?.requireVideo && !verifiedBindings.some(binding => binding.resourceType === "VIDEO")) {
     push(issues, "required_video", "error", undefined, "已选择需要视频,但课程资源中没有可用视频")
   }
 }
@@ -266,9 +268,9 @@ function inspectTeachingContract(outline: AiCoursewareOutline, slides: Slide[],
     studentTexts(page).forEach(text => {
       if (!rendered.includes(text.replace(/\s+/g, ""))) push(issues, "missing_content", "error", no, `内容没有进入页面:“${short(text)}”`)
     })
-    if (isScorePage(page)) {
+    if (isScorePage(page) || /melody|score/.test(page.type || '')) {
       const score = scoreBinding(page)
-      if (!score) push(issues, "missing_score", "error", no, "本页需要真实曲谱,请绑定已核验乐谱或修改教学任务")
+      if (!score) push(issues, "missing_score", "warning", no, "旋律活动已保留;如上课需要谱例,可由老师补充")
       else if (!usableResourceUrl(score.scoreImageUrl)) push(issues, "score_export", "warning", no, "乐谱可在平台打开,但缺少静态谱图,暂不能导出为离线PPTX")
       if (score && !slide.elements.some(el => el.type === "elf" && el.subtype === "elf-sing-play" && el.sid === score.resourceId || el.type === "image" && el.src === score.scoreImageUrl)) {
         push(issues, "missing_score_render", "error", no, "已绑定的真实乐谱没有进入页面")

+ 98 - 9
src/utils/aiPptTemplateEngine.ts

@@ -1,4 +1,5 @@
 import { nanoid } from "nanoid"
+import tinycolor from 'tinycolor2'
 import { measureAiPptText, aiPptBlockHeight, aiPptTextGeometry } from "@/utils/aiPptTextMetrics"
 import type {
   Gradient,
@@ -105,7 +106,12 @@ export function buildAiPptSlides(
 function buildSlide(page: AiCoursewarePage, outline: AiCoursewareOutline, palette: Palette, options: AiPptOptions, index: number, layoutOccurrences: Map<string, number>): Slide {
   const role = pageRole(page, index)
   const intent = pageRenderIntent(page, role)
-  const master = getAiCoursewareTemplateMaster(options.templateId!)
+  const originalMaster = getAiCoursewareTemplateMaster(options.templateId!)
+  const master = options.templateId === 'blank' && hasModelPageDesign(page) ? {
+    ...originalMaster, titleColor: palette.primary, coverTitleColor: palette.primary,
+    textColor: palette.text, mutedColor: palette.muted,
+    pages: originalMaster.pages.map(item => ({ ...item, background: palette.background }))
+  } : originalMaster
   const baseMasterPage = getTemplateMasterPage(master, role, resolveAiPptLayout(page, index))
   const occurrence = layoutOccurrences.get(baseMasterPage.id) || 0
   layoutOccurrences.set(baseMasterPage.id, occurrence + 1)
@@ -160,6 +166,7 @@ export function getAiPptImageAspectRatios(templateId: AiCoursewareTemplateId): R
   }
   ratios.model_design_v1 = 1 // 服务端依据本版编译器的安全区域和模型比例计算图片尺寸。
   ratios.model_design_v2 = 1
+  ratios.model_design_v3 = 1 // 完整示范、判断标准及结构化教学图示。
   return ratios
 }
 
@@ -187,11 +194,33 @@ function fillDesignedPage(master: TemplateMaster, layout: TemplateMasterPage, pa
     else if (presentation === 'cards' && values.length === 4 && slot && slot.width >= 1000) addCardGrid(elements, slot, values, master, palette, 2)
     else addSlotList(elements, slot, values, master, palette, presentation === 'cards')
   }
+  const focusBody = slotOf(layout, 'body')
+  if (composition === 'focus_question' && page.interaction?.options?.length && !hasImage(page)
+    && !content.steps.length && !content.groups.length && points.length >= 2 && points.length <= 4 && focusBody) {
+    const columns = 2, rows = Math.ceil(points.length / columns), gap = 30
+    const width = (focusBody.width - gap) / 2
+    const height = Math.min(150, (focusBody.height - gap * (rows - 1)) / rows)
+    points.forEach((value, index) => {
+      const left = focusBody.left + index % columns * (width + gap), top = focusBody.top + Math.floor(index / columns) * (height + gap)
+      elements.push(shapeElement({ left, top, width, height, fill: palette.soft, radius: 12 }))
+      addSlotText(elements, { ...focusBody, left: left + 32, top: top + 20, width: width - 64, height: height - 40, preferredFontSize: 44 }, value, master.textColor, true)
+    })
+    addInlineMedia(elements, slotOf(layout, 'media'), page, master, palette, intent.showMedia)
+    return elements
+  }
   if (composition === 'comparison' && content.groups.length === 2) {
     content.groups.forEach((group, i) => {
       const slot = slotOf(layout, i === 0 ? 'left' : 'right')!
-      if (group.label) addSlotText(elements, { ...slot, height: 52 }, group.label, master.titleColor, true)
-      paintText(group.label ? { ...slot, top: slot.top + 66, height: slot.height - 66 } : slot, group.points)
+      const spacious = !hasImage(page) && slot.height >= 300 && group.label.length <= 12 && group.points.length <= 3 && group.points.every(point => point.length <= 32)
+      if (spacious) {
+        elements.push(shapeElement({ left: slot.left, top: slot.top, width: slot.width, height: slot.height, fill: i === 0 ? palette.soft : palette.surface, radius: 12 }))
+        elements.push(lineElement(slot.left + 34, slot.top + 100, slot.left + slot.width - 34, slot.top + 100, palette.secondary, 4))
+        if (group.label) addSlotText(elements, { ...slot, left: slot.left + 34, top: slot.top + 20, width: slot.width - 68, height: 72, preferredFontSize: 48 }, group.label, master.titleColor, true)
+        paintText({ ...slot, left: slot.left + 34, width: slot.width - 68, top: slot.top + 134, height: slot.height - 160 }, group.points)
+      } else {
+        if (group.label) addSlotText(elements, { ...slot, height: 52 }, group.label, master.titleColor, true)
+        paintText(group.label ? { ...slot, top: slot.top + 66, height: slot.height - 66 } : slot, group.points)
+      }
     })
     paintText(slotOf(layout, 'body'), points)
   } else {
@@ -199,7 +228,18 @@ function fillDesignedPage(master: TemplateMaster, layout: TemplateMasterPage, pa
     const rows = page.visualPlan?.visualMode === 'native_diagram' ? rhythmRows(points) : []
     const pattern = page.visualPlan?.visualMode === 'native_diagram' && pageRole(page, index) === 'rhythm'
       ? [headline, ...points].join(' ').match(/[短长](?:\s*[-—·、]\s*[短长]){1,7}/)?.[0].match(/[短长]/g) : undefined
-    if (!content.steps.length && rows.length && rows.length === points.length && body.height >= rows.length * 160) {
+    if (page.diagram && page.visualPlan?.visualMode === 'native_diagram') {
+      const values = [...content.steps, ...points]
+      const textHeight = values.length ? aiPptBlockHeight(values, body.width, presentation) : 0
+      const diagramHeight = body.height - textHeight - (values.length ? 24 : 0)
+      if (diagramHeight >= 210 && validTeachingDiagram(page.diagram)) {
+        addTeachingDiagram(elements, { ...body, height: diagramHeight }, page.diagram, master, palette)
+        if (values.length) paintText({ ...body, top: body.top + diagramHeight + 24, height: textHeight }, values)
+      } else {
+        // 异常或过密数据保留完整文本,由质量检查提示,不编造或截掉图示内容。
+        paintText(body, [page.diagram.caption, ...(page.diagram.items || []).map(item => item?.label || ''), ...values].filter(Boolean))
+      }
+    } else if (!content.steps.length && rows.length && rows.length === points.length && body.height >= rows.length * 160) {
       addRhythmRows(elements, body, rows, master, palette)
     } else if (pattern && !content.steps.length) {
       addDurationDiagram(elements, { ...body, height: body.height * .55 }, pattern, master, palette)
@@ -210,6 +250,42 @@ function fillDesignedPage(master: TemplateMaster, layout: TemplateMasterPage, pa
   return elements
 }
 
+export function validTeachingDiagram(diagram: NonNullable<AiCoursewarePage['diagram']>) {
+  return ['beats', 'duration', 'sequence'].includes(diagram.kind) && Array.isArray(diagram.items) && diagram.items.length >= 2 && diagram.items.length <= 6
+    && diagram.items.every(item => item?.label?.trim() && item.label.length <= 12)
+    && (diagram.kind === 'sequence' || diagram.items.every(item => Number.isFinite(item.value) && item.value! > 0 && item.value! <= 8))
+}
+
+function addTeachingDiagram(elements: PPTElement[], slot: TemplateSlot, diagram: NonNullable<AiCoursewarePage['diagram']>, master: TemplateMaster, palette: Palette) {
+  const captionHeight = diagram.caption ? Math.max(54, measureAiPptText(diagram.caption, slot.width, 32)) : 0
+  const area = { ...slot, height: slot.height - captionHeight - 16 }
+  const gap = 32, count = diagram.items.length
+  const total = diagram.items.reduce((sum, item) => sum + (item.value || 1), 0)
+  const max = Math.max(...diagram.items.map(item => item.value || 1))
+  const column = (slot.width - gap * (count - 1)) / count
+  const shapeHeight = Math.min(150, area.height - 80)
+  let left = slot.left
+  diagram.items.forEach((item, index) => {
+    const width = diagram.kind === 'duration' ? (slot.width - gap * (count - 1)) * item.value! / total : column
+    const size = diagram.kind === 'beats' ? Math.min(width - 24, shapeHeight) * Math.sqrt(item.value! / max) : shapeHeight
+    const top = area.top + Math.max(0, (area.height - shapeHeight - 68) / 2)
+    const color = index % 2 ? palette.secondary : palette.primary
+    if (diagram.kind === 'beats') {
+      const circle = shapeElement({ left: left + (width - size) / 2, top: top + (shapeHeight - size) / 2, width: size, height: size, fill: color })
+      circle.path = 'M100,0 A100,100 0 1,1 99.99,0 Z'
+      elements.push(circle)
+    } else if (diagram.kind === 'duration') {
+      elements.push(shapeElement({ left, top: top + shapeHeight * .25, width, height: shapeHeight * .5, fill: color, radius: 8 }))
+    } else {
+      elements.push(lineElement(left, top + shapeHeight / 2, left + width, top + shapeHeight / 2, palette.secondary, 4))
+      addSlotText(elements, { ...slot, left, top, width, height: 65, preferredFontSize: 44 }, String(index + 1).padStart(2, '0'), palette.primary, true)
+    }
+    addSlotText(elements, { ...slot, left, top: top + shapeHeight + 12, width, height: 64, preferredFontSize: 32 }, item.label, master.textColor, true, diagram.kind === 'beats' ? 'center' : 'left')
+    left += width + gap
+  })
+  if (diagram.caption) addSlotText(elements, { ...slot, top: slot.top + slot.height - captionHeight, height: captionHeight, preferredFontSize: 32 }, diagram.caption, master.mutedColor)
+}
+
 function compactTextSlot(slot: TemplateSlot, values: string[], presentation: string): TemplateSlot {
   const grid = presentation === 'grid' || presentation === 'steps'
   const height = Math.min(slot.height, aiPptBlockHeight(values, slot.width, presentation))
@@ -453,7 +529,7 @@ function assertContentContract(master: TemplateMaster, page: TemplateMasterPage,
   if (collision) throw new Error(`模板 ${master.id}/${page.id} 生成内容侵入“${collision.purpose}”保留区,请更换版式或缩减内容。`)
 }
 
-function addSlotText(elements: PPTElement[], slot: TemplateSlot | undefined, content: string, color: string, bold = false) {
+function addSlotText(elements: PPTElement[], slot: TemplateSlot | undefined, content: string, color: string, bold = false, align: 'left' | 'center' = 'left') {
   if (!slot || !cleanText(content)) return
   elements.push(textElement({
     left: slot.left,
@@ -464,7 +540,8 @@ function addSlotText(elements: PPTElement[], slot: TemplateSlot | undefined, con
     fontSize: fitFont(content, slot.width, slot.height, slot.preferredFontSize || 30, slot.minimumFontSize || 22),
     minimumFontSize: slot.minimumFontSize,
     color,
-    bold
+    bold,
+    align
   }))
 }
 
@@ -934,7 +1011,10 @@ function slideRemark(page: AiCoursewarePage) {
   const explanation = page.interaction?.explanation ? `解析:${page.interaction.explanation}` : ""
   const resources = (page.resourceBindings || []).map(item => `${item.name || item.resourceType}:${item.purpose || "课堂资源"}`).join("\n")
   const mediaReminder = /listen|sing/.test(page.type) && audioBindings(page).length === 0 ? "资源准备:请为本页听唱任务补充对应音频。" : ""
-  return [lessonMeta, page.teachingPurpose, ...teacherFacingTexts(page), activity, fallbackInstruction, page.speakerNotes, page.teacherTips, evidence, answer, explanation, resources, mediaReminder, page.imageReview]
+  const scoreReminder = /melody|score/.test(page.type) && !scoreBinding(page) ? '资源准备:如需展示本曲谱例,请在授课前插入已核验曲谱。' : ''
+  const example = page.classroomAction?.modelExample ? `教学示范:${page.classroomAction.modelExample}` : ''
+  const criteria = page.classroomAction?.successCriteria ? `判断标准:${page.classroomAction.successCriteria}` : ''
+  return [lessonMeta, page.teachingPurpose, ...teacherFacingTexts(page), activity, fallbackInstruction, page.classroomAction?.storyScript, example, criteria, page.speakerNotes, page.teacherTips, evidence, answer, explanation, resources, mediaReminder, scoreReminder, page.imageReview]
     .map(cleanText)
     .filter(Boolean)
     .filter((item, index, all) => all.indexOf(item) === index)
@@ -943,7 +1023,15 @@ function slideRemark(page: AiCoursewarePage) {
 
 function buildPalette(outline: AiCoursewareOutline, variant: number, templateId?: AiCoursewareTemplateId): Palette {
   const resolvedTemplateId = templateId || DEFAULT_AI_COURSEWARE_TEMPLATE_ID
-  return { ...getAiCoursewarePalette(resolvedTemplateId, variant), ...(resolvedTemplateId === "dark-lute" ? { soft: "#16485C" } : {}) }
+  const base = { ...getAiCoursewarePalette(resolvedTemplateId, variant), ...(resolvedTemplateId === "dark-lute" ? { soft: "#16485C" } : {}) }
+  if (resolvedTemplateId !== 'blank') return base
+  const colors = (outline.visualBible?.palette || []).filter(color => /^#[0-9a-f]{6}$/i.test(color))
+  if (colors.length < 2) return base
+  const background = colors.find(color => tinycolor(color).getLuminance() > .8) || '#FAF8F2'
+  const ink = colors.find(color => tinycolor.readability(color, background) >= 7) || '#203345'
+  const accent = colors.find(color => color !== ink && color !== background && tinycolor(color).getLuminance() < .65) || base.secondary
+  return { ...base, primary: ink, secondary: accent, accent, background, text: ink, dark: ink,
+    muted: tinycolor.mix(ink, background, 18).toHexString(), soft: tinycolor.mix(background, accent, 10).toHexString() }
 }
 
 function backgroundConfig(masterPage: TemplateMasterPage, options: AiPptOptions): SlideBackground {
@@ -975,6 +1063,7 @@ function textElement(params: {
   minimumFontSize?: number
   color: string
   bold?: boolean
+  align?: 'left' | 'center'
   lineHeight?: number
 }): PPTTextElement {
   const lineHeight = params.lineHeight || 1.25
@@ -989,7 +1078,7 @@ function textElement(params: {
     height: params.height,
     rotate: 0,
     // 永远保留完整文本。若最小字号仍放不下,由质量门禁阻止应用,而不是静默截断。
-    content: `<div style="box-sizing:border-box;width:calc(100% - 20px);font-family:${FONT_NAME};font-size:${resolvedFontSize}px;${params.bold ? "font-weight:700;" : ""}">${escapeHtml(cleanText(params.content))}</div>`,
+    content: `<div style="box-sizing:border-box;width:calc(100% - 20px);text-align:${params.align || 'left'};font-family:${FONT_NAME};font-size:${resolvedFontSize}px;${params.bold ? "font-weight:700;" : ""}">${escapeHtml(cleanText(params.content))}</div>`,
     defaultFontName: FONT_NAME,
     defaultColor: params.color,
     lineHeight,

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

@@ -22,6 +22,12 @@
       <main>
         <section v-if="step === 1" class="setup">
           <h3>这节课准备怎么上?</h3>
+          <label>教学思路<select v-model="preferences.teachingApproach" :disabled="outlineLoading">
+            <option value="auto">根据教材设计</option>
+            <option value="inquiry">音乐要素探究 · 带问题发现与验证</option>
+            <option value="story">情境任务 · 用故事串联学习</option>
+            <option value="practice">表现实践 · 从模仿到合作创编</option>
+          </select></label>
           <div class="fields">
             <label
               >课堂时长<select v-model.number="preferences.lessonMinutes" :disabled="outlineLoading">
@@ -64,7 +70,12 @@
               >
             </div>
             <label class="check"><input v-model="preferences.enableAnimations" type="checkbox" />添加适量动画</label>
+            <label>音视频与谱例<select v-model="preferences.resourceMode" :disabled="outlineLoading">
+              <option value="teacher_supplement">生成后由老师补充</option>
+              <option value="auto_bind">优先使用已有课程资源</option>
+            </select></label>
           </details>
+          <p class="hint">先完成教学内容与页面设计。音视频、谱例可在生成后添加,所需资源会写入教师备注。</p>
           <p v-if="settingsChanged" class="notice">需求已调整,请重新生成大纲后确认;当前大纲会保留到新大纲生成成功。</p>
           <p v-if="outlineLoading" role="status" class="notice">正在梳理教材和教学大纲,请稍候…</p>
           <p v-if="setupError" role="alert" class="error">{{ setupError }}</p>
@@ -78,6 +89,11 @@
             <span class="page-count">共 {{ pages.length }} 页</span>
           </div>
           <p v-if="requirementsSummary" class="requirements-summary">本次要求:{{ requirementsSummary }}</p>
+          <div v-if="outline.teachingDesign" class="teaching-design">
+            <strong>{{ outline.teachingDesign.drivingQuestion }}</strong>
+            <p>{{ outline.teachingDesign.approach }}</p>
+            <p>{{ outline.teachingDesign.learningPath?.join(' → ') }}</p>
+          </div>
           <ul v-if="validationErrors.length" class="error validation" role="alert">
             <li v-for="message in validationErrors" :key="message">{{ message }}</li>
           </ul>
@@ -229,6 +245,8 @@ const slidesStore = useSlidesStore(),
   snapshots = useSnapshotStore()
 const clone = <T,>(value: T): T => JSON.parse(JSON.stringify(value))
 const defaults = (): AiCoursewarePreferences => ({
+  teachingApproach: 'auto',
+  resourceMode: 'teacher_supplement',
   lessonMinutes: 40,
   minPages: 9,
   maxPages: 12,
@@ -1074,9 +1092,18 @@ footer > div {
   color: #66798f;
 }
 .outline-list {
+  margin-top: 16px;
   max-width: 920px;
   margin: 0 auto;
 }
+.teaching-design {
+  margin: 16px 0;
+  padding: 16px 20px;
+  border-left: 3px solid #1989fa;
+  background: #f3f7fc;
+  color: #25374b;
+}
+.teaching-design p { margin: 8px 0 0; line-height: 1.6; }
 .outline-options {
   display: flex;
   align-items: baseline;

Неке датотеке нису приказане због велике количине промена