yonge před 1 dnem
rodič
revize
03fe336ebe

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
dist/assets/aiPptRenderedQuality-CECCQ7It.js


+ 1 - 1
dist/assets/index-XNZ3x88t.js → dist/assets/index-BfIIFDEk.js

@@ -1 +1 @@
-import{u as c,ag as l}from"./index-DOGzR9zm.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-Dr9yIelq.js";import"./shapes-DFq5iAv_.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-CaSBib0C.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-RiSr9OZv.js";import"./shapes-DT_pEMTC.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};

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
dist/assets/index-C57VK9so.css


+ 1 - 1
dist/assets/index-XfjIkvhN.js → dist/assets/index-C_svNBzM.js

@@ -1 +1 @@
-import{_ as t}from"./404-R0y5dkvZ.js";import{E as c}from"./index-CmIQ720b.js";import{a5 as n,ae as i,af as r,ak as o,aq as _,am as d,P as l,aS as p,bP as m,bQ as f,bH as u}from"./index-Dr9yIelq.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-CpK-2Fiq.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,bQ as m,bR as f,bH as u}from"./index-RiSr9OZv.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};

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 1 - 1
dist/assets/index-CaSBib0C.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
dist/assets/index-CpK-2Fiq.js


+ 1 - 1
dist/assets/index-DiOeXnXo.js → dist/assets/index-CzgQ-iMI.js

@@ -1 +1 @@
-import{u as s,ag as r}from"./index-DOGzR9zm.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-Dr9yIelq.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-CaSBib0C.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-RiSr9OZv.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};

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 1 - 0
dist/assets/index-D69u9cwK.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 1 - 1
dist/assets/index-RiSr9OZv.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
dist/assets/index-avtaqPKf.js


+ 1 - 1
dist/assets/index-DQMEoHCs.js → dist/assets/index-bX-RTEeC.js

@@ -1 +1 @@
-import{_ as a}from"./404-R0y5dkvZ.js";import{a5 as o,eo as t,ae as r,af as c,bP as _,bQ as d,ak as s,bH as p}from"./index-Dr9yIelq.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,bQ as _,bR as d,ak as s,bH as p}from"./index-RiSr9OZv.js";const i=e=>(_("data-v-dda25179"),e=e(),d(),e),n={class:"errorPage"},m=i(()=>s("div",{class:"error"},[s("img",{src:a,class:"img",alt:""}),s("div",{class:"tit"},"页面找不到了~")],-1)),l=[m],f=o({__name:"errorPage",setup(e){return t(),(u,h)=>(r(),c("div",n,l))}}),I=p(f,[["__scopeId","data-v-dda25179"]]);export{I as default};

+ 1 - 1
dist/assets/shapes-DFq5iAv_.js → dist/assets/shapes-DT_pEMTC.js

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

+ 7 - 2
scripts/aiPptDialogTest.html

@@ -26,6 +26,7 @@ import "/src/assets/styles/prosemirror.scss"
 const scenario = new URLSearchParams(location.search).get("scenario") || "normal"
 setToken(`local-ai-ppt-regression-${scenario}`)
 const generated = ref(0)
+let requestedTopic
 const reportedIssues = ref([])
 const outlineCalls = ref(0), savedCalls = ref(0), submitted = ref(null)
 const savedText = ref("")
