yonge 4 days ago
parent
commit
da59a013ba

+ 1 - 1
dist/assets/index-w0k1vHRR.js → dist/assets/index-B4-jzq4g.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-DhtvsvPP.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-TCQxKpEQ.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-BR-AEsZy.css


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


+ 1 - 1
dist/assets/index-NdlCmiuj.js → dist/assets/index-Bc2STCKN.js

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

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


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


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


+ 1 - 1
dist/assets/index-C3f6UED5.js → dist/assets/index-C-Aaa1Hr.js

@@ -1 +1 @@
-import{u as s,ah as r}from"./index-BASCbLby.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-DhtvsvPP.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}))]))}}),P=l(d,[["__scopeId","data-v-678905e9"]]);export{P as default};
+import{u as s,ah as r}from"./index-Bpy1Ietx.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-TCQxKpEQ.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}))]))}}),P=l(d,[["__scopeId","data-v-678905e9"]]);export{P as default};

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


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


+ 1 - 1
dist/assets/index-Co6LbKFR.js → dist/assets/index-DXxFpfzQ.js

@@ -1 +1 @@
-import{u as c,ah as l}from"./index-BASCbLby.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-DhtvsvPP.js";import"./shapes-ek89awhm.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,ah as l}from"./index-Bpy1Ietx.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-TCQxKpEQ.js";import"./shapes-CDadNhDF.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};

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


+ 1 - 1
dist/assets/shapes-ek89awhm.js → dist/assets/shapes-CDadNhDF.js

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

+ 414 - 299
src/views/Editor/EditorHeader/AiCoursewareDialog.vue

@@ -104,45 +104,7 @@
         </div>
       </div>
 
-      <div v-if="workflowStep === 3 && draftConverted" class="result-preview">
-        <div :class="['draft-status', draftPreparationState]">
-          <span class="draft-status-icon">{{ draftPreparationState === "ready" ? "✓" : draftPreparationState === "failed" ? "!" : "" }}</span>
-          <div>
-            <strong>{{ draftStatusTitle }}</strong>
-            <span>{{ draftStatusDescription }}</span>
-          </div>
-        </div>
-
-        <div class="draft-workspace">
-          <div class="draft-page-list" aria-label="课件页面">
-            <button
-              v-for="(slide, index) in draftConverted.slides"
-              :key="slide.id"
-              type="button"
-              :class="['draft-page-item', { active: selectedDraftPageIndex === index }]"
-              @click="selectedDraftPageIndex = index"
-            >
-              <ThumbnailSlide :slide="slide" :size="148" />
-              <span>{{ formatPageNo(index + 1) }} · {{ draftOutline?.pages[index]?.title || "未命名页面" }}</span>
-            </button>
-          </div>
-
-          <div v-if="selectedDraftSlide" class="draft-preview-pane">
-            <div class="draft-preview-toolbar">
-              <div>
-                <span>第{{ selectedDraftPageIndex + 1 }} / {{ draftConverted.slides.length }}页</span>
-                <strong>{{ selectedDraftPage?.title || "课件页面" }}</strong>
-              </div>
-              <button type="button" :disabled="working" @click="regenerateDraftPage(selectedDraftPageIndex)">重新生成本页</button>
-            </div>
-            <div class="draft-canvas-shell">
-              <ThumbnailSlide :slide="selectedDraftSlide" :size="680" />
-            </div>
-          </div>
-        </div>
-      </div>
-
-      <div v-else-if="workflowStep === 2 && outline" class="outline">
+      <div v-else-if="workflowStep === 2 && outline" class="outline unified-review">
         <div class="sample-stage-head">
           <div class="sample-template-current">
             <span>当前样式</span>
@@ -154,41 +116,6 @@
           </button>
         </div>
 
-        <div class="sample-carousel-shell">
-          <button
-            type="button"
-            class="sample-carousel-arrow previous"
-            aria-label="查看前面的页面"
-            :disabled="!canScrollSamplePrevious"
-            @click="scrollSampleCarousel(-1)"
-          >
-            ‹
-          </button>
-          <div ref="sampleCarouselRef" class="sample-thumbnail-track" @scroll="updateSampleCarouselNavigation">
-            <button
-              v-for="(slide, index) in previewSlides"
-              :key="slide.id"
-              type="button"
-              :class="['sample-thumbnail-item', { active: selectedPageNo === outline.pages[index]?.pageNo }]"
-              :disabled="working"
-              :title="outline.pages[index]?.title || `第${index + 1}页`"
-              @click="selectSamplePage(outline.pages[index]?.pageNo || index + 1, index)"
-            >
-              <ThumbnailSlide :slide="slide" :size="116" />
-              <span>{{ outline.pages[index]?.title || `第${index + 1}页` }}</span>
-            </button>
-          </div>
-          <button
-            type="button"
-            class="sample-carousel-arrow next"
-            aria-label="查看后面的页面"
-            :disabled="!canScrollSampleNext"
-            @click="scrollSampleCarousel(1)"
-          >
-            ›
-          </button>
-        </div>
-
         <div v-if="templatePickerExpanded" class="compact-template-panel">
           <div class="sample-template-picker">
             <button
@@ -213,84 +140,75 @@
           </div>
         </div>
 
-        <div v-if="sampleSlide && selectedPage" class="sample-detail-section">
-          <div class="sample-detail-heading">
-            <div>
-              <span>第{{ samplePageNo }}页</span>
-              <strong>{{ samplePageTitle }}</strong>
-            </div>
-            <small>{{ samplePageReason }}</small>
+        <div v-if="draftConverted" :class="['compact-process-state', draftPreparationState]">
+          <span v-if="draftPreparationState === 'optimizing'" class="loading-dot"></span>
+          <span v-else class="process-state-icon">{{ draftPreparationState === "ready" ? "✓" : "!" }}</span>
+          <div>
+            <strong>{{ draftStatusTitle }}</strong>
+            <span>{{ draftStatusDescription }}</span>
           </div>
