|
|
@@ -62,6 +62,10 @@
|
|
|
</div>
|
|
|
|
|
|
<div class="extra">
|
|
|
+ <label class="label">本节课范围</label>
|
|
|
+ <TextArea :value="preferences.lessonScope || ''" @update:value="preferences.lessonScope = $event" :rows="2" placeholder="例如:第一课时,重点学习第一段,不安排整曲演唱" :disabled="working" />
|
|
|
+ <label class="label">学生基础与课堂条件</label>
|
|
|
+ <TextArea :value="preferences.learningContext || ''" @update:value="preferences.learningContext = $event" :rows="2" placeholder="例如:一年级,尚不识谱,有打击乐器,需离线播放" :disabled="working" />
|
|
|
<div class="label">还有什么特别要求?<span>选填</span></div>
|
|
|
<TextArea v-model:value="extraPrompt" :rows="3" placeholder="例如:多安排节奏游戏,语言适合一年级学生" :disabled="working" />
|
|
|
</div>
|
|
|
@@ -97,6 +101,31 @@
|
|
|
</div>
|
|
|
|
|
|
<div v-else-if="workflowStep === 2 && outline" class="outline unified-review">
|
|
|
+ <details class="teaching-review" :open="!outlineConfirmed">
|
|
|
+ <summary>教材依据与教学主线</summary>
|
|
|
+ <p>{{ reliableResearch ? "已检索到匹配教材的依据,请确认本课教学范围。" : "教材尚未精确匹配,暂不能生成授课成品。请返回核对课程信息后重新检索。" }}</p>
|
|
|
+ <p>{{ outline.textbookResearch?.contentSummary }}</p>
|
|
|
+ <p v-for="item in outline.textbookResearch?.missingEvidence || []" :key="item">待核对:{{ item }}</p>
|
|
|
+ <a v-for="source in researchSources" :key="source.url" :href="source.url" target="_blank" rel="noopener noreferrer">{{ source.title || "教材来源" }}</a>
|
|
|
+ <ol><li v-for="objective in outline.textbookResearch?.teachingObjectives || []" :key="objective">{{ objective }}</li></ol>
|
|
|
+ <div v-if="!outlineConfirmed" class="outline-plan">
|
|
|
+ <label v-for="page in outline.pages" :key="page.pageNo">
|
|
|
+ <span>第{{ page.pageNo }}页 · {{ page.teachingStage }}</span>
|
|
|
+ <input v-model="page.title" aria-label="页面标题" />
|
|
|
+ <input v-model="page.teachingPurpose" aria-label="教学目的" placeholder="本页教学目的" />
|
|
|
+ </label>
|
|
|
+ </div>
|
|
|
+ </details>
|
|
|
+ <section v-if="displayedConverted && draftQuality" class="quality-panel" aria-live="polite">
|
|
|
+ <strong>{{ draftQuality.passed ? "结构与资源检查通过 · 请试讲确认" : `待修正 ${draftQuality.errors.length} 项,暂不能作为授课成品应用` }}</strong>
|
|
|
+ <span>必讲 {{ plannedMinutes }} / {{ preferences.lessonMinutes }} 分钟</span>
|
|
|
+ <details v-if="draftQuality.issues.length" :open="!draftQuality.passed">
|
|
|
+ <summary>查看检查结果(规则检查,不代表教学准确率)</summary>
|
|
|
+ <button v-for="(issue, index) in draftQuality.issues" :key="index" type="button" @click="issue.pageNo && selectWorkspacePage(issue.pageNo - 1)">
|
|
|
+ {{ issue.severity === 'error' ? '需修正' : '建议' }} · {{ issue.pageNo ? `第${issue.pageNo}页:` : '' }}{{ issue.message }}
|
|
|
+ </button>
|
|
|
+ </details>
|
|
|
+ </section>
|
|
|
<div class="sample-stage-head">
|
|
|
<div class="sample-template-current">
|
|
|
<span>当前样式</span>
|
|
|
@@ -132,7 +161,7 @@
|
|
|
</div>
|
|
|
</div>
|
|
|
|
|
|
- <div v-if="!workspaceSlide" :class="['final-preview-placeholder', draftPreparationState]">
|
|
|
+ <div v-if="!workspaceSlide && outlineConfirmed" :class="['final-preview-placeholder', draftPreparationState]">
|
|
|
<span v-if="draftPreparationState === 'optimizing'" class="loading-dot"></span>
|
|
|
<span v-else class="placeholder-icon">!</span>
|
|
|
<strong>{{ draftPreparationState === "failed" ? "课件生成失败" : generationStatusTitle }}</strong>
|
|
|
@@ -181,6 +210,7 @@
|
|
|
</div>
|
|
|
<div v-if="sampleBackgroundState.status === 'error'" class="template-load-error">模板背景加载失败,请更换模板或稍后重试</div>
|
|
|
<div class="sample-feedback workspace-feedback">
|
|
|
+ <label><input v-model="editTeachingContent" type="checkbox" :disabled="working" />同时修改本页教学内容(保留其他页面)</label>
|
|
|
<div class="feedback-label">图片或版式需要调整?</div>
|
|
|
<div class="feedback-chips">
|
|
|
<button
|
|
|
@@ -199,6 +229,16 @@
|
|
|
<Button @click="adjustCurrentPage" :disabled="working">调整当前页</Button>
|
|
|
</div>
|
|
|
</div>
|
|
|
+ <details class="teaching-review" v-if="workspacePage">
|
|
|
+ <summary>教师备注、用时与课堂资源</summary>
|
|
|
+ <label>用时(分钟)<input type="number" min="1" max="60" v-model.number="workspacePage.durationMinutes" :disabled="working" @input="refreshEditedDraft" /></label>
|
|
|
+ <label><input type="checkbox" v-model="workspacePage.optional" :disabled="working" @change="refreshEditedDraft" />选讲页,不计入必讲时长</label>
|
|
|
+ <textarea v-model="workspacePage.speakerNotes" :disabled="working" @blur="refreshEditedDraft" aria-label="教师备注"></textarea>
|
|
|
+ <p v-if="workspacePage.interaction?.answer">参考答案:{{ workspacePage.interaction.answer }} {{ workspacePage.interaction.explanation }}</p>
|
|
|
+ <label v-for="resource in availableResources" :key="resource.resourceType + resource.resourceId">
|
|
|
+ <input type="checkbox" :checked="pageHasResource(resource)" :disabled="working" @change="toggleResource(resource)" />{{ resource.resourceType }} · {{ resource.name }}
|
|
|
+ </label>
|
|
|
+ </details>
|
|
|
</section>
|
|
|
</div>
|
|
|
</div>
|
|
|
@@ -211,10 +251,13 @@
|
|
|
</template>
|
|
|
<template v-else-if="isStyleStep">
|
|
|
<Button @click="workflowStep = 1" :disabled="working">返回修改需求</Button>
|
|
|
+ <Button v-if="!outlineConfirmed" type="primary" :disabled="working || !reliableResearch" @click="confirmOutline">确认教学安排并生成</Button>
|
|
|
+ <Button v-if="displayedConverted" :disabled="working" @click="saveDraftExplicitly">保存初稿</Button>
|
|
|
+ <Button v-if="previousCourseware" :disabled="working" @click="restorePreviousCourseware">恢复应用前课件</Button>
|
|
|
<Button
|
|
|
v-if="displayedConverted && draftPreparationState === 'ready'"
|
|
|
type="primary"
|
|
|
- :disabled="sampleBackgroundState.status === 'error'"
|
|
|
+ :disabled="working || !draftQuality?.passed || sampleBackgroundState.status === 'error'"
|
|
|
@click="applyDraft"
|
|
|
>应用到当前课件</Button
|
|
|
>
|
|
|
@@ -249,6 +292,7 @@ import {
|
|
|
} from "@/api/aiCourseware"
|
|
|
import { aiCoursewareToSlides } from "@/utils/aiCoursewareToSlides"
|
|
|
import { inspectAiPptQuality, type AiPptQualityReport } from "@/utils/aiPptQuality"
|
|
|
+import { inspectAiPptResources } from "@/utils/aiPptResources"
|
|
|
import {
|
|
|
AI_COURSEWARE_TEMPLATES,
|
|
|
getAiCoursewareTemplate,
|
|
|
@@ -258,6 +302,9 @@ import {
|
|
|
} from "@/config/aiCoursewareTemplates"
|
|
|
import { getAiCoursewareTemplateMaster } from "@/config/aiCoursewareTemplateMasters"
|
|
|
import { resolveTemplateAssetUrl } from "@/utils/templateAssetUrl"
|
|
|
+import { draftKey, loadAiDraft, saveAiDraft, type AiDraftRecord, type ProtectedPageContent } from "@/utils/aiCoursewareDraft"
|
|
|
+import { usableResourceUrl } from "@/utils/aiPptContent"
|
|
|
+import type { AiCoursewareResourceBinding } from "@/api/aiCourseware"
|
|
|
import type { Slide, SlideTheme } from "@/types/slides"
|
|
|
|
|
|
const props = defineProps<{
|
|
|
@@ -274,6 +321,13 @@ const { addHistorySnapshot } = useHistorySnapshot()
|
|
|
|
|
|
const extraPrompt = ref("")
|
|
|
const outline = ref<AiCoursewareOutline>()
|
|
|
+const outlineConfirmed = ref(false)
|
|
|
+const editTeachingContent = ref(false)
|
|
|
+const availableResources = ref<AiCoursewareResourceBinding[]>([])
|
|
|
+const previousCourseware = ref<AiDraftRecord["backup"]>()
|
|
|
+const reliableResearch = computed(() => outline.value?.textbookResearch?.exactMatch === true && outline.value.textbookResearch.confidence === "HIGH" && researchSources.value.length > 0)
|
|
|
+const researchSources = computed(() => (outline.value?.textbookResearch?.sources || []).filter(source => /^https?:\/\//i.test(source.url)))
|
|
|
+const plannedMinutes = computed(() => (displayedOutline.value?.pages || []).filter(page => !page.optional).reduce((total, page) => total + (Number(page.durationMinutes) || 0), 0))
|
|
|
const taskInfo = ref<AiCoursewareTaskInfo>()
|
|
|
const workflowStep = ref<1 | 2>(1)
|
|
|
const isSetupStep = computed(() => workflowStep.value === 1)
|
|
|
@@ -313,15 +367,6 @@ const displayedTemplateId = ref<AiCoursewareTemplateId>()
|
|
|
const draftQuality = ref<AiPptQualityReport>()
|
|
|
const draftPreparationState = ref<"idle" | "optimizing" | "ready" | "failed">("idle")
|
|
|
const draftFailureMessage = ref("请稍后重试")
|
|
|
-type ProtectedPageContent = {
|
|
|
- title: string
|
|
|
- subtitle?: string
|
|
|
- bullets: string[]
|
|
|
- studentContent?: AiCoursewarePage["studentContent"]
|
|
|
- studentTask?: string
|
|
|
- learningEvidence?: string
|
|
|
- interaction?: AiCoursewarePage["interaction"]
|
|
|
-}
|
|
|
const protectedPageContent = new Map<number, ProtectedPageContent>()
|
|
|
const TEACHER_VISIBLE_PATTERN = /(?:教师|老师)(?:讲述|提问|引导|指导|组织|巡视|示范)|(?:引导|指导|组织)学生/
|
|
|
const PLACEHOLDER_PATTERN = /(?:待补充|待完善|请输入|可参考教材|placeholder|lorem ipsum|\{\{.+?\}\}|\[.+?占位.+?\])/i
|
|
|
@@ -415,6 +460,7 @@ function handleWorkspaceTextChange(payload: { elementId: string; content: string
|
|
|
protectPageContent(page)
|
|
|
restoreProtectedPageContent(outline.value)
|
|
|
restoreProtectedPageContent(draftOutline.value)
|
|
|
+ refreshEditedDraft()
|
|
|
ElMessage.success("文字已更新")
|
|
|
}
|
|
|
|
|
|
@@ -501,6 +547,7 @@ watch(
|
|
|
() => props.visible,
|
|
|
visible => {
|
|
|
if (!visible) clearTask()
|
|
|
+ else if (!outline.value) void restoreSavedDraft()
|
|
|
}
|
|
|
)
|
|
|
|
|
|
@@ -535,9 +582,10 @@ async function handleOutline() {
|
|
|
selectedDraftPageIndex.value = 0
|
|
|
sampleFeedbackTags.value = []
|
|
|
sampleFeedbackText.value = ""
|
|
|
- draftPreparationState.value = "optimizing"
|
|
|
+ outlineConfirmed.value = false
|
|
|
+ draftPreparationState.value = "idle"
|
|
|
workflowStep.value = 2
|
|
|
- await confirmSamplesAndGenerate()
|
|
|
+ await persistDraft()
|
|
|
}
|
|
|
}
|
|
|
|
|
|
@@ -547,8 +595,7 @@ async function continueToFinalPreview() {
|
|
|
draftPreparationState.value = "ready"
|
|
|
return
|
|
|
}
|
|
|
- draftPreparationState.value = "optimizing"
|
|
|
- await confirmSamplesAndGenerate()
|
|
|
+ if (outlineConfirmed.value) await confirmSamplesAndGenerate()
|
|
|
}
|
|
|
|
|
|
async function regenerateFinalPreview() {
|
|
|
@@ -575,7 +622,7 @@ function handleFinalPreparationFailure(message = "自动生成和优化未能完
|
|
|
selectedTemplateId.value = displayedTemplateId.value
|
|
|
draftOutline.value = cloneCoursewareData(displayedOutline.value)
|
|
|
draftConverted.value = cloneCoursewareData(displayedConverted.value)
|
|
|
- draftQuality.value = undefined
|
|
|
+ draftQuality.value = inspectAiPptQuality(displayedOutline.value, displayedConverted.value.slides, preferences)
|
|
|
draftPreparationState.value = "ready"
|
|
|
ElMessage.warning(`${message},已保留上一次最终预览`)
|
|
|
return
|
|
|
@@ -613,19 +660,19 @@ async function handleGenerate() {
|
|
|
}
|
|
|
|
|
|
taskInfo.value = res.data as AiCoursewareTaskInfo
|
|
|
+ await persistDraft()
|
|
|
await pollTask(taskInfo.value.taskId)
|
|
|
}
|
|
|
|
|
|
async function pollTask(taskId: string) {
|
|
|
if (pollTimer.value) window.clearInterval(pollTimer.value)
|
|
|
await fetchTask(taskId)
|
|
|
- if (taskInfo.value?.status === "PENDING" || taskInfo.value?.status === "RUNNING") {
|
|
|
- pollTimer.value = window.setInterval(() => fetchTask(taskId), 2000)
|
|
|
- }
|
|
|
}
|
|
|
|
|
|
async function fetchTask(taskId: string) {
|
|
|
+ try {
|
|
|
const res = await httpAjaxErrMsg(aiCoursewareTaskApi, taskId)
|
|
|
+ if (!props.visible || taskInfo.value?.taskId !== taskId) return
|
|
|
if (res.code !== 200) {
|
|
|
clearTask()
|
|
|
handleFinalPreparationFailure(res.message || "未能取得课件生成进度,请重试")
|
|
|
@@ -642,6 +689,12 @@ async function fetchTask(taskId: string) {
|
|
|
console.error("AI课件生成失败", message)
|
|
|
handleFinalPreparationFailure(message)
|
|
|
}
|
|
|
+ else pollTimer.value = window.setTimeout(() => fetchTask(taskId), 2000)
|
|
|
+ } catch {
|
|
|
+ if (!props.visible || taskInfo.value?.taskId !== taskId) return
|
|
|
+ clearPollTimer()
|
|
|
+ handleFinalPreparationFailure("生成进度读取失败,可重新打开窗口恢复任务")
|
|
|
+ }
|
|
|
}
|
|
|
|
|
|
function clearTask() {
|
|
|
@@ -671,6 +724,7 @@ async function loadDraft(taskId: string) {
|
|
|
return
|
|
|
}
|
|
|
draftOutline.value = result.outline
|
|
|
+ availableResources.value = Array.isArray(result.context?.resources) ? result.context.resources : []
|
|
|
restoreProtectedPageContent(draftOutline.value)
|
|
|
draftOutline.value.templateId = selectedTemplateId.value
|
|
|
selectedDraftPageIndex.value = 0
|
|
|
@@ -697,6 +751,8 @@ function rebuildDraftPreview() {
|
|
|
|
|
|
async function regenerateDraftPage(index: number) {
|
|
|
if (!draftOutline.value || !pptWork.id) return
|
|
|
+ if (editTeachingContent.value) protectedPageContent.delete(index + 1)
|
|
|
+ else if (displayedOutline.value?.pages[index]) protectPageContent(displayedOutline.value.pages[index])
|
|
|
draftPreparationState.value = "optimizing"
|
|
|
draftFailureMessage.value = "请稍后重试"
|
|
|
restoreProtectedPageContent(draftOutline.value)
|
|
|
@@ -707,7 +763,7 @@ async function regenerateDraftPage(index: number) {
|
|
|
fromType: queryParams.pptResourcesType || queryParams.fromType,
|
|
|
outline: draftOutline.value,
|
|
|
templateId: selectedTemplateId.value,
|
|
|
- visualPrompt: `${buildVisualPrompt()}请只调整第${index + 1}页的图片和版式,保持页面文字完全不变。${feedback ? `用户反馈:${feedback}。` : "请优化视觉组织和信息层级。"}`,
|
|
|
+ visualPrompt: `${buildVisualPrompt()}请只调整第${index + 1}页。${editTeachingContent.value ? "允许按反馈修改本页教学内容,保持整课目标不变。" : "只修改图片和版式,保持页面文字完全不变。"}${feedback ? `用户反馈:${feedback}。` : "请优化视觉组织和信息层级。"}`,
|
|
|
preferences: { ...preferences },
|
|
|
regeneratePageNo: index + 1
|
|
|
})
|
|
|
@@ -716,11 +772,22 @@ async function regenerateDraftPage(index: number) {
|
|
|
return
|
|
|
}
|
|
|
taskInfo.value = res.data as AiCoursewareTaskInfo
|
|
|
+ await persistDraft()
|
|
|
await pollTask(taskInfo.value.taskId)
|
|
|
}
|
|
|
|
|
|
-function applyDraft() {
|
|
|
- if (!displayedConverted.value || draftPreparationState.value !== "ready") return
|
|
|
+async function applyDraft() {
|
|
|
+ if (!displayedConverted.value || !displayedOutline.value || working.value) return
|
|
|
+ draftPreparationState.value = "optimizing"
|
|
|
+ const resourceIssues = await inspectAiPptResources(displayedOutline.value, displayedConverted.value.slides)
|
|
|
+ draftQuality.value = inspectAiPptQuality(displayedOutline.value, displayedConverted.value.slides, preferences)
|
|
|
+ appendResourceIssues(resourceIssues)
|
|
|
+ draftPreparationState.value = "ready"
|
|
|
+ if (!draftQuality.value.passed) return
|
|
|
+ previousCourseware.value = cloneCoursewareData({ title: slidesStore.title, theme: slidesStore.theme, slides: slidesStore.slides, width: slidesStore.viewportSize, ratio: slidesStore.viewportRatio })
|
|
|
+ if (!await persistDraft()) return
|
|
|
+ slidesStore.setViewportSize(1920)
|
|
|
+ slidesStore.setViewportRatio(0.5625)
|
|
|
slidesStore.setTitle(displayedConverted.value.title)
|
|
|
slidesStore.setTheme(displayedConverted.value.theme)
|
|
|
slidesStore.setSlides(displayedConverted.value.slides)
|
|
|
@@ -786,13 +853,19 @@ async function selectTemplate(templateId: AiCoursewareTemplateId, fromSample = f
|
|
|
templatePickerExpanded.value = false
|
|
|
showAllTemplates.value = false
|
|
|
}
|
|
|
- await regenerateFinalPreview()
|
|
|
+ if (displayedOutline.value) {
|
|
|
+ draftOutline.value = cloneCoursewareData(displayedOutline.value)
|
|
|
+ await prepareDraftForUse()
|
|
|
+ }
|
|
|
}
|
|
|
|
|
|
async function useRecommendedTemplate() {
|
|
|
templateSelectionMode.value = "auto"
|
|
|
selectedTemplateId.value = templateRecommendation.value.id
|
|
|
- await regenerateFinalPreview()
|
|
|
+ if (displayedOutline.value) {
|
|
|
+ draftOutline.value = cloneCoursewareData(displayedOutline.value)
|
|
|
+ await prepareDraftForUse()
|
|
|
+ }
|
|
|
}
|
|
|
|
|
|
function templateCoverStyle(templateId: AiCoursewareTemplateId): Record<string, string> {
|
|
|
@@ -807,8 +880,9 @@ function preloadImage(url: string) {
|
|
|
if (!url || typeof Image === "undefined") return Promise.resolve()
|
|
|
return new Promise<void>((resolve, reject) => {
|
|
|
const image = new Image()
|
|
|
- image.onload = () => resolve()
|
|
|
- image.onerror = () => reject(new Error(`failed to load ${url}`))
|
|
|
+ const timer = window.setTimeout(() => reject(new Error("图片加载超时")), 15000)
|
|
|
+ image.onload = () => { window.clearTimeout(timer); resolve() }
|
|
|
+ image.onerror = () => { window.clearTimeout(timer); reject(new Error(`failed to load ${url}`)) }
|
|
|
image.src = url
|
|
|
})
|
|
|
}
|
|
|
@@ -830,12 +904,12 @@ async function prepareDraftForUse() {
|
|
|
handleFinalPreparationFailure("课件页面数据不完整,请重新生成")
|
|
|
return
|
|
|
}
|
|
|
- if (draftQuality.value?.errors.length) {
|
|
|
- console.warn("AI课件质量检查发现非阻断问题", draftQuality.value.errors)
|
|
|
- }
|
|
|
publishFinalPreview()
|
|
|
+ const resourceIssues = await inspectAiPptResources(displayedOutline.value!, displayedConverted.value!.slides)
|
|
|
+ appendResourceIssues(resourceIssues)
|
|
|
draftFailureMessage.value = "请稍后重试"
|
|
|
draftPreparationState.value = "ready"
|
|
|
+ await persistDraft()
|
|
|
} catch (error) {
|
|
|
console.error("AI课件预览转换失败", error)
|
|
|
handleFinalPreparationFailure(error instanceof Error ? error.message : "课件预览转换失败,请重试")
|
|
|
@@ -844,7 +918,6 @@ async function prepareDraftForUse() {
|
|
|
|
|
|
function normalizeDraftLocally() {
|
|
|
if (!draftOutline.value) return
|
|
|
- const imageUseCounts = new Map<string, number>()
|
|
|
draftOutline.value.pages.forEach((page, index) => {
|
|
|
if (!page.visualPlan) page.visualPlan = {}
|
|
|
if (!page.studentContent) page.studentContent = { headline: "", points: [] }
|
|
|
@@ -890,14 +963,7 @@ function normalizeDraftLocally() {
|
|
|
page.visualPlan.mainVisual ||= page.imageKeywords?.[0] || `${page.title}的课堂主视觉`
|
|
|
}
|
|
|
|
|
|
- if (page.image?.url) {
|
|
|
- const useCount = (imageUseCounts.get(page.image.url) || 0) + 1
|
|
|
- imageUseCounts.set(page.image.url, useCount)
|
|
|
- if (useCount > 2) {
|
|
|
- page.image = undefined
|
|
|
- page.resourceBindings = (page.resourceBindings || []).filter(binding => binding.resourceType !== "IMG")
|
|
|
- }
|
|
|
- }
|
|
|
+ // 同一观察对象可以跨页复用;重复图只给出建议,不擅自删除教学资源。
|
|
|
|
|
|
if (index > 0) {
|
|
|
const fallbackInstruction = `引导学生完成“${page.classroomAction.studentTask || page.title}”,根据学生的语言、动作或声音表现及时反馈。`
|
|
|
@@ -940,6 +1006,114 @@ function buildExtraPrompt() {
|
|
|
return extraPrompt.value.trim()
|
|
|
}
|
|
|
|
|
|
+async function confirmOutline() {
|
|
|
+ if (!reliableResearch.value || working.value) return
|
|
|
+ outlineConfirmed.value = true
|
|
|
+ await confirmSamplesAndGenerate()
|
|
|
+}
|
|
|
+
|
|
|
+async function refreshEditedDraft() {
|
|
|
+ if (!displayedOutline.value || working.value) return
|
|
|
+ draftOutline.value = cloneCoursewareData(displayedOutline.value)
|
|
|
+ await prepareDraftForUse()
|
|
|
+}
|
|
|
+
|
|
|
+function appendResourceIssues(issues: Awaited<ReturnType<typeof inspectAiPptResources>>) {
|
|
|
+ if (!draftQuality.value) return
|
|
|
+ draftQuality.value.issues.push(...issues)
|
|
|
+ draftQuality.value.errors.push(...issues)
|
|
|
+ draftQuality.value.passed = draftQuality.value.errors.length === 0
|
|
|
+}
|
|
|
+
|
|
|
+function pageHasResource(resource: AiCoursewareResourceBinding) {
|
|
|
+ return workspacePage.value?.resourceBindings?.some(item => item.resourceType === resource.resourceType && item.resourceId === resource.resourceId) || false
|
|
|
+}
|
|
|
+
|
|
|
+function toggleResource(resource: AiCoursewareResourceBinding) {
|
|
|
+ const page = workspacePage.value
|
|
|
+ if (!page || working.value) return
|
|
|
+ const bindings = (page.resourceBindings || []).filter(item => item.resourceType !== resource.resourceType || item.resourceId !== resource.resourceId)
|
|
|
+ if (!pageHasResource(resource)) bindings.push({ ...resource, verified: true, required: true, purpose: "教师为本页选择的教学资源" })
|
|
|
+ page.resourceBindings = bindings
|
|
|
+ if (resource.resourceType === "IMG") {
|
|
|
+ const image = bindings.find(item => item.resourceType === "IMG")
|
|
|
+ page.image = image ? { url: image.contentUrl || "", keyword: image.name || "", provider: "SYSTEM" } : undefined
|
|
|
+ }
|
|
|
+ refreshEditedDraft()
|
|
|
+}
|
|
|
+
|
|
|
+let saveQueue: Promise<unknown> = Promise.resolve()
|
|
|
+async function persistDraft() {
|
|
|
+ if (!pptWork.id) return false
|
|
|
+ const snapshot = cloneCoursewareData({ outline: displayedOutline.value || draftOutline.value || outline.value,
|
|
|
+ converted: displayedConverted.value, preferences: { ...preferences }, extraPrompt: extraPrompt.value,
|
|
|
+ taskId: taskInfo.value?.taskId, resources: availableResources.value, backup: previousCourseware.value,
|
|
|
+ protectedContent: Array.from(protectedPageContent.entries()) })
|
|
|
+ const id = String(pptWork.id)
|
|
|
+ const source = queryParams.pptResourcesType || queryParams.fromType || "TEACHER"
|
|
|
+ try {
|
|
|
+ const key = await draftKey(id, source)
|
|
|
+ saveQueue = saveQueue.catch(() => undefined).then(() => saveAiDraft({ ...snapshot, key, updatedAt: Date.now() }))
|
|
|
+ await saveQueue
|
|
|
+ return true
|
|
|
+ } catch {
|
|
|
+ ElMessage.warning("本机草稿保存失败,请勿刷新页面,并及时手动保存课件")
|
|
|
+ return false
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+async function saveDraftExplicitly() {
|
|
|
+ if (await persistDraft()) ElMessage.success("初稿已保存在当前登录会话的本机草稿中,尚未标记为可授课")
|
|
|
+}
|
|
|
+
|
|
|
+async function restoreSavedDraft() {
|
|
|
+ if (!pptWork.id) return
|
|
|
+ const id = String(pptWork.id)
|
|
|
+ try {
|
|
|
+ const saved = await loadAiDraft(await draftKey(id, queryParams.pptResourcesType || queryParams.fromType || "TEACHER"))
|
|
|
+ if (!saved?.outline || outline.value || !props.visible || String(pptWork.id) !== id) return
|
|
|
+ Object.assign(preferences, saved.preferences)
|
|
|
+ pageRange.value = `${preferences.minPages}-${preferences.maxPages}`
|
|
|
+ extraPrompt.value = saved.extraPrompt
|
|
|
+ availableResources.value = saved.resources || []
|
|
|
+ protectedPageContent.clear()
|
|
|
+ for (const [pageNo, content] of saved.protectedContent || []) protectedPageContent.set(pageNo, content)
|
|
|
+ previousCourseware.value = saved.backup
|
|
|
+ outline.value = saved.outline
|
|
|
+ selectedTemplateId.value = getAiCoursewareTemplate(saved.outline.templateId).id
|
|
|
+ draftOutline.value = cloneCoursewareData(saved.outline)
|
|
|
+ workflowStep.value = 2
|
|
|
+ outlineConfirmed.value = Boolean(saved.converted || saved.taskId)
|
|
|
+ if (saved.converted) {
|
|
|
+ displayedOutline.value = cloneCoursewareData(saved.outline)
|
|
|
+ displayedConverted.value = saved.converted
|
|
|
+ displayedTemplateId.value = selectedTemplateId.value
|
|
|
+ draftConverted.value = cloneCoursewareData(saved.converted)
|
|
|
+ draftQuality.value = inspectAiPptQuality(saved.outline, saved.converted.slides, preferences)
|
|
|
+ draftPreparationState.value = "ready"
|
|
|
+ }
|
|
|
+ if (saved.taskId) {
|
|
|
+ taskInfo.value = { taskId: saved.taskId, status: "RUNNING", progress: 0, message: "恢复生成进度" }
|
|
|
+ draftPreparationState.value = "optimizing"
|
|
|
+ await pollTask(saved.taskId)
|
|
|
+ }
|
|
|
+ ElMessage.success("已恢复本机AI课件草稿")
|
|
|
+ } catch { ElMessage.warning("无法读取本机草稿,可重新生成") }
|
|
|
+}
|
|
|
+
|
|
|
+function restorePreviousCourseware() {
|
|
|
+ const backup = previousCourseware.value
|
|
|
+ if (!backup || working.value) return
|
|
|
+ slidesStore.setTitle(backup.title)
|
|
|
+ slidesStore.setTheme(backup.theme)
|
|
|
+ slidesStore.setViewportSize(backup.width)
|
|
|
+ slidesStore.setViewportRatio(backup.ratio)
|
|
|
+ slidesStore.setSlides(cloneCoursewareData(backup.slides))
|
|
|
+ slidesStore.updateSlideIndex(0)
|
|
|
+ addHistorySnapshot()
|
|
|
+ ElMessage.success("已恢复应用前的课件,请手动保存")
|
|
|
+}
|
|
|
+
|
|
|
function buildVisualPrompt() {
|
|
|
const base = selectedTemplate.value.visualPrompt
|
|
|
const system = `模板布局族:${selectedTemplate.value.layoutFamily}。字体体系:${selectedTemplate.value.typography}。背景体系:${selectedTemplate.value.backgroundStyle}。素材画风:${selectedTemplate.value.illustrationStyle}。`
|
|
|
@@ -983,6 +1157,24 @@ function shortProgressLabel(message?: string) {
|
|
|
</script>
|
|
|
|
|
|
<style lang="scss" scoped>
|
|
|
+.teaching-review, .quality-panel {
|
|
|
+ margin: 10px 0;
|
|
|
+ padding: 12px 16px;
|
|
|
+ border: 1px solid #cbd5df;
|
|
|
+ background: #f8fafc;
|
|
|
+ border-radius: 6px;
|
|
|
+ line-height: 1.6;
|
|
|
+ max-height: 260px;
|
|
|
+ overflow: auto;
|
|
|
+ flex-shrink: 0;
|
|
|
+ summary { cursor: pointer; font-weight: 600; }
|
|
|
+ label, a { display: block; margin: 6px 0; }
|
|
|
+ input:not([type="checkbox"]), textarea { border: 1px solid #aebdca; padding: 6px; max-width: 100%; }
|
|
|
+ textarea { width: 95%; min-height: 70px; }
|
|
|
+ button { display: block; padding: 4px 0; background: transparent; text-align: left; color: #8b3419; cursor: pointer; }
|
|
|
+ span { margin-left: 12px; }
|
|
|
+}
|
|
|
+.outline-plan label { display: grid; grid-template-columns: 130px 1fr 2fr; gap: 8px; }
|
|
|
.ai-dialog {
|
|
|
max-height: calc(100vh - 64px);
|
|
|
display: flex;
|