yonge 5 days ago
parent
commit
84a97997c3

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

@@ -0,0 +1 @@
+import{u as s,ag as r}from"./index-_Lnr4Zj0.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-C0ih7_TA.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
+ 0 - 0
dist/assets/index-BTa-KQPU.js


+ 1 - 1
dist/assets/index-BvjDSyDG.js → dist/assets/index-BnLqeivw.js

@@ -1 +1 @@
-import{_ as t}from"./404-R0y5dkvZ.js";import{E as c}from"./index-CO6F-jyK.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-DQjpXyI_.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-BTa-KQPU.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-C0ih7_TA.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
+ 1 - 1
dist/assets/index-C0ih7_TA.js


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


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


+ 1 - 1
dist/assets/index-BMQTrOxO.js → dist/assets/index-DOYppM4l.js

@@ -1 +1 @@
-import{_ as a}from"./404-R0y5dkvZ.js";import{a5 as o,en as t,ae as r,af as c,bO as _,bP as d,ak as s,bH as p}from"./index-DQjpXyI_.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,en as t,ae as r,af as c,bO as _,bP as d,ak as s,bH as p}from"./index-C0ih7_TA.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-Dl3uNlie.css


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


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


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


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

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

+ 1 - 1
dist/assets/index-BtYg-IeC.js → dist/assets/index-s-kcTQnb.js

@@ -1 +1 @@
-import{u as c,af as l}from"./index-D3IVe-mf.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-DQjpXyI_.js";import"./shapes-ml813k73.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-_Lnr4Zj0.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-C0ih7_TA.js";import"./shapes-DdvWIHyS.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/shapes-ml813k73.js → dist/assets/shapes-DdvWIHyS.js

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

+ 5 - 2
scripts/aiPptSelfTest.mjs

