yonge 13 小时之前
父节点
当前提交
09c8c5c05b

文件差异内容过多而无法显示
+ 1 - 1
dist/assets/index-0nUrN4id.js


文件差异内容过多而无法显示
+ 0 - 0
dist/assets/index-BhUEbTUz.js


+ 1 - 1
dist/assets/index-BSFzaoNr.js → dist/assets/index-BoyGj7o1.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,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";/**
+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-0nUrN4id.js";/**
 * vue v3.4.34
 * (c) 2018-present Yuxi (Evan) You and Vue contributors
 * @license MIT

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

@@ -1 +1 @@
-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};
+import{u as s,ag as r}from"./index-BoyGj7o1.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-0nUrN4id.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-BkVgy0Su.js → dist/assets/index-CpUJw2rK.js

@@ -1 +1 @@
-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};
+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-0nUrN4id.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-U_S8Nx7O.js → dist/assets/index-Csww_ETo.js

@@ -1 +1 @@
-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};
+import{u as c,ag as l}from"./index-BoyGj7o1.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-0nUrN4id.js";import"./shapes-Ch67t62e.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};

文件差异内容过多而无法显示
+ 0 - 0
dist/assets/index-D391XKVH.css


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

@@ -1 +1 @@
-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};
+import{_ as t}from"./404-R0y5dkvZ.js";import{E as c}from"./index-DTueP-ff.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-0nUrN4id.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};

文件差异内容过多而无法显示
+ 0 - 0
dist/assets/index-DTueP-ff.js


文件差异内容过多而无法显示
+ 0 - 0
dist/assets/index-Djdz6-2K.js


+ 1 - 1
dist/assets/shapes-BlsOIWqa.js → dist/assets/shapes-Ch67t62e.js