+        </div>
+
+        <div v-if="workspaceSlide" class="unified-review-workspace">
+          <aside class="workspace-page-list" aria-label="课件页面">
+            <button
+              v-for="(slide, index) in workspaceSlides"
+              :key="slide.id"
+              type="button"
+              :class="['workspace-page-item', { active: selectedWorkspacePageIndex === index }]"
+              :disabled="working"
+              @click="selectWorkspacePage(index)"
+            >
+              <ThumbnailSlide :slide="slide" :size="136" />
+              <span>{{ formatPageNo(index + 1) }} · {{ workspacePages[index]?.title || "未命名页面" }}</span>
+            </button>
+          </aside>
 
-          <div class="sample-detail-layout">
-            <section class="sample-outline-panel" aria-label="页面内容编辑">
-              <div class="page-editor simplified-page-editor">
-                <div class="editor-head">
-                  <div class="title-editor">
-                    <label class="outline-field-label">页面主题</label>
-                    <input v-model="selectedPage.title" class="page-title" :disabled="working" />
-                  </div>
-                </div>
-
-                <div class="field-card">
-                  <div class="field-title">学生看到的内容</div>
-                  <input
-                    :value="studentHeadline"
-                    :disabled="working"
-                    class="headline-input"
-                    placeholder="这一页给学生看的核心标题"
-                    @input="event => updateStudentHeadline(selectedPage!, event)"
-                  />
-                  <textarea
-                    :value="studentPoints.join('\n')"
-                    :disabled="working"
-                    class="page-bullets"
-                    placeholder="每行一条学生可见要点,避免写老师操作步骤"
-                    @input="event => updateStudentPoints(selectedPage!, event)"
-                  ></textarea>
-                  <div v-if="studentContentReadabilityHint" class="content-readability-hint">
-                    {{ studentContentReadabilityHint }}
-                  </div>
-                </div>
+          <section class="workspace-preview-column" aria-label="页面预览及调整">
+            <div class="workspace-preview-heading">
+              <div>
+                <span>第{{ selectedWorkspacePageIndex + 1 }} / {{ workspaceSlides.length }}页</span>
+                <strong>{{ workspacePage?.title || "课件页面" }}</strong>
               </div>
-            </section>
-
-            <section class="sample-preview-column" aria-label="页面预览及调整">
-              <div class="sample-canvas">
-                <ThumbnailSlide
-                  :slide="sampleSlide"
-                  :size="600"
-                  :show-background-error="true"
-                  @background-load-state="handleSampleBackgroundLoadState"
-                />
+              <small>{{ canEditWorkspaceText ? "双击预览中的文字即可编辑" : "系统处理完成后可直接编辑文字" }}</small>
+            </div>
+            <div class="workspace-canvas-shell">
+              <ThumbnailSlide
+                :slide="workspaceSlide"
+                :size="720"
+                :show-background-error="true"
+                :editable-text="true"
+                :edit-disabled="!canEditWorkspaceText"
+                @background-load-state="handleSampleBackgroundLoadState"
+                @text-change="handleWorkspaceTextChange"
+              />
+            </div>
+            <div v-if="sampleBackgroundState.status === 'error'" class="template-load-error">模板背景加载失败,请更换模板或稍后重试</div>
+            <div class="sample-feedback workspace-feedback">
+              <div class="feedback-label">图片或版式需要调整?</div>
+              <div class="feedback-chips">
+                <button
+                  v-for="item in sampleFeedbackOptions"
+                  :key="item"
+                  type="button"
+                  :class="{ active: sampleFeedbackTags.includes(item) }"
+                  :disabled="working"
+                  @click="toggleSampleFeedback(item)"
+                >
+                  {{ item }}
+                </button>
               </div>
-              <div v-if="sampleBackgroundState.status === 'error'" class="template-load-error">模板背景加载失败,请更换模板或稍后重试</div>
-              <div class="sample-feedback">
-                <div class="feedback-label">调整当前页面</div>
-                <div class="feedback-chips">
-                  <button
-                    v-for="item in sampleFeedbackOptions"
-                    :key="item"
-                    type="button"
-                    :class="{ active: sampleFeedbackTags.includes(item) }"
-                    :disabled="working"
-                    @click="toggleSampleFeedback(item)"
-                  >
-                    {{ item }}
-                  </button>
-                </div>
-                <div class="sample-feedback-action">
-                  <textarea
-                    v-model="sampleFeedbackText"
-                    :disabled="working"
-                    placeholder="也可以直接说明你希望怎么调整"
-                    @input="invalidateVisualApproval"
-                  ></textarea>
-                  <Button @click="regenerateSamplePage" :disabled="working">调整当前页面</Button>
-                </div>
+              <div class="sample-feedback-action">
+                <textarea
+                  v-model="sampleFeedbackText"
+                  :disabled="working"
+                  placeholder="说明希望如何调整,文字可直接在上方预览中双击修改"
+                  @input="invalidateVisualApproval"
+                ></textarea>
+                <Button @click="adjustCurrentPage" :disabled="working">调整当前页</Button>
               </div>
-            </section>
-          </div>
+            </div>
+          </section>
         </div>
       </div>
 
@@ -302,14 +220,14 @@
         </template>
         <template v-else-if="isStyleStep">
           <Button @click="workflowStep = 1" :disabled="working">返回修改需求</Button>
-          <Button type="primary" @click="confirmSamplesAndGenerate" :disabled="working || sampleBackgroundState.status === 'error'"
-            >使用此样式并生成课件</Button
+          <span v-if="draftPreparationState === 'optimizing'" class="footer-progress">正在自动检查并优化,无需操作…</span>
+          <Button
+            v-if="!draftConverted"
+            type="primary"
+            @click="confirmSamplesAndGenerate"
+            :disabled="working || sampleBackgroundState.status === 'error'"
+            >生成并自动优化</Button
           >
-        </template>
-        <template v-else>
-          <Button v-if="draftPreparationState !== 'optimizing'" @click="backToOutline">返回调整</Button>
-          <span v-if="draftPreparationState === 'optimizing'" class="footer-progress">正在准备可用课件…</span>
-          <Button v-else-if="draftPreparationState === 'failed'" type="primary" @click="retryAutomaticOptimization">重新自动优化</Button>
           <Button v-else-if="draftPreparationState === 'ready'" type="primary" @click="applyDraft">应用到当前课件</Button>
         </template>
       </div>
