yonge 12 hours ago
parent
commit
f9f5f45b50

+ 1 - 0
dist/assets/index--cdcFgok.js

@@ -0,0 +1 @@
+import{u as s,ag as r}from"./index-Dn8Bix9c.js";import{a5 as c,cD as n,R as p,ae as t,af as _,al as u,ag as i,bH as l}from"./index-fQW7Shzm.js";const m={class:"pptScreen"},d=c({__name:"pptScreen",setup(f){const a=n(),e=p(!0),o=s();return a.setScreenMode("pptScreen"),o.initPPTData().then(()=>{e.value=!1}),(S,k)=>(t(),_("div",m,[e.value?i("",!0):(t(),u(r,{key:0}))]))}}),g=l(d,[["__scopeId","data-v-678905e9"]]);export{g as default};

+ 1 - 1
dist/assets/index-D4K7eQU_.js → dist/assets/index-6c16Trns.js

@@ -1 +1 @@
-import{u as c,ah as l}from"./index-CZzfQySX.js";import{w as p,bA as d,a5 as g,cE as m,R as t,ae as r,af as u,al as f,ag as w,bH as k}from"./index-D5a0B_Kj.js";import"./shapes-BpYRtj2j.js";const S=()=>{function o(a){if(window.parent===window)return;const{api:e,playState:n}=a.data||{};e==="headerTogge"?window.parent.postMessage({api:"headerTogge",playState:n},"*"):e==="changeTogge"?window.parent.postMessage({api:"changeTogge"},"*"):["clickTempo","clickViewFigner"].includes(e)&&window.parent.postMessage({api:e},"*")}p(()=>{window.addEventListener("message",o)}),d(()=>{window.removeEventListener("message",o)})},_=g({__name:"mobileScreen",setup(o){S();const a=m(),e=t(!0),n=t(!1),i=c();return a.setScreenMode("mobileScreen"),i.initPPTData().then(()=>{e.value=!1}),(v,s)=>(r(),u("div",{class:"mobileScreen",onClick:s[0]||(s[0]=b=>n.value=!1)},[e.value?w("",!0):(r(),f(l,{key:0}))]))}}),E=k(_,[["__scopeId","data-v-90b2692a"]]);export{E as default};
+import{u as c,ag as l}from"./index-Dn8Bix9c.js";import{w as p,bA as d,a5 as g,cD as m,R as t,ae as r,af as u,al as f,ag as w,bH as k}from"./index-fQW7Shzm.js";import"./shapes-CvPETsg1.js";const S=()=>{function o(a){if(window.parent===window)return;const{api:e,playState:n}=a.data||{};e==="headerTogge"?window.parent.postMessage({api:"headerTogge",playState:n},"*"):e==="changeTogge"?window.parent.postMessage({api:"changeTogge"},"*"):["clickTempo","clickViewFigner"].includes(e)&&window.parent.postMessage({api:e},"*")}p(()=>{window.addEventListener("message",o)}),d(()=>{window.removeEventListener("message",o)})},_=g({__name:"mobileScreen",setup(o){S();const a=m(),e=t(!0),n=t(!1),i=c();return a.setScreenMode("mobileScreen"),i.initPPTData().then(()=>{e.value=!1}),(v,s)=>(r(),u("div",{class:"mobileScreen",onClick:s[0]||(s[0]=b=>n.value=!1)},[e.value?w("",!0):(r(),f(l,{key:0}))]))}}),P=k(_,[["__scopeId","data-v-90b2692a"]]);export{P as default};

File diff suppressed because it is too large
+ 0 - 0
dist/assets/index-B8-dprO7.js


+ 1 - 1
dist/assets/index-Dk4RqCdN.js → dist/assets/index-BQwCazVp.js

