yonge 19 hours ago
parent
commit
01815a0fdc

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

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


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


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


+ 1 - 1
dist/assets/index--cdcFgok.js → dist/assets/index-CfDur8x1.js

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

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


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


+ 1 - 1
dist/assets/index-Dn8Bix9c.js → dist/assets/index-D2YeDB8-.js

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

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

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

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

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


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

@@ -1,4 +1,4 @@
-import{cG as k,bB as $1}from"./index-fQW7Shzm.js";import{ah as Z,ai as H1,aj as U,ak as n0,al as c0,am as i0,an as j1,ao as K1,ap as Y1,aq as R}from"./index-Dn8Bix9c.js";var z1={exports:{}};/*!
+import{cG as k,bB as $1}from"./index-Cgncrfd-.js";import{ah as Z,ai as H1,aj as U,ak as n0,al as c0,am as i0,an as j1,ao as K1,ap as Y1,aq as R}from"./index-D2YeDB8-.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-fQW7Shzm.js"></script>
+    <script type="module" crossorigin src="./assets/index-Cgncrfd-.js"></script>
     <link rel="stylesheet" crossorigin href="./assets/index-AavUEaYy.css">
   </head>
   <body>

+ 12 - 3
scripts/aiPptDialogTest.html

@@ -21,6 +21,7 @@ const scenario = new URLSearchParams(location.search).get("scenario") || "normal
 setToken(`local-ai-ppt-regression-${scenario}`)
 const generated = ref(0)
 const outlineCalls = ref(0), savedCalls = ref(0), submitted = ref(null)
+const savedText = ref("")
 let pollCount = 0
 const repairs = ref(0)
 const historicalPolls = ref(0)
@@ -33,6 +34,7 @@ const outline = {
   title: "声音强弱课堂测试", gradeName: "一年级", textbookName: "本地测试教材", chapterName: "声音的强弱", courseType: "music", templateId: "ethnic-flower",
   textbookResearch: { exactMatch: true, confidence: "HIGH", contentSummary: "仅用于软件回归的教学夹具", teachingObjectives: ["能用声音表现强弱"], sources: [{ title: "本地测试依据(非真实教材认证)", url: "https://example.com/test-only" }] },
   pages: types.map((type, index) => ({ pageNo:index+1, type, title: index === 0 ? "声音强弱课堂测试" : `任务${index}:表现声音强弱`,
+    teachingBrief: index === 0 ? "从山谷故事引入,邀请学生寻找身边的声音" : "听两组声音,辨别强弱,再用拍手模仿,并说明判断理由",
     studentContent:{ points:["用声音表现强弱变化", "说出判断的理由"] }, durationMinutes:index===0?8:4, objectiveIds:["1"],
     teachingPurpose:"表现声音强弱", learningEvidence:"能表现强弱并说明理由", speakerNotes:"示范后请学生练习,根据表现及时反馈。",
     classroomAction:{studentTask:"完成声音强弱挑战", teacherInstruction:"示范强弱对比,邀请学生练习后互评"},
@@ -58,7 +60,13 @@ httpAxios.axioseRquest = async config => {
   }
   else if (config.url.endsWith("/result")) {
     data={outline:JSON.parse(JSON.stringify(submitted.value?.outline || outline)),context:{resources:[]}}
-    data.outline.pages.forEach(page => page.objectiveIds=["1"])
+    data.outline.pages.forEach((page, index) => {
+      const fixture = outline.pages[index % outline.pages.length]
+      Object.assign(page, { ...structuredClone(fixture), pageNo: index + 1, title: page.title, teachingBrief: page.teachingBrief })
+      page.studentContent = { points: ['哪种声音更轻?', '试着用拍手表现出来'] }
+      page.bullets = [...page.studentContent.points]
+      page.objectiveIds=["1"]
+    })
     if (scenario.includes("draft") && !repaired) {
       data.outline.pages.forEach(page => page.objectiveIds=[])
       data.reviewMessage="声音强弱的练习与评价尚未完整关联"
@@ -84,12 +92,13 @@ const app=createApp({setup(){
   const slidesStore=useSlidesStore()
   const pptWork=usePptWork()
   // 仅隔离存储边界,按钮、编辑器和课件转换均使用正式组件。
-  pptWork.updatePPT=async()=>{savedCalls.value++;pptWork.isSave=true}
+  pptWork.updatePPT=async()=>{savedText.value=slidesStore.slides.flatMap(slide=>slide.elements.filter(el=>el.type==="text").map(el=>el.content.replace(/<[^>]+>/g,""))).join("、");savedCalls.value++;pptWork.isSave=true}
   if (!scenario.startsWith("editor")) nextTick(()=>{visible.value=true})
   return ()=>h("main",[
     runtimeError.value ? h("pre", runtimeError.value) : null,
+    savedCalls.value ? h("p",{id:"saved-text"},`保存内容:${savedText.value}`):null,
     h("p",{id:"test-status"},`大纲请求:${outlineCalls.value};生成请求次数:${generated.value};完善请求:${repairs.value};保存请求:${savedCalls.value};历史失败任务查询:${historicalPolls.value};已应用页数:${slidesStore.slides.length};资源检查:${resourceCheck.value}`),
-    submitted.value ? h("p",{id:"submitted-plan"},`提交的目标:${submitted.value.outline.textbookResearch.teachingObjectives.join("、")};提交的标题:${submitted.value.outline.pages.map(p=>p.title).join("、")};页数:${submitted.value.outline.pages.length};已确认:${submitted.value.outlineConfirmed}`):null,
+    submitted.value ? h("p",{id:"submitted-plan"},`模式:${submitted.value.outlineMode};提交的摘要:${submitted.value.outline.pages.map(p=>p.teachingBrief).join("、")};旧正文数量:${submitted.value.outline.pages.filter(p=>p.studentContent?.points?.length || p.bullets?.length).length};页数:${submitted.value.outline.pages.length}`):null,
     scenario.startsWith("editor") ? h("div",{style:"height:calc(100vh - 65px)"},[h(Editor)]) : h("button",{onClick:()=>{visible.value=true}},"打开AI课件"),
     scenario.startsWith("editor") ? null : h(AiDialog,{visible:visible.value,"onUpdate:visible":value=>{visible.value=value}}),
     h("button",{onClick:()=>useSnapshotStore().unDo()},"测试撤销")

+ 21 - 18
scripts/aiPptOutlineTest.mjs

@@ -4,28 +4,31 @@ import assert from 'node:assert/strict'
 const vite = await createServer({ server: { middlewareMode: true }, appType: 'custom', logLevel: 'error' })
 try {
   const { editableOutlinePages, confirmedCoursewareOutline, inspectConfirmedOutline } = await vite.ssrLoadModule('/src/utils/aiCoursewareOutline.ts')
-  const outline = { title: '课堂', textbookResearch: { teachingObjectives: ['旧目标'] }, pages: Array.from({ length: 8 }, (_, i) => ({ pageNo: i + 1, type: i ? 'practice_check' : 'cover', title: `页面${i}`, durationMinutes: 5, bullets: ['旧内容'], imageKeywords: [], objectiveIds: ['1'] })) }
+  const { aiCoursewareToSlides } = await vite.ssrLoadModule('/src/utils/aiCoursewareToSlides.ts')
+  const outline = { title: '课堂', pages: Array.from({ length: 8 }, (_, i) => ({ pageNo: i + 1, type: i ? 'practice_check' : 'cover', title: `页面${i}`, teachingBrief: '比较声音并模仿', durationMinutes: 5, bullets: ['旧正文'], studentContent: { points: ['旧正文'] }, imageKeywords: [] })) }
   const pages = editableOutlinePages(outline)
-  pages[1].page.title = '老师修改的标题'
-  pages[1].pointsText = ' 听辨强弱\n\n用拍手表现 '
+  pages[1].page.title = '听辨小挑战'
+  pages[1].brief = ' 听两组声音,辨别强弱,再用拍手模仿 '
   pages.splice(2, 0, pages.splice(1, 1)[0])
-  const confirmed = confirmedCoursewareOutline(outline, ' 能分辨声音强弱\n能用拍手表现 ', pages)
-  assert.deepEqual(inspectConfirmedOutline(confirmed, 40), [])
-  assert.deepEqual(confirmed.textbookResearch.teachingObjectives, ['能分辨声音强弱', '能用拍手表现'])
-  assert.equal(confirmed.pages[2].title, '老师修改的标题')
+  const confirmed = confirmedCoursewareOutline(outline, pages)
+  assert.deepEqual(inspectConfirmedOutline(confirmed), []) // 无需教师填写目标与分钟数
+  assert.equal(confirmed.pages[2].title, '听辨小挑战')
+  assert.equal(confirmed.pages[2].teachingBrief, '听两组声音,辨别强弱,再用拍手模仿')
   assert.deepEqual(confirmed.pages.map(p => p.pageNo), [1,2,3,4,5,6,7,8])
-  assert.deepEqual(confirmed.pages[2].studentContent.points, ['听辨强弱','用拍手表现'])
-  assert.deepEqual(confirmed.pages[2].objectiveIds, [])
+  assert.equal(confirmed.pages[2].studentContent, undefined)
+  assert.equal(confirmed.pages[2].durationMinutes, undefined)
+  assert.deepEqual(confirmed.pages[2].bullets, [])
   assert.equal(outline.pages[1].title, '页面1')
   const invalid = structuredClone(confirmed)
   invalid.pages[1].title = ''
-  invalid.pages[2].durationMinutes = 0
-  invalid.textbookResearch.teachingObjectives = []
-  assert.ok(inspectConfirmedOutline(invalid, 40).some(s => s.includes('教学目标')))
-  assert.ok(inspectConfirmedOutline(invalid, 40).some(s => s.includes('第2页标题')))
-  assert.ok(inspectConfirmedOutline(invalid, 40).some(s => s.includes('第3页用时')))
-  const optional = structuredClone(confirmed)
-  optional.pages.push({ ...optional.pages[1], pageNo: 9, durationMinutes: 4, optional: true })
-  assert.deepEqual(inspectConfirmedOutline(optional, 40), [])
-  console.log('PASS: edited goals, 8-page outline, reordered pages, preserved content, invalid inputs, optional timing and source isolation')
+  invalid.pages[2].teachingBrief = ''
+  assert.equal(inspectConfirmedOutline(invalid).length, 2)
+  const legacy = editableOutlinePages({ pages: [{ title: '旧大纲', bullets: ['保留老师旧修改'], imageKeywords: [] }] })
+  assert.equal(legacy[0].brief, '保留老师旧修改')
+  // 摘要不渲染进学生画面;扩写后的短句仍然显示。
+  const result = aiCoursewareToSlides({ ...outline, pages: [{ ...outline.pages[0], teachingBrief: '仅供教师审阅的摘要', studentContent: { points: ['听一听哪种声音更轻'] }, bullets: ['听一听哪种声音更轻'] }] })
+  const text = JSON.stringify(result.slides)
+  assert.ok(text.includes('听一听哪种声音更轻'))
+  assert.ok(!text.includes('仅供教师审阅的摘要'))
+  console.log('PASS: editable teaching plan, no manual goals/timing, preserved order, legacy migration, separate slide content')
 } finally { await vite.close() }

+ 3 - 0
src/api/aiCourseware.ts

@@ -18,6 +18,7 @@ export interface AiCoursewareGenerateQuery {
   regeneratePageNo?: number
   repairOnly?: boolean
   outlineConfirmed?: boolean
+  outlineMode?: 'teaching_plan'
 }
 
 export interface AiCoursewarePreferences {
@@ -81,6 +82,8 @@ export interface AiCoursewareTextbookResearch {
 }
 
 export interface AiCoursewarePage {
+  /** 教师确认的教学安排,指导扩写,不直接作为学生可见正文。 */
+  teachingBrief?: string
   pageNo: number
   type: string
   title: string

