|
|
@@ -1,79 +1,376 @@
|
|
|
<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">
|
|
|
+ <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>
|
|
|
+ <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>
|
|
|
<section v-if="step === 1" class="setup">
|
|
|
<h3>这节课准备怎么上?</h3>
|
|
|
- <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></details>
|
|
|
- <p v-if="outlineLoading" role="status" class="notice">正在梳理教材和教学大纲,请稍候…</p><p v-if="setupError" role="alert" class="error">{{ setupError }}</p>
|
|
|
+ <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>
|
|
|
+ </details>
|
|
|
+ <p v-if="settingsChanged" class="notice">需求已调整,请重新生成大纲后确认;当前大纲会保留到新大纲生成成功。</p>
|
|
|
+ <p v-if="outlineLoading" role="status" class="notice">正在梳理教材和教学大纲,请稍候…</p>
|
|
|
+ <p v-if="setupError" role="alert" class="error">{{ setupError }}</p>
|
|
|
</section>
|
|
|
<section v-else-if="step === 2 && outline" class="outline-stage">
|
|
|
- <div class="section-heading"><div><h3>确认教学大纲</h3><p>可直接修改内容,拖动调整顺序。确认后再生成课件。</p></div><div class="time">{{ pages.length }} 页 · 必讲 {{ totalMinutes }} / {{ preferences.lessonMinutes }} 分钟</div></div>
|
|
|
- <ul v-if="validationErrors.length" class="error validation" role="alert"><li v-for="message in validationErrors" :key="message">{{ message }}</li></ul>
|
|
|
- <div class="outline-grid"><aside><label>教学目标<span class="hint">每行一个</span><textarea v-model="goalsText" rows="7" @change="remember" /></label><label>课件模板<select v-model="templateId" @change="remember"><option v-for="template in AI_COURSEWARE_TEMPLATES" :key="template.id" :value="template.id">{{ template.name }}</option></select></label><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><p v-if="!reliableResearch" class="error">尚未核实教材内容,请返回补充教学范围后重新生成大纲。</p></aside>
|
|
|
- <div><Draggable v-model="pages" item-key="key" handle=".drag-handle" :animation="160" :move="canMove" @end="remember"><template #item="{ element, index }"><article class="page-card"><div class="page-heading"><span>第 {{ index + 1 }} 页{{ index === 0 ? ' · 封面' : '' }}</span><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><label>页面标题<input v-model="element.page.title" @change="remember" /></label><div class="page-fields"><label>教学环节<input v-model="element.page.teachingStage" placeholder="例如:听辨练习" @change="remember" /></label><label>用时(分钟)<input v-model.number="element.page.durationMinutes" type="number" min="1" max="60" @change="remember" /></label><label class="check"><input v-model="element.page.optional" type="checkbox" @change="remember" />选讲</label></div><label>内容要点<span class="hint">每行一条</span><textarea v-model="element.pointsText" rows="3" @change="remember" /></label></article></template></Draggable><button class="add-page" type="button" :disabled="pages.length >= MAX_OUTLINE_PAGES" @click="addPage">+ 添加一页</button></div>
|
|
|
+ <div class="section-heading">
|
|
|
+ <div>
|
|
|
+ <h3>确认教学大纲</h3>
|
|
|
+ <p>先看整节课的安排,点击页面展开编辑。确认后再生成课件。</p>
|
|
|
+ </div>
|
|
|
+ <div class="time">{{ pages.length }} 页 · 必讲 {{ totalMinutes }} / {{ preferences.lessonMinutes }} 分钟</div>
|
|
|
+ </div>
|
|
|
+ <ul v-if="validationErrors.length" class="error validation" role="alert">
|
|
|
+ <li v-for="message in validationErrors" :key="message">{{ message }}</li>
|
|
|
+ </ul>
|
|
|
+ <div class="outline-grid">
|
|
|
+ <aside>
|
|
|
+ <label>教学目标<span class="hint">每行一个</span><textarea v-model="goalsText" rows="4" @change="remember" /></label>
|
|
|
+ <div class="template-choice">
|
|
|
+ <span>课件模板</span><img :src="templatePreview(templateId)" :alt="getAiCoursewareTemplate(templateId).name" />
|
|
|
+ <div>
|
|
|
+ <strong>{{ getAiCoursewareTemplate(templateId).name }}</strong
|
|
|
+ ><button type="button" @click="showTemplates = !showTemplates">{{ showTemplates ? "收起" : "更换模板" }}</button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <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>
|
|
|
+ <p v-if="!reliableResearch" class="error">尚未核实教材内容,请返回补充教学范围后重新生成大纲。</p>
|
|
|
+ </aside>
|
|
|
+ <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)"
|
|
|
+ >
|
|
|
+ <img :src="templatePreview(template.id)" :alt="template.name" /><span>{{ template.name }}</span>
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+ <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 class="page-time">{{ element.page.optional ? "选讲 · " : "" }}{{ element.page.durationMinutes }} 分钟</span
|
|
|
+ ><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.pointsText.split("\n").filter(Boolean).join(" · ") || "点击页面,补充内容要点" }}
|
|
|
+ </p>
|
|
|
+ <div v-else class="page-edit">
|
|
|
+ <label>页面标题<input v-model="element.page.title" @change="remember" /></label>
|
|
|
+ <div class="page-fields">
|
|
|
+ <label>教学环节<input v-model="element.page.teachingStage" placeholder="例如:听辨练习" @change="remember" /></label
|
|
|
+ ><label
|
|
|
+ >用时(分钟)<input
|
|
|
+ v-model.number="element.page.durationMinutes"
|
|
|
+ type="number"
|
|
|
+ min="1"
|
|
|
+ max="60"
|
|
|
+ @change="remember" /></label
|
|
|
+ ><label class="check"><input v-model="element.page.optional" type="checkbox" @change="remember" />选讲</label>
|
|
|
+ </div>
|
|
|
+ <label>内容要点<span class="hint">每行一条</span><textarea v-model="element.pointsText" rows="3" @change="remember" /></label>
|
|
|
+ </div></article></template></Draggable
|
|
|
+ ><button class="add-page" type="button" :disabled="pages.length >= MAX_OUTLINE_PAGES" @click="addPage">+ 添加一页</button>
|
|
|
+ </div>
|
|
|
</div>
|
|
|
</section>
|
|
|
- <section v-else class="generation" aria-live="polite"><template v-if="generationError"><span class="error-icon">!</span><h3>{{ task ? '课件进度暂时无法继续' : '这次生成未完成' }}</h3><p>{{ generationError }}</p><p class="hint">已保留确认的大纲,当前课件没有被替换。</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>
|
|
|
+ <section v-else class="generation" aria-live="polite">
|
|
|
+ <template v-if="generationError"
|
|
|
+ ><span class="error-icon">!</span>
|
|
|
+ <h3>{{ task ? "课件进度暂时无法继续" : "这次生成未完成" }}</h3>
|
|
|
+ <p>{{ generationError }}</p>
|
|
|
+ <p class="hint">已保留确认的大纲,当前课件没有被替换。</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>
|
|
|
- <footer><Button :disabled="transferring" @click="close">{{ task || outlineLoading ? '收起窗口' : '关闭' }}</Button><div v-if="step === 1"><Button v-if="outline" :disabled="outlineLoading" @click="step = 2">继续确认大纲</Button><Button type="primary" :disabled="outlineLoading" @click="generateOutline">{{ outlineLoading ? '正在生成大纲…' : outline ? '重新生成大纲' : '生成大纲' }}</Button></div><div v-else-if="step === 2"><Button @click="step = 1; remember()">返回修改需求</Button><Button type="primary" :disabled="!reliableResearch || transferring" @click="confirmAndGenerate">确认大纲并生成</Button></div><div v-else-if="generationError"><Button v-if="!task" @click="step = 2">返回修改大纲</Button><Button type="primary" :disabled="transferring" @click="retry">{{ task ? '继续查看课件' : '重试生成' }}</Button></div></footer>
|
|
|
+ <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 ? "正在生成大纲…" : 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="!task" @click="step = 2">返回修改大纲</Button
|
|
|
+ ><Button type="primary" :disabled="transferring" @click="retry">{{ task ? "继续查看课件" : "重试生成" }}</Button>
|
|
|
+ </div>
|
|
|
+ </footer>
|
|
|
</div>
|
|
|
</Modal>
|
|
|
</template>
|
|
|
|
|
|
<script setup lang="ts">
|
|
|
-import { computed, 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 queryParams from '@/queryParams'
|
|
|
-import usePptWork from '@/store/pptWork'
|
|
|
-import { useMainStore, useSlidesStore, useSnapshotStore } from '@/store'
|
|
|
-import { httpAjax } from '@/plugins/httpAjax'
|
|
|
-import { aiCoursewareGenerateApi, aiCoursewareOutlineApi, aiCoursewareResultApi, aiCoursewareTaskApi, type AiCoursewareOutline, type AiCoursewarePreferences, type AiCoursewareTaskInfo, type AiCoursewareGenerateResult } from '@/api/aiCourseware'
|
|
|
-import { AI_COURSEWARE_TEMPLATES, recommendAiCoursewareTemplate, getAiCoursewareTemplate } from '@/config/aiCoursewareTemplates'
|
|
|
-import { aiCoursewareToSlides } from '@/utils/aiCoursewareToSlides'
|
|
|
-import { inspectAiPptQuality, type AiPptQualityIssue } from '@/utils/aiPptQuality'
|
|
|
-import { inspectAiPptResources } from '@/utils/aiPptResources'
|
|
|
-import { draftKey, loadAiDraft, saveAiDraft } from '@/utils/aiCoursewareDraft'
|
|
|
-import { editableOutlinePages, confirmedCoursewareOutline, inspectConfirmedOutline, MIN_OUTLINE_PAGES, MAX_OUTLINE_PAGES, type EditableOutlinePage } from '@/utils/aiCoursewareOutline'
|
|
|
+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 queryParams from "@/queryParams"
|
|
|
+import usePptWork from "@/store/pptWork"
|
|
|
+import { useMainStore, useSlidesStore, useSnapshotStore } from "@/store"
|
|
|
+import { httpAjax } from "@/plugins/httpAjax"
|
|
|
+import {
|
|
|
+ aiCoursewareGenerateApi,
|
|
|
+ aiCoursewareOutlineApi,
|
|
|
+ aiCoursewareResultApi,
|
|
|
+ aiCoursewareTaskApi,
|
|
|
+ type AiCoursewareOutline,
|
|
|
+ type AiCoursewarePreferences,
|
|
|
+ type AiCoursewareTaskInfo,
|
|
|
+ type AiCoursewareGenerateResult
|
|
|
+} from "@/api/aiCourseware"
|
|
|
+import { AI_COURSEWARE_TEMPLATES, recommendAiCoursewareTemplate, getAiCoursewareTemplate } from "@/config/aiCoursewareTemplates"
|
|
|
+import { aiCoursewareToSlides } from "@/utils/aiCoursewareToSlides"
|
|
|
+import { inspectAiPptQuality, type AiPptQualityIssue } from "@/utils/aiPptQuality"
|
|
|
+import { inspectAiPptResources } from "@/utils/aiPptResources"
|
|
|
+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[]): void }>()
|
|
|
-const slidesStore = useSlidesStore(), pptWork = usePptWork(), snapshots = useSnapshotStore()
|
|
|
+const emit = defineEmits<{ (e: "update:visible", value: boolean): void; (e: "generated", issues: AiPptQualityIssue[]): void }>()
|
|
|
+const slidesStore = useSlidesStore(),
|
|
|
+ pptWork = usePptWork(),
|
|
|
+ snapshots = useSnapshotStore()
|
|
|
const clone = <T,>(value: T): T => JSON.parse(JSON.stringify(value))
|
|
|
-const defaults = (): AiCoursewarePreferences => ({ lessonMinutes: 40, minPages: 9, maxPages: 12, assetStyle: 'illustration', sourceImagePolicy: 'balanced', requireAudio: false, requireVideo: false, enableAnimations: true })
|
|
|
+const defaults = (): AiCoursewarePreferences => ({
|
|
|
+ lessonMinutes: 40,
|
|
|
+ minPages: 9,
|
|
|
+ maxPages: 12,
|
|
|
+ assetStyle: "illustration",
|
|
|
+ sourceImagePolicy: "balanced",
|
|
|
+ requireAudio: false,
|
|
|
+ requireVideo: false,
|
|
|
+ enableAnimations: true
|
|
|
+})
|
|
|
const preferences = reactive(defaults())
|
|
|
-const steps = ['设置需求', '确认大纲', '生成课件', '编辑课件', '保存课件']
|
|
|
-const step = ref(1), outline = ref<AiCoursewareOutline>(), pages = ref<EditableOutlinePage[]>([]), goalsText = ref('')
|
|
|
-const extraPrompt = ref(''), pageRange = ref('9-12'), templateId = ref('')
|
|
|
-const outlineLoading = ref(false), transferring = ref(false), setupError = ref(''), generationError = ref(''), validationErrors = ref<string[]>([])
|
|
|
-const task = ref<AiCoursewareTaskInfo>(), confirmed = ref<AiCoursewareOutline>()
|
|
|
-let pollTimer: ReturnType<typeof setTimeout> | undefined, version = 0, replacementSnapshot = '', saveQueue = Promise.resolve()
|
|
|
-const source = () => queryParams.pptResourcesType || queryParams.fromType || 'TEACHER'
|
|
|
-const metadata = computed(() => [outline.value?.gradeName, outline.value?.unitName].filter(Boolean).join(' · '))
|
|
|
+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[]>([]),
|
|
|
+ goalsText = ref("")
|
|
|
+const extraPrompt = ref(""),
|
|
|
+ pageRange = ref("9-12"),
|
|
|
+ templateId = ref("")
|
|
|
+const outlineLoading = ref(false),
|
|
|
+ transferring = ref(false),
|
|
|
+ setupError = ref(""),
|
|
|
+ generationError = ref(""),
|
|
|
+ validationErrors = ref<string[]>([])
|
|
|
+const task = ref<AiCoursewareTaskInfo>(),
|
|
|
+ confirmed = ref<AiCoursewareOutline>()
|
|
|
+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 totalMinutes = computed(() => pages.value.filter(item => !item.page.optional).reduce((sum, item) => sum + (Number(item.page.durationMinutes) || 0), 0))
|
|
|
-const editorSnapshot = () => JSON.stringify([slidesStore.title, slidesStore.slides, slidesStore.theme, slidesStore.viewportSize, slidesStore.viewportRatio])
|
|
|
-const currentPlan = () => outline.value ? confirmedCoursewareOutline(outline.value, goalsText.value, pages.value) : undefined
|
|
|
-const stopPolling = () => { 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(); else if (task.value) void pollTask(); else if (!outline.value) void restore() })
|
|
|
-watch(() => pptWork.id, () => { version++; stopPolling(); outlineLoading.value = false; task.value = undefined; confirmed.value = undefined; outline.value = undefined; pages.value = []; goalsText.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) }
|
|
|
+const reliableResearch = computed(
|
|
|
+ () => outline.value?.textbookResearch?.exactMatch && outline.value.textbookResearch.confidence === "HIGH" && sources.value.length > 0
|
|
|
+)
|
|
|
+const totalMinutes = computed(() =>
|
|
|
+ pages.value.filter(item => !item.page.optional).reduce((sum, item) => sum + (Number(item.page.durationMinutes) || 0), 0)
|
|
|
+)
|
|
|
+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, goalsText.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()
|
|
|
+ else if (task.value) void pollTask()
|
|
|
+ else if (!outline.value) void restore()
|
|
|
+ }
|
|
|
+)
|
|
|
+watch(
|
|
|
+ () => pptWork.id,
|
|
|
+ () => {
|
|
|
+ version++
|
|
|
+ stopPolling()
|
|
|
+ outlineLoading.value = false
|
|
|
+ task.value = undefined
|
|
|
+ confirmed.value = undefined
|
|
|
+ outline.value = undefined
|
|
|
+ pages.value = []
|
|
|
+ goalsText.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
|
|
|
+ const id = pptWork.id,
|
|
|
+ from = source(),
|
|
|
+ plan = currentPlan(),
|
|
|
+ prefs = clone(preferences),
|
|
|
+ prompt = extraPrompt.value,
|
|
|
+ taskId = task.value?.taskId,
|
|
|
+ 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 }) }).catch(error => { console.warn('AI courseware local state unavailable', error) })
|
|
|
+ saveQueue = saveQueue
|
|
|
+ .then(async () => {
|
|
|
+ await saveAiDraft({ key: await draftKey(id, from), updatedAt: Date.now(), outline: plan, preferences: prefs, extraPrompt: prompt, taskId, 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() {
|
|
|
+ step.value = 1
|
|
|
+ remember()
|
|
|
}
|
|
|
async function restore() {
|
|
|
if (!pptWork.id) return
|
|
|
@@ -81,103 +378,702 @@ async function restore() {
|
|
|
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
|
|
|
- if (saved.outline) { loadOutline(saved.outline); step.value = 2 }
|
|
|
- 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) }
|
|
|
+ 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.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); goalsText.value = value.textbookResearch?.teachingObjectives?.join('\n') || ''
|
|
|
+ outline.value = clone(value)
|
|
|
+ pages.value = editableOutlinePages(value)
|
|
|
+ goalsText.value = value.textbookResearch?.teachingObjectives?.join("\n") || ""
|
|
|
templateId.value = value.templateId || recommendAiCoursewareTemplate(value).id
|
|
|
const missing = pages.value.filter(item => !Number.isInteger(item.page.durationMinutes) || Number(item.page.durationMinutes) < 1)
|
|
|
- let remaining = preferences.lessonMinutes - pages.value.filter(item => !missing.includes(item) && !item.page.optional).reduce((sum, item) => sum + Number(item.page.durationMinutes), 0)
|
|
|
+ let remaining =
|
|
|
+ preferences.lessonMinutes -
|
|
|
+ pages.value.filter(item => !missing.includes(item) && !item.page.optional).reduce((sum, item) => sum + Number(item.page.durationMinutes), 0)
|
|
|
let required = missing.filter(item => !item.page.optional).length
|
|
|
- missing.forEach(item => { item.page.durationMinutes = item.page.optional ? 3 : Math.max(1, Math.floor(remaining / Math.max(1, required))); if (!item.page.optional) { remaining -= item.page.durationMinutes; required-- } })
|
|
|
+ missing.forEach(item => {
|
|
|
+ item.page.durationMinutes = item.page.optional ? 3 : Math.max(1, Math.floor(remaining / Math.max(1, required)))
|
|
|
+ if (!item.page.optional) {
|
|
|
+ remaining -= item.page.durationMinutes
|
|
|
+ required--
|
|
|
+ }
|
|
|
+ })
|
|
|
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 }
|
|
|
+ try {
|
|
|
+ await ElMessageBox.confirm(message, "请确认", { confirmButtonText, cancelButtonText: "取消", closeOnClickModal: false })
|
|
|
+ return true
|
|
|
+ } catch {
|
|
|
+ return false
|
|
|
+ }
|
|
|
}
|
|
|
async function generateOutline() {
|
|
|
if (!pptWork.id || outlineLoading.value) return
|
|
|
- if (outline.value && !await ask('重新生成会替换当前大纲,包括你已修改的内容。是否继续?', '重新生成')) return
|
|
|
- const requestVersion = ++version; outlineLoading.value = true; setupError.value = ''
|
|
|
+ if (outline.value && !(await ask("重新生成会替换当前大纲,包括你已修改的内容。是否继续?", "重新生成"))) return
|
|
|
+ const requestVersion = ++version
|
|
|
+ outlineLoading.value = true
|
|
|
+ setupError.value = ""
|
|
|
try {
|
|
|
- const response = await httpAjax(aiCoursewareOutlineApi, { id: pptWork.id, fromType: source(), extraPrompt: extraPrompt.value, preferences: clone(preferences) })
|
|
|
+ const response = await httpAjax(aiCoursewareOutlineApi, {
|
|
|
+ id: pptWork.id,
|
|
|
+ fromType: source(),
|
|
|
+ extraPrompt: extraPrompt.value,
|
|
|
+ preferences: clone(preferences)
|
|
|
+ })
|
|
|
if (requestVersion !== version) return
|
|
|
- if (response.code !== 200 || !response.data?.pages?.length) throw new Error(response.message || '大纲生成未完成,请重试')
|
|
|
- loadOutline(response.data); confirmed.value = undefined; step.value = 2; remember()
|
|
|
- } catch (error) { if (requestVersion === version) setupError.value = error instanceof Error ? error.message : '大纲生成未完成,请重试' }
|
|
|
- finally { if (requestVersion === version) outlineLoading.value = false }
|
|
|
-}
|
|
|
-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: '活动练习', durationMinutes: 3, optional: false }, pointsText: '' }) }
|
|
|
-function removePage(index: number) { if (index < 1 || pages.value.length <= MIN_OUTLINE_PAGES) return; pages.value.splice(index, 1); remember() }
|
|
|
+ if (response.code !== 200 || !response.data?.pages?.length) throw new Error(response.message || "大纲生成未完成,请重试")
|
|
|
+ loadOutline(response.data)
|
|
|
+ confirmed.value = undefined
|
|
|
+ step.value = 2
|
|
|
+ remember()
|
|
|
+ } catch (error) {
|
|
|
+ if (requestVersion === version) setupError.value = error instanceof Error ? error.message : "大纲生成未完成,请重试"
|
|
|
+ } finally {
|
|
|
+ if (requestVersion === version) outlineLoading.value = false
|
|
|
+ }
|
|
|
+}
|
|
|
+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: "活动练习",
|
|
|
+ durationMinutes: 3,
|
|
|
+ optional: false
|
|
|
+ },
|
|
|
+ pointsText: ""
|
|
|
+ })
|
|
|
+ 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, preferences.lessonMinutes); if (validationErrors.value.length) return
|
|
|
- if (slidesStore.slides.some(slide => slide.elements.length) && !await ask('生成完成后,将用新课件替换编辑器中的当前内容。你仍可撤销恢复原页面,保存课件后才会更新服务器。', '确认并生成')) return
|
|
|
- confirmed.value = clone(plan); replacementSnapshot = editorSnapshot(); await startGeneration()
|
|
|
+ const plan = currentPlan()
|
|
|
+ if (!plan || !reliableResearch.value || transferring.value) return
|
|
|
+ validationErrors.value = inspectConfirmedOutline(plan, preferences.lessonMinutes)
|
|
|
+ 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
|
|
|
- const requestVersion = ++version; step.value = 3; generationError.value = ''; transferring.value = true
|
|
|
+ const requestVersion = ++version
|
|
|
+ step.value = 3
|
|
|
+ generationError.value = ""
|
|
|
+ transferring.value = true
|
|
|
try {
|
|
|
- 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, templateId: templateId.value, outlineConfirmed: true, preferences: { ...clone(preferences), minPages: plan.pages.length, maxPages: plan.pages.length } })
|
|
|
+ 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,
|
|
|
+ templateId: templateId.value,
|
|
|
+ outlineConfirmed: true,
|
|
|
+ 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 (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() { generationError.value = ''; if (task.value) await pollTask(); else await startGeneration() }
|
|
|
+async function retry() {
|
|
|
+ generationError.value = ""
|
|
|
+ if (task.value) await pollTask()
|
|
|
+ else await startGeneration()
|
|
|
+}
|
|
|
async function pollTask() {
|
|
|
- stopPolling(); const taskId = task.value?.taskId, requestVersion = version
|
|
|
+ stopPolling()
|
|
|
+ const taskId = task.value?.taskId,
|
|
|
+ requestVersion = version,
|
|
|
+ currentPoll = pollVersion
|
|
|
if (!taskId) return
|
|
|
- const active = () => requestVersion === version && props.visible && task.value?.taskId === taskId
|
|
|
+ const active = () => requestVersion === version && currentPoll === pollVersion && props.visible && task.value?.taskId === taskId
|
|
|
try {
|
|
|
- const response = await httpAjax(aiCoursewareTaskApi, taskId); if (!active()) return
|
|
|
- if (response.code !== 200) throw new Error(response.message || '暂时无法获取进度,请稍后继续查看')
|
|
|
+ const response = await httpAjax(aiCoursewareTaskApi, taskId)
|
|
|
+ if (!active()) 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 || '课件读取失败,请继续查看')
|
|
|
+ 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()) generationError.value = error instanceof Error ? error.message : '暂时无法读取课件,请继续查看' }
|
|
|
+ } catch (error) {
|
|
|
+ if (active()) {
|
|
|
+ console.error("AI courseware result unavailable", taskId, error)
|
|
|
+ generationError.value =
|
|
|
+ error instanceof TypeError
|
|
|
+ ? "课件未能打开,请稍后继续查看;无需重新生成。"
|
|
|
+ : error instanceof Error
|
|
|
+ ? error.message
|
|
|
+ : "暂时无法读取课件,请继续查看"
|
|
|
+ }
|
|
|
+ }
|
|
|
}
|
|
|
async function enterEditor(result: AiCoursewareGenerateResult, active: () => boolean) {
|
|
|
- if (!result?.outline?.pages?.length) throw new Error('尚未获得课件内容,请继续查看')
|
|
|
+ if (!result?.outline?.pages?.length) throw new Error("尚未获得课件内容,请继续查看")
|
|
|
const converted = aiCoursewareToSlides(result.outline, { templateId: templateId.value, enableAnimations: preferences.enableAnimations })
|
|
|
- const issues = [...inspectAiPptQuality(result.outline, converted.slides, { ...preferences, minPages: result.outline.pages.length, maxPages: result.outline.pages.length }).errors, ...await inspectAiPptResources(result.outline, converted.slides)]
|
|
|
- if (result.reviewMessage) issues.unshift({ code: 'teaching-review', severity: 'warning', message: result.reviewMessage })
|
|
|
+ const issues = [
|
|
|
+ ...inspectAiPptQuality(result.outline, converted.slides, {
|
|
|
+ ...preferences,
|
|
|
+ minPages: result.outline.pages.length,
|
|
|
+ maxPages: result.outline.pages.length
|
|
|
+ }).errors,
|
|
|
+ ...(await inspectAiPptResources(result.outline, converted.slides))
|
|
|
+ ]
|
|
|
+ if (result.reviewMessage) issues.unshift({ code: "teaching-review", severity: "warning", message: result.reviewMessage })
|
|
|
if (!active()) return
|
|
|
- if (replacementSnapshot !== editorSnapshot() && slidesStore.slides.some(slide => slide.elements.length) && !await ask('当前课件已有新的修改。是否用生成的课件替换?替换前的页面可撤销恢复。', '进入新课件')) { generationError.value = '生成结果已就绪,可在准备好后继续进入编辑器。'; 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 })
|
|
|
+ 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.setSlideIndex(0); useMainStore().setActiveElementIdList([])
|
|
|
- await snapshots.addSnapshot(); pptWork.isSave = false; task.value = undefined; generationError.value = ''; step.value = 2; remember(); emit('generated', issues); emit('update:visible', false); 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.setSlideIndex(previous.index) } throw error }
|
|
|
- finally { transferring.value = false }
|
|
|
+ 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
|
|
|
+ generationError.value = ""
|
|
|
+ step.value = 2
|
|
|
+ remember()
|
|
|
+ emit("generated", issues)
|
|
|
+ emit("update:visible", false)
|
|
|
+ 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: 16px 18px 2px; margin-bottom: 14px; border: 1px solid #dfe6ee; border-radius: 8px; background: white; } .page-heading { display: flex; align-items: center; justify-content: space-between; color: #708299; margin-bottom: 12px; height: 28px; } .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: .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; } 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; 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; } }
|
|
|
+.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;
|
|
|
+}
|
|
|
+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;
|
|
|
+ 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: 2;
|
|
|
+ -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 {
|
|
|
+ display: block;
|
|
|
+ width: 100%;
|
|
|
+ aspect-ratio: 16/9;
|
|
|
+ object-fit: cover;
|
|
|
+ margin-bottom: 5px;
|
|
|
+}
|
|
|
+@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;
|
|
|
+ }
|
|
|
+}
|
|
|
</style>
|