Просмотр исходного кода

feat: 容器化
docs: README 补充 Docker 部署方式与必填配置

jimmylee 13 часов назад
Родитель
Сommit
9f1c169f58

+ 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 - 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 =