+ 20 - 27
src/utils/aiCoursewareOutline.ts

@@ -2,55 +2,48 @@ 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
+  brief: 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")
+    // 兼容之前保存的大纲;新的大纲直接使用独立的教学摘要。
+    brief:
+      page.teachingBrief ||
+      (page.studentContent?.points?.length ? page.studentContent.points : page.bullets || []).join(";") ||
+      page.teachingPurpose ||
+      ""
   }))
 }
 
-export function confirmedCoursewareOutline(outline: AiCoursewareOutline, goals: string, pages: EditableOutlinePage[]): AiCoursewareOutline {
+export function confirmedCoursewareOutline(outline: AiCoursewareOutline, 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
-  })
+  // 不携带旧正文、课堂任务或用时,防止老师调整主题后仍沿用旧内容。
+  result.pages = pages.map(({ page, brief }, index) => ({
+    pageNo: index + 1,
+    type: page.type,
+    title: page.title.trim(),
+    teachingBrief: brief.trim(),
+    bullets: [],
+    imageKeywords: []
+  }))
   return result
 }
 
-export function inspectConfirmedOutline(outline: AiCoursewareOutline, lessonMinutes: number): string[] {
+export function inspectConfirmedOutline(outline: AiCoursewareOutline): 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分钟的整数`)
+    if (!page.title.trim()) issues.push(`请填写第${index + 1}页标题`)
+    if (!page.teachingBrief?.trim()) issues.push(`请简要说明第${index + 1}页准备讲什么或开展什么活动`)
   })
-  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
 }

+ 2 - 2
src/utils/aiPptQuality.ts

@@ -287,7 +287,7 @@ function inspectTeachingContract(outline: AiCoursewareOutline, slides: Slide[],
       push(issues, "teaching_chain", "error", no, "请补齐教学目的、学生任务、教师操作和可观察的学习表现")
     }
     if (preferences) {
-      if (!Number.isFinite(page.durationMinutes) || Number(page.durationMinutes) <= 0) push(issues, "missing_duration", "error", no, "请填写本页用时")
+      if (!Number.isFinite(page.durationMinutes) || Number(page.durationMinutes) <= 0) push(issues, "missing_duration", "error", no, "本页课堂安排尚未完成自动复核,请检查活动是否完整")
       else if (!page.optional) minutes += Number(page.durationMinutes)
     }
   })
@@ -295,7 +295,7 @@ function inspectTeachingContract(outline: AiCoursewareOutline, slides: Slide[],
     const id = String(index + 1)
     if (!taught.has(id) || !assessed.has(id)) push(issues, "objective_coverage", "error", undefined, `“${objectives[index]}”缺少完整的练习与评价安排,请核对并补充相应课堂任务`)
   })
-  if (preferences && Math.abs(minutes - preferences.lessonMinutes) > 2) push(issues, "lesson_duration", "error", undefined, `必讲页面合计${minutes}分钟,与${preferences.lessonMinutes}分钟课时不符(允许2分钟机动)`)
+  if (preferences && Math.abs(minutes - preferences.lessonMinutes) > 2) push(issues, "lesson_duration", "error", undefined, "整课内容量尚未通过自动复核,请检查活动数量与难度是否适合本节课")
 }
 
 function qualityScores(outline: AiCoursewareOutline, issues: AiPptQualityIssue[]) {

+ 1 - 1
src/utils/aiPptTemplateEngine.ts

@@ -775,7 +775,7 @@ function activitySteps(page: AiCoursewarePage) {
 }
 
 function slideRemark(page: AiCoursewarePage) {
-  const lessonMeta = [page.teachingStage, page.durationMinutes ? `建议用时:${page.durationMinutes}分钟` : ""].filter(Boolean).join(" · ")
+  const lessonMeta = page.teachingStage || ""
   const evidence = page.learningEvidence ? `学习表现:${page.learningEvidence}` : ""
   const activity = [page.activity?.task, ...(page.activity?.steps || [])].map(cleanText).filter(Boolean).join("\n")
   const fallbackInstruction = cleanText(page.classroomAction?.teacherInstruction || page.teacherTips || page.speakerNotes)

+ 130 - 113
src/views/Editor/EditorHeader/AiCoursewareDialog.vue

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

+ 5 - 0
src/views/components/element/ProsemirrorEditor.vue

@@ -67,6 +67,9 @@ const handleFocus = () => {
 }
 
 const handleBlur = () => {
+  // 离开正文编辑区时立即同步,确保随后点击保存取得完整内容。
+  handleInput()
+  handleInput.flush()
   mainStore.setDisableHotkeysState(false)
   emit('blur')
 }
@@ -276,6 +279,8 @@ const handleMouseup = () => {
 onMounted(() => {
   editorView = initProsemirrorEditor((editorViewRef.value as Element), textContent.value, {
     handleDOMEvents: {
+      input: () => handleInput(),
+      compositionend: () => handleInput(),
       focus: handleFocus,
       blur: handleBlur,
       keydown: handleKeydown,

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