Procházet zdrojové kódy

Merge branch 'feat/extract-en' of jack/PPT into beta

lihongjun před 1 dnem
rodič
revize
339fc90392

+ 21 - 0
.dockerignore

@@ -0,0 +1,21 @@
+node_modules
+dist
+.git
+.github
+.husky
+.idea
+.vscode
+.agent
+.claude
+.superpowers
+openspec
+doc
+scripts
+*.tsbuildinfo
+vite.config.ts.timestamp-*
+
+# 不把本机 .env 烧进镜像。需要构建期变量时用 --build-arg 显式传入,
+# 见 Dockerfile 与 API 仓库的 DEPLOY.md。
+.env
+.env.*
+!.env.example

+ 49 - 0
Dockerfile

@@ -0,0 +1,49 @@
+# 本文件不指定平台。目标架构由构建命令决定:
+#   docker build --platform linux/arm64 ...
+# 交付构建请走 API 仓库的 scripts/build-release.sh,它统一了两个镜像的平台。
+
+# ---------- build ----------
+FROM node:20-alpine AS build
+
+WORKDIR /app
+
+# 镜像里没有 .git,husky install 会失败;HUSKY=0 让它跳过。
+ENV HUSKY=0
+
+COPY package.json package-lock.json ./
+RUN npm ci
+
+COPY . .
+
+# 这里刻意没有任何 ARG:所有环境相关的配置都走运行时注入(见文件末尾的 ENV)。
+# 镜像因此与具体环境无关,同一份产物可以在任何环境跑,换配置只需重启容器。
+
+# 用 build-only 而非 build:跳过 vue-tsc 类型检查,
+# 避免既有类型问题阻塞交付镜像的构建。类型检查请在 CI / 本地跑。
+RUN npm run build-only
+
+# ---------- runtime ----------
+FROM nginx:alpine
+
+COPY --from=build /app/dist /usr/share/nginx/html
+COPY nginx.conf /etc/nginx/conf.d/default.conf
+
+# nginx 官方镜像自带的 docker-entrypoint 会在启动时对
+# /etc/nginx/templates/*.template 做 envsubst,输出到 NGINX_ENVSUBST_OUTPUT_DIR。
+# 这里把输出目录指向静态根,于是生成 /usr/share/nginx/html/config.js,
+# 覆盖构建产物里那份空的占位 config.js。不需要自定义 entrypoint。
+COPY config.js.template /etc/nginx/templates/config.js.template
+ENV NGINX_ENVSUBST_OUTPUT_DIR=/usr/share/nginx/html
+
+# 运行时可配置项。必须给默认值:envsubst 只替换"已定义"的变量,
+# 未定义时模板里的 ${XXX} 会被原样保留,变成一个非空的垃圾字符串,
+# 反而绕过了代码里的兜底逻辑。
+#
+# AZURE_SPEECH_* 供前端 TTS(speechService.ts)使用。放在这里而非构建期 ARG,
+# 是为了让镜像与环境解耦 —— 但要清楚:它最终会写进浏览器可读的 /config.js,
+# 并不比构建期烧入更安全,只是更灵活。详见 DEPLOY.md「已知问题」。
+ENV SPEAKING_API_HOST="" \
+    AZURE_SPEECH_KEY="" \
+    AZURE_SPEECH_REGION=""
+
+EXPOSE 80

+ 54 - 0
README.md

@@ -41,6 +41,60 @@ Browser access: http://127.0.0.1:5173/
 > Note: If you deploy this project on your own server and find that it fails to initialize, it's because the initialization data is stored in the author's private object storage and is not publicly accessible. You'll need to transfer the data to your own server, object storage service, database, or front-end local storage.
 
 
