yonge 1 day ago
parent
commit
9d0c1969eb

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


+ 1 - 1
dist/assets/index-DZTO8Ox_.js → dist/assets/index-CYLRKotL.js

@@ -1 +1 @@
-import{u as s,ah as r}from"./index-CMb64Y3q.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-D5u1pD2Q.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};
+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};

+ 1 - 1
dist/assets/index-CMb64Y3q.js → dist/assets/index-CZzfQySX.js

@@ -1,4 +1,4 @@
-import{cI as pw,cJ as gw,aG as mw,cK as yw,cL as Sw,cM as bw,cN as xw,ah as Wt,cO as Cw,cP as ww,cQ as Tw,cR as Iw,aM as _w,aF as Aw,cS as Ew,aB as d1,bg as Pw,cT as Mw,cU as Rw,cV as Dw,cW as Lw,cX as kw,cY as Ow,cZ as Bw,aE as Nw,c_ as Fw,s as Te,bl as $w,al as je,ag as Fe,af as he,ak as Z,c$ as zw,d0 as Uw,d1 as Hw,d2 as Vw,bk as Gw,cz as cl,aS as wv,aq as Ye,d3 as Ww,d4 as jw,a5 as ze,d5 as Kw,d6 as Xw,d7 as Yw,d8 as Zw,d9 as Qw,da as Jw,db as qw,dc as eT,dd as tT,de as rT,df as nT,v as iT,dg as aT,dh as oT,di as sT,aW as lT,dj as uT,dk as cT,dl as fT,dm as dT,dn as hT,ay as ur,dp as vT,dq as pT,dr as gT,ds as mT,A as yT,dt as ST,du as bT,bq as xT,dv as CT,dw as wT,dx as TT,ap as IT,ac as Tv,ai as ct,dy as _T,au as xe,dz as AT,X as h1,y as ET,dA as PT,aP as MT,dB as RT,w as Er,dC as DT,dD as LT,H as kT,dE as OT,bA as hn,aD as BT,ae as ne,bQ as ea,$ as Tc,dF as NT,bP as ta,dG as FT,aC as $T,aO as zT,R as fe,dH as UT,br as ih,b3 as _r,aj as Hs,b1 as lt,b2 as Ua,an as Iv,dI as HT,dJ as VT,dK as GT,dL as WT,dM as jT,dN as KT,dO as XT,Q as YT,dP as ZT,dQ as QT,dR as JT,at as Mt,dS as qT,aR as eI,aY as tI,ad as fl,aZ as rI,dT as nI,dU as iI,dV as aI,P as le,a6 as oI,dW as sI,dX as lI,dY as uI,dZ as cI,a7 as fI,d_ as dI,bM as hI,d$ as vI,e0 as pI,bL as gI,b4 as v1,aA as _v,e1 as mI,e2 as yI,E as Jt,x as SI,e3 as bI,e4 as xI,e5 as CI,am as _n,e6 as wI,az as sr,aU as Vs,e7 as TI,as as pr,e8 as II,e9 as Av,cH as yr,by as ah,cw as oh,bI as _I,bH as Ve,bu as sh,bv as Ev,bx as jt,bB as Ic,bO as di,ea as Tu,bE as hi,eb as AI,bJ as ra,bC as EI,bw as Lt,ec as PI,ed as ui,cx as MI,ee as RI,ef as DI,eg as LI,eh as kI,bN as Jp,ei as OI,bF as qp,cE as _c,cD as BI,ej as Vu,ek as _l}from"./index-D5u1pD2Q.js";/**
+import{cI as pw,cJ as gw,aG as mw,cK as yw,cL as Sw,cM as bw,cN as xw,ah as Wt,cO as Cw,cP as ww,cQ as Tw,cR as Iw,aM as _w,aF as Aw,cS as Ew,aB as d1,bg as Pw,cT as Mw,cU as Rw,cV as Dw,cW as Lw,cX as kw,cY as Ow,cZ as Bw,aE as Nw,c_ as Fw,s as Te,bl as $w,al as je,ag as Fe,af as he,ak as Z,c$ as zw,d0 as Uw,d1 as Hw,d2 as Vw,bk as Gw,cz as cl,aS as wv,aq as Ye,d3 as Ww,d4 as jw,a5 as ze,d5 as Kw,d6 as Xw,d7 as Yw,d8 as Zw,d9 as Qw,da as Jw,db as qw,dc as eT,dd as tT,de as rT,df as nT,v as iT,dg as aT,dh as oT,di as sT,aW as lT,dj as uT,dk as cT,dl as fT,dm as dT,dn as hT,ay as ur,dp as vT,dq as pT,dr as gT,ds as mT,A as yT,dt as ST,du as bT,bq as xT,dv as CT,dw as wT,dx as TT,ap as IT,ac as Tv,ai as ct,dy as _T,au as xe,dz as AT,X as h1,y as ET,dA as PT,aP as MT,dB as RT,w as Er,dC as DT,dD as LT,H as kT,dE as OT,bA as hn,aD as BT,ae as ne,bQ as ea,$ as Tc,dF as NT,bP as ta,dG as FT,aC as $T,aO as zT,R as fe,dH as UT,br as ih,b3 as _r,aj as Hs,b1 as lt,b2 as Ua,an as Iv,dI as HT,dJ as VT,dK as GT,dL as WT,dM as jT,dN as KT,dO as XT,Q as YT,dP as ZT,dQ as QT,dR as JT,at as Mt,dS as qT,aR as eI,aY as tI,ad as fl,aZ as rI,dT as nI,dU as iI,dV as aI,P as le,a6 as oI,dW as sI,dX as lI,dY as uI,dZ as cI,a7 as fI,d_ as dI,bM as hI,d$ as vI,e0 as pI,bL as gI,b4 as v1,aA as _v,e1 as mI,e2 as yI,E as Jt,x as SI,e3 as bI,e4 as xI,e5 as CI,am as _n,e6 as wI,az as sr,aU as Vs,e7 as TI,as as pr,e8 as II,e9 as Av,cH as yr,by as ah,cw as oh,bI as _I,bH as Ve,bu as sh,bv as Ev,bx as jt,bB as Ic,bO as di,ea as Tu,bE as hi,eb as AI,bJ as ra,bC as EI,bw as Lt,ec as PI,ed as ui,cx as MI,ee as RI,ef as DI,eg as LI,eh as kI,bN as Jp,ei as OI,bF as qp,cE as _c,cD as BI,ej as Vu,ek as _l}from"./index-D5a0B_Kj.js";/**
 * vue v3.4.34
 * (c) 2018-present Yuxi (Evan) You and Vue contributors
 * @license MIT

+ 1 - 1
dist/assets/index-B0ExMQdV.js → dist/assets/index-D4K7eQU_.js

@@ -1 +1 @@
-import{u as c,ah as l}from"./index-CMb64Y3q.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-D5u1pD2Q.js";import"./shapes-Bj1iCeu-.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,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};

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


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

@@ -1 +1 @@
-import{_ as t}from"./404-R0y5dkvZ.js";import{E as c}from"./index-xLFyt8_5.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-D5u1pD2Q.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-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};

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


+ 1 - 1
dist/assets/index-Do_XpLna.js → dist/assets/index-olsXM_5E.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-D5u1pD2Q.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,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};

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


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

@@ -1,4 +1,4 @@
-import{cH as k,bB as $1}from"./index-D5u1pD2Q.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-CMb64Y3q.js";var z1={exports:{}};/*!
+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:{}};/*!
  * 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-D5u1pD2Q.js"></script>
+    <script type="module" crossorigin src="./assets/index-D5a0B_Kj.js"></script>
     <link rel="stylesheet" crossorigin href="./assets/index-AavUEaYy.css">
   </head>
   <body>

+ 14 - 1
scripts/aiPptDialogTest.html

@@ -9,6 +9,7 @@ import { httpAxios } from "/src/api/ApiInstance.ts"
 import { setToken } from "/src/libs/auth.ts"
 import AiDialog from "/src/views/Editor/EditorHeader/AiCoursewareDialog.vue"
 import { inspectAiPptResources } from "/src/utils/aiPptResources.ts"
+import { draftKey, loadAiDraft, saveAiDraft } from "/src/utils/aiCoursewareDraft.ts"
 import "/src/assets/styles/global.scss"
 import "/src/assets/styles/elementTheme.scss"
 import "/src/assets/styles/prosemirror.scss"
@@ -17,6 +18,7 @@ const scenario = new URLSearchParams(location.search).get("scenario") || "normal
 setToken(`local-ai-ppt-regression-${scenario}`)
 const generated = ref(0)
 const repairs = ref(0)
+const historicalPolls = ref(0)
 let repaired = false
 const resourceCheck = ref("检查中")
 const runtimeError = ref("")
@@ -32,6 +34,13 @@ const outline = {
     interaction:type==="practice_check"?{question:"哪种方式声音更轻?",options:["轻声哼唱","大声呼喊"],answer:"轻声哼唱",explanation:"根据声音强弱判断"}:undefined,
     visualPlan:{layout:layouts[index]}, imageKeywords:[] }))
 }
+if (scenario === "failed-resume") {
+  const key = await draftKey("local-ai-ppt-regression", "TEACHER")
+  if (!await loadAiDraft(key)) await saveAiDraft({
+    key, updatedAt: Date.now(), outline: structuredClone(outline), taskId: "historical-failed-task", extraPrompt: "",
+    preferences: { minPages: 9, maxPages: 12, lessonMinutes: 40 }
+  })
+}
 httpAxios.axioseRquest = async config => {
   let data
   if (config.url.endsWith("/outline")) data = structuredClone(outline)
@@ -47,6 +56,10 @@ httpAxios.axioseRquest = async config => {
       data.reviewMessage="声音强弱的练习与评价尚未完整关联"
     }
   }
+  else if (config.url.endsWith("/task/historical-failed-task")) {
+    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 throw new Error("测试禁止未声明的业务请求")
   return {data:{code:200,data}}
@@ -60,7 +73,7 @@ const app=createApp({setup(){
   nextTick(()=>{visible.value=true})
   return ()=>h("main",[
     runtimeError.value ? h("pre", runtimeError.value) : null,
-    h("p",{id:"test-status"},`生成请求次数:${generated.value};完善请求:${repairs.value};已应用页数:${slidesStore.slides.length};资源检查:${resourceCheck.value}`),
+    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}})
   ])

+ 1 - 0
src/api/aiCourseware.ts

@@ -17,6 +17,7 @@ export interface AiCoursewareGenerateQuery {
   preferences?: AiCoursewarePreferences
   regeneratePageNo?: number
   repairOnly?: boolean
+  outlineConfirmed?: boolean
 }
 
 export interface AiCoursewarePreferences {

+ 50 - 0
src/utils/aiCoursewareOutline.ts

@@ -0,0 +1,50 @@
+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 interface EditableOutlinePage {
+  key: string
+  page: AiCoursewarePage
+  pointsText: string
+}
+
+export function editableOutlinePages(outline: AiCoursewareOutline): EditableOutlinePage[] {
+  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')
+  }))
+}
+
+export function confirmedCoursewareOutline(outline: AiCoursewareOutline, goals: string, pages: EditableOutlinePage[]): AiCoursewareOutline {
+  const result: AiCoursewareOutline = JSON.parse(JSON.stringify(outline))
+  if (result.textbookResearch) result.textbookResearch.teachingObjectives = outlineLines(goals)
+  result.pages = pages.map(({ page, pointsText }, index) => {
+    const copy: AiCoursewarePage = JSON.parse(JSON.stringify(page))
+    copy.pageNo = index + 1
+    copy.title = copy.title.trim()
+    copy.bullets = outlineLines(pointsText)
+    copy.studentContent = { ...copy.studentContent, points: [...copy.bullets] }
+    // 老师可能增删了目标,旧序号不再可靠;生成时按已确认的目标重新关联。
+    copy.objectiveIds = []
+    return copy
+  })
+  return result
+}
+
+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('请保留首页作为封面')
+  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分钟的整数`)
+  })
+  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分钟机动)`)
+  return issues
+}

+ 168 - 2929
src/views/Editor/EditorHeader/AiCoursewareDialog.vue

@@ -1,2944 +1,183 @@
 <template>
-  <Modal
-    :visible="visible"
-    :width="1120"
-    :content-style="{ maxWidth: 'calc(100vw - 32px)', boxSizing: 'border-box' }"
-    :close-button="!working"
-    :close-on-click-mask="!working"
-    :close-on-esc="!working"
-    @update:visible="value => emit('update:visible', value)"
-  >
-    <div :class="['ai-dialog', workflowStep === 1 ? 'setup-mode' : 'review-mode']">
-      <div class="header">
-        <div>
-          <div class="title">{{ pageTitle }}</div>
-          <div class="sub">{{ pageMeta }}</div>
-        </div>
-      </div>
-
-      <div class="workflow-steps">
-        <div
-          v-for="step in workflowSteps"
-          :key="step.value"
-          :class="['workflow-step', { active: workflowStep === step.value, done: workflowStep > step.value }]"
-        >
-          <span class="step-no">{{ workflowStep > step.value ? "✓" : step.value }}</span>
-          <span>{{ step.label }}</span>
-        </div>
-      </div>
-
-      <div v-if="outlineLoading" class="loading-strip">
-        <span class="loading-dot"></span>
-        <span>正在生成大纲,请稍候</span>
-      </div>
-
-      <div v-if="workflowStep === 1" class="setup-stage simplified-setup">
-        <div class="stage-intro">
-          <div>
-            <div class="stage-title">设置这节课的基本需求</div>
-            <div class="stage-desc">只需确认课时和篇幅,样式、素材与质量检查由系统完成。</div>
-          </div>
-          <span v-if="outline" class="saved-state">已有大纲,可继续确认</span>
-        </div>
-
-        <div class="setup-primary-grid">
-          <label>
-            <span>课时长度</span>
-            <select v-model.number="preferences.lessonMinutes" :disabled="working">
-              <option :value="20">20分钟</option>
-              <option :value="30">30分钟</option>
-              <option :value="40">40分钟</option>
-              <option :value="45">45分钟</option>
-            </select>
-          </label>
-          <label>
-            <span>课件页数</span>
-            <select v-model="pageRange" :disabled="working">
-              <option value="9-12">精简版(9~12页)</option>
-              <option value="12-15">完整课堂(12~15页)</option>
-              <option value="9-15">由系统决定</option>
-            </select>
-          </label>
-        </div>
-
-        <div class="extra">
-          <div class="label">还有什么特别要求?<span>选填</span></div>
-          <TextArea v-model:value="extraPrompt" :rows="3" placeholder="例如:多安排节奏游戏,语言适合一年级学生" :disabled="working" />
-        </div>
-
-        <button type="button" class="advanced-toggle" @click="advancedSettingsExpanded = !advancedSettingsExpanded">
-          {{ advancedSettingsExpanded ? "收起高级设置" : "高级设置" }}
-        </button>
-        <div v-if="advancedSettingsExpanded" class="generation-preferences advanced-settings">
-          <label class="label">本节课范围</label>
-          <TextArea :value="preferences.lessonScope || ''" @update:value="preferences.lessonScope = $event" :rows="2" placeholder="例如:第一课时,重点学习第一段" :disabled="working" />
-          <label class="label">学生基础与课堂条件</label>
-          <TextArea :value="preferences.learningContext || ''" @update:value="preferences.learningContext = $event" :rows="2" placeholder="例如:尚不识谱,有打击乐器" :disabled="working" />
-          <div class="preference-grid">
-            <label
-              ><span>素材</span
-              ><select v-model="preferences.assetStyle" :disabled="working">
-                <option value="illustration">统一插画</option>
-                <option value="photo">真实照片</option>
-                <option value="mixed">插画与照片混合</option>
-              </select></label
-            >
-            <label
-              ><span>教材原图</span
-              ><select v-model="preferences.sourceImagePolicy" :disabled="working">
-                <option value="strict">优先保留</option>
-                <option value="balanced">适度使用</option>
-                <option value="creative">自由创作</option>
-              </select></label
-            >
-          </div>
-          <div class="media-options">
-            <label><input v-model="preferences.requireAudio" type="checkbox" :disabled="working" />需要音频</label>
-            <label><input v-model="preferences.requireVideo" type="checkbox" :disabled="working" />需要视频</label>
-            <label><input v-model="preferences.enableAnimations" type="checkbox" :disabled="working" />启用克制动画</label>
-          </div>
-        </div>
-      </div>
-
-      <div v-else-if="workflowStep === 2 && outline" class="outline unified-review">
-        <details v-if="teachingDetailsExpanded || !outlineConfirmed" class="teaching-review" open>
-          <summary>教材依据与教学主线</summary>
-          <p>{{ reliableResearch ? "已检索到匹配教材的依据,可在这里查看教学安排。" : "教材尚未精确匹配,暂不能生成授课成品。请返回核对课程信息后重新检索。" }}</p>
-          <p v-if="displayedConverted">必讲内容 {{ plannedMinutes }} 分钟 · 课时设置 {{ preferences.lessonMinutes }} 分钟</p>
-          <p>{{ outline.textbookResearch?.contentSummary }}</p>
-          <p v-for="item in outline.textbookResearch?.missingEvidence || []" :key="item">待核对:{{ item }}</p>
-          <a v-for="source in researchSources" :key="source.url" :href="source.url" target="_blank" rel="noopener noreferrer">{{ source.title || "教材来源" }}</a>
-          <ol><li v-for="objective in outline.textbookResearch?.teachingObjectives || []" :key="objective">{{ objective }}</li></ol>
-          <div v-if="!outlineConfirmed" class="outline-plan">
-            <label v-for="page in outline.pages" :key="page.pageNo">
-              <span>第{{ page.pageNo }}页 · {{ page.teachingStage }}</span>
-              <input v-model="page.title" aria-label="页面标题" />
-              <input v-model="page.teachingPurpose" aria-label="教学目的" placeholder="本页教学目的" />
-            </label>
-          </div>
-        </details>
-        <div v-if="displayedConverted && working" class="loading-strip" aria-live="polite"><span class="loading-dot"></span>正在完善课件,已有内容会保留</div>
-        <section v-else-if="displayedConverted && (draftQuality?.errors.length || serverReviewMessage)" class="draft-notice" aria-live="polite">
-          <span>初稿已保留,{{ draftQuality?.errors.length ? `有${draftQuality.errors.length}处待完善` : '还有内容待复核' }}</span>
-          <button type="button" @click="qualityDetailsExpanded = !qualityDetailsExpanded">{{ qualityDetailsExpanded ? '收起' : '查看问题' }}</button>
-          <Button :disabled="working" @click="repairDraft">自动完善</Button>
+  <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>
+      <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>
         </section>
-        <div v-if="qualityDetailsExpanded && displayedConverted" class="quality-panel">
-          <p v-if="serverReviewMessage">{{ serverReviewMessage }}</p>
-          <button v-for="(issue, index) in draftQuality?.errors || []" :key="index" type="button" @click="issue.pageNo && selectWorkspacePage(issue.pageNo - 1)">
-            {{ issue.pageNo ? `第${issue.pageNo}页:` : '' }}{{ issue.message }}
-          </button>
-        </div>
-        <div class="sample-stage-head">
-          <div class="sample-template-current">
-            <span>当前样式</span>
-            <strong>{{ selectedTemplate.name }}</strong>
-            <small v-if="selectedTemplate.id === recommendedTemplate.id">系统推荐</small>
-          </div>
-          <button type="button" @click="teachingDetailsExpanded = !teachingDetailsExpanded">{{ teachingDetailsExpanded ? '收起说明' : '教学说明' }}</button>
-          <button type="button" :disabled="working" @click="templatePickerExpanded = !templatePickerExpanded">
-            {{ templatePickerExpanded ? "收起模板" : "更换模板" }}
-          </button>
-        </div>
-
-        <div v-if="templatePickerExpanded" class="compact-template-panel">
-          <div class="sample-template-picker">
-            <button
-              v-for="template in visibleTemplates"
-              :key="template.id"
-              type="button"
-              :class="['sample-template-option', { active: selectedTemplateId === template.id }]"
-              :disabled="working"
-              @click="selectTemplate(template.id, true)"
-            >
-              <span class="template-real-cover option" :style="templateCoverStyle(template.id)"></span>
-              <span
-                ><strong>{{ template.name }}</strong
-                ><small>{{ template.id === recommendedTemplate.id ? "系统推荐 · " : "" }}{{ template.scene }}</small></span
-              >
-            </button>
-          </div>
-          <button v-if="!showAllTemplates" type="button" class="show-all-templates" @click="showAllTemplates = true">查看全部模板</button>
-          <div v-if="selectedTemplateFit.level === 'low'" class="template-fit-warning">
-            <span>当前模板不太适合这节课:{{ selectedTemplateFit.reason }}</span>
-            <button type="button" :disabled="working" @click="useRecommendedTemplate">切换为系统推荐</button>
+        <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>
-        </div>
-
-        <div v-if="!workspaceSlide && outlineConfirmed" :class="['final-preview-placeholder', draftPreparationState]">
-          <span v-if="draftPreparationState === 'optimizing'" class="loading-dot"></span>
-          <span v-else class="placeholder-icon">!</span>
-          <strong>{{ draftPreparationState === "failed" ? "这次生成未完成" : generationStatusTitle }}</strong>
-          <small>{{ draftPreparationState === "failed" ? draftFailureMessage : "完成后可直接预览、编辑和应用" }}</small>
-          <Button v-if="draftPreparationState === 'failed'" type="primary" @click="regenerateFinalPreview">重试生成</Button>
-        </div>
-
-        <div v-if="workspaceSlide" class="unified-review-workspace">
-          <aside class="workspace-page-list" aria-label="课件页面">
-            <button
-              v-for="(slide, index) in workspaceSlides"
-              :key="slide.id"
-              type="button"
-              :class="['workspace-page-item', { active: selectedWorkspacePageIndex === index }]"
-              :disabled="working"
-              @click="selectWorkspacePage(index)"
-            >
-              <ThumbnailSlide :slide="slide" :size="136" />
-              <span>{{ formatPageNo(index + 1) }} · {{ workspacePages[index]?.title || "未命名页面" }}</span>
-            </button>
-          </aside>
-
-          <section class="workspace-preview-column" aria-label="页面预览及调整">
-            <div class="workspace-preview-heading">
-              <div>
-                <span>第{{ selectedWorkspacePageIndex + 1 }} / {{ workspaceSlides.length }}页</span>
-                <strong>{{ workspacePage?.title || "课件页面" }}</strong>
-              </div>
-              <small>{{ canEditWorkspaceText ? "双击预览中的文字即可编辑" : "系统处理完成后可直接编辑文字" }}</small>
-            </div>
-            <div ref="workspaceCanvas" class="workspace-canvas-shell">
-              <ThumbnailSlide
-                :slide="workspaceSlide"
-                :size="workspaceCanvasSize"
-                :show-background-error="true"
-                :editable-text="true"
-                :edit-disabled="!canEditWorkspaceText"
-                @background-load-state="handleSampleBackgroundLoadState"
-                @text-change="handleWorkspaceTextChange"
-              />
-              <div v-if="working" class="workspace-processing-overlay">
-                <span class="loading-dot"></span>
-                <strong>{{ generationStatusTitle }}</strong>
-                <small>完成后自动更新当前预览</small>
-              </div>
-            </div>
-            <div v-if="sampleBackgroundState.status === 'error'" class="template-load-error">模板背景加载失败,请更换模板或稍后重试</div>
-            <details class="sample-feedback workspace-feedback">
-              <summary>调整当前页</summary>
-              <label><input v-model="editTeachingContent" type="checkbox" :disabled="working" />同时修改本页教学内容(保留其他页面)</label>
-              <div class="feedback-label">图片或版式需要调整?</div>
-              <div class="feedback-chips">
-                <button
-                  v-for="item in sampleFeedbackOptions"
-                  :key="item"
-                  type="button"
-                  :class="{ active: sampleFeedbackTags.includes(item) }"
-                  :disabled="working"
-                  @click="toggleSampleFeedback(item)"
-                >
-                  {{ item }}
-                </button>
-              </div>
-              <div class="sample-feedback-action">
-                <textarea v-model="sampleFeedbackText" :disabled="working" placeholder="说明希望如何调整,文字可直接在上方预览中双击修改"></textarea>
-                <Button @click="adjustCurrentPage" :disabled="working">调整当前页</Button>
-              </div>
-            </details>
-            <details class="teaching-review" v-if="workspacePage">
-              <summary>教师备注、用时与课堂资源</summary>
-              <label>用时(分钟)<input type="number" min="1" max="60" v-model.number="workspacePage.durationMinutes" :disabled="working" @input="refreshEditedDraft" /></label>
-              <label><input type="checkbox" v-model="workspacePage.optional" :disabled="working" @change="refreshEditedDraft" />选讲页,不计入必讲时长</label>
-              <textarea v-model="workspacePage.speakerNotes" :disabled="working" @blur="refreshEditedDraft" aria-label="教师备注"></textarea>
-              <p v-if="workspacePage.interaction?.answer">参考答案:{{ workspacePage.interaction.answer }} {{ workspacePage.interaction.explanation }}</p>
-              <label v-for="resource in availableResources" :key="resource.resourceType + resource.resourceId">
-                <input type="checkbox" :checked="pageHasResource(resource)" :disabled="working" @change="toggleResource(resource)" />{{ resource.resourceType }} · {{ resource.name }}
-              </label>
-            </details>
-          </section>
-        </div>
-      </div>
-
-      <div class="footer">
-        <Button @click="emit('update:visible', false)" :disabled="working">关闭</Button>
-        <template v-if="isSetupStep">
-          <Button v-if="outline" @click="continueToFinalPreview" :disabled="working">{{ displayedConverted ? "继续预览" : "继续生成" }}</Button>
-          <Button type="primary" @click="handleOutline" :disabled="working">{{ outline ? "按当前设置重新生成" : "开始生成课件" }}</Button>
-        </template>
-        <template v-else-if="isStyleStep">
-          <Button @click="workflowStep = 1" :disabled="working">返回修改需求</Button>
-          <Button v-if="!outlineConfirmed" type="primary" :disabled="working || !reliableResearch" @click="confirmOutline">确认教学安排并生成</Button>
-          <span v-if="displayedConverted" class="draft-save-status">{{ draftSaved ? '草稿已保存' : '草稿未保存' }}</span>
-          <details v-if="displayedConverted" class="more-actions"><summary>更多</summary><div class="more-actions-menu">
-            <Button :disabled="working" @click="saveDraftExplicitly">保存初稿</Button>
-            <Button v-if="previousCourseware" :disabled="working" @click="restorePreviousCourseware">恢复应用前课件</Button>
-          </div></details>
-          <Button
-            v-if="displayedConverted && draftPreparationState === 'ready'"
-            type="primary"
-            :disabled="working || !draftQuality?.passed || Boolean(serverReviewMessage) || sampleBackgroundState.status === 'error'"
-            @click="applyDraft"
-            >应用到当前课件</Button
-          >
-        </template>
-      </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>
+      </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>
     </div>
   </Modal>
 </template>
 
-<script lang="ts" setup>
-import { ref, reactive, computed, watch } from "vue"
-import { ElMessage } from "element-plus"
-import Modal from "@/components/Modal.vue"
-import Button from "@/components/Button.vue"
-import TextArea from "@/components/TextArea.vue"
-import ThumbnailSlide from "@/views/components/ThumbnailSlide/index.vue"
-import queryParams from "@/queryParams"
-import usePptWork from "@/store/pptWork"
-import { useSlidesStore } from "@/store"
-import useHistorySnapshot from "@/hooks/useHistorySnapshot"
-import { httpAjaxErrMsg } from "@/plugins/httpAjax"
-import {
-  aiCoursewareGenerateApi,
-  aiCoursewareOutlineApi,
-  aiCoursewareResultApi,
-  aiCoursewareTaskApi,
-  type AiCoursewareOutline,
-  type AiCoursewarePage,
-  type AiCoursewarePreferences,
-  type AiCoursewareTaskInfo,
-  type AiCoursewareGenerateResult
-} from "@/api/aiCourseware"
-import { aiCoursewareToSlides } from "@/utils/aiCoursewareToSlides"
-import { inspectAiPptQuality, type AiPptQualityReport } from "@/utils/aiPptQuality"
-import { inspectAiPptResources } from "@/utils/aiPptResources"
-import {
-  AI_COURSEWARE_TEMPLATES,
-  getAiCoursewareTemplate,
-  recommendAiCoursewareTemplate,
-  scoreAiCoursewareTemplate,
-  type AiCoursewareTemplateId
-} from "@/config/aiCoursewareTemplates"
-import { getAiCoursewareTemplateMaster } from "@/config/aiCoursewareTemplateMasters"
-import { resolveTemplateAssetUrl } from "@/utils/templateAssetUrl"
-import { draftKey, loadAiDraft, saveAiDraft, type AiDraftRecord, type ProtectedPageContent } from "@/utils/aiCoursewareDraft"
-import { usableResourceUrl } from "@/utils/aiPptContent"
-import type { AiCoursewareResourceBinding } from "@/api/aiCourseware"
-import type { Slide, SlideTheme } from "@/types/slides"
-
-const props = defineProps<{
-  visible: boolean
-}>()
-
-const emit = defineEmits<{
-  (event: "update:visible", payload: boolean): void
-}>()
-
-const pptWork = usePptWork()
-const slidesStore = useSlidesStore()
-const { addHistorySnapshot } = useHistorySnapshot()
-
-const extraPrompt = ref("")
-const outline = ref<AiCoursewareOutline>()
-const outlineConfirmed = ref(false)
-const teachingDetailsExpanded = ref(false)
-const qualityDetailsExpanded = ref(false)
-const serverReviewMessage = ref("")
-const draftSaved = ref(false)
-const workspaceCanvas = ref<HTMLElement>()
-const canvasWidth = ref(720)
-const canvasHeight = ref(405)
-watch(workspaceCanvas, (element, _, onCleanup) => {
-  if (!element) return
-  const observer = new ResizeObserver(([entry]) => {
-    canvasWidth.value = entry.contentRect.width
-    canvasHeight.value = entry.contentRect.height
-  })
-  observer.observe(element)
-  onCleanup(() => observer.disconnect())
-})
-// 同时约束宽高,避免小窗口里只显示幻灯片中间的一截。
-const workspaceCanvasSize = computed(() => Math.max(1, Math.min(canvasWidth.value, canvasHeight.value / slidesStore.viewportRatio)))
-const editTeachingContent = ref(false)
-const availableResources = ref<AiCoursewareResourceBinding[]>([])
-const previousCourseware = ref<AiDraftRecord["backup"]>()
-const reliableResearch = computed(() => outline.value?.textbookResearch?.exactMatch === true && outline.value.textbookResearch.confidence === "HIGH" && researchSources.value.length > 0)
-const researchSources = computed(() => (outline.value?.textbookResearch?.sources || []).filter(source => /^https?:\/\//i.test(source.url)))
-const plannedMinutes = computed(() => (displayedOutline.value?.pages || []).filter(page => !page.optional).reduce((total, page) => total + (Number(page.durationMinutes) || 0), 0))
-const taskInfo = ref<AiCoursewareTaskInfo>()
-const workflowStep = ref<1 | 2>(1)
-const isSetupStep = computed(() => workflowStep.value === 1)
-const isStyleStep = computed(() => workflowStep.value === 2)
-const workflowSteps = [
-  { value: 1, label: "设置需求" },
-  { value: 2, label: "预览、编辑并应用" }
-] as const
-const coursewareTemplates = AI_COURSEWARE_TEMPLATES
-const defaultTemplateId = AI_COURSEWARE_TEMPLATES[0].id
-const selectedTemplateId = ref<AiCoursewareTemplateId>(defaultTemplateId)
-const templateSelectionMode = ref<"auto" | "manual">("auto")
-const templatePickerExpanded = ref(false)
-const advancedSettingsExpanded = ref(false)
-const showAllTemplates = ref(false)
-const paletteVariant = ref(0)
-const pageRange = ref("9-12")
-const preferences = reactive<AiCoursewarePreferences>({
-  lessonMinutes: 40,
-  minPages: 9,
-  maxPages: 12,
-  assetStyle: "illustration",
-  sourceImagePolicy: "balanced",
-  requireAudio: false,
-  requireVideo: false,
-  enableAnimations: true
-})
-const sampleBackgroundState = ref<{ status: "none" | "loading" | "loaded" | "error"; url: string }>({ status: "none", url: "" })
-const sampleFeedbackOptions = ["画风不合适", "页面太空", "图片不合适", "层级不清晰"]
-const sampleFeedbackTags = ref<string[]>([])
-const sampleFeedbackText = ref("")
-const draftOutline = ref<AiCoursewareOutline>()
-const draftConverted = ref<{ title: string; theme: Partial<SlideTheme>; slides: Slide[] }>()
-const displayedOutline = ref<AiCoursewareOutline>()
-const displayedConverted = ref<{ title: string; theme: Partial<SlideTheme>; slides: Slide[] }>()
-const displayedTemplateId = ref<AiCoursewareTemplateId>()
-const draftQuality = ref<AiPptQualityReport>()
-const draftPreparationState = ref<"idle" | "optimizing" | "ready" | "failed">("idle")
-const draftFailureMessage = ref("请稍后重试")
-const protectedPageContent = new Map<number, ProtectedPageContent>()
-const TEACHER_VISIBLE_PATTERN = /(?:教师|老师)(?:讲述|提问|引导|指导|组织|巡视|示范)|(?:引导|指导|组织)学生/
-const PLACEHOLDER_PATTERN = /(?:待补充|待完善|请输入|可参考教材|placeholder|lorem ipsum|\{\{.+?\}\}|\[.+?占位.+?\])/i
-const pollTimer = ref<number>()
-const selectedDraftPageIndex = ref(0)
-const outlineLoading = ref(false)
-const working = computed(
-  () =>
-    outlineLoading.value ||
-    draftPreparationState.value === "optimizing" ||
-    taskInfo.value?.status === "PENDING" ||
-    taskInfo.value?.status === "RUNNING"
-)
-const selectedTemplate = computed(() => getAiCoursewareTemplate(selectedTemplateId.value))
-const selectedStyle = computed(() => selectedTemplate.value.baseStyle)
-const templateRecommendation = computed(() =>
-  outline.value ? recommendAiCoursewareTemplate(outline.value) : { id: defaultTemplateId, reason: "生成大纲后将根据课程内容自动推荐" }
-)
-const recommendedTemplate = computed(() => getAiCoursewareTemplate(templateRecommendation.value.id))
-const visibleTemplates = computed(() => {
-  if (showAllTemplates.value) return coursewareTemplates
-  const recommendedId = recommendedTemplate.value.id
-  return [...coursewareTemplates]
-    .sort((left, right) => {
-      if (left.id === recommendedId) return -1
-      if (right.id === recommendedId) return 1
-      return outline.value ? templateFit(right.id).score - templateFit(left.id).score : 0
-    })
-    .slice(0, 4)
-})
-const selectedTemplateFit = computed(() =>
-  outline.value
-    ? scoreAiCoursewareTemplate(outline.value, selectedTemplateId.value)
-    : { templateId: selectedTemplateId.value, score: 80, level: "usable" as const, label: "可以使用" as const, reason: "生成大纲后评估" }
-)
-const progressValue = computed(() => taskInfo.value?.progress || 0)
-const progressSteps = [
-  { threshold: 0, label: "准备中" },
-  { threshold: 25, label: "生成内容" },
-  { threshold: 50, label: "优化设计" },
-  { threshold: 65, label: "匹配图片" },
-  { threshold: 100, label: "生成完成" }
-]
-const currentProgressLabel = computed(() => {
-  const matched = [...progressSteps].reverse().find(step => progressValue.value >= step.threshold)
-  return shortProgressLabel(taskInfo.value?.message) || matched?.label || "处理中"
-})
-const generationStatusTitle = computed(() => {
-  if (!taskInfo.value) return "正在生成课件"
-  return `${currentProgressLabel.value} ${progressValue.value}%`
-})
-const pageTitle = computed(() => outline.value?.chapterName || outline.value?.title || "AI生成课件")
-const pageMeta = computed(() => {
-  if (!outline.value) return `根据当前教材生成${preferences.minPages}至${preferences.maxPages}页音乐课件`
-  return [...[outline.value.gradeName, outline.value.unitName].filter(Boolean), `共${outline.value.pages.length}页`].join(" · ")
-})
-const workspaceSlides = computed(() => displayedConverted.value?.slides || [])
-const workspacePages = computed(() => displayedOutline.value?.pages || [])
-const selectedWorkspacePageIndex = computed(() => selectedDraftPageIndex.value)
-const workspacePage = computed(() => workspacePages.value[selectedWorkspacePageIndex.value])
-const workspaceSlide = computed(() => workspaceSlides.value[selectedWorkspacePageIndex.value])
-const canEditWorkspaceText = computed(() => !working.value && Boolean(displayedConverted.value) && draftPreparationState.value === "ready")
-const previewSlides = computed(() => {
-  if (!outline.value?.pages.length) return undefined
-  const previewOutline = { ...outline.value, style: selectedStyle.value }
-  return aiCoursewareToSlides(previewOutline, {
-    enableAnimations: preferences.enableAnimations,
-    paletteVariant: paletteVariant.value,
-    templateId: selectedTemplateId.value
-  }).slides
-})
-function selectWorkspacePage(index: number) {
-  sampleFeedbackTags.value = []
-  sampleFeedbackText.value = ""
-  selectedDraftPageIndex.value = index
-}
-
-function handleWorkspaceTextChange(payload: { elementId: string; content: string; previousContent: string; text: string; previousText: string }) {
-  const slide = workspaceSlide.value
-  const page = workspacePage.value
-  if (!slide || !page || !canEditWorkspaceText.value) return
-
-  const element = slide.elements.find(item => item.id === payload.elementId)
-  if (element?.type === "text") element.content = payload.content
-  if (!replacePageVisibleText(page, payload.previousText, payload.text)) {
-    if (element?.type === "text") element.content = payload.previousContent
-    ElMessage.warning("这段文字由模板生成,请调整页面内容后再试")
-    return
-  }
-
-  protectPageContent(page)
-  restoreProtectedPageContent(outline.value)
-  restoreProtectedPageContent(draftOutline.value)
-  refreshEditedDraft()
-  ElMessage.success("文字已更新")
-}
-
-function replacePageVisibleText(page: AiCoursewarePage, previousText: string, nextText: string) {
-  const target = normalizeDraftText(previousText)
-  if (!target) return false
-  let changed = false
-  const replaceValue = (value?: string) => {
-    if (normalizeDraftText(value) !== target) return value
-    changed = true
-    return nextText
-  }
-  const replaceList = (values?: string[]) => (values || []).map(value => replaceValue(value) || "")
-
-  page.title = replaceValue(page.title) || page.title
-  page.subtitle = replaceValue(page.subtitle)
-  page.bullets = replaceList(page.bullets)
-  if (page.studentContent) {
-    page.studentContent.headline = replaceValue(page.studentContent.headline)
-    page.studentContent.points = replaceList(page.studentContent.points)
-  }
-  if (page.classroomAction) page.classroomAction.studentTask = replaceValue(page.classroomAction.studentTask)
-  page.learningEvidence = replaceValue(page.learningEvidence)
-  if (page.interaction) {
-    page.interaction.question = replaceValue(page.interaction.question) || page.interaction.question
-    page.interaction.options = replaceList(page.interaction.options)
-  }
-  return changed
-}
-
-function protectPageContent(page: AiCoursewarePage) {
-  protectedPageContent.set(page.pageNo, {
-    title: page.title,
-    subtitle: page.subtitle,
-    bullets: [...(page.bullets || [])],
-    studentContent: page.studentContent ? { headline: page.studentContent.headline, points: [...(page.studentContent.points || [])] } : undefined,
-    studentTask: page.classroomAction?.studentTask,
-    learningEvidence: page.learningEvidence,
-    interaction: page.interaction ? { ...page.interaction, options: page.interaction.options ? [...page.interaction.options] : undefined } : undefined
-  })
-}
-
-function restoreProtectedPageContent(targetOutline?: AiCoursewareOutline) {
-  if (!targetOutline || !protectedPageContent.size) return
-  targetOutline.pages.forEach(page => {
-    const protectedContent = protectedPageContent.get(page.pageNo)
-    if (!protectedContent) return
-    page.title = protectedContent.title
-    page.subtitle = protectedContent.subtitle
-    page.bullets = [...protectedContent.bullets]
-    page.studentContent = protectedContent.studentContent
-      ? { headline: protectedContent.studentContent.headline, points: [...(protectedContent.studentContent.points || [])] }
-      : undefined
-    if (!page.classroomAction) page.classroomAction = { teacherInstruction: "", studentTask: "", interactionType: "" }
-    page.classroomAction.studentTask = protectedContent.studentTask
-    page.learningEvidence = protectedContent.learningEvidence
-    page.interaction = protectedContent.interaction
-      ? { ...protectedContent.interaction, options: protectedContent.interaction.options ? [...protectedContent.interaction.options] : undefined }
-      : undefined
-  })
-}
-
-watch(selectedTemplateId, () => {
-  paletteVariant.value = 0
-  if (outline.value) outline.value.style = selectedStyle.value
-})
-
-watch(pageRange, value => {
-  const [minPages, maxPages] = value.split("-").map(Number)
-  preferences.minPages = minPages
-  preferences.maxPages = maxPages
-})
-
-watch(
-  outline,
-  () => {
-    if (!outline.value) return
-    if (templateSelectionMode.value === "auto") selectedTemplateId.value = templateRecommendation.value.id
-  },
-  { deep: true }
-)
-
-watch(
-  () => props.visible,
-  visible => {
-    if (!visible) clearTask()
-    else if (!outline.value) void restoreSavedDraft()
-  }
-)
-
-async function handleOutline() {
-  if (!pptWork.id) {
-    ElMessage.error("当前课件ID为空")
-    return
-  }
-  outlineLoading.value = true
-  const res = await httpAjaxErrMsg(aiCoursewareOutlineApi, {
-    id: pptWork.id,
-    fromType: queryParams.pptResourcesType || queryParams.fromType,
-    extraPrompt: buildExtraPrompt(),
-    preferences: { ...preferences }
-  }).finally(() => {
-    outlineLoading.value = false
-  })
-  if (res.code === 200) {
-    protectedPageContent.clear()
-    draftOutline.value = undefined
-    draftConverted.value = undefined
-    displayedOutline.value = undefined
-    displayedConverted.value = undefined
-    displayedTemplateId.value = undefined
-    draftQuality.value = undefined
-    serverReviewMessage.value = ""
-    qualityDetailsExpanded.value = false
-    draftPreparationState.value = "idle"
-    draftFailureMessage.value = "请稍后重试"
-    outline.value = res.data as AiCoursewareOutline
-    if (templateSelectionMode.value === "auto") selectedTemplateId.value = recommendAiCoursewareTemplate(outline.value).id
-    outline.value.style = selectedStyle.value
-    outline.value.templateId = selectedTemplateId.value
-    selectedDraftPageIndex.value = 0
-    sampleFeedbackTags.value = []
-    sampleFeedbackText.value = ""
-    outlineConfirmed.value = false
-    draftPreparationState.value = "idle"
-    workflowStep.value = 2
-    await persistDraft()
-    if (reliableResearch.value) await confirmOutline()
-  }
-}
-
-async function continueToFinalPreview() {
-  workflowStep.value = 2
-  if (displayedConverted.value) {
-    draftPreparationState.value = "ready"
-    return
-  }
-  if (outlineConfirmed.value) await handleGenerate()
-}
-
-async function regenerateFinalPreview() {
-  const sourceOutline = displayedOutline.value || draftOutline.value || outline.value
-  if (!sourceOutline) return
-  draftOutline.value = cloneCoursewareData(sourceOutline)
-  restoreProtectedPageContent(draftOutline.value)
-  workflowStep.value = 2
-  await handleGenerate()
-}
-
-function publishFinalPreview() {
-  if (!draftOutline.value || !draftConverted.value) return
-  displayedOutline.value = cloneCoursewareData(draftOutline.value)
-  displayedConverted.value = cloneCoursewareData(draftConverted.value)
-  displayedTemplateId.value = selectedTemplateId.value
-  selectedDraftPageIndex.value = Math.max(0, Math.min(selectedDraftPageIndex.value, displayedConverted.value.slides.length - 1))
-  sampleFeedbackTags.value = []
-  sampleFeedbackText.value = ""
-}
-
-function handleFinalPreparationFailure(message = "自动生成和优化未能完成") {
-  if (displayedOutline.value && displayedConverted.value && displayedTemplateId.value) {
-    selectedTemplateId.value = displayedTemplateId.value
-    draftOutline.value = cloneCoursewareData(displayedOutline.value)
-    draftConverted.value = cloneCoursewareData(displayedConverted.value)
-    draftQuality.value = inspectAiPptQuality(displayedOutline.value, displayedConverted.value.slides, preferences)
-    draftPreparationState.value = "ready"
-    ElMessage.warning(`${message},已保留上一次最终预览`)
-    return
-  }
-  draftFailureMessage.value = message
-  draftPreparationState.value = "failed"
-}
-
-async function repairDraft() {
-  if (working.value || !displayedOutline.value) return
-  draftOutline.value = cloneCoursewareData(displayedOutline.value)
-  await handleGenerate(true)
-}
-
-function cloneCoursewareData<T>(value: T): T {
-  return JSON.parse(JSON.stringify(value)) as T
-}
-
-async function handleGenerate(repairOnly = false) {
-  const sourceOutline = draftOutline.value || outline.value
-  if (!sourceOutline || !pptWork.id) return
-
-  draftConverted.value = undefined
-  if (!displayedConverted.value) draftQuality.value = undefined
-  draftPreparationState.value = "optimizing"
-  draftFailureMessage.value = "请稍后重试"
-  sourceOutline.style = selectedStyle.value
-  sourceOutline.templateId = selectedTemplateId.value
-  restoreProtectedPageContent(sourceOutline)
-  const res = await httpAjaxErrMsg(aiCoursewareGenerateApi, {
-    id: pptWork.id,
-    fromType: queryParams.pptResourcesType || queryParams.fromType,
-    outline: sourceOutline,
-    templateId: selectedTemplateId.value,
-    visualPrompt: buildVisualPrompt() + (repairOnly ? `\n请在现有初稿上修复以下问题,保留无关内容及教师修改:${[serverReviewMessage.value, ...(draftQuality.value?.errors || []).map(issue => issue.message)].filter(Boolean).join(';')}` : ''),
-    repairOnly,
-    preferences: { ...preferences }
-  })
-  if (res.code !== 200) {
-    handleFinalPreparationFailure(res.message || "课件生成请求失败,请重试")
-    return
-  }
-
-  taskInfo.value = res.data as AiCoursewareTaskInfo
-  await persistDraft()
-  await pollTask(taskInfo.value.taskId)
-}
-
-async function pollTask(taskId: string) {
-  if (pollTimer.value) window.clearInterval(pollTimer.value)
-  await fetchTask(taskId)
-}
-
-async function fetchTask(taskId: string) {
+<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'
+
+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 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 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 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) }
+function remember() {
+  const id = pptWork.id, from = source(), plan = currentPlan(), prefs = clone(preferences), prompt = extraPrompt.value, taskId = task.value?.taskId
+  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) })
+}
+async function restore() {
+  if (!pptWork.id) return
+  const requestVersion = version
   try {
-  const res = await httpAjaxErrMsg(aiCoursewareTaskApi, taskId)
-  if (!props.visible || taskInfo.value?.taskId !== taskId) return
-  if (res.code !== 200) {
-    clearTask()
-    handleFinalPreparationFailure(res.message || "未能取得课件生成进度,请重试")
-    return
-  }
-  taskInfo.value = res.data as AiCoursewareTaskInfo
-  if (taskInfo.value.status === "DONE" || taskInfo.value.status === "DRAFT") {
-    clearPollTimer()
-    await loadDraft(taskId)
-  } else if (taskInfo.value.status === "FAILED") {
-    clearPollTimer()
-    const message = taskInfo.value.message || "AI课件生成失败"
-    clearTask()
-    console.error("AI课件生成失败", message)
-    handleFinalPreparationFailure(message)
-  }
-  else pollTimer.value = window.setTimeout(() => fetchTask(taskId), 2000)
-  } catch {
-    if (!props.visible || taskInfo.value?.taskId !== taskId) return
-    clearPollTimer()
-    handleFinalPreparationFailure("生成进度读取失败,可重新打开窗口恢复任务")
-  }
-}
-
-function clearTask() {
-  clearPollTimer()
-  taskInfo.value = undefined
-}
-
-function clearPollTimer() {
-  if (pollTimer.value) {
-    window.clearInterval(pollTimer.value)
-    pollTimer.value = undefined
-  }
-}
-
-async function loadDraft(taskId: string) {
-  const res = await httpAjaxErrMsg(aiCoursewareResultApi, taskId)
-  if (res.code !== 200) {
-    clearTask()
-    handleFinalPreparationFailure(res.message || "未能取得最终课件")
-    return
-  }
-
-  const result = res.data as AiCoursewareGenerateResult
-  if (!result?.outline?.pages?.length) {
-    clearTask()
-    handleFinalPreparationFailure("服务器未返回完整课件数据,请重新生成")
-    return
-  }
-  draftOutline.value = result.outline
-  serverReviewMessage.value = result.reviewMessage || ""
-  availableResources.value = Array.isArray(result.context?.resources) ? result.context.resources : []
-  restoreProtectedPageContent(draftOutline.value)
-  draftOutline.value.templateId = selectedTemplateId.value
-  selectedDraftPageIndex.value = 0
-  workflowStep.value = 2
-  clearTask()
-  await prepareDraftForUse()
-}
-
-function rebuildDraftPreview() {
-  if (!draftOutline.value) {
-    draftConverted.value = undefined
-    draftQuality.value = undefined
-    return
-  }
-  draftOutline.value.style = selectedStyle.value
-  draftOutline.value.templateId = selectedTemplateId.value
-  draftConverted.value = aiCoursewareToSlides(draftOutline.value, {
-    enableAnimations: preferences.enableAnimations,
-    paletteVariant: paletteVariant.value,
-    templateId: selectedTemplateId.value
-  })
-  draftQuality.value = inspectAiPptQuality(draftOutline.value, draftConverted.value.slides, preferences)
-}
-
-async function regenerateDraftPage(index: number) {
-  if (!draftOutline.value || !pptWork.id) return
-  if (editTeachingContent.value) protectedPageContent.delete(index + 1)
-  else if (displayedOutline.value?.pages[index]) protectPageContent(displayedOutline.value.pages[index])
-  draftPreparationState.value = "optimizing"
-  draftFailureMessage.value = "请稍后重试"
-  restoreProtectedPageContent(draftOutline.value)
-  if (sampleFeedbackTags.value.includes("画风不合适")) paletteVariant.value = (paletteVariant.value + 1) % 3
-  const feedback = [...sampleFeedbackTags.value, sampleFeedbackText.value.trim()].filter(Boolean).join(";")
-  const res = await httpAjaxErrMsg(aiCoursewareGenerateApi, {
-    id: pptWork.id,
-    fromType: queryParams.pptResourcesType || queryParams.fromType,
-    outline: draftOutline.value,
-    templateId: selectedTemplateId.value,
-    visualPrompt: `${buildVisualPrompt()}请只调整第${index + 1}页。${editTeachingContent.value ? "允许按反馈修改本页教学内容,保持整课目标不变。" : "只修改图片和版式,保持页面文字完全不变。"}${feedback ? `用户反馈:${feedback}。` : "请优化视觉组织和信息层级。"}`,
-    preferences: { ...preferences },
-    regeneratePageNo: index + 1
-  })
-  if (res.code !== 200) {
-    handleFinalPreparationFailure(res.message || "当前页面更新失败,请重试")
-    return
-  }
-  taskInfo.value = res.data as AiCoursewareTaskInfo
-  await persistDraft()
-  await pollTask(taskInfo.value.taskId)
-}
-
-async function applyDraft() {
-  if (!displayedConverted.value || !displayedOutline.value || working.value) return
-  draftPreparationState.value = "optimizing"
-  const resourceIssues = await inspectAiPptResources(displayedOutline.value, displayedConverted.value.slides)
-  draftQuality.value = inspectAiPptQuality(displayedOutline.value, displayedConverted.value.slides, preferences)
-  appendResourceIssues(resourceIssues)
-  draftPreparationState.value = "ready"
-  if (!draftQuality.value.passed || serverReviewMessage.value) return
-  previousCourseware.value = cloneCoursewareData({ title: slidesStore.title, theme: slidesStore.theme, slides: slidesStore.slides, width: slidesStore.viewportSize, ratio: slidesStore.viewportRatio })
-  if (!await persistDraft()) return
-  slidesStore.setViewportSize(1920)
-  slidesStore.setViewportRatio(0.5625)
-  slidesStore.setTitle(displayedConverted.value.title)
-  slidesStore.setTheme(displayedConverted.value.theme)
-  slidesStore.setSlides(displayedConverted.value.slides)
-  slidesStore.updateSlideIndex(0)
-  addHistorySnapshot()
-  ElMessage.success("PPT已应用,请检查后手动保存")
-  emit("update:visible", false)
-}
-
-async function adjustCurrentPage() {
-  if (!displayedConverted.value) return
-  await regenerateDraftPage(selectedDraftPageIndex.value)
-}
-
-function handleSampleBackgroundLoadState(payload: { status: "none" | "loading" | "loaded" | "error"; url: string }) {
-  sampleBackgroundState.value = payload
-  if (payload.status === "error") {
-    const page = workspacePage.value || draftOutline.value?.pages[selectedDraftPageIndex.value] || outline.value?.pages[selectedDraftPageIndex.value]
-    console.error("AI课件模板背景加载失败", {
-      templateId: selectedTemplateId.value,
-      pageNo: page?.pageNo || selectedDraftPageIndex.value + 1,
-      layout: page?.visualPlan?.layout,
-      url: payload.url
-    })
-  }
-}
-
-function toggleSampleFeedback(item: string) {
-  sampleFeedbackTags.value = sampleFeedbackTags.value.includes(item)
-    ? sampleFeedbackTags.value.filter(value => value !== item)
-    : [...sampleFeedbackTags.value, item]
-}
-
-async function selectTemplate(templateId: AiCoursewareTemplateId, fromSample = false) {
-  if (templateId === selectedTemplateId.value && displayedConverted.value) {
-    templatePickerExpanded.value = false
-    return
-  }
-  templateSelectionMode.value = "manual"
-  selectedTemplateId.value = templateId
-  if (fromSample) {
-    templatePickerExpanded.value = false
-    showAllTemplates.value = false
-  }
-  if (displayedOutline.value) {
-    draftOutline.value = cloneCoursewareData(displayedOutline.value)
-    await prepareDraftForUse()
-  }
-}
-
-async function useRecommendedTemplate() {
-  templateSelectionMode.value = "auto"
-  selectedTemplateId.value = templateRecommendation.value.id
-  if (displayedOutline.value) {
-    draftOutline.value = cloneCoursewareData(displayedOutline.value)
-    await prepareDraftForUse()
-  }
-}
-
-function templateCoverStyle(templateId: AiCoursewareTemplateId): Record<string, string> {
-  const master = getAiCoursewareTemplateMaster(templateId)
-  const cover = master.pages.find(page => page.layout === "cover") || master.pages[0]
-  const backgroundImage = resolveTemplateAssetUrl(cover?.backgroundImage)
-  if (backgroundImage) return { backgroundImage: `url("${backgroundImage}")` }
-  return { background: cover?.background || selectedTemplate.value.palettes[0].background }
-}
-
-function templateFit(templateId: AiCoursewareTemplateId) {
-  return outline.value
-    ? scoreAiCoursewareTemplate(outline.value, templateId)
-    : { templateId, score: 80, level: "usable" as const, label: "可以使用" as const, reason: "生成大纲后评估" }
-}
-
-async function prepareDraftForUse() {
-  if (!draftOutline.value) return
-  draftPreparationState.value = "optimizing"
+    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) }
+}
+function loadOutline(value: AiCoursewareOutline) {
+  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 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 = []
+}
+async function ask(message: string, confirmButtonText: string) {
+  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 = ''
   try {
-    normalizeDraftLocally()
-    restoreProtectedPageContent(draftOutline.value)
-    rebuildDraftPreview()
-    if (!draftConverted.value?.slides.length || draftConverted.value.slides.length !== draftOutline.value.pages.length) {
-      handleFinalPreparationFailure("课件页面数据不完整,请重新生成")
-      return
-    }
-    publishFinalPreview()
-    const resourceIssues = await inspectAiPptResources(displayedOutline.value!, displayedConverted.value!.slides)
-    appendResourceIssues(resourceIssues)
-    draftFailureMessage.value = "请稍后重试"
-    draftPreparationState.value = "ready"
-    await persistDraft()
-  } catch (error) {
-    console.error("AI课件预览转换失败", error)
-    handleFinalPreparationFailure(error instanceof Error ? error.message : "课件预览转换失败,请重试")
-  }
-}
-
-function normalizeDraftLocally() {
-  if (!draftOutline.value) return
-  draftOutline.value.pages.forEach((page, index) => {
-    if (!page.visualPlan) page.visualPlan = {}
-    if (!page.studentContent) page.studentContent = { headline: "", points: [] }
-
-    const movedTeacherNotes: string[] = []
-    const headline = cleanDraftText(page.studentContent.headline)
-    if (isTeacherOnlyText(headline)) movedTeacherNotes.push(headline)
-    page.studentContent.headline = isTeacherOnlyText(headline) ? "" : headline
-
-    const excluded = new Set([page.title, page.studentContent.headline].map(normalizeDraftText).filter(Boolean))
-    const pointKeys = new Set<string>()
-    const points = (page.studentContent.points?.length ? page.studentContent.points : page.bullets || [])
-      .map(item => cleanDraftText(item))
-      .filter(item => {
-        if (!item) return false
-        if (isTeacherOnlyText(item)) {
-          movedTeacherNotes.push(item)
-          return false
-        }
-        const key = normalizeDraftText(item)
-        if (!key || excluded.has(key) || pointKeys.has(key)) return false
-        pointKeys.add(key)
-        return true
-      })
-    page.studentContent.points = points
-    page.bullets = [...points]
-    page.visualPlan.visualDensity = points.length <= 2 ? "low" : points.length >= 4 ? "high" : "medium"
-
-    if (movedTeacherNotes.length) {
-      if (!page.activity) page.activity = { task: "", steps: [] }
-      page.activity.steps = uniqueDraftTexts([...(page.activity.steps || []), ...movedTeacherNotes])
-    }
-
-    if (!page.classroomAction) {
-      page.classroomAction = { teacherInstruction: "", studentTask: page.studentContent.headline || page.title, interactionType: "" }
-    }
-    const studentTask = cleanDraftText(page.classroomAction.studentTask)
-    page.classroomAction.studentTask = !studentTask || isTeacherOnlyText(studentTask) ? page.studentContent.headline || page.title : studentTask
-
-    const layout = page.visualPlan.layout || ""
-    if (["hero_focus", "media_focus", "split_content", "diagram_focus"].includes(layout)) {
-      page.visualPlan.focalContent ||= page.title
-      page.visualPlan.mainVisual ||= page.imageKeywords?.[0] || `${page.title}的课堂主视觉`
-    }
-
-    // 同一观察对象可以跨页复用;重复图只给出建议,不擅自删除教学资源。
-
-    if (index > 0) {
-      const fallbackInstruction = `引导学生完成“${page.classroomAction.studentTask || page.title}”,根据学生的语言、动作或声音表现及时反馈。`
-      const instruction = cleanDraftText(page.classroomAction.teacherInstruction || page.teacherTips || page.speakerNotes) || fallbackInstruction
-      page.classroomAction.teacherInstruction = instruction
-      page.teacherTips ||= instruction
-      page.speakerNotes ||= instruction
-    }
-  })
-}
-
-function cleanDraftText(value?: string) {
-  return String(value || "")
-    .replace(/\s+/g, " ")
-    .trim()
-}
-
-function normalizeDraftText(value?: string) {
-  return cleanDraftText(value)
-    .replace(/[,。!?、;:,.!?;:\s]+/g, "")
-    .toLowerCase()
-}
-
-function uniqueDraftTexts(values: string[]) {
-  const keys = new Set<string>()
-  return values.filter(value => {
-    const key = normalizeDraftText(value)
-    if (!key || keys.has(key)) return false
-    keys.add(key)
-    return true
-  })
-}
-
-function isTeacherOnlyText(value?: string) {
-  const text = cleanDraftText(value)
-  return Boolean(text && (TEACHER_VISIBLE_PATTERN.test(text) || PLACEHOLDER_PATTERN.test(text)))
-}
-
-function buildExtraPrompt() {
-  return extraPrompt.value.trim()
-}
-
-async function confirmOutline() {
-  if (!reliableResearch.value || working.value) return
-  outlineConfirmed.value = true
-  await handleGenerate()
-}
-
-async function refreshEditedDraft() {
-  if (!displayedOutline.value || working.value) return
-  draftOutline.value = cloneCoursewareData(displayedOutline.value)
-  await prepareDraftForUse()
-}
-
-function appendResourceIssues(issues: Awaited<ReturnType<typeof inspectAiPptResources>>) {
-  if (!draftQuality.value) return
-  draftQuality.value.issues.push(...issues)
-  draftQuality.value.errors.push(...issues)
-  draftQuality.value.passed = draftQuality.value.errors.length === 0
-}
-
-function pageHasResource(resource: AiCoursewareResourceBinding) {
-  return workspacePage.value?.resourceBindings?.some(item => item.resourceType === resource.resourceType && item.resourceId === resource.resourceId) || false
-}
-
-function toggleResource(resource: AiCoursewareResourceBinding) {
-  const page = workspacePage.value
-  if (!page || working.value) return
-  const bindings = (page.resourceBindings || []).filter(item => item.resourceType !== resource.resourceType || item.resourceId !== resource.resourceId)
-  if (!pageHasResource(resource)) bindings.push({ ...resource, verified: true, required: true, purpose: "教师为本页选择的教学资源" })
-  page.resourceBindings = bindings
-  if (resource.resourceType === "IMG") {
-    const image = bindings.find(item => item.resourceType === "IMG")
-    page.image = image ? { url: image.contentUrl || "", keyword: image.name || "", provider: "SYSTEM" } : undefined
-  }
-  refreshEditedDraft()
-}
-
-let saveQueue: Promise<unknown> = Promise.resolve()
-async function persistDraft() {
-  if (!pptWork.id) return false
-  draftSaved.value = false
-  const snapshot = cloneCoursewareData({ outline: displayedOutline.value || draftOutline.value || outline.value,
-    converted: displayedConverted.value, preferences: { ...preferences }, extraPrompt: extraPrompt.value,
-    taskId: taskInfo.value && ['PENDING', 'RUNNING'].includes(taskInfo.value.status) ? taskInfo.value.taskId : undefined,
-    reviewMessage: serverReviewMessage.value, resources: availableResources.value, backup: previousCourseware.value,
-    protectedContent: Array.from(protectedPageContent.entries()) })
-  const id = String(pptWork.id)
-  const source = queryParams.pptResourcesType || queryParams.fromType || "TEACHER"
+    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() }
+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()
+}
+async function startGeneration() {
+  if (!confirmed.value || !pptWork.id || transferring.value || task.value) return
+  const requestVersion = ++version; step.value = 3; generationError.value = ''; transferring.value = true
   try {
-    const key = await draftKey(id, source)
-    saveQueue = saveQueue.catch(() => undefined).then(() => saveAiDraft({ ...snapshot, key, updatedAt: Date.now() }))
-    await saveQueue
-    draftSaved.value = true
-    return true
-  } catch {
-    draftSaved.value = false
-    ElMessage.warning("本机草稿保存失败,请勿刷新页面,并及时手动保存课件")
-    return false
-  }
-}
-
-async function saveDraftExplicitly() {
-  if (await persistDraft()) ElMessage.success("初稿已保存在当前登录会话的本机草稿中,尚未标记为可授课")
-}
-
-async function restoreSavedDraft() {
-  if (!pptWork.id) return
-  const id = String(pptWork.id)
+    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 (props.visible && task.value) await pollTask()
+}
+async function retry() { generationError.value = ''; if (task.value) await pollTask(); else await startGeneration() }
+async function pollTask() {
+  stopPolling(); const taskId = task.value?.taskId, requestVersion = version
+  if (!taskId) return
+  const active = () => requestVersion === version && props.visible && task.value?.taskId === taskId
   try {
-    const saved = await loadAiDraft(await draftKey(id, queryParams.pptResourcesType || queryParams.fromType || "TEACHER"))
-    if (!saved?.outline || outline.value || !props.visible || String(pptWork.id) !== id) return
-    Object.assign(preferences, saved.preferences)
-    pageRange.value = `${preferences.minPages}-${preferences.maxPages}`
-    extraPrompt.value = saved.extraPrompt
-    availableResources.value = saved.resources || []
-    protectedPageContent.clear()
-    for (const [pageNo, content] of saved.protectedContent || []) protectedPageContent.set(pageNo, content)
-    previousCourseware.value = saved.backup
-    serverReviewMessage.value = saved.reviewMessage || ""
-    draftSaved.value = true
-    outline.value = saved.outline
-    selectedTemplateId.value = getAiCoursewareTemplate(saved.outline.templateId).id
-    draftOutline.value = cloneCoursewareData(saved.outline)
-    workflowStep.value = 2
-    outlineConfirmed.value = Boolean(saved.converted || saved.taskId)
-    if (saved.converted) {
-      displayedOutline.value = cloneCoursewareData(saved.outline)
-      displayedConverted.value = saved.converted
-      displayedTemplateId.value = selectedTemplateId.value
-      draftConverted.value = cloneCoursewareData(saved.converted)
-      draftQuality.value = inspectAiPptQuality(saved.outline, saved.converted.slides, preferences)
-      draftPreparationState.value = "ready"
-    }
-    if (saved.taskId) {
-      taskInfo.value = { taskId: saved.taskId, status: "RUNNING", progress: 0, message: "恢复生成进度" }
-      draftPreparationState.value = "optimizing"
-      await pollTask(saved.taskId)
-    }
-    ElMessage.success("已恢复本机AI课件草稿")
-  } catch { ElMessage.warning("无法读取本机草稿,可重新生成") }
-}
-
-function restorePreviousCourseware() {
-  const backup = previousCourseware.value
-  if (!backup || working.value) return
-  slidesStore.setTitle(backup.title)
-  slidesStore.setTheme(backup.theme)
-  slidesStore.setViewportSize(backup.width)
-  slidesStore.setViewportRatio(backup.ratio)
-  slidesStore.setSlides(cloneCoursewareData(backup.slides))
-  slidesStore.updateSlideIndex(0)
-  addHistorySnapshot()
-  ElMessage.success("已恢复应用前的课件,请手动保存")
-}
-
-function buildVisualPrompt() {
-  const base = selectedTemplate.value.visualPrompt
-  const system = `模板布局族:${selectedTemplate.value.layoutFamily}。字体体系:${selectedTemplate.value.typography}。背景体系:${selectedTemplate.value.backgroundStyle}。素材画风:${selectedTemplate.value.illustrationStyle}。`
-  const layouts = [...new Set(selectedTemplate.value.layoutCapabilities || Object.values(selectedTemplate.value.pageLayouts))].join("、")
-  const asset =
-    preferences.assetStyle === "illustration"
-      ? "主视觉使用统一画风插画。"
-      : preferences.assetStyle === "photo"
-        ? "主视觉优先真实照片。"
-        : "插画和真实照片按教学内容合理混用。"
-  const source =
-    preferences.sourceImagePolicy === "strict"
-      ? "优先保留教材原图,不随意替换。"
-      : preferences.sourceImagePolicy === "creative"
-        ? "允许根据教学目标重新创作主视觉。"
-        : "教材原图与新视觉保持平衡。"
-  const palette =
-    paletteVariant.value === 1
-      ? "配色使用清新蓝绿体系。"
-      : paletteVariant.value === 2
-        ? "配色使用柔和紫色与珊瑚色体系。"
-        : "配色保持明快且有清晰对比。"
-  return `${base}${system}可用视觉版式:${layouts}。版式只按每页真实内容需求选择,不得反向增加歌词、谱例、聆听或活动页面。模板场景:${selectedTemplate.value.scene}。${asset}${source}${palette}整套课件必须共享角色外观、环境、光线和重复道具;一页只保留一个视觉焦点。`
-}
-
-function formatPageNo(pageNo?: number) {
-  return String(pageNo || 0).padStart(2, "0")
-}
-
-function shortProgressLabel(message?: string) {
-  if (!message) return ""
-  if (message.includes("重新生成第")) return "重做样张"
-  if (message.includes("读取") || message.includes("等待")) return "准备中"
-  if (message.includes("页面") || message.includes("内容")) return "生成内容"
-  if (message.includes("优化") || message.includes("教学设计")) return "优化设计"
-  if (message.includes("图片") || message.includes("检索")) return "匹配图片"
-  if (message.includes("完成")) return "生成完成"
-  if (message.includes("失败")) return "生成失败"
-  return message.length > 4 ? message.slice(0, 4) : 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 || '课件读取失败,请继续查看')
+      await enterEditor(result.data, active)
+    } else pollTimer = setTimeout(pollTask, 2000)
+  } catch (error) { if (active()) generationError.value = error instanceof Error ? error.message : '暂时无法读取课件,请继续查看' }
+}
+async function enterEditor(result: AiCoursewareGenerateResult, active: () => boolean) {
+  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 })
+  if (!active()) 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 })
+  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 }
 }
 </script>
 
-<style lang="scss" scoped>
-.draft-notice {
-  display: flex;
-  align-items: center;
-  gap: 12px;
-  padding: 8px 12px;
-  margin: 8px 0;
-  border-radius: 6px;
-  background: #fff7e8;
-  color: #76552b;
-  font-size: 13px;
-  flex-shrink: 0;
-  > span { flex: 1; }
-  > button:not(.button) { color: #1677d2; border: 0; background: none; cursor: pointer; }
-}
-.draft-save-status { font-size: 12px; color: #7b8794; margin-right: auto; }
-.more-actions {
-  position: relative;
-  font-size: 13px;
-  summary { cursor: pointer; color: #64748b; }
-}
-.more-actions-menu {
-  position: absolute;
-  right: 0;
-  bottom: 26px;
-  z-index: 10;
-  display: flex;
-  flex-direction: column;
-  gap: 6px;
-  width: 160px;
-  padding: 8px;
-  border: 1px solid #e1e8f0;
-  border-radius: 6px;
-  background: #fff;
-  box-shadow: 0 4px 18px rgba(31, 41, 51, 0.12);
-}
-.teaching-review, .quality-panel {
-  margin: 10px 0;
-  padding: 12px 16px;
-  border: 1px solid #cbd5df;
-  background: #f8fafc;
-  border-radius: 6px;
-  line-height: 1.6;
-  max-height: 260px;
-  overflow: auto;
-  flex-shrink: 0;
-  summary { cursor: pointer; font-weight: 600; }
-  label, a { display: block; margin: 6px 0; }
-  input:not([type="checkbox"]), textarea { border: 1px solid #aebdca; padding: 6px; max-width: 100%; }
-  textarea { width: 95%; min-height: 70px; }
-  button { display: block; padding: 4px 0; background: transparent; text-align: left; color: #8b3419; cursor: pointer; }
-  span { margin-left: 12px; }
-}
-.outline-plan label { display: grid; grid-template-columns: 130px 1fr 2fr; gap: 8px; }
-.ai-dialog {
-  max-height: calc(100vh - 64px);
-  display: flex;
-  flex-direction: column;
-  color: #1f2933;
-}
-.ai-dialog.review-mode {
-  height: 82vh;
-}
-.ai-dialog.review-mode .header {
-  .title {
-    font-size: 22px;
-    line-height: 1.25;
-  }
-  .sub {
-    margin-top: 3px;
-    font-size: 13px;
-  }
-}
-.header,
-.footer {
-  display: flex;
-  align-items: center;
-  justify-content: space-between;
-}
-.header,
-.workflow-steps,
-.footer {
-  flex-shrink: 0;
-}
-.title {
-  font-size: 24px;
-  font-weight: 700;
-  color: #1f2933;
-}
-.sub {
-  margin-top: 6px;
-  color: #7b8794;
-  font-size: 14px;
-}
-.workflow-steps {
-  display: grid;
-  grid-template-columns: repeat(2, 1fr);
-  margin-top: 12px;
-  padding: 4px;
-  border-radius: 8px;
-  background: #f4f7fa;
-}
-.workflow-step {
-  position: relative;
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  gap: 8px;
-  height: 34px;
-  color: #7b8794;
-  font-size: 13px;
-  font-weight: 600;
-  &.active {
-    border-radius: 6px;
-    background: #fff;
-    color: $themeColor;
-    box-shadow: 0 2px 8px rgba(31, 41, 51, 0.08);
-  }
-  &.done {
-    color: #168f86;
-  }
-}
-.step-no {
-  display: inline-flex;
-  align-items: center;
-  justify-content: center;
-  width: 22px;
-  height: 22px;
-  border-radius: 50%;
-  background: #e7edf5;
-  font-size: 12px;
-}
-.workflow-step.active .step-no {
-  background: $themeColor;
-  color: #fff;
-}
-.workflow-step.done .step-no {
-  background: #dff3ed;
-  color: #168f86;
-}
-.setup-stage {
-  min-height: 0;
-  flex: 0 1 auto;
-  overflow: auto;
-  padding-right: 4px;
-}
-.stage-intro {
-  display: flex;
-  align-items: center;
-  justify-content: space-between;
-  gap: 20px;
-  margin-top: 16px;
-  padding: 14px 16px;
-  border-radius: 8px;
-  background: #f7faff;
-}
-.stage-title {
-  color: #1f2933;
-  font-size: 16px;
-  font-weight: 700;
-}
-.stage-desc {
-  margin-top: 4px;
-  color: #7b8794;
-  font-size: 12px;
-}
-.saved-state {
-  flex-shrink: 0;
-  padding: 5px 9px;
-  border-radius: 999px;
-  background: #eaf7f4;
-  color: #168f86;
-  font-size: 12px;
-  font-weight: 600;
-}
-.loading-strip {
-  margin-top: 12px;
-  height: 42px;
-  display: flex;
-  align-items: center;
-  gap: 12px;
-}
-.loading-strip {
-  color: #52616b;
-  font-size: 13px;
-}
-.loading-dot {
-  width: 14px;
-  height: 14px;
-  border: 2px solid #d7e7ff;
-  border-top-color: $themeColor;
-  border-radius: 50%;
-  animation: loading-rotate 0.8s linear infinite;
-}
-@keyframes loading-rotate {
-  to {
-    transform: rotate(360deg);
-  }
-}
-.extra {
-  margin-top: 12px;
-}
-.setup-primary-grid {
-  display: grid;
-  grid-template-columns: repeat(2, minmax(0, 1fr));
-  gap: 12px;
-  margin-top: 14px;
-  label > span {
-    display: block;
-    margin-bottom: 6px;
-    color: #52616b;
-    font-size: 13px;
-  }
-  select {
-    width: 100%;
-    height: 38px;
-    padding: 0 10px;
-    border: 1px solid #dfe5ec;
-    border-radius: 7px;
-    background: #fff;
-    color: #33404d;
-  }
-}
-.template-real-cover {
-  display: block;
-  width: 116px;
-  aspect-ratio: 16 / 9;
-  border-radius: 6px;
-  background-position: center;
-  background-size: cover;
-  box-shadow: inset 0 0 0 1px rgba(31, 41, 51, 0.08);
-  &.option {
-    width: 100%;
-    margin-bottom: 8px;
-  }
-}
-.compact-template-selector {
-  padding: 12px;
-  border: 1px solid #edf1f5;
-  border-radius: 8px;
-}
-.show-all-templates,
-.advanced-toggle,
-.outline-toggle {
-  margin-top: 10px;
-  padding: 0;
-  border: 0;
-  background: transparent;
-  color: $themeColor;
-  font-size: 12px;
-  cursor: pointer;
-}
-.advanced-toggle {
-  color: #657381;
-}
-.advanced-settings {
-  margin-top: 8px;
-}
-.generation-preferences {
-  margin-top: 12px;
-  padding: 12px;
-  border: 1px solid #e5eaf0;
-  border-radius: 8px;
-  background: #fafcff;
-}
-.preference-grid {
-  display: grid;
-  grid-template-columns: repeat(4, minmax(140px, 1fr));
-  gap: 10px;
-  label,
-  span {
-    display: block;
-  }
-  span {
-    margin-bottom: 5px;
-    color: #52616b;
-    font-size: 12px;
-  }
-  input,
-  select {
-    width: 100%;
-    height: 34px;
-    padding: 0 8px;
-    border: 1px solid #dfe5ec;
-    border-radius: 6px;
-    background: #fff;
-  }
-}
-.media-options {
-  display: flex;
-  gap: 18px;
-  margin-top: 10px;
-  color: #52616b;
-  font-size: 13px;
-  input {
-    margin-right: 6px;
-  }
-}
-.style-selector {
-  margin-top: 12px;
-}
-.template-selector-head {
-  display: flex;
-  align-items: flex-end;
-  justify-content: space-between;
-  gap: 16px;
-}
-.template-help {
-  margin-top: -3px;
-  color: #7b8794;
-  font-size: 12px;
-}
-.auto-template-button {
-  flex-shrink: 0;
-  padding: 5px 10px;
-  border: 1px solid #cfe0f6;
-  border-radius: 6px;
-  background: #f5f9ff;
-  color: $themeColor;
-  font-size: 12px;
-  cursor: pointer;
-}
-.template-recommendation {
-  display: flex;
-  align-items: center;
-  gap: 8px;
-  margin-top: 8px;
-  padding: 8px 10px;
-  border-radius: 6px;
-  background: #eef6ff;
-  color: #52616b;
-  font-size: 12px;
-  span:first-child {
-    padding: 2px 6px;
-    border-radius: 999px;
-    background: $themeColor;
-    color: #fff;
-  }
-  strong {
-    color: #1f2933;
-  }
-}
-.style-options {
-  display: grid;
-  grid-template-columns: repeat(3, minmax(0, 1fr));
-  gap: 10px;
-  margin-top: 9px;
-}
-.style-option {
-  min-width: 0;
-  padding: 9px;
-  border: 1px solid #dfe5ec;
-  border-radius: 8px;
-  background: #fff;
-  text-align: left;
-  cursor: pointer;
-}
-.style-option strong,
-.style-option span,
-.style-option small {
-  display: block;
-}
-.style-option > span:not(.template-mini):not(.template-card-title) {
-  margin-top: 4px;
-  color: #7b8794;
-  font-size: 12px;
-}
-.style-option small {
-  margin-top: 6px;
-  color: #52616b;
-  font-size: 11px;
-  font-weight: 600;
-}
-.style-option.active {
-  border-color: $themeColor;
-  box-shadow: 0 0 0 2px rgba($themeColor, 0.12);
-}
-.template-mini {
-  position: relative;
-  height: 66px;
-  overflow: hidden;
-  margin-bottom: 8px;
-  border-radius: 6px;
-  background: var(--template-background);
-  box-shadow: inset 0 0 0 1px rgba(31, 41, 51, 0.06);
-  i {
-    position: absolute;
-    display: block;
-  }
-  .mini-band {
-    left: 0;
-    top: 0;
-    width: 32%;
-    height: 100%;
-    background: var(--template-primary);
-  }
-  .mini-title {
-    left: 38%;
-    top: 13px;
-    width: 42%;
-    height: 7px;
-    border-radius: 4px;
-    background: var(--template-primary);
-  }
-  .mini-content {
-    left: 38%;
-    width: 52%;
-    height: 14px;
-    border-radius: 3px;
-    background: var(--template-surface);
-    box-shadow: 0 2px 5px rgba(31, 41, 51, 0.08);
-  }
-  .mini-content.first {
-    top: 29px;
-  }
-  .mini-content.second {
-    top: 47px;
-    width: 37%;
-  }
-  .mini-accent {
-    right: 7%;
-    bottom: 6px;
-    width: 11px;
-    height: 11px;
-    border-radius: 50%;
-    background: var(--template-accent);
-  }
-}
-.template-card-title {
-  display: flex !important;
-  align-items: center;
-  justify-content: space-between;
-  gap: 8px;
-  em {
-    padding: 2px 5px;
-    border-radius: 999px;
-    background: #eaf7f4;
-    color: #168f86;
-    font-size: 10px;
-    font-style: normal;
-    font-weight: 600;
-  }
-}
-.label {
-  margin-bottom: 8px;
-  font-size: 14px;
-  color: #52616b;
-}
-.outline {
-  margin-top: 10px;
-  min-height: 0;
-  flex: 1;
-  display: block;
-  overflow: auto;
-  padding-right: 4px;
-}
-.unified-review {
-  display: flex;
-  flex-direction: column;
-  overflow: hidden;
-}
-.final-preview-placeholder {
-  min-height: 320px;
-  flex: 1;
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  flex-direction: column;
-  gap: 8px;
-  margin-top: 8px;
-  border: 1px solid #e1e8f0;
-  border-radius: 8px;
-  background: #f7f9fc;
-  color: #52616b;
-  strong {
-    color: #263442;
-    font-size: 15px;
-  }
-  small {
-    color: #7b8794;
-    font-size: 12px;
-  }
-  &.failed {
-    background: #fffaf3;
-  }
-  .loading-dot {
-    width: 20px;
-    height: 20px;
-  }
-  :deep(.button.primary) {
-    min-width: 104px;
-    margin-top: 6px;
-  }
-}
-.placeholder-icon {
-  display: grid;
-  width: 28px;
-  height: 28px;
-  place-items: center;
-  border: 1px solid #e5b66d;
-  border-radius: 50%;
-  color: #a46108;
-  font-weight: 700;
-}
-.unified-review-workspace {
-  min-height: 0;
-  flex: 1;
-  display: grid;
-  grid-template-columns: 154px minmax(0, 1fr);
-  gap: 12px;
-  margin-top: 8px;
-}
-.workspace-page-list {
-  min-height: 0;
-  overflow-y: auto;
-  padding: 2px 5px 2px 2px;
-  scrollbar-width: thin;
-}
-.workspace-page-item {
-  width: 100%;
-  padding: 5px;
-  overflow: hidden;
-  border: 1px solid transparent;
-  border-radius: 7px;
-  background: transparent;
-  color: #52616b;
-  text-align: left;
-  cursor: pointer;
-  + .workspace-page-item {
-    margin-top: 5px;
-  }
-  &:hover:not(:disabled) {
-    background: #f5f8fc;
-  }
-  &.active {
-    border-color: $themeColor;
-    background: rgba($themeColor, 0.06);
-    box-shadow: 0 0 0 2px rgba($themeColor, 0.08);
-  }
-  &:disabled {
-    cursor: default;
-    opacity: 0.72;
-  }
-  :deep(.thumbnail-slide) {
-    border-radius: 4px;
-    box-shadow: 0 2px 8px rgba(31, 41, 51, 0.1);
-  }
-  > span {
-    display: block;
-    margin-top: 5px;
-    overflow: hidden;
-    font-size: 10px;
-    line-height: 1.35;
-    text-overflow: ellipsis;
-    white-space: nowrap;
-  }
-}
-.workspace-preview-column {
-  min-width: 0;
-  min-height: 0;
-  display: flex;
-  flex-direction: column;
-  overflow: auto;
-  padding: 10px;
-  border: 1px solid #dfe6ee;
-  border-radius: 8px;
-  background: #fff;
-}
-.workspace-preview-heading {
-  display: flex;
-  align-items: center;
-  justify-content: space-between;
-  gap: 12px;
-  margin-bottom: 7px;
-  > div {
-    display: flex;
-    min-width: 0;
-    align-items: baseline;
-    gap: 8px;
-  }
-  span,
-  small {
-    color: #7b8794;
-    font-size: 11px;
-  }
-  strong {
-    overflow: hidden;
-    color: #1f2933;
-    font-size: 14px;
-    text-overflow: ellipsis;
-    white-space: nowrap;
-  }
-  small {
-    flex-shrink: 0;
-  }
-}
-.workspace-canvas-shell {
-  position: relative;
-  min-height: 180px;
-  flex: 1 0 180px;
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  overflow: hidden;
-  padding: 10px;
-  border-radius: 7px;
-  background: #eef2f7;
-  :deep(.thumbnail-slide) {
-    flex-shrink: 0;
-    border-radius: 6px;
-    box-shadow: 0 10px 28px rgba(31, 41, 51, 0.15);
-  }
-}
-.workspace-processing-overlay {
-  position: absolute;
-  inset: 10px;
-  z-index: 5;
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  flex-direction: column;
-  gap: 7px;
-  border-radius: 6px;
-  background: rgba(245, 248, 252, 0.92);
-  color: #52616b;
-  backdrop-filter: blur(2px);
-  strong {
-    color: #263442;
-    font-size: 14px;
-  }
-  small {
-    color: #7b8794;
-    font-size: 11px;
-  }
-}
-.workspace-feedback {
-  flex: 0 0 auto;
-  margin-top: 8px;
-  padding-top: 8px;
-  summary { color: #52616b; font-size: 13px; cursor: pointer; }
-  &[open] summary { margin-bottom: 10px; }
-}
-.workspace-preview-column > .teaching-review {
-  flex-shrink: 0;
-  margin-bottom: 0;
-  padding: 8px 12px;
-  summary { font-size: 13px; }
-}
-.sample-stage-head {
-  display: flex;
-  align-items: center;
-  justify-content: space-between;
-  gap: 12px;
-  button {
-    height: 30px;
-    padding: 0 10px;
-    border: 1px solid #cbd9e8;
-    border-radius: 6px;
-    background: #fff;
-    color: $themeColor;
-    font-size: 12px;
-    cursor: pointer;
-  }
-}
-.sample-template-current {
-  display: flex;
-  align-items: center;
-  min-width: 0;
-  margin-right: auto;
-  gap: 7px;
-  color: #657381;
-  font-size: 12px;
-  strong {
-    overflow: hidden;
-    color: #1f2933;
-    font-size: 14px;
-    text-overflow: ellipsis;
-    white-space: nowrap;
-  }
-  small {
-    flex-shrink: 0;
-    padding: 2px 6px;
-    border-radius: 999px;
-    background: #eaf7f4;
-    color: #168f86;
-    font-size: 10px;
-    font-weight: 600;
-  }
-}
-.sample-carousel-shell {
-  display: grid;
-  grid-template-columns: 30px minmax(0, 1fr) 30px;
-  align-items: center;
-  gap: 6px;
-  margin-top: 8px;
-}
-.sample-carousel-arrow {
-  width: 30px;
-  height: 30px;
-  padding: 0;
-  border: 1px solid #d8e1eb;
-  border-radius: 50%;
-  background: #fff;
-  color: #52616b;
-  font-size: 21px;
-  line-height: 26px;
-  cursor: pointer;
-  box-shadow: 0 3px 10px rgba(31, 41, 51, 0.08);
-  &:hover:not(:disabled) {
-    border-color: $themeColor;
-    color: $themeColor;
-  }
-  &:disabled {
-    opacity: 0.28;
-    cursor: default;
-  }
-}
-.sample-thumbnail-track {
-  display: flex;
-  gap: 8px;
-  min-width: 0;
-  overflow-x: auto;
-  padding: 2px;
-  scroll-behavior: smooth;
-  scroll-snap-type: x proximity;
-  scrollbar-width: none;
-  &::-webkit-scrollbar {
-    display: none;
-  }
-}
-.sample-thumbnail-item {
-  width: 120px;
-  flex: 0 0 120px;
-  padding: 0;
-  overflow: hidden;
-  border: 2px solid transparent;
-  border-radius: 6px;
-  background: #fff;
-  color: #52616b;
-  text-align: left;
-  cursor: pointer;
-  scroll-snap-align: start;
-  box-shadow: 0 2px 7px rgba(31, 41, 51, 0.07);
-  transition:
-    border-color 0.16s ease,
-    box-shadow 0.16s ease;
-  &.active {
-    border-color: $themeColor;
-    box-shadow: 0 0 0 2px rgba($themeColor, 0.1);
-  }
-  > span {
-    display: block;
-    padding: 5px 6px 6px;
-    overflow: hidden;
-    font-size: 11px;
-    font-weight: 600;
-    text-overflow: ellipsis;
-    white-space: nowrap;
-  }
-}
-.compact-template-panel {
-  margin-top: 8px;
-  padding: 10px;
-  border: 1px solid #e5eaf0;
-  border-radius: 8px;
-  background: #fafcff;
-  .sample-template-picker {
-    grid-template-columns: repeat(4, minmax(0, 1fr));
-  }
-  .template-real-cover.option {
-    width: 86px;
-    flex-shrink: 0;
-    margin: 0;
-  }
-}
-.sample-detail-section {
-  margin-top: 10px;
-}
-.sample-detail-heading {
-  display: flex;
-  align-items: center;
-  justify-content: space-between;
-  min-height: 26px;
-  gap: 16px;
-  margin-bottom: 6px;
-  > div {
-    display: flex;
-    align-items: baseline;
-    min-width: 0;
-    gap: 8px;
-  }
-  span {
-    flex-shrink: 0;
-    color: $themeColor;
-    font-size: 12px;
-    font-weight: 700;
-  }
-  strong {
-    overflow: hidden;
-    color: #1f2933;
-    font-size: 15px;
-    text-overflow: ellipsis;
-    white-space: nowrap;
-  }
-  small {
-    overflow: hidden;
-    max-width: 48%;
-    color: #8a96a3;
-    font-size: 11px;
-    text-align: right;
-    text-overflow: ellipsis;
-    white-space: nowrap;
-  }
-}
-.sample-detail-layout {
-  display: grid;
-  grid-template-columns: minmax(260px, 0.68fr) minmax(0, 1.48fr);
-  align-items: stretch;
-  gap: 12px;
-}
-.sample-outline-panel,
-.sample-preview-column {
-  min-width: 0;
-  padding: 12px;
-  border: 1px solid #dfe6ee;
-  border-radius: 8px;
-  background: #fff;
-}
-.sample-outline-panel {
-  display: flex;
-}
-.sample-preview-column {
-  display: flex;
-  flex-direction: column;
-}
-.simplified-page-editor {
-  display: flex;
-  width: 100%;
-  flex-direction: column;
-  padding: 0;
-  overflow: visible;
-  border: 0;
-  border-radius: 0;
-  background: transparent;
-  .title-editor {
-    max-width: none;
-  }
-}
-.simplified-page-editor .field-card {
-  margin-top: 11px;
-  padding: 11px 0 0;
-  border: 0;
-  border-top: 1px solid #edf1f5;
-  border-radius: 0;
-  background: transparent;
-}
-.simplified-page-editor .page-bullets {
-  min-height: 124px;
-  height: 124px;
-}
-.content-readability-hint {
-  margin-top: 6px;
-  color: #b76e00;
-  font-size: 11px;
-  line-height: 1.45;
-}
-.representative-samples {
-  display: grid;
-  grid-template-columns: repeat(3, minmax(0, 1fr));
-  gap: 6px;
-  margin-bottom: 10px;
-  button {
-    display: grid;
-    grid-template-columns: 24px 1fr;
-    grid-template-rows: auto auto;
-    column-gap: 6px;
-    align-items: center;
-    min-width: 0;
-    padding: 7px;
-    border: 1px solid #dfe5ec;
-    border-radius: 6px;
-    background: #fff;
-    color: #52616b;
-    text-align: left;
-    cursor: pointer;
-  }
-  button.active {
-    border-color: $themeColor;
-    background: #f5f9ff;
-  }
-  button.approved span {
-    background: #1f9d69;
-  }
-  span {
-    grid-row: 1 / 3;
-    display: grid;
-    width: 24px;
-    height: 24px;
-    place-items: center;
-    border-radius: 50%;
-    background: $themeColor;
-    color: #fff;
-    font-size: 11px;
-    font-weight: 700;
-  }
-  strong,
-  small {
-    overflow: hidden;
-    text-overflow: ellipsis;
-    white-space: nowrap;
-  }
-  strong {
-    font-size: 11px;
-  }
-  small {
-    color: #8a96a3;
-    font-size: 9px;
-  }
-  &.story {
-    .mini-band {
-      width: 100%;
-      height: 100%;
-      opacity: 0.22;
-    }
-    .mini-title {
-      left: 10%;
-      top: 12px;
-      width: 46%;
-    }
-    .mini-content {
-      left: 10%;
-      background: rgba(255, 255, 255, 0.9);
-    }
-    .mini-accent {
-      right: 12%;
-      bottom: 13px;
-      width: 30px;
-      height: 30px;
-      opacity: 0.85;
-    }
-  }
-  &.activity {
-    .mini-band {
-      left: 7%;
-      top: 12px;
-      width: 18%;
-      height: 42px;
-      border-radius: 50%;
-    }
-    .mini-title {
-      left: 32%;
-      width: 48%;
-    }
-    .mini-content.first {
-      left: 32%;
-      top: 29px;
-      width: 58%;
-    }
-    .mini-content.second {
-      left: 32%;
-      top: 47px;
-      width: 45%;
-    }
-  }
-  &.technology {
-    background: var(--template-dark, #102744);
-    .mini-band {
-      width: 100%;
-      height: 3px;
-      opacity: 0.8;
-    }
-    .mini-title {
-      left: 10%;
-      background: var(--template-secondary);
-    }
-    .mini-content {
-      left: 10%;
-      width: 76%;
-      background: rgba(255, 255, 255, 0.15);
-    }
-    .mini-accent {
-      border-radius: 3px;
-    }
-  }
-  &.culture {
-    .mini-band {
-      left: 78%;
-      width: 14%;
-      height: 100%;
-      opacity: 0.18;
-    }
-    .mini-title {
-      left: 10%;
-      width: 44%;
-    }
-    .mini-content {
-      left: 10%;
-      width: 58%;
-      background: var(--template-soft);
-      box-shadow: none;
-    }
-  }
-  &.academic {
-    .mini-band {
-      width: 4%;
-    }
-    .mini-title {
-      left: 12%;
-      width: 54%;
-    }
-    .mini-content {
-      left: 12%;
-      width: 76%;
-      border-radius: 0;
-      box-shadow: none;
-    }
-    .mini-accent {
-      border-radius: 1px;
-    }
-  }
-  em.fit-usable {
-    background: #fff7e6;
-    color: #b76e00;
-  }
-  em.fit-low {
-    background: #fff1f0;
-    color: #cf1322;
-  }
-}
-.template-fit-warning {
-  display: flex;
-  align-items: center;
-  justify-content: space-between;
-  gap: 12px;
-  margin-top: 10px;
-  padding: 9px 12px;
-  border: 1px solid #ffd591;
-  border-radius: 7px;
-  background: #fffbe6;
-  color: #874d00;
-  font-size: 12px;
-  button {
-    flex: 0 0 auto;
-    border: 0;
-    background: transparent;
-    color: $themeColor;
-    font-weight: 700;
-    cursor: pointer;
-  }
-}
-.template-fit-warning.compact {
-  margin: 0 0 10px;
-}
-.template-load-error {
-  margin-bottom: 10px;
-  color: #b42318;
-  font-size: 12px;
-  overflow-wrap: anywhere;
-}
-.sample-template-picker {
-  display: grid;
-  grid-template-columns: repeat(2, minmax(0, 1fr));
-  gap: 6px;
-  margin: -2px 0 10px;
-}
-.sample-template-option {
-  display: flex;
-  align-items: center;
-  gap: 8px;
-  min-width: 0;
-  padding: 7px 8px;
-  border: 1px solid #dfe5ec;
-  border-radius: 6px;
-  background: #fff;
-  text-align: left;
-  cursor: pointer;
-  &.active {
-    border-color: $themeColor;
-    background: #f5f9ff;
-  }
-  > span:last-child {
-    min-width: 0;
-  }
-  strong,
-  small {
-    display: block;
-  }
-  strong {
-    overflow: hidden;
-    color: #33404d;
-    font-size: 11px;
-    text-overflow: ellipsis;
-    white-space: nowrap;
-  }
-  small {
-    margin-top: 2px;
-    color: #8a96a3;
-    font-size: 9px;
-  }
-}
-.template-swatches {
-  display: flex;
-  flex-shrink: 0;
-  overflow: hidden;
-  width: 34px;
-  height: 28px;
-  border-radius: 4px;
-  i {
-    flex: 1;
-  }
-}
-.sample-page-control {
-  display: grid;
-  grid-template-columns: auto minmax(0, 1fr) auto;
-  gap: 7px;
-  button,
-  select {
-    height: 32px;
-    border: 1px solid #dfe5ec;
-    border-radius: 6px;
-    background: #fff;
-    color: #52616b;
-    font-family: inherit;
-    font-size: 12px;
-  }
-  button {
-    padding: 0 10px;
-    cursor: pointer;
-  }
-  button:disabled {
-    cursor: not-allowed;
-    opacity: 0.45;
-  }
-  select {
-    min-width: 0;
-    padding: 0 8px;
-  }
-}
-.sample-feedback {
-  margin-top: auto;
-  padding-top: 10px;
-  border-top: 1px solid #e5eaf0;
-}
-.feedback-label {
-  color: #52616b;
-  font-size: 12px;
-  font-weight: 600;
-}
-.feedback-chips {
-  display: flex;
-  flex-wrap: wrap;
-  gap: 6px;
-  margin-top: 6px;
-  button {
-    height: 26px;
-    padding: 0 9px;
-    border: 1px solid #dfe5ec;
-    border-radius: 999px;
-    background: #fff;
-    color: #52616b;
-    font-family: inherit;
-    font-size: 11px;
-    cursor: pointer;
-  }
-  button.active {
-    border-color: $themeColor;
-    background: rgba($themeColor, 0.08);
-    color: $themeColor;
-  }
-}
-.sample-feedback textarea {
-  width: 100%;
-  height: 48px;
-  margin: 0;
-  padding: 8px 10px;
-  border: 1px solid #dfe5ec;
-  border-radius: 6px;
-  box-sizing: border-box;
-  resize: none;
-  font-family: inherit;
-  font-size: 12px;
-  line-height: 1.5;
-}
-.sample-feedback-action {
-  display: grid;
-  grid-template-columns: minmax(0, 1fr) auto;
-  align-items: end;
-  gap: 8px;
-  margin-top: 6px;
-  :deep(.button) {
-    height: 34px;
-    line-height: 34px;
-    white-space: nowrap;
-  }
-}
-.sample-canvas {
-  position: relative;
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  overflow: auto;
-  min-height: 0;
-  flex: 1 1 auto;
-  padding: 10px;
-  border: 0;
-  border-radius: 6px;
-  background: #f1f4f8;
-  :deep(.thumbnail-slide) {
-    flex-shrink: 0;
-    border-radius: 6px;
-    box-shadow: 0 8px 24px rgba(31, 41, 51, 0.14);
-  }
-}
-.result-preview {
-  min-height: 0;
-  flex: 1;
-  margin-top: 14px;
-  display: flex;
-  flex-direction: column;
-  overflow: hidden;
-}
-.draft-status {
-  display: flex;
-  align-items: center;
-  gap: 12px;
-  padding: 12px 14px;
-  border: 1px solid #dce8f7;
-  border-radius: 10px;
-  background: #f6f9fe;
-  color: #35516f;
-  &.ready {
-    border-color: #cce8df;
-    background: #f0faf7;
-    color: #16776f;
-  }
-  &.failed {
-    border-color: #f3d7b2;
-    background: #fff8ee;
-    color: #9a5b08;
-  }
-  > div {
-    min-width: 0;
-    flex: 1;
-  }
-  strong,
-  span {
-    display: block;
-  }
-  strong {
-    color: #1f2933;
-    font-size: 14px;
-  }
-  > div > span {
-    margin-top: 3px;
-    font-size: 12px;
-  }
-}
-.draft-status-icon {
-  width: 28px;
-  height: 28px;
-  flex-shrink: 0;
-  display: inline-flex !important;
-  align-items: center;
-  justify-content: center;
-  border: 2px solid currentColor;
-  border-radius: 50%;
-  font-size: 15px !important;
-  font-weight: 800;
-  .optimizing & {
-    border-color: #c6d7ee;
-    border-top-color: $themeColor;
-    animation: loading-rotate 0.8s linear infinite;
-  }
-}
-.draft-workspace {
-  min-height: 0;
-  flex: 1;
-  display: grid;
-  grid-template-columns: 170px minmax(0, 1fr);
-  gap: 16px;
-  margin-top: 12px;
-}
-.draft-page-list {
-  min-height: 0;
-  overflow-y: auto;
-  padding: 3px 7px 3px 3px;
-}
-.draft-page-item {
-  width: 100%;
-  padding: 6px;
-  border: 1px solid transparent;
-  border-radius: 8px;
-  background: transparent;
-  color: #52616b;
-  text-align: left;
-  cursor: pointer;
-  transition:
-    border-color 0.16s ease,
-    background 0.16s ease;
-  + .draft-page-item {
-    margin-top: 7px;
-  }
-  &:hover {
-    background: #f5f8fc;
-  }
-  &.active {
-    border-color: $themeColor;
-    background: rgba($themeColor, 0.06);
-    box-shadow: 0 0 0 2px rgba($themeColor, 0.08);
-  }
-  :deep(.thumbnail-slide) {
-    border-radius: 5px;
-    box-shadow: 0 3px 10px rgba(31, 41, 51, 0.1);
-  }
-  > span {
-    display: block;
-    margin-top: 6px;
-    overflow: hidden;
-    font-size: 11px;
-    line-height: 1.4;
-    text-overflow: ellipsis;
-    white-space: nowrap;
-  }
-}
-.draft-preview-pane {
-  min-width: 0;
-  min-height: 0;
-  display: flex;
-  flex-direction: column;
-}
-.draft-preview-toolbar {
-  display: flex;
-  align-items: center;
-  justify-content: space-between;
-  gap: 14px;
-  margin-bottom: 8px;
-  span,
-  strong {
-    display: block;
-  }
-  span {
-    color: #7b8794;
-    font-size: 11px;
-  }
-  strong {
-    margin-top: 2px;
-    color: #1f2933;
-    font-size: 14px;
-  }
-  button {
-    padding: 7px 11px;
-    border: 1px solid #d9e2ec;
-    border-radius: 6px;
-    background: #fff;
-    color: #52616b;
-    cursor: pointer;
-  }
-}
-.draft-canvas-shell {
-  min-width: 0;
-  min-height: 0;
-  flex: 1;
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  overflow: auto;
-  padding: 18px;
-  border: 1px solid #e2e8f0;
-  border-radius: 10px;
-  background: #eef2f7;
-  :deep(.thumbnail-slide) {
-    flex-shrink: 0;
-    border-radius: 7px;
-    box-shadow: 0 14px 34px rgba(31, 41, 51, 0.16);
-  }
-}
-.page-actions {
-  display: grid;
-  gap: 7px;
-  padding: 8px;
-  select,
-  input {
-    min-width: 0;
-    height: 30px;
-    padding: 0 7px;
-    border: 1px solid #dfe5ec;
-    border-radius: 5px;
-    font-size: 12px;
-  }
-  > div {
-    display: flex;
-    flex-wrap: wrap;
-    gap: 5px;
-  }
-  button {
-    padding: 4px 7px;
-    border: 1px solid #dfe5ec;
-    border-radius: 5px;
-    background: #fff;
-    color: #52616b;
-    font-size: 11px;
-    cursor: pointer;
-  }
-  button:disabled {
-    cursor: not-allowed;
-    opacity: 0.5;
-  }
-}
-.page-nav {
-  overflow: auto;
-  border: 1px solid #edf1f5;
-  border-radius: 8px;
-  background: #fafcff;
-  padding: 8px;
-}
-.nav-item {
-  width: 100%;
-  display: flex;
-  align-items: center;
-  gap: 10px;
-  min-height: 42px;
-  padding: 8px 10px;
-  border: 0;
-  border-radius: 6px;
-  background: transparent;
-  color: #52616b;
-  text-align: left;
-  cursor: pointer;
-  &.active {
-    background: #eaf4ff;
-    color: #1f2933;
-    .nav-no {
-      color: #fff;
-      background: $themeColor;
-    }
-  }
-}
-.nav-no {
-  width: 34px;
-  height: 28px;
-  line-height: 28px;
-  text-align: center;
-  border-radius: 5px;
-  color: #7b8794;
-  background: #edf1f5;
-  flex-shrink: 0;
-  font-size: 12px;
-  font-weight: 600;
-}
-.nav-title {
-  min-width: 0;
-  overflow: hidden;
-  text-overflow: ellipsis;
-  white-space: nowrap;
-  font-size: 13px;
-  font-weight: 600;
-}
-.page-editor {
-  min-width: 0;
-  overflow: auto;
-  border: 1px solid #edf1f5;
-  border-radius: 8px;
-  background: #fff;
-  padding: 16px;
-}
-.editor-head {
-  display: block;
-}
-.title-editor {
-  max-width: 460px;
-}
-.outline-field-label {
-  display: block;
-  margin-bottom: 8px;
-  color: #52616b;
-  font-size: 13px;
-  font-weight: 600;
-}
-.purpose-card {
-  margin-top: 12px;
-}
-.field-grid {
-  margin-top: 12px;
-  display: grid;
-  grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
-  gap: 12px;
-}
-.field-card {
-  padding: 12px;
-  border: 1px solid #edf1f5;
-  border-radius: 8px;
-  background: #fbfcfe;
-  &.visual {
-    background: #fffaf3;
-  }
-}
-.field-title {
-  margin-bottom: 8px;
-  color: #52616b;
-  font-size: 13px;
-  font-weight: 600;
-}
-.page-title,
-.purpose-input,
-.headline-input,
-.page-bullets,
-.page-design,
-.keyword-input,
-.remark-input {
-  width: 100%;
-  border: 1px solid #d9d9d9;
-  border-radius: 6px;
-  box-sizing: border-box;
-  font-family: inherit;
-}
-.page-title {
-  height: 36px;
-  padding: 0 10px;
-  font-size: 16px;
-  font-weight: 600;
-}
-.purpose-input {
-  height: 36px;
-  padding: 0 10px;
-  font-size: 14px;
-}
-.headline-input {
-  height: 36px;
-  padding: 0 10px;
-  margin-bottom: 10px;
-  font-size: 14px;
-}
-.page-bullets,
-.page-design {
-  height: 150px;
-  padding: 10px;
-  resize: vertical;
-  line-height: 1.5;
-}
-.full-field {
-  margin-top: 12px;
-}
-.page-design {
-  height: 90px;
-}
-.resource-card {
-  margin-top: 12px;
-  padding: 10px 12px;
-  border: 1px solid #dcefe9;
-  border-radius: 6px;
-  background: #f6fbf9;
-}
-.resource-head {
-  display: flex;
-  align-items: baseline;
-  gap: 10px;
-}
-.resource-hint {
-  color: #7b8794;
-  font-size: 12px;
-}
-.resource-list {
-  display: flex;
-  flex-wrap: wrap;
-  gap: 8px;
-}
-.resource-item {
-  display: flex;
-  align-items: center;
-  min-width: 0;
-  height: 30px;
-  padding: 0 10px;
-  border: 1px solid #cfe6de;
-  border-radius: 4px;
-  background: #fff;
-  font-size: 12px;
-}
-.resource-type {
-  margin-right: 8px;
-  color: #168f86;
-  font-weight: 600;
-}
-.resource-name {
-  max-width: 240px;
-  overflow: hidden;
-  color: #374151;
-  text-overflow: ellipsis;
-  white-space: nowrap;
-}
-.keyword-card {
-  margin-top: 12px;
-}
-.keyword-head {
-  display: flex;
-  align-items: baseline;
-  gap: 10px;
-  margin-bottom: 8px;
-}
-.keyword-label {
-  color: #52616b;
-  font-size: 13px;
-  font-weight: 600;
-}
-.keyword-hint {
-  color: #9aa5b1;
-  font-size: 12px;
-}
-.keyword-input {
-  height: 34px;
-  padding: 0 10px;
-}
-.remark-card {
-  margin-top: 12px;
-  padding: 10px 12px;
-  border-radius: 6px;
-  background: #f7f9fc;
-}
-.remark-head {
-  display: flex;
-  align-items: baseline;
-  gap: 10px;
-  margin-bottom: 8px;
-}
-.remark-title {
-  color: #52616b;
-  font-size: 13px;
-  font-weight: 600;
-}
-.remark-hint {
-  color: #9aa5b1;
-  font-size: 12px;
-}
-.remark-input {
-  height: 72px;
-  padding: 10px;
-  resize: vertical;
-  line-height: 1.5;
-  background: #fff;
-}
-.task {
-  min-height: 20px;
-}
-.footer {
-  margin-top: 14px;
-  padding-top: 12px;
-  border-top: 1px solid #edf1f5;
-  background: #fff;
-  justify-content: flex-end;
-  gap: 10px;
-  :deep(.button.primary) {
-    min-width: 154px;
-    height: 40px;
-    line-height: 40px;
-    font-size: 14px;
-    font-weight: 700;
-  }
-}
-.ai-dialog.review-mode .footer {
-  margin-top: 10px;
-  padding-top: 10px;
-  :deep(.button.primary) {
-    height: 36px;
-    line-height: 36px;
-  }
-}
-
-@media (max-width: 900px) {
-  .ai-dialog.review-mode {
-    height: calc(100vh - 64px);
-  }
-  .setup-primary-grid,
-  .sample-detail-layout {
-    grid-template-columns: 1fr;
-  }
-  .sample-canvas {
-    justify-self: center;
-    max-width: 100%;
-  }
-  .sample-preview-column {
-    order: -1;
-  }
-  .compact-template-panel .sample-template-picker {
-    grid-template-columns: repeat(2, minmax(0, 1fr));
-  }
-  .draft-workspace {
-    grid-template-columns: 1fr;
-  }
-  .unified-review-workspace {
-    grid-template-columns: 132px minmax(0, 1fr);
-  }
-  .workspace-page-item :deep(.thumbnail-slide) {
-    max-width: 100%;
-  }
-  .draft-page-list {
-    display: flex;
-    gap: 8px;
-    overflow-x: auto;
-    overflow-y: hidden;
-    padding: 3px 3px 8px;
-  }
-  .draft-page-item {
-    width: 160px;
-    flex-shrink: 0;
-    + .draft-page-item {
-      margin-top: 0;
-    }
-  }
-}
-
-@media (max-width: 640px) {
-  .workflow-step {
-    gap: 4px;
-    font-size: 12px;
-  }
-  .step-no {
-    width: 20px;
-    height: 20px;
-  }
-  .sample-carousel-shell {
-    grid-template-columns: 30px minmax(0, 1fr) 30px;
-    gap: 5px;
-  }
-  .sample-carousel-arrow {
-    width: 30px;
-    height: 30px;
-  }
-  .sample-feedback-action {
-    grid-template-columns: 1fr;
-  }
-  .sample-detail-heading {
-    align-items: flex-start;
-    flex-direction: column;
-    gap: 3px;
-    small {
-      max-width: none;
-      text-align: left;
-      white-space: normal;
-    }
-  }
-  .compact-template-panel .sample-template-picker {
-    grid-template-columns: 1fr;
-  }
-  .draft-status,
-  .draft-preview-toolbar,
-  .sample-stage-head {
-    align-items: flex-start;
-    flex-direction: column;
-  }
-  .unified-review-workspace {
-    grid-template-columns: 1fr;
-  }
-  .workspace-page-list {
-    display: flex;
-    gap: 6px;
-    overflow-x: auto;
-    overflow-y: hidden;
-    padding-bottom: 6px;
-  }
-  .workspace-page-item {
-    width: 146px;
-    flex: 0 0 146px;
-    + .workspace-page-item {
-      margin-top: 0;
-    }
-  }
-  .workspace-preview-heading {
-    align-items: flex-start;
-    flex-direction: column;
-    gap: 3px;
-  }
-  .footer {
-    flex-wrap: wrap;
-  }
-}
+<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; } }
 </style>

+ 20 - 2
src/views/Editor/EditorHeader/index.vue

@@ -23,7 +23,7 @@
                 aiCoursewareVisible = true
               }
             "
-            >AI生成PPT</PopoverMenuItem
+            >AI生成课件</PopoverMenuItem
           >
           <PopoverMenuItem
             @click="
@@ -55,6 +55,16 @@
     </div>
 
     <div class="right">
+      <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>
+          </div>
+        </template>
+        <button class="generation-notice">需核对 {{ generationIssues.length }} 项</button>
+      </Popover>
       <div class="cancelBtn" @click="handleClose">退出</div>
       <div class="saveBtn" @click="handleSave">保存课件</div>
     </div>
@@ -63,7 +73,7 @@
       <HotkeyDoc />
       <template v-slot:title>快捷操作</template>
     </Drawer>
-    <AiCoursewareDialog v-model:visible="aiCoursewareVisible" />
+    <AiCoursewareDialog v-model:visible="aiCoursewareVisible" @generated="generationIssues = $event" />
     <FullscreenSpin :loading="fullscreenSpinData.loading" :progress="fullscreenSpinData.progress" :tip="fullscreenSpinData.tip" />
   </div>
 </template>
@@ -82,6 +92,7 @@ import Drawer from "@/components/Drawer.vue"
 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 { ElMessageBox } from "element-plus"
 import usePptWork from "@/store/pptWork"
@@ -97,6 +108,7 @@ const { resetSlides } = useSlideHandler()
 const mainMenuVisible = ref(false)
 const hotkeyDrawerVisible = ref(false)
 const aiCoursewareVisible = ref(false)
+const generationIssues = ref<AiPptQualityIssue[]>([])
 
 const spinType = ref<"import" | "export">("import")
 /* 导入 */
@@ -222,4 +234,10 @@ 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; }
 </style>

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