@@ -103,6 +103,7 @@ try {
       const converted = aiCoursewareToSlides(outline, { templateId: template.id, enableAnimations: true })
       const report = inspectAiPptQuality(outline, converted.slides, preferences)
       const master = getAiCoursewareTemplateMaster(template.id)
+      const coverMasterPage = getTemplateMasterPage(master, "cover")
       const backgrounds = new Set(converted.slides.map(slide => slide.background?.color || slide.background?.image || ""))
       const expectedBackgrounds = new Set(master.pages.flatMap(item => item.backgroundImages?.length ? item.backgroundImages : [item.backgroundImage || item.background]))
       const unknownBackgrounds = [...backgrounds].filter(item => !expectedBackgrounds.has(item))
@@ -142,7 +143,9 @@ try {
         contentMaster: getTemplateMasterPage(master, "content").id,
         emptySlides: converted.slides.filter(slide => !slide.elements.length).length,
         contractPageCount: master.pages.filter(page => page.safeArea && page.reservedZones).length,
-        disabledSlotLeakCount: converted.slides[0].elements.filter(element => element.type === "image").length,
+        coverVisualContractValid: coverMasterPage.backgroundVisual
+          ? coverMasterPage.slots.find(slot => slot.kind === "image")?.enabled === false
+          : coverMasterPage.slots.find(slot => slot.kind === "image")?.enabled !== false,
         layoutMismatchCount,
         expectedMediaPlaceholders: variant.expectedMediaPlaceholders,
         mediaPlaceholderPages,
@@ -161,7 +164,7 @@ try {
     if (result.masterPageCount < 10) messages.push(`母版页型不足:${result.masterPageCount}`)
     if (result.emptySlides) messages.push(`空白页:${result.emptySlides}`)
     if (result.contractPageCount !== result.masterPageCount) messages.push(`空间契约缺失:${result.contractPageCount}/${result.masterPageCount}`)
-    if (result.disabledSlotLeakCount) messages.push(`封面禁用图片槽仍写入内容:${result.disabledSlotLeakCount}`)
+    if (!result.coverVisualContractValid) messages.push("封面背景类型与图片槽契约不一致")
     if (result.layoutMismatchCount) messages.push(`视觉布局匹配错误:${result.layoutMismatchCount}`)
     if (result.mediaPlaceholderPages.length !== result.expectedMediaPlaceholders) messages.push(`媒体占位页不符合预期:${result.mediaPlaceholderPages.join("、") || "无"}`)
     if (result.emptyRemarkPages.length) messages.push(`教师备注为空:${result.emptyRemarkPages.join("、")}`)

+ 87 - 10
src/config/aiCoursewareTemplateMasters.ts

@@ -1,4 +1,5 @@
 import { AI_COURSEWARE_TEMPLATES, type AiCoursewareTemplate, type AiCoursewareTemplateId } from "@/config/aiCoursewareTemplates"
+import { templateAssetPath } from "@/utils/templateAssetUrl"
 
 export type TemplatePageRole = "cover" | "intro" | "goal" | "story" | "listen" | "lyric" | "notation" | "instrument" | "compare" | "knowledge" | "rhythm" | "interaction" | "performance" | "practice" | "activity" | "summary" | "homework" | "content"
 /** 纯视觉布局能力,不承载“歌词课/欣赏课”等教学语义。 */
@@ -6,6 +7,7 @@ export type TemplatePageLayout = "cover" | "hero" | "objectives" | "media" | "sp
 export type TemplateSlotKind = "title" | "meta" | "headline" | "body" | "image" | "media" | "left" | "right" | "steps"
 export type TemplateCardStyle = "bubble" | "paper" | "cinematic" | "ink" | "grid" | "sticker"
 export type TemplateComposition = "left-visual" | "right-visual" | "wide-stage" | "scroll" | "asymmetric" | "playful"
+export type TemplateBackgroundKind = "hero" | "decorative"
 
 export interface TemplateMasterElement {
   type: "shape" | "text"
@@ -53,6 +55,8 @@ export interface TemplateMasterPage {
   backgroundImage?: string
   /** 同一逻辑版式可轮换的已验收背景,避免长课件反复出现同一画面。 */
   backgroundImages?: string[]
+  /** hero 表示背景本身承担主视觉,decorative 表示仍需内容图片。 */
+  backgroundKind?: TemplateBackgroundKind
   /** 背景已自带主题人物或乐器时,仅在有真实素材时覆盖 image 槽位。 */
   backgroundVisual?: boolean
   titleColor?: string
@@ -68,6 +72,58 @@ export interface TemplateMasterPage {
   reservedZones?: TemplateRegion[]
 }
 
+interface ExtractedBackgroundContract {
+  byLayout: Record<TemplatePageLayout, string[]>
+  backgroundVisualLayouts: TemplatePageLayout[]
+}
+
+const EXTRACTED_BACKGROUND_CONTRACTS: Record<string, ExtractedBackgroundContract> = {
+  "ethnic-flower": {
+    byLayout: { cover: ["background-01.jpg", "background-10.jpg"], hero: ["background-02.jpg", "background-03.jpg", "background-08.jpg"], objectives: ["background-04.jpg"], media: ["background-05.jpg"], split: ["background-06.jpg", "background-07.jpg"], text: ["background-04.jpg"], diagram: ["background-06.jpg"], compare: ["background-08.jpg", "background-09.jpg"], activity: ["background-09.jpg"], summary: ["background-10.jpg"] },
+    backgroundVisualLayouts: ["cover", "hero", "media"]
+  },
+  "opera-fan": {
+    byLayout: { cover: ["background-01.jpg"], hero: ["background-02.jpg", "background-04.jpg", "background-06.jpg"], objectives: ["background-03.jpg", "background-08.jpg"], media: ["background-04.jpg", "background-06.jpg"], split: ["background-05.jpg", "background-07.jpg"], text: ["background-03.jpg", "background-09.jpg"], diagram: ["background-05.jpg", "background-07.jpg"], compare: ["background-08.jpg", "background-09.jpg"], activity: ["background-06.jpg"], summary: ["background-10.jpg"] },
+    backgroundVisualLayouts: ["cover"]
+  },
+  "dark-lute": {
+    byLayout: { cover: ["background-01.jpg", "background-10.jpg"], hero: ["background-04.jpg", "background-09.jpg"], objectives: ["background-04.jpg"], media: ["background-01.jpg"], split: ["background-04.jpg"], text: ["background-09.jpg"], diagram: ["background-04.jpg"], compare: ["background-09.jpg"], activity: ["background-01.jpg"], summary: ["background-10.jpg"] },
+    backgroundVisualLayouts: ["cover", "summary"]
+  },
+  "spring-outing": {
+    byLayout: { cover: ["background-01.jpg"], hero: ["background-10.jpg"], objectives: ["background-02.jpg"], media: ["background-03.jpg"], split: ["background-04.jpg"], text: ["background-05.jpg"], diagram: ["background-06.jpg"], compare: ["background-02.jpg", "background-04.jpg"], activity: ["background-10.jpg"], summary: ["background-01.jpg"] },
+    backgroundVisualLayouts: ["cover"]
+  },
+  "bird-garden": {
+    byLayout: { cover: ["background-01.jpg", "background-10.jpg"], hero: ["background-01.jpg", "background-10.jpg"], objectives: ["background-02.jpg", "background-05.jpg"], media: ["background-04.jpg", "background-06.jpg"], split: ["background-07.jpg", "background-08.jpg"], text: ["background-03.jpg", "background-05.jpg"], diagram: ["background-06.jpg", "background-09.jpg"], compare: ["background-07.jpg", "background-08.jpg"], activity: ["background-09.jpg"], summary: ["background-10.jpg"] },
+    backgroundVisualLayouts: ["cover", "hero"]
+  },
+  "rainy-cat": {
+    byLayout: { cover: ["background-01.jpg", "background-10.jpg"], hero: ["background-02.jpg", "background-04.jpg"], objectives: ["background-03.jpg"], media: ["background-04.jpg", "background-06.jpg"], split: ["background-05.jpg", "background-07.jpg"], text: ["background-06.jpg", "background-08.jpg"], diagram: ["background-07.jpg"], compare: ["background-08.jpg", "background-09.jpg"], activity: ["background-09.jpg"], summary: ["background-10.jpg"] },
+    backgroundVisualLayouts: []
+  },
+  "ink-jiangnan": {
+    byLayout: { cover: ["background-02.jpg"], hero: ["background-03.jpg"], objectives: ["background-04.jpg"], media: ["background-05.jpg"], split: ["background-03.jpg"], text: ["background-04.jpg"], diagram: ["background-05.jpg"], compare: ["background-06.jpg"], activity: ["background-03.jpg", "background-05.jpg"], summary: ["background-06.jpg"] },
+    backgroundVisualLayouts: []
+  },
+  "rhythm-train": {
+    byLayout: { cover: ["background-01.jpg", "background-10.jpg"], hero: ["background-10.jpg"], objectives: ["background-04.jpg"], media: ["background-06.jpg"], split: ["background-07.jpg"], text: ["background-08.jpg"], diagram: ["background-09.jpg"], compare: ["background-06.jpg", "background-08.jpg"], activity: ["background-10.jpg"], summary: ["background-01.jpg"] },
+    backgroundVisualLayouts: []
+  },
+  "sunset-drum": {
+    byLayout: { cover: ["background-01.jpg", "background-02.jpg"], hero: ["background-03.jpg", "background-05.jpg"], objectives: ["background-04.jpg"], media: ["background-05.jpg"], split: ["background-06.jpg"], text: ["background-07.jpg"], diagram: ["background-08.jpg"], compare: ["background-06.jpg", "background-08.jpg"], activity: ["background-03.jpg"], summary: ["background-09.jpg"] },
+    backgroundVisualLayouts: ["cover", "hero"]
+  },
+  "ballroom-silhouette": {
+    byLayout: { cover: ["background-02.jpg"], hero: ["background-03.jpg", "background-05.jpg"], objectives: ["background-04.jpg"], media: ["background-06.jpg"], split: ["background-07.jpg"], text: ["background-08.jpg"], diagram: ["background-09.jpg"], compare: ["background-04.jpg", "background-08.jpg"], activity: ["background-06.jpg"], summary: ["background-10.jpg"] },
+    backgroundVisualLayouts: ["cover"]
+  },
+  "color-theory": {
+    byLayout: { cover: ["background-10.jpg"], hero: ["background-03.jpg", "background-05.jpg"], objectives: ["background-02.jpg"], media: ["background-04.jpg"], split: ["background-06.jpg"], text: ["background-07.jpg"], diagram: ["background-08.jpg"], compare: ["background-04.jpg", "background-07.jpg"], activity: ["background-09.jpg"], summary: ["background-10.jpg"] },
+    backgroundVisualLayouts: []
+  }
+}
+
 export interface TemplateMaster {
   id: AiCoursewareTemplateId
   label: string
@@ -294,26 +350,42 @@ function createExtractedMaster(template: AiCoursewareTemplate): TemplateMaster {
     contentBase: [],
     contentChrome: []
   })
-  const backgroundUrls = template.backgroundFiles.map(file => `/ai-courseware-templates/extracted/${template.id}/${file}`)
-  if (!backgroundUrls.length) throw new Error(`课件模板 ${template.id} 没有可用背景资产`)
-  const rotateBackgrounds = !["ethnic-flower", "opera-fan", "dark-lute", "ballroom-silhouette"].includes(template.id)
+  const backgroundContract = EXTRACTED_BACKGROUND_CONTRACTS[template.id]
+  if (!backgroundContract) throw new Error(`课件模板 ${template.id} 缺少背景版式契约`)
+  const registeredFiles = new Set(template.backgroundFiles)
+  Object.entries(backgroundContract.byLayout).forEach(([layout, files]) => {
+    if (!files.length) throw new Error(`课件模板 ${template.id} 的 ${layout} 版式没有背景资产`)
+    const missing = files.filter(file => !registeredFiles.has(file))
+    if (missing.length) throw new Error(`课件模板 ${template.id} 的 ${layout} 版式引用未注册背景:${missing.join(",")}`)
+  })
   return {
     ...master,
-    pages: master.pages.map((page, index) => {
-      const primaryIndex = index % backgroundUrls.length
-      const primary = backgroundUrls[primaryIndex]
-      const alternative = backgroundUrls[(primaryIndex + Math.max(1, Math.floor(backgroundUrls.length / 2))) % backgroundUrls.length]
-      const backgroundImages = rotateBackgrounds && alternative !== primary ? [primary, alternative] : [primary]
+    pages: master.pages.map(page => {
+      const backgroundImages = backgroundContract.byLayout[page.layout].map(file => templateAssetPath(template.id, file))
+      const primary = backgroundImages[0]
+      const backgroundVisual = backgroundContract.backgroundVisualLayouts.includes(page.layout)
+      let slots = pilotSlots(template.id, page.layout, page.slots).map(item =>
+        item.kind === "image" && !backgroundVisual ? { ...item, enabled: true } : item
+      )
+      if (page.layout === "cover" && !backgroundVisual) {
+        slots = [
+          slot("title", "title", 110, 180, 760, 160, 1, 66, 44),
+          slot("meta", "meta", 115, 375, 700, 48, 1, 24, 20),
+          slot("headline", "headline", 115, 475, 700, 120, 2, 28, 22),
+          slot("image", "image", 950, 170, 500, 500)
+        ]
+      }
       return {
         ...page,
         ...pilotContract(template.id, page.layout),
         backgroundImage: primary,
         backgroundImages,
-        backgroundVisual: true,
+        backgroundKind: backgroundVisual ? "hero" as const : "decorative" as const,
+        backgroundVisual,
         base: template.id === "dark-lute" && !["cover", "media", "activity", "summary"].includes(page.layout)
           ? [shape(0, 0, 1600, 86, "#06364B")]
           : page.base,
-        slots: pilotSlots(template.id, page.layout, page.slots),
+        slots,
         ...(template.id === "dark-lute" && ["cover", "media", "activity", "summary"].includes(page.layout)
           ? {
               titleColor: "#D3B270",
@@ -409,6 +481,11 @@ export function validateTemplateMasterRegistry(templateIds: AiCoursewareTemplate
       const slotKinds = new Set(page.slots.map(item => item.kind))
       const absentSlots = requiredSlots[page.layout].filter(kind => !slotKinds.has(kind))
       if (absentSlots.length) problems.push(`${registryId}/${page.id}: slots=${absentSlots.join(",")}`)
+      if (!page.backgroundImage || !page.backgroundImages?.length) problems.push(`${registryId}/${page.id}: missing-background`)
+      if (!page.backgroundKind) problems.push(`${registryId}/${page.id}: missing-background-kind`)
+      if ((page.backgroundKind === "hero") !== Boolean(page.backgroundVisual)) problems.push(`${registryId}/${page.id}: inconsistent-background-kind`)
+      const imageSlot = page.slots.find(item => item.kind === "image")
+      if (page.backgroundKind === "decorative" && imageSlot?.enabled === false) problems.push(`${registryId}/${page.id}: decorative-background-disabled-image`)
       page.slots.forEach(item => {
         if (outsideCanvas(item)) problems.push(`${registryId}/${page.id}/${item.id}: out-of-canvas`)
       })

+ 2 - 1
src/config/aiCoursewareTemplates.ts

@@ -1,4 +1,5 @@
 import type { AiCoursewareOutline } from "@/api/aiCourseware"
+import { templateAssetPath } from "@/utils/templateAssetUrl"
 
 export type AiCoursewareTemplateId = string
 
@@ -123,7 +124,7 @@ function extractedTemplate(
     sourceName,
     scene,
     description,
-    templateFile: `/ai-courseware-templates/extracted/${id}/${id}.pptx`,
+    templateFile: templateAssetPath(id, `${id}.pptx`),
     templateSlides: 10,
     backgroundFiles: EXTRACTED_TEMPLATE_BACKGROUNDS[id] || [],
     baseStyle: layoutFamily === "story" || layoutFamily === "activity" ? "cartoon" : "clean",

+ 4 - 2
src/hooks/useSlideBackgroundStyle.ts

@@ -1,5 +1,6 @@
 import { type Ref, computed } from 'vue'
 import type { SlideBackground } from '@/types/slides'
+import { resolveTemplateAssetUrl } from '@/utils/templateAssetUrl'
 
 // 将页面背景数据转换为css样式
 export default (background: Ref<SlideBackground | undefined>) => {
@@ -20,15 +21,16 @@ export default (background: Ref<SlideBackground | undefined>) => {
     // 背景图模式
     // 包括:背景图、背景大小,是否重复
     else if (type === 'image' && image) {
+      const resolvedImage = resolveTemplateAssetUrl(image)
       if (imageSize === 'repeat') {
         return {
-          backgroundImage: `url("${image}")`,
+          backgroundImage: `url("${resolvedImage}")`,
           backgroundRepeat: 'repeat',
           backgroundSize: 'contain',
         }
       }
       return {
-        backgroundImage: `url("${image}")`,
+        backgroundImage: `url("${resolvedImage}")`,
         backgroundRepeat: 'no-repeat',
         backgroundSize: imageSize || 'cover',
       }

+ 5 - 3
src/libs/jsonTool.ts

@@ -1,6 +1,7 @@
 import axios from "axios"
 import { useSlidesStore } from "@/store"
 import { slides as slidesData } from "@/mocks/slides"
+import { resolveTemplateAssetUrl } from "@/utils/templateAssetUrl"
 /**
  * http 请求json
  */
@@ -78,11 +79,12 @@ export async function hydrateSlideBackgroundImages<T extends { background?: { ty
   const cloned = JSON.parse(JSON.stringify(slides)) as T[]
   const imagePaths = [...new Set(cloned
     .map(slide => slide.background?.type === "image" ? slide.background.image : "")
-    .filter((path): path is string => Boolean(path) && !path.startsWith("data:")))]
+    .filter((path): path is string => typeof path === "string" && path.length > 0 && !path.startsWith("data:")))]
   const dataUrls = new Map<string, string>()
   await Promise.all(imagePaths.map(async path => {
-    const response = await fetch(new URL(path, window.location.href))
-    if (!response.ok) throw new Error(`模板背景加载失败:${path}`)
+    const resolvedPath = resolveTemplateAssetUrl(path)
+    const response = await fetch(new URL(resolvedPath, window.location.href))
+    if (!response.ok) throw new Error(`模板背景加载失败(HTTP ${response.status}):${resolvedPath}`)
     const blob = await response.blob()
     const dataUrl = await new Promise<string>((resolve, reject) => {
       const reader = new FileReader()

+ 23 - 0
src/utils/templateAssetUrl.ts

@@ -0,0 +1,23 @@
+const TEMPLATE_ASSET_PREFIX = "ai-courseware-templates/"
+
+function appBaseUrl() {
+  const base = import.meta.env.BASE_URL || "./"
+  return base.endsWith("/") ? base : `${base}/`
+}
+
+/**
+ * 模板资源必须跟随 Vite 的部署 base,避免应用部署到子目录后仍请求站点根目录。
+ * 远程地址、data URL 和 blob URL 保持原样。
+ */
+export function resolveTemplateAssetUrl(value?: string) {
+  const source = String(value || "").trim()
+  if (!source || /^(?:data:|blob:|https?:\/\/|\/\/)/i.test(source)) return source
+
+  const normalized = source.replace(/^\.\//, "").replace(/^\//, "")
+  if (!normalized.startsWith(TEMPLATE_ASSET_PREFIX)) return source
+  return `${appBaseUrl()}${normalized}`
+}
+
+export function templateAssetPath(templateId: string, file: string) {
+  return resolveTemplateAssetUrl(`${TEMPLATE_ASSET_PREFIX}extracted/${templateId}/${file}`)
+}

+ 55 - 4
src/views/Editor/EditorHeader/AiCoursewareDialog.vue

@@ -226,6 +226,10 @@
                 <span>当前模板</span>
                 <strong>{{ selectedTemplate.name }}</strong>
                 <small>切换模板会重排页面结构,不改变教学内容</small>
+                <small class="template-debug-info">{{ sampleTemplateInfo }}</small>
+                <small v-if="sampleBackgroundState.status === 'error'" class="template-load-error">
+                  模板背景加载失败:{{ sampleBackgroundState.url }}
+                </small>
               </div>
               <button type="button" :disabled="working" @click="templatePickerExpanded = !templatePickerExpanded">
                 {{ templatePickerExpanded ? "收起模板" : "更换模板" }}
@@ -286,13 +290,18 @@
 
             <div class="sample-actions">
               <Button @click="regenerateSamplePage" :disabled="working">重新生成当前样张</Button>
-              <Button type="primary" @click="approveSample" :disabled="working || !currentSampleRequired">
-                {{ currentSampleApproved ? "本张样稿已确认" : currentSampleRequired ? `确认本张样稿(${approvedSamplePages.length}/3)` : "请切换到代表样稿确认" }}
+              <Button type="primary" @click="approveSample" :disabled="working || !currentSampleRequired || !sampleBackgroundReady">
+                {{ approveSampleButtonText }}
               </Button>
             </div>
           </div>
           <div :class="['sample-canvas', { approved: currentSampleApproved }]">
-            <ThumbnailSlide :slide="sampleSlide" :size="420" />
+            <ThumbnailSlide
+              :slide="sampleSlide"
+              :size="420"
+              :show-background-error="true"
+              @background-load-state="handleSampleBackgroundLoadState"
+            />
             <div v-if="currentSampleApproved" class="sample-approved-mark">已确认</div>
           </div>
         </div>
@@ -416,7 +425,7 @@
         <template v-else-if="workflowStep === 2">
           <Button @click="workflowStep = 1" :disabled="working">返回生成设置</Button>
           <Button @click="regenerateSamplePage" :disabled="working">重新生成当前样张</Button>
-          <Button type="primary" @click="handleGenerate" :disabled="!allSamplesApproved || working">三张样稿确认后生成完整课件</Button>
+          <Button type="primary" @click="handleGenerate" :disabled="!allSamplesApproved || working">{{ fullGenerateButtonText }}</Button>
         </template>
         <template v-else>
           <Button @click="backToOutline" :disabled="working">返回修改</Button>
@@ -503,6 +512,7 @@ const preferences = reactive<AiCoursewarePreferences>({
   enableAnimations: true
 })
 const approvedSamplePages = ref<number[]>([])
+const sampleBackgroundState = ref<{ status: "none" | "loading" | "loaded" | "error"; url: string }>({ status: "none", url: "" })
 const sampleFeedbackOptions = ["配色不合适", "版式太单一", "图片不合适", "文字太多"]
 const sampleFeedbackTags = ref<string[]>([])
 const sampleFeedbackText = ref("")
@@ -586,6 +596,20 @@ const currentSampleApproved = computed(() => approvedSamplePages.value.includes(
 const allSamplesApproved = computed(
   () => requiredSamplePages.value.length === 3 && requiredSamplePages.value.every(pageNo => approvedSamplePages.value.includes(pageNo))
 )
+const sampleBackgroundReady = computed(() => ["none", "loaded"].includes(sampleBackgroundState.value.status))
+const remainingSampleCount = computed(() => Math.max(0, requiredSamplePages.value.filter(pageNo => !approvedSamplePages.value.includes(pageNo)).length))
+const fullGenerateButtonText = computed(() => allSamplesApproved.value ? "生成完整课件" : `还需确认${remainingSampleCount.value}张样稿`)
+const approveSampleButtonText = computed(() => {
+  if (sampleBackgroundState.value.status === "loading") return "模板背景加载中"
+  if (sampleBackgroundState.value.status === "error") return "模板背景加载失败"
+  if (currentSampleApproved.value) return "本张样稿已确认"
+  return currentSampleRequired.value ? `确认本张样稿(${approvedSamplePages.value.length}/3)` : "请切换到代表样稿确认"
+})
+const sampleTemplateInfo = computed(() => {
+  const layout = outline.value?.pages[samplePageIndex.value]?.visualPlan?.layout || "未指定版式"
+  const image = sampleSlide.value?.background?.type === "image" ? sampleSlide.value.background.image : "纯色背景"
+  return `版式:${layout} · 背景:${String(image).split("/").pop() || image}`
+})
 const samplePageReason = computed(() => {
   const layout = outline.value?.pages[samplePageIndex.value]?.visualPlan?.layout || ""
   if (["activity_steps", "action_demo", "group_mission", "performance_stage"].includes(layout)) return "这是课堂活动页,适合判断互动方式和信息层级"
@@ -911,11 +935,28 @@ function invalidateVisualApproval() {
 
 function approveSample() {
   if (!currentSampleRequired.value) return
+  if (!sampleBackgroundReady.value) {
+    ElMessage.error("模板背景尚未成功加载,不能确认该样稿")
+    return
+  }
   approvedSamplePages.value = Array.from(new Set([...approvedSamplePages.value, samplePageNo.value]))
   if (allSamplesApproved.value) ElMessage.success("三张代表样稿已确认,可以生成完整课件")
   else ElMessage.success(`本张样稿已确认,还需确认${3 - approvedSamplePages.value.length}张`)
 }
 
+function handleSampleBackgroundLoadState(payload: { status: "none" | "loading" | "loaded" | "error"; url: string }) {
+  sampleBackgroundState.value = payload
+  if (payload.status === "error") {
+    approvedSamplePages.value = approvedSamplePages.value.filter(pageNo => pageNo !== samplePageNo.value)
+    console.error("AI课件模板背景加载失败", {
+      templateId: selectedTemplateId.value,
+      pageNo: samplePageNo.value,
+      layout: outline.value?.pages[samplePageIndex.value]?.visualPlan?.layout,
+      url: payload.url
+    })
+  }
+}
+
 function toggleSampleFeedback(item: string) {
   approvedSamplePages.value = approvedSamplePages.value.filter(pageNo => pageNo !== samplePageNo.value)
   sampleFeedbackTags.value = sampleFeedbackTags.value.includes(item)
@@ -1633,6 +1674,16 @@ function resourceTypeName(type: string) {
     color: #7b8794;
     font-size: 10px;
   }
+  .template-debug-info {
+    margin-top: 3px;
+    color: #50647a;
+  }
+  .template-load-error {
+    max-width: 620px;
+    margin-top: 4px;
+    color: #b42318;
+    overflow-wrap: anywhere;
+  }
   button {
     flex-shrink: 0;
     height: 30px;

+ 54 - 2
src/views/components/ThumbnailSlide/index.vue

@@ -19,16 +19,20 @@
       <ThumbnailElement v-for="(element, index) in slide.elements" :key="element.id" :elementInfo="element" :elementIndex="index + 1" />
     </div>
     <div class="placeholder" v-else>加载中 ...</div>
+    <div v-if="showBackgroundError && backgroundLoadState === 'error'" class="background-error">
+      模板背景加载失败,请检查资源部署
+    </div>
   </div>
 </template>
 
 <script lang="ts" setup>
-import { computed, provide } from "vue"
+import { computed, provide, ref, watch } from "vue"
 import { storeToRefs } from "pinia"
 import { useSlidesStore } from "@/store"
 import type { Slide } from "@/types/slides"
 import { injectKeySlideScale } from "@/types/injectKey"
 import useSlideBackgroundStyle from "@/hooks/useSlideBackgroundStyle"
+import { resolveTemplateAssetUrl } from "@/utils/templateAssetUrl"
 
 import ThumbnailElement from "./ThumbnailElement.vue"
 
@@ -37,16 +41,51 @@ const props = withDefaults(
     slide: Slide
     size: number
     visible?: boolean
+    showBackgroundError?: boolean
   }>(),
   {
-    visible: true
+    visible: true,
+    showBackgroundError: false
   }
 )
 
+const emit = defineEmits<{
+  (event: "background-load-state", payload: { status: "none" | "loading" | "loaded" | "error"; url: string }): void
+}>()
+
 const { viewportRatio, viewportSize } = storeToRefs(useSlidesStore())
 
 const background = computed(() => props.slide.background)
 const { backgroundStyle } = useSlideBackgroundStyle(background)
+const backgroundImageUrl = computed(() =>
+  props.slide.background?.type === "image" ? resolveTemplateAssetUrl(props.slide.background.image) : ""
+)
+const backgroundLoadState = ref<"none" | "loading" | "loaded" | "error">("none")
+let backgroundRequestId = 0
+
+watch(backgroundImageUrl, url => {
+  const requestId = ++backgroundRequestId
+  if (!url) {
+    backgroundLoadState.value = "none"
+    emit("background-load-state", { status: "none", url: "" })
+    return
+  }
+  backgroundLoadState.value = "loading"
+  emit("background-load-state", { status: "loading", url })
+  if (typeof Image === "undefined") return
+  const image = new Image()
+  image.onload = () => {
+    if (requestId !== backgroundRequestId) return
+    backgroundLoadState.value = "loaded"
+    emit("background-load-state", { status: "loaded", url })
+  }
+  image.onerror = () => {
+    if (requestId !== backgroundRequestId) return
+    backgroundLoadState.value = "error"
+    emit("background-load-state", { status: "error", url })
+  }
+  image.src = url
+}, { immediate: true })
 
 const scale = computed(() => props.size / viewportSize.value)
 provide(injectKeySlideScale, scale)
@@ -57,6 +96,19 @@ provide(injectKeySlideScale, scale)
   background-color: #fff;
   overflow: hidden;
   user-select: none;
+  position: relative;
+}
+.background-error {
+  position: absolute;
+  inset: 0;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  padding: 20px;
+  color: #b42318;
+  background: rgba(255, 244, 242, 0.94);
+  font-size: 14px;
+  text-align: center;
 }
 .elements {
   transform-origin: 0 0;

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