yonge 1 день назад
Родитель
Сommit
d34c4c1ea5

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


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


+ 1 - 1
dist/assets/index-DYTjdmo1.js → dist/assets/index-BY0eRu6G.js

@@ -1 +1 @@
-import{_ as t}from"./404-R0y5dkvZ.js";import{E as c}from"./index-BDoATKp-.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,bK as m,bL as f,bH as u}from"./index-DIgXwPMu.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-D18s75YP.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,bK as m,bL as f,bH as u}from"./index-D2QfjzK0.js";const s=a=>(m("data-v-340bbdaa"),a=a(),f(),a),h={class:"login"},b={class:"error"},g=s(()=>o("img",{src:t,class:"img",alt:""},null,-1)),k=s(()=>o("div",{class:"tit"},"登录已过期或服务器错误!",-1)),v=n({__name:"login",setup(a){function e(){window.close()}return(x,B)=>(i(),r("div",h,[o("div",b,[g,k,_(l(c),{class:"backBtn",type:"primary",plain:"",onClick:e},{default:d(()=>[p("关闭页面")]),_:1})])]))}}),C=u(v,[["__scopeId","data-v-340bbdaa"]]);export{C as default};

+ 1 - 1
dist/assets/index-B92TRPPS.js → dist/assets/index-CKtkKYz2.js

@@ -1 +1 @@
-import{u as c,ag as l}from"./index-DMS84AVO.js";import{w as p,bA as d,a5 as g,cE as m,R as t,ae as r,af as u,al as f,ag as w,bH as k}from"./index-DIgXwPMu.js";import"./shapes-CUaFgXE3.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}))]))}}),E=k(_,[["__scopeId","data-v-90b2692a"]]);export{E as default};
+import{u as c,ag as l}from"./index-nN5PyIP0.js";import{w as p,bA as d,a5 as g,cE as m,R as t,ae as r,af as u,al as f,ag as w,bH as k}from"./index-D2QfjzK0.js";import"./shapes-DG-CIAVU.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}))]))}}),E=k(_,[["__scopeId","data-v-90b2692a"]]);export{E as default};

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


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


+ 1 - 1
dist/assets/index-BokSsAGL.js → dist/assets/index-D4RSbcyx.js

@@ -1 +1 @@
-import{u as s,ag as r}from"./index-DMS84AVO.js";import{a5 as c,cE as n,R as p,ae as t,af as _,al as u,ag as i,bH as l}from"./index-DIgXwPMu.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-nN5PyIP0.js";import{a5 as c,cE as n,R as p,ae as t,af as _,al as u,ag as i,bH as l}from"./index-D2QfjzK0.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};

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


+ 1 - 1
dist/assets/index-dw5X9YHY.js → dist/assets/index-Dtx5Wbe5.js

@@ -1 +1 @@
-import{_ as a}from"./404-R0y5dkvZ.js";import{a5 as o,ep as t,ae as r,af as c,bK as _,bL as d,ak as s,bH as p}from"./index-DIgXwPMu.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,ep as t,ae as r,af as c,bK as _,bL as d,ak as s,bH as p}from"./index-D2QfjzK0.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};

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


+ 1 - 1
dist/assets/index-DMS84AVO.js → dist/assets/index-nN5PyIP0.js

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

+ 1 - 1
dist/assets/shapes-CUaFgXE3.js → dist/assets/shapes-DG-CIAVU.js

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

+ 33 - 0
scripts/aiPptDraftPreviewTest.html

