yonge 3 дней назад
Родитель
Сommit
c80fde3866

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
dist/assets/aiPptRenderedQuality-CvEFUOrk.js


+ 1 - 1
dist/assets/index-BfIIFDEk.js → dist/assets/index-B92TRPPS.js

@@ -1 +1 @@
-import{u as c,ag as l}from"./index-CaSBib0C.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-RiSr9OZv.js";import"./shapes-DT_pEMTC.js";const S=()=>{function o(a){if(window.parent===window)return;const{api:e,playState:n}=a.data||{};e==="headerTogge"?window.parent.postMessage({api:"headerTogge",playState:n},"*"):e==="changeTogge"?window.parent.postMessage({api:"changeTogge"},"*"):["clickTempo","clickViewFigner"].includes(e)&&window.parent.postMessage({api:e},"*")}p(()=>{window.addEventListener("message",o)}),d(()=>{window.removeEventListener("message",o)})},_=g({__name:"mobileScreen",setup(o){S();const a=m(),e=t(!0),n=t(!1),i=c();return a.setScreenMode("mobileScreen"),i.initPPTData().then(()=>{e.value=!1}),(v,s)=>(r(),u("div",{class:"mobileScreen",onClick:s[0]||(s[0]=b=>n.value=!1)},[e.value?w("",!0):(r(),f(l,{key:0}))]))}}),E=k(_,[["__scopeId","data-v-90b2692a"]]);export{E as default};
+import{u as c,ag as l}from"./index-DMS84AVO.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-DIgXwPMu.js";import"./shapes-CUaFgXE3.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};

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
dist/assets/index-BDoATKp-.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
dist/assets/index-BZqZX3m_.css


+ 1 - 1
dist/assets/index-CzgQ-iMI.js → dist/assets/index-BokSsAGL.js

@@ -1 +1 @@
-import{u as s,ag as r}from"./index-CaSBib0C.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-RiSr9OZv.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-DMS84AVO.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-DIgXwPMu.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};

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
dist/assets/index-C57VK9so.css


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
dist/assets/index-CUi6YEmt.css


Разница между файлами не показана из-за своего большого размера
+ 1 - 0
dist/assets/index-D4UJJkaA.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 1
dist/assets/index-D69u9cwK.js


Разница между файлами не показана из-за своего большого размера
+ 1 - 1
dist/assets/index-DIgXwPMu.js


Разница между файлами не показана из-за своего большого размера
+ 1 - 1
dist/assets/index-DMS84AVO.js


+ 1 - 1
dist/assets/index-C_svNBzM.js → dist/assets/index-DYTjdmo1.js

@@ -1 +1 @@
-import{_ as t}from"./404-R0y5dkvZ.js";import{E as c}from"./index-CpK-2Fiq.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,bQ as m,bR as f,bH as u}from"./index-RiSr9OZv.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-BDoATKp-.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,bK as m,bL as f,bH as u}from"./index-DIgXwPMu.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};

+ 1 - 1
dist/assets/index-bX-RTEeC.js → dist/assets/index-dw5X9YHY.js

@@ -1 +1 @@
-import{_ as a}from"./404-R0y5dkvZ.js";import{a5 as o,ep as t,ae as r,af as c,bQ as _,bR as d,ak as s,bH as p}from"./index-RiSr9OZv.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,ep as t,ae as r,af as c,bK as _,bL as d,ak as s,bH as p}from"./index-DIgXwPMu.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};

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
dist/assets/index-yR9LmUSJ.css


+ 1 - 1
dist/assets/shapes-DT_pEMTC.js → dist/assets/shapes-CUaFgXE3.js

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

+ 171 - 0
scripts/aiPptDraftSelfTest.mjs

