| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488 |
- <template>
- <Modal
- :visible="visible"
- :width="1040"
- :content-style="{ maxWidth: 'calc(100vw - 32px)' }"
- :close-button="!transferring"
- :close-on-click-mask="false"
- :close-on-esc="!transferring"
- @update:visible="close"
- >
- <div class="ai-dialog">
- <header>
- <h2>{{ outline?.chapterName || outline?.title || "AI 生成课件" }}</h2>
- <p>{{ metadata || "先确定教学安排,再生成课堂内容" }}</p>
- </header>
- <ol class="steps">
- <li v-for="(label, index) in steps" :key="label" :class="{ active: step === index + 1, done: step > index + 1 }">
- <span>{{ step > index + 1 ? "✓" : index + 1 }}</span
- >{{ label }}
- </li>
- </ol>
- <main ref="mainContent">
- <section v-if="step === 1" class="setup">
- <h3>这节课准备怎么上?</h3>
- <label>课题来源<select :value="preferences.lessonTopic ? 'custom' : 'current'" :disabled="outlineLoading" @change="changeTopicSource">
- <option value="current">当前教材章节</option>
- <option value="custom">本次生成其他课题</option>
- </select></label>
- <template v-if="preferences.lessonTopic">
- <div class="fields">
- <label>年级<input v-model="preferences.lessonTopic.gradeName" maxlength="50" :disabled="outlineLoading" placeholder="例如:六年级" /></label>
- <label>教材版本与册次<input v-model="preferences.lessonTopic.textbookName" maxlength="150" :disabled="outlineLoading" placeholder="例如:花城版音乐六年级下册" /></label>
- </div>
- <div class="fields">
- <label>单元<input v-model="preferences.lessonTopic.unitName" maxlength="150" :disabled="outlineLoading" placeholder="例如:第六单元" /></label>
- <label>课题<input v-model="preferences.lessonTopic.chapterName" maxlength="150" :disabled="outlineLoading" placeholder="例如:学键盘(十)" /></label>
- </div>
- <p class="hint">按本次课题重新核实教材依据;课件所属章节不变。具体乐器和公开课要求可写在下方。</p>
- </template>
- <label>教学思路<select v-model="preferences.teachingApproach" :disabled="outlineLoading">
- <option value="auto">根据教材设计</option>
- <option value="inquiry">音乐要素探究 · 带问题发现与验证</option>
- <option value="story">情境任务 · 用故事串联学习</option>
- <option value="practice">表现实践 · 从模仿到合作创编</option>
- </select></label>
- <div class="fields">
- <label
- >课堂时长<select v-model.number="preferences.lessonMinutes" :disabled="outlineLoading">
- <option v-for="minutes in [20, 30, 40, 45]" :key="minutes" :value="minutes">{{ minutes }} 分钟</option>
- </select></label
- ><label
- >预计页数<select v-model="pageRange" :disabled="outlineLoading">
- <option value="9-12">9~12 页</option>
- <option value="12-15">12~15 页</option>
- <option value="9-15">9~15 页</option>
- </select></label
- >
- </div>
- <label
- >教学范围<span class="hint">选填</span
- ><input v-model="preferences.lessonScope" :disabled="outlineLoading" placeholder="例如:第一课时,重点认识声音的强弱"
- /></label>
- <label
- >补充要求<span class="hint">选填</span
- ><textarea v-model="extraPrompt" :disabled="outlineLoading" rows="3" placeholder="例如:多安排听辨和模仿活动,适合一年级学生" />
- </label>
- <details>
- <summary>更多设置</summary>
- <label
- >学生情况<input v-model="preferences.learningContext" :disabled="outlineLoading" placeholder="例如:音乐基础较弱,喜欢游戏"
- /></label>
- <div class="fields">
- <label
- >配图风格<select v-model="preferences.assetStyle">
- <option value="illustration">插画</option>
- <option value="photo">实景照片</option>
- <option value="mixed">综合搭配</option>
- </select></label
- ><label
- >教材配图<select v-model="preferences.sourceImagePolicy">
- <option value="balanced">情境配图优先生成</option>
- <option value="strict">仅使用教材配图</option>
- <option value="creative">优先生成创意配图</option>
- </select></label
- >
- </div>
- <label class="check"><input v-model="preferences.enableAnimations" type="checkbox" />添加适量动画</label>
- <label>音视频与谱例<select v-model="preferences.resourceMode" :disabled="outlineLoading">
- <option value="teacher_supplement">生成后由老师补充</option>
- <option value="auto_bind">优先使用已有课程资源</option>
- </select></label>
- </details>
- <p class="hint">先完成教学内容与页面设计。音视频、谱例可在生成后添加,所需资源会写入教师备注。</p>
- <p v-if="settingsChanged" class="notice">需求已调整,请重新生成大纲后确认;当前大纲会保留到新大纲生成成功。</p>
- <p v-if="outlineLoading" role="status" class="notice">{{ outlineTask?.message || '正在提交大纲任务,请稍候…' }}。可以收起窗口,稍后继续查看。</p>
- </section>
- <section v-else-if="step === 2 && outline" class="outline-stage">
- <div class="section-heading">
- <div>
- <h3>确认教学安排</h3>
- <p>看看主题、活动和顺序是否合适。需要调整时点击某页,具体文字和排版在生成后直接编辑。</p>
- </div>
- <span class="page-count">共 {{ pages.length }} 页</span>
- </div>
- <p v-if="requirementsSummary" class="requirements-summary">本次要求:{{ requirementsSummary }}</p>
- <div v-if="outline.teachingDesign" class="teaching-design">
- <strong>{{ outline.teachingDesign.drivingQuestion }}</strong>
- <p>{{ outline.teachingDesign.approach }}</p>
- <p>{{ outline.teachingDesign.learningPath?.join(' → ') }}</p>
- </div>
- <ul v-if="validationErrors.length" class="error validation" role="alert">
- <li v-for="message in validationErrors" :key="message">{{ message }}</li>
- </ul>
- <p v-if="!reliableResearch" class="error">尚未核实教材内容,请返回补充教学范围后重新生成大纲。</p>
- <div class="outline-list">
- <Draggable v-model="pages" item-key="key" handle=".drag-handle" :animation="160" :move="canMove" @end="remember"
- ><template #item="{ element, index }"
- ><article class="page-card" :class="{ expanded: expandedPage === element.key }">
- <div class="page-heading">
- <button
- class="page-toggle"
- type="button"
- :aria-expanded="expandedPage === element.key"
- :aria-label="`编辑第${index + 1}页:${element.page.title || '未填写标题'}`"
- @click="expandedPage = expandedPage === element.key ? '' : element.key"
- >
- <span class="page-number">{{ index + 1 }}</span
- ><strong>{{ element.page.title || "未填写标题" }}</strong
- ><span>{{ expandedPage === element.key ? "⌃" : "⌄" }}</span>
- </button>
- <div v-if="index > 0" class="page-actions">
- <button class="drag-handle" type="button" aria-label="拖动排序">⠿</button
- ><button type="button" :disabled="index === 1" :aria-label="`上移第${index + 1}页`" @click="movePage(index, -1)">↑</button
- ><button type="button" :disabled="index === pages.length - 1" :aria-label="`下移第${index + 1}页`" @click="movePage(index, 1)">
- ↓</button
- ><button
- type="button"
- :disabled="pages.length <= MIN_OUTLINE_PAGES"
- :aria-label="`删除第${index + 1}页`"
- @click="removePage(index)"
- >
- 删除
- </button>
- </div>
- </div>
- <p v-if="expandedPage !== element.key" class="page-summary">
- {{ element.brief || "点击页面,简要说明准备讲什么或开展什么活动" }}
- </p>
- <div v-else class="page-edit">
- <label>页面标题<input v-model="element.page.title" @change="remember" /></label>
- <label
- >教学安排<textarea v-model="element.brief" rows="2" placeholder="例如:听两组声音,辨别强弱,再用拍手模仿" @change="remember" />
- </label>
- </div></article></template></Draggable
- ><button class="add-page" type="button" :disabled="pages.length >= MAX_OUTLINE_PAGES" @click="addPage">+ 添加一页</button>
- </div>
- <div class="outline-options">
- <span>课件样式:{{ getAiCoursewareTemplate(templateId).name }}</span
- ><button type="button" @click="showTemplates = !showTemplates">{{ showTemplates ? "收起模板" : "更换模板" }}</button>
- <details>
- <summary>教材依据</summary>
- <p>{{ outline.textbookResearch?.contentSummary }}</p>
- <a v-for="source in sources" :key="source.url" :href="source.url" target="_blank" rel="noopener noreferrer">{{
- source.title || "教材来源"
- }}</a>
- </details>
- </div>
- <div v-if="showTemplates" class="template-options">
- <button
- v-for="template in AI_COURSEWARE_TEMPLATES"
- :key="template.id"
- :class="{ selected: templateId === template.id }"
- :aria-pressed="templateId === template.id"
- @click="selectTemplate(template.id)"
- >
- <span v-if="template.id === DEFAULT_AI_COURSEWARE_TEMPLATE_ID" class="blank-template-preview" aria-hidden="true"></span>
- <img v-else :src="templatePreview(template.id)" :alt="template.name" /><span>{{ template.name }}</span>
- </button>
- </div>
- </section>
- <section v-else class="generation" :class="{ 'showing-preview': generationError && completionSource && showCompletionContent }" aria-live="polite">
- <AiCoursewareDraftPreview v-if="generationError && completionSource && showCompletionContent"
- :outline="completionSource" :template-id="templateId" :message="generationError" @layout-errors="recordCompletionLayoutErrors" />
- <template v-else-if="generationError"
- ><span class="error-icon">!</span>
- <h3>{{ completionSource ? `已保留 ${completionSource.pages.length} 页,部分内容待完善` : task ? "课件进度暂时无法继续" : "这次生成未完成" }}</h3>
- <p>{{ generationError }}</p>
- <p class="hint">{{ completionSource ? '可以查看逐页预览,或打开草稿编辑并补图。自动完善仍未成功时,已生成的页面仍可使用。' : '已保留确认的大纲,当前课件没有被替换。' }}</p>
- </template
- ><template v-else
- ><span class="spinner"></span>
- <h3>正在生成课件</h3>
- <p>{{ task?.message || "正在准备课堂内容" }}</p>
- <progress :value="task?.progress || 0" max="100"></progress>
- <p class="hint">完成后进入编辑器。你可以先收起窗口。</p></template
- >
- </section>
- </main>
- <p v-if="step === 1 && setupError" role="alert" class="error">{{ setupError }}</p>
- <footer>
- <Button :disabled="transferring" @click="close">{{ task || outlineLoading ? "收起窗口" : "关闭" }}</Button>
- <div v-if="step === 1">
- <Button v-if="outline" :disabled="outlineLoading || settingsChanged" @click="step = 2">继续确认大纲</Button
- ><Button type="primary" :disabled="outlineLoading" @click="generateOutline">{{
- outlineLoading ? "正在生成大纲…" : outlineTask ? "继续查看大纲" : outline ? "重新生成大纲" : "生成大纲"
- }}</Button>
- </div>
- <div v-else-if="step === 2">
- <Button @click="backToSettings">返回修改需求</Button
- ><Button type="primary" :disabled="!reliableResearch || transferring" @click="confirmAndGenerate">确认大纲并生成</Button>
- </div>
- <div v-else-if="generationError">
- <Button v-if="!transferring" @click="backToSettings">修改需求</Button>
- <Button v-if="completionSource" :disabled="transferring" :aria-expanded="showCompletionContent" @click="showCompletionContent = !showCompletionContent">{{ showCompletionContent ? "收起预览" : "查看已生成内容" }}</Button>
- <Button v-if="!task && !completionSource" @click="step = 2">返回修改大纲</Button>
- <Button :type="completionSource ? 'default' : 'primary'" :disabled="transferring" @click="retry">{{ layoutRepairSource && layoutRepairFeedback ? (modelLayoutRepair ? "重新设计页面" : "精简并重新排版") : completionSource ? "继续自动完善" : task ? "继续查看课件" : "重试生成" }}</Button>
- <Button v-if="completionSource" type="primary" :disabled="transferring" @click="openCompletionDraft">{{ transferring ? "正在打开…" : "打开草稿编辑" }}</Button>
- </div>
- </footer>
- </div>
- </Modal>
- </template>
- <script setup lang="ts">
- import { blockingAiPptLayoutIssues } from '@/utils/aiPptQuality'
- import { computed, nextTick, onBeforeUnmount, reactive, ref, watch } from "vue"
- import { ElMessage, ElMessageBox } from "element-plus"
- import Draggable from "vuedraggable"
- import { nanoid } from "nanoid"
- import Modal from "@/components/Modal.vue"
- import Button from "@/components/Button.vue"
- import AiCoursewareDraftPreview from './AiCoursewareDraftPreview.vue'
- import queryParams from "@/queryParams"
- import usePptWork from "@/store/pptWork"
- import { useMainStore, useSlidesStore, useSnapshotStore } from "@/store"
- import { httpAjax } from "@/plugins/httpAjax"
- import {
- aiCoursewareGenerateApi,
- aiCoursewareOutlineTaskApi,
- aiCoursewareResultApi,
- aiCoursewareTaskApi,
- type AiCoursewareOutline,
- type AiCoursewarePreferences,
- type AiCoursewareTaskInfo,
- type AiCoursewareGenerateResult
- } from "@/api/aiCourseware"
- import { AI_COURSEWARE_TEMPLATES, DEFAULT_AI_COURSEWARE_TEMPLATE_ID, getAiCoursewareTemplate } from "@/config/aiCoursewareTemplates"
- import { assertAiPptClientCurrent } from "@/utils/aiPptClientVersion"
- import { aiCoursewareToSlides } from "@/utils/aiCoursewareToSlides"
- import { getAiPptImageAspectRatios } from "@/utils/aiPptTemplateEngine"
- import { inspectAiPptQuality, type AiPptQualityIssue } from "@/utils/aiPptQuality"
- import { inspectAiPptResources } from "@/utils/aiPptResources"
- import { teachingReviewIssues, blockingImageIssues, coursewareCompletionMessage, isAdvisoryCompletion } from '@/utils/aiPptFeedback'
- import { templateAssetPath } from "@/utils/templateAssetUrl"
- import { draftKey, loadAiDraft, saveAiDraft } from "@/utils/aiCoursewareDraft"
- import {
- editableOutlinePages,
- confirmedCoursewareOutline,
- inspectConfirmedOutline,
- MIN_OUTLINE_PAGES,
- MAX_OUTLINE_PAGES,
- type EditableOutlinePage
- } from "@/utils/aiCoursewareOutline"
- const props = defineProps<{ visible: boolean }>()
- const emit = defineEmits<{ (e: "update:visible", value: boolean): void; (e: "generated", issues: AiPptQualityIssue[], outline: AiCoursewareOutline, preferences: AiCoursewarePreferences): void }>()
- const slidesStore = useSlidesStore(),
- pptWork = usePptWork(),
- snapshots = useSnapshotStore()
- const clone = <T,>(value: T): T => JSON.parse(JSON.stringify(value))
- const defaults = (): AiCoursewarePreferences => ({
- lessonTopic: undefined,
- teachingApproach: 'auto',
- resourceMode: 'teacher_supplement',
- lessonMinutes: 40,
- minPages: 9,
- maxPages: 12,
- assetStyle: "illustration",
- sourceImagePolicy: "balanced",
- requireAudio: false,
- requireVideo: false,
- enableAnimations: true
- })
- const preferences = reactive(defaults())
- const expandedPage = ref(""),
- showTemplates = ref(false),
- outlineSettings = ref("")
- const settingsSignature = () => JSON.stringify([preferences, extraPrompt.value, pageRange.value])
- const settingsChanged = computed(() => !!outline.value && outlineSettings.value !== settingsSignature())
- const templatePreview = (id: string) => templateAssetPath(id, getAiCoursewareTemplate(id).backgroundFiles[0])
- const steps = ["设置需求", "确认大纲", "生成课件"]
- const step = ref(1),
- outline = ref<AiCoursewareOutline>(),
- pages = ref<EditableOutlinePage[]>([])
- const extraPrompt = ref(""),
- pageRange = ref("9-12"),
- templateId = ref(DEFAULT_AI_COURSEWARE_TEMPLATE_ID)
- const outlineLoading = ref(false),
- transferring = ref(false),
- setupError = ref(""),
- generationError = ref(""),
- validationErrors = ref<string[]>([])
- const task = ref<AiCoursewareTaskInfo>(),
- confirmed = ref<AiCoursewareOutline>()
- const layoutRepairSource = ref<AiCoursewareOutline>()
- const layoutRepairFeedback = ref('')
- const modelLayoutRepair = computed(() => layoutRepairSource.value?.pages.some(page => !!page.pageDesign))
- const renderDiagnostics = ref<Array<{ pageNo: number; code: string; message: string }>>([])
- let automaticDesignRepairs = 0
- const completionSource = ref<AiCoursewareOutline>()
- const completionReviewMessage = ref('')
- const mainContent = ref<HTMLElement>()
- const showCompletionContent = ref(false)
- watch(completionSource, value => {
- showCompletionContent.value = !!value
- if (!value) completionReviewMessage.value = ''
- else void nextTick(() => { if (mainContent.value) mainContent.value.scrollTop = 0 })
- })
- const outlineTask = ref<AiCoursewareTaskInfo>(), outlineTaskSettings = ref('')
- let pollTimer: ReturnType<typeof setTimeout> | undefined,
- version = 0,
- replacementSnapshot = "",
- saveQueue = Promise.resolve(),
- pollVersion = 0
- const source = () => queryParams.pptResourcesType || queryParams.fromType || "TEACHER"
- const metadata = computed(() => [outline.value?.gradeName, outline.value?.unitName].filter(Boolean).join(" · "))
- const sources = computed(() => (outline.value?.textbookResearch?.sources || []).filter(item => /^https?:\/\//i.test(item.url)))
- const reliableResearch = computed(
- () => outline.value?.textbookResearch?.exactMatch && outline.value.textbookResearch.confidence === "HIGH" && sources.value.length > 0
- )
- const matchesRequestedTopic = (value: AiCoursewareOutline) => !preferences.lessonTopic ||
- (['gradeName', 'textbookName', 'unitName', 'chapterName'] as const)
- .every(key => (value[key] || '').trim() === preferences.lessonTopic![key].trim())
- const requirementsSummary = computed(() => [preferences.lessonScope, preferences.learningContext, extraPrompt.value].filter(Boolean).join(";"))
- const editorSnapshot = () =>
- JSON.stringify([slidesStore.title, slidesStore.slides, slidesStore.theme, slidesStore.viewportSize, slidesStore.viewportRatio])
- const currentPlan = () => {
- if (!outline.value) return undefined
- const plan = confirmedCoursewareOutline(outline.value, pages.value)
- plan.templateId = templateId.value
- return plan
- }
- const stopPolling = () => {
- pollVersion++
- if (pollTimer) clearTimeout(pollTimer)
- pollTimer = undefined
- }
- watch(pageRange, value => {
- const [min, max] = value.split("-").map(Number)
- preferences.minPages = min
- preferences.maxPages = max
- })
- watch(
- () => props.visible,
- visible => {
- if (!visible) { stopPolling(); if (outlineTask.value) outlineLoading.value = false }
- else if (outlineTask.value) void pollOutlineTask()
- else if (task.value) void pollTask()
- else if (!outline.value) void restore()
- }
- )
- watch(
- () => pptWork.id,
- () => {
- version++
- stopPolling()
- outlineLoading.value = false
- task.value = undefined
- outlineTask.value = undefined
- outlineTaskSettings.value = ''
- confirmed.value = undefined
- layoutRepairSource.value = undefined
- completionSource.value = undefined
- outline.value = undefined
- pages.value = []
- extraPrompt.value = ""
- Object.assign(preferences, defaults())
- pageRange.value = "9-12"
- step.value = 1
- if (props.visible) void restore()
- }
- )
- onBeforeUnmount(() => {
- version++
- stopPolling()
- })
- function close() {
- if (transferring.value) return
- remember()
- emit("update:visible", false)
- }
- function remember() {
- const id = pptWork.id,
- from = source(),
- plan = currentPlan(),
- prefs = clone(preferences),
- prompt = extraPrompt.value,
- taskId = task.value?.taskId,
- outlineTaskId = outlineTask.value?.taskId,
- pendingOutlineSettings = outlineTaskSettings.value,
- pendingCompletion = completionSource.value ? clone(completionSource.value) : undefined,
- completionMessage = generationError.value,
- reviewMessage = completionReviewMessage.value,
- savedSettings = outlineSettings.value
- if (!id) return
- saveQueue = saveQueue
- .then(async () => {
- await saveAiDraft({
- key: await draftKey(id, from),
- updatedAt: Date.now(),
- outline: plan,
- preferences: prefs,
- extraPrompt: prompt,
- taskId,
- outlineTaskId,
- outlineTaskSettings: pendingOutlineSettings,
- completionSource: pendingCompletion,
- completionMessage,
- reviewMessage,
- outlineSettings: savedSettings
- })
- })
- .catch(error => {
- console.warn("AI courseware local state unavailable", error)
- })
- }
- function selectTemplate(id: string) {
- templateId.value = id
- showTemplates.value = false
- remember()
- }
- function backToSettings() {
- stopPolling()
- version++
- task.value = undefined
- step.value = 1
- remember()
- }
- function changeTopicSource(event: Event) {
- preferences.lessonTopic = (event.target as HTMLSelectElement).value === 'custom'
- ? { gradeName: '', textbookName: '', unitName: '', chapterName: '' } : undefined
- }
- async function restore() {
- if (!pptWork.id) return
- const requestVersion = version
- try {
- const saved = await loadAiDraft(await draftKey(pptWork.id, source()))
- if (!saved || requestVersion !== version || outline.value || !props.visible) return
- Object.assign(preferences, saved.preferences)
- pageRange.value = `${preferences.minPages}-${preferences.maxPages}`
- extraPrompt.value = saved.extraPrompt
- await nextTick()
- if (requestVersion !== version || !props.visible) return
- if (saved.outline) {
- loadOutline(saved.outline)
- if (saved.outlineSettings) outlineSettings.value = saved.outlineSettings
- step.value = 1
- }
- if (saved.outlineTaskId) {
- outlineTask.value = { taskId: saved.outlineTaskId, status: 'RUNNING', progress: 0, message: '正在获取大纲进度' }
- outlineTaskSettings.value = saved.outlineTaskSettings || settingsSignature()
- step.value = 1
- await pollOutlineTask()
- } else if (saved.completionSource) {
- confirmed.value = currentPlan()
- completionSource.value = clone(saved.completionSource)
- completionReviewMessage.value = saved.reviewMessage || ''
- generationError.value = saved.completionMessage || '课件尚未全部完成,可接着完成。'
- step.value = 3
- if (saved.taskId) {
- task.value = { taskId: saved.taskId, status: 'RUNNING', progress: 0, message: '正在恢复课件进度' }
- await pollTask()
- }
- } else if (saved.taskId && outline.value) {
- confirmed.value = currentPlan()
- task.value = { taskId: saved.taskId, status: "RUNNING", progress: 0, message: "正在获取生成进度" }
- step.value = 3
- await pollTask()
- }
- } catch (error) {
- console.warn("AI courseware restore unavailable", error)
- }
- }
- function loadOutline(value: AiCoursewareOutline) {
- outline.value = clone(value)
- pages.value = editableOutlinePages(value)
- templateId.value = value.templateId || DEFAULT_AI_COURSEWARE_TEMPLATE_ID
- validationErrors.value = []
- expandedPage.value = ""
- outlineSettings.value = settingsSignature()
- }
- async function ask(message: string, confirmButtonText: string) {
- try {
- await ElMessageBox.confirm(message, "请确认", { confirmButtonText, cancelButtonText: "取消", closeOnClickModal: false })
- return true
- } catch {
- return false
- }
- }
- async function generateOutline() {
- if (!pptWork.id || outlineLoading.value) return
- const topic = preferences.lessonTopic
- if (topic && (!topic.gradeName.trim() || !topic.textbookName.trim() || !topic.chapterName.trim())) {
- setupError.value = '请填写年级、教材版本与册次、课题。'; return
- }
- if (outlineTask.value) { await pollOutlineTask(); return }
- if (outline.value && !(await ask("重新生成会替换当前大纲,包括你已修改的内容。是否继续?", "重新生成"))) return
- const requestVersion = ++version
- outlineLoading.value = true
- setupError.value = ""
- try {
- await assertAiPptClientCurrent()
- if (requestVersion !== version) return
- const requestedSettings = settingsSignature()
- const response = await httpAjax(aiCoursewareOutlineTaskApi, {
- id: pptWork.id,
- fromType: source(),
- extraPrompt: extraPrompt.value,
- preferences: clone(preferences)
- })
- if (requestVersion !== version) return
- if (response.code !== 200 || !response.data?.taskId) throw new Error(response.message || "未能提交大纲任务,请重试")
- outlineTask.value = response.data
- outlineTaskSettings.value = requestedSettings
- remember()
- } catch (error) {
- if (requestVersion === version) setupError.value = error instanceof Error ? error.message : "大纲生成未完成,请重试"
- } finally {
- if (requestVersion === version) outlineLoading.value = false
- }
- if (requestVersion === version && outlineTask.value && props.visible) await pollOutlineTask()
- }
- async function pollOutlineTask() {
- stopPolling()
- const taskId = outlineTask.value?.taskId, requestVersion = version, currentPoll = pollVersion
- if (!taskId) return
- const active = () => requestVersion === version && currentPoll === pollVersion && props.visible && outlineTask.value?.taskId === taskId
- outlineLoading.value = true
- setupError.value = ''
- try {
- const response = await httpAjax(aiCoursewareTaskApi, taskId)
- if (!active()) return
- if (response.code !== 200 && /任务不存在|任务已过期/.test(response.message || '')) {
- setupError.value = '大纲任务已失效,设置和原课件已保留,请重新生成大纲。'
- outlineTask.value = undefined
- outlineLoading.value = false
- remember()
- return
- }
- if (response.code !== 200 || !response.data) throw new Error(response.message || '暂时无法读取大纲进度,请继续查看')
- outlineTask.value = response.data
- if (response.data.status === 'FAILED') {
- setupError.value = response.data.message || '大纲生成未完成,请重试'
- outlineTask.value = undefined
- outlineLoading.value = false
- remember()
- return
- }
- if (response.data.status === 'DONE') {
- const result = await httpAjax(aiCoursewareResultApi, taskId)
- if (!active()) return
- if (result.code !== 200 || !result.data?.outline?.pages?.length) throw new Error(result.message || '大纲读取失败,请继续查看')
- if (!matchesRequestedTopic(result.data.outline)) {
- outlineTask.value = undefined
- outlineLoading.value = false
- setupError.value = '生成服务返回的教材或课题与本次要求不符,原课件已保留。请确认服务端已更新后重新生成。'
- remember()
- return
- }
- loadOutline(result.data.outline)
- outlineSettings.value = outlineTaskSettings.value
- confirmed.value = undefined
- task.value = undefined
- completionSource.value = undefined
- layoutRepairSource.value = undefined
- layoutRepairFeedback.value = ''
- generationError.value = ''
- outlineTask.value = undefined
- outlineLoading.value = false
- step.value = 2
- remember()
- } else pollTimer = setTimeout(pollOutlineTask, 2500)
- } catch (error) {
- if (active()) {
- outlineLoading.value = false
- setupError.value = (error instanceof Error ? error.message : '暂时无法读取大纲进度') + ';任务编号已保留,可继续查看,无需重复生成。'
- remember()
- }
- }
- }
- function canMove(event: { draggedContext: { index: number }; relatedContext: { index: number } }) {
- return event.draggedContext.index > 0 && event.relatedContext.index > 0
- }
- function movePage(index: number, delta: number) {
- const next = index + delta
- if (index < 1 || next < 1 || next >= pages.value.length) return
- pages.value.splice(next, 0, pages.value.splice(index, 1)[0])
- remember()
- }
- function addPage() {
- if (pages.value.length >= MAX_OUTLINE_PAGES) return
- pages.value.push({
- key: nanoid(),
- page: {
- pageNo: pages.value.length + 1,
- type: "creative_activity",
- title: "",
- bullets: [],
- imageKeywords: [],
- teachingStage: "活动练习"
- },
- brief: ""
- })
- expandedPage.value = pages.value[pages.value.length - 1].key
- }
- function removePage(index: number) {
- if (index < 1 || pages.value.length <= MIN_OUTLINE_PAGES) return
- pages.value.splice(index, 1)
- remember()
- }
- async function confirmAndGenerate() {
- const plan = currentPlan()
- if (!plan || !reliableResearch.value || transferring.value) return
- validationErrors.value = inspectConfirmedOutline(plan)
- if (validationErrors.value.length) return
- if (
- slidesStore.slides.some(slide => slide.elements.length) &&
- !(await ask("生成完成后,将用新课件替换编辑器中的当前内容。你仍可撤销恢复原页面,保存课件后才会更新服务器。", "确认并生成"))
- )
- return
- confirmed.value = clone(plan)
- replacementSnapshot = editorSnapshot()
- await startGeneration()
- }
- async function startGeneration() {
- if (!confirmed.value || !pptWork.id || transferring.value || task.value) return
- layoutRepairSource.value = undefined
- automaticDesignRepairs = 0
- renderDiagnostics.value = []
- completionSource.value = undefined
- const requestVersion = ++version
- step.value = 3
- generationError.value = ""
- transferring.value = true
- try {
- await assertAiPptClientCurrent()
- if (requestVersion !== version) return
- const plan = clone(confirmed.value)
- plan.templateId = templateId.value
- plan.style = getAiCoursewareTemplate(templateId.value).baseStyle
- const response = await httpAjax(aiCoursewareGenerateApi, {
- id: pptWork.id,
- fromType: source(),
- outline: plan,
- extraPrompt: extraPrompt.value.trim(),
- visualPrompt: getAiCoursewareTemplate(templateId.value).visualPrompt,
- imageAspectRatios: getAiPptImageAspectRatios(templateId.value),
- templateId: templateId.value,
- outlineConfirmed: true,
- outlineMode: "teaching_plan",
- preferences: { ...clone(preferences), minPages: plan.pages.length, maxPages: plan.pages.length }
- })
- if (requestVersion !== version) return
- if (response.code !== 200 || !response.data?.taskId) throw new Error(response.message || "未能开始生成,请重试")
- task.value = response.data
- remember()
- } catch (error) {
- if (requestVersion === version) generationError.value = error instanceof Error ? error.message : "未能开始生成,请重试"
- } finally {
- if (requestVersion === version) transferring.value = false
- }
- if (props.visible && task.value) await pollTask()
- }
- async function retry() {
- if (transferring.value) return
- if (layoutRepairSource.value && layoutRepairFeedback.value) { await repairBlockedLayout(); return }
- if (completionSource.value) {
- if (task.value && (['PENDING', 'RUNNING'].includes(task.value.status)
- || isAdvisoryCompletion(generationError.value.split(';已保留')[0]))) { generationError.value = ''; await pollTask() }
- else await continueCompletion()
- return
- }
- if (layoutRepairSource.value) { await repairBlockedLayout(); return }
- generationError.value = ""
- if (task.value) await pollTask()
- else await startGeneration()
- }
- async function repairBlockedLayout() {
- if (transferring.value || !layoutRepairSource.value) return
- const requestVersion = ++version
- stopPolling()
- transferring.value = true
- const reason = layoutRepairFeedback.value || generationError.value
- generationError.value = ''
- try {
- await assertAiPptClientCurrent()
- if (requestVersion !== version) return
- const plan = clone(layoutRepairSource.value)
- const modelDesign = plan.pages.some(page => page.pageDesign)
- const feedback = renderDiagnostics.value.length ? clone(renderDiagnostics.value) : plan.pages
- .filter(page => reason.includes(`第${page.pageNo}页`)).map(page => ({ pageNo: page.pageNo, code: 'rendered_layout', message: reason.slice(0, 2000) }))
- const response = await httpAjax(aiCoursewareGenerateApi, {
- id: pptWork.id, fromType: source(), outline: plan, templateId: templateId.value, repairOnly: true,
- ...(modelDesign && feedback.length ? { designOnly: true, renderFeedback: feedback } : {}),
- imageAspectRatios: getAiPptImageAspectRatios(templateId.value),
- preferences: { ...clone(preferences), minPages: plan.pages.length, maxPages: plan.pages.length },
- visualPrompt: modelDesign
- ? getAiCoursewareTemplate(templateId.value).visualPrompt
- : `${getAiCoursewareTemplate(templateId.value).visualPrompt}\n保持页数与教学目标,精简重复的学生文字,教师操作放备注,不截断歌词或完整范例。解决排版问题:${reason}`
- })
- if (requestVersion !== version) return
- if (response.code !== 200 || !response.data?.taskId) throw new Error(response.message || '未能开始重新排版')
- task.value = response.data
- layoutRepairSource.value = undefined
- remember()
- } catch (error) {
- if (requestVersion === version) generationError.value = error instanceof Error ? error.message : '重新排版失败,请重试'
- } finally {
- if (requestVersion === version) transferring.value = false
- }
- if (requestVersion === version && !generationError.value && props.visible) await pollTask()
- }
- async function continueCompletion() {
- if (transferring.value || !completionSource.value) return
- const requestVersion = ++version
- stopPolling()
- transferring.value = true
- generationError.value = ''
- try {
- await assertAiPptClientCurrent()
- if (requestVersion !== version) return
- const plan = clone(completionSource.value)
- const response = await httpAjax(aiCoursewareGenerateApi, {
- id: pptWork.id, fromType: source(), outline: plan, templateId: templateId.value, repairOnly: true,
- imageAspectRatios: getAiPptImageAspectRatios(templateId.value),
- preferences: { ...clone(preferences), minPages: plan.pages.length, maxPages: plan.pages.length }
- })
- if (requestVersion !== version) return
- if (response.code !== 200 || !response.data?.taskId) throw new Error(response.message || '暂时无法继续完成课件,请重试')
- task.value = response.data
- remember()
- } catch (error) {
- if (requestVersion === version) generationError.value = error instanceof Error ? error.message : '暂时无法继续完成课件'
- } finally {
- if (requestVersion === version) transferring.value = false
- }
- if (requestVersion === version && task.value && !generationError.value && props.visible) await pollTask()
- }
- async function pollTask() {
- stopPolling()
- generationError.value = ''
- const taskId = task.value?.taskId,
- requestVersion = version,
- currentPoll = pollVersion
- if (!taskId) return
- const active = () => requestVersion === version && currentPoll === pollVersion && props.visible && task.value?.taskId === taskId
- try {
- const response = await httpAjax(aiCoursewareTaskApi, taskId)
- if (!active()) return
- if (completionSource.value && response.code !== 200 && /任务不存在|任务已过期/.test(response.message || '')) {
- task.value = undefined
- generationError.value = '任务记录已过期,已保留生成内容,可继续完成课件。'
- remember()
- return
- }
- if (response.code !== 200) throw new Error(response.message || "暂时无法获取进度,请稍后继续查看")
- task.value = response.data
- if (task.value?.status === "FAILED") {
- generationError.value = task.value.message || "课件生成未完成,请重试"
- console.error("AI courseware failed", taskId, task.value.message)
- task.value = undefined
- remember()
- return
- }
- if (task.value?.status === "DONE" || task.value?.status === "DRAFT") {
- const result = await httpAjax(aiCoursewareResultApi, taskId)
- if (!active()) return
- if (result.code !== 200) throw new Error(result.message || "课件读取失败,请继续查看")
- await enterEditor(result.data, active)
- } else pollTimer = setTimeout(pollTask, 2000)
- } catch (error) {
- if (active()) {
- console.error("AI courseware result unavailable", taskId, error)
- generationError.value =
- error instanceof TypeError
- ? "课件未能打开,请稍后继续查看;无需重新生成。"
- : error instanceof Error
- ? error.message
- : "暂时无法读取课件,请继续查看"
- }
- }
- }
- async function openCompletionDraft() {
- if (transferring.value || !completionSource.value) return
- const requestVersion = ++version
- stopPolling()
- const active = () => requestVersion === version && props.visible
- transferring.value = true
- try {
- await enterEditor({ outline: clone(completionSource.value), context: {},
- blockingMessage: generationError.value, reviewMessage: completionReviewMessage.value }, active, true)
- } catch (error) {
- if (active()) generationError.value = error instanceof Error ? error.message : '暂时无法打开草稿,已保留生成内容。'
- } finally {
- if (requestVersion === version) transferring.value = false
- }
- }
- function recordCompletionLayoutErrors(messages: string[]) {
- if (!completionSource.value || !messages.length) return
- layoutRepairSource.value = clone(completionSource.value)
- layoutRepairFeedback.value = messages.join(';')
- renderDiagnostics.value = messages.flatMap(message => {
- const pageNo = Number(message.match(/第(\d+)页/)?.[1])
- return pageNo ? [{ pageNo, code: 'rendered_layout', message: message.slice(0, 2000) }] : []
- })
- }
- async function automaticallyRepairDesign(result: AiCoursewareGenerateResult, active: () => boolean, allowIncompleteDraft: boolean) {
- if (!active() || allowIncompleteDraft || automaticDesignRepairs >= 2 || !renderDiagnostics.value.length || !result.outline.pages.some(page => page.pageDesign)) return false
- automaticDesignRepairs++
- completionSource.value = clone(result.outline)
- completionReviewMessage.value = result.reviewMessage || ''
- await repairBlockedLayout()
- return true
- }
- async function enterEditor(result: AiCoursewareGenerateResult, active: () => boolean, allowIncompleteDraft = false) {
- if (!result?.outline?.pages?.length) throw new Error("尚未获得课件内容,请继续查看")
- if (!matchesRequestedTopic(result.outline)) throw new Error('生成结果与本次教材或课题不符,原课件已保留,请重新生成大纲。')
- await assertAiPptClientCurrent()
- if (!active()) return
- // 已收到的正文先保留为草稿,任意后续渲染/资源检查失败都不能只剩错误页。
- completionSource.value = clone(result.outline)
- completionReviewMessage.value = result.reviewMessage || ''
- remember()
- if (task.value?.status === 'DRAFT' && !allowIncompleteDraft) {
- if (isAdvisoryCompletion(result.blockingMessage)) {
- // 历史任务保留DRAFT状态时无需再次调用生成;让正常渲染与缺图校验决定能否进入。
- result = { ...result, reviewMessage: [result.reviewMessage, result.blockingMessage].filter(Boolean).join(';'), blockingMessage: '' }
- } else {
- completionSource.value = clone(result.outline)
- completionReviewMessage.value = result.reviewMessage || ''
- generationError.value = coursewareCompletionMessage(result, task.value.message)
- if (result.renderFeedback?.length) {
- renderDiagnostics.value = clone(result.renderFeedback)
- layoutRepairSource.value = clone(result.outline)
- layoutRepairFeedback.value = result.renderFeedback.map(item => `第${item.pageNo}页:${item.message}`).join(';')
- }
- remember()
- return
- }
- }
- layoutRepairSource.value = clone(result.outline)
- layoutRepairFeedback.value = ''
- renderDiagnostics.value = []
- let converted: ReturnType<typeof aiCoursewareToSlides>
- try {
- converted = aiCoursewareToSlides(result.outline, { templateId: templateId.value, enableAnimations: preferences.enableAnimations, recoverDraft: allowIncompleteDraft })
- } catch (error) {
- if (error instanceof Error && /投屏安全区|保留区|模型页面设计/.test(error.message)) {
- layoutRepairFeedback.value = error.message
- const pageNo = Number(error.message.match(/^第(\d+)页/)?.[1])
- renderDiagnostics.value = pageNo ? [{ pageNo, code: 'model_design_geometry', message: error.message.slice(0, 2000) }] : []
- if (await automaticallyRepairDesign(result, active, allowIncompleteDraft)) return
- completionSource.value = clone(result.outline)
- remember()
- throw new Error(`${error.message.match(/^第\d+页/)?.[0] || '生成内容'}页面设计未通过,已保留当前课件。${modelLayoutRepair.value ? '请重新设计页面。' : '请精简并重新排版。'}`)
- }
- throw error
- }
- let report = inspectAiPptQuality(result.outline, converted.slides, {
- ...preferences,
- minPages: result.outline.pages.length,
- maxPages: result.outline.pages.length
- })
- const { inspectAiPptRenderedText } = await import('@/utils/aiPptRenderedQuality')
- let renderedIssues = await inspectAiPptRenderedText(converted.slides)
- if (!active()) return
- const draftDiagnostics: AiPptQualityIssue[] = converted.recoveredPages.map(page => ({ code: 'draft_layout_recovered', severity: 'error', pageNo: page.pageNo, message: page.error }))
- if (allowIncompleteDraft) {
- const originalLayoutIssues = [...blockingAiPptLayoutIssues(report), ...renderedIssues]
- draftDiagnostics.push(...originalLayoutIssues)
- const failed = [...new Set(originalLayoutIssues.flatMap(issue => issue.pageNo ? [issue.pageNo] : []))]
- if (failed.length) {
- converted = aiCoursewareToSlides(result.outline, { templateId: templateId.value, recoverDraft: true, fallbackPageNos: failed })
- draftDiagnostics.push(...converted.recoveredPages.map(page => ({ code: 'draft_layout_recovered', severity: 'error' as const, pageNo: page.pageNo, message: page.error })))
- report = inspectAiPptQuality(result.outline, converted.slides, { ...preferences, minPages: result.outline.pages.length, maxPages: result.outline.pages.length })
- renderedIssues = await inspectAiPptRenderedText(converted.slides)
- if (!active()) return
- }
- }
- const layoutIssues = [...blockingAiPptLayoutIssues(report), ...renderedIssues]
- layoutRepairFeedback.value = layoutIssues.map(issue => `第${issue.pageNo}页:${issue.message}`).join(';')
- renderDiagnostics.value = layoutIssues.filter(issue => !!issue.pageNo).map(issue => ({ pageNo: issue.pageNo!, code: issue.code, message: issue.message.slice(0, 2000) }))
- if (layoutIssues.length && await automaticallyRepairDesign(result, active, allowIncompleteDraft)) return
- if (layoutIssues.length && !allowIncompleteDraft) throw new Error(`第${[...new Set(layoutIssues.map(issue => issue.pageNo))].join('、')}页文字过密,已保留当前课件。${modelLayoutRepair.value ? '自动调整后仍未通过,请重新设计页面或查看草稿。' : '请精简并重新排版。'}`)
- layoutRepairSource.value = undefined
- const issues = [
- ...draftDiagnostics,
- ...report.errors,
- ...report.warnings.filter(issue => ["image_review", "missing_required_visual", "layout_variety", "layout_consecutive", "rendered_layout_repeat", "invalid_page_design"].includes(issue.code)),
- ...(await inspectAiPptResources(result.outline, converted.slides))
- ]
- issues.unshift(...teachingReviewIssues(result.reviewMessage, result.blockingMessage))
- const imageIssues = blockingImageIssues(issues)
- if (imageIssues.length) {
- if (!active()) return
- result = { ...result, outline: clone(result.outline) }
- // 地址存在但实际加载失败的图片不能在续作时误当作成功配图复用。
- imageIssues.filter(issue => issue.code === 'resource_unavailable').forEach(issue => {
- const page = result.outline.pages.find(page => page.pageNo === issue.pageNo)
- if (page) {
- const failedUrl = page.image?.url
- page.image = undefined; page.imageStatus = 'generation_failed'
- page.resourceBindings = page.resourceBindings?.filter(binding => binding.resourceType !== 'IMG' || binding.contentUrl !== failedUrl)
- }
- })
- if (!allowIncompleteDraft) {
- completionSource.value = result.outline
- completionReviewMessage.value = result.reviewMessage || ''
- generationError.value = `第${[...new Set(imageIssues.map(issue => issue.pageNo))].join('、')}页配图尚未完成,已保留生成内容。`
- remember()
- return
- }
- }
- const draft = allowIncompleteDraft || issues.some(issue => issue.severity === 'error')
- if (!active()) return
- if (
- replacementSnapshot !== editorSnapshot() &&
- slidesStore.slides.some(slide => slide.elements.length) &&
- !(await ask("当前课件已有新的修改。是否用生成的课件替换?替换前的页面可撤销恢复。", "进入新课件"))
- ) {
- generationError.value = "生成结果已就绪,可在准备好后继续进入编辑器。"
- return
- }
- if (!active()) return
- transferring.value = true
- const previous = clone({
- title: slidesStore.title,
- slides: slidesStore.slides,
- theme: slidesStore.theme,
- width: slidesStore.viewportSize,
- ratio: slidesStore.viewportRatio,
- index: slidesStore.slideIndex
- })
- let changed = false
- try {
- await snapshots.addSnapshot()
- if (!active()) return
- changed = true
- slidesStore.setViewportSize(1920)
- slidesStore.setViewportRatio(0.5625)
- slidesStore.setTitle(converted.title)
- slidesStore.setTheme({ ...slidesStore.theme, ...converted.theme })
- slidesStore.setSlides(converted.slides)
- slidesStore.updateSlideIndex(0)
- useMainStore().setActiveElementIdList([])
- await snapshots.addSnapshot()
- pptWork.isSave = false
- task.value = undefined
- completionSource.value = undefined
- generationError.value = ""
- step.value = 2
- remember()
- emit("generated", issues, clone({ ...result.outline, templateId: templateId.value }), clone(preferences))
- emit("update:visible", false)
- if (draft) ElMessage.warning("草稿已打开,待完善问题已保留。可逐页编辑、补图,完成后保存课件。")
- else ElMessage.success("课件已生成,可直接编辑,完成后点击“保存课件”")
- } catch (error) {
- if (changed) {
- slidesStore.setTitle(previous.title)
- slidesStore.setTheme(previous.theme)
- slidesStore.setSlides(previous.slides)
- slidesStore.setViewportSize(previous.width)
- slidesStore.setViewportRatio(previous.ratio)
- slidesStore.updateSlideIndex(previous.index)
- }
- throw error
- } finally {
- transferring.value = false
- }
- }
- </script>
- <style scoped lang="scss">
- .ai-dialog {
- display: flex;
- flex-direction: column;
- max-height: calc(100vh - 88px);
- color: #283544;
- font-size: 14px;
- }
- header {
- padding: 2px 0 18px;
- }
- h2 {
- font-size: 25px;
- margin: 0 0 6px;
- }
- h3 {
- font-size: 18px;
- margin: 0 0 8px;
- }
- p {
- margin: 6px 0;
- color: #77869a;
- line-height: 1.6;
- }
- .steps {
- display: flex;
- padding: 12px;
- margin: 0 0 20px;
- list-style: none;
- background: #f4f7fa;
- border-radius: 8px;
- gap: 6px;
- }
- .steps li {
- flex: 1;
- display: flex;
- justify-content: center;
- align-items: center;
- gap: 8px;
- color: #8491a2;
- padding: 8px 0;
- white-space: nowrap;
- }
- .steps span {
- display: grid;
- place-items: center;
- width: 24px;
- height: 24px;
- border-radius: 50%;
- background: #e6ebf1;
- }
- .steps .active {
- color: #1989fa;
- background: white;
- border-radius: 6px;
- font-weight: 600;
- box-shadow: 0 2px 10px #23395708;
- }
- .active span {
- background: #1989fa;
- color: white;
- }
- .steps .done {
- color: #169e91;
- }
- .done span {
- background: #ddf3ee;
- }
- main {
- overflow: auto;
- min-height: 0;
- flex: 1;
- padding: 0 3px 3px;
- }
- .setup {
- max-width: 720px;
- margin: 8px auto 24px;
- }
- label {
- display: block;
- font-weight: 500;
- margin: 0 0 16px;
- line-height: 1.5;
- }
- input:not([type="checkbox"]),
- select,
- textarea {
- display: block;
- width: 100%;
- box-sizing: border-box;
- border: 1px solid #d7dfe8;
- border-radius: 5px;
- padding: 9px 11px;
- margin-top: 7px;
- background: white;
- color: #283544;
- font: inherit;
- font-weight: 400;
- }
- textarea {
- resize: vertical;
- min-height: 76px;
- }
- input:focus,
- textarea:focus,
- select:focus {
- outline: 2px solid #1989fa35;
- border-color: #1989fa;
- }
- .fields {
- display: grid;
- grid-template-columns: 1fr 1fr;
- gap: 20px;
- }
- .hint {
- color: #8a96a6;
- font-size: 12px;
- font-weight: 400;
- margin-left: 8px;
- }
- details {
- margin-top: 12px;
- }
- summary {
- cursor: pointer;
- color: #60748b;
- margin-bottom: 14px;
- }
- .check {
- display: flex;
- align-items: center;
- gap: 6px;
- font-weight: 400;
- }
- .check input {
- margin: 0;
- }
- .notice {
- color: #1989fa;
- }
- .error {
- color: #b96218;
- }
- .validation {
- padding: 12px 28px;
- background: #fff7ed;
- border-radius: 6px;
- line-height: 1.7;
- }
- .section-heading {
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: 12px;
- margin-bottom: 20px;
- }
- .time {
- color: #46718f;
- background: #f0f6fc;
- padding: 9px 12px;
- border-radius: 6px;
- white-space: nowrap;
- }
- .outline-grid {
- display: grid;
- grid-template-columns: 225px minmax(0, 1fr);
- gap: 24px;
- }
- aside {
- padding: 16px;
- background: #f7f9fc;
- border-radius: 8px;
- align-self: start;
- }
- aside a {
- display: block;
- color: #1989fa;
- margin: 10px 0;
- overflow-wrap: anywhere;
- }
- .page-card {
- padding: 12px 14px;
- margin-bottom: 14px;
- border: 1px solid #dfe6ee;
- border-radius: 8px;
- background: white;
- }
- .page-heading {
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: 10px;
- }
- .page-actions {
- display: flex;
- gap: 6px;
- }
- .page-actions button {
- border: 0;
- background: #f4f7fa;
- color: #597088;
- padding: 4px 8px;
- border-radius: 4px;
- cursor: pointer;
- min-width: 28px;
- }
- button:disabled {
- opacity: 0.4;
- cursor: default;
- }
- button:focus-visible {
- outline: 2px solid #1989fa;
- outline-offset: 2px;
- }
- .drag-handle {
- cursor: grab !important;
- }
- .page-fields {
- display: grid;
- grid-template-columns: 1fr 110px 65px;
- gap: 12px;
- align-items: end;
- }
- .page-fields .check {
- padding-bottom: 9px;
- }
- .add-page {
- width: 100%;
- padding: 13px;
- border: 1px dashed #9bbbe0;
- border-radius: 6px;
- background: #f8fbff;
- color: #1989fa;
- cursor: pointer;
- }
- .generation {
- min-height: 360px;
- display: flex;
- flex-direction: column;
- align-items: center;
- justify-content: center;
- text-align: center;
- padding: 30px;
- background: #f8fafc;
- border: 1px solid #e2e9f1;
- border-radius: 8px;
- }
- .generation p {
- max-width: 650px;
- }
- .generation.showing-preview { justify-content: flex-start; padding: 16px; }
- progress {
- width: 320px;
- max-width: 100%;
- height: 8px;
- margin: 20px 0;
- accent-color: #1989fa;
- }
- .error-icon {
- color: #d58a2d;
- border: 1px solid;
- border-radius: 50%;
- width: 36px;
- height: 36px;
- line-height: 36px;
- margin-bottom: 18px;
- font-size: 24px;
- }
- .spinner {
- width: 30px;
- height: 30px;
- border: 3px solid #dceafa;
- border-top-color: #1989fa;
- border-radius: 50%;
- animation: spin 1s linear infinite;
- margin-bottom: 22px;
- }
- @keyframes spin {
- to {
- transform: rotate(360deg);
- }
- }
- @media (prefers-reduced-motion: reduce) {
- .spinner {
- animation: none;
- }
- }
- footer {
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: 12px;
- border-top: 1px solid #e9eef4;
- padding-top: 16px;
- margin-top: 18px;
- }
- footer > div {
- display: flex;
- flex-wrap: wrap;
- justify-content: flex-end;
- gap: 12px;
- }
- @media (max-width: 740px) {
- .steps {
- padding: 6px;
- }
- .steps li {
- flex-direction: column;
- font-size: 12px;
- gap: 5px;
- }
- .outline-grid {
- grid-template-columns: 1fr;
- }
- .section-heading {
- align-items: start;
- flex-direction: column;
- }
- .page-fields {
- grid-template-columns: 1fr 90px 60px;
- }
- .fields {
- gap: 12px;
- }
- footer {
- flex-wrap: wrap;
- }
- .generation {
- padding: 15px;
- min-height: 260px;
- }
- }
- .page-toggle {
- display: flex;
- align-items: center;
- gap: 10px;
- border: 0;
- padding: 4px 0;
- background: transparent;
- text-align: left;
- flex: 1;
- min-width: 0;
- cursor: pointer;
- color: #283544;
- font: inherit;
- }
- .page-toggle strong {
- flex: 1;
- overflow-wrap: anywhere;
- }
- .page-number {
- display: grid;
- place-items: center;
- width: 25px;
- height: 25px;
- flex-shrink: 0;
- border-radius: 5px;
- background: #edf4fc;
- color: #5482b3;
- font-size: 12px;
- }
- .page-time {
- white-space: nowrap;
- font-size: 12px;
- color: #8392a5;
- }
- .page-summary {
- font-size: 12px;
- margin: 8px 0 2px 35px;
- display: -webkit-box;
- -webkit-line-clamp: 3;
- -webkit-box-orient: vertical;
- overflow: hidden;
- }
- .page-edit {
- margin-top: 16px;
- }
- .expanded {
- border-color: #a8cef4;
- }
- .template-choice {
- margin: 18px 0;
- }
- .template-choice img {
- width: 100%;
- aspect-ratio: 16/9;
- object-fit: cover;
- border-radius: 4px;
- border: 1px solid #e4eaf2;
- margin: 8px 0;
- }
- .template-choice > div {
- display: flex;
- justify-content: space-between;
- align-items: center;
- }
- .template-choice button {
- border: 0;
- background: transparent;
- color: #1989fa;
- cursor: pointer;
- padding: 4px;
- }
- .template-options {
- display: grid;
- grid-template-columns: repeat(3, 1fr);
- gap: 10px;
- margin-bottom: 18px;
- }
- .template-options button {
- padding: 5px;
- background: white;
- border: 1px solid #e0e7ef;
- border-radius: 5px;
- cursor: pointer;
- color: #506278;
- }
- .template-options .selected {
- border-color: #1989fa;
- }
- .template-options img,
- .blank-template-preview {
- display: block;
- width: 100%;
- aspect-ratio: 16/9;
- object-fit: cover;
- margin-bottom: 5px;
- }
- .blank-template-preview {
- background: #fff;
- box-shadow: inset 0 0 0 1px #e5e7eb;
- }
- @media (max-width: 740px) {
- .page-heading {
- flex-wrap: wrap;
- }
- .page-toggle {
- flex-basis: 100%;
- }
- .page-actions {
- margin-left: 35px;
- }
- .template-choice img {
- max-width: 225px;
- display: block;
- }
- }
- .page-count {
- white-space: nowrap;
- color: #7a899c;
- }
- .requirements-summary {
- padding: 10px 12px;
- margin: 0 0 16px;
- background: #f4f7fb;
- border-radius: 5px;
- color: #66798f;
- }
- .outline-list {
- margin-top: 16px;
- max-width: 920px;
- margin: 0 auto;
- }
- .teaching-design {
- margin: 16px 0;
- padding: 16px 20px;
- border-left: 3px solid #1989fa;
- background: #f3f7fc;
- color: #25374b;
- }
- .teaching-design p { margin: 8px 0 0; line-height: 1.6; }
- .outline-options {
- display: flex;
- align-items: baseline;
- gap: 12px;
- flex-wrap: wrap;
- padding: 16px 0 4px;
- color: #7b899a;
- font-size: 13px;
- }
- .outline-options button {
- background: none;
- border: 0;
- color: #1989fa;
- cursor: pointer;
- }
- .outline-options details {
- margin: 0 0 0 auto;
- max-width: 100%;
- }
- .outline-options summary {
- margin: 0;
- font-size: 13px;
- }
- .outline-options a {
- display: block;
- color: #1989fa;
- margin: 8px 0;
- overflow-wrap: anywhere;
- }
- .page-summary {
- margin-top: 4px;
- font-size: 13px;
- line-height: 1.7;
- }
- </style>
|