yonge 11 ساعت پیش
والد
کامیت
317b20ea45

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
dist/assets/index-BKIH-tW9.js


+ 1 - 1
dist/assets/index-ChhapN08.js → dist/assets/index-BKfg65tz.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-BZP7uwjz.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-DJZEgmhl.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-Bc4VIfy0.js


+ 1 - 1
dist/assets/index-hdCrI7uE.js → dist/assets/index-BmCYJAft.js

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

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
dist/assets/index-CJfmVt57.css


+ 1 - 1
dist/assets/index-BeKROJiY.js → dist/assets/index-CpWoYTul.js

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

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
dist/assets/index-Cv7ule-m.css


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 1 - 1
dist/assets/index-DJZEgmhl.js


+ 1 - 1
dist/assets/index-BhRZlZ0y.js → dist/assets/index-Da4JVioe.js

@@ -1 +1 @@
-import{u as s,af as r}from"./index-BeKROJiY.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-BZP7uwjz.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};
+import{u as s,af as r}from"./index-CpWoYTul.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-DJZEgmhl.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-CvSP0MM_.js → dist/assets/index-NK16V9hj.js

@@ -1 +1 @@
-import{u as c,af as l}from"./index-BeKROJiY.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-BZP7uwjz.js";import"./shapes-DiLDK5TY.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,af as l}from"./index-CpWoYTul.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-DJZEgmhl.js";import"./shapes-KTA-L4zp.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};

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
dist/assets/index-vpsjAQKg.js


+ 1 - 1
dist/assets/shapes-DiLDK5TY.js → dist/assets/shapes-KTA-L4zp.js

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

+ 16 - 0
src/api/aiCourseware.ts

@@ -35,10 +35,25 @@ export interface AiCoursewareOutline {
   chapterName: string
   courseType: string
   style: "cartoon" | "clean" | string
+  visualBible?: AiCoursewareVisualBible
   textbookResearch?: AiCoursewareTextbookResearch
   pages: AiCoursewarePage[]
 }
 