@@ -0,0 +1,33 @@
+<!doctype html>
+<html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>草稿逐页预览回归</title></head>
+<body><div id="app"></div><script type="module">
+import { createApp, h, ref, computed, nextTick } from 'vue'
+import { store } from '/src/store/index.ts'
+import Preview from '/src/views/Editor/EditorHeader/AiCoursewareDraftPreview.vue'
+import { buildAiPptSlidePreviews } from '/src/utils/aiPptTemplateEngine.ts'
+import { inspectAiPptRenderedText } from '/src/utils/aiPptRenderedQuality.ts'
+import { comparisonCapacityFixture } from './fixtures/aiPptComparisonCapacity.mjs'
+import '/src/assets/styles/global.scss'
+import '/src/assets/styles/prosemirror.scss'
+createApp({setup(){
+  const overloaded = ref(new URLSearchParams(location.search).get('overloaded') === 'true')
+  const outline = computed(() => comparisonCapacityFixture(overloaded.value))
+  const errors = ref([]), measured = ref('尚未测量')
+  const check = async () => {
+    await nextTick()
+    await document.fonts.ready
+    const pages = buildAiPptSlidePreviews(outline.value, { templateId:'blank' })
+    const valid = pages.filter(page => page.slide)
+    measured.value = JSON.stringify({ total:pages.length, rendered:valid.length, failed:pages.filter(page => page.error).map(page => page.pageNo),
+      textIssues:await inspectAiPptRenderedText(valid.map(page => page.slide), valid.map(page => page.pageNo)) })
+  }
+  return () => h('main',{style:'max-width:1040px;margin:20px auto;padding:16px'},[
+    h('p','本地回归样例,不调用生成接口,不修改任何线上课件。'),
+    h('button',{onClick:()=>{overloaded.value=!overloaded.value;measured.value='尚未测量'}},overloaded.value?'切换正常对比页':'模拟第3页严重超密'),
+    h('button',{onClick:check},'检查实际排版'),
+    h('pre',{id:'render-status',style:'white-space:pre-wrap'},measured.value),
+    h('pre',{id:'layout-errors',style:'white-space:pre-wrap'},JSON.stringify(errors.value)),
+    h(Preview,{outline:outline.value,templateId:'blank',message:'本地测试:检查对比页排版与异常隔离。',onLayoutErrors:value=>{errors.value=value}})
+  ])
+}}).use(store).mount('#app')
+</script></body></html>

+ 12 - 0
scripts/aiPptDraftSelfTest.mjs

@@ -3,6 +3,7 @@ import { createServer } from 'vite'
 import { createRenderer, reactive, nextTick, h, ssrContextKey } from 'vue'
 import { renderToString } from '@vue/server-renderer'
 import { editorialFixture } from './fixtures/aiPptEditorial.mjs'
+import { comparisonCapacityFixture } from './fixtures/aiPptComparisonCapacity.mjs'
 
 // 测试实际对话框的续作/草稿状态流程。浏览器排版和媒体探测以明确结果模拟,
 // 此脚本不代表浏览器视觉验收,不访问生成服务或线上课件。