@@ -63,9 +64,12 @@ if (scenario === "failed-resume") {
 }
 httpAxios.axioseRquest = async config => {
   let data
-  if (config.url.endsWith('/outline/task')) { outlineCalls.value++; data = { taskId: `local-outline-${outlineCalls.value}`, status: 'PENDING', progress: 0, message: '已提交大纲任务' } }
+  if (config.url.endsWith('/outline/task')) { outlineCalls.value++; requestedTopic = config.data.preferences?.lessonTopic; data = { taskId: `local-outline-${outlineCalls.value}`, status: 'PENDING', progress: 0, message: '已提交大纲任务' } }
   else if (config.url.includes('/task/local-outline-')) {
-    if (config.url.endsWith('/result')) data = { outline: structuredClone(outline), context: {} }
+    if (config.url.endsWith('/result')) {
+      data = { outline: structuredClone(outline), context: {} }
+      if (scenario === 'custom-topic' && requestedTopic) Object.assign(data.outline, requestedTopic)
+    }
     else {
       outlinePolls++
       if (scenario === 'outline-network' && outlinePolls === 1) throw new Error('测试:读取大纲进度时网络中断')
@@ -209,6 +213,7 @@ const app=createApp({setup(){
     runtimeError.value ? h("pre", runtimeError.value) : null,
     savedCalls.value ? h("p",{id:"saved-text"},`保存内容:${savedText.value}`):null,
     h("p",{id:"test-status"},`大纲请求:${outlineCalls.value};生成请求次数:${generated.value};完善请求:${repairs.value};保存请求:${savedCalls.value};历史失败任务查询:${historicalPolls.value};已应用页数:${slidesStore.slides.length};资源检查:${resourceCheck.value}`),
+    h('p', { id: 'topic-status' }, JSON.stringify(submitted.value?.preferences?.lessonTopic || null)),
     h('p', {id:'review-status'}, `复核意见:${reportedIssues.value.filter(issue => issue.code === 'teaching-review').map(issue => issue.severity).join('、')}`),
     submitted.value ? h("p",{id:"submitted-plan"},`模式:${submitted.value.outlineMode};提交的摘要:${submitted.value.outline.pages.map(p=>p.teachingBrief).join("、")};旧正文数量:${submitted.value.outline.pages.filter(p=>p.studentContent?.points?.length || p.bullets?.length).length};页数:${submitted.value.outline.pages.length}`):null,
     scenario.startsWith("editor") ? h("div",{style:"height:calc(100vh - 65px)"},[h(useScreenStore().screening ? Screen : Editor)]) : h("button",{onClick:()=>{visible.value=true}},"打开AI课件"),

+ 6 - 0
scripts/aiPptExportSelfTest.mjs

@@ -3,6 +3,7 @@ import { readFileSync, writeFileSync, mkdirSync } from "node:fs"
 import vm from "node:vm"
 import { webcrypto } from "node:crypto"
 import { createServer } from "vite"
+import { editorialFixture } from './fixtures/aiPptEditorial.mjs'
 
 const vite = await createServer({ server: { middlewareMode: true }, appType: "custom", logLevel: "error" })
 try {
@@ -42,6 +43,11 @@ try {
   assert.equal(bytes.subarray(0, 2).toString(), "PK")
   mkdirSync("artifacts/ai-ppt-tests", { recursive: true })
   writeFileSync("artifacts/ai-ppt-tests/offline-media.pptx", bytes)
+  const { aiCoursewareToSlides } = await vite.ssrLoadModule('/src/utils/aiCoursewareToSlides.ts')
+  const editorial = aiCoursewareToSlides(editorialFixture())
+  const editorialPpt = await context.renderPptForTest({ width: 1920, height: 1080, slides: editorial.slides,
+    theme: { ...editorial.theme, viewportRatio: 0.5625 } }, { viewportRatio: 0.5625 })
+  writeFileSync('artifacts/ai-ppt-tests/editorial-layout.pptx', Buffer.from(await editorialPpt.write({ outputType: 'arraybuffer' })))
   await assert.rejects(() => prepareMediaForExport([{ id: "score", elements: [{ ...base, id: "score", type: "elf", subtype: "elf-sing-play", sid: "1" }] }]), /静态|谱图/)
   console.log(JSON.stringify({ passed: true, bytes: bytes.length, output: "artifacts/ai-ppt-tests/offline-media.pptx", checks: ["native-audio", "legacy-audio", "offline-embedded-assets", "reject-online-only-score"] }))
 } finally { await vite.close() }

+ 11 - 0
scripts/aiPptSelfTest.mjs

@@ -11,12 +11,23 @@ import { contentGateFixture } from './fixtures/aiPptContentGate.mjs'
 import { liveRegressionFixture } from './fixtures/aiPptLiveRegression.mjs'
 import { storyVisualFixture } from './fixtures/aiPptStoryVisual.mjs'
 import { nativeVisualFixture } from './fixtures/aiPptNativeVisual.mjs'
+import { editorialFixture } from './fixtures/aiPptEditorial.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 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)), [])
+  const stepText = editorialSlides[3].elements.filter(element => element.type === 'text')
+  const stepNumbers = stepText.filter(element => /^[1-4]$/.test(element.content.replace(/<[^>]*>/g, '')))
+  assert.equal(stepNumbers.length, 4)
+  assert(stepNumbers.every((element, i) => !i || element.left === stepNumbers[0].left && element.top > stepNumbers[i - 1].top), '长步骤使用纵向阅读顺序')
+  for (const step of editorial.pages[3].studentContent.steps) assert(stepText.some(element => element.content.replace(/<[^>]*>/g, '').includes(step)), '步骤原文完整保留')
+  assert(editorialSlides[1].elements.filter(element => element.type === 'text' && element.content.includes('<strong>')).length === 3, '明确标注的要点形成主次层级')
+  assert(editorialSlides[0].elements.find(element => element.type === 'text' && element.content.includes(editorial.title)).content.includes('text-align:center'), '文字封面以课题为中心')
   const gateFixture = contentGateFixture(), gateSlides = aiCoursewareToSlides(gateFixture).slides
   const layoutCodes = ['out_of_bounds', 'text_overlap', 'text_overflow', 'rendered_text_geometry', 'missing_content', 'small_font']
   const live = liveRegressionFixture(), liveSlides = aiCoursewareToSlides(live).slides

+ 33 - 1
scripts/aiPptVisualTest.html

@@ -29,6 +29,8 @@
       import { semanticDesignFixture, densityRegressionFixture, heroImageRegressionFixture } from "./fixtures/aiPptSemanticDesign.mjs"
       import { modelDesignFixture } from "./fixtures/aiPptModelDesign.mjs"
       import { inquiryFixture } from './fixtures/aiPptInquiry.mjs'
+      import { editorialFixture } from './fixtures/aiPptEditorial.mjs'
+      import { inspectAiPptRenderedText } from '/src/utils/aiPptRenderedQuality.ts'
 
       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 })
@@ -49,7 +51,7 @@
       }
       const scenario = new URLSearchParams(location.search).get("scenario")
       const fixtureImage = '/ai-courseware-templates/extracted/bird-garden/background-01.jpg'
-      const fixture = scenario === 'live' ? liveRegressionFixture() : scenario === 'content-gate' ? contentGateFixture() : scenario === 'repair' ? lessonRepairFixture(fixtureImage) : scenario === 'inquiry' ? inquiryFixture() : scenario === 'hero-images' ? heroImageRegressionFixture(fixtureImage) : scenario === 'density' ? densityRegressionFixture(fixtureImage) : scenario === 'semantic' ? semanticDesignFixture(fixtureImage) : scenario === 'model-design' ? modelDesignFixture(fixtureImage)
+      const fixture = scenario === 'editorial' ? editorialFixture() : scenario === 'live' ? liveRegressionFixture() : scenario === 'content-gate' ? contentGateFixture() : scenario === 'repair' ? lessonRepairFixture(fixtureImage) : scenario === 'inquiry' ? inquiryFixture() : scenario === 'hero-images' ? heroImageRegressionFixture(fixtureImage) : scenario === 'density' ? densityRegressionFixture(fixtureImage) : scenario === 'semantic' ? semanticDesignFixture(fixtureImage) : scenario === 'model-design' ? modelDesignFixture(fixtureImage)
         : scenario === 'classroom' ? classroomFixture(fixtureImage) : outline
       const result = aiCoursewareToSlides(fixture, { templateId: id })
       const group = ref(0)
@@ -90,6 +92,36 @@
       watch(group, checkRenderedGeometry)
       checkRenderedGeometry()
 
+      if (scenario === 'editorial') {
+        const status = document.createElement('p')
+        status.id = 'rendered-quality-status'
+        status.textContent = '正在运行实际渲染回归'
+        document.querySelector('main').prepend(status)
+        try {
+          const original = JSON.stringify(result.slides)
+          const issues = await inspectAiPptRenderedText(result.slides)
+          const suites = []
+          for (const [name, sample] of [['live', liveRegressionFixture()], ['content-gate', contentGateFixture()],
+            ['repair', lessonRepairFixture(fixtureImage)], ['inquiry', inquiryFixture()],
+            ['model', modelDesignFixture(fixtureImage)], ['density', densityRegressionFixture(fixtureImage)]]) {
+            const slides = aiCoursewareToSlides(sample, { templateId: 'blank' }).slides
+            suites.push({ name, pages: slides.length, issues: await inspectAiPptRenderedText(slides) })
+          }
+          const damaged = JSON.parse(original)
+          const text = damaged[0].elements.find(element => element.type === 'text')
+          Object.assign(text, { left: 100, top: 150, width: 800, height: 60, content: '<div style="font-size:40px"><p>第一段正文</p><p style="font-size:72px">混合字号</p><p>第三段正文</p><p>第四段正文</p></div>' })
+          damaged[0].elements.push({ ...text, id: 'overlap-regression', top: 240, height: 80, content: '<div style="font-size:40px">这里不应被上一段覆盖</div>' })
+          const damagedIssues = await inspectAiPptRenderedText([damaged[0]], [9])
+          const regressions = {
+            multilineDetected: damagedIssues.some(issue => issue.code === 'rendered_text_overflow' && issue.pageNo === 9),
+            overlapDetected: damagedIssues.some(issue => issue.code === 'rendered_text_overlap'),
+            sourceUnchanged: JSON.stringify(result.slides) === original,
+            probeCleanedUp: document.querySelectorAll('[data-ai-ppt-render-probe]').length === 0
+          }
+          status.textContent = JSON.stringify({ passed: issues.length === 0 && suites.every(suite => !suite.issues.length) && Object.values(regressions).every(Boolean), pages: result.slides.length, issues, regressions, suites })
+        } catch (error) { status.textContent = JSON.stringify({ passed: false, error: error.message }) }
+      }
+
       if (new URLSearchParams(location.search).get("export") === "1") {
         const status = document.createElement("p")
         status.id = "export-status"

+ 38 - 0
scripts/fixtures/aiPptEditorial.mjs

@@ -0,0 +1,38 @@
+// 排版回归样本,借鉴公开课的阅读层级;不是教材原文或模型实测结果。
+export function editorialFixture() {
+  const page = (pageNo, type, title, points, composition = 'concept_grid', extra = {}) => ({
+    pageNo, type, title, bullets: [], imageKeywords: [], studentContent: { points },
+    visualPlan: { composition, visualMode: 'text_only', imageNecessity: 'none', imagePurpose: 'none', textPresentation: 'plain' },
+    speakerNotes: '先示范,再请学生尝试,依据本页任务反馈。', ...extra
+  })
+  return {
+    title: '口风琴吹奏与表现', chapterName: '口风琴吹奏与表现', gradeName: '六年级', textbookName: '音乐', unitName: '器乐实践',
+    courseType: 'music', templateId: 'blank', style: 'clean',
+    visualBible: { palette: ['#FFFDF5', '#2C3E6B', '#E67E22'] },
+    pages: [
+      page(1, 'cover', '口风琴吹奏与表现', ['让旋律连贯,让乐句有呼吸'], 'full_scene'),
+      page(2, 'objectives', '今天的学习任务', [
+        '聆听:比较两次示范,指出乐句衔接是否自然。',
+        '实践:跟随示范分句练习,在约定的位置换气。',
+        '评价:给同伴一条有具体依据的改进建议。'
+      ]),
+      page(3, 'compare_listen', '哪一次演奏更连贯?', [], 'comparison', { studentContent: { comparisonGroups: [
+        { label: '第一次', points: ['关注长音是否保持', '留意乐句间的停顿'] },
+        { label: '第二次', points: ['关注长音是否保持', '留意乐句间的停顿'] }
+      ] } }),
+      page(4, 'performance_activity', '分句练习的四个步骤', [], 'steps', {
+        studentContent: { steps: [
+          '先听示范:完整听一遍,记住乐句结束的位置,用手势标出需要换气的地方。',
+          '逐句尝试:按示范的速度练习一个乐句,听清每个音,把长音保持到约定的位置。',
+          '连接乐句:把相邻的两个乐句连起来,保持稳定的速度,检查换气后是否及时进入。',
+          '交流反馈:两人轮流吹奏和聆听,说出一处进步,再选择一个问题重新练习。'
+        ] },
+        visualPlan: { composition: 'steps', visualMode: 'text_only', imageNecessity: 'none', imagePurpose: 'none', textPresentation: 'steps' }
+      }),
+      page(5, 'practice_check', '听完再判断', ['说出一个你听到的依据'], 'focus_question', {
+        studentContent: { headline: '哪一次换气更自然?', points: ['说出一个你听到的依据'] }
+      }),
+      page(6, 'summary', '把反馈带进下一次练习', ['我听到的进步:说出具体的乐句和变化。', '我准备改进:选择一个问题,再练习一次。'])
+    ]
+  }
+}

+ 1 - 0
src/api/aiCourseware.ts

@@ -24,6 +24,7 @@ export interface AiCoursewareGenerateQuery {
 }
 
 export interface AiCoursewarePreferences {
+  lessonTopic?: { gradeName: string; textbookName: string; unitName: string; chapterName: string }
   teachingApproach?: 'auto' | 'inquiry' | 'story' | 'practice'
   resourceMode?: 'teacher_supplement' | 'auto_bind'
   learningContext?: string

+ 10 - 0
src/hooks/useAiCoursewarePageRepair.ts

@@ -84,6 +84,9 @@ export default function useAiCoursewarePageRepair() {
       if (!active()) return
       if (result.code !== 200 || !result.data?.outline?.pages?.[whole ? 0 : pageNo - 1]) throw new Error(result.message || "生成结果不完整")
       const resultOutline = result.data.outline as AiCoursewareOutline
+      if (prefs.lessonTopic && (['gradeName', 'textbookName', 'unitName', 'chapterName'] as const)
+        .some(key => (resultOutline[key] || '').trim() !== prefs.lessonTopic![key].trim()))
+        throw new Error('完善结果的教材或课题与本次要求不符,已保留当前课件。')
       if (whole && (resultOutline.pages.length !== sourceOutline.pages.length || resultOutline.pages.some((page, i) => page.pageNo !== i + 1))) throw new Error('完善后的页数或顺序不符,已保留当前课件。')
       // 忽略后端返回的其他页面,保留本地原始教学安排。
       const nextOutline = clone(whole ? resultOutline : sourceOutline)
@@ -99,6 +102,13 @@ export default function useAiCoursewarePageRepair() {
       const target = converted.slides[pageNo - 1]
       const report = inspectAiPptQuality(nextOutline, converted.slides, { ...prefs, minPages: nextOutline.pages.length, maxPages: nextOutline.pages.length })
       if (blockingAiPptLayoutIssues(report).some(issue => whole || issue.pageNo === pageNo)) throw new Error('生成页的文字仍然过密,已保留当前课件,请再次精简后生成。')
+      const { inspectAiPptRenderedText } = await import('@/utils/aiPptRenderedQuality')
+      const renderedIssues = await inspectAiPptRenderedText(whole ? converted.slides : [target], whole ? undefined : [pageNo])
+      if (!active()) return
+      if (renderedIssues.length) {
+        issues.value = [...issues.value.filter(issue => !issue.code.startsWith('rendered_text_')), ...renderedIssues]
+        throw new Error(`第${[...new Set(renderedIssues.map(issue => issue.pageNo))].join('、')}页实际排版仍有溢出或重叠,已保留当前课件。请精简并重新排版。`)
+      }
       const resourceIssues = await inspectAiPptResources(whole ? nextOutline : { ...nextOutline, pages: [nextOutline.pages[pageNo - 1]] }, whole ? converted.slides : [target])
       if (!active()) return
       const imageIssues = blockingImageIssues([...report.issues.filter(issue => whole || issue.pageNo === pageNo), ...resourceIssues])

+ 2 - 1
src/utils/aiPptQuality.ts

@@ -11,7 +11,8 @@ import { imagePreparationMessage } from '@/utils/aiPptFeedback'
 type BoundedPPTElement = Exclude<PPTElement, { type: "line" }>
 
 export function blockingAiPptLayoutIssues(report: AiPptQualityReport) {
-  return report.errors.filter(issue => ['out_of_bounds', 'text_overlap', 'text_overflow', 'rendered_text_geometry'].includes(issue.code))
+  return report.errors.filter(issue => ['out_of_bounds', 'text_overlap', 'text_overflow', 'rendered_text_geometry',
+    'rendered_text_overflow', 'rendered_text_overlap', 'rendered_text_out_of_bounds'].includes(issue.code))
 }
 
 export type AiPptQualitySeverity = "error" | "warning"

+ 59 - 0
src/utils/aiPptRenderedQuality.ts

@@ -0,0 +1,59 @@
+import { h, nextTick, render } from 'vue'
+import type { PPTTextElement, Slide } from '@/types/slides'
+import type { AiPptQualityIssue } from '@/utils/aiPptQuality'
+import BaseTextElement from '@/views/components/element/TextElement/BaseTextElement.vue'
+
+/** 使用投屏组件验收最终富文本;不改动编辑器、课件数据,也不触发媒体播放。 */
+export async function inspectAiPptRenderedText(slides: Slide[], pageNos = slides.map((_, i) => i + 1)): Promise<AiPptQualityIssue[]> {
+  if (typeof document === 'undefined' || !document.body) throw new Error('当前环境无法完成课件排版检查')
+  const host = document.createElement('div')
+  host.setAttribute('aria-hidden', 'true')
+  host.dataset.aiPptRenderProbe = 'true'
+  Object.assign(host.style, { position: 'fixed', left: '-20000px', top: '0', width: '1920px',
+    visibility: 'hidden', pointerEvents: 'none', contain: 'layout style' })
+  document.body.appendChild(host)
+  let timer: ReturnType<typeof setTimeout> | undefined
+  try {
+    render(h('div', slides.map((slide, index) => h('div', {
+      'data-render-page': index, style: { position: 'relative', width: '1920px', height: '1080px' }
+    }, slide.elements.filter((element): element is PPTTextElement => element.type === 'text')
+      .map(element => h('div', { 'data-render-text': element.id }, [h(BaseTextElement, { elementInfo: element, target: 'thumbnail' })]))))), host)
+    await nextTick()
+    // 必须等实际字体完成加载;超时不能冒充验收通过。
+    if (document.fonts) await Promise.race([document.fonts.ready, new Promise<never>((_, reject) => {
+      timer = setTimeout(() => reject(new Error('课件字体加载未完成,请稍后继续查看')), 8000)
+    })])
+    const issues: AiPptQualityIssue[] = []
+    const add = (code: string, pageNo: number, message: string) => {
+      if (!issues.some(issue => issue.code === code && issue.pageNo === pageNo && issue.message === message))
+        issues.push({ code, pageNo, message, severity: 'error' })
+    }
+    host.querySelectorAll<HTMLElement>('[data-render-page]').forEach((page, index) => {
+      const pageNo = pageNos[index] ?? index + 1
+      const canvas = page.getBoundingClientRect()
+      const bounds: Array<{ left: number; top: number; right: number; bottom: number; label: string }> = []
+      page.querySelectorAll<HTMLElement>('[data-render-text]').forEach(wrapper => {
+        const element = slides[index].elements.find(item => item.id === wrapper.dataset.renderText) as PPTTextElement
+        const content = wrapper.querySelector<HTMLElement>('.element-content')
+        if (!content) throw new Error(`第${pageNo}页文字未能完成渲染,请稍后继续查看`)
+        const rect = content.getBoundingClientRect()
+        const label = (content.textContent || '').trim().slice(0, 24)
+        if (content.offsetHeight > element.height + 2 || content.scrollWidth > content.clientWidth + 2)
+          add('rendered_text_overflow', pageNo, `实际排版超出文本框:“${label}”`)
+        if (rect.left < canvas.left - 2 || rect.top < canvas.top - 2 || rect.right > canvas.right + 2 || rect.bottom > canvas.bottom + 2)
+          add('rendered_text_out_of_bounds', pageNo, `实际文字超出画布:“${label}”`)
+        // 旋转文字的外接矩形会产生误报;AI 正文均为水平文字。
+        if (!element.rotate && label) bounds.push({ left: rect.left, top: rect.top, right: rect.right, bottom: rect.bottom, label })
+      })
+      bounds.forEach((a, i) => bounds.slice(i + 1).forEach(b => {
+        if (Math.min(a.right, b.right) - Math.max(a.left, b.left) > 2 && Math.min(a.bottom, b.bottom) - Math.max(a.top, b.top) > 2)
+          add('rendered_text_overlap', pageNo, `实际文字重叠:“${a.label}”与“${b.label}”`)
+      }))
+    })
+    return issues
+  } finally {
+    clearTimeout(timer)
+    render(null, host)
+    host.remove()
+  }
+}

+ 55 - 19
src/utils/aiPptTemplateEngine.ts

@@ -1,6 +1,6 @@
 import { nanoid } from "nanoid"
 import tinycolor from 'tinycolor2'
-import { measureAiPptText, aiPptBlockHeight, aiPptTextGeometry } from "@/utils/aiPptTextMetrics"
+import { AI_PPT_SCALE, measureAiPptText, aiPptBlockHeight, aiPptTextGeometry } from "@/utils/aiPptTextMetrics"
 import type {
   Gradient,
   PPTElement,
@@ -154,7 +154,9 @@ function buildSlide(page: AiCoursewarePage, outline: AiCoursewareOutline, palett
   assertContentContract(master, masterPage, contentElements)
   const elements = [
     ...renderTemplateMasterElements(masterPage.base),
-    ...(designed ? [lineElement(52, slotOf(masterPage, 'title')!.top + 8, 52,
+    ...(designed && role === 'cover' && !hasImage(page) && !intent.showMedia
+      ? [lineElement(80, 70, 1520, 70, palette.primary, 6), lineElement(80, 842, 1520, 842, palette.primary, 2)]
+      : designed ? [lineElement(52, slotOf(masterPage, 'title')!.top + 8, 52,
       slotOf(masterPage, 'title')!.top + Math.min(slotOf(masterPage, 'title')!.height - 8, 64), palette.secondary, 6)] : []),
     ...contentElements,
     ...renderTemplateMasterElements(masterPage.chrome)
@@ -193,19 +195,28 @@ export function getAiPptImageAspectRatios(templateId: AiCoursewareTemplateId): R
 function fillDesignedPage(master: TemplateMaster, layout: TemplateMasterPage, page: AiCoursewarePage, outline: AiCoursewareOutline, palette: Palette, index: number, headline: string, intent: PageRenderIntent): PPTElement[] {
   const elements: PPTElement[] = []
   const content = modelPageContent(page)
+  const textCover = index === 0 && !hasImage(page) && !intent.showMedia && !content.steps.length && !content.groups.length
+    && !content.task && content.points.length <= 2
   const exampleSlot = layout.slots.find(slot => slot.id === 'example')
   const examples = exampleSlot ? content.points.filter(text => text.startsWith('例子:')) : []
   const points = content.points.filter(text => !examples.includes(text))
   if (exampleSlot) addMeasuredFlow(elements, exampleSlot, examples, master.textColor, false, 32)
-  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)
+  addSlotText(elements, slotOf(layout, 'title'), page.title, master.titleColor, true, textCover ? 'center' : 'left')
+  if (index === 0) addSlotText(elements, slotOf(layout, 'meta'), [outline.gradeName, conciseTextbook(outline.textbookName), outline.unitName].filter(Boolean).join(' · '), master.mutedColor, false, textCover ? 'center' : 'left')
+  addSlotText(elements, slotOf(layout, 'headline'), headline, master.textColor, true, textCover ? 'center' : 'left')
   addSlotText(elements, layout.slots.find(slot => slot.id === 'instruction'), content.task, master.mutedColor)
   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[], format = presentation) => {
     if (!slot || !values.length) return
+    if (textCover) {
+      const coverSlot = slot
+      const gap = 18, height = (coverSlot.height - gap * (values.length - 1)) / values.length
+      values.forEach((value, i) => addSlotText(elements, { ...coverSlot, top: coverSlot.top + i * (height + gap), height }, value, master.textColor, false, 'center'))
+      return
+    }
+    if (addEditorialRows(elements, slot, values, format, master, palette)) return
     if (!hasImage(page) && !page.diagram && !content.groups.length
       && addDesignedPanels(elements, slot, values, format, master, palette)) return
     if (slot?.textFlow === 'columns' && values.length) {
@@ -314,6 +325,39 @@ function fillDesignedPage(master: TemplateMaster, layout: TemplateMasterPage, pa
   return elements
 }
 
+/** 长步骤采用纵向阅读;明确的“要点:说明”保留原文并突出要点,不自动发明小标题。 */
+function addEditorialRows(elements: PPTElement[], slot: TemplateSlot, values: string[], format: string, master: TemplateMaster, palette: Palette) {
+  if (slot.width < 900 || values.length < 2 || values.length > 5 || values.some(value => value.startsWith('例子:'))) return false
+  const labels = values.map(value => value.match(/^(.{2,14}[::])(.+)$/))
+  const numbered = format === 'steps'
+  if (numbered ? values.length <= 4 && values.every(value => value.length <= 24) : format !== 'plain' || !labels.every(Boolean)) return false
+  const gutter = numbered ? 92 : 0, width = slot.width - gutter
+  const rowHeight = (value: string, i: number, font: number) => !numbered && labels[i]
+    ? measureAiPptText(labels[i]![1], width, font + 6, AI_PPT_SCALE, true) + measureAiPptText(labels[i]![2], width, font) - 20 / AI_PPT_SCALE + 8
+    : Math.max(68, measureAiPptText(value, width, font, AI_PPT_SCALE, true))
+  // 先完整测量,容量不足时不留下部分元素,交回原有排版与质量检查。
+  const font = [38, 36, 34, 32, 30, 28].find(font => values.reduce((sum, value, i) =>
+    sum + rowHeight(value, i, font), 0) + 22 * (values.length - 1) <= slot.height)
+  if (!font) return false
+  const heights = values.map((value, i) => rowHeight(value, i, font))
+  const gap = Math.min(48, (slot.height - heights.reduce((sum, height) => sum + height, 0)) / (values.length - 1))
+  const total = heights.reduce((sum, height) => sum + height, 0) + gap * (values.length - 1)
+  let top = slot.top + Math.min(28, (slot.height - total) / 2)
+  values.forEach((value, i) => {
+    if (numbered) addSlotText(elements, { ...slot, top, width: 68, height: heights[i], preferredFontSize: 40 }, String(i + 1), master.titleColor, true)
+    const text = textElement({ left: slot.left + gutter, top, width, height: heights[i], content: value,
+      fontSize: font, minimumFontSize: font, color: master.textColor })
+    if (labels[i]) text.content = numbered
+      ? text.content.replace(escapeHtml(labels[i]![1]), `<strong>${escapeHtml(labels[i]![1])}</strong>`)
+      : text.content.replace(escapeHtml(value), `<p style="margin:0;font-size:${font + 6}px"><strong>${escapeHtml(labels[i]![1])}</strong></p><p style="margin:8px 0 0">${escapeHtml(labels[i]![2])}</p>`)
+    elements.push(text)
+    if (i < values.length - 1) elements.push(lineElement(slot.left + gutter, top + heights[i] + gap / 2,
+      slot.left + slot.width, top + heights[i] + gap / 2, tinycolor.mix(palette.background, palette.primary, 18).toHexString(), 1))
+    top += heights[i] + gap
+  })
+  return true
+}
+
 /** 短任务用有语义的图标和色块形成阅读顺序,密集正文继续采用测量后的文字流。 */
 function addDesignedPanels(elements: PPTElement[], slot: TemplateSlot, values: string[], format: string, master: TemplateMaster, palette: Palette) {
   if (values.length < 2 || values.length > 4 || slot.width < 1100 || slot.height < 240
@@ -483,7 +527,8 @@ function fillTemplatePage(master: TemplateMaster, masterPage: TemplateMasterPage
     const meta = [outline.gradeName, conciseTextbook(outline.textbookName), outline.unitName].filter(Boolean).join(" · ")
     const coverSecondaryColor = master.coverTitleColor.toUpperCase() === "#FFFFFF" ? "#DDE6F4" : master.mutedColor
     addSlotText(elements, slotOf(masterPage, "meta"), meta, coverSecondaryColor)
-    addSlotList(elements, slotOf(masterPage, "headline"), uniqueVisibleTexts([headline, ...points]), master, palette, false, master.coverTitleColor)
+    // 封面的短导语共用一个文本框,避免每个短句的固定内边距挤占行高。
+    addSlotText(elements, slotOf(masterPage, "headline"), uniqueVisibleTexts([headline, ...points]).join(' · '), master.coverTitleColor)
     addVisualOrResource(elements, slotOf(masterPage, "image"), page, master, palette, !masterPage.backgroundVisual)
     return elements
   }
@@ -947,18 +992,10 @@ function addChecklist(elements: PPTElement[], slot: TemplateSlot | undefined, so
         radius: iconSize / 2
       })
     )
+    // 勾号使用原生线条,微小图标不经过带 10px 内边距的富文本组件。
     elements.push(
-      textElement({
-        left: slot.left + iconSize * 0.2,
-        top: iconTop + iconSize * 0.12,
-        width: iconSize * 0.6,
-        height: iconSize * 0.7,
-        content: "✓",
-        fontSize: Math.min(24, Math.max(10, iconSize * 0.55)),
-        minimumFontSize: 10,
-        color: "#FFFFFF",
-        bold: true
-      })
+      lineElement(slot.left + iconSize * .25, iconTop + iconSize * .5, slot.left + iconSize * .43, iconTop + iconSize * .68, '#FFFFFF', Math.max(1.5, iconSize * .07)),
+      lineElement(slot.left + iconSize * .43, iconTop + iconSize * .68, slot.left + iconSize * .76, iconTop + iconSize * .32, '#FFFFFF', Math.max(1.5, iconSize * .07))
     )
     elements.push(
       textElement({
@@ -1191,8 +1228,7 @@ function fitFont(content: string, width: number, height: number, preferred: numb
   if (!text) return preferred
   let size = preferred
   while (size > minimum) {
-    const estimated = minimum >= 28 ? measureAiPptText(text, width, size)
-      : Math.ceil(text.length / Math.max(1, Math.floor(width / size))) * size * 1.35
+    const estimated = measureAiPptText(text, width, size)
     if (estimated <= height) break
     size = Math.max(minimum, size - 2)
   }

+ 17 - 6
src/utils/aiPptTextMetrics.ts

@@ -32,12 +32,23 @@ export function measureAiPptText(text: string, width: number, fontSize: number,
 export function aiPptTextHeight(content: string, width: number, lineHeight = LINE_HEIGHT) {
   const size = Number(content.match(/font-size:\s*([\d.]+)px/)?.[1] || 0)
   if (!size) return 0
-  let text: string
-  if (typeof document !== 'undefined') {
-    const template = document.createElement('template')
-    template.innerHTML = content
-    text = template.content.textContent || ''
-  } else text = content.replace(/<[^>]*>/g, '').replace(/&(?:amp|lt|gt|quot|#39);/g, '字')
+  if (typeof document !== 'undefined' && document.body) {
+    const outer = document.createElement('div')
+    outer.setAttribute('aria-hidden', 'true')
+    Object.assign(outer.style, { position: 'fixed', left: '-20000px', top: '0', visibility: 'hidden', pointerEvents: 'none',
+      width: `${width}px`, padding: '10px', margin: '0', boxSizing: 'border-box', fontFamily: 'Microsoft Yahei',
+      lineHeight: String(lineHeight), wordBreak: 'break-word', letterSpacing: '0px' })
+    const inner = document.createElement('div')
+    inner.className = 'ProseMirror-static'
+    inner.style.setProperty('--paragraphSpace', '0px')
+    // 保留段落、换行、粗体和混合字号,不能把最终 HTML 压成一行再估算。
+    inner.innerHTML = content
+    outer.appendChild(inner)
+    document.body.appendChild(outer)
+    try { return Math.ceil(outer.getBoundingClientRect().height + 1) }
+    finally { outer.remove() }
+  }
+  const text = content.replace(/<[^>]*>/g, '').replace(/&(?:amp|lt|gt|quot|#39);/g, '字')
   return measureAiPptText(text, width, size, 1, /font-weight:\s*(700|bold)/.test(content), lineHeight)
 }
 

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

@@ -22,6 +22,21 @@
       <main>
         <section v-if="step === 1" class="setup">
           <h3>这节课准备怎么上?</h3>
+          <label>课题来源<select :value="preferences.lessonTopic ? 'custom' : 'current'" :disabled="outlineLoading" @change="changeTopicSource">
+            <option value="current">当前教材章节</option>
+            <option value="custom">本次生成其他课题</option>
+          </select></label>
+          <template v-if="preferences.lessonTopic">
+            <div class="fields">
+              <label>年级<input v-model="preferences.lessonTopic.gradeName" maxlength="50" :disabled="outlineLoading" placeholder="例如:六年级" /></label>
+              <label>教材版本与册次<input v-model="preferences.lessonTopic.textbookName" maxlength="150" :disabled="outlineLoading" placeholder="例如:花城版音乐六年级下册" /></label>
+            </div>
+            <div class="fields">
+              <label>单元<input v-model="preferences.lessonTopic.unitName" maxlength="150" :disabled="outlineLoading" placeholder="例如:第六单元" /></label>
+              <label>课题<input v-model="preferences.lessonTopic.chapterName" maxlength="150" :disabled="outlineLoading" placeholder="例如:学键盘(十)" /></label>
+            </div>
+            <p class="hint">按本次课题重新核实教材依据;课件所属章节不变。具体乐器和公开课要求可写在下方。</p>
+          </template>
           <label>教学思路<select v-model="preferences.teachingApproach" :disabled="outlineLoading">
             <option value="auto">根据教材设计</option>
             <option value="inquiry">音乐要素探究 · 带问题发现与验证</option>
@@ -78,7 +93,6 @@
           <p class="hint">先完成教学内容与页面设计。音视频、谱例可在生成后添加,所需资源会写入教师备注。</p>
           <p v-if="settingsChanged" class="notice">需求已调整,请重新生成大纲后确认;当前大纲会保留到新大纲生成成功。</p>
           <p v-if="outlineLoading" role="status" class="notice">{{ outlineTask?.message || '正在提交大纲任务,请稍候…' }}。可以收起窗口,稍后继续查看。</p>
-          <p v-if="setupError" role="alert" class="error">{{ setupError }}</p>
         </section>
         <section v-else-if="step === 2 && outline" class="outline-stage">
           <div class="section-heading">
@@ -200,6 +214,7 @@
           >
         </section>
       </main>
+      <p v-if="step === 1 && setupError" role="alert" class="error">{{ setupError }}</p>
       <footer>
         <Button :disabled="transferring" @click="close">{{ task || outlineLoading ? "收起窗口" : "关闭" }}</Button>
         <div v-if="step === 1">
@@ -213,6 +228,7 @@
           ><Button type="primary" :disabled="!reliableResearch || transferring" @click="confirmAndGenerate">确认大纲并生成</Button>
         </div>
         <div v-else-if="generationError">
+          <Button v-if="!transferring" @click="backToSettings">修改需求</Button>
           <Button v-if="completionSource" :aria-expanded="showCompletionContent" @click="showCompletionContent = !showCompletionContent">{{ showCompletionContent ? "收起已生成内容" : "查看已生成内容" }}</Button>
           <Button v-if="!task" @click="step = 2">返回修改大纲</Button
           ><Button type="primary" :disabled="transferring" @click="retry">{{ completionSource ? "继续自动完善" : layoutRepairSource ? "精简并重新排版" : task ? "继续查看课件" : "重试生成" }}</Button>
@@ -269,6 +285,7 @@ const slidesStore = useSlidesStore(),
   snapshots = useSnapshotStore()
 const clone = <T,>(value: T): T => JSON.parse(JSON.stringify(value))
 const defaults = (): AiCoursewarePreferences => ({
+  lessonTopic: undefined,
   teachingApproach: 'auto',
   resourceMode: 'teacher_supplement',
   lessonMinutes: 40,
@@ -302,6 +319,7 @@ const outlineLoading = ref(false),
 const task = ref<AiCoursewareTaskInfo>(),
   confirmed = ref<AiCoursewareOutline>()
 const layoutRepairSource = ref<AiCoursewareOutline>()
+const layoutRepairFeedback = ref('')
 const completionSource = ref<AiCoursewareOutline>()
 const showCompletionContent = ref(false)
 watch(completionSource, () => { showCompletionContent.value = false })
@@ -317,6 +335,9 @@ const sources = computed(() => (outline.value?.textbookResearch?.sources || []).
 const reliableResearch = computed(
   () => outline.value?.textbookResearch?.exactMatch && outline.value.textbookResearch.confidence === "HIGH" && sources.value.length > 0
 )
+const matchesRequestedTopic = (value: AiCoursewareOutline) => !preferences.lessonTopic ||
+  (['gradeName', 'textbookName', 'unitName', 'chapterName'] as const)
+    .every(key => (value[key] || '').trim() === preferences.lessonTopic![key].trim())
 const requirementsSummary = computed(() => [preferences.lessonScope, preferences.learningContext, extraPrompt.value].filter(Boolean).join(";"))
 const editorSnapshot = () =>
   JSON.stringify([slidesStore.title, slidesStore.slides, slidesStore.theme, slidesStore.viewportSize, slidesStore.viewportRatio])
@@ -414,9 +435,16 @@ function selectTemplate(id: string) {
   remember()
 }
 function backToSettings() {
+  stopPolling()
+  version++
+  task.value = undefined
   step.value = 1
   remember()
 }
+function changeTopicSource(event: Event) {
+  preferences.lessonTopic = (event.target as HTMLSelectElement).value === 'custom'
+    ? { gradeName: '', textbookName: '', unitName: '', chapterName: '' } : undefined
+}
 async function restore() {
   if (!pptWork.id) return
   const requestVersion = version
@@ -475,6 +503,10 @@ async function ask(message: string, confirmButtonText: string) {
 }
 async function generateOutline() {
   if (!pptWork.id || outlineLoading.value) return
+  const topic = preferences.lessonTopic
+  if (topic && (!topic.gradeName.trim() || !topic.textbookName.trim() || !topic.chapterName.trim())) {
+    setupError.value = '请填写年级、教材版本与册次、课题。'; return
+  }
   if (outlineTask.value) { await pollOutlineTask(); return }
   if (outline.value && !(await ask("重新生成会替换当前大纲,包括你已修改的内容。是否继续?", "重新生成"))) return
   const requestVersion = ++version
@@ -532,9 +564,21 @@ async function pollOutlineTask() {
       const result = await httpAjax(aiCoursewareResultApi, taskId)
       if (!active()) return
       if (result.code !== 200 || !result.data?.outline?.pages?.length) throw new Error(result.message || '大纲读取失败,请继续查看')
+      if (!matchesRequestedTopic(result.data.outline)) {
+        outlineTask.value = undefined
+        outlineLoading.value = false
+        setupError.value = '生成服务返回的教材或课题与本次要求不符,原课件已保留。请确认服务端已更新后重新生成。'
+        remember()
+        return
+      }
       loadOutline(result.data.outline)
       outlineSettings.value = outlineTaskSettings.value
       confirmed.value = undefined
+      task.value = undefined
+      completionSource.value = undefined
+      layoutRepairSource.value = undefined
+      layoutRepairFeedback.value = ''
+      generationError.value = ''
       outlineTask.value = undefined
       outlineLoading.value = false
       step.value = 2
@@ -645,7 +689,7 @@ async function repairBlockedLayout() {
   const requestVersion = ++version
   stopPolling()
   transferring.value = true
-  const reason = generationError.value
+  const reason = layoutRepairFeedback.value || generationError.value
   generationError.value = ''
   try {
     await assertAiPptClientCurrent()
@@ -741,6 +785,7 @@ async function pollTask() {
 }
 async function enterEditor(result: AiCoursewareGenerateResult, active: () => boolean) {
   if (!result?.outline?.pages?.length) throw new Error("尚未获得课件内容,请继续查看")
+  if (!matchesRequestedTopic(result.outline)) throw new Error('生成结果与本次教材或课题不符,原课件已保留,请重新生成大纲。')
   await assertAiPptClientCurrent()
   if (!active()) return
   if (task.value?.status === 'DRAFT') {
@@ -756,6 +801,7 @@ async function enterEditor(result: AiCoursewareGenerateResult, active: () => boo
   }
   completionSource.value = undefined
   layoutRepairSource.value = clone(result.outline)
+  layoutRepairFeedback.value = ''
   let converted: ReturnType<typeof aiCoursewareToSlides>
   try {
     converted = aiCoursewareToSlides(result.outline, { templateId: templateId.value, enableAnimations: preferences.enableAnimations })
@@ -768,7 +814,11 @@ async function enterEditor(result: AiCoursewareGenerateResult, active: () => boo
       minPages: result.outline.pages.length,
       maxPages: result.outline.pages.length
     })
-  const layoutIssues = blockingAiPptLayoutIssues(report)
+  const { inspectAiPptRenderedText } = await import('@/utils/aiPptRenderedQuality')
+  const renderedIssues = await inspectAiPptRenderedText(converted.slides)
+  if (!active()) return
+  const layoutIssues = [...blockingAiPptLayoutIssues(report), ...renderedIssues]
+  layoutRepairFeedback.value = layoutIssues.map(issue => `第${issue.pageNo}页:${issue.message}`).join(';')
   if (layoutIssues.length) throw new Error(`第${[...new Set(layoutIssues.map(issue => issue.pageNo))].join('、')}页文字过密,已保留当前课件。请精简并重新排版。`)
   layoutRepairSource.value = undefined
   const issues = [

Některé soubory nejsou zobrazeny, neboť je v těchto rozdílových datech změněno mnoho souborů