yonge 3 dias atrás
pai
commit
4df03b7fb0

Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
dist/assets/aiPptRenderedQuality-JAIfO6hr.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
dist/assets/index-B9fSqgiR.css


+ 1 - 1
dist/assets/index-BZ9imM9e.js → dist/assets/index-BGor5NqM.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-DCGMNxmG.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-CQIh-Q03.js";/**
 * vue v3.4.34
 * (c) 2018-present Yuxi (Evan) You and Vue contributors
 * @license MIT

Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
dist/assets/index-BWhBrdxR.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 1
dist/assets/index-Br3Rb5CR.js


+ 1 - 1
dist/assets/index-DVguX3Ap.js → dist/assets/index-CK0-ZPB_.js

@@ -1 +1 @@
-import{u as c,ag as l}from"./index-BZ9imM9e.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-DCGMNxmG.js";import"./shapes-BUwCPLia.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-BGor5NqM.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-CQIh-Q03.js";import"./shapes-BzawXFTG.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};

Diferenças do arquivo suprimidas por serem muito extensas
+ 1 - 1
dist/assets/index-CQIh-Q03.js


+ 1 - 1
dist/assets/index-DpNiBfyg.js → dist/assets/index-DuI25JxZ.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-DCGMNxmG.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-CQIh-Q03.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-mu1reoqP.js → dist/assets/index-TQOGWCvg.js

@@ -1 +1 @@
-import{_ as t}from"./404-R0y5dkvZ.js";import{E as c}from"./index-DVYxpLqz.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-DCGMNxmG.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-BWhBrdxR.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-CQIh-Q03.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-Epnr0Ly_.js → dist/assets/index-bA5aKz9y.js

@@ -1 +1 @@
-import{u as s,ag as r}from"./index-BZ9imM9e.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-DCGMNxmG.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-BGor5NqM.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-CQIh-Q03.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/shapes-BUwCPLia.js → dist/assets/shapes-BzawXFTG.js

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

+ 27 - 3
scripts/aiPptDraftSelfTest.mjs

