|
@@ -72,101 +72,79 @@
|
|
|
<section v-else-if="step === 2 && outline" class="outline-stage">
|
|
<section v-else-if="step === 2 && outline" class="outline-stage">
|
|
|
<div class="section-heading">
|
|
<div class="section-heading">
|
|
|
<div>
|
|
<div>
|
|
|
- <h3>确认教学大纲</h3>
|
|
|
|
|
- <p>先看整节课的安排,点击页面展开编辑。确认后再生成课件。</p>
|
|
|
|
|
|
|
+ <h3>确认教学安排</h3>
|
|
|
|
|
+ <p>看看主题、活动和顺序是否合适。需要调整时点击某页,具体文字和排版在生成后直接编辑。</p>
|
|
|
</div>
|
|
</div>
|
|
|
- <div class="time">{{ pages.length }} 页 · 必讲 {{ totalMinutes }} / {{ preferences.lessonMinutes }} 分钟</div>
|
|
|
|
|
|
|
+ <span class="page-count">共 {{ pages.length }} 页</span>
|
|
|
</div>
|
|
</div>
|
|
|
|
|
+ <p v-if="requirementsSummary" class="requirements-summary">本次要求:{{ requirementsSummary }}</p>
|
|
|
<ul v-if="validationErrors.length" class="error validation" role="alert">
|
|
<ul v-if="validationErrors.length" class="error validation" role="alert">
|
|
|
<li v-for="message in validationErrors" :key="message">{{ message }}</li>
|
|
<li v-for="message in validationErrors" :key="message">{{ message }}</li>
|
|
|
</ul>
|
|
</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"
|
|
|
|
|
|
|
+ <p v-if="!reliableResearch" class="error">尚未核实教材内容,请返回补充教学范围后重新生成大纲。</p>
|
|
|
|
|
+ <div class="outline-list">
|
|
|
|
|
+ <Draggable v-model="pages" item-key="key" handle=".drag-handle" :animation="160" :move="canMove" @end="remember"
|
|
|
|
|
+ ><template #item="{ element, index }"
|
|
|
|
|
+ ><article class="page-card" :class="{ expanded: expandedPage === element.key }">
|
|
|
|
|
+ <div class="page-heading">
|
|
|
|
|
+ <button
|
|
|
|
|
+ class="page-toggle"
|
|
|
|
|
+ type="button"
|
|
|
|
|
+ :aria-expanded="expandedPage === element.key"
|
|
|
|
|
+ :aria-label="`编辑第${index + 1}页:${element.page.title || '未填写标题'}`"
|
|
|
|
|
+ @click="expandedPage = expandedPage === element.key ? '' : element.key"
|
|
|
|
|
+ >
|
|
|
|
|
+ <span class="page-number">{{ index + 1 }}</span
|
|
|
|
|
+ ><strong>{{ element.page.title || "未填写标题" }}</strong
|
|
|
|
|
+ ><span>{{ expandedPage === element.key ? "⌃" : "⌄" }}</span>
|
|
|
|
|
+ </button>
|
|
|
|
|
+ <div v-if="index > 0" class="page-actions">
|
|
|
|
|
+ <button class="drag-handle" type="button" aria-label="拖动排序">⠿</button
|
|
|
|
|
+ ><button type="button" :disabled="index === 1" :aria-label="`上移第${index + 1}页`" @click="movePage(index, -1)">↑</button
|
|
|
|
|
+ ><button type="button" :disabled="index === pages.length - 1" :aria-label="`下移第${index + 1}页`" @click="movePage(index, 1)">
|
|
|
|
|
+ ↓</button
|
|
|
|
|
+ ><button
|
|
|
type="button"
|
|
type="button"
|
|
|
- :aria-expanded="expandedPage === element.key"
|
|
|
|
|
- :aria-label="`编辑第${index + 1}页:${element.page.title || '未填写标题'}`"
|
|
|
|
|
- @click="expandedPage = expandedPage === element.key ? '' : element.key"
|
|
|
|
|
|
|
+ :disabled="pages.length <= MIN_OUTLINE_PAGES"
|
|
|
|
|
+ :aria-label="`删除第${index + 1}页`"
|
|
|
|
|
+ @click="removePage(index)"
|
|
|
>
|
|
>
|
|
|
- <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>
|
|
</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>
|
|
</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>
|
|
|
|
|
+ <p v-if="expandedPage !== element.key" class="page-summary">
|
|
|
|
|
+ {{ element.brief || "点击页面,简要说明准备讲什么或开展什么活动" }}
|
|
|
|
|
+ </p>
|
|
|
|
|
+ <div v-else class="page-edit">
|
|
|
|
|
+ <label>页面标题<input v-model="element.page.title" @change="remember" /></label>
|
|
|
|
|
+ <label
|
|
|
|
|
+ >教学安排<textarea v-model="element.brief" rows="2" placeholder="例如:听两组声音,辨别强弱,再用拍手模仿" @change="remember" />
|
|
|
|
|
+ </label>
|
|
|
|
|
+ </div></article></template></Draggable
|
|
|
|
|
+ ><button class="add-page" type="button" :disabled="pages.length >= MAX_OUTLINE_PAGES" @click="addPage">+ 添加一页</button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="outline-options">
|
|
|
|
|
+ <span>课件样式:{{ getAiCoursewareTemplate(templateId).name }}</span
|
|
|
|
|
+ ><button type="button" @click="showTemplates = !showTemplates">{{ showTemplates ? "收起模板" : "更换模板" }}</button>
|
|
|
|
|
+ <details>
|
|
|
|
|
+ <summary>教材依据</summary>
|
|
|
|
|
+ <p>{{ outline.textbookResearch?.contentSummary }}</p>
|
|
|
|
|
+ <a v-for="source in sources" :key="source.url" :href="source.url" target="_blank" rel="noopener noreferrer">{{
|
|
|
|
|
+ source.title || "教材来源"
|
|
|
|
|
+ }}</a>
|
|
|
|
|
+ </details>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div v-if="showTemplates" class="template-options">
|
|
|
|
|
+ <button
|
|
|
|
|
+ v-for="template in AI_COURSEWARE_TEMPLATES"
|
|
|
|
|
+ :key="template.id"
|
|
|
|
|
+ :class="{ selected: templateId === template.id }"
|
|
|
|
|
+ :aria-pressed="templateId === template.id"
|
|
|
|
|
+ @click="selectTemplate(template.id)"
|
|
|
|
|
+ >
|
|
|
|
|
+ <img :src="templatePreview(template.id)" :alt="template.name" /><span>{{ template.name }}</span>
|
|
|
|
|
+ </button>
|
|
|
</div>
|
|
</div>
|
|
|
</section>
|
|
</section>
|
|
|
<section v-else class="generation" aria-live="polite">
|
|
<section v-else class="generation" aria-live="polite">
|
|
@@ -267,8 +245,7 @@ const templatePreview = (id: string) => templateAssetPath(id, getAiCoursewareTem
|
|
|
const steps = ["设置需求", "确认大纲", "生成课件", "编辑课件", "保存课件"]
|
|
const steps = ["设置需求", "确认大纲", "生成课件", "编辑课件", "保存课件"]
|
|
|
const step = ref(1),
|
|
const step = ref(1),
|
|
|
outline = ref<AiCoursewareOutline>(),
|
|
outline = ref<AiCoursewareOutline>(),
|
|
|
- pages = ref<EditableOutlinePage[]>([]),
|
|
|
|
|
- goalsText = ref("")
|
|
|
|
|
|
|
+ pages = ref<EditableOutlinePage[]>([])
|
|
|
const extraPrompt = ref(""),
|
|
const extraPrompt = ref(""),
|
|
|
pageRange = ref("9-12"),
|
|
pageRange = ref("9-12"),
|
|
|
templateId = ref("")
|
|
templateId = ref("")
|
|
@@ -290,14 +267,12 @@ const sources = computed(() => (outline.value?.textbookResearch?.sources || []).
|
|
|
const reliableResearch = computed(
|
|
const reliableResearch = computed(
|
|
|
() => outline.value?.textbookResearch?.exactMatch && outline.value.textbookResearch.confidence === "HIGH" && sources.value.length > 0
|
|
() => 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 requirementsSummary = computed(() => [preferences.lessonScope, preferences.learningContext, extraPrompt.value].filter(Boolean).join(";"))
|
|
|
const editorSnapshot = () =>
|
|
const editorSnapshot = () =>
|
|
|
JSON.stringify([slidesStore.title, slidesStore.slides, slidesStore.theme, slidesStore.viewportSize, slidesStore.viewportRatio])
|
|
JSON.stringify([slidesStore.title, slidesStore.slides, slidesStore.theme, slidesStore.viewportSize, slidesStore.viewportRatio])
|
|
|
const currentPlan = () => {
|
|
const currentPlan = () => {
|
|
|
if (!outline.value) return undefined
|
|
if (!outline.value) return undefined
|
|
|
- const plan = confirmedCoursewareOutline(outline.value, goalsText.value, pages.value)
|
|
|
|
|
|
|
+ const plan = confirmedCoursewareOutline(outline.value, pages.value)
|
|
|
plan.templateId = templateId.value
|
|
plan.templateId = templateId.value
|
|
|
return plan
|
|
return plan
|
|
|
}
|
|
}
|
|
@@ -329,7 +304,6 @@ watch(
|
|
|
confirmed.value = undefined
|
|
confirmed.value = undefined
|
|
|
outline.value = undefined
|
|
outline.value = undefined
|
|
|
pages.value = []
|
|
pages.value = []
|
|
|
- goalsText.value = ""
|
|
|
|
|
extraPrompt.value = ""
|
|
extraPrompt.value = ""
|
|
|
Object.assign(preferences, defaults())
|
|
Object.assign(preferences, defaults())
|
|
|
pageRange.value = "9-12"
|
|
pageRange.value = "9-12"
|
|
@@ -357,7 +331,15 @@ function remember() {
|
|
|
if (!id) return
|
|
if (!id) return
|
|
|
saveQueue = saveQueue
|
|
saveQueue = saveQueue
|
|
|
.then(async () => {
|
|
.then(async () => {
|
|
|
- await saveAiDraft({ key: await draftKey(id, from), updatedAt: Date.now(), outline: plan, preferences: prefs, extraPrompt: prompt, taskId, outlineSettings: savedSettings })
|
|
|
|
|
|
|
+ await saveAiDraft({
|
|
|
|
|
+ key: await draftKey(id, from),
|
|
|
|
|
+ updatedAt: Date.now(),
|
|
|
|
|
+ outline: plan,
|
|
|
|
|
+ preferences: prefs,
|
|
|
|
|
+ extraPrompt: prompt,
|
|
|
|
|
+ taskId,
|
|
|
|
|
+ outlineSettings: savedSettings
|
|
|
|
|
+ })
|
|
|
})
|
|
})
|
|
|
.catch(error => {
|
|
.catch(error => {
|
|
|
console.warn("AI courseware local state unavailable", error)
|
|
console.warn("AI courseware local state unavailable", error)
|
|
@@ -401,20 +383,7 @@ async function restore() {
|
|
|
function loadOutline(value: AiCoursewareOutline) {
|
|
function loadOutline(value: AiCoursewareOutline) {
|
|
|
outline.value = clone(value)
|
|
outline.value = clone(value)
|
|
|
pages.value = editableOutlinePages(value)
|
|
pages.value = editableOutlinePages(value)
|
|
|
- goalsText.value = value.textbookResearch?.teachingObjectives?.join("\n") || ""
|
|
|
|
|
templateId.value = value.templateId || recommendAiCoursewareTemplate(value).id
|
|
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 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--
|
|
|
|
|
- }
|
|
|
|
|
- })
|
|
|
|
|
validationErrors.value = []
|
|
validationErrors.value = []
|
|
|
expandedPage.value = ""
|
|
expandedPage.value = ""
|
|
|
outlineSettings.value = settingsSignature()
|
|
outlineSettings.value = settingsSignature()
|
|
@@ -471,11 +440,9 @@ function addPage() {
|
|
|
title: "",
|
|
title: "",
|
|
|
bullets: [],
|
|
bullets: [],
|
|
|
imageKeywords: [],
|
|
imageKeywords: [],
|
|
|
- teachingStage: "活动练习",
|
|
|
|
|
- durationMinutes: 3,
|
|
|
|
|
- optional: false
|
|
|
|
|
|
|
+ teachingStage: "活动练习"
|
|
|
},
|
|
},
|
|
|
- pointsText: ""
|
|
|
|
|
|
|
+ brief: ""
|
|
|
})
|
|
})
|
|
|
expandedPage.value = pages.value[pages.value.length - 1].key
|
|
expandedPage.value = pages.value[pages.value.length - 1].key
|
|
|
}
|
|
}
|
|
@@ -487,7 +454,7 @@ function removePage(index: number) {
|
|
|
async function confirmAndGenerate() {
|
|
async function confirmAndGenerate() {
|
|
|
const plan = currentPlan()
|
|
const plan = currentPlan()
|
|
|
if (!plan || !reliableResearch.value || transferring.value) return
|
|
if (!plan || !reliableResearch.value || transferring.value) return
|
|
|
- validationErrors.value = inspectConfirmedOutline(plan, preferences.lessonMinutes)
|
|
|
|
|
|
|
+ validationErrors.value = inspectConfirmedOutline(plan)
|
|
|
if (validationErrors.value.length) return
|
|
if (validationErrors.value.length) return
|
|
|
if (
|
|
if (
|
|
|
slidesStore.slides.some(slide => slide.elements.length) &&
|
|
slidesStore.slides.some(slide => slide.elements.length) &&
|
|
@@ -514,6 +481,7 @@ async function startGeneration() {
|
|
|
outline: plan,
|
|
outline: plan,
|
|
|
templateId: templateId.value,
|
|
templateId: templateId.value,
|
|
|
outlineConfirmed: true,
|
|
outlineConfirmed: true,
|
|
|
|
|
+ outlineMode: "teaching_plan",
|
|
|
preferences: { ...clone(preferences), minPages: plan.pages.length, maxPages: plan.pages.length }
|
|
preferences: { ...clone(preferences), minPages: plan.pages.length, maxPages: plan.pages.length }
|
|
|
})
|
|
})
|
|
|
if (requestVersion !== version) return
|
|
if (requestVersion !== version) return
|
|
@@ -1004,7 +972,7 @@ footer > div {
|
|
|
font-size: 12px;
|
|
font-size: 12px;
|
|
|
margin: 8px 0 2px 35px;
|
|
margin: 8px 0 2px 35px;
|
|
|
display: -webkit-box;
|
|
display: -webkit-box;
|
|
|
- -webkit-line-clamp: 2;
|
|
|
|
|
|
|
+ -webkit-line-clamp: 3;
|
|
|
-webkit-box-orient: vertical;
|
|
-webkit-box-orient: vertical;
|
|
|
overflow: hidden;
|
|
overflow: hidden;
|
|
|
}
|
|
}
|
|
@@ -1076,4 +1044,53 @@ footer > div {
|
|
|
display: block;
|
|
display: block;
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
+.page-count {
|
|
|
|
|
+ white-space: nowrap;
|
|
|
|
|
+ color: #7a899c;
|
|
|
|
|
+}
|
|
|
|
|
+.requirements-summary {
|
|
|
|
|
+ padding: 10px 12px;
|
|
|
|
|
+ margin: 0 0 16px;
|
|
|
|
|
+ background: #f4f7fb;
|
|
|
|
|
+ border-radius: 5px;
|
|
|
|
|
+ color: #66798f;
|
|
|
|
|
+}
|
|
|
|
|
+.outline-list {
|
|
|
|
|
+ max-width: 920px;
|
|
|
|
|
+ margin: 0 auto;
|
|
|
|
|
+}
|
|
|
|
|
+.outline-options {
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ align-items: baseline;
|
|
|
|
|
+ gap: 12px;
|
|
|
|
|
+ flex-wrap: wrap;
|
|
|
|
|
+ padding: 16px 0 4px;
|
|
|
|
|
+ color: #7b899a;
|
|
|
|
|
+ font-size: 13px;
|
|
|
|
|
+}
|
|
|
|
|
+.outline-options button {
|
|
|
|
|
+ background: none;
|
|
|
|
|
+ border: 0;
|
|
|
|
|
+ color: #1989fa;
|
|
|
|
|
+ cursor: pointer;
|
|
|
|
|
+}
|
|
|
|
|
+.outline-options details {
|
|
|
|
|
+ margin: 0 0 0 auto;
|
|
|
|
|
+ max-width: 100%;
|
|
|
|
|
+}
|
|
|
|
|
+.outline-options summary {
|
|
|
|
|
+ margin: 0;
|
|
|
|
|
+ font-size: 13px;
|
|
|
|
|
+}
|
|
|
|
|
+.outline-options a {
|
|
|
|
|
+ display: block;
|
|
|
|
|
+ color: #1989fa;
|
|
|
|
|
+ margin: 8px 0;
|
|
|
|
|
+ overflow-wrap: anywhere;
|
|
|
|
|
+}
|
|
|
|
|
+.page-summary {
|
|
|
|
|
+ margin-top: 4px;
|
|
|
|
|
+ font-size: 13px;
|
|
|
|
|
+ line-height: 1.7;
|
|
|
|
|
+}
|
|
|
</style>
|
|
</style>
|