|
@@ -32,7 +32,127 @@
|
|
|
<TextArea v-model:value="extraPrompt" :rows="2" placeholder="可选,例如:多一些节奏互动,语言更适合小学低年级" :disabled="working" />
|
|
<TextArea v-model:value="extraPrompt" :rows="2" placeholder="可选,例如:多一些节奏互动,语言更适合小学低年级" :disabled="working" />
|
|
|
</div>
|
|
</div>
|
|
|
|
|
|
|
|
- <div v-if="outline" class="outline">
|
|
|
|
|
|
|
+ <div class="style-selector">
|
|
|
|
|
+ <div class="label">视觉方向</div>
|
|
|
|
|
+ <div class="style-options">
|
|
|
|
|
+ <button
|
|
|
|
|
+ type="button"
|
|
|
|
|
+ :class="['style-option', { active: selectedStyle === 'cartoon' }]"
|
|
|
|
|
+ :disabled="working"
|
|
|
|
|
+ @click="selectedStyle = 'cartoon'"
|
|
|
|
|
+ >
|
|
|
|
|
+ <strong>卡通课堂</strong>
|
|
|
|
|
+ <span>适合小学和互动课堂</span>
|
|
|
|
|
+ </button>
|
|
|
|
|
+ <button type="button" :class="['style-option', { active: selectedStyle === 'clean' }]" :disabled="working" @click="selectedStyle = 'clean'">
|
|
|
|
|
+ <strong>简洁教学</strong>
|
|
|
|
|
+ <span>适合高年级和知识讲解</span>
|
|
|
|
|
+ </button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <div class="generation-preferences">
|
|
|
|
|
+ <div class="label">生成偏好</div>
|
|
|
|
|
+ <div class="preference-grid">
|
|
|
|
|
+ <label
|
|
|
|
|
+ ><span>课时</span
|
|
|
|
|
+ ><select v-model.number="preferences.lessonMinutes" :disabled="working">
|
|
|
|
|
+ <option :value="20">20分钟</option>
|
|
|
|
|
+ <option :value="30">30分钟</option>
|
|
|
|
|
+ <option :value="40">40分钟</option>
|
|
|
|
|
+ <option :value="45">45分钟</option>
|
|
|
|
|
+ </select></label
|
|
|
|
|
+ >
|
|
|
|
|
+ <label
|
|
|
|
|
+ ><span>页数</span
|
|
|
|
|
+ ><select v-model="pageRange" :disabled="working">
|
|
|
|
|
+ <option value="9-12">9~12页</option>
|
|
|
|
|
+ <option value="12-15">12~15页</option>
|
|
|
|
|
+ <option value="9-15">9~15页</option>
|
|
|
|
|
+ </select></label
|
|
|
|
|
+ >
|
|
|
|
|
+ <label
|
|
|
|
|
+ ><span>素材</span
|
|
|
|
|
+ ><select v-model="preferences.assetStyle" :disabled="working">
|
|
|
|
|
+ <option value="illustration">统一插画</option>
|
|
|
|
|
+ <option value="photo">真实照片</option>
|
|
|
|
|
+ <option value="mixed">插画与照片混合</option>
|
|
|
|
|
+ </select></label
|
|
|
|
|
+ >
|
|
|
|
|
+ <label
|
|
|
|
|
+ ><span>教材原图</span
|
|
|
|
|
+ ><select v-model="preferences.sourceImagePolicy" :disabled="working">
|
|
|
|
|
+ <option value="strict">优先保留</option>
|
|
|
|
|
+ <option value="balanced">适度使用</option>
|
|
|
|
|
+ <option value="creative">自由创作</option>
|
|
|
|
|
+ </select></label
|
|
|
|
|
+ >
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="media-options">
|
|
|
|
|
+ <label><input v-model="preferences.requireAudio" type="checkbox" :disabled="working" />需要音频</label>
|
|
|
|
|
+ <label><input v-model="preferences.requireVideo" type="checkbox" :disabled="working" />需要视频</label>
|
|
|
|
|
+ <label><input v-model="preferences.enableAnimations" type="checkbox" :disabled="working" />启用克制动画</label>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <div v-if="draftConverted" class="result-preview">
|
|
|
|
|
+ <div class="preview-head">
|
|
|
|
|
+ <div>
|
|
|
|
|
+ <div class="preview-title">全稿预览</div>
|
|
|
|
|
+ <div class="preview-sub">共{{ draftConverted.slides.length }}页,确认后才会替换当前课件</div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div :class="['quality-badge', { passed: draftQuality?.passed }]">
|
|
|
|
|
+ {{ draftQuality?.passed ? "质量检查通过" : `${draftQuality?.errors.length || 0}项问题待处理` }}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div v-if="draftQuality?.issues.length" class="quality-list">
|
|
|
|
|
+ <div
|
|
|
|
|
+ v-for="issue in draftQuality.issues"
|
|
|
|
|
+ :key="`${issue.code}-${issue.pageNo || 0}-${issue.message}`"
|
|
|
|
|
+ :class="['quality-item', issue.severity]"
|
|
|
|
|
+ >
|
|
|
|
|
+ <span>{{ issue.severity === "error" ? "阻止应用" : "建议检查" }}</span>
|
|
|
|
|
+ <span>{{ issue.pageNo ? `第${issue.pageNo}页:` : "" }}{{ issue.message }}</span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="preview-grid">
|
|
|
|
|
+ <div v-for="(slide, index) in draftConverted.slides" :key="slide.id" class="preview-card">
|
|
|
|
|
+ <ThumbnailSlide :slide="slide" :size="220" />
|
|
|
|
|
+ <div class="preview-page-no">{{ formatPageNo(index + 1) }}</div>
|
|
|
|
|
+ <div class="page-actions">
|
|
|
|
|
+ <select :value="draftOutline?.pages[index]?.visualPlan?.layout" :disabled="working" @change="changeDraftLayout(index, $event)">
|
|
|
|
|
+ <option v-for="layout in layoutOptions" :key="layout.value" :value="layout.value">{{ layout.label }}</option>
|
|
|
|
|
+ </select>
|
|
|
|
|
+ <input
|
|
|
|
|
+ :value="(draftOutline?.pages[index]?.imageKeywords || []).join('、')"
|
|
|
|
|
+ :disabled="working"
|
|
|
|
|
+ placeholder="图片关键词"
|
|
|
|
|
+ @change="changeDraftKeywords(index, $event)"
|
|
|
|
|
+ />
|
|
|
|
|
+ <div>
|
|
|
|
|
+ <button type="button" :disabled="working" @click="compactDraftPage(index)">精简文字</button>
|
|
|
|
|
+ <button type="button" :disabled="working || !draftOutline?.pages[index]?.image" @click="removeDraftImage(index)">移除图片</button>
|
|
|
|
|
+ <button type="button" :disabled="working" @click="regenerateDraftPage(index)">重新生成本页</button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <div v-else-if="outline" class="outline">
|
|
|
|
|
+ <div v-if="sampleSlide" class="sample-review">
|
|
|
|
|
+ <div class="sample-copy">
|
|
|
|
|
+ <div class="field-title">视觉样张</div>
|
|
|
|
|
+ <div class="sample-title">第{{ samplePageNo }}页 · {{ samplePageTitle }}</div>
|
|
|
|
|
+ <div class="sample-hint">样张使用最终模板和当前视觉方向渲染。修改大纲或视觉方向后需要重新确认。</div>
|
|
|
|
|
+ <Button type="primary" @click="sampleApproved = !sampleApproved" :disabled="working">
|
|
|
|
|
+ {{ sampleApproved ? "已确认样张" : "确认这个视觉方向" }}
|
|
|
|
|
+ </Button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div :class="['sample-canvas', { approved: sampleApproved }]">
|
|
|
|
|
+ <ThumbnailSlide :slide="sampleSlide" :size="420" />
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
<div class="page-nav">
|
|
<div class="page-nav">
|
|
|
<button
|
|
<button
|
|
|
v-for="page in outline.pages"
|
|
v-for="page in outline.pages"
|
|
@@ -56,12 +176,7 @@
|
|
|
|
|
|
|
|
<div class="purpose-card">
|
|
<div class="purpose-card">
|
|
|
<div class="field-title">教学目的</div>
|
|
<div class="field-title">教学目的</div>
|
|
|
- <input
|
|
|
|
|
- v-model="selectedPage.teachingPurpose"
|
|
|
|
|
- :disabled="working"
|
|
|
|
|
- class="purpose-input"
|
|
|
|
|
- placeholder="这一页在课堂中要解决的教学目标"
|
|
|
|
|
- />
|
|
|
|
|
|
|
+ <input v-model="selectedPage.teachingPurpose" :disabled="working" class="purpose-input" placeholder="这一页在课堂中要解决的教学目标" />
|
|
|
</div>
|
|
</div>
|
|
|
|
|
|
|
|
<div class="field-grid">
|
|
<div class="field-grid">
|
|
@@ -102,13 +217,13 @@
|
|
|
</div>
|
|
</div>
|
|
|
|
|
|
|
|
<div class="field-card visual full-field">
|
|
<div class="field-card visual full-field">
|
|
|
- <div class="field-title">设计要求</div>
|
|
|
|
|
- <textarea
|
|
|
|
|
- v-model="selectedPage.designRequirement"
|
|
|
|
|
- :disabled="working"
|
|
|
|
|
- class="page-design"
|
|
|
|
|
- placeholder="例如:左文右图,右侧放卡通山谷和音符,整体暖色活泼,文字保持大字号。"
|
|
|
|
|
- ></textarea>
|
|
|
|
|
|
|
+ <div class="field-title">设计要求</div>
|
|
|
|
|
+ <textarea
|
|
|
|
|
+ v-model="selectedPage.designRequirement"
|
|
|
|
|
+ :disabled="working"
|
|
|
|
|
+ class="page-design"
|
|
|
|
|
+ placeholder="例如:左文右图,右侧放卡通山谷和音符,整体暖色活泼,文字保持大字号。"
|
|
|
|
|
+ ></textarea>
|
|
|
</div>
|
|
</div>
|
|
|
|
|
|
|
|
<div v-if="selectedPage.resourceBindings?.length" class="resource-card">
|
|
<div v-if="selectedPage.resourceBindings?.length" class="resource-card">
|
|
@@ -147,23 +262,31 @@
|
|
|
</div>
|
|
</div>
|
|
|
</div>
|
|
</div>
|
|
|
|
|
|
|
|
- <div v-else class="empty">生成大纲后,可在这里确认15页结构、页面内容和设计要求。</div>
|
|
|
|
|
|
|
+ <div v-else class="empty">生成大纲后,可在这里确认课件结构、页面内容和设计要求。</div>
|
|
|
|
|
|
|
|
<div class="footer">
|
|
<div class="footer">
|
|
|
<Button @click="emit('update:visible', false)" :disabled="working">取消</Button>
|
|
<Button @click="emit('update:visible', false)" :disabled="working">取消</Button>
|
|
|
- <Button type="primary" @click="handleOutline" :disabled="working">{{ outline ? "重新生成大纲" : "生成大纲" }}</Button>
|
|
|
|
|
- <Button type="primary" @click="handleGenerate" :disabled="!outline || working">确认并生成PPT</Button>
|
|
|
|
|
|
|
+ <template v-if="draftConverted">
|
|
|
|
|
+ <Button @click="backToOutline" :disabled="working">返回修改</Button>
|
|
|
|
|
+ <Button type="primary" @click="handleGenerate" :disabled="working">重新生成</Button>
|
|
|
|
|
+ <Button type="primary" @click="applyDraft" :disabled="working || !draftQuality?.passed">应用到当前课件</Button>
|
|
|
|
|
+ </template>
|
|
|
|
|
+ <template v-else>
|
|
|
|
|
+ <Button type="primary" @click="handleOutline" :disabled="working">{{ outline ? "重新生成大纲" : "生成大纲" }}</Button>
|
|
|
|
|
+ <Button type="primary" @click="handleGenerate" :disabled="!outline || !sampleApproved || working">确认并生成PPT</Button>
|
|
|
|
|
+ </template>
|
|
|
</div>
|
|
</div>
|
|
|
</div>
|
|
</div>
|
|
|
</Modal>
|
|
</Modal>
|
|
|
</template>
|
|
</template>
|
|
|
|
|
|
|
|
<script lang="ts" setup>
|
|
<script lang="ts" setup>
|
|
|
-import { ref, computed, watch } from "vue"
|
|
|
|
|
|
|
+import { ref, reactive, computed, watch } from "vue"
|
|
|
import { ElMessage, ElMessageBox } from "element-plus"
|
|
import { ElMessage, ElMessageBox } from "element-plus"
|
|
|
import Modal from "@/components/Modal.vue"
|
|
import Modal from "@/components/Modal.vue"
|
|
|
import Button from "@/components/Button.vue"
|
|
import Button from "@/components/Button.vue"
|
|
|
import TextArea from "@/components/TextArea.vue"
|
|
import TextArea from "@/components/TextArea.vue"
|
|
|
|
|
+import ThumbnailSlide from "@/views/components/ThumbnailSlide/index.vue"
|
|
|
import queryParams from "@/queryParams"
|
|
import queryParams from "@/queryParams"
|
|
|
import usePptWork from "@/store/pptWork"
|
|
import usePptWork from "@/store/pptWork"
|
|
|
import { useSlidesStore } from "@/store"
|
|
import { useSlidesStore } from "@/store"
|
|
@@ -176,10 +299,13 @@ import {
|
|
|
aiCoursewareTaskApi,
|
|
aiCoursewareTaskApi,
|
|
|
type AiCoursewareOutline,
|
|
type AiCoursewareOutline,
|
|
|
type AiCoursewarePage,
|
|
type AiCoursewarePage,
|
|
|
|
|
+ type AiCoursewarePreferences,
|
|
|
type AiCoursewareTaskInfo,
|
|
type AiCoursewareTaskInfo,
|
|
|
type AiCoursewareGenerateResult
|
|
type AiCoursewareGenerateResult
|
|
|
} from "@/api/aiCourseware"
|
|
} from "@/api/aiCourseware"
|
|
|
import { aiCoursewareToSlides } from "@/utils/aiCoursewareToSlides"
|
|
import { aiCoursewareToSlides } from "@/utils/aiCoursewareToSlides"
|
|
|
|
|
+import { inspectAiPptQuality, type AiPptQualityReport } from "@/utils/aiPptQuality"
|
|
|
|
|
+import type { Slide, SlideTheme } from "@/types/slides"
|
|
|
|
|
|
|
|
const props = defineProps<{
|
|
const props = defineProps<{
|
|
|
visible: boolean
|
|
visible: boolean
|
|
@@ -196,6 +322,22 @@ const { addHistorySnapshot } = useHistorySnapshot()
|
|
|
const extraPrompt = ref("")
|
|
const extraPrompt = ref("")
|
|
|
const outline = ref<AiCoursewareOutline>()
|
|
const outline = ref<AiCoursewareOutline>()
|
|
|
const taskInfo = ref<AiCoursewareTaskInfo>()
|
|
const taskInfo = ref<AiCoursewareTaskInfo>()
|
|
|
|
|
+const selectedStyle = ref<"cartoon" | "clean">("cartoon")
|
|
|
|
|
+const pageRange = ref("9-12")
|
|
|
|
|
+const preferences = reactive<AiCoursewarePreferences>({
|
|
|
|
|
+ lessonMinutes: 40,
|
|
|
|
|
+ minPages: 9,
|
|
|
|
|
+ maxPages: 12,
|
|
|
|
|
+ assetStyle: "illustration",
|
|
|
|
|
+ sourceImagePolicy: "balanced",
|
|
|
|
|
+ requireAudio: false,
|
|
|
|
|
+ requireVideo: false,
|
|
|
|
|
+ enableAnimations: true
|
|
|
|
|
+})
|
|
|
|
|
+const sampleApproved = ref(false)
|
|
|
|
|
+const draftOutline = ref<AiCoursewareOutline>()
|
|
|
|
|
+const draftConverted = ref<{ title: string; theme: Partial<SlideTheme>; slides: Slide[] }>()
|
|
|
|
|
+const draftQuality = ref<AiPptQualityReport>()
|
|
|
const pollTimer = ref<number>()
|
|
const pollTimer = ref<number>()
|
|
|
const selectedPageNo = ref(1)
|
|
const selectedPageNo = ref(1)
|
|
|
const outlineLoading = ref(false)
|
|
const outlineLoading = ref(false)
|
|
@@ -214,7 +356,7 @@ const currentProgressLabel = computed(() => {
|
|
|
})
|
|
})
|
|
|
const pageTitle = computed(() => outline.value?.chapterName || outline.value?.title || "课件大纲")
|
|
const pageTitle = computed(() => outline.value?.chapterName || outline.value?.title || "课件大纲")
|
|
|
const pageMeta = computed(() => {
|
|
const pageMeta = computed(() => {
|
|
|
- if (!outline.value) return "根据当前教材、单元和章节生成15页音乐课件"
|
|
|
|
|
|
|
+ if (!outline.value) return "根据当前教材、单元和章节生成9至15页音乐课件"
|
|
|
return [...[outline.value.gradeName, outline.value.unitName].filter(Boolean), `共${outline.value.pages.length}页`].join(" · ")
|
|
return [...[outline.value.gradeName, outline.value.unitName].filter(Boolean), `共${outline.value.pages.length}页`].join(" · ")
|
|
|
})
|
|
})
|
|
|
const selectedPage = computed(() => {
|
|
const selectedPage = computed(() => {
|
|
@@ -227,6 +369,68 @@ const studentPoints = computed(() => {
|
|
|
return selectedPage.value.studentContent?.points?.length ? selectedPage.value.studentContent.points : selectedPage.value.bullets || []
|
|
return selectedPage.value.studentContent?.points?.length ? selectedPage.value.studentContent.points : selectedPage.value.bullets || []
|
|
|
})
|
|
})
|
|
|
const activityTask = computed(() => selectedPage.value?.classroomAction?.studentTask || selectedPage.value?.activity?.task || "")
|
|
const activityTask = computed(() => selectedPage.value?.classroomAction?.studentTask || selectedPage.value?.activity?.task || "")
|
|
|
|
|
+const samplePageIndex = computed(() => {
|
|
|
|
|
+ const pages = outline.value?.pages || []
|
|
|
|
|
+ const preferred = pages.findIndex(
|
|
|
|
|
+ (page, index) => index > 0 && ["goal_cards", "activity_steps", "compare_listen", "summary_checklist"].includes(page.visualPlan?.layout || "")
|
|
|
|
|
+ )
|
|
|
|
|
+ return preferred >= 0 ? preferred : Math.min(1, Math.max(0, pages.length - 1))
|
|
|
|
|
+})
|
|
|
|
|
+const samplePageNo = computed(() => outline.value?.pages[samplePageIndex.value]?.pageNo || samplePageIndex.value + 1)
|
|
|
|
|
+const samplePageTitle = computed(() => outline.value?.pages[samplePageIndex.value]?.title || "代表页面")
|
|
|
|
|
+const sampleSlide = computed(() => {
|
|
|
|
|
+ if (!outline.value?.pages.length) return undefined
|
|
|
|
|
+ const previewOutline = { ...outline.value, style: selectedStyle.value }
|
|
|
|
|
+ return aiCoursewareToSlides(previewOutline, { enableAnimations: preferences.enableAnimations }).slides[samplePageIndex.value]
|
|
|
|
|
+})
|
|
|
|
|
+
|
|
|
|
|
+const layoutOptions = [
|
|
|
|
|
+ { value: "cover_scene", label: "封面场景" },
|
|
|
|
|
+ { value: "story_stage", label: "故事舞台" },
|
|
|
|
|
+ { value: "observe_question", label: "观察提问" },
|
|
|
|
|
+ { value: "listen_compare_visual", label: "听辨对比" },
|
|
|
|
|
+ { value: "sound_timeline", label: "声音时间线" },
|
|
|
|
|
+ { value: "action_demo", label: "动作示范" },
|
|
|
|
|
+ { value: "group_mission", label: "小组任务" },
|
|
|
|
|
+ { value: "performance_stage", label: "表演舞台" },
|
|
|
|
|
+ { value: "visual_summary", label: "视觉总结" },
|
|
|
|
|
+ { value: "full_image_question", label: "全图提问" },
|
|
|
|
|
+ { value: "split_image", label: "图文分栏" },
|
|
|
|
|
+ { value: "goal_cards", label: "目标卡片" },
|
|
|
|
|
+ { value: "listen_wave", label: "听赏任务" },
|
|
|
|
|
+ { value: "lyric_focus", label: "歌词聚焦" },
|
|
|
|
|
+ { value: "score_focus", label: "谱例聚焦" },
|
|
|
|
|
+ { value: "rhythm_grid", label: "节奏练习" },
|
|
|
|
|
+ { value: "instrument_focus", label: "乐器认识" },
|
|
|
|
|
+ { value: "compare_listen", label: "对比听赏" },
|
|
|
|
|
+ { value: "knowledge_cards", label: "知识列表" },
|
|
|
|
|
+ { value: "activity_steps", label: "活动步骤" },
|
|
|
|
|
+ { value: "practice_check", label: "课堂检测" },
|
|
|
|
|
+ { value: "summary_checklist", label: "总结清单" },
|
|
|
|
|
+ { value: "homework_list", label: "课后任务" }
|
|
|
|
|
+]
|
|
|
|
|
+
|
|
|
|
|
+watch(selectedStyle, style => {
|
|
|
|
|
+ if (outline.value) outline.value.style = style
|
|
|
|
|
+ invalidateVisualApproval()
|
|
|
|
|
+})
|
|
|
|
|
+
|
|
|
|
|
+watch(pageRange, value => {
|
|
|
|
|
+ const [minPages, maxPages] = value.split("-").map(Number)
|
|
|
|
|
+ preferences.minPages = minPages
|
|
|
|
|
+ preferences.maxPages = maxPages
|
|
|
|
|
+})
|
|
|
|
|
+
|
|
|
|
|
+watch(preferences, () => invalidateVisualApproval(), { deep: true })
|
|
|
|
|
+
|
|
|
|
|
+watch(
|
|
|
|
|
+ outline,
|
|
|
|
|
+ () => {
|
|
|
|
|
+ if (!outline.value) return
|
|
|
|
|
+ invalidateVisualApproval()
|
|
|
|
|
+ },
|
|
|
|
|
+ { deep: true }
|
|
|
|
|
+)
|
|
|
|
|
|
|
|
watch(
|
|
watch(
|
|
|
() => props.visible,
|
|
() => props.visible,
|
|
@@ -244,19 +448,22 @@ async function handleOutline() {
|
|
|
const res = await httpAjaxErrMsg(aiCoursewareOutlineApi, {
|
|
const res = await httpAjaxErrMsg(aiCoursewareOutlineApi, {
|
|
|
id: pptWork.id,
|
|
id: pptWork.id,
|
|
|
fromType: queryParams.pptResourcesType || queryParams.fromType,
|
|
fromType: queryParams.pptResourcesType || queryParams.fromType,
|
|
|
- extraPrompt: buildExtraPrompt()
|
|
|
|
|
|
|
+ extraPrompt: buildExtraPrompt(),
|
|
|
|
|
+ preferences: { ...preferences }
|
|
|
}).finally(() => {
|
|
}).finally(() => {
|
|
|
outlineLoading.value = false
|
|
outlineLoading.value = false
|
|
|
})
|
|
})
|
|
|
if (res.code === 200) {
|
|
if (res.code === 200) {
|
|
|
outline.value = res.data as AiCoursewareOutline
|
|
outline.value = res.data as AiCoursewareOutline
|
|
|
|
|
+ outline.value.style = selectedStyle.value
|
|
|
selectedPageNo.value = outline.value.pages[0]?.pageNo || 1
|
|
selectedPageNo.value = outline.value.pages[0]?.pageNo || 1
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
async function handleGenerate() {
|
|
async function handleGenerate() {
|
|
|
- if (!outline.value || !pptWork.id) return
|
|
|
|
|
- const confirmed = await ElMessageBox.confirm("生成后会替换当前编辑器中的课件内容,未保存内容将丢失。确认继续?", "确认生成", {
|
|
|
|
|
|
|
+ const sourceOutline = draftOutline.value || outline.value
|
|
|
|
|
+ if (!sourceOutline || !pptWork.id || !sampleApproved.value) return
|
|
|
|
|
+ const confirmed = await ElMessageBox.confirm("系统会生成一份完整课件草稿,完成后先进入预览,不会直接替换当前课件。确认继续?", "确认生成", {
|
|
|
confirmButtonText: "确认生成",
|
|
confirmButtonText: "确认生成",
|
|
|
cancelButtonText: "取消",
|
|
cancelButtonText: "取消",
|
|
|
type: "warning"
|
|
type: "warning"
|
|
@@ -265,21 +472,28 @@ async function handleGenerate() {
|
|
|
.catch(() => false)
|
|
.catch(() => false)
|
|
|
if (!confirmed) return
|
|
if (!confirmed) return
|
|
|
|
|
|
|
|
|
|
+ draftConverted.value = undefined
|
|
|
|
|
+ draftQuality.value = undefined
|
|
|
|
|
+ sourceOutline.style = selectedStyle.value
|
|
|
const res = await httpAjaxErrMsg(aiCoursewareGenerateApi, {
|
|
const res = await httpAjaxErrMsg(aiCoursewareGenerateApi, {
|
|
|
id: pptWork.id,
|
|
id: pptWork.id,
|
|
|
fromType: queryParams.pptResourcesType || queryParams.fromType,
|
|
fromType: queryParams.pptResourcesType || queryParams.fromType,
|
|
|
- outline: outline.value
|
|
|
|
|
|
|
+ outline: sourceOutline,
|
|
|
|
|
+ visualPrompt: buildVisualPrompt(),
|
|
|
|
|
+ preferences: { ...preferences }
|
|
|
})
|
|
})
|
|
|
if (res.code !== 200) return
|
|
if (res.code !== 200) return
|
|
|
|
|
|
|
|
taskInfo.value = res.data as AiCoursewareTaskInfo
|
|
taskInfo.value = res.data as AiCoursewareTaskInfo
|
|
|
- pollTask(taskInfo.value.taskId)
|
|
|
|
|
|
|
+ await pollTask(taskInfo.value.taskId)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
async function pollTask(taskId: string) {
|
|
async function pollTask(taskId: string) {
|
|
|
if (pollTimer.value) window.clearInterval(pollTimer.value)
|
|
if (pollTimer.value) window.clearInterval(pollTimer.value)
|
|
|
await fetchTask(taskId)
|
|
await fetchTask(taskId)
|
|
|
- pollTimer.value = window.setInterval(() => fetchTask(taskId), 2000)
|
|
|
|
|
|
|
+ if (taskInfo.value?.status === "PENDING" || taskInfo.value?.status === "RUNNING") {
|
|
|
|
|
+ pollTimer.value = window.setInterval(() => fetchTask(taskId), 2000)
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
async function fetchTask(taskId: string) {
|
|
async function fetchTask(taskId: string) {
|
|
@@ -291,7 +505,7 @@ async function fetchTask(taskId: string) {
|
|
|
taskInfo.value = res.data as AiCoursewareTaskInfo
|
|
taskInfo.value = res.data as AiCoursewareTaskInfo
|
|
|
if (taskInfo.value.status === "DONE") {
|
|
if (taskInfo.value.status === "DONE") {
|
|
|
clearPollTimer()
|
|
clearPollTimer()
|
|
|
- await applyResult(taskId)
|
|
|
|
|
|
|
+ await loadDraft(taskId)
|
|
|
} else if (taskInfo.value.status === "FAILED") {
|
|
} else if (taskInfo.value.status === "FAILED") {
|
|
|
clearPollTimer()
|
|
clearPollTimer()
|
|
|
ElMessage.error(taskInfo.value.message || "AI课件生成失败")
|
|
ElMessage.error(taskInfo.value.message || "AI课件生成失败")
|
|
@@ -310,21 +524,126 @@ function clearPollTimer() {
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-async function applyResult(taskId: string) {
|
|
|
|
|
|
|
+async function loadDraft(taskId: string) {
|
|
|
const res = await httpAjaxErrMsg(aiCoursewareResultApi, taskId)
|
|
const res = await httpAjaxErrMsg(aiCoursewareResultApi, taskId)
|
|
|
if (res.code !== 200) return
|
|
if (res.code !== 200) return
|
|
|
|
|
|
|
|
const result = res.data as AiCoursewareGenerateResult
|
|
const result = res.data as AiCoursewareGenerateResult
|
|
|
- const converted = aiCoursewareToSlides(result.outline)
|
|
|
|
|
- slidesStore.setTitle(converted.title)
|
|
|
|
|
- slidesStore.setTheme(converted.theme)
|
|
|
|
|
- slidesStore.setSlides(converted.slides)
|
|
|
|
|
|
|
+ draftOutline.value = result.outline
|
|
|
|
|
+ rebuildDraftPreview()
|
|
|
|
|
+ if (draftQuality.value?.passed) ElMessage.success("课件草稿已生成,请预览后确认应用")
|
|
|
|
|
+ else ElMessage.error(`课件草稿存在${draftQuality.value?.errors.length || 0}项阻止应用的问题`)
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function rebuildDraftPreview() {
|
|
|
|
|
+ if (!draftOutline.value) {
|
|
|
|
|
+ draftConverted.value = undefined
|
|
|
|
|
+ draftQuality.value = undefined
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+ draftOutline.value.style = selectedStyle.value
|
|
|
|
|
+ draftConverted.value = aiCoursewareToSlides(draftOutline.value, { enableAnimations: preferences.enableAnimations })
|
|
|
|
|
+ draftQuality.value = inspectAiPptQuality(draftOutline.value, draftConverted.value.slides, preferences)
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function changeDraftLayout(index: number, event: Event) {
|
|
|
|
|
+ const page = draftOutline.value?.pages[index]
|
|
|
|
|
+ if (!page) return
|
|
|
|
|
+ if (!page.visualPlan) page.visualPlan = {}
|
|
|
|
|
+ page.visualPlan.layout = (event.target as HTMLSelectElement).value
|
|
|
|
|
+ rebuildDraftPreview()
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function changeDraftKeywords(index: number, event: Event) {
|
|
|
|
|
+ const page = draftOutline.value?.pages[index]
|
|
|
|
|
+ if (!page) return
|
|
|
|
|
+ const keywords = (event.target as HTMLInputElement).value
|
|
|
|
|
+ .split(/[,,、;;\s]+/)
|
|
|
|
|
+ .map(item => item.trim())
|
|
|
|
|
+ .filter(Boolean)
|
|
|
|
|
+ .slice(0, 6)
|
|
|
|
|
+ page.imageKeywords = keywords
|
|
|
|
|
+ if (!page.visualPlan) page.visualPlan = {}
|
|
|
|
|
+ page.visualPlan.imageKeywords = keywords
|
|
|
|
|
+ rebuildDraftPreview()
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function compactDraftPage(index: number) {
|
|
|
|
|
+ const page = draftOutline.value?.pages[index]
|
|
|
|
|
+ if (!page) return
|
|
|
|
|
+ const compact = (value: string | undefined, limit: number) =>
|
|
|
|
|
+ String(value || "")
|
|
|
|
|
+ .replace(/\s+/g, " ")
|
|
|
|
|
+ .trim()
|
|
|
|
|
+ .slice(0, limit)
|
|
|
|
|
+ if (page.studentContent) {
|
|
|
|
|
+ page.studentContent.headline = compact(page.studentContent.headline, 18)
|
|
|
|
|
+ page.studentContent.points = [...new Set(page.studentContent.points || [])]
|
|
|
|
|
+ .slice(0, 3)
|
|
|
|
|
+ .map(item => compact(item, 20))
|
|
|
|
|
+ .filter(Boolean)
|
|
|
|
|
+ page.bullets = [...page.studentContent.points]
|
|
|
|
|
+ }
|
|
|
|
|
+ if (page.activity) {
|
|
|
|
|
+ page.activity.task = compact(page.activity.task, 22)
|
|
|
|
|
+ page.activity.steps = [...new Set(page.activity.steps || [])]
|
|
|
|
|
+ .slice(0, 3)
|
|
|
|
|
+ .map(item => compact(item, 20))
|
|
|
|
|
+ .filter(Boolean)
|
|
|
|
|
+ }
|
|
|
|
|
+ rebuildDraftPreview()
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function removeDraftImage(index: number) {
|
|
|
|
|
+ const page = draftOutline.value?.pages[index]
|
|
|
|
|
+ if (!page) return
|
|
|
|
|
+ page.image = undefined
|
|
|
|
|
+ page.resourceBindings = (page.resourceBindings || []).filter(binding => binding.resourceType !== "IMG")
|
|
|
|
|
+ rebuildDraftPreview()
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+async function regenerateDraftPage(index: number) {
|
|
|
|
|
+ if (!draftOutline.value || !pptWork.id) return
|
|
|
|
|
+ const res = await httpAjaxErrMsg(aiCoursewareGenerateApi, {
|
|
|
|
|
+ id: pptWork.id,
|
|
|
|
|
+ fromType: queryParams.pptResourcesType || queryParams.fromType,
|
|
|
|
|
+ outline: draftOutline.value,
|
|
|
|
|
+ visualPrompt: buildVisualPrompt(),
|
|
|
|
|
+ preferences: { ...preferences },
|
|
|
|
|
+ regeneratePageNo: index + 1
|
|
|
|
|
+ })
|
|
|
|
|
+ if (res.code !== 200) return
|
|
|
|
|
+ taskInfo.value = res.data as AiCoursewareTaskInfo
|
|
|
|
|
+ await pollTask(taskInfo.value.taskId)
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function applyDraft() {
|
|
|
|
|
+ if (!draftConverted.value || !draftQuality.value?.passed) return
|
|
|
|
|
+ slidesStore.setTitle(draftConverted.value.title)
|
|
|
|
|
+ slidesStore.setTheme(draftConverted.value.theme)
|
|
|
|
|
+ slidesStore.setSlides(draftConverted.value.slides)
|
|
|
slidesStore.updateSlideIndex(0)
|
|
slidesStore.updateSlideIndex(0)
|
|
|
addHistorySnapshot()
|
|
addHistorySnapshot()
|
|
|
- ElMessage.success("PPT已生成,请检查后手动保存")
|
|
|
|
|
|
|
+ ElMessage.success("PPT已应用,请检查后手动保存")
|
|
|
emit("update:visible", false)
|
|
emit("update:visible", false)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+function backToOutline() {
|
|
|
|
|
+ clearTask()
|
|
|
|
|
+ if (draftOutline.value) outline.value = draftOutline.value
|
|
|
|
|
+ draftOutline.value = undefined
|
|
|
|
|
+ draftConverted.value = undefined
|
|
|
|
|
+ draftQuality.value = undefined
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function invalidateVisualApproval() {
|
|
|
|
|
+ sampleApproved.value = false
|
|
|
|
|
+ if (!draftConverted.value) return
|
|
|
|
|
+ draftOutline.value = undefined
|
|
|
|
|
+ draftConverted.value = undefined
|
|
|
|
|
+ draftQuality.value = undefined
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
function updateStudentHeadline(page: AiCoursewarePage, event: Event) {
|
|
function updateStudentHeadline(page: AiCoursewarePage, event: Event) {
|
|
|
if (!page.studentContent) page.studentContent = { headline: "", points: [] }
|
|
if (!page.studentContent) page.studentContent = { headline: "", points: [] }
|
|
|
page.studentContent.headline = (event.target as HTMLInputElement).value.trim()
|
|
page.studentContent.headline = (event.target as HTMLInputElement).value.trim()
|
|
@@ -368,6 +687,26 @@ function buildExtraPrompt() {
|
|
|
return extraPrompt.value.trim()
|
|
return extraPrompt.value.trim()
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+function buildVisualPrompt() {
|
|
|
|
|
+ const base =
|
|
|
|
|
+ selectedStyle.value === "clean"
|
|
|
|
|
+ ? "简洁教学风格,层级清晰,避免装饰性卡片堆叠,保持大字号和明确视觉焦点。"
|
|
|
|
|
+ : "卡通课堂风格,适合低龄学生,使用统一画风的具体人物、动作和场景,避免成人商务卡片风格。"
|
|
|
|
|
+ const asset =
|
|
|
|
|
+ preferences.assetStyle === "illustration"
|
|
|
|
|
+ ? "主视觉使用统一画风插画。"
|
|
|
|
|
+ : preferences.assetStyle === "photo"
|
|
|
|
|
+ ? "主视觉优先真实照片。"
|
|
|
|
|
+ : "插画和真实照片按教学内容合理混用。"
|
|
|
|
|
+ const source =
|
|
|
|
|
+ preferences.sourceImagePolicy === "strict"
|
|
|
|
|
+ ? "优先保留教材原图,不随意替换。"
|
|
|
|
|
+ : preferences.sourceImagePolicy === "creative"
|
|
|
|
|
+ ? "允许根据教学目标重新创作主视觉。"
|
|
|
|
|
+ : "教材原图与新视觉保持平衡。"
|
|
|
|
|
+ return `${base}${asset}${source}`
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
function formatPageNo(pageNo?: number) {
|
|
function formatPageNo(pageNo?: number) {
|
|
|
return String(pageNo || 0).padStart(2, "0")
|
|
return String(pageNo || 0).padStart(2, "0")
|
|
|
}
|
|
}
|
|
@@ -385,12 +724,19 @@ function shortProgressLabel(message?: string) {
|
|
|
|
|
|
|
|
function resourceTypeName(type: string) {
|
|
function resourceTypeName(type: string) {
|
|
|
const names: Record<string, string> = {
|
|
const names: Record<string, string> = {
|
|
|
- IMG: "图片", VIDEO: "视频", SONG: "音频", MUSIC: "乐谱", LISTEN: "听音练习",
|
|
|
|
|
- RHYTHM: "节奏练习", THEORY: "乐理知识", MUSIC_WIKI: "名曲鉴赏", INSTRUMENT: "乐器百科", MUSICIAN: "音乐家",
|
|
|
|
|
|
|
+ IMG: "图片",
|
|
|
|
|
+ VIDEO: "视频",
|
|
|
|
|
+ SONG: "音频",
|
|
|
|
|
+ MUSIC: "乐谱",
|
|
|
|
|
+ LISTEN: "听音练习",
|
|
|
|
|
+ RHYTHM: "节奏练习",
|
|
|
|
|
+ THEORY: "乐理知识",
|
|
|
|
|
+ MUSIC_WIKI: "名曲鉴赏",
|
|
|
|
|
+ INSTRUMENT: "乐器百科",
|
|
|
|
|
+ MUSICIAN: "音乐家"
|
|
|
}
|
|
}
|
|
|
return names[type] || "课堂资源"
|
|
return names[type] || "课堂资源"
|
|
|
}
|
|
}
|
|
|
-
|
|
|
|
|
</script>
|
|
</script>
|
|
|
|
|
|
|
|
<style lang="scss" scoped>
|
|
<style lang="scss" scoped>
|
|
@@ -472,6 +818,75 @@ function resourceTypeName(type: string) {
|
|
|
.extra {
|
|
.extra {
|
|
|
margin-top: 12px;
|
|
margin-top: 12px;
|
|
|
}
|
|
}
|
|
|
|
|
+.generation-preferences {
|
|
|
|
|
+ margin-top: 12px;
|
|
|
|
|
+ padding: 12px;
|
|
|
|
|
+ border: 1px solid #e5eaf0;
|
|
|
|
|
+ border-radius: 8px;
|
|
|
|
|
+ background: #fafcff;
|
|
|
|
|
+}
|
|
|
|
|
+.preference-grid {
|
|
|
|
|
+ display: grid;
|
|
|
|
|
+ grid-template-columns: repeat(4, minmax(140px, 1fr));
|
|
|
|
|
+ gap: 10px;
|
|
|
|
|
+ label,
|
|
|
|
|
+ span {
|
|
|
|
|
+ display: block;
|
|
|
|
|
+ }
|
|
|
|
|
+ span {
|
|
|
|
|
+ margin-bottom: 5px;
|
|
|
|
|
+ color: #52616b;
|
|
|
|
|
+ font-size: 12px;
|
|
|
|
|
+ }
|
|
|
|
|
+ input,
|
|
|
|
|
+ select {
|
|
|
|
|
+ width: 100%;
|
|
|
|
|
+ height: 34px;
|
|
|
|
|
+ padding: 0 8px;
|
|
|
|
|
+ border: 1px solid #dfe5ec;
|
|
|
|
|
+ border-radius: 6px;
|
|
|
|
|
+ background: #fff;
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+.media-options {
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ gap: 18px;
|
|
|
|
|
+ margin-top: 10px;
|
|
|
|
|
+ color: #52616b;
|
|
|
|
|
+ font-size: 13px;
|
|
|
|
|
+ input {
|
|
|
|
|
+ margin-right: 6px;
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+.style-selector {
|
|
|
|
|
+ margin-top: 12px;
|
|
|
|
|
+}
|
|
|
|
|
+.style-options {
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ gap: 10px;
|
|
|
|
|
+}
|
|
|
|
|
+.style-option {
|
|
|
|
|
+ width: 210px;
|
|
|
|
|
+ padding: 10px 12px;
|
|
|
|
|
+ border: 1px solid #dfe5ec;
|
|
|
|
|
+ border-radius: 8px;
|
|
|
|
|
+ background: #fff;
|
|
|
|
|
+ text-align: left;
|
|
|
|
|
+ cursor: pointer;
|
|
|
|
|
+}
|
|
|
|
|
+.style-option strong,
|
|
|
|
|
+.style-option span {
|
|
|
|
|
+ display: block;
|
|
|
|
|
+}
|
|
|
|
|
+.style-option span {
|
|
|
|
|
+ margin-top: 4px;
|
|
|
|
|
+ color: #7b8794;
|
|
|
|
|
+ font-size: 12px;
|
|
|
|
|
+}
|
|
|
|
|
+.style-option.active {
|
|
|
|
|
+ border-color: $themeColor;
|
|
|
|
|
+ box-shadow: 0 0 0 2px rgba($themeColor, 0.12);
|
|
|
|
|
+}
|
|
|
.label {
|
|
.label {
|
|
|
margin-bottom: 8px;
|
|
margin-bottom: 8px;
|
|
|
font-size: 14px;
|
|
font-size: 14px;
|
|
@@ -485,6 +900,150 @@ function resourceTypeName(type: string) {
|
|
|
grid-template-columns: 280px minmax(0, 1fr);
|
|
grid-template-columns: 280px minmax(0, 1fr);
|
|
|
gap: 14px;
|
|
gap: 14px;
|
|
|
}
|
|
}
|
|
|
|
|
+.sample-review {
|
|
|
|
|
+ grid-column: 1 / -1;
|
|
|
|
|
+ display: grid;
|
|
|
|
|
+ grid-template-columns: minmax(240px, 1fr) 420px;
|
|
|
|
|
+ align-items: center;
|
|
|
|
|
+ gap: 20px;
|
|
|
|
|
+ padding: 14px;
|
|
|
|
|
+ border: 1px solid #e5eaf0;
|
|
|
|
|
+ border-radius: 8px;
|
|
|
|
|
+ background: #fafcff;
|
|
|
|
|
+}
|
|
|
|
|
+.sample-title {
|
|
|
|
|
+ margin: 8px 0;
|
|
|
|
|
+ font-size: 16px;
|
|
|
|
|
+ font-weight: 700;
|
|
|
|
|
+}
|
|
|
|
|
+.sample-hint {
|
|
|
|
|
+ margin-bottom: 14px;
|
|
|
|
|
+ color: #7b8794;
|
|
|
|
|
+ font-size: 12px;
|
|
|
|
|
+ line-height: 1.6;
|
|
|
|
|
+}
|
|
|
|
|
+.sample-canvas {
|
|
|
|
|
+ overflow: hidden;
|
|
|
|
|
+ border: 2px solid transparent;
|
|
|
|
|
+ border-radius: 6px;
|
|
|
|
|
+ box-shadow: 0 6px 20px rgba(31, 41, 51, 0.12);
|
|
|
|
|
+}
|
|
|
|
|
+.sample-canvas.approved {
|
|
|
|
|
+ border-color: #168f86;
|
|
|
|
|
+}
|
|
|
|
|
+.result-preview {
|
|
|
|
|
+ min-height: 0;
|
|
|
|
|
+ flex: 1;
|
|
|
|
|
+ margin-top: 14px;
|
|
|
|
|
+ overflow: auto;
|
|
|
|
|
+}
|
|
|
|
|
+.preview-head {
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ align-items: center;
|
|
|
|
|
+ justify-content: space-between;
|
|
|
|
|
+}
|
|
|
|
|
+.preview-title {
|
|
|
|
|
+ font-size: 18px;
|
|
|
|
|
+ font-weight: 700;
|
|
|
|
|
+}
|
|
|
|
|
+.preview-sub {
|
|
|
|
|
+ margin-top: 4px;
|
|
|
|
|
+ color: #7b8794;
|
|
|
|
|
+ font-size: 12px;
|
|
|
|
|
+}
|
|
|
|
|
+.quality-badge {
|
|
|
|
|
+ padding: 7px 12px;
|
|
|
|
|
+ border-radius: 999px;
|
|
|
|
|
+ background: #fff1f0;
|
|
|
|
|
+ color: #cf1322;
|
|
|
|
|
+ font-size: 12px;
|
|
|
|
|
+ font-weight: 700;
|
|
|
|
|
+}
|
|
|
|
|
+.quality-badge.passed {
|
|
|
|
|
+ background: #eaf7f4;
|
|
|
|
|
+ color: #168f86;
|
|
|
|
|
+}
|
|
|
|
|
+.quality-list {
|
|
|
|
|
+ margin-top: 12px;
|
|
|
|
|
+ max-height: 150px;
|
|
|
|
|
+ overflow: auto;
|
|
|
|
|
+ border: 1px solid #edf1f5;
|
|
|
|
|
+ border-radius: 8px;
|
|
|
|
|
+}
|
|
|
|
|
+.quality-item {
|
|
|
|
|
+ display: grid;
|
|
|
|
|
+ grid-template-columns: 70px 1fr;
|
|
|
|
|
+ gap: 10px;
|
|
|
|
|
+ padding: 8px 10px;
|
|
|
|
|
+ font-size: 12px;
|
|
|
|
|
+}
|
|
|
|
|
+.quality-item + .quality-item {
|
|
|
|
|
+ border-top: 1px solid #edf1f5;
|
|
|
|
|
+}
|
|
|
|
|
+.quality-item.error > span:first-child {
|
|
|
|
|
+ color: #cf1322;
|
|
|
|
|
+ font-weight: 700;
|
|
|
|
|
+}
|
|
|
|
|
+.quality-item.warning > span:first-child {
|
|
|
|
|
+ color: #d97706;
|
|
|
|
|
+ font-weight: 700;
|
|
|
|
|
+}
|
|
|
|
|
+.preview-grid {
|
|
|
|
|
+ display: grid;
|
|
|
|
|
+ grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
|
|
|
|
+ gap: 14px;
|
|
|
|
|
+ margin-top: 14px;
|
|
|
|
|
+}
|
|
|
|
|
+.preview-card {
|
|
|
|
|
+ position: relative;
|
|
|
|
|
+ width: 220px;
|
|
|
|
|
+ border: 1px solid #e5eaf0;
|
|
|
|
|
+ border-radius: 6px;
|
|
|
|
|
+ background: #fff;
|
|
|
|
|
+ box-shadow: 0 4px 12px rgba(31, 41, 51, 0.08);
|
|
|
|
|
+}
|
|
|
|
|
+.page-actions {
|
|
|
|
|
+ display: grid;
|
|
|
|
|
+ gap: 7px;
|
|
|
|
|
+ padding: 8px;
|
|
|
|
|
+ select,
|
|
|
|
|
+ input {
|
|
|
|
|
+ min-width: 0;
|
|
|
|
|
+ height: 30px;
|
|
|
|
|
+ padding: 0 7px;
|
|
|
|
|
+ border: 1px solid #dfe5ec;
|
|
|
|
|
+ border-radius: 5px;
|
|
|
|
|
+ font-size: 12px;
|
|
|
|
|
+ }
|
|
|
|
|
+ > div {
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ flex-wrap: wrap;
|
|
|
|
|
+ gap: 5px;
|
|
|
|
|
+ }
|
|
|
|
|
+ button {
|
|
|
|
|
+ padding: 4px 7px;
|
|
|
|
|
+ border: 1px solid #dfe5ec;
|
|
|
|
|
+ border-radius: 5px;
|
|
|
|
|
+ background: #fff;
|
|
|
|
|
+ color: #52616b;
|
|
|
|
|
+ font-size: 11px;
|
|
|
|
|
+ cursor: pointer;
|
|
|
|
|
+ }
|
|
|
|
|
+ button:disabled {
|
|
|
|
|
+ cursor: not-allowed;
|
|
|
|
|
+ opacity: 0.5;
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+.preview-page-no {
|
|
|
|
|
+ position: absolute;
|
|
|
|
|
+ right: 6px;
|
|
|
|
|
+ bottom: 6px;
|
|
|
|
|
+ padding: 2px 5px;
|
|
|
|
|
+ border-radius: 4px;
|
|
|
|
|
+ background: rgba(31, 41, 51, 0.72);
|
|
|
|
|
+ color: #fff;
|
|
|
|
|
+ font-size: 11px;
|
|
|
|
|
+}
|
|
|
.page-nav {
|
|
.page-nav {
|
|
|
overflow: auto;
|
|
overflow: auto;
|
|
|
border: 1px solid #edf1f5;
|
|
border: 1px solid #edf1f5;
|