@@ -126,9 +126,9 @@ try {
   await stageDraft(dialog)
   state.renderedIssues = [{ code: 'rendered_text_overlap', pageNo: 1, severity: 'error', message: '正文重叠' }]
   await dialog.openCompletionDraft()
-  assert.equal(state.slides.slides[0].id, 'old', '打开草稿仍保留排版检查')
-  assert(dialog.completionSource && dialog.generationError.includes('文字过密'), '排版失败仍保留草稿供预览')
-  assert.equal(state.emitted.length, 0)
+  assert.notEqual(state.slides.slides[0].id, 'old', '显式打开草稿不再被排版校验拦住')
+  assert(state.emitted[0][0].some(issue => issue.code === 'rendered_text_overlap'), '原排版问题继续保留在编辑器')
+  assert(state.emitted[0][0].some(issue => issue.code === 'draft_layout_recovered' && issue.pageNo === 1))
 
   ;({ state, dialog } = mount())
   state.result.outline = comparisonCapacityFixture(true)
@@ -140,6 +140,30 @@ try {
   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)
+  const callsBeforeRecovery = state.calls.length
+  await dialog.openCompletionDraft()
+  assert.equal(state.slides.slides.length, 6, '超容量页可作为原生可编辑草稿打开,页序不变')
+  assert.equal(state.calls.length, callsBeforeRecovery, '恢复草稿不能重新调用模型')
+  const recoveredTexts = state.slides.slides[2].elements.filter(e => e.type === 'text').map(e => e.content.replace(/<[^>]*>/g, '')).join('')
+  for (const group of state.result.outline.pages[2].studentContent.comparisonGroups) {
+    assert(recoveredTexts.includes(group.points[0]), '超长正文不可截断或仅用错误提示替代')
+  }
+
+  ;({ state, dialog } = mount())
+  state.result = { outline: slideDesignFixture(), context: {}, blockingMessage: '页面设计待完善' }
+  state.result.outline.pages[0].pageDesign = { version: 1, elements: [] }
+  state.result.outline.pages[1].pageDesign.elements[1].height = 12
+  await stageDraft(dialog)
+  const sourceBeforeRecovery = clone(state.result.outline)
+  const modelPreview = await renderToString(h(Preview, { outline: dialog.completionSource, templateId: 'blank', message: dialog.generationError }))
+  assert(modelPreview.includes('data-draft-canvas="1920"'), '模型设计不完整时也可显示基础预览')
+  assert(!modelPreview.includes('排版超出页面容量'), '不能把模型设计格式错误误报为页面容量不足')
+  await dialog.openCompletionDraft()
+  assert.equal(state.slides.slides.length, 2)
+  assert(state.slides.slides.every(slide => slide.elements.some(e => e.type === 'text')), '所有失败页都有可编辑内容')
+  assert.deepEqual(state.result.outline, sourceBeforeRecovery, '草稿恢复不改写原模型设计与教学内容')
+  assert.equal(state.calls.length, 0)
+  assert.equal(state.snapshots.length, 2)
 
   ;({ state, dialog } = mount())
   state.saved = { outline: clone(state.result.outline), preferences: { minPages: 6, maxPages: 6 },

+ 2 - 0
scripts/aiPptSelfTest.mjs

@@ -186,6 +186,8 @@ try {
   const { imagePreparationMessage, teacherReviewMessage } = await vite.ssrLoadModule('/src/utils/aiPptFeedback.ts')
   assert(!imagePreparationMessage({ imageStatus: 'review_rejected', imageReview: '评分45低于65,mustInclude缺失,模型说明...' }).includes('评分'))
   assert(teacherReviewMessage('第6页请补充studentContent.steps').includes('学生操作步骤'))
+  const designReview = teacherReviewMessage('第3页:模型页面设计缺失或格式不完整,请提供version=1、background、rationale及1至60个原生元素;第5页:教学图示格式无效,请使用2到6个完整标签')
+  assert(designReview.includes('打开草稿') && !/version|background|rationale/.test(designReview), '教师提示保留处理办法,协议诊断保留在原结果中')
 
   const singingOutline = {
     title: "凤鸣山谷的故事",

+ 3 - 1
src/utils/aiCoursewareToSlides.ts

@@ -4,6 +4,8 @@ import type { AiCoursewareTemplateId } from "@/config/aiCoursewareTemplates"
 import { buildAiPptSlides } from "@/utils/aiPptTemplateEngine"
 
 interface AiCoursewareVisualOptions {
+  recoverDraft?: boolean
+  fallbackPageNos?: number[]
   backgroundImage?: string
   enableAnimations?: boolean
   paletteVariant?: number
@@ -13,6 +15,6 @@ interface AiCoursewareVisualOptions {
 export function aiCoursewareToSlides(
   outline: AiCoursewareOutline,
   options: AiCoursewareVisualOptions = {}
-): { title: string; theme: Partial<SlideTheme>; slides: Slide[] } {
+): { title: string; theme: Partial<SlideTheme>; slides: Slide[]; recoveredPages: Array<{ pageNo: number; error: string }> } {
   return buildAiPptSlides(outline, options)
 }

+ 3 - 1
src/utils/aiPptFeedback.ts

@@ -16,6 +16,8 @@ export function imagePreparationMessage(page: AiCoursewarePage) {
 }
 
 export function teacherReviewMessage(message: string) {
+  const readable = message.replace(/模型页面设计缺失或格式不完整[^;]*(?=;|$)/g, '页面设计尚未完成,可重新设计页面或打开草稿编辑')
+    .replace(/教学图示格式无效[^;]*(?=;|$)/g, '教学图示尚未完成,可打开草稿调整或重新设计本页')
   const names: Record<string, string> = {
     'studentContent.steps': '学生操作步骤', 'studentContent.points': '学生画面中的示例',
     'studentContent.example': '学生可见范例', 'studentContent': '学生画面内容', 'comparisonGroups': '对比组',
@@ -28,7 +30,7 @@ export function teacherReviewMessage(message: string) {
     'headline': '引导问题', 'studentTask': '学生任务', 'objectiveIds': '教学目标关联',
     'points': '正文要点', 'steps': '操作步骤'
   }
-  return Object.entries(names).reduce((text, [key, value]) => text.split(key).join(value), message)
+  return Object.entries(names).reduce((text, [key, value]) => text.split(key).join(value), readable)
 }
 
 /** 兼容旧任务:仅明确的密度/表达建议可以继续打开;仍要经过实际渲染与资源校验。 */

+ 54 - 7
src/utils/aiPptTemplateEngine.ts

@@ -18,7 +18,7 @@ import type {
   SlideTheme
 } from "@/types/slides"
 import type { AiCoursewareOutline, AiCoursewarePage } from "@/api/aiCourseware"
-import { audioBindings, scoreBinding, isScorePage, usableResourceUrl, isTeacherFacingText, teacherFacingTexts, modelPageContent, rhythmRows } from "@/utils/aiPptContent"
+import { audioBindings, scoreBinding, isScorePage, usableResourceUrl, isTeacherFacingText, teacherFacingTexts, modelPageContent, rhythmRows, studentTexts } from "@/utils/aiPptContent"
 import { compilePageDesign, hasModelPageDesign, externalVisual } from "@/utils/aiPptPageDesign"
 import { teachingActionIcon } from '@/config/aiPptTeachingIcons'
 import { composeAiPptTypography, type TypographyDesign } from '@/utils/aiPptTypography'
@@ -45,6 +45,8 @@ import {
 } from "@/config/aiCoursewareTemplateMasters"
 
 interface AiPptOptions {
+  recoverDraft?: boolean
+  fallbackPageNos?: number[]
   backgroundImage?: string
   enableAnimations?: boolean
   paletteVariant?: number
@@ -81,14 +83,23 @@ const GENERIC_PAGE_TITLES = new Set(["封面", "课程导入", "故事导入", "
 export function buildAiPptSlides(
   outline: AiCoursewareOutline,
   options: AiPptOptions = {}
-): { title: string; theme: Partial<SlideTheme>; slides: Slide[] } {
+): { title: string; theme: Partial<SlideTheme>; slides: Slide[]; recoveredPages: Array<{ pageNo: number; error: string }> } {
   const { normalized, palette, resolvedOptions } = prepareRender(outline, options)
   const layoutOccurrences = new Map<string, number>()
+  const recoveredPages: Array<{ pageNo: number; error: string }> = []
   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)) }
+    try {
+      if (options.recoverDraft && options.fallbackPageNos?.includes(page.pageNo)) throw new Error('实际渲染存在排版问题')
+      return buildSlide(page, normalized, palette, resolvedOptions, index, layoutOccurrences)
+    }
+    catch (error) {
+      const message = pageRenderError(page, index, error)
+      if (!options.recoverDraft) throw new Error(message)
+      recoveredPages.push({ pageNo: page.pageNo, error: message })
+      return buildEditableDraftSlide(page, palette, resolvedOptions, message)
+    }
   })
-  runPreflight(slides)
+  if (!options.recoverDraft) runPreflight(slides)
   return {
     title: normalized.title || normalized.chapterName || "音乐课件",
     theme: {
@@ -98,7 +109,8 @@ export function buildAiPptSlides(
       fontName: FONT_NAME,
       fontSize: "36px"
     },
-    slides
+    slides,
+    recoveredPages
   }
 }
 
@@ -111,10 +123,45 @@ export function buildAiPptSlidePreviews(outline: AiCoursewareOutline, options: A
   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) } }
+    catch (error) {
+      const message = pageRenderError(page, index, error)
+      return { pageNo, error: message, slide: options.recoverDraft ? buildEditableDraftSlide(page, palette, resolvedOptions, message) : undefined }
+    }
   })
 }
 
+/** 草稿预览和编辑恢复失败页;完整文字仍为原生可编辑元素,不能据此宣称生成已通过。 */
+function buildEditableDraftSlide(page: AiCoursewarePage, palette: Palette, options: AiPptOptions, reason: string): Slide {
+  const master = getAiCoursewareTemplateMaster(options.templateId!), elements: PPTElement[] = []
+  elements.push(textElement({ left: 80, top: 60, width: 1440, height: Math.max(120, measureAiPptText(page.title, 1440, 48)),
+    content: page.title, fontSize: 48, minimumFontSize: 48, color: palette.primary, bold: true }))
+  const diagramValid = !!page.diagram && validTeachingDiagram(page.diagram)
+  const visual = hasImage(page) || !!scoreBinding(page) || !!videoBinding(page) || diagramValid
+  const media = audioBindings(page).length > 0
+  const bottom = media ? 730 : 830, top = Math.max(220, elements[0].top + (elements[0] as PPTTextElement).height + 24)
+  const values = uniqueVisibleTexts(studentTexts(page)).filter(value => !sameVisibleText(value, page.title))
+  const bodyWidth = visual ? 780 : 1440, font = values.length > 10 ? 28 : 32
+  const total = values.reduce((sum, value) => sum + measureAiPptText(value, bodyWidth, font) + 16, 0)
+  const columns = !visual && total > bottom - top ? 2 : 1
+  const width = (bodyWidth - (columns - 1) * 40) / columns
+  let y = top, column = 0
+  values.forEach(value => {
+    const height = Math.ceil(measureAiPptText(value, width, font)) + 4
+    if (y > top && y + height > bottom && column < columns - 1) { column++; y = top }
+    elements.push(textElement({ left: 80 + column * (width + 40), top: y, width, height,
+      content: value, fontSize: font, minimumFontSize: font, color: palette.text }))
+    y += height + 12
+  })
+  if (visual) {
+    const slot = { id: 'draft-visual', kind: 'image', left: 900, top, width: 620, height: Math.max(100, bottom - top) } as TemplateSlot
+    if (diagramValid) addTeachingDiagram(elements, slot, page.diagram!, master, palette)
+    else addVisualOrResource(elements, slot, { ...page, resourceBindings: page.resourceBindings?.map(b => ({ ...b, dedicatedPage: b.resourceType === 'MUSIC' })) }, master, palette)
+  }
+  if (media) addInlineMedia(elements, { id: 'draft-media', kind: 'media', left: 80, top: 750, width: 1440, height: 80 } as TemplateSlot, page, master, palette, true)
+  return { id: nanoid(10), elements: scaleElementsToViewport(elements), background: { type: 'solid', color: palette.background },
+    remark: `${slideRemark(page)}\n草稿排版待完善:${reason}\n原始学生文字:\n${studentTexts(page).join('\n')}`, turningMode: 'no' }
+}
+
 function pageRenderError(page: AiCoursewarePage, index: number, error: unknown) {
   return `第${page.pageNo || index + 1}页“${page.title}”:${error instanceof Error ? error.message : '排版未完成'}`
 }

+ 18 - 4
src/views/Editor/EditorHeader/AiCoursewareDialog.vue

@@ -849,7 +849,7 @@ async function enterEditor(result: AiCoursewareGenerateResult, active: () => boo
   renderDiagnostics.value = []
   let converted: ReturnType<typeof aiCoursewareToSlides>
   try {
-    converted = aiCoursewareToSlides(result.outline, { templateId: templateId.value, enableAnimations: preferences.enableAnimations })
+    converted = aiCoursewareToSlides(result.outline, { templateId: templateId.value, enableAnimations: preferences.enableAnimations, recoverDraft: allowIncompleteDraft })
   } catch (error) {
     if (error instanceof Error && /投屏安全区|保留区|模型页面设计/.test(error.message)) {
       layoutRepairFeedback.value = error.message
@@ -862,21 +862,35 @@ async function enterEditor(result: AiCoursewareGenerateResult, active: () => boo
     }
     throw error
   }
-  const report = inspectAiPptQuality(result.outline, converted.slides, {
+  let report = inspectAiPptQuality(result.outline, converted.slides, {
       ...preferences,
       minPages: result.outline.pages.length,
       maxPages: result.outline.pages.length
     })
   const { inspectAiPptRenderedText } = await import('@/utils/aiPptRenderedQuality')
-  const renderedIssues = await inspectAiPptRenderedText(converted.slides)
+  let renderedIssues = await inspectAiPptRenderedText(converted.slides)
   if (!active()) return
+  const draftDiagnostics: AiPptQualityIssue[] = converted.recoveredPages.map(page => ({ code: 'draft_layout_recovered', severity: 'error', pageNo: page.pageNo, message: page.error }))
+  if (allowIncompleteDraft) {
+    const originalLayoutIssues = [...blockingAiPptLayoutIssues(report), ...renderedIssues]
+    draftDiagnostics.push(...originalLayoutIssues)
+    const failed = [...new Set(originalLayoutIssues.flatMap(issue => issue.pageNo ? [issue.pageNo] : []))]
+    if (failed.length) {
+      converted = aiCoursewareToSlides(result.outline, { templateId: templateId.value, recoverDraft: true, fallbackPageNos: failed })
+      draftDiagnostics.push(...converted.recoveredPages.map(page => ({ code: 'draft_layout_recovered', severity: 'error' as const, pageNo: page.pageNo, message: page.error })))
+      report = inspectAiPptQuality(result.outline, converted.slides, { ...preferences, minPages: result.outline.pages.length, maxPages: result.outline.pages.length })
+      renderedIssues = await inspectAiPptRenderedText(converted.slides)
+      if (!active()) return
+    }
+  }
   const layoutIssues = [...blockingAiPptLayoutIssues(report), ...renderedIssues]
   layoutRepairFeedback.value = layoutIssues.map(issue => `第${issue.pageNo}页:${issue.message}`).join(';')
   renderDiagnostics.value = layoutIssues.filter(issue => !!issue.pageNo).map(issue => ({ pageNo: issue.pageNo!, code: issue.code, message: issue.message.slice(0, 2000) }))
   if (layoutIssues.length && await automaticallyRepairDesign(result, active, allowIncompleteDraft)) return
-  if (layoutIssues.length) throw new Error(`第${[...new Set(layoutIssues.map(issue => issue.pageNo))].join('、')}页文字过密,已保留当前课件。${modelLayoutRepair.value ? '自动调整后仍未通过,请重新设计页面或查看草稿。' : '请精简并重新排版。'}`)
+  if (layoutIssues.length && !allowIncompleteDraft) throw new Error(`第${[...new Set(layoutIssues.map(issue => issue.pageNo))].join('、')}页文字过密,已保留当前课件。${modelLayoutRepair.value ? '自动调整后仍未通过,请重新设计页面或查看草稿。' : '请精简并重新排版。'}`)
   layoutRepairSource.value = undefined
   const issues = [
+    ...draftDiagnostics,
     ...report.errors,
     ...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))

+ 5 - 3
src/views/Editor/EditorHeader/AiCoursewareDraftPreview.vue

@@ -17,11 +17,12 @@
       <button type="button" :disabled="selected >= outline.pages.length - 1" aria-label="下一页" @click="selected++">下一页</button>
     </div>
     <p v-if="currentPage && pageNeedsImage(currentPage)" class="image-notice">{{ imagePreparationMessage(currentPage) }}</p>
+    <p v-if="currentError" class="image-notice" role="status">本页原版式未通过检查,已显示可编辑的基础排版。打开草稿后可调整,问题仍会保留。</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">第 {{ selected + 1 }} 页排版超出页面容量。以下保留完整文字,其他页面可继续预览;请点击“精简并重新排版”。</p>
+      <p v-else role="status">本页暂时无法显示,以下保留完整文字与教学备注。</p>
     </div>
-    <details v-if="currentPage" :key="currentPage.pageNo" class="page-content" :open="!currentSlide">
+    <details v-if="currentPage" :key="currentPage.pageNo" class="page-content" :open="!!currentError || !currentSlide">
       <summary>本页文字与教学备注</summary>
       <p v-if="currentPage.studentContent?.headline">{{ currentPage.studentContent.headline }}</p>
       <p v-if="currentPage.classroomAction?.studentTask">{{ currentPage.classroomAction.studentTask }}</p>
@@ -55,10 +56,11 @@ 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(() => buildAiPptSlidePreviews(props.outline, { templateId: props.templateId, enableAnimations: false }))
+const preview = computed(() => buildAiPptSlidePreviews(props.outline, { templateId: props.templateId, enableAnimations: false, recoverDraft: true }))
 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[selected.value]?.slide)
+const currentError = computed(() => preview.value[selected.value]?.error)
 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)
   : [])))

Alguns arquivos não foram mostrados porque muitos arquivos mudaram nesse diff