@@ -1,4 +1,4 @@
-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:{}};/*!
+import{cG as k,bB as $1}from"./index-0nUrN4id.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-BoyGj7o1.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-BunKwzZX.js"></script>
+    <script type="module" crossorigin src="./assets/index-0nUrN4id.js"></script>
     <link rel="stylesheet" crossorigin href="./assets/index-AavUEaYy.css">
   </head>
   <body>

+ 24 - 2
scripts/aiPptSelfTest.mjs

@@ -6,6 +6,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'
+import { lessonRepairFixture } from './fixtures/aiPptLessonRepair.mjs'
 
 const vite = await createServer({ server: { middlewareMode: true }, appType: "custom", logLevel: "error" })
 
@@ -46,6 +47,27 @@ try {
       const rendered = (slides[index]?.elements || []).map(elementText).join("\n")
       return expected.filter(text => !rendered.includes(text)).map(text => ({ pageNo: item.pageNo, text }))
     })
+  const repairFixture = lessonRepairFixture(inquiry.pages[0].image.url)
+  const repaired = aiCoursewareToSlides(repairFixture, { enableAnimations: true }).slides
+  assert.deepEqual(inspectAiPptQuality(repairFixture, repaired).errors.filter(issue => ['out_of_bounds', 'text_overlap', 'text_overflow', 'rendered_text_geometry', 'missing_content', 'small_font', 'invalid_teaching_diagram'].includes(issue.code)), [])
+  const repairedCoverImage = repaired[0].elements.find(el => el.type === 'image')
+  assert(repairedCoverImage.width * repairedCoverImage.height / (1920 * 1080) >= .2, '真实封面结构不得把主图压成小横幅')
+  const durationBars = repaired[2].elements.filter(el => el.type === 'shape')
+  assert.equal(durationBars.length, 4, '每个短音独立呈现')
+  assert(Math.abs(durationBars[0].width / durationBars[1].width - 2) < .01, '时值条严格使用提供的2:1比例')
+  const pitchLabels = ['do', 're', 'mi'].map(label => repaired[3].elements.find(el => elementText(el) === label))
+  assert(pitchLabels[0].top > pitchLabels[1].top && pitchLabels[1].top > pitchLabels[2].top, '相对音高逐级升高,不能只是水平流程')
+  assert(repaired[3].remark.includes('不表示频率'), '图示依据保留在教师备注')
+  assert(repaired[5].remark.includes('do → re → mi') && !repaired[5].elements.map(elementText).join('').includes('do → re → mi'), '答案仅供教师查看')
+  for (const slide of repaired) {
+    assert.equal(slide.animations.length, slide.elements.length, '标题、正文、形状和线条作为整体呈现')
+    assert.equal(slide.animations[0].trigger, 'auto')
+    assert(slide.animations.slice(1).every(animation => animation.trigger === 'meantime'), '不按文本元素顺序拆散教学图示')
+  }
+  const { imagePreparationMessage, teacherReviewMessage } = await vite.ssrLoadModule('/src/utils/aiPptFeedback.ts')
+  assert(!imagePreparationMessage({ imageStatus: 'review_rejected', imageReview: '评分45低于65,mustInclude缺失,模型说明...' }).includes('评分'))
+  assert(teacherReviewMessage('第6页请补充studentContent.steps').includes('学生操作步骤'))
+
   const singingOutline = {
     title: "凤鸣山谷的故事",
     gradeName: "一年级",
@@ -310,7 +332,7 @@ try {
   const classroomReport = inspectAiPptQuality(classroom, classroomSlides)
   assert(!classroomReport.errors.some(issue => ["out_of_bounds", "text_overlap", "text_overflow", "rendered_text_geometry", "missing_content", "small_font"].includes(issue.code)), JSON.stringify(classroomReport.errors))
   assert.equal(classroomReport.issues.filter(issue => issue.code === "missing_required_visual").length, 6)
-  assert(classroomReport.issues.filter(issue => issue.code === "missing_required_visual").every(issue => issue.message.includes("未返回图片")))
+  assert(classroomReport.issues.filter(issue => issue.code === "missing_required_visual").every(issue => /重新生成|补充/.test(issue.message) && !/模型说明|评分|mustInclude/.test(issue.message)))
   // 配图不能迫使对比、活动、总结、目标页统一分栏,也不能在保留版式后丢图。
   for (const layout of ["compare", "activity", "summary", "objectives"]) {
     for (const template of AI_COURSEWARE_TEMPLATES) {
@@ -372,7 +394,7 @@ try {
   const missingPlannedImage = structuredClone(modelFixture)
   missingPlannedImage.pages[1].image = undefined
   missingPlannedImage.pages[1].imageStatus = 'budget_exhausted'
-  assert(inspectAiPptQuality(missingPlannedImage, render(missingPlannedImage)).issues.some(issue => issue.pageNo === 2 && issue.message.includes('额度')))
+  assert(inspectAiPptQuality(missingPlannedImage, render(missingPlannedImage)).issues.some(issue => issue.pageNo === 2 && issue.message.includes('单独重新生成')))
   const repeatedSlides = structuredClone(modelSlides)
   repeatedSlides[2] = structuredClone(modelSlides[1])
   repeatedSlides[3] = structuredClone(modelSlides[1])

+ 3 - 2
scripts/aiPptVisualTest.html

@@ -15,6 +15,7 @@
     <div id="app"></div>
     <script type="module">
       import { createApp, h, ref, nextTick, watch } from "vue"
+      import { lessonRepairFixture } from './fixtures/aiPptLessonRepair.mjs'
       import { createPinia, setActivePinia } from "pinia"
       import ThumbnailSlide from "/src/views/components/ThumbnailSlide/index.vue"
       import { aiCoursewareToSlides } from "/src/utils/aiCoursewareToSlides.ts"
@@ -46,7 +47,7 @@
       }
       const scenario = new URLSearchParams(location.search).get("scenario")
       const fixtureImage = '/ai-courseware-templates/extracted/bird-garden/background-01.jpg'
-      const fixture = scenario === 'inquiry' ? inquiryFixture() : scenario === 'hero-images' ? heroImageRegressionFixture(fixtureImage) : scenario === 'density' ? densityRegressionFixture(fixtureImage) : scenario === 'semantic' ? semanticDesignFixture(fixtureImage) : scenario === 'model-design' ? modelDesignFixture(fixtureImage)
+      const fixture = scenario === 'repair' ? lessonRepairFixture(fixtureImage) : 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)
@@ -56,7 +57,7 @@
         render: () => h("main", [
           h("h1", `${getAiCoursewareTemplate(id).name} · 通用版式视觉回归`),
           h("div", { id: "geometry-status" }, geometryStatus.value),
-          ...[0, 1, 2].map(index => h('button', { onClick: () => { group.value = index } }, `查看第${index * 4 + 1}—${Math.min(index * 4 + 4, result.slides.length)}页`)),
+          ...Array.from({ length: Math.ceil(result.slides.length / 4) }, (_, index) => h('button', { onClick: () => { group.value = index } }, `查看第${index * 4 + 1}—${Math.min(index * 4 + 4, result.slides.length)}页`)),
           h("section", { class: "grid", style: { gridTemplateColumns: `repeat(2, ${previewWidth}px)` } }, result.slides.slice(group.value * 4, group.value * 4 + 4).map(slide => h("div", { class: "slide" }, [h(ThumbnailSlide, { slide, size: previewWidth })])))
         ])
       })

+ 31 - 0
scripts/fixtures/aiPptLessonRepair.mjs

@@ -0,0 +1,31 @@
+import { inquiryFixture } from './aiPptInquiry.mjs'
+
+// 复现服务器审查中的结构问题;内容为人工修正的排版测试数据,非真实模型验收结果。
+export function lessonRepairFixture(imageUrl) {
+  const outline = inquiryFixture()
+  outline.title = outline.chapterName = '凤鸣山谷的故事'
+  outline.gradeName = '一年级'
+  outline.textbookName = '音乐课堂 · 排版测试'
+  const base = outline.pages[1]
+  const page = (no, title, headline, extra = {}) => ({ ...structuredClone(base), pageNo: no, type: 'music_knowledge', title,
+    studentContent: { headline, points: [] }, classroomAction: { ...base.classroomAction, studentTask: headline },
+    image: undefined, visualPlan: { composition: 'concept_grid', visualMode: 'text_only', textPresentation: 'plain', imagePurpose: 'none', imageNecessity: 'none', layout: 'text_focus' }, ...extra })
+  const scene = { url: imageUrl, width: 1600, height: 900 }
+  const diagramPlan = { composition: 'concept_grid', visualMode: 'native_diagram', textPresentation: 'plain', imagePurpose: 'none', imageNecessity: 'none', layout: 'diagram_focus' }
+  outline.pages = [
+    page(1, outline.title, '山谷里藏着什么声音?', { type: 'cover', image: scene, visualPlan: { composition: 'full_scene', visualMode: 'generated_image', textPresentation: 'plain', imagePurpose: 'scene', imageNecessity: 'supporting', layout: 'cover_scene' } }),
+    page(2, '同一个声音,两种力度', '哪次拍手更响?', { studentContent: { headline: '哪次拍手更响?', points: ['先听两次,再说出你的发现'] } }),
+    page(3, '跟着拍,再自己拍', '两个短音能放进一个长音里吗?', { type: 'rhythm_practice', visualPlan: diagramPlan,
+      diagram: { kind: 'duration', caption: '条的长度表示持续时间', basis: '教师自编练习:时值比例为2:1:1:2。', items: [{ label: '长', value: 2 }, { label: '短', value: 1 }, { label: '短', value: 1 }, { label: '长', value: 2 }] } }),
+    page(4, '声音向上走', '边唱边把手慢慢抬高', { visualPlan: diagramPlan,
+      diagram: { kind: 'pitch', caption: '从左到右,声音逐渐升高', basis: '同一音区内do、re、mi的相对高低;数值表示级序,不表示频率。', items: [{ label: 'do', value: 1 }, { label: 're', value: 2 }, { label: 'mi', value: 3 }] } }),
+    page(5, '编一句,再唱给同伴听', '选两个音,试着改变先后顺序', { type: 'creative_activity', studentContent: { headline: '选两个音,试着改变先后顺序', points: ['示例:do — mi mi', '我的尝试:mi — do do'] } }),
+    page(6, '谁能排好队?', '把这些唱名按从低到高排好', { type: 'practice_check', visualPlan: { ...diagramPlan, visualMode: 'text_only', composition: 'focus_question' },
+      interaction: { type: 'sort', question: '把这些唱名按从低到高排好', options: ['mi', 'do', 're'], answer: 'do → re → mi', explanation: '在同一音区内,三个唱名的音高依次升高。' } }),
+    page(7, '先练习,再一起表演', '听见指挥再加入', { type: 'performance_activity',
+      studentContent: { headline: '听见指挥再加入', points: [], steps: ['选定一个声部', '先单独练习', '看指挥一起开始'] },
+      classroomAction: { ...base.classroomAction, studentTask: '' }, visualPlan: { ...diagramPlan, visualMode: 'text_only', composition: 'steps', textPresentation: 'steps' } }),
+    page(8, '带走一个发现', '用声音告诉同伴你学会了什么', { type: 'summary', studentContent: { headline: '用声音告诉同伴你学会了什么', points: ['同一个声音可以有强弱变化', '两个短音也能组成一个长音的时间'] } })
+  ]
+  return outline
+}

+ 3 - 1
src/api/aiCourseware.ts

@@ -43,6 +43,7 @@ export interface AiCoursewareOutline {
     approach: string
     drivingQuestion: string
     learningPath: string[]
+    consistencyRules?: string[]
   }
   teacherRequirements?: string
   title: string
@@ -127,8 +128,9 @@ export interface AiCoursewarePage {
     storyScript?: string
   }
   diagram?: {
-    kind: 'beats' | 'duration' | 'sequence'
+    kind: 'beats' | 'duration' | 'pitch' | 'sequence'
     caption: string
+    basis?: string
     items: Array<{ label: string; value?: number }>
   }
   visual?: {

+ 3 - 3
src/utils/aiPptContent.ts

@@ -12,7 +12,7 @@ export const needsAudio = (page: AiCoursewarePage) => /listen|phrase_sing|expres
   (page.resourceBindings || []).some(item => item.required && item.resourceType === "SONG")
 
 // 只识别明确的教案表述,保留“观察图片”“跟老师唱”等有效学生指令。
-export const isTeacherFacingText = (text?: string) => /(?:教师|老师)(?:讲述|提问|引导|指导|组织|巡视|示范)|(?:引导|指导|组织)学生|(?:激发|产生|建立)(?:学生)?(?:兴趣|期待|好奇)|学习表现[::]|复述课后任务要求|回顾并口头总结本课所学/.test(text || "")
+export const isTeacherFacingText = (text?: string) => /(?:教师|老师)(?:讲述|提问|引导|指导|组织|巡视|示范)|(?:引导|指导|组织)学生|(?:激发|产生|建立)(?:学生)?(?:兴趣|期待|好奇)|(?:展示|评价|检查)(?:学生)?掌握情况|学习表现[::]|复述课后任务要求|回顾并口头总结本课所学/.test(text || "")
 
 export function teacherFacingTexts(page: AiCoursewarePage): string[] {
   return [page.studentContent?.headline, page.classroomAction?.studentTask, page.subtitle,
@@ -22,7 +22,7 @@ export function teacherFacingTexts(page: AiCoursewarePage): string[] {
 }
 
 export function studentTexts(page: AiCoursewarePage): string[] {
-  return [page.title, page.studentContent?.headline, page.classroomAction?.studentTask,
+  return [page.title, page.interaction?.question ? undefined : page.studentContent?.headline, page.classroomAction?.studentTask,
     page.diagram?.caption, ...(page.diagram?.items || []).map(item => item?.label),
     ...bodyPoints(page),
     ...(page.studentContent?.steps || []),
@@ -62,7 +62,7 @@ export function modelPageContent(page: AiCoursewarePage) {
   }
   const grouped = groups.flatMap(group => [group.label, ...group.points]).map(textKey)
   const points = distinct([...body.filter(text => !grouped.includes(textKey(text)) && !steps.some(step => textKey(step) === textKey(text))), ...(page.interaction?.options || [])])
-  const secondary = distinct([task, clean(page.studentContent?.headline || page.subtitle)])
+  const secondary = distinct([task])
     .filter(text => textKey(text) !== textKey(heading)).join(' · ')
   return { heading, task: secondary, points: groups.length ? distinct([...points, ...steps]) : points, steps, groups }
 }

+ 28 - 0
src/utils/aiPptFeedback.ts

@@ -0,0 +1,28 @@
+import type { AiCoursewarePage } from '@/api/aiCourseware'
+
+/** 教师只需知道待补内容和处理方法,审核原文保留在生成结果中供开发诊断。 */
+export function imagePreparationMessage(page: AiCoursewarePage) {
+  const messages: Record<string, string> = {
+    budget_exhausted: '本页图片尚未生成。可单独重新生成本页,或插入适合本课的图片。',
+    review_rejected: '候选图片与本页教学要求不符,已移除。请重新生成本页或补充正确图片。',
+    reference_missing: '本页需要准确的教材或实物图片,请补充已核实的图片。',
+    generation_failed: '本页图片生成未成功,可稍后重新生成本页或手动插图。',
+    review_unavailable: '本页图片尚未完成核对,请检查角色外观、数量与动作是否符合教学要求。'
+  }
+  return messages[page.imageStatus || ''] || '请核对本页图片是否与教学内容一致;缺少图片时可重新生成本页或手动补充。'
+}
+
+export function teacherReviewMessage(message: string) {
+  const names: Record<string, string> = {
+    'studentContent.steps': '学生操作步骤', 'studentContent.points': '学生画面中的示例',
+    'studentContent': '学生画面内容', 'comparisonGroups': '两组对比内容',
+    'classroomAction.modelExample': '具体教学示范', 'classroomAction.successCriteria': '判断标准',
+    'classroomAction.storyScript': '可讲述的故事', 'storyScript': '可讲述的故事',
+    'interaction.explanation': '答案依据', 'interaction.answer': '教师参考答案',
+    'visualPlan.composition': '页面构图', 'visualPlan': '页面设计', 'composition': '页面构图',
+    'visualMode': '图片或图示形式', 'visualShare': '主图比例', 'textPresentation': '文字排版',
+    'imageNecessity': '配图用途', 'imageReason': '配图理由', 'essential': '教学必需',
+    'headline': '引导问题', 'studentTask': '学生任务', 'objectiveIds': '教学目标关联'
+  }
+  return Object.entries(names).reduce((text, [key, value]) => text.split(key).join(value), message)
+}

