yonge 2 days ago
parent
commit
a2da4afcdc

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


+ 1 - 1
dist/assets/index-DAJloYKi.js → dist/assets/index-BC5zTAN0.js

@@ -1 +1 @@
-import{_ as t}from"./404-R0y5dkvZ.js";import{E as c}from"./index-DNLDuM9e.js";import{a5 as n,ae as i,af as r,ak as o,aq as _,am as d,P as l,aS as p,bO as m,bP as f,bH as u}from"./index-CV9b2pRy.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-DiYfm6-W.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-2h60LrB_.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};

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


+ 1 - 1
dist/assets/index-D9APes4i.js → dist/assets/index-CXoBn-__.js

@@ -1 +1 @@
-import{u as c,ah as l}from"./index-CPfrsi3J.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-CV9b2pRy.js";import"./shapes-CcFuW7MI.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,ah as l}from"./index-CnN2CM8W.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-2h60LrB_.js";import"./shapes-CEo4WaXJ.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};

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


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


+ 1 - 1
dist/assets/index-uHnm71Uq.js → dist/assets/index-D18H1nIO.js

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

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


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


+ 1 - 1
dist/assets/index-BaymfMqL.js → dist/assets/index-DzGDNavQ.js

@@ -1 +1 @@
-import{u as s,ah as r}from"./index-CPfrsi3J.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-CV9b2pRy.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}))]))}}),P=l(d,[["__scopeId","data-v-678905e9"]]);export{P as default};
+import{u as s,ah as r}from"./index-CnN2CM8W.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-2h60LrB_.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}))]))}}),P=l(d,[["__scopeId","data-v-678905e9"]]);export{P as default};

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


+ 1 - 1
dist/assets/shapes-CcFuW7MI.js → dist/assets/shapes-CEo4WaXJ.js

