yonge il y a 1 jour
Parent
commit
272871a212

Fichier diff supprimé car celui-ci est trop grand
+ 1 - 1
dist/assets/index-B9Psu6rF.js


Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
dist/assets/index-B_Q7eBRc.js


+ 1 - 1
dist/assets/index-DK4Uk8NW.js → dist/assets/index-BdcNZ2_e.js

@@ -1 +1 @@
-import{_ as t}from"./404-R0y5dkvZ.js";import{E as c}from"./index-akCyjIHa.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,bM as m,bN as f,bH as u}from"./index-C8ueHu0b.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})])]))}}),S=u(v,[["__scopeId","data-v-340bbdaa"]]);export{S as default};
+import{_ as t}from"./404-R0y5dkvZ.js";import{E as c}from"./index-B_Q7eBRc.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-BwOBmnxZ.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};

Fichier diff supprimé car celui-ci est trop grand
+ 1 - 1
dist/assets/index-BwOBmnxZ.js


Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
dist/assets/index-C9xXz38r.js


Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
dist/assets/index-C_slUrwq.css


Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
dist/assets/index-Ciee1K4I.css


+ 1 - 1
dist/assets/index-D63wSGFe.js → dist/assets/index-CqQxGCBv.js

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

Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
dist/assets/index-DN33akwi.css


+ 1 - 1
dist/assets/index-BYdiATdU.js → dist/assets/index-DPDnn2YN.js

@@ -1 +1 @@
-import{u as c,af as l}from"./index-J5UJZWGH.js";import{w as p,bA as d,a5 as g,cB as m,R as t,ae as r,af as u,al as f,ag as w,bH as k}from"./index-C8ueHu0b.js";import"./shapes-C4Tyb9c6.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}))]))}}),B=k(_,[["__scopeId","data-v-90b2692a"]]);export{B as default};
+import{u as c,af as l}from"./index-B9Psu6rF.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-BwOBmnxZ.js";import"./shapes-oj-TN6vd.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 - 0
dist/assets/index-DSq_rUPp.js

@@ -0,0 +1 @@
+import{u as s,af as r}from"./index-B9Psu6rF.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-BwOBmnxZ.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};

Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
dist/assets/index-IQOjLD--.js


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

@@ -1 +0,0 @@
-import{u as s,af as r}from"./index-J5UJZWGH.js";import{a5 as c,cB as n,R as p,ae as t,af as _,al as u,ag as i,bH as f}from"./index-C8ueHu0b.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}))]))}}),P=f(m,[["__scopeId","data-v-678905e9"]]);export{P as default};

Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
dist/assets/index-VZAwkS-j.css


+ 1 - 1
dist/assets/shapes-C4Tyb9c6.js → dist/assets/shapes-oj-TN6vd.js

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

+ 15 - 0
src/api/aiCourseware.ts