+ 8 - 5
src/utils/aiPptPageDesign.ts

@@ -61,9 +61,10 @@ export function compilePageDesign(page: AiCoursewarePage, index: number, master:
         && !content.steps.length && !content.groups.length && content.points.length >= 2 && content.points.length <= 3
         && captionHeight <= 140 && height - captionHeight - 28 >= 240
       // 短句必须保留可读空间,不能靠缩小字号挤到图片下面。
+      const neededBody = content.points.length || content.steps.length || content.groups.length ? bodyHeight(1440) + 28 : 0
       const imageHeight = captionRow ? height - captionHeight - 28
-        : Math.max(80, Math.min(height * share, height - Math.max(180, bodyHeight(1440)) - 28))
-      const imageRatio = page.image?.width && page.image?.height ? page.image.width / page.image.height : 2.8
+        : Math.max(80, height - neededBody)
+      const imageRatio = page.image?.width && page.image?.height ? page.image.width / page.image.height : 16 / 9
       const imageWidth = Math.min(1440, imageHeight * imageRatio)
       slots.push(slot('image', 80 + (1440 - imageWidth) / 2, top, imageWidth, imageHeight))
       body = slot('body', 80, top + imageHeight + 28, 1440, height - imageHeight - 28)
@@ -106,15 +107,17 @@ export function compilePageDesign(page: AiCoursewarePage, index: number, master:
       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 sparseText = plan.visualMode === 'text_only' && presentation === 'plain' && !page.diagram
+    && content.points.length <= 2 && content.points.every(point => point.length <= 40)
+  if (!cover && !hasVisual && !showMedia && (composition === 'focus_question' || sparseText) && hasHeadline
+    && !content.groups.length && 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 })
+    Object.assign(body, { top: 330 + questionHeight, height: 470 - questionHeight, preferredFontSize: 42 })
   }
   return {
     id: `model-${composition}`, layout: cover ? 'cover' : 'text', roles: [cover ? 'cover' : 'content'],

+ 5 - 5
src/utils/aiPptQuality.ts

@@ -6,6 +6,7 @@ import { resolveAiPptLayout, validTeachingDiagram } from "@/utils/aiPptTemplateE
 import { resolveTemplatePageLayout } from "@/config/aiCoursewareTemplateMasters"
 import { aiPptTextGeometry } from "@/utils/aiPptTextMetrics"
 import { hasModelPageDesign, externalVisual } from "@/utils/aiPptPageDesign"
+import { imagePreparationMessage } from '@/utils/aiPptFeedback'
 
 type BoundedPPTElement = Exclude<PPTElement, { type: "line" }>
 
@@ -287,11 +288,10 @@ function inspectTeachingContract(outline: AiCoursewareOutline, slides: Slide[],
     }
     const plannedImage = hasModelPageDesign(page) ? externalVisual(page) : ["scene", "observation", "comparison", "factual", "demonstration"].includes(page.visualPlan?.imagePurpose || "") && Boolean(page.visualPlan?.mainVisual || page.visualPlan?.mustInclude?.length)
     const missingImage = (page.requiredVisual || plannedImage) && !usableResourceUrl(page.image?.url) && !scoreBinding(page)
-    const statusLabels: Record<string, string> = { budget_exhausted: '本页生图额度不足,未调用图片模型', generation_failed: '本页图片模型请求未能取得可用图片', review_rejected: '本页配图未通过教学审核', reference_missing: '本页缺少可核实的图片资源' }
     if (missingImage) push(issues, "missing_required_visual", page.requiredVisual ? "error" : "warning", no,
-      `${statusLabels[page.imageStatus || ''] || '本页计划配图尚未取得'}:${page.imageReview || "可重新生成本页或手动补充图片"}`)
-    if (page.imageReview && !missingImage) push(issues, "image_review", "warning", no, page.imageReview)
-    else if (page.image?.reviewStatus === "unverified") push(issues, "image_review", "warning", no, page.image.reviewReason || "配图未完成教学适用性审核,请核对主体、细节与学生年龄是否合适")
+      imagePreparationMessage(page))
+    if (page.imageReview && !missingImage) push(issues, "image_review", "warning", no, imagePreparationMessage(page))
+    else if (page.image?.reviewStatus === "unverified") push(issues, "image_review", "warning", no, '配图尚未完成核对,请检查角色、数量、动作与课堂任务是否一致。')
     if (page.requiredVisual && usableResourceUrl(page.image?.url) && !slide.elements.some(el => el.type === "image" && el.src === page.image?.url)) push(issues, "missing_visual_render", "error", no, "必要教学图片没有进入页面")
     for (const binding of (page.resourceBindings || []).filter(item => item.resourceType === "VIDEO")) {
       if (binding.verified !== true || !usableResourceUrl(binding.contentUrl) || !slide.elements.some(el => el.type === "elf" && el.subtype === "elf-video" && el.src === binding.contentUrl)) push(issues, "missing_video_render", "error", no, "已选择的视频不可用或没有进入页面")
@@ -301,7 +301,7 @@ function inspectTeachingContract(outline: AiCoursewareOutline, slides: Slide[],
       if (page.interaction?.options?.length === 1) push(issues, "invalid_options", "error", no, "选择题至少有两个有效选项")
     }
     if (page.interaction?.answer && !slide.remark?.includes(page.interaction.answer)) push(issues, "missing_answer", "error", no, "参考答案未进入教师备注")
-    if (index > 0 && (!page.teachingPurpose || !page.learningEvidence || !page.classroomAction?.studentTask || !page.classroomAction.teacherInstruction)) {
+    if (index > 0 && (!page.teachingPurpose || !page.learningEvidence || (!page.classroomAction?.studentTask && !page.studentContent?.steps?.length) || !page.classroomAction?.teacherInstruction)) {
       push(issues, "teaching_chain", "error", no, "请补齐教学目的、学生任务、教师操作和可观察的学习表现")
     }
     if (preferences) {

+ 24 - 15
src/utils/aiPptTemplateEngine.ts

@@ -125,13 +125,17 @@ function buildSlide(page: AiCoursewarePage, outline: AiCoursewareOutline, palett
   let contentElements = designed
     ? fillDesignedPage(master, masterPage, page, outline, palette, index, headline, intent)
     : fillTemplatePage(master, masterPage, page, outline, palette, role, intent)
-  if (designed && aiPptTextGeometry(scaleElementsToViewport(contentElements)).length) {
+  const visualArea = (items: PPTElement[]) => Math.max(0, ...items.map(item => item.type === 'image' ? item.width * item.height : 0))
+  const smallMainImage = hasImage(page) && visualArea(contentElements) < SLIDE_WIDTH * SLIDE_HEIGHT * .18
+  if (designed && (aiPptTextGeometry(scaleElementsToViewport(contentElements)).length || smallMainImage)) {
     // 实际文字放不下时只重排一次:让图文并列,为正文让出宽度,保持分组和步骤语义。
-    const retryPage = { ...page, visualPlan: { ...page.visualPlan!, visualShare: .35 } }
+    const retryPage = { ...page, visualPlan: { ...page.visualPlan!, visualShare: smallMainImage ? .6 : .35 } }
     const retryLayout = compilePageDesign(retryPage, index, master, intent.showMedia,
       (externalVisual(page) && hasImage(page)) || Boolean(videoBinding(page)), Boolean(headline), true)
     const retryElements = fillDesignedPage(master, retryLayout, retryPage, outline, palette, index, headline, intent)
-    if (aiPptTextGeometry(scaleElementsToViewport(retryElements)).length < aiPptTextGeometry(scaleElementsToViewport(contentElements)).length) {
+    const beforeErrors = aiPptTextGeometry(scaleElementsToViewport(contentElements)).length
+    const afterErrors = aiPptTextGeometry(scaleElementsToViewport(retryElements)).length
+    if (afterErrors < beforeErrors || (smallMainImage && afterErrors <= beforeErrors && visualArea(retryElements) > visualArea(contentElements))) {
       masterPage = retryLayout
       contentElements = retryElements
     }
@@ -167,6 +171,7 @@ export function getAiPptImageAspectRatios(templateId: AiCoursewareTemplateId): R
   ratios.model_design_v1 = 1 // 服务端依据本版编译器的安全区域和模型比例计算图片尺寸。
   ratios.model_design_v2 = 1
   ratios.model_design_v3 = 1 // 完整示范、判断标准及结构化教学图示。
+  ratios.model_design_v4 = 1 // 整课一致性复核与内容驱动的图片尺寸。
   return ratios
 }
 
@@ -251,7 +256,7 @@ function fillDesignedPage(master: TemplateMaster, layout: TemplateMasterPage, pa
 }
 
 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
+  return ['beats', 'duration', 'pitch', '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))
 }
@@ -262,6 +267,7 @@ function addTeachingDiagram(elements: PPTElement[], slot: TemplateSlot, diagram:
   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 min = Math.min(...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
@@ -270,7 +276,13 @@ function addTeachingDiagram(elements: PPTElement[], slot: TemplateSlot, diagram:
     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') {
+    if (diagram.kind === 'pitch') {
+      const y = area.top + 28 + (max - item.value!) / Math.max(1, max - min) * Math.max(0, area.height - 146)
+      elements.push(shapeElement({ left, top: y, width, height: 10, fill: color }))
+      addSlotText(elements, { ...slot, left, top: y + 16, width, height: 64, preferredFontSize: 40 }, item.label, master.textColor, true, 'center')
+      left += width + gap
+      return
+    } else 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)
@@ -909,18 +921,14 @@ function renderTemplateMasterElements(elements: TemplateMasterElement[]): PPTEle
 }
 
 function entranceAnimations(elements: PPTElement[]): PPTAnimation[] {
-  return elements
-    .filter(
-       element => element.type === "text" && element.width >= 120 && element.height >= 45
-    )
-    .slice(0, 6)
-    .map((element, index) => ({
+  // 没有明确的教学揭晓顺序时整页同时淡入,图形、编号和标签不能拆开播放。
+  return elements.map((element, index) => ({
       id: nanoid(10),
       elId: element.id,
-      effect: index === 0 ? "fadeIn" : "fadeInUp",
+      effect: "fadeIn",
       type: "in",
-      duration: 500,
-       trigger: index === 0 ? "auto" : "click"
+      duration: 300,
+      trigger: index === 0 ? "auto" : "meantime"
     }))
 }
 
@@ -1014,7 +1022,8 @@ function slideRemark(page: AiCoursewarePage) {
   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]
+  return [lessonMeta, page.teachingPurpose, ...teacherFacingTexts(page), activity, fallbackInstruction, page.classroomAction?.storyScript, example, criteria, page.speakerNotes, page.teacherTips, evidence, answer, explanation, resources, mediaReminder, scoreReminder,
+    page.interaction?.question ? page.studentContent?.headline : '', page.diagram?.basis ? `图示依据:${page.diagram.basis}` : '']
     .map(cleanText)
     .filter(Boolean)
     .filter((item, index, all) => all.indexOf(item) === index)

+ 2 - 1
src/views/Editor/EditorHeader/AiCoursewareDialog.vue

@@ -227,6 +227,7 @@ import { aiCoursewareToSlides } from "@/utils/aiCoursewareToSlides"
 import { getAiPptImageAspectRatios } from "@/utils/aiPptTemplateEngine"
 import { inspectAiPptQuality, type AiPptQualityIssue } from "@/utils/aiPptQuality"
 import { inspectAiPptResources } from "@/utils/aiPptResources"
+import { teacherReviewMessage } from '@/utils/aiPptFeedback'
 import { templateAssetPath } from "@/utils/templateAssetUrl"
 import { draftKey, loadAiDraft, saveAiDraft } from "@/utils/aiCoursewareDraft"
 import {
@@ -579,7 +580,7 @@ async function enterEditor(result: AiCoursewareGenerateResult, active: () => boo
     ...report.warnings.filter(issue => ["image_review", "missing_required_visual", "layout_variety", "layout_consecutive", "rendered_layout_repeat", "invalid_page_design"].includes(issue.code)),
     ...(await inspectAiPptResources(result.outline, converted.slides))
   ]
-  if (result.reviewMessage) issues.unshift({ code: "teaching-review", severity: "warning", message: result.reviewMessage })
+  if (result.reviewMessage) issues.unshift({ code: "teaching-review", severity: "warning", message: teacherReviewMessage(result.reviewMessage) })
   if (!active()) return
   if (
     replacementSnapshot !== editorSnapshot() &&

部分文件因为文件数量过多而无法显示