@@ -1,4 +1,4 @@
-import{cG as k,bB as $1}from"./index-CV9b2pRy.js";import{ai as Z,aj as H1,ak as U,al as n0,am as c0,an as i0,ao as j1,ap as K1,aq as Y1,ar as R}from"./index-CPfrsi3J.js";var z1={exports:{}};/*!
+import{cH as k,bB as $1}from"./index-2h60LrB_.js";import{ai as Z,aj as H1,ak as U,al as n0,am as c0,an as i0,ao as j1,ap as K1,aq as Y1,ar as R}from"./index-CnN2CM8W.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-CV9b2pRy.js"></script>
+    <script type="module" crossorigin src="./assets/index-2h60LrB_.js"></script>
     <link rel="stylesheet" crossorigin href="./assets/index-AavUEaYy.css">
   </head>
   <body>

+ 6 - 4
dist/pptworker/pptJson.js

@@ -57439,10 +57439,11 @@
                })
             },
             wr = function (e, t, r, n, i, o) {
-               return br(e.poster || e.src + "?x-oss-process=video/snapshot,t_0,f_jpg,w_0,h_0,m_fast").then(function (r) {
+               return br(e.poster || "").then(function (r) {
                   var n = {
-                     type: "online",
-                     link: e.src,
+                     type: "video",
+                     path: e.src && e.src.indexOf("data:") !== 0 ? e.src : void 0,
+                     data: e.src && e.src.indexOf("data:") === 0 ? e.src : void 0,
                      x: e.left / ht,
                      y: e.top / ht,
                      w: e.width / ht,
@@ -57462,7 +57463,8 @@
                return new Promise(function (r, n) {
                   var i = {
                      type: "audio",
-                     path: e.src,
+                     path: e.src && e.src.indexOf("data:") !== 0 ? e.src : void 0,
+                     data: e.src && e.src.indexOf("data:") === 0 ? e.src : void 0,
                      x: e.left / ht,
                      y: e.top / ht,
                      w: 60 / ht,

+ 6 - 4
public/pptworker/pptJson.js

@@ -57439,10 +57439,11 @@
                })
             },
             wr = function (e, t, r, n, i, o) {
-               return br(e.poster || e.src + "?x-oss-process=video/snapshot,t_0,f_jpg,w_0,h_0,m_fast").then(function (r) {
+               return br(e.poster || "").then(function (r) {
                   var n = {
-                     type: "online",
-                     link: e.src,
+                     type: "video",
+                     path: e.src && e.src.indexOf("data:") !== 0 ? e.src : void 0,
+                     data: e.src && e.src.indexOf("data:") === 0 ? e.src : void 0,
                      x: e.left / ht,
                      y: e.top / ht,
                      w: e.width / ht,
@@ -57462,7 +57463,8 @@
                return new Promise(function (r, n) {
                   var i = {
                      type: "audio",
-                     path: e.src,
+                     path: e.src && e.src.indexOf("data:") !== 0 ? e.src : void 0,
+                     data: e.src && e.src.indexOf("data:") === 0 ? e.src : void 0,
                      x: e.left / ht,
                      y: e.top / ht,
                      w: 60 / ht,

+ 59 - 0
scripts/aiPptDialogTest.html

@@ -0,0 +1,59 @@
+<!doctype html>
+<html lang="zh-CN"><head><meta charset="UTF-8"><title>AI课件流程回归</title></head><body><div id="app"></div>
+<script type="module">
+import { createApp, h, ref, nextTick } from "vue"
+import { createRouter, createMemoryHistory } from "vue-router"
+import { store, useSlidesStore } from "/src/store/index.ts"
+import "/src/router/index.ts"
+import { httpAxios } from "/src/api/ApiInstance.ts"
+import { setToken } from "/src/libs/auth.ts"
+import AiDialog from "/src/views/Editor/EditorHeader/AiCoursewareDialog.vue"
+import { inspectAiPptResources } from "/src/utils/aiPptResources.ts"
+import "/src/assets/styles/global.scss"
+import "/src/assets/styles/elementTheme.scss"
+import "/src/assets/styles/prosemirror.scss"
+// 所有业务接口使用本地夹具,不调用生成服务,不修改任何线上课件。
+setToken("local-ai-ppt-regression-only")
+const generated = ref(0)
+const resourceCheck = ref("检查中")
+const types = ["cover", "scene_intro", "objectives", "music_knowledge", "rhythm_practice", "performance_activity", "practice_check", "creative_activity", "summary"]
+const layouts = ["cover_scene", "hero_focus", "objectives", "split_content", "diagram_focus", "activity", "text_focus", "activity", "summary"]
+const outline = {
+  title: "声音强弱课堂测试", gradeName: "一年级", textbookName: "本地测试教材", chapterName: "声音的强弱", courseType: "music", templateId: "ethnic-flower",
+  textbookResearch: { exactMatch: true, confidence: "HIGH", contentSummary: "仅用于软件回归的教学夹具", teachingObjectives: ["能用声音表现强弱"], sources: [{ title: "本地测试依据(非真实教材认证)", url: "https://example.com/test-only" }] },
+  pages: types.map((type, index) => ({ pageNo:index+1, type, title: index === 0 ? "声音强弱课堂测试" : `任务${index}:表现声音强弱`,
+    studentContent:{ points:["用声音表现强弱变化", "说出判断的理由"] }, durationMinutes:index===0?8:4, objectiveIds:["1"],
+    teachingPurpose:"表现声音强弱", learningEvidence:"能表现强弱并说明理由", speakerNotes:"示范后请学生练习,根据表现及时反馈。",
+    classroomAction:{studentTask:"完成声音强弱挑战", teacherInstruction:"示范强弱对比,邀请学生练习后互评"},
+    interaction:type==="practice_check"?{question:"哪种方式声音更轻?",options:["轻声哼唱","大声呼喊"],answer:"轻声哼唱",explanation:"根据声音强弱判断"}:undefined,
+    visualPlan:{layout:layouts[index]}, imageKeywords:[] }))
+}
+httpAxios.axioseRquest = async config => {
+  let data
+  if (config.url.endsWith("/outline")) data = structuredClone(outline)
+  else if (config.url.endsWith("/generate")) { generated.value++; data={taskId:"local-task",status:"PENDING",progress:0} }
+  else if (config.url.endsWith("/result")) data={outline:structuredClone(outline),context:{resources:[]}}
+  else if (config.url.includes("/task/")) data={taskId:"local-task",status:"DONE",progress:100}
+  else throw new Error("测试禁止未声明的业务请求")
+  return {data:{code:200,data}}
+}
+const router=createRouter({history:createMemoryHistory(),routes:[{path:"/",component:{render:()=>null}}]})
+await router.push("/?id=local-ai-ppt-regression")
+await router.isReady()
+const app=createApp({setup(){
+  const visible=ref(false)
+  nextTick(()=>{visible.value=true})
+  return ()=>h("main",[
+    h("p",{id:"test-status"},`生成请求次数:${generated.value};资源检查:${resourceCheck.value}`),
+    h("button",{onClick:()=>{visible.value=true}},"打开AI课件"),
+    h(AiDialog,{visible:visible.value,"onUpdate:visible":value=>{visible.value=value}})
+  ])
+}})
+app.use(store).use(router).mount("#app")
+// 使用浏览器真实图片解码验证损坏资源阻断及谱图等比缩放。
+const svg='data:image/svg+xml;charset=utf-8,'+encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" width="400" height="100"><rect width="400" height="100" fill="white"/></svg>')
+const score={id:"image",type:"image",src:svg,left:0,top:0,width:200,height:200}
+const slides=[{elements:[score,{id:"broken",type:"image",src:"data:image/png;base64,invalid",left:0,top:0,width:100,height:100}]}]
+const issues=await inspectAiPptResources({pages:[{resourceBindings:[{resourceType:"MUSIC",scoreImageUrl:svg}]}]},slides)
+resourceCheck.value=issues.length===1&&score.width===200&&score.height===50&&score.top===75?"通过(损坏图片阻断、谱图等比缩放)":"失败"
+</script></body></html>

+ 47 - 0
scripts/aiPptExportSelfTest.mjs

@@ -0,0 +1,47 @@
+import assert from "node:assert/strict"
+import { readFileSync, writeFileSync, mkdirSync } from "node:fs"
+import vm from "node:vm"
+import { webcrypto } from "node:crypto"
+import { createServer } from "vite"
+
+const vite = await createServer({ server: { middlewareMode: true }, appType: "custom", logLevel: "error" })
+try {
+  const { prepareMediaForExport } = await vite.ssrLoadModule("/src/utils/aiPptExport.ts")
+  // 加载生产导出器,在测试副本暴露其转换函数;不改写或替换生产转换逻辑。
+  const source = readFileSync("public/pptworker/pptJson.js", "utf8")
+    .replace('r((r.s = "9a7e"))', 'globalThis.bundleRequire = r')
+    .replace('Sr = function (e, t) {', 'Sr = globalThis.renderPptForTest = function (e, t) {')
+  const context = vm.createContext({ console, setTimeout, clearTimeout, setInterval, clearInterval,
+    TextEncoder, TextDecoder, Uint8Array, ArrayBuffer, Buffer, Blob, crypto: webcrypto,
+    fetch: () => { throw new Error("离线导出不得请求网络") } })
+  context.self = context
+  vm.runInContext(source, context)
+  context.bundleRequire("f5f5")
+  assert.equal(typeof context.renderPptForTest, "function")
+
+  const png = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="
+  const wav = Buffer.alloc(44 + 8000 * 2)
+  wav.write("RIFF"); wav.writeUInt32LE(wav.length - 8, 4); wav.write("WAVEfmt ", 8)
+  wav.writeUInt32LE(16, 16); wav.writeUInt16LE(1, 20); wav.writeUInt16LE(1, 22)
+  wav.writeUInt32LE(8000, 24); wav.writeUInt32LE(16000, 28); wav.writeUInt16LE(2, 32); wav.writeUInt16LE(16, 34)
+  wav.write("data", 36); wav.writeUInt32LE(16000, 40)
+  for (let i = 0; i < 8000; i++) wav.writeInt16LE(Math.round(Math.sin(i * Math.PI * 2 * 440 / 8000) * 5000), 44 + i * 2)
+  const audio = `data:audio/wav;base64,${wav.toString("base64")}`
+  const base = { left: 80, top: 180, width: 60, height: 60, rotate: 0 }
+  const slides = [{ id: "offline-test", remark: "参考答案:轻声哼唱\n解析:比较声音的强弱。", elements: [
+    { ...base, id: "title", type: "text", width: 1000, height: 90, content: '<div style="font-size:48px">离线媒体测试</div>', defaultFontName: "Microsoft Yahei", defaultColor: "#222222" },
+    { ...base, top: 320, id: "audio-a", type: "elf", subtype: "elf-audio", src: audio, color: "#333333", autoplay: false, loop: false, fixedRatio: true },
+    { ...base, left: 300, top: 320, id: "legacy-audio", type: "elf", subtype: "elf-enjoy", src: audio, enjoyList: [{ src: audio }] },
+    { ...base, top: 500, id: "image", type: "image", src: png, fixedRatio: true }
+  ] }]
+  const prepared = await prepareMediaForExport(slides)
+  assert.equal(prepared[0].elements.filter(element => element.subtype === "elf-audio").length, 2)
+  const ppt = await context.renderPptForTest({ width: 1920, height: 1080, slides: prepared,
+    theme: { fontName: "Microsoft Yahei", fontColor: "#222222", viewportRatio: 0.5625 } }, { viewportRatio: 0.5625 })
+  const bytes = Buffer.from(await ppt.write({ outputType: "arraybuffer" }))
+  assert.equal(bytes.subarray(0, 2).toString(), "PK")
+  mkdirSync("artifacts/ai-ppt-tests", { recursive: true })
+  writeFileSync("artifacts/ai-ppt-tests/offline-media.pptx", bytes)
+  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() }

+ 66 - 4
scripts/aiPptSelfTest.mjs

@@ -1,6 +1,7 @@
 import { createServer } from "vite"
 import { existsSync } from "node:fs"
 import { resolve } from "node:path"
+import assert from "node:assert/strict"
 
 const vite = await createServer({ server: { middlewareMode: true }, appType: "custom", logLevel: "error" })
 
@@ -103,16 +104,35 @@ try {
   }
   const courseVariants = [
     { id: "singing", outline: singingOutline, expectedMediaPlaceholders: 0 },
-    { id: "appreciation-no-lyrics", outline: appreciationOutline, expectedMediaPlaceholders: 1 },
+    { id: "appreciation-no-lyrics", outline: appreciationOutline, expectedMediaPlaceholders: 0 },
     { id: "rhythm-no-lyrics-or-score", outline: rhythmOutline, expectedMediaPlaceholders: 0 },
     { id: "long-15-pages", outline: longOutline, expectedMediaPlaceholders: 0 }
   ]
   const preferences = { lessonMinutes: 40, minPages: 9, maxPages: 15, assetStyle: "illustration", sourceImagePolicy: "balanced", requireAudio: false, requireVideo: false, enableAnimations: true }
   const results = []
+  const testAudio = index => ({ resourceId: `audio-${index}`, resourceType: "SONG", name: `测试音频${index}`, verified: true, contentUrl: `https://example.com/test-${index}.mp3` })
+  const scoreImage = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="
+  // 此处是契约测试夹具,不是实际教材证据或媒体可播放性认证。
+  const completeFixture = source => {
+    const outline = structuredClone(source)
+    outline.textbookResearch = { exactMatch: true, confidence: "HIGH", sources: [{ url: "https://example.com/test-textbook" }], teachingObjectives: ["用声音表现变化"] }
+    outline.pages.forEach((item, index) => {
+      item.durationMinutes = Math.floor(40 / outline.pages.length) + (index < 40 % outline.pages.length ? 1 : 0)
+      item.teachingPurpose = "用声音表现变化"
+      item.learningEvidence = "学生能用声音表现一种变化并说明理由"
+      item.classroomAction = { teacherInstruction: "示范声音变化,邀请学生模仿并说明理由", studentTask: item.studentContent?.headline || item.title }
+      item.speakerNotes = "示范后留出练习时间,根据学生表现调整难度。"
+      item.objectiveIds = ["1"]
+      if (/listen|phrase_sing|expressive_sing/.test(item.type)) item.resourceBindings = /compare/.test(item.type) ? [testAudio(1), testAudio(2)] : [testAudio(1)]
+      if (item.type === "melody_score") item.resourceBindings = [{ resourceId: "test-score", resourceType: "MUSIC", verified: true, dedicatedPage: true, scoreImageUrl: scoreImage }]
+      if (item.type === "practice_check") item.interaction = { type: "choice", question: "哪一种声音更轻?", options: ["轻声哼唱", "大声呼喊"], answer: "轻声哼唱", explanation: "比较相同距离下声音的强弱。" }
+    })
+    return outline
+  }
 
   for (const template of AI_COURSEWARE_TEMPLATES) {
     for (const variant of courseVariants) {
-      const outline = { ...variant.outline, templateId: template.id }
+      const outline = { ...completeFixture(variant.outline), templateId: template.id }
       const converted = aiCoursewareToSlides(outline, { templateId: template.id, enableAnimations: true })
       const report = inspectAiPptQuality(outline, converted.slides, preferences)
       const master = getAiCoursewareTemplateMaster(template.id)
@@ -124,7 +144,7 @@ try {
         .filter(item => item.startsWith("/"))
         .filter(item => !existsSync(resolve("public", item.replace(/^\//, ""))))
       const layoutMismatchCount = outline.pages.filter((page, index) => {
-        const layout = resolveTemplatePageLayout(page.visualPlan?.layout)
+        const layout = resolveTemplatePageLayout(page.type === "melody_score" ? "diagram_focus" : page.interaction?.question ? "text_focus" : page.visualPlan?.layout)
         const expectedPage = master.pages.find(item => item.layout === layout)
         const expectedBackgroundsForPage = expectedPage?.backgroundImages?.length
           ? expectedPage.backgroundImages
@@ -190,7 +210,49 @@ try {
     return messages.map(message => `${result.templateId}/${result.scenario}: ${message}`)
   })
 
-  console.log(JSON.stringify({ passed: failures.length === 0, scenarios: courseVariants.map(item => item.id), results, failures }, null, 2))
+  const valid = { ...completeFixture(rhythmOutline), templateId: "ethnic-flower" }
+  const render = value => aiCoursewareToSlides(value).slides
+  const codes = value => inspectAiPptQuality(value, render(value), preferences).errors.map(issue => issue.code)
+  const noResearch = structuredClone(valid)
+  noResearch.textbookResearch.exactMatch = false
+  assert(codes(noResearch).includes("textbook_evidence"))
+  const missingAudio = structuredClone(valid)
+  missingAudio.pages[4].resourceBindings = []
+  assert(codes(missingAudio).includes("missing_page_audio"))
+  assert(!JSON.stringify(render(missingAudio)).includes("请老师在此补充"))
+  const noScore = { ...completeFixture(singingOutline), templateId: "ethnic-flower" }
+  noScore.pages[7].resourceBindings = []
+  assert(codes(noScore).includes("missing_score"))
+  assert.equal(render(noScore)[7].elements.filter(element => element.type === "line").length, 0, "缺谱不得绘制假五线谱")
+  noScore.pages[7].resourceBindings = [{ resourceType: "MUSIC", resourceId: "verified-score", verified: true, dedicatedPage: true }]
+  assert(render(noScore)[7].elements.some(element => element.subtype === "elf-sing-play" && element.sid === "verified-score"))
+  const exercise = render(valid)[7]
+  assert(exercise.elements.some(element => elementText(element).includes("哪一种声音更轻?")))
+  assert(exercise.elements.some(element => elementText(element).includes("大声呼喊")))
+  assert(exercise.remark.includes("参考答案:轻声哼唱") && exercise.remark.includes("比较相同距离"))
+  const lost = render(valid)
+  lost[7].elements = lost[7].elements.filter(element => !elementText(element).includes("哪一种声音更轻?"))
+  assert(inspectAiPptQuality(valid, lost, preferences).errors.some(issue => issue.code === "missing_content"))
+  const overtime = structuredClone(valid)
+  overtime.pages[0].durationMinutes += 10
+  assert(codes(overtime).includes("lesson_duration"))
+  const dense = structuredClone(valid)
+  dense.pages[3].studentContent.points = Array.from({length: 24}, (_, index) => `第${index + 1}条超长课堂说明,不应无限缩小字号塞进页面`)
+  assert(codes(dense).some(code => ["text_overflow", "text_overlap", "out_of_bounds"].includes(code)))
+  const compareAudio = render(valid)[4].elements.filter(element => element.subtype === "elf-audio")
+  assert.equal(compareAudio.length, 2)
+  assert(compareAudio.every(element => !element.autoplay))
+  const editedCover = structuredClone(valid)
+  editedCover.pages[0].title = "老师修改后的封面标题"
+  assert(render(editedCover)[0].elements.some(element => elementText(element).includes("老师修改后的封面标题")))
+  const noObjectives = structuredClone(valid)
+  noObjectives.pages.forEach(page => page.objectiveIds = [])
+  assert(codes(noObjectives).includes("objective_coverage"))
+  const withVideo = structuredClone(valid)
+  withVideo.pages[7].resourceBindings = [{resourceType:"VIDEO",resourceId:"test-video",contentUrl:"https://example.com/test.mp4",verified:true}]
+  assert(render(withVideo)[7].elements.some(element => element.subtype === "elf-video"))
+  console.log(JSON.stringify({ passed: failures.length === 0, scenarioCount: results.length, contractChecks: 13,
+    scenarios: courseVariants.map(item => item.id), ...(process.argv.includes("--verbose") ? { results } : {}), failures }, null, 2))
   if (failures.length) process.exitCode = 1
 } finally {
   await vite.close()

+ 2 - 0
scripts/aiPptVisualTest.html

@@ -19,6 +19,8 @@
       import ThumbnailSlide from "/src/views/components/ThumbnailSlide/index.vue"
       import { aiCoursewareToSlides } from "/src/utils/aiCoursewareToSlides.ts"
       import { getAiCoursewareTemplate } from "/src/config/aiCoursewareTemplates.ts"
+      import "/src/assets/styles/global.scss"
+      import "/src/assets/styles/prosemirror.scss"
       import { hydrateSlideBackgroundImages } from "/src/libs/jsonTool.ts"
 
       const id = new URLSearchParams(location.search).get("template") || "ethnic-flower"

+ 7 - 0
src/api/aiCourseware.ts

@@ -19,6 +19,8 @@ export interface AiCoursewareGenerateQuery {
 }
 
 export interface AiCoursewarePreferences {
+  learningContext?: string
+  lessonScope?: string
   lessonMinutes: number
   minPages: number
   maxPages: number
@@ -86,6 +88,9 @@ export interface AiCoursewarePage {
   teachingPurpose?: string
   learningEvidence?: string
   durationMinutes?: number
+  objectiveIds?: string[]
+  optional?: boolean
+  requiredVisual?: boolean
   studentContent?: {
     headline?: string
     points?: string[]
@@ -124,6 +129,7 @@ export interface AiCoursewarePage {
     question: string
     options?: string[]
     answer?: string
+    explanation?: string
   }
   imageKeywords: string[]
   image?: {
@@ -138,6 +144,7 @@ export interface AiCoursewarePage {
 }
 
 export interface AiCoursewareResourceBinding {
+  scoreImageUrl?: string
   resourceId: string
   resourceType: "IMG" | "VIDEO" | "SONG" | "MUSIC" | "LISTEN" | "RHYTHM" | "THEORY" | "MUSIC_WIKI" | "INSTRUMENT" | "MUSICIAN" | string
   name?: string

+ 2 - 1
src/components/Button.vue

@@ -1,6 +1,7 @@
 <template>
   <button 
     class="button"
+    :disabled="disabled"
     :class="{
       'disabled': disabled,
       'checked': !disabled && checked,
@@ -113,4 +114,4 @@ const handleClick = () => {
     cursor: default;
   }
 }
-</style>
+</style>

+ 2 - 2
src/config/aiCoursewareTemplateMasters.ts

@@ -236,7 +236,7 @@ function pilotSlots(templateId: string, layout: TemplatePageLayout, fallback: Te
     objectives: [title(500, 62, 600), slot("body", "body", 360, 205, 880, 430, 4, 30, 23)],
     media: [title(360, 70, 880), slot("headline", "headline", 390, 175, 820, 70, 1, 34, 26), disabled(slot("image", "image", 465, 275, 670, 250)), slot("body", "body", 430, 305, 740, 290, 3, 30, 23), slot("media", "media", 420, 650, 760, 82)],
     split: [title(150, 62, 880), slot("image", "image", 120, 220, 440, 420), slot("body", "body", 620, 220, 470, 420, 4, 30, 23), slot("media", "media", 150, 685, 920, 82)],
-    text: [title(150, 62, 880), slot("headline", "headline", 150, 180, 900, 64, 1, 32, 26), slot("body", "body", 150, 265, 900, 390, 5, 36, 27), slot("media", "media", 150, 690, 900, 82)],
+    text: [title(380, 62, 840), slot("headline", "headline", 380, 180, 840, 64, 1, 32, 26), slot("body", "body", 380, 265, 840, 390, 5, 36, 27), slot("media", "media", 380, 690, 840, 82)],
     diagram: [title(150, 62, 880), slot("image", "image", 120, 220, 680, 420), slot("body", "body", 840, 235, 280, 370, 3, 27, 22), slot("media", "media", 150, 690, 900, 82)],
     compare: [title(350, 65, 900), slot("headline", "headline", 390, 175, 820, 70, 1, 32, 25), slot("left", "left", 350, 300, 410, 300, 3, 28, 22), slot("right", "right", 840, 300, 410, 300, 3, 28, 22), slot("media", "media", 420, 650, 760, 82)],
     activity: [title(470, 55, 660), slot("headline", "headline", 400, 155, 800, 72, 1, 34, 26), slot("steps", "steps", 360, 270, 880, 350, 4, 28, 22), slot("media", "media", 430, 665, 740, 82)],
@@ -274,7 +274,7 @@ function pilotContract(templateId: string, layout: TemplatePageLayout): Pick<Tem
       safeArea,
       reservedZones: [region("right-children", "民族人物主视觉", 960, 150, 580, 610), region("flower-meadow", "花草装饰", 0, 790, 1600, 110)]
     }
-    if (["split", "text", "diagram"].includes(layout)) return {
+    if (["split", "diagram"].includes(layout)) return {
       safeArea,
       reservedZones: [region("right-pianist", "演奏人物主视觉", 1160, 300, 400, 500), region("bottom-hills", "山丘装饰", 0, 800, 1600, 100)]
     }

+ 2 - 0
src/hooks/useExport.ts

@@ -1,4 +1,5 @@
 import { ref } from "vue"
+import { ElMessage } from "element-plus"
 import { addCourseWareTask } from "@/worker/useCoursewaresWorker"
 import { downloadFile } from "@/libs/tools"
 import { getJsonToBlobForExport } from "@/libs/jsonTool"
@@ -17,6 +18,7 @@ export default () => {
     }
     catch (error) {
       exporting.value = false
+      ElMessage.error(error instanceof Error ? error.message : "导出资源准备失败")
       console.error(error)
       return
     }

+ 2 - 1
src/libs/jsonTool.ts

@@ -2,6 +2,7 @@ import axios from "axios"
 import { useSlidesStore } from "@/store"
 import { slides as slidesData } from "@/mocks/slides"
 import { resolveTemplateAssetUrl } from "@/utils/templateAssetUrl"
+import { prepareMediaForExport } from "@/utils/aiPptExport"
 /**
  * http 请求json
  */