+# 🐳 Docker Deployment
+
+Packaged as a static nginx image. **The image contains no environment-specific values and no credentials** — all configuration is injected at container startup, so the same image runs in any environment and changing configuration never requires a rebuild.
+
+### Build
+
+```bash
+docker build --platform linux/arm64 -t pptist:2.0.0 .
+```
+
+`--platform` determines the target architecture. Check with `uname -m` first: `aarch64` → `linux/arm64`, `x86_64` → `linux/amd64`. A mismatch leaves the operator with nothing but `exec format error` after `docker load`.
+
+### Run
+
+```bash
+docker run -d --name pptist -p 8080:80 \
+  -e SPEAKING_API_HOST=https://your-api-host \
+  -e AZURE_SPEECH_KEY=xxxxxxxx \
+  -e AZURE_SPEECH_REGION=eastasia \
+  -e TZ=Asia/Shanghai \
+  pptist:2.0.0
+```
+
+### Configuration
+
+| Variable | Required | Description |
+| --- | :---: | --- |
+| `SPEAKING_API_HOST` | ✅ | Backend API address. **Must be reachable from the browser** (public domain or `http://server-ip:8000`) — a container name will not work. If omitted, falls back to the domain hardcoded in the source |
+| `AZURE_SPEECH_KEY` | ✅ | Azure Speech key, used by the frontend for TTS playback. Without it, playback throws |
+| `AZURE_SPEECH_REGION` | ✅ | Azure region, e.g. `eastasia` |
+| `TZ` | | Timezone. Defaults to UTC, affects nginx log timestamps only |
+
+> ⚠️ `AZURE_SPEECH_KEY` is written into `/config.js`, which is readable by anyone in the browser. This is inherited from the existing design (the frontend calls Azure directly); moving to runtime injection improved flexibility, not security.
+
+### Verify
+
+```bash
+curl http://localhost:8080/config.js
+```
+
+All three values should be non-empty. An empty value means that environment variable was not passed in; the output carries an explanatory comment.
+
+### Changing configuration
+
+Re-run the container with new `-e` flags — **no rebuild required**:
+
+```bash
+docker rm -f pptist
+docker run -d --name pptist -p 8080:80 -e SPEAKING_API_HOST=... pptist:2.0.0
+```
+
+How it works: on startup nginx renders `config.js.template` into `/config.js` via `envsubst`, and the page loads it synchronously before the app bundle. The lookup order in code is `window.__APP_CONFIG__` > `import.meta.env.VITE_*` > hardcoded fallback, so local `npm run dev` and the existing non-container build behave exactly as before.
+
+
 # 📚 Features
 ### Basic Features
 - History (undo, redo)

+ 54 - 0
README_zh.md

@@ -42,6 +42,60 @@ npm run dev
 > 注意:如果你将本项目部署在自己的服务器上,发现无法初始化成功,那是因为初始化的数据是放在作者私人对象存储中的,不对外开放,你需要把数据转移到自己的服务器/对象存储服务/数据库/前端本地
 
 
+# 🐳 Docker 部署
+
+打包成 nginx 静态镜像。**镜像不含任何环境相关的值和密钥**,所有配置在容器启动时注入,因此同一份镜像可以跑在任何环境,改配置不需要重新构建。
+
+### 构建
+
+```bash
+docker build --platform linux/arm64 -t pptist:2.0.0 .
+```
+
+`--platform` 决定目标机器架构,先用 `uname -m` 确认:`aarch64` → `linux/arm64`,`x86_64` → `linux/amd64`。架构不匹配的话,对方 `docker load` 之后只会得到一句 `exec format error`。
+
+### 运行
+
+```bash
+docker run -d --name pptist -p 8080:80 \
+  -e SPEAKING_API_HOST=https://your-api-host \
+  -e AZURE_SPEECH_KEY=xxxxxxxx \
+  -e AZURE_SPEECH_REGION=eastasia \
+  -e TZ=Asia/Shanghai \
+  pptist:2.0.0
+```
+
+### 配置项
+
+| 变量 | 必填 | 说明 |
+| --- | :---: | --- |
+| `SPEAKING_API_HOST` | ✅ | 后端 API 地址。**必须是浏览器能访问到的地址**(公网域名或 `http://服务器IP:8000`),不能填容器名。不传会回落到代码内置的默认域名 |
+| `AZURE_SPEECH_KEY` | ✅ | Azure Speech 密钥,前端 TTS 朗读用。不传则朗读功能直接报错 |
+| `AZURE_SPEECH_REGION` | ✅ | Azure 区域,如 `eastasia` |
+| `TZ` | | 时区。不传则容器为 UTC,只影响 nginx 日志时间 |
+
+> ⚠️ `AZURE_SPEECH_KEY` 会写进浏览器可读的 `/config.js`,任何人都能取走。这是既有设计(前端直连 Azure)遗留的问题;改成运行时注入只解决了灵活性,没有解决这一点。
+
+### 验证
+
+```bash
+curl http://localhost:8080/config.js
+```
+
+三个值都应该非空。为空说明对应的环境变量没传进去,输出里自带说明文字。
+
+### 改配置
+
+用新的 `-e` 参数重跑容器即可,**不需要重新构建镜像**:
+
+```bash
+docker rm -f pptist
+docker run -d --name pptist -p 8080:80 -e SPEAKING_API_HOST=... pptist:2.0.0
+```
+
+原理:nginx 启动时用 `envsubst` 把 `config.js.template` 渲染成 `/config.js`,页面在 app bundle 之前同步加载它。代码的读取优先级是 `window.__APP_CONFIG__` > `import.meta.env.VITE_*` > 硬编码兜底,所以本地 `npm run dev` 和原有的构建部署方式行为完全不变。
+
+
 # 📚 功能列表
 ### 基础功能
 - 历史记录(撤销、重做)

