aiPptDialogTest.html 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205
  1. <!doctype html>
  2. <html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>AI课件流程回归</title></head><body><div id="app"></div>
  3. <script type="module">
  4. import { createApp, h, ref, nextTick } from "vue"
  5. import { createRouter, createMemoryHistory } from "vue-router"
  6. import { store, useSlidesStore, useScreenStore } from "/src/store/index.ts"
  7. import "/src/router/index.ts"
  8. import { httpAxios } from "/src/api/ApiInstance.ts"
  9. import { setToken } from "/src/libs/auth.ts"
  10. import AiDialog from "/src/views/Editor/EditorHeader/AiCoursewareDialog.vue"
  11. import Editor from "/src/views/Editor/index.vue"
  12. import Screen from "/src/views/Screen/index.vue"
  13. import usePptWork from "/src/store/pptWork.ts"
  14. import { useSnapshotStore } from "/src/store/index.ts"
  15. import { inspectAiPptResources } from "/src/utils/aiPptResources.ts"
  16. import { draftKey, loadAiDraft, saveAiDraft } from "/src/utils/aiCoursewareDraft.ts"
  17. import useAiCoursewarePageRepair from "/src/hooks/useAiCoursewarePageRepair.ts"
  18. import { aiCoursewareToSlides } from "/src/utils/aiCoursewareToSlides.ts"
  19. import { liveRegressionFixture } from './fixtures/aiPptLiveRegression.mjs'
  20. import "/src/assets/styles/global.scss"
  21. import "/src/assets/styles/elementTheme.scss"
  22. import "/src/assets/styles/prosemirror.scss"
  23. // 所有业务接口使用本地夹具,不调用生成服务,不修改任何线上课件。
  24. const scenario = new URLSearchParams(location.search).get("scenario") || "normal"
  25. setToken(`local-ai-ppt-regression-${scenario}`)
  26. const generated = ref(0)
  27. const outlineCalls = ref(0), savedCalls = ref(0), submitted = ref(null)
  28. const savedText = ref("")
  29. let pollCount = 0
  30. let outlinePolls = 0
  31. const repairs = ref(0)
  32. const historicalPolls = ref(0)
  33. let repaired = false
  34. const resourceCheck = ref("检查中")
  35. const runtimeError = ref("")
  36. const types = ["cover", "scene_intro", "objectives", "music_knowledge", "rhythm_practice", "performance_activity", "practice_check", "creative_activity", "summary"]
  37. const layouts = ["cover_scene", "hero_focus", "objectives", "split_content", "diagram_focus", "activity", "text_focus", "activity", "summary"]
  38. const outline = {
  39. title: "声音强弱课堂测试", gradeName: "一年级", textbookName: "本地测试教材", chapterName: "声音的强弱", courseType: "music", templateId: "ethnic-flower",
  40. textbookResearch: { exactMatch: true, confidence: "HIGH", contentSummary: "仅用于软件回归的教学夹具", teachingObjectives: ["能用声音表现强弱"], sources: [{ title: "本地测试依据(非真实教材认证)", url: "https://example.com/test-only" }] },
  41. pages: types.map((type, index) => ({ pageNo:index+1, type, title: index === 0 ? "声音强弱课堂测试" : `任务${index}:表现声音强弱`,
  42. teachingBrief: index === 0 ? "从山谷故事引入,邀请学生寻找身边的声音" : "听两组声音,辨别强弱,再用拍手模仿,并说明判断理由",
  43. studentContent:{ points:["用声音表现强弱变化", "说出判断的理由"] }, durationMinutes:index===0?8:4, objectiveIds:["1"],
  44. teachingPurpose:"表现声音强弱", learningEvidence:"能表现强弱并说明理由", speakerNotes:"示范后请学生练习,根据表现及时反馈。",
  45. classroomAction:{studentTask:"完成声音强弱挑战", teacherInstruction:"示范强弱对比,邀请学生练习后互评"},
  46. interaction:type==="practice_check"?{question:"哪种方式声音更轻?",options:["轻声哼唱","大声呼喊"],answer:"轻声哼唱",explanation:"根据声音强弱判断"}:undefined,
  47. visualPlan:{layout:layouts[index]}, imageKeywords:[] }))
  48. }
  49. if (scenario === 'outline-resume') {
  50. const key = await draftKey('local-ai-ppt-regression', 'TEACHER')
  51. await saveAiDraft({ key, updatedAt: Date.now(), outlineTaskId: 'local-outline-restored', extraPrompt: '',
  52. preferences: { minPages: 9, maxPages: 12, lessonMinutes: 40 } })
  53. }
  54. if (scenario === "failed-resume") {
  55. const key = await draftKey("local-ai-ppt-regression", "TEACHER")
  56. if (!await loadAiDraft(key)) await saveAiDraft({
  57. key, updatedAt: Date.now(), outline: structuredClone(outline), taskId: "historical-failed-task", extraPrompt: "",
  58. preferences: { minPages: 9, maxPages: 12, lessonMinutes: 40 }
  59. })
  60. }
  61. httpAxios.axioseRquest = async config => {
  62. let data
  63. if (config.url.endsWith('/outline/task')) { outlineCalls.value++; data = { taskId: `local-outline-${outlineCalls.value}`, status: 'PENDING', progress: 0, message: '已提交大纲任务' } }
  64. else if (config.url.includes('/task/local-outline-')) {
  65. if (config.url.endsWith('/result')) data = { outline: structuredClone(outline), context: {} }
  66. else {
  67. outlinePolls++
  68. if (scenario === 'outline-network' && outlinePolls === 1) throw new Error('测试:读取大纲进度时网络中断')
  69. data = { taskId: config.url.split('/').pop(), status: scenario === 'outline-failure' && outlineCalls.value === 1 ? 'FAILED' : scenario === 'outline-slow' && outlinePolls < 3 ? 'RUNNING' : 'DONE', progress: 10,
  70. message: scenario === 'outline-failure' ? '大纲复核后的页数不符合要求,请重新生成' : '正在核实教材、生成并复核教学大纲,请稍候' }
  71. }
  72. }
  73. else if (config.url.endsWith("/generate")) {
  74. generated.value++
  75. submitted.value = JSON.parse(JSON.stringify(config.data))
  76. pollCount = 0
  77. if (config.data.repairOnly || config.data.regeneratePageNo) { repairs.value++; repaired=true }
  78. data={taskId:`local-task-${generated.value}`,status:"PENDING",progress:0}
  79. }
  80. else if (config.url.endsWith("/result")) {
  81. data={outline:JSON.parse(JSON.stringify(submitted.value?.outline || outline)),context:{resources:[]}}
  82. data.outline.pages.forEach((page, index) => {
  83. const fixture = outline.pages[index % outline.pages.length]
  84. Object.assign(page, { ...structuredClone(fixture), pageNo: index + 1, title: page.title, teachingBrief: page.teachingBrief })
  85. page.studentContent = { points: ['哪种声音更轻?', '试着用拍手表现出来'] }
  86. page.bullets = [...page.studentContent.points]
  87. page.objectiveIds=["1"]
  88. })
  89. if (scenario.includes("draft") && !repaired) {
  90. data.outline.pages.forEach(page => page.objectiveIds=[])
  91. data.reviewMessage="声音强弱的练习与评价尚未完整关联"
  92. }
  93. if (scenario === 'whole-repair-draft') data.reviewMessage = '第2页:学生画面缺少具体范例;整课一致性复核暂未完成'
  94. if (scenario === 'layout-overflow' && !repaired) {
  95. data.outline.pages[1].visualPlan = { layout: 'text_focus', composition: 'steps', visualMode: 'text_only', textPresentation: 'steps' }
  96. data.outline.pages[1].studentContent = { headline: '内容过密回归', points: Array.from({ length: 14 }, (_, i) => `第${i + 1}条:` + '保持完整的教材内容,不能用截字方式通过检查。'.repeat(5)) }
  97. }
  98. if (scenario === 'page-repair') {
  99. const page = data.outline.pages[submitted.value.regeneratePageNo - 1]
  100. page.studentContent = { points: ['重新生成后的课堂任务', '观察图片,说说声音的变化'] }
  101. page.bullets = [...page.studentContent.points]
  102. page.image = { url: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', width: 1, height: 1 }
  103. }
  104. if (scenario.startsWith('image-completion')) {
  105. data.outline.pages.slice(0, 2).forEach((page, index) => {
  106. page.requiredVisual = true
  107. page.visualPlan = { layout: index ? 'hero_focus' : 'cover_scene', composition: 'full_scene', visualMode: 'generated_image', textPresentation: 'keywords', visualShare: 0.65, imageNecessity: 'essential', imagePurpose: 'scene', mainVisual: '公园里的声音' }
  108. page.image = index === 0 || repaired ? { url: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', width: 1, height: 1 } : undefined
  109. page.imageStatus = page.image ? 'ready' : 'generation_failed'
  110. })
  111. }
  112. }
  113. else if (config.url.endsWith("/task/historical-failed-task")) {
  114. historicalPolls.value++
  115. data={taskId:"historical-failed-task",status:"FAILED",progress:50,message:"教学目标1缺少对应实践或评价任务"}
  116. }
  117. else if (config.url.includes("/task/")) {
  118. if (scenario === 'page-repair') await new Promise(resolve => setTimeout(resolve, 1500))
  119. pollCount++
  120. if (scenario.includes("network") && pollCount === 1) throw new Error("测试网络中断")
  121. data={taskId:config.url.split("/").pop(),status:scenario.includes("failure") && generated.value === 1 ? "FAILED" : scenario.includes("slow") && pollCount < 6 ? "RUNNING" : scenario.includes("draft") ? "DRAFT" : "DONE",progress:pollCount < 6 && scenario.includes("slow") ? 50 : 100,message:scenario.includes("failure") ? "测试:生成服务暂不可用" : "正在完善课堂任务"}
  122. if (scenario === 'image-completion' && !repaired) Object.assign(data, { status: 'DRAFT', progress: 95, message: '第2页配图尚未完成;已保留生成结果,可继续完成课件' })
  123. }
  124. else throw new Error("测试禁止未声明的业务请求")
  125. return {data:{code:200,data}}
  126. }
  127. const router=createRouter({history:createMemoryHistory(),routes:[{path:"/",component:{render:()=>null}}]})
  128. await router.push("/?id=local-ai-ppt-regression")
  129. await router.isReady()
  130. const app=createApp({setup(){
  131. const visible=ref(false)
  132. const slidesStore=useSlidesStore()
  133. const pptWork=usePptWork()
  134. const repair = useAiCoursewarePageRepair()
  135. if (scenario === 'editor-live' || scenario === 'screen-live') {
  136. const result = aiCoursewareToSlides(liveRegressionFixture(), { templateId: 'blank' })
  137. slidesStore.setViewportSize(1920)
  138. slidesStore.setViewportRatio(.5625)
  139. slidesStore.setTheme({ ...slidesStore.theme, ...result.theme })
  140. slidesStore.setSlides(result.slides)
  141. slidesStore.updateSlideIndex(6)
  142. }
  143. if (scenario === 'screen-live') return () => h('div', { style: 'height:100vh;width:100vw' }, [h(Screen)])
  144. if (scenario.startsWith('whole-repair')) {
  145. const fixture = structuredClone(outline)
  146. fixture.templateId = 'blank'
  147. slidesStore.setSlides(aiCoursewareToSlides(fixture).slides)
  148. const originalIds = slidesStore.slides.map(slide => slide.id).join(',')
  149. repair.generated([{ code: 'teaching-review', severity: 'error', message: '第2页:请补充具体课堂任务' }], fixture,
  150. { lessonMinutes:40, minPages:9, maxPages:9, resourceMode:'teacher_supplement', enableAnimations:false })
  151. return () => h('main', [
  152. h('h1', '整课完善回归(本地模拟接口)'),
  153. h('button', { disabled: repair.repairingPage.value !== undefined, onClick: () => repair.repair(0) }, '完善整课'),
  154. h('button', { onClick: () => { slidesStore.slides[1].elements.find(e => e.type === 'text').content = '<p>老师手动修改的内容</p>' } }, '手动修改第2页'),
  155. h('p', { id: 'repair-state' }, `状态:${repair.repairingPage.value === undefined ? '空闲' : '完善中'};完善请求:${repairs.value};repairOnly:${submitted.value?.repairOnly === true}`),
  156. h('p', { id: 'repair-error' }, repair.error.value),
  157. h('p', { id: 'repair-issues' }, JSON.stringify(repair.issues.value)),
  158. h('p', { id: 'repair-content' }, slidesStore.slides[1].elements.filter(e => e.type === 'text').map(e => e.content.replace(/<[^>]+>/g, '')).join(';')),
  159. h('p', { id: 'repair-ids' }, `页面标识${slidesStore.slides.map(slide => slide.id).join(',') === originalIds ? '保持不变' : '变化'}`),
  160. h('button', { onClick: () => useSnapshotStore().unDo() }, '测试撤销')
  161. ])
  162. }
  163. if (scenario === 'page-repair') {
  164. const fixture = structuredClone(outline)
  165. fixture.templateId = 'blank'
  166. fixture.pages[1].requiredVisual = true
  167. fixture.pages[1].visualPlan.imagePurpose = 'scene'
  168. slidesStore.setSlides(aiCoursewareToSlides(fixture).slides)
  169. const beforeOthers = JSON.stringify(slidesStore.slides.filter((_, index) => index !== 1))
  170. repair.generated([{ code: 'missing_required_visual', severity: 'error', pageNo: 2, message: '缺少配图' }], fixture, { lessonMinutes:40,minPages:9,maxPages:9,assetStyle:'illustration',sourceImagePolicy:'balanced',enableAnimations:false })
  171. const pageText = () => slidesStore.slides[1].elements.filter(e => e.type === 'text').map(e => e.content.replace(/<[^>]+>/g, '')).join(';')
  172. return () => h('main', [
  173. h('h1', '单页生成回归(本地模拟接口)'),
  174. h('button', { disabled: !!repair.repairingPage.value, onClick: () => repair.repair(2) }, '重新生成第2页'),
  175. h('button', { onClick: () => { slidesStore.slides[1].elements.find(e => e.type === 'text').content = '<p>生成期间手动修改</p>' } }, '修改第2页'),
  176. h('p', { id: 'repair-state' }, `状态:${repair.repairingPage.value ? '生成中' : '空闲'};重试次数:${repairs.value}`),
  177. h('p', { id: 'repair-error' }, repair.error.value),
  178. h('p', { id: 'repair-content' }, pageText()),
  179. h('p', { id: 'repair-image' }, `本页图片:${slidesStore.slides[1].elements.filter(e => e.type === 'image').length}`),
  180. h('p', { id: 'other-pages' }, `其他页面${JSON.stringify(slidesStore.slides.filter((_, index) => index !== 1)) === beforeOthers ? '保持不变' : '被修改'}`),
  181. h('button', { onClick: () => useSnapshotStore().unDo() }, '测试撤销')
  182. ])
  183. }
  184. // 仅隔离存储边界,按钮、编辑器和课件转换均使用正式组件。
  185. pptWork.updatePPT=async()=>{savedText.value=slidesStore.slides.flatMap(slide=>slide.elements.filter(el=>el.type==="text").map(el=>el.content.replace(/<[^>]+>/g,""))).join("、");savedCalls.value++;pptWork.isSave=true}
  186. if (!scenario.startsWith("editor")) nextTick(()=>{visible.value=true})
  187. return ()=>h("main",[
  188. runtimeError.value ? h("pre", runtimeError.value) : null,
  189. savedCalls.value ? h("p",{id:"saved-text"},`保存内容:${savedText.value}`):null,
  190. h("p",{id:"test-status"},`大纲请求:${outlineCalls.value};生成请求次数:${generated.value};完善请求:${repairs.value};保存请求:${savedCalls.value};历史失败任务查询:${historicalPolls.value};已应用页数:${slidesStore.slides.length};资源检查:${resourceCheck.value}`),
  191. 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,
  192. scenario.startsWith("editor") ? h("div",{style:"height:calc(100vh - 65px)"},[h(useScreenStore().screening ? Screen : Editor)]) : h("button",{onClick:()=>{visible.value=true}},"打开AI课件"),
  193. scenario.startsWith("editor") ? null : h(AiDialog,{visible:visible.value,"onUpdate:visible":value=>{visible.value=value}}),
  194. h("button",{onClick:()=>useSnapshotStore().unDo()},"测试撤销")
  195. ])
  196. }})
  197. app.config.errorHandler = error => { runtimeError.value=String(error?.stack || error) }
  198. app.use(store).use(router).mount("#app")
  199. // 使用浏览器真实图片解码验证损坏资源阻断及谱图等比缩放。
  200. 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>')
  201. const score={id:"image",type:"image",src:svg,left:0,top:0,width:200,height:200}
  202. const slides=[{elements:[score,{id:"broken",type:"image",src:"data:image/png;base64,invalid",left:0,top:0,width:100,height:100}]}]
  203. const issues=await inspectAiPptResources({pages:[{resourceBindings:[{resourceType:"MUSIC",scoreImageUrl:svg}]}]},slides)
  204. resourceCheck.value=issues.length===1&&score.width===200&&score.height===50&&score.top===75?"通过(损坏图片阻断、谱图等比缩放)":"失败"
  205. </script></body></html>