@@ -1 +1 @@
-import{_ as t}from"./404-R0y5dkvZ.js";import{E as c}from"./index-pxmtmNXm.js";import{a5 as n,ae as i,af as r,ak as o,aq as _,am as d,P as l,aS as p,bP as m,bQ as f,bH as u}from"./index-D5a0B_Kj.js";const s=a=>(m("data-v-340bbdaa"),a=a(),f(),a),h={class:"login"},b={class:"error"},g=s(()=>o("img",{src:t,class:"img",alt:""},null,-1)),k=s(()=>o("div",{class:"tit"},"登录已过期或服务器错误!",-1)),v=n({__name:"login",setup(a){function e(){window.close()}return(x,B)=>(i(),r("div",h,[o("div",b,[g,k,_(l(c),{class:"backBtn",type:"primary",plain:"",onClick:e},{default:d(()=>[p("关闭页面")]),_:1})])]))}}),C=u(v,[["__scopeId","data-v-340bbdaa"]]);export{C as default};
+import{_ as t}from"./404-R0y5dkvZ.js";import{E as c}from"./index-B8-dprO7.js";import{a5 as n,ae as i,af as r,ak as o,aq as _,am as d,P as l,aS as p,bO as m,bP as f,bH as u}from"./index-fQW7Shzm.js";const s=a=>(m("data-v-340bbdaa"),a=a(),f(),a),h={class:"login"},b={class:"error"},g=s(()=>o("img",{src:t,class:"img",alt:""},null,-1)),k=s(()=>o("div",{class:"tit"},"登录已过期或服务器错误!",-1)),v=n({__name:"login",setup(a){function e(){window.close()}return(x,B)=>(i(),r("div",h,[o("div",b,[g,k,_(l(c),{class:"backBtn",type:"primary",plain:"",onClick:e},{default:d(()=>[p("关闭页面")]),_:1})])]))}}),C=u(v,[["__scopeId","data-v-340bbdaa"]]);export{C as default};

File diff suppressed because it is too large
+ 0 - 0
dist/assets/index-BZqZX3m_.css


File diff suppressed because it is too large
+ 0 - 0
dist/assets/index-CA9N-r1G.css


+ 1 - 1
dist/assets/index-olsXM_5E.js → dist/assets/index-CBf6LX-u.js

@@ -1 +1 @@
-import{_ as a}from"./404-R0y5dkvZ.js";import{a5 as o,eo as t,ae as r,af as c,bP as _,bQ as d,ak as s,bH as p}from"./index-D5a0B_Kj.js";const i=e=>(_("data-v-dda25179"),e=e(),d(),e),n={class:"errorPage"},m=i(()=>s("div",{class:"error"},[s("img",{src:a,class:"img",alt:""}),s("div",{class:"tit"},"页面找不到了~")],-1)),l=[m],f=o({__name:"errorPage",setup(e){return t(),(u,h)=>(r(),c("div",n,l))}}),I=p(f,[["__scopeId","data-v-dda25179"]]);export{I as default};
+import{_ as a}from"./404-R0y5dkvZ.js";import{a5 as o,eo as t,ae as r,af as c,bO as _,bP as d,ak as s,bH as p}from"./index-fQW7Shzm.js";const i=e=>(_("data-v-dda25179"),e=e(),d(),e),n={class:"errorPage"},m=i(()=>s("div",{class:"error"},[s("img",{src:a,class:"img",alt:""}),s("div",{class:"tit"},"页面找不到了~")],-1)),l=[m],f=o({__name:"errorPage",setup(e){return t(),(u,h)=>(r(),c("div",n,l))}}),I=p(f,[["__scopeId","data-v-dda25179"]]);export{I as default};

File diff suppressed because it is too large
+ 0 - 0
dist/assets/index-CW9DhDbS.css


+ 0 - 1
dist/assets/index-CYLRKotL.js

@@ -1 +0,0 @@
-import{u as s,ah as r}from"./index-CZzfQySX.js";import{a5 as c,cE as n,R as p,ae as t,af as _,al as u,ag as i,bH as l}from"./index-D5a0B_Kj.js";const m={class:"pptScreen"},d=c({__name:"pptScreen",setup(f){const a=n(),e=p(!0),o=s();return a.setScreenMode("pptScreen"),o.initPPTData().then(()=>{e.value=!1}),(S,k)=>(t(),_("div",m,[e.value?i("",!0):(t(),u(r,{key:0}))]))}}),P=l(d,[["__scopeId","data-v-678905e9"]]);export{P as default};