@@ -318,7 +236,7 @@
 </template>
 
 <script lang="ts" setup>
-import { ref, reactive, computed, watch, nextTick } from "vue"
+import { ref, reactive, computed, watch } from "vue"
 import { ElMessage } from "element-plus"
 import Modal from "@/components/Modal.vue"
 import Button from "@/components/Button.vue"
@@ -368,13 +286,12 @@ const { addHistorySnapshot } = useHistorySnapshot()
 const extraPrompt = ref("")
 const outline = ref<AiCoursewareOutline>()
 const taskInfo = ref<AiCoursewareTaskInfo>()
-const workflowStep = ref<1 | 2 | 3>(1)
+const workflowStep = ref<1 | 2>(1)
 const isSetupStep = computed(() => workflowStep.value === 1)
 const isStyleStep = computed(() => workflowStep.value === 2)
 const workflowSteps = [
   { value: 1, label: "设置需求" },
-  { value: 2, label: "选择样式" },
-  { value: 3, label: "预览并应用" }
+  { value: 2, label: "预览、编辑并应用" }
 ] as const
 const taskMode = ref<"full" | "sample">("full")
 const coursewareTemplates = AI_COURSEWARE_TEMPLATES
@@ -398,7 +315,7 @@ const preferences = reactive<AiCoursewarePreferences>({
 })
 const approvedSamplePages = ref<number[]>([])
 const sampleBackgroundState = ref<{ status: "none" | "loading" | "loaded" | "error"; url: string }>({ status: "none", url: "" })
-const sampleFeedbackOptions = ["画风不合适", "页面太空", "图片不合适", "文字太多"]
+const sampleFeedbackOptions = ["画风不合适", "页面太空", "图片不合适", "层级不清晰"]
 const sampleFeedbackTags = ref<string[]>([])
 const sampleFeedbackText = ref("")
 const draftOutline = ref<AiCoursewareOutline>()