@@ -62,7 +63,7 @@ export function getJsonToBlob() {
 export async function getJsonToBlobForExport() {
   const slidesStore = useSlidesStore()
   const { slides, theme, viewportRatio, viewportSize, title } = slidesStore
-  const exportSlides = await hydrateSlideBackgroundImages(slides)
+  const exportSlides = await prepareMediaForExport(await hydrateSlideBackgroundImages(slides))
   const json = {
     width: viewportSize,
     height: viewportSize * viewportRatio,

+ 31 - 0
src/utils/aiCoursewareDraft.ts

@@ -0,0 +1,31 @@
+import Dexie from "dexie"
+import { getToken } from "@/libs/auth"
+import type { AiCoursewareOutline, AiCoursewarePreferences, AiCoursewareResourceBinding, AiCoursewarePage } from "@/api/aiCourseware"
+import type { Slide, SlideTheme } from "@/types/slides"
+
+export type ProtectedPageContent = Pick<AiCoursewarePage, "title" | "subtitle" | "bullets" | "studentContent" | "learningEvidence" | "interaction"> & { studentTask?: string }
+
+export interface AiDraftRecord {
+  key: string
+  updatedAt: number
+  outline?: AiCoursewareOutline
+  converted?: { title: string; theme: Partial<SlideTheme>; slides: Slide[] }
+  preferences: AiCoursewarePreferences
+  extraPrompt: string
+  taskId?: string
+  resources?: AiCoursewareResourceBinding[]
+  protectedContent?: Array<[number, ProtectedPageContent]>
+  backup?: { title: string; theme: SlideTheme; slides: Slide[]; width: number; ratio: number }
+}
+const database = new Dexie("AI_COURSEWARE_DRAFTS")
+database.version(1).stores({ drafts: "key,updatedAt" })
+const drafts = database.table<AiDraftRecord, string>("drafts")
+export async function draftKey(id: string, source: string): Promise<string> {
+  const token = getToken()
+  if (!token) throw new Error("登录状态不可用,无法保存个人草稿")
+  const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(token))
+  const owner = Array.from(new Uint8Array(digest), byte => byte.toString(16).padStart(2, "0")).join("")
+  return `${owner}:${source}:${id}`
+}
+export const loadAiDraft = (key: string) => drafts.get(key)
+export const saveAiDraft = (record: AiDraftRecord) => drafts.put(JSON.parse(JSON.stringify(record)))

+ 18 - 0
src/utils/aiPptContent.ts

@@ -0,0 +1,18 @@
+import type { AiCoursewarePage } from "@/api/aiCourseware"
+
+// 资源身份、静态谱图与装饰配图分别判断,不能用封面图冒充曲谱。
+export const usableResourceUrl = (value?: string) => /^(https?:\/\/|data:(image|audio|video)\/)/i.test(value || "")
+export const scoreBinding = (page: AiCoursewarePage) => (page.resourceBindings || [])
+  .find(item => item.verified === true && item.resourceType === "MUSIC" && Boolean(item.resourceId))
+export const audioBindings = (page: AiCoursewarePage) => (page.resourceBindings || [])
+  .filter(item => item.verified === true && item.resourceType === "SONG" && usableResourceUrl(item.contentUrl))
+export const isScorePage = (page: AiCoursewarePage) => /melody|score/.test(page.type || "") || Boolean(scoreBinding(page))
+export const needsAudio = (page: AiCoursewarePage) => /listen|phrase_sing|expressive_sing/.test(page.type || "") ||
+  (page.resourceBindings || []).some(item => item.required && item.resourceType === "SONG")
+
+export function studentTexts(page: AiCoursewarePage): string[] {
+  return [page.title, page.studentContent?.headline, page.classroomAction?.studentTask,
+    ...(page.studentContent?.points?.length ? page.studentContent.points : page.bullets || []),
+    page.interaction?.question, ...(page.interaction?.options || [])]
+    .map(text => String(text || "").trim()).filter(Boolean)
+}

+ 71 - 0
src/utils/aiPptExport.ts

@@ -0,0 +1,71 @@
+import type { PPTElement, Slide } from "@/types/slides"
+
+// 在导出前把网络资源固化,任何关键资源失败都明确报错,避免生成缺图或无声文件。
+export async function prepareMediaForExport(slides: Slide[]): Promise<Slide[]> {
+  const result: Slide[] = JSON.parse(JSON.stringify(slides))
+  const assets = new Map<string, Promise<string>>()
+  const embed = (url: string) => {
+    if (url.startsWith("data:")) return Promise.resolve(url)
+    if (!url) return Promise.reject(new Error("资源地址为空"))
+    if (!assets.has(url)) assets.set(url, (async () => {
+      const controller = new AbortController()
+      const timer = setTimeout(() => controller.abort(), 30000)
+      try {
+        const response = await fetch(url, { signal: controller.signal })
+        if (!response.ok) throw new Error(`资源读取失败(HTTP ${response.status})`)
+        let blob = await response.blob()
+        if (!blob.size) throw new Error("资源文件为空")
+        if (!blob.type || /octet-stream/i.test(blob.type)) {
+          const bytes = new Uint8Array(await blob.slice(0, 16).arrayBuffer())
+          const signature = String.fromCharCode(...bytes)
+          const mime = signature.startsWith("RIFF") && signature.slice(8, 12) === "WAVE" ? "audio/wav"
+            : signature.startsWith("RIFF") && signature.slice(8, 12) === "WEBP" ? "image/webp"
+            : signature.slice(4, 8) === "ftyp" ? "video/mp4"
+            : signature.startsWith("ID3") || bytes[0] === 255 && (bytes[1] & 224) === 224 ? "audio/mpeg"
+            : bytes[0] === 137 && signature.slice(1, 4) === "PNG" ? "image/png"
+            : bytes[0] === 255 && bytes[1] === 216 ? "image/jpeg"
+            : signature.startsWith("GIF8") ? "image/gif" : ""
+          if (!mime) throw new Error("无法识别资源格式,请换用标准图片或音视频文件")
+          blob = new Blob([blob], { type: mime })
+        }
+        if (!/^(image|audio|video)\//i.test(blob.type)) throw new Error("资源响应不是图片或音视频文件")
+        return await new Promise<string>((resolve, reject) => {
+          const reader = new FileReader()
+          reader.onload = () => resolve(String(reader.result))
+          reader.onerror = () => reject(new Error("资源编码失败"))
+          reader.readAsDataURL(blob)
+        })
+      } finally { clearTimeout(timer) }
+    })())
+    return assets.get(url)!
+  }
+  for (const [index, slide] of result.entries()) {
+    const elements: PPTElement[] = []
+    for (const element of slide.elements) {
+      try {
+        if (element.type === "elf" && element.subtype === "elf-sing-play") {
+          throw new Error("此乐谱只有在线组件,请先换成真实谱图后导出")
+        }
+        if (element.type === "elf" && element.subtype === "elf-enjoy") {
+          const tracks = element.enjoyList?.length ? element.enjoyList : [{ src: element.src }]
+          for (const [trackIndex, track] of tracks.entries()) elements.push({
+            ...element, id: `${element.id}-audio-${trackIndex}`, subtype: "elf-audio", width: 60, height: 60,
+            left: element.left + trackIndex * 76, fixedRatio: true, color: "#333333", loop: false,
+            autoplay: false, src: await embed(track.src)
+          })
+          continue
+        }
+        if (element.type === "image" || element.type === "elf" && ["elf-audio", "elf-video"].includes(element.subtype)) {
+          const asset = element as typeof element & { src: string }
+          asset.src = await embed(asset.src)
+          if (element.type === "elf" && element.subtype === "elf-video" && element.poster) element.poster = await embed(element.poster)
+        }
+        elements.push(element)
+      } catch (error) {
+        throw new Error(`第${index + 1}页无法完整导出:${error instanceof Error ? error.message : "资源不可用"}`)
+      }
+    }
+    slide.elements = elements
+  }
+  return result
+}

+ 79 - 8
src/utils/aiPptQuality.ts

@@ -1,6 +1,7 @@
 import type { AiCoursewareOutline, AiCoursewarePreferences } from "@/api/aiCourseware"
 import { scoreAiCoursewareTemplate } from "@/config/aiCoursewareTemplates"
 import type { PPTElement, Slide } from "@/types/slides"
+import { audioBindings, scoreBinding, isScorePage, needsAudio, studentTexts, usableResourceUrl } from "@/utils/aiPptContent"
 
 type BoundedPPTElement = Exclude<PPTElement, { type: "line" }>
 
@@ -72,6 +73,7 @@ export function inspectAiPptQuality(outline: AiCoursewareOutline, slides: Slide[
   const issues: AiPptQualityIssue[] = []
   inspectOutline(outline, issues, preferences)
   inspectSlides(slides, issues, preferences)
+  inspectTeachingContract(outline, slides, issues, preferences)
   const errors = issues.filter(issue => issue.severity === "error")
   const warnings = issues.filter(issue => issue.severity === "warning")
   return { passed: errors.length === 0, issues, errors, warnings, scores: qualityScores(outline, issues) }
@@ -106,7 +108,7 @@ function inspectOutline(outline: AiCoursewareOutline, issues: AiPptQualityIssue[
 
     if (layout === previousLayout) consecutive += 1
     else consecutive = 1
-    if (consecutive > 2) push(issues, "layout_consecutive", "error", pageNo, `连续${consecutive}页使用相同布局“${layout}”`)
+    if (consecutive > 2) push(issues, "layout_consecutive", "warning", pageNo, `连续${consecutive}页使用相同布局“${layout}”`)
     previousLayout = layout
 
     // 只检查真正会进入学生投屏画面的字段。activity 属于教师组织信息,
@@ -148,7 +150,7 @@ function inspectOutline(outline: AiCoursewareOutline, issues: AiPptQualityIssue[
       const mainVisual = clean(page.visualPlan?.mainVisual || page.visualPlan?.focalContent || page.visual?.mainVisual)
       const expectsVisual = ["hero_focus", "media_focus", "split_content", "diagram_focus"].includes(layout)
       if (expectsVisual && !imageUrl && !mainVisual) {
-        push(issues, "generated_focal_visual", "warning", pageNo, "未取得外部主图,系统已使用可编辑教学图示补足画面")
+        push(issues, "generated_focal_visual", "warning", pageNo, "本页没有教学主图,请确认文字足以支持课堂任务")
       }
       if (visibleCount <= 1 && !imageUrl && !mainVisual && !["summary", "objectives"].includes(layout)) {
         push(issues, "low_content_coverage", "warning", pageNo, "页面有效内容偏少,建议补充教学图示或合并页面")
@@ -158,25 +160,25 @@ function inspectOutline(outline: AiCoursewareOutline, issues: AiPptQualityIssue[
 
   const requiredLayouts = Math.min(6, Math.max(4, Math.ceil(pages.length / 3)))
   if (layoutCounts.size < requiredLayouts) {
-    push(issues, "layout_variety", "error", undefined, `整套课件只有${layoutCounts.size}种布局,至少需要${requiredLayouts}种`)
+    push(issues, "layout_variety", "warning", undefined, `整套课件只有${layoutCounts.size}种布局,建议使用${requiredLayouts}种`)
   }
   if (pages.length && cardPages / pages.length > 0.4) {
-    push(issues, "card_ratio", "error", undefined, `卡片或列表页占${Math.round((cardPages / pages.length) * 100)}%,不能超过40%`)
+    push(issues, "card_ratio", "warning", undefined, `卡片或列表页占${Math.round((cardPages / pages.length) * 100)}%,建议减少连续列表`)
   }
   imagePages.forEach(pageNos => {
     if (pageNos.length > 1) {
-      push(issues, "duplicate_image", pageNos.length > 2 ? "error" : "warning", pageNos[1], `同一主图片用于第${pageNos.join("、")}页`)
+      push(issues, "duplicate_image", "warning", pageNos[1], `同一主图片用于第${pageNos.join("、")}页`)
     }
   })
 
   const verifiedBindings = pages
     .flatMap(page => page.resourceBindings || [])
-    .filter(binding => binding.verified !== false && Boolean(binding.contentUrl))
+    .filter(binding => binding.verified === true && usableResourceUrl(binding.contentUrl))
   if (preferences?.requireAudio && !verifiedBindings.some(binding => binding.resourceType === "SONG")) {
-    push(issues, "required_audio", "warning", undefined, "已选择需要音频,但课程资源中没有可用音频")
+    push(issues, "required_audio", "error", undefined, "已选择需要音频,但课程资源中没有可用音频")
   }
   if (preferences?.requireVideo && !verifiedBindings.some(binding => binding.resourceType === "VIDEO")) {
-    push(issues, "required_video", "warning", undefined, "已选择需要视频,但课程资源中没有可用视频")
+    push(issues, "required_video", "error", undefined, "已选择需要视频,但课程资源中没有可用视频")
   }
 }
 
@@ -227,6 +229,75 @@ function inspectSlides(slides: Slide[], issues: AiPptQualityIssue[], preferences
   }
 }
 
+function inspectTeachingContract(outline: AiCoursewareOutline, slides: Slide[], issues: AiPptQualityIssue[], preferences?: AiCoursewarePreferences) {
+  const research = outline.textbookResearch
+  if (!research?.exactMatch || research.confidence !== "HIGH" || !research.sources?.some(source => /^https?:\/\//i.test(source.url))) {
+    push(issues, "textbook_evidence", "error", undefined, "教材依据尚未达到精确匹配且高可信,请核对版本、册次和章节后重新生成")
+  }
+  const pages = outline.pages || []
+  const objectives = research?.teachingObjectives || []
+  const taught = new Set<string>()
+  const assessed = new Set<string>()
+  if (!objectives.length) push(issues, "missing_objectives", "error", undefined, "缺少可核对的教学目标")
+  let minutes = 0
+  pages.forEach((page, index) => {
+    const no = index + 1
+    for (const id of page.objectiveIds || []) {
+      if (!/^[1-9][0-9]*$/.test(id) || Number(id) > objectives.length) push(issues, "invalid_objective", "error", no, "教学目标编号无效")
+      else if (!page.optional) {
+        if (/practice|activity|sing|listen/.test(page.type)) taught.add(id)
+        if (/performance|creative/.test(page.type) || page.type === "practice_check") assessed.add(id)
+      }
+    }
+    const slide = slides[index]
+    if (!slide) { push(issues, "missing_slide", "error", no, "页面未生成"); return }
+    const rendered = slide.elements.map(elementText).join("\n").replace(/\s+/g, "")
+    // 去除空白后逐项核对,不能以有元素或有备注替代内容完整性。
+    studentTexts(page).forEach(text => {
+      if (!rendered.includes(text.replace(/\s+/g, ""))) push(issues, "missing_content", "error", no, `内容没有进入页面:“${short(text)}”`)
+    })
+    if (isScorePage(page)) {
+      const score = scoreBinding(page)
+      if (!score) push(issues, "missing_score", "error", no, "本页需要真实曲谱,请绑定已核验乐谱或修改教学任务")
+      else if (!usableResourceUrl(score.scoreImageUrl)) push(issues, "score_export", "warning", no, "乐谱可在平台打开,但缺少静态谱图,暂不能导出为离线PPTX")
+      if (score && !slide.elements.some(el => el.type === "elf" && el.subtype === "elf-sing-play" && el.sid === score.resourceId || el.type === "image" && el.src === score.scoreImageUrl)) {
+        push(issues, "missing_score_render", "error", no, "已绑定的真实乐谱没有进入页面")
+      }
+    }
+    if (needsAudio(page) && audioBindings(page).length === 0) push(issues, "missing_page_audio", "error", no, "本页听唱任务缺少可播放音频,请补充资源或修改任务")
+    if (/compare_listen/.test(page.type) && new Set(audioBindings(page).map(item => item.contentUrl)).size < 2) {
+      push(issues, "compare_audio", "error", no, "对比听赏需要两个不同的音频资源,并明确A、B任务")
+    }
+    for (const binding of audioBindings(page)) {
+      if (!slide.elements.some(el => el.type === "elf" && (el.subtype === "elf-audio" || el.subtype === "elf-enjoy") && el.src === binding.contentUrl)) {
+        push(issues, "missing_audio_render", "error", no, "已绑定的音频没有进入页面")
+      }
+    }
+    if (page.requiredVisual && !usableResourceUrl(page.image?.url) && !scoreBinding(page)) push(issues, "missing_required_visual", "error", no, "本页观察任务缺少必要图片")
+    if (page.requiredVisual && usableResourceUrl(page.image?.url) && !slide.elements.some(el => el.type === "image" && el.src === page.image?.url)) push(issues, "missing_visual_render", "error", no, "必要教学图片没有进入页面")
+    for (const binding of (page.resourceBindings || []).filter(item => item.resourceType === "VIDEO")) {
+      if (binding.verified !== true || !usableResourceUrl(binding.contentUrl) || !slide.elements.some(el => el.type === "elf" && el.subtype === "elf-video" && el.src === binding.contentUrl)) push(issues, "missing_video_render", "error", no, "已选择的视频不可用或没有进入页面")
+    }
+    if (page.type === "practice_check") {
+      if (!page.interaction?.question || !page.interaction.answer) push(issues, "incomplete_question", "error", no, "检测题必须有题干和教师参考答案")
+      if (page.interaction?.options?.length === 1) push(issues, "invalid_options", "error", no, "选择题至少有两个有效选项")
+    }
+    if (page.interaction?.answer && !slide.remark?.includes(page.interaction.answer)) push(issues, "missing_answer", "error", no, "参考答案未进入教师备注")
+    if (index > 0 && (!page.teachingPurpose || !page.learningEvidence || !page.classroomAction?.studentTask || !page.classroomAction.teacherInstruction)) {
+      push(issues, "teaching_chain", "error", no, "请补齐教学目的、学生任务、教师操作和可观察的学习表现")
+    }
+    if (preferences) {
+      if (!Number.isFinite(page.durationMinutes) || Number(page.durationMinutes) <= 0) push(issues, "missing_duration", "error", no, "请填写本页用时")
+      else if (!page.optional) minutes += Number(page.durationMinutes)
+    }
+  })
+  objectives.forEach((_, index) => {
+    const id = String(index + 1)
+    if (!taught.has(id) || !assessed.has(id)) push(issues, "objective_coverage", "error", undefined, `教学目标${id}缺少必讲的实践或评价任务`)
+  })
+  if (preferences && Math.abs(minutes - preferences.lessonMinutes) > 2) push(issues, "lesson_duration", "error", undefined, `必讲页面合计${minutes}分钟,与${preferences.lessonMinutes}分钟课时不符(允许2分钟机动)`)
+}
+
 function qualityScores(outline: AiCoursewareOutline, issues: AiPptQualityIssue[]) {
   const deduct = (codes: string[], errorValue: number, warningValue: number) => Math.min(60, issues
     .filter(issue => codes.includes(issue.code))

+ 58 - 0
src/utils/aiPptResources.ts

@@ -0,0 +1,58 @@
+import type { AiCoursewareOutline } from "@/api/aiCourseware"
+import type { Slide } from "@/types/slides"
+import type { AiPptQualityIssue } from "@/utils/aiPptQuality"
+
+// 仅在浏览器检查实际可加载性,不以 URL 格式代替媒体可用性。
+export async function inspectAiPptResources(outline: AiCoursewareOutline, slides: Slide[]): Promise<AiPptQualityIssue[]> {
+  const pending = new Map<string, Promise<{ width: number; height: number }>>()
+  const issues: AiPptQualityIssue[] = []
+  const probe = (url: string, kind: "image" | "audio" | "video") => {
+    const key = `${kind}:${url}`
+    if (!pending.has(key)) pending.set(key, new Promise((resolve, reject) => {
+      const media = kind === "image" ? new Image() : document.createElement(kind)
+      const finish = (error?: string) => {
+        clearTimeout(timer)
+        media.onload = media.onerror = null
+        if (media instanceof HTMLMediaElement) {
+          media.onloadedmetadata = null
+          media.removeAttribute("src")
+          media.load()
+        }
+        if (error) reject(new Error(error))
+        else resolve(media instanceof HTMLImageElement ? { width: media.naturalWidth, height: media.naturalHeight } : { width: 0, height: 0 })
+      }
+      const timer = window.setTimeout(() => finish("加载超时"), 15000)
+      media.onerror = () => finish("无法加载或格式不受支持")
+      if (media instanceof HTMLMediaElement) {
+        media.preload = "metadata"
+        media.onloadedmetadata = () => finish()
+      } else media.onload = () => finish()
+      media.src = url
+    }))
+    return pending.get(key)!
+  }
+  await Promise.all(slides.map(async (slide, index) => {
+    const scoreUrls = new Set((outline.pages[index]?.resourceBindings || []).filter(item => item.resourceType === "MUSIC").map(item => item.scoreImageUrl))
+    await Promise.all(slide.elements.map(async element => {
+      const kind = element.type === "image" ? "image" : element.type === "elf" && element.subtype === "elf-audio" ? "audio" : element.type === "elf" && element.subtype === "elf-video" ? "video" : undefined
+      if (!kind || !("src" in element)) return
+      try {
+        const size = await probe(element.src, kind)
+        // 谱图完整等比放进原槽位,禁止拉伸音符或裁掉小节。
+        if (element.type === "image" && scoreUrls.has(element.src) && size.width && size.height) {
+          const scale = Math.min(element.width / size.width, element.height / size.height)
+          const width = size.width * scale
+          const height = size.height * scale
+          element.left += (element.width - width) / 2
+          element.top += (element.height - height) / 2
+          element.width = width
+          element.height = height
+        }
+      } catch (error) {
+        issues.push({ code: "resource_unavailable", severity: "error", pageNo: index + 1,
+          message: `${kind === "image" ? "图片" : kind === "audio" ? "音频" : "视频"}${error instanceof Error ? error.message : "不可用"},请更换资源后重试` })
+      }
+    }))
+  }))
+  return issues
+}

+ 53 - 44
src/utils/aiPptTemplateEngine.ts

@@ -5,7 +5,7 @@ import type {
   PPTElementShadow,
   PPTImageElement,
   PPTVideoElement,
-  PPTEnjoyElement,
+  PPTAudioElement,
   PPTCloudCoachElement,
   PPTLineElement,
   PPTAnimation,
@@ -16,6 +16,7 @@ import type {
   SlideTheme
 } from "@/types/slides"
 import type { AiCoursewareOutline, AiCoursewarePage } from "@/api/aiCourseware"
+import { audioBindings, scoreBinding, isScorePage, usableResourceUrl } from "@/utils/aiPptContent"
 import {
   AI_COURSEWARE_TEMPLATES,
   getAiCoursewarePalette,
@@ -102,7 +103,7 @@ function buildSlide(page: AiCoursewarePage, outline: AiCoursewareOutline, palett
   const role = pageRole(page, index)
   const intent = pageRenderIntent(page, role)
   const master = getAiCoursewareTemplateMaster(options.templateId!)
-  const baseMasterPage = getTemplateMasterPage(master, role, preferredLayout(page, intent))
+  const baseMasterPage = getTemplateMasterPage(master, role, isScorePage(page) ? "diagram_focus" : videoBinding(page) || page.requiredVisual ? "split_content" : page.interaction?.question ? "text_focus" : preferredLayout(page, intent))
   const occurrence = layoutOccurrences.get(baseMasterPage.id) || 0
   layoutOccurrences.set(baseMasterPage.id, occurrence + 1)
   const masterPage = adaptMasterPage(selectTemplateMasterPageVariant(baseMasterPage, occurrence), intent)
@@ -134,16 +135,29 @@ function fillTemplatePage(master: TemplateMaster, masterPage: TemplateMasterPage
   const elements: PPTElement[] = []
   const title = masterPage.layout === "cover" ? chapterTitle(outline, page) : displayPageTitle(page, outline)
   // 学生可见内容必须与编辑区保持一致,不能因模板容量静默丢弃条目。
-  const points = studentPoints(page)
   const headline = activityTask(page) || studentHeadline(page)
+  const points = studentPoints(page).filter(point => !sameVisibleText(point, headline))
 
   addSlotText(elements, slotOf(masterPage, "title"), title, masterPage.layout === "cover" ? master.coverTitleColor : master.titleColor, true)
 
+  if (role !== "cover" && isScorePage(page)) {
+    addNotationVisual(elements, slotOf(masterPage, "image"), page, master, palette)
+    addSlotList(elements, slotOf(masterPage, "body"), uniqueVisibleTexts([headline, ...points, page.interaction?.question || "", ...(page.interaction?.options || [])]), master, palette, false)
+    addInlineMedia(elements, slotOf(masterPage, "media"), page, master, palette, intent.showMedia)
+    return elements
+  }
+  if (role !== "cover" && page.interaction?.question) {
+    addVisualOrResource(elements, slotOf(masterPage, "image"), page, master, palette, false)
+    addSlotList(elements, slotOf(masterPage, "body"), uniqueVisibleTexts([page.interaction.question, headline, ...points, ...(page.interaction.options || [])]), master, palette, false)
+    addInlineMedia(elements, slotOf(masterPage, "media"), page, master, palette, intent.showMedia)
+    return elements
+  }
+
   if (masterPage.layout === "cover") {
     const meta = [outline.gradeName, conciseTextbook(outline.textbookName), outline.unitName].filter(Boolean).join(" · ")
     const coverSecondaryColor = master.coverTitleColor.toUpperCase() === "#FFFFFF" ? "#DDE6F4" : master.mutedColor
     addSlotText(elements, slotOf(masterPage, "meta"), meta, coverSecondaryColor)
-    addSlotList(elements, slotOf(masterPage, "headline"), points, master, palette, false, master.coverTitleColor)
+    addSlotList(elements, slotOf(masterPage, "headline"), uniqueVisibleTexts([headline, ...points]), master, palette, false, master.coverTitleColor)
     addVisualOrResource(elements, slotOf(masterPage, "image"), page, master, palette, !masterPage.backgroundVisual)
     return elements
   }
@@ -188,7 +202,8 @@ function fillTemplatePage(master: TemplateMaster, masterPage: TemplateMasterPage
     if (role === "notation") addNotationVisual(elements, slotOf(masterPage, "image"), page, master, palette, false)
     else if (role === "rhythm") addStepGrid(elements, slotOf(masterPage, "image"), activitySteps(page), master, palette)
     else addVisualOrResource(elements, slotOf(masterPage, "image"), page, master, palette, true)
-    if (role !== "rhythm") addSlotList(elements, slotOf(masterPage, "body"), points, master, palette)
+    if (role !== "rhythm") addSlotList(elements, slotOf(masterPage, "body"), uniqueVisibleTexts([headline, ...points]), master, palette)
+    else addSlotText(elements, slotOf(masterPage, "body"), headline, master.textColor)
     addInlineMedia(elements, slotOf(masterPage, "media"), page, master, palette, intent.showMedia)
     return elements
   }
@@ -356,7 +371,8 @@ function collectionGap(height: number, count: number, preferred: number) {
 }
 
 function adaptiveMinimumFont(slot: TemplateSlot, availableHeight: number) {
-  return Math.min(slot.minimumFontSize || 22, Math.max(12, Math.floor(availableHeight / 1.35)))
+  // 内容过多时交给质量门禁提示拆页,不能靠无限缩小正文隐藏问题。
+  return Math.max(24, slot.minimumFontSize || 24)
 }
 
 function addCardFrame(elements: PPTElement[], left: number, top: number, width: number, height: number, index: number, master: TemplateMaster, palette: Palette) {
@@ -455,23 +471,16 @@ function addLyricLines(elements: PPTElement[], slot: TemplateSlot | undefined, s
 
 function addNotationVisual(elements: PPTElement[], slot: TemplateSlot | undefined, page: AiCoursewarePage, master: TemplateMaster, palette: Palette, backgroundVisual = false) {
   if (!slot) return
-  if (hasImage(page)) {
-    addVisualOrResource(elements, slot, page, master, palette)
-    return
+  const binding = scoreBinding(page)
+  if (!binding) return // 缺谱在教师质量面板中提示,绝不绘制虚构谱例。
+  if (usableResourceUrl(binding.scoreImageUrl)) {
+    // 保持整张曲谱,不使用 cover 裁切,防止丢失谱号、小节或歌词。
+    elements.push({ id: nanoid(10), type: "image", src: binding.scoreImageUrl!, left: slot.left, top: slot.top,
+      width: slot.width, height: slot.height, fixedRatio: true, rotate: 0 } as PPTImageElement)
+  } else {
+    elements.push({ id: nanoid(10), type: "elf", subtype: "elf-sing-play", sid: binding.resourceId,
+      title: binding.name || page.title, left: slot.left, top: slot.top, width: slot.width, height: slot.height, rotate: 0 } as PPTCloudCoachElement)
   }
-  if (backgroundVisual) return
-  elements.push(shapeElement({ left: slot.left, top: slot.top, width: slot.width, height: slot.height, fill: master.surfaceColor, radius: 16, shadow: lightShadow() }))
-  const lineTop = slot.top + 126
-  for (let index = 0; index < 5; index += 1) elements.push(lineElement(slot.left + 70, lineTop + index * 38, slot.left + slot.width - 70, lineTop + index * 38, master.borderColor, 2))
-  const notes = [0.13, 0.31, 0.49, 0.68, 0.82]
-  notes.forEach((ratio, index) => {
-    const left = slot.left + slot.width * ratio
-    const top = lineTop + (index % 4) * 38 - 15
-    elements.push(shapeElement({ left, top, width: 30, height: 24, fill: index % 2 ? palette.secondary : palette.primary, radius: 12 }))
-    elements.push(shapeElement({ left: left + 25, top: top - 54, width: 5, height: 66, fill: index % 2 ? palette.secondary : palette.primary }))
-  })
-  const cue = cleanText(page.visualPlan?.focalContent || page.visualPlan?.mainVisual || "旋律走向与节奏位置")
-  elements.push(textElement({ left: slot.left + 70, top: slot.top + 42, width: slot.width - 140, height: 56, content: cue, fontSize: 28, color: master.titleColor, bold: true }))
 }
 
 function addStepGrid(elements: PPTElement[], slot: TemplateSlot | undefined, sourceItems: string[], master: TemplateMaster, palette: Palette) {
@@ -600,8 +609,7 @@ function addVisualOrResource(elements: PPTElement[], slot: TemplateSlot | undefi
     elements.push(imageElement(page, { left: slot.left, top: slot.top, width: slot.width, height: slot.height, radius: 18 }))
     return
   }
-  if (!allowPlaceholder) return
-  addSemanticVisual(elements, slot, page, master, palette)
+  // 无教学图片时留给文字排版;通用装饰不能充当观察对象。
 }
 
 function addSemanticVisual(elements: PPTElement[], slot: TemplateSlot, page: AiCoursewarePage, master: TemplateMaster, palette: Palette) {
@@ -630,18 +638,15 @@ function addSemanticVisual(elements: PPTElement[], slot: TemplateSlot, page: AiC
 
 function addInlineMedia(elements: PPTElement[], slot: TemplateSlot | undefined, page: AiCoursewarePage, master: TemplateMaster, palette: Palette, showMedia: boolean) {
   if (!slot || !showMedia) return
-  const binding = (page.resourceBindings || []).find(item => item.verified !== false && item.resourceType === "SONG" && item.contentUrl)
-  if (!binding) {
-    elements.push(shapeElement({ left: slot.left, top: slot.top, width: slot.width, height: slot.height, fill: master.surfaceColor, opacity: 0.78, radius: 12 }))
-    elements.push(shapeElement({ left: slot.left + 22, top: slot.top + slot.height / 2 - 17, width: 34, height: 34, fill: palette.primary, opacity: 0.82, radius: 17 }))
-    elements.push(textElement({ left: slot.left + 76, top: slot.top + 11, width: slot.width - 98, height: slot.height - 22, content: "本页需要课堂音视频,请老师在此补充", fontSize: 24, minimumFontSize: 22, color: master.textColor }))
-    return
-  }
-  elements.push({
-    id: nanoid(10), type: "elf", subtype: "elf-enjoy", left: slot.left, top: slot.top, width: slot.width, height: slot.height, rotate: 0,
-    sid: binding.resourceId, title: binding.name || page.title, src: binding.contentUrl || "",
-    enjoyList: [{ id: binding.resourceId, title: binding.name || page.title, src: binding.contentUrl || "" }]
-  } as PPTEnjoyElement)
+  const bindings = audioBindings(page)
+  const width = slot.width / Math.max(1, bindings.length)
+  bindings.forEach((binding, index) => {
+    elements.push({ id: nanoid(10), type: "elf", subtype: "elf-audio", left: slot.left + index * width,
+      top: slot.top + (slot.height - 60) / 2, width: 60, height: 60, rotate: 0, fixedRatio: true,
+      color: palette.primary, autoplay: false, loop: false, src: binding.contentUrl! } as PPTAudioElement)
+    addSlotText(elements, { ...slot, left: slot.left + index * width + 76, width: width - 92 },
+      `${bindings.length > 1 ? String.fromCharCode(65 + index) + " · " : ""}${binding.name || "课堂音频"}`, master.textColor)
+  })
 }
 
 function renderTemplateMasterElements(elements: TemplateMasterElement[]): PPTElement[] {
@@ -672,7 +677,7 @@ function renderTemplateMasterElements(elements: TemplateMasterElement[]): PPTEle
 function entranceAnimations(elements: PPTElement[]): PPTAnimation[] {
   return elements
     .filter(
-      element => (element.type === "text" || element.type === "image" || element.type === "shape") && element.width >= 120 && element.height >= 45
+       element => element.type === "text" && element.width >= 120 && element.height >= 45
     )
     .slice(0, 6)
     .map((element, index) => ({
@@ -681,12 +686,12 @@ function entranceAnimations(elements: PPTElement[]): PPTAnimation[] {
       effect: index === 0 ? "fadeIn" : "fadeInUp",
       type: "in",
       duration: 500,
-      trigger: index === 0 ? "auto" : "click"
+       trigger: index === 0 ? "auto" : "click"
     }))
 }
 
 function videoBinding(page: AiCoursewarePage) {
-  return (page.resourceBindings || []).find(item => item.verified !== false && item.resourceType === "VIDEO" && item.contentUrl)
+  return (page.resourceBindings || []).find(item => item.verified === true && item.resourceType === "VIDEO" && usableResourceUrl(item.contentUrl))
 }
 
 function normalizeOutline(outline: AiCoursewareOutline): AiCoursewareOutline {
@@ -732,7 +737,7 @@ function displayPageTitle(page: AiCoursewarePage, outline: AiCoursewareOutline)
 }
 
 function chapterTitle(outline: AiCoursewareOutline, page: AiCoursewarePage) {
-  return cleanText(outline.chapterName || outline.title || page.title || "音乐课件")
+  return cleanText(page.title || outline.title || outline.chapterName || "音乐课件")
 }
 
 function conciseTextbook(value?: string) {
@@ -751,15 +756,16 @@ function studentHeadline(page: AiCoursewarePage) {
 function studentPoints(page: AiCoursewarePage) {
   const structured = (page.studentContent?.points || []).map(cleanText).filter(Boolean)
   const legacy = (page.bullets || []).map(cleanText).filter(Boolean)
-  const points = structured.length ? structured : legacy
-  const excluded = [page.title, studentHeadline(page)].map(normalizeVisibleText).filter(Boolean)
+  const points = [...(structured.length ? structured : legacy), studentHeadline(page)].filter(Boolean)
+  const excluded = [page.title].map(normalizeVisibleText).filter(Boolean)
   const unique = uniqueVisibleTexts(points).filter(item => !excluded.includes(normalizeVisibleText(item)))
   return unique.length ? unique : [studentHeadline(page) || cleanText(page.title) || "课堂内容"]
 }
 
 function activityTask(page: AiCoursewarePage) {
   // activity 属于教师组织信息,不能回退到学生投屏画面。
-  return cleanText(page.classroomAction?.studentTask || studentHeadline(page) || "")
+  const task = cleanText(page.classroomAction?.studentTask || studentHeadline(page) || "")
+  return sameVisibleText(task, page.title) ? "" : task
 }
 
 function activitySteps(page: AiCoursewarePage) {
@@ -774,7 +780,10 @@ function slideRemark(page: AiCoursewarePage) {
   const activity = [page.activity?.task, ...(page.activity?.steps || [])].map(cleanText).filter(Boolean).join("\n")
   const fallbackInstruction = cleanText(page.classroomAction?.teacherInstruction || page.teacherTips || page.speakerNotes)
     || `引导学生完成“${activityTask(page) || page.title}”,观察学生是否能用语言、动作或声音表达本页学习结果。`
-  return [lessonMeta, page.teachingPurpose, activity, fallbackInstruction, page.speakerNotes, page.teacherTips, evidence]
+  const answer = page.interaction?.answer ? `参考答案:${page.interaction.answer}` : ""
+  const explanation = page.interaction?.explanation ? `解析:${page.interaction.explanation}` : ""
+  const resources = (page.resourceBindings || []).map(item => `${item.name || item.resourceType}:${item.purpose || "课堂资源"}`).join("\n")
+  return [lessonMeta, page.teachingPurpose, activity, fallbackInstruction, page.speakerNotes, page.teacherTips, evidence, answer, explanation, resources]
     .map(cleanText)
     .filter(Boolean)
     .filter((item, index, all) => all.indexOf(item) === index)

+ 230 - 38
src/views/Editor/EditorHeader/AiCoursewareDialog.vue

@@ -62,6 +62,10 @@
         </div>
 
         <div class="extra">
+          <label class="label">本节课范围</label>
+          <TextArea :value="preferences.lessonScope || ''" @update:value="preferences.lessonScope = $event" :rows="2" placeholder="例如:第一课时,重点学习第一段,不安排整曲演唱" :disabled="working" />
+          <label class="label">学生基础与课堂条件</label>
+          <TextArea :value="preferences.learningContext || ''" @update:value="preferences.learningContext = $event" :rows="2" placeholder="例如:一年级,尚不识谱,有打击乐器,需离线播放" :disabled="working" />
           <div class="label">还有什么特别要求?<span>选填</span></div>
           <TextArea v-model:value="extraPrompt" :rows="3" placeholder="例如:多安排节奏游戏,语言适合一年级学生" :disabled="working" />
         </div>
@@ -97,6 +101,31 @@
       </div>
 
       <div v-else-if="workflowStep === 2 && outline" class="outline unified-review">
+        <details class="teaching-review" :open="!outlineConfirmed">
+          <summary>教材依据与教学主线</summary>
+          <p>{{ reliableResearch ? "已检索到匹配教材的依据,请确认本课教学范围。" : "教材尚未精确匹配,暂不能生成授课成品。请返回核对课程信息后重新检索。" }}</p>
+          <p>{{ outline.textbookResearch?.contentSummary }}</p>
+          <p v-for="item in outline.textbookResearch?.missingEvidence || []" :key="item">待核对:{{ item }}</p>
+          <a v-for="source in researchSources" :key="source.url" :href="source.url" target="_blank" rel="noopener noreferrer">{{ source.title || "教材来源" }}</a>
+          <ol><li v-for="objective in outline.textbookResearch?.teachingObjectives || []" :key="objective">{{ objective }}</li></ol>
+          <div v-if="!outlineConfirmed" class="outline-plan">
+            <label v-for="page in outline.pages" :key="page.pageNo">
+              <span>第{{ page.pageNo }}页 · {{ page.teachingStage }}</span>
+              <input v-model="page.title" aria-label="页面标题" />
+              <input v-model="page.teachingPurpose" aria-label="教学目的" placeholder="本页教学目的" />
+            </label>
+          </div>
+        </details>
+        <section v-if="displayedConverted && draftQuality" class="quality-panel" aria-live="polite">
+          <strong>{{ draftQuality.passed ? "结构与资源检查通过 · 请试讲确认" : `待修正 ${draftQuality.errors.length} 项,暂不能作为授课成品应用` }}</strong>
+          <span>必讲 {{ plannedMinutes }} / {{ preferences.lessonMinutes }} 分钟</span>
+          <details v-if="draftQuality.issues.length" :open="!draftQuality.passed">
+            <summary>查看检查结果(规则检查,不代表教学准确率)</summary>
+            <button v-for="(issue, index) in draftQuality.issues" :key="index" type="button" @click="issue.pageNo && selectWorkspacePage(issue.pageNo - 1)">
+              {{ issue.severity === 'error' ? '需修正' : '建议' }} · {{ issue.pageNo ? `第${issue.pageNo}页:` : '' }}{{ issue.message }}
+            </button>
+          </details>
+        </section>
         <div class="sample-stage-head">
           <div class="sample-template-current">
             <span>当前样式</span>
@@ -132,7 +161,7 @@
           </div>
         </div>
 
-        <div v-if="!workspaceSlide" :class="['final-preview-placeholder', draftPreparationState]">
+        <div v-if="!workspaceSlide && outlineConfirmed" :class="['final-preview-placeholder', draftPreparationState]">
           <span v-if="draftPreparationState === 'optimizing'" class="loading-dot"></span>
           <span v-else class="placeholder-icon">!</span>
           <strong>{{ draftPreparationState === "failed" ? "课件生成失败" : generationStatusTitle }}</strong>
@@ -181,6 +210,7 @@
             </div>
             <div v-if="sampleBackgroundState.status === 'error'" class="template-load-error">模板背景加载失败,请更换模板或稍后重试</div>
             <div class="sample-feedback workspace-feedback">
+              <label><input v-model="editTeachingContent" type="checkbox" :disabled="working" />同时修改本页教学内容(保留其他页面)</label>
               <div class="feedback-label">图片或版式需要调整?</div>
               <div class="feedback-chips">
                 <button
@@ -199,6 +229,16 @@
                 <Button @click="adjustCurrentPage" :disabled="working">调整当前页</Button>
               </div>
             </div>
+            <details class="teaching-review" v-if="workspacePage">
+              <summary>教师备注、用时与课堂资源</summary>
+              <label>用时(分钟)<input type="number" min="1" max="60" v-model.number="workspacePage.durationMinutes" :disabled="working" @input="refreshEditedDraft" /></label>
+              <label><input type="checkbox" v-model="workspacePage.optional" :disabled="working" @change="refreshEditedDraft" />选讲页,不计入必讲时长</label>
+              <textarea v-model="workspacePage.speakerNotes" :disabled="working" @blur="refreshEditedDraft" aria-label="教师备注"></textarea>
+              <p v-if="workspacePage.interaction?.answer">参考答案:{{ workspacePage.interaction.answer }} {{ workspacePage.interaction.explanation }}</p>
+              <label v-for="resource in availableResources" :key="resource.resourceType + resource.resourceId">
+                <input type="checkbox" :checked="pageHasResource(resource)" :disabled="working" @change="toggleResource(resource)" />{{ resource.resourceType }} · {{ resource.name }}
+              </label>
+            </details>
           </section>
         </div>
       </div>
@@ -211,10 +251,13 @@
         </template>
         <template v-else-if="isStyleStep">
           <Button @click="workflowStep = 1" :disabled="working">返回修改需求</Button>
+          <Button v-if="!outlineConfirmed" type="primary" :disabled="working || !reliableResearch" @click="confirmOutline">确认教学安排并生成</Button>
+          <Button v-if="displayedConverted" :disabled="working" @click="saveDraftExplicitly">保存初稿</Button>
+          <Button v-if="previousCourseware" :disabled="working" @click="restorePreviousCourseware">恢复应用前课件</Button>
           <Button
             v-if="displayedConverted && draftPreparationState === 'ready'"
             type="primary"
-            :disabled="sampleBackgroundState.status === 'error'"
+            :disabled="working || !draftQuality?.passed || sampleBackgroundState.status === 'error'"
             @click="applyDraft"
             >应用到当前课件</Button
           >
@@ -249,6 +292,7 @@ import {
 } from "@/api/aiCourseware"
 import { aiCoursewareToSlides } from "@/utils/aiCoursewareToSlides"
 import { inspectAiPptQuality, type AiPptQualityReport } from "@/utils/aiPptQuality"
+import { inspectAiPptResources } from "@/utils/aiPptResources"
 import {
   AI_COURSEWARE_TEMPLATES,
   getAiCoursewareTemplate,
@@ -258,6 +302,9 @@ import {
 } from "@/config/aiCoursewareTemplates"
 import { getAiCoursewareTemplateMaster } from "@/config/aiCoursewareTemplateMasters"
 import { resolveTemplateAssetUrl } from "@/utils/templateAssetUrl"
+import { draftKey, loadAiDraft, saveAiDraft, type AiDraftRecord, type ProtectedPageContent } from "@/utils/aiCoursewareDraft"
+import { usableResourceUrl } from "@/utils/aiPptContent"
+import type { AiCoursewareResourceBinding } from "@/api/aiCourseware"
 import type { Slide, SlideTheme } from "@/types/slides"
 
 const props = defineProps<{
@@ -274,6 +321,13 @@ const { addHistorySnapshot } = useHistorySnapshot()
 
 const extraPrompt = ref("")
 const outline = ref<AiCoursewareOutline>()
+const outlineConfirmed = ref(false)
+const editTeachingContent = ref(false)
+const availableResources = ref<AiCoursewareResourceBinding[]>([])
+const previousCourseware = ref<AiDraftRecord["backup"]>()
+const reliableResearch = computed(() => outline.value?.textbookResearch?.exactMatch === true && outline.value.textbookResearch.confidence === "HIGH" && researchSources.value.length > 0)
+const researchSources = computed(() => (outline.value?.textbookResearch?.sources || []).filter(source => /^https?:\/\//i.test(source.url)))
+const plannedMinutes = computed(() => (displayedOutline.value?.pages || []).filter(page => !page.optional).reduce((total, page) => total + (Number(page.durationMinutes) || 0), 0))
 const taskInfo = ref<AiCoursewareTaskInfo>()
 const workflowStep = ref<1 | 2>(1)
 const isSetupStep = computed(() => workflowStep.value === 1)
@@ -313,15 +367,6 @@ const displayedTemplateId = ref<AiCoursewareTemplateId>()
 const draftQuality = ref<AiPptQualityReport>()
 const draftPreparationState = ref<"idle" | "optimizing" | "ready" | "failed">("idle")
 const draftFailureMessage = ref("请稍后重试")
-type ProtectedPageContent = {
-  title: string
-  subtitle?: string
-  bullets: string[]
-  studentContent?: AiCoursewarePage["studentContent"]
-  studentTask?: string
-  learningEvidence?: string
-  interaction?: AiCoursewarePage["interaction"]
-}
 const protectedPageContent = new Map<number, ProtectedPageContent>()
 const TEACHER_VISIBLE_PATTERN = /(?:教师|老师)(?:讲述|提问|引导|指导|组织|巡视|示范)|(?:引导|指导|组织)学生/
 const PLACEHOLDER_PATTERN = /(?:待补充|待完善|请输入|可参考教材|placeholder|lorem ipsum|\{\{.+?\}\}|\[.+?占位.+?\])/i
@@ -415,6 +460,7 @@ function handleWorkspaceTextChange(payload: { elementId: string; content: string
   protectPageContent(page)
   restoreProtectedPageContent(outline.value)
   restoreProtectedPageContent(draftOutline.value)
+  refreshEditedDraft()
   ElMessage.success("文字已更新")
 }
 
@@ -501,6 +547,7 @@ watch(
   () => props.visible,
   visible => {
     if (!visible) clearTask()
+    else if (!outline.value) void restoreSavedDraft()
   }
 )
 
@@ -535,9 +582,10 @@ async function handleOutline() {
     selectedDraftPageIndex.value = 0
     sampleFeedbackTags.value = []
     sampleFeedbackText.value = ""
-    draftPreparationState.value = "optimizing"
+    outlineConfirmed.value = false
+    draftPreparationState.value = "idle"
     workflowStep.value = 2
-    await confirmSamplesAndGenerate()
+    await persistDraft()
   }
 }
 
@@ -547,8 +595,7 @@ async function continueToFinalPreview() {
     draftPreparationState.value = "ready"
     return
   }
-  draftPreparationState.value = "optimizing"
-  await confirmSamplesAndGenerate()
+  if (outlineConfirmed.value) await confirmSamplesAndGenerate()
 }
 
 async function regenerateFinalPreview() {
@@ -575,7 +622,7 @@ function handleFinalPreparationFailure(message = "自动生成和优化未能完
     selectedTemplateId.value = displayedTemplateId.value
     draftOutline.value = cloneCoursewareData(displayedOutline.value)
     draftConverted.value = cloneCoursewareData(displayedConverted.value)
-    draftQuality.value = undefined
+    draftQuality.value = inspectAiPptQuality(displayedOutline.value, displayedConverted.value.slides, preferences)
     draftPreparationState.value = "ready"
     ElMessage.warning(`${message},已保留上一次最终预览`)
     return
@@ -613,19 +660,19 @@ async function handleGenerate() {
   }
 
   taskInfo.value = res.data as AiCoursewareTaskInfo
+  await persistDraft()
   await pollTask(taskInfo.value.taskId)
 }
 
 async function pollTask(taskId: string) {
   if (pollTimer.value) window.clearInterval(pollTimer.value)
   await fetchTask(taskId)
-  if (taskInfo.value?.status === "PENDING" || taskInfo.value?.status === "RUNNING") {
-    pollTimer.value = window.setInterval(() => fetchTask(taskId), 2000)
-  }
 }
 
 async function fetchTask(taskId: string) {
+  try {
   const res = await httpAjaxErrMsg(aiCoursewareTaskApi, taskId)
+  if (!props.visible || taskInfo.value?.taskId !== taskId) return
   if (res.code !== 200) {
     clearTask()
     handleFinalPreparationFailure(res.message || "未能取得课件生成进度,请重试")
@@ -642,6 +689,12 @@ async function fetchTask(taskId: string) {
     console.error("AI课件生成失败", message)
     handleFinalPreparationFailure(message)
   }
+  else pollTimer.value = window.setTimeout(() => fetchTask(taskId), 2000)
+  } catch {
+    if (!props.visible || taskInfo.value?.taskId !== taskId) return
+    clearPollTimer()
+    handleFinalPreparationFailure("生成进度读取失败,可重新打开窗口恢复任务")
+  }
 }
 
 function clearTask() {
@@ -671,6 +724,7 @@ async function loadDraft(taskId: string) {
     return
   }
   draftOutline.value = result.outline
+  availableResources.value = Array.isArray(result.context?.resources) ? result.context.resources : []
   restoreProtectedPageContent(draftOutline.value)
   draftOutline.value.templateId = selectedTemplateId.value
   selectedDraftPageIndex.value = 0
@@ -697,6 +751,8 @@ function rebuildDraftPreview() {
 
 async function regenerateDraftPage(index: number) {
   if (!draftOutline.value || !pptWork.id) return
+  if (editTeachingContent.value) protectedPageContent.delete(index + 1)
+  else if (displayedOutline.value?.pages[index]) protectPageContent(displayedOutline.value.pages[index])
   draftPreparationState.value = "optimizing"
   draftFailureMessage.value = "请稍后重试"
   restoreProtectedPageContent(draftOutline.value)
@@ -707,7 +763,7 @@ async function regenerateDraftPage(index: number) {
     fromType: queryParams.pptResourcesType || queryParams.fromType,
     outline: draftOutline.value,
     templateId: selectedTemplateId.value,
-    visualPrompt: `${buildVisualPrompt()}请只调整第${index + 1}页图片和版式,保持页面文字完全不变。${feedback ? `用户反馈:${feedback}。` : "请优化视觉组织和信息层级。"}`,
+    visualPrompt: `${buildVisualPrompt()}请只调整第${index + 1}页。${editTeachingContent.value ? "允许按反馈修改本页教学内容,保持整课目标不变。" : "只修改图片和版式,保持页面文字完全不变。"}${feedback ? `用户反馈:${feedback}。` : "请优化视觉组织和信息层级。"}`,
     preferences: { ...preferences },
     regeneratePageNo: index + 1
   })
@@ -716,11 +772,22 @@ async function regenerateDraftPage(index: number) {
     return
   }
   taskInfo.value = res.data as AiCoursewareTaskInfo
+  await persistDraft()
   await pollTask(taskInfo.value.taskId)
 }
 
-function applyDraft() {
-  if (!displayedConverted.value || draftPreparationState.value !== "ready") return
+async function applyDraft() {
+  if (!displayedConverted.value || !displayedOutline.value || working.value) return
+  draftPreparationState.value = "optimizing"
+  const resourceIssues = await inspectAiPptResources(displayedOutline.value, displayedConverted.value.slides)
+  draftQuality.value = inspectAiPptQuality(displayedOutline.value, displayedConverted.value.slides, preferences)
+  appendResourceIssues(resourceIssues)
+  draftPreparationState.value = "ready"
+  if (!draftQuality.value.passed) return
+  previousCourseware.value = cloneCoursewareData({ title: slidesStore.title, theme: slidesStore.theme, slides: slidesStore.slides, width: slidesStore.viewportSize, ratio: slidesStore.viewportRatio })
+  if (!await persistDraft()) return
+  slidesStore.setViewportSize(1920)
+  slidesStore.setViewportRatio(0.5625)
   slidesStore.setTitle(displayedConverted.value.title)
   slidesStore.setTheme(displayedConverted.value.theme)
   slidesStore.setSlides(displayedConverted.value.slides)
@@ -786,13 +853,19 @@ async function selectTemplate(templateId: AiCoursewareTemplateId, fromSample = f
     templatePickerExpanded.value = false
     showAllTemplates.value = false
   }
-  await regenerateFinalPreview()
+  if (displayedOutline.value) {
+    draftOutline.value = cloneCoursewareData(displayedOutline.value)
+    await prepareDraftForUse()
+  }
 }
 
 async function useRecommendedTemplate() {
   templateSelectionMode.value = "auto"
   selectedTemplateId.value = templateRecommendation.value.id
-  await regenerateFinalPreview()
+  if (displayedOutline.value) {
+    draftOutline.value = cloneCoursewareData(displayedOutline.value)
+    await prepareDraftForUse()
+  }
 }
 
 function templateCoverStyle(templateId: AiCoursewareTemplateId): Record<string, string> {
@@ -807,8 +880,9 @@ function preloadImage(url: string) {
   if (!url || typeof Image === "undefined") return Promise.resolve()
   return new Promise<void>((resolve, reject) => {
     const image = new Image()
-    image.onload = () => resolve()
-    image.onerror = () => reject(new Error(`failed to load ${url}`))
+    const timer = window.setTimeout(() => reject(new Error("图片加载超时")), 15000)
+    image.onload = () => { window.clearTimeout(timer); resolve() }
+    image.onerror = () => { window.clearTimeout(timer); reject(new Error(`failed to load ${url}`)) }
     image.src = url
   })
 }
@@ -830,12 +904,12 @@ async function prepareDraftForUse() {
       handleFinalPreparationFailure("课件页面数据不完整,请重新生成")
       return
     }
-    if (draftQuality.value?.errors.length) {
-      console.warn("AI课件质量检查发现非阻断问题", draftQuality.value.errors)
-    }
     publishFinalPreview()
+    const resourceIssues = await inspectAiPptResources(displayedOutline.value!, displayedConverted.value!.slides)
+    appendResourceIssues(resourceIssues)
     draftFailureMessage.value = "请稍后重试"
     draftPreparationState.value = "ready"
+    await persistDraft()
   } catch (error) {
     console.error("AI课件预览转换失败", error)
     handleFinalPreparationFailure(error instanceof Error ? error.message : "课件预览转换失败,请重试")
@@ -844,7 +918,6 @@ async function prepareDraftForUse() {
 
 function normalizeDraftLocally() {
   if (!draftOutline.value) return
-  const imageUseCounts = new Map<string, number>()
   draftOutline.value.pages.forEach((page, index) => {
     if (!page.visualPlan) page.visualPlan = {}
     if (!page.studentContent) page.studentContent = { headline: "", points: [] }
@@ -890,14 +963,7 @@ function normalizeDraftLocally() {
       page.visualPlan.mainVisual ||= page.imageKeywords?.[0] || `${page.title}的课堂主视觉`
     }
 
-    if (page.image?.url) {
-      const useCount = (imageUseCounts.get(page.image.url) || 0) + 1
-      imageUseCounts.set(page.image.url, useCount)
-      if (useCount > 2) {
-        page.image = undefined
-        page.resourceBindings = (page.resourceBindings || []).filter(binding => binding.resourceType !== "IMG")
-      }
-    }
+    // 同一观察对象可以跨页复用;重复图只给出建议,不擅自删除教学资源。
 
     if (index > 0) {
       const fallbackInstruction = `引导学生完成“${page.classroomAction.studentTask || page.title}”,根据学生的语言、动作或声音表现及时反馈。`
@@ -940,6 +1006,114 @@ function buildExtraPrompt() {
   return extraPrompt.value.trim()
 }
 
+async function confirmOutline() {
+  if (!reliableResearch.value || working.value) return
+  outlineConfirmed.value = true
+  await confirmSamplesAndGenerate()
+}
+
+async function refreshEditedDraft() {
+  if (!displayedOutline.value || working.value) return
+  draftOutline.value = cloneCoursewareData(displayedOutline.value)
+  await prepareDraftForUse()
+}
+
+function appendResourceIssues(issues: Awaited<ReturnType<typeof inspectAiPptResources>>) {
+  if (!draftQuality.value) return
+  draftQuality.value.issues.push(...issues)
+  draftQuality.value.errors.push(...issues)
+  draftQuality.value.passed = draftQuality.value.errors.length === 0
+}
+
+function pageHasResource(resource: AiCoursewareResourceBinding) {
+  return workspacePage.value?.resourceBindings?.some(item => item.resourceType === resource.resourceType && item.resourceId === resource.resourceId) || false
+}
+
+function toggleResource(resource: AiCoursewareResourceBinding) {
+  const page = workspacePage.value
+  if (!page || working.value) return
+  const bindings = (page.resourceBindings || []).filter(item => item.resourceType !== resource.resourceType || item.resourceId !== resource.resourceId)
+  if (!pageHasResource(resource)) bindings.push({ ...resource, verified: true, required: true, purpose: "教师为本页选择的教学资源" })
+  page.resourceBindings = bindings
+  if (resource.resourceType === "IMG") {
+    const image = bindings.find(item => item.resourceType === "IMG")
+    page.image = image ? { url: image.contentUrl || "", keyword: image.name || "", provider: "SYSTEM" } : undefined
+  }
+  refreshEditedDraft()
+}
+
+let saveQueue: Promise<unknown> = Promise.resolve()
+async function persistDraft() {
+  if (!pptWork.id) return false
+  const snapshot = cloneCoursewareData({ outline: displayedOutline.value || draftOutline.value || outline.value,
+    converted: displayedConverted.value, preferences: { ...preferences }, extraPrompt: extraPrompt.value,
+    taskId: taskInfo.value?.taskId, resources: availableResources.value, backup: previousCourseware.value,
+    protectedContent: Array.from(protectedPageContent.entries()) })
+  const id = String(pptWork.id)
+  const source = queryParams.pptResourcesType || queryParams.fromType || "TEACHER"
+  try {
+    const key = await draftKey(id, source)
+    saveQueue = saveQueue.catch(() => undefined).then(() => saveAiDraft({ ...snapshot, key, updatedAt: Date.now() }))
+    await saveQueue
+    return true
+  } catch {
+    ElMessage.warning("本机草稿保存失败,请勿刷新页面,并及时手动保存课件")
+    return false
+  }
+}
+
+async function saveDraftExplicitly() {
+  if (await persistDraft()) ElMessage.success("初稿已保存在当前登录会话的本机草稿中,尚未标记为可授课")
+}
+
+async function restoreSavedDraft() {
+  if (!pptWork.id) return
+  const id = String(pptWork.id)
+  try {
+    const saved = await loadAiDraft(await draftKey(id, queryParams.pptResourcesType || queryParams.fromType || "TEACHER"))
+    if (!saved?.outline || outline.value || !props.visible || String(pptWork.id) !== id) return
+    Object.assign(preferences, saved.preferences)
+    pageRange.value = `${preferences.minPages}-${preferences.maxPages}`
+    extraPrompt.value = saved.extraPrompt
+    availableResources.value = saved.resources || []
+    protectedPageContent.clear()
+    for (const [pageNo, content] of saved.protectedContent || []) protectedPageContent.set(pageNo, content)
+    previousCourseware.value = saved.backup
+    outline.value = saved.outline
+    selectedTemplateId.value = getAiCoursewareTemplate(saved.outline.templateId).id
+    draftOutline.value = cloneCoursewareData(saved.outline)
+    workflowStep.value = 2
+    outlineConfirmed.value = Boolean(saved.converted || saved.taskId)
+    if (saved.converted) {
+      displayedOutline.value = cloneCoursewareData(saved.outline)
+      displayedConverted.value = saved.converted
+      displayedTemplateId.value = selectedTemplateId.value
+      draftConverted.value = cloneCoursewareData(saved.converted)
+      draftQuality.value = inspectAiPptQuality(saved.outline, saved.converted.slides, preferences)
+      draftPreparationState.value = "ready"
+    }
+    if (saved.taskId) {
+      taskInfo.value = { taskId: saved.taskId, status: "RUNNING", progress: 0, message: "恢复生成进度" }
+      draftPreparationState.value = "optimizing"
+      await pollTask(saved.taskId)
+    }
+    ElMessage.success("已恢复本机AI课件草稿")
+  } catch { ElMessage.warning("无法读取本机草稿,可重新生成") }
+}
+
+function restorePreviousCourseware() {
+  const backup = previousCourseware.value
+  if (!backup || working.value) return
+  slidesStore.setTitle(backup.title)
+  slidesStore.setTheme(backup.theme)
+  slidesStore.setViewportSize(backup.width)
+  slidesStore.setViewportRatio(backup.ratio)
+  slidesStore.setSlides(cloneCoursewareData(backup.slides))
+  slidesStore.updateSlideIndex(0)
+  addHistorySnapshot()
+  ElMessage.success("已恢复应用前的课件,请手动保存")
+}
+
 function buildVisualPrompt() {
   const base = selectedTemplate.value.visualPrompt
   const system = `模板布局族:${selectedTemplate.value.layoutFamily}。字体体系:${selectedTemplate.value.typography}。背景体系:${selectedTemplate.value.backgroundStyle}。素材画风:${selectedTemplate.value.illustrationStyle}。`
@@ -983,6 +1157,24 @@ function shortProgressLabel(message?: string) {
 </script>
 
 <style lang="scss" scoped>
+.teaching-review, .quality-panel {
+  margin: 10px 0;
+  padding: 12px 16px;
+  border: 1px solid #cbd5df;
+  background: #f8fafc;
+  border-radius: 6px;
+  line-height: 1.6;
+  max-height: 260px;
+  overflow: auto;
+  flex-shrink: 0;
+  summary { cursor: pointer; font-weight: 600; }
+  label, a { display: block; margin: 6px 0; }
+  input:not([type="checkbox"]), textarea { border: 1px solid #aebdca; padding: 6px; max-width: 100%; }
+  textarea { width: 95%; min-height: 70px; }
+  button { display: block; padding: 4px 0; background: transparent; text-align: left; color: #8b3419; cursor: pointer; }
+  span { margin-left: 12px; }
+}
+.outline-plan label { display: grid; grid-template-columns: 130px 1fr 2fr; gap: 8px; }
 .ai-dialog {
   max-height: calc(100vh - 64px);
   display: flex;

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