yonge 3 дней назад
Родитель
Сommit
1b10ce2dd1

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
dist/assets/index-B-p4r2zd.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
dist/assets/index-BoiMSGNH.css


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

@@ -1 +1 @@
-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};
+import{u as s,ag as r}from"./index-b1OlXtrI.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-DHK5xtNf.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-DHK5xtNf.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
dist/assets/index-DT_zvQx-.js


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

@@ -1 +1 @@
-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};
+import{_ as t}from"./404-R0y5dkvZ.js";import{E as c}from"./index-B-p4r2zd.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-DHK5xtNf.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-Djdz6-2K.js


+ 1 - 1
dist/assets/index-Csww_ETo.js → dist/assets/index-I-gnTBCC.js

@@ -1 +1 @@
-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};
+import{u as c,ag as l}from"./index-b1OlXtrI.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-DHK5xtNf.js";import"./shapes-D6c_7wx8.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-BoyGj7o1.js → dist/assets/index-b1OlXtrI.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-0nUrN4id.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-DHK5xtNf.js";/**
 * vue v3.4.34
 * (c) 2018-present Yuxi (Evan) You and Vue contributors
 * @license MIT

+ 1 - 1
dist/assets/index-CpUJw2rK.js → dist/assets/index-hxHGd8Cl.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-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};
+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-DHK5xtNf.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/shapes-Ch67t62e.js → dist/assets/shapes-D6c_7wx8.js

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

+ 20 - 0
scripts/aiPptDialogTest.html

@@ -73,6 +73,7 @@ httpAxios.axioseRquest = async config => {
       data.outline.pages.forEach(page => page.objectiveIds=[])
       data.reviewMessage="声音强弱的练习与评价尚未完整关联"
     }
+    if (scenario === 'whole-repair-draft') data.reviewMessage = '第2页:学生画面缺少具体范例;整课一致性复核暂未完成'
     if (scenario === 'page-repair') {
       const page = data.outline.pages[submitted.value.regeneratePageNo - 1]
       page.studentContent = { points: ['重新生成后的课堂任务', '观察图片,说说声音的变化'] }
@@ -101,6 +102,25 @@ const app=createApp({setup(){
   const slidesStore=useSlidesStore()
   const pptWork=usePptWork()
   const repair = useAiCoursewarePageRepair()
+  if (scenario.startsWith('whole-repair')) {
+    const fixture = structuredClone(outline)
+    fixture.templateId = 'blank'
+    slidesStore.setSlides(aiCoursewareToSlides(fixture).slides)
+    const originalIds = slidesStore.slides.map(slide => slide.id).join(',')
+    repair.generated([{ code: 'teaching-review', severity: 'error', message: '第2页:请补充具体课堂任务' }], fixture,
+      { lessonMinutes:40, minPages:9, maxPages:9, resourceMode:'teacher_supplement', enableAnimations:false })
+    return () => h('main', [
+      h('h1', '整课完善回归(本地模拟接口)'),
+      h('button', { disabled: repair.repairingPage.value !== undefined, onClick: () => repair.repair(0) }, '完善整课'),
+      h('button', { onClick: () => { slidesStore.slides[1].elements.find(e => e.type === 'text').content = '<p>老师手动修改的内容</p>' } }, '手动修改第2页'),
+      h('p', { id: 'repair-state' }, `状态:${repair.repairingPage.value === undefined ? '空闲' : '完善中'};完善请求:${repairs.value};repairOnly:${submitted.value?.repairOnly === true}`),
+      h('p', { id: 'repair-error' }, repair.error.value),
+      h('p', { id: 'repair-issues' }, JSON.stringify(repair.issues.value)),
+      h('p', { id: 'repair-content' }, slidesStore.slides[1].elements.filter(e => e.type === 'text').map(e => e.content.replace(/<[^>]+>/g, '')).join(';')),
+      h('p', { id: 'repair-ids' }, `页面标识${slidesStore.slides.map(slide => slide.id).join(',') === originalIds ? '保持不变' : '变化'}`),
+      h('button', { onClick: () => useSnapshotStore().unDo() }, '测试撤销')
+    ])
+  }
   if (scenario === 'page-repair') {
     const fixture = structuredClone(outline)
     fixture.templateId = 'blank'

+ 19 - 0
scripts/aiPptSelfTest.mjs

@@ -7,12 +7,30 @@ import { semanticDesignFixture, densityRegressionFixture, heroImageRegressionFix
 import { modelDesignFixture } from "./fixtures/aiPptModelDesign.mjs"
 import { inquiryFixture } from './fixtures/aiPptInquiry.mjs'
 import { lessonRepairFixture } from './fixtures/aiPptLessonRepair.mjs'
+import { contentGateFixture } from './fixtures/aiPptContentGate.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 gateFixture = contentGateFixture(), gateSlides = aiCoursewareToSlides(gateFixture).slides
+  const layoutCodes = ['out_of_bounds', 'text_overlap', 'text_overflow', 'rendered_text_geometry', 'missing_content', 'small_font']
+  assert.deepEqual(inspectAiPptQuality(gateFixture, gateSlides).errors.filter(issue => layoutCodes.includes(issue.code)), [], '线上结构回归应完整可读')
+  const dots = slide => slide.elements.filter(element => element.type === 'shape' && element.path === 'M100,0 A100,100 0 1,1 99.99,0 Z')
+  assert.equal(dots(gateSlides[2]).length, 4, '一拍一声:四拍四个发声点')
+  assert.equal(dots(gateSlides[3]).length, 8, '一拍两声:四拍八个发声点')
+  const cells = slide => slide.elements.filter(element => element.type === 'shape' && element.width > 200 && element.height > 80 && element.height < 200)
+  assert.deepEqual(cells(gateSlides[2]).map(({left,width}) => ({left,width})), cells(gateSlides[3]).map(({left,width}) => ({left,width})), '两页拍格宽度及位置一致')
+  assert.equal(gateSlides[5].elements.filter(el => el.type === 'text' && /^[1-9]$/.test(el.content.replace(/<[^>]+>/g, ''))).length, 3, '创编例子不编号成第四个步骤')
+  const leaked = structuredClone(gateFixture)
+  leaked.pages[6].interaction.question += '(第一次更响)'
+  assert(inspectAiPptQuality(leaked, aiCoursewareToSlides(leaked).slides).errors.some(issue => issue.code === 'answer_leak'))
+  assert(!inspectAiPptQuality(gateFixture, gateSlides).errors.some(issue => issue.code === 'answer_leak'), '正确答案作为候选项不算泄露')
+  const { teachingReviewIssues } = await vite.ssrLoadModule('/src/utils/aiPptFeedback.ts')
+  const reviewItems = teachingReviewIssues('第5页:缺少studentContent.example;第10页:题干泄露答案;整课一致性复核暂未完成')
+  assert.deepEqual(reviewItems.map(issue => issue.pageNo), [5, 10, undefined])
+  assert(reviewItems.every(issue => issue.severity === 'error') && reviewItems[0].message.includes('学生可见范例'))
   const inquiry = inquiryFixture()
   const inquiryResult = aiCoursewareToSlides(inquiry)
   const inquiryReport = inspectAiPptQuality(inquiry, inquiryResult.slides, {
@@ -427,6 +445,7 @@ try {
   const { modelPageContent } = await vite.ssrLoadModule('/src/utils/aiPptContent.ts')
   const invalidGroups = structuredClone(semantic.pages[4])
   invalidGroups.studentContent.comparisonGroups.push({ label: '第三组', points: ['第三组的完整内容'] })
+  invalidGroups.studentContent.comparisonGroups.push({ label: '第四组', points: ['第四组的完整内容'] }, { label: '第五组', points: ['第五组的完整内容'] })
   assert(modelPageContent(invalidGroups).points.includes('第三组的完整内容'), '异常分组不能丢失正文')
   const density = densityRegressionFixture(scoreImage)
   const densitySlides = render(density)

+ 2 - 1
scripts/aiPptVisualTest.html

@@ -16,6 +16,7 @@
     <script type="module">
       import { createApp, h, ref, nextTick, watch } from "vue"
       import { lessonRepairFixture } from './fixtures/aiPptLessonRepair.mjs'
+      import { contentGateFixture } from './fixtures/aiPptContentGate.mjs'
       import { createPinia, setActivePinia } from "pinia"
       import ThumbnailSlide from "/src/views/components/ThumbnailSlide/index.vue"
       import { aiCoursewareToSlides } from "/src/utils/aiCoursewareToSlides.ts"
@@ -47,7 +48,7 @@
       }
       const scenario = new URLSearchParams(location.search).get("scenario")
       const fixtureImage = '/ai-courseware-templates/extracted/bird-garden/background-01.jpg'
-      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)
+      const fixture = scenario === 'content-gate' ? contentGateFixture() : 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)

+ 35 - 0
scripts/fixtures/aiPptContentGate.mjs

@@ -0,0 +1,35 @@
+import { inquiryFixture } from './aiPptInquiry.mjs'
+
+// 人工修正的回归数据:复现线上第5至10页结构,不作为模型输出质量证明。
+export function contentGateFixture() {
+  const outline = inquiryFixture()
+  outline.title = outline.chapterName = '凤鸣山谷的故事'
+  outline.gradeName = '一年级'
+  const base = outline.pages[1]
+  const plan = { composition: 'concept_grid', visualMode: 'text_only', textPresentation: 'plain', imagePurpose: 'none', imageNecessity: 'none', layout: 'text_focus' }
+  const page = (pageNo, title, headline, extra = {}) => ({ ...structuredClone(base), pageNo, title, type: 'music_knowledge', image: undefined,
+    studentContent: { headline, points: [] }, classroomAction: { ...base.classroomAction, studentTask: headline }, visualPlan: { ...plan }, ...extra })
+  const rhythm = sounds => ({ kind: 'rhythm', caption: `每格一拍 · 每拍${sounds === 1 ? '一' : '两'}声 · 保持相同拍速`, basis: '教师自编四拍练习,以每分钟80拍为共同拍速。',
+    items: Array.from({ length: 4 }, (_, i) => ({ label: `第${i + 1}拍`, value: sounds })) })
+  outline.pages = [
+    page(1, outline.title, '一起找到声音的秘密', { type: 'cover' }),
+    page(2, '声音的强弱和长短', '每次只比较一种变化', { visualPlan: { ...plan, composition: 'comparison' },
+      studentContent: { headline: '每次只比较一种变化', points: [], comparisonGroups: [
+        { label: '强', points: ['同样拍一次手,声音响一些'] }, { label: '弱', points: ['同样拍一次手,声音轻一些'] },
+        { label: '长', points: ['用同样的声音,持续久一些'] }, { label: '短', points: ['用同样的声音,很快停下来'] }
+      ] } }),
+    page(3, '麒麟的脚步:一拍一声', '跟着相同的拍子拍手', { type: 'rhythm_practice', diagram: rhythm(1), visualPlan: { ...plan, visualMode: 'native_diagram' } }),
+    page(4, '麒麟的脚步:一拍两声', '拍子不变,每拍均匀拍两下', { type: 'rhythm_practice', diagram: rhythm(2), visualPlan: { ...plan, visualMode: 'native_diagram' } }),
+    page(5, '身体交响乐', '看指挥,让山谷响起来', { type: 'performance_activity',
+      studentContent: { headline: '看指挥,让山谷响起来', points: ['风声组:轻声“呼”', '溪水组:轻声“哗啦”', '脚步组:均匀跺脚“咚”'], steps: ['各组先练习', '风→水→脚步依次加入', '看见握拳,一起停'] },
+      visualPlan: { ...plan, composition: 'steps', textPresentation: 'steps' } }),
+    page(6, '创编声音故事', '换一个顺序,讲一个新故事', { type: 'creative_activity',
+      studentContent: { headline: '换一个顺序,讲一个新故事', example: '清晨鸟鸣“啾啾” → 溪水“哗啦” → 脚步“咚咚”', points: [], steps: ['选三种声音', '排好先后顺序', '一起演给同伴听'] },
+      visualPlan: { ...plan, composition: 'steps', textPresentation: 'steps' } }),
+    page(7, '声音侦探', '听两次拍手,哪次更响?', { type: 'practice_check', visualPlan: { ...plan, composition: 'focus_question' },
+      interaction: { question: '听两次拍手,哪次更响?', options: ['第一次更响', '第二次更响'], answer: '第一次更响', explanation: '教师第一次重拍、第二次轻拍,保持声源和次数相同。' },
+      classroomAction: { teacherInstruction: '依次拍手两次,等待学生选择后再揭晓。', studentTask: '听两次拍手,哪次更响?', modelExample: '用同样姿势,第一次重拍一下,停两秒,第二次轻拍一下。', successCriteria: '能选出第一次更响,并指出两次拍手的力度不同。' } }),
+    page(8, '把声音的秘密带回家', '找一种声音,做出两种变化', { type: 'summary', studentContent: { headline: '找一种声音,做出两种变化', points: ['同一声音试试强和弱', '请同伴听听哪里不同'] } })
+  ]
+  return outline
+}

+ 2 - 1
src/api/aiCourseware.ts

@@ -111,6 +111,7 @@ export interface AiCoursewarePage {
   requiredVisual?: boolean
   studentContent?: {
     headline?: string
+    example?: string
     points?: string[]
     steps?: string[]
     comparisonGroups?: Array<{ label: string; points: string[] }>
@@ -128,7 +129,7 @@ export interface AiCoursewarePage {
     storyScript?: string
   }
   diagram?: {
-    kind: 'beats' | 'duration' | 'pitch' | 'sequence'
+    kind: 'beats' | 'rhythm' | 'duration' | 'pitch' | 'sequence'
     caption: string
     basis?: string
     items: Array<{ label: string; value?: number }>

+ 41 - 21
src/hooks/useAiCoursewarePageRepair.ts

@@ -11,17 +11,19 @@ import { getAiPptImageAspectRatios } from "@/utils/aiPptTemplateEngine"
 import { DEFAULT_AI_COURSEWARE_TEMPLATE_ID } from "@/config/aiCoursewareTemplates"
 import { inspectAiPptQuality, type AiPptQualityIssue } from "@/utils/aiPptQuality"
 import { inspectAiPptResources } from "@/utils/aiPptResources"
+import { teachingReviewIssues } from '@/utils/aiPptFeedback'
 
 const clone = <T,>(value: T): T => JSON.parse(JSON.stringify(value))
 
-// 使用已有单页生成接口,重试结果只替换原幻灯片,不覆盖其他页及生成期间的新编辑。
+// 单页修复与整课复核共用已有任务接口;始终保护生成期间的新编辑。
 export default function useAiCoursewarePageRepair() {
   const slides = useSlidesStore(), work = usePptWork(), snapshots = useSnapshotStore()
   const issues = ref<AiPptQualityIssue[]>([]), repairingPage = ref<number>()
   const error = ref("")
   let outline: AiCoursewareOutline | undefined, preferences: AiCoursewarePreferences | undefined
   let slideIds: string[] = [], version = 0
-  const reset = () => { version++; outline = undefined; preferences = undefined; slideIds = []; issues.value = []; repairingPage.value = undefined; error.value = "" }
+  let baseline: string[] = []
+  const reset = () => { version++; outline = undefined; preferences = undefined; slideIds = []; baseline = []; issues.value = []; repairingPage.value = undefined; error.value = "" }
   watch(() => work.id, reset)
   onBeforeUnmount(reset)
 
@@ -31,6 +33,7 @@ export default function useAiCoursewarePageRepair() {
     outline = clone(nextOutline)
     preferences = clone(nextPreferences)
     slideIds = slides.slides.map(slide => slide.id)
+    baseline = slides.slides.map(slide => JSON.stringify(slide))
   }
   function locate(pageNo: number) {
     const index = slides.slides.findIndex(slide => slide.id === slideIds[pageNo - 1])
@@ -38,10 +41,14 @@ export default function useAiCoursewarePageRepair() {
     return index
   }
   async function repair(pageNo: number) {
-    if (!outline || !preferences || repairingPage.value) return
-    const index = locate(pageNo)
+    if (!outline || !preferences || repairingPage.value !== undefined) return
+    const whole = pageNo === 0
+    if (whole && (slides.slides.length !== baseline.length || slides.slides.some((slide, i) => JSON.stringify(slide) !== baseline[i]))) {
+      error.value = '课件已有手动编辑。为保留你的修改,请使用对应问题的“重新生成本页”。'; return
+    }
+    const index = whole ? 0 : locate(pageNo)
     if (index < 0) { error.value = "原页面已删除,请从当前课件重新生成。"; return }
-    const slideId = slideIds[pageNo - 1], before = JSON.stringify(slides.slides[index])
+    const slideId = slideIds[pageNo - 1], before = JSON.stringify(whole ? slides.slides : slides.slides[index])
     const requestVersion = version, materialId = work.id
     const active = () => requestVersion === version && work.id === materialId
     const sourceOutline = clone(outline), prefs = clone(preferences)
@@ -53,53 +60,66 @@ export default function useAiCoursewarePageRepair() {
       if (!active()) return
       const response = await httpAjax(aiCoursewareGenerateApi, {
         id: materialId, fromType: queryParams.pptResourcesType || queryParams.fromType || "TEACHER",
-        outline: sourceOutline, templateId, preferences: prefs, regeneratePageNo: pageNo,
+        outline: sourceOutline, templateId, preferences: prefs, ...(whole ? { repairOnly: true } : { regeneratePageNo: pageNo }),
         imageAspectRatios: getAiPptImageAspectRatios(templateId),
-        visualPrompt: "仅完善本页,保持教学目标。补齐必要配图、具体题干及教师参考答案,采用符合本页教学任务的版式。"
+        visualPrompt: (whole ? '在当前草稿上完善整课,保持页数与页序,保留正确内容。' : '仅完善本页,保持教学目标。')
+          + '补齐学生可见的示例、分工和操作规则,统一题干与教师备注,修正图示及排版。媒体由教师补充。待解决问题:'
+          + issues.value.filter(issue => whole || !issue.pageNo || issue.pageNo === pageNo).map(issue => issue.message).join(';')
       })
       if (!active()) return
       if (response.code !== 200 || !response.data?.taskId) throw new Error(response.message || "未能开始单页生成")
       const started = Date.now()
+      let draft = false
       while (active()) {
         const task = await httpAjax(aiCoursewareTaskApi, response.data.taskId)
         if (!active()) return
         if (task.code !== 200) throw new Error(task.message || "无法读取生成进度")
         if (task.data.status === "FAILED") throw new Error(task.data.message || "本页生成失败")
-        if (["DONE", "DRAFT"].includes(task.data.status)) break
+        if (["DONE", "DRAFT"].includes(task.data.status)) { draft = task.data.status === 'DRAFT'; break }
         if (Date.now() - started > 15 * 60 * 1000) throw new Error("本页生成超过15分钟,请稍后重试")
         await new Promise(resolve => setTimeout(resolve, 2500))
       }
       if (!active()) return
       const result = await httpAjax(aiCoursewareResultApi, response.data.taskId)
       if (!active()) return
-      if (result.code !== 200 || !result.data?.outline?.pages?.[pageNo - 1]) throw new Error(result.message || "本页结果不完整")
+      if (result.code !== 200 || !result.data?.outline?.pages?.[whole ? 0 : pageNo - 1]) throw new Error(result.message || "生成结果不完整")
+      const resultOutline = result.data.outline as AiCoursewareOutline
+      if (whole && (resultOutline.pages.length !== sourceOutline.pages.length || resultOutline.pages.some((page, i) => page.pageNo !== i + 1))) throw new Error('完善后的页数或顺序不符,已保留当前课件。')
       // 忽略后端返回的其他页面,保留本地原始教学安排。
-      const nextOutline = clone(sourceOutline)
-      nextOutline.pages[pageNo - 1] = result.data.outline.pages[pageNo - 1]
+      const nextOutline = clone(whole ? resultOutline : sourceOutline)
+      if (!whole) nextOutline.pages[pageNo - 1] = resultOutline.pages[pageNo - 1]
       await assertAiPptClientCurrent()
       if (!active()) return
       const converted = aiCoursewareToSlides(nextOutline, { templateId, enableAnimations: prefs.enableAnimations })
       const target = converted.slides[pageNo - 1]
       const report = inspectAiPptQuality(nextOutline, converted.slides, { ...prefs, minPages: nextOutline.pages.length, maxPages: nextOutline.pages.length })
-      const resourceIssues = await inspectAiPptResources({ ...nextOutline, pages: [nextOutline.pages[pageNo - 1]] }, [target])
+      const resourceIssues = await inspectAiPptResources(whole ? nextOutline : { ...nextOutline, pages: [nextOutline.pages[pageNo - 1]] }, whole ? converted.slides : [target])
       if (!active()) return
-      const current = slides.slides.findIndex(slide => slide.id === slideId)
-      if (current < 0 || JSON.stringify(slides.slides[current]) !== before) throw new Error("本页在生成期间已被修改或删除,未替换当前内容,避免丢失你的编辑。")
+      const unchanged = () => whole ? JSON.stringify(slides.slides) === before : JSON.stringify(slides.slides.find(slide => slide.id === slideId)) === before
+      if (!unchanged()) throw new Error("课件在生成期间已被修改或删除,未替换当前内容,避免丢失你的编辑。")
       await snapshots.addSnapshot()
       if (!active()) return
-      const latest = slides.slides.findIndex(slide => slide.id === slideId)
-      if (latest < 0 || JSON.stringify(slides.slides[latest]) !== before) throw new Error("本页内容已变化,未覆盖当前编辑。")
-      const nextSlides = [...slides.slides]
-      nextSlides[latest] = { ...target, id: slideId }
+      const latest = whole ? 0 : slides.slides.findIndex(slide => slide.id === slideId)
+      if (!unchanged()) throw new Error("课件内容已变化,未覆盖当前编辑。")
+      const nextSlides = whole ? converted.slides.map((slide, i) => ({ ...slide, id: slideIds[i] })) : [...slides.slides]
+      if (!whole) nextSlides[latest] = { ...target, id: slideId }
       slides.setSlides(nextSlides)
       slides.updateSlideIndex(latest)
       useMainStore().setActiveElementIdList([])
       work.isSave = false
       outline = nextOutline
-      const pageIssues = report.issues.filter(issue => issue.pageNo === pageNo && (issue.severity === "error" || ["image_review", "missing_required_visual"].includes(issue.code)))
-      issues.value = [...issues.value.filter(issue => issue.pageNo !== pageNo), ...pageIssues, ...resourceIssues.map(issue => ({ ...issue, pageNo }))]
+      if (whole) baseline = nextSlides.map(slide => JSON.stringify(slide))
+      else baseline[pageNo - 1] = JSON.stringify(nextSlides[latest])
+      const pageIssues = report.issues.filter(issue => (whole || issue.pageNo === pageNo) && (issue.severity === "error" || ["image_review", "missing_required_visual"].includes(issue.code)))
+      const reviewIssues = teachingReviewIssues(result.data.reviewMessage)
+      if (draft && !reviewIssues.length) reviewIssues.push({ code: 'teaching-review', severity: 'error', message: '课堂内容尚未通过复核,请继续完善草稿。' })
+      issues.value = [...(whole ? [] : issues.value.filter(issue => issue.pageNo !== pageNo && issue.code !== 'teaching-review')),
+        ...reviewIssues, ...pageIssues, ...resourceIssues.map(issue => whole ? issue : { ...issue, pageNo })]
       await snapshots.addSnapshot()
-      if (active()) ElMessage.success(`第 ${pageNo} 页已重新生成,可撤销恢复;请核对后保存。`)
+      if (active()) {
+        if (draft || issues.value.some(issue => issue.severity === 'error')) ElMessage.warning('草稿已更新,仍有待完善内容,请查看问题列表。')
+        else ElMessage.success(`${whole ? '整课已完善' : `第 ${pageNo} 页已重新生成`},可撤销恢复;请核对后保存。`)
+      }
     } catch (reason) {
       if (active()) error.value = reason instanceof Error ? reason.message : "本页生成失败,请重试"
     } finally {

+ 7 - 4
src/utils/aiPptContent.ts

@@ -15,7 +15,7 @@ export const needsAudio = (page: AiCoursewarePage) => /listen|phrase_sing|expres
 export const isTeacherFacingText = (text?: string) => /(?:教师|老师)(?:讲述|提问|引导|指导|组织|巡视|示范)|(?:引导|指导|组织)学生|(?:激发|产生|建立)(?:学生)?(?:兴趣|期待|好奇)|(?:展示|评价|检查)(?:学生)?掌握情况|学习表现[::]|复述课后任务要求|回顾并口头总结本课所学/.test(text || "")
 
 export function teacherFacingTexts(page: AiCoursewarePage): string[] {
-  return [page.studentContent?.headline, page.classroomAction?.studentTask, page.subtitle,
+  return [page.studentContent?.headline, page.studentContent?.example, page.classroomAction?.studentTask, page.subtitle,
     ...(page.studentContent?.points || []), ...(page.studentContent?.steps || []),
     ...(page.studentContent?.comparisonGroups || []).flatMap(group => [group.label, ...(group.points || [])]), ...(page.bullets || [])]
     .filter((text): text is string => Boolean(text) && isTeacherFacingText(text))
@@ -34,8 +34,11 @@ export function studentTexts(page: AiCoursewarePage): string[] {
 const textKey = (text: string) => text.replace(/[\s,。!?、;:,.!?;:]+/g, '')
 const visible = (text?: string) => String(text || '').trim()
 const distinct = (texts: string[]) => texts.filter((text, i) => text && !isTeacherFacingText(text) && texts.findIndex(other => textKey(other) === textKey(text)) === i)
-const bodyPoints = (page: AiCoursewarePage) => page.studentContent?.points?.length || page.studentContent?.steps?.length || page.studentContent?.comparisonGroups?.length
-  ? page.studentContent?.points || [] : page.bullets || []
+const bodyPoints = (page: AiCoursewarePage) => [
+  ...(page.studentContent?.points?.length || page.studentContent?.steps?.length || page.studentContent?.comparisonGroups?.length || page.studentContent?.example
+    ? page.studentContent?.points || [] : page.bullets || []),
+  ...(page.studentContent?.example ? [`例子:${page.studentContent.example}`] : [])
+]
 
 /** 引导语、课堂任务和正文保持各自语义,不把标题追加为步骤或对比项。 */
 export function modelPageContent(page: AiCoursewarePage) {
@@ -56,7 +59,7 @@ export function modelPageContent(page: AiCoursewarePage) {
       if (labels.length === 2 && labels.every(Boolean)) groups = labels.map(label => ({ label: '', points: body.filter((_, i) => labelled[i] === label) }))
     }
   }
