aiPptDialogTest.html 17 KB

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