File diff suppressed because it is too large
+ 0 - 0
dist/assets/index-DC5HW56j.css


File diff suppressed because it is too large
+ 1 - 1
dist/assets/index-Dn8Bix9c.js


File diff suppressed because it is too large
+ 0 - 0
dist/assets/index-DsJv7T-F.js


File diff suppressed because it is too large
+ 0 - 0
dist/assets/index-bnOtYJ8Q.js


File diff suppressed because it is too large
+ 1 - 1
dist/assets/index-fQW7Shzm.js


+ 1 - 1
dist/assets/shapes-BpYRtj2j.js → dist/assets/shapes-CvPETsg1.js

@@ -1,4 +1,4 @@
-import{cH as k,bB as $1}from"./index-D5a0B_Kj.js";import{ai as Z,aj as H1,ak as U,al as n0,am as c0,an as i0,ao as j1,ap as K1,aq as Y1,ar as R}from"./index-CZzfQySX.js";var z1={exports:{}};/*!
+import{cG as k,bB as $1}from"./index-fQW7Shzm.js";import{ah as Z,ai as H1,aj as U,ak as n0,al as c0,am as i0,an as j1,ao as K1,ap as Y1,aq as R}from"./index-Dn8Bix9c.js";var z1={exports:{}};/*!
  * clipboard.js v2.0.11
  * https://clipboardjs.com/
  *

+ 1 - 1
dist/index.html

@@ -10,7 +10,7 @@
     <meta name="description" content="ppt编辑器" />
     <meta name="keywords" content="ppt,powerpoint,office powerpoint,在线ppt,幻灯片,演示文稿,ppt在线制作,Vue3,TypeScript" />
     <title>PPT</title>
-    <script type="module" crossorigin src="./assets/index-D5a0B_Kj.js"></script>
+    <script type="module" crossorigin src="./assets/index-fQW7Shzm.js"></script>
     <link rel="stylesheet" crossorigin href="./assets/index-AavUEaYy.css">
   </head>
   <body>

+ 27 - 10
scripts/aiPptDialogTest.html

@@ -1,5 +1,5 @@
 <!doctype html>
-<html lang="zh-CN"><head><meta charset="UTF-8"><title>AI课件流程回归</title></head><body><div id="app"></div>
+<html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>AI课件流程回归</title></head><body><div id="app"></div>
 <script type="module">
 import { createApp, h, ref, nextTick } from "vue"
 import { createRouter, createMemoryHistory } from "vue-router"
@@ -8,6 +8,9 @@ import "/src/router/index.ts"
 import { httpAxios } from "/src/api/ApiInstance.ts"
 import { setToken } from "/src/libs/auth.ts"
 import AiDialog from "/src/views/Editor/EditorHeader/AiCoursewareDialog.vue"
+import Editor from "/src/views/Editor/index.vue"
+import usePptWork from "/src/store/pptWork.ts"
+import { useSnapshotStore } from "/src/store/index.ts"
 import { inspectAiPptResources } from "/src/utils/aiPptResources.ts"
 import { draftKey, loadAiDraft, saveAiDraft } from "/src/utils/aiCoursewareDraft.ts"
 import "/src/assets/styles/global.scss"
@@ -17,6 +20,8 @@ import "/src/assets/styles/prosemirror.scss"
 const scenario = new URLSearchParams(location.search).get("scenario") || "normal"
 setToken(`local-ai-ppt-regression-${scenario}`)
 const generated = ref(0)
+const outlineCalls = ref(0), savedCalls = ref(0), submitted = ref(null)
+let pollCount = 0
 const repairs = ref(0)
 const historicalPolls = ref(0)
 let repaired = false
@@ -43,15 +48,18 @@ if (scenario === "failed-resume") {
 }
 httpAxios.axioseRquest = async config => {
   let data
-  if (config.url.endsWith("/outline")) data = structuredClone(outline)
+  if (config.url.endsWith("/outline")) { outlineCalls.value++; data = structuredClone(outline) }
   else if (config.url.endsWith("/generate")) {
     generated.value++
+    submitted.value = JSON.parse(JSON.stringify(config.data))
+    pollCount = 0
     if (config.data.repairOnly) { repairs.value++; repaired=true }
-    data={taskId:"local-task",status:"PENDING",progress:0}
+    data={taskId:`local-task-${generated.value}`,status:"PENDING",progress:0}
   }
   else if (config.url.endsWith("/result")) {
-    data={outline:structuredClone(outline),context:{resources:[]}}
-    if (scenario === "draft" && !repaired) {
+    data={outline:JSON.parse(JSON.stringify(submitted.value?.outline || outline)),context:{resources:[]}}
+    data.outline.pages.forEach(page => page.objectiveIds=["1"])
+    if (scenario.includes("draft") && !repaired) {
       data.outline.pages.forEach(page => page.objectiveIds=[])
       data.reviewMessage="声音强弱的练习与评价尚未完整关联"
     }
@@ -60,7 +68,11 @@ httpAxios.axioseRquest = async config => {
     historicalPolls.value++
     data={taskId:"historical-failed-task",status:"FAILED",progress:50,message:"教学目标1缺少对应实践或评价任务"}
   }
-  else if (config.url.includes("/task/")) data={taskId:"local-task",status:scenario === "draft" && !repaired ? "DRAFT" : "DONE",progress:100}
+  else if (config.url.includes("/task/")) {
+    pollCount++
+    if (scenario.includes("network") && pollCount === 1) throw new Error("测试网络中断")
+    data={taskId:`local-task-${generated.value}`,status:scenario.includes("failure") && generated.value === 1 ? "FAILED" : scenario.includes("slow") && pollCount < 6 ? "RUNNING" : scenario.includes("draft") ? "DRAFT" : "DONE",progress:pollCount < 6 && scenario.includes("slow") ? 50 : 100,message:scenario.includes("failure") ? "测试:生成服务暂不可用" : "正在完善课堂任务"}
+  }
   else throw new Error("测试禁止未声明的业务请求")
   return {data:{code:200,data}}
 }
@@ -70,12 +82,17 @@ await router.isReady()
 const app=createApp({setup(){
   const visible=ref(false)
   const slidesStore=useSlidesStore()
-  nextTick(()=>{visible.value=true})
+  const pptWork=usePptWork()
+  // 仅隔离存储边界,按钮、编辑器和课件转换均使用正式组件。
+  pptWork.updatePPT=async()=>{savedCalls.value++;pptWork.isSave=true}
+  if (!scenario.startsWith("editor")) nextTick(()=>{visible.value=true})
   return ()=>h("main",[
     runtimeError.value ? h("pre", runtimeError.value) : null,
-    h("p",{id:"test-status"},`生成请求次数:${generated.value};完善请求:${repairs.value};历史失败任务查询:${historicalPolls.value};已应用页数:${slidesStore.slides.length};资源检查:${resourceCheck.value}`),
-    h("button",{onClick:()=>{visible.value=true}},"打开AI课件"),
-    h(AiDialog,{visible:visible.value,"onUpdate:visible":value=>{visible.value=value}})
+    h("p",{id:"test-status"},`大纲请求:${outlineCalls.value};生成请求次数:${generated.value};完善请求:${repairs.value};保存请求:${savedCalls.value};历史失败任务查询:${historicalPolls.value};已应用页数:${slidesStore.slides.length};资源检查:${resourceCheck.value}`),
+    submitted.value ? h("p",{id:"submitted-plan"},`提交的目标:${submitted.value.outline.textbookResearch.teachingObjectives.join("、")};提交的标题:${submitted.value.outline.pages.map(p=>p.title).join("、")};页数:${submitted.value.outline.pages.length};已确认:${submitted.value.outlineConfirmed}`):null,
+    scenario.startsWith("editor") ? h("div",{style:"height:calc(100vh - 65px)"},[h(Editor)]) : h("button",{onClick:()=>{visible.value=true}},"打开AI课件"),
+    scenario.startsWith("editor") ? null : h(AiDialog,{visible:visible.value,"onUpdate:visible":value=>{visible.value=value}}),
+    h("button",{onClick:()=>useSnapshotStore().unDo()},"测试撤销")
   ])
 }})
 app.config.errorHandler = error => { runtimeError.value=String(error?.stack || error) }

+ 31 - 0
scripts/aiPptOutlineTest.mjs

@@ -0,0 +1,31 @@
+import { createServer } from 'vite'
+import assert from 'node:assert/strict'
+
+const vite = await createServer({ server: { middlewareMode: true }, appType: 'custom', logLevel: 'error' })
+try {
+  const { editableOutlinePages, confirmedCoursewareOutline, inspectConfirmedOutline } = await vite.ssrLoadModule('/src/utils/aiCoursewareOutline.ts')
+  const outline = { title: '课堂', textbookResearch: { teachingObjectives: ['旧目标'] }, pages: Array.from({ length: 8 }, (_, i) => ({ pageNo: i + 1, type: i ? 'practice_check' : 'cover', title: `页面${i}`, durationMinutes: 5, bullets: ['旧内容'], imageKeywords: [], objectiveIds: ['1'] })) }
+  const pages = editableOutlinePages(outline)
+  pages[1].page.title = '老师修改的标题'
+  pages[1].pointsText = ' 听辨强弱\n\n用拍手表现 '
+  pages.splice(2, 0, pages.splice(1, 1)[0])
+  const confirmed = confirmedCoursewareOutline(outline, ' 能分辨声音强弱\n能用拍手表现 ', pages)
+  assert.deepEqual(inspectConfirmedOutline(confirmed, 40), [])
+  assert.deepEqual(confirmed.textbookResearch.teachingObjectives, ['能分辨声音强弱', '能用拍手表现'])
+  assert.equal(confirmed.pages[2].title, '老师修改的标题')
+  assert.deepEqual(confirmed.pages.map(p => p.pageNo), [1,2,3,4,5,6,7,8])
+  assert.deepEqual(confirmed.pages[2].studentContent.points, ['听辨强弱','用拍手表现'])
+  assert.deepEqual(confirmed.pages[2].objectiveIds, [])
+  assert.equal(outline.pages[1].title, '页面1')
+  const invalid = structuredClone(confirmed)
+  invalid.pages[1].title = ''
+  invalid.pages[2].durationMinutes = 0
+  invalid.textbookResearch.teachingObjectives = []
+  assert.ok(inspectConfirmedOutline(invalid, 40).some(s => s.includes('教学目标')))
+  assert.ok(inspectConfirmedOutline(invalid, 40).some(s => s.includes('第2页标题')))
+  assert.ok(inspectConfirmedOutline(invalid, 40).some(s => s.includes('第3页用时')))
+  const optional = structuredClone(confirmed)
+  optional.pages.push({ ...optional.pages[1], pageNo: 9, durationMinutes: 4, optional: true })
+  assert.deepEqual(inspectConfirmedOutline(optional, 40), [])
+  console.log('PASS: edited goals, 8-page outline, reordered pages, preserved content, invalid inputs, optional timing and source isolation')
+} finally { await vite.close() }

+ 1 - 0
src/utils/aiCoursewareDraft.ts

@@ -12,6 +12,7 @@ export interface AiDraftRecord {
   converted?: { title: string; theme: Partial<SlideTheme>; slides: Slide[] }
   preferences: AiCoursewarePreferences
   extraPrompt: string
+  outlineSettings?: string
   taskId?: string
   reviewMessage?: string
   resources?: AiCoursewareResourceBinding[]

+ 13 - 7
src/utils/aiCoursewareOutline.ts

@@ -1,8 +1,12 @@
-import type { AiCoursewareOutline, AiCoursewarePage } from '@/api/aiCourseware'
+import type { AiCoursewareOutline, AiCoursewarePage } from "@/api/aiCourseware"
 
 export const MIN_OUTLINE_PAGES = 2
 export const MAX_OUTLINE_PAGES = 30
-export const outlineLines = (text: string) => text.split(/\r?\n/).map(line => line.trim()).filter(Boolean)
+export const outlineLines = (text: string) =>
+  text
+    .split(/\r?\n/)
+    .map(line => line.trim())
+    .filter(Boolean)
 
 export interface EditableOutlinePage {
   key: string
@@ -14,7 +18,7 @@ export function editableOutlinePages(outline: AiCoursewareOutline): EditableOutl
   return outline.pages.map((page, index) => ({
     key: `${Date.now()}-${index}`,
     page: JSON.parse(JSON.stringify(page)),
-    pointsText: (page.studentContent?.points?.length ? page.studentContent.points : page.bullets || []).join('\n')
+    pointsText: (page.studentContent?.points?.length ? page.studentContent.points : page.bullets || []).join("\n")
   }))
 }
 
@@ -36,13 +40,15 @@ export function confirmedCoursewareOutline(outline: AiCoursewareOutline, goals:
 
 export function inspectConfirmedOutline(outline: AiCoursewareOutline, lessonMinutes: number): string[] {
   const issues: string[] = []
-  if (outline.pages.length < MIN_OUTLINE_PAGES || outline.pages.length > MAX_OUTLINE_PAGES) issues.push(`大纲需包含${MIN_OUTLINE_PAGES}~${MAX_OUTLINE_PAGES}页`)
-  if (!outline.textbookResearch?.teachingObjectives?.length) issues.push('请填写至少一个教学目标')
-  if (outline.pages[0]?.type !== 'cover') issues.push('请保留首页作为封面')
+  if (outline.pages.length < MIN_OUTLINE_PAGES || outline.pages.length > MAX_OUTLINE_PAGES)
+    issues.push(`大纲需包含${MIN_OUTLINE_PAGES}~${MAX_OUTLINE_PAGES}页`)
+  if (!outline.textbookResearch?.teachingObjectives?.length) issues.push("请填写至少一个教学目标")
+  if (outline.pages[0]?.type !== "cover") issues.push("请保留首页作为封面")
   outline.pages.forEach((page, index) => {
     if (!page.title) issues.push(`请填写第${index + 1}页标题`)
     if (!page.bullets?.length) issues.push(`请填写第${index + 1}页内容要点`)
-    if (!Number.isInteger(page.durationMinutes) || Number(page.durationMinutes) < 1 || Number(page.durationMinutes) > 60) issues.push(`第${index + 1}页用时需为1~60分钟的整数`)
+    if (!Number.isInteger(page.durationMinutes) || Number(page.durationMinutes) < 1 || Number(page.durationMinutes) > 60)
+      issues.push(`第${index + 1}页用时需为1~60分钟的整数`)
   })
   const total = outline.pages.filter(page => !page.optional).reduce((sum, page) => sum + (Number(page.durationMinutes) || 0), 0)
   if (Math.abs(total - lessonMinutes) > 2) issues.push(`必讲内容共${total}分钟,请调整到${lessonMinutes}分钟左右(可有2分钟机动)`)

+ 2 - 2
src/utils/aiPptQuality.ts

@@ -101,7 +101,7 @@ function inspectOutline(outline: AiCoursewareOutline, issues: AiPptQualityIssue[
   pages.forEach((page, index) => {
     const pageNo = page.pageNo || index + 1
     const layout = clean(page.visualPlan?.layout || page.visual?.layout || page.type || "content").toLowerCase()
-    if (!ALLOWED_LAYOUTS.has(layout)) push(issues, "invalid_layout", "error", pageNo, `未使用受支持的页面布局:“${layout}”`)
+    if (!ALLOWED_LAYOUTS.has(layout)) push(issues, "invalid_layout", "error", pageNo, "本页排版需要检查,请确认文字、图片和活动内容显示完整")
     if (index === 0 && layout !== "cover_scene") push(issues, "invalid_cover_layout", "error", pageNo, "第1页必须使用封面场景布局")
     layoutCounts.set(layout, (layoutCounts.get(layout) || 0) + 1)
     cardPages += CARD_LAYOUTS.has(layout) ? 1 : 0
@@ -293,7 +293,7 @@ function inspectTeachingContract(outline: AiCoursewareOutline, slides: Slide[],
   })
   objectives.forEach((_, index) => {
     const id = String(index + 1)
-    if (!taught.has(id) || !assessed.has(id)) push(issues, "objective_coverage", "error", undefined, `“${objectives[index]}”尚未关联完整的练习与评价,请自动完善或核对教学安排`)
+    if (!taught.has(id) || !assessed.has(id)) push(issues, "objective_coverage", "error", undefined, `“${objectives[index]}”缺少完整的练习与评价安排,请核对并补充相应课堂任务`)
   })
   if (preferences && Math.abs(minutes - preferences.lessonMinutes) > 2) push(issues, "lesson_duration", "error", undefined, `必讲页面合计${minutes}分钟,与${preferences.lessonMinutes}分钟课时不符(允许2分钟机动)`)
 }

+ 1010 - 114
src/views/Editor/EditorHeader/AiCoursewareDialog.vue

@@ -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>

+ 74 - 11
src/views/Editor/EditorHeader/index.vue

@@ -44,7 +44,7 @@
             >快捷操作</PopoverMenuItem
           >
         </template>
-        <div class="menuCon" :class="{ menuVisible: mainMenuVisible }">
+        <div class="menuCon" :class="{ menuVisible: mainMenuVisible }" role="button" tabindex="0" aria-label="课件菜单" @keydown.enter="mainMenuVisible = !mainMenuVisible" @keydown.space.prevent="mainMenuVisible = !mainMenuVisible">
           <img class="icon" src="./imgs/file.png" />
         </div>
       </Popover>
@@ -55,12 +55,24 @@
     </div>
 
     <div class="right">
-      <Popover v-if="generationIssues.length" trigger="click" placement="bottom-end" :content-style="{ width: '360px', maxWidth: 'calc(100vw - 40px)' }">
+      <span v-if="aiEditing" class="ai-editing-status" role="status">{{ usePptWorkHook.isSave ? "✓ 课件已保存" : "④ 编辑课件 → ⑤ 保存课件" }}</span>
+      <Popover
+        v-if="generationIssues.length"
+        trigger="click"
+        placement="bottom-end"
+        :content-style="{ width: '360px', maxWidth: 'calc(100vw - 40px)' }"
+      >
         <template #content>
           <div class="generation-issues">
             <strong>上课前请核对以下内容</strong>
             <p>可在编辑器中补充或调整,完成后保存课件。</p>
-            <ul><li v-for="(issue, index) in generationIssues" :key="index"><button v-if="issue.pageNo" @click="slidesStore.setSlideIndex(Math.min(issue.pageNo - 1, slidesStore.slides.length - 1))">第 {{ issue.pageNo }} 页</button>{{ issue.message }}</li></ul>
+            <ul>
+              <li v-for="(issue, index) in generationIssues" :key="index">
+                <button v-if="issue.pageNo" @click="slidesStore.updateSlideIndex(Math.min(issue.pageNo - 1, slidesStore.slides.length - 1))">
+                  第 {{ issue.pageNo }} 页</button
+                >{{ issue.message }}
+              </li>
+            </ul>
           </div>
         </template>
         <button class="generation-notice">需核对 {{ generationIssues.length }} 项</button>
@@ -73,7 +85,10 @@
       <HotkeyDoc />
       <template v-slot:title>快捷操作</template>
     </Drawer>
-    <AiCoursewareDialog v-model:visible="aiCoursewareVisible" @generated="generationIssues = $event" />
+    <AiCoursewareDialog
+      v-model:visible="aiCoursewareVisible"
+      @generated="onGenerated"
+    />
     <FullscreenSpin :loading="fullscreenSpinData.loading" :progress="fullscreenSpinData.progress" :tip="fullscreenSpinData.tip" />
   </div>
 </template>
@@ -93,7 +108,7 @@ import Popover from "@/components/Popover.vue"
 import PopoverMenuItem from "@/components/PopoverMenuItem.vue"
 import AiCoursewareDialog from "./AiCoursewareDialog.vue"
 import type { AiPptQualityIssue } from "@/utils/aiPptQuality"
-import { ref, computed } from "vue"
+import { ref, computed, watch } from "vue"
 import { ElMessageBox } from "element-plus"
 import usePptWork from "@/store/pptWork"
 import { iframeExitMes } from "@/messageHooks/closePage"
@@ -108,7 +123,12 @@ const { resetSlides } = useSlideHandler()
 const mainMenuVisible = ref(false)
 const hotkeyDrawerVisible = ref(false)
 const aiCoursewareVisible = ref(false)
+const aiEditing = ref(false)
 const generationIssues = ref<AiPptQualityIssue[]>([])
+function onGenerated(issues: AiPptQualityIssue[]) {
+  generationIssues.value = issues
+  aiEditing.value = true
+}
 
 const spinType = ref<"import" | "export">("import")
 /* 导入 */