-  if (groups.length !== 2 || page.visualPlan?.composition !== 'comparison') {
+  if (groups.length < 2 || groups.length > 4 || page.visualPlan?.composition !== 'comparison') {
     body.push(...groups.flatMap(group => [group.label, ...group.points]).filter(Boolean))
     groups = []
   }

+ 10 - 1
src/utils/aiPptFeedback.ts

@@ -1,4 +1,5 @@
 import type { AiCoursewarePage } from '@/api/aiCourseware'
+import type { AiPptQualityIssue } from '@/utils/aiPptQuality'
 
 /** 教师只需知道待补内容和处理方法,审核原文保留在生成结果中供开发诊断。 */
 export function imagePreparationMessage(page: AiCoursewarePage) {
@@ -15,7 +16,7 @@ export function imagePreparationMessage(page: AiCoursewarePage) {
 export function teacherReviewMessage(message: string) {
   const names: Record<string, string> = {
     'studentContent.steps': '学生操作步骤', 'studentContent.points': '学生画面中的示例',
-    'studentContent': '学生画面内容', 'comparisonGroups': '两组对比内容',
+    'studentContent.example': '学生可见范例', 'studentContent': '学生画面内容', 'comparisonGroups': '对比组',
     'classroomAction.modelExample': '具体教学示范', 'classroomAction.successCriteria': '判断标准',
     'classroomAction.storyScript': '可讲述的故事', 'storyScript': '可讲述的故事',
     'interaction.explanation': '答案依据', 'interaction.answer': '教师参考答案',
@@ -26,3 +27,11 @@ export function teacherReviewMessage(message: string) {
   }
   return Object.entries(names).reduce((text, [key, value]) => text.split(key).join(value), message)
 }
+
+export function teachingReviewIssues(message?: string): AiPptQualityIssue[] {
+  if (!message?.trim()) return []
+  return [...new Set(message.split(/;(?=第\d+页|课堂|整课|“)/).map(text => text.trim()).filter(Boolean))].map(text => ({
+    code: 'teaching-review', severity: 'error', pageNo: text.match(/^第(\d+)页/) ? Number(text.match(/^第(\d+)页/)![1]) : undefined,
+    message: teacherReviewMessage(text)
+  }))
+}

+ 10 - 5
src/utils/aiPptPageDesign.ts

@@ -39,10 +39,13 @@ export function compilePageDesign(page: AiCoursewarePage, index: number, master:
   const height = bottom - top
   if (showMedia) slots.push(slot('media', 80, 758, 1440, 72, 30))
   const presentation = plan.textPresentation || 'plain'
-  const bodyHeight = (width: number) => content.groups.length === 2
+  const bodyHeight = (width: number) => content.groups.length >= 2
     ? Math.max(...content.groups.map(group => (group.label ? 66 : 0) + aiPptBlockHeight(group.points, (width - 40) / 2, presentation)))
+      * Math.ceil(content.groups.length / 2) + (content.groups.length > 2 ? 24 : 0)
       + (content.points.length ? aiPptBlockHeight(content.points, width, 'plain') + 20 : 0)
-    : aiPptBlockHeight([...content.steps, ...content.points], width, presentation)
+    : aiPptBlockHeight(content.steps, width, presentation)
+      + aiPptBlockHeight(content.points, width, content.steps.length ? 'plain' : presentation)
+      + (content.steps.length && content.points.length ? 24 : 0)
   let body = slot('body', 80, top, 1440, height)
   if (hasVisual) {
     if (preferSide || ['image_left', 'image_right', 'concept_grid', 'focus_question'].includes(composition)) {
@@ -71,14 +74,16 @@ export function compilePageDesign(page: AiCoursewarePage, index: number, master:
       if (captionRow) body.textFlow = 'columns'
     }
   }
-  if (composition === 'comparison' && content.groups.length === 2) {
+  if (composition === 'comparison' && content.groups.length >= 2) {
     if (content.points.length) {
       const sharedHeight = Math.min(aiPptBlockHeight(content.points, body.width, 'plain'), body.height * .4)
       slots.push({ ...body, top: body.top + body.height - sharedHeight, height: sharedHeight })
       body = { ...body, height: body.height - sharedHeight - 20 }
     }
-    slots.push({ ...body, id: 'left', kind: 'left', width: (body.width - 40) / 2 })
-    slots.push({ ...body, id: 'right', kind: 'right', left: body.left + (body.width + 40) / 2, width: (body.width - 40) / 2 })
+    const rows = Math.ceil(content.groups.length / 2), rowHeight = (body.height - 24 * (rows - 1)) / rows
+    content.groups.forEach((_, i) => slots.push({ ...body, id: i === 0 ? 'left' : i === 1 ? 'right' : `comparison-${i}`,
+      kind: i % 2 ? 'right' : 'left', left: body.left + (i % 2) * (body.width + 40) / 2,
+      top: body.top + Math.floor(i / 2) * (rowHeight + 24), width: (body.width - 40) / 2, height: rowHeight }))
   } else slots.push(body)
   if (cover && !hasVisual && !showMedia) {
     // 主图缺失时重新组织成文字封面,不把原图位置变成整页空洞。

+ 7 - 0
src/utils/aiPptQuality.ts

@@ -299,6 +299,13 @@ function inspectTeachingContract(outline: AiCoursewareOutline, slides: Slide[],
     if (page.type === "practice_check") {
       if (!page.interaction?.question || !page.interaction.answer) push(issues, "incomplete_question", "error", no, "检测题必须有题干和教师参考答案")
       if (page.interaction?.options?.length === 1) push(issues, "invalid_options", "error", no, "选择题至少有两个有效选项")
+      const answerKey = (text?: string) => (text || '').toLowerCase().replace(/[\s\p{P}→⇒]+/gu, '')
+      const answer = answerKey(page.interaction?.answer)
+      // 选项允许包含正确答案;题干、正文及解答图示不能先给出答案。
+      const visibleQuestion = [page.interaction?.question, page.studentContent?.headline, page.studentContent?.example,
+        ...(page.studentContent?.points || []), ...(page.studentContent?.steps || [])].map(answerKey).join('')
+      if (answer.length >= 3 && (visibleQuestion.includes(answer) || answerKey(page.diagram?.items.map(item => item.label).join('')) === answer))
+        push(issues, 'answer_leak', '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.studentContent?.steps?.length) || !page.classroomAction?.teacherInstruction)) {

+ 39 - 14
src/utils/aiPptTemplateEngine.ts

@@ -172,6 +172,7 @@ export function getAiPptImageAspectRatios(templateId: AiCoursewareTemplateId): R
   ratios.model_design_v2 = 1
   ratios.model_design_v3 = 1 // 完整示范、判断标准及结构化教学图示。
   ratios.model_design_v4 = 1 // 整课一致性复核与内容驱动的图片尺寸。
+  ratios.model_design_v5 = 1 // 按拍分组、学生范例和内容复核完成状态。
   return ratios
 }
 
@@ -186,18 +187,27 @@ function fillDesignedPage(master: TemplateMaster, layout: TemplateMasterPage, pa
   addVisualOrResource(elements, slotOf(layout, 'image'), page, master, palette)
   const composition = page.visualPlan!.composition
   const presentation = page.visualPlan!.textPresentation || 'plain'
-  const paintText = (slot: TemplateSlot | undefined, values: string[]) => {
+  const paintText = (slot: TemplateSlot | undefined, values: string[], format = presentation) => {
     if (slot?.textFlow === 'columns' && values.length) {
       const width = (slot.width - 28 * (values.length - 1)) / values.length
       for (let index = 0; index < values.length; index++) addSlotText(elements, { ...slot, left: slot.left + index * (width + 28), width,
         preferredFontSize: 38 }, values[index], master.textColor)
       return
     }
-    if (slot) slot = compactTextSlot(slot, values, presentation)
-    if (presentation === 'steps') addStepGrid(elements, slot, values, master, palette)
-    else if (presentation === 'grid') addCardGrid(elements, slot, values, master, palette)
-    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')
+    if (slot) slot = compactTextSlot(slot, values, format)
+    if (format === 'steps') addStepGrid(elements, slot, values, master, palette)
+    else if (format === 'grid') addCardGrid(elements, slot, values, master, palette)
+    else if (format === 'cards' && values.length === 4 && slot && slot.width >= 1000) addCardGrid(elements, slot, values, master, palette, 2)
+    else addSlotList(elements, slot, values, master, palette, format === 'cards')
+  }
+  const bodyTextHeight = (width: number) => aiPptBlockHeight(content.steps, width, presentation)
+    + aiPptBlockHeight(points, width, content.steps.length ? 'plain' : presentation) + (content.steps.length && points.length ? 24 : 0)
+  const paintBody = (slot: TemplateSlot) => {
+    if (!content.steps.length || !points.length) { paintText(slot, [...content.steps, ...points]); return }
+    const pointsHeight = aiPptBlockHeight(points, slot.width, 'plain')
+    const stepsHeight = Math.min(aiPptBlockHeight(content.steps, slot.width, presentation), slot.height - pointsHeight - 24)
+    paintText({ ...slot, height: stepsHeight }, content.steps)
+    paintText({ ...slot, top: slot.top + stepsHeight + 24, height: slot.height - stepsHeight - 24 }, points, 'plain')
   }
   const focusBody = slotOf(layout, 'body')
   if (composition === 'focus_question' && page.interaction?.options?.length && !hasImage(page)
@@ -213,15 +223,19 @@ function fillDesignedPage(master: TemplateMaster, layout: TemplateMasterPage, pa
     addInlineMedia(elements, slotOf(layout, 'media'), page, master, palette, intent.showMedia)
     return elements
   }
-  if (composition === 'comparison' && content.groups.length === 2) {
+  if (composition === 'comparison' && content.groups.length >= 2) {
     content.groups.forEach((group, i) => {
-      const slot = slotOf(layout, i === 0 ? 'left' : 'right')!
+      const slot = slotOf(layout, i === 0 ? 'left' : i === 1 ? 'right' : `comparison-${i}`)!
       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 (content.groups.length > 2 && !hasImage(page) && slot.height >= 170) {
+        elements.push(shapeElement({ left: slot.left, top: slot.top, width: slot.width, height: slot.height, fill: i % 2 ? palette.surface : palette.soft, radius: 10 }))
+        addSlotText(elements, { ...slot, left: slot.left + 28, top: slot.top + 18, width: slot.width - 56, height: 64, preferredFontSize: 46 }, group.label, master.titleColor, true)
+        paintText({ ...slot, left: slot.left + 28, top: slot.top + 100, width: slot.width - 56, height: slot.height - 118 }, group.points, 'plain')
       } 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)
@@ -235,11 +249,11 @@ function fillDesignedPage(master: TemplateMaster, layout: TemplateMasterPage, pa
       ? [headline, ...points].join(' ').match(/[短长](?:\s*[-—·、]\s*[短长]){1,7}/)?.[0].match(/[短长]/g) : undefined
     if (page.diagram && page.visualPlan?.visualMode === 'native_diagram') {
       const values = [...content.steps, ...points]
-      const textHeight = values.length ? aiPptBlockHeight(values, body.width, presentation) : 0
+      const textHeight = values.length ? bodyTextHeight(body.width) : 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)
+        if (values.length) paintBody({ ...body, top: body.top + diagramHeight + 24, height: textHeight })
       } else {
         // 异常或过密数据保留完整文本,由质量检查提示,不编造或截掉图示内容。
         paintText(body, [page.diagram.caption, ...(page.diagram.items || []).map(item => item?.label || ''), ...values].filter(Boolean))
@@ -249,16 +263,17 @@ function fillDesignedPage(master: TemplateMaster, layout: TemplateMasterPage, pa
     } else if (pattern && !content.steps.length) {
       addDurationDiagram(elements, { ...body, height: body.height * .55 }, pattern, master, palette)
       paintText({ ...body, top: body.top + body.height * .60, height: body.height * .40 }, points)
-    } else paintText(body, [...content.steps, ...points])
+    } else paintBody(body)
   }
   addInlineMedia(elements, slotOf(layout, 'media'), page, master, palette, intent.showMedia)
   return elements
 }
 
 export function validTeachingDiagram(diagram: NonNullable<AiCoursewarePage['diagram']>) {
-  return ['beats', 'duration', 'pitch', 'sequence'].includes(diagram.kind) && Array.isArray(diagram.items) && diagram.items.length >= 2 && diagram.items.length <= 6
+  return ['beats', 'rhythm', '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))
+    && (diagram.kind !== 'rhythm' || diagram.items.every(item => Number.isInteger(item.value) && item.value! <= 4))
 }
 
 function addTeachingDiagram(elements: PPTElement[], slot: TemplateSlot, diagram: NonNullable<AiCoursewarePage['diagram']>, master: TemplateMaster, palette: Palette) {
@@ -276,7 +291,17 @@ 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 === 'pitch') {
+    if (diagram.kind === 'rhythm') {
+      // 每个等宽格是一拍;圆点数只表示该拍内的发声次数,不用大小或颜色暗示强弱。
+      const sounds = item.value!, dotSize = Math.min(54, (width - 32) / (sounds * 1.5), shapeHeight - 18)
+      elements.push(shapeElement({ left, top, width, height: shapeHeight, fill: palette.soft, radius: 10 }))
+      for (let sound = 0; sound < sounds; sound++) {
+        const circle = shapeElement({ left: left + width * (sound + .5) / sounds - dotSize / 2,
+          top: top + (shapeHeight - dotSize) / 2, width: dotSize, height: dotSize, fill: palette.primary })
+        circle.path = 'M100,0 A100,100 0 1,1 99.99,0 Z'
+        elements.push(circle)
+      }
+    } else 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')
@@ -292,7 +317,7 @@ function addTeachingDiagram(elements: PPTElement[], slot: TemplateSlot, diagram:
       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')
+    addSlotText(elements, { ...slot, left, top: top + shapeHeight + 12, width, height: 64, preferredFontSize: 32 }, item.label, master.textColor, true, ['beats', 'rhythm'].includes(diagram.kind) ? '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)

+ 6 - 3
src/views/Editor/EditorHeader/AiCoursewareDialog.vue

@@ -227,7 +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 { teachingReviewIssues } from '@/utils/aiPptFeedback'
 import { templateAssetPath } from "@/utils/templateAssetUrl"
 import { draftKey, loadAiDraft, saveAiDraft } from "@/utils/aiCoursewareDraft"
 import {
@@ -580,7 +580,9 @@ 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: teacherReviewMessage(result.reviewMessage) })
+  issues.unshift(...teachingReviewIssues(result.reviewMessage))
+  const draft = task.value?.status === 'DRAFT' || issues.some(issue => issue.severity === 'error')
+  if (draft && !issues.some(issue => issue.severity === 'error')) issues.unshift({ code: 'teaching-review', severity: 'error', message: '课堂内容尚未通过复核,请继续完善草稿。' })
   if (!active()) return
   if (
     replacementSnapshot !== editorSnapshot() &&
@@ -620,7 +622,8 @@ async function enterEditor(result: AiCoursewareGenerateResult, active: () => boo
     remember()
     emit("generated", issues, clone({ ...result.outline, templateId: templateId.value }), clone(preferences))
     emit("update:visible", false)
-    ElMessage.success("课件已生成,可直接编辑,完成后点击“保存课件”")
+    if (draft) ElMessage.warning("草稿已打开,内容仍需完善;可查看问题并点击“完善整课”。")
+    else ElMessage.success("课件已生成,可直接编辑,完成后点击“保存课件”")
   } catch (error) {
     if (changed) {
       slidesStore.setTitle(previous.title)

+ 6 - 2
src/views/Editor/EditorHeader/index.vue

@@ -55,7 +55,7 @@
     </div>
 
     <div class="right">
-      <span v-if="aiEditing" class="ai-editing-status" role="status">{{ usePptWorkHook.isSave ? "✓ 课件已保存" : "课件已生成 · 可继续编辑" }}</span>
+      <span v-if="aiEditing" class="ai-editing-status" role="status">{{ generationIssues.some(issue => issue.severity === 'error') ? (usePptWorkHook.isSave ? "草稿已保存 · 内容待完善" : "草稿待完善") : usePptWorkHook.isSave ? "✓ 课件已保存" : "课件已生成 · 可继续编辑" }}</span>
       <Popover
         v-if="generationIssues.length"
         trigger="click"
@@ -67,13 +67,17 @@
             <strong>上课前请核对以下内容</strong>
             <p>可在编辑器中补充或调整,完成后保存课件。</p>
             <p>重新生成仅替换对应页面的文字、图片和排版,可撤销恢复。</p>
+            <button v-if="generationIssues.some(issue => issue.severity === 'error')" :disabled="repairingPage !== undefined" @click="repairPage(0)">
+              {{ repairingPage === 0 ? '正在完善整课…' : '完善整课' }}
+            </button>
+            <p v-if="generationIssues.some(issue => issue.severity === 'error')">整课完善会重新复核内容与排版;已有手动编辑时请使用单页生成。</p>
             <p v-if="repairError" role="alert">{{ repairError }}</p>
             <ul>
               <li v-for="(issue, index) in generationIssues" :key="index">
                 <button v-if="issue.pageNo" @click="locatePage(issue.pageNo)">
                   第 {{ issue.pageNo }} 页</button
                 >{{ issue.message }}
-                <button v-if="issue.pageNo && ['missing_required_visual', 'image_review', 'resource_unavailable', 'incomplete_question'].includes(issue.code)" :disabled="!!repairingPage" @click="repairPage(issue.pageNo)">
+                <button v-if="issue.pageNo && (issue.severity === 'error' || ['missing_required_visual', 'image_review', 'resource_unavailable'].includes(issue.code))" :disabled="repairingPage !== undefined" @click="repairPage(issue.pageNo)">
                   {{ repairingPage === issue.pageNo ? '正在生成…' : '重新生成本页' }}
                 </button>
               </li>

Некоторые файлы не были показаны из-за большого количества измененных файлов