yonge 1 hónapja
szülő
commit
ec1fa98734

+ 8 - 0
AGENTS.md

@@ -0,0 +1,8 @@
+# 前端项目工作约定
+
+- 修改前端源码、样式、静态资源或影响构建的配置后,交付前必须在项目根目录执行 `npm run build:stag`。
+- 构建必须更新项目根目录下的 `dist/`,确保 `dist/index.html` 和 `dist/assets/` 对应最新代码。仅完成源码修改、组件编译检查或临时目录构建,不算完成交付。
+- 本项目部署包统一使用 `build:stag`,不要使用 `build:devProd` 替代。
+- 构建后检查退出状态和关键改动是否进入产物;构建失败时先修复并重新打包,无法完成时明确说明原因,不得声称部署包已更新。
+- 交付时简要说明打包结果。打包不代表已经部署到服务器,未获部署授权时不自动发布。
+- 仅修改工作约定等不影响前端产物的文档时,无需重复打包。

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

+ 1 - 1
dist/assets/index-Je6L8d-i.js → dist/assets/index-B1xcfQSh.js

@@ -1 +1 @@
-import{u as s,ag as r}from"./index-BPhLW7Hd.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-BqjEJARK.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-B-AvEHX6.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-DcnERrGr.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-Do9vLfXz.js → dist/assets/index-BIbB21s2.js

@@ -1 +1 @@
-import{u as c,ag as l}from"./index-BPhLW7Hd.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-BqjEJARK.js";import"./shapes-CE5C_iws.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-B-AvEHX6.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-DcnERrGr.js";import"./shapes-By78GjSV.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};

A különbségek nem kerülnek megjelenítésre, a fájl túl nagy
+ 0 - 0
dist/assets/index-BN9pmSfS.js


+ 1 - 1
dist/assets/index-bXhLkURh.js → dist/assets/index-BbNNmW9j.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-BqjEJARK.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,bO as _,bP as d,ak as s,bH as p}from"./index-DcnERrGr.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};

A különbségek nem kerülnek megjelenítésre, a fájl túl nagy
+ 0 - 0
dist/assets/index-BjFMDnd7.js


A különbségek nem kerülnek megjelenítésre, a fájl túl nagy
+ 0 - 0
dist/assets/index-CnHD_Ctr.css


A különbségek nem kerülnek megjelenítésre, a fájl túl nagy
+ 0 - 0
dist/assets/index-DCBZ3r8b.js


A különbségek nem kerülnek megjelenítésre, a fájl túl nagy
+ 1 - 1
dist/assets/index-DcnERrGr.js


+ 1 - 1
dist/assets/index-CxmKAXDt.js → dist/assets/index-DvImII9Q.js

@@ -1 +1 @@
-import{_ as t}from"./404-R0y5dkvZ.js";import{E as c}from"./index-D774Oe8_.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-BqjEJARK.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-BN9pmSfS.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-DcnERrGr.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/shapes-CE5C_iws.js → dist/assets/shapes-By78GjSV.js