@@ -145,6 +165,12 @@ const fullscreenSpinData = computed(() => {
 })
 
 const usePptWorkHook = usePptWork()
+watch(
+  () => [slidesStore.slides, slidesStore.title, slidesStore.theme, slidesStore.viewportSize, slidesStore.viewportRatio],
+  () => { if (aiEditing.value) usePptWorkHook.isSave = false },
+  { deep: true, flush: 'sync' }
+)
+watch(() => usePptWorkHook.id, () => { aiEditing.value = false; generationIssues.value = [] })
 /* 保存课件 */
 function handleSave() {
   usePptWorkHook.updatePPT()
@@ -234,10 +260,47 @@ function handleClose() {
     opacity: 0.8;
   }
 }
-.generation-notice { margin-right: 16px; border: 1px solid #ecd8b7; border-radius: 5px; background: #fff9ef; color: #a76b20; padding: 7px 12px; cursor: pointer; }
-.generation-issues { padding: 16px; line-height: 1.6; max-height: 50vh; overflow: auto; }
-.generation-issues p { color: #7b899a; margin: 8px 0; }
-.generation-issues ul { padding-left: 18px; }
-.generation-issues li { margin: 10px 0; }
-.generation-issues button { border: 0; background: transparent; color: #1989fa; cursor: pointer; margin-right: 6px; }
+.generation-notice {
+  margin-right: 16px;
+  border: 1px solid #ecd8b7;
+  border-radius: 5px;
+  background: #fff9ef;
+  color: #a76b20;
+  padding: 7px 12px;
+  cursor: pointer;
+}
+.generation-issues {
+  padding: 16px;
+  line-height: 1.6;
+  max-height: 50vh;
+  overflow: auto;
+}
+.generation-issues p {
+  color: #7b899a;
+  margin: 8px 0;
+}
+.generation-issues ul {
+  padding-left: 18px;
+}
+.generation-issues li {
+  margin: 10px 0;
+}
+.generation-issues button {
+  border: 0;
+  background: transparent;
+  color: #1989fa;
+  cursor: pointer;
+  margin-right: 6px;
+}
+.ai-editing-status {
+  color: #70849b;
+  font-size: 12px;
+  margin-right: 16px;
+  white-space: nowrap;
+}
+@media (max-width: 1100px) {
+  .ai-editing-status {
+    display: none;
+  }
+}
 </style>

Some files were not shown because too many files changed in this diff