@@ -0,0 +1,171 @@
+import assert from 'node:assert/strict'
+import { createServer } from 'vite'
+import { createRenderer, reactive, nextTick, h, ssrContextKey } from 'vue'
+import { renderToString } from '@vue/server-renderer'
+import { editorialFixture } from './fixtures/aiPptEditorial.mjs'
+
+// 测试实际对话框的续作/草稿状态流程。浏览器排版和媒体探测以明确结果模拟,
+// 此脚本不代表浏览器视觉验收,不访问生成服务或线上课件。
+const clone = value => JSON.parse(JSON.stringify(value))
+const testKey = '__aiDraftRegression'
+const stubs = {
+  '/src/store': `export const useSlidesStore=()=>globalThis.${testKey}.slides;
+    export const useMainStore=()=>({setActiveElementIdList(){}});
+    export const useSnapshotStore=()=>({async addSnapshot(){globalThis.${testKey}.snapshots.push(JSON.stringify(globalThis.${testKey}.slides.slides))}});`,
+  '/src/store/pptWork': `export default ()=>globalThis.${testKey}.work;`,
+  '/src/queryParams': 'export default {fromType:"TEACHER"};',
+  '/src/plugins/httpAjax': `export const httpAjax=(...args)=>globalThis.${testKey}.request(...args);`,
+  '/src/api/aiCourseware': `export const aiCoursewareGenerateApi='generate',aiCoursewareOutlineTaskApi='outline',aiCoursewareResultApi='result',aiCoursewareTaskApi='task';`,
+  '/src/utils/aiCoursewareDraft': `export const draftKey=async()=> 'draft';
+    export const loadAiDraft=async()=>globalThis.${testKey}.saved;
+    export const saveAiDraft=async value=>{globalThis.${testKey}.saved=JSON.parse(JSON.stringify(value))};`,
+  '/src/utils/aiPptRenderedQuality': `export const inspectAiPptRenderedText=async()=>globalThis.${testKey}.renderedIssues;`,
+  '/src/utils/aiPptResources': `export const inspectAiPptResources=async()=>globalThis.${testKey}.resourceIssues;`,
+  '/src/utils/aiPptClientVersion': 'export const assertAiPptClientCurrent=async()=>{};',
+  '/src/components/Modal.vue': 'export default {};',
+  '/src/components/Button.vue': 'export default {};',
+  '/src/views/components/ThumbnailSlide/index.vue': `import { h } from 'vue'; export default {
+    props:['slide','size','viewportSize','viewportRatio'], render(){return h('div',{'data-draft-canvas':this.viewportSize,'data-ratio':this.viewportRatio,'data-elements':this.slide.elements.length})}};`,
+  'element-plus': `export const ElMessage={warning:text=>globalThis.${testKey}.messages.push(text),success:text=>globalThis.${testKey}.messages.push(text)};
+    export const ElMessageBox={confirm:async()=>{if(!globalThis.${testKey}.confirm) throw new Error('cancel')}};`,
+  'vuedraggable': 'export default {};'
+}
+const stubKeys = Object.keys(stubs)
+const vite = await createServer({ server: { middlewareMode: true }, appType: 'custom', logLevel: 'error',
+  optimizeDeps: { noDiscovery: true, entries: [] }, ssr: { noExternal: ['element-plus', 'vuedraggable'] }, plugins: [{
+  name: 'draft-flow-test-stubs', enforce: 'pre',
+  resolveId(id) {
+    const normalized = id.replaceAll('\\', '/').replace(/^@\//, '/src/')
+    const index = stubKeys.findIndex(key => normalized === key || key.startsWith('/src/') && (normalized.endsWith(key) || normalized.endsWith(`${key}.ts`)))
+    if (index >= 0) return `\0draft-test:${index}.js`
+  },
+  load(id) { if (id.startsWith('\0draft-test:')) return stubs[stubKeys[parseInt(id.slice('\0draft-test:'.length))]] }
+}] })
+const renderer = createRenderer({
+  createElement: () => ({}), createText: () => ({}), createComment: () => ({}),
+  insert() {}, remove() {}, setText() {}, setElementText() {}, patchProp() {},
+  parentNode: () => null, nextSibling: () => null
+})
+const result = () => {
+  const outline = editorialFixture()
+  const page = outline.pages[1]
+  page.type = 'scene_intro'
+  page.requiredVisual = true
+  page.imageStatus = 'generation_failed'
+  page.imageReview = '未找到符合本页教学要求的图片,请补充或替换图片'
+  page.visualPlan = { composition: 'image_right', visualMode: 'generated_image', imageNecessity: 'essential', imagePurpose: 'scene', mainVisual: '口风琴课堂练习' }
+  return { outline, context: {}, blockingMessage: '第2页配图尚未完成', reviewMessage: '第3页:请补充对比音频' }
+}
+let app
+try {
+  const { default: Dialog } = await vite.ssrLoadModule('/src/views/Editor/EditorHeader/AiCoursewareDialog.vue')
+  const { default: Preview } = await vite.ssrLoadModule('/src/views/Editor/EditorHeader/AiCoursewareDraftPreview.vue')
+  const mount = () => {
+    app?.unmount()
+    const state = globalThis[testKey] = {
+      slides: reactive({ title: '原课件', slides: [{ id: 'old', elements: [] }], theme: {}, viewportSize: 1000, viewportRatio: .75, slideIndex: 0,
+        setTitle(v){this.title=v},setSlides(v){this.slides=v},setTheme(v){this.theme=v},setViewportSize(v){this.viewportSize=v},setViewportRatio(v){this.viewportRatio=v},updateSlideIndex(v){this.slideIndex=v} }),
+      work: reactive({ id: 'local-test', isSave: true }), snapshots: [], messages: [], calls: [], emitted: [], confirm: true,
+      renderedIssues: [], resourceIssues: [], saved: undefined, result: result(),
+      async request(kind, payload) {
+        this.calls.push({ kind, payload: clone(payload) })
+        return { code: 200, data: kind === 'generate' ? { taskId: 'continued', status: 'PENDING' }
+          : kind === 'task' ? { taskId: 'continued', status: 'DRAFT', message: '第2页配图尚未完成' } : clone(this.result) }
+      }
+    }
+    app = renderer.createApp({ ...Dialog, render: () => null }, { visible: true,
+      onGenerated: (...args) => state.emitted.push(args), 'onUpdate:visible': () => {} })
+    app.provide(ssrContextKey, {})
+    app.mount({})
+    return { state, dialog: app._instance.setupState }
+  }
+  const stageDraft = async dialog => {
+    dialog.loadOutline(clone(globalThis[testKey].result.outline))
+    dialog.task = { taskId: 'draft-result', status: 'DRAFT', message: '第2页配图尚未完成' }
+    await dialog.enterEditor(clone(globalThis[testKey].result), () => true)
+    await nextTick()
+  }
+
+  let { state, dialog } = mount()
+  await stageDraft(dialog)
+  assert(dialog.completionSource && dialog.showCompletionContent, '未完成任务自动展示预览')
+  const before = JSON.stringify(state.slides)
+  const input = clone(dialog.completionSource)
+  const html = await renderToString(h(Preview, { outline: input, templateId: 'blank', message: dialog.generationError }))
+  assert(html.includes('data-draft-canvas="1920"') && html.includes('data-ratio="0.5625"'), '草稿有独立的16:9画布')
+  assert(html.includes('口风琴吹奏与表现') && html.includes('aria-label="配图待完善"'), '预览显示课题和缺图标记')
+  assert.equal(JSON.stringify(state.slides), before, '预览不改原课件和画布尺寸')
+  assert.deepEqual(input, clone(dialog.completionSource), '预览不修改生成结果')
+  assert.equal(state.calls.length, 0, '预览不调用生成服务')
+  await dialog.retry()
+  await nextTick()
+  assert(dialog.completionSource && dialog.showCompletionContent, '重试仍失败后保留逐页预览')
+  assert.equal(state.calls.filter(c => c.kind === 'generate').length, 1)
+  assert.equal(state.calls[0].payload.outline.pages.length, 6)
+  const callsBeforeOpening = state.calls.length
+  await dialog.openCompletionDraft()
+  assert.equal(state.slides.slides.length, 6, '缺图不阻止显式打开草稿')
+  assert.equal(state.slides.viewportSize, 1920)
+  assert.equal(state.slides.viewportRatio, .5625)
+  assert.equal(state.work.isSave, false)
+  assert.equal(state.calls.length, callsBeforeOpening, '打开草稿无需重新生成')
+  assert.equal(state.snapshots.length, 2, '替换前后均保留可撤销快照')
+  assert.equal(JSON.parse(state.snapshots[0])[0].id, 'old')
+  assert(state.emitted[0][0].some(issue => issue.code === 'missing_required_visual' && issue.pageNo === 2 && issue.severity === 'error'))
+  assert(state.emitted[0][0].some(issue => issue.pageNo === 3 && issue.message.includes('音频')), '后端审核建议保留')
+  assert(state.messages.some(message => message.startsWith('草稿已打开')))
+
+  ;({ state, dialog } = mount())
+  await stageDraft(dialog)
+  state.renderedIssues = [{ code: 'rendered_text_overlap', pageNo: 1, severity: 'error', message: '正文重叠' }]
+  await dialog.openCompletionDraft()
+  assert.equal(state.slides.slides[0].id, 'old', '打开草稿仍保留排版检查')
+  assert(dialog.completionSource && dialog.generationError.includes('文字过密'), '排版失败仍保留草稿供预览')
+  assert.equal(state.emitted.length, 0)
+
+  ;({ state, dialog } = mount())
+  state.saved = { outline: clone(state.result.outline), preferences: { minPages: 6, maxPages: 6 },
+    completionSource: clone(state.result.outline), completionMessage: state.result.blockingMessage,
+    reviewMessage: state.result.reviewMessage, taskId: 'expired', extraPrompt: '' }
+  state.request = async () => ({ code: 404, message: '任务已过期' })
+  await dialog.restore()
+  await nextTick()
+  assert(dialog.completionSource && dialog.showCompletionContent, '过期任务可从本地恢复预览')
+  assert.equal(dialog.task, undefined)
+  await dialog.openCompletionDraft()
+  assert.equal(state.slides.slides.length, 6, '无需有效任务ID即可打开保存的草稿')
+  assert(state.emitted[0][0].some(issue => issue.pageNo === 3 && issue.message.includes('音频')), '恢复后仍保留审核建议')
+
+  ;({ state, dialog } = mount())
+  state.result.outline.pages[1].image = { url: 'https://example.com/failed.png', width: 800, height: 600 }
+  state.result.outline.pages[1].imageStatus = 'ready'
+  await stageDraft(dialog)
+  state.resourceIssues = [{ code: 'resource_unavailable', pageNo: 2, severity: 'error', message: '图片加载失败' }]
+  await dialog.openCompletionDraft()
+  assert.equal(state.slides.slides.length, 6)
+  assert(state.emitted[0][0].some(issue => issue.code === 'resource_unavailable'), '资源失败仍显示在草稿问题中')
+  assert.equal(state.emitted[0][1].pages[1].image, undefined, '后续完善不能复用已经加载失败的图片')
+  assert.equal(state.emitted[0][1].pages[1].imageStatus, 'generation_failed')
+
+  ;({ state, dialog } = mount())
+  await stageDraft(dialog)
+  state.slides.slides[0].elements.push({ id: 'manual', type: 'text', content: '老师的新修改' })
+  state.confirm = false
+  await dialog.openCompletionDraft()
+  assert.equal(state.slides.slides[0].elements[0].content, '老师的新修改', '取消替换保留新修改')
+  assert(dialog.completionSource, '取消替换保留生成草稿')
+  assert.equal(state.snapshots.length, 0)
+
+  ;({ state, dialog } = mount())
+  await stageDraft(dialog)
+  dialog.preferences.lessonTopic = { gradeName: '一年级', textbookName: '另一教材', unitName: '', chapterName: '另一课题' }
+  await dialog.openCompletionDraft()
+  assert.equal(state.slides.slides[0].id, 'old', '显式打开也不能混用课题')
+  assert(dialog.generationError.includes('不符'))
+
+  console.log('PASS: 草稿自动预览、独立画布、重复缺图续作、打开编辑、问题保留、快照、排版阻断、取消替换和课题校验')
+} finally {
+  app?.unmount()
+  await vite.close()
+  delete globalThis[testKey]
+}

+ 1 - 1
scripts/aiPptSelfTest.mjs

@@ -499,7 +499,7 @@ try {
   const missingPlannedImage = structuredClone(modelFixture)
   missingPlannedImage.pages[1].image = undefined
   missingPlannedImage.pages[1].imageStatus = 'budget_exhausted'
-  assert(inspectAiPptQuality(missingPlannedImage, render(missingPlannedImage)).issues.some(issue => issue.pageNo === 2 && issue.severity === 'error' && issue.message.includes('继续完成课件')))
+  assert(inspectAiPptQuality(missingPlannedImage, render(missingPlannedImage)).issues.some(issue => issue.pageNo === 2 && issue.severity === 'error' && issue.message.includes('打开草稿补图')))
   const repeatedSlides = structuredClone(modelSlides)
   repeatedSlides[2] = structuredClone(modelSlides[1])
   repeatedSlides[3] = structuredClone(modelSlides[1])

+ 3 - 3
src/utils/aiPptFeedback.ts

@@ -6,10 +6,10 @@ export function imagePreparationMessage(page: AiCoursewarePage) {
   if (page.imageReview && /配置不完整|配置关闭|配置不受支持/.test(page.imageReview))
     return '配图服务尚未就绪,请管理员检查图片服务配置。已保留课件内容,配置恢复后可继续完成。'
   const messages: Record<string, string> = {
-    budget_exhausted: '本页配图尚未完成,继续完成课件时将补齐。',
+    budget_exhausted: '本轮配图额度已用完,可继续自动完善,或打开草稿补图。',
     review_rejected: '本页配图审核未通过,系统需重新生成符合教学要求的图片。',
-    reference_missing: '本页所需的准确教材或实物图片尚未取得,课件尚未完成。',
-    generation_failed: '本页配图多次尝试后仍未成功,已保留内容,可继续完成课件。',
+    reference_missing: '本页需要准确的教材或实物图片,请打开草稿补充图片。',
+    generation_failed: '本页配图多次尝试后仍未成功,可打开草稿补图,或再次尝试自动完善。',
     review_unavailable: '本页图片尚未完成核对,请检查角色外观、数量与动作是否符合教学要求。'
   }
   return messages[page.imageStatus || ''] || '本页配图尚未完成,已保留内容,可继续完成课件。'

+ 56 - 57
src/views/Editor/EditorHeader/AiCoursewareDialog.vue

@@ -19,7 +19,7 @@
           >{{ label }}
         </li>
       </ol>
-      <main>
+      <main ref="mainContent">
         <section v-if="step === 1" class="setup">
           <h3>这节课准备怎么上?</h3>
           <label>课题来源<select :value="preferences.lessonTopic ? 'custom' : 'current'" :disabled="outlineLoading" @change="changeTopicSource">
@@ -178,33 +178,15 @@
             </button>
           </div>
         </section>
-        <section v-else class="generation" aria-live="polite">
-          <template v-if="generationError"
+        <section v-else class="generation" :class="{ 'showing-preview': generationError && completionSource && showCompletionContent }" aria-live="polite">
+          <AiCoursewareDraftPreview v-if="generationError && completionSource && showCompletionContent"
+            :outline="completionSource" :template-id="templateId" :message="generationError" />
+          <template v-else-if="generationError"
             ><span class="error-icon">!</span>
             <h3>{{ completionSource ? `已保留 ${completionSource.pages.length} 页,部分内容待完善` : task ? "课件进度暂时无法继续" : "这次生成未完成" }}</h3>
             <p>{{ generationError }}</p>
-            <p class="hint">{{ completionSource ? /配置不完整|配置关闭|配置不受支持/.test(generationError) ? '可以先查看已生成内容;配图服务恢复后再点击“继续自动完善”。' : '可以先查看已生成内容,再点击“继续自动完善”。系统会沿用当前结果处理,当前课件没有被替换。' : '已保留确认的大纲,当前课件没有被替换。' }}</p>
-            <div v-if="completionSource && showCompletionContent" class="completion-content">
-              <h4>已生成内容</h4>
-              <p>这里可查看各页文字和教学备注。</p>
-              <details v-for="page in completionSource.pages" :key="page.pageNo">
-                <summary>第 {{ page.pageNo }} 页 · {{ page.title }}</summary>
-                <p v-if="page.studentContent?.headline">{{ page.studentContent.headline }}</p>
-                <p v-if="page.classroomAction?.studentTask">{{ page.classroomAction.studentTask }}</p>
-                <ul><li v-for="(point, index) in (page.studentContent?.points || page.bullets || [])" :key="index">{{ point }}</li></ul>
-                <ol><li v-for="(item, index) in page.studentContent?.steps" :key="index">{{ item }}</li></ol>
-                <p v-if="page.studentContent?.example">示例:{{ page.studentContent.example }}</p>
-                <div v-for="(group, index) in page.studentContent?.comparisonGroups" :key="`group-${index}`">
-                  <p>{{ group.label }}</p>
-                  <ul><li v-for="(point, pointIndex) in group.points" :key="pointIndex">{{ point }}</li></ul>
-                </div>
-                <p v-if="page.interaction?.question">课堂提问:{{ page.interaction.question }}</p>
-                <ul><li v-for="(option, index) in page.interaction?.options" :key="index">{{ option }}</li></ul>
-                <p v-if="page.classroomAction?.modelExample" class="completion-notes">教学示范:{{ page.classroomAction.modelExample }}</p>
-                <p v-if="page.classroomAction?.storyScript" class="completion-notes">故事讲稿:{{ page.classroomAction.storyScript }}</p>
-                <p v-if="page.speakerNotes || page.teacherTips" class="completion-notes">教学备注:{{ page.speakerNotes || page.teacherTips }}</p>
-              </details>
-            </div></template
+            <p class="hint">{{ completionSource ? '可以查看逐页预览,或打开草稿编辑并补图。自动完善仍未成功时,已生成的页面仍可使用。' : '已保留确认的大纲,当前课件没有被替换。' }}</p>
+            </template
           ><template v-else
             ><span class="spinner"></span>
             <h3>正在生成课件</h3>
@@ -229,9 +211,10 @@
         </div>
         <div v-else-if="generationError">
           <Button v-if="!transferring" @click="backToSettings">修改需求</Button>
-          <Button v-if="completionSource" :aria-expanded="showCompletionContent" @click="showCompletionContent = !showCompletionContent">{{ showCompletionContent ? "收起已生成内容" : "查看已生成内容" }}</Button>
-          <Button v-if="!task" @click="step = 2">返回修改大纲</Button
-          ><Button type="primary" :disabled="transferring" @click="retry">{{ completionSource ? "继续自动完善" : layoutRepairSource ? "精简并重新排版" : task ? "继续查看课件" : "重试生成" }}</Button>
+          <Button v-if="completionSource" :disabled="transferring" :aria-expanded="showCompletionContent" @click="showCompletionContent = !showCompletionContent">{{ showCompletionContent ? "收起预览" : "查看已生成内容" }}</Button>
+          <Button v-if="!task && !completionSource" @click="step = 2">返回修改大纲</Button>
+          <Button :type="completionSource ? 'default' : 'primary'" :disabled="transferring" @click="retry">{{ layoutRepairSource && layoutRepairFeedback ? "精简并重新排版" : completionSource ? "继续自动完善" : task ? "继续查看课件" : "重试生成" }}</Button>
+          <Button v-if="completionSource" type="primary" :disabled="transferring" @click="openCompletionDraft">{{ transferring ? "正在打开…" : "打开草稿编辑" }}</Button>
         </div>
       </footer>
     </div>
@@ -246,6 +229,7 @@ import Draggable from "vuedraggable"
 import { nanoid } from "nanoid"
 import Modal from "@/components/Modal.vue"
 import Button from "@/components/Button.vue"
+import AiCoursewareDraftPreview from './AiCoursewareDraftPreview.vue'
 import queryParams from "@/queryParams"
 import usePptWork from "@/store/pptWork"
 import { useMainStore, useSlidesStore, useSnapshotStore } from "@/store"
@@ -321,8 +305,14 @@ const task = ref<AiCoursewareTaskInfo>(),
 const layoutRepairSource = ref<AiCoursewareOutline>()
 const layoutRepairFeedback = ref('')
 const completionSource = ref<AiCoursewareOutline>()
+const completionReviewMessage = ref('')
+const mainContent = ref<HTMLElement>()
 const showCompletionContent = ref(false)
-watch(completionSource, () => { showCompletionContent.value = false })
+watch(completionSource, value => {
+  showCompletionContent.value = !!value
+  if (!value) completionReviewMessage.value = ''
+  else void nextTick(() => { if (mainContent.value) mainContent.value.scrollTop = 0 })
+})
 const outlineTask = ref<AiCoursewareTaskInfo>(), outlineTaskSettings = ref('')
 let pollTimer: ReturnType<typeof setTimeout> | undefined,
   version = 0,
@@ -407,6 +397,7 @@ function remember() {
     pendingOutlineSettings = outlineTaskSettings.value,
     pendingCompletion = completionSource.value ? clone(completionSource.value) : undefined,
     completionMessage = generationError.value,
+    reviewMessage = completionReviewMessage.value,
     savedSettings = outlineSettings.value
   if (!id) return
   saveQueue = saveQueue
@@ -422,6 +413,7 @@ function remember() {
         outlineTaskSettings: pendingOutlineSettings,
         completionSource: pendingCompletion,
         completionMessage,
+        reviewMessage,
         outlineSettings: savedSettings
       })
     })
@@ -469,6 +461,7 @@ async function restore() {
     } else if (saved.completionSource) {
       confirmed.value = currentPlan()
       completionSource.value = clone(saved.completionSource)
+      completionReviewMessage.value = saved.reviewMessage || ''
       generationError.value = saved.completionMessage || '课件尚未全部完成,可接着完成。'
       step.value = 3
       if (saved.taskId) {
@@ -673,6 +666,8 @@ async function startGeneration() {
   if (props.visible && task.value) await pollTask()
 }
 async function retry() {
+  if (transferring.value) return
+  if (layoutRepairSource.value && layoutRepairFeedback.value) { await repairBlockedLayout(); return }
   if (completionSource.value) {
     if (task.value && (['PENDING', 'RUNNING'].includes(task.value.status)
         || isAdvisoryCompletion(generationError.value.split(';已保留')[0]))) { generationError.value = ''; await pollTask() }
@@ -783,23 +778,38 @@ async function pollTask() {
     }
   }
 }
-async function enterEditor(result: AiCoursewareGenerateResult, active: () => boolean) {
+async function openCompletionDraft() {
+  if (transferring.value || !completionSource.value) return
+  const requestVersion = ++version
+  stopPolling()
+  const active = () => requestVersion === version && props.visible
+  transferring.value = true
+  try {
+    await enterEditor({ outline: clone(completionSource.value), context: {},
+      blockingMessage: generationError.value, reviewMessage: completionReviewMessage.value }, active, true)
+  } catch (error) {
+    if (active()) generationError.value = error instanceof Error ? error.message : '暂时无法打开草稿,已保留生成内容。'
+  } finally {
+    if (requestVersion === version) transferring.value = false
+  }
+}
+async function enterEditor(result: AiCoursewareGenerateResult, active: () => boolean, allowIncompleteDraft = false) {
   if (!result?.outline?.pages?.length) throw new Error("尚未获得课件内容,请继续查看")
   if (!matchesRequestedTopic(result.outline)) throw new Error('生成结果与本次教材或课题不符,原课件已保留,请重新生成大纲。')
   await assertAiPptClientCurrent()
   if (!active()) return
-  if (task.value?.status === 'DRAFT') {
+  if (task.value?.status === 'DRAFT' && !allowIncompleteDraft) {
     if (isAdvisoryCompletion(result.blockingMessage)) {
       // 历史任务保留DRAFT状态时无需再次调用生成;让正常渲染与缺图校验决定能否进入。
       result = { ...result, reviewMessage: [result.reviewMessage, result.blockingMessage].filter(Boolean).join(';'), blockingMessage: '' }
     } else {
       completionSource.value = clone(result.outline)
+      completionReviewMessage.value = result.reviewMessage || ''
       generationError.value = coursewareCompletionMessage(result, task.value.message)
       remember()
       return
     }
   }
-  completionSource.value = undefined
   layoutRepairSource.value = clone(result.outline)
   layoutRepairFeedback.value = ''
   let converted: ReturnType<typeof aiCoursewareToSlides>
@@ -830,21 +840,25 @@ async function enterEditor(result: AiCoursewareGenerateResult, active: () => boo
   const imageIssues = blockingImageIssues(issues)
   if (imageIssues.length) {
     if (!active()) return
-    completionSource.value = clone(result.outline)
+    result = { ...result, outline: clone(result.outline) }
     // 地址存在但实际加载失败的图片不能在续作时误当作成功配图复用。
     imageIssues.filter(issue => issue.code === 'resource_unavailable').forEach(issue => {
-      const page = completionSource.value?.pages.find(page => page.pageNo === issue.pageNo)
+      const page = result.outline.pages.find(page => page.pageNo === issue.pageNo)
       if (page) {
         const failedUrl = page.image?.url
         page.image = undefined; page.imageStatus = 'generation_failed'
         page.resourceBindings = page.resourceBindings?.filter(binding => binding.resourceType !== 'IMG' || binding.contentUrl !== failedUrl)
       }
     })
-    generationError.value = `第${[...new Set(imageIssues.map(issue => issue.pageNo))].join('、')}页配图尚未完成,已保留生成内容。`
-    remember()
-    return
+    if (!allowIncompleteDraft) {
+      completionSource.value = result.outline
+      completionReviewMessage.value = result.reviewMessage || ''
+      generationError.value = `第${[...new Set(imageIssues.map(issue => issue.pageNo))].join('、')}页配图尚未完成,已保留生成内容。`
+      remember()
+      return
+    }
   }
-  const draft = issues.some(issue => issue.severity === 'error')
+  const draft = allowIncompleteDraft || issues.some(issue => issue.severity === 'error')
   if (!active()) return
   if (
     replacementSnapshot !== editorSnapshot() &&
@@ -885,7 +899,7 @@ async function enterEditor(result: AiCoursewareGenerateResult, active: () => boo
     remember()
     emit("generated", issues, clone({ ...result.outline, templateId: templateId.value }), clone(preferences))
     emit("update:visible", false)
-    if (draft) ElMessage.warning("草稿已打开,内容仍需完善;可查看问题并点击“完善整课”。")
+    if (draft) ElMessage.warning("草稿已打开,待完善问题已保留。可逐页编辑、补图,完成后保存课件。")
     else ElMessage.success("课件已生成,可直接编辑,完成后点击“保存课件”")
   } catch (error) {
     if (changed) {
@@ -1153,24 +1167,7 @@ button:focus-visible {
 .generation p {
   max-width: 650px;
 }
-.completion-content {
-  width: 100%;
-  max-width: 850px;
-  margin-top: 20px;
-  padding: 18px;
-  text-align: left;
-  background: #fff;
-  border: 1px solid #e2e9f1;
-  border-radius: 8px;
-}
-.completion-content details { padding: 12px 0; border-top: 1px solid #e2e9f1; }
-.completion-content summary { cursor: pointer; font-weight: 600; }
-.completion-content p { max-width: none; white-space: pre-wrap; }
-.completion-content ul, .completion-content ol { padding-left: 22px; }
-.completion-content ul { list-style: disc; }
-.completion-content ol { list-style: decimal; }
-.completion-content li { margin: 7px 0; }
-.completion-notes { color: #65758a; }
+.generation.showing-preview { justify-content: flex-start; padding: 16px; }
 progress {
   width: 320px;
   max-width: 100%;
@@ -1218,6 +1215,8 @@ footer {
 }
 footer > div {
   display: flex;
+  flex-wrap: wrap;
+  justify-content: flex-end;
   gap: 12px;
 }
 @media (max-width: 740px) {

+ 101 - 0
src/views/Editor/EditorHeader/AiCoursewareDraftPreview.vue

@@ -0,0 +1,101 @@
+<template>
+  <div class="draft-preview">
+    <div class="draft-status">
+      <strong>已生成 {{ outline.pages.length }} 页 · 草稿预览</strong>
+      <p>{{ message }}</p>
+      <p class="hint">可逐页查看,或打开草稿编辑并补充图片。待完善的问题会保留在编辑器中。</p>
+    </div>
+    <nav class="page-list" aria-label="已生成课件页面">
+      <button v-for="(page, index) in outline.pages" :key="page.pageNo" type="button"
+        :aria-current="index === selected ? 'page' : undefined" :title="page.title" @click="selected = index">
+        {{ index + 1 }}<span v-if="pageNeedsImage(page)" class="pending-dot" aria-label="配图待完善"></span>
+      </button>
+    </nav>
+    <div class="page-heading">
+      <button type="button" :disabled="selected === 0" aria-label="上一页" @click="selected--">上一页</button>
+      <h4>第 {{ selected + 1 }} 页 · {{ currentPage?.title }}</h4>
+      <button type="button" :disabled="selected >= outline.pages.length - 1" aria-label="下一页" @click="selected++">下一页</button>
+    </div>
+    <p v-if="currentPage && pageNeedsImage(currentPage)" class="image-notice">{{ imagePreparationMessage(currentPage) }}</p>
+    <div ref="canvasHost" class="canvas-host">
+      <ThumbnailSlide v-if="currentSlide" :slide="currentSlide" :size="previewWidth" :viewport-size="1920" :viewport-ratio="0.5625" />
+      <p v-else role="status">本页暂时无法预览排版,以下保留完整文字。{{ preview.error }}</p>
+    </div>
+    <details v-if="currentPage" :key="currentPage.pageNo" class="page-content" :open="!currentSlide">
+      <summary>本页文字与教学备注</summary>
+      <p v-if="currentPage.studentContent?.headline">{{ currentPage.studentContent.headline }}</p>
+      <p v-if="currentPage.classroomAction?.studentTask">{{ currentPage.classroomAction.studentTask }}</p>
+      <ul><li v-for="(point, index) in (currentPage.studentContent?.points || currentPage.bullets || [])" :key="index">{{ point }}</li></ul>
+      <ol><li v-for="(item, index) in currentPage.studentContent?.steps" :key="index">{{ item }}</li></ol>
+      <p v-if="currentPage.studentContent?.example">示例:{{ currentPage.studentContent.example }}</p>
+      <div v-for="(group, index) in currentPage.studentContent?.comparisonGroups" :key="index">
+        <p>{{ group.label }}</p><ul><li v-for="(point, pointIndex) in group.points" :key="pointIndex">{{ point }}</li></ul>
+      </div>
+      <p v-if="currentPage.interaction?.question">课堂提问:{{ currentPage.interaction.question }}</p>
+      <ul><li v-for="(option, index) in currentPage.interaction?.options" :key="index">{{ option }}</li></ul>
+      <p v-if="currentPage.classroomAction?.modelExample">教学示范:{{ currentPage.classroomAction.modelExample }}</p>
+      <p v-if="currentPage.classroomAction?.storyScript">故事讲稿:{{ currentPage.classroomAction.storyScript }}</p>
+      <p v-if="currentPage.speakerNotes || currentPage.teacherTips">教学备注:{{ currentPage.speakerNotes || currentPage.teacherTips }}</p>
+    </details>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
+import type { AiCoursewareOutline, AiCoursewarePage } from '@/api/aiCourseware'
+import type { Slide } from '@/types/slides'
+import { aiCoursewareToSlides } from '@/utils/aiCoursewareToSlides'
+import { blockingImageIssues, imagePreparationMessage } from '@/utils/aiPptFeedback'
+import { inspectAiPptQuality } from '@/utils/aiPptQuality'
+import ThumbnailSlide from '@/views/components/ThumbnailSlide/index.vue'
+
+const props = defineProps<{ outline: AiCoursewareOutline; templateId: string; message: string }>()
+const selected = ref(0)
+const canvasHost = ref<HTMLElement>()
+const previewWidth = ref(640)
+let observer: ResizeObserver | undefined
+watch(() => props.outline, () => { selected.value = Math.min(selected.value, Math.max(0, props.outline.pages.length - 1)) })
+const preview = computed(() => {
+  try {
+    return { slides: aiCoursewareToSlides(props.outline, { templateId: props.templateId, enableAnimations: false }).slides, error: '' }
+  } catch (error) {
+    return { slides: [] as Slide[], error: error instanceof Error ? error.message : '请精简并重新排版。' }
+  }
+})
+const currentPage = computed(() => props.outline.pages[selected.value])
+const currentSlide = computed(() => preview.value.slides[selected.value])
+const missingImagePages = computed(() => new Set(blockingImageIssues(inspectAiPptQuality(props.outline, preview.value.slides).issues).map(issue => issue.pageNo)))
+const pageNeedsImage = (page: AiCoursewarePage) => missingImagePages.value.has(page.pageNo) || !!page.imageStatus && !['ready', 'not_required'].includes(page.imageStatus)
+onMounted(() => {
+  if (!canvasHost.value) return
+  const resize = () => { if (canvasHost.value?.clientWidth) previewWidth.value = Math.min(960, canvasHost.value.clientWidth) }
+  resize()
+  observer = new ResizeObserver(resize)
+  observer.observe(canvasHost.value)
+})
+onBeforeUnmount(() => observer?.disconnect())
+</script>
+
+<style scoped lang="scss">
+.draft-preview { width: 100%; min-width: 0; text-align: left; }
+.draft-status { padding: 12px 16px; background: #fff7eb; border-radius: 6px; color: #785422; }
+.draft-status p { margin: 6px 0 0; white-space: pre-wrap; }
+.draft-status .hint { color: #65758a; font-size: 13px; }
+.page-list { display: flex; flex-wrap: wrap; gap: 6px; margin: 16px 0; }
+button { border: 1px solid #dce4ee; border-radius: 4px; background: white; color: #34475c; padding: 6px 10px; cursor: pointer; }
+button:disabled { opacity: .4; cursor: default; }
+button:focus-visible { outline: 2px solid #1989fa; outline-offset: 2px; }
+.page-list button { position: relative; min-width: 34px; }
+.page-list button[aria-current="page"] { background: #1989fa; border-color: #1989fa; color: white; }
+.pending-dot { position: absolute; width: 6px; height: 6px; border-radius: 50%; background: #e79a34; right: 1px; top: 1px; }
+.page-heading { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-bottom: 12px; }
+.page-heading h4 { margin: 0; flex: 1; text-align: center; }
+.page-heading button { flex-shrink: 0; }
+.canvas-host { width: 100%; display: flex; justify-content: center; overflow: hidden; background: #e8edf3; }
+.image-notice { margin: 0 0 12px; color: #906321; }
+.page-content { margin-top: 16px; padding: 12px; background: white; border: 1px solid #e2e9f1; border-radius: 6px; }
+summary { cursor: pointer; font-weight: 600; }
+.page-content p { white-space: pre-wrap; }
+ul, ol { padding-left: 22px; }
+ul { list-style: disc; } ol { list-style: decimal; } li { margin: 7px 0; }
+</style>

+ 6 - 1
src/views/components/ThumbnailSlide/index.vue

@@ -65,6 +65,8 @@ const props = withDefaults(
     showBackgroundError?: boolean
     editableText?: boolean
     editDisabled?: boolean
+    viewportSize?: number
+    viewportRatio?: number
   }>(),
   {
     visible: true,
@@ -79,7 +81,10 @@ const emit = defineEmits<{
   (event: "text-change", payload: { elementId: string; content: string; previousContent: string; text: string; previousText: string }): void
 }>()
 
-const { viewportRatio, viewportSize } = storeToRefs(useSlidesStore())
+const { viewportRatio: editorViewportRatio, viewportSize: editorViewportSize } = storeToRefs(useSlidesStore())
+// 草稿预览使用自己的画布尺寸,不改变正在编辑的课件。
+const viewportSize = computed(() => props.viewportSize ?? editorViewportSize.value)
+const viewportRatio = computed(() => props.viewportRatio ?? editorViewportRatio.value)
 
 const background = computed(() => props.slide.background)
 const { backgroundStyle } = useSlideBackgroundStyle(background)

Некоторые файлы не были показаны из-за большого количества измененных файлов