From 86998daa07f8809c61c53d154a09ba6f1cc5c0b3 Mon Sep 17 00:00:00 2001 From: huyan Date: Mon, 10 Aug 2026 17:38:47 +0800 Subject: [PATCH 1/6] build(frontend): containerize the app with nginx The frontend ships no container image, so the deployment described in docker-compose.yml cannot serve it next to the backend. Add a multi-stage Dockerfile that builds with Node and ships only dist on nginx, together with an nginx config providing SPA fallback and an /api reverse proxy to the backend service. The image serves pages and API calls from one origin, so the deployment needs no CORS configuration at all. --- frontend/.dockerignore | 15 ++++++++++ frontend/Dockerfile | 47 +++++++++++++++++++++++++++++ frontend/nginx.conf | 67 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 129 insertions(+) create mode 100644 frontend/.dockerignore create mode 100644 frontend/Dockerfile create mode 100644 frontend/nginx.conf diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 00000000..acaaa77b --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1,15 @@ +# 构建上下文排除项:本地 node_modules 与 dist 加起来数百 MB, +# 全量传给 daemon 会让每次构建白等一分钟,且容器内会重新装依赖、重新构建。 +node_modules +dist +dist-ssr + +# 本地环境变量文件不进镜像;后端地址由 Dockerfile 的 build arg 传入。 +.env +.env.* + +.vercel +.git +.gitignore +.DS_Store +*.log diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 00000000..68b21d93 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,47 @@ +# ── 前端 Dockerfile ────────────────────────────────────────────────── +# 多阶段构建:builder 产出 dist → runtime 只带静态文件和 nginx,运行时不含 Node + +# ── 阶段 1: 构建 ── +# Node 版本跟 frontend-ci.yml 对齐,避免 CI 过了、镜像里构建结果不同 +FROM node:24-alpine AS builder + +# 国内网络:走镜像源,与 backend/Dockerfile 换阿里 pypi 源同因 +ENV NPM_CONFIG_REGISTRY=https://registry.npmmirror.com + +WORKDIR /app + +# 先只拷依赖清单,命中 Docker layer cache —— 改源码不触发重装依赖 +COPY package.json package-lock.json ./ +RUN npm ci + +COPY . . + +# VITE_ 前缀的变量在 vite build 时就被烘进 dist,运行期再往容器注环境变量对 +# 已构建产物无效 —— 这是前端和 backend 在配置方式上唯一的实质差别。 +# 默认值 /api 是相对路径:浏览器请求与页面同源,由同容器的 nginx 转发给后端, +# 镜像因此不与任何域名绑定,同一份产物可直接换环境跑。 +# 若某个环境确实要指向外部后端,构建时传 --build-arg VITE_API_BASE_URL=https://…, +# 但那样后端必须同步配 WINDUP_CORS_ORIGINS,否则浏览器预检就被拦死。 +ARG VITE_API_BASE_URL=/api +ENV VITE_API_BASE_URL=$VITE_API_BASE_URL + +# build 脚本是 tsc -b && vite build,类型不过直接构建失败 +RUN npm run build + +# ── 阶段 2: 运行时 ── +FROM nginx:1.27-alpine AS runtime + +COPY nginx.conf /etc/nginx/conf.d/default.conf +COPY --from=builder /app/dist /usr/share/nginx/html + +EXPOSE 80 + +# 探首页而不是 /healthz:静态站点没有独立健康端点,首页能返回 200 +# 就说明 nginx 起来了且 dist 拷进来了。 +# 必须写 127.0.0.1 不能写 localhost:busybox 的 wget 会优先解析到 ::1,而 nginx.conf +# 只 listen 80(IPv4),探针会一直 "Connection refused",容器永远 unhealthy—— +# 实测踩过,站点从宿主机访问完全正常,只有探针在报错。 +HEALTHCHECK --interval=30s --timeout=5s --retries=3 \ + CMD wget -q -O /dev/null http://127.0.0.1/ || exit 1 + +CMD ["nginx", "-g", "daemon off;"] diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 00000000..cc73e85c --- /dev/null +++ b/frontend/nginx.conf @@ -0,0 +1,67 @@ +# ── 前端容器内的 nginx 配置 ────────────────────────────────────────── +# 只管容器内这一层:静态资源 + SPA 回退 + /api 反代。 +# 宿主机若另有 nginx / 宝塔在管域名和证书,让它转发到本容器映射出去的端口即可, +# 这份配置不需要跟着改。 + +server { + listen 80; + server_name _; + + root /usr/share/nginx/html; + index index.html; + + gzip on; + gzip_vary on; + gzip_min_length 1024; + gzip_types text/css text/javascript application/javascript application/json image/svg+xml; + + # ── 后端反代 ── + # 后端路由挂在根路径(/projects、/characters、/users),所以要把 /api 前缀剥掉: + # /api/projects → backend:8000/projects。改动前先确认后端没有自带路由前缀, + # 否则会变成 /api/api/…。 + location /api/ { + # 用变量 + Docker 内嵌 DNS 而不是直接写 proxy_pass http://backend:8000/, + # 是为了让 nginx 每次请求重新解析:直写主机名时 nginx 只在启动时解析一次, + # backend 容器一重建就换 IP,前端会一直 502 到手动 reload 为止; + # 而且启动时解析不到还会直接起不来,后端没起前端连静态页都打不开。 + resolver 127.0.0.11 valid=30s ipv6=off; + set $windup_backend backend; + + # rewrite + 不带 URI 的 proxy_pass:变量形式下 nginx 不会自动剥前缀, + # 得自己改写(query string 由 rewrite 自动保留)。 + rewrite ^/api/(.*)$ /$1 break; + proxy_pass http://$windup_backend:8000; + + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # 生成类接口一次要跑数分钟(后端对第三方的轮询上限是 30 分钟), + # nginx 默认 60s 会在中途掐断连接,前端表现为任务莫名失败。 + proxy_connect_timeout 10s; + proxy_read_timeout 300s; + proxy_send_timeout 300s; + } + + # ── 静态资源缓存 ── + # Vite 产物带内容 hash,文件名变了才是新内容,可以放心长缓存。 + location /assets/ { + expires 1y; + add_header Cache-Control "public, immutable"; + } + + # index.html 必须不缓存:它内嵌的是带 hash 的资源名,发版后浏览器若拿旧 html + # 去要已被删掉的旧文件,页面直接白屏。 + location = /index.html { + add_header Cache-Control "no-cache"; + } + + # ── SPA 回退 ── + # 磁盘上没有 /projects 这种文件,不回退到 index.html 的话刷新子路由就是 404。 + # 对应 vercel.json 里那条把一切重写到 /index.html 的 rewrite。 + location / { + try_files $uri $uri/ /index.html; + } +} From 1437fa2f816829f9e77c13464ca47ae5077d4049 Mon Sep 17 00:00:00 2001 From: huyan Date: Mon, 10 Aug 2026 17:38:57 +0800 Subject: [PATCH 2/6] build(deploy): add the frontend service to docker compose Compose orchestrates Redis, PostgreSQL and the backend, leaving the frontend outside the single-server deployment. Add a frontend service building from ./frontend on the shared network, publishing WINDUP_WEB_PORT, and passing VITE_API_BASE_URL as a build argument since Vite bakes it at build time. One compose command now brings up the whole stack, and the example env file documents both new variables. --- .env.example | 5 +++++ docker-compose.yml | 21 +++++++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/.env.example b/.env.example index 77df9d48..ee606b4c 100644 --- a/.env.example +++ b/.env.example @@ -42,6 +42,11 @@ AI_API_KEY=your-ai-api-key # ── 服务配置 ── WINDUP_HOST=127.0.0.1 WINDUP_PORT=8000 +# 宿主机访问前端的端口(Docker 映射用);宿主机若已有 nginx 管域名,让它转到这个端口 +WINDUP_WEB_PORT=8080 +# 前端构建期写入的后端地址。默认相对路径,由前端容器内 nginx 反代给 backend; +# 只有在前端要指向外部后端时才改成完整 URL,那时后端需同步配 WINDUP_CORS_ORIGINS +VITE_API_BASE_URL=/api # 跨域来源(逗号分隔,默认覆盖 localhost:5173/3000) WINDUP_CORS_ORIGINS= # 跨域正则匹配(默认允许 *.vercel.app) diff --git a/docker-compose.yml b/docker-compose.yml index b1eaf551..69004044 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -67,6 +67,27 @@ services: networks: - windup-net + # ── 前端静态站点(nginx 兼 /api 反代) ── + frontend: + build: + context: ./frontend + dockerfile: Dockerfile + args: + # 构建期烘进产物的后端地址,运行期改不动,所以放在 build.args 而非 + # environment。默认相对路径 → 与页面同源 → 不产生跨域,后端不必配来源白名单。 + VITE_API_BASE_URL: ${VITE_API_BASE_URL:-/api} + container_name: windup-frontend + restart: unless-stopped + depends_on: + # 只等 backend 起来,不等它 healthy:后端异常时前端静态页仍应打得开, + # 否则排障时连页面都看不到。容器内 nginx 是按请求解析 backend 的, + # 先起后起都不影响反代。 + - backend + ports: + - "${WINDUP_WEB_PORT:-8080}:80" + networks: + - windup-net + networks: windup-net: driver: bridge From adf04fa389fc26998ce868e329335506aeb23421 Mon Sep 17 00:00:00 2001 From: huyan Date: Mon, 10 Aug 2026 17:39:08 +0800 Subject: [PATCH 3/6] docs(frontend): document the container deployment The README covers local development only and says nothing about how the app reaches the backend once containerized. Describe the compose workflow, the nginx reverse proxy, and why VITE_API_BASE_URL cannot be injected at runtime. Readers can deploy without rediscovering the build-time constraint, and the Vercel path is stated as unaffected. --- frontend/README.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/frontend/README.md b/frontend/README.md index 6b946285..02bfa095 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -21,6 +21,21 @@ npm run build # 构建 CI 按上面顺序全跑一遍。 +## 容器部署 + +跟后端、数据库同一份根目录 `docker-compose.yml`: + +```bash +cp .env.example .env # 在仓库根目录,按需改 WINDUP_WEB_PORT +docker compose up -d --build +``` + +起来后访问 `http://:${WINDUP_WEB_PORT:-8080}`。前端容器里是 nginx:静态资源加 SPA 回退,另把 `/api/` 反代到内网 `backend:8000`,所以浏览器只看见一个源,跨域配置一概不需要。 + +`VITE_API_BASE_URL` 是构建期变量,`vite build` 时就烘进产物,运行期给容器注环境变量无效。默认取 `/api` 走上面的反代;确实要指向外部后端时,构建时传 `--build-arg VITE_API_BASE_URL=https://…`,同时后端得配 `WINDUP_CORS_ORIGINS`。 + +Vercel 部署路径不受影响,`vercel.json` 照旧。 + ## 结构 `ProjectApis` 与 `CharacterApis` 负责业务 DTO 映射。项目中心、项目工作区、资产库与角色详情已接入 PR #75 的真实接口;测试数据只存在于测试环境的 HTTP 替身中。 From 5da1786201f6aee0826d699019b2a0d22e1ee9d7 Mon Sep 17 00:00:00 2001 From: huyan Date: Mon, 10 Aug 2026 18:15:17 +0800 Subject: [PATCH 4/6] fix(frontend): raise the proxy body size limit for uploads The nginx proxy inherits a 1 MB default request body limit, so images sent to /media/upload are rejected with 413 before FastAPI receives them. Set client_max_body_size to 20 MB on the /api/ location, leaving the static routes untouched. Uploads up to 20 MB now reach the backend, while a 25 MB request is still refused at the proxy. --- frontend/nginx.conf | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/frontend/nginx.conf b/frontend/nginx.conf index cc73e85c..94d23056 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -32,6 +32,11 @@ server { rewrite ^/api/(.*)$ /$1 break; proxy_pass http://$windup_backend:8000; + # 后端 /media/upload 接受图片且自身不限大小,而 nginx 默认只放行 1 MB, + # 不放宽的话参考图稍大就会在这一层被 413,FastAPI 根本看不到请求。 + # 20m 是部署层兜底,后端定出上传上限后与之对齐。 + client_max_body_size 20m; + proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; From df8fb55bfdebac259a0166ca69f539733a972c59 Mon Sep 17 00:00:00 2001 From: huyan Date: Tue, 11 Aug 2026 10:55:59 +0800 Subject: [PATCH 5/6] build(deploy): emit frontend assets to the host The server already owns static serving and API proxying. Make the Node container write Vite output into the mounted web root and exit. Deployments no longer run or expose a second nginx instance. --- docker-compose.yml | 12 ++++---- frontend/Dockerfile | 35 +++++----------------- frontend/nginx.conf | 72 --------------------------------------------- 3 files changed, 13 insertions(+), 106 deletions(-) delete mode 100644 frontend/nginx.conf diff --git a/docker-compose.yml b/docker-compose.yml index 69004044..f5d769c9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -67,7 +67,7 @@ services: networks: - windup-net - # ── 前端静态站点(nginx 兼 /api 反代) ── + # ── 前端静态产物构建任务 ── frontend: build: context: ./frontend @@ -77,14 +77,12 @@ services: # environment。默认相对路径 → 与页面同源 → 不产生跨域,后端不必配来源白名单。 VITE_API_BASE_URL: ${VITE_API_BASE_URL:-/api} container_name: windup-frontend - restart: unless-stopped + restart: "no" depends_on: - # 只等 backend 起来,不等它 healthy:后端异常时前端静态页仍应打得开, - # 否则排障时连页面都看不到。容器内 nginx 是按请求解析 backend 的, - # 先起后起都不影响反代。 - backend - ports: - - "${WINDUP_WEB_PORT:-8080}:80" + volumes: + # 构建命令直接写入宿主机 nginx 的站点目录;容器退出后产物仍保留。 + - /var/www/react-windup:/var/www/react-windup networks: - windup-net diff --git a/frontend/Dockerfile b/frontend/Dockerfile index 68b21d93..50e58805 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -1,9 +1,7 @@ -# ── 前端 Dockerfile ────────────────────────────────────────────────── -# 多阶段构建:builder 产出 dist → runtime 只带静态文件和 nginx,运行时不含 Node - -# ── 阶段 1: 构建 ── +# ── 前端构建容器 ───────────────────────────────────────────────────── +# 容器只负责生成静态产物,宿主机 nginx 直接读取挂载目录。 # Node 版本跟 frontend-ci.yml 对齐,避免 CI 过了、镜像里构建结果不同 -FROM node:24-alpine AS builder +FROM node:24-alpine # 国内网络:走镜像源,与 backend/Dockerfile 换阿里 pypi 源同因 ENV NPM_CONFIG_REGISTRY=https://registry.npmmirror.com @@ -18,30 +16,13 @@ COPY . . # VITE_ 前缀的变量在 vite build 时就被烘进 dist,运行期再往容器注环境变量对 # 已构建产物无效 —— 这是前端和 backend 在配置方式上唯一的实质差别。 -# 默认值 /api 是相对路径:浏览器请求与页面同源,由同容器的 nginx 转发给后端, -# 镜像因此不与任何域名绑定,同一份产物可直接换环境跑。 +# 默认值 /api 是相对路径:浏览器请求与页面同源,由宿主机 nginx 转发给后端, +# 构建镜像因此不与任何域名绑定,同一份产物可直接换环境跑。 # 若某个环境确实要指向外部后端,构建时传 --build-arg VITE_API_BASE_URL=https://…, # 但那样后端必须同步配 WINDUP_CORS_ORIGINS,否则浏览器预检就被拦死。 ARG VITE_API_BASE_URL=/api ENV VITE_API_BASE_URL=$VITE_API_BASE_URL -# build 脚本是 tsc -b && vite build,类型不过直接构建失败 -RUN npm run build - -# ── 阶段 2: 运行时 ── -FROM nginx:1.27-alpine AS runtime - -COPY nginx.conf /etc/nginx/conf.d/default.conf -COPY --from=builder /app/dist /usr/share/nginx/html - -EXPOSE 80 - -# 探首页而不是 /healthz:静态站点没有独立健康端点,首页能返回 200 -# 就说明 nginx 起来了且 dist 拷进来了。 -# 必须写 127.0.0.1 不能写 localhost:busybox 的 wget 会优先解析到 ::1,而 nginx.conf -# 只 listen 80(IPv4),探针会一直 "Connection refused",容器永远 unhealthy—— -# 实测踩过,站点从宿主机访问完全正常,只有探针在报错。 -HEALTHCHECK --interval=30s --timeout=5s --retries=3 \ - CMD wget -q -O /dev/null http://127.0.0.1/ || exit 1 - -CMD ["nginx", "-g", "daemon off;"] +# build 脚本是 tsc -b && vite build,类型不过直接构建失败。产物直接写进 +# Compose 挂载的宿主机目录;构建完成后进程退出,不启动常驻服务。 +CMD ["npm", "run", "build", "--", "--outDir", "/var/www/react-windup", "--emptyOutDir"] diff --git a/frontend/nginx.conf b/frontend/nginx.conf deleted file mode 100644 index 94d23056..00000000 --- a/frontend/nginx.conf +++ /dev/null @@ -1,72 +0,0 @@ -# ── 前端容器内的 nginx 配置 ────────────────────────────────────────── -# 只管容器内这一层:静态资源 + SPA 回退 + /api 反代。 -# 宿主机若另有 nginx / 宝塔在管域名和证书,让它转发到本容器映射出去的端口即可, -# 这份配置不需要跟着改。 - -server { - listen 80; - server_name _; - - root /usr/share/nginx/html; - index index.html; - - gzip on; - gzip_vary on; - gzip_min_length 1024; - gzip_types text/css text/javascript application/javascript application/json image/svg+xml; - - # ── 后端反代 ── - # 后端路由挂在根路径(/projects、/characters、/users),所以要把 /api 前缀剥掉: - # /api/projects → backend:8000/projects。改动前先确认后端没有自带路由前缀, - # 否则会变成 /api/api/…。 - location /api/ { - # 用变量 + Docker 内嵌 DNS 而不是直接写 proxy_pass http://backend:8000/, - # 是为了让 nginx 每次请求重新解析:直写主机名时 nginx 只在启动时解析一次, - # backend 容器一重建就换 IP,前端会一直 502 到手动 reload 为止; - # 而且启动时解析不到还会直接起不来,后端没起前端连静态页都打不开。 - resolver 127.0.0.11 valid=30s ipv6=off; - set $windup_backend backend; - - # rewrite + 不带 URI 的 proxy_pass:变量形式下 nginx 不会自动剥前缀, - # 得自己改写(query string 由 rewrite 自动保留)。 - rewrite ^/api/(.*)$ /$1 break; - proxy_pass http://$windup_backend:8000; - - # 后端 /media/upload 接受图片且自身不限大小,而 nginx 默认只放行 1 MB, - # 不放宽的话参考图稍大就会在这一层被 413,FastAPI 根本看不到请求。 - # 20m 是部署层兜底,后端定出上传上限后与之对齐。 - client_max_body_size 20m; - - proxy_http_version 1.1; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - - # 生成类接口一次要跑数分钟(后端对第三方的轮询上限是 30 分钟), - # nginx 默认 60s 会在中途掐断连接,前端表现为任务莫名失败。 - proxy_connect_timeout 10s; - proxy_read_timeout 300s; - proxy_send_timeout 300s; - } - - # ── 静态资源缓存 ── - # Vite 产物带内容 hash,文件名变了才是新内容,可以放心长缓存。 - location /assets/ { - expires 1y; - add_header Cache-Control "public, immutable"; - } - - # index.html 必须不缓存:它内嵌的是带 hash 的资源名,发版后浏览器若拿旧 html - # 去要已被删掉的旧文件,页面直接白屏。 - location = /index.html { - add_header Cache-Control "no-cache"; - } - - # ── SPA 回退 ── - # 磁盘上没有 /projects 这种文件,不回退到 index.html 的话刷新子路由就是 404。 - # 对应 vercel.json 里那条把一切重写到 /index.html 的 rewrite。 - location / { - try_files $uri $uri/ /index.html; - } -} From 8a9a7fc45084e3ba7f68b2a6e90c3efeac41be0b Mon Sep 17 00:00:00 2001 From: huyan Date: Tue, 11 Aug 2026 10:56:14 +0800 Subject: [PATCH 6/6] docs(frontend): describe host nginx deployment The previous guide still described the removed container nginx and web port. Document the host web root, one-shot build command, and API proxy ownership. Operators can now update frontend assets without expecting a long-running container. --- .env.example | 4 +--- frontend/README.md | 13 +++++++------ 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/.env.example b/.env.example index ee606b4c..f7962b98 100644 --- a/.env.example +++ b/.env.example @@ -42,9 +42,7 @@ AI_API_KEY=your-ai-api-key # ── 服务配置 ── WINDUP_HOST=127.0.0.1 WINDUP_PORT=8000 -# 宿主机访问前端的端口(Docker 映射用);宿主机若已有 nginx 管域名,让它转到这个端口 -WINDUP_WEB_PORT=8080 -# 前端构建期写入的后端地址。默认相对路径,由前端容器内 nginx 反代给 backend; +# 前端构建期写入的后端地址。默认相对路径,由宿主机 nginx 反代给 backend; # 只有在前端要指向外部后端时才改成完整 URL,那时后端需同步配 WINDUP_CORS_ORIGINS VITE_API_BASE_URL=/api # 跨域来源(逗号分隔,默认覆盖 localhost:5173/3000) diff --git a/frontend/README.md b/frontend/README.md index 02bfa095..0a22e3d4 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -21,18 +21,19 @@ npm run build # 构建 CI 按上面顺序全跑一遍。 -## 容器部署 +## 容器构建与宿主机部署 -跟后端、数据库同一份根目录 `docker-compose.yml`: +服务器由宿主机 nginx 提供静态资源并反代 `/api`。前端容器只构建产物: ```bash -cp .env.example .env # 在仓库根目录,按需改 WINDUP_WEB_PORT -docker compose up -d --build +cp .env.example .env +sudo mkdir -p /var/www/react-windup +docker compose up -d --build frontend ``` -起来后访问 `http://:${WINDUP_WEB_PORT:-8080}`。前端容器里是 nginx:静态资源加 SPA 回退,另把 `/api/` 反代到内网 `backend:8000`,所以浏览器只看见一个源,跨域配置一概不需要。 +构建命令会清空专用站点目录 `/var/www/react-windup`,再把文件直接写入宿主机;完成后容器正常退出,没有端口映射,也不常驻。宿主机 nginx 的站点根目录需指向该目录,并负责 SPA 回退与 `/api` 反代。之后每次更新仍执行上面的 Compose 命令,nginx 会直接读取新产物。 -`VITE_API_BASE_URL` 是构建期变量,`vite build` 时就烘进产物,运行期给容器注环境变量无效。默认取 `/api` 走上面的反代;确实要指向外部后端时,构建时传 `--build-arg VITE_API_BASE_URL=https://…`,同时后端得配 `WINDUP_CORS_ORIGINS`。 +`VITE_API_BASE_URL` 是构建期变量,`vite build` 时就烘进产物,运行期给容器注环境变量无效。默认取 `/api` 走宿主机反代;确实要指向外部后端时,构建时传 `--build-arg VITE_API_BASE_URL=https://…`,同时后端得配 `WINDUP_CORS_ORIGINS`。 Vercel 部署路径不受影响,`vercel.json` 照旧。