AiCoursewareDialog.vue 56 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488
  1. <template>
  2. <Modal
  3. :visible="visible"
  4. :width="1040"
  5. :content-style="{ maxWidth: 'calc(100vw - 32px)' }"
  6. :close-button="!transferring"
  7. :close-on-click-mask="false"
  8. :close-on-esc="!transferring"
  9. @update:visible="close"
  10. >
  11. <div class="ai-dialog">
  12. <header>
  13. <h2>{{ outline?.chapterName || outline?.title || "AI 生成课件" }}</h2>
  14. <p>{{ metadata || "先确定教学安排,再生成课堂内容" }}</p>
  15. </header>
  16. <ol class="steps">
  17. <li v-for="(label, index) in steps" :key="label" :class="{ active: step === index + 1, done: step > index + 1 }">
  18. <span>{{ step > index + 1 ? "✓" : index + 1 }}</span
  19. >{{ label }}
  20. </li>
  21. </ol>
  22. <main ref="mainContent">
  23. <section v-if="step === 1" class="setup">
  24. <h3>这节课准备怎么上?</h3>
  25. <label>课题来源<select :value="preferences.lessonTopic ? 'custom' : 'current'" :disabled="outlineLoading" @change="changeTopicSource">
  26. <option value="current">当前教材章节</option>
  27. <option value="custom">本次生成其他课题</option>
  28. </select></label>
  29. <template v-if="preferences.lessonTopic">
  30. <div class="fields">
  31. <label>年级<input v-model="preferences.lessonTopic.gradeName" maxlength="50" :disabled="outlineLoading" placeholder="例如:六年级" /></label>
  32. <label>教材版本与册次<input v-model="preferences.lessonTopic.textbookName" maxlength="150" :disabled="outlineLoading" placeholder="例如:花城版音乐六年级下册" /></label>
  33. </div>
  34. <div class="fields">
  35. <label>单元<input v-model="preferences.lessonTopic.unitName" maxlength="150" :disabled="outlineLoading" placeholder="例如:第六单元" /></label>
  36. <label>课题<input v-model="preferences.lessonTopic.chapterName" maxlength="150" :disabled="outlineLoading" placeholder="例如:学键盘(十)" /></label>
  37. </div>
  38. <p class="hint">按本次课题重新核实教材依据;课件所属章节不变。具体乐器和公开课要求可写在下方。</p>
  39. </template>
  40. <label>教学思路<select v-model="preferences.teachingApproach" :disabled="outlineLoading">
  41. <option value="auto">根据教材设计</option>
  42. <option value="inquiry">音乐要素探究 · 带问题发现与验证</option>
  43. <option value="story">情境任务 · 用故事串联学习</option>
  44. <option value="practice">表现实践 · 从模仿到合作创编</option>
  45. </select></label>
  46. <div class="fields">
  47. <label
  48. >课堂时长<select v-model.number="preferences.lessonMinutes" :disabled="outlineLoading">
  49. <option v-for="minutes in [20, 30, 40, 45]" :key="minutes" :value="minutes">{{ minutes }} 分钟</option>
  50. </select></label
  51. ><label
  52. >预计页数<select v-model="pageRange" :disabled="outlineLoading">
  53. <option value="9-12">9~12 页</option>
  54. <option value="12-15">12~15 页</option>
  55. <option value="9-15">9~15 页</option>
  56. </select></label
  57. >
  58. </div>
  59. <label
  60. >教学范围<span class="hint">选填</span
  61. ><input v-model="preferences.lessonScope" :disabled="outlineLoading" placeholder="例如:第一课时,重点认识声音的强弱"
  62. /></label>
  63. <label
  64. >补充要求<span class="hint">选填</span
  65. ><textarea v-model="extraPrompt" :disabled="outlineLoading" rows="3" placeholder="例如:多安排听辨和模仿活动,适合一年级学生" />
  66. </label>
  67. <details>
  68. <summary>更多设置</summary>
  69. <label
  70. >学生情况<input v-model="preferences.learningContext" :disabled="outlineLoading" placeholder="例如:音乐基础较弱,喜欢游戏"
  71. /></label>
  72. <div class="fields">
  73. <label
  74. >配图风格<select v-model="preferences.assetStyle">
  75. <option value="illustration">插画</option>
  76. <option value="photo">实景照片</option>
  77. <option value="mixed">综合搭配</option>
  78. </select></label
  79. ><label
  80. >教材配图<select v-model="preferences.sourceImagePolicy">
  81. <option value="balanced">情境配图优先生成</option>
  82. <option value="strict">仅使用教材配图</option>
  83. <option value="creative">优先生成创意配图</option>
  84. </select></label
  85. >
  86. </div>
  87. <label class="check"><input v-model="preferences.enableAnimations" type="checkbox" />添加适量动画</label>
  88. <label>音视频与谱例<select v-model="preferences.resourceMode" :disabled="outlineLoading">
  89. <option value="teacher_supplement">生成后由老师补充</option>
  90. <option value="auto_bind">优先使用已有课程资源</option>
  91. </select></label>
  92. </details>
  93. <p class="hint">先完成教学内容与页面设计。音视频、谱例可在生成后添加,所需资源会写入教师备注。</p>
  94. <p v-if="settingsChanged" class="notice">需求已调整,请重新生成大纲后确认;当前大纲会保留到新大纲生成成功。</p>
  95. <p v-if="outlineLoading" role="status" class="notice">{{ outlineTask?.message || '正在提交大纲任务,请稍候…' }}。可以收起窗口,稍后继续查看。</p>
  96. </section>
  97. <section v-else-if="step === 2 && outline" class="outline-stage">
  98. <div class="section-heading">
  99. <div>
  100. <h3>确认教学安排</h3>
  101. <p>看看主题、活动和顺序是否合适。需要调整时点击某页,具体文字和排版在生成后直接编辑。</p>
  102. </div>
  103. <span class="page-count">共 {{ pages.length }} 页</span>
  104. </div>
  105. <p v-if="requirementsSummary" class="requirements-summary">本次要求:{{ requirementsSummary }}</p>
  106. <div v-if="outline.teachingDesign" class="teaching-design">
  107. <strong>{{ outline.teachingDesign.drivingQuestion }}</strong>
  108. <p>{{ outline.teachingDesign.approach }}</p>
  109. <p>{{ outline.teachingDesign.learningPath?.join(' → ') }}</p>
  110. </div>
  111. <ul v-if="validationErrors.length" class="error validation" role="alert">
  112. <li v-for="message in validationErrors" :key="message">{{ message }}</li>
  113. </ul>
  114. <p v-if="!reliableResearch" class="error">尚未核实教材内容,请返回补充教学范围后重新生成大纲。</p>
  115. <div class="outline-list">
  116. <Draggable v-model="pages" item-key="key" handle=".drag-handle" :animation="160" :move="canMove" @end="remember"
  117. ><template #item="{ element, index }"
  118. ><article class="page-card" :class="{ expanded: expandedPage === element.key }">
  119. <div class="page-heading">
  120. <button
  121. class="page-toggle"
  122. type="button"
  123. :aria-expanded="expandedPage === element.key"
  124. :aria-label="`编辑第${index + 1}页:${element.page.title || '未填写标题'}`"
  125. @click="expandedPage = expandedPage === element.key ? '' : element.key"
  126. >
  127. <span class="page-number">{{ index + 1 }}</span
  128. ><strong>{{ element.page.title || "未填写标题" }}</strong
  129. ><span>{{ expandedPage === element.key ? "⌃" : "⌄" }}</span>
  130. </button>
  131. <div v-if="index > 0" class="page-actions">
  132. <button class="drag-handle" type="button" aria-label="拖动排序">⠿</button
  133. ><button type="button" :disabled="index === 1" :aria-label="`上移第${index + 1}页`" @click="movePage(index, -1)">↑</button
  134. ><button type="button" :disabled="index === pages.length - 1" :aria-label="`下移第${index + 1}页`" @click="movePage(index, 1)">
  135. ↓</button
  136. ><button
  137. type="button"
  138. :disabled="pages.length <= MIN_OUTLINE_PAGES"
  139. :aria-label="`删除第${index + 1}页`"
  140. @click="removePage(index)"
  141. >
  142. 删除
  143. </button>
  144. </div>
  145. </div>
  146. <p v-if="expandedPage !== element.key" class="page-summary">
  147. {{ element.brief || "点击页面,简要说明准备讲什么或开展什么活动" }}
  148. </p>
  149. <div v-else class="page-edit">
  150. <label>页面标题<input v-model="element.page.title" @change="remember" /></label>
  151. <label
  152. >教学安排<textarea v-model="element.brief" rows="2" placeholder="例如:听两组声音,辨别强弱,再用拍手模仿" @change="remember" />
  153. </label>
  154. </div></article></template></Draggable
  155. ><button class="add-page" type="button" :disabled="pages.length >= MAX_OUTLINE_PAGES" @click="addPage">+ 添加一页</button>
  156. </div>
  157. <div class="outline-options">
  158. <span>课件样式:{{ getAiCoursewareTemplate(templateId).name }}</span
  159. ><button type="button" @click="showTemplates = !showTemplates">{{ showTemplates ? "收起模板" : "更换模板" }}</button>
  160. <details>
  161. <summary>教材依据</summary>
  162. <p>{{ outline.textbookResearch?.contentSummary }}</p>
  163. <a v-for="source in sources" :key="source.url" :href="source.url" target="_blank" rel="noopener noreferrer">{{
  164. source.title || "教材来源"
  165. }}</a>
  166. </details>
  167. </div>
  168. <div v-if="showTemplates" class="template-options">
  169. <button
  170. v-for="template in AI_COURSEWARE_TEMPLATES"
  171. :key="template.id"
  172. :class="{ selected: templateId === template.id }"
  173. :aria-pressed="templateId === template.id"
  174. @click="selectTemplate(template.id)"
  175. >
  176. <span v-if="template.id === DEFAULT_AI_COURSEWARE_TEMPLATE_ID" class="blank-template-preview" aria-hidden="true"></span>
  177. <img v-else :src="templatePreview(template.id)" :alt="template.name" /><span>{{ template.name }}</span>
  178. </button>
  179. </div>
  180. </section>
  181. <section v-else class="generation" :class="{ 'showing-preview': generationError && completionSource && showCompletionContent }" aria-live="polite">
  182. <AiCoursewareDraftPreview v-if="generationError && completionSource && showCompletionContent"
  183. :outline="completionSource" :template-id="templateId" :message="generationError" @layout-errors="recordCompletionLayoutErrors" />
  184. <template v-else-if="generationError"
  185. ><span class="error-icon">!</span>
  186. <h3>{{ completionSource ? `已保留 ${completionSource.pages.length} 页,部分内容待完善` : task ? "课件进度暂时无法继续" : "这次生成未完成" }}</h3>
  187. <p>{{ generationError }}</p>
  188. <p class="hint">{{ completionSource ? '可以查看逐页预览,或打开草稿编辑并补图。自动完善仍未成功时,已生成的页面仍可使用。' : '已保留确认的大纲,当前课件没有被替换。' }}</p>
  189. </template
  190. ><template v-else
  191. ><span class="spinner"></span>
  192. <h3>正在生成课件</h3>
  193. <p>{{ task?.message || "正在准备课堂内容" }}</p>
  194. <progress :value="task?.progress || 0" max="100"></progress>
  195. <p class="hint">完成后进入编辑器。你可以先收起窗口。</p></template
  196. >
  197. </section>
  198. </main>
  199. <p v-if="step === 1 && setupError" role="alert" class="error">{{ setupError }}</p>
  200. <footer>
  201. <Button :disabled="transferring" @click="close">{{ task || outlineLoading ? "收起窗口" : "关闭" }}</Button>
  202. <div v-if="step === 1">
  203. <Button v-if="outline" :disabled="outlineLoading || settingsChanged" @click="step = 2">继续确认大纲</Button
  204. ><Button type="primary" :disabled="outlineLoading" @click="generateOutline">{{
  205. outlineLoading ? "正在生成大纲…" : outlineTask ? "继续查看大纲" : outline ? "重新生成大纲" : "生成大纲"
  206. }}</Button>
  207. </div>
  208. <div v-else-if="step === 2">
  209. <Button @click="backToSettings">返回修改需求</Button
  210. ><Button type="primary" :disabled="!reliableResearch || transferring" @click="confirmAndGenerate">确认大纲并生成</Button>
  211. </div>
  212. <div v-else-if="generationError">
  213. <Button v-if="!transferring" @click="backToSettings">修改需求</Button>
  214. <Button v-if="completionSource" :disabled="transferring" :aria-expanded="showCompletionContent" @click="showCompletionContent = !showCompletionContent">{{ showCompletionContent ? "收起预览" : "查看已生成内容" }}</Button>
  215. <Button v-if="!task && !completionSource" @click="step = 2">返回修改大纲</Button>
  216. <Button :type="completionSource ? 'default' : 'primary'" :disabled="transferring" @click="retry">{{ layoutRepairSource && layoutRepairFeedback ? (modelLayoutRepair ? "重新设计页面" : "精简并重新排版") : completionSource ? "继续自动完善" : task ? "继续查看课件" : "重试生成" }}</Button>
  217. <Button v-if="completionSource" type="primary" :disabled="transferring" @click="openCompletionDraft">{{ transferring ? "正在打开…" : "打开草稿编辑" }}</Button>
  218. </div>
  219. </footer>
  220. </div>
  221. </Modal>
  222. </template>
  223. <script setup lang="ts">
  224. import { blockingAiPptLayoutIssues } from '@/utils/aiPptQuality'
  225. import { computed, nextTick, onBeforeUnmount, reactive, ref, watch } from "vue"
  226. import { ElMessage, ElMessageBox } from "element-plus"
  227. import Draggable from "vuedraggable"
  228. import { nanoid } from "nanoid"
  229. import Modal from "@/components/Modal.vue"
  230. import Button from "@/components/Button.vue"
  231. import AiCoursewareDraftPreview from './AiCoursewareDraftPreview.vue'
  232. import queryParams from "@/queryParams"
  233. import usePptWork from "@/store/pptWork"
  234. import { useMainStore, useSlidesStore, useSnapshotStore } from "@/store"
  235. import { httpAjax } from "@/plugins/httpAjax"
  236. import {
  237. aiCoursewareGenerateApi,
  238. aiCoursewareOutlineTaskApi,
  239. aiCoursewareResultApi,
  240. aiCoursewareTaskApi,
  241. type AiCoursewareOutline,
  242. type AiCoursewarePreferences,
  243. type AiCoursewareTaskInfo,
  244. type AiCoursewareGenerateResult
  245. } from "@/api/aiCourseware"
  246. import { AI_COURSEWARE_TEMPLATES, DEFAULT_AI_COURSEWARE_TEMPLATE_ID, getAiCoursewareTemplate } from "@/config/aiCoursewareTemplates"
  247. import { assertAiPptClientCurrent } from "@/utils/aiPptClientVersion"
  248. import { aiCoursewareToSlides } from "@/utils/aiCoursewareToSlides"
  249. import { getAiPptImageAspectRatios } from "@/utils/aiPptTemplateEngine"
  250. import { inspectAiPptQuality, type AiPptQualityIssue } from "@/utils/aiPptQuality"
  251. import { inspectAiPptResources } from "@/utils/aiPptResources"
  252. import { teachingReviewIssues, blockingImageIssues, coursewareCompletionMessage, isAdvisoryCompletion } from '@/utils/aiPptFeedback'
  253. import { templateAssetPath } from "@/utils/templateAssetUrl"
  254. import { draftKey, loadAiDraft, saveAiDraft } from "@/utils/aiCoursewareDraft"
  255. import {
  256. editableOutlinePages,
  257. confirmedCoursewareOutline,
  258. inspectConfirmedOutline,
  259. MIN_OUTLINE_PAGES,
  260. MAX_OUTLINE_PAGES,
  261. type EditableOutlinePage
  262. } from "@/utils/aiCoursewareOutline"
  263. const props = defineProps<{ visible: boolean }>()
  264. const emit = defineEmits<{ (e: "update:visible", value: boolean): void; (e: "generated", issues: AiPptQualityIssue[], outline: AiCoursewareOutline, preferences: AiCoursewarePreferences): void }>()
  265. const slidesStore = useSlidesStore(),
  266. pptWork = usePptWork(),
  267. snapshots = useSnapshotStore()
  268. const clone = <T,>(value: T): T => JSON.parse(JSON.stringify(value))
  269. const defaults = (): AiCoursewarePreferences => ({
  270. lessonTopic: undefined,
  271. teachingApproach: 'auto',
  272. resourceMode: 'teacher_supplement',
  273. lessonMinutes: 40,
  274. minPages: 9,
  275. maxPages: 12,
  276. assetStyle: "illustration",
  277. sourceImagePolicy: "balanced",
  278. requireAudio: false,
  279. requireVideo: false,
  280. enableAnimations: true
  281. })
  282. const preferences = reactive(defaults())
  283. const expandedPage = ref(""),
  284. showTemplates = ref(false),
  285. outlineSettings = ref("")
  286. const settingsSignature = () => JSON.stringify([preferences, extraPrompt.value, pageRange.value])
  287. const settingsChanged = computed(() => !!outline.value && outlineSettings.value !== settingsSignature())
  288. const templatePreview = (id: string) => templateAssetPath(id, getAiCoursewareTemplate(id).backgroundFiles[0])
  289. const steps = ["设置需求", "确认大纲", "生成课件"]
  290. const step = ref(1),
  291. outline = ref<AiCoursewareOutline>(),
  292. pages = ref<EditableOutlinePage[]>([])
  293. const extraPrompt = ref(""),
  294. pageRange = ref("9-12"),
  295. templateId = ref(DEFAULT_AI_COURSEWARE_TEMPLATE_ID)
  296. const outlineLoading = ref(false),
  297. transferring = ref(false),
  298. setupError = ref(""),
  299. generationError = ref(""),
  300. validationErrors = ref<string[]>([])
  301. const task = ref<AiCoursewareTaskInfo>(),
  302. confirmed = ref<AiCoursewareOutline>()
  303. const layoutRepairSource = ref<AiCoursewareOutline>()
  304. const layoutRepairFeedback = ref('')
  305. const modelLayoutRepair = computed(() => layoutRepairSource.value?.pages.some(page => !!page.pageDesign))
  306. const renderDiagnostics = ref<Array<{ pageNo: number; code: string; message: string }>>([])
  307. let automaticDesignRepairs = 0
  308. const completionSource = ref<AiCoursewareOutline>()
  309. const completionReviewMessage = ref('')
  310. const mainContent = ref<HTMLElement>()
  311. const showCompletionContent = ref(false)
  312. watch(completionSource, value => {
  313. showCompletionContent.value = !!value
  314. if (!value) completionReviewMessage.value = ''
  315. else void nextTick(() => { if (mainContent.value) mainContent.value.scrollTop = 0 })
  316. })
  317. const outlineTask = ref<AiCoursewareTaskInfo>(), outlineTaskSettings = ref('')
  318. let pollTimer: ReturnType<typeof setTimeout> | undefined,
  319. version = 0,
  320. replacementSnapshot = "",
  321. saveQueue = Promise.resolve(),
  322. pollVersion = 0
  323. const source = () => queryParams.pptResourcesType || queryParams.fromType || "TEACHER"
  324. const metadata = computed(() => [outline.value?.gradeName, outline.value?.unitName].filter(Boolean).join(" · "))
  325. const sources = computed(() => (outline.value?.textbookResearch?.sources || []).filter(item => /^https?:\/\//i.test(item.url)))
  326. const reliableResearch = computed(
  327. () => outline.value?.textbookResearch?.exactMatch && outline.value.textbookResearch.confidence === "HIGH" && sources.value.length > 0
  328. )
  329. const matchesRequestedTopic = (value: AiCoursewareOutline) => !preferences.lessonTopic ||
  330. (['gradeName', 'textbookName', 'unitName', 'chapterName'] as const)
  331. .every(key => (value[key] || '').trim() === preferences.lessonTopic![key].trim())
  332. const requirementsSummary = computed(() => [preferences.lessonScope, preferences.learningContext, extraPrompt.value].filter(Boolean).join(";"))
  333. const editorSnapshot = () =>
  334. JSON.stringify([slidesStore.title, slidesStore.slides, slidesStore.theme, slidesStore.viewportSize, slidesStore.viewportRatio])
  335. const currentPlan = () => {
  336. if (!outline.value) return undefined
  337. const plan = confirmedCoursewareOutline(outline.value, pages.value)
  338. plan.templateId = templateId.value
  339. return plan
  340. }
  341. const stopPolling = () => {
  342. pollVersion++
  343. if (pollTimer) clearTimeout(pollTimer)
  344. pollTimer = undefined
  345. }
  346. watch(pageRange, value => {
  347. const [min, max] = value.split("-").map(Number)
  348. preferences.minPages = min
  349. preferences.maxPages = max
  350. })
  351. watch(
  352. () => props.visible,
  353. visible => {
  354. if (!visible) { stopPolling(); if (outlineTask.value) outlineLoading.value = false }
  355. else if (outlineTask.value) void pollOutlineTask()
  356. else if (task.value) void pollTask()
  357. else if (!outline.value) void restore()
  358. }
  359. )
  360. watch(
  361. () => pptWork.id,
  362. () => {
  363. version++
  364. stopPolling()
  365. outlineLoading.value = false
  366. task.value = undefined
  367. outlineTask.value = undefined
  368. outlineTaskSettings.value = ''
  369. confirmed.value = undefined
  370. layoutRepairSource.value = undefined
  371. completionSource.value = undefined
  372. outline.value = undefined
  373. pages.value = []
  374. extraPrompt.value = ""
  375. Object.assign(preferences, defaults())
  376. pageRange.value = "9-12"
  377. step.value = 1
  378. if (props.visible) void restore()
  379. }
  380. )
  381. onBeforeUnmount(() => {
  382. version++
  383. stopPolling()
  384. })
  385. function close() {
  386. if (transferring.value) return
  387. remember()
  388. emit("update:visible", false)
  389. }
  390. function remember() {
  391. const id = pptWork.id,
  392. from = source(),
  393. plan = currentPlan(),
  394. prefs = clone(preferences),
  395. prompt = extraPrompt.value,
  396. taskId = task.value?.taskId,
  397. outlineTaskId = outlineTask.value?.taskId,
  398. pendingOutlineSettings = outlineTaskSettings.value,
  399. pendingCompletion = completionSource.value ? clone(completionSource.value) : undefined,
  400. completionMessage = generationError.value,
  401. reviewMessage = completionReviewMessage.value,
  402. savedSettings = outlineSettings.value
  403. if (!id) return
  404. saveQueue = saveQueue
  405. .then(async () => {
  406. await saveAiDraft({
  407. key: await draftKey(id, from),
  408. updatedAt: Date.now(),
  409. outline: plan,
  410. preferences: prefs,
  411. extraPrompt: prompt,
  412. taskId,
  413. outlineTaskId,
  414. outlineTaskSettings: pendingOutlineSettings,
  415. completionSource: pendingCompletion,
  416. completionMessage,
  417. reviewMessage,
  418. outlineSettings: savedSettings
  419. })
  420. })
  421. .catch(error => {
  422. console.warn("AI courseware local state unavailable", error)
  423. })
  424. }
  425. function selectTemplate(id: string) {
  426. templateId.value = id
  427. showTemplates.value = false
  428. remember()
  429. }
  430. function backToSettings() {
  431. stopPolling()
  432. version++
  433. task.value = undefined
  434. step.value = 1
  435. remember()
  436. }
  437. function changeTopicSource(event: Event) {
  438. preferences.lessonTopic = (event.target as HTMLSelectElement).value === 'custom'
  439. ? { gradeName: '', textbookName: '', unitName: '', chapterName: '' } : undefined
  440. }
  441. async function restore() {
  442. if (!pptWork.id) return
  443. const requestVersion = version
  444. try {
  445. const saved = await loadAiDraft(await draftKey(pptWork.id, source()))
  446. if (!saved || requestVersion !== version || outline.value || !props.visible) return
  447. Object.assign(preferences, saved.preferences)
  448. pageRange.value = `${preferences.minPages}-${preferences.maxPages}`
  449. extraPrompt.value = saved.extraPrompt
  450. await nextTick()
  451. if (requestVersion !== version || !props.visible) return
  452. if (saved.outline) {
  453. loadOutline(saved.outline)
  454. if (saved.outlineSettings) outlineSettings.value = saved.outlineSettings
  455. step.value = 1
  456. }
  457. if (saved.outlineTaskId) {
  458. outlineTask.value = { taskId: saved.outlineTaskId, status: 'RUNNING', progress: 0, message: '正在获取大纲进度' }
  459. outlineTaskSettings.value = saved.outlineTaskSettings || settingsSignature()
  460. step.value = 1
  461. await pollOutlineTask()
  462. } else if (saved.completionSource) {
  463. confirmed.value = currentPlan()
  464. completionSource.value = clone(saved.completionSource)
  465. completionReviewMessage.value = saved.reviewMessage || ''
  466. generationError.value = saved.completionMessage || '课件尚未全部完成,可接着完成。'
  467. step.value = 3
  468. if (saved.taskId) {
  469. task.value = { taskId: saved.taskId, status: 'RUNNING', progress: 0, message: '正在恢复课件进度' }
  470. await pollTask()
  471. }
  472. } else if (saved.taskId && outline.value) {
  473. confirmed.value = currentPlan()
  474. task.value = { taskId: saved.taskId, status: "RUNNING", progress: 0, message: "正在获取生成进度" }
  475. step.value = 3
  476. await pollTask()
  477. }
  478. } catch (error) {
  479. console.warn("AI courseware restore unavailable", error)
  480. }
  481. }
  482. function loadOutline(value: AiCoursewareOutline) {
  483. outline.value = clone(value)
  484. pages.value = editableOutlinePages(value)
  485. templateId.value = value.templateId || DEFAULT_AI_COURSEWARE_TEMPLATE_ID
  486. validationErrors.value = []
  487. expandedPage.value = ""
  488. outlineSettings.value = settingsSignature()
  489. }
  490. async function ask(message: string, confirmButtonText: string) {
  491. try {
  492. await ElMessageBox.confirm(message, "请确认", { confirmButtonText, cancelButtonText: "取消", closeOnClickModal: false })
  493. return true
  494. } catch {
  495. return false
  496. }
  497. }
  498. async function generateOutline() {
  499. if (!pptWork.id || outlineLoading.value) return
  500. const topic = preferences.lessonTopic
  501. if (topic && (!topic.gradeName.trim() || !topic.textbookName.trim() || !topic.chapterName.trim())) {
  502. setupError.value = '请填写年级、教材版本与册次、课题。'; return
  503. }
  504. if (outlineTask.value) { await pollOutlineTask(); return }
  505. if (outline.value && !(await ask("重新生成会替换当前大纲,包括你已修改的内容。是否继续?", "重新生成"))) return
  506. const requestVersion = ++version
  507. outlineLoading.value = true
  508. setupError.value = ""
  509. try {
  510. await assertAiPptClientCurrent()
  511. if (requestVersion !== version) return
  512. const requestedSettings = settingsSignature()
  513. const response = await httpAjax(aiCoursewareOutlineTaskApi, {
  514. id: pptWork.id,
  515. fromType: source(),
  516. extraPrompt: extraPrompt.value,
  517. preferences: clone(preferences)
  518. })
  519. if (requestVersion !== version) return
  520. if (response.code !== 200 || !response.data?.taskId) throw new Error(response.message || "未能提交大纲任务,请重试")
  521. outlineTask.value = response.data
  522. outlineTaskSettings.value = requestedSettings
  523. remember()
  524. } catch (error) {
  525. if (requestVersion === version) setupError.value = error instanceof Error ? error.message : "大纲生成未完成,请重试"
  526. } finally {
  527. if (requestVersion === version) outlineLoading.value = false
  528. }
  529. if (requestVersion === version && outlineTask.value && props.visible) await pollOutlineTask()
  530. }
  531. async function pollOutlineTask() {
  532. stopPolling()
  533. const taskId = outlineTask.value?.taskId, requestVersion = version, currentPoll = pollVersion
  534. if (!taskId) return
  535. const active = () => requestVersion === version && currentPoll === pollVersion && props.visible && outlineTask.value?.taskId === taskId
  536. outlineLoading.value = true
  537. setupError.value = ''
  538. try {
  539. const response = await httpAjax(aiCoursewareTaskApi, taskId)
  540. if (!active()) return
  541. if (response.code !== 200 && /任务不存在|任务已过期/.test(response.message || '')) {
  542. setupError.value = '大纲任务已失效,设置和原课件已保留,请重新生成大纲。'
  543. outlineTask.value = undefined
  544. outlineLoading.value = false
  545. remember()
  546. return
  547. }
  548. if (response.code !== 200 || !response.data) throw new Error(response.message || '暂时无法读取大纲进度,请继续查看')
  549. outlineTask.value = response.data
  550. if (response.data.status === 'FAILED') {
  551. setupError.value = response.data.message || '大纲生成未完成,请重试'
  552. outlineTask.value = undefined
  553. outlineLoading.value = false
  554. remember()
  555. return
  556. }
  557. if (response.data.status === 'DONE') {
  558. const result = await httpAjax(aiCoursewareResultApi, taskId)
  559. if (!active()) return
  560. if (result.code !== 200 || !result.data?.outline?.pages?.length) throw new Error(result.message || '大纲读取失败,请继续查看')
  561. if (!matchesRequestedTopic(result.data.outline)) {
  562. outlineTask.value = undefined
  563. outlineLoading.value = false
  564. setupError.value = '生成服务返回的教材或课题与本次要求不符,原课件已保留。请确认服务端已更新后重新生成。'
  565. remember()
  566. return
  567. }
  568. loadOutline(result.data.outline)
  569. outlineSettings.value = outlineTaskSettings.value
  570. confirmed.value = undefined
  571. task.value = undefined
  572. completionSource.value = undefined
  573. layoutRepairSource.value = undefined
  574. layoutRepairFeedback.value = ''
  575. generationError.value = ''
  576. outlineTask.value = undefined
  577. outlineLoading.value = false
  578. step.value = 2
  579. remember()
  580. } else pollTimer = setTimeout(pollOutlineTask, 2500)
  581. } catch (error) {
  582. if (active()) {
  583. outlineLoading.value = false
  584. setupError.value = (error instanceof Error ? error.message : '暂时无法读取大纲进度') + ';任务编号已保留,可继续查看,无需重复生成。'
  585. remember()
  586. }
  587. }
  588. }
  589. function canMove(event: { draggedContext: { index: number }; relatedContext: { index: number } }) {
  590. return event.draggedContext.index > 0 && event.relatedContext.index > 0
  591. }
  592. function movePage(index: number, delta: number) {
  593. const next = index + delta
  594. if (index < 1 || next < 1 || next >= pages.value.length) return
  595. pages.value.splice(next, 0, pages.value.splice(index, 1)[0])
  596. remember()
  597. }
  598. function addPage() {
  599. if (pages.value.length >= MAX_OUTLINE_PAGES) return
  600. pages.value.push({
  601. key: nanoid(),
  602. page: {
  603. pageNo: pages.value.length + 1,
  604. type: "creative_activity",
  605. title: "",
  606. bullets: [],
  607. imageKeywords: [],
  608. teachingStage: "活动练习"
  609. },
  610. brief: ""
  611. })
  612. expandedPage.value = pages.value[pages.value.length - 1].key
  613. }
  614. function removePage(index: number) {
  615. if (index < 1 || pages.value.length <= MIN_OUTLINE_PAGES) return
  616. pages.value.splice(index, 1)
  617. remember()
  618. }
  619. async function confirmAndGenerate() {
  620. const plan = currentPlan()
  621. if (!plan || !reliableResearch.value || transferring.value) return
  622. validationErrors.value = inspectConfirmedOutline(plan)
  623. if (validationErrors.value.length) return
  624. if (
  625. slidesStore.slides.some(slide => slide.elements.length) &&
  626. !(await ask("生成完成后,将用新课件替换编辑器中的当前内容。你仍可撤销恢复原页面,保存课件后才会更新服务器。", "确认并生成"))
  627. )
  628. return
  629. confirmed.value = clone(plan)
  630. replacementSnapshot = editorSnapshot()
  631. await startGeneration()
  632. }
  633. async function startGeneration() {
  634. if (!confirmed.value || !pptWork.id || transferring.value || task.value) return
  635. layoutRepairSource.value = undefined
  636. automaticDesignRepairs = 0
  637. renderDiagnostics.value = []
  638. completionSource.value = undefined
  639. const requestVersion = ++version
  640. step.value = 3
  641. generationError.value = ""
  642. transferring.value = true
  643. try {
  644. await assertAiPptClientCurrent()
  645. if (requestVersion !== version) return
  646. const plan = clone(confirmed.value)
  647. plan.templateId = templateId.value
  648. plan.style = getAiCoursewareTemplate(templateId.value).baseStyle
  649. const response = await httpAjax(aiCoursewareGenerateApi, {
  650. id: pptWork.id,
  651. fromType: source(),
  652. outline: plan,
  653. extraPrompt: extraPrompt.value.trim(),
  654. visualPrompt: getAiCoursewareTemplate(templateId.value).visualPrompt,
  655. imageAspectRatios: getAiPptImageAspectRatios(templateId.value),
  656. templateId: templateId.value,
  657. outlineConfirmed: true,
  658. outlineMode: "teaching_plan",
  659. preferences: { ...clone(preferences), minPages: plan.pages.length, maxPages: plan.pages.length }
  660. })
  661. if (requestVersion !== version) return
  662. if (response.code !== 200 || !response.data?.taskId) throw new Error(response.message || "未能开始生成,请重试")
  663. task.value = response.data
  664. remember()
  665. } catch (error) {
  666. if (requestVersion === version) generationError.value = error instanceof Error ? error.message : "未能开始生成,请重试"
  667. } finally {
  668. if (requestVersion === version) transferring.value = false
  669. }
  670. if (props.visible && task.value) await pollTask()
  671. }
  672. async function retry() {
  673. if (transferring.value) return
  674. if (layoutRepairSource.value && layoutRepairFeedback.value) { await repairBlockedLayout(); return }
  675. if (completionSource.value) {
  676. if (task.value && (['PENDING', 'RUNNING'].includes(task.value.status)
  677. || isAdvisoryCompletion(generationError.value.split(';已保留')[0]))) { generationError.value = ''; await pollTask() }
  678. else await continueCompletion()
  679. return
  680. }
  681. if (layoutRepairSource.value) { await repairBlockedLayout(); return }
  682. generationError.value = ""
  683. if (task.value) await pollTask()
  684. else await startGeneration()
  685. }
  686. async function repairBlockedLayout() {
  687. if (transferring.value || !layoutRepairSource.value) return
  688. const requestVersion = ++version
  689. stopPolling()
  690. transferring.value = true
  691. const reason = layoutRepairFeedback.value || generationError.value
  692. generationError.value = ''
  693. try {
  694. await assertAiPptClientCurrent()
  695. if (requestVersion !== version) return
  696. const plan = clone(layoutRepairSource.value)
  697. const modelDesign = plan.pages.some(page => page.pageDesign)
  698. const feedback = renderDiagnostics.value.length ? clone(renderDiagnostics.value) : plan.pages
  699. .filter(page => reason.includes(`第${page.pageNo}页`)).map(page => ({ pageNo: page.pageNo, code: 'rendered_layout', message: reason.slice(0, 2000) }))
  700. const response = await httpAjax(aiCoursewareGenerateApi, {
  701. id: pptWork.id, fromType: source(), outline: plan, templateId: templateId.value, repairOnly: true,
  702. ...(modelDesign && feedback.length ? { designOnly: true, renderFeedback: feedback } : {}),
  703. imageAspectRatios: getAiPptImageAspectRatios(templateId.value),
  704. preferences: { ...clone(preferences), minPages: plan.pages.length, maxPages: plan.pages.length },
  705. visualPrompt: modelDesign
  706. ? getAiCoursewareTemplate(templateId.value).visualPrompt
  707. : `${getAiCoursewareTemplate(templateId.value).visualPrompt}\n保持页数与教学目标,精简重复的学生文字,教师操作放备注,不截断歌词或完整范例。解决排版问题:${reason}`
  708. })
  709. if (requestVersion !== version) return
  710. if (response.code !== 200 || !response.data?.taskId) throw new Error(response.message || '未能开始重新排版')
  711. task.value = response.data
  712. layoutRepairSource.value = undefined
  713. remember()
  714. } catch (error) {
  715. if (requestVersion === version) generationError.value = error instanceof Error ? error.message : '重新排版失败,请重试'
  716. } finally {
  717. if (requestVersion === version) transferring.value = false
  718. }
  719. if (requestVersion === version && !generationError.value && props.visible) await pollTask()
  720. }
  721. async function continueCompletion() {
  722. if (transferring.value || !completionSource.value) return
  723. const requestVersion = ++version
  724. stopPolling()
  725. transferring.value = true
  726. generationError.value = ''
  727. try {
  728. await assertAiPptClientCurrent()
  729. if (requestVersion !== version) return
  730. const plan = clone(completionSource.value)
  731. const response = await httpAjax(aiCoursewareGenerateApi, {
  732. id: pptWork.id, fromType: source(), outline: plan, templateId: templateId.value, repairOnly: true,
  733. imageAspectRatios: getAiPptImageAspectRatios(templateId.value),
  734. preferences: { ...clone(preferences), minPages: plan.pages.length, maxPages: plan.pages.length }
  735. })
  736. if (requestVersion !== version) return
  737. if (response.code !== 200 || !response.data?.taskId) throw new Error(response.message || '暂时无法继续完成课件,请重试')
  738. task.value = response.data
  739. remember()
  740. } catch (error) {
  741. if (requestVersion === version) generationError.value = error instanceof Error ? error.message : '暂时无法继续完成课件'
  742. } finally {
  743. if (requestVersion === version) transferring.value = false
  744. }
  745. if (requestVersion === version && task.value && !generationError.value && props.visible) await pollTask()
  746. }
  747. async function pollTask() {
  748. stopPolling()
  749. generationError.value = ''
  750. const taskId = task.value?.taskId,
  751. requestVersion = version,
  752. currentPoll = pollVersion
  753. if (!taskId) return
  754. const active = () => requestVersion === version && currentPoll === pollVersion && props.visible && task.value?.taskId === taskId
  755. try {
  756. const response = await httpAjax(aiCoursewareTaskApi, taskId)
  757. if (!active()) return
  758. if (completionSource.value && response.code !== 200 && /任务不存在|任务已过期/.test(response.message || '')) {
  759. task.value = undefined
  760. generationError.value = '任务记录已过期,已保留生成内容,可继续完成课件。'
  761. remember()
  762. return
  763. }
  764. if (response.code !== 200) throw new Error(response.message || "暂时无法获取进度,请稍后继续查看")
  765. task.value = response.data
  766. if (task.value?.status === "FAILED") {
  767. generationError.value = task.value.message || "课件生成未完成,请重试"
  768. console.error("AI courseware failed", taskId, task.value.message)
  769. task.value = undefined
  770. remember()
  771. return
  772. }
  773. if (task.value?.status === "DONE" || task.value?.status === "DRAFT") {
  774. const result = await httpAjax(aiCoursewareResultApi, taskId)
  775. if (!active()) return
  776. if (result.code !== 200) throw new Error(result.message || "课件读取失败,请继续查看")
  777. await enterEditor(result.data, active)
  778. } else pollTimer = setTimeout(pollTask, 2000)
  779. } catch (error) {
  780. if (active()) {
  781. console.error("AI courseware result unavailable", taskId, error)
  782. generationError.value =
  783. error instanceof TypeError
  784. ? "课件未能打开,请稍后继续查看;无需重新生成。"
  785. : error instanceof Error
  786. ? error.message
  787. : "暂时无法读取课件,请继续查看"
  788. }
  789. }
  790. }
  791. async function openCompletionDraft() {
  792. if (transferring.value || !completionSource.value) return
  793. const requestVersion = ++version
  794. stopPolling()
  795. const active = () => requestVersion === version && props.visible
  796. transferring.value = true
  797. try {
  798. await enterEditor({ outline: clone(completionSource.value), context: {},
  799. blockingMessage: generationError.value, reviewMessage: completionReviewMessage.value }, active, true)
  800. } catch (error) {
  801. if (active()) generationError.value = error instanceof Error ? error.message : '暂时无法打开草稿,已保留生成内容。'
  802. } finally {
  803. if (requestVersion === version) transferring.value = false
  804. }
  805. }
  806. function recordCompletionLayoutErrors(messages: string[]) {
  807. if (!completionSource.value || !messages.length) return
  808. layoutRepairSource.value = clone(completionSource.value)
  809. layoutRepairFeedback.value = messages.join(';')
  810. renderDiagnostics.value = messages.flatMap(message => {
  811. const pageNo = Number(message.match(/第(\d+)页/)?.[1])
  812. return pageNo ? [{ pageNo, code: 'rendered_layout', message: message.slice(0, 2000) }] : []
  813. })
  814. }
  815. async function automaticallyRepairDesign(result: AiCoursewareGenerateResult, active: () => boolean, allowIncompleteDraft: boolean) {
  816. if (!active() || allowIncompleteDraft || automaticDesignRepairs >= 2 || !renderDiagnostics.value.length || !result.outline.pages.some(page => page.pageDesign)) return false
  817. automaticDesignRepairs++
  818. completionSource.value = clone(result.outline)
  819. completionReviewMessage.value = result.reviewMessage || ''
  820. await repairBlockedLayout()
  821. return true
  822. }
  823. async function enterEditor(result: AiCoursewareGenerateResult, active: () => boolean, allowIncompleteDraft = false) {
  824. if (!result?.outline?.pages?.length) throw new Error("尚未获得课件内容,请继续查看")
  825. if (!matchesRequestedTopic(result.outline)) throw new Error('生成结果与本次教材或课题不符,原课件已保留,请重新生成大纲。')
  826. await assertAiPptClientCurrent()
  827. if (!active()) return
  828. // 已收到的正文先保留为草稿,任意后续渲染/资源检查失败都不能只剩错误页。
  829. completionSource.value = clone(result.outline)
  830. completionReviewMessage.value = result.reviewMessage || ''
  831. remember()
  832. if (task.value?.status === 'DRAFT' && !allowIncompleteDraft) {
  833. if (isAdvisoryCompletion(result.blockingMessage)) {
  834. // 历史任务保留DRAFT状态时无需再次调用生成;让正常渲染与缺图校验决定能否进入。
  835. result = { ...result, reviewMessage: [result.reviewMessage, result.blockingMessage].filter(Boolean).join(';'), blockingMessage: '' }
  836. } else {
  837. completionSource.value = clone(result.outline)
  838. completionReviewMessage.value = result.reviewMessage || ''
  839. generationError.value = coursewareCompletionMessage(result, task.value.message)
  840. if (result.renderFeedback?.length) {
  841. renderDiagnostics.value = clone(result.renderFeedback)
  842. layoutRepairSource.value = clone(result.outline)
  843. layoutRepairFeedback.value = result.renderFeedback.map(item => `第${item.pageNo}页:${item.message}`).join(';')
  844. }
  845. remember()
  846. return
  847. }
  848. }
  849. layoutRepairSource.value = clone(result.outline)
  850. layoutRepairFeedback.value = ''
  851. renderDiagnostics.value = []
  852. let converted: ReturnType<typeof aiCoursewareToSlides>
  853. try {
  854. converted = aiCoursewareToSlides(result.outline, { templateId: templateId.value, enableAnimations: preferences.enableAnimations, recoverDraft: allowIncompleteDraft })
  855. } catch (error) {
  856. if (error instanceof Error && /投屏安全区|保留区|模型页面设计/.test(error.message)) {
  857. layoutRepairFeedback.value = error.message
  858. const pageNo = Number(error.message.match(/^第(\d+)页/)?.[1])
  859. renderDiagnostics.value = pageNo ? [{ pageNo, code: 'model_design_geometry', message: error.message.slice(0, 2000) }] : []
  860. if (await automaticallyRepairDesign(result, active, allowIncompleteDraft)) return
  861. completionSource.value = clone(result.outline)
  862. remember()
  863. throw new Error(`${error.message.match(/^第\d+页/)?.[0] || '生成内容'}页面设计未通过,已保留当前课件。${modelLayoutRepair.value ? '请重新设计页面。' : '请精简并重新排版。'}`)
  864. }
  865. throw error
  866. }
  867. let report = inspectAiPptQuality(result.outline, converted.slides, {
  868. ...preferences,
  869. minPages: result.outline.pages.length,
  870. maxPages: result.outline.pages.length
  871. })
  872. const { inspectAiPptRenderedText } = await import('@/utils/aiPptRenderedQuality')
  873. let renderedIssues = await inspectAiPptRenderedText(converted.slides)
  874. if (!active()) return
  875. const draftDiagnostics: AiPptQualityIssue[] = converted.recoveredPages.map(page => ({ code: 'draft_layout_recovered', severity: 'error', pageNo: page.pageNo, message: page.error }))
  876. if (allowIncompleteDraft) {
  877. const originalLayoutIssues = [...blockingAiPptLayoutIssues(report), ...renderedIssues]
  878. draftDiagnostics.push(...originalLayoutIssues)
  879. const failed = [...new Set(originalLayoutIssues.flatMap(issue => issue.pageNo ? [issue.pageNo] : []))]
  880. if (failed.length) {
  881. converted = aiCoursewareToSlides(result.outline, { templateId: templateId.value, recoverDraft: true, fallbackPageNos: failed })
  882. draftDiagnostics.push(...converted.recoveredPages.map(page => ({ code: 'draft_layout_recovered', severity: 'error' as const, pageNo: page.pageNo, message: page.error })))
  883. report = inspectAiPptQuality(result.outline, converted.slides, { ...preferences, minPages: result.outline.pages.length, maxPages: result.outline.pages.length })
  884. renderedIssues = await inspectAiPptRenderedText(converted.slides)
  885. if (!active()) return
  886. }
  887. }
  888. const layoutIssues = [...blockingAiPptLayoutIssues(report), ...renderedIssues]
  889. layoutRepairFeedback.value = layoutIssues.map(issue => `第${issue.pageNo}页:${issue.message}`).join(';')
  890. renderDiagnostics.value = layoutIssues.filter(issue => !!issue.pageNo).map(issue => ({ pageNo: issue.pageNo!, code: issue.code, message: issue.message.slice(0, 2000) }))
  891. if (layoutIssues.length && await automaticallyRepairDesign(result, active, allowIncompleteDraft)) return
  892. if (layoutIssues.length && !allowIncompleteDraft) throw new Error(`第${[...new Set(layoutIssues.map(issue => issue.pageNo))].join('、')}页文字过密,已保留当前课件。${modelLayoutRepair.value ? '自动调整后仍未通过,请重新设计页面或查看草稿。' : '请精简并重新排版。'}`)
  893. layoutRepairSource.value = undefined
  894. const issues = [
  895. ...draftDiagnostics,
  896. ...report.errors,
  897. ...report.warnings.filter(issue => ["image_review", "missing_required_visual", "layout_variety", "layout_consecutive", "rendered_layout_repeat", "invalid_page_design"].includes(issue.code)),
  898. ...(await inspectAiPptResources(result.outline, converted.slides))
  899. ]
  900. issues.unshift(...teachingReviewIssues(result.reviewMessage, result.blockingMessage))
  901. const imageIssues = blockingImageIssues(issues)
  902. if (imageIssues.length) {
  903. if (!active()) return
  904. result = { ...result, outline: clone(result.outline) }
  905. // 地址存在但实际加载失败的图片不能在续作时误当作成功配图复用。
  906. imageIssues.filter(issue => issue.code === 'resource_unavailable').forEach(issue => {
  907. const page = result.outline.pages.find(page => page.pageNo === issue.pageNo)
  908. if (page) {
  909. const failedUrl = page.image?.url
  910. page.image = undefined; page.imageStatus = 'generation_failed'
  911. page.resourceBindings = page.resourceBindings?.filter(binding => binding.resourceType !== 'IMG' || binding.contentUrl !== failedUrl)
  912. }
  913. })
  914. if (!allowIncompleteDraft) {
  915. completionSource.value = result.outline
  916. completionReviewMessage.value = result.reviewMessage || ''
  917. generationError.value = `第${[...new Set(imageIssues.map(issue => issue.pageNo))].join('、')}页配图尚未完成,已保留生成内容。`
  918. remember()
  919. return
  920. }
  921. }
  922. const draft = allowIncompleteDraft || issues.some(issue => issue.severity === 'error')
  923. if (!active()) return
  924. if (
  925. replacementSnapshot !== editorSnapshot() &&
  926. slidesStore.slides.some(slide => slide.elements.length) &&
  927. !(await ask("当前课件已有新的修改。是否用生成的课件替换?替换前的页面可撤销恢复。", "进入新课件"))
  928. ) {
  929. generationError.value = "生成结果已就绪,可在准备好后继续进入编辑器。"
  930. return
  931. }
  932. if (!active()) return
  933. transferring.value = true
  934. const previous = clone({
  935. title: slidesStore.title,
  936. slides: slidesStore.slides,
  937. theme: slidesStore.theme,
  938. width: slidesStore.viewportSize,
  939. ratio: slidesStore.viewportRatio,
  940. index: slidesStore.slideIndex
  941. })
  942. let changed = false
  943. try {
  944. await snapshots.addSnapshot()
  945. if (!active()) return
  946. changed = true
  947. slidesStore.setViewportSize(1920)
  948. slidesStore.setViewportRatio(0.5625)
  949. slidesStore.setTitle(converted.title)
  950. slidesStore.setTheme({ ...slidesStore.theme, ...converted.theme })
  951. slidesStore.setSlides(converted.slides)
  952. slidesStore.updateSlideIndex(0)
  953. useMainStore().setActiveElementIdList([])
  954. await snapshots.addSnapshot()
  955. pptWork.isSave = false
  956. task.value = undefined
  957. completionSource.value = undefined
  958. generationError.value = ""
  959. step.value = 2
  960. remember()
  961. emit("generated", issues, clone({ ...result.outline, templateId: templateId.value }), clone(preferences))
  962. emit("update:visible", false)
  963. if (draft) ElMessage.warning("草稿已打开,待完善问题已保留。可逐页编辑、补图,完成后保存课件。")
  964. else ElMessage.success("课件已生成,可直接编辑,完成后点击“保存课件”")
  965. } catch (error) {
  966. if (changed) {
  967. slidesStore.setTitle(previous.title)
  968. slidesStore.setTheme(previous.theme)
  969. slidesStore.setSlides(previous.slides)
  970. slidesStore.setViewportSize(previous.width)
  971. slidesStore.setViewportRatio(previous.ratio)
  972. slidesStore.updateSlideIndex(previous.index)
  973. }
  974. throw error
  975. } finally {
  976. transferring.value = false
  977. }
  978. }
  979. </script>
  980. <style scoped lang="scss">
  981. .ai-dialog {
  982. display: flex;
  983. flex-direction: column;
  984. max-height: calc(100vh - 88px);
  985. color: #283544;
  986. font-size: 14px;
  987. }
  988. header {
  989. padding: 2px 0 18px;
  990. }
  991. h2 {
  992. font-size: 25px;
  993. margin: 0 0 6px;
  994. }
  995. h3 {
  996. font-size: 18px;
  997. margin: 0 0 8px;
  998. }
  999. p {
  1000. margin: 6px 0;
  1001. color: #77869a;
  1002. line-height: 1.6;
  1003. }
  1004. .steps {
  1005. display: flex;
  1006. padding: 12px;
  1007. margin: 0 0 20px;
  1008. list-style: none;
  1009. background: #f4f7fa;
  1010. border-radius: 8px;
  1011. gap: 6px;
  1012. }
  1013. .steps li {
  1014. flex: 1;
  1015. display: flex;
  1016. justify-content: center;
  1017. align-items: center;
  1018. gap: 8px;
  1019. color: #8491a2;
  1020. padding: 8px 0;
  1021. white-space: nowrap;
  1022. }
  1023. .steps span {
  1024. display: grid;
  1025. place-items: center;
  1026. width: 24px;
  1027. height: 24px;
  1028. border-radius: 50%;
  1029. background: #e6ebf1;
  1030. }
  1031. .steps .active {
  1032. color: #1989fa;
  1033. background: white;
  1034. border-radius: 6px;
  1035. font-weight: 600;
  1036. box-shadow: 0 2px 10px #23395708;
  1037. }
  1038. .active span {
  1039. background: #1989fa;
  1040. color: white;
  1041. }
  1042. .steps .done {
  1043. color: #169e91;
  1044. }
  1045. .done span {
  1046. background: #ddf3ee;
  1047. }
  1048. main {
  1049. overflow: auto;
  1050. min-height: 0;
  1051. flex: 1;
  1052. padding: 0 3px 3px;
  1053. }
  1054. .setup {
  1055. max-width: 720px;
  1056. margin: 8px auto 24px;
  1057. }
  1058. label {
  1059. display: block;
  1060. font-weight: 500;
  1061. margin: 0 0 16px;
  1062. line-height: 1.5;
  1063. }
  1064. input:not([type="checkbox"]),
  1065. select,
  1066. textarea {
  1067. display: block;
  1068. width: 100%;
  1069. box-sizing: border-box;
  1070. border: 1px solid #d7dfe8;
  1071. border-radius: 5px;
  1072. padding: 9px 11px;
  1073. margin-top: 7px;
  1074. background: white;
  1075. color: #283544;
  1076. font: inherit;
  1077. font-weight: 400;
  1078. }
  1079. textarea {
  1080. resize: vertical;
  1081. min-height: 76px;
  1082. }
  1083. input:focus,
  1084. textarea:focus,
  1085. select:focus {
  1086. outline: 2px solid #1989fa35;
  1087. border-color: #1989fa;
  1088. }
  1089. .fields {
  1090. display: grid;
  1091. grid-template-columns: 1fr 1fr;
  1092. gap: 20px;
  1093. }
  1094. .hint {
  1095. color: #8a96a6;
  1096. font-size: 12px;
  1097. font-weight: 400;
  1098. margin-left: 8px;
  1099. }
  1100. details {
  1101. margin-top: 12px;
  1102. }
  1103. summary {
  1104. cursor: pointer;
  1105. color: #60748b;
  1106. margin-bottom: 14px;
  1107. }
  1108. .check {
  1109. display: flex;
  1110. align-items: center;
  1111. gap: 6px;
  1112. font-weight: 400;
  1113. }
  1114. .check input {
  1115. margin: 0;
  1116. }
  1117. .notice {
  1118. color: #1989fa;
  1119. }
  1120. .error {
  1121. color: #b96218;
  1122. }
  1123. .validation {
  1124. padding: 12px 28px;
  1125. background: #fff7ed;
  1126. border-radius: 6px;
  1127. line-height: 1.7;
  1128. }
  1129. .section-heading {
  1130. display: flex;
  1131. align-items: center;
  1132. justify-content: space-between;
  1133. gap: 12px;
  1134. margin-bottom: 20px;
  1135. }
  1136. .time {
  1137. color: #46718f;
  1138. background: #f0f6fc;
  1139. padding: 9px 12px;
  1140. border-radius: 6px;
  1141. white-space: nowrap;
  1142. }
  1143. .outline-grid {
  1144. display: grid;
  1145. grid-template-columns: 225px minmax(0, 1fr);
  1146. gap: 24px;
  1147. }
  1148. aside {
  1149. padding: 16px;
  1150. background: #f7f9fc;
  1151. border-radius: 8px;
  1152. align-self: start;
  1153. }
  1154. aside a {
  1155. display: block;
  1156. color: #1989fa;
  1157. margin: 10px 0;
  1158. overflow-wrap: anywhere;
  1159. }
  1160. .page-card {
  1161. padding: 12px 14px;
  1162. margin-bottom: 14px;
  1163. border: 1px solid #dfe6ee;
  1164. border-radius: 8px;
  1165. background: white;
  1166. }
  1167. .page-heading {
  1168. display: flex;
  1169. align-items: center;
  1170. justify-content: space-between;
  1171. gap: 10px;
  1172. }
  1173. .page-actions {
  1174. display: flex;
  1175. gap: 6px;
  1176. }
  1177. .page-actions button {
  1178. border: 0;
  1179. background: #f4f7fa;
  1180. color: #597088;
  1181. padding: 4px 8px;
  1182. border-radius: 4px;
  1183. cursor: pointer;
  1184. min-width: 28px;
  1185. }
  1186. button:disabled {
  1187. opacity: 0.4;
  1188. cursor: default;
  1189. }
  1190. button:focus-visible {
  1191. outline: 2px solid #1989fa;
  1192. outline-offset: 2px;
  1193. }
  1194. .drag-handle {
  1195. cursor: grab !important;
  1196. }
  1197. .page-fields {
  1198. display: grid;
  1199. grid-template-columns: 1fr 110px 65px;
  1200. gap: 12px;
  1201. align-items: end;
  1202. }
  1203. .page-fields .check {
  1204. padding-bottom: 9px;
  1205. }
  1206. .add-page {
  1207. width: 100%;
  1208. padding: 13px;
  1209. border: 1px dashed #9bbbe0;
  1210. border-radius: 6px;
  1211. background: #f8fbff;
  1212. color: #1989fa;
  1213. cursor: pointer;
  1214. }
  1215. .generation {
  1216. min-height: 360px;
  1217. display: flex;
  1218. flex-direction: column;
  1219. align-items: center;
  1220. justify-content: center;
  1221. text-align: center;
  1222. padding: 30px;
  1223. background: #f8fafc;
  1224. border: 1px solid #e2e9f1;
  1225. border-radius: 8px;
  1226. }
  1227. .generation p {
  1228. max-width: 650px;
  1229. }
  1230. .generation.showing-preview { justify-content: flex-start; padding: 16px; }
  1231. progress {
  1232. width: 320px;
  1233. max-width: 100%;
  1234. height: 8px;
  1235. margin: 20px 0;
  1236. accent-color: #1989fa;
  1237. }
  1238. .error-icon {
  1239. color: #d58a2d;
  1240. border: 1px solid;
  1241. border-radius: 50%;
  1242. width: 36px;
  1243. height: 36px;
  1244. line-height: 36px;
  1245. margin-bottom: 18px;
  1246. font-size: 24px;
  1247. }
  1248. .spinner {
  1249. width: 30px;
  1250. height: 30px;
  1251. border: 3px solid #dceafa;
  1252. border-top-color: #1989fa;
  1253. border-radius: 50%;
  1254. animation: spin 1s linear infinite;
  1255. margin-bottom: 22px;
  1256. }
  1257. @keyframes spin {
  1258. to {
  1259. transform: rotate(360deg);
  1260. }
  1261. }
  1262. @media (prefers-reduced-motion: reduce) {
  1263. .spinner {
  1264. animation: none;
  1265. }
  1266. }
  1267. footer {
  1268. display: flex;
  1269. align-items: center;
  1270. justify-content: space-between;
  1271. gap: 12px;
  1272. border-top: 1px solid #e9eef4;
  1273. padding-top: 16px;
  1274. margin-top: 18px;
  1275. }
  1276. footer > div {
  1277. display: flex;
  1278. flex-wrap: wrap;
  1279. justify-content: flex-end;
  1280. gap: 12px;
  1281. }
  1282. @media (max-width: 740px) {
  1283. .steps {
  1284. padding: 6px;
  1285. }
  1286. .steps li {
  1287. flex-direction: column;
  1288. font-size: 12px;
  1289. gap: 5px;
  1290. }
  1291. .outline-grid {
  1292. grid-template-columns: 1fr;
  1293. }
  1294. .section-heading {
  1295. align-items: start;
  1296. flex-direction: column;
  1297. }
  1298. .page-fields {
  1299. grid-template-columns: 1fr 90px 60px;
  1300. }
  1301. .fields {
  1302. gap: 12px;
  1303. }
  1304. footer {
  1305. flex-wrap: wrap;
  1306. }
  1307. .generation {
  1308. padding: 15px;
  1309. min-height: 260px;
  1310. }
  1311. }
  1312. .page-toggle {
  1313. display: flex;
  1314. align-items: center;
  1315. gap: 10px;
  1316. border: 0;
  1317. padding: 4px 0;
  1318. background: transparent;
  1319. text-align: left;
  1320. flex: 1;
  1321. min-width: 0;
  1322. cursor: pointer;
  1323. color: #283544;
  1324. font: inherit;
  1325. }
  1326. .page-toggle strong {
  1327. flex: 1;
  1328. overflow-wrap: anywhere;
  1329. }
  1330. .page-number {
  1331. display: grid;
  1332. place-items: center;
  1333. width: 25px;
  1334. height: 25px;
  1335. flex-shrink: 0;
  1336. border-radius: 5px;
  1337. background: #edf4fc;
  1338. color: #5482b3;
  1339. font-size: 12px;
  1340. }
  1341. .page-time {
  1342. white-space: nowrap;
  1343. font-size: 12px;
  1344. color: #8392a5;
  1345. }
  1346. .page-summary {
  1347. font-size: 12px;
  1348. margin: 8px 0 2px 35px;
  1349. display: -webkit-box;
  1350. -webkit-line-clamp: 3;
  1351. -webkit-box-orient: vertical;
  1352. overflow: hidden;
  1353. }
  1354. .page-edit {
  1355. margin-top: 16px;
  1356. }
  1357. .expanded {
  1358. border-color: #a8cef4;
  1359. }
  1360. .template-choice {
  1361. margin: 18px 0;
  1362. }
  1363. .template-choice img {
  1364. width: 100%;
  1365. aspect-ratio: 16/9;
  1366. object-fit: cover;
  1367. border-radius: 4px;
  1368. border: 1px solid #e4eaf2;
  1369. margin: 8px 0;
  1370. }
  1371. .template-choice > div {
  1372. display: flex;
  1373. justify-content: space-between;
  1374. align-items: center;
  1375. }
  1376. .template-choice button {
  1377. border: 0;
  1378. background: transparent;
  1379. color: #1989fa;
  1380. cursor: pointer;
  1381. padding: 4px;
  1382. }
  1383. .template-options {
  1384. display: grid;
  1385. grid-template-columns: repeat(3, 1fr);
  1386. gap: 10px;
  1387. margin-bottom: 18px;
  1388. }
  1389. .template-options button {
  1390. padding: 5px;
  1391. background: white;
  1392. border: 1px solid #e0e7ef;
  1393. border-radius: 5px;
  1394. cursor: pointer;
  1395. color: #506278;
  1396. }
  1397. .template-options .selected {
  1398. border-color: #1989fa;
  1399. }
  1400. .template-options img,
  1401. .blank-template-preview {
  1402. display: block;
  1403. width: 100%;
  1404. aspect-ratio: 16/9;
  1405. object-fit: cover;
  1406. margin-bottom: 5px;
  1407. }
  1408. .blank-template-preview {
  1409. background: #fff;
  1410. box-shadow: inset 0 0 0 1px #e5e7eb;
  1411. }
  1412. @media (max-width: 740px) {
  1413. .page-heading {
  1414. flex-wrap: wrap;
  1415. }
  1416. .page-toggle {
  1417. flex-basis: 100%;
  1418. }
  1419. .page-actions {
  1420. margin-left: 35px;
  1421. }
  1422. .template-choice img {
  1423. max-width: 225px;
  1424. display: block;
  1425. }
  1426. }
  1427. .page-count {
  1428. white-space: nowrap;
  1429. color: #7a899c;
  1430. }
  1431. .requirements-summary {
  1432. padding: 10px 12px;
  1433. margin: 0 0 16px;
  1434. background: #f4f7fb;
  1435. border-radius: 5px;
  1436. color: #66798f;
  1437. }
  1438. .outline-list {
  1439. margin-top: 16px;
  1440. max-width: 920px;
  1441. margin: 0 auto;
  1442. }
  1443. .teaching-design {
  1444. margin: 16px 0;
  1445. padding: 16px 20px;
  1446. border-left: 3px solid #1989fa;
  1447. background: #f3f7fc;
  1448. color: #25374b;
  1449. }
  1450. .teaching-design p { margin: 8px 0 0; line-height: 1.6; }
  1451. .outline-options {
  1452. display: flex;
  1453. align-items: baseline;
  1454. gap: 12px;
  1455. flex-wrap: wrap;
  1456. padding: 16px 0 4px;
  1457. color: #7b899a;
  1458. font-size: 13px;
  1459. }
  1460. .outline-options button {
  1461. background: none;
  1462. border: 0;
  1463. color: #1989fa;
  1464. cursor: pointer;
  1465. }
  1466. .outline-options details {
  1467. margin: 0 0 0 auto;
  1468. max-width: 100%;
  1469. }
  1470. .outline-options summary {
  1471. margin: 0;
  1472. font-size: 13px;
  1473. }
  1474. .outline-options a {
  1475. display: block;
  1476. color: #1989fa;
  1477. margin: 8px 0;
  1478. overflow-wrap: anywhere;
  1479. }
  1480. .page-summary {
  1481. margin-top: 4px;
  1482. font-size: 13px;
  1483. line-height: 1.7;
  1484. }
  1485. </style>