yonge 1 month ago
parent
commit
3911f473b8

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


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

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


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


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


+ 1 - 1
dist/assets/index-BIbB21s2.js → dist/assets/index-DDNAKKCx.js

@@ -1 +1 @@
-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};
+import{u as c,ag as l}from"./index-B6naDp-b.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-C1h8K2FI.js";import"./shapes-BUW3xmTK.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-B1xcfQSh.js → dist/assets/index-DSH0alkb.js

@@ -1 +1 @@
-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};
+import{u as s,ag as r}from"./index-B6naDp-b.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-C1h8K2FI.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-BbNNmW9j.js → dist/assets/index-Dr2qUrJD.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-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};
+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-C1h8K2FI.js";const i=e=>(_("data-v-dda25179"),e=e(),d(),e),n={class:"errorPage"},m=i(()=>s("div",{class:"error"},[s("img",{src:a,class:"img",alt:""}),s("div",{class:"tit"},"页面找不到了~")],-1)),l=[m],f=o({__name:"errorPage",setup(e){return t(),(u,h)=>(r(),c("div",n,l))}}),I=p(f,[["__scopeId","data-v-dda25179"]]);export{I as default};

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

@@ -1 +1 @@
-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};
+import{_ as t}from"./404-R0y5dkvZ.js";import{E as c}from"./index-B-QH0-xB.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-C1h8K2FI.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-By78GjSV.js → dist/assets/shapes-BUW3xmTK.js

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

+ 53 - 1
scripts/aiPptSelfTest.mjs

@@ -3,6 +3,7 @@ import { existsSync } from "node:fs"
 import { resolve } from "node:path"
 import assert from "node:assert/strict"
 import { classroomFixture } from "./fixtures/aiPptClassroom.mjs"
+import { modelDesignFixture } from "./fixtures/aiPptModelDesign.mjs"
 
 const vite = await createServer({ server: { middlewareMode: true }, appType: "custom", logLevel: "error" })
 