+export interface AiCoursewareVisualBible {
+  artStyle?: string
+  palette?: string[]
+  environment?: string
+  lighting?: string
+  recurringProps?: string[]
+  characters?: Array<{
+    name: string
+    appearance?: string
+    costume?: string
+    signatureColor?: string
+  }>
+}
+
 export interface AiCoursewareTextbookResearch {
   exactMatch: boolean
   confidence: "HIGH" | "MEDIUM" | "LOW" | string
@@ -104,6 +119,7 @@ export interface AiCoursewarePage {
   interaction?: {
     type: string
     question: string
+    options?: string[]
     answer?: string
   }
   imageKeywords: string[]

+ 28 - 0
src/config/aiCoursewareTemplates.ts

@@ -28,6 +28,10 @@ export interface AiCoursewareTemplate {
   scene: string
   description: string
   baseStyle: "cartoon" | "clean"
+  layoutFamily: "story" | "education" | "academic" | "culture" | "technology" | "activity"
+  typography: string
+  backgroundStyle: string
+  illustrationStyle: string
   visualPrompt: string
   palettes: [AiCoursewareThemePalette, AiCoursewareThemePalette, AiCoursewareThemePalette]
 }
@@ -76,6 +80,10 @@ export const AI_COURSEWARE_TEMPLATES: AiCoursewareTemplate[] = [
     scene: "小学低年级",
     description: "故事导入、情境欣赏和童趣互动",
     baseStyle: "cartoon",
+    layoutFamily: "story",
+    typography: "圆润醒目的低龄课堂字体层级,标题短而大,正文不低于30px",
+    backgroundStyle: "绘本式连续场景背景,保留大面积干净文字区",
+    illustrationStyle: "统一儿童绘本插画,角色造型、服装和标志色跨页一致",
     visualPrompt: "趣味手绘课堂风格,画面亲切活泼,使用统一插画语言和明确场景,避免商务卡片堆叠。",
     palettes: playfulPalettes
   },
@@ -86,6 +94,10 @@ export const AI_COURSEWARE_TEMPLATES: AiCoursewareTemplate[] = [
     scene: "常规音乐课",
     description: "知识讲解、听赏练习和课堂总结",
     baseStyle: "clean",
+    layoutFamily: "education",
+    typography: "清晰现代的教学字体层级,标题明确,正文适合远距离投屏",
+    backgroundStyle: "浅色教育场景与克制几何底纹",
+    illustrationStyle: "简洁教育插画或真实课堂素材,主体明确",
     visualPrompt: "清新教育课件风格,留白充足、结构清楚、色彩温和,兼顾教师讲解和学生远距离阅读。",
     palettes: freshPalettes
   },
@@ -96,6 +108,10 @@ export const AI_COURSEWARE_TEMPLATES: AiCoursewareTemplate[] = [
     scene: "初高中课堂",
     description: "乐理、作品结构和知识密集型内容",
     baseStyle: "clean",
+    layoutFamily: "academic",
+    typography: "严谨的标题、正文和注释三级层级",
+    backgroundStyle: "克制蓝灰背景与清晰内容分区",
+    illustrationStyle: "写实乐器、谱例和结构图优先",
     visualPrompt: "蓝色学术课堂风格,严谨克制、层级明确,强化标题、数据和谱例焦点,减少无关装饰。",
     palettes: academicPalettes
   },
@@ -106,6 +122,10 @@ export const AI_COURSEWARE_TEMPLATES: AiCoursewareTemplate[] = [
     scene: "传统文化主题",
     description: "民歌、戏曲、民族音乐和传统节日",
     baseStyle: "clean",
+    layoutFamily: "culture",
+    typography: "现代中文标题与舒展正文层级",
+    backgroundStyle: "米白纸感与克制东方纹样",
+    illustrationStyle: "统一现代东方插画,避免素材拼贴",
     visualPrompt: "现代东方文化课堂风格,使用米白、黛色和朱砂色,装饰克制,突出传统文化意境与课堂可读性。",
     palettes: orientalPalettes
   },
@@ -116,6 +136,10 @@ export const AI_COURSEWARE_TEMPLATES: AiCoursewareTemplate[] = [
     scene: "音乐科技主题",
     description: "电子音乐、数字工具和声音科技",
     baseStyle: "clean",
+    layoutFamily: "technology",
+    typography: "高对比科技标题与简洁正文层级",
+    backgroundStyle: "深色网格、光点和节点背景",
+    illustrationStyle: "统一数字音乐科技视觉,避免无关赛博装饰",
     visualPrompt: "数字科技课堂风格,深色背景配高对比蓝青光感,使用克制的网格和节点元素,保证教学文字清晰。",
     palettes: techPalettes
   },
@@ -126,6 +150,10 @@ export const AI_COURSEWARE_TEMPLATES: AiCoursewareTemplate[] = [
     scene: "课堂活动",
     description: "分组练习、节奏游戏、表演与展示",
     baseStyle: "cartoon",
+    layoutFamily: "activity",
+    typography: "大字号动作指令与清晰编号层级",
+    backgroundStyle: "明快舞台或课堂活动场景",
+    illustrationStyle: "统一动作示范插画,人物姿态清楚",
     visualPrompt: "活力互动课堂风格,节奏感强、色彩明快,用清晰编号和动作路径组织任务,避免信息拥挤。",
     palettes: activePalettes
   }

+ 64 - 4
src/utils/aiPptQuality.ts

@@ -1,6 +1,8 @@
 import type { AiCoursewareOutline, AiCoursewarePreferences } from "@/api/aiCourseware"
 import type { PPTElement, Slide } from "@/types/slides"
 
+type BoundedPPTElement = Exclude<PPTElement, { type: "line" }>
+
 export type AiPptQualitySeverity = "error" | "warning"
 
 export interface AiPptQualityIssue {
@@ -43,8 +45,13 @@ const ALLOWED_LAYOUTS = new Set([
   "performance_stage",
   "visual_summary"
 ])
-const PLACEHOLDER_PATTERN = /(?:待补充|待完善|请输入|placeholder|lorem ipsum|\{\{.+?\}\}|\[.+?占位.+?\])/i
+const PLACEHOLDER_PATTERN = /(?:待补充|待完善|请输入|可参考教材|placeholder|lorem ipsum|\{\{.+?\}\}|\[.+?占位.+?\])/i
 const BROKEN_ELLIPSIS_PATTERN = /^(?:0?\d{1,3}|[A-Z])[.…]{1,3}$/i
+const TRUNCATED_TEXT_PATTERN = /(?:…|\.\.\.)$/
+const TEACHER_LANGUAGE_PATTERN = /(?:教师|老师)(?:讲述|提问|引导|指导|组织|巡视|示范)|(?:引导|指导|组织)学生/
+// 模板内部使用1600x900,输出到编辑器前会统一缩放为1920x1080。
+const SLIDE_WIDTH = 1920
+const SLIDE_HEIGHT = 1080
 
 export function inspectAiPptQuality(outline: AiCoursewareOutline, slides: Slide[], preferences?: AiCoursewarePreferences): AiPptQualityReport {
   const issues: AiPptQualityIssue[] = []
@@ -96,6 +103,17 @@ function inspectOutline(outline: AiCoursewareOutline, issues: AiPptQualityIssue[
     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}”`)
+      if (TEACHER_LANGUAGE_PATTERN.test(text)) push(issues, "teacher_text_visible", "error", pageNo, `学生可见内容混入教师指令:“${short(text)}”`)
+    })
+
+    const options = (page.interaction?.options || []).map(clean).filter(Boolean)
+    if (layout === "practice_check" && options.length > 0 && options.length < 2) {
+      push(issues, "invalid_options", "error", pageNo, "检测题至少需要两个有效选项")
+    }
+    options.forEach(option => {
+      if (/^(?:准备好了吗|开始吧|请回答|想一想|说一说|听一听)[??!!。]*$/.test(option)) {
+        push(issues, "instruction_as_option", "error", pageNo, `操作提示不能作为答案选项:“${option}”`)
+      }
     })
 
     const duplicate = firstDuplicate(visibleTexts)
@@ -134,22 +152,30 @@ function inspectSlides(slides: Slide[], issues: AiPptQualityIssue[], preferences
   slides.forEach((slide, index) => {
     const pageNo = index + 1
     const texts: string[] = []
+    const textElements: Array<{ element: BoundedPPTElement; text: 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)
+        element.left + element.width > SLIDE_WIDTH + 1 ||
+        (element.type !== "line" && element.top + element.height > SLIDE_HEIGHT + 1)
       ) {
         push(issues, "out_of_bounds", "error", pageNo, "存在超出画布的元素")
       }
       const text = elementText(element)
       if (text) {
         texts.push(text)
+        if (element.type !== "line") textElements.push({ element, text })
         if (BROKEN_ELLIPSIS_PATTERN.test(text)) push(issues, "rendered_broken_ellipsis", "error", pageNo, `渲染后出现异常截断:“${text}”`)
+        if (TRUNCATED_TEXT_PATTERN.test(text)) push(issues, "rendered_truncated_text", "error", pageNo, `文字被省略号截断:“${short(text)}”`)
         if (PLACEHOLDER_PATTERN.test(text)) push(issues, "rendered_placeholder", "error", pageNo, `渲染后仍有占位文本:“${short(text)}”`)
+        if (TEACHER_LANGUAGE_PATTERN.test(text)) push(issues, "rendered_teacher_text", "error", pageNo, `学生画面出现教师指令:“${short(text)}”`)
         const minimumSize = minimumFontSize(element)
-        if (minimumSize > 0 && minimumSize < 20) push(issues, "small_font", "warning", pageNo, `存在小于20px的文字:“${short(text)}”`)
+        if (minimumSize > 0 && minimumSize < 20) push(issues, "small_font", "error", pageNo, `存在小于20px的文字:“${short(text)}”`)
+        else if (minimumSize > 0 && minimumSize < 24) push(issues, "small_font_warning", "warning", pageNo, `投屏文字小于24px:“${short(text)}”`)
+        if (element.type !== "line" && isLikelyOverflow(element, text, minimumSize)) {
+          push(issues, "text_overflow", "error", pageNo, `文本框空间不足:“${short(text)}”`)
+        }
       } else if (element.type === "text") {
         push(issues, "empty_text", "error", pageNo, "存在空文本元素")
       }
@@ -157,12 +183,46 @@ function inspectSlides(slides: Slide[], issues: AiPptQualityIssue[], preferences
     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
+    inspectTextOverlaps(textElements, pageNo, issues)
   })
   if (preferences?.enableAnimations && slides.length && animationCount === 0) {
     push(issues, "missing_animation", "warning", undefined, "已启用动画,但当前课件没有可播放的入场动画")
   }
 }
 
+function isLikelyOverflow(element: BoundedPPTElement, text: string, fontSize: number) {
+  if (!fontSize || element.width <= 0 || element.height <= 0) return false
+  const lines = Math.max(1, Math.floor(element.height / (fontSize * 1.25)))
+  const capacity = Math.max(1, ((element.width - 20) / fontSize) * lines)
+  return textUnits(text) > capacity * 1.35
+}
+
+function inspectTextOverlaps(
+  entries: Array<{ element: BoundedPPTElement; text: string }>,
+  pageNo: number,
+  issues: AiPptQualityIssue[]
+) {
+  for (let i = 0; i < entries.length; i += 1) {
+    for (let j = i + 1; j < entries.length; j += 1) {
+      const first = entries[i].element
+      const second = entries[j].element
+      const overlapWidth = Math.min(first.left + first.width, second.left + second.width) - Math.max(first.left, second.left)
+      const overlapHeight = Math.min(first.top + first.height, second.top + second.height) - Math.max(first.top, second.top)
+      if (overlapWidth <= 0 || overlapHeight <= 0) continue
+      const overlapArea = overlapWidth * overlapHeight
+      const smallerArea = Math.min(first.width * first.height, second.width * second.height)
+      if (smallerArea > 0 && overlapArea / smallerArea > 0.18) {
+        push(issues, "text_overlap", "error", pageNo, `文字区域相互遮挡:“${short(entries[i].text)}”与“${short(entries[j].text)}”`)
+        return
+      }
+    }
+  }
+}
+
+function textUnits(value: string) {
+  return Array.from(value).reduce((total, char) => total + (char.charCodeAt(0) <= 0xff ? 0.55 : 1), 0)
+}
+
 function elementText(element: PPTElement) {
   if (element.type === "text") return stripHtml(element.content)
   if (element.type === "shape" && element.text) return stripHtml(element.text.content)

+ 31 - 67
src/utils/aiPptTemplateEngine.ts

@@ -313,31 +313,33 @@ function coverTemplate(page: AiCoursewarePage, outline: AiCoursewareOutline, pal
   const subtitle = [outline.gradeName, conciseTextbook(outline.textbookName), outline.unitName].filter(Boolean).join(" · ")
   const points = studentPoints(page, 2)
   if (hasImage(page)) {
-    const elements: PPTElement[] = [imageElement(page, { left: 0, top: 0, width: SLIDE_WIDTH, height: SLIDE_HEIGHT })]
-    elements.push(shapeElement({ left: 0, top: 0, width: 910, height: 900, fill: palette.dark, opacity: 0.78, radius: 0 }))
+    const elements: PPTElement[] = [shapeElement({ left: 0, top: 0, width: SLIDE_WIDTH, height: SLIDE_HEIGHT, fill: palette.dark, radius: 0 })]
+    // 教材原图只作为经过裁切的主视觉使用,避免整张扫描页直接成为封面。
+    elements.push(imageElement(page, { left: 650, top: 0, width: 950, height: 900 }))
+    elements.push(shapeElement({ left: 570, top: 0, width: 240, height: 900, fill: palette.dark, opacity: 0.62, radius: 0 }))
     elements.push(
       textElement({
         left: 100,
         top: 150,
-        width: 700,
+        width: 470,
         height: 150,
         content: title,
-        fontSize: fitFont(title, 700, 150, 66, 48),
+        fontSize: fitFont(title, 470, 150, 66, 44),
         color: "#FFFFFF",
         bold: true
       })
     )
-    if (subtitle) elements.push(textElement({ left: 104, top: 330, width: 680, height: 48, content: subtitle, fontSize: 24, color: "#EAF2F7" }))
+    if (subtitle) elements.push(textElement({ left: 104, top: 330, width: 470, height: 72, content: subtitle, fontSize: 24, color: "#EAF2F7" }))
     elements.push(shapeElement({ left: 104, top: 430, width: 84, height: 10, fill: palette.accent, radius: 4 }))
     points.forEach((point, index) =>
       elements.push(
         textElement({
           left: 104,
           top: 500 + index * 64,
-          width: 680,
+          width: 470,
           height: 44,
           content: point,
-          fontSize: fitFont(point, 680, 44, 28, 22),
+          fontSize: fitFont(point, 470, 44, 28, 22),
           color: "#FFFFFF",
           bold: index === 0
         })
@@ -1066,7 +1068,9 @@ function practiceTemplate(page: AiCoursewarePage, outline: AiCoursewareOutline,
         bold: true
       })
     )
-  studentPoints(page, 4).forEach((point, index) => {
+  const options = validInteractionOptions(page)
+  const items = options.length >= 2 ? options : studentPoints(page, 3)
+  items.forEach((point, index) => {
     const top = 330 + index * 94
     elements.push(shapeElement({ left: 110, top, width: 70, height: 62, fill: index % 2 ? palette.secondary : palette.primary, radius: 8 }))
     elements.push(
@@ -1281,31 +1285,10 @@ function knowledgeList(items: string[], palette: Palette, left: number, top: num
 }
 
 function addClassroomCue(elements: PPTElement[], page: AiCoursewarePage, palette: Palette) {
-  const cue = page.interaction?.question || activityTask(page)
-  if (!cue) return
-  const cueKey = normalizeVisibleText(cue)
-  const alreadyVisible = elements.some(element => {
-    if (element.type === "text") return normalizeVisibleText(stripHtml(element.content)) === cueKey
-    if (element.type === "shape" && element.text) return normalizeVisibleText(stripHtml(element.text.content)) === cueKey
-    return false
-  })
-  if (alreadyVisible) return
-  const bar = { left: 92, top: 818, width: 1416, height: 54 }
-  const fontSize = fitFont(cue, bar.width - 40, 28, 20, 17)
-  const visibleCue = fitVisibleText(cue, bar.width - 40, fontSize * 1.2, fontSize, 1.2)
-  elements.push(
-    shapeElement({
-      ...bar,
-      fill: palette.soft,
-      radius: 8,
-      text: {
-        content: `<div style="font-family:${FONT_NAME};font-size:${fontSize}px;font-weight:700;">${escapeHtml(visibleCue)}</div>`,
-        defaultFontName: FONT_NAME,
-        defaultColor: palette.text,
-        align: "middle"
-      }
-    })
-  )
+  // 保留调用点以兼容既有模板;课堂任务由页面主体呈现,不再生成重复底栏。
+  void elements
+  void page
+  void palette
 }
 
 function dedicatedResourceElement(page: AiCoursewarePage): PPTElement | undefined {
@@ -1429,21 +1412,27 @@ function studentPoints(page: AiCoursewarePage, limit: number) {
 }
 
 function activityTask(page: AiCoursewarePage) {
-  return cleanText(page.classroomAction?.studentTask || page.activity?.task || "")
+  // activity 属于教师组织信息,不能回退到学生投屏画面。
+  return cleanText(page.classroomAction?.studentTask || studentHeadline(page) || "")
 }
 
 function activitySteps(page: AiCoursewarePage, limit: number) {
-  const steps = (page.activity?.steps || []).map(cleanText).filter(Boolean)
   const task = activityTask(page)
   const points = studentPoints(page, limit).filter(item => !sameVisibleText(item, task))
-  const values = steps.length ? steps : points
-  return uniqueVisibleTexts(values).slice(0, limit)
+  return uniqueVisibleTexts(points).slice(0, limit)
+}
+
+function validInteractionOptions(page: AiCoursewarePage) {
+  return uniqueVisibleTexts((page.interaction?.options || []).map(cleanText).filter(Boolean))
+    .filter(item => !/^(?:准备好了吗|开始吧|请回答|想一想|说一说|听一听)[??!!。]*$/.test(item))
+    .slice(0, 4)
 }
 
 function slideRemark(page: AiCoursewarePage) {
   const lessonMeta = [page.teachingStage, page.durationMinutes ? `建议用时:${page.durationMinutes}分钟` : ""].filter(Boolean).join(" · ")
   const evidence = page.learningEvidence ? `学习表现:${page.learningEvidence}` : ""
-  return [lessonMeta, page.teachingPurpose, page.speakerNotes, page.teacherTips, page.classroomAction?.teacherInstruction, evidence]
+  const activity = [page.activity?.task, ...(page.activity?.steps || [])].map(cleanText).filter(Boolean).join("\n")
+  return [lessonMeta, page.teachingPurpose, activity, page.speakerNotes, page.teacherTips, page.classroomAction?.teacherInstruction, evidence]
     .map(cleanText)
     .filter(Boolean)
     .filter((item, index, all) => all.indexOf(item) === index)
@@ -1485,7 +1474,8 @@ function textElement(params: {
   lineHeight?: number
 }): PPTTextElement {
   const lineHeight = params.lineHeight || 1.25
-  const visibleContent = fitVisibleText(params.content, params.width - 20, params.height, params.fontSize, lineHeight)
+  const minimum = Math.min(params.fontSize, params.fontSize >= 30 ? 22 : 18)
+  const resolvedFontSize = fitFont(params.content, params.width - 20, params.height, params.fontSize, minimum)
   return {
     id: nanoid(10),
     type: "text",
@@ -1494,7 +1484,8 @@ function textElement(params: {
     width: params.width,
     height: params.height,
     rotate: 0,
-    content: `<div style="box-sizing:border-box;width:calc(100% - 20px);font-family:${FONT_NAME};font-size:${params.fontSize}px;${params.bold ? "font-weight:700;" : ""}">${escapeHtml(visibleContent)}</div>`,
+    // 永远保留完整文本。若最小字号仍放不下,由质量门禁阻止应用,而不是静默截断。
+    content: `<div style="box-sizing:border-box;width:calc(100% - 20px);font-family:${FONT_NAME};font-size:${resolvedFontSize}px;${params.bold ? "font-weight:700;" : ""}">${escapeHtml(cleanText(params.content))}</div>`,
     defaultFontName: FONT_NAME,
     defaultColor: params.color,
     lineHeight,
@@ -1504,33 +1495,6 @@ function textElement(params: {
   }
 }
 
-function fitVisibleText(content: string, width: number, height: number, fontSize: number, lineHeight: number) {
-  const text = cleanText(content)
-  if (!text) return ""
-  // 编号、选项和勾选符号属于不可截断的原子文本。
-  if (/^(?:0?\d{1,3}|[A-Z]|[✓√])(?:[.)、])?$/.test(text)) return text
-  const safeWidth = Math.max(1, width)
-  const safeHeight = Math.max(1, height)
-  const maxLines = Math.max(1, Math.floor(safeHeight / (fontSize * lineHeight)))
-  // 保留小数容量,避免宽度足够容纳“01”时因向下取整被误判为溢出。
-  const maxUnits = Math.max(1, (safeWidth / fontSize) * maxLines)
-  if (textUnits(text) <= maxUnits) return text
-
-  let units = 0
-  let result = ""
-  for (const char of text) {
-    const charUnits = char.charCodeAt(0) <= 0xff ? 0.55 : 1
-    if (units + charUnits > Math.max(1, maxUnits - 1)) break
-    result += char
-    units += charUnits
-  }
-  return `${result.trim()}…`
-}
-
-function textUnits(content: string) {
-  return Array.from(content).reduce((total, char) => total + (char.charCodeAt(0) <= 0xff ? 0.55 : 1), 0)
-}
-
 function imageElement(page: AiCoursewarePage, rect: ImageRect): PPTImageElement {
   const width = page.image?.width || 0
   const height = page.image?.height || 0

+ 112 - 17
src/views/Editor/EditorHeader/AiCoursewareDialog.vue

@@ -194,6 +194,20 @@
             <div class="sample-eyebrow">视觉样张 · 当前同步左侧页面</div>
             <div class="sample-title">第{{ samplePageNo }}页 · {{ samplePageTitle }}</div>
             <div class="sample-hint">{{ samplePageReason }}。你可以切换任意页面查看,也可以只重新生成当前页样张。</div>
+            <div class="representative-samples">
+              <button
+                v-for="sample in representativeSamples"
+                :key="sample.pageNo"
+                type="button"
+                :class="{ active: selectedPageNo === sample.pageNo, approved: approvedSamplePages.includes(sample.pageNo) }"
+                :disabled="working"
+                @click="selectedPageNo = sample.pageNo"
+              >
+                <span>{{ approvedSamplePages.includes(sample.pageNo) ? "✓" : sample.order }}</span>
+                <strong>{{ sample.label }}</strong>
+                <small>第{{ sample.pageNo }}页</small>
+              </button>
+            </div>
 
             <div class="current-template-card">
               <div>
@@ -250,20 +264,20 @@
                 v-model="sampleFeedbackText"
                 :disabled="working"
                 placeholder="还可以补充,例如:希望画面更童趣、减少卡片、突出山谷主视觉"
-                @input="sampleApproved = false"
+                @input="invalidateVisualApproval"
               ></textarea>
             </div>
 
             <div class="sample-actions">
               <Button @click="regenerateSamplePage" :disabled="working">重新生成当前样张</Button>
-              <Button type="primary" @click="approveSample" :disabled="working">
-                {{ sampleApproved ? "视觉方向已确认" : "确认这个视觉方向" }}
+              <Button type="primary" @click="approveSample" :disabled="working || !currentSampleRequired">
+                {{ currentSampleApproved ? "本张样稿已确认" : currentSampleRequired ? `确认本张样稿(${approvedSamplePages.length}/3)` : "请切换到代表样稿确认" }}
               </Button>
             </div>
           </div>
-          <div :class="['sample-canvas', { approved: sampleApproved }]">
+          <div :class="['sample-canvas', { approved: currentSampleApproved }]">
             <ThumbnailSlide :slide="sampleSlide" :size="420" />
-            <div v-if="sampleApproved" class="sample-approved-mark">已确认</div>
+            <div v-if="currentSampleApproved" class="sample-approved-mark">已确认</div>
           </div>
         </div>
         <div class="page-nav">
@@ -386,7 +400,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="!sampleApproved || working">确认视觉并生成完整课件</Button>
+          <Button type="primary" @click="handleGenerate" :disabled="!allSamplesApproved || working">三张样稿确认后生成完整课件</Button>
         </template>
         <template v-else>
           <Button @click="backToOutline" :disabled="working">返回修改</Button>
@@ -470,7 +484,7 @@ const preferences = reactive<AiCoursewarePreferences>({
   requireVideo: false,
   enableAnimations: true
 })
-const sampleApproved = ref(false)
+const approvedSamplePages = ref<number[]>([])
 const sampleFeedbackOptions = ["配色不合适", "版式太单一", "图片不合适", "文字太多"]
 const sampleFeedbackTags = ref<string[]>([])
 const sampleFeedbackText = ref("")
@@ -515,7 +529,7 @@ const studentPoints = computed(() => {
   if (!selectedPage.value) return []
   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 activityTask = computed(() => selectedPage.value?.classroomAction?.studentTask || selectedPage.value?.studentContent?.headline || "")
 const samplePageIndex = computed(() => {
   const pages = outline.value?.pages || []
   const selectedIndex = pages.findIndex(page => page.pageNo === selectedPageNo.value)
@@ -523,6 +537,33 @@ const samplePageIndex = computed(() => {
 })
 const samplePageNo = computed(() => outline.value?.pages[samplePageIndex.value]?.pageNo || samplePageIndex.value + 1)
 const samplePageTitle = computed(() => outline.value?.pages[samplePageIndex.value]?.title || "代表页面")
+const representativeSamples = computed(() => {
+  const pages = outline.value?.pages || []
+  if (!pages.length) return []
+  const selected: Array<{ pageNo: number; label: string }> = []
+  const add = (page: AiCoursewarePage | undefined, label: string) => {
+    if (page && !selected.some(item => item.pageNo === page.pageNo)) selected.push({ pageNo: page.pageNo, label })
+  }
+  add(pages[0], "封面主视觉")
+  add(
+    pages.find(page => ["story_stage", "full_image_question", "split_image", "observe_question"].includes(page.visualPlan?.layout || "") && page.pageNo !== pages[0].pageNo),
+    "故事情境"
+  )
+  add(
+    pages.find(page => ["action_demo", "group_mission", "performance_stage", "activity_steps", "practice_check"].includes(page.visualPlan?.layout || "")),
+    "课堂活动"
+  )
+  pages.forEach(page => {
+    if (selected.length < 3) add(page, selected.length === 1 ? "故事情境" : "课堂活动")
+  })
+  return selected.slice(0, 3).map((item, index) => ({ ...item, order: index + 1 }))
+})
+const requiredSamplePages = computed(() => representativeSamples.value.map(item => item.pageNo))
+const currentSampleRequired = computed(() => requiredSamplePages.value.includes(samplePageNo.value))
+const currentSampleApproved = computed(() => approvedSamplePages.value.includes(samplePageNo.value))
+const allSamplesApproved = computed(
+  () => requiredSamplePages.value.length === 3 && requiredSamplePages.value.every(pageNo => approvedSamplePages.value.includes(pageNo))
+)
 const samplePageReason = computed(() => {
   const layout = outline.value?.pages[samplePageIndex.value]?.visualPlan?.layout || ""
   if (["activity_steps", "action_demo", "group_mission", "performance_stage"].includes(layout)) return "这是课堂活动页,适合判断互动方式和信息层级"
@@ -624,7 +665,7 @@ async function handleOutline() {
 
 async function handleGenerate() {
   const sourceOutline = draftOutline.value || outline.value
-  if (!sourceOutline || !pptWork.id || !sampleApproved.value) return
+  if (!sourceOutline || !pptWork.id || !allSamplesApproved.value) return
   const confirmed = await ElMessageBox.confirm("系统会生成一份完整课件草稿,完成后先进入预览,不会直接替换当前课件。确认继续?", "确认生成", {
     confirmButtonText: "确认生成",
     cancelButtonText: "取消",
@@ -653,7 +694,7 @@ async function handleGenerate() {
 
 async function regenerateSamplePage() {
   if (!outline.value || !pptWork.id) return
-  sampleApproved.value = false
+  approvedSamplePages.value = approvedSamplePages.value.filter(pageNo => pageNo !== samplePageNo.value)
   taskMode.value = "sample"
   if (sampleFeedbackTags.value.includes("配色不合适")) paletteVariant.value = (paletteVariant.value + 1) % 3
   const feedback = [...sampleFeedbackTags.value, sampleFeedbackText.value.trim()].filter(Boolean).join(";")
@@ -847,16 +888,18 @@ function backToOutline() {
 }
 
 function invalidateVisualApproval() {
-  sampleApproved.value = false
+  approvedSamplePages.value = []
 }
 
 function approveSample() {
-  sampleApproved.value = true
-  ElMessage.success("视觉方向已确认,可以生成完整课件")
+  if (!currentSampleRequired.value) return
+  approvedSamplePages.value = Array.from(new Set([...approvedSamplePages.value, samplePageNo.value]))
+  if (allSamplesApproved.value) ElMessage.success("三张代表样稿已确认,可以生成完整课件")
+  else ElMessage.success(`本张样稿已确认,还需确认${3 - approvedSamplePages.value.length}张`)
 }
 
 function toggleSampleFeedback(item: string) {
-  sampleApproved.value = false
+  approvedSamplePages.value = approvedSamplePages.value.filter(pageNo => pageNo !== samplePageNo.value)
   sampleFeedbackTags.value = sampleFeedbackTags.value.includes(item)
     ? sampleFeedbackTags.value.filter(value => value !== item)
     : [...sampleFeedbackTags.value, item]
@@ -919,10 +962,8 @@ function updateStudentPoints(page: AiCoursewarePage, event: Event) {
 }
 
 function updateActivityTask(page: AiCoursewarePage, event: Event) {
-  if (!page.activity) page.activity = { task: "", steps: [] }
   if (!page.classroomAction) page.classroomAction = { teacherInstruction: "", studentTask: "", interactionType: "" }
   const value = (event.target as HTMLInputElement).value.trim()
-  page.activity.task = value
   page.classroomAction.studentTask = value
 }
 
@@ -948,6 +989,7 @@ function buildExtraPrompt() {
 
 function buildVisualPrompt() {
   const base = selectedTemplate.value.visualPrompt
+  const system = `模板布局族:${selectedTemplate.value.layoutFamily}。字体体系:${selectedTemplate.value.typography}。背景体系:${selectedTemplate.value.backgroundStyle}。素材画风:${selectedTemplate.value.illustrationStyle}。`
   const asset =
     preferences.assetStyle === "illustration"
       ? "主视觉使用统一画风插画。"
@@ -966,7 +1008,7 @@ function buildVisualPrompt() {
       : paletteVariant.value === 2
         ? "配色使用柔和紫色与珊瑚色体系。"
         : "配色保持明快且有清晰对比。"
-  return `${base}模板场景:${selectedTemplate.value.scene}。${asset}${source}${palette}`
+  return `${base}${system}模板场景:${selectedTemplate.value.scene}。${asset}${source}${palette}整套课件必须共享角色外观、环境、光线和重复道具;一页只保留一个视觉焦点。`
 }
 
 function formatPageNo(pageNo?: number) {
@@ -1387,6 +1429,59 @@ function resourceTypeName(type: string) {
   font-size: 12px;
   line-height: 1.6;
 }
+.representative-samples {
+  display: grid;
+  grid-template-columns: repeat(3, minmax(0, 1fr));
+  gap: 6px;
+  margin-bottom: 10px;
+  button {
+    display: grid;
+    grid-template-columns: 24px 1fr;
+    grid-template-rows: auto auto;
+    column-gap: 6px;
+    align-items: center;
+    min-width: 0;
+    padding: 7px;
+    border: 1px solid #dfe5ec;
+    border-radius: 6px;
+    background: #fff;
+    color: #52616b;
+    text-align: left;
+    cursor: pointer;
+  }
+  button.active {
+    border-color: $themeColor;
+    background: #f5f9ff;
+  }
+  button.approved span {
+    background: #1f9d69;
+  }
+  span {
+    grid-row: 1 / 3;
+    display: grid;
+    width: 24px;
+    height: 24px;
+    place-items: center;
+    border-radius: 50%;
+    background: $themeColor;
+    color: #fff;
+    font-size: 11px;
+    font-weight: 700;
+  }
+  strong,
+  small {
+    overflow: hidden;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+  }
+  strong {
+    font-size: 11px;
+  }
+  small {
+    color: #8a96a3;
+    font-size: 9px;
+  }
+}
 .current-template-card {
   display: flex;
   align-items: center;

برخی فایل ها در این مقایسه diff نمایش داده نمی شوند زیرا تعداد فایل ها بسیار زیاد است