@@ -406,14 +323,21 @@ const draftConverted = ref<{ title: string; theme: Partial<SlideTheme>; slides:
 const draftQuality = ref<AiPptQualityReport>()
 const draftPreparationState = ref<"idle" | "optimizing" | "ready" | "failed">("idle")
 const automaticRepairAttempts = ref(0)
-const MAX_AUTOMATIC_REPAIR_ATTEMPTS = 1
+const MAX_AUTOMATIC_REPAIR_ATTEMPTS = 3
+type ProtectedPageContent = {
+  title: string
+  subtitle?: string
+  bullets: string[]
+  studentContent?: AiCoursewarePage["studentContent"]
+  studentTask?: string
+  learningEvidence?: string
+  interaction?: AiCoursewarePage["interaction"]
+}
+const protectedPageContent = new Map<number, ProtectedPageContent>()
 const TEACHER_VISIBLE_PATTERN = /(?:教师|老师)(?:讲述|提问|引导|指导|组织|巡视|示范)|(?:引导|指导|组织)学生/
 const PLACEHOLDER_PATTERN = /(?:待补充|待完善|请输入|可参考教材|placeholder|lorem ipsum|\{\{.+?\}\}|\[.+?占位.+?\])/i
 const pollTimer = ref<number>()
 const selectedPageNo = ref(1)
-const sampleCarouselRef = ref<HTMLElement>()
-const canScrollSamplePrevious = ref(false)
-const canScrollSampleNext = ref(false)
 const selectedDraftPageIndex = ref(0)
 const outlineLoading = ref(false)
 const working = computed(
@@ -462,86 +386,27 @@ const pageMeta = computed(() => {
   if (!outline.value) return `根据当前教材生成${preferences.minPages}至${preferences.maxPages}页音乐课件`
   return [...[outline.value.gradeName, outline.value.unitName].filter(Boolean), `共${outline.value.pages.length}页`].join(" · ")
 })
-const selectedPage = computed(() => {
-  const pages = outline.value?.pages || []
-  return pages.find(page => page.pageNo === selectedPageNo.value) || pages[0]
-})
-const studentHeadline = computed(() => selectedPage.value?.studentContent?.headline || selectedPage.value?.subtitle || "")
-const studentPoints = computed(() => {
-  if (!selectedPage.value) return []
-  return selectedPage.value.studentContent?.points?.length ? selectedPage.value.studentContent.points : selectedPage.value.bullets || []
-})
-const studentContentReadabilityHint = computed(() => {
-  const contentCount = studentPoints.value.length + (studentHeadline.value ? 1 : 0)
-  return contentCount > 6 ? `当前有${contentCount}条学生内容,预览会完整显示,但投屏字号可能变小,建议适当精简。` : ""
-})
 const samplePageIndex = computed(() => {
   const pages = outline.value?.pages || []
   const selectedIndex = pages.findIndex(page => page.pageNo === selectedPageNo.value)
   return selectedIndex >= 0 ? selectedIndex : 0
 })
 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 =>
-        [
-          "hero_focus",
-          "media_focus",
-          "split_content",
-          "diagram_focus",
-          "story_stage",
-          "full_image_question",
-          "split_image",
-          "observe_question"
-        ].includes(page.visualPlan?.layout || "") && page.pageNo !== pages[0].pageNo
-    ),
-    "内容主视觉"
-  )
-  add(
-    pages.find(page =>
-      ["activity", "summary", "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 selectedDraftPage = computed(() => draftOutline.value?.pages[selectedDraftPageIndex.value])
-const selectedDraftSlide = computed(() => draftConverted.value?.slides[selectedDraftPageIndex.value])
+const workspaceSlides = computed(() => draftConverted.value?.slides || previewSlides.value || [])
+const workspacePages = computed(() => draftOutline.value?.pages || outline.value?.pages || [])
+const selectedWorkspacePageIndex = computed(() => (draftConverted.value ? selectedDraftPageIndex.value : samplePageIndex.value))
+const workspacePage = computed(() => workspacePages.value[selectedWorkspacePageIndex.value])
+const workspaceSlide = computed(() => workspaceSlides.value[selectedWorkspacePageIndex.value])
+const canEditWorkspaceText = computed(() => !working.value && (!draftConverted.value || draftPreparationState.value === "ready"))
 const draftStatusTitle = computed(() => {
   if (draftPreparationState.value === "ready") return "课件已准备完成"
-  if (draftPreparationState.value === "failed") return "部分页面还未处理完成"
+  if (draftPreparationState.value === "failed") return "自动优化未能完成"
   return "正在自动检查和优化课件"
 })
 const draftStatusDescription = computed(() => {
-  if (draftPreparationState.value === "ready") return "系统已完成内容、排版和教学可用性检查,可以直接预览并应用。"
-  if (draftPreparationState.value === "failed") return "无需逐项修改,点击重新自动优化即可继续。"
-  return "正在清理重复内容、调整版式并修复问题页面,请稍候。"
-})
-const requiredSamplePages = computed(() => representativeSamples.value.map(item => item.pageNo))
-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 "这是课堂活动页,适合判断互动方式和信息层级"
-  if (["cover_scene", "story_stage", "full_image_question", "observe_question"].includes(layout))
-    return "这是主视觉页面,适合判断画风、配色和场景表现"
-  if (["summary_checklist", "visual_summary", "practice_check"].includes(layout)) return "这是总结检测页,适合判断文字密度和阅读效率"
-  return "这是你当前选中的页面,可用来判断版式和学生阅读体验"
+  if (draftPreparationState.value === "ready") return "可双击预览中的文字修改,确认后直接应用。"
+  if (draftPreparationState.value === "failed") return "请更换模板或返回修改需求后重新生成。"
+  return `系统正在自动处理问题页面(${Math.max(1, automaticRepairAttempts.value)}/${MAX_AUTOMATIC_REPAIR_ATTEMPTS})。`
 })
 const previewSlides = computed(() => {
   if (!outline.value?.pages.length) return undefined
@@ -552,33 +417,91 @@ const previewSlides = computed(() => {
     templateId: selectedTemplateId.value
   }).slides
 })
-const sampleSlide = computed(() => previewSlides.value?.[samplePageIndex.value])
-
-function updateSampleCarouselNavigation() {
-  const carousel = sampleCarouselRef.value
-  if (!carousel) {
-    canScrollSamplePrevious.value = false
-    canScrollSampleNext.value = false
+function selectWorkspacePage(index: number) {
+  sampleFeedbackTags.value = []
+  sampleFeedbackText.value = ""
+  if (draftConverted.value) {
+    selectedDraftPageIndex.value = index
     return
   }
-  canScrollSamplePrevious.value = carousel.scrollLeft > 2
-  canScrollSampleNext.value = carousel.scrollLeft + carousel.clientWidth < carousel.scrollWidth - 2
+  selectedPageNo.value = outline.value?.pages[index]?.pageNo || index + 1
 }
 
-function scrollSampleCarousel(direction: -1 | 1) {
-  const carousel = sampleCarouselRef.value
-  if (!carousel) return
-  carousel.scrollBy({ left: direction * Math.max(320, carousel.clientWidth * 0.72), behavior: "smooth" })
-}
+function handleWorkspaceTextChange(payload: { elementId: string; content: string; previousContent: string; text: string; previousText: string }) {
+  const slide = workspaceSlide.value
+  const page = workspacePage.value
+  if (!slide || !page || !canEditWorkspaceText.value) return
 
-function selectSamplePage(pageNo: number, index: number) {
-  if (selectedPageNo.value !== pageNo) {
-    sampleFeedbackTags.value = []
-    sampleFeedbackText.value = ""
+  const element = slide.elements.find(item => item.id === payload.elementId)
+  if (element?.type === "text") element.content = payload.content
+  if (!replacePageVisibleText(page, payload.previousText, payload.text)) {
+    if (element?.type === "text") element.content = payload.previousContent
+    ElMessage.warning("这段文字由模板生成,请调整页面内容后再试")
+    return
   }
-  selectedPageNo.value = pageNo
-  const item = sampleCarouselRef.value?.children.item(index) as HTMLElement | null
-  item?.scrollIntoView({ behavior: "smooth", block: "nearest", inline: "center" })
+
+  protectPageContent(page)
+  invalidateVisualApproval()
+  ElMessage.success("文字已更新")
+}
+
+function replacePageVisibleText(page: AiCoursewarePage, previousText: string, nextText: string) {
+  const target = normalizeDraftText(previousText)
+  if (!target) return false
+  let changed = false
+  const replaceValue = (value?: string) => {
+    if (normalizeDraftText(value) !== target) return value
+    changed = true
+    return nextText
+  }
+  const replaceList = (values?: string[]) => (values || []).map(value => replaceValue(value) || "")
+
+  page.title = replaceValue(page.title) || page.title
+  page.subtitle = replaceValue(page.subtitle)
+  page.bullets = replaceList(page.bullets)
+  if (page.studentContent) {
+    page.studentContent.headline = replaceValue(page.studentContent.headline)
+    page.studentContent.points = replaceList(page.studentContent.points)
+  }
+  if (page.classroomAction) page.classroomAction.studentTask = replaceValue(page.classroomAction.studentTask)
+  page.learningEvidence = replaceValue(page.learningEvidence)
+  if (page.interaction) {
+    page.interaction.question = replaceValue(page.interaction.question) || page.interaction.question
+    page.interaction.options = replaceList(page.interaction.options)
+  }
+  return changed
+}
+
+function protectPageContent(page: AiCoursewarePage) {
+  protectedPageContent.set(page.pageNo, {
+    title: page.title,
+    subtitle: page.subtitle,
+    bullets: [...(page.bullets || [])],
+    studentContent: page.studentContent ? { headline: page.studentContent.headline, points: [...(page.studentContent.points || [])] } : undefined,
+    studentTask: page.classroomAction?.studentTask,
+    learningEvidence: page.learningEvidence,
+    interaction: page.interaction ? { ...page.interaction, options: page.interaction.options ? [...page.interaction.options] : undefined } : undefined
+  })
+}
+
+function restoreProtectedPageContent(targetOutline?: AiCoursewareOutline) {
+  if (!targetOutline || !protectedPageContent.size) return
+  targetOutline.pages.forEach(page => {
+    const protectedContent = protectedPageContent.get(page.pageNo)
+    if (!protectedContent) return
+    page.title = protectedContent.title
+    page.subtitle = protectedContent.subtitle
+    page.bullets = [...protectedContent.bullets]
+    page.studentContent = protectedContent.studentContent
+      ? { headline: protectedContent.studentContent.headline, points: [...(protectedContent.studentContent.points || [])] }
+      : undefined
+    if (!page.classroomAction) page.classroomAction = { teacherInstruction: "", studentTask: "", interactionType: "" }
+    page.classroomAction.studentTask = protectedContent.studentTask
+    page.learningEvidence = protectedContent.learningEvidence
+    page.interaction = protectedContent.interaction
+      ? { ...protectedContent.interaction, options: protectedContent.interaction.options ? [...protectedContent.interaction.options] : undefined }
+      : undefined
+  })
 }
 
 watch(selectedTemplateId, () => {
@@ -612,15 +535,6 @@ watch(
   }
 )
 
-watch(
-  [workflowStep, selectedTemplateId, () => outline.value?.pages.length],
-  async () => {
-    await nextTick()
-    updateSampleCarouselNavigation()
-  },
-  { immediate: true }
-)
-
 async function handleOutline() {
   if (!pptWork.id) {
     ElMessage.error("当前课件ID为空")
@@ -636,6 +550,11 @@ async function handleOutline() {
     outlineLoading.value = false
   })
   if (res.code === 200) {
+    protectedPageContent.clear()
+    draftOutline.value = undefined
+    draftConverted.value = undefined
+    draftQuality.value = undefined
+    draftPreparationState.value = "idle"
     outline.value = res.data as AiCoursewareOutline
     if (templateSelectionMode.value === "auto") selectedTemplateId.value = recommendAiCoursewareTemplate(outline.value).id
     outline.value.style = selectedStyle.value
@@ -650,10 +569,6 @@ async function handleOutline() {
 async function handleGenerate() {
   const sourceOutline = draftOutline.value || outline.value
   if (!sourceOutline || !pptWork.id) return
-  if (!allSamplesApproved.value) {
-    ElMessage.error("请先确认三张代表样稿")
-    return
-  }
 
   draftConverted.value = undefined
   draftQuality.value = undefined
@@ -661,6 +576,7 @@ async function handleGenerate() {
   automaticRepairAttempts.value = 0
   sourceOutline.style = selectedStyle.value
   sourceOutline.templateId = selectedTemplateId.value
+  restoreProtectedPageContent(sourceOutline)
   taskMode.value = "full"
   const res = await httpAjaxErrMsg(aiCoursewareGenerateApi, {
     id: pptWork.id,
@@ -719,10 +635,10 @@ async function fetchTask(taskId: string) {
     clearPollTimer()
     const message = taskInfo.value.message || "AI课件生成失败"
     clearTask()
-    if (workflowStep.value === 3) {
+    if (taskMode.value === "full" && draftOutline.value) {
       draftPreparationState.value = "failed"
       console.error("AI课件自动优化失败", message)
-      ElMessage.error("自动优化未完成,请重新尝试")
+      await continueAutomaticRepairAfterFailure()
     } else {
       ElMessage.error(message)
     }
@@ -750,9 +666,10 @@ async function loadDraft(taskId: string) {
 
   const result = res.data as AiCoursewareGenerateResult
   draftOutline.value = result.outline
+  restoreProtectedPageContent(draftOutline.value)
   draftOutline.value.templateId = selectedTemplateId.value
   selectedDraftPageIndex.value = 0
-  workflowStep.value = 3
+  workflowStep.value = 2
   clearTask()
   await prepareDraftForUse()
 }
@@ -765,6 +682,7 @@ async function loadSampleResult(taskId: string) {
   }
   const result = res.data as AiCoursewareGenerateResult
   outline.value = result.outline
+  restoreProtectedPageContent(outline.value)
   outline.value.style = selectedStyle.value
   outline.value.templateId = selectedTemplateId.value
   sampleFeedbackTags.value = []
@@ -795,17 +713,20 @@ async function regenerateDraftPage(index: number) {
   draftPreparationState.value = "optimizing"
   automaticRepairAttempts.value = 0
   taskMode.value = "full"
+  restoreProtectedPageContent(draftOutline.value)
+  if (sampleFeedbackTags.value.includes("画风不合适")) paletteVariant.value = (paletteVariant.value + 1) % 3
+  const feedback = [...sampleFeedbackTags.value, sampleFeedbackText.value.trim()].filter(Boolean).join(";")
   const res = await httpAjaxErrMsg(aiCoursewareGenerateApi, {
     id: pptWork.id,
     fromType: queryParams.pptResourcesType || queryParams.fromType,
     outline: draftOutline.value,
     templateId: selectedTemplateId.value,
-    visualPrompt: `${buildVisualPrompt()}请重新生成本页并主动修正学生可见内容、文字密度和版式问题。`,
+    visualPrompt: `${buildVisualPrompt()}请只调整第${index + 1}页的图片和版式,保持页面文字完全不变。${feedback ? `用户反馈:${feedback}。` : "请优化视觉组织和信息层级。"}`,
     preferences: { ...preferences },
     regeneratePageNo: index + 1
   })
   if (res.code !== 200) {
-    draftPreparationState.value = "failed"
+    await continueAutomaticRepairAfterFailure()
     return
   }
   taskInfo.value = res.data as AiCoursewareTaskInfo
@@ -834,6 +755,14 @@ function backToOutline() {
   workflowStep.value = 2
 }
 
+async function adjustCurrentPage() {
+  if (draftConverted.value) {
+    await regenerateDraftPage(selectedDraftPageIndex.value)
+    return
+  }
+  await regenerateSamplePage()
+}
+
 function invalidateVisualApproval() {
   approvedSamplePages.value = []
 }
@@ -856,7 +785,7 @@ async function confirmSamplesAndGenerate() {
     return
   }
 
-  approvedSamplePages.value = [...requiredSamplePages.value]
+  approvedSamplePages.value = outline.value.pages.map(page => page.pageNo)
   await handleGenerate()
 }
 
@@ -881,6 +810,7 @@ function toggleSampleFeedback(item: string) {
 }
 
 function selectTemplate(templateId: AiCoursewareTemplateId, fromSample = false) {
+  if (draftOutline.value) backToOutline()
   templateSelectionMode.value = "manual"
   selectedTemplateId.value = templateId
   if (fromSample) {
@@ -891,6 +821,7 @@ function selectTemplate(templateId: AiCoursewareTemplateId, fromSample = false)
 }
 
 function useRecommendedTemplate() {
+  if (draftOutline.value) backToOutline()
   templateSelectionMode.value = "auto"
   selectedTemplateId.value = templateRecommendation.value.id
   ElMessage.success(`已使用系统推荐的${recommendedTemplate.value.name}`)
@@ -924,6 +855,7 @@ async function prepareDraftForUse() {
   if (!draftOutline.value) return
   draftPreparationState.value = "optimizing"
   normalizeDraftLocally()
+  restoreProtectedPageContent(draftOutline.value)
   rebuildDraftPreview()
 
   if (draftQuality.value?.passed) {
@@ -961,16 +893,21 @@ async function requestAutomaticRepair() {
     preferences: { ...preferences }
   })
   if (res.code !== 200) {
-    draftPreparationState.value = "failed"
+    await continueAutomaticRepairAfterFailure()
     return
   }
   taskInfo.value = res.data as AiCoursewareTaskInfo
   await pollTask(taskInfo.value.taskId)
 }
 
-async function retryAutomaticOptimization() {
-  automaticRepairAttempts.value = 0
-  await prepareDraftForUse()
+async function continueAutomaticRepairAfterFailure() {
+  clearTask()
+  if (automaticRepairAttempts.value >= MAX_AUTOMATIC_REPAIR_ATTEMPTS) {
+    draftPreparationState.value = "failed"
+    return
+  }
+  automaticRepairAttempts.value += 1
+  await requestAutomaticRepair()
 }
 
 function normalizeDraftLocally() {
@@ -981,14 +918,14 @@ function normalizeDraftLocally() {
     if (!page.studentContent) page.studentContent = { headline: "", points: [] }
 
     const movedTeacherNotes: string[] = []
-    const headline = cleanDraftText(page.studentContent.headline, 24)
+    const headline = cleanDraftText(page.studentContent.headline)
     if (isTeacherOnlyText(headline)) movedTeacherNotes.push(headline)
     page.studentContent.headline = isTeacherOnlyText(headline) ? "" : headline
 
     const excluded = new Set([page.title, page.studentContent.headline].map(normalizeDraftText).filter(Boolean))
     const pointKeys = new Set<string>()
     const points = (page.studentContent.points?.length ? page.studentContent.points : page.bullets || [])
-      .map(item => cleanDraftText(item, 22))
+      .map(item => cleanDraftText(item))
       .filter(item => {
         if (!item) return false
         if (isTeacherOnlyText(item)) {
@@ -1000,7 +937,6 @@ function normalizeDraftLocally() {
         pointKeys.add(key)
         return true
       })
-      .slice(0, 4)
     page.studentContent.points = points
     page.bullets = [...points]
     page.visualPlan.visualDensity = points.length <= 2 ? "low" : points.length >= 4 ? "high" : "medium"
@@ -1013,7 +949,7 @@ function normalizeDraftLocally() {
     if (!page.classroomAction) {
       page.classroomAction = { teacherInstruction: "", studentTask: page.studentContent.headline || page.title, interactionType: "" }
     }
-    const studentTask = cleanDraftText(page.classroomAction.studentTask, 24)
+    const studentTask = cleanDraftText(page.classroomAction.studentTask)
     page.classroomAction.studentTask = !studentTask || isTeacherOnlyText(studentTask) ? page.studentContent.headline || page.title : studentTask
 
     const layout = page.visualPlan.layout || ""
@@ -1041,11 +977,10 @@ function normalizeDraftLocally() {
   })
 }
 
-function cleanDraftText(value?: string, limit?: number) {
-  const cleaned = String(value || "")
+function cleanDraftText(value?: string) {
+  return String(value || "")
     .replace(/\s+/g, " ")
     .trim()
-  return limit ? cleaned.slice(0, limit) : cleaned
 }
 
 function normalizeDraftText(value?: string) {
@@ -1069,21 +1004,6 @@ function isTeacherOnlyText(value?: string) {
   return Boolean(text && (TEACHER_VISIBLE_PATTERN.test(text) || PLACEHOLDER_PATTERN.test(text)))
 }
 
-function updateStudentHeadline(page: AiCoursewarePage, event: Event) {
-  if (!page.studentContent) page.studentContent = { headline: "", points: [] }
-  page.studentContent.headline = (event.target as HTMLInputElement).value.trim()
-}
-
-function updateStudentPoints(page: AiCoursewarePage, event: Event) {
-  if (!page.studentContent) page.studentContent = { headline: "", points: [] }
-  const points = (event.target as HTMLTextAreaElement).value
-    .split("\n")
-    .map(item => item.trim())
-    .filter(Boolean)
-  page.studentContent.points = points
-  page.bullets = points
-}
-
 function buildExtraPrompt() {
   return extraPrompt.value.trim()
 }
@@ -1173,7 +1093,7 @@ function shortProgressLabel(message?: string) {
 }
 .workflow-steps {
   display: grid;
-  grid-template-columns: repeat(3, 1fr);
+  grid-template-columns: repeat(2, 1fr);
   margin-top: 12px;
   padding: 4px;
   border-radius: 8px;
@@ -1562,6 +1482,173 @@ function shortProgressLabel(message?: string) {
   overflow: auto;
   padding-right: 4px;
 }
+.unified-review {
+  display: flex;
+  flex-direction: column;
+  overflow: hidden;
+}
+.compact-process-state {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  margin-top: 8px;
+  padding: 7px 10px;
+  border-radius: 7px;
+  background: #f5f8fc;
+  color: #52616b;
+  font-size: 11px;
+  &.ready {
+    background: #eff9f5;
+    color: #16776f;
+  }
+  &.failed {
+    background: #fff7ed;
+    color: #9a5b08;
+  }
+  > div {
+    display: flex;
+    min-width: 0;
+    align-items: baseline;
+    gap: 8px;
+  }
+  strong {
+    color: #263442;
+    font-size: 12px;
+  }
+  span:last-child {
+    overflow: hidden;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+  }
+  .loading-dot {
+    width: 12px;
+    height: 12px;
+  }
+}
+.process-state-icon {
+  display: grid;
+  width: 18px;
+  height: 18px;
+  flex: 0 0 18px;
+  place-items: center;
+  border-radius: 50%;
+  background: currentColor;
+  color: #fff;
+  font-size: 11px;
+  font-weight: 700;
+}
+.unified-review-workspace {
+  min-height: 0;
+  flex: 1;
+  display: grid;
+  grid-template-columns: 154px minmax(0, 1fr);
+  gap: 12px;
+  margin-top: 8px;
+}
+.workspace-page-list {
+  min-height: 0;
+  overflow-y: auto;
+  padding: 2px 5px 2px 2px;
+  scrollbar-width: thin;
+}
+.workspace-page-item {
+  width: 100%;
+  padding: 5px;
+  overflow: hidden;
+  border: 1px solid transparent;
+  border-radius: 7px;
+  background: transparent;
+  color: #52616b;
+  text-align: left;
+  cursor: pointer;
+  + .workspace-page-item {
+    margin-top: 5px;
+  }
+  &:hover:not(:disabled) {
+    background: #f5f8fc;
+  }
+  &.active {
+    border-color: $themeColor;
+    background: rgba($themeColor, 0.06);
+    box-shadow: 0 0 0 2px rgba($themeColor, 0.08);
+  }
+  &:disabled {
+    cursor: default;
+    opacity: 0.72;
+  }
+  :deep(.thumbnail-slide) {
+    border-radius: 4px;
+    box-shadow: 0 2px 8px rgba(31, 41, 51, 0.1);
+  }
+  > span {
+    display: block;
+    margin-top: 5px;
+    overflow: hidden;
+    font-size: 10px;
+    line-height: 1.35;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+  }
+}
+.workspace-preview-column {
+  min-width: 0;
+  min-height: 0;
+  display: flex;
+  flex-direction: column;
+  padding: 10px;
+  border: 1px solid #dfe6ee;
+  border-radius: 8px;
+  background: #fff;
+}
+.workspace-preview-heading {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 12px;
+  margin-bottom: 7px;
+  > div {
+    display: flex;
+    min-width: 0;
+    align-items: baseline;
+    gap: 8px;
+  }
+  span,
+  small {
+    color: #7b8794;
+    font-size: 11px;
+  }
+  strong {
+    overflow: hidden;
+    color: #1f2933;
+    font-size: 14px;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+  }
+  small {
+    flex-shrink: 0;
+  }
+}
+.workspace-canvas-shell {
+  min-height: 0;
+  flex: 1 1 auto;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  overflow: auto;
+  padding: 10px;
+  border-radius: 7px;
+  background: #eef2f7;
+  :deep(.thumbnail-slide) {
+    flex-shrink: 0;
+    border-radius: 6px;
+    box-shadow: 0 10px 28px rgba(31, 41, 51, 0.15);
+  }
+}
+.workspace-feedback {
+  flex: 0 0 auto;
+  margin-top: 8px;
+  padding-top: 8px;
+}
 .sample-stage-head {
   display: flex;
   align-items: center;
@@ -2607,6 +2694,12 @@ function shortProgressLabel(message?: string) {
   .draft-workspace {
     grid-template-columns: 1fr;
   }
+  .unified-review-workspace {
+    grid-template-columns: 132px minmax(0, 1fr);
+  }
+  .workspace-page-item :deep(.thumbnail-slide) {
+    max-width: 100%;
+  }
   .draft-page-list {
     display: flex;
     gap: 8px;
@@ -2662,6 +2755,28 @@ function shortProgressLabel(message?: string) {
     align-items: flex-start;
     flex-direction: column;
   }
+  .unified-review-workspace {
+    grid-template-columns: 1fr;
+  }
+  .workspace-page-list {
+    display: flex;
+    gap: 6px;
+    overflow-x: auto;
+    overflow-y: hidden;
+    padding-bottom: 6px;
+  }
+  .workspace-page-item {
+    width: 146px;
+    flex: 0 0 146px;
+    + .workspace-page-item {
+      margin-top: 0;
+    }
+  }
+  .workspace-preview-heading {
+    align-items: flex-start;
+    flex-direction: column;
+    gap: 3px;
+  }
   .footer {
     flex-wrap: wrap;
   }

+ 177 - 31
src/views/components/ThumbnailSlide/index.vue

@@ -17,19 +17,40 @@
     >
       <div class="background" :style="backgroundStyle"></div>
       <ThumbnailElement v-for="(element, index) in slide.elements" :key="element.id" :elementInfo="element" :elementIndex="index + 1" />
+      <template v-if="editableText">
+        <div
+          v-for="element in textElements"
+          :key="`editable-${element.id}`"
+          class="editable-text-hitbox"
+          :class="{ editing: editingElementId === element.id, disabled: editDisabled }"
+          :style="editableElementStyle(element)"
+          :title="editDisabled ? '' : '双击编辑文字'"
+          @dblclick.stop="startTextEdit(element)"
+        >
+          <textarea
+            v-if="editingElementId === element.id"
+            ref="editorRefs"
+            v-model="editingText"
+            class="inline-text-editor"
+            :style="inlineEditorStyle(element)"
+            @blur="commitTextEdit(element)"
+            @keydown.esc.prevent="cancelTextEdit"
+            @keydown.ctrl.enter.prevent="commitTextEdit(element)"
+          ></textarea>
+        </div>
+      </template>
     </div>
     <div class="placeholder" v-else>加载中 ...</div>
-    <div v-if="showBackgroundError && backgroundLoadState === 'error'" class="background-error">
-      模板背景加载失败,请检查资源部署
-    </div>
+    <div v-if="showBackgroundError && backgroundLoadState === 'error'" class="background-error">模板背景加载失败,请检查资源部署</div>
   </div>
 </template>
 
 <script lang="ts" setup>
-import { computed, provide, ref, watch } from "vue"
+import { computed, nextTick, provide, ref, watch } from "vue"
+import type { CSSProperties } from "vue"
 import { storeToRefs } from "pinia"
 import { useSlidesStore } from "@/store"
-import type { Slide } from "@/types/slides"
+import { ElementTypes, type PPTTextElement, type Slide } from "@/types/slides"
 import { injectKeySlideScale } from "@/types/injectKey"
 import useSlideBackgroundStyle from "@/hooks/useSlideBackgroundStyle"
 import { resolveTemplateAssetUrl } from "@/utils/templateAssetUrl"
@@ -42,53 +63,144 @@ const props = withDefaults(
     size: number
     visible?: boolean
     showBackgroundError?: boolean
+    editableText?: boolean
+    editDisabled?: boolean
   }>(),
   {
     visible: true,
-    showBackgroundError: false
+    showBackgroundError: false,
+    editableText: false,
+    editDisabled: false
   }
 )
 
 const emit = defineEmits<{
   (event: "background-load-state", payload: { status: "none" | "loading" | "loaded" | "error"; url: string }): void
+  (event: "text-change", payload: { elementId: string; content: string; previousContent: string; text: string; previousText: 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 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
+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)
+
+const textElements = computed(() => props.slide.elements.filter(element => element.type === ElementTypes.TEXT) as PPTTextElement[])
+const editingElementId = ref("")
+const editingText = ref("")
+const editorRefs = ref<HTMLTextAreaElement[]>([])
+
+function htmlToText(content: string) {
+  const container = document.createElement("div")
+  container.innerHTML = content
+  return container.textContent || ""
+}
+
+function textToContent(originalContent: string, text: string) {
+  const container = document.createElement("div")
+  container.innerHTML = originalContent
+  const root = container.firstElementChild as HTMLElement | null
+  if (!root) {
+    const fallback = document.createElement("div")
+    fallback.textContent = text
+    return fallback.outerHTML
   }
-  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 })
+  root.textContent = text
+  return container.innerHTML
+}
+
+function startTextEdit(element: PPTTextElement) {
+  if (props.editDisabled) return
+  editingElementId.value = element.id
+  editingText.value = htmlToText(element.content)
+  nextTick(() => {
+    const editor = editorRefs.value[0]
+    editor?.focus()
+    editor?.select()
+  })
+}
+
+function commitTextEdit(element: PPTTextElement) {
+  if (editingElementId.value !== element.id) return
+  const nextText = editingText.value.trim()
+  const previousText = htmlToText(element.content).trim()
+  editingElementId.value = ""
+  if (!nextText || nextText === previousText) return
+  emit("text-change", {
+    elementId: element.id,
+    content: textToContent(element.content, nextText),
+    previousContent: element.content,
+    text: nextText,
+    previousText
+  })
+}
+
+function cancelTextEdit() {
+  editingElementId.value = ""
+  editingText.value = ""
+}
+
+function editableElementStyle(element: PPTTextElement) {
+  return {
+    left: `${element.left}px`,
+    top: `${element.top}px`,
+    width: `${element.width}px`,
+    height: `${element.height}px`,
+    transform: `rotate(${element.rotate || 0}deg)`
   }
-  image.onerror = () => {
-    if (requestId !== backgroundRequestId) return
-    backgroundLoadState.value = "error"
-    emit("background-load-state", { status: "error", url })
+}
+
+function inlineEditorStyle(element: PPTTextElement): CSSProperties {
+  const fontSize = element.content.match(/font-size:\s*([\d.]+)px/i)?.[1] || "22"
+  const fontWeight = element.content.match(/font-weight:\s*([^;"']+)/i)?.[1] || "400"
+  const textAlign = element.content.match(/text-align:\s*([^;"']+)/i)?.[1] || "left"
+  return {
+    color: element.defaultColor,
+    fontFamily: element.defaultFontName,
+    fontSize: `${fontSize}px`,
+    fontWeight,
+    textAlign: textAlign as CSSProperties["textAlign"],
+    lineHeight: String(element.lineHeight || 1.25)
   }
-  image.src = url
-}, { immediate: true })
+}
 
-const scale = computed(() => props.size / viewportSize.value)
-provide(injectKeySlideScale, scale)
+watch(
+  () => props.slide.id,
+  () => cancelTextEdit()
+)
 </script>
 
 <style lang="scss" scoped>
@@ -112,6 +224,7 @@ provide(injectKeySlideScale, scale)
 }
 .elements {
   transform-origin: 0 0;
+  position: relative;
 }
 .background {
   width: 100%;
@@ -126,4 +239,37 @@ provide(injectKeySlideScale, scale)
   justify-content: center;
   align-items: center;
 }
+.editable-text-hitbox {
+  position: absolute;
+  z-index: 999;
+  box-sizing: border-box;
+  cursor: text;
+  border: 2px solid transparent;
+  transform-origin: center;
+  transition:
+    border-color 0.15s ease,
+    background-color 0.15s ease;
+}
+.editable-text-hitbox:not(.disabled):hover {
+  border-color: #1689f8;
+  background: rgba(22, 137, 248, 0.04);
+}
+.editable-text-hitbox.editing {
+  border-color: #1689f8;
+  background: #fff;
+}
+.editable-text-hitbox.disabled {
+  pointer-events: none;
+}
+.inline-text-editor {
+  width: 100%;
+  height: 100%;
+  box-sizing: border-box;
+  resize: none;
+  overflow: hidden;
+  padding: 0;
+  border: 0;
+  outline: 0;
+  background: #fff;
+}
 </style>

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