@@ -311,7 +312,58 @@ try {
       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,
+  const { PAGE_COMPOSITIONS, compilePageDesign } = await vite.ssrLoadModule('/src/utils/aiPptPageDesign.ts')
+  let modelDesignChecks = 0
+  const geometry = slide => slide.elements.filter(el => ['text', 'image', 'shape'].includes(el.type)).map(el => [el.type, el.left, el.top, el.width, el.height])
+  const modelFixture = modelDesignFixture(scoreImage)
+  const modelSlides = render(modelFixture)
+  const modelReport = inspectAiPptQuality(modelFixture, modelSlides)
+  assert(!modelReport.errors.some(issue => ['out_of_bounds', 'text_overlap', 'text_overflow', 'missing_content', 'small_font'].includes(issue.code)), JSON.stringify(modelReport.errors))
+  assert(!modelReport.issues.some(issue => ['missing_required_visual', 'generated_focal_visual', 'rendered_layout_repeat'].includes(issue.code)), JSON.stringify(modelReport.issues))
+  assert(new Set(modelSlides.map(slide => JSON.stringify(geometry(slide)))).size >= 8, '模型设计应实际产生不同坐标与内容组织')
+  for (const template of AI_COURSEWARE_TEMPLATES) {
+    for (const composition of PAGE_COMPOSITIONS) {
+      for (const visualShare of [.35, .7]) {
+        for (const textPresentation of ['plain', 'cards', 'steps', 'grid']) {
+          for (const illustrated of [true, false]) {
+            const sample = structuredClone(modelFixture)
+            sample.templateId = template.id
+            const target = sample.pages[1] = page(2, 'scene_intro', '观察角色的动作', 'split_content', ['找一找主体', '说说变化'], {
+              studentContent: { headline: '它在做什么?', points: ['找一找主体', '说说变化'] },
+              image: illustrated ? { url: scoreImage, width: 960, height: 720 } : undefined,
+              requiredVisual: false, visualPlan: { composition, visualShare, textPresentation, visualMode: illustrated ? 'generated_image' : 'native_diagram', imagePurpose: illustrated ? 'observation' : 'none' }
+            })
+            const slides = render(sample)
+            const errors = inspectAiPptQuality(sample, slides).errors.filter(issue => issue.pageNo === 2 && ['out_of_bounds', 'text_overlap', 'text_overflow', 'missing_content', 'small_font'].includes(issue.code))
+            assert.equal(errors.length, 0, `${template.id}/${composition}/${visualShare}/${textPresentation}/${illustrated}: ${JSON.stringify(errors)}`)
+            assert.equal(slides[1].elements.some(el => el.type === 'image'), illustrated)
+            const text = slides[1].elements.map(elementText).join(' ')
+            assert(['它在做什么?', ...target.studentContent.points].every(value => text.includes(value)), '新构图不能遗漏任务和正文')
+            modelDesignChecks++
+          }
+        }
+      }
+    }
+    assert.equal(getAiPptImageAspectRatios(template.id).model_design_v1, 1)
+  }
+  const positioned = structuredClone(modelFixture)
+  positioned.pages[1].visualPlan.composition = 'image_left'
+  const leftImage = render(positioned)[1].elements.find(el => el.type === 'image')
+  positioned.pages[1].visualPlan.composition = 'image_right'
+  const rightImage = render(positioned)[1].elements.find(el => el.type === 'image')
+  assert(rightImage.left > leftImage.left, '模型选择图右必须改变图片实际位置')
+  const ratioPage = { title: '观察', visualPlan: { composition: 'image_left', visualMode: 'generated_image', visualShare: .63 } }
+  const ratioSlot = compilePageDesign(ratioPage, 1, getAiCoursewareTemplateMaster('blank'), false, true, false).slots.find(slot => slot.kind === 'image')
+  assert.equal(ratioSlot.width / ratioSlot.height, (1440 - 36) * .65 / 640, '前后端主视觉宽高比约定保持一致')
+  const missingPlannedImage = structuredClone(modelFixture)
+  missingPlannedImage.pages[1].image = undefined
+  missingPlannedImage.pages[1].imageStatus = 'budget_exhausted'
+  assert(inspectAiPptQuality(missingPlannedImage, render(missingPlannedImage)).issues.some(issue => issue.pageNo === 2 && issue.message.includes('额度')))
+  const repeatedSlides = structuredClone(modelSlides)
+  repeatedSlides[2] = structuredClone(modelSlides[1])
+  repeatedSlides[3] = structuredClone(modelSlides[1])
+  assert(inspectAiPptQuality(modelFixture, repeatedSlides).issues.some(issue => issue.code === 'rendered_layout_repeat'), '版式名不同但坐标相同也应被检测到')
+  console.log(JSON.stringify({ passed: failures.length === 0, scenarioCount: results.length, contractChecks: 13, imageTemplateChecks: AI_COURSEWARE_TEMPLATES.length, modelDesignChecks,
     scenarios: courseVariants.map(item => item.id), ...(process.argv.includes("--verbose") ? { results } : {}), failures }, null, 2))
   if (failures.length) process.exitCode = 1
 } finally {

+ 5 - 2
scripts/aiPptVisualTest.html

@@ -23,6 +23,7 @@
       import "/src/assets/styles/prosemirror.scss"
       import { hydrateSlideBackgroundImages } from "/src/libs/jsonTool.ts"
       import { classroomFixture } from "./fixtures/aiPptClassroom.mjs"
+      import { modelDesignFixture } from "./fixtures/aiPptModelDesign.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 })
@@ -41,8 +42,10 @@
           page(10, "summary", "我的音乐收获", "summary_checklist", ["我能分辨声音的高低", "我能表现声音的强弱", "我能与同伴合作表演"], { learningEvidence: "用一种声音表现你心中的凤鸣山谷" })
         ]
       }