+ 11 - 0
config.js.template

@@ -0,0 +1,11 @@
+// 运行时配置。本文件由 nginx 启动时用 envsubst 从 config.js.template 生成,
+// 覆盖构建产物里那份空的占位 config.js。
+//
+// 下列值为空 = 未配置对应的环境变量,前端会回落到镜像内置的默认值
+// (API 地址会指向 cocorobo 的生产域名,多半不是你想要的)。
+// 容器部署请用 -e 提供:SPEAKING_API_HOST / AZURE_SPEECH_KEY / AZURE_SPEECH_REGION
+window.__APP_CONFIG__ = {
+  SPEAKING_API_HOST: "${SPEAKING_API_HOST}",
+  AZURE_SPEECH_KEY: "${AZURE_SPEECH_KEY}",
+  AZURE_SPEECH_REGION: "${AZURE_SPEECH_REGION}",
+}

+ 11 - 0
env.d.ts

@@ -4,3 +4,14 @@
 interface ImportMetaEnv {
   readonly VITE_SPEAKING_API_HOST?: string
 }
+
+// 容器部署时由 nginx 启动脚本生成 /config.js 注入;dev 与普通构建下是空对象。
+interface AppRuntimeConfig {
+  readonly SPEAKING_API_HOST?: string
+  readonly AZURE_SPEECH_KEY?: string
+  readonly AZURE_SPEECH_REGION?: string
+}
+
+interface Window {
+  __APP_CONFIG__?: AppRuntimeConfig
+}

+ 2 - 0
index.html

@@ -3,6 +3,8 @@
   <head>
     <meta charset="UTF-8">
     <link rel="icon" href="/favicon.ico">
+    <!-- 运行时配置。必须在 /src/main.ts 之前执行;同步 script 先于 defer 的 module script 运行。 -->
+    <script src="/config.js"></script>
     <meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1" />
     <meta name="renderer" content="webkit">
     <meta name="viewport" content="width=device-width, initial-scale=1.0">

+ 36 - 0
nginx.conf

@@ -0,0 +1,36 @@
+server {
+    listen 80;
+    server_name _;
+
+    root /usr/share/nginx/html;
+    index index.html;
+
+    gzip on;
+    gzip_types text/plain text/css application/javascript application/json image/svg+xml;
+    gzip_min_length 1024;
+
+    # 运行时配置:绝不能被缓存,否则运维改了环境变量重启,
+    # 浏览器仍在用旧的 API 地址,且极难排查。
+    # 不要在这里再加 expires 指令:它会额外发一个 Cache-Control 头,
+    # 与下面的 add_header 重复。no-store 已经足够。
+    location = /config.js {
+        add_header Cache-Control "no-store" always;
+    }
+
+    # index.html 必须每次回源校验(通常是一个很便宜的 304)。
+    # 它是唯一指向 /assets/ 那些 hash 文件的地方 —— 一旦它被缓存,浏览器就会继续
+    # 请求旧的 asset URL,而那些 URL 是 immutable 的,用户会被钉死在旧版本且无法自愈。
+    location = /index.html {
+        add_header Cache-Control "no-cache" always;
+    }
+
+    # 构建产物文件名带内容 hash(改图片不改文件名,产物名一样会变),可永久缓存。
+    # 不要用 expires 指令:它会额外发一个 Cache-Control 头,与 add_header 重复。
+    location /assets/ {
+        add_header Cache-Control "public, max-age=31536000, immutable" always;
+    }
+
+    location / {
+        try_files $uri $uri/ /index.html;
+    }
+}