@@ -124,6 +125,17 @@ try {
   assert.equal(state.emitted.length, 0)
 
   ;({ state, dialog } = mount())
+  state.result.outline = comparisonCapacityFixture(true)
+  await stageDraft(dialog)
+  const isolatedHtml = await renderToString(h(Preview, { outline:dialog.completionSource,templateId:'blank',message:dialog.generationError,
+    onLayoutErrors:dialog.recordCompletionLayoutErrors }))
+  assert(isolatedHtml.includes('data-draft-canvas="1920"'), '别页排版失败不应让正常首页失去预览')
+  assert(dialog.layoutRepairFeedback.includes('第3页'), '预览向续作流程提供具体失败页')
+  await dialog.retry()
+  assert(state.calls.find(call => call.kind === 'generate').payload.visualPrompt.includes('第3页'), '续作必须发送排版问题,不得只重复配图')
+  assert.equal(state.calls.find(call => call.kind === 'generate').payload.repairOnly, true)
+
+  ;({ state, dialog } = mount())
   state.saved = { outline: clone(state.result.outline), preferences: { minPages: 6, maxPages: 6 },
     completionSource: clone(state.result.outline), completionMessage: state.result.blockingMessage,
     reviewMessage: state.result.reviewMessage, taskId: 'expired', extraPrompt: '' }

+ 14 - 0
scripts/aiPptSelfTest.mjs

@@ -12,12 +12,26 @@ import { liveRegressionFixture } from './fixtures/aiPptLiveRegression.mjs'
 import { storyVisualFixture } from './fixtures/aiPptStoryVisual.mjs'
 import { nativeVisualFixture } from './fixtures/aiPptNativeVisual.mjs'
 import { editorialFixture } from './fixtures/aiPptEditorial.mjs'
+import { comparisonCapacityFixture } from './fixtures/aiPptComparisonCapacity.mjs'
 
 const vite = await createServer({ server: { middlewareMode: true }, appType: "custom", logLevel: "error" })
 
 try {
   const { aiCoursewareToSlides } = await vite.ssrLoadModule("/src/utils/aiCoursewareToSlides.ts")
   const { inspectAiPptQuality } = await vite.ssrLoadModule("/src/utils/aiPptQuality.ts")
+  const { buildAiPptSlidePreviews } = await vite.ssrLoadModule('/src/utils/aiPptTemplateEngine.ts')
+  const comparison = comparisonCapacityFixture()
+  const comparisonSlides = aiCoursewareToSlides(comparison).slides
+  assert.equal(comparisonSlides.length, 6, '长对比组放不下卡片装饰时应使用紧凑分栏')
+  assert.deepEqual(inspectAiPptQuality(comparison, comparisonSlides).errors.filter(issue => ['out_of_bounds','text_overlap','text_overflow','rendered_text_geometry'].includes(issue.code)), [])
+  const overloadedComparison = comparisonCapacityFixture(true)
+  assert.throws(() => aiCoursewareToSlides(overloadedComparison), /第3页.*投屏安全区/, '异常必须携带具体页码')
+  const previews = buildAiPptSlidePreviews(overloadedComparison)
+  assert.equal(previews.length, 6, '失败页也保留页序位置')
+  assert(previews[2].error.includes('第3页'))
+  assert.equal(previews.filter(page => page.slide).length, 5, '单页失败不得清空其他预览')
+  const textGeometry = slide => slide.elements.filter(e => e.type === 'text').map(({left,top,width,height,content}) => ({left,top,width,height,content}))
+  assert.deepEqual(textGeometry(previews[3].slide), textGeometry(comparisonSlides[3]), '失败后的页面仍沿用正确页序和布局规则')
   const editorial = editorialFixture(), editorialSlides = aiCoursewareToSlides(editorial).slides
   const editorialErrors = inspectAiPptQuality(editorial, editorialSlides, { minPages: 6, maxPages: 6 }).errors
   assert.deepEqual(editorialErrors.filter(issue => ['out_of_bounds', 'text_overlap', 'text_overflow', 'rendered_text_geometry', 'missing_content', 'small_font'].includes(issue.code)), [])

+ 18 - 0
scripts/fixtures/aiPptComparisonCapacity.mjs

@@ -0,0 +1,18 @@
+import { editorialFixture } from './aiPptEditorial.mjs'
+
+// 对比页容量及单页预览失败的回归夹具,不是用户的真实生成课件。
+export function comparisonCapacityFixture(overloaded = false) {
+  const outline = editorialFixture()
+  const page = outline.pages[2]
+  page.title = '辨别声音的强弱与长短'
+  page.studentContent = {
+    headline: '它们有什么不同?',
+    comparisonGroups: Array.from({ length: 4 }, (_, i) => ({
+      label: `第${i + 1}组声音`,
+      points: ['认真听教师发出的声音,举手说出角色名字。'.repeat(overloaded ? 40 : 4)]
+    }))
+  }
+  page.classroomAction = { studentTask: '认真听,再说说你听到的声音。' }
+  page.visualPlan = { composition: 'comparison', visualMode: 'text_only', imagePurpose: 'none', imageNecessity: 'none', textPresentation: 'plain' }
+  return outline
+}

+ 38 - 10
src/utils/aiPptTemplateEngine.ts

@@ -80,16 +80,12 @@ export function buildAiPptSlides(
   outline: AiCoursewareOutline,
   options: AiPptOptions = {}
 ): { title: string; theme: Partial<SlideTheme>; slides: Slide[] } {
-  const normalized = normalizeOutline(outline)
-  validateTemplateMasterRegistry(AI_COURSEWARE_TEMPLATES.map(template => template.id))
-  const requestedTemplateId = options.templateId || normalized.templateId
-  const templateId = requestedTemplateId
-    ? getAiCoursewareTemplate(requestedTemplateId).id
-    : DEFAULT_AI_COURSEWARE_TEMPLATE_ID
-  const palette = buildPalette(normalized, options.paletteVariant || 0, templateId)
-  const resolvedOptions = { ...options, templateId }
+  const { normalized, palette, resolvedOptions } = prepareRender(outline, options)
   const layoutOccurrences = new Map<string, number>()
-  const slides = normalized.pages.map((page, index) => buildSlide(page, normalized, palette, resolvedOptions, index, layoutOccurrences))
+  const slides = normalized.pages.map((page, index) => {
+    try { return buildSlide(page, normalized, palette, resolvedOptions, index, layoutOccurrences) }
+    catch (error) { throw new Error(pageRenderError(page, index, error)) }
+  })
   runPreflight(slides)
   return {
     title: normalized.title || normalized.chapterName || "音乐课件",
@@ -104,6 +100,35 @@ export function buildAiPptSlides(
   }
 }
 
+export interface AiPptPreviewPage { pageNo: number; slide?: Slide; error?: string }
+
+/** 保留整课的页序、封面规则与母版轮换;单页错误不得清空其他页面的预览。 */
+export function buildAiPptSlidePreviews(outline: AiCoursewareOutline, options: AiPptOptions = {}): AiPptPreviewPage[] {
+  const { normalized, palette, resolvedOptions } = prepareRender(outline, options)
+  const layoutOccurrences = new Map<string, number>()
+  return normalized.pages.map((page, index) => {
+    const pageNo = page.pageNo || index + 1
+    try { return { pageNo, slide: buildSlide(page, normalized, palette, resolvedOptions, index, layoutOccurrences) } }
+    catch (error) { return { pageNo, error: pageRenderError(page, index, error) } }
+  })
+}
+
+function pageRenderError(page: AiCoursewarePage, index: number, error: unknown) {
+  return `第${page.pageNo || index + 1}页“${page.title}”:${error instanceof Error ? error.message : '排版未完成'}`
+}
+
+function prepareRender(outline: AiCoursewareOutline, options: AiPptOptions) {
+  const normalized = normalizeOutline(outline)
+  validateTemplateMasterRegistry(AI_COURSEWARE_TEMPLATES.map(template => template.id))
+  const requestedTemplateId = options.templateId || normalized.templateId
+  const templateId = requestedTemplateId
+    ? getAiCoursewareTemplate(requestedTemplateId).id
+    : DEFAULT_AI_COURSEWARE_TEMPLATE_ID
+  const palette = buildPalette(normalized, options.paletteVariant || 0, templateId)
+  const resolvedOptions = { ...options, templateId }
+  return { normalized, palette, resolvedOptions }
+}
+
 function buildSlide(page: AiCoursewarePage, outline: AiCoursewareOutline, palette: Palette, options: AiPptOptions, index: number, layoutOccurrences: Map<string, number>): Slide {
   const role = pageRole(page, index)
   const intent = pageRenderIntent(page, role)
@@ -282,13 +307,16 @@ function fillDesignedPage(master: TemplateMaster, layout: TemplateMasterPage, pa
   if (composition === 'comparison' && content.groups.length >= 2) {
     content.groups.forEach((group, i) => {
       const slot = slotOf(layout, i === 0 ? 'left' : i === 1 ? 'right' : `comparison-${i}`)!
+      // 装饰标题和内边距必须计入容量;不能只看卡片外框高度就塞入长正文。
       const spacious = !hasImage(page) && slot.height >= 300 && group.label.length <= 12 && group.points.length <= 3 && group.points.every(point => point.length <= 32)
+        && flowHeight(group.points, slot.width - 68, 28) <= slot.height - 160
       if (spacious) {
         elements.push(shapeElement({ left: slot.left, top: slot.top, width: slot.width, height: slot.height, fill: i === 0 ? palette.soft : palette.surface, radius: 12 }))
         elements.push(lineElement(slot.left + 34, slot.top + 100, slot.left + slot.width - 34, slot.top + 100, palette.secondary, 4))
         if (group.label) addSlotText(elements, { ...slot, left: slot.left + 34, top: slot.top + 20, width: slot.width - 68, height: 72, preferredFontSize: 48 }, group.label, master.titleColor, true)
         paintText({ ...slot, left: slot.left + 34, width: slot.width - 68, top: slot.top + 134, height: slot.height - 160 }, group.points)
-      } else if (content.groups.length > 2 && !hasImage(page) && slot.height >= 170) {
+      } else if (content.groups.length > 2 && !hasImage(page) && slot.height >= 170
+        && flowHeight(group.points, slot.width - 56, 28) <= slot.height - 118) {
         elements.push(shapeElement({ left: slot.left, top: slot.top, width: slot.width, height: slot.height, fill: i % 2 ? palette.surface : palette.soft, radius: 10 }))
         addSlotText(elements, { ...slot, left: slot.left + 28, top: slot.top + 18, width: slot.width - 56, height: 64, preferredFontSize: 46 }, group.label, master.titleColor, true)
         paintText({ ...slot, left: slot.left + 28, top: slot.top + 100, width: slot.width - 56, height: slot.height - 118 }, group.points, 'plain')

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

@@ -180,7 +180,7 @@
         </section>
         <section v-else class="generation" :class="{ 'showing-preview': generationError && completionSource && showCompletionContent }" aria-live="polite">
           <AiCoursewareDraftPreview v-if="generationError && completionSource && showCompletionContent"
-            :outline="completionSource" :template-id="templateId" :message="generationError" />
+            :outline="completionSource" :template-id="templateId" :message="generationError" @layout-errors="recordCompletionLayoutErrors" />
           <template v-else-if="generationError"
             ><span class="error-icon">!</span>
             <h3>{{ completionSource ? `已保留 ${completionSource.pages.length} 页,部分内容待完善` : task ? "课件进度暂时无法继续" : "这次生成未完成" }}</h3>
@@ -793,6 +793,11 @@ async function openCompletionDraft() {
     if (requestVersion === version) transferring.value = false
   }
 }
+function recordCompletionLayoutErrors(messages: string[]) {
+  if (!completionSource.value || !messages.length) return
+  layoutRepairSource.value = clone(completionSource.value)
+  layoutRepairFeedback.value = messages.join(';')
+}
 async function enterEditor(result: AiCoursewareGenerateResult, active: () => boolean, allowIncompleteDraft = false) {
   if (!result?.outline?.pages?.length) throw new Error("尚未获得课件内容,请继续查看")
   if (!matchesRequestedTopic(result.outline)) throw new Error('生成结果与本次教材或课题不符,原课件已保留,请重新生成大纲。')
@@ -816,7 +821,10 @@ async function enterEditor(result: AiCoursewareGenerateResult, active: () => boo
   try {
     converted = aiCoursewareToSlides(result.outline, { templateId: templateId.value, enableAnimations: preferences.enableAnimations })
   } catch (error) {
-    if (error instanceof Error && /投屏安全区|保留区/.test(error.message)) throw new Error('生成内容超出页面容量,已保留当前课件。请精简并重新排版。')
+    if (error instanceof Error && /投屏安全区|保留区/.test(error.message)) {
+      layoutRepairFeedback.value = error.message
+      throw new Error(`${error.message.match(/^第\d+页/)?.[0] || '生成内容'}超出页面容量,已保留当前课件。请精简并重新排版。`)
+    }
     throw error
   }
   const report = inspectAiPptQuality(result.outline, converted.slides, {

+ 9 - 12
src/views/Editor/EditorHeader/AiCoursewareDraftPreview.vue

@@ -19,7 +19,7 @@
     <p v-if="currentPage && pageNeedsImage(currentPage)" class="image-notice">{{ imagePreparationMessage(currentPage) }}</p>
     <div ref="canvasHost" class="canvas-host">
       <ThumbnailSlide v-if="currentSlide" :slide="currentSlide" :size="previewWidth" :viewport-size="1920" :viewport-ratio="0.5625" />
-      <p v-else role="status">本页暂时无法预览排版,以下保留完整文字。{{ preview.error }}</p>
+      <p v-else role="status">第 {{ selected + 1 }} 页排版超出页面容量。以下保留完整文字,其他页面可继续预览;请点击“精简并重新排版”。</p>
     </div>
     <details v-if="currentPage" :key="currentPage.pageNo" class="page-content" :open="!currentSlide">
       <summary>本页文字与教学备注</summary>
@@ -43,28 +43,25 @@
 <script setup lang="ts">
 import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
 import type { AiCoursewareOutline, AiCoursewarePage } from '@/api/aiCourseware'
-import type { Slide } from '@/types/slides'
-import { aiCoursewareToSlides } from '@/utils/aiCoursewareToSlides'
+import { buildAiPptSlidePreviews } from '@/utils/aiPptTemplateEngine'
 import { blockingImageIssues, imagePreparationMessage } from '@/utils/aiPptFeedback'
 import { inspectAiPptQuality } from '@/utils/aiPptQuality'
 import ThumbnailSlide from '@/views/components/ThumbnailSlide/index.vue'
 
 const props = defineProps<{ outline: AiCoursewareOutline; templateId: string; message: string }>()
+const emit = defineEmits<{ (event: 'layout-errors', messages: string[]): void }>()
 const selected = ref(0)
 const canvasHost = ref<HTMLElement>()
 const previewWidth = ref(640)
 let observer: ResizeObserver | undefined
 watch(() => props.outline, () => { selected.value = Math.min(selected.value, Math.max(0, props.outline.pages.length - 1)) })
-const preview = computed(() => {
-  try {
-    return { slides: aiCoursewareToSlides(props.outline, { templateId: props.templateId, enableAnimations: false }).slides, error: '' }
-  } catch (error) {
-    return { slides: [] as Slide[], error: error instanceof Error ? error.message : '请精简并重新排版。' }
-  }
-})
+const preview = computed(() => buildAiPptSlidePreviews(props.outline, { templateId: props.templateId, enableAnimations: false }))
+watch(preview, pages => emit('layout-errors', pages.flatMap(page => page.error ? [page.error] : [])), { immediate: true })
 const currentPage = computed(() => props.outline.pages[selected.value])
-const currentSlide = computed(() => preview.value.slides[selected.value])
-const missingImagePages = computed(() => new Set(blockingImageIssues(inspectAiPptQuality(props.outline, preview.value.slides).issues).map(issue => issue.pageNo)))
+const currentSlide = computed(() => preview.value[selected.value]?.slide)
+const missingImagePages = computed(() => new Set(preview.value.flatMap((page, index) => page.slide
+  ? blockingImageIssues(inspectAiPptQuality({ ...props.outline, pages: [props.outline.pages[index]] }, [page.slide]).issues).map(issue => issue.pageNo)
+  : [])))
 const pageNeedsImage = (page: AiCoursewarePage) => missingImagePages.value.has(page.pageNo) || !!page.imageStatus && !['ready', 'not_required'].includes(page.imageStatus)
 onMounted(() => {
   if (!canvasHost.value) return

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