-      const fixture = new URLSearchParams(location.search).get("scenario") === "classroom"
-        ? classroomFixture('/ai-courseware-templates/extracted/bird-garden/background-01.jpg') : outline
+      const scenario = new URLSearchParams(location.search).get("scenario")
+      const fixtureImage = '/ai-courseware-templates/extracted/bird-garden/background-01.jpg'
+      const fixture = scenario === 'model-design' ? modelDesignFixture(fixtureImage)
+        : scenario === 'classroom' ? classroomFixture(fixtureImage) : outline
       const result = aiCoursewareToSlides(fixture, { templateId: id })
       const group = ref(0)
       const app = createApp({

+ 32 - 0
scripts/fixtures/aiPptModelDesign.mjs

@@ -0,0 +1,32 @@
+import { classroomFixture } from './aiPptClassroom.mjs'
+
+// 本地编译器回归数据;图片是模板测试资源,并非真实模型生成结果。
+export function modelDesignFixture(imageUrl) {
+  const outline = classroomFixture(imageUrl)
+  const designs = [
+    ['full_scene', 'generated_image', 'plain', .65],
+    ['image_left', 'generated_image', 'plain', .6],
+    ['focus_question', 'text_only', 'plain'],
+    ['image_right', 'generated_image', 'cards', .55],
+    ['comparison', 'text_only', 'plain'],
+    ['steps', 'native_diagram', 'plain'],
+    ['concept_grid', 'text_only', 'grid'],
+    ['steps', 'text_only', 'steps'],
+    ['image_top', 'generated_image', 'plain', .4],
+    ['focus_question', 'text_only', 'plain'],
+    ['concept_grid', 'native_diagram', 'grid'],
+    ['steps', 'text_only', 'steps']
+  ]
+  outline.pages.forEach((page, index) => {
+    const [composition, visualMode, textPresentation, visualShare = .55] = designs[index]
+    const illustrated = visualMode === 'generated_image'
+    page.visualPlan = { layout: 'split_content', composition, visualMode, textPresentation, visualShare,
+      imageNecessity: illustrated ? 'essential' : 'none', imageReason: illustrated ? '观察画面中的角色与动作' : '通过文字或可编辑图示完成任务',
+      imagePurpose: illustrated ? 'observation' : 'none' }
+    page.requiredVisual = illustrated
+    page.image = illustrated ? { url: imageUrl, width: 960, height: 720 } : undefined
+    page.imageStatus = illustrated ? 'ready' : 'not_required'
+    page.imageReview = undefined
+  })
+  return outline
+}

+ 7 - 0
src/api/aiCourseware.ts

@@ -86,6 +86,7 @@ export interface AiCoursewareTextbookResearch {
 
 export interface AiCoursewarePage {
   imageReview?: string
+  imageStatus?: 'not_required' | 'ready' | 'budget_exhausted' | 'generation_failed' | 'review_rejected' | 'reference_missing' | 'unavailable'
   /** 教师确认的教学安排,指导扩写,不直接作为学生可见正文。 */
   teachingBrief?: string
   pageNo: number
@@ -120,6 +121,12 @@ export interface AiCoursewarePage {
     imageKeywords?: string[]
   }
   visualPlan?: {
+    composition?: 'full_scene' | 'image_left' | 'image_right' | 'image_top' | 'comparison' | 'steps' | 'focus_question' | 'concept_grid'
+    visualMode?: 'generated_image' | 'reference_image' | 'native_diagram' | 'text_only'
+    imageNecessity?: 'essential' | 'supporting' | 'optional' | 'none'
+    imageReason?: string
+    visualShare?: number
+    textPresentation?: 'plain' | 'cards' | 'steps' | 'grid'
     imagePurpose?: 'scene' | 'observation' | 'comparison' | 'factual' | 'demonstration' | 'decoration' | 'none'
     mustInclude?: string[]
     avoidElements?: string[]

+ 57 - 0
src/utils/aiPptPageDesign.ts

@@ -0,0 +1,57 @@
+import type { AiCoursewarePage } from "@/api/aiCourseware"
+import type { TemplateMaster, TemplateMasterPage, TemplateSlot } from "@/config/aiCoursewareTemplateMasters"
+
+export const PAGE_COMPOSITIONS = ['full_scene', 'image_left', 'image_right', 'image_top', 'comparison', 'steps', 'focus_question', 'concept_grid'] as const
+export const VISUAL_MODES = ['generated_image', 'reference_image', 'native_diagram', 'text_only'] as const
+
+export function hasModelPageDesign(page: AiCoursewarePage) {
+  return PAGE_COMPOSITIONS.includes(page.visualPlan?.composition as typeof PAGE_COMPOSITIONS[number])
+    && VISUAL_MODES.includes(page.visualPlan?.visualMode as typeof VISUAL_MODES[number])
+}
+
+export const externalVisual = (page: AiCoursewarePage) => ['generated_image', 'reference_image'].includes(page.visualPlan?.visualMode || '')
+
+// 模型提供构图、比例和阅读顺序,编译器只负责安全坐标。旧大纲继续走原母版。
+export function compilePageDesign(page: AiCoursewarePage, index: number, master: TemplateMaster, showMedia: boolean, hasVisual: boolean, hasHeadline: boolean): TemplateMasterPage {
+  const plan = page.visualPlan!
+  const composition = plan.composition!
+  const share = Number.isFinite(plan.visualShare) ? Math.round(Math.min(.70, Math.max(.35, plan.visualShare!)) * 20) / 20 : .55
+  const cover = index === 0
+  const slot = (id: string, left: number, top: number, width: number, height: number, font = 34): TemplateSlot => ({
+    id, kind: id as TemplateSlot['kind'], left, top, width, height, preferredFontSize: font, minimumFontSize: 28
+  })
+  const slots: TemplateSlot[] = [slot('title', 80, 64, 1440, cover ? 100 : 86, cover ? 68 : 50)]
+  if (cover) slots.push(slot('meta', 80, 174, 1440, 48, 28))
+  if (hasHeadline) slots.push(slot('headline', 80, cover ? 236 : 174, 1440, 82, composition === 'focus_question' ? 44 : 36))
+  const top = cover ? hasHeadline ? 340 : 250 : hasHeadline ? 280 : 190
+  const bottom = showMedia ? 730 : 830
+  const height = bottom - top
+  if (showMedia) slots.push(slot('media', 80, 758, 1440, 72, 30))
+  let body = slot('body', 80, top, 1440, height)
+  if (hasVisual) {
+    if (['image_left', 'image_right', 'concept_grid', 'focus_question'].includes(composition)) {
+      const imageWidth = (1440 - 36) * share
+      const bodyWidth = 1440 - imageWidth - 36
+      const imageLeft = composition === 'image_right' ? 80 + bodyWidth + 36 : 80
+      slots.push(slot('image', imageLeft, top, imageWidth, height))
+      body = slot('body', composition === 'image_right' ? 80 : 80 + imageWidth + 36, top, bodyWidth, height)
+    } else {
+      // 短句必须保留可读空间,不能靠缩小字号挤到图片下面。
+      const imageHeight = Math.min(height * share, height - 180 - 28)
+      const imageWidth = Math.min(1440, imageHeight * 2.8)
+      slots.push(slot('image', 80 + (1440 - imageWidth) / 2, top, imageWidth, imageHeight))
+      body = slot('body', 80, top + imageHeight + 28, 1440, height - imageHeight - 28)
+    }
+  }
+  if (composition === 'comparison') {
+    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 })
+  } else slots.push(body)
+  return {
+    id: `model-${composition}`, layout: cover ? 'cover' : 'text', roles: [cover ? 'cover' : 'content'],
+    // 模型设计使用模板配色;固定插画背景不再限制模型构图,也不冒充教学主图。
+    background: master.pages[0].background, backgroundKind: 'decorative', backgroundVisual: false,
+    titleColor: master.titleColor, coverTitleColor: master.titleColor, textColor: master.textColor,
+    base: [], chrome: [], slots, safeArea: { id: 'model-safe', purpose: '投屏内容', left: 64, top: 48, width: 1472, height: 804 }, reservedZones: []
+  }
+}

+ 21 - 9
src/utils/aiPptQuality.ts

@@ -4,6 +4,7 @@ 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"
+import { hasModelPageDesign, externalVisual } from "@/utils/aiPptPageDesign"
 
 type BoundedPPTElement = Exclude<PPTElement, { type: "line" }>
 
@@ -106,13 +107,16 @@ function inspectOutline(outline: AiCoursewareOutline, issues: AiPptQualityIssue[
     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
+    const designed = hasModelPageDesign(page)
+    const designKey = designed ? `${page.visualPlan!.composition}:${page.visualPlan!.textPresentation || 'plain'}` : layout
+    layoutCounts.set(designKey, (layoutCounts.get(designKey) || 0) + 1)
+    cardPages += designed ? ['cards', 'grid', 'steps'].includes(page.visualPlan!.textPresentation || '') ? 1 : 0 : CARD_LAYOUTS.has(layout) ? 1 : 0
+    if (!designed && page.visualPlan?.composition) push(issues, 'invalid_page_design', 'warning', pageNo, '模型页面设计字段不完整,已使用兼容版式,请核对页面构图')
 
-    if (layout === previousLayout) consecutive += 1
+    if (designKey === previousLayout) consecutive += 1
     else consecutive = 1
-    if (consecutive > 2) push(issues, "layout_consecutive", "warning", pageNo, `连续${consecutive}页使用相同布局“${layout}”`)
-    previousLayout = layout
+    if (consecutive > 2) push(issues, "layout_consecutive", "warning", pageNo, `连续${consecutive}页使用相同构图与文字形式,请调整页面设计`)
+    previousLayout = designKey
 
     // 只检查真正会进入学生投屏画面的字段。activity 属于教师组织信息,
     // 最终只会写入 PPT 备注,不能把它当成学生可见内容制造误报。
@@ -142,7 +146,7 @@ function inspectOutline(outline: AiCoursewareOutline, issues: AiPptQualityIssue[
       if (!teacherInstruction) push(issues, "teacher_notes_fallback", "warning", pageNo, "教师备注缺少具体操作,系统已生成基础兜底提示")
       const visibleCount = visibleTexts.length
       const mainVisual = clean(page.visualPlan?.mainVisual || page.visualPlan?.focalContent || page.visual?.mainVisual)
-      const expectsVisual = ["hero_focus", "media_focus", "split_content", "diagram_focus"].includes(layout)
+      const expectsVisual = designed ? externalVisual(page) : ["hero_focus", "media_focus", "split_content", "diagram_focus"].includes(layout)
       if (expectsVisual && !imageUrl && !mainVisual) {
         push(issues, "generated_focal_visual", "warning", pageNo, "本页没有教学主图,请确认文字足以支持课堂任务")
       }
@@ -178,6 +182,7 @@ function inspectOutline(outline: AiCoursewareOutline, issues: AiPptQualityIssue[
 
 function inspectSlides(slides: Slide[], issues: AiPptQualityIssue[], preferences?: AiCoursewarePreferences) {
   let animationCount = 0
+  let previousGeometry = '', repeatedGeometry = 0
   slides.forEach((slide, index) => {
     const pageNo = index + 1
     const texts: string[] = []
@@ -212,6 +217,12 @@ function inspectSlides(slides: Slide[], issues: AiPptQualityIssue[], preferences
     if (duplicate) push(issues, "rendered_duplicate_text", "error", pageNo, `渲染页面存在重复文本:“${short(duplicate)}”`)
     animationCount += slide.animations?.length || 0
     inspectTextOverlaps(textElements, pageNo, issues)
+    // 依据最终元素的位置与面积验收,不能用不同 layout 名称冒充排版变化。
+    const geometry = slide.elements.filter(el => (el.type === 'text' || el.type === 'image') && el.top > 200)
+      .map(el => `${el.type}:${[el.left, el.top, el.width, 'height' in el ? el.height : 0].map(n => Math.round(n / 80)).join(',')}`).sort().join('|')
+    repeatedGeometry = geometry && geometry === previousGeometry ? repeatedGeometry + 1 : 1
+    if (repeatedGeometry === 3) push(issues, 'rendered_layout_repeat', 'warning', pageNo, '连续三页实际排版高度相似,请调整主视觉位置、内容层级或文字呈现方式')
+    previousGeometry = geometry
     const mediaPlaceholder = texts.some(text => text.includes("本页需要课堂音视频"))
     if (mediaPlaceholder && !texts.some(text => /听|赏|唱|音频|视频/.test(text))) {
       push(issues, "unnecessary_media_slot", "warning", pageNo, "页面保留了音视频区域,但学生任务没有媒体活动")
@@ -267,10 +278,11 @@ function inspectTeachingContract(outline: AiCoursewareOutline, slides: Slide[],
         push(issues, "missing_audio_render", "error", no, "已绑定的音频没有进入页面")
       }
     }
-    const plannedImage = ["scene", "observation", "comparison", "factual", "demonstration"].includes(page.visualPlan?.imagePurpose || "") && Boolean(page.visualPlan?.mainVisual || page.visualPlan?.mustInclude?.length)
+    const plannedImage = hasModelPageDesign(page) ? externalVisual(page) : ["scene", "observation", "comparison", "factual", "demonstration"].includes(page.visualPlan?.imagePurpose || "") && Boolean(page.visualPlan?.mainVisual || page.visualPlan?.mustInclude?.length)
     const missingImage = (page.requiredVisual || plannedImage) && !usableResourceUrl(page.image?.url) && !scoreBinding(page)
+    const statusLabels: Record<string, string> = { budget_exhausted: '本页生图额度不足,未调用图片模型', generation_failed: '本页图片模型请求未能取得可用图片', review_rejected: '本页配图未通过教学审核', reference_missing: '本页缺少可核实的图片资源' }
     if (missingImage) push(issues, "missing_required_visual", page.requiredVisual ? "error" : "warning", no,
-      `本页计划配图尚未取得:${page.imageReview || "未返回可用图片,可重新生成本页或手动补充图片"}`)
+      `${statusLabels[page.imageStatus || ''] || '本页计划配图尚未取得'}:${page.imageReview || "可重新生成本页或手动补充图片"}`)
     if (page.imageReview && !missingImage) push(issues, "image_review", "warning", no, page.imageReview)
     else if (page.image?.reviewStatus === "unverified") push(issues, "image_review", "warning", no, page.image.reviewReason || "配图未完成教学适用性审核,请核对主体、细节与学生年龄是否合适")
     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, "必要教学图片没有进入页面")
@@ -305,7 +317,7 @@ function qualityScores(outline: AiCoursewareOutline, issues: AiPptQualityIssue[]
   return {
     teaching: 100 - deduct(["teacher_text_visible", "rendered_teacher_text", "teacher_notes_fallback", "missing_slide_remark", "invalid_options", "instruction_as_option"], 18, 6),
     readability: 100 - deduct(["small_font", "small_font_warning", "text_overflow", "text_overlap", "out_of_bounds"], 18, 7),
-    visual: 100 - deduct(["generated_focal_visual", "low_content_coverage", "layout_variety", "layout_consecutive", "card_ratio", "duplicate_image", "unnecessary_media_slot"], 16, 6),
+    visual: 100 - deduct(["generated_focal_visual", "low_content_coverage", "layout_variety", "layout_consecutive", "rendered_layout_repeat", "invalid_page_design", "card_ratio", "duplicate_image", "unnecessary_media_slot"], 16, 6),
     templateFit
   }
 }

+ 42 - 3
src/utils/aiPptTemplateEngine.ts

@@ -17,6 +17,7 @@ import type {
 } from "@/types/slides"
 import type { AiCoursewareOutline, AiCoursewarePage } from "@/api/aiCourseware"
 import { audioBindings, scoreBinding, isScorePage, usableResourceUrl, isTeacherFacingText, teacherFacingTexts } from "@/utils/aiPptContent"
+import { compilePageDesign, hasModelPageDesign, externalVisual } from "@/utils/aiPptPageDesign"
 import {
   AI_COURSEWARE_TEMPLATES,
   getAiCoursewarePalette,
@@ -109,8 +110,14 @@ function buildSlide(page: AiCoursewarePage, outline: AiCoursewareOutline, palett
   layoutOccurrences.set(baseMasterPage.id, occurrence + 1)
   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)
+  const designed = hasModelPageDesign(page) && !isScorePage(page)
+  const headline = cleanText(page.interaction?.question) || activityTask(page) || studentHeadline(page)
+  const masterPage = designed
+    ? compilePageDesign(page, index, master, intent.showMedia, (externalVisual(page) && hasImage(page)) || Boolean(videoBinding(page)), Boolean(headline))
+    : fitVisualSlots(adapted, hasImage(page) || Boolean(videoBinding(page)), nativeVisual)
+  const contentElements = designed
+    ? fillDesignedPage(master, masterPage, page, outline, palette, index, headline, intent)
+    : fillTemplatePage(master, masterPage, page, outline, palette, role, intent)
   assertContentContract(master, masterPage, contentElements)
   const elements = [
     ...renderTemplateMasterElements(masterPage.base),
@@ -139,9 +146,41 @@ export function getAiPptImageAspectRatios(templateId: AiCoursewareTemplateId): R
       if (slot) ratios[layouts[page.layout] + (showMedia ? ":media" : "")] = slot.width / slot.height
     }
   }
+  ratios.model_design_v1 = 1 // 服务端依据本版编译器的安全区域和模型比例计算图片尺寸。
   return ratios
 }
 
+function fillDesignedPage(master: TemplateMaster, layout: TemplateMasterPage, page: AiCoursewarePage, outline: AiCoursewareOutline, palette: Palette, index: number, headline: string, intent: PageRenderIntent): PPTElement[] {
+  const elements: PPTElement[] = []
+  const points = uniqueVisibleTexts([activityTask(page), ...studentPoints(page), ...(page.interaction?.options || [])]).filter(point => !sameVisibleText(point, headline))
+  addSlotText(elements, slotOf(layout, 'title'), page.title, master.titleColor, true)
+  if (index === 0) addSlotText(elements, slotOf(layout, 'meta'), [outline.gradeName, conciseTextbook(outline.textbookName), outline.unitName].filter(Boolean).join(' · '), master.mutedColor)
+  addSlotText(elements, slotOf(layout, 'headline'), headline, master.textColor, true)
+  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[]) => {
+    if (presentation === 'steps') addStepGrid(elements, slot, values, master, palette)
+    else if (presentation === 'grid') addCardGrid(elements, slot, values, master, palette)
+    else addSlotList(elements, slot, values, master, palette, presentation === 'cards')
+  }
+  if (composition === 'comparison') {
+    const midpoint = Math.ceil(points.length / 2)
+    paintText(slotOf(layout, 'left'), points.slice(0, midpoint))
+    paintText(slotOf(layout, 'right'), points.slice(midpoint))
+  } else {
+    const body = slotOf(layout, 'body')!
+    const pattern = page.visualPlan?.visualMode === 'native_diagram' && pageRole(page, index) === 'rhythm'
+      ? [headline, ...points].join(' ').match(/[短长](?:\s*[-—·、]\s*[短长]){1,7}/)?.[0].match(/[短长]/g) : undefined
+    if (pattern) {
+      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, points)
+  }
+  addInlineMedia(elements, slotOf(layout, 'media'), page, master, palette, intent.showMedia)
+  return elements
+}
+
 function fillTemplatePage(master: TemplateMaster, masterPage: TemplateMasterPage, page: AiCoursewarePage, outline: AiCoursewareOutline, palette: Palette, role: PageRole = masterPage.roles[0], intent = pageRenderIntent(page, role)): PPTElement[] {
   master = {
     ...master,
@@ -564,7 +603,7 @@ function addStepGrid(elements: PPTElement[], slot: TemplateSlot | undefined, sou
   items.forEach((item, index) => {
     const left = slot.left + (index % columns) * (width + gap)
     const top = slot.top + Math.floor(index / columns) * (height + gap)
-    const badgeSize = Math.min(72, Math.max(24, height * 0.2))
+    const badgeSize = Math.min(72, Math.max((slot.minimumFontSize || 0) >= 28 ? 52 : 24, height * 0.2))
     const cardTop = top + badgeSize * 0.55
     const cardHeight = Math.max(12, height - badgeSize * 0.55)
     const textInset = Math.min(30, Math.max(8, width * 0.08))

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

@@ -551,7 +551,7 @@ async function enterEditor(result: AiCoursewareGenerateResult, active: () => boo
     })
   const issues = [
     ...report.errors,
-    ...report.warnings.filter(issue => ["image_review", "missing_required_visual", "layout_variety", "layout_consecutive"].includes(issue.code)),
+    ...report.warnings.filter(issue => ["image_review", "missing_required_visual", "layout_variety", "layout_consecutive", "rendered_layout_repeat", "invalid_page_design"].includes(issue.code)),
     ...(await inspectAiPptResources(result.outline, converted.slides))
   ]
   if (result.reviewMessage) issues.unshift({ code: "teaching-review", severity: "warning", message: result.reviewMessage })

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