+ 7 - 0
public/config.js

@@ -0,0 +1,7 @@
+// 运行时配置占位文件。保持空对象。
+//
+// 本地 dev / 现有的构建部署:走本文件,配置继续由 .env 的 VITE_* 变量提供。
+// 容器部署:nginx 启动时用 config.js.template + envsubst 生成同名文件覆盖它。
+//
+// 优先级说明见 speakingApiConfig.ts。
+window.__APP_CONFIG__ = {}

+ 4 - 4
src/App.vue

@@ -19,10 +19,10 @@
         key="editor3"
       />
       <EditorEn
-        v-else-if="viewMode === 'editorEn' && _isPC && !screening"
+        v-else-if="viewMode === 'editor-en' && _isPC && !screening"
         :courseid="urlParams.courseid"
         :userid="urlParams.userid"
-        key="editorEn"
+        key="editor-en"
       />
       <Student
         v-else-if="viewMode === 'student'"
@@ -94,8 +94,8 @@ const getInitialViewMode = () => {
     return 'editor3'
   }
   
-  if (modeFromUrl === 'editorEn') {
-    return 'editorEn'
+  if (modeFromUrl === 'editor-en') {
+    return 'editor-en'
   }
 
   // 检查localStorage

+ 17 - 8
src/components/CollapsibleToolbar/index2.vue

@@ -2,7 +2,8 @@
   <div class="collapsible-toolbar" :class="{ collapsed: isCollapsed }">
     <div class="toolbar-content" v-show="!isCollapsed">
       <div class="sidebar-content">
-        <div class="sidebar-item feature-sidebar-item" :class="{ active: activeSubmenu === 'cocoai' }"
+        <div class="sidebar-item feature-sidebar-item" v-if="!onlyEnglish"
+          :class="{ active: activeSubmenu === 'cocoai' }"
           @click="toggleSubmenu('cocoai')">
           <svg class="item-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
             <path d="M12 2L2 7l10 5 10-5-10-5z"></path>
@@ -11,7 +12,8 @@
           </svg>
           <span class="item-label">Coco AI</span>
         </div>
-        <div class="sidebar-item feature-sidebar-item" :class="{ active: activeSubmenu === 'uploadFile' }"
+        <div class="sidebar-item feature-sidebar-item" v-if="!onlyEnglish"
+          :class="{ active: activeSubmenu === 'uploadFile' }"
           @click="toggleSubmenu('uploadFile')">
           <svg class="item-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
             <path d="M12 16V4"></path>
@@ -21,7 +23,7 @@
           </svg>
           <span class="item-label">{{ lang.ssUploadFile }}</span>
         </div>
-        <div class="sidebar-divider"></div>
+        <div class="sidebar-divider" v-if="!onlyEnglish"></div>
         <!-- <div class="sidebar-item" :class="{ active: activeSubmenu === 'page' }" @click="toggleSubmenu('page')">
           <svg class="item-icon" viewBox="0 0 22 22" fill="none" xmlns="http://www.w3.org/2000/svg">
             <g id="Component 1">
@@ -33,7 +35,7 @@
           </svg>
           <span class="item-label">{{ lang.ssPage }}</span>
         </div> -->
-        <div class="sidebar-item" :class="{ active: activeSubmenu === 'interactive' }"
+        <div class="sidebar-item" v-if="!onlyEnglish" :class="{ active: activeSubmenu === 'interactive' }"
           @click="toggleSubmenu('interactive')">
           <svg class="item-icon" viewBox="0 0 22 22" fill="none" xmlns="http://www.w3.org/2000/svg">
             <g id="Frame">
@@ -47,7 +49,8 @@
           </svg>
           <span class="item-label">{{ lang.ssInteract }}</span>
         </div>
-        <div class="sidebar-item" :class="{ active: activeSubmenu === 'aiapp' }" @click="toggleSubmenu('aiapp')">
+        <div class="sidebar-item" v-if="!onlyEnglish" :class="{ active: activeSubmenu === 'aiapp' }"
+          @click="toggleSubmenu('aiapp')">
           <svg class="item-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
             <rect x="3" y="3" width="7" height="7" />
             <rect x="14" y="3" width="7" height="7" />
@@ -56,7 +59,8 @@
           </svg>
           <span class="item-label">{{ lang.ssAiApp }}</span>
         </div>
-        <div class="sidebar-item" :class="{ active: activeSubmenu === 'h5page' }" @click="toggleSubmenu('h5page')">
+        <div class="sidebar-item" v-if="!onlyEnglish" :class="{ active: activeSubmenu === 'h5page' }"
+          @click="toggleSubmenu('h5page')">
           <svg class="item-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
             <circle cx="12" cy="12" r="10" />
             <path d="M2 12h20" />
@@ -79,7 +83,7 @@
           </svg>
           <span class="item-label">{{ lang.ssCreative }}</span>
         </div> -->
-        <div class="sidebar-item" :class="{ active: activeSubmenu === 'multimedia' }"
+        <div class="sidebar-item" v-if="!onlyEnglish" :class="{ active: activeSubmenu === 'multimedia' }"
           @click="toggleSubmenu('multimedia')">
           <svg class="item-icon" viewBox="0 0 22 22" fill="none" xmlns="http://www.w3.org/2000/svg">
             <g id="Component 1">
@@ -745,10 +749,14 @@ const props = withDefaults(defineProps<{
   defaultCollapsed?: boolean
   userid?: string | null
   courseTitle?: string | null
+  // 只保留英语口语这一项工具,其余侧边栏入口全部隐藏(EditorEn 展示页用)。
+  // 默认 false,index3 等现役编辑器走默认分支,行为不变。
+  onlyEnglish?: boolean
 }>(), {
   defaultCollapsed: false,
   userid: null,
   courseTitle: null,
+  onlyEnglish: false,
 })
 
 
@@ -766,7 +774,8 @@ const emit = defineEmits<{
 }>()
 
 const isCollapsed = ref(props.defaultCollapsed)
-const activeSubmenu = ref<string | null>(null)
+// onlyEnglish 时侧边栏只剩英语一项,收着让人先点一下没有意义,直接展开
+const activeSubmenu = ref<string | null>(props.onlyEnglish ? 'english' : null)
 const contentList = ref<ContentItem[]>([])
 const hoveredTool = ref<string | null>(null)
 const webpageUrl = ref('')

+ 10 - 0
src/hooks/useAddSlidesOrElements.ts

@@ -1,7 +1,9 @@
+import { inject } from 'vue'
 import { storeToRefs } from 'pinia'
 import { nanoid } from 'nanoid'
 import { useSlidesStore, useMainStore } from '@/store'
 import type { PPTElement, Slide } from '@/types/slides'
+import { injectKeySingleSlideMode } from '@/types/injectKey'
 import { createSlideIdMap, createElementIdMap, getElementRange } from '@/utils/element'
 import useHistorySnapshot from '@/hooks/useHistorySnapshot'
 
@@ -10,6 +12,9 @@ export default () => {
   const slidesStore = useSlidesStore()
   const { currentSlide } = storeToRefs(slidesStore)
 
+  // 由 indexEn.vue provide;其余编辑器没有 provide,这里拿到 default false
+  const singleSlideMode = inject(injectKeySingleSlideMode, false)
+
   const { addHistorySnapshot } = useHistorySnapshot()
 
   /**
@@ -64,6 +69,11 @@ export default () => {
    * @param slide 页面数据
    */
   const addSlidesFromData = (slides: Slide[]) => {
+    // 单页模式下唯一还能加出第二页的入口:Ctrl+V 粘贴整页数据(Thumbnails 的右键
+    // 菜单已经随缩略图一起隐藏,但 usePasteEvent 仍然活着),以及 useAIPPT 的批量加页。
+    // 两条路都经过这里,一道守卫全封住。
+    if (singleSlideMode) return
+
     const slideIdMap = createSlideIdMap(slides)
     const newSlides = slides.map(slide => {
       const { groupIdMap, elIdMap } = createElementIdMap(slide.elements)

+ 21 - 0
src/hooks/useSlideHandler.ts

@@ -91,6 +91,26 @@ export default () => {
     addHistorySnapshot()
   }
 
+  // 用一页全新的空白页替换掉当前全部页面(单页模式下代替 createSlide 使用)
+  //
+  // 刻意做成一次赋值而不是「先 createSlide 再 deleteSlide 旧页」:
+  // 1. setSlides 是原子的,不存在「新页建好了但旧页没删掉」这种中间态;
+  // 2. 不在这里存快照,让后续 createElement 那一笔成为唯一的一笔,Ctrl+Z 一次回到原状;
+  // 3. 新页保证是空的,createFrameElement 里「当前页已有 frame 就拒绝插入」的守卫必定放行。
+  const replaceAllSlidesWithNew = () => {
+    const emptySlide: Slide = {
+      id: nanoid(10),
+      elements: [],
+      background: {
+        type: 'solid',
+        color: theme.value.backgroundColor,
+      },
+    }
+    mainStore.setActiveElementIdList([])
+    slidesStore.setSlides([emptySlide])
+    slidesStore.updateSlideIndex(0)
+  }
+
   // 根据模板创建新页面
   const createSlideByTemplate = (slide: Slide) => {
     const { groupIdMap, elIdMap } = createElementIdMap(slide.elements)
@@ -207,6 +227,7 @@ export default () => {
     copySlide,
     pasteSlide,
     createSlide,
+    replaceAllSlidesWithNew,
     createSlideByTemplate,
     copyAndPasteSlide,
     deleteSlide,

+ 15 - 1
src/types/injectKey.ts

@@ -9,4 +9,18 @@ export type RadioGroupValue = {
 
 export const injectKeySlideScale: InjectionKey<SlideScale> = Symbol()
 export const injectKeySlideId: InjectionKey<SlideId> = Symbol()
-export const injectKeyRadioGroupValue: InjectionKey<RadioGroupValue> = Symbol()
+export const injectKeyRadioGroupValue: InjectionKey<RadioGroupValue> = Symbol()
+
+/**
+ * 单页模式。EditorEn(英语展示页)专用:整个编辑器只允许存在一页,
+ * 插入口语工具时用「替换当前全部页面」代替「新增一页」。
+ *
+ * provide:views/Editor/indexEn.vue
+ * inject :EnglishSpeaking/layers/Layer2Speaking.vue —— 建页分支
+ *          hooks/useAddSlidesOrElements.ts —— 挡住 Ctrl+V 贴页 / AIPPT 批量加页
+ *
+ * 其余编辑器(index / index2 / index3)不 provide,inject 一律拿到 default false,
+ * 行为与今天完全一致。因为是 provide 而不是全局 store,值随 EditorEn 一起消失,
+ * 不存在「切走之后旗标还留着」的问题。
+ */
+export const injectKeySingleSlideMode: InjectionKey<boolean> = Symbol()

+ 22 - 4
src/views/Editor/EnglishSpeaking/layers/Layer2Speaking.vue

@@ -74,11 +74,12 @@
 </template>
 
 <script lang="ts" setup>
-import { ref, computed } from 'vue'
+import { ref, computed, inject } from 'vue'
 import { lang } from '@/main'
 import type { CreationMode, SpeakingRecommendationTask, TopicDiscussionTask } from '@/types/englishSpeaking'
 import type { ArticleReadingTask } from '@/types/articleReading'
 import { ARTICLE_READING_TOOL_TYPE, TOPIC_DISCUSSION_TOOL_TYPE } from '@/configs/englishSpeakingTools'
+import { injectKeySingleSlideMode } from '@/types/injectKey'
 import { filterSpeakingTasks, type SpeakingTaskFilter } from '../preview/articleReadingModel'
 import { useSpeakingStore } from '@/store/speaking'
 import { useArticleReadingStore } from '@/store/articleReading'
@@ -143,15 +144,32 @@ const filteredTasks = computed(() => filterSpeakingTasks(unitTasks.value, select
 
 // 新交互:点卡片即新建一页 + 创建配置 + 插入画布元素;配置页改由"点击画布里的 77 型元素"唤起
 const { createFrameElement } = useCreateElement()
-const { createSlide } = useSlideHandler()
+const { createSlide, replaceAllSlidesWithNew } = useSlideHandler()
 const inserting = ref(false)
 
+// 由 indexEn.vue provide;其余编辑器没有 provide,这里拿到 default false
+const singleSlideMode = inject(injectKeySingleSlideMode, false)
+
+// 腾出一页空白页来放新工具。
+//
+// 单页模式(英语展示页)下换成「替换掉全部页面」:那个编辑器藏了缩略图、只允许一页,
+// 再往后加页的话老师看不见也切不过去。两条路都保证新页是空的,因为 createFrameElement
+// 会拒绝插进已经有 frame 的页面。
+//
+// 已知问题(两种模式都有,单页模式下更频繁):被替换掉的那个工具,它的 config 已经
+// POST 到后端了,这里只是丢掉画布上引用它的 frame 元素,后端那条记录会变成孤儿。
+// 服务端目前没有删除 config 的接口,前端无法回收。
+const openSlideForNewTool = () => {
+  if (singleSlideMode) replaceAllSlidesWithNew()
+  else createSlide()
+}
+
 async function insertSpeakingToolToCanvas(source: 'select' | 'manual') {
   if (inserting.value) return
   inserting.value = true
   try {
     const { id } = await createSpeakingConfig(speakingStore.config)
-    createSlide()
+    openSlideForNewTool()
     createFrameElement(id, TOPIC_DISCUSSION_TOOL_TYPE)
     // 创建后立即唤起左侧配置面板(与点击画布 77 型 frame 同款信号),使用户可直接编辑
     speakingStore.openConfigPanel()
@@ -179,7 +197,7 @@ async function insertArticleToolToCanvas(source: 'select' | 'manual') {
   const pending = message.info(lang.ssArticleGeneratingDemo as string, { duration: 0 })
   try {
     const { id } = await createArticleConfig(articleStore.config)
-    createSlide()
+    openSlideForNewTool()
     createFrameElement(id, ARTICLE_READING_TOOL_TYPE)
     articleStore.openConfigPanel(id)
     message.success(source === 'select' ? lang.ssArticleTemplateCreated : lang.ssArticleManualCreated)

+ 4 - 1
src/views/Editor/EnglishSpeaking/services/speakingApiConfig.ts

@@ -1,6 +1,9 @@
 export const FALLBACK_SPEAKING_API_HOST = 'https://ppt-english-speaking-api.cocorobo.cn'
 
-const ENV_SPEAKING_API_HOST = import.meta.env.VITE_SPEAKING_API_HOST?.trim()
+// 优先级:容器运行时注入的 config.js > 构建期 .env 的 VITE_* > 硬编码兜底。
+// config.js 在本地 dev 与现有构建部署下都是空对象,因此这两种场景行为不变。
+const RUNTIME_SPEAKING_API_HOST = window.__APP_CONFIG__?.SPEAKING_API_HOST
+const ENV_SPEAKING_API_HOST = (RUNTIME_SPEAKING_API_HOST || import.meta.env.VITE_SPEAKING_API_HOST)?.trim()
 const SPEAKING_API_HOST = (ENV_SPEAKING_API_HOST || FALLBACK_SPEAKING_API_HOST).replace(/\/+$/, '')
 
 export const SPEAKING_DIALOGUE_API_BASE_URL = `${SPEAKING_API_HOST}/api/speaking/dialogue`

+ 6 - 3
src/views/Editor/EnglishSpeaking/services/speechService.ts

@@ -1,5 +1,8 @@
-const KEY = import.meta.env.VITE_AZURE_SPEECH_KEY as string | undefined
-const REGION = import.meta.env.VITE_AZURE_SPEECH_REGION as string | undefined
+// 优先级与 speakingApiConfig.ts 一致:容器运行时注入的 config.js > 构建期 .env。
+// 这样交付镜像里不必烧入密钥,运维用环境变量提供即可。
+// 注意:这里只是把「构建期固定」改成「运行时可换」,密钥仍在浏览器中公开可见。
+const KEY = (window.__APP_CONFIG__?.AZURE_SPEECH_KEY || import.meta.env.VITE_AZURE_SPEECH_KEY) as string | undefined
+const REGION = (window.__APP_CONFIG__?.AZURE_SPEECH_REGION || import.meta.env.VITE_AZURE_SPEECH_REGION) as string | undefined
 // 美式男声,口齿清晰、随和。备选(都是 en-US-*Neural):
 //   GuyNeural / AndrewNeural / ChristopherNeural / TonyNeural(男)
 //   JennyNeural / EmmaNeural / AvaNeural(女,清晰度也高)
@@ -18,7 +21,7 @@ const RATE = '+10%'
  */
 export async function synthesize(text: string, signal?: AbortSignal): Promise<Blob> {
   if (!KEY || !REGION) {
-    throw new Error('Azure Speech credentials not configured (VITE_AZURE_SPEECH_KEY / VITE_AZURE_SPEECH_REGION)')
+    throw new Error('Azure Speech credentials not configured (容器部署设 AZURE_SPEECH_KEY / AZURE_SPEECH_REGION;本地开发设 .env 的 VITE_AZURE_SPEECH_KEY / VITE_AZURE_SPEECH_REGION)')
   }
 
   const ssml =

+ 16 - 7
src/views/Editor/indexEn.vue

@@ -162,10 +162,10 @@
     </div>
     <!-- <EditorHeader class="layout-header" /> -->
     <div class="layout-content">
-      <CollapsibleToolbar class="layout-sidebar" @toggle="handleToolbarToggle" :userid="props.userid" :courseTitle="courseTitle" />
+      <CollapsibleToolbar class="layout-sidebar" @toggle="handleToolbarToggle" :userid="props.userid" :courseTitle="courseTitle" :onlyEnglish="true" />
       <div class="layout-content-center">
         <CanvasTool class="center-top"  :userid="props.userid"/>
-        <Canvas class="center-body" :style="{ height: `calc(100% - ${remarkHeight + 60}px  - 120px)` }"
+        <Canvas class="center-body" :style="{ height: `calc(100% - ${remarkHeight + 60}px  - ${singleSlideMode ? 0 : 120}px)` }"
           :courseid="props.courseid" @course-loaded="handleCourseLoaded"  ref="canvas"/>
         <!-- <Remark
           class="center-bottom" 
@@ -173,7 +173,8 @@
           :style="{ height: `${remarkHeight}px` }"
            v-show="false"
         /> -->
-        <Thumbnails class="layout-content-left" />
+        <!-- 单页模式下没有第二页可切,缩略图条只会占掉 120px 画布高度 -->
+        <Thumbnails class="layout-content-left" v-if="!singleSlideMode" />
       </div>
       <Toolbar class="layout-content-right" v-show="false" />
     </div>
@@ -200,7 +201,7 @@
 </template>
 
 <script lang="ts" setup>
-import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
+import { ref, computed, onMounted, onUnmounted, watch, provide } from 'vue'
 import { storeToRefs } from 'pinia'
 import { useMainStore, useSlidesStore } from '@/store'
 import useGlobalHotkey from '@/hooks/useGlobalHotkey'
@@ -221,6 +222,7 @@ import AIPPTDialog from './AIPPTDialog.vue'
 import Modal from '@/components/Modal.vue'
 import CollapsibleToolbar from '@/components/CollapsibleToolbar/index2.vue'
 import CreateCourseDialog from '@/components/CreateCourseDialog.vue'
+import { injectKeySingleSlideMode } from '@/types/injectKey'
 import api from '@/services/course'
 import { lang } from '@/main'
 
@@ -247,6 +249,12 @@ const props = withDefaults(defineProps<Props>(), {
 })
 
 
+// 英语展示页固定单页:不显示缩略图,插入口语工具时替换掉当前全部页面而不是新增一页。
+// 通过 provide 下发而不是写进 store,是为了让这个值随本组件一起消失 —— 切到别的
+// 编辑器时它拿到的是 inject 的 default false,不存在旗标残留的问题。
+const singleSlideMode: boolean = true
+provide(injectKeySingleSlideMode, singleSlideMode)
+
 const mainStore = useMainStore()
 const slidesStore = useSlidesStore()
 const { dialogForExport, showSelectPanel, showSearchPanel, showNotesPanel, showMarkupPanel, showAIPPTDialog } = storeToRefs(mainStore)
@@ -402,9 +410,10 @@ const setTitle2 = (newTitle: string) => {
 Object.assign(window, { getCourseDetail, setTitle })
 
 onMounted(() => {
-  if (!props.courseid) {
-    showCreateCourseDialog.value = true
-  }
+  // 展示页不弹「创建新课程」遮罩,直接进编辑页。
+  // 等价于在遮罩里点了「创建空白」—— CreateCourseDialog 的 blank 分支只 emit
+  // select + close,handleCreateCourseSelect 又只处理 'upload',所以那条路本来
+  // 就什么都没做,这里省掉遮罩不会漏掉任何初始化。
   ccourseid.value = props.courseid || ''
   mainStore.setUserid(props.userid || '')
   // 添加点击外部关闭下拉菜单的事件监听