useAiCoursewarePageRepair.ts 9.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138
  1. import { onBeforeUnmount, ref, watch } from "vue"
  2. import { ElMessage } from "element-plus"
  3. import { useMainStore, useSlidesStore, useSnapshotStore } from "@/store"
  4. import usePptWork from "@/store/pptWork"
  5. import queryParams from "@/queryParams"
  6. import { httpAjax } from "@/plugins/httpAjax"
  7. import { aiCoursewareGenerateApi, aiCoursewareTaskApi, aiCoursewareResultApi, type AiCoursewareOutline, type AiCoursewarePreferences } from "@/api/aiCourseware"
  8. import { assertAiPptClientCurrent } from "@/utils/aiPptClientVersion"
  9. import { aiCoursewareToSlides } from "@/utils/aiCoursewareToSlides"
  10. import { getAiPptImageAspectRatios } from "@/utils/aiPptTemplateEngine"
  11. import { DEFAULT_AI_COURSEWARE_TEMPLATE_ID } from "@/config/aiCoursewareTemplates"
  12. import { inspectAiPptQuality, blockingAiPptLayoutIssues, type AiPptQualityIssue } from "@/utils/aiPptQuality"
  13. import { inspectAiPptResources } from "@/utils/aiPptResources"
  14. import { teachingReviewIssues, blockingImageIssues } from '@/utils/aiPptFeedback'
  15. const clone = <T,>(value: T): T => JSON.parse(JSON.stringify(value))
  16. // 单页修复与整课复核共用已有任务接口;始终保护生成期间的新编辑。
  17. export default function useAiCoursewarePageRepair() {
  18. const slides = useSlidesStore(), work = usePptWork(), snapshots = useSnapshotStore()
  19. const issues = ref<AiPptQualityIssue[]>([]), repairingPage = ref<number>()
  20. const error = ref("")
  21. let outline: AiCoursewareOutline | undefined, preferences: AiCoursewarePreferences | undefined
  22. let slideIds: string[] = [], version = 0
  23. let baseline: string[] = []
  24. const reset = () => { version++; outline = undefined; preferences = undefined; slideIds = []; baseline = []; issues.value = []; repairingPage.value = undefined; error.value = "" }
  25. watch(() => work.id, reset)
  26. onBeforeUnmount(reset)
  27. function generated(nextIssues: AiPptQualityIssue[], nextOutline: AiCoursewareOutline, nextPreferences: AiCoursewarePreferences) {
  28. reset()
  29. issues.value = nextIssues
  30. outline = clone(nextOutline)
  31. preferences = clone(nextPreferences)
  32. slideIds = slides.slides.map(slide => slide.id)
  33. baseline = slides.slides.map(slide => JSON.stringify(slide))
  34. }
  35. function locate(pageNo: number) {
  36. const index = slides.slides.findIndex(slide => slide.id === slideIds[pageNo - 1])
  37. if (index >= 0) slides.updateSlideIndex(index)
  38. return index
  39. }
  40. async function repair(pageNo: number) {
  41. if (!outline || !preferences || repairingPage.value !== undefined) return
  42. const whole = pageNo === 0
  43. if (whole && (slides.slides.length !== baseline.length || slides.slides.some((slide, i) => JSON.stringify(slide) !== baseline[i]))) {
  44. error.value = '课件已有手动编辑。为保留你的修改,请使用对应问题的“重新生成本页”。'; return
  45. }
  46. const index = whole ? 0 : locate(pageNo)
  47. if (index < 0) { error.value = "原页面已删除,请从当前课件重新生成。"; return }
  48. const slideId = slideIds[pageNo - 1], before = JSON.stringify(whole ? slides.slides : slides.slides[index])
  49. const requestVersion = version, materialId = work.id
  50. const active = () => requestVersion === version && work.id === materialId
  51. const sourceOutline = clone(outline), prefs = clone(preferences)
  52. const templateId = sourceOutline.templateId || DEFAULT_AI_COURSEWARE_TEMPLATE_ID
  53. repairingPage.value = pageNo
  54. error.value = ""
  55. try {
  56. await assertAiPptClientCurrent()
  57. if (!active()) return
  58. const response = await httpAjax(aiCoursewareGenerateApi, {
  59. id: materialId, fromType: queryParams.pptResourcesType || queryParams.fromType || "TEACHER",
  60. outline: sourceOutline, templateId, preferences: prefs, ...(whole ? { repairOnly: true } : { regeneratePageNo: pageNo }),
  61. imageAspectRatios: getAiPptImageAspectRatios(templateId),
  62. visualPrompt: (whole ? '在当前草稿上完善整课,保持页数与页序,保留正确内容。' : '仅完善本页,保持教学目标。')
  63. + '补齐学生可见的示例、分工和操作规则,统一题干与教师备注,修正图示及排版。媒体由教师补充。待解决问题:'
  64. + issues.value.filter(issue => whole || !issue.pageNo || issue.pageNo === pageNo).map(issue => issue.message).join(';')
  65. })
  66. if (!active()) return
  67. if (response.code !== 200 || !response.data?.taskId) throw new Error(response.message || "未能开始单页生成")
  68. const started = Date.now()
  69. let draft = false
  70. while (active()) {
  71. const task = await httpAjax(aiCoursewareTaskApi, response.data.taskId)
  72. if (!active()) return
  73. if (task.code !== 200) throw new Error(task.message || "无法读取生成进度")
  74. if (task.data.status === "FAILED") throw new Error(task.data.message || "本页生成失败")
  75. if (["DONE", "DRAFT"].includes(task.data.status)) { draft = task.data.status === 'DRAFT'; break }
  76. if (Date.now() - started > 15 * 60 * 1000) throw new Error("本页生成超过15分钟,请稍后重试")
  77. await new Promise(resolve => setTimeout(resolve, 2500))
  78. }
  79. if (!active()) return
  80. const result = await httpAjax(aiCoursewareResultApi, response.data.taskId)
  81. if (!active()) return
  82. if (result.code !== 200 || !result.data?.outline?.pages?.[whole ? 0 : pageNo - 1]) throw new Error(result.message || "生成结果不完整")
  83. const resultOutline = result.data.outline as AiCoursewareOutline
  84. if (whole && (resultOutline.pages.length !== sourceOutline.pages.length || resultOutline.pages.some((page, i) => page.pageNo !== i + 1))) throw new Error('完善后的页数或顺序不符,已保留当前课件。')
  85. // 忽略后端返回的其他页面,保留本地原始教学安排。
  86. const nextOutline = clone(whole ? resultOutline : sourceOutline)
  87. if (!whole) nextOutline.pages[pageNo - 1] = resultOutline.pages[pageNo - 1]
  88. if (draft) {
  89. // 保留服务端已经完成的配图供下次续作,画布保持不变。
  90. outline = nextOutline
  91. throw new Error('课件尚未全部完成,已保留生成内容和成功配图,请继续完成。当前页面未被替换。')
  92. }
  93. await assertAiPptClientCurrent()
  94. if (!active()) return
  95. const converted = aiCoursewareToSlides(nextOutline, { templateId, enableAnimations: prefs.enableAnimations })
  96. const target = converted.slides[pageNo - 1]
  97. const report = inspectAiPptQuality(nextOutline, converted.slides, { ...prefs, minPages: nextOutline.pages.length, maxPages: nextOutline.pages.length })
  98. if (blockingAiPptLayoutIssues(report).some(issue => whole || issue.pageNo === pageNo)) throw new Error('生成页的文字仍然过密,已保留当前课件,请再次精简后生成。')
  99. const resourceIssues = await inspectAiPptResources(whole ? nextOutline : { ...nextOutline, pages: [nextOutline.pages[pageNo - 1]] }, whole ? converted.slides : [target])
  100. if (!active()) return
  101. const imageIssues = blockingImageIssues([...report.issues.filter(issue => whole || issue.pageNo === pageNo), ...resourceIssues])
  102. if (imageIssues.length) throw new Error('配图尚未全部完成或加载失败,当前课件已保留,请继续完成。')
  103. const unchanged = () => whole ? JSON.stringify(slides.slides) === before : JSON.stringify(slides.slides.find(slide => slide.id === slideId)) === before
  104. if (!unchanged()) throw new Error("课件在生成期间已被修改或删除,未替换当前内容,避免丢失你的编辑。")
  105. await snapshots.addSnapshot()
  106. if (!active()) return
  107. const latest = whole ? 0 : slides.slides.findIndex(slide => slide.id === slideId)
  108. if (!unchanged()) throw new Error("课件内容已变化,未覆盖当前编辑。")
  109. const nextSlides = whole ? converted.slides.map((slide, i) => ({ ...slide, id: slideIds[i] })) : [...slides.slides]
  110. if (!whole) nextSlides[latest] = { ...target, id: slideId }
  111. slides.setSlides(nextSlides)
  112. slides.updateSlideIndex(latest)
  113. useMainStore().setActiveElementIdList([])
  114. work.isSave = false
  115. outline = nextOutline
  116. if (whole) baseline = nextSlides.map(slide => JSON.stringify(slide))
  117. else baseline[pageNo - 1] = JSON.stringify(nextSlides[latest])
  118. const pageIssues = report.issues.filter(issue => (whole || issue.pageNo === pageNo) && (issue.severity === "error" || ["image_review", "missing_required_visual"].includes(issue.code)))
  119. const reviewIssues = teachingReviewIssues(result.data.reviewMessage)
  120. if (draft && !reviewIssues.length) reviewIssues.push({ code: 'teaching-review', severity: 'error', message: '课堂内容尚未通过复核,请继续完善草稿。' })
  121. issues.value = [...(whole ? [] : issues.value.filter(issue => issue.pageNo !== pageNo && issue.code !== 'teaching-review')),
  122. ...reviewIssues, ...pageIssues, ...resourceIssues.map(issue => whole ? issue : { ...issue, pageNo })]
  123. await snapshots.addSnapshot()
  124. if (active()) {
  125. if (draft || issues.value.some(issue => issue.severity === 'error')) ElMessage.warning('草稿已更新,仍有待完善内容,请查看问题列表。')
  126. else ElMessage.success(`${whole ? '整课已完善' : `第 ${pageNo} 页已重新生成`},可撤销恢复;请核对后保存。`)
  127. }
  128. } catch (reason) {
  129. if (active()) error.value = reason instanceof Error ? reason.message : "本页生成失败,请重试"
  130. } finally {
  131. if (active()) repairingPage.value = undefined
  132. }
  133. }
  134. return { issues, repairingPage, error, generated, locate, repair }
  135. }