@@ -1,4 +1,4 @@
-import{cG as k,bB as $1}from"./index-BqjEJARK.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-BPhLW7Hd.js";var z1={exports:{}};/*!
+import{cG as k,bB as $1}from"./index-DcnERrGr.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-B-AvEHX6.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-BqjEJARK.js"></script>
+    <script type="module" crossorigin src="./assets/index-DcnERrGr.js"></script>
     <link rel="stylesheet" crossorigin href="./assets/index-AavUEaYy.css">
   </head>
   <body>

+ 32 - 1
scripts/aiPptDialogTest.html

@@ -13,6 +13,8 @@ import usePptWork from "/src/store/pptWork.ts"
 import { useSnapshotStore } from "/src/store/index.ts"
 import { inspectAiPptResources } from "/src/utils/aiPptResources.ts"
 import { draftKey, loadAiDraft, saveAiDraft } from "/src/utils/aiCoursewareDraft.ts"
+import useAiCoursewarePageRepair from "/src/hooks/useAiCoursewarePageRepair.ts"
+import { aiCoursewareToSlides } from "/src/utils/aiCoursewareToSlides.ts"
 import "/src/assets/styles/global.scss"
 import "/src/assets/styles/elementTheme.scss"
 import "/src/assets/styles/prosemirror.scss"
@@ -55,7 +57,7 @@ httpAxios.axioseRquest = async config => {
     generated.value++
     submitted.value = JSON.parse(JSON.stringify(config.data))
     pollCount = 0
-    if (config.data.repairOnly) { repairs.value++; repaired=true }
+    if (config.data.repairOnly || config.data.regeneratePageNo) { repairs.value++; repaired=true }
     data={taskId:`local-task-${generated.value}`,status:"PENDING",progress:0}
   }
   else if (config.url.endsWith("/result")) {
@@ -71,12 +73,19 @@ httpAxios.axioseRquest = async config => {
       data.outline.pages.forEach(page => page.objectiveIds=[])
       data.reviewMessage="声音强弱的练习与评价尚未完整关联"
     }
+    if (scenario === 'page-repair') {
+      const page = data.outline.pages[submitted.value.regeneratePageNo - 1]
+      page.studentContent = { points: ['重新生成后的课堂任务', '观察图片,说说声音的变化'] }
+      page.bullets = [...page.studentContent.points]
+      page.image = { url: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', width: 1, height: 1 }
+    }
   }
   else if (config.url.endsWith("/task/historical-failed-task")) {
     historicalPolls.value++
     data={taskId:"historical-failed-task",status:"FAILED",progress:50,message:"教学目标1缺少对应实践或评价任务"}
   }
   else if (config.url.includes("/task/")) {
+    if (scenario === 'page-repair') await new Promise(resolve => setTimeout(resolve, 1500))
     pollCount++
     if (scenario.includes("network") && pollCount === 1) throw new Error("测试网络中断")
     data={taskId:`local-task-${generated.value}`,status:scenario.includes("failure") && generated.value === 1 ? "FAILED" : scenario.includes("slow") && pollCount < 6 ? "RUNNING" : scenario.includes("draft") ? "DRAFT" : "DONE",progress:pollCount < 6 && scenario.includes("slow") ? 50 : 100,message:scenario.includes("failure") ? "测试:生成服务暂不可用" : "正在完善课堂任务"}
@@ -91,6 +100,28 @@ const app=createApp({setup(){
   const visible=ref(false)
   const slidesStore=useSlidesStore()
   const pptWork=usePptWork()
+  const repair = useAiCoursewarePageRepair()
+  if (scenario === 'page-repair') {
+    const fixture = structuredClone(outline)
+    fixture.templateId = 'blank'
+    fixture.pages[1].requiredVisual = true
+    fixture.pages[1].visualPlan.imagePurpose = 'scene'
+    slidesStore.setSlides(aiCoursewareToSlides(fixture).slides)
+    const beforeOthers = JSON.stringify(slidesStore.slides.filter((_, index) => index !== 1))
+    repair.generated([{ code: 'missing_required_visual', severity: 'error', pageNo: 2, message: '缺少配图' }], fixture, { lessonMinutes:40,minPages:9,maxPages:9,assetStyle:'illustration',sourceImagePolicy:'balanced',enableAnimations:false })
+    const pageText = () => slidesStore.slides[1].elements.filter(e => e.type === 'text').map(e => e.content.replace(/<[^>]+>/g, '')).join(';')
+    return () => h('main', [
+      h('h1', '单页生成回归(本地模拟接口)'),
+      h('button', { disabled: !!repair.repairingPage.value, onClick: () => repair.repair(2) }, '重新生成第2页'),
+      h('button', { onClick: () => { slidesStore.slides[1].elements.find(e => e.type === 'text').content = '<p>生成期间手动修改</p>' } }, '修改第2页'),
+      h('p', { id: 'repair-state' }, `状态:${repair.repairingPage.value ? '生成中' : '空闲'};重试次数:${repairs.value}`),
+      h('p', { id: 'repair-error' }, repair.error.value),
+      h('p', { id: 'repair-content' }, pageText()),
+      h('p', { id: 'repair-image' }, `本页图片:${slidesStore.slides[1].elements.filter(e => e.type === 'image').length}`),
+      h('p', { id: 'other-pages' }, `其他页面${JSON.stringify(slidesStore.slides.filter((_, index) => index !== 1)) === beforeOthers ? '保持不变' : '被修改'}`),
+      h('button', { onClick: () => useSnapshotStore().unDo() }, '测试撤销')
+    ])
+  }
   // 仅隔离存储边界,按钮、编辑器和课件转换均使用正式组件。
   pptWork.updatePPT=async()=>{savedText.value=slidesStore.slides.flatMap(slide=>slide.elements.filter(el=>el.type==="text").map(el=>el.content.replace(/<[^>]+>/g,""))).join("、");savedCalls.value++;pptWork.isSave=true}
   if (!scenario.startsWith("editor")) nextTick(()=>{visible.value=true})

+ 32 - 1
scripts/aiPptSelfTest.mjs

@@ -2,6 +2,7 @@ import { createServer } from "vite"
 import { existsSync } from "node:fs"
 import { resolve } from "node:path"
 import assert from "node:assert/strict"
+import { classroomFixture } from "./fixtures/aiPptClassroom.mjs"
 
 const vite = await createServer({ server: { middlewareMode: true }, appType: "custom", logLevel: "error" })
 
@@ -10,6 +11,7 @@ try {
   const { inspectAiPptQuality } = await vite.ssrLoadModule("/src/utils/aiPptQuality.ts")
   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")
 
   const page = (pageNo, type, title, layout, points, extra = {}) => ({
     pageNo,
@@ -144,7 +146,7 @@ try {
         .filter(item => item.startsWith("/"))
         .filter(item => !existsSync(resolve("public", item.replace(/^\//, ""))))
       const layoutMismatchCount = outline.pages.filter((page, index) => {
-        const layout = resolveTemplatePageLayout(page.type === "melody_score" ? "diagram_focus" : page.interaction?.question ? "text_focus" : page.visualPlan?.layout)
+        const layout = resolveTemplatePageLayout(resolveAiPptLayout(page, index))
         const expectedPage = master.pages.find(item => item.layout === layout)
         const expectedBackgroundsForPage = expectedPage?.backgroundImages?.length
           ? expectedPage.backgroundImages
@@ -280,6 +282,35 @@ try {
   const unreviewed = structuredClone(valid)
   unreviewed.pages[1].image = { url: "https://example.com/scene.png", provider: "TEST", reviewStatus: "unverified" }
   assert(inspectAiPptQuality(unreviewed, render(unreviewed), preferences).warnings.some(issue => issue.code === "image_review"))
+  const classroom = classroomFixture(scoreImage)
+  const classroomSlides = render(classroom)
+  assert.equal(resolveAiPptLayout(classroom.pages[0], 0), "cover_scene", "requiredVisual 不能把封面改成分栏")
+  assert(new Set(classroom.pages.map(resolveAiPptLayout)).size >= 6, "统一 split 的历史大纲应恢复不同教学版式")
+  for (const index of [0, 10, 11]) {
+    assert(!classroomSlides[index].elements.map(elementText).join(" ").includes(classroom.pages[index].classroomAction.studentTask), "教案表述不得进入学生画面")
+    assert(classroomSlides[index].remark.includes(classroom.pages[index].classroomAction.studentTask), "移出的教师说明必须保留在备注")
+  }
+  assert(classroomSlides[6].elements.some(element => element.type === "text" && element.width > 1200), "无图听唱页应使用宽版正文")
+  assert(classroomSlides[9].elements.some(element => element.type === "text" && element.width > 1200), "无图检测页不得保留半页空白")
+  const classroomReport = inspectAiPptQuality(classroom, classroomSlides)
+  assert(!classroomReport.errors.some(issue => ["out_of_bounds", "text_overlap", "text_overflow", "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("未返回图片")))
+  // 配图不能迫使对比、活动、总结、目标页统一分栏,也不能在保留版式后丢图。
+  for (const layout of ["compare", "activity", "summary", "objectives"]) {
+    for (const template of AI_COURSEWARE_TEMPLATES) {
+      const sample = structuredClone(valid)
+      sample.templateId = template.id
+      sample.pages[1] = page(2, "content", "观察并完成任务", layout, ["找一找主体", "说说变化"], {
+        image: { url: scoreImage, width: 960, height: 720 }, requiredVisual: true,
+        visualPlan: { layout, imagePurpose: "observation" }
+      })
+      const sampleSlides = render(sample)
+      assert.equal(resolveAiPptLayout(sample.pages[1], 1), layout)
+      assert(sampleSlides[1].elements.some(element => element.type === "image"), `${template.id}/${layout} 丢失配图`)
+      assert(!inspectAiPptQuality(sample, sampleSlides).errors.some(issue => issue.pageNo === 2 && ["out_of_bounds", "text_overlap", "text_overflow", "missing_content"].includes(issue.code)))
+    }
+  }
   console.log(JSON.stringify({ passed: failures.length === 0, scenarioCount: results.length, contractChecks: 13, imageTemplateChecks: AI_COURSEWARE_TEMPLATES.length,
     scenarios: courseVariants.map(item => item.id), ...(process.argv.includes("--verbose") ? { results } : {}), failures }, null, 2))
   if (failures.length) process.exitCode = 1

+ 8 - 3
scripts/aiPptVisualTest.html

@@ -14,7 +14,7 @@
   <body>
     <div id="app"></div>
     <script type="module">
-      import { createApp, h } from "vue"
+      import { createApp, h, ref } from "vue"
       import { createPinia, setActivePinia } from "pinia"
       import ThumbnailSlide from "/src/views/components/ThumbnailSlide/index.vue"
       import { aiCoursewareToSlides } from "/src/utils/aiCoursewareToSlides.ts"
@@ -22,6 +22,7 @@
       import "/src/assets/styles/global.scss"
       import "/src/assets/styles/prosemirror.scss"
       import { hydrateSlideBackgroundImages } from "/src/libs/jsonTool.ts"
+      import { classroomFixture } from "./fixtures/aiPptClassroom.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 })
@@ -40,11 +41,15 @@
           page(10, "summary", "我的音乐收获", "summary_checklist", ["我能分辨声音的高低", "我能表现声音的强弱", "我能与同伴合作表演"], { learningEvidence: "用一种声音表现你心中的凤鸣山谷" })
         ]
       }
-      const result = aiCoursewareToSlides(outline, { templateId: id })
+      const fixture = new URLSearchParams(location.search).get("scenario") === "classroom"
+        ? classroomFixture('/ai-courseware-templates/extracted/bird-garden/background-01.jpg') : outline
+      const result = aiCoursewareToSlides(fixture, { templateId: id })
+      const group = ref(0)
       const app = createApp({
         render: () => h("main", [
           h("h1", `${getAiCoursewareTemplate(id).name} · 通用版式视觉回归`),
-          h("section", { class: "grid" }, result.slides.map(slide => h("div", { class: "slide" }, [h(ThumbnailSlide, { slide, size: 720 })])))
+          ...[0, 1, 2].map(index => h('button', { onClick: () => { group.value = index } }, `查看第${index * 4 + 1}—${Math.min(index * 4 + 4, result.slides.length)}页`)),
+          h("section", { class: "grid" }, result.slides.slice(group.value * 4, group.value * 4 + 4).map(slide => h("div", { class: "slide" }, [h(ThumbnailSlide, { slide, size: 720 })])))
         ])
       })
       const pinia = createPinia()

+ 27 - 0
scripts/fixtures/aiPptClassroom.mjs

@@ -0,0 +1,27 @@
+// 根据现场问题构造的渲染回归夹具,不作为教材内容认证。
+export function classroomFixture(imageUrl) {
+  const rows = [
+    ["cover", "凤鸣山谷的故事", "观看封面,产生期待", ["聆听奇妙的声音", "认识小动物", "一起做音乐游戏"]],
+    ["scene_intro", "走进凤鸣山谷", "猜一猜有哪些动物", ["凤凰住在这里", "住着很多小动物"]],
+    ["first_listen", "听听山谷里的声音", "说一说你听到了什么", ["闭上眼睛", "安静听"]],
+    ["interaction", "声音小侦探", "说说声音有什么不同", ["认识动物图片", "模仿它的叫声"]],
+    ["compare_listen", "声音的强弱", "用动作表示", ["老虎吼叫(强)", "小猫喵喵(弱)"]],
+    ["rhythm_practice", "声音的长短和节奏", "拍一拍:短-短-长", ["蛐蛐叫(短)", "老鹰叫(长)"]],
+    ["phrase_sing", "do re mi 之歌", "跟老师唱", ["低音在腰", "中音在胸", "高音在头"]],
+    ["performance_activity", "声音模仿秀", "选择小动物角色", ["用声音和动作模仿", "注意强弱长短变化"]],
+    ["performance_activity", "山谷交响乐", "全班一起演奏", ["用拍手、跺脚、捻指", "合在一起真神奇"]],
+    ["practice_check", "小小音乐家", "听辨并用动作回答", ["听一听", "用动作回答", "比一比谁最棒"]],
+    ["summary", "今天你学到了什么?", "回顾并口头总结本课所学", ["认识了山谷里的小动物", "声音有强弱、长短、高低", "学会了do re mi"]],
+    ["homework", "寻找生活中的声音", "复述课后任务要求", ["回家找三种声音", "可以用动作或录音", "下节课分享"]]
+  ]
+  return {
+    title: "凤鸣山谷的故事", templateId: "blank", gradeName: "一年级", textbookName: "本地渲染回归夹具", unitName: "奇妙的声音世界", chapterName: "凤鸣山谷的故事", courseType: "music", style: "cartoon",
+    pages: rows.map(([type, title, task, points], index) => ({
+      pageNo: index + 1, type, title, bullets: points, studentContent: { points }, imageKeywords: [],
+      classroomAction: { studentTask: task, teacherInstruction: "根据学生表现调整活动难度" },
+      requiredVisual: true, visualPlan: { layout: "split_content", imagePurpose: "scene", mainVisual: title },
+      image: [0, 1, 2, 3, 4, 7].includes(index) ? { url: imageUrl, width: 960, height: 720 } : undefined,
+      imageReview: [5, 6, 8, 9, 10, 11].includes(index) ? "测试夹具:本页未返回图片" : undefined
+    }))
+  }
+}

+ 10 - 0
src/config/aiCoursewareTemplateMasters.ts

@@ -454,6 +454,16 @@ export function getTemplateMasterPage(master: TemplateMaster, role: TemplatePage
 
 export function selectTemplateMasterPageVariant(page: TemplateMasterPage, occurrence: number): TemplateMasterPage {
   const candidates = page.backgroundImages?.length ? page.backgroundImages : page.backgroundImage ? [page.backgroundImage] : []
+  if (!candidates.length && occurrence % 2 && ["hero", "split", "media"].includes(page.layout)) {
+    const image = page.slots.find(slot => slot.kind === "image")
+    const body = page.slots.find(slot => slot.kind === "body")
+    if (image && body && Math.abs(image.top - body.top) < 40) {
+      const left = Math.min(image.left, body.left)
+      const right = Math.max(image.left + image.width, body.left + body.width)
+      return { ...page, slots: page.slots.map(slot => ["image", "body"].includes(slot.kind)
+        ? { ...slot, left: left + right - slot.left - slot.width } : slot) }
+    }
+  }
   if (candidates.length < 2) return page
   return { ...page, backgroundImage: candidates[Math.abs(occurrence) % candidates.length] }
 }

+ 105 - 0
src/hooks/useAiCoursewarePageRepair.ts

@@ -0,0 +1,105 @@
+import { onBeforeUnmount, ref, watch } from "vue"
+import { ElMessage } from "element-plus"
+import { useMainStore, useSlidesStore, useSnapshotStore } from "@/store"
+import usePptWork from "@/store/pptWork"
+import queryParams from "@/queryParams"
+import { httpAjax } from "@/plugins/httpAjax"
+import { aiCoursewareGenerateApi, aiCoursewareTaskApi, aiCoursewareResultApi, type AiCoursewareOutline, type AiCoursewarePreferences } from "@/api/aiCourseware"
+import { aiCoursewareToSlides } from "@/utils/aiCoursewareToSlides"
+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"
+
+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 = "" }
+  watch(() => work.id, reset)
+  onBeforeUnmount(reset)
+
+  function generated(nextIssues: AiPptQualityIssue[], nextOutline: AiCoursewareOutline, nextPreferences: AiCoursewarePreferences) {
+    reset()
+    issues.value = nextIssues
+    outline = clone(nextOutline)
+    preferences = clone(nextPreferences)
+    slideIds = slides.slides.map(slide => slide.id)
+  }
+  function locate(pageNo: number) {
+    const index = slides.slides.findIndex(slide => slide.id === slideIds[pageNo - 1])
+    if (index >= 0) slides.updateSlideIndex(index)
+    return index
+  }
+  async function repair(pageNo: number) {
+    if (!outline || !preferences || repairingPage.value) return
+    const index = locate(pageNo)
+    if (index < 0) { error.value = "原页面已删除,请从当前课件重新生成。"; return }
+    const slideId = slideIds[pageNo - 1], before = JSON.stringify(slides.slides[index])
+    const requestVersion = version, materialId = work.id
+    const active = () => requestVersion === version && work.id === materialId
+    const sourceOutline = clone(outline), prefs = clone(preferences)
+    const templateId = sourceOutline.templateId || DEFAULT_AI_COURSEWARE_TEMPLATE_ID
+    repairingPage.value = pageNo
+    error.value = ""
+    try {
+      const response = await httpAjax(aiCoursewareGenerateApi, {
+        id: materialId, fromType: queryParams.pptResourcesType || queryParams.fromType || "TEACHER",
+        outline: sourceOutline, templateId, preferences: prefs, regeneratePageNo: pageNo,
+        imageAspectRatios: getAiPptImageAspectRatios(templateId),
+        visualPrompt: "仅完善本页,保持教学目标。补齐必要配图、具体题干及教师参考答案,采用符合本页教学任务的版式。"
+      })
+      if (!active()) return
+      if (response.code !== 200 || !response.data?.taskId) throw new Error(response.message || "未能开始单页生成")
+      const started = Date.now()
+      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 (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 || "本页结果不完整")
+      // 忽略后端返回的其他页面,保留本地原始教学安排。
+      const nextOutline = clone(sourceOutline)
+      nextOutline.pages[pageNo - 1] = result.data.outline.pages[pageNo - 1]
+      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])
+      if (!active()) return
+      const current = slides.slides.findIndex(slide => slide.id === slideId)
+      if (current < 0 || JSON.stringify(slides.slides[current]) !== before) 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 }
+      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 }))]
+      await snapshots.addSnapshot()
+      if (active()) ElMessage.success(`第 ${pageNo} 页已重新生成,可撤销恢复;请核对后保存。`)
+    } catch (reason) {
+      if (active()) error.value = reason instanceof Error ? reason.message : "本页生成失败,请重试"
+    } finally {
+      if (active()) repairingPage.value = undefined
+    }
+  }
+  return { issues, repairingPage, error, generated, locate, repair }
+}

+ 10 - 1
src/utils/aiPptContent.ts

@@ -10,9 +10,18 @@ export const isScorePage = (page: AiCoursewarePage) => /melody|score/.test(page.
 export const needsAudio = (page: AiCoursewarePage) => /listen|phrase_sing|expressive_sing/.test(page.type || "") ||
   (page.resourceBindings || []).some(item => item.required && item.resourceType === "SONG")
 
+// 只识别明确的教案表述,保留“观察图片”“跟老师唱”等有效学生指令。
+export const isTeacherFacingText = (text?: string) => /(?:教师|老师)(?:讲述|提问|引导|指导|组织|巡视|示范)|(?:引导|指导|组织)学生|(?:激发|产生|建立)(?:学生)?(?:兴趣|期待|好奇)|学习表现[::]|复述课后任务要求|回顾并口头总结本课所学/.test(text || "")
+
+export function teacherFacingTexts(page: AiCoursewarePage): string[] {
+  return [page.studentContent?.headline, page.classroomAction?.studentTask, page.subtitle,
+    ...(page.studentContent?.points || []), ...(page.bullets || [])]
+    .filter((text): text is string => Boolean(text) && isTeacherFacingText(text))
+}
+
 export function studentTexts(page: AiCoursewarePage): string[] {
   return [page.title, page.studentContent?.headline, page.classroomAction?.studentTask,
     ...(page.studentContent?.points?.length ? page.studentContent.points : page.bullets || []),
     page.interaction?.question, ...(page.interaction?.options || [])]
-    .map(text => String(text || "").trim()).filter(Boolean)
+    .map(text => String(text || "").trim()).filter(text => Boolean(text) && !isTeacherFacingText(text))
 }

+ 11 - 14
src/utils/aiPptQuality.ts

@@ -2,6 +2,8 @@ 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 { resolveTemplatePageLayout } from "@/config/aiCoursewareTemplateMasters"
 
 type BoundedPPTElement = Exclude<PPTElement, { type: "line" }>
 
@@ -100,8 +102,9 @@ function inspectOutline(outline: AiCoursewareOutline, issues: AiPptQualityIssue[
 
   pages.forEach((page, index) => {
     const pageNo = page.pageNo || index + 1
-    const layout = clean(page.visualPlan?.layout || page.visual?.layout || page.type || "content").toLowerCase()
-    if (!ALLOWED_LAYOUTS.has(layout)) push(issues, "invalid_layout", "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, "本页排版需要检查,请确认文字、图片和活动内容显示完整")
     if (index === 0 && layout !== "cover_scene") push(issues, "invalid_cover_layout", "error", pageNo, "第1页必须使用封面场景布局")
     layoutCounts.set(layout, (layoutCounts.get(layout) || 0) + 1)
     cardPages += CARD_LAYOUTS.has(layout) ? 1 : 0
@@ -113,16 +116,7 @@ function inspectOutline(outline: AiCoursewareOutline, issues: AiPptQualityIssue[
 
     // 只检查真正会进入学生投屏画面的字段。activity 属于教师组织信息,
     // 最终只会写入 PPT 备注,不能把它当成学生可见内容制造误报。
-    const visibleTexts = [
-      page.title,
-      page.studentContent?.headline,
-      page.classroomAction?.studentTask,
-      page.interaction?.question,
-      ...(page.studentContent?.points || []),
-      ...((page.interaction?.options || []).map(clean).filter(Boolean))
-    ]
-      .map(clean)
-      .filter(Boolean)
+    const visibleTexts = studentTexts(page)
 
     visibleTexts.forEach(text => {
       if (PLACEHOLDER_PATTERN.test(text)) push(issues, "placeholder", "error", pageNo, `存在占位文本:“${short(text)}”`)
@@ -273,8 +267,11 @@ function inspectTeachingContract(outline: AiCoursewareOutline, slides: Slide[],
         push(issues, "missing_audio_render", "error", no, "已绑定的音频没有进入页面")
       }
     }
-    if (page.requiredVisual && !usableResourceUrl(page.image?.url) && !scoreBinding(page)) push(issues, "missing_required_visual", "error", no, "本页观察任务缺少必要图片")
-    if (page.imageReview) push(issues, "image_review", "warning", no, page.imageReview)
+    const plannedImage = ["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)
+    if (missingImage) push(issues, "missing_required_visual", page.requiredVisual ? "error" : "warning", no,
+      `本页计划配图尚未取得:${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 || "配图未完成教学适用性审核,请核对主体、细节与学生年龄是否合适")
     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")) {

+ 87 - 43
src/utils/aiPptTemplateEngine.ts

@@ -16,7 +16,7 @@ import type {
   SlideTheme
 } from "@/types/slides"
 import type { AiCoursewareOutline, AiCoursewarePage } from "@/api/aiCourseware"
-import { audioBindings, scoreBinding, isScorePage, usableResourceUrl } from "@/utils/aiPptContent"
+import { audioBindings, scoreBinding, isScorePage, usableResourceUrl, isTeacherFacingText, teacherFacingTexts } from "@/utils/aiPptContent"
 import {
   AI_COURSEWARE_TEMPLATES,
   getAiCoursewarePalette,
@@ -29,6 +29,7 @@ import {
   getAiCoursewareTemplateMaster,
   getTemplateMasterPage,
   selectTemplateMasterPageVariant,
+  resolveTemplatePageLayout,
   validateTemplateMasterRegistry,
   type TemplateMaster,
   type TemplateMasterElement,
@@ -103,13 +104,12 @@ function buildSlide(page: AiCoursewarePage, outline: AiCoursewareOutline, palett
   const role = pageRole(page, index)
   const intent = pageRenderIntent(page, role)
   const master = getAiCoursewareTemplateMaster(options.templateId!)
-  const requested = preferredLayout(page, intent)
-  const teachingImage = hasImage(page) && page.visualPlan?.imagePurpose !== "decoration" && page.visualPlan?.imagePurpose !== "none"
-  const needsImageSlot = role !== "cover" && teachingImage && (page.interaction?.question || ["text_focus", "compare", "activity", "summary", "objectives"].includes(requested || ""))
-  const baseMasterPage = getTemplateMasterPage(master, role, isScorePage(page) ? "diagram_focus" : videoBinding(page) || page.requiredVisual || needsImageSlot ? "split_content" : page.interaction?.question ? "text_focus" : requested)
+  const baseMasterPage = getTemplateMasterPage(master, role, resolveAiPptLayout(page, index))
   const occurrence = layoutOccurrences.get(baseMasterPage.id) || 0
   layoutOccurrences.set(baseMasterPage.id, occurrence + 1)
-  const masterPage = adaptMasterPage(selectTemplateMasterPageVariant(baseMasterPage, occurrence), intent)
+  const adapted = adaptMasterPage(selectTemplateMasterPageVariant(baseMasterPage, occurrence), intent)
+  const nativeVisual = isScorePage(page) || (role === "rhythm" && adapted.layout === "diagram")
+  const masterPage = fitVisualSlots(adapted, hasImage(page) || Boolean(videoBinding(page)), nativeVisual)
   const contentElements = fillTemplatePage(master, masterPage, page, outline, palette, role, intent)
   assertContentContract(master, masterPage, contentElements)
   const elements = [
@@ -134,7 +134,7 @@ export function getAiPptImageAspectRatios(templateId: AiCoursewareTemplateId): R
   const ratios: Record<string, number> = {}
   for (const page of master.pages) {
     for (const showMedia of [true, false]) {
-      const adapted = adaptMasterPage(page, { role: page.roles[0], density: "medium", visualKind: "image", showMedia })
+      const adapted = fitVisualSlots(adaptMasterPage(page, { role: page.roles[0], density: "medium", visualKind: "image", showMedia }), true, false)
       const slot = adapted.slots.find(slot => slot.kind === "image" && slot.enabled !== false)
       if (slot) ratios[layouts[page.layout] + (showMedia ? ":media" : "")] = slot.width / slot.height
     }
@@ -153,8 +153,9 @@ function fillTemplatePage(master: TemplateMaster, masterPage: TemplateMasterPage
   const elements: PPTElement[] = []
   const title = masterPage.layout === "cover" ? chapterTitle(outline, page) : displayPageTitle(page, outline)
   // 学生可见内容必须与编辑区保持一致,不能因模板容量静默丢弃条目。
-  const headline = activityTask(page) || studentHeadline(page)
-  const points = studentPoints(page).filter(point => !sameVisibleText(point, headline))
+  const headline = cleanText(page.interaction?.question) || activityTask(page) || studentHeadline(page)
+  const points = uniqueVisibleTexts([activityTask(page), ...studentPoints(page), ...(page.interaction?.options || [])])
+    .filter(point => !sameVisibleText(point, headline))
 
   addSlotText(elements, slotOf(masterPage, "title"), title, masterPage.layout === "cover" ? master.coverTitleColor : master.titleColor, true)
 
@@ -164,12 +165,6 @@ function fillTemplatePage(master: TemplateMaster, masterPage: TemplateMasterPage
     addInlineMedia(elements, slotOf(masterPage, "media"), page, master, palette, intent.showMedia)
     return elements
   }
-  if (role !== "cover" && page.interaction?.question) {
-    addVisualOrResource(elements, slotOf(masterPage, "image"), page, master, palette, false)
-    addSlotList(elements, slotOf(masterPage, "body"), uniqueVisibleTexts([page.interaction.question, headline, ...points, ...(page.interaction.options || [])]), master, palette, false)
-    addInlineMedia(elements, slotOf(masterPage, "media"), page, master, palette, intent.showMedia)
-    return elements
-  }
 
   if (masterPage.layout === "cover") {
     const meta = [outline.gradeName, conciseTextbook(outline.textbookName), outline.unitName].filter(Boolean).join(" · ")
@@ -189,6 +184,7 @@ function fillTemplatePage(master: TemplateMaster, masterPage: TemplateMasterPage
   }
 
   if (masterPage.layout === "objectives") {
+    addVisualOrResource(elements, slotOf(masterPage, "image"), page, master, palette)
     addCardGrid(elements, slotOf(masterPage, "body"), uniqueVisibleTexts([headline, ...points]), master, palette)
     return elements
   }
@@ -209,7 +205,8 @@ function fillTemplatePage(master: TemplateMaster, masterPage: TemplateMasterPage
   }
 
   if (masterPage.layout === "text") {
-    addSlotText(elements, slotOf(masterPage, "headline"), headline || (role === "lyric" ? "读一读,再唱一唱" : "抓住这一页的关键信息"), palette.primary, true)
+    addVisualOrResource(elements, slotOf(masterPage, "image"), page, master, palette)
+    addSlotText(elements, slotOf(masterPage, "headline"), headline || (role === "lyric" ? "读一读,再唱一唱" : "抓住这一页的关键信息"), page.interaction?.question ? master.textColor : palette.primary, true)
     if (role === "lyric") addLyricLines(elements, slotOf(masterPage, "body"), points, master, palette)
     else addSlotList(elements, slotOf(masterPage, "body"), points, master, palette, false)
     addInlineMedia(elements, slotOf(masterPage, "media"), page, master, palette, intent.showMedia)
@@ -217,16 +214,19 @@ function fillTemplatePage(master: TemplateMaster, masterPage: TemplateMasterPage
   }
 
   if (masterPage.layout === "diagram") {
+    const rhythmPattern = role === "rhythm" ? [headline, ...points].join(" ").match(/[短长](?:\s*[-—·、]\s*[短长]){1,7}/)?.[0].match(/[短长]/g) : undefined
     if (role === "notation") addNotationVisual(elements, slotOf(masterPage, "image"), page, master, palette, false)
-    else if (role === "rhythm") addStepGrid(elements, slotOf(masterPage, "image"), activitySteps(page), master, palette)
+    else if (rhythmPattern) addDurationDiagram(elements, slotOf(masterPage, "image"), rhythmPattern, master, palette)
+    else if (role === "rhythm") addStepGrid(elements, slotOf(masterPage, "image"), points, master, palette)
     else addVisualOrResource(elements, slotOf(masterPage, "image"), page, master, palette, true)
-    if (role !== "rhythm") addSlotList(elements, slotOf(masterPage, "body"), uniqueVisibleTexts([headline, ...points]), master, palette)
+    if (role !== "rhythm" || rhythmPattern) addSlotList(elements, slotOf(masterPage, "body"), uniqueVisibleTexts([headline, ...points]), master, palette)
     else addSlotText(elements, slotOf(masterPage, "body"), headline, master.textColor)
     addInlineMedia(elements, slotOf(masterPage, "media"), page, master, palette, intent.showMedia)
     return elements
   }
 
   if (masterPage.layout === "compare") {
+    addVisualOrResource(elements, slotOf(masterPage, "image"), page, master, palette)
     const values = uniqueVisibleTexts([...(page.interaction?.options || []), ...points])
     const midpoint = Math.max(1, Math.ceil(values.length / 2))
     addSlotText(elements, slotOf(masterPage, "headline"), headline || page.interaction?.question || "听一听,它们有什么不同?", palette.primary, true)
@@ -237,14 +237,16 @@ function fillTemplatePage(master: TemplateMaster, masterPage: TemplateMasterPage
   }
 
   if (masterPage.layout === "activity") {
+    addVisualOrResource(elements, slotOf(masterPage, "image"), page, master, palette)
     addSlotText(elements, slotOf(masterPage, "headline"), headline || "一起完成音乐任务", palette.primary, true)
-    addStepGrid(elements, slotOf(masterPage, "steps"), activitySteps(page), master, palette)
+    addStepGrid(elements, slotOf(masterPage, "steps"), points, master, palette)
     addInlineMedia(elements, slotOf(masterPage, "media"), page, master, palette, intent.showMedia)
     return elements
   }
 
-  addChecklist(elements, slotOf(masterPage, "body"), uniqueVisibleTexts([headline, ...points]), master, palette)
-  addSlotText(elements, slotOf(masterPage, "headline"), page.learningEvidence || "说一说今天的收获", palette.primary, true)
+  addVisualOrResource(elements, slotOf(masterPage, "image"), page, master, palette)
+  addChecklist(elements, slotOf(masterPage, "body"), points, master, palette)
+  addSlotText(elements, slotOf(masterPage, "headline"), headline, palette.primary, true)
   return elements
 }
 
@@ -278,18 +280,53 @@ function pageRenderIntent(page: AiCoursewarePage, role: PageRole): PageRenderInt
   return { role, density, visualKind, showMedia: hasBoundMedia || expectsMedia }
 }
 
-function preferredLayout(page: AiCoursewarePage, intent: PageRenderIntent) {
-  const requested = page.visualPlan?.layout || page.visual?.layout
-  if (requested) return requested
-  if (intent.visualKind === "notation" || intent.visualKind === "rhythm") return "diagram_focus"
-  if (intent.visualKind === "compare") return "compare"
-  if (intent.visualKind === "activity") return "activity"
-  if (!hasImage(page) && intent.visualKind === "text" && !intent.showMedia) {
-    if (intent.role === "goal") return "objectives"
-    if (intent.role === "summary" || intent.role === "practice") return "summary"
-    return "text_focus"
+export function resolveAiPptLayout(page: AiCoursewarePage, index: number): string {
+  const role = pageRole(page, index)
+  if (role === "cover") return "cover_scene"
+  if (isScorePage(page)) return "diagram_focus"
+  if (role === "practice") return "text_focus"
+  const requested = resolveTemplatePageLayout(page.visualPlan?.layout || page.visual?.layout)
+  // 历史大纲可能已被服务端统一改为 split,优先恢复教学页型;配图由槽位适配处理。
+  if (role === "rhythm") return hasImage(page) ? "activity" : "diagram_focus"
+  if (requested && requested !== "split" && requested !== "cover") return requested
+  const layouts: Partial<Record<PageRole, string>> = {
+    intro: "hero_focus", story: "hero_focus", goal: "objectives", knowledge: "objectives",
+    listen: "media_focus", compare: "compare", activity: "activity", performance: "activity",
+    summary: "summary", homework: "activity", practice: "text_focus", interaction: "text_focus", lyric: "text_focus"
+  }
+  return layouts[role] || "split_content"
+}
+
+function fitVisualSlots(page: TemplateMasterPage, hasVisual: boolean, nativeVisual: boolean): TemplateMasterPage {
+  if (nativeVisual) return page
+  const image = slotOf(page, "image")
+  const body = slotOf(page, "body")
+  if (!hasVisual) {
+    if (!image || !body) return page
+    // 只合并并排区域,不能跨过标题或侵入模板保留区域。
+    if (Math.abs(image.top - body.top) > 40) return page
+    const left = Math.min(image.left, body.left)
+    const right = Math.max(image.left + image.width, body.left + body.width)
+    return { ...page, slots: page.slots.filter(s => s.kind !== "image").map(s => s.id === body.id ? { ...s, left, width: right - left } : s) }
   }
-  return requested
+  if (image) return page
+  // 在既有安全内容区内给教学图片分配位置,保留对比、步骤、总结的语义结构。
+  const content = page.slots.filter(s => s.enabled !== false && ["body", "steps", "left", "right"].includes(s.kind))
+  if (!content.length) return page // 自带封面主视觉的母版保持原有背景契约。
+  const left = Math.min(...content.map(s => s.left)), top = Math.min(...content.map(s => s.top))
+  const width = Math.max(...content.map(s => s.left + s.width)) - left
+  const height = Math.max(...content.map(s => s.top + s.height)) - top
+  // 对比图片置于两栏上方,左右内容保持同等面积,避免暗示一侧更重要。
+  if (content.length > 1) {
+    const imageHeight = Math.min(...content.map(s => s.height)) * 0.42
+    const imageWidth = Math.min(width, imageHeight * 2)
+    return { ...page, slots: [...page.slots.map(s => content.some(c => c.id === s.id)
+      ? { ...s, top: s.top + imageHeight + 20, height: s.height - imageHeight - 20 } : s),
+      { id: "image", kind: "image", left: left + (width - imageWidth) / 2, top, width: imageWidth, height: imageHeight }] }
+  }
+  const imageWidth = width * 0.4
+  return { ...page, slots: [...page.slots.map(s => s.id === content[0].id ? { ...s, left: left + imageWidth + 28, width: width - imageWidth - 28 } : s),
+    { id: "image", kind: "image", left, top, width: imageWidth, height }] }
 }
 
 function adaptMasterPage(page: TemplateMasterPage, intent: PageRenderIntent): TemplateMasterPage {
@@ -501,6 +538,20 @@ function addNotationVisual(elements: PPTElement[], slot: TemplateSlot | undefine
   }
 }
 
+function addDurationDiagram(elements: PPTElement[], slot: TemplateSlot | undefined, pattern: string[], master: TemplateMaster, palette: Palette) {
+  if (!slot) return
+  // 只可视化正文明确给出的长短顺序,不猜拍号、音符时值或教材谱例。
+  const gap = 24, unit = (slot.width - gap * (pattern.length - 1)) / pattern.reduce((sum, item) => sum + (item === "长" ? 2 : 1), 0)
+  let left = slot.left
+  const barHeight = Math.min(80, slot.height * 0.22), top = slot.top + slot.height * 0.36
+  pattern.forEach(item => {
+    const width = unit * (item === "长" ? 2 : 1)
+    elements.push(shapeElement({ left, top, width, height: barHeight, fill: item === "长" ? palette.secondary : palette.primary, radius: 12 }))
+    addSlotText(elements, { id: "duration-label", kind: "body", left, top: top + barHeight + 22, width, height: 70, preferredFontSize: 40, minimumFontSize: 24 }, item, master.textColor, true)
+    left += width + gap
+  })
+}
+
 function addStepGrid(elements: PPTElement[], slot: TemplateSlot | undefined, sourceItems: string[], master: TemplateMaster, palette: Palette) {
   if (!slot) return
   const items = uniqueVisibleTexts(sourceItems.map(cleanText).filter(Boolean))
@@ -768,7 +819,7 @@ function hasImage(page: AiCoursewarePage) {
 
 function studentHeadline(page: AiCoursewarePage) {
   const headline = cleanText(page.studentContent?.headline || page.subtitle || "")
-  return sameVisibleText(headline, page.title) ? "" : headline
+  return sameVisibleText(headline, page.title) || isTeacherFacingText(headline) ? "" : headline
 }
 
 function studentPoints(page: AiCoursewarePage) {
@@ -776,20 +827,13 @@ function studentPoints(page: AiCoursewarePage) {
   const legacy = (page.bullets || []).map(cleanText).filter(Boolean)
   const points = [...(structured.length ? structured : legacy), studentHeadline(page)].filter(Boolean)
   const excluded = [page.title].map(normalizeVisibleText).filter(Boolean)
-  const unique = uniqueVisibleTexts(points).filter(item => !excluded.includes(normalizeVisibleText(item)))
-  return unique.length ? unique : [studentHeadline(page) || cleanText(page.title) || "课堂内容"]
+  return uniqueVisibleTexts(points).filter(item => !excluded.includes(normalizeVisibleText(item)) && !isTeacherFacingText(item))
 }
 
 function activityTask(page: AiCoursewarePage) {
   // activity 属于教师组织信息,不能回退到学生投屏画面。
   const task = cleanText(page.classroomAction?.studentTask || studentHeadline(page) || "")
-  return sameVisibleText(task, page.title) ? "" : task
-}
-
-function activitySteps(page: AiCoursewarePage) {
-  const task = activityTask(page)
-  const points = studentPoints(page).filter(item => !sameVisibleText(item, task))
-  return uniqueVisibleTexts(points)
+  return sameVisibleText(task, page.title) || isTeacherFacingText(task) ? "" : task
 }
 
 function slideRemark(page: AiCoursewarePage) {
@@ -802,7 +846,7 @@ 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, activity, fallbackInstruction, page.speakerNotes, page.teacherTips, evidence, answer, explanation, resources, mediaReminder, page.imageReview]
+  return [lessonMeta, page.teachingPurpose, ...teacherFacingTexts(page), activity, fallbackInstruction, page.speakerNotes, page.teacherTips, evidence, answer, explanation, resources, mediaReminder, page.imageReview]
     .map(cleanText)
     .filter(Boolean)
     .filter((item, index, all) => all.indexOf(item) === index)

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

@@ -222,7 +222,7 @@ import {
 } from "@/utils/aiCoursewareOutline"
 
 const props = defineProps<{ visible: boolean }>()
-const emit = defineEmits<{ (e: "update:visible", value: boolean): void; (e: "generated", issues: AiPptQualityIssue[]): void }>()
+const emit = defineEmits<{ (e: "update:visible", value: boolean): void; (e: "generated", issues: AiPptQualityIssue[], outline: AiCoursewareOutline, preferences: AiCoursewarePreferences): void }>()
 const slidesStore = useSlidesStore(),
   pptWork = usePptWork(),
   snapshots = useSnapshotStore()
@@ -544,12 +544,14 @@ async function pollTask() {
 async function enterEditor(result: AiCoursewareGenerateResult, active: () => boolean) {
   if (!result?.outline?.pages?.length) throw new Error("尚未获得课件内容,请继续查看")
   const converted = aiCoursewareToSlides(result.outline, { templateId: templateId.value, enableAnimations: preferences.enableAnimations })
-  const issues = [
-    ...inspectAiPptQuality(result.outline, converted.slides, {
+  const report = inspectAiPptQuality(result.outline, converted.slides, {
       ...preferences,
       minPages: result.outline.pages.length,
       maxPages: result.outline.pages.length
-    }).errors,
+    })
+  const issues = [
+    ...report.errors,
+    ...report.warnings.filter(issue => ["image_review", "missing_required_visual", "layout_variety", "layout_consecutive"].includes(issue.code)),
     ...(await inspectAiPptResources(result.outline, converted.slides))
   ]
   if (result.reviewMessage) issues.unshift({ code: "teaching-review", severity: "warning", message: result.reviewMessage })
@@ -590,7 +592,7 @@ async function enterEditor(result: AiCoursewareGenerateResult, active: () => boo
     generationError.value = ""
     step.value = 2
     remember()
-    emit("generated", issues)
+    emit("generated", issues, clone({ ...result.outline, templateId: templateId.value }), clone(preferences))
     emit("update:visible", false)
     ElMessage.success("课件已生成,可直接编辑,完成后点击“保存课件”")
   } catch (error) {

+ 12 - 5
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">{{ usePptWorkHook.isSave ? "✓ 课件已保存" : "课件已生成 · 可继续编辑" }}</span>
       <Popover
         v-if="generationIssues.length"
         trigger="click"
@@ -66,11 +66,16 @@
           <div class="generation-issues">
             <strong>上课前请核对以下内容</strong>
             <p>可在编辑器中补充或调整,完成后保存课件。</p>
+            <p>重新生成仅替换对应页面的文字、图片和排版,可撤销恢复。</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="slidesStore.updateSlideIndex(Math.min(issue.pageNo - 1, slidesStore.slides.length - 1))">
+                <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)">
+                  {{ repairingPage === issue.pageNo ? '正在生成…' : '重新生成本页' }}
+                </button>
               </li>
             </ul>
           </div>
@@ -108,6 +113,8 @@ import Popover from "@/components/Popover.vue"
 import PopoverMenuItem from "@/components/PopoverMenuItem.vue"
 import AiCoursewareDialog from "./AiCoursewareDialog.vue"
 import type { AiPptQualityIssue } from "@/utils/aiPptQuality"
+import type { AiCoursewareOutline, AiCoursewarePreferences } from "@/api/aiCourseware"
+import useAiCoursewarePageRepair from "@/hooks/useAiCoursewarePageRepair"
 import { ref, computed, watch } from "vue"
 import { ElMessageBox } from "element-plus"
 import usePptWork from "@/store/pptWork"
@@ -124,9 +131,9 @@ const mainMenuVisible = ref(false)
 const hotkeyDrawerVisible = ref(false)
 const aiCoursewareVisible = ref(false)
 const aiEditing = ref(false)
-const generationIssues = ref<AiPptQualityIssue[]>([])
-function onGenerated(issues: AiPptQualityIssue[]) {
-  generationIssues.value = issues
+const { issues: generationIssues, repairingPage, error: repairError, generated, locate: locatePage, repair: repairPage } = useAiCoursewarePageRepair()
+function onGenerated(issues: AiPptQualityIssue[], outline: AiCoursewareOutline, preferences: AiCoursewarePreferences) {
+  generated(issues, outline, preferences)
   aiEditing.value = true
 }
 

Nem az összes módosított fájl került megjelenítésre, mert túl sok fájl változott