AiCoursewareDialog.vue 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639
  1. <template>
  2. <Modal
  3. :visible="visible"
  4. :width="1120"
  5. :close-button="true"
  6. :close-on-click-mask="!working"
  7. @update:visible="value => emit('update:visible', value)"
  8. >
  9. <div class="ai-dialog">
  10. <div class="header">
  11. <div>
  12. <div class="title">{{ pageTitle }}</div>
  13. <div class="sub">{{ pageMeta }}</div>
  14. </div>
  15. </div>
  16. <div v-if="outlineLoading" class="loading-strip">
  17. <span class="loading-dot"></span>
  18. <span>正在生成大纲,请稍候</span>
  19. </div>
  20. <div v-else-if="taskInfo" class="progress-strip">
  21. <div class="current-step">{{ currentProgressLabel }}</div>
  22. <div class="progress-line">
  23. <div class="progress-bar" :style="{ width: `${progressValue}%` }"></div>
  24. </div>
  25. <div class="progress-value">{{ progressValue }}%</div>
  26. </div>
  27. <div class="extra">
  28. <div class="label">补充要求</div>
  29. <TextArea v-model:value="extraPrompt" :rows="2" placeholder="可选,例如:多一些节奏互动,语言更适合小学低年级" :disabled="working" />
  30. </div>
  31. <div v-if="outline" class="outline">
  32. <div class="page-nav">
  33. <button
  34. v-for="page in outline.pages"
  35. :key="page.pageNo"
  36. class="nav-item"
  37. :class="{ active: selectedPageNo === page.pageNo }"
  38. type="button"
  39. @click="selectedPageNo = page.pageNo"
  40. >
  41. <span class="nav-no">{{ formatPageNo(page.pageNo) }}</span>
  42. <span class="nav-title">{{ page.title || "未命名页面" }}</span>
  43. </button>
  44. </div>
  45. <div v-if="selectedPage" class="page-editor">
  46. <div class="editor-head">
  47. <div class="title-editor">
  48. <input v-model="selectedPage.title" class="page-title" :disabled="working" />
  49. </div>
  50. </div>
  51. <div class="field-grid">
  52. <div class="field-card">
  53. <div class="field-title">学生看到的内容</div>
  54. <input
  55. :value="studentHeadline"
  56. :disabled="working"
  57. class="headline-input"
  58. placeholder="这一页给学生看的核心标题"
  59. @input="event => updateStudentHeadline(selectedPage!, event)"
  60. />
  61. <textarea
  62. :value="studentPoints.join('\n')"
  63. :disabled="working"
  64. class="page-bullets"
  65. placeholder="每行一条学生可见要点,避免写老师操作步骤"
  66. @input="event => updateStudentPoints(selectedPage!, event)"
  67. ></textarea>
  68. </div>
  69. <div class="field-card">
  70. <div class="field-title">课堂活动</div>
  71. <input
  72. :value="selectedPage.activity?.task || ''"
  73. :disabled="working"
  74. class="headline-input"
  75. placeholder="课堂任务,例如:听辨山谷和小鸟的声音"
  76. @input="event => updateActivityTask(selectedPage!, event)"
  77. />
  78. <textarea
  79. :value="(selectedPage.activity?.steps || []).join('\n')"
  80. :disabled="working"
  81. class="page-bullets"
  82. placeholder="每行一步课堂操作,供生成活动页或备注使用"
  83. @input="event => updateActivitySteps(selectedPage!, event)"
  84. ></textarea>
  85. </div>
  86. </div>
  87. <div class="field-card visual full-field">
  88. <div class="field-title">设计要求</div>
  89. <textarea
  90. v-model="selectedPage.designRequirement"
  91. :disabled="working"
  92. class="page-design"
  93. placeholder="例如:左文右图,右侧放卡通山谷和音符,整体暖色活泼,文字保持大字号。"
  94. ></textarea>
  95. </div>
  96. <div class="keyword-card">
  97. <div class="keyword-head">
  98. <div class="keyword-label">图片关键词</div>
  99. <div class="keyword-hint">用于匹配课件图片,可手动补充或删减</div>
  100. </div>
  101. <input
  102. :value="selectedPage.imageKeywords.join('、')"
  103. class="keyword-input"
  104. :disabled="working"
  105. placeholder="用于检索网络图片,例如:山谷、音乐、卡通、小鸟"
  106. @input="event => updateImageKeywords(selectedPage!, event)"
  107. />
  108. </div>
  109. <div class="remark-card">
  110. <div class="remark-head">
  111. <div class="remark-title">PPT备注</div>
  112. <div class="remark-hint">仅老师授课时参考,不会显示在幻灯片画面中</div>
  113. </div>
  114. <textarea v-model="selectedPage.teacherTips" :disabled="working" class="remark-input"></textarea>
  115. </div>
  116. </div>
  117. </div>
  118. <div v-else class="empty">生成大纲后,可在这里确认15页结构、页面内容和设计要求。</div>
  119. <div class="footer">
  120. <Button @click="emit('update:visible', false)" :disabled="working">取消</Button>
  121. <Button type="primary" @click="handleOutline" :disabled="working">{{ outline ? "重新生成大纲" : "生成大纲" }}</Button>
  122. <Button type="primary" @click="handleGenerate" :disabled="!outline || working">确认并生成PPT</Button>
  123. </div>
  124. </div>
  125. </Modal>
  126. </template>
  127. <script lang="ts" setup>
  128. import { ref, computed, watch } from "vue"
  129. import { ElMessage, ElMessageBox } from "element-plus"
  130. import Modal from "@/components/Modal.vue"
  131. import Button from "@/components/Button.vue"
  132. import TextArea from "@/components/TextArea.vue"
  133. import queryParams from "@/queryParams"
  134. import usePptWork from "@/store/pptWork"
  135. import { useSlidesStore } from "@/store"
  136. import useHistorySnapshot from "@/hooks/useHistorySnapshot"
  137. import { httpAjaxErrMsg } from "@/plugins/httpAjax"
  138. import {
  139. aiCoursewareGenerateApi,
  140. aiCoursewareOutlineApi,
  141. aiCoursewareResultApi,
  142. aiCoursewareTaskApi,
  143. type AiCoursewareOutline,
  144. type AiCoursewarePage,
  145. type AiCoursewareTaskInfo,
  146. type AiCoursewareGenerateResult
  147. } from "@/api/aiCourseware"
  148. import { aiCoursewareToSlides } from "@/utils/aiCoursewareToSlides"
  149. const props = defineProps<{
  150. visible: boolean
  151. }>()
  152. const emit = defineEmits<{
  153. (event: "update:visible", payload: boolean): void
  154. }>()
  155. const pptWork = usePptWork()
  156. const slidesStore = useSlidesStore()
  157. const { addHistorySnapshot } = useHistorySnapshot()
  158. const extraPrompt = ref("")
  159. const outline = ref<AiCoursewareOutline>()
  160. const taskInfo = ref<AiCoursewareTaskInfo>()
  161. const pollTimer = ref<number>()
  162. const selectedPageNo = ref(1)
  163. const outlineLoading = ref(false)
  164. const working = computed(() => outlineLoading.value || taskInfo.value?.status === "PENDING" || taskInfo.value?.status === "RUNNING")
  165. const progressValue = computed(() => taskInfo.value?.progress || 0)
  166. const progressSteps = [
  167. { threshold: 0, label: "准备中" },
  168. { threshold: 25, label: "生成内容" },
  169. { threshold: 65, label: "匹配图片" },
  170. { threshold: 100, label: "生成完成" }
  171. ]
  172. const currentProgressLabel = computed(() => {
  173. const matched = [...progressSteps].reverse().find(step => progressValue.value >= step.threshold)
  174. return shortProgressLabel(taskInfo.value?.message) || matched?.label || "处理中"
  175. })
  176. const pageTitle = computed(() => outline.value?.chapterName || outline.value?.title || "课件大纲")
  177. const pageMeta = computed(() => {
  178. if (!outline.value) return "根据当前教材、单元和章节生成15页音乐课件"
  179. return [...[outline.value.gradeName, outline.value.unitName].filter(Boolean), `共${outline.value.pages.length}页`].join(" · ")
  180. })
  181. const selectedPage = computed(() => {
  182. const pages = outline.value?.pages || []
  183. return pages.find(page => page.pageNo === selectedPageNo.value) || pages[0]
  184. })
  185. const studentHeadline = computed(() => selectedPage.value?.studentContent?.headline || selectedPage.value?.subtitle || "")
  186. const studentPoints = computed(() => {
  187. if (!selectedPage.value) return []
  188. return selectedPage.value.studentContent?.points?.length ? selectedPage.value.studentContent.points : selectedPage.value.bullets || []
  189. })
  190. watch(
  191. () => props.visible,
  192. visible => {
  193. if (!visible) clearTask()
  194. }
  195. )
  196. async function handleOutline() {
  197. if (!pptWork.id) {
  198. ElMessage.error("当前课件ID为空")
  199. return
  200. }
  201. outlineLoading.value = true
  202. const res = await httpAjaxErrMsg(aiCoursewareOutlineApi, {
  203. id: pptWork.id,
  204. fromType: queryParams.pptResourcesType || queryParams.fromType,
  205. extraPrompt: buildExtraPrompt()
  206. }).finally(() => {
  207. outlineLoading.value = false
  208. })
  209. if (res.code === 200) {
  210. outline.value = res.data as AiCoursewareOutline
  211. selectedPageNo.value = outline.value.pages[0]?.pageNo || 1
  212. }
  213. }
  214. async function handleGenerate() {
  215. if (!outline.value || !pptWork.id) return
  216. const confirmed = await ElMessageBox.confirm("生成后会替换当前编辑器中的课件内容,未保存内容将丢失。确认继续?", "确认生成", {
  217. confirmButtonText: "确认生成",
  218. cancelButtonText: "取消",
  219. type: "warning"
  220. })
  221. .then(() => true)
  222. .catch(() => false)
  223. if (!confirmed) return
  224. const res = await httpAjaxErrMsg(aiCoursewareGenerateApi, {
  225. id: pptWork.id,
  226. fromType: queryParams.pptResourcesType || queryParams.fromType,
  227. outline: outline.value
  228. })
  229. if (res.code !== 200) return
  230. taskInfo.value = res.data as AiCoursewareTaskInfo
  231. pollTask(taskInfo.value.taskId)
  232. }
  233. async function pollTask(taskId: string) {
  234. if (pollTimer.value) window.clearInterval(pollTimer.value)
  235. await fetchTask(taskId)
  236. pollTimer.value = window.setInterval(() => fetchTask(taskId), 2000)
  237. }
  238. async function fetchTask(taskId: string) {
  239. const res = await httpAjaxErrMsg(aiCoursewareTaskApi, taskId)
  240. if (res.code !== 200) {
  241. clearTask()
  242. return
  243. }
  244. taskInfo.value = res.data as AiCoursewareTaskInfo
  245. if (taskInfo.value.status === "DONE") {
  246. clearPollTimer()
  247. await applyResult(taskId)
  248. } else if (taskInfo.value.status === "FAILED") {
  249. clearPollTimer()
  250. ElMessage.error(taskInfo.value.message || "AI课件生成失败")
  251. }
  252. }
  253. function clearTask() {
  254. clearPollTimer()
  255. taskInfo.value = undefined
  256. }
  257. function clearPollTimer() {
  258. if (pollTimer.value) {
  259. window.clearInterval(pollTimer.value)
  260. pollTimer.value = undefined
  261. }
  262. }
  263. async function applyResult(taskId: string) {
  264. const res = await httpAjaxErrMsg(aiCoursewareResultApi, taskId)
  265. if (res.code !== 200) return
  266. const result = res.data as AiCoursewareGenerateResult
  267. const converted = aiCoursewareToSlides(result.outline)
  268. slidesStore.setTitle(converted.title)
  269. slidesStore.setTheme(converted.theme)
  270. slidesStore.setSlides(converted.slides)
  271. slidesStore.updateSlideIndex(0)
  272. addHistorySnapshot()
  273. ElMessage.success("AI课件已生成,请检查后手动保存")
  274. emit("update:visible", false)
  275. }
  276. function updateStudentHeadline(page: AiCoursewarePage, event: Event) {
  277. if (!page.studentContent) page.studentContent = { headline: "", points: [] }
  278. page.studentContent.headline = (event.target as HTMLInputElement).value.trim()
  279. }
  280. function updateStudentPoints(page: AiCoursewarePage, event: Event) {
  281. if (!page.studentContent) page.studentContent = { headline: "", points: [] }
  282. const points = (event.target as HTMLTextAreaElement).value
  283. .split("\n")
  284. .map(item => item.trim())
  285. .filter(Boolean)
  286. page.studentContent.points = points
  287. page.bullets = points
  288. }
  289. function updateActivityTask(page: AiCoursewarePage, event: Event) {
  290. if (!page.activity) page.activity = { task: "", steps: [] }
  291. page.activity.task = (event.target as HTMLInputElement).value.trim()
  292. }
  293. function updateActivitySteps(page: AiCoursewarePage, event: Event) {
  294. if (!page.activity) page.activity = { task: "", steps: [] }
  295. page.activity.steps = (event.target as HTMLTextAreaElement).value
  296. .split("\n")
  297. .map(item => item.trim())
  298. .filter(Boolean)
  299. }
  300. function updateImageKeywords(page: AiCoursewarePage, event: Event) {
  301. page.imageKeywords = (event.target as HTMLInputElement).value
  302. .split(/[,,、;;\s]+/)
  303. .map(item => item.trim())
  304. .filter(Boolean)
  305. .slice(0, 6)
  306. }
  307. function buildExtraPrompt() {
  308. return extraPrompt.value.trim()
  309. }
  310. function formatPageNo(pageNo?: number) {
  311. return String(pageNo || 0).padStart(2, "0")
  312. }
  313. function shortProgressLabel(message?: string) {
  314. if (!message) return ""
  315. if (message.includes("读取") || message.includes("等待")) return "准备中"
  316. if (message.includes("页面") || message.includes("内容")) return "生成内容"
  317. if (message.includes("图片") || message.includes("检索")) return "匹配图片"
  318. if (message.includes("完成")) return "生成完成"
  319. if (message.includes("失败")) return "生成失败"
  320. return message.length > 4 ? message.slice(0, 4) : message
  321. }
  322. </script>
  323. <style lang="scss" scoped>
  324. .ai-dialog {
  325. height: 82vh;
  326. display: flex;
  327. flex-direction: column;
  328. color: #1f2933;
  329. }
  330. .header,
  331. .footer {
  332. display: flex;
  333. align-items: center;
  334. justify-content: space-between;
  335. }
  336. .title {
  337. font-size: 24px;
  338. font-weight: 700;
  339. color: #1f2933;
  340. }
  341. .sub {
  342. margin-top: 6px;
  343. color: #7b8794;
  344. font-size: 14px;
  345. }
  346. .progress-strip,
  347. .loading-strip {
  348. margin-top: 12px;
  349. height: 42px;
  350. display: flex;
  351. align-items: center;
  352. gap: 12px;
  353. }
  354. .loading-strip {
  355. color: #52616b;
  356. font-size: 13px;
  357. }
  358. .loading-dot {
  359. width: 14px;
  360. height: 14px;
  361. border: 2px solid #d7e7ff;
  362. border-top-color: $themeColor;
  363. border-radius: 50%;
  364. animation: loading-rotate 0.8s linear infinite;
  365. }
  366. .current-step {
  367. width: 64px;
  368. flex-shrink: 0;
  369. color: $themeColor;
  370. font-size: 13px;
  371. font-weight: 600;
  372. white-space: nowrap;
  373. }
  374. .progress-value {
  375. width: 42px;
  376. text-align: right;
  377. font-size: 13px;
  378. color: $themeColor;
  379. font-weight: 700;
  380. }
  381. .progress-line {
  382. height: 6px;
  383. flex: 1;
  384. overflow: hidden;
  385. border-radius: 999px;
  386. background: #e7edf5;
  387. }
  388. .progress-bar {
  389. height: 100%;
  390. border-radius: inherit;
  391. background: $themeColor;
  392. transition: width 0.25s ease;
  393. }
  394. @keyframes loading-rotate {
  395. to {
  396. transform: rotate(360deg);
  397. }
  398. }
  399. .extra {
  400. margin-top: 12px;
  401. }
  402. .label {
  403. margin-bottom: 8px;
  404. font-size: 14px;
  405. color: #52616b;
  406. }
  407. .outline {
  408. margin-top: 14px;
  409. min-height: 0;
  410. flex: 1;
  411. display: grid;
  412. grid-template-columns: 280px minmax(0, 1fr);
  413. gap: 14px;
  414. }
  415. .page-nav {
  416. overflow: auto;
  417. border: 1px solid #edf1f5;
  418. border-radius: 8px;
  419. background: #fafcff;
  420. padding: 8px;
  421. }
  422. .nav-item {
  423. width: 100%;
  424. display: flex;
  425. align-items: center;
  426. gap: 10px;
  427. min-height: 42px;
  428. padding: 8px 10px;
  429. border: 0;
  430. border-radius: 6px;
  431. background: transparent;
  432. color: #52616b;
  433. text-align: left;
  434. cursor: pointer;
  435. &.active {
  436. background: #eaf4ff;
  437. color: #1f2933;
  438. .nav-no {
  439. color: #fff;
  440. background: $themeColor;
  441. }
  442. }
  443. }
  444. .nav-no {
  445. width: 34px;
  446. height: 28px;
  447. line-height: 28px;
  448. text-align: center;
  449. border-radius: 5px;
  450. color: #7b8794;
  451. background: #edf1f5;
  452. flex-shrink: 0;
  453. font-size: 12px;
  454. font-weight: 600;
  455. }
  456. .nav-title {
  457. min-width: 0;
  458. overflow: hidden;
  459. text-overflow: ellipsis;
  460. white-space: nowrap;
  461. font-size: 13px;
  462. font-weight: 600;
  463. }
  464. .page-editor {
  465. min-width: 0;
  466. overflow: auto;
  467. border: 1px solid #edf1f5;
  468. border-radius: 8px;
  469. background: #fff;
  470. padding: 16px;
  471. }
  472. .editor-head {
  473. display: block;
  474. }
  475. .title-editor {
  476. max-width: 460px;
  477. }
  478. .field-grid {
  479. margin-top: 12px;
  480. display: grid;
  481. grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
  482. gap: 12px;
  483. }
  484. .field-card {
  485. padding: 12px;
  486. border: 1px solid #edf1f5;
  487. border-radius: 8px;
  488. background: #fbfcfe;
  489. &.visual {
  490. background: #fffaf3;
  491. }
  492. }
  493. .field-title {
  494. margin-bottom: 8px;
  495. color: #52616b;
  496. font-size: 13px;
  497. font-weight: 600;
  498. }
  499. .page-title,
  500. .headline-input,
  501. .page-bullets,
  502. .page-design,
  503. .keyword-input,
  504. .remark-input {
  505. width: 100%;
  506. border: 1px solid #d9d9d9;
  507. border-radius: 6px;
  508. box-sizing: border-box;
  509. font-family: inherit;
  510. }
  511. .page-title {
  512. height: 36px;
  513. padding: 0 10px;
  514. font-size: 16px;
  515. font-weight: 600;
  516. }
  517. .headline-input {
  518. height: 36px;
  519. padding: 0 10px;
  520. margin-bottom: 10px;
  521. font-size: 14px;
  522. }
  523. .page-bullets,
  524. .page-design {
  525. height: 150px;
  526. padding: 10px;
  527. resize: vertical;
  528. line-height: 1.5;
  529. }
  530. .full-field {
  531. margin-top: 12px;
  532. }
  533. .page-design {
  534. height: 90px;
  535. }
  536. .keyword-card {
  537. margin-top: 12px;
  538. }
  539. .keyword-head {
  540. display: flex;
  541. align-items: baseline;
  542. gap: 10px;
  543. margin-bottom: 8px;
  544. }
  545. .keyword-label {
  546. color: #52616b;
  547. font-size: 13px;
  548. font-weight: 600;
  549. }
  550. .keyword-hint {
  551. color: #9aa5b1;
  552. font-size: 12px;
  553. }
  554. .keyword-input {
  555. height: 34px;
  556. padding: 0 10px;
  557. }
  558. .remark-card {
  559. margin-top: 12px;
  560. padding: 10px 12px;
  561. border-radius: 6px;
  562. background: #f7f9fc;
  563. }
  564. .remark-head {
  565. display: flex;
  566. align-items: baseline;
  567. gap: 10px;
  568. margin-bottom: 8px;
  569. }
  570. .remark-title {
  571. color: #52616b;
  572. font-size: 13px;
  573. font-weight: 600;
  574. }
  575. .remark-hint {
  576. color: #9aa5b1;
  577. font-size: 12px;
  578. }
  579. .remark-input {
  580. height: 72px;
  581. padding: 10px;
  582. resize: vertical;
  583. line-height: 1.5;
  584. background: #fff;
  585. }
  586. .empty {
  587. flex: 1;
  588. margin-top: 14px;
  589. display: flex;
  590. align-items: center;
  591. justify-content: center;
  592. border: 1px dashed #d9d9d9;
  593. color: #7b8794;
  594. }
  595. .task {
  596. min-height: 20px;
  597. }
  598. .footer {
  599. margin-top: 14px;
  600. justify-content: flex-end;
  601. gap: 10px;
  602. }
  603. </style>