@@ -4,12 +4,27 @@ export interface AiCoursewareOutlineQuery {
   id: string
   fromType?: string
   extraPrompt?: string
+  preferences?: AiCoursewarePreferences
 }
 
 export interface AiCoursewareGenerateQuery {
   id: string
   fromType?: string
   outline: AiCoursewareOutline
+  visualPrompt?: string
+  preferences?: AiCoursewarePreferences
+  regeneratePageNo?: number
+}
+
+export interface AiCoursewarePreferences {
+  lessonMinutes: number
+  minPages: number
+  maxPages: number
+  assetStyle: "illustration" | "photo" | "mixed"
+  sourceImagePolicy: "strict" | "balanced" | "creative"
+  requireAudio: boolean
+  requireVideo: boolean
+  enableAnimations: boolean
 }
 
 export interface AiCoursewareOutline {

+ 5 - 1
src/utils/aiCoursewareToSlides.ts

@@ -4,8 +4,12 @@ import { buildAiPptSlides } from "@/utils/aiPptTemplateEngine"
 
 interface AiCoursewareVisualOptions {
   backgroundImage?: string
+  enableAnimations?: boolean
 }
 
-export function aiCoursewareToSlides(outline: AiCoursewareOutline, options: AiCoursewareVisualOptions = {}): { title: string; theme: Partial<SlideTheme>; slides: Slide[] } {
+export function aiCoursewareToSlides(
+  outline: AiCoursewareOutline,
+  options: AiCoursewareVisualOptions = {}
+): { title: string; theme: Partial<SlideTheme>; slides: Slide[] } {
   return buildAiPptSlides(outline, options)
 }

+ 217 - 0
src/utils/aiPptQuality.ts

@@ -0,0 +1,217 @@
+import type { AiCoursewareOutline, AiCoursewarePreferences } from "@/api/aiCourseware"
+import type { PPTElement, Slide } from "@/types/slides"
+
+export type AiPptQualitySeverity = "error" | "warning"
+
+export interface AiPptQualityIssue {
+  code: string
+  severity: AiPptQualitySeverity
+  pageNo?: number
+  message: string
+}
+
+export interface AiPptQualityReport {
+  passed: boolean
+  issues: AiPptQualityIssue[]
+  errors: AiPptQualityIssue[]
+  warnings: AiPptQualityIssue[]
+}
+
+const CARD_LAYOUTS = new Set(["goal_cards", "knowledge_cards", "activity_steps", "practice_check", "summary_checklist", "homework_list"])
+const ALLOWED_LAYOUTS = new Set([
+  "cover_scene",
+  "full_image_question",
+  "split_image",
+  "goal_cards",
+  "listen_wave",
+  "lyric_focus",
+  "score_focus",
+  "rhythm_grid",
+  "instrument_focus",
+  "compare_listen",
+  "knowledge_cards",
+  "activity_steps",
+  "practice_check",
+  "summary_checklist",
+  "homework_list",
+  "story_stage",
+  "observe_question",
+  "listen_compare_visual",
+  "sound_timeline",
+  "action_demo",
+  "group_mission",
+  "performance_stage",
+  "visual_summary"
+])
+const PLACEHOLDER_PATTERN = /(?:待补充|待完善|请输入|placeholder|lorem ipsum|\{\{.+?\}\}|\[.+?占位.+?\])/i
+const BROKEN_ELLIPSIS_PATTERN = /^(?:0?\d{1,3}|[A-Z])[.…]{1,3}$/i
+
+export function inspectAiPptQuality(outline: AiCoursewareOutline, slides: Slide[], preferences?: AiCoursewarePreferences): AiPptQualityReport {
+  const issues: AiPptQualityIssue[] = []
+  inspectOutline(outline, issues, preferences)
+  inspectSlides(slides, issues, preferences)
+  const errors = issues.filter(issue => issue.severity === "error")
+  const warnings = issues.filter(issue => issue.severity === "warning")
+  return { passed: errors.length === 0, issues, errors, warnings }
+}
+
+function inspectOutline(outline: AiCoursewareOutline, issues: AiPptQualityIssue[], preferences?: AiCoursewarePreferences) {
+  const pages = outline.pages || []
+  const minPages = preferences?.minPages || 9
+  const maxPages = preferences?.maxPages || 15
+  if (pages.length < minPages || pages.length > maxPages) {
+    push(issues, "page_count", "error", undefined, `课件页数为${pages.length},应在${minPages}至${maxPages}页之间`)
+  }
+
+  const layoutCounts = new Map<string, number>()
+  const imagePages = new Map<string, number[]>()
+  let previousLayout = ""
+  let consecutive = 0
+  let cardPages = 0
+
+  pages.forEach((page, index) => {
+    const pageNo = page.pageNo || index + 1
+    const layout = clean(page.visualPlan?.layout || page.visual?.layout || page.type || "content").toLowerCase()
+    if (!ALLOWED_LAYOUTS.has(layout)) push(issues, "invalid_layout", "error", pageNo, `未使用受支持的页面布局:“${layout}”`)
+    if (index === 0 && layout !== "cover_scene") push(issues, "invalid_cover_layout", "error", pageNo, "第1页必须使用封面场景布局")
+    layoutCounts.set(layout, (layoutCounts.get(layout) || 0) + 1)
+    cardPages += CARD_LAYOUTS.has(layout) ? 1 : 0
+
+    if (layout === previousLayout) consecutive += 1
+    else consecutive = 1
+    if (consecutive > 2) push(issues, "layout_consecutive", "error", pageNo, `连续${consecutive}页使用相同布局“${layout}”`)
+    previousLayout = layout
+
+    const visibleTexts = [
+      page.title,
+      page.studentContent?.headline,
+      page.activity?.task,
+      page.interaction?.question,
+      ...(page.studentContent?.points || []),
+      ...(page.activity?.steps || [])
+    ]
+      .map(clean)
+      .filter(Boolean)
+
+    visibleTexts.forEach(text => {
+      if (PLACEHOLDER_PATTERN.test(text)) push(issues, "placeholder", "error", pageNo, `存在占位文本:“${short(text)}”`)
+      if (BROKEN_ELLIPSIS_PATTERN.test(text)) push(issues, "broken_ellipsis", "error", pageNo, `存在异常截断文本:“${text}”`)
+    })
+
+    const duplicate = firstDuplicate(visibleTexts)
+    if (duplicate) push(issues, "duplicate_text", "warning", pageNo, `结构化字段存在重复文本:“${short(duplicate)}”`)
+
+    const imageUrl = clean(page.image?.url)
+    if (imageUrl) imagePages.set(imageUrl, [...(imagePages.get(imageUrl) || []), pageNo])
+  })
+
+  const requiredLayouts = Math.min(6, Math.max(4, Math.ceil(pages.length / 3)))
+  if (layoutCounts.size < requiredLayouts) {
+    push(issues, "layout_variety", "error", undefined, `整套课件只有${layoutCounts.size}种布局,至少需要${requiredLayouts}种`)
+  }
+  if (pages.length && cardPages / pages.length > 0.4) {
+    push(issues, "card_ratio", "error", undefined, `卡片或列表页占${Math.round((cardPages / pages.length) * 100)}%,不能超过40%`)
+  }
+  imagePages.forEach(pageNos => {
+    if (pageNos.length > 1) {
+      push(issues, "duplicate_image", pageNos.length > 2 ? "error" : "warning", pageNos[1], `同一主图片用于第${pageNos.join("、")}页`)
+    }
+  })
+
+  const verifiedBindings = pages
+    .flatMap(page => page.resourceBindings || [])
+    .filter(binding => binding.verified !== false && Boolean(binding.contentUrl))
+  if (preferences?.requireAudio && !verifiedBindings.some(binding => binding.resourceType === "SONG")) {
+    push(issues, "required_audio", "warning", undefined, "已选择需要音频,但课程资源中没有可用音频")
+  }
+  if (preferences?.requireVideo && !verifiedBindings.some(binding => binding.resourceType === "VIDEO")) {
+    push(issues, "required_video", "warning", undefined, "已选择需要视频,但课程资源中没有可用视频")
+  }
+}
+
+function inspectSlides(slides: Slide[], issues: AiPptQualityIssue[], preferences?: AiCoursewarePreferences) {
+  let animationCount = 0
+  slides.forEach((slide, index) => {
+    const pageNo = index + 1
+    const texts: string[] = []
+    slide.elements.forEach(element => {
+      if (
+        element.left < 0 ||
+        element.top < 0 ||
+        element.left + element.width > 1921 ||
+        (element.type !== "line" && element.top + element.height > 1081)
+      ) {
+        push(issues, "out_of_bounds", "error", pageNo, "存在超出画布的元素")
+      }
+      const text = elementText(element)
+      if (text) {
+        texts.push(text)
+        if (BROKEN_ELLIPSIS_PATTERN.test(text)) push(issues, "rendered_broken_ellipsis", "error", pageNo, `渲染后出现异常截断:“${text}”`)
+        if (PLACEHOLDER_PATTERN.test(text)) push(issues, "rendered_placeholder", "error", pageNo, `渲染后仍有占位文本:“${short(text)}”`)
+        const minimumSize = minimumFontSize(element)
+        if (minimumSize > 0 && minimumSize < 20) push(issues, "small_font", "warning", pageNo, `存在小于20px的文字:“${short(text)}”`)
+      } else if (element.type === "text") {
+        push(issues, "empty_text", "error", pageNo, "存在空文本元素")
+      }
+    })
+    const duplicate = firstDuplicate(texts.filter(text => normalize(text).length >= 4))
+    if (duplicate) push(issues, "rendered_duplicate_text", "error", pageNo, `渲染页面存在重复文本:“${short(duplicate)}”`)
+    animationCount += slide.animations?.length || 0
+  })
+  if (preferences?.enableAnimations && slides.length && animationCount === 0) {
+    push(issues, "missing_animation", "warning", undefined, "已启用动画,但当前课件没有可播放的入场动画")
+  }
+}
+
+function elementText(element: PPTElement) {
+  if (element.type === "text") return stripHtml(element.content)
+  if (element.type === "shape" && element.text) return stripHtml(element.text.content)
+  return ""
+}
+
+function minimumFontSize(element: PPTElement) {
+  const content = element.type === "text" ? element.content : element.type === "shape" && element.text ? element.text.content : ""
+  const sizes = [...content.matchAll(/font-size:\s*([\d.]+)px/g)].map(match => Number(match[1])).filter(Number.isFinite)
+  return sizes.length ? Math.min(...sizes) : 0
+}
+
+function firstDuplicate(values: string[]) {
+  const seen = new Set<string>()
+  for (const value of values) {
+    const key = normalize(value)
+    if (!key) continue
+    if (seen.has(key)) return value
+    seen.add(key)
+  }
+  return ""
+}
+
+function push(issues: AiPptQualityIssue[], code: string, severity: AiPptQualitySeverity, pageNo: number | undefined, message: string) {
+  if (issues.some(issue => issue.code === code && issue.pageNo === pageNo && issue.message === message)) return
+  issues.push({ code, severity, pageNo, message })
+}
+
+function stripHtml(value: string) {
+  return clean(
+    value
+      .replace(/<[^>]+>/g, " ")
+      .replace(/&nbsp;/g, " ")
+      .replace(/&amp;/g, "&")
+  )
+}
+
+function normalize(value: string) {
+  return clean(value)
+    .replace(/[,。!?、;:,.!?;:\s]+/g, "")
+    .toLowerCase()
+}
+
+function clean(value?: string) {
+  return String(value || "")
+    .replace(/\s+/g, " ")
+    .trim()
+}
+
+function short(value: string) {
+  return value.length > 24 ? `${value.slice(0, 24)}…` : value
+}

Fichier diff supprimé car celui-ci est trop grand
+ 739 - 81
src/utils/aiPptTemplateEngine.ts


+ 594 - 35
src/views/Editor/EditorHeader/AiCoursewareDialog.vue

@@ -32,7 +32,127 @@
         <TextArea v-model:value="extraPrompt" :rows="2" placeholder="可选,例如:多一些节奏互动,语言更适合小学低年级" :disabled="working" />
       </div>
 
-      <div v-if="outline" class="outline">
+      <div class="style-selector">
+        <div class="label">视觉方向</div>
+        <div class="style-options">
+          <button
+            type="button"
+            :class="['style-option', { active: selectedStyle === 'cartoon' }]"
+            :disabled="working"
+            @click="selectedStyle = 'cartoon'"
+          >
+            <strong>卡通课堂</strong>
+            <span>适合小学和互动课堂</span>
+          </button>
+          <button type="button" :class="['style-option', { active: selectedStyle === 'clean' }]" :disabled="working" @click="selectedStyle = 'clean'">
+            <strong>简洁教学</strong>
+            <span>适合高年级和知识讲解</span>
+          </button>
+        </div>
+      </div>
+
+      <div class="generation-preferences">
+        <div class="label">生成偏好</div>
+        <div class="preference-grid">
+          <label
+            ><span>课时</span
+            ><select v-model.number="preferences.lessonMinutes" :disabled="working">
+              <option :value="20">20分钟</option>
+              <option :value="30">30分钟</option>
+              <option :value="40">40分钟</option>
+              <option :value="45">45分钟</option>
+            </select></label
+          >
+          <label
+            ><span>页数</span
+            ><select v-model="pageRange" :disabled="working">
+              <option value="9-12">9~12页</option>
+              <option value="12-15">12~15页</option>
+              <option value="9-15">9~15页</option>
+            </select></label
+          >
+          <label
+            ><span>素材</span
+            ><select v-model="preferences.assetStyle" :disabled="working">
+              <option value="illustration">统一插画</option>
+              <option value="photo">真实照片</option>
+              <option value="mixed">插画与照片混合</option>
+            </select></label
+          >
+          <label
+            ><span>教材原图</span
+            ><select v-model="preferences.sourceImagePolicy" :disabled="working">
+              <option value="strict">优先保留</option>
+              <option value="balanced">适度使用</option>
+              <option value="creative">自由创作</option>
+            </select></label
+          >
+        </div>
+        <div class="media-options">
+          <label><input v-model="preferences.requireAudio" type="checkbox" :disabled="working" />需要音频</label>
+          <label><input v-model="preferences.requireVideo" type="checkbox" :disabled="working" />需要视频</label>
+          <label><input v-model="preferences.enableAnimations" type="checkbox" :disabled="working" />启用克制动画</label>
+        </div>
+      </div>
+
+      <div v-if="draftConverted" class="result-preview">
+        <div class="preview-head">
+          <div>
+            <div class="preview-title">全稿预览</div>
+            <div class="preview-sub">共{{ draftConverted.slides.length }}页,确认后才会替换当前课件</div>
+          </div>
+          <div :class="['quality-badge', { passed: draftQuality?.passed }]">
+            {{ draftQuality?.passed ? "质量检查通过" : `${draftQuality?.errors.length || 0}项问题待处理` }}
+          </div>
+        </div>
+        <div v-if="draftQuality?.issues.length" class="quality-list">
+          <div
+            v-for="issue in draftQuality.issues"
+            :key="`${issue.code}-${issue.pageNo || 0}-${issue.message}`"
+            :class="['quality-item', issue.severity]"
+          >
+            <span>{{ issue.severity === "error" ? "阻止应用" : "建议检查" }}</span>
+            <span>{{ issue.pageNo ? `第${issue.pageNo}页:` : "" }}{{ issue.message }}</span>
+          </div>
+        </div>
+        <div class="preview-grid">
+          <div v-for="(slide, index) in draftConverted.slides" :key="slide.id" class="preview-card">
+            <ThumbnailSlide :slide="slide" :size="220" />
+            <div class="preview-page-no">{{ formatPageNo(index + 1) }}</div>
+            <div class="page-actions">
+              <select :value="draftOutline?.pages[index]?.visualPlan?.layout" :disabled="working" @change="changeDraftLayout(index, $event)">
+                <option v-for="layout in layoutOptions" :key="layout.value" :value="layout.value">{{ layout.label }}</option>
+              </select>
+              <input
+                :value="(draftOutline?.pages[index]?.imageKeywords || []).join('、')"
+                :disabled="working"
+                placeholder="图片关键词"
+                @change="changeDraftKeywords(index, $event)"
+              />
+              <div>
+                <button type="button" :disabled="working" @click="compactDraftPage(index)">精简文字</button>
+                <button type="button" :disabled="working || !draftOutline?.pages[index]?.image" @click="removeDraftImage(index)">移除图片</button>
+                <button type="button" :disabled="working" @click="regenerateDraftPage(index)">重新生成本页</button>
+              </div>
+            </div>
+          </div>
+        </div>
+      </div>
+
+      <div v-else-if="outline" class="outline">
+        <div v-if="sampleSlide" class="sample-review">
+          <div class="sample-copy">
+            <div class="field-title">视觉样张</div>
+            <div class="sample-title">第{{ samplePageNo }}页 · {{ samplePageTitle }}</div>
+            <div class="sample-hint">样张使用最终模板和当前视觉方向渲染。修改大纲或视觉方向后需要重新确认。</div>
+            <Button type="primary" @click="sampleApproved = !sampleApproved" :disabled="working">
+              {{ sampleApproved ? "已确认样张" : "确认这个视觉方向" }}
+            </Button>
+          </div>
+          <div :class="['sample-canvas', { approved: sampleApproved }]">
+            <ThumbnailSlide :slide="sampleSlide" :size="420" />
+          </div>
+        </div>
         <div class="page-nav">
           <button
             v-for="page in outline.pages"
@@ -56,12 +176,7 @@
 
           <div class="purpose-card">
             <div class="field-title">教学目的</div>
-            <input
-              v-model="selectedPage.teachingPurpose"
-              :disabled="working"
-              class="purpose-input"
-              placeholder="这一页在课堂中要解决的教学目标"
-            />
+            <input v-model="selectedPage.teachingPurpose" :disabled="working" class="purpose-input" placeholder="这一页在课堂中要解决的教学目标" />
           </div>
 
           <div class="field-grid">
@@ -102,13 +217,13 @@
           </div>
 
           <div class="field-card visual full-field">
-              <div class="field-title">设计要求</div>
-              <textarea
-                v-model="selectedPage.designRequirement"
-                :disabled="working"
-                class="page-design"
-                placeholder="例如:左文右图,右侧放卡通山谷和音符,整体暖色活泼,文字保持大字号。"
-              ></textarea>
+            <div class="field-title">设计要求</div>
+            <textarea
+              v-model="selectedPage.designRequirement"
+              :disabled="working"
+              class="page-design"
+              placeholder="例如:左文右图,右侧放卡通山谷和音符,整体暖色活泼,文字保持大字号。"
+            ></textarea>
           </div>
 
           <div v-if="selectedPage.resourceBindings?.length" class="resource-card">
@@ -147,23 +262,31 @@
         </div>
       </div>
 
-      <div v-else class="empty">生成大纲后,可在这里确认15页结构、页面内容和设计要求。</div>
+      <div v-else class="empty">生成大纲后,可在这里确认课件结构、页面内容和设计要求。</div>
 
       <div class="footer">
         <Button @click="emit('update:visible', false)" :disabled="working">取消</Button>
-        <Button type="primary" @click="handleOutline" :disabled="working">{{ outline ? "重新生成大纲" : "生成大纲" }}</Button>
-        <Button type="primary" @click="handleGenerate" :disabled="!outline || working">确认并生成PPT</Button>
+        <template v-if="draftConverted">
+          <Button @click="backToOutline" :disabled="working">返回修改</Button>
+          <Button type="primary" @click="handleGenerate" :disabled="working">重新生成</Button>
+          <Button type="primary" @click="applyDraft" :disabled="working || !draftQuality?.passed">应用到当前课件</Button>
+        </template>
+        <template v-else>
+          <Button type="primary" @click="handleOutline" :disabled="working">{{ outline ? "重新生成大纲" : "生成大纲" }}</Button>
+          <Button type="primary" @click="handleGenerate" :disabled="!outline || !sampleApproved || working">确认并生成PPT</Button>
+        </template>
       </div>
     </div>
   </Modal>
 </template>
 
 <script lang="ts" setup>
-import { ref, computed, watch } from "vue"
+import { ref, reactive, computed, watch } from "vue"
 import { ElMessage, ElMessageBox } from "element-plus"
 import Modal from "@/components/Modal.vue"
 import Button from "@/components/Button.vue"
 import TextArea from "@/components/TextArea.vue"
+import ThumbnailSlide from "@/views/components/ThumbnailSlide/index.vue"
 import queryParams from "@/queryParams"
 import usePptWork from "@/store/pptWork"
 import { useSlidesStore } from "@/store"
@@ -176,10 +299,13 @@ import {
   aiCoursewareTaskApi,
   type AiCoursewareOutline,
   type AiCoursewarePage,
+  type AiCoursewarePreferences,
   type AiCoursewareTaskInfo,
   type AiCoursewareGenerateResult
 } from "@/api/aiCourseware"
 import { aiCoursewareToSlides } from "@/utils/aiCoursewareToSlides"
+import { inspectAiPptQuality, type AiPptQualityReport } from "@/utils/aiPptQuality"
+import type { Slide, SlideTheme } from "@/types/slides"
 
 const props = defineProps<{
   visible: boolean
@@ -196,6 +322,22 @@ const { addHistorySnapshot } = useHistorySnapshot()
 const extraPrompt = ref("")
 const outline = ref<AiCoursewareOutline>()
 const taskInfo = ref<AiCoursewareTaskInfo>()
+const selectedStyle = ref<"cartoon" | "clean">("cartoon")
+const pageRange = ref("9-12")
+const preferences = reactive<AiCoursewarePreferences>({
+  lessonMinutes: 40,
+  minPages: 9,
+  maxPages: 12,
+  assetStyle: "illustration",
+  sourceImagePolicy: "balanced",
+  requireAudio: false,
+  requireVideo: false,
+  enableAnimations: true
+})
+const sampleApproved = ref(false)
+const draftOutline = ref<AiCoursewareOutline>()
+const draftConverted = ref<{ title: string; theme: Partial<SlideTheme>; slides: Slide[] }>()
+const draftQuality = ref<AiPptQualityReport>()
 const pollTimer = ref<number>()
 const selectedPageNo = ref(1)
 const outlineLoading = ref(false)
@@ -214,7 +356,7 @@ const currentProgressLabel = computed(() => {
 })
 const pageTitle = computed(() => outline.value?.chapterName || outline.value?.title || "课件大纲")
 const pageMeta = computed(() => {
-  if (!outline.value) return "根据当前教材、单元和章节生成15页音乐课件"
+  if (!outline.value) return "根据当前教材、单元和章节生成9至15页音乐课件"
   return [...[outline.value.gradeName, outline.value.unitName].filter(Boolean), `共${outline.value.pages.length}页`].join(" · ")
 })
 const selectedPage = computed(() => {
@@ -227,6 +369,68 @@ const studentPoints = computed(() => {
   return selectedPage.value.studentContent?.points?.length ? selectedPage.value.studentContent.points : selectedPage.value.bullets || []
 })
 const activityTask = computed(() => selectedPage.value?.classroomAction?.studentTask || selectedPage.value?.activity?.task || "")
+const samplePageIndex = computed(() => {
+  const pages = outline.value?.pages || []
+  const preferred = pages.findIndex(
+    (page, index) => index > 0 && ["goal_cards", "activity_steps", "compare_listen", "summary_checklist"].includes(page.visualPlan?.layout || "")
+  )
+  return preferred >= 0 ? preferred : Math.min(1, Math.max(0, pages.length - 1))
+})
+const samplePageNo = computed(() => outline.value?.pages[samplePageIndex.value]?.pageNo || samplePageIndex.value + 1)
+const samplePageTitle = computed(() => outline.value?.pages[samplePageIndex.value]?.title || "代表页面")
+const sampleSlide = computed(() => {
+  if (!outline.value?.pages.length) return undefined
+  const previewOutline = { ...outline.value, style: selectedStyle.value }
+  return aiCoursewareToSlides(previewOutline, { enableAnimations: preferences.enableAnimations }).slides[samplePageIndex.value]
+})
+
+const layoutOptions = [
+  { value: "cover_scene", label: "封面场景" },
+  { value: "story_stage", label: "故事舞台" },
+  { value: "observe_question", label: "观察提问" },
+  { value: "listen_compare_visual", label: "听辨对比" },
+  { value: "sound_timeline", label: "声音时间线" },
+  { value: "action_demo", label: "动作示范" },
+  { value: "group_mission", label: "小组任务" },
+  { value: "performance_stage", label: "表演舞台" },
+  { value: "visual_summary", label: "视觉总结" },
+  { value: "full_image_question", label: "全图提问" },
+  { value: "split_image", label: "图文分栏" },
+  { value: "goal_cards", label: "目标卡片" },
+  { value: "listen_wave", label: "听赏任务" },
+  { value: "lyric_focus", label: "歌词聚焦" },
+  { value: "score_focus", label: "谱例聚焦" },
+  { value: "rhythm_grid", label: "节奏练习" },
+  { value: "instrument_focus", label: "乐器认识" },
+  { value: "compare_listen", label: "对比听赏" },
+  { value: "knowledge_cards", label: "知识列表" },
+  { value: "activity_steps", label: "活动步骤" },
+  { value: "practice_check", label: "课堂检测" },
+  { value: "summary_checklist", label: "总结清单" },
+  { value: "homework_list", label: "课后任务" }
+]
+
+watch(selectedStyle, style => {
+  if (outline.value) outline.value.style = style
+  invalidateVisualApproval()
+})
+
+watch(pageRange, value => {
+  const [minPages, maxPages] = value.split("-").map(Number)
+  preferences.minPages = minPages
+  preferences.maxPages = maxPages
+})
+
+watch(preferences, () => invalidateVisualApproval(), { deep: true })
+
+watch(
+  outline,
+  () => {
+    if (!outline.value) return
+    invalidateVisualApproval()
+  },
+  { deep: true }
+)
 
 watch(
   () => props.visible,
@@ -244,19 +448,22 @@ async function handleOutline() {
   const res = await httpAjaxErrMsg(aiCoursewareOutlineApi, {
     id: pptWork.id,
     fromType: queryParams.pptResourcesType || queryParams.fromType,
-    extraPrompt: buildExtraPrompt()
+    extraPrompt: buildExtraPrompt(),
+    preferences: { ...preferences }
   }).finally(() => {
     outlineLoading.value = false
   })
   if (res.code === 200) {
     outline.value = res.data as AiCoursewareOutline
+    outline.value.style = selectedStyle.value
     selectedPageNo.value = outline.value.pages[0]?.pageNo || 1
   }
 }
 
 async function handleGenerate() {
-  if (!outline.value || !pptWork.id) return
-  const confirmed = await ElMessageBox.confirm("生成后会替换当前编辑器中的课件内容,未保存内容将丢失。确认继续?", "确认生成", {
+  const sourceOutline = draftOutline.value || outline.value
+  if (!sourceOutline || !pptWork.id || !sampleApproved.value) return
+  const confirmed = await ElMessageBox.confirm("系统会生成一份完整课件草稿,完成后先进入预览,不会直接替换当前课件。确认继续?", "确认生成", {
     confirmButtonText: "确认生成",
     cancelButtonText: "取消",
     type: "warning"
@@ -265,21 +472,28 @@ async function handleGenerate() {
     .catch(() => false)
   if (!confirmed) return
 
+  draftConverted.value = undefined
+  draftQuality.value = undefined
+  sourceOutline.style = selectedStyle.value
   const res = await httpAjaxErrMsg(aiCoursewareGenerateApi, {
     id: pptWork.id,
     fromType: queryParams.pptResourcesType || queryParams.fromType,
-    outline: outline.value
+    outline: sourceOutline,
+    visualPrompt: buildVisualPrompt(),
+    preferences: { ...preferences }
   })
   if (res.code !== 200) return
 
   taskInfo.value = res.data as AiCoursewareTaskInfo
-  pollTask(taskInfo.value.taskId)
+  await pollTask(taskInfo.value.taskId)
 }
 
 async function pollTask(taskId: string) {
   if (pollTimer.value) window.clearInterval(pollTimer.value)
   await fetchTask(taskId)
-  pollTimer.value = window.setInterval(() => fetchTask(taskId), 2000)
+  if (taskInfo.value?.status === "PENDING" || taskInfo.value?.status === "RUNNING") {
+    pollTimer.value = window.setInterval(() => fetchTask(taskId), 2000)
+  }
 }
 
 async function fetchTask(taskId: string) {
@@ -291,7 +505,7 @@ async function fetchTask(taskId: string) {
   taskInfo.value = res.data as AiCoursewareTaskInfo
   if (taskInfo.value.status === "DONE") {
     clearPollTimer()
-    await applyResult(taskId)
+    await loadDraft(taskId)
   } else if (taskInfo.value.status === "FAILED") {
     clearPollTimer()
     ElMessage.error(taskInfo.value.message || "AI课件生成失败")
@@ -310,21 +524,126 @@ function clearPollTimer() {
   }
 }
 
-async function applyResult(taskId: string) {
+async function loadDraft(taskId: string) {
   const res = await httpAjaxErrMsg(aiCoursewareResultApi, taskId)
   if (res.code !== 200) return
 
   const result = res.data as AiCoursewareGenerateResult
-  const converted = aiCoursewareToSlides(result.outline)
-  slidesStore.setTitle(converted.title)
-  slidesStore.setTheme(converted.theme)
-  slidesStore.setSlides(converted.slides)
+  draftOutline.value = result.outline
+  rebuildDraftPreview()
+  if (draftQuality.value?.passed) ElMessage.success("课件草稿已生成,请预览后确认应用")
+  else ElMessage.error(`课件草稿存在${draftQuality.value?.errors.length || 0}项阻止应用的问题`)
+}
+
+function rebuildDraftPreview() {
+  if (!draftOutline.value) {
+    draftConverted.value = undefined
+    draftQuality.value = undefined
+    return
+  }
+  draftOutline.value.style = selectedStyle.value
+  draftConverted.value = aiCoursewareToSlides(draftOutline.value, { enableAnimations: preferences.enableAnimations })
+  draftQuality.value = inspectAiPptQuality(draftOutline.value, draftConverted.value.slides, preferences)
+}
+
+function changeDraftLayout(index: number, event: Event) {
+  const page = draftOutline.value?.pages[index]
+  if (!page) return
+  if (!page.visualPlan) page.visualPlan = {}
+  page.visualPlan.layout = (event.target as HTMLSelectElement).value
+  rebuildDraftPreview()
+}
+
+function changeDraftKeywords(index: number, event: Event) {
+  const page = draftOutline.value?.pages[index]
+  if (!page) return
+  const keywords = (event.target as HTMLInputElement).value
+    .split(/[,,、;;\s]+/)
+    .map(item => item.trim())
+    .filter(Boolean)
+    .slice(0, 6)
+  page.imageKeywords = keywords
+  if (!page.visualPlan) page.visualPlan = {}
+  page.visualPlan.imageKeywords = keywords
+  rebuildDraftPreview()
+}
+
+function compactDraftPage(index: number) {
+  const page = draftOutline.value?.pages[index]
+  if (!page) return
+  const compact = (value: string | undefined, limit: number) =>
+    String(value || "")
+      .replace(/\s+/g, " ")
+      .trim()
+      .slice(0, limit)
+  if (page.studentContent) {
+    page.studentContent.headline = compact(page.studentContent.headline, 18)
+    page.studentContent.points = [...new Set(page.studentContent.points || [])]
+      .slice(0, 3)
+      .map(item => compact(item, 20))
+      .filter(Boolean)
+    page.bullets = [...page.studentContent.points]
+  }
+  if (page.activity) {
+    page.activity.task = compact(page.activity.task, 22)
+    page.activity.steps = [...new Set(page.activity.steps || [])]
+      .slice(0, 3)
+      .map(item => compact(item, 20))
+      .filter(Boolean)
+  }
+  rebuildDraftPreview()
+}
+
+function removeDraftImage(index: number) {
+  const page = draftOutline.value?.pages[index]
+  if (!page) return
+  page.image = undefined
+  page.resourceBindings = (page.resourceBindings || []).filter(binding => binding.resourceType !== "IMG")
+  rebuildDraftPreview()
+}
+
+async function regenerateDraftPage(index: number) {
+  if (!draftOutline.value || !pptWork.id) return
+  const res = await httpAjaxErrMsg(aiCoursewareGenerateApi, {
+    id: pptWork.id,
+    fromType: queryParams.pptResourcesType || queryParams.fromType,
+    outline: draftOutline.value,
+    visualPrompt: buildVisualPrompt(),
+    preferences: { ...preferences },
+    regeneratePageNo: index + 1
+  })
+  if (res.code !== 200) return
+  taskInfo.value = res.data as AiCoursewareTaskInfo
+  await pollTask(taskInfo.value.taskId)
+}
+
+function applyDraft() {
+  if (!draftConverted.value || !draftQuality.value?.passed) return
+  slidesStore.setTitle(draftConverted.value.title)
+  slidesStore.setTheme(draftConverted.value.theme)
+  slidesStore.setSlides(draftConverted.value.slides)
   slidesStore.updateSlideIndex(0)
   addHistorySnapshot()
-  ElMessage.success("PPT已生成,请检查后手动保存")
+  ElMessage.success("PPT已应用,请检查后手动保存")
   emit("update:visible", false)
 }
 
+function backToOutline() {
+  clearTask()
+  if (draftOutline.value) outline.value = draftOutline.value
+  draftOutline.value = undefined
+  draftConverted.value = undefined
+  draftQuality.value = undefined
+}
+
+function invalidateVisualApproval() {
+  sampleApproved.value = false
+  if (!draftConverted.value) return
+  draftOutline.value = undefined
+  draftConverted.value = undefined
+  draftQuality.value = undefined
+}
+
 function updateStudentHeadline(page: AiCoursewarePage, event: Event) {
   if (!page.studentContent) page.studentContent = { headline: "", points: [] }
   page.studentContent.headline = (event.target as HTMLInputElement).value.trim()
@@ -368,6 +687,26 @@ function buildExtraPrompt() {
   return extraPrompt.value.trim()
 }
 
+function buildVisualPrompt() {
+  const base =
+    selectedStyle.value === "clean"
+      ? "简洁教学风格,层级清晰,避免装饰性卡片堆叠,保持大字号和明确视觉焦点。"
+      : "卡通课堂风格,适合低龄学生,使用统一画风的具体人物、动作和场景,避免成人商务卡片风格。"
+  const asset =
+    preferences.assetStyle === "illustration"
+      ? "主视觉使用统一画风插画。"
+      : preferences.assetStyle === "photo"
+        ? "主视觉优先真实照片。"
+        : "插画和真实照片按教学内容合理混用。"
+  const source =
+    preferences.sourceImagePolicy === "strict"
+      ? "优先保留教材原图,不随意替换。"
+      : preferences.sourceImagePolicy === "creative"
+        ? "允许根据教学目标重新创作主视觉。"
+        : "教材原图与新视觉保持平衡。"
+  return `${base}${asset}${source}`
+}
+
 function formatPageNo(pageNo?: number) {
   return String(pageNo || 0).padStart(2, "0")
 }
@@ -385,12 +724,19 @@ function shortProgressLabel(message?: string) {
 
 function resourceTypeName(type: string) {
   const names: Record<string, string> = {
-    IMG: "图片", VIDEO: "视频", SONG: "音频", MUSIC: "乐谱", LISTEN: "听音练习",
-    RHYTHM: "节奏练习", THEORY: "乐理知识", MUSIC_WIKI: "名曲鉴赏", INSTRUMENT: "乐器百科", MUSICIAN: "音乐家",
+    IMG: "图片",
+    VIDEO: "视频",
+    SONG: "音频",
+    MUSIC: "乐谱",
+    LISTEN: "听音练习",
+    RHYTHM: "节奏练习",
+    THEORY: "乐理知识",
+    MUSIC_WIKI: "名曲鉴赏",
+    INSTRUMENT: "乐器百科",
+    MUSICIAN: "音乐家"
   }
   return names[type] || "课堂资源"
 }
-
 </script>
 
 <style lang="scss" scoped>
@@ -472,6 +818,75 @@ function resourceTypeName(type: string) {
 .extra {
   margin-top: 12px;
 }
+.generation-preferences {
+  margin-top: 12px;
+  padding: 12px;
+  border: 1px solid #e5eaf0;
+  border-radius: 8px;
+  background: #fafcff;
+}
+.preference-grid {
+  display: grid;
+  grid-template-columns: repeat(4, minmax(140px, 1fr));
+  gap: 10px;
+  label,
+  span {
+    display: block;
+  }
+  span {
+    margin-bottom: 5px;
+    color: #52616b;
+    font-size: 12px;
+  }
+  input,
+  select {
+    width: 100%;
+    height: 34px;
+    padding: 0 8px;
+    border: 1px solid #dfe5ec;
+    border-radius: 6px;
+    background: #fff;
+  }
+}
+.media-options {
+  display: flex;
+  gap: 18px;
+  margin-top: 10px;
+  color: #52616b;
+  font-size: 13px;
+  input {
+    margin-right: 6px;
+  }
+}
+.style-selector {
+  margin-top: 12px;
+}
+.style-options {
+  display: flex;
+  gap: 10px;
+}
+.style-option {
+  width: 210px;
+  padding: 10px 12px;
+  border: 1px solid #dfe5ec;
+  border-radius: 8px;
+  background: #fff;
+  text-align: left;
+  cursor: pointer;
+}
+.style-option strong,
+.style-option span {
+  display: block;
+}
+.style-option span {
+  margin-top: 4px;
+  color: #7b8794;
+  font-size: 12px;
+}
+.style-option.active {
+  border-color: $themeColor;
+  box-shadow: 0 0 0 2px rgba($themeColor, 0.12);
+}
 .label {
   margin-bottom: 8px;
   font-size: 14px;
@@ -485,6 +900,150 @@ function resourceTypeName(type: string) {
   grid-template-columns: 280px minmax(0, 1fr);
   gap: 14px;
 }
+.sample-review {
+  grid-column: 1 / -1;
+  display: grid;
+  grid-template-columns: minmax(240px, 1fr) 420px;
+  align-items: center;
+  gap: 20px;
+  padding: 14px;
+  border: 1px solid #e5eaf0;
+  border-radius: 8px;
+  background: #fafcff;
+}
+.sample-title {
+  margin: 8px 0;
+  font-size: 16px;
+  font-weight: 700;
+}
+.sample-hint {
+  margin-bottom: 14px;
+  color: #7b8794;
+  font-size: 12px;
+  line-height: 1.6;
+}
+.sample-canvas {
+  overflow: hidden;
+  border: 2px solid transparent;
+  border-radius: 6px;
+  box-shadow: 0 6px 20px rgba(31, 41, 51, 0.12);
+}
+.sample-canvas.approved {
+  border-color: #168f86;
+}
+.result-preview {
+  min-height: 0;
+  flex: 1;
+  margin-top: 14px;
+  overflow: auto;
+}
+.preview-head {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+}
+.preview-title {
+  font-size: 18px;
+  font-weight: 700;
+}
+.preview-sub {
+  margin-top: 4px;
+  color: #7b8794;
+  font-size: 12px;
+}
+.quality-badge {
+  padding: 7px 12px;
+  border-radius: 999px;
+  background: #fff1f0;
+  color: #cf1322;
+  font-size: 12px;
+  font-weight: 700;
+}
+.quality-badge.passed {
+  background: #eaf7f4;
+  color: #168f86;
+}
+.quality-list {
+  margin-top: 12px;
+  max-height: 150px;
+  overflow: auto;
+  border: 1px solid #edf1f5;
+  border-radius: 8px;
+}
+.quality-item {
+  display: grid;
+  grid-template-columns: 70px 1fr;
+  gap: 10px;
+  padding: 8px 10px;
+  font-size: 12px;
+}
+.quality-item + .quality-item {
+  border-top: 1px solid #edf1f5;
+}
+.quality-item.error > span:first-child {
+  color: #cf1322;
+  font-weight: 700;
+}
+.quality-item.warning > span:first-child {
+  color: #d97706;
+  font-weight: 700;
+}
+.preview-grid {
+  display: grid;
+  grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
+  gap: 14px;
+  margin-top: 14px;
+}
+.preview-card {
+  position: relative;
+  width: 220px;
+  border: 1px solid #e5eaf0;
+  border-radius: 6px;
+  background: #fff;
+  box-shadow: 0 4px 12px rgba(31, 41, 51, 0.08);
+}
+.page-actions {
+  display: grid;
+  gap: 7px;
+  padding: 8px;
+  select,
+  input {
+    min-width: 0;
+    height: 30px;
+    padding: 0 7px;
+    border: 1px solid #dfe5ec;
+    border-radius: 5px;
+    font-size: 12px;
+  }
+  > div {
+    display: flex;
+    flex-wrap: wrap;
+    gap: 5px;
+  }
+  button {
+    padding: 4px 7px;
+    border: 1px solid #dfe5ec;
+    border-radius: 5px;
+    background: #fff;
+    color: #52616b;
+    font-size: 11px;
+    cursor: pointer;
+  }
+  button:disabled {
+    cursor: not-allowed;
+    opacity: 0.5;
+  }
+}
+.preview-page-no {
+  position: absolute;
+  right: 6px;
+  bottom: 6px;
+  padding: 2px 5px;
+  border-radius: 4px;
+  background: rgba(31, 41, 51, 0.72);
+  color: #fff;
+  font-size: 11px;
+}
 .page-nav {
   overflow: auto;
   border: 1px solid #edf1f5;

Certains fichiers n'ont pas été affichés car il y a eu trop de